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:
parent
3c159d8d75
commit
1f1d63dd1b
6 changed files with 413 additions and 88 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue