multi-voice: per-character dialogue rendering

Schema: characters.voice_id + characters.slug (migration 0007).
voice_id is FK to voices(id); slug is the stable lowercase token
the narrate_prep pass uses inside [voice:slug]...[/voice].

Forge::narrate_prep takes &[CharacterSpeaker]. System prompt
expanded to instruct the author to wrap dialogue lines in voice
tags based on a roster supplied in the user prompt (slug + name +
short hint from key_facts). Unattributed dialogue stays unwrapped
and inherits the narrator voice.

skald narrate substitutes [voice:<character-slug>] →
[voice:<kokoro-voice-name>] right before sending to Kokoro, using
characters.voice_id JOIN voices.reference_path as the map. Slugs
with no voice or no character row fall back to the narrator voice
defensively (logged as warn).

kokoro_server.py v0.4: splitter recognises [voice:X]...[/voice]
blocks at the paragraph level. Each text node carries an optional
voice attribution; renderer feeds it to Kokoro per-segment. Outside
voice blocks the request's default voice is used. voices_used is
reported back so callers can verify multi-voice actually ran.

Only kokoro-routed renders pre-process voice tags; F5 paths leave
the tags in place (F5 multi-voice not implemented). Defensive
fallback: orphan/unclosed [/voice] markers are silently absorbed
rather than failing the render.
This commit is contained in:
Kayos 2026-05-14 08:35:33 -07:00
parent 330bc8bde2
commit c9bd38034c
6 changed files with 186 additions and 10 deletions

View file

@ -0,0 +1,16 @@
-- Per-character voice assignment for multi-voice audiobook rendering.
-- A NULL voice_id means "use the story's default narrator voice for
-- this character's dialogue" (effectively no voice change vs the
-- narrator). A non-NULL voice_id pins the character's dialogue to
-- that specific voice.
--
-- slug is the stable lowercase token the narrate_prep pass uses
-- inside [voice:<slug>]...[/voice] markers. Distinct from name so
-- "Anatoly Dyatlov" can carry slug "dyatlov" without re-deriving on
-- every render.
ALTER TABLE characters
ADD COLUMN voice_id uuid REFERENCES voices(id) ON DELETE SET NULL,
ADD COLUMN slug text;
CREATE INDEX idx_characters_voice ON characters(voice_id) WHERE voice_id IS NOT NULL;
CREATE UNIQUE INDEX idx_characters_story_slug ON characters(story_id, slug) WHERE slug IS NOT NULL;