From 227e4407c41b84e21141a7e614fe2d42a32fc5c0 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 7 Jun 2026 10:43:08 +0200 Subject: [PATCH] routing: announce which preset/keyword you triggered (incl. hands-free) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matched-preset feedback used _dnotify, which is suppressed for hands-free sessions — so wakeword users never saw which keyword/preset fired. - Add a dedicated "Announce matched preset" notification (_rnotify), gated by a new [general] notify_routing flag (default on) and independent of the hands-free silence, so it shows for wakeword commands too. It only fires on a real routing match, so it never spams when nothing is said. - Show each preset's emoji in that notification; add a per-preset "Icon (emoji)" field to the Presets editor so matches are visually distinct. - General-tab toggle; MANUAL + CHANGELOG updated. Adds a test that the match is announced even when _session_silent is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- MANUAL.md | 2 ++ linux/CHANGELOG.md | 10 ++++++++++ linux/blitztext/config.py | 4 ++++ linux/blitztext/daemon.py | 12 ++++++++++-- linux/blitztext/gtksettings.py | 11 +++++++++++ linux/tests/test_daemon_notify.py | 23 +++++++++++++++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index a09a295..365dbfd 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -33,6 +33,7 @@ Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, o | Setting | TOML key | Description | |---|---|---| | **Name** | `name` | Short name for the action, shown in the main panel. | +| **Icon (emoji)** | `icon` | Emoji shown next to this preset in the "matched preset" notification — give each a distinct one to tell them apart at a glance. Default `⚡`. | | **Description** | `description` | One line explaining what the preset does (shown in the panel). | | **Keywords (comma)** | `keywords` | Spoken trigger words, comma-separated. Say one at the **start or end** of your speech to select this preset (fuzzy-matched, e.g. `nicer email, bessere email`). | | **Hotkey (optional)** | `hotkey` | A direct keyboard shortcut for this preset. Click **Set** and press the combo, or type it (e.g. `++e`). Leave blank for keyword-only. | @@ -175,6 +176,7 @@ Microphone, text delivery, language, notifications, and autostart. | **Output** | `output` | `type` types the text key-by-key · `paste` copies it and presses Ctrl+V (faster for long text). | | **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. | | **Notifications** | `notify` | Show desktop notifications for recording/transcription status and errors (manual sessions). | +| **Announce matched preset** | `notify_routing` | After a voice command, pop a notification showing which preset (and spoken keyword) matched — shown **even for hands-free** sessions, with the preset's emoji. Only fires on a real match. | | **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). | --- diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index aee23a8..695baea 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,16 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +### Added +- **"Announce matched preset" notification** (Settings → General, or + `[general] notify_routing`, default on): after a voice command, a notification + shows which preset and spoken keyword matched — **shown even for hands-free + wakeword sessions**, so you can see what you triggered. It only fires on a real + match, so it never spams when nothing is said. +- **Per-preset emoji icon** (Presets → "Icon (emoji)"): give each preset a + distinct emoji, shown in the matched-preset notification so you can tell at a + glance which fired. + ### Fixed - **Voice-routing default went to a rewrite**: when no `[routing] default` preset is set, the no-keyword fallback used the *first* preset — which, if that happened diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index ec0cfdd..042df8b 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -38,6 +38,7 @@ class Config: output: str = "type" # type | paste type_delay_ms: int = 12 notify: bool = True + notify_routing: bool = True # announce which preset/keyword a voice command matched language: str = "de" # whisper hint; "" = autodetect # input scheme input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos) @@ -155,6 +156,7 @@ def load(path: Path = CONFIG_PATH) -> Config: output=g.get("output", "type"), type_delay_ms=int(g.get("type_delay_ms", 4)), notify=bool(g.get("notify", True)), + notify_routing=bool(g.get("notify_routing", True)), language=g.get("language", "de"), model=w.get("model", "small"), device=w.get("device", "auto"), @@ -252,6 +254,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None: "output": cfg.output, "type_delay_ms": cfg.type_delay_ms, "notify": cfg.notify, + "notify_routing": cfg.notify_routing, "language": cfg.language, }, "whisper": { @@ -355,6 +358,7 @@ recorder = "auto" # auto | pw-record | parecord | arecord output = "type" # "type" = xdotool types it; "paste" = clipboard + Ctrl+V type_delay_ms = 12 # per-keystroke delay for xdotool type (raise if chars drop) notify = true # desktop notifications for each phase +notify_routing = true # announce which preset/keyword a voice command matched (shown even hands-free) language = "de" # Whisper language hint; "" = autodetect [input] diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index f69fa78..7b5502e 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -84,6 +84,12 @@ class Daemon: if not self._session_silent: self._notify(title, body, urgency=urgency) + def _rnotify(self, title: str, body: str = "", urgency: str = "normal") -> None: + """Routing feedback — which preset/keyword a voice command matched. Shown + even for hands-free sessions (it has its own toggle) so you can always see + what you triggered. Only fires on a real match, so it never spams silence.""" + notify(title, body, urgency=urgency, enabled=self.cfg.notify_routing) + def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None: if self.status_cb: try: @@ -382,9 +388,11 @@ class Daemon: 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" + icon = (getattr(target, "icon", "") or "🎙") if target else "🎙" + via = f"“{res.keyword}”" if res.keyword else "no keyword → default" self._emit("busy", label, f"→ {label} ({via})") - self._dnotify(f"🎙 {label}", f"matched: {via}") + self._rnotify(f"{icon} {label}", f"matched: {via}") + log(f"→ routed to {label} (matched: {via})") else: target = workflow diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index e656f42..930d8cb 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -324,6 +324,9 @@ class SettingsDialog: page.pack_start(form, True, True, 6) self.wf_name = _labeled(form, "Name", _entry(placeholder="Preset name"), tooltip="A short name for this action, shown in the main panel.") + self.wf_icon = _labeled(form, "Icon (emoji)", _entry(placeholder="⚡ (shown in the ‘matched preset’ notification)"), + tooltip="An emoji shown next to this preset when a voice command matches it — " + "give each preset a distinct one so you can tell at a glance which fired.") self.wf_desc = _labeled(form, "Description", _entry(placeholder="Short description shown in the panel"), tooltip="One line explaining what this preset does.") self.wf_keywords = _labeled(form, "Keywords (comma)", _entry(placeholder="nicer email, bessere email"), @@ -350,6 +353,7 @@ class SettingsDialog: return wf = self.cfg.workflows[idx] self.wf_name.set_text(wf.name) + self.wf_icon.set_text(wf.icon or "") self.wf_desc.set_text(wf.description) self.wf_keywords.set_text(", ".join(wf.keywords)) self.wf_hotkey.set_text(wf.hotkey) @@ -365,6 +369,7 @@ class SettingsDialog: return wf = self.cfg.workflows[idx] wf.name = self.wf_name.get_text().strip() or wf.name + wf.icon = self.wf_icon.get_text().strip() or "⚡" 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() @@ -857,6 +862,11 @@ class SettingsDialog: tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.") 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_notify_routing = Gtk.Switch(); self.gen_notify_routing.set_active(self.cfg.notify_routing); self.gen_notify_routing.set_halign(Gtk.Align.START) + _labeled(page, "Announce matched preset", self.gen_notify_routing, + tooltip="Pop a notification showing which preset (and spoken keyword) a voice command " + "matched. Shown even for hands-free wakeword sessions, so you can always see " + "what you triggered. Each preset's emoji icon appears in the notification.") 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) self._start_meter() @@ -1153,6 +1163,7 @@ class SettingsDialog: 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.notify_routing = self.gen_notify_routing.get_active() c.device = self.stt_device.get_active_text() or "auto" c.compute_type = self.stt_compute.get_active_text() or "auto" autostart.set_enabled(self.gen_boot.get_active()) diff --git a/linux/tests/test_daemon_notify.py b/linux/tests/test_daemon_notify.py index 777455c..98fa58c 100644 --- a/linux/tests/test_daemon_notify.py +++ b/linux/tests/test_daemon_notify.py @@ -85,6 +85,29 @@ def test_wakeword_cues_independent_of_master_switch(monkeypatch): assert plays == [], "an unset wakeword cue should be silent, not fall back to a system sound" +def test_matched_preset_announced_even_hands_free(monkeypatch): + """You should see which preset/keyword you triggered, even via the wakeword. + The routing announcement is gated by notify_routing, NOT by _session_silent.""" + shown = [] + + def fake_notify(*a, enabled=True, **k): + if enabled: + shown.append(a) + + monkeypatch.setattr(daemon_mod, "notify", fake_notify) + d = _make_daemon(monkeypatch) + d._session_silent = True # hands-free session + + d.cfg.notify_routing = True + d._rnotify("⚡ Nicer email", "matched: “nicer email”") + assert shown, "the matched preset must be announced even for hands-free sessions" + + shown.clear() + d.cfg.notify_routing = False + d._rnotify("⚡ Nicer email", "matched: “nicer email”") + assert shown == [], "no announcement when 'Announce matched preset' is off" + + def test_wakeword_while_busy_does_not_notify(monkeypatch): """The away-from-keyboard "Busy" storm: a detection arriving while the previous clip is still being processed must be ignored silently."""