From f5f65ffba91dcfcdf826f7cc33b60df2ce567968 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 9 Jun 2026 22:01:14 +0200 Subject: [PATCH] fix: stability, WhisperX 404, log levels (v2.03.00) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stability: remove blocking TCP socket call from _collect() — was freezing the GTK main thread on Save when wakeword server unreachable; now checks async in background and logs result - Thread safety: fix _ww_load() reading GTK widget from background thread; capture URI on main thread before spawning - WhisperX 404: _transcribe_remote now detects non-standard paths (anything other than /v1) and uses the URL as the full endpoint, so http://host/transcribe works without /audio/transcriptions appended - Log levels: logbuffer stores (ts, level, msg) tuples; log() accepts level= (DEBUG/INFO/WARNING/ERROR); Log tab gets a Level dropdown (Verbose/Info/Warning/Error) that filters displayed entries live Co-Authored-By: Claude Sonnet 4.6 --- linux/blitztext/__init__.py | 2 +- linux/blitztext/gtksettings.py | 46 +++++++++++++++++++++++----------- linux/blitztext/logbuffer.py | 44 ++++++++++++++++++++++++-------- linux/blitztext/stt.py | 8 +++++- linux/blitztext/wakeword.py | 4 +-- 5 files changed, 76 insertions(+), 28 deletions(-) diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index a408087..78cc2aa 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "2.02.03" +__version__ = "2.03.00" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index c6e1e74..40d9a2e 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -1825,10 +1825,10 @@ notebook.bt-nb tab:checked label { def _ww_load(self) -> None: self._probe_dot(self.ww_dot, self.ww_uri.get_text(), 10400) + uri = self.ww_uri.get_text().strip() # capture on GTK thread before spawning def work(): import socket, json from urllib.parse import urlparse - uri = self.ww_uri.get_text().strip() parsed = urlparse(uri) host = parsed.hostname or "127.0.0.1" port = parsed.port or 10400 @@ -2435,6 +2435,17 @@ notebook.bt-nb tab:checked label { bar = Gtk.Box(spacing=8); bar.set_margin_top(6) self.log_autoscroll = Gtk.CheckButton(label="Auto-scroll"); self.log_autoscroll.set_active(True) bar.pack_start(self.log_autoscroll, False, False, 0) + + lvl_lbl = Gtk.Label(label="Level:"); lvl_lbl.set_margin_start(8) + bar.pack_start(lvl_lbl, False, False, 0) + self.log_level = Gtk.ComboBoxText() + for lvl in ("Verbose", "Info", "Warning", "Error"): + self.log_level.append_text(lvl) + self.log_level.set_active(1) # default: Info + self.log_level.set_tooltip_text("Show log entries at or above this severity level") + self.log_level.connect("changed", lambda _: (setattr(self, "_log_last", None), self._log_refresh())) + bar.pack_start(self.log_level, False, False, 0) + clear = Gtk.Button(label="Clear"); clear.connect("clicked", lambda _b: (logbuffer.clear(), self._log_refresh())) copy = Gtk.Button(label="Copy"); copy.connect("clicked", lambda _b: self._log_copy()) bar.pack_end(clear, False, False, 0); bar.pack_end(copy, False, False, 0) @@ -2444,12 +2455,16 @@ notebook.bt-nb tab:checked label { self._log_refresh() self._log_timer = GLib.timeout_add(1000, self._log_tick) + def _log_min_level(self) -> str: + idx = self.log_level.get_active() if hasattr(self, "log_level") else 1 + return ("DEBUG", "INFO", "WARNING", "ERROR")[max(0, min(idx, 3))] + def _log_tick(self) -> bool: self._log_refresh() return True def _log_refresh(self) -> None: - text = "\n".join(logbuffer.lines()) + text = "\n".join(logbuffer.lines(self._log_min_level())) if text == getattr(self, "_log_last", None): return self._log_last = text @@ -2460,7 +2475,7 @@ notebook.bt-nb tab:checked label { def _log_copy(self) -> None: cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - cb.set_text("\n".join(logbuffer.lines()), -1) + cb.set_text("\n".join(logbuffer.lines(self._log_min_level())), -1) def _cleanup(self) -> None: self._stop_meter() @@ -2516,17 +2531,20 @@ notebook.bt-nb tab:checked label { autostart.set_enabled(self.gen_boot.get_active()) if c.wakeword_enabled: - import socket - from urllib.parse import urlparse - parsed = urlparse(c.wakeword_uri) - host = parsed.hostname or "127.0.0.1" - port = parsed.port or 10400 - try: - with socket.create_connection((host, port), timeout=1.5): - pass - except OSError as e: - self._error(f"Cannot connect to Wakeword server at {c.wakeword_uri}:\n{e}\n\nPlease check your server or disable wakeword.") - return False + # Check reachability in background — don't block the GTK thread + uri = c.wakeword_uri + def _bg_ww_check(uri=uri): + import socket + from urllib.parse import urlparse + parsed = urlparse(uri) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 10400 + try: + with socket.create_connection((host, port), timeout=2.0): + logbuffer.log(f"[wakeword] server reachable at {uri}", level="INFO") + except OSError as e: + logbuffer.log(f"[wakeword] cannot reach {uri}: {e}", level="WARNING") + threading.Thread(target=_bg_ww_check, daemon=True).start() except ValueError as exc: self._error(f"Check numeric fields: {exc}") return False diff --git a/linux/blitztext/logbuffer.py b/linux/blitztext/logbuffer.py index b8060e3..757e44f 100644 --- a/linux/blitztext/logbuffer.py +++ b/linux/blitztext/logbuffer.py @@ -3,6 +3,9 @@ Our own messages go through log(); library logs (faster-whisper, huggingface_hub) are captured via a logging handler so model download/load progress is visible instead of an opaque "Loading…". + +Each entry stores (timestamp_str, level, message) so the UI can filter by level. +Levels: DEBUG, INFO, WARNING, ERROR (default: INFO) """ from __future__ import annotations @@ -13,32 +16,53 @@ import threading import time from collections import deque -_LINES: deque[str] = deque(maxlen=2000) +# Each entry: (time_str, level, message) +_ENTRIES: deque[tuple[str, str, str]] = deque(maxlen=2000) _LOCK = threading.Lock() +_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3} -def log(msg: str, *, echo: bool = True) -> None: - line = f"{time.strftime('%H:%M:%S')} {msg}" + +def log(msg: str, *, echo: bool = True, level: str = "INFO") -> None: + level = level.upper() + ts = time.strftime("%H:%M:%S") with _LOCK: - _LINES.append(line) + _ENTRIES.append((ts, level, msg)) if echo: - print(line, file=sys.stderr, flush=True) + print(f"{ts} [{level}] {msg}", file=sys.stderr, flush=True) -def lines() -> list[str]: +def lines(min_level: str = "DEBUG") -> list[str]: + """Return formatted lines at or above min_level.""" + threshold = _LEVEL_ORDER.get(min_level.upper(), 0) with _LOCK: - return list(_LINES) + entries = list(_ENTRIES) + result = [] + for ts, lvl, msg in entries: + if _LEVEL_ORDER.get(lvl, 1) >= threshold: + prefix = f"[{lvl}] " if lvl not in ("INFO",) else "" + result.append(f"{ts} {prefix}{msg}") + return result def clear() -> None: with _LOCK: - _LINES.clear() + _ENTRIES.clear() class _BufferHandler(logging.Handler): + _PY_TO_LEVEL = { + logging.DEBUG: "DEBUG", + logging.INFO: "INFO", + logging.WARNING: "WARNING", + logging.ERROR: "ERROR", + logging.CRITICAL:"ERROR", + } + def emit(self, record: logging.LogRecord) -> None: try: - log(self.format(record), echo=False) + lvl = self._PY_TO_LEVEL.get(record.levelno, "INFO") + log(self.format(record), echo=False, level=lvl) except Exception: # noqa: BLE001 - never let logging break the app pass @@ -57,4 +81,4 @@ def install_logging() -> None: for name in ("faster_whisper", "huggingface_hub", "blitztext"): lg = logging.getLogger(name) lg.addHandler(handler) - lg.setLevel(logging.INFO) + lg.setLevel(logging.DEBUG) diff --git a/linux/blitztext/stt.py b/linux/blitztext/stt.py index f22ba9b..28bb06d 100644 --- a/linux/blitztext/stt.py +++ b/linux/blitztext/stt.py @@ -193,7 +193,13 @@ def _transcribe_remote(engine: STTEngine, audio_path: Path, *, language: str, pr import os base = engine.url.rstrip("/") - endpoint = base + "/audio/transcriptions" + # If path is non-standard (not empty / /v1 / /v1.0), treat the full URL as + # the endpoint — supports WhisperX (/transcribe) and other custom paths. + _path = urlparse(base).path.rstrip("/") + if _path in ("", "/v1", "/v1.0"): + endpoint = base + "/audio/transcriptions" + else: + endpoint = base fields: dict[str, str] = {"response_format": "json"} if engine.model: fields["model"] = engine.model diff --git a/linux/blitztext/wakeword.py b/linux/blitztext/wakeword.py index b1283f3..be36acd 100644 --- a/linux/blitztext/wakeword.py +++ b/linux/blitztext/wakeword.py @@ -35,7 +35,7 @@ def set_muted(muted: bool) -> None: elif os.path.exists(MUTE_FILE): os.remove(MUTE_FILE) except OSError as e: # noqa: BLE001 - mute is best-effort, never crash - logbuffer.log(f"[wakeword] Could not update mute flag: {e}") + logbuffer.log(f"[wakeword] Could not update mute flag: {e}", level="WARNING") class WakewordListener: @@ -67,7 +67,7 @@ class WakewordListener: try: self._stream() except Exception as e: - logbuffer.log(f"[wakeword] Connection error: {e}") + logbuffer.log(f"[wakeword] Connection error: {e}", level="WARNING") time.sleep(3) # Retry backoff def _stream(self):