Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,7 @@ def _start_recording(self, language_override=None):
self._hide_mic_osd()
self._stop_audio_level_monitoring()
self._notify_zero_volume(
"Realtime backend not connected yet — try again in a moment.",
self._realtime_unavailable_message(),
log_level="ERROR",
)
# Restore audio if it was ducked
Expand Down Expand Up @@ -1540,6 +1540,20 @@ def _mic_failure_message(self, fallback: str) -> str:
return "Microphone unavailable - input device missing or still initializing - check the connection and try again"
return fallback

def _realtime_unavailable_message(self) -> str:
"""Explain why realtime couldn't start, based on what recovery actually hit.

"try again in a moment" is only true while a handshake is in flight; for a
failed connect it invites the user to keep retrying against a dead endpoint.
"""
reason = self.whisper_manager.realtime_connect_failure()
if reason == 'failed':
return "Realtime connection failed — check network or provider status"
if reason == 'cooldown':
# Nothing retries in the background — the next attempt is the user's.
return "Realtime connection failed — try again in a few seconds"
return "Realtime backend not connected yet — try again in a moment."

def _notify_zero_volume(self, message: str, log_level: str = "WARN"):
"""Log a mic failure, signal waybar, and show a coalesced desktop notification"""
# Prevent duplicate handling of the same error within 2 seconds (user
Expand Down
95 changes: 81 additions & 14 deletions lib/src/backends/realtime_ws_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class RealtimeWsBackend(TranscriptionBackend):
is_local = False
reinit_on_resume = True

# Don't rebuild a torn-down client on every keypress while an endpoint is down.
REBUILD_COOLDOWN_SECS = 5.0

def __init__(self, manager):
super().__init__(manager)
# Realtime WebSocket client
Expand All @@ -53,6 +56,10 @@ def __init__(self, manager):
# Connection parameters used for reconnect-on-demand.
# (Stored in-memory only; do not log API keys.)
self._realtime_connect_params = None
# Why the last recovery attempt failed, for an honest user-facing message:
# 'connecting' | 'cooldown' | 'failed' | None
self._last_connect_failure = None
self._last_rebuild_attempt = None

@property
def _realtime_partial_callback(self):
Expand Down Expand Up @@ -415,18 +422,19 @@ def get_streaming_callback(self) -> Optional[Callable]:
backend = self.config.get_setting('transcription_backend', 'pywhispercpp')
backend = normalize_backend(backend)

if backend == 'realtime-ws' and self._realtime_client:
# If the server closed the socket while idle, reconnect on-demand here
# (before we start capturing audio) to avoid dropping the first chunks.
if not self._realtime_client.connected:
if not self._reconnect_realtime_client():
return None

# Clear server buffer before starting new recording
self._realtime_client.clear_audio_buffer()
self._clear_realtime_partial_preview()
return self._realtime_streaming_callback
return None
if backend != 'realtime-ws':
return None

# Recover here — before we start capturing audio — so the first chunks
# aren't dropped, whether the socket went idle or the client was torn
# down entirely by an earlier failure.
if not self._ensure_client():
return None

# Clear server buffer before starting new recording
self._realtime_client.clear_audio_buffer()
self._clear_realtime_partial_preview()
return self._realtime_streaming_callback

def apply_partial_callback(self, callback: Optional[Callable[[str], None]]) -> None:
"""Apply the partial-preview callback to the active realtime provider."""
Expand Down Expand Up @@ -484,6 +492,53 @@ def _clear_realtime_partial_preview(self) -> None:
except Exception as e:
print(f'[REALTIME] Failed to clear partial transcript preview: {e}', flush=True)

@property
def last_connect_failure(self) -> Optional[str]:
"""Why the last recovery attempt failed: 'connecting', 'cooldown', 'failed', or None."""
return self._last_connect_failure

def _ensure_client(self) -> bool:
"""Make the realtime client usable for a new recording.

Single recovery entry point for the three states a client can be in:
connected, disconnected (idle close), or gone entirely — the last one
happens whenever close_realtime_connection() runs on a recording failure
or suspend, and nothing outside the resume path rebuilds it.
"""
if self._realtime_client:
if self._realtime_client.connected:
self._last_connect_failure = None
return True
return self._reconnect_realtime_client()

