Files
vh 5c3d0ad010 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).
2026-08-11 08:02:30 -07:00

117 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("Donuts crown and Carl's cat") == [ # noqa: RUF001
("narration", "Donuts 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"