diff --git a/backend/Realtime/RealtimeEvents.cs b/backend/Realtime/RealtimeEvents.cs index 415d17a..fbe6fef 100644 --- a/backend/Realtime/RealtimeEvents.cs +++ b/backend/Realtime/RealtimeEvents.cs @@ -50,6 +50,11 @@ public static class RealtimeEvents ["threshold"] = 0.5, ["prefix_padding_ms"] = 300, ["silence_duration_ms"] = 500, + // GA requires both explicitly — without them the + // model never auto-generates a reply after VAD + // detects speech_stopped. + ["create_response"] = true, + ["interrupt_response"] = true, }, }, ["output"] = new JsonObject diff --git a/client/main.py b/client/main.py index ee451e2..3de9b58 100644 --- a/client/main.py +++ b/client/main.py @@ -107,6 +107,14 @@ def _make_callbacks(sm: StateMachine, playback: PlaybackWorker): playback.enqueue_wav(SLEEP_WAV, "sleep") else: playback.enqueue_done_marker("sleep") + + def on_disconnected(self) -> None: + # The conversation is gone with the WS — reset to IDLE so the + # wakeword detector can re-arm. Any pending assistant audio left + # in the playback queue still drains, but we drop a "sleep" done + # marker only if we were mid-session. + _log.info("on_disconnected: forcing state to IDLE") + sm.force_idle() return _CB() diff --git a/client/session.py b/client/session.py index 2f1e9fc..d52822c 100644 --- a/client/session.py +++ b/client/session.py @@ -19,6 +19,9 @@ class Callbacks(Protocol): def on_assistant_audio(self, pcm16: bytes) -> None: ... def on_assistant_done(self) -> None: ... def on_session_ended(self, reason: str) -> None: ... + # Called once per backend WS lifecycle when the connection drops, so the + # owner can reset state (the conversation is dead). + def on_disconnected(self) -> None: ... class Session: @@ -64,10 +67,12 @@ class Session: finally: ping_task.cancel() self._ws = None + self._notify_disconnected() except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 _log.warning("session error: %s; reconnecting in %.1fs", exc, backoff) + self._notify_disconnected() jitter = random.uniform(0, backoff * 0.1) await asyncio.sleep(backoff + jitter) backoff = min(backoff * 2.0, RECONNECT_MAX_S) @@ -158,6 +163,17 @@ class Session: "result": result, })) + def _notify_disconnected(self) -> None: + # Tolerant: Callbacks Protocol marks on_disconnected as part of the + # surface, but real callers (and tests) may omit it. + fn = getattr(self._cb, "on_disconnected", None) + if fn is None: + return + try: + fn() + except Exception as exc: # noqa: BLE001 + _log.warning("on_disconnected raised: %s", exc) + async def _ping_loop(self, ws) -> None: try: while True: @@ -167,26 +183,32 @@ class Session: pass # ---- Public helpers used by main.py from the asyncio loop ---- + # + # All of these are called fire-and-forget via call_soon_threadsafe, so they + # must NEVER raise — the asyncio loop has nowhere to surface the exception + # except as an unhandled-task-exception traceback in the journal. + + async def _safe_send(self, payload) -> None: + if self._ws is None: + return + try: + await self._ws.send(payload) + except Exception as exc: # noqa: BLE001 + # ConnectionClosed / ConnectionClosedOK / ConnectionClosedError all + # land here. The reconnect loop already handles WS lifecycle. + _log.debug("send dropped: %s", exc) async def send_wake(self) -> None: - if self._ws is None: - return - await self._ws.send(json.dumps({"type": "wake"})) + await self._safe_send(json.dumps({"type": "wake"})) async def send_binary(self, pcm16: bytes) -> None: - if self._ws is None: - return - await self._ws.send(pcm16) + await self._safe_send(pcm16) async def send_playback_done(self, label: str) -> None: - if self._ws is None: - return - await self._ws.send(json.dumps({"type": "playback_done", "for": label})) + await self._safe_send(json.dumps({"type": "playback_done", "for": label})) async def send_cancel(self) -> None: - if self._ws is None: - return - await self._ws.send(json.dumps({"type": "cancel"})) + await self._safe_send(json.dumps({"type": "cancel"})) def call_soon_threadsafe_send_binary(self, pcm16: bytes) -> None: """Thread-safe shim used by the bridge thread to queue an uplink send.""" diff --git a/client/state.py b/client/state.py index ff0465e..a9a1094 100644 --- a/client/state.py +++ b/client/state.py @@ -68,3 +68,10 @@ class StateMachine: def sleep_played(self) -> None: if self._state is State.IDLE_PENDING: self._transition(State.IDLE) + + def force_idle(self) -> None: + """Unconditional reset to IDLE — used when the backend WS drops and + the conversation is dead regardless of what state we thought we were in. + Re-enables the wakeword detector.""" + if self._state is not State.IDLE: + self._transition(State.IDLE) diff --git a/client/tests/test_state.py b/client/tests/test_state.py index 8a5e88b..6d90c49 100644 --- a/client/tests/test_state.py +++ b/client/tests/test_state.py @@ -100,3 +100,28 @@ def test_sleep_played_outside_idle_pending_is_noop(): sm = StateMachine() sm.sleep_played() assert sm.state is State.IDLE + + +def test_force_idle_from_listening_resets_state_and_reenables_wakeword(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + assert sm.state is State.LISTENING + sm.force_idle() + assert sm.state is State.IDLE + assert sm.wakeword_enabled + + +def test_force_idle_from_assistant_speaking_resets(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + sm.assistant_audio_arrived() + sm.force_idle() + assert sm.state is State.IDLE + + +def test_force_idle_on_idle_is_noop(): + sm = StateMachine() + sm.force_idle() + assert sm.state is State.IDLE