narrate: body_md_tts column + narrate_prep pass + Kokoro routing

Two new things working together:

1. Migration 0005 adds chapters.body_md_tts (nullable). Narrate path
   prefers it over body_md when present — that's the annotated-for-
   audiobook variant. Falls back to body_md if not set.

2. New Forge::narrate_prep pass: author (or House) annotates prose
   with [breath] / [pause:Xs] / [scene] beat markers AND occasional
   humanizing narrator stumbles (em-dash repetition, self-correction,
   hesitation — sparingly, 1-3 per chapter). Apart from stumbles, the
   prose is verbatim. Author voice threads through.

3. New CLI: 'skald prepare-narration --chapter <uuid> [--author slug]
   [--overwrite]'. Records as generation_runs row kind=narrate_prep.

4. skald narrate now routes by voice.source — kokoro_* voices hit
   KOKORO_URL (Apache 2.0 stack, audiobook-tuned with the v0.2 render-
   and-stitch server), everything else hits F5_TTS_URL (voice-cloning
   path). Voice DB row carries source as the dispatch key.

Why no new tag for narrator stumbles: em-dash repetition and self-
correction are just prose patterns Kokoro reads correctly because of
its punctuation cues. No new server-side machinery.
This commit is contained in:
Sulkta 2026-05-13 20:24:38 -07:00
parent 3c159d8d75
commit 1f1d63dd1b
6 changed files with 413 additions and 88 deletions

View file

@ -114,73 +114,69 @@ 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<(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,
}
}))
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<(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,
}
}))
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<(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,
}
}))
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))
}