now = time.monotonic()
last = self._last_rebuild_attempt
if last is not None and (now - last) < self.REBUILD_COOLDOWN_SECS:
print('[REALTIME] Rebuild failed recently; waiting before retry', flush=True)
self._last_connect_failure = 'cooldown'
return False

# initialize() can block on the connect timeout, so don't retry it on
# every keypress while the endpoint is down.
self._last_rebuild_attempt = now
print('[REALTIME] Rebuilding client after teardown', flush=True)
try:
rebuilt = self.initialize()
except Exception as e:
print(f'[REALTIME] Rebuild failed: {e}', flush=True)
self._last_connect_failure = 'failed'
return False

if not rebuilt or not self._realtime_client:
self._last_connect_failure = 'failed'
return False

# Only failures should hold the cooldown, or a teardown shortly after a
# good rebuild would be stalled by the previous success.
self._last_rebuild_attempt = None
self._last_connect_failure = None
return True

def _reconnect_realtime_client(self) -> bool:
"""Reconnect realtime client using stored connect params."""
if not self._realtime_client:
Expand All @@ -501,9 +556,11 @@ def _reconnect_realtime_client(self) -> bool:
time.sleep(0.1)
if self._realtime_client.connected:
print('[REALTIME] In-flight connection landed; proceeding', flush=True)
self._last_connect_failure = None
return True
if getattr(self._realtime_client, 'connecting', False):
print('[REALTIME] Still connecting; try again in a moment', flush=True)
self._last_connect_failure = 'connecting'
return False
# Attempt finished without connecting; fall through to reconnect.

Expand All @@ -515,23 +572,33 @@ def _reconnect_realtime_client(self) -> bool:

if not (websocket_url and api_key and model_id):
print('[REALTIME] Missing connection parameters; cannot reconnect', flush=True)
self._last_connect_failure = 'failed'
return False

try:
# Best-effort: close any stale connection first
# Best-effort: drop stale socket/thread state first. Use reset() where
# available — close() latches the client shut and would make every
# reconnect from here fail instantly (issue #229). ElevenLabs has no
# reset(); its close() is already a transient teardown.
try:
self._realtime_client.close()
teardown = getattr(self._realtime_client, 'reset', None)
if teardown is None:
teardown = self._realtime_client.close
teardown()
except Exception:
pass

if not self._realtime_client.connect(websocket_url, api_key, model_id, instructions):
print('[REALTIME] Reconnect failed', flush=True)
self._last_connect_failure = 'failed'
return False

print('[REALTIME] Reconnected on-demand', flush=True)
self._last_connect_failure = None
return True
except Exception as e:
print(f'[REALTIME] Reconnect failed: {e}', flush=True)
self._last_connect_failure = 'failed'
return False

def discard_audio(self) -> None:
Expand Down
64 changes: 56 additions & 8 deletions lib/src/realtime_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ def __init__(self, mode: str = 'transcribe'):
self._reconnect_threads = set()
self._stop_event = threading.Event()
self._closed = False
# Set by the transport callbacks when the in-flight attempt dies.
self._attempt_failed = None
self._connection_generation = 0
self._active_generation = 0

Expand Down Expand Up @@ -373,12 +375,18 @@ def _connect_internal(self) -> bool:
on_close=lambda ws, code, message: self._on_close(ws, code, message, generation),
**kwargs,
)
# Lets the transport's error/close callbacks end this attempt's wait
# early instead of burning the full timeout on an endpoint that has
# already refused us.
attempt_failed = threading.Event()

abandon_closed_attempt = False
with self.lock:
if self._closed or generation != self._active_generation:
abandon_closed_attempt = True
else:
self.ws = attempt_ws
self._attempt_failed = attempt_failed
if abandon_closed_attempt:
attempt_ws.close()
return False
Expand All @@ -395,14 +403,16 @@ def _connect_internal(self) -> bool:
while not self.connected and (time.time() - start_time) < timeout:
if self._stop_event.wait(0.1):
break
if attempt_failed.is_set():
break

if self.connected:
self._log('Connected successfully')
self.reconnect_attempts = 0
self._on_connect_success()
return True
else:
self._log('Connection timeout')
self._log('Connection failed' if attempt_failed.is_set() else 'Connection timeout')
self._abandon_attempt(attempt_ws)
return False

