5c3d0ad010
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).
117 lines
4.6 KiB
Python
117 lines
4.6 KiB
Python
"""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"
|