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:
@@ -50,6 +50,11 @@ public static class RealtimeEvents
|
|||||||
["threshold"] = 0.5,
|
["threshold"] = 0.5,
|
||||||
["prefix_padding_ms"] = 300,
|
["prefix_padding_ms"] = 300,
|
||||||
["silence_duration_ms"] = 500,
|
["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
|
["output"] = new JsonObject
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ def _make_callbacks(sm: StateMachine, playback: PlaybackWorker):
|
|||||||
playback.enqueue_wav(SLEEP_WAV, "sleep")
|
playback.enqueue_wav(SLEEP_WAV, "sleep")
|
||||||
else:
|
else:
|
||||||
playback.enqueue_done_marker("sleep")
|
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()
|
return _CB()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -19,6 +19,9 @@ class Callbacks(Protocol):
|
|||||||
def on_assistant_audio(self, pcm16: bytes) -> None: ...
|
def on_assistant_audio(self, pcm16: bytes) -> None: ...
|
||||||
def on_assistant_done(self) -> None: ...
|
def on_assistant_done(self) -> None: ...
|
||||||
def on_session_ended(self, reason: str) -> 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:
|
class Session:
|
||||||
@@ -64,10 +67,12 @@ class Session:
|
|||||||
finally:
|
finally:
|
||||||
ping_task.cancel()
|
ping_task.cancel()
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
self._notify_disconnected()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
_log.warning("session error: %s; reconnecting in %.1fs", exc, backoff)
|
_log.warning("session error: %s; reconnecting in %.1fs", exc, backoff)
|
||||||
|
self._notify_disconnected()
|
||||||
jitter = random.uniform(0, backoff * 0.1)
|
jitter = random.uniform(0, backoff * 0.1)
|
||||||
await asyncio.sleep(backoff + jitter)
|
await asyncio.sleep(backoff + jitter)
|
||||||
backoff = min(backoff * 2.0, RECONNECT_MAX_S)
|
backoff = min(backoff * 2.0, RECONNECT_MAX_S)
|
||||||
@@ -158,6 +163,17 @@ class Session:
|
|||||||
"result": result,
|
"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:
|
async def _ping_loop(self, ws) -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -167,26 +183,32 @@ class Session:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# ---- Public helpers used by main.py from the asyncio loop ----
|
# ---- 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:
|
async def send_wake(self) -> None:
|
||||||
if self._ws is None:
|
await self._safe_send(json.dumps({"type": "wake"}))
|
||||||
return
|
|
||||||
await self._ws.send(json.dumps({"type": "wake"}))
|
|
||||||
|
|
||||||
async def send_binary(self, pcm16: bytes) -> None:
|
async def send_binary(self, pcm16: bytes) -> None:
|
||||||
if self._ws is None:
|
await self._safe_send(pcm16)
|
||||||
return
|
|
||||||
await self._ws.send(pcm16)
|
|
||||||
|
|
||||||
async def send_playback_done(self, label: str) -> None:
|
async def send_playback_done(self, label: str) -> None:
|
||||||
if self._ws is None:
|
await self._safe_send(json.dumps({"type": "playback_done", "for": label}))
|
||||||
return
|
|
||||||
await self._ws.send(json.dumps({"type": "playback_done", "for": label}))
|
|
||||||
|
|
||||||
async def send_cancel(self) -> None:
|
async def send_cancel(self) -> None:
|
||||||
if self._ws is None:
|
await self._safe_send(json.dumps({"type": "cancel"}))
|
||||||
return
|
|
||||||
await self._ws.send(json.dumps({"type": "cancel"}))
|
|
||||||
|
|
||||||
def call_soon_threadsafe_send_binary(self, pcm16: bytes) -> None:
|
def call_soon_threadsafe_send_binary(self, pcm16: bytes) -> None:
|
||||||
"""Thread-safe shim used by the bridge thread to queue an uplink send."""
|
"""Thread-safe shim used by the bridge thread to queue an uplink send."""
|
||||||
|
|||||||
@@ -68,3 +68,10 @@ class StateMachine:
|
|||||||
def sleep_played(self) -> None:
|
def sleep_played(self) -> None:
|
||||||
if self._state is State.IDLE_PENDING:
|
if self._state is State.IDLE_PENDING:
|
||||||
self._transition(State.IDLE)
|
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)
|
||||||
|
|||||||
@@ -100,3 +100,28 @@ def test_sleep_played_outside_idle_pending_is_noop():
|
|||||||
sm = StateMachine()
|
sm = StateMachine()
|
||||||
sm.sleep_played()
|
sm.sleep_played()
|
||||||
assert sm.state is State.IDLE
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user