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:
@@ -236,9 +236,38 @@ each independently shippable. Slice order is chosen for fastest visible result.
|
||||
becomes POST (DEC-10a) so the full text rides the body, not a length-capped URL; the
|
||||
outer text cap rises 2000→8000 (a shared-3090 hold bound, not a URL bound).
|
||||
|
||||
- **DEC-11 — config-driven voices + two-voice dialogue/narration split (2026-08-11, operator-directed).**
|
||||
Voice assignment moves from the hardcoded `_TTS_VOICE_MAP` (DEC-8) to a rata-side config file
|
||||
`~/.config/ratatoskr/voices.json` (beside local_agents.json + provider.env; NOT folded into the
|
||||
agent-index schema — isolated from its v1→v2 silent-drop foot-gun). Supersedes DEC-8's hardcoded map.
|
||||
- **Schema:** `{"default": "<voice>", "agents": {"<agent_id>": {"voice": "<voice>", "narration_voice": "<voice>"?}}}`.
|
||||
`voice` = the agent's dialogue/primary voice; an unmapped agent (or one with no `voice`) falls to
|
||||
`default`. Voice names are GATEWAY-validated (GET /v1/voices), not client-asserted.
|
||||
- **`narration_voice` is OPTIONAL and its PRESENCE is the two-voice switch** (no separate flag).
|
||||
Absent → the whole turn is one span in `voice` (byte-identical to the prior single-call passthrough —
|
||||
this is why dialogue-only Donut needs NO special-casing). Present → the turn is SEGMENTED into
|
||||
dialogue vs narration spans; dialogue → `voice`, narration → `narration_voice`.
|
||||
- **Segmentation (FN segment_dialogue):** QUOTED text (straight `"` OR curly `“ ”`) = dialogue;
|
||||
text OUTSIDE quotes = narration. Order preserved; empty/whitespace spans dropped. An unbalanced
|
||||
trailing open-quote → its run-to-end is dialogue (best-effort, never raises). dots' server-side
|
||||
curly→ASCII fold is pronunciation-only and does NOT affect boundary detection (we match both styles).
|
||||
- **Synthesis (FN tts_stream_stitched):** spans synth SERIALLY (dots single-consumer) into ONE
|
||||
continuous stream — span 0 streamed as wav VERBATIM (header + PCM), spans 1..N streamed as wav with
|
||||
the leading WAV header STRIPPED (accumulate-until-`data`, emit after `data`+8) so the browser decodes
|
||||
one gapless 48kHz mono s16le stream after a single leading header. The single-span case is EXACTLY the
|
||||
prior tts_stream passthrough (INV-TTS-6, no regression). The `yielded_any` degrade pivot spans the whole
|
||||
sequence: a pre-first-byte failure on ANY span before span 0 has committed → TtsUnavailable → 503; a
|
||||
failure after ≥1 byte committed → degrade (drop the tail, keep what played), never raise into the 200.
|
||||
- **Config load (FN load_voice_config):** entrypoint reads voices.json at startup → `create_app(voice_config)`
|
||||
→ `app.state.voice_config`; `_tts_endpoint` resolves spans per turn (FN resolve_voice_spans). An
|
||||
absent/malformed file → the built-in DEFAULT_VOICE_CONFIG (donut→donut; sindra→miranda dialogue + emmie
|
||||
narration; glados default), logged — degrade-not-crash (INV-TTS-5).
|
||||
- **Initial voices.json:** `donut → {voice: donut}` (dialogue-only, single voice); `sindra →
|
||||
{voice: miranda, narration_voice: emmie}`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-TTS-1 [hard]** — the TTS gateway host/URL (chatterbox-fast :8197) never
|
||||
- **INV-TTS-1 [hard]** — the TTS gateway host/URL (dots-tts :8198) never
|
||||
reaches the browser; all synthesis goes through `/api/tts`.
|
||||
- **INV-TTS-2 [hard]** — TTS is opt-in: a 🔊 toggle (default OFF), persisted to
|
||||
localStorage (mirrors the theme/cot-toggle pattern). No speech without it.
|
||||
@@ -249,6 +278,16 @@ each independently shippable. Slice order is chosen for fastest visible result.
|
||||
scoped to GENUINE failure: a committed-200 mid-stream/later-chunk degrade writes a
|
||||
`tts_degrade` stderr line (server) or a `no WAV header` ticker (browser); a browser-side
|
||||
ABORT/cancel (INV-TTS-3 new-turn) is deliberately SILENT — cancellation is not a failure.
|
||||
- **INV-TTS-5 [hard]** — voice config is degrade-not-crash: an absent, unreadable, or malformed
|
||||
`voices.json` (bad JSON, wrong types, missing keys) falls back to the built-in DEFAULT_VOICE_CONFIG
|
||||
and logs; it NEVER crashes `create_app`/the server. Per-agent malformed entries fall to `default`.
|
||||
- **INV-TTS-6 [hard]** — the single-span path is a byte-identical passthrough: an agent with no
|
||||
`narration_voice` (e.g. Donut), or any turn that segments to one span, produces the exact stream the
|
||||
prior single `tts_stream` call did (one leading WAV header + PCM, verbatim). No regression for the
|
||||
dialogue-only / single-voice case.
|
||||
- **INV-TTS-7 [hard]** — a stitched multi-span stream carries EXACTLY ONE WAV header (span 0's); spans
|
||||
1..N are header-stripped before their PCM is emitted, so the browser decodes one continuous s16le
|
||||
stream (never a RIFF header buried mid-stream).
|
||||
- **INV-KB-1 [hard]** — the KB bridge is import-isolated behind a single seam:
|
||||
`server.py`'s turn path calls exactly one function `pin_kb_context(question,
|
||||
agent_id) -> list[memory_context] | []`. Retiring the bridge = delete
|
||||
|
||||
Reference in New Issue
Block a user