blitztext-app-linux/linux/blitztext/daemon.py
mARTin-B78 44ca0feabd Redesign Settings: preset/engine managers, mic + level meter, autostart
New GTK settings with a dropdown+editor pattern across four tabs:
- Presets: select/add/delete prompt presets; edit name, keywords, hotkey, mode,
  per-preset model/temperature, and a clearly-bordered prompt textfield.
- Engines: STT and LLM engine managers with green/red online-offline status
  dots, add/edit/delete, active selector, and an STT record-and-benchmark Test.
- Input: input scheme + keys + quality-gate settings.
- General: microphone picker with a live sounddevice level meter, output,
  language, notifications, launch-on-login toggle, and local-Whisper settings.

Adds audio.py (mic enumeration via pactl + LevelMeter) and autostart.py
(~/.config/autostart entry). Recorder + daemon honour the selected mic.
Requirements gain sounddevice + tomli-w.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:13:36 +02:00

286 lines
12 KiB
Python

"""Hotkey-driven engine: toggle recording per workflow, then transcribe + deliver.
Used by the headless CLI (`run`) and the GTK GUI. A status callback lets the UI
reflect each phase; desktop notifications fire regardless. Supports voice-keyword
routing: one hotkey records, then the spoken keyword selects the preset.
"""
from __future__ import annotations
import sys
import threading
from typing import Callable
from . import llm, quality, stt
from .config import Config, Workflow
from .llm import LLMError
from .notify import notify
from .paste import active_window_id, deliver
from .recorder import Recording, detect_recorder
from .routing import route
from .transcribe import Transcriber
# status_cb(state, workflow_name, message)
# state in {"loading", "idle", "recording", "busy", "done", "error"}
StatusCallback = Callable[[str, str | None, str], None]
class Daemon:
def __init__(self, cfg: Config, status_cb: StatusCallback | None = None):
self.cfg = cfg
self.status_cb = status_cb
self._lock = threading.Lock()
self._recording: Recording | None = None
self._active_workflow: Workflow | None = None
self._target_window: str | None = None
self._busy = False
self._prepared = False
self._listener = None
# Synthetic preset used by the voice-routing hotkey.
self._route_workflow = Workflow(name="Voice", hotkey=cfg.routing_hotkey, mode="route")
self.recorder_name = detect_recorder(cfg.recorder)
self.transcriber: Transcriber | None = None
# -- feedback -------------------------------------------------------------
def _notify(self, title: str, body: str = "", urgency: str = "normal") -> None:
notify(title, body, urgency=urgency, enabled=self.cfg.notify)
def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None:
if self.status_cb:
try:
self.status_cb(state, workflow, message)
except Exception: # noqa: BLE001 - never let UI errors break the engine
pass
# -- model load (slow; call off the UI thread) ----------------------------
def prepare(self) -> None:
engine = self.cfg.active_stt
if engine.is_local:
model = engine.model or self.cfg.model
self._emit("loading", None, f"Loading Whisper '{model}'")
self._notify("Loading model…", f"Whisper '{model}' ({self.cfg.device})")
self.transcriber = Transcriber(
model=model,
device=self.cfg.device,
compute_type=self.cfg.compute_type,
beam_size=self.cfg.beam_size,
)
else:
# Remote STT engine — no local model to load.
self.transcriber = None
self._emit("loading", None, f"Using {engine.name}")
self._prepared = True
self._emit("idle", None, "Ready")
@property
def ready(self) -> bool:
return getattr(self, "_prepared", False)
@property
def is_recording(self) -> bool:
return self._recording is not None
# -- recording control ----------------------------------------------------
def start_dictation(self, workflow: Workflow | None = None) -> None:
wf = workflow or self._route_workflow
with self._lock:
if not self.ready or self._busy or self._recording is not None:
return
self._target_window = active_window_id()
self._recording = Recording(self.recorder_name, self.cfg.mic)
self._active_workflow = wf
self._emit("recording", wf.name, "Recording…")
self._notify(f"{wf.name}", "Recording…")
def finish_dictation(self, send_enter: bool = False) -> None:
with self._lock:
if self._recording is None:
return
rec, wf, win = self._recording, self._active_workflow, self._target_window
self._recording = None
self._active_workflow = None
self._busy = True
audio_path = rec.stop()
threading.Thread(
target=self._process, args=(audio_path, wf, win, send_enter), daemon=True
).start()
def cancel_dictation(self) -> None:
with self._lock:
if self._recording is None:
return
rec = self._recording
self._recording = None
self._active_workflow = None
rec.discard()
self._emit("idle", None, "Cancelled")
self._notify("Cancelled", "Recording discarded.", "low")
def toggle(self, workflow: Workflow) -> None:
"""Start recording, or stop + process (used by GUI clicks and combos)."""
if not self.ready:
self._notify("Please wait", "Model still loading…", "low")
return
if self._busy:
self._notify("Busy", "Still processing the last clip…", "low")
return
if self._recording is None:
self.start_dictation(workflow)
else:
self.finish_dictation(send_enter=False)
# -- worker ---------------------------------------------------------------
def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None:
label = workflow.name
try:
# Quality gate: drop silent / too-short clips before we even transcribe.
duration, rms = quality.analyze_wav(audio_path)
if quality.too_quiet(duration, rms,
min_seconds=self.cfg.min_speech_seconds,
silence_rms=self.cfg.silence_rms):
self._emit("idle", label, "Too quiet")
self._notify("Nothing heard", "No speech detected.", "low")
return
self._emit("busy", label, "Transcribing…")
self._notify(f"{label}", "Transcribing…")
hotwords = ", ".join(self.cfg.all_keywords) if workflow.mode == "route" else ""
text = stt.transcribe(
self.cfg.active_stt,
audio_path,
language=self.cfg.language,
hotwords=hotwords,
local_transcriber=self.transcriber,
timeout=self.cfg.timeout,
)
text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation)
if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
self._emit("idle", label, "No speech detected")
self._notify("Nothing heard", "No speech detected.", "low")
return
# Voice routing: pick the preset from a spoken keyword, strip it.
if workflow.mode == "route":
res = route(text, self.cfg.workflows, threshold=self.cfg.routing_threshold)
target = self.cfg.preset_by_name(res.preset_name) or self.cfg.default_preset
text = res.text
label = target.name if target else "Transcribe"
via = f"{res.keyword}" if res.keyword else "default"
self._emit("busy", label, f"{label} ({via})")
self._notify(f"🎙 {label}", f"matched: {via}")
else:
target = workflow
if target and target.mode == "rewrite" and target.prompt:
if not text:
self._emit("idle", label, "Only a keyword heard")
self._notify("Nothing to do", "Only the keyword was heard.", "low")
return
self._emit("busy", label, "Rewriting…")
self._notify(f"{label}", "Rewriting…")
try:
text = llm.chat(
self.cfg.active_llm,
target.prompt,
text,
model=target.model or None,
temperature=target.temperature,
timeout=self.cfg.timeout,
)
except LLMError as exc:
self._emit("error", label, str(exc))
self._notify("Rewrite failed", str(exc), "critical")
return
if not text:
self._emit("idle", label, "Nothing to type")
return
deliver(
text,
mode=self.cfg.output,
window_id=window_id,
type_delay_ms=self.cfg.type_delay_ms,
)
if send_enter:
from .paste import press_enter
press_enter(window_id)
self._emit("done", label, text)
self._notify(f"{label}", text[:80] + ("" if len(text) > 80 else ""))
except Exception as exc: # noqa: BLE001 - surface any failure
self._emit("error", label, str(exc))
self._notify("Error", str(exc), "critical")
print(f"[blitztext] error: {exc}", file=sys.stderr)
finally:
audio_path.unlink(missing_ok=True)
with self._lock:
self._busy = False
self._emit("idle", None, "Ready")
# -- hotkeys --------------------------------------------------------------
def _build_mapping(self) -> dict:
"""hotkey -> callback, skipping empty hotkeys, plus the routing hotkey."""
mapping: dict = {}
for wf in self.cfg.workflows:
if wf.hotkey:
mapping[wf.hotkey] = (lambda wf=wf: self.toggle(wf))
if self.cfg.routing_enabled and self.cfg.routing_hotkey:
mapping[self.cfg.routing_hotkey] = (lambda: self.toggle(self._route_workflow))
return mapping
def start_hotkeys(self):
"""Register global hotkeys non-blocking; returns the pynput listener."""
from pynput import keyboard
self._listener = keyboard.GlobalHotKeys(self._build_mapping())
self._listener.start()
return self._listener
def start_input(self):
"""Start the configured input handler; returns its listener (joinable)."""
if self.cfg.input_mode == "modifiers":
from .inputmode import ModifierScheme
self._scheme = ModifierScheme(
self,
start=self.cfg.key_start,
stop=self.cfg.key_stop,
send=self.cfg.key_send,
cancel=self.cfg.key_cancel,
push_to_talk=self.cfg.push_to_talk,
)
return self._scheme.start_listener()
return self.start_hotkeys()
def stop_input(self) -> None:
scheme = getattr(self, "_scheme", None)
if scheme is not None:
scheme.stop_listener()
self._scheme = None
self.stop_hotkeys()
def stop_hotkeys(self) -> None:
if self._listener is not None:
self._listener.stop()
self._listener = None
# -- headless run loop ----------------------------------------------------
def run(self) -> None:
self.prepare()
if self.cfg.input_mode == "modifiers":
scheme = "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:
lines = [f" {self.cfg.routing_hotkey} → Voice routing"] if self.cfg.routing_enabled else []
lines += [f" {wf.hotkey}{wf.name}" for wf in self.cfg.workflows if wf.hotkey]
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.")
listener = self.start_input()
try:
listener.join()
finally:
self.stop_input()