Add text-to-dialogue, self-restart capability, and misc updates
This commit is contained in:
@@ -41,10 +41,10 @@ def test_full_turn_happy_path(monkeypatch, ctrl):
|
||||
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: "sunny and 72")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None: controller_mod.server_client.Reply("sunny and 72"))
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: (spoken.append(text), True)[1])
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_heartbeat_speaks_a_pending_announcement_when_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm")
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: (spoken.append(text), True)[1])
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: (spoken.append(text), True)[1])
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet.notifications import Notification
|
||||
from bolt_pet.server_client import Reply
|
||||
from bolt_pet.state import PetState
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
@@ -159,7 +160,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
||||
ctrl._barge_in = detector
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def interrupted_playback(text, on_error=None, should_stop=None):
|
||||
def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None):
|
||||
# What really happens: frames get scored during playback, then one
|
||||
# clears the threshold and playback aborts.
|
||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
||||
@@ -179,7 +180,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
||||
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||
logs = _capture(ctrl.log)
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: False) # interrupted
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: False) # interrupted
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
@@ -189,7 +190,7 @@ def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||
|
||||
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
ctrl._speak("short answer")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
@@ -201,7 +202,7 @@ def spoke(monkeypatch):
|
||||
"""Playback that always completes, so only the follow-up rule decides
|
||||
whether another turn is queued."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
|
||||
|
||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||
@@ -289,7 +290,7 @@ def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
||||
|
||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: False)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: False)
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
@@ -300,7 +301,7 @@ def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
|
||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
ctrl._speak("**bold** reply")
|
||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
||||
|
||||
@@ -314,10 +315,10 @@ def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for",
|
||||
lambda text: f"{text}\n\n[on screen right now: app.py]")
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or "that's a KeyError")
|
||||
lambda text, on_command=None: sent.append(text) or Reply("that's a KeyError"))
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
@@ -370,10 +371,10 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: "always")
|
||||
lambda text, on_command=None: Reply("always"))
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||
ctrl.set_napping(True)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
@@ -388,9 +389,9 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or "your build is green")
|
||||
lambda text, on_command=None: sent.append(text) or Reply("your build is green"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
ctrl._drain_notifications()
|
||||
@@ -506,9 +507,9 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: "it's on the way")
|
||||
lambda text, on_command=None: Reply("it's on the way"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
||||
@@ -518,3 +519,294 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
|
||||
|
||||
|
||||
# ── server-picked voice (the desk API's speak_as marker) ────────────────────
|
||||
|
||||
def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"):
|
||||
"""Run one full conversation turn whose reply is *reply*, returning the
|
||||
voice_id each tts.speak() call was given."""
|
||||
voices = []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said)
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: reply)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
voices.append(voice_id) or True,
|
||||
)
|
||||
ctrl._handle_conversation_turn()
|
||||
return voices
|
||||
|
||||
|
||||
def test_a_speak_as_reply_is_spoken_in_that_voice(monkeypatch, ctrl):
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
assert voices == ["VOICE1"]
|
||||
assert ctrl.current_voice() == "Terence"
|
||||
|
||||
|
||||
def test_the_picked_voice_sticks_for_later_replies(monkeypatch, ctrl):
|
||||
"""The server tags one reply and doesn't keep the id in its history, so
|
||||
it can't re-request the voice when you say "keep talking like that"."""
|
||||
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", True)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Still me."), said="and now?")
|
||||
assert voices == ["VOICE1"]
|
||||
|
||||
|
||||
def test_voice_stickiness_can_be_turned_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "VOICE_STICKY", False)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Back to normal."), said="and now?")
|
||||
assert voices == [None]
|
||||
assert ctrl.current_voice() == ""
|
||||
|
||||
|
||||
def test_a_new_pick_replaces_the_old_one(monkeypatch, ctrl):
|
||||
changes = _capture(ctrl.voice_changed)
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("こんにちは。", "VOICE2", "Asahi"),
|
||||
said="say that in Japanese")
|
||||
assert voices == ["VOICE2"]
|
||||
assert changes == ["Terence", "Asahi"]
|
||||
|
||||
|
||||
def test_resetting_the_voice_goes_back_to_the_default(monkeypatch, ctrl):
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
changes = _capture(ctrl.voice_changed)
|
||||
|
||||
ctrl.reset_voice()
|
||||
|
||||
assert ctrl.current_voice() == ""
|
||||
assert changes == [""] # the tray's menu entry follows this signal
|
||||
voices = _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi")
|
||||
assert voices == [None]
|
||||
|
||||
|
||||
def test_an_unnamed_voice_still_reports_something_resettable(monkeypatch, ctrl):
|
||||
"""voice_name is optional server-side — falling back to the id keeps the
|
||||
tray entry from reading "now: " with nothing after it."""
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1"))
|
||||
assert ctrl.current_voice() == "VOICE1"
|
||||
|
||||
|
||||
def test_petctl_voice_reset_returns_bolt_to_his_own_voice(monkeypatch, ctrl):
|
||||
"""The server can pick a voice but can't ask for the default back — it
|
||||
was never told what Bolt's own voice id is. This is how it asks."""
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
_voice_turn(monkeypatch, ctrl, Reply("Ahoy.", "VOICE1", "Terence"))
|
||||
|
||||
output = ctrl._handle_command("petctl voice reset")
|
||||
|
||||
assert ran == [] # never reaches a shell, like every other petctl verb
|
||||
assert "Terence" in output # the server can't see the voice; tell it what changed
|
||||
assert ctrl.current_voice() == ""
|
||||
assert _voice_turn(monkeypatch, ctrl, Reply("Normal again."), said="hi") == [None]
|
||||
|
||||
|
||||
def test_petctl_voice_reset_says_so_when_there_was_nothing_to_reset(ctrl):
|
||||
assert "already" in ctrl._handle_command("petctl voice reset")
|
||||
|
||||
|
||||
# ── dialoguectl (multi-voice scenes) ────────────────────────────────────────
|
||||
|
||||
def _dialogue_command(*lines):
|
||||
import json
|
||||
return "dialoguectl " + json.dumps({"lines": list(lines)})
|
||||
|
||||
|
||||
def test_dialoguectl_never_reaches_the_shell(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None: True)
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "[cheerfully] hi"}))
|
||||
|
||||
assert ran == []
|
||||
assert "[dialogue] played 1 line" in output
|
||||
|
||||
|
||||
def test_a_scene_shows_in_the_bubble_with_the_delivery_tags_stripped(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._handle_command(_dialogue_command(
|
||||
{"voice": "self", "text": "[cheerfully] Hello there!"},
|
||||
{"voice": "narrator", "text": "[whispering] He is lying."},
|
||||
))
|
||||
|
||||
assert said == ["Hello there! He is lying."]
|
||||
assert ctrl.history.last().text == "Hello there! He is lying."
|
||||
|
||||
|
||||
def test_a_mid_turn_scene_returns_to_thinking_not_idle(monkeypatch, ctrl):
|
||||
"""The server is still waiting on the tool result, so the pet talks and
|
||||
goes back to waiting — dropping to IDLE would look like the turn ended."""
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
states = _capture(ctrl.state_changed)
|
||||
|
||||
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert states == ["talking", "thinking"]
|
||||
assert ctrl._state.state == PetState.THINKING
|
||||
|
||||
|
||||
def test_the_scene_uses_a_voice_the_server_picked_with_speak_as(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
seen = {}
|
||||
|
||||
def capture(inputs, model_id=None, stability=None):
|
||||
seen["inputs"] = inputs
|
||||
return np.zeros(4, dtype=np.int16), 24000
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", capture)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
ctrl._apply_voice(controller_mod.server_client.Reply("ok", "PICKEDvoice123456789", "Terence"))
|
||||
|
||||
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert seen["inputs"][0]["voice_id"] == "PICKEDvoice123456789"
|
||||
|
||||
|
||||
def test_a_synthesis_failure_is_reported_back_for_bolt_to_retry(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
|
||||
def boom(inputs, model_id=None, stability=None):
|
||||
raise controller_mod.tts.TtsError("voice_id not found")
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", boom)
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert "couldn't synthesize" in output and "voice_id not found" in output
|
||||
assert ctrl._state.state == PetState.IDLE # nothing left half-transitioned
|
||||
|
||||
|
||||
def test_a_bad_voice_name_comes_back_as_advice_not_an_exception(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "wizard", "text": "hi"}))
|
||||
assert "unknown voice" in output and "narrator" in output
|
||||
|
||||
|
||||
def test_dialogue_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE", False)
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda *a, **k: called.append(1))
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
assert "disabled" in output and called == []
|
||||
|
||||
|
||||
def test_talking_over_a_scene_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None: False) # barge-in
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
assert "interrupted" in output
|
||||
|
||||
|
||||
# ── petctl self_restart ─────────────────────────────────────────────────────
|
||||
|
||||
def test_self_restart_arms_after_the_turn_rather_than_dying_mid_relay(monkeypatch, ctrl, tmp_path):
|
||||
"""Restarting inline would kill the HTTP tool relay before the result was
|
||||
posted, and the server would wait out its timeout on a turn that can never
|
||||
finish. So the command returns, the turn completes, *then* the pet dies."""
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
||||
restarts = _capture(ctrl.restart_requested)
|
||||
|
||||
output = ctrl._handle_command("petctl self_restart check the new dialogue code")
|
||||
|
||||
assert "restarting as soon as this turn finishes" in output
|
||||
assert restarts == [] # nothing has happened yet
|
||||
|
||||
assert ctrl._maybe_self_restart() is True
|
||||
assert restarts and "check the new dialogue code" in restarts[0]
|
||||
|
||||
|
||||
def test_a_broken_edit_is_reported_instead_of_leaving_nothing_running(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise controller_mod.self_restart.RestartError(
|
||||
"the current code does not import, so restarting would leave you with "
|
||||
"nothing running. Fix this first:\nSyntaxError: invalid syntax"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", boom)
|
||||
restarts = _capture(ctrl.restart_requested)
|
||||
|
||||
output = ctrl._handle_command("petctl self_restart try the new code")
|
||||
|
||||
assert "SyntaxError" in output and "refused" in output
|
||||
assert ctrl._maybe_self_restart() is False
|
||||
assert restarts == []
|
||||
|
||||
|
||||
def test_a_second_restart_request_in_one_turn_is_a_no_op(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "ctx.json")
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight", lambda *a, **k: None)
|
||||
ctrl._handle_command("petctl self_restart first")
|
||||
assert "already armed" in ctrl._handle_command("petctl self_restart second")
|
||||
|
||||
|
||||
def test_self_restart_can_be_switched_off_on_this_device(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SELF_RESTART", False)
|
||||
checked = []
|
||||
monkeypatch.setattr(controller_mod.self_restart, "preflight",
|
||||
lambda *a, **k: checked.append(1))
|
||||
assert "disabled" in ctrl._handle_command("petctl self_restart go")
|
||||
assert checked == []
|
||||
|
||||
|
||||
def test_coming_back_up_reports_to_the_server_and_speaks_the_reply(monkeypatch, ctrl, tmp_path):
|
||||
"""The half that makes it a loop: the new process tells Bolt it's back and
|
||||
why, and his answer is spoken like any other turn."""
|
||||
state = tmp_path / "ctx.json"
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", state)
|
||||
controller_mod.self_restart.arm("check the walk cycle", version="0.2.3",
|
||||
path=state, now=1000.0)
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("Good, it's up."))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert "[pet self-restart]" in sent[0] and "check the walk cycle" in sent[0]
|
||||
assert spoken == ["Good, it's up."]
|
||||
# Consumed, so the next start doesn't announce the same restart again.
|
||||
assert controller_mod.self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_an_ordinary_start_reports_nothing(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "none.json")
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: called.append(text))
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert called == []
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""`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()
|
||||
@@ -69,3 +69,17 @@ def test_describe_is_reported_back_to_the_server():
|
||||
assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"})
|
||||
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
||||
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
||||
|
||||
|
||||
def test_voice_reset_parses_with_or_without_the_word_reset():
|
||||
assert pet_actions.parse("petctl voice reset") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice default") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice") == {"action": "voice", "voice": "default"}
|
||||
|
||||
|
||||
def test_petctl_cannot_be_used_to_pick_a_voice():
|
||||
"""Choosing a voice is the server's job (speak_as) — it has the voice
|
||||
library. petctl only ever undoes one, so an attempt to set a voice here
|
||||
is pointed back at the marker that works."""
|
||||
with pytest.raises(pet_actions.ActionError, match="speak_as"):
|
||||
pet_actions.parse("petctl voice Terence")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""`petctl self_restart` — the pet restarting itself and remembering why.
|
||||
|
||||
Everything here runs against a temp context file and a fake subprocess runner,
|
||||
so the tests exercise the arming/preflight/report logic without any process
|
||||
actually dying.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import pet_actions, self_restart
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(tmp_path):
|
||||
return tmp_path / "restart_context.json"
|
||||
|
||||
|
||||
def _ok_run(*args, **kwargs):
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
|
||||
def _broken_run(*args, **kwargs):
|
||||
return SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr=' File "bolt_pet/controller.py", line 42\n def _speak(\nSyntaxError: invalid syntax',
|
||||
)
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_self_restart_parses_with_a_free_text_reason():
|
||||
action = pet_actions.parse("petctl self_restart check the new walk cycle loads")
|
||||
assert action == {
|
||||
"action": "self_restart", "reason": "check the new walk cycle loads",
|
||||
}
|
||||
|
||||
|
||||
def test_self_restart_needs_no_reason_and_accepts_aliases():
|
||||
assert pet_actions.parse("petctl self_restart")["reason"] == ""
|
||||
assert pet_actions.parse("petctl restart")["action"] == "self_restart"
|
||||
assert pet_actions.parse("petctl reboot")["action"] == "self_restart"
|
||||
|
||||
|
||||
def test_self_restart_is_listed_in_the_help():
|
||||
assert "self_restart" in pet_actions.HELP
|
||||
|
||||
|
||||
# ── preflight ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_preflight_passes_when_the_code_imports():
|
||||
self_restart.preflight(run=_ok_run) # no exception
|
||||
|
||||
|
||||
def test_preflight_hands_back_the_traceback_instead_of_dying(state):
|
||||
"""The whole point: a syntax error Bolt just introduced comes back as
|
||||
something he can read and fix, in the same turn, with the pet still up."""
|
||||
with pytest.raises(self_restart.RestartError) as excinfo:
|
||||
self_restart.preflight(run=_broken_run)
|
||||
message = str(excinfo.value)
|
||||
assert "does not import" in message
|
||||
assert "SyntaxError" in message and "controller.py" in message
|
||||
|
||||
|
||||
def test_preflight_runs_the_import_in_a_subprocess_not_here():
|
||||
"""This process holds the *old* modules, so an in-process import would
|
||||
pass on a file that no longer parses."""
|
||||
seen = {}
|
||||
|
||||
def capture(cmd, **kwargs):
|
||||
seen["cmd"], seen["kwargs"] = cmd, kwargs
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
self_restart.preflight(run=capture)
|
||||
assert seen["cmd"][0] == sys.executable
|
||||
assert "import bolt_pet" in seen["cmd"][2]
|
||||
assert seen["kwargs"]["env"]["QT_QPA_PLATFORM"] == "offscreen" # imports need no display
|
||||
|
||||
|
||||
def test_a_subprocess_that_cannot_even_run_is_reported(monkeypatch):
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("no python here")
|
||||
|
||||
with pytest.raises(self_restart.RestartError, match="couldn't run the preflight"):
|
||||
self_restart.preflight(run=explode)
|
||||
|
||||
|
||||
# ── context across the restart ──────────────────────────────────────────────
|
||||
|
||||
def test_arming_persists_the_reason_for_the_next_process(state):
|
||||
self_restart.arm("check the sprite frames load", version="0.2.3",
|
||||
session="pet-desktop", recent=["you: reload the sprites"],
|
||||
path=state, now=1000.0)
|
||||
revived = self_restart.load(state)
|
||||
assert revived.reason == "check the sprite frames load"
|
||||
assert revived.version == "0.2.3"
|
||||
assert revived.recent == ["you: reload the sprites"]
|
||||
assert revived.restarts == [1000.0]
|
||||
|
||||
|
||||
def test_no_context_means_a_normal_start(state):
|
||||
assert self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_a_corrupt_context_file_is_ignored_not_fatal(state):
|
||||
state.write_text("{not json at all", encoding="utf-8")
|
||||
assert self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_clearing_the_context_stops_it_being_re_announced(state):
|
||||
self_restart.arm("once", path=state, now=1000.0)
|
||||
self_restart.clear(state)
|
||||
assert self_restart.load(state) is None
|
||||
self_restart.clear(state) # clearing twice is not an error
|
||||
|
||||
|
||||
def test_the_report_says_what_happened_and_what_to_check(state):
|
||||
context = self_restart.arm(
|
||||
"verify the dialogue command works", verify="verify the dialogue command works",
|
||||
version="0.2.3", recent=["you: try a scene"], path=state, now=1000.0,
|
||||
)
|
||||
text = self_restart.report(context, version="0.2.4", now=1004.5)
|
||||
assert "I restarted myself" in text
|
||||
assert "verify the dialogue command works" in text
|
||||
assert "4.5s" in text
|
||||
assert "0.2.4" in text and "was 0.2.3" in text
|
||||
assert "you: try a scene" in text
|
||||
|
||||
|
||||
# ── loop guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_restart_history_accumulates_across_restarts(state):
|
||||
self_restart.arm("one", path=state, now=1000.0)
|
||||
self_restart.arm("two", path=state, now=1100.0)
|
||||
assert self_restart.load(state).restarts == [1000.0, 1100.0]
|
||||
|
||||
|
||||
def test_too_many_restarts_in_the_window_is_refused(state):
|
||||
now = 1000.0
|
||||
for index in range(self_restart.MAX_RESTARTS):
|
||||
self_restart.arm(f"attempt {index}", path=state, now=now + index)
|
||||
with pytest.raises(self_restart.RestartError, match="looping"):
|
||||
self_restart.check_loop_guard(self_restart.load(state), now=now + 10)
|
||||
|
||||
|
||||
def test_old_restarts_fall_out_of_the_window(state):
|
||||
now = 1000.0
|
||||
for index in range(self_restart.MAX_RESTARTS):
|
||||
self_restart.arm(f"attempt {index}", path=state, now=now + index)
|
||||
later = now + self_restart.WINDOW_SECONDS + 60
|
||||
self_restart.check_loop_guard(self_restart.load(state), now=later) # no exception
|
||||
assert self_restart.recent_restarts(self_restart.load(state), now=later) == []
|
||||
@@ -27,7 +27,8 @@ def test_converse_returns_reply_directly():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
||||
result = server_client.converse("hi")
|
||||
assert result == "hello there"
|
||||
assert result.text == "hello there"
|
||||
assert result.voice_id == "" # no speak_as on this reply
|
||||
post.assert_called_once()
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/converse"
|
||||
@@ -43,7 +44,7 @@ def test_converse_relays_a_command_then_returns_reply():
|
||||
with patch.object(server_client.requests, "post", side_effect=responses) as post:
|
||||
on_command = MagicMock(return_value="[exit 0]\nhi")
|
||||
result = server_client.converse("run echo hi", on_command=on_command)
|
||||
assert result == "done"
|
||||
assert result.text == "done"
|
||||
on_command.assert_called_once_with("echo hi")
|
||||
# second call was to /desk/tool_result with the command's output
|
||||
second_call = post.call_args_list[1]
|
||||
@@ -53,6 +54,19 @@ def test_converse_relays_a_command_then_returns_reply():
|
||||
}
|
||||
|
||||
|
||||
def test_converse_carries_a_speak_as_voice_back_with_the_reply():
|
||||
"""The server tags a reply with the voice it picked (speak_as); this
|
||||
client is what actually speaks in it, so the id has to survive the
|
||||
return trip rather than being dropped with the rest of the payload."""
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({
|
||||
"type": "reply", "text": "Ahoy there.",
|
||||
"voice_id": "hnhGxwvHP8fc469w51rM", "voice_name": "Terence",
|
||||
})
|
||||
result = server_client.converse("talk like a pirate")
|
||||
assert result == server_client.Reply("Ahoy there.", "hnhGxwvHP8fc469w51rM", "Terence")
|
||||
|
||||
|
||||
def test_converse_raises_server_error_on_error_payload():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
|
||||
|
||||
@@ -8,7 +8,8 @@ import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio.tts import chunks_to_int16
|
||||
from bolt_pet import config as tts_config
|
||||
from bolt_pet.audio.tts import chunks_to_int16, model_for, voice_for
|
||||
from bolt_pet.audio.wake_word import NearMissLog
|
||||
|
||||
|
||||
@@ -80,3 +81,27 @@ def test_clear_resets_peak_and_entries():
|
||||
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
||||
log.clear()
|
||||
assert log.entries() == [] and log.peak == 0.0
|
||||
|
||||
|
||||
# ── voice / model selection (server speak_as) ───────────────────────────────
|
||||
|
||||
def test_the_override_voice_wins_over_the_configured_one(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "DEFAULT")
|
||||
assert voice_for("VOICE1") == "VOICE1"
|
||||
assert voice_for("") == "DEFAULT"
|
||||
assert voice_for(None) == "DEFAULT"
|
||||
|
||||
|
||||
def test_english_replies_in_the_default_voice_use_the_default_model(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||
assert model_for("all good here", None) == "eleven_flash_v2"
|
||||
|
||||
|
||||
def test_a_picked_voice_or_non_english_text_uses_the_multilingual_model(monkeypatch):
|
||||
# eleven_flash_v2 is English-only: it would read either of these as
|
||||
# mangled phonetic English rather than failing outright.
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_MULTILINGUAL_MODEL_ID", "eleven_flash_v2_5")
|
||||
assert model_for("all good here", "VOICE1") == "eleven_flash_v2_5"
|
||||
assert model_for("こんにちは", None) == "eleven_flash_v2_5"
|
||||
|
||||
Reference in New Issue
Block a user