Add Log tab + in-memory log buffer
New logbuffer.py captures app messages (log()) and library logs (faster-whisper, huggingface_hub) into a ring buffer, mirrored to stderr. Settings gains a Log tab: monospace view, 1s refresh, auto-scroll, Copy, Clear — so model download/load progress is visible instead of an opaque "Loading…". transcribe and daemon now log via the buffer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a508ecc69c
commit
78350bdb99
@ -29,6 +29,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
cmd = args.cmd or "tray"
|
cmd = args.cmd or "tray"
|
||||||
|
|
||||||
|
from .logbuffer import install_logging
|
||||||
|
install_logging()
|
||||||
|
|
||||||
if cmd == "config-path":
|
if cmd == "config-path":
|
||||||
print(ensure_default(CONFIG_PATH))
|
print(ensure_default(CONFIG_PATH))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from typing import Callable
|
|||||||
from . import llm, quality, stt
|
from . import llm, quality, stt
|
||||||
from .config import Config, Workflow
|
from .config import Config, Workflow
|
||||||
from .llm import LLMError
|
from .llm import LLMError
|
||||||
|
from .logbuffer import log
|
||||||
from .notify import notify
|
from .notify import notify
|
||||||
from .paste import active_window_id, deliver
|
from .paste import active_window_id, deliver
|
||||||
from .recorder import Recording, detect_recorder
|
from .recorder import Recording, detect_recorder
|
||||||
@ -56,6 +57,7 @@ class Daemon:
|
|||||||
# -- model load (slow; call off the UI thread) ----------------------------
|
# -- model load (slow; call off the UI thread) ----------------------------
|
||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
engine = self.cfg.active_stt
|
engine = self.cfg.active_stt
|
||||||
|
log(f"STT engine: {engine.name} ({'local' if engine.is_local else engine.url})")
|
||||||
if engine.is_local:
|
if engine.is_local:
|
||||||
model = engine.model or self.cfg.model
|
model = engine.model or self.cfg.model
|
||||||
self._emit("loading", None, f"Loading Whisper '{model}'…")
|
self._emit("loading", None, f"Loading Whisper '{model}'…")
|
||||||
@ -70,7 +72,9 @@ class Daemon:
|
|||||||
# Remote STT engine — no local model to load.
|
# Remote STT engine — no local model to load.
|
||||||
self.transcriber = None
|
self.transcriber = None
|
||||||
self._emit("loading", None, f"Using {engine.name}")
|
self._emit("loading", None, f"Using {engine.name}")
|
||||||
|
log(f"Using remote STT '{engine.name}' — no local model to load")
|
||||||
self._prepared = True
|
self._prepared = True
|
||||||
|
log("Ready.")
|
||||||
self._emit("idle", None, "Ready")
|
self._emit("idle", None, "Ready")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -208,11 +212,12 @@ class Daemon:
|
|||||||
from .paste import press_enter
|
from .paste import press_enter
|
||||||
press_enter(window_id)
|
press_enter(window_id)
|
||||||
self._emit("done", label, text)
|
self._emit("done", label, text)
|
||||||
|
log(f"✓ {label}: {text[:120]}")
|
||||||
self._notify(f"✓ {label}", text[:80] + ("…" if len(text) > 80 else ""))
|
self._notify(f"✓ {label}", text[:80] + ("…" if len(text) > 80 else ""))
|
||||||
except Exception as exc: # noqa: BLE001 - surface any failure
|
except Exception as exc: # noqa: BLE001 - surface any failure
|
||||||
self._emit("error", label, str(exc))
|
self._emit("error", label, str(exc))
|
||||||
self._notify("Error", str(exc), "critical")
|
self._notify("Error", str(exc), "critical")
|
||||||
print(f"[blitztext] error: {exc}", file=sys.stderr)
|
log(f"ERROR ({label}): {exc}")
|
||||||
finally:
|
finally:
|
||||||
audio_path.unlink(missing_ok=True)
|
audio_path.unlink(missing_ok=True)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@ -270,12 +275,10 @@ class Daemon:
|
|||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.prepare()
|
self.prepare()
|
||||||
if self.cfg.input_mode == "modifiers":
|
if self.cfg.input_mode == "modifiers":
|
||||||
scheme = "Ctrl+Win start · Ctrl stop+paste · Alt stop+paste+Enter · Esc cancel"
|
log(f"Recorder: {self.recorder_name}. Input: Ctrl+Win start · Ctrl stop+paste · Alt stop+paste+Enter · Esc cancel")
|
||||||
print(f"[blitztext] ready. Recorder: {self.recorder_name}. Input: {scheme}", file=sys.stderr)
|
|
||||||
else:
|
else:
|
||||||
lines = [f" {self.cfg.routing_hotkey} → Voice routing"] if self.cfg.routing_enabled else []
|
keys = ", ".join([f"{self.cfg.routing_hotkey}→Voice"] if self.cfg.routing_enabled else [])
|
||||||
lines += [f" {wf.hotkey} → {wf.name}" for wf in self.cfg.workflows if wf.hotkey]
|
log(f"Recorder: {self.recorder_name}. Hotkeys: {keys}")
|
||||||
print(f"[blitztext] ready. Recorder: {self.recorder_name}. Hotkeys:\n" + "\n".join(lines), file=sys.stderr)
|
|
||||||
self._notify("Blitztext ready", "Focus a text field and start dictating.")
|
self._notify("Blitztext ready", "Focus a text field and start dictating.")
|
||||||
|
|
||||||
listener = self.start_input()
|
listener = self.start_input()
|
||||||
|
|||||||
@ -21,7 +21,7 @@ gi.require_version("Gtk", "3.0")
|
|||||||
gi.require_version("Gdk", "3.0")
|
gi.require_version("Gdk", "3.0")
|
||||||
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
from . import audio, autostart, llm, stt # noqa: E402
|
from . import audio, autostart, llm, logbuffer, stt # noqa: E402
|
||||||
from .config import Config, save # noqa: E402
|
from .config import Config, save # noqa: E402
|
||||||
from .llm import LLMEngine # noqa: E402
|
from .llm import LLMEngine # noqa: E402
|
||||||
from .stt import STTEngine # noqa: E402
|
from .stt import STTEngine # noqa: E402
|
||||||
@ -209,13 +209,14 @@ class SettingsDialog:
|
|||||||
self._build_engines(_page(nb, "Engines"))
|
self._build_engines(_page(nb, "Engines"))
|
||||||
self._build_input(_page(nb, "Input"))
|
self._build_input(_page(nb, "Input"))
|
||||||
self._build_general(_page(nb, "General"))
|
self._build_general(_page(nb, "General"))
|
||||||
|
self._build_log(_page(nb, "Log"))
|
||||||
|
|
||||||
self._bind_entry = None
|
self._bind_entry = None
|
||||||
self._bind_pressed: list[str] = []
|
self._bind_pressed: list[str] = []
|
||||||
self.dlg.connect("key-press-event", self._on_bind_press)
|
self.dlg.connect("key-press-event", self._on_bind_press)
|
||||||
self.dlg.connect("key-release-event", self._on_bind_release)
|
self.dlg.connect("key-release-event", self._on_bind_release)
|
||||||
self.dlg.connect("response", self._on_response)
|
self.dlg.connect("response", self._on_response)
|
||||||
self.dlg.connect("destroy", lambda *_: self._stop_meter())
|
self.dlg.connect("destroy", lambda *_: self._cleanup())
|
||||||
|
|
||||||
# ===== Presets ==========================================================
|
# ===== Presets ==========================================================
|
||||||
def _build_presets(self, page: Gtk.Box) -> None:
|
def _build_presets(self, page: Gtk.Box) -> None:
|
||||||
@ -632,6 +633,52 @@ class SettingsDialog:
|
|||||||
if self._meter is not None:
|
if self._meter is not None:
|
||||||
self._meter.stop(); self._meter = None
|
self._meter.stop(); self._meter = None
|
||||||
|
|
||||||
|
# ===== Log ==============================================================
|
||||||
|
def _build_log(self, page: Gtk.Box) -> None:
|
||||||
|
sw = Gtk.ScrolledWindow(); sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||||
|
self.log_view = Gtk.TextView()
|
||||||
|
self.log_view.set_editable(False); self.log_view.set_cursor_visible(False)
|
||||||
|
self.log_view.set_monospace(True); self.log_view.set_left_margin(6); self.log_view.set_top_margin(6)
|
||||||
|
sw.add(self.log_view)
|
||||||
|
page.pack_start(sw, True, True, 0)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
page.pack_start(bar, False, False, 0)
|
||||||
|
|
||||||
|
self._log_last = None
|
||||||
|
self._log_refresh()
|
||||||
|
self._log_timer = GLib.timeout_add(1000, self._log_tick)
|
||||||
|
|
||||||
|
def _log_tick(self) -> bool:
|
||||||
|
self._log_refresh()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _log_refresh(self) -> None:
|
||||||
|
text = "\n".join(logbuffer.lines())
|
||||||
|
if text == getattr(self, "_log_last", None):
|
||||||
|
return
|
||||||
|
self._log_last = text
|
||||||
|
buf = self.log_view.get_buffer()
|
||||||
|
buf.set_text(text)
|
||||||
|
if self.log_autoscroll.get_active():
|
||||||
|
self.log_view.scroll_to_iter(buf.get_end_iter(), 0.0, False, 0, 0)
|
||||||
|
|
||||||
|
def _log_copy(self) -> None:
|
||||||
|
cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||||
|
cb.set_text("\n".join(logbuffer.lines()), -1)
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
self._stop_meter()
|
||||||
|
t = getattr(self, "_log_timer", 0)
|
||||||
|
if t:
|
||||||
|
GLib.source_remove(t)
|
||||||
|
self._log_timer = 0
|
||||||
|
|
||||||
# ===== save / collect ====================================================
|
# ===== save / collect ====================================================
|
||||||
def _collect(self) -> bool:
|
def _collect(self) -> bool:
|
||||||
try:
|
try:
|
||||||
@ -681,10 +728,10 @@ class SettingsDialog:
|
|||||||
if resp == RESP_SAVE_RESTART:
|
if resp == RESP_SAVE_RESTART:
|
||||||
if self._collect():
|
if self._collect():
|
||||||
save(self.cfg)
|
save(self.cfg)
|
||||||
self._stop_meter()
|
self._cleanup()
|
||||||
os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"])
|
os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"])
|
||||||
return
|
return
|
||||||
self._stop_meter()
|
self._cleanup()
|
||||||
dlg.destroy()
|
dlg.destroy()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
60
linux/blitztext/logbuffer.py
Normal file
60
linux/blitztext/logbuffer.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""In-memory log buffer shown in Settings → Log, mirrored to stderr.
|
||||||
|
|
||||||
|
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…".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
_LINES: deque[str] = deque(maxlen=2000)
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str, *, echo: bool = True) -> None:
|
||||||
|
line = f"{time.strftime('%H:%M:%S')} {msg}"
|
||||||
|
with _LOCK:
|
||||||
|
_LINES.append(line)
|
||||||
|
if echo:
|
||||||
|
print(line, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def lines() -> list[str]:
|
||||||
|
with _LOCK:
|
||||||
|
return list(_LINES)
|
||||||
|
|
||||||
|
|
||||||
|
def clear() -> None:
|
||||||
|
with _LOCK:
|
||||||
|
_LINES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _BufferHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
log(self.format(record), echo=False)
|
||||||
|
except Exception: # noqa: BLE001 - never let logging break the app
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_installed = False
|
||||||
|
|
||||||
|
|
||||||
|
def install_logging() -> None:
|
||||||
|
"""Capture library log records into the buffer (once)."""
|
||||||
|
global _installed
|
||||||
|
if _installed:
|
||||||
|
return
|
||||||
|
_installed = True
|
||||||
|
handler = _BufferHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
||||||
|
for name in ("faster_whisper", "huggingface_hub", "blitztext"):
|
||||||
|
lg = logging.getLogger(name)
|
||||||
|
lg.addHandler(handler)
|
||||||
|
lg.setLevel(logging.INFO)
|
||||||
@ -30,6 +30,8 @@ class Transcriber:
|
|||||||
def _load(self, model: str, device: str, compute_type: str):
|
def _load(self, model: str, device: str, compute_type: str):
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
from .logbuffer import log
|
||||||
|
|
||||||
attempts: list[str]
|
attempts: list[str]
|
||||||
if device == "auto":
|
if device == "auto":
|
||||||
attempts = ["cuda", "cpu"]
|
attempts = ["cuda", "cpu"]
|
||||||
@ -40,13 +42,14 @@ class Transcriber:
|
|||||||
for dev in attempts:
|
for dev in attempts:
|
||||||
try:
|
try:
|
||||||
ct = self._resolve_compute_type(dev, compute_type)
|
ct = self._resolve_compute_type(dev, compute_type)
|
||||||
|
log(f"Loading Whisper '{model}' on {dev} ({ct})… (first run may download the model)")
|
||||||
m = WhisperModel(model, device=dev, compute_type=ct)
|
m = WhisperModel(model, device=dev, compute_type=ct)
|
||||||
print(f"[blitztext] Whisper '{model}' loaded on {dev} ({ct})", file=sys.stderr)
|
log(f"Whisper '{model}' ready on {dev} ({ct})")
|
||||||
return m
|
return m
|
||||||
except Exception as exc: # noqa: BLE001 - CUDA libs may be absent
|
except Exception as exc: # noqa: BLE001 - CUDA libs may be absent
|
||||||
last_err = exc
|
last_err = exc
|
||||||
if dev != attempts[-1]:
|
if dev != attempts[-1]:
|
||||||
print(f"[blitztext] {dev} unavailable ({exc}); trying next device", file=sys.stderr)
|
log(f"{dev} unavailable ({exc}); trying next device")
|
||||||
raise RuntimeError(f"Failed to load Whisper model '{model}': {last_err}")
|
raise RuntimeError(f"Failed to load Whisper model '{model}': {last_err}")
|
||||||
|
|
||||||
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "") -> str:
|
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "") -> str:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user