3a0959f55d
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON endpoint, so the wait is time-to-first-sentence rather than the whole model call, and Deepgram's live websocket transcribes while you're still talking instead of uploading the WAV afterwards. Both fall back invisibly — a stream that fails before anything was said drops to converse(), and a socket that never opens just means the old one-shot path. Speaking lived in four near-copies in the controller (a reply, a holding line, a streamed sentence, a dialogue scene) that had already drifted: one didn't arm barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an Utterance describing the policy differences, with collaborators injected so the whole of it tests without Qt or audio. The mouth follows the audio rather than a timer: tts.level_of reduces each PCM frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a linear map leaves the mouth barely open during normal talking) and that indexes the talking frames, which the sprite script now draws as an openness ramp. Offline pyttsx3 has no waveform, so stale levels hand control back to the timed loop instead of freezing the mouth mid-syllable. Also: the pet starts where you left it (ignoring positions on monitors that are no longer connected, since restoring those faithfully is how it ends up somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that says what to do about each problem rather than only what's wrong. tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit test passed all week while notifications sat unspoken for minutes, the pet said things twice and [laughing] got read aloud — each an interaction between two individually-correct units. It drives whole turns against a real HTTP server on a loopback port, faking only the mic and the speakers. It found a NameError in the paint path that would have fired on every repaint while talking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
10 KiB
Python
237 lines
10 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).startswith(
|
|
"[dialogue] played 2 lines in 2 voices: narrator, self")
|
|
|
|
|
|
def test_the_report_says_the_scene_was_already_heard():
|
|
"""Observed 2026-07-31: the scene played, then the final reply summarised
|
|
it, so the pet said the same thing twice with nothing in between. The
|
|
model cannot know the audio already happened unless it is told."""
|
|
action = dialogue.parse('dialoguectl {"lines": [{"text": "hello"}]}')
|
|
report = dialogue.describe(action)
|
|
assert "HEARD this already" in report
|
|
assert "Do not repeat" in report
|
|
|
|
|
|
# ── 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()
|