Expand Down Expand Up @@ -465,8 +475,14 @@ def _on_error(self, ws, error, generation=None):
with self.lock:
if not self._is_active_connection(ws, generation):
return
self._fail_pending_attempt_locked()
self._log(f'WebSocket error: {error}')

def _fail_pending_attempt_locked(self):
"""Mark the in-flight connect attempt dead (no-op once connected)."""
if not self.connected and self._attempt_failed is not None:
self._attempt_failed.set()

def _on_close(self, ws, close_status_code, _close_msg, generation=None):
"""Handle WebSocket close"""
with self.lock:
Expand All @@ -476,6 +492,9 @@ def _on_close(self, ws, close_status_code, _close_msg, generation=None):
if not self._is_active_connection(ws, generation):
self._log(f'Ignoring close from obsolete WebSocket (code: {close_status_code})')
return
# A close before the handshake lands is a failed attempt, not an
# idle drop — don't make the caller wait out the connect timeout.
self._fail_pending_attempt_locked()
was_connected = self.connected
self.connected = False
# Stop sender thread on disconnect; it will be restarted on next connect.
Expand Down Expand Up @@ -528,8 +547,12 @@ def _attempt_reconnect(self):
self._log('Reconnection cancelled (closing)')
return False

# Abort if the client was closed while we were waiting
if self._closed or not self.receiver_running:
# Abort if the client was closed or torn down while we were waiting.
# Read under the lock: _closed can flip both ways now that an
# explicit connect() reopens a latched client.
with self.lock:
cancelled = self._closed or not self.receiver_running
if cancelled:
self._log('Reconnection cancelled (closing)')
return False

Expand All @@ -543,13 +566,33 @@ def _attempt_reconnect(self):
self._reconnect_lock.release()

def close(self):
"""Close WebSocket connection and cleanup"""
"""Close WebSocket connection and cleanup (permanent: latches the client shut)."""
self._shutdown(latch=True)

def reset(self):
"""Tear the connection down without latching, so connect() can reopen it.

close() latches `_closed` so background reconnect loops stand down during
a real shutdown. Reconnect paths want the same teardown *without* that
latch — see issue #229, where closing before reconnecting left the client
permanently unusable.
"""
self._shutdown(latch=False)

def _shutdown(self, latch: bool):
"""Shared teardown. `_closed` is mutated only under self.lock."""
with self.lock:
if self._closed:
if self._closed and latch:
return
self._closed = True
self._stop_event.set()
if latch:
self._closed = True
self._stop_event.set()
self._active_generation += 1
# Any in-flight attempt is abandoned by the generation bump, and its
# own finally clause won't clear this flag once generations diverge.
self.connecting = False
# reset() sets no stop signal, so wake a waiting attempt explicitly.
self._fail_pending_attempt_locked()
self._sender_running = False
self.receiver_running = False
self._audio_queue.clear()
Expand Down Expand Up @@ -579,8 +622,13 @@ def close(self):

with self.lock:
self.connected = False
if not latch and not self._closed:
# Leave the client usable: no latch, and no lingering stop signal
# for the next attempt's wait loops to trip over. A client that
# was already closed for good keeps its stop signal.
self._stop_event.clear()

self._log('Connection closed')
self._log('Connection closed' if latch else 'Connection reset for reconnect')

# ------------------------------------------------------------------
# Transcript assembly / commit
Expand Down
11 changes: 11 additions & 0 deletions lib/src/whisper_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ def get_realtime_streaming_callback(self) -> Optional[Callable]:
return backend.get_streaming_callback()
return None

def realtime_connect_failure(self) -> Optional[str]:
"""Why realtime recovery last failed ('connecting'/'cooldown'/'failed'), else None.

Reads the backend directly rather than _active_realtime_backend(), which
excludes exactly the torn-down case we most need to explain.
"""
backend = self._backend
if backend is not None and backend.name == 'realtime-ws':
return getattr(backend, 'last_connect_failure', None)
return None

def _active_realtime_backend(self):
"""The realtime-ws backend when it has a live client, else None."""
backend = self._backend
Expand Down
Loading
Loading