//! F5-TTS client + voice DB access for the narrate pipeline. //! //! The boundary: skald (Rust) speaks HTTP+JSON to the f5-tts //! container's purpose-built FastAPI sidecar (the python lives there //! because torch + transformers + safetensors do). Skald never //! imports python deps; the python service has no business logic. //! //! v0.1 flow: //! 1. Skald loads chapter prose + chosen Voice row from the DB. //! 2. POST /synthesize to f5-tts with gen_text + ref_audio_path. //! 3. F5 writes the WAV to its /audio bind mount and returns the //! path + duration metadata. //! 4. Skald inserts a narration_runs row pointing at that path. //! //! Path note: output_path stored in narration_runs is the f5-tts //! container's view (e.g. /audio/coast-down/8-abc.wav). To serve it //! from skald's web UI we'll either mount the same dir on skald or //! route audio bytes through f5-tts. Deferred. use std::time::Duration; use anyhow::Context; use reqwest::Client as HttpClient; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, Clone)] pub struct F5Config { /// e.g. http://localhost:7792 pub base_url: String, /// Inference subprocess timeout. Long-form chapters (3000 words) /// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge. pub timeout: Duration, } #[derive(Debug, Clone)] pub struct Narrator { cfg: F5Config, http: HttpClient, } impl Narrator { pub fn new(cfg: F5Config) -> anyhow::Result { let http = HttpClient::builder() .timeout(cfg.timeout) .user_agent(concat!("skald-narrate/", env!("CARGO_PKG_VERSION"))) .build()?; Ok(Self { cfg, http }) } /// Synthesize one chapter to a WAV via the F5-TTS sidecar. /// `output_filename` is a bare name (no slashes); the file lands /// at `/audio/` in the f5-tts container. pub async fn synthesize( &self, req: &SynthesizeRequest, ) -> anyhow::Result { let url = format!("{}/synthesize", self.cfg.base_url.trim_end_matches('/')); let res = self .http .post(&url) .json(req) .send() .await .with_context(|| format!("POST {url} failed"))?; if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); anyhow::bail!("f5-tts /synthesize returned {status}: {body}"); } Ok(res.json::().await?) } pub async fn healthz(&self) -> anyhow::Result { let url = format!("{}/healthz", self.cfg.base_url.trim_end_matches('/')); Ok(self.http.get(&url).send().await?.json().await?) } } #[derive(Debug, Clone, Serialize)] pub struct SynthesizeRequest { pub gen_text: String, pub ref_audio_path: String, pub ref_text: Option, pub output_filename: String, pub speed: f32, } #[derive(Debug, Clone, Deserialize)] pub struct SynthesizeResponse { pub ok: bool, pub output_path: String, pub sample_rate_hz: i32, pub duration_seconds: f32, pub elapsed_ms: u64, pub chars_in: i64, } #[derive(Debug, Clone, Deserialize)] pub struct HealthResponse { pub ok: bool, pub device: String, pub model: String, pub vocoder: String, pub loaded: bool, } // ─── voice DB access ───────────────────────────────────────────── #[derive(Debug, Clone)] pub struct Voice { pub id: Uuid, pub name: String, pub display_name: String, /// Source label = engine bucket. "lj_speech" + similar → f5-tts /// engine. "kokoro_*" → kokoro engine. Used to pick the HTTP /// target. Future: a dedicated voices.engine column. pub source: String, pub reference_path: Option, pub reference_text: Option, pub license: String, pub is_default: bool, } const VOICE_COLUMNS: &str = "id, name, display_name, source, reference_path, reference_text, license, is_default"; type VoiceTuple = ( Uuid, String, String, String, Option, Option, String, bool, ); fn voice_from_tuple(t: VoiceTuple) -> Voice { let (id, name, display_name, source, reference_path, reference_text, license, is_default) = t; Voice { id, name, display_name, source, reference_path, reference_text, license, is_default, } } pub async fn get_voice_by_name(pool: &PgPool, name: &str) -> anyhow::Result> { let row: Option = sqlx::query_as(&format!( "SELECT {VOICE_COLUMNS} FROM voices WHERE name = $1" )) .bind(name) .fetch_optional(pool) .await?; Ok(row.map(voice_from_tuple)) } pub async fn get_default_voice(pool: &PgPool) -> anyhow::Result> { let row: Option = sqlx::query_as(&format!( "SELECT {VOICE_COLUMNS} FROM voices WHERE is_default = true LIMIT 1" )) .fetch_optional(pool) .await?; Ok(row.map(voice_from_tuple)) } pub async fn get_voice_by_id(pool: &PgPool, id: Uuid) -> anyhow::Result> { let row: Option = sqlx::query_as(&format!( "SELECT {VOICE_COLUMNS} FROM voices WHERE id = $1" )) .bind(id) .fetch_optional(pool) .await?; Ok(row.map(voice_from_tuple)) }