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:
parent
ae12eea861
commit
227e4407c4
@ -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 |
|
| Setting | TOML key | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Name** | `name` | Short name for the action, shown in the main panel. |
|
| **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). |
|
| **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`). |
|
| **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. |
|
| **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). |
|
| **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. |
|
| **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. |
|
||||||
| **Notifications** | `notify` | Show desktop notifications for recording/transcription status and errors (manual sessions). |
|
| **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`). |
|
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -9,6 +9,16 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
- **Voice-routing default went to a rewrite**: when no `[routing] default` preset
|
- **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
|
is set, the no-keyword fallback used the *first* preset — which, if that happened
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class Config:
|
|||||||
output: str = "type" # type | paste
|
output: str = "type" # type | paste
|
||||||
type_delay_ms: int = 12
|
type_delay_ms: int = 12
|
||||||
notify: bool = True
|
notify: bool = True
|
||||||
|
notify_routing: bool = True # announce which preset/keyword a voice command matched
|
||||||
language: str = "de" # whisper hint; "" = autodetect
|
language: str = "de" # whisper hint; "" = autodetect
|
||||||
# input scheme
|
# input scheme
|
||||||
input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos)
|
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"),
|
output=g.get("output", "type"),
|
||||||
type_delay_ms=int(g.get("type_delay_ms", 4)),
|
type_delay_ms=int(g.get("type_delay_ms", 4)),
|
||||||
notify=bool(g.get("notify", True)),
|
notify=bool(g.get("notify", True)),
|
||||||
|
notify_routing=bool(g.get("notify_routing", True)),
|
||||||
language=g.get("language", "de"),
|
language=g.get("language", "de"),
|
||||||
model=w.get("model", "small"),
|
model=w.get("model", "small"),
|
||||||
device=w.get("device", "auto"),
|
device=w.get("device", "auto"),
|
||||||
@ -252,6 +254,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
|
|||||||
"output": cfg.output,
|
"output": cfg.output,
|
||||||
"type_delay_ms": cfg.type_delay_ms,
|
"type_delay_ms": cfg.type_delay_ms,
|
||||||
"notify": cfg.notify,
|
"notify": cfg.notify,
|
||||||
|
"notify_routing": cfg.notify_routing,
|
||||||
"language": cfg.language,
|
"language": cfg.language,
|
||||||
},
|
},
|
||||||
"whisper": {
|
"whisper": {
|
||||||
@ -355,6 +358,7 @@ recorder = "auto" # auto | pw-record | parecord | arecord
|
|||||||
output = "type" # "type" = xdotool types it; "paste" = clipboard + Ctrl+V
|
output = "type" # "type" = xdotool types it; "paste" = clipboard + Ctrl+V
|
||||||
type_delay_ms = 12 # per-keystroke delay for xdotool type (raise if chars drop)
|
type_delay_ms = 12 # per-keystroke delay for xdotool type (raise if chars drop)
|
||||||
notify = true # desktop notifications for each phase
|
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
|
language = "de" # Whisper language hint; "" = autodetect
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|||||||
@ -84,6 +84,12 @@ class Daemon:
|
|||||||
if not self._session_silent:
|
if not self._session_silent:
|
||||||
self._notify(title, body, urgency=urgency)
|
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:
|
def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None:
|
||||||
if self.status_cb:
|
if self.status_cb:
|
||||||
try:
|
try:
|
||||||
@ -382,9 +388,11 @@ class Daemon:
|
|||||||
target = self.cfg.preset_by_name(res.preset_name) or self.cfg.default_preset
|
target = self.cfg.preset_by_name(res.preset_name) or self.cfg.default_preset
|
||||||
text = res.text
|
text = res.text
|
||||||
label = target.name if target else "Transcribe"
|
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._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:
|
else:
|
||||||
target = workflow
|
target = workflow
|
||||||
|
|
||||||
|
|||||||
@ -324,6 +324,9 @@ class SettingsDialog:
|
|||||||
page.pack_start(form, True, True, 6)
|
page.pack_start(form, True, True, 6)
|
||||||
self.wf_name = _labeled(form, "Name", _entry(placeholder="Preset name"),
|
self.wf_name = _labeled(form, "Name", _entry(placeholder="Preset name"),
|
||||||
tooltip="A short name for this action, shown in the main panel.")
|
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"),
|
self.wf_desc = _labeled(form, "Description", _entry(placeholder="Short description shown in the panel"),
|
||||||
tooltip="One line explaining what this preset does.")
|
tooltip="One line explaining what this preset does.")
|
||||||
self.wf_keywords = _labeled(form, "Keywords (comma)", _entry(placeholder="nicer email, bessere email"),
|
self.wf_keywords = _labeled(form, "Keywords (comma)", _entry(placeholder="nicer email, bessere email"),
|
||||||
@ -350,6 +353,7 @@ class SettingsDialog:
|
|||||||
return
|
return
|
||||||
wf = self.cfg.workflows[idx]
|
wf = self.cfg.workflows[idx]
|
||||||
self.wf_name.set_text(wf.name)
|
self.wf_name.set_text(wf.name)
|
||||||
|
self.wf_icon.set_text(wf.icon or "")
|
||||||
self.wf_desc.set_text(wf.description)
|
self.wf_desc.set_text(wf.description)
|
||||||
self.wf_keywords.set_text(", ".join(wf.keywords))
|
self.wf_keywords.set_text(", ".join(wf.keywords))
|
||||||
self.wf_hotkey.set_text(wf.hotkey)
|
self.wf_hotkey.set_text(wf.hotkey)
|
||||||
@ -365,6 +369,7 @@ class SettingsDialog:
|
|||||||
return
|
return
|
||||||
wf = self.cfg.workflows[idx]
|
wf = self.cfg.workflows[idx]
|
||||||
wf.name = self.wf_name.get_text().strip() or wf.name
|
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.description = self.wf_desc.get_text().strip()
|
||||||
wf.keywords = [k.strip() for k in self.wf_keywords.get_text().split(",") if k.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.hotkey = self.wf_hotkey.get_text().strip()
|
||||||
@ -857,6 +862,11 @@ class SettingsDialog:
|
|||||||
tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.")
|
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)
|
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)
|
_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)
|
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)
|
_labeled(page, "Launch on login", self.gen_boot)
|
||||||
self._start_meter()
|
self._start_meter()
|
||||||
@ -1153,6 +1163,7 @@ class SettingsDialog:
|
|||||||
c.output = self.gen_output.get_active_text() or "type"
|
c.output = self.gen_output.get_active_text() or "type"
|
||||||
c.language = self.gen_lang.get_text().strip()
|
c.language = self.gen_lang.get_text().strip()
|
||||||
c.notify = self.gen_notify.get_active()
|
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.device = self.stt_device.get_active_text() or "auto"
|
||||||
c.compute_type = self.stt_compute.get_active_text() or "auto"
|
c.compute_type = self.stt_compute.get_active_text() or "auto"
|
||||||
autostart.set_enabled(self.gen_boot.get_active())
|
autostart.set_enabled(self.gen_boot.get_active())
|
||||||
|
|||||||
@ -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"
|
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):
|
def test_wakeword_while_busy_does_not_notify(monkeypatch):
|
||||||
"""The away-from-keyboard "Busy" storm: a detection arriving while the
|
"""The away-from-keyboard "Busy" storm: a detection arriving while the
|
||||||
previous clip is still being processed must be ignored silently."""
|
previous clip is still being processed must be ignored silently."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user