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
+116
View File
@@ -0,0 +1,116 @@
"""Tests for ratatoskr.voices — config load + dialogue/narration segmentation (DEC-11)."""
import json
from typing import ClassVar
from ratatoskr.voices import (
DEFAULT_VOICE_CONFIG,
load_voice_config,
resolve_voice_spans,
segment_dialogue,
)
class TestSegmentDialogue:
def test_narration_only_no_quotes(self) -> None:
assert segment_dialogue("She tilts her head.") == [("narration", "She tilts her head.")]
def test_straight_quotes_dialogue(self) -> None:
assert segment_dialogue('"Hi there."') == [("dialogue", "Hi there.")]
def test_curly_quotes_dialogue(self) -> None:
assert segment_dialogue("“Hi there.”") == [("dialogue", "Hi there.")]
def test_mixed_narration_and_dialogue_order_preserved(self) -> None:
text = "She smiles. “Hello.” She left."
assert segment_dialogue(text) == [
("narration", "She smiles. "),
("dialogue", "Hello."),
("narration", " She left."),
]
def test_empty_and_whitespace_spans_dropped(self) -> None:
# The between-quotes gap here is a single space → dropped; leading empty narration too.
assert segment_dialogue('"A" "B"') == [("dialogue", "A"), ("dialogue", "B")]
def test_apostrophe_is_not_a_boundary(self) -> None:
# Curly and straight apostrophes stay inside the span (no word split).
assert segment_dialogue("Donut’s crown and Carl's cat") == [ # noqa: RUF001
("narration", "Donut’s crown and Carl's cat") # noqa: RUF001
]
def test_unbalanced_trailing_open_quote_is_dialogue(self) -> None:
assert segment_dialogue('She said "hello') == [
("narration", "She said "),
("dialogue", "hello"),
]
def test_empty_text(self) -> None:
assert segment_dialogue("") == []
class TestResolveVoiceSpans:
CFG: ClassVar[dict] = {
"default": "glados",
"agents": {
"ratatoskr:donut": {"voice": "donut"},
"ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"},
},
}
def test_unmapped_agent_single_default_voice(self) -> None:
assert resolve_voice_spans(self.CFG, "mimir", "hello") == [("glados", "hello")]
def test_non_str_agent_id_single_default(self) -> None:
assert resolve_voice_spans(self.CFG, ["donut"], "hi") == [("glados", "hi")]
def test_mapped_no_narration_single_voice(self) -> None:
# Donut is dialogue-only (no narration_voice) → whole turn in one voice, no split,
# even though the text has no quotes.
assert resolve_voice_spans(self.CFG, "ratatoskr:donut", "The crown is mine.") == [
("donut", "The crown is mine.")
]
def test_mapped_with_narration_splits(self) -> None:
spans = resolve_voice_spans(
self.CFG, "ratatoskr:sindra", "She smiles. “Hello.”"
)
assert spans == [("emmie", "She smiles. "), ("miranda", "Hello.")]
def test_mapped_with_narration_all_dialogue(self) -> None:
assert resolve_voice_spans(self.CFG, "ratatoskr:sindra", '"Only speech here."') == [
("miranda", "Only speech here.")
]
def test_segmentation_empty_falls_back_to_single_span(self) -> None:
# Text that segments to nothing (only quote chars) still yields one span, never [].
out = resolve_voice_spans(self.CFG, "ratatoskr:sindra", '""')
assert len(out) == 1 and out[0][0] == "miranda"
class TestLoadVoiceConfig:
def test_absent_file_returns_builtin_default(self, tmp_path) -> None:
cfg = load_voice_config(str(tmp_path / "nope.json"))
assert cfg == DEFAULT_VOICE_CONFIG
assert cfg is not DEFAULT_VOICE_CONFIG # a copy, not the shared constant
def test_valid_file_loaded(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"default": "glados", "agents": {"x:y": {"voice": "emmie"}}}))
cfg = load_voice_config(str(p))
assert cfg["agents"]["x:y"]["voice"] == "emmie"
def test_malformed_json_falls_back(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text("{not json")
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
def test_missing_agents_mapping_falls_back(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"default": "glados"}))
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
def test_default_key_backfilled(self, tmp_path) -> None:
p = tmp_path / "voices.json"
p.write_text(json.dumps({"agents": {"x:y": {"voice": "emmie"}}}))
assert load_voice_config(str(p))["default"] == "glados"