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
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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