feat(tts): config-driven voices + two-voice dialogue/narration split (DEC-11)

Voice assignment moves from the hardcoded server map to ~/.config/ratatoskr/
voices.json (per-agent voice + optional narration_voice). An agent with a
narration_voice gets a two-voice split: quoted speech in `voice`, narration in
`narration_voice`, synthesized per-span and stitched under one WAV header.

- new src/ratatoskr/voices.py: load_voice_config (degrade-not-crash),
  segment_dialogue (quote-based, straight + curly), resolve_voice_spans
- tts.py: tts_stream_stitched replaces tts_stream — serial per-span synth,
  span 0 verbatim, spans 1..N header-stripped -> one gapless 48kHz stream;
  a single-span list is a byte-identical passthrough (no single-voice regression)
- server.py: _tts_endpoint resolves spans from app.state.voice_config; the
  hardcoded _TTS_VOICE_MAP is retired; create_app gains a voice_config param
- entrypoint.py: loads voices.json at startup
- contract DEC-11 + INV-TTS-5/6/7; initial config donut->donut,
  sindra->miranda (dialogue) / emmie (narration)

Live-verified on :8765: Sindra mixed turn -> 2 dots calls (emmie+miranda)
stitched into one 48kHz WAV with a single RIFF header; Donut single-voice
unchanged. 545 tests green (incl. new test_voices.py).
This commit is contained in:
vh
2026-08-11 08:02:30 -07:00
parent 3e69bc9c01
commit 5c3d0ad010
10 changed files with 495 additions and 90 deletions
Generated
+1 -1
View File
@@ -463,7 +463,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.22.2"
version = "0.22.3"
source = { editable = "." }
dependencies = [
{ name = "httpx" },