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:
mARTin-B78 2026-06-05 09:42:48 +02:00
parent a508ecc69c
commit 78350bdb99
5 changed files with 128 additions and 12 deletions

View File

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

View File

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

View File

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

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

View File

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