fix: stability, WhisperX 404, log levels (v2.03.00)

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-09 22:01:14 +02:00
parent 6fb41d5a9b
commit f5f65ffba9
5 changed files with 76 additions and 28 deletions

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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):