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:
parent
6fb41d5a9b
commit
f5f65ffba9
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user