diff --git a/linux/blitztext/audio.py b/linux/blitztext/audio.py
new file mode 100644
index 0000000..2a19879
--- /dev/null
+++ b/linux/blitztext/audio.py
@@ -0,0 +1,94 @@
+"""Audio helpers: enumerate input devices and a live input-level meter.
+
+Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record
+and parecord accept via --target/-d). The level meter uses sounddevice to read
+the chosen input and report a 0..1 level to a callback.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import threading
+
+
+def list_mics() -> list[tuple[str, str]]:
+ """Return [(source_name, friendly_label)] for real input sources.
+
+ The first entry is always the system default ("", "Default device").
+ Monitor sources (loopback of outputs) are excluded.
+ """
+ mics: list[tuple[str, str]] = [("", "Default device")]
+ if not shutil.which("pactl"):
+ return mics
+ try:
+ out = subprocess.run(
+ ["pactl", "list", "short", "sources"], capture_output=True, text=True, check=True
+ ).stdout
+ except (OSError, subprocess.CalledProcessError):
+ return mics
+ for line in out.splitlines():
+ parts = line.split("\t")
+ if len(parts) < 2:
+ continue
+ name = parts[1]
+ if name.endswith(".monitor"):
+ continue
+ label = name.replace("alsa_input.", "").replace("-", " ")
+ mics.append((name, label))
+ return mics
+
+
+class LevelMeter:
+ """Open the given input device and call `on_level(0..1)` periodically."""
+
+ def __init__(self, device: str = "", on_level=None):
+ self.device = device or None
+ self.on_level = on_level
+ self._stream = None
+ self._lock = threading.Lock()
+
+ def start(self) -> bool:
+ import numpy as np
+ import sounddevice as sd
+
+ def _cb(indata, _frames, _time, _status):
+ level = float(np.sqrt(np.mean(np.square(indata)))) if indata.size else 0.0
+ if self.on_level:
+ # Scale RMS (typically small) into a usable 0..1 range.
+ self.on_level(min(1.0, level * 12.0))
+
+ try:
+ self._stream = sd.InputStream(
+ samplerate=16000, channels=1, dtype="float32",
+ blocksize=1600, device=self._resolve_device(), callback=_cb,
+ )
+ self._stream.start()
+ return True
+ except Exception: # noqa: BLE001 - device may be busy/unavailable
+ self._stream = None
+ return False
+
+ def _resolve_device(self):
+ # sounddevice wants an index/name it knows; pactl names rarely match, so
+ # fall back to the default input when the name isn't resolvable.
+ if not self.device:
+ return None
+ try:
+ import sounddevice as sd
+
+ for i, d in enumerate(sd.query_devices()):
+ if d["max_input_channels"] > 0 and self.device in d["name"]:
+ return i
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
+ def stop(self) -> None:
+ with self._lock:
+ if self._stream is not None:
+ try:
+ self._stream.stop()
+ self._stream.close()
+ finally:
+ self._stream = None
diff --git a/linux/blitztext/autostart.py b/linux/blitztext/autostart.py
new file mode 100644
index 0000000..b4de76d
--- /dev/null
+++ b/linux/blitztext/autostart.py
@@ -0,0 +1,44 @@
+"""Launch-on-login via a freedesktop autostart entry.
+
+Writes ~/.config/autostart/blitztext.desktop so the tray starts with the GNOME
+session. No root needed.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+from pathlib import Path
+
+_AUTOSTART_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "autostart"
+_ENTRY = _AUTOSTART_DIR / "blitztext.desktop"
+
+
+def _exec_command() -> str:
+ """Prefer an installed `blitztext` launcher; else run this venv's module."""
+ launcher = shutil.which("blitztext")
+ if launcher:
+ return f"{launcher} tray"
+ return f"{sys.executable} -m blitztext tray"
+
+
+def is_enabled() -> bool:
+ return _ENTRY.exists()
+
+
+def set_enabled(enabled: bool) -> None:
+ if not enabled:
+ _ENTRY.unlink(missing_ok=True)
+ return
+ _AUTOSTART_DIR.mkdir(parents=True, exist_ok=True)
+ _ENTRY.write_text(
+ "[Desktop Entry]\n"
+ "Type=Application\n"
+ "Name=Blitztext\n"
+ f"Exec={_exec_command()}\n"
+ "Icon=blitztext\n"
+ "Terminal=false\n"
+ "X-GNOME-Autostart-enabled=true\n",
+ encoding="utf-8",
+ )
diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py
index c749a26..efc441f 100644
--- a/linux/blitztext/config.py
+++ b/linux/blitztext/config.py
@@ -34,6 +34,7 @@ class Workflow:
class Config:
# general
recorder: str = "auto"
+ mic: str = "" # pactl/pipewire source name; "" = default device
output: str = "type" # type | paste
type_delay_ms: int = 12
notify: bool = True
@@ -129,6 +130,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
cfg = Config(
recorder=g.get("recorder", "auto"),
+ mic=g.get("mic", ""),
output=g.get("output", "type"),
type_delay_ms=int(g.get("type_delay_ms", 4)),
notify=bool(g.get("notify", True)),
@@ -215,6 +217,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
data: dict = {
"general": {
"recorder": cfg.recorder,
+ "mic": cfg.mic,
"output": cfg.output,
"type_delay_ms": cfg.type_delay_ms,
"notify": cfg.notify,
diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py
index cd5ea1b..ead1119 100644
--- a/linux/blitztext/daemon.py
+++ b/linux/blitztext/daemon.py
@@ -88,7 +88,7 @@ class Daemon:
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._recording = Recording(self.recorder_name, self.cfg.mic)
self._active_workflow = wf
self._emit("recording", wf.name, "Recording…")
self._notify(f"● {wf.name}", "Recording…")
diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py
index 7ffcf1e..712fc8d 100644
--- a/linux/blitztext/gtksettings.py
+++ b/linux/blitztext/gtksettings.py
@@ -1,194 +1,493 @@
-"""GTK settings dialog for Blitztext.
+"""GTK settings: dropdown + editor managers for prompt presets and engines.
-A functional editor for the engine, rewrite endpoint, and per-workflow prompts.
-The richer presets manager + remote-engine controls build on this next.
+Tabs:
+ Presets - prompt presets (select from a dropdown, edit, add, delete)
+ Engines - STT and LLM engine presets with online/offline status + STT test
+ Input - input scheme, keys, quality gate
+ General - mic (with live level meter), output, language, notifications, autostart
"""
from __future__ import annotations
import os
import sys
+import threading
+import time
+from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk # noqa: E402
+from gi.repository import GLib, Gtk # noqa: E402
+from . import audio, autostart, llm, stt # noqa: E402
from .config import Config, save # noqa: E402
+from .llm import LLMEngine # noqa: E402
+from .stt import STTEngine # noqa: E402
+from .config import Workflow # noqa: E402
RESP_SAVE = 1
RESP_SAVE_RESTART = 2
+GREEN, RED, GREY = "#34c759", "#ff3b30", "#b8b8be"
-def _row(parent: Gtk.Box, label: str) -> Gtk.Box:
- box = Gtk.Box(spacing=10)
- box.set_margin_top(4)
- box.set_margin_bottom(4)
- lbl = Gtk.Label(label=label, xalign=0.0)
- lbl.set_size_request(150, -1)
- box.pack_start(lbl, False, False, 0)
- parent.pack_start(box, False, False, 0)
- return box
+def _dot(color: str) -> str:
+ return f'●'
-def _entry(parent: Gtk.Box, label: str, value) -> Gtk.Entry:
- box = _row(parent, label)
- e = Gtk.Entry()
- e.set_text(str(value))
- e.set_hexpand(True)
- box.pack_start(e, True, True, 0)
- return e
+def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130) -> Gtk.Widget:
+ row = Gtk.Box(spacing=10)
+ row.set_margin_top(3); row.set_margin_bottom(3)
+ lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1)
+ row.pack_start(lbl, False, False, 0)
+ row.pack_start(widget, True, True, 0)
+ parent.pack_start(row, False, False, 0)
+ return widget
-def _combo(parent: Gtk.Box, label: str, value: str, options: list[str]) -> Gtk.ComboBoxText:
- box = _row(parent, label)
+def _entry(text="") -> Gtk.Entry:
+ e = Gtk.Entry(); e.set_text(str(text)); e.set_hexpand(True); return e
+
+
+def _combo(options, active=None) -> Gtk.ComboBoxText:
c = Gtk.ComboBoxText()
- for i, o in enumerate(options):
+ for o in options:
c.append_text(o)
- if o == value:
- c.set_active(i)
- if c.get_active() < 0:
+ if active in options:
+ c.set_active(options.index(active))
+ elif options:
c.set_active(0)
- box.pack_start(c, False, False, 0)
return c
def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box:
- page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
- page.set_margin_top(12)
- page.set_margin_bottom(12)
- page.set_margin_start(14)
- page.set_margin_end(14)
- nb.append_page(page, Gtk.Label(label=title))
- return page
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ for m in ("top", "bottom", "start", "end"):
+ getattr(box, f"set_margin_{m}")(12)
+ nb.append_page(box, Gtk.Label(label=title))
+ return box
class SettingsDialog:
- def __init__(self, parent: Gtk.Window, cfg: Config):
+ def __init__(self, parent: Gtk.Window, cfg: Config, daemon=None):
self.cfg = cfg
+ self.daemon = daemon
+ self._meter = None
+ self._wf_idx = self._stt_idx = self._llm_idx = 0
+
self.dlg = Gtk.Dialog(title="Blitztext — Settings", transient_for=parent, modal=True)
- self.dlg.set_default_size(580, 580)
+ self.dlg.set_default_size(620, 600)
self.dlg.add_button("Close", Gtk.ResponseType.CLOSE)
self.dlg.add_button("Save", RESP_SAVE)
self.dlg.add_button("Save & Restart", RESP_SAVE_RESTART)
nb = Gtk.Notebook()
self.dlg.get_content_area().pack_start(nb, True, True, 0)
-
- # Engine
- p = _page(nb, "Engine")
- self.e_model = _entry(p, "Whisper model", cfg.model)
- self.e_device = _combo(p, "Device", cfg.device, ["auto", "cpu", "cuda"])
- self.e_compute = _combo(p, "Compute type", cfg.compute_type, ["auto", "int8", "float16", "int8_float16"])
- self.e_lang = _entry(p, "Language hint", cfg.language)
- self.e_output = _combo(p, "Output", cfg.output, ["type", "paste"])
- self.e_delay = _entry(p, "Type delay (ms)", cfg.type_delay_ms)
- nbox = _row(p, "Notifications")
- self.e_notify = Gtk.Switch()
- self.e_notify.set_active(cfg.notify)
- self.e_notify.set_halign(Gtk.Align.START)
- nbox.pack_start(self.e_notify, False, False, 0)
-
- # Rewrite
- p = _page(nb, "Rewrite LLM")
- self.r_url = _entry(p, "Base URL", cfg.base_url)
- self.r_keyenv = _entry(p, "API key env var", cfg.api_key_env)
- self.r_model = _entry(p, "Model", cfg.rewrite_model)
- self.r_temp = _entry(p, "Temperature", cfg.temperature)
- self.r_timeout = _entry(p, "Timeout (s)", cfg.timeout)
- hint = Gtk.Label(xalign=0.0, wrap=True)
- hint.set_markup(
- f'OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). '
- f'Env {cfg.api_key_env}: '
- f'{"set ✓" if cfg.api_key else "NOT set"}.'
- )
- p.pack_start(hint, False, False, 8)
-
- # Per-workflow prompts
- self.wf_widgets: list[dict] = []
- for wf in cfg.workflows:
- p = _page(nb, wf.name[:14])
- w = {
- "name": _entry(p, "Name", wf.name),
- "description": _entry(p, "Description", wf.description),
- "hotkey": _entry(p, "Hotkey", wf.hotkey),
- "model": _entry(p, "Model (opt.)", wf.model or ""),
- "temperature": _entry(p, "Temp (opt.)", "" if wf.temperature is None else wf.temperature),
- "mode": _combo(p, "Mode", wf.mode, ["transcribe", "rewrite"]),
- }
- p.pack_start(Gtk.Label(label="Rewrite prompt (system):", xalign=0.0), False, False, 6)
- sw = Gtk.ScrolledWindow()
- sw.set_min_content_height(150)
- sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
- tv = Gtk.TextView()
- tv.set_wrap_mode(Gtk.WrapMode.WORD)
- tv.get_buffer().set_text(wf.prompt)
- sw.add(tv)
- p.pack_start(sw, True, True, 0)
- w["prompt"] = tv
- self.wf_widgets.append(w)
+ self._build_presets(_page(nb, "Presets"))
+ self._build_engines(_page(nb, "Engines"))
+ self._build_input(_page(nb, "Input"))
+ self._build_general(_page(nb, "General"))
self.dlg.connect("response", self._on_response)
+ self.dlg.connect("destroy", lambda *_: self._stop_meter())
- # -- helpers --------------------------------------------------------------
- @staticmethod
- def _tv_text(tv: Gtk.TextView) -> str:
- b = tv.get_buffer()
- return b.get_text(b.get_start_iter(), b.get_end_iter(), True).strip()
+ # ===== Presets ==========================================================
+ def _build_presets(self, page: Gtk.Box) -> None:
+ self._wf_idx = 0
+ bar = Gtk.Box(spacing=8)
+ self.wf_combo = Gtk.ComboBoxText()
+ for wf in self.cfg.workflows:
+ self.wf_combo.append_text(wf.name)
+ self.wf_combo.set_active(0)
+ self.wf_combo.connect("changed", self._wf_changed)
+ bar.pack_start(self.wf_combo, True, True, 0)
+ add = Gtk.Button(label="+ Add"); add.connect("clicked", self._wf_add)
+ rm = Gtk.Button(label="Delete"); rm.connect("clicked", self._wf_delete)
+ bar.pack_start(add, False, False, 0); bar.pack_start(rm, False, False, 0)
+ page.pack_start(bar, False, False, 4)
+ form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ page.pack_start(form, True, True, 6)
+ self.wf_name = _labeled(form, "Name", _entry())
+ self.wf_desc = _labeled(form, "Description", _entry())
+ self.wf_keywords = _labeled(form, "Keywords (comma)", _entry())
+ self.wf_hotkey = _labeled(form, "Hotkey (optional)", _entry())
+ self.wf_mode = _labeled(form, "Mode", _combo(["transcribe", "rewrite"]))
+ self.wf_model = _labeled(form, "LLM model (opt.)", _entry())
+ self.wf_temp = _labeled(form, "Temperature (opt.)", _entry())
+
+ form.pack_start(Gtk.Label(label="Prompt sent to the LLM (rewrite mode):", xalign=0.0), False, False, 6)
+ frame = Gtk.Frame(); frame.set_shadow_type(Gtk.ShadowType.IN)
+ sw = Gtk.ScrolledWindow(); sw.set_min_content_height(150)
+ self.wf_prompt = Gtk.TextView(); self.wf_prompt.set_wrap_mode(Gtk.WrapMode.WORD)
+ self.wf_prompt.set_left_margin(6); self.wf_prompt.set_top_margin(6)
+ sw.add(self.wf_prompt); frame.add(sw)
+ form.pack_start(frame, True, True, 0)
+ self._wf_load(0)
+
+ def _wf_load(self, idx: int) -> None:
+ if not (0 <= idx < len(self.cfg.workflows)):
+ return
+ wf = self.cfg.workflows[idx]
+ self.wf_name.set_text(wf.name)
+ self.wf_desc.set_text(wf.description)
+ self.wf_keywords.set_text(", ".join(wf.keywords))
+ self.wf_hotkey.set_text(wf.hotkey)
+ self.wf_mode.set_active(["transcribe", "rewrite"].index(wf.mode) if wf.mode in ("transcribe", "rewrite") else 0)
+ self.wf_model.set_text(wf.model or "")
+ self.wf_temp.set_text("" if wf.temperature is None else str(wf.temperature))
+ self.wf_prompt.get_buffer().set_text(wf.prompt)
+ self._wf_idx = idx
+
+ def _wf_commit(self) -> None:
+ idx = self._wf_idx
+ if not (0 <= idx < len(self.cfg.workflows)):
+ return
+ wf = self.cfg.workflows[idx]
+ wf.name = self.wf_name.get_text().strip() or wf.name
+ wf.description = self.wf_desc.get_text().strip()
+ wf.keywords = [k.strip() for k in self.wf_keywords.get_text().split(",") if k.strip()]
+ wf.hotkey = self.wf_hotkey.get_text().strip()
+ wf.mode = self.wf_mode.get_active_text() or "transcribe"
+ wf.model = self.wf_model.get_text().strip() or None
+ t = self.wf_temp.get_text().strip()
+ wf.temperature = float(t) if _isfloat(t) else None
+ b = self.wf_prompt.get_buffer()
+ wf.prompt = b.get_text(b.get_start_iter(), b.get_end_iter(), True).strip()
+ # reflect a possibly-changed name in the dropdown
+ self.wf_combo.remove(idx); self.wf_combo.insert_text(idx, wf.name)
+ self.wf_combo.set_active(idx)
+
+ def _wf_changed(self, combo) -> None:
+ new = combo.get_active()
+ if new < 0 or new == self._wf_idx:
+ return
+ self._wf_commit()
+ self._wf_load(new)
+
+ def _wf_add(self, _b) -> None:
+ self._wf_commit()
+ wf = Workflow(name="New preset", hotkey="", mode="rewrite", prompt="", description="")
+ self.cfg.workflows.append(wf)
+ self.wf_combo.append_text(wf.name)
+ self.wf_combo.set_active(len(self.cfg.workflows) - 1)
+ self._wf_load(len(self.cfg.workflows) - 1)
+
+ def _wf_delete(self, _b) -> None:
+ if len(self.cfg.workflows) <= 1:
+ return
+ idx = self._wf_idx
+ del self.cfg.workflows[idx]
+ self.wf_combo.remove(idx)
+ self._wf_idx = -1
+ self.wf_combo.set_active(0)
+ self._wf_load(0)
+
+ # ===== Engines ==========================================================
+ def _build_engines(self, page: Gtk.Box) -> None:
+ page.pack_start(self._stt_section(), False, False, 4)
+ page.pack_start(Gtk.Separator(), False, False, 8)
+ page.pack_start(self._llm_section(), False, False, 4)
+ self._refresh_status()
+
+ def _stt_section(self) -> Gtk.Box:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ box.pack_start(Gtk.Label(label="Speech-to-text engine", xalign=0.0), False, False, 2)
+ self._stt_idx = 0
+ bar = Gtk.Box(spacing=8)
+ self.stt_combo = Gtk.ComboBoxText()
+ for e in self.cfg.stt_engines:
+ self.stt_combo.append_text(e.name)
+ self.stt_combo.set_active(self._index_of(self.cfg.stt_engines, self.cfg.stt_active))
+ self.stt_combo.connect("changed", self._stt_changed)
+ self.stt_dot = Gtk.Label(); self.stt_dot.set_markup(_dot(GREY))
+ bar.pack_start(self.stt_dot, False, False, 0)
+ bar.pack_start(self.stt_combo, True, True, 0)
+ for label, cb in (("+ Add", self._stt_add), ("Delete", self._stt_delete),
+ ("Test", self._stt_test), ("Refresh", lambda _b: self._refresh_status())):
+ b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0)
+ box.pack_start(bar, False, False, 2)
+
+ form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2)
+ self.stt_type = _labeled(form, "Type", _combo(["local", "openai"]))
+ self.stt_url = _labeled(form, "URL", _entry())
+ self.stt_model = _labeled(form, "Model", _entry())
+ self.stt_key = _labeled(form, "API key env", _entry())
+ self.stt_result = Gtk.Label(xalign=0.0); self.stt_result.set_line_wrap(True)
+ box.pack_start(self.stt_result, False, False, 2)
+ self._stt_load(self.stt_combo.get_active())
+ return box
+
+ def _llm_section(self) -> Gtk.Box:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ box.pack_start(Gtk.Label(label="Language model (rewrite)", xalign=0.0), False, False, 2)
+ self._llm_idx = 0
+ bar = Gtk.Box(spacing=8)
+ self.llm_combo = Gtk.ComboBoxText()
+ for e in self.cfg.llm_engines:
+ self.llm_combo.append_text(e.name)
+ self.llm_combo.set_active(self._index_of(self.cfg.llm_engines, self.cfg.llm_active))
+ self.llm_combo.connect("changed", self._llm_changed)
+ self.llm_dot = Gtk.Label(); self.llm_dot.set_markup(_dot(GREY))
+ bar.pack_start(self.llm_dot, False, False, 0)
+ bar.pack_start(self.llm_combo, True, True, 0)
+ for label, cb in (("+ Add", self._llm_add), ("Delete", self._llm_delete),
+ ("Refresh", lambda _b: self._refresh_status())):
+ b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0)
+ box.pack_start(bar, False, False, 2)
+
+ form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2)
+ self.llm_url = _labeled(form, "Base URL", _entry())
+ self.llm_model = _labeled(form, "Model", _entry())
+ self.llm_key = _labeled(form, "API key env", _entry())
+ self.llm_temp = _labeled(form, "Temperature", _entry())
+ self._llm_load(self.llm_combo.get_active())
+ return box
+
+ # -- STT engine editor ---
+ def _stt_load(self, idx: int) -> None:
+ if not (0 <= idx < len(self.cfg.stt_engines)):
+ return
+ e = self.cfg.stt_engines[idx]
+ self.stt_type.set_active(["local", "openai"].index(e.type) if e.type in ("local", "openai") else 0)
+ self.stt_url.set_text(e.url); self.stt_model.set_text(e.model); self.stt_key.set_text(e.api_key_env)
+ self._stt_idx = idx
+
+ def _stt_commit(self) -> None:
+ idx = self._stt_idx
+ if not (0 <= idx < len(self.cfg.stt_engines)):
+ return
+ e = self.cfg.stt_engines[idx]
+ e.type = self.stt_type.get_active_text() or "local"
+ e.url = self.stt_url.get_text().strip().rstrip("/")
+ e.model = self.stt_model.get_text().strip()
+ e.api_key_env = self.stt_key.get_text().strip()
+
+ def _stt_changed(self, combo):
+ new = combo.get_active()
+ if new < 0 or new == self._stt_idx:
+ return
+ self._stt_commit(); self._stt_load(new); self._refresh_status()
+
+ def _stt_add(self, _b):
+ self._stt_commit()
+ e = STTEngine("New STT", "openai", "http://localhost:8010/v1", "whisper-1")
+ self.cfg.stt_engines.append(e); self.stt_combo.append_text(e.name)
+ self.stt_combo.set_active(len(self.cfg.stt_engines) - 1)
+ self._stt_load(len(self.cfg.stt_engines) - 1)
+
+ def _stt_delete(self, _b):
+ if len(self.cfg.stt_engines) <= 1:
+ return
+ del self.cfg.stt_engines[self._stt_idx]
+ self.stt_combo.remove(self._stt_idx); self._stt_idx = -1
+ self.stt_combo.set_active(0); self._stt_load(0)
+
+ def _stt_test(self, _b):
+ self._stt_commit()
+ e = self.cfg.stt_engines[self._stt_idx]
+ self.stt_result.set_markup("Recording 4s — speak now…")
+ threading.Thread(target=self._run_stt_test, args=(e,), daemon=True).start()
+
+ def _run_stt_test(self, engine):
+ from .recorder import Recording, detect_recorder
+ try:
+ rec = Recording(detect_recorder(self.cfg.recorder), self.cfg.mic)
+ time.sleep(4.0)
+ wav = rec.stop()
+ tr = self.daemon.transcriber if (self.daemon and engine.is_local) else None
+ res = stt.benchmark(engine, wav, language=self.cfg.language, local_transcriber=tr)
+ wav.unlink(missing_ok=True)
+ if res.ok:
+ msg = f"{res.seconds:.2f}s · {GLib.markup_escape_text(res.text or '(empty)')}"
+ else:
+ msg = f'{GLib.markup_escape_text(res.error)}'
+ except Exception as exc: # noqa: BLE001
+ msg = f'{GLib.markup_escape_text(str(exc))}'
+ GLib.idle_add(self.stt_result.set_markup, msg)
+
+ # -- LLM engine editor ---
+ def _llm_load(self, idx: int) -> None:
+ if not (0 <= idx < len(self.cfg.llm_engines)):
+ return
+ e = self.cfg.llm_engines[idx]
+ self.llm_url.set_text(e.url); self.llm_model.set_text(e.model)
+ self.llm_key.set_text(e.api_key_env); self.llm_temp.set_text(str(e.temperature))
+ self._llm_idx = idx
+
+ def _llm_commit(self) -> None:
+ idx = self._llm_idx
+ if not (0 <= idx < len(self.cfg.llm_engines)):
+ return
+ e = self.cfg.llm_engines[idx]
+ e.url = self.llm_url.get_text().strip().rstrip("/")
+ e.model = self.llm_model.get_text().strip()
+ e.api_key_env = self.llm_key.get_text().strip()
+ if _isfloat(self.llm_temp.get_text().strip()):
+ e.temperature = float(self.llm_temp.get_text().strip())
+
+ def _llm_changed(self, combo):
+ new = combo.get_active()
+ if new < 0 or new == self._llm_idx:
+ return
+ self._llm_commit(); self._llm_load(new); self._refresh_status()
+
+ def _llm_add(self, _b):
+ self._llm_commit()
+ e = LLMEngine("New LLM", "http://localhost:28080/v1", "model", "")
+ self.cfg.llm_engines.append(e); self.llm_combo.append_text(e.name)
+ self.llm_combo.set_active(len(self.cfg.llm_engines) - 1)
+ self._llm_load(len(self.cfg.llm_engines) - 1)
+
+ def _llm_delete(self, _b):
+ if len(self.cfg.llm_engines) <= 1:
+ return
+ del self.cfg.llm_engines[self._llm_idx]
+ self.llm_combo.remove(self._llm_idx); self._llm_idx = -1
+ self.llm_combo.set_active(0); self._llm_load(0)
+
+ # -- status dots (threaded) ---
+ def _refresh_status(self) -> None:
+ s = self.cfg.stt_engines[self._stt_idx] if 0 <= self._stt_idx < len(self.cfg.stt_engines) else None
+ self._stt_commit()
+ l = self.cfg.llm_engines[self._llm_idx] if 0 <= self._llm_idx < len(self.cfg.llm_engines) else None
+ self._llm_commit()
+
+ def check():
+ sc = GREEN if (s and stt.status(s)) else RED if s else GREY
+ lc = GREEN if (l and llm.status(l)) else RED if l else GREY
+ GLib.idle_add(self.stt_dot.set_markup, _dot(sc))
+ GLib.idle_add(self.llm_dot.set_markup, _dot(lc))
+ threading.Thread(target=check, daemon=True).start()
+
+ # ===== Input ============================================================
+ def _build_input(self, page: Gtk.Box) -> None:
+ self.in_mode = _labeled(page, "Input mode", _combo(["modifiers", "hotkeys"], self.cfg.input_mode))
+ self.in_ptt = Gtk.Switch(); self.in_ptt.set_active(self.cfg.push_to_talk); self.in_ptt.set_halign(Gtk.Align.START)
+ _labeled(page, "Push-to-talk", self.in_ptt)
+ self.in_start = _labeled(page, "Start", _entry(self.cfg.key_start))
+ self.in_stop = _labeled(page, "Stop + paste", _entry(self.cfg.key_stop))
+ self.in_send = _labeled(page, "Stop + paste + Enter", _entry(self.cfg.key_send))
+ self.in_cancel = _labeled(page, "Cancel", _entry(self.cfg.key_cancel))
+ page.pack_start(Gtk.Separator(), False, False, 8)
+ page.pack_start(Gtk.Label(label="Quality gate", xalign=0.0), False, False, 2)
+ self.q_min = _labeled(page, "Min seconds", _entry(self.cfg.min_speech_seconds))
+ self.q_rms = _labeled(page, "Silence RMS", _entry(self.cfg.silence_rms))
+ self.q_halluc = Gtk.Switch(); self.q_halluc.set_active(self.cfg.reject_hallucinations); self.q_halluc.set_halign(Gtk.Align.START)
+ _labeled(page, "Reject hallucinations", self.q_halluc)
+ self.q_strip = Gtk.Switch(); self.q_strip.set_active(self.cfg.strip_trailing_punctuation); self.q_strip.set_halign(Gtk.Align.START)
+ _labeled(page, "Strip trailing punctuation", self.q_strip)
+
+ # ===== General ==========================================================
+ def _build_general(self, page: Gtk.Box) -> None:
+ self._mics = audio.list_mics()
+ names = [label for _, label in self._mics]
+ cur = next((lbl for nm, lbl in self._mics if nm == self.cfg.mic), names[0])
+ self.gen_mic = _labeled(page, "Microphone", _combo(names, cur))
+
+ self.mic_level = Gtk.LevelBar(); self.mic_level.set_min_value(0); self.mic_level.set_max_value(1)
+ _labeled(page, "Input level", self.mic_level)
+ self.gen_mic.connect("changed", lambda _c: self._restart_meter())
+
+ self.gen_output = _labeled(page, "Output", _combo(["type", "paste"], self.cfg.output))
+ self.gen_lang = _labeled(page, "Language hint", _entry(self.cfg.language))
+ self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify); self.gen_notify.set_halign(Gtk.Align.START)
+ _labeled(page, "Notifications", self.gen_notify)
+ self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled()); self.gen_boot.set_halign(Gtk.Align.START)
+ _labeled(page, "Launch on login", self.gen_boot)
+
+ page.pack_start(Gtk.Separator(), False, False, 8)
+ page.pack_start(Gtk.Label(label="Local Whisper (for the local STT engine)", xalign=0.0), False, False, 2)
+ self.gen_model = _labeled(page, "Model", _entry(self.cfg.model))
+ self.gen_device = _labeled(page, "Device", _combo(["auto", "cpu", "cuda"], self.cfg.device))
+ self.gen_compute = _labeled(page, "Compute type", _combo(["auto", "int8", "float16", "int8_float16"], self.cfg.compute_type))
+ self._start_meter()
+
+ def _selected_mic_name(self) -> str:
+ i = self.gen_mic.get_active()
+ return self._mics[i][0] if 0 <= i < len(self._mics) else ""
+
+ def _start_meter(self) -> None:
+ self._stop_meter()
+ self._meter = audio.LevelMeter(self._selected_mic_name(), on_level=lambda v: GLib.idle_add(self.mic_level.set_value, v))
+ self._meter.start()
+
+ def _restart_meter(self) -> None:
+ self._start_meter()
+
+ def _stop_meter(self) -> None:
+ if self._meter is not None:
+ self._meter.stop(); self._meter = None
+
+ # ===== save / collect ====================================================
def _collect(self) -> bool:
try:
+ self._wf_commit(); self._stt_commit(); self._llm_commit()
c = self.cfg
- c.model = self.e_model.get_text().strip()
- c.device = self.e_device.get_active_text()
- c.compute_type = self.e_compute.get_active_text()
- c.language = self.e_lang.get_text().strip()
- c.output = self.e_output.get_active_text()
- c.type_delay_ms = int(self.e_delay.get_text())
- c.notify = self.e_notify.get_active()
- c.base_url = self.r_url.get_text().strip().rstrip("/")
- c.api_key_env = self.r_keyenv.get_text().strip()
- c.rewrite_model = self.r_model.get_text().strip()
- c.temperature = float(self.r_temp.get_text())
- c.timeout = int(self.r_timeout.get_text())
- for wf, w in zip(c.workflows, self.wf_widgets):
- wf.name = w["name"].get_text().strip() or wf.name
- wf.description = w["description"].get_text().strip()
- wf.hotkey = w["hotkey"].get_text().strip()
- wf.model = w["model"].get_text().strip() or None
- t = w["temperature"].get_text().strip()
- wf.temperature = float(t) if t else None
- wf.mode = w["mode"].get_active_text()
- wf.prompt = self._tv_text(w["prompt"])
+ c.stt_active = self.stt_combo.get_active_text() or c.stt_active
+ c.llm_active = self.llm_combo.get_active_text() or c.llm_active
+ c.input_mode = self.in_mode.get_active_text() or "modifiers"
+ c.push_to_talk = self.in_ptt.get_active()
+ c.key_start = self.in_start.get_text().strip()
+ c.key_stop = self.in_stop.get_text().strip()
+ c.key_send = self.in_send.get_text().strip()
+ c.key_cancel = self.in_cancel.get_text().strip()
+ c.min_speech_seconds = float(self.q_min.get_text())
+ c.silence_rms = float(self.q_rms.get_text())
+ c.reject_hallucinations = self.q_halluc.get_active()
+ c.strip_trailing_punctuation = self.q_strip.get_active()
+ c.mic = self._selected_mic_name()
+ c.output = self.gen_output.get_active_text() or "type"
+ c.language = self.gen_lang.get_text().strip()
+ c.notify = self.gen_notify.get_active()
+ c.model = self.gen_model.get_text().strip()
+ c.device = self.gen_device.get_active_text() or "auto"
+ c.compute_type = self.gen_compute.get_active_text() or "auto"
+ autostart.set_enabled(self.gen_boot.get_active())
except ValueError as exc:
self._error(f"Check numeric fields: {exc}")
return False
return True
def _error(self, msg: str) -> None:
- d = Gtk.MessageDialog(transient_for=self.dlg, modal=True,
- message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=msg)
- d.run()
- d.destroy()
+ d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, message_type=Gtk.MessageType.ERROR,
+ buttons=Gtk.ButtonsType.OK, text=msg)
+ d.run(); d.destroy()
def _info(self, msg: str) -> None:
- d = Gtk.MessageDialog(transient_for=self.dlg, modal=True,
- message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=msg)
- d.run()
- d.destroy()
+ d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, message_type=Gtk.MessageType.INFO,
+ buttons=Gtk.ButtonsType.OK, text=msg)
+ d.run(); d.destroy()
- def _on_response(self, dlg: Gtk.Dialog, resp: int) -> None:
+ def _on_response(self, dlg, resp):
if resp == RESP_SAVE:
if self._collect():
save(self.cfg)
- self._info("Saved. Restart Blitztext to apply hotkey/model changes.")
+ self._info("Saved. Restart Blitztext to apply engine/hotkey changes.")
return
if resp == RESP_SAVE_RESTART:
if self._collect():
save(self.cfg)
+ self._stop_meter()
os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"])
return
+ self._stop_meter()
dlg.destroy()
+ @staticmethod
+ def _index_of(engines, name):
+ for i, e in enumerate(engines):
+ if e.name == name:
+ return i
+ return 0
+
def run_dialog(self) -> None:
self.dlg.show_all()
+
+
+def _isfloat(s: str) -> bool:
+ try:
+ float(s)
+ return True
+ except (TypeError, ValueError):
+ return False
diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py
index 1679319..74a6924 100644
--- a/linux/blitztext/gtkui.py
+++ b/linux/blitztext/gtkui.py
@@ -256,7 +256,7 @@ class App:
def open_settings(self) -> None:
from .gtksettings import SettingsDialog
- SettingsDialog(self.win, self.cfg).run_dialog()
+ SettingsDialog(self.win, self.cfg, daemon=self.daemon).run_dialog()
def show_panel(self) -> None:
self.win.show_all()
diff --git a/linux/blitztext/recorder.py b/linux/blitztext/recorder.py
index ae2a3f0..969d2b5 100644
--- a/linux/blitztext/recorder.py
+++ b/linux/blitztext/recorder.py
@@ -19,6 +19,13 @@ _RECORDERS: dict[str, list[str]] = {
"arecord": ["arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav"],
}
+# How to point each recorder at a specific pactl/pipewire source.
+_DEVICE_FLAG: dict[str, list[str]] = {
+ "pw-record": ["--target"],
+ "parecord": ["-d"],
+ "arecord": [], # ALSA names differ from pactl; fall back to default device
+}
+
def detect_recorder(preference: str = "auto") -> str:
if preference != "auto":
@@ -34,9 +41,13 @@ def detect_recorder(preference: str = "auto") -> str:
class Recording:
"""A single in-progress recording. Start on construction, call stop()."""
- def __init__(self, recorder: str):
+ def __init__(self, recorder: str, device: str = ""):
self._tmp = Path(tempfile.mkstemp(prefix="blitztext-", suffix=".wav")[1])
- argv = list(_RECORDERS[recorder]) + [str(self._tmp)]
+ argv = list(_RECORDERS[recorder])
+ flag = _DEVICE_FLAG.get(recorder, [])
+ if device and flag:
+ argv += flag + [device]
+ argv += [str(self._tmp)]
# arecord writes to the file given as a positional arg; the others too.
self._proc = subprocess.Popen(
argv,
diff --git a/linux/requirements.txt b/linux/requirements.txt
index 48737cf..576a2d1 100644
--- a/linux/requirements.txt
+++ b/linux/requirements.txt
@@ -1,2 +1,4 @@
faster-whisper>=1.0
pynput>=1.7
+tomli-w>=1.0
+sounddevice>=0.4