// TTS-dispatcher — delegerer til synops-tts CLI. // // Maskinrommet beholder: voice_id-oppslag (payload > node metadata > env). // Alt annet (ElevenLabs-kall, CAS-lagring, PG-skriving) gjøres av synops-tts. // PG NOTIFY-triggere sender sanntidsoppdateringer. // // Jobbtype: "tts_generate" // Payload: { "text", "voice_id"?, "language"?, "source_node_id"?, "requested_by" } // // Ref: docs/retninger/unix_filosofi.md, docs/proposals/ghost_host_tts.md use sqlx::PgPool; use uuid::Uuid; use crate::cli_dispatch; use crate::jobs::JobRow; /// Synops-tts binary path. fn tts_bin() -> String { std::env::var("SYNOPS_TTS_BIN") .unwrap_or_else(|_| "synops-tts".to_string()) } /// Håndterer tts_generate-jobb. /// /// Spawner synops-tts med --write for å gjøre alt arbeidet: /// ElevenLabs-kall, CAS-lagring, PG-skriving, ressurslogging. /// PG NOTIFY-triggere sender sanntidsoppdateringer til klienter. pub async fn handle_tts_job( job: &JobRow, db: &PgPool, ) -> Result { let text = job.payload["text"] .as_str() .ok_or("Mangler 'text' i payload")?; let source_node_id: Option = job.payload["source_node_id"] .as_str() .and_then(|s| s.parse().ok()); let requested_by: Uuid = job.payload["requested_by"] .as_str() .and_then(|s| s.parse().ok()) .ok_or("Mangler gyldig 'requested_by' i payload")?; let language = job.payload["language"] .as_str() .unwrap_or("no"); // Bestem voice_id: payload > source-node metadata > env default let voice_id = resolve_voice_id(job, db, source_node_id).await?; // Bygg kommando let bin = tts_bin(); let mut cmd = tokio::process::Command::new(&bin); cmd.arg("--text").arg(text) .arg("--voice").arg(&voice_id) .arg("--language").arg(language) .arg("--requested-by").arg(requested_by.to_string()) .arg("--write"); if let Some(source_id) = source_node_id { cmd.arg("--source-node-id").arg(source_id.to_string()); } // Sett miljøvariabler CLI-verktøyet trenger cli_dispatch::set_database_url(&mut cmd)?; cli_dispatch::forward_env(&mut cmd, "CAS_ROOT"); cli_dispatch::forward_env(&mut cmd, "ELEVENLABS_API_KEY"); cli_dispatch::forward_env(&mut cmd, "ELEVENLABS_MODEL"); cli_dispatch::forward_env(&mut cmd, "ELEVENLABS_DEFAULT_VOICE"); tracing::info!( text_len = text.len(), voice_id = %voice_id, bin = %bin, "Starter synops-tts" ); let result = cli_dispatch::run_cli_tool(&bin, &mut cmd).await?; // PG-skriving gjøres av synops-tts med --write. // PG NOTIFY-triggere sender sanntidsoppdateringer til WebSocket-klienter. tracing::info!( cas_hash = result["cas_hash"].as_str().unwrap_or("n/a"), media_node_id = result["media_node_id"].as_str().unwrap_or("n/a"), "synops-tts fullført" ); Ok(result) } /// Bestem voice_id: payload-verdi > source-node metadata.voice_preference > env default. async fn resolve_voice_id( job: &JobRow, db: &PgPool, source_node_id: Option, ) -> Result { // 1. Eksplisitt i payload if let Some(vid) = job.payload["voice_id"].as_str() { if !vid.is_empty() { return Ok(vid.to_string()); } } // 2. Source-nodens metadata.voice_preference.voice_id if let Some(node_id) = source_node_id { let meta: Option = sqlx::query_scalar( "SELECT metadata FROM nodes WHERE id = $1", ) .bind(node_id) .fetch_optional(db) .await .map_err(|e| format!("PG-feil ved henting av voice_preference: {e}"))? .flatten(); if let Some(meta) = meta { if let Some(vid) = meta .get("voice_preference") .and_then(|vp| vp.get("voice_id")) .and_then(|v| v.as_str()) { if !vid.is_empty() { tracing::info!(node_id = %node_id, voice_id = %vid, "Bruker mottaker-preferanse"); return Ok(vid.to_string()); } } } } // 3. Miljøvariabel-default Ok(std::env::var("ELEVENLABS_DEFAULT_VOICE") .unwrap_or_else(|_| "21m00Tcm4TlvDq8ikWAM".to_string())) }