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.
|
(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:
|
def _ww_load(self) -> None:
|
||||||
self._probe_dot(self.ww_dot, self.ww_uri.get_text(), 10400)
|
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():
|
def work():
|
||||||
import socket, json
|
import socket, json
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
uri = self.ww_uri.get_text().strip()
|
|
||||||
parsed = urlparse(uri)
|
parsed = urlparse(uri)
|
||||||
host = parsed.hostname or "127.0.0.1"
|
host = parsed.hostname or "127.0.0.1"
|
||||||
port = parsed.port or 10400
|
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)
|
bar = Gtk.Box(spacing=8); bar.set_margin_top(6)
|
||||||
self.log_autoscroll = Gtk.CheckButton(label="Auto-scroll"); self.log_autoscroll.set_active(True)
|
self.log_autoscroll = Gtk.CheckButton(label="Auto-scroll"); self.log_autoscroll.set_active(True)
|
||||||
bar.pack_start(self.log_autoscroll, False, False, 0)
|
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()))
|
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())
|
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)
|
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_refresh()
|
||||||
self._log_timer = GLib.timeout_add(1000, self._log_tick)
|
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:
|
def _log_tick(self) -> bool:
|
||||||
self._log_refresh()
|
self._log_refresh()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _log_refresh(self) -> None:
|
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):
|
if text == getattr(self, "_log_last", None):
|
||||||
return
|
return
|
||||||
self._log_last = text
|
self._log_last = text
|
||||||
@ -2460,7 +2475,7 @@ notebook.bt-nb tab:checked label {
|
|||||||
|
|
||||||
def _log_copy(self) -> None:
|
def _log_copy(self) -> None:
|
||||||
cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
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:
|
def _cleanup(self) -> None:
|
||||||
self._stop_meter()
|
self._stop_meter()
|
||||||
@ -2516,17 +2531,20 @@ notebook.bt-nb tab:checked label {
|
|||||||
autostart.set_enabled(self.gen_boot.get_active())
|
autostart.set_enabled(self.gen_boot.get_active())
|
||||||
|
|
||||||
if c.wakeword_enabled:
|
if c.wakeword_enabled:
|
||||||
|
# Check reachability in background — don't block the GTK thread
|
||||||
|
uri = c.wakeword_uri
|
||||||
|
def _bg_ww_check(uri=uri):
|
||||||
import socket
|
import socket
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
parsed = urlparse(c.wakeword_uri)
|
parsed = urlparse(uri)
|
||||||
host = parsed.hostname or "127.0.0.1"
|
host = parsed.hostname or "127.0.0.1"
|
||||||
port = parsed.port or 10400
|
port = parsed.port or 10400
|
||||||
try:
|
try:
|
||||||
with socket.create_connection((host, port), timeout=1.5):
|
with socket.create_connection((host, port), timeout=2.0):
|
||||||
pass
|
logbuffer.log(f"[wakeword] server reachable at {uri}", level="INFO")
|
||||||
except OSError as e:
|
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.")
|
logbuffer.log(f"[wakeword] cannot reach {uri}: {e}", level="WARNING")
|
||||||
return False
|
threading.Thread(target=_bg_ww_check, daemon=True).start()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self._error(f"Check numeric fields: {exc}")
|
self._error(f"Check numeric fields: {exc}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
Our own messages go through log(); library logs (faster-whisper, huggingface_hub)
|
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
|
are captured via a logging handler so model download/load progress is visible
|
||||||
instead of an opaque "Loading…".
|
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
|
from __future__ import annotations
|
||||||
@ -13,32 +16,53 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import deque
|
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()
|
_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:
|
with _LOCK:
|
||||||
_LINES.append(line)
|
_ENTRIES.append((ts, level, msg))
|
||||||
if echo:
|
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:
|
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:
|
def clear() -> None:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_LINES.clear()
|
_ENTRIES.clear()
|
||||||
|
|
||||||
|
|
||||||
class _BufferHandler(logging.Handler):
|
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:
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
try:
|
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
|
except Exception: # noqa: BLE001 - never let logging break the app
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -57,4 +81,4 @@ def install_logging() -> None:
|
|||||||
for name in ("faster_whisper", "huggingface_hub", "blitztext"):
|
for name in ("faster_whisper", "huggingface_hub", "blitztext"):
|
||||||
lg = logging.getLogger(name)
|
lg = logging.getLogger(name)
|
||||||
lg.addHandler(handler)
|
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
|
import os
|
||||||
|
|
||||||
base = engine.url.rstrip("/")
|
base = engine.url.rstrip("/")
|
||||||
|
# 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"
|
endpoint = base + "/audio/transcriptions"
|
||||||
|
else:
|
||||||
|
endpoint = base
|
||||||
fields: dict[str, str] = {"response_format": "json"}
|
fields: dict[str, str] = {"response_format": "json"}
|
||||||
if engine.model:
|
if engine.model:
|
||||||
fields["model"] = engine.model
|
fields["model"] = engine.model
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def set_muted(muted: bool) -> None:
|
|||||||
elif os.path.exists(MUTE_FILE):
|
elif os.path.exists(MUTE_FILE):
|
||||||
os.remove(MUTE_FILE)
|
os.remove(MUTE_FILE)
|
||||||
except OSError as e: # noqa: BLE001 - mute is best-effort, never crash
|
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:
|
class WakewordListener:
|
||||||
@ -67,7 +67,7 @@ class WakewordListener:
|
|||||||
try:
|
try:
|
||||||
self._stream()
|
self._stream()
|
||||||
except Exception as e:
|
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
|
time.sleep(3) # Retry backoff
|
||||||
|
|
||||||
def _stream(self):
|
def _stream(self):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user