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

@ -59,7 +59,7 @@ pub struct PassOutput {
/// What a given pass over the model is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "snake_case")]
pub enum PassKind {
/// First-pass long-form draft from prompt + context.
Gen,
@ -69,6 +69,11 @@ pub enum PassKind {
Audit,
/// Chapter summary for cheap context loading on long series.
Summary,
/// Annotate prose with narration control tags ([pause:Xs],
/// [breath], [scene]) for the TTS render path. Does NOT change
/// prose; output should be byte-identical except for the
/// tag insertions.
NarratePrep,
}
impl PassKind {
@ -78,6 +83,7 @@ impl PassKind {
Self::Cleanup => "cleanup",
Self::Audit => "audit",
Self::Summary => "summary",
Self::NarratePrep => "narrate_prep",
}
}
}
@ -179,6 +185,52 @@ impl Forge {
})
}
/// Annotate prose with narration control tags. The model
/// receives the full chapter prose and returns the SAME prose
/// with `[pause:Xs]`, `[breath]`, `[scene]` markers inserted
/// at natural beats. The author voice DOES thread through —
/// Orson Black places beats differently than another author
/// would. Replace-mode if author is set; Append otherwise.
///
/// Hard rule the system prompt enforces: do not change a word
/// of prose. Tags are additive only.
pub async fn narrate_prep(
&self,
prose: &str,
author: Option<&AuthorWithRevision>,
) -> anyhow::Result<PassOutput> {
let user_prompt = narrate_prep_user_prompt(prose);
let (system, mode) = match author {
Some(a) => {
let scaffold = a
.revision
.system_template
.as_deref()
.unwrap_or(DEFAULT_AUTHOR_SCAFFOLD);
let composed = scaffold
.replace("{{display_name}}", &a.author.display_name)
.replace("{{pass_directive}}", NARRATE_PREP_DIRECTIVE)
.replace("{{soul}}", &a.revision.soul);
(composed, SystemMode::Replace)
}
None => (HOUSE_NARRATE_PREP_SYSTEM.to_string(), SystemMode::Append),
};
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
// Tag placement IS a craft choice; max effort buys
// better beat sense. Same posture as gen/cleanup.
effort: Some(Effort::Max),
timeout_secs: Some(1800),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::NarratePrep, result: r, duration_ms })
}
/// Summarize one chapter to ~250 words. The summary feeds into
/// the continuation context for older chapters so the token
/// budget stays sane on long series (book 12 doesn't carry book 1
@ -287,6 +339,10 @@ const HOUSE_CLEANUP_SYSTEM: &str = "You are a copy editor polishing a draft chap
const SYSTEM_AUDIT: &str = "You are a canon auditor for long-form fiction. You compare a parent story and a new chapter against the bible. You flag continuity drift, character voice shift, retconned facts, dropped threads, timeline contradictions. You return STRUCTURED JSON ONLY — no commentary, no preamble. The exact shape: { \"findings\": [ { \"severity\": \"info\"|\"warn\"|\"crit\", \"area\": \"character\"|\"continuity\"|\"tone\"|\"fact\"|\"timeline\"|\"other\", \"body\": \"...\" } ] }. If no findings, return { \"findings\": [] }.";
const NARRATE_PREP_DIRECTIVE: &str = "This is a NARRATION-ANNOTATION pass. You receive your own prose and prepare it for an audiobook reading. Two kinds of inserts are allowed:\n\n1. BEAT MARKERS (additive, not prose): `[breath]` (~400ms), `[pause:1.2s]` (explicit silence in seconds, e.g. 0.5s, 1.2s, 2s), `[scene]` (~1500ms scene break). Place where the prose's rhythm asks for them — after a hard one-line beat, before a turn in dialogue, on a paragraph that lands with weight.\n\n2. NARRATOR STUMBLES (humanizing prose-level inserts): a real narrator occasionally stumbles on a hard word, catches themselves, repeats. You may add these *sparingly* where the prose's pacing makes them feel right. Patterns: em-dash repetition (`Prip— Pripyat`), self-correction (`she — no, the wife — had been told`), hesitation (`the dose, the dose was`). USE SPARINGLY. Maybe 1-3 per chapter. Pick proper nouns, technical terms, or moments where the narrator might genuinely catch herself. Avoid stumbling on emotional climaxes — those should land clean.\n\nApart from stumbles, do NOT change a word of the original prose. Return the prose with beat markers and stumbles inline. No preamble. No commentary about your choices.";
const HOUSE_NARRATE_PREP_SYSTEM: &str = "You are a senior audiobook director annotating prose for narration. You insert (a) beat markers — `[breath]`, `[pause:Xs]`, `[scene]` — where a skilled narrator would breathe or pause, and (b) occasional humanizing narrator stumbles using em-dash repetition or self-correction (sparingly — maybe 1-3 per chapter, on proper nouns or hard words). Apart from those stumbles you do NOT change a word of the prose. Return the prose verbatim plus beat markers and (rare) stumbles inline. No preamble, no commentary.";
// ─── User-prompt builders ───────────────────────────────────────
fn gen_user_prompt(
@ -320,6 +376,18 @@ fn gen_user_prompt(
out
}
fn narrate_prep_user_prompt(prose: &str) -> String {
let mut out = String::with_capacity(prose.len() + 512);
out.push_str("# Prose to annotate\n\n");
out.push_str(prose);
out.push_str(
"\n\n# Task\n\nReturn the prose above with `[breath]`, `[pause:Xs]`, and \
`[scene]` markers inserted at natural narration beats. Do not change \
any word. Do not skip any sentence. Return only the annotated prose.\n",
);
out
}
fn cleanup_user_prompt(draft: &str, context: &str, chapter_n: Option<i32>) -> String {
let mut out = String::with_capacity(context.len() + draft.len() + 512);
out.push_str("# Story canon (for reference — do not retcon)\n\n");

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))
}