Skip to content

Cancelling a realtime-ws recording destroys the websocket client with no recovery path — dictation wedged until daemon restart #213

Description

@lyh970817

Summary

Cancelling an in-flight realtime-ws recording destroys the websocket client object, and nothing ever recreates it — every subsequent recording attempt fails with [ERROR] Realtime backend not connected (WebSocket closed while idle?). Try again. until the daemon is restarted (or a suspend-resume / model reload happens to re-run initialize()). Observed and diagnosed on v1.34.1; the same logic appears to be present (relocated) in the current v1.37.0 layout.

Related: #212 (a different realtime-ws connection-lifecycle race; same subsystem).

Reproduction

  1. Configure transcription_backend: "realtime-ws" (OpenAI provider), start the daemon, confirm dictation works.
  2. Start a recording, then cancel it (FIFO cancel, e.g. via a cancel keybinding).
  3. Press the record shortcut again → recording aborts immediately, and every retry fails the same way. Journal:
[CONTROL] Recording cancel requested (immediate)
Recording cancelled (discarded)
[REALTIME] WebSocket closed (code: None)
[REALTIME] Connection closed          <- no "Reconnecting" line (intentional close)
[CONTROL] Recording start requested (immediate)
Recording started
[ERROR] Realtime backend not connected (WebSocket closed while idle?). Try again.

"Try again" can never succeed — the daemon is permanently wedged in an idle-but-unusable state.

Root cause (v1.34.1 file/line references)

  1. main.py:_cancel_recording() (~line 1445): for realtime-ws it calls whisper_manager._cleanup_realtime_client().
  2. whisper_manager.py:_cleanup_realtime_client() (~line 1972): client.close() (intentional close, so the client's auto-reconnect does not fire) and sets _realtime_client = None and _realtime_streaming_callback = None.
  3. On the next record press, get_realtime_streaming_callback() (~line 1571) is the only recovery point, but its reconnect is gated on the client object existing:
if backend == 'realtime-ws' and self._realtime_client:   # False: client is None
    ...
return None                                               # taken instead → fail-fast

The reconnect path can revive a disconnected client but never recreates a destroyed one, and the client is only constructed inside initialize(). So cancel → wedged until restart.

The same destroy-without-recreate pattern also exists in the recording-start failure handlers (main.py ~lines 1361 and 1391), so a transient mic/start failure can wedge the daemon the same way.

Suggested fixes (we run both locally as a patch against v1.34.1; happy to adapt into a PR)

  1. Recovery net: make the record-press path able to recreate the client when it is None — the connect params (url, api key, model, instructions) are already retained in _realtime_connect_params, so get_realtime_streaming_callback() / the reconnect helper can rebuild and connect() on demand. This also fixes the two failure-handler wedge sites.
  2. Better UX for cancel specifically: don't destroy the connection at all. Cancel can send input_audio_buffer.clear and set a client-side discard flag for any in-flight transcript events from the cancelled utterance (cleared at the next recording start), keeping the websocket alive so the next recording starts instantly instead of paying a fresh handshake. The destroy-and-recreate path then remains as a fallback when the connection is actually dead.

Environment

hyprwhspr v1.34.1 (NixOS, systemd user service), realtime-ws + OpenAI (wss://api.openai.com/v1/realtime?intent=transcription), use_hypr_bindings: true (cancel delivered via FIFO from a compositor bind).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions