Skip to content

Commit 769bc98

Browse files
committed
STT slow-internet: extend cloud timeout 3s->15s, reachability 0.5s->3s; add AV-block pill; friendly download pills; hook self-heal cursor-motion gate; yt-dlp logger bridge
1 parent 6edb3df commit 769bc98

4 files changed

Lines changed: 128 additions & 17 deletions

File tree

core/transcriber.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ def _prewarm_cloud(self) -> None:
447447
t0 = time.perf_counter()
448448
sess.get(
449449
'https://api.groq.com/openai/v1/models',
450-
timeout=3.0,
450+
timeout=10.0,
451451
)
452452
logger.info(
453453
f'Cloud TLS pre-warmed in {(time.perf_counter()-t0)*1000:.0f}ms'
@@ -466,7 +466,7 @@ def _cloud_enabled(self) -> bool:
466466
return True
467467

468468
def _cloud_reachable(self, host: str = 'api.groq.com',
469-
port: int = 443, timeout: float = 0.5) -> bool:
469+
port: int = 443, timeout: float = 3.0) -> bool:
470470
"""Fast non-blocking TCP probe to detect whether the cloud
471471
Whisper host is reachable RIGHT NOW. Returns True on successful
472472
connect, False on any failure (DNS resolution failure, network
@@ -728,9 +728,14 @@ def _transcribe_hybrid(self, audio: np.ndarray):
728728
'skipping cloud, going to local.')
729729
return self._transcribe(audio, _already_denoised=denoised_here)
730730

731-
# Cloud timeout budget, short, because local is the safety net.
732-
# 3 s covers 99 % of Groq calls; anything slower → just go local.
733-
cloud_timeout = float(getattr(self._cfg.audio, 'cloud_timeout_s', 3.0))
731+
# Cloud timeout budget. Was 3s (too aggressive: users on slow /
732+
# rural / congested WiFi never completed the upload phase for a
733+
# 2s audio clip before we aborted to local, even when the network
734+
# would eventually deliver the response). Bumped to 15s so slow
735+
# uploaders on ~20-50 kbps connections still get the higher-
736+
# quality Groq result. Local pipeline stays as safety net for
737+
# anything slower than that.
738+
cloud_timeout = float(getattr(self._cfg.audio, 'cloud_timeout_s', 15.0))
734739
lang_hint = self._cfg.transcription.language or None
735740

736741
t0 = time.perf_counter()
@@ -768,17 +773,37 @@ def _transcribe_hybrid(self, audio: np.ndarray):
768773
# AV-blocked / permission-denied / other "cloud reachable but
769774
# refusing" cases, the local fallback runs transparently and
770775
# produces a correct result, so we stay silent.
776+
exc_name = type(e).__name__.lower()
771777
if 'name resolution' in msg_l or 'getaddrinfo' in msg_l:
772778
self._cloud_last_error = 'Cloud unreachable (DNS) — using local model'
773779
elif 'connection' in msg_l and ('refus' in msg_l or 'reset' in msg_l):
774780
self._cloud_last_error = 'Cloud refused connection — using local model'
775781
elif 'timeout' in msg_l or 'timed out' in msg_l:
776-
self._cloud_last_error = 'Cloud timed out — using local model'
782+
# Timeout on slow internet is the most common cause of the
783+
# silent "why is my dictation worse" complaint. Make the
784+
# cause explicit so the user knows to check connection.
785+
self._cloud_last_error = 'Cloud too slow (internet slow?) — used local model'
786+
elif (
787+
# Antivirus HTTPS interception. AVG / Avast / Kaspersky /
788+
# Bitdefender / ESET all MITM outbound TLS by default and
789+
# present their own cert, which our TLS stack rejects
790+
# because it isn't in truststore. Signature: SSL /
791+
# certificate errors, PermissionError 13, "wrong version
792+
# number", "unknown ca", "self signed certificate".
793+
'ssl' in msg_l or 'certificate' in msg_l
794+
or 'winerror 13' in msg_l or 'permission' in exc_name
795+
or 'wrong version number' in msg_l or 'unknown ca' in msg_l
796+
or 'self signed' in msg_l or 'self-signed' in msg_l
797+
or 'cert_' in msg_l
798+
):
799+
self._cloud_last_error = (
800+
'Antivirus blocked cloud — add api.groq.com to AV exceptions')
777801
else:
778-
# AV blocks (PermissionError 13), unknown errors, etc:
779-
# log for debugging but do not pop a pill — local result
780-
# speaks for itself.
781-
self._cloud_last_error = None
802+
# Unknown errors: still surface something so the user has
803+
# a hint. Truncated exception name is enough for us to
804+
# diagnose from a screenshot without needing full logs.
805+
self._cloud_last_error = (
806+
f'Cloud failed ({type(e).__name__}) — used local model')
782807
logger.warning(
783808
f'Cloud transcription failed in {dt*1000:.0f}ms, falling back '
784809
f'to local Whisper. ({type(e).__name__}: {e})'

kbhook.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,17 @@ class _LASTINPUTINFO(ctypes.Structure):
207207
_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
208208
_kernel32.GetTickCount.restype = _wt.DWORD
209209

210+
_user32.GetCursorPos.argtypes = [ctypes.POINTER(_wt.POINT)]
211+
_user32.GetCursorPos.restype = _wt.BOOL
212+
213+
# Cursor position snapshot from previous is_hook_alive() call. Used to
214+
# distinguish "input was mouse" (cursor moved) from "input was keyboard"
215+
# (cursor stationary). GetLastInputInfo reports any input; our hook only
216+
# sees keyboard, so mouse motion would otherwise falsely mark hook dead.
217+
_last_cursor_x = 0
218+
_last_cursor_y = 0
219+
_last_cursor_check_tick = 0 # GetTickCount ms at prev snapshot
220+
210221
# When True, the hook stops matching/suppressing/dispatching, but still
211222
# tracks the modifier mask so it can recognise "a registered hotkey was
212223
# pressed while paused" and tell the app to show a reminder toast.
@@ -447,7 +458,8 @@ def stop() -> None:
447458

448459
# ── Liveness detection + self-heal ────────────────────────────────────────
449460

450-
def is_hook_alive(idle_grace_sec: float = 30.0) -> bool:
461+
def is_hook_alive(idle_grace_sec: float = 30.0,
462+
min_silent_sec: float = 300.0) -> bool:
451463
"""Return True if the WH_KEYBOARD_LL hook is provably still installed.
452464
453465
Detection approach: compare our own last-hook-callback timestamp
@@ -466,7 +478,16 @@ def is_hook_alive(idle_grace_sec: float = 30.0) -> bool:
466478
will report dead).
467479
468480
Called from the main.py watchdog loop; must be cheap.
481+
482+
Mouse-vs-keyboard disambiguation: GetLastInputInfo reports the last
483+
time Windows saw ANY input (mouse OR keyboard), but WH_KEYBOARD_LL
484+
only sees keyboard. Mouse motion alone would otherwise trip a
485+
false-positive every ~2s. We snapshot the cursor position across
486+
calls; if the cursor moved between the previous check and now, the
487+
recent input includes mouse, so we cannot conclude the hook is dead.
469488
"""
489+
global _last_cursor_x, _last_cursor_y, _last_cursor_check_tick
490+
470491
if not _started or _hook_ref[0] is None:
471492
return False
472493

@@ -482,15 +503,46 @@ def is_hook_alive(idle_grace_sec: float = 30.0) -> bool:
482503
ms_since_windows_input = (now_tick - lii.dwTime) & 0xFFFFFFFF
483504
sec_since_windows_input = ms_since_windows_input / 1000.0
484505

506+
# Snapshot cursor now; compare against previous snapshot to see if
507+
# mouse moved between checks. Do this BEFORE any early return so the
508+
# baseline is fresh for the next call.
509+
pt = _wt.POINT()
510+
cursor_moved = False
511+
if _user32.GetCursorPos(ctypes.byref(pt)):
512+
if _last_cursor_check_tick != 0 and (
513+
pt.x != _last_cursor_x or pt.y != _last_cursor_y
514+
):
515+
cursor_moved = True
516+
_last_cursor_x = pt.x
517+
_last_cursor_y = pt.y
518+
_last_cursor_check_tick = now_tick
519+
485520
if sec_since_windows_input > idle_grace_sec:
486521
# User is idle. Can't distinguish dead hook from real idleness.
487522
return True
488523

524+
if cursor_moved:
525+
# Mouse moved since last check — recent input includes mouse
526+
# events our keyboard-only hook doesn't see. Can't conclude
527+
# anything about hook liveness.
528+
return True
529+
489530
sec_since_hook_callback = time.monotonic() - _last_hook_tick
490531

491-
# Windows saw input in the last N seconds. Our hook must have too
492-
# (with a small tolerance for the timestamp race).
493-
return sec_since_hook_callback < sec_since_windows_input + 2.0
532+
# Even with cursor stationary, mouse CLICKS (button events without
533+
# motion) also update GetLastInputInfo but don't fire our keyboard
534+
# hook. To avoid tripping on those, require the hook to have been
535+
# silent for at least `min_silent_sec` — normal keyboard activity
536+
# keeps _last_hook_tick fresh, so 5-min silence combined with recent
537+
# OS input is a very reliable "hook is actually dead" signal.
538+
if sec_since_hook_callback < min_silent_sec:
539+
return True
540+
541+
# Hook silent for 5+ minutes, cursor stationary between watchdog
542+
# ticks, and Windows saw input recently — that input was almost
543+
# certainly keyboard (or a mouse click) that our hook should have
544+
# seen but didn't. Treat as dead.
545+
return False
494546

495547

496548
def reinstall_hook() -> bool:

overlay.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,27 @@ def show_done(self, elapsed: float) -> None:
182182

183183
# ── URL downloader pills (Ctrl+Alt+D) ────────────────────────────────────
184184

185+
def show_download_capturing(self) -> None:
186+
"""Immediate pill shown the instant Ctrl+Alt+D fires, before the
187+
clipboard capture completes. Bridges the ~500ms silence between
188+
the hotkey press and the download-starting pill."""
189+
self._close()
190+
self._build('🔍 Reading URL…', _TEXT_CLR, ACCENT)
191+
185192
def show_download_starting(self) -> None:
186193
"""Initial pill — shown the moment the URL is captured, before
187194
yt-dlp's first progress hook fires (which can take a few seconds
188195
while it resolves the format manifest)."""
189196
self._close()
190197
self._build('📥 Downloading… 0%', _TEXT_CLR, ACCENT)
191198

199+
def show_download_asking(self) -> None:
200+
"""Pill shown while the playlist-confirmation dialog is on screen,
201+
so the user knows the download is waiting on their input rather
202+
than stalled."""
203+
self._close()
204+
self._build('❓ Playlist detected — choose in dialog', _TEXT_CLR, ACCENT)
205+
192206
def show_download_progress(self, frac: float) -> None:
193207
"""Update the percentage in the in-flight download pill. Safe to
194208
call from a worker thread via Tk's after()."""

