routing: announce which preset/keyword you triggered (incl. hands-free)

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) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-07 10:43:08 +02:00
parent ae12eea861
commit 227e4407c4
6 changed files with 60 additions and 2 deletions

View File

@ -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. `<ctrl>+<alt>+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`). |
---

View File

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

View File

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

View File

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

View File

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

View File

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