226 lines
9.7 KiB
Python
226 lines
9.7 KiB
Python
"""`dialoguectl` — multi-voice scene parsing, voice resolution, API limits,
|
|
and the request the ElevenLabs Text to Dialogue endpoint actually gets.
|
|
|
|
Pure logic plus one mocked HTTP call: no audio device, no network, no display.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet import dialogue
|
|
from bolt_pet.audio import tts
|
|
|
|
SELF_ID = "aaorr6ZHIL88gEexu7dC"
|
|
NARRATOR_ID = "9BWtsMINqrJLrRacOk9x"
|
|
VILLAIN_ID = "IKne3meq5aSn9XLyUdCD"
|
|
VOICES = {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}
|
|
|
|
|
|
def _scene(*lines):
|
|
return '{"lines": [' + ", ".join(lines) + "]}"
|
|
|
|
|
|
# ── parsing ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_non_dialogue_commands_are_left_alone():
|
|
assert dialogue.parse("ls -la") is None
|
|
assert dialogue.parse('filectl {"op": "list"}') is None
|
|
assert dialogue.parse("") is None
|
|
# "dialogues" must not be mistaken for the "dialogue" prefix
|
|
assert dialogue.parse("dialogues --list") is None
|
|
|
|
|
|
def test_a_scene_parses_into_lines():
|
|
action = dialogue.parse(
|
|
'dialoguectl ' + _scene(
|
|
'{"voice": "self", "text": "[cheerfully] Hello, how are you?"}',
|
|
'{"voice": "villain", "text": "[stuttering] I am... fine."}',
|
|
)
|
|
)
|
|
assert action["action"] == "dialogue"
|
|
assert [line["voice"] for line in action["lines"]] == ["self", "villain"]
|
|
assert action["lines"][0]["text"].startswith("[cheerfully]")
|
|
|
|
|
|
def test_the_elevenlabs_field_names_are_accepted_too():
|
|
"""The model has read that API; copying its shape is the obvious thing to
|
|
try, so 'inputs'/'voice_id' work as well as 'lines'/'voice'."""
|
|
action = dialogue.parse(
|
|
'dialoguectl {"inputs": [{"voice_id": "%s", "text": "hi"}]}' % NARRATOR_ID
|
|
)
|
|
assert action["lines"] == [{"voice": NARRATOR_ID, "text": "hi"}]
|
|
|
|
|
|
def test_a_line_with_no_voice_defaults_to_the_pet_itself():
|
|
action = dialogue.parse('dialoguectl {"lines": [{"text": "just me talking"}]}')
|
|
assert action["lines"][0]["voice"] == "self"
|
|
|
|
|
|
def test_truncated_json_explains_the_one_line_rule():
|
|
"""The real failure mode: the server's command extractor stops at the
|
|
first newline, so a multi-line payload arrives cut in half. The error has
|
|
to name the cause, since Bolt is the one who has to fix it."""
|
|
with pytest.raises(dialogue.DialogueError, match="one line"):
|
|
dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"')
|
|
|
|
|
|
def test_an_empty_or_shapeless_payload_is_rejected():
|
|
with pytest.raises(dialogue.DialogueError, match="needs a JSON argument"):
|
|
dialogue.parse("dialoguectl")
|
|
with pytest.raises(dialogue.DialogueError, match="non-empty"):
|
|
dialogue.parse('dialoguectl {"lines": []}')
|
|
with pytest.raises(dialogue.DialogueError, match="no text"):
|
|
dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": " "}]}')
|
|
|
|
|
|
def test_optional_model_and_stability_ride_along():
|
|
action = dialogue.parse(
|
|
'dialoguectl {"model_id": "eleven_v3", "stability": 0.8, '
|
|
'"lines": [{"text": "hi"}]}'
|
|
)
|
|
assert action["model"] == "eleven_v3"
|
|
assert action["stability"] == 0.8
|
|
|
|
|
|
# ── voice resolution ────────────────────────────────────────────────────────
|
|
|
|
def test_named_voices_resolve_from_the_configured_cast():
|
|
action = dialogue.parse('dialoguectl ' + _scene(
|
|
'{"voice": "narrator", "text": "Once upon a time."}',
|
|
'{"voice": "villain", "text": "Not this again."}',
|
|
))
|
|
inputs = dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
|
|
assert [entry["voice_id"] for entry in inputs] == [NARRATOR_ID, VILLAIN_ID]
|
|
|
|
|
|
def test_self_tracks_the_voice_the_pet_is_currently_using():
|
|
"""A scene featuring Bolt should sound like whoever Bolt currently is —
|
|
including a voice the server picked mid-conversation with speak_as."""
|
|
action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
|
|
picked = "VOICEfromSPEAKas1234"
|
|
assert dialogue.resolve(action, self_voice=picked)[0]["voice_id"] == picked
|
|
|
|
|
|
def test_a_raw_voice_id_passes_straight_through():
|
|
action = dialogue.parse('dialoguectl {"lines": [{"voice": "%s", "text": "hi"}]}' % NARRATOR_ID)
|
|
assert dialogue.resolve(action, self_voice=SELF_ID)[0]["voice_id"] == NARRATOR_ID
|
|
|
|
|
|
def test_an_unknown_name_lists_what_is_available():
|
|
action = dialogue.parse('dialoguectl {"lines": [{"voice": "wizard", "text": "hi"}]}')
|
|
with pytest.raises(dialogue.DialogueError) as excinfo:
|
|
dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
|
|
message = str(excinfo.value)
|
|
assert "wizard" in message and "narrator" in message and "villain" in message
|
|
|
|
|
|
def test_self_without_a_configured_voice_says_so():
|
|
action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
|
|
with pytest.raises(dialogue.DialogueError, match="ELEVENLABS_VOICE_ID"):
|
|
dialogue.resolve(action, self_voice="")
|
|
|
|
|
|
def test_the_voice_map_parser_skips_typos_instead_of_dying():
|
|
voices = dialogue.parse_voice_map(f"narrator:{NARRATOR_ID}, broken-entry, villain:{VILLAIN_ID}")
|
|
assert voices == {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}
|
|
assert dialogue.parse_voice_map("") == {}
|
|
|
|
|
|
# ── API limits, enforced before the request goes out ────────────────────────
|
|
|
|
def test_too_many_distinct_voices_is_refused_locally():
|
|
inputs = [{"text": "hi", "voice_id": f"voice{index:015d}"} for index in range(11)]
|
|
with pytest.raises(dialogue.DialogueError, match="limit is 10"):
|
|
dialogue.check_limits(inputs)
|
|
|
|
|
|
def test_an_over_long_scene_is_refused_with_advice():
|
|
inputs = [{"text": "x" * 1100, "voice_id": SELF_ID} for _ in range(2)]
|
|
with pytest.raises(dialogue.DialogueError) as excinfo:
|
|
dialogue.check_limits(inputs)
|
|
assert "Split it" in str(excinfo.value) # actionable, since Bolt reads this
|
|
|
|
|
|
# ── display / reporting ─────────────────────────────────────────────────────
|
|
|
|
def test_delivery_tags_are_stripped_from_what_the_bubble_shows():
|
|
action = dialogue.parse('dialoguectl ' + _scene(
|
|
'{"voice": "self", "text": "[cheerfully] Hello there!"}',
|
|
'{"voice": "narrator", "text": "[whispering] He is lying."}',
|
|
))
|
|
assert dialogue.spoken_text(action) == "Hello there! He is lying."
|
|
|
|
|
|
def test_the_relay_report_names_the_cast():
|
|
action = dialogue.parse('dialoguectl ' + _scene(
|
|
'{"voice": "self", "text": "one"}', '{"voice": "narrator", "text": "two"}',
|
|
))
|
|
assert dialogue.describe(action) == "[dialogue] played 2 lines in 2 voices: narrator, self"
|
|
|
|
|
|
# ── the HTTP request ────────────────────────────────────────────────────────
|
|
|
|
def _pcm_response(samples=(1, 2, 3, 4)):
|
|
response = MagicMock()
|
|
response.content = np.array(samples, dtype=np.int16).tobytes()
|
|
response.raise_for_status = MagicMock()
|
|
return response
|
|
|
|
|
|
def test_the_request_matches_the_text_to_dialogue_api(monkeypatch):
|
|
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
|
monkeypatch.setattr(tts.config, "TTS_SAMPLE_RATE", 24000)
|
|
monkeypatch.setattr(tts.config, "DIALOGUE_MODEL_ID", "eleven_v3")
|
|
inputs = [
|
|
{"text": "[cheerfully] Hello", "voice_id": NARRATOR_ID},
|
|
{"text": "[stuttering] H-hi", "voice_id": VILLAIN_ID},
|
|
]
|
|
with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
|
|
pcm, rate = tts.synthesize_dialogue(inputs)
|
|
|
|
assert rate == 24000 and pcm.tolist() == [1, 2, 3, 4]
|
|
args, kwargs = post.call_args
|
|
assert args[0] == "https://api.elevenlabs.io/v1/text-to-dialogue"
|
|
assert kwargs["params"] == {"output_format": "pcm_24000"}
|
|
assert kwargs["headers"] == {"xi-api-key": "test-key"}
|
|
assert kwargs["json"]["inputs"] == inputs
|
|
assert kwargs["json"]["model_id"] == "eleven_v3"
|
|
assert "settings" not in kwargs["json"] # omitted unless asked for
|
|
|
|
|
|
def test_stability_is_only_sent_when_given(monkeypatch):
|
|
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
|
with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
|
|
tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}], stability=0.3)
|
|
assert post.call_args.kwargs["json"]["settings"] == {"stability": 0.3}
|
|
|
|
|
|
def test_a_rejected_request_surfaces_what_the_api_said(monkeypatch):
|
|
"""The API explains refusals in the body; Bolt reads this through the tool
|
|
relay, so it has to reach him rather than being flattened to '422'."""
|
|
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
|
failure = MagicMock()
|
|
failure.text = '{"detail": "voice_id not found"}'
|
|
error = Exception("422 Client Error")
|
|
error.response = failure
|
|
response = MagicMock()
|
|
response.raise_for_status = MagicMock(side_effect=error)
|
|
|
|
with patch.object(tts.requests, "post", return_value=response):
|
|
with pytest.raises(tts.TtsError, match="voice_id not found"):
|
|
tts.synthesize_dialogue([{"text": "hi", "voice_id": "nope"}])
|
|
|
|
|
|
def test_no_api_key_fails_before_the_request(monkeypatch):
|
|
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "")
|
|
with patch.object(tts.requests, "post") as post:
|
|
with pytest.raises(tts.TtsError):
|
|
tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}])
|
|
post.assert_not_called()
|