Plan 3 fix: GA turn_detection auto-create_response + reset state on WS reconnect

GA Realtime API requires create_response:true and interrupt_response:true
inside turn_detection — without them the model never auto-generates a
reply after VAD detects speech_stopped, so the relay idles out at 30s
with no assistant audio. Add both.

Client side: when the /device WS drops (Coolify rolling deploy, etc.),
Session.run_forever reconnects but the StateMachine kept whatever state
it was in — usually LISTENING, which permanently disables the wakeword.
Add StateMachine.force_idle() and a Callbacks.on_disconnected() hook;
main.py wires it to force the state back to IDLE on every disconnect.
Also harden Session's fire-and-forget send_* helpers to swallow
ConnectionClosed instead of bubbling up as unhandled task exceptions.
This commit is contained in:
2026-06-12 06:50:00 +00:00
parent 7294a81a9a
commit 8bb0ce51b1
5 changed files with 79 additions and 12 deletions
+34 -12
View File
@@ -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."""