the host bind paths + LAN host pins replaced with env defaults. Repository URLs → git.sulkta.com. Audit-changelog scaffolding stripped from inline comments (technical reasoning preserved). README sheds marketing scaffolding. AI-speak in load-bearing prompts/SOULs left alone — that IS the product.
182 lines
5.4 KiB
Rust
182 lines
5.4 KiB
Rust
//! 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<Self> {
|
|
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/<output_filename>` in the f5-tts container.
|
|
pub async fn synthesize(
|
|
&self,
|
|
req: &SynthesizeRequest,
|
|
) -> anyhow::Result<SynthesizeResponse> {
|
|
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::<SynthesizeResponse>().await?)
|
|
}
|
|
|
|
pub async fn healthz(&self) -> anyhow::Result<HealthResponse> {
|
|
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<String>,
|
|
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<String>,
|
|
pub reference_text: Option<String>,
|
|
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<String>,
|
|
Option<String>,
|
|
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<Option<Voice>> {
|
|
let row: Option<VoiceTuple> = 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<Option<Voice>> {
|
|
let row: Option<VoiceTuple> = 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<Option<Voice>> {
|
|
let row: Option<VoiceTuple> = sqlx::query_as(&format!(
|
|
"SELECT {VOICE_COLUMNS} FROM voices WHERE id = $1"
|
|
))
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(voice_from_tuple))
|
|
}
|