transcribe/youtube.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def download_url(
134134
on_progress: Callable[[float], None] | None = None,
135135
on_log: Callable[[str], None] | None = None,
136136
on_phase: Callable[[str], None] | None = None,
137+
allow_playlist: bool = False,
137138
) -> Path:
138139
"""User-facing downloader: saves `url` to `dest_dir` using the yt-dlp
139140
format string `fmt` (see DOWNLOAD_FORMATS for the curated menu). Returns
@@ -145,11 +146,12 @@ def download_url(
145146
"""
146147
return _download(url, dest_dir, fmt,
147148
on_progress=on_progress, on_log=on_log,
148-
on_phase=on_phase)
149+
on_phase=on_phase, allow_playlist=allow_playlist)
149150

150151

151152
def _download(url: str, dest_dir, fmt: str, *,
152-
on_progress, on_log, on_phase=None) -> Path:
153+
on_progress, on_log, on_phase=None,
154+
allow_playlist: bool = False) -> Path:
153155
"""on_phase(label) is called with short status strings like 'merging',
154156
'transcoding', or 'done' so callers can update UI text separately
155157
from the progress bar."""
@@ -201,12 +203,30 @@ def _pp_hook(d):
201203
except Exception:
202204
pass
203205

206+
# Bridge yt-dlp's internal logger into ours so failures show the
207+
# real extractor/HTTP trace in app.log without needing quiet=False
208+
# (which would flood the console). Every yt-dlp log line is prefixed
209+
# with 'yt-dlp:' so it's greppable.
210+
class _YtdlpLogger:
211+
def debug(self, msg):
212+
# yt-dlp routes both real debug AND normal info through debug()
213+
# (an [debug] prefix distinguishes them). We downgrade real
214+
# debug lines and keep info at INFO.
215+
if msg.startswith('[debug] '):
216+
logger.debug(f'yt-dlp: {msg[8:]}')
217+
else:
218+
logger.info(f'yt-dlp: {msg}')
219+
def info(self, msg): logger.info(f'yt-dlp: {msg}')
220+
def warning(self, msg): logger.warning(f'yt-dlp: {msg}')
221+
def error(self, msg): logger.error(f'yt-dlp: {msg}')
222+
204223
opts = {
205224
'format': fmt,
206225
'outtmpl': str(dest / '%(title).80s [%(id)s].%(ext)s'),
207-
'noplaylist': True,
226+
'noplaylist': not allow_playlist,
208227
'quiet': True,
209228
'no_warnings': True,
229+
'logger': _YtdlpLogger(),
210230
'progress_hooks': [_hook],
211231
'postprocessor_hooks': [_pp_hook],
212232
'extract_flat': False,

0 commit comments

Comments
 (0)