narrate: F5-TTS HTTP client + skald narrate CLI

skald-core::narrate ships a thin reqwest client + voice DB access
(get_by_name, get_default, get_by_id). The boundary is the f5-tts
container's purpose-built FastAPI sidecar (python lives there
because torch + transformers + safetensors do); skald never touches
python.

CLI: skald narrate --chapter <uuid> [--voice slug] [--speed 1.0].
Voice resolution: --voice flag → story.preferred_voice_id → system
default. Persists narration_runs row (engine='f5-tts', engine_version
pinned, status: running → succeeded|failed). Output path stored is
the f5-tts container's view (/audio/<story>-<n>-<run>.wav); web
playback wiring deferred.
This commit is contained in:
Sulkta 2026-05-13 16:45:04 -07:00
parent a291f44a10
commit 1f2dd40105
6 changed files with 388 additions and 0 deletions

View file

@ -11,6 +11,7 @@ pub mod db;
pub mod forge;
pub mod ingest;
pub mod models;
pub mod narrate;
/// Embeds the workspace `migrations/` directory at compile time.
/// Run via `MIGRATOR.run(&pool).await` at boot.

186
skald-core/src/narrate.rs Normal file
View file

@ -0,0 +1,186 @@
//! 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://127.0.0.1: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,
pub reference_path: Option<String>,
pub reference_text: Option<String>,
pub license: String,
pub is_default: bool,
}
pub async fn get_voice_by_name(pool: &PgPool, name: &str) -> anyhow::Result<Option<Voice>> {
let row: Option<(Uuid, String, String, Option<String>, Option<String>, String, bool)> =
sqlx::query_as(
"SELECT id, name, display_name, reference_path, reference_text, license, is_default
FROM voices WHERE name = $1",
)
.bind(name)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, name, display_name, reference_path, reference_text, license, is_default)| {
Voice {
id,
name,
display_name,
reference_path,
reference_text,
license,
is_default,
}
}))
}
pub async fn get_default_voice(pool: &PgPool) -> anyhow::Result<Option<Voice>> {
let row: Option<(Uuid, String, String, Option<String>, Option<String>, String, bool)> =
sqlx::query_as(
"SELECT id, name, display_name, reference_path, reference_text, license, is_default
FROM voices WHERE is_default = true LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, name, display_name, reference_path, reference_text, license, is_default)| {
Voice {
id,
name,
display_name,
reference_path,
reference_text,
license,
is_default,
}
}))
}
pub async fn get_voice_by_id(pool: &PgPool, id: Uuid) -> anyhow::Result<Option<Voice>> {
let row: Option<(Uuid, String, String, Option<String>, Option<String>, String, bool)> =
sqlx::query_as(
"SELECT id, name, display_name, reference_path, reference_text, license, is_default
FROM voices WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, name, display_name, reference_path, reference_text, license, is_default)| {
Voice {
id,
name,
display_name,
reference_path,
reference_text,
license,
is_default,
}
}))
}