wakeword: stop hands-free notifications leaking when away; surface paused state

Two reported bugs:

1) Notifications still appeared during hands-free use while away. A wakeword
   hit arriving while the previous clip was still transcribing went through
   toggle(), which popped a "Busy" notification — the away-from-keyboard
   notification storm. Wakeword triggers now call start_dictation(silent=True)
   directly, so a busy/not-ready state is ignored silently. Transcription and
   rewrite failures during a silent session are now logged instead of raising
   critical desktop notifications.

2) "Wakeword did not work" with the log line "muted via /tmp/wake_muted": a
   stale mute flag silently disabled detection. The tray "Pause wakeword"
   toggle (already added) clears it; the daemon now also logs a clear
   "Starting PAUSED" warning when it boots with the flag present.

Adds tests/test_daemon_notify.py covering silent-session suppression and the
busy-while-wakeword no-notify path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-06 23:10:48 +02:00
parent 93de455b01
commit 080e7297dd
3 changed files with 85 additions and 12 deletions

View File

@ -18,7 +18,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
### Fixed ### Fixed
- **Wakeword stuck muted**: a leftover `/tmp/wake_muted` flag silently disabled - **Wakeword stuck muted**: a leftover `/tmp/wake_muted` flag silently disabled
detection with no in-app way to clear it. The state is now exposed and detection with no in-app way to clear it. The state is now exposed and
reversible from the tray, so a stale flag no longer kills hands-free use. reversible from the tray, so a stale flag no longer kills hands-free use. The
daemon also logs a clear `Starting PAUSED` warning when it boots muted.
- **Away-from-keyboard "Busy" storm**: a wakeword hit arriving while the previous
clip was still transcribing went through `toggle()` and popped a "Busy"
notification. Wakeword triggers now go straight to `start_dictation()`, so a
busy/not-ready state is ignored silently instead.
- **Quiet hands-free errors**: transcription/rewrite failures during a
wakeword-triggered session no longer raise critical desktop notifications —
they are logged instead, keeping background sessions silent.
- **Notification storm / lock-screen pile-up**: desktop notifications are now - **Notification storm / lock-screen pile-up**: desktop notifications are now
sent as transient with a short expiry and reuse a single bubble, so they no sent as transient with a short expiry and reuse a single bubble, so they no
longer stack in the notification log or persist on the lock screen. longer stack in the notification log or persist on the lock screen.

View File

@ -48,12 +48,16 @@ class Daemon:
self._wakeword_listener = None self._wakeword_listener = None
# When a session is started hands-free by the wakeword, its desktop # When a session is started hands-free by the wakeword, its desktop
# notifications are suppressed (kept quiet in the background). # notifications are suppressed (kept quiet in the background).
self._wake_pending = False
self._session_silent = False self._session_silent = False
def _init_wakeword(self): def _init_wakeword(self):
if self.cfg.wakeword_enabled: if self.cfg.wakeword_enabled:
from .wakeword import WakewordListener from .wakeword import WakewordListener, is_muted
if is_muted():
# A stale flag silently disables detection — make it visible so
# "the wakeword does nothing" has an obvious explanation/fix.
log("[wakeword] Starting PAUSED — /tmp/wake_muted is present; "
"resume via the tray 'Pause wakeword' toggle.")
self._wakeword_listener = WakewordListener( self._wakeword_listener = WakewordListener(
uri=self.cfg.wakeword_uri, uri=self.cfg.wakeword_uri,
model=self.cfg.wakeword_model, model=self.cfg.wakeword_model,
@ -63,10 +67,13 @@ class Daemon:
self._wakeword_listener.start() self._wakeword_listener.start()
def _on_wakeword(self): def _on_wakeword(self):
if not self.is_recording: # Hands-free trigger: start a quiet (notification-suppressed) session.
# Hands-free trigger: keep this session's notifications quiet. # We call start_dictation directly rather than toggle() so that a busy
self._wake_pending = True # or not-yet-ready state is ignored silently instead of popping a
self.toggle(self._route_workflow) # "Busy"/"Please wait" notification — repeated false detections while a
# clip is still transcribing were the source of the away-from-keyboard
# notification storm.
self.start_dictation(self._route_workflow, silent=True)
# -- feedback ------------------------------------------------------------- # -- feedback -------------------------------------------------------------
def _notify(self, title: str, body: str = "", urgency: str = "normal") -> None: def _notify(self, title: str, body: str = "", urgency: str = "normal") -> None:
@ -158,14 +165,13 @@ class Daemon:
sound.play(custom, fallback="complete") sound.play(custom, fallback="complete")
# -- recording control ---------------------------------------------------- # -- recording control ----------------------------------------------------
def start_dictation(self, workflow: Workflow | None = None) -> None: def start_dictation(self, workflow: Workflow | None = None, silent: bool = False) -> None:
wf = workflow or self._route_workflow wf = workflow or self._route_workflow
streamer: RivaRealtimeStreamer | None = None streamer: RivaRealtimeStreamer | None = None
with self._lock: with self._lock:
if not self.ready or self._busy or self.is_recording: if not self.ready or self._busy or self.is_recording:
return return
self._session_silent = self._wake_pending self._session_silent = silent
self._wake_pending = False
self._target_window = active_window_id() self._target_window = active_window_id()
self._active_workflow = wf self._active_workflow = wf
if wf.mode == "stream": if wf.mode == "stream":
@ -384,7 +390,8 @@ class Daemon:
) )
except LLMError as exc: except LLMError as exc:
self._emit("error", label, str(exc)) self._emit("error", label, str(exc))
self._notify("Rewrite failed", str(exc), "critical") self._dnotify("Rewrite failed", str(exc), "critical")
log(f"ERROR ({label} rewrite): {exc}")
return return
if not text: if not text:
@ -405,7 +412,7 @@ class Daemon:
self._dnotify(f"{label}", text[:80] + ("" if len(text) > 80 else "")) self._dnotify(f"{label}", text[:80] + ("" if len(text) > 80 else ""))
except Exception as exc: # noqa: BLE001 - surface any failure except Exception as exc: # noqa: BLE001 - surface any failure
self._emit("error", label, str(exc)) self._emit("error", label, str(exc))
self._notify("Error", str(exc), "critical") self._dnotify("Error", str(exc), "critical")
log(f"ERROR ({label}): {exc}") log(f"ERROR ({label}): {exc}")
finally: finally:
audio_path.unlink(missing_ok=True) audio_path.unlink(missing_ok=True)

View File

@ -0,0 +1,58 @@
"""Hands-free (wakeword) sessions must stay quiet.
Regression tests for two reported bugs:
1. The wakeword produced desktop notifications when the user was away most
notably a "Busy" storm from repeated false detections arriving while a
previous clip was still transcribing.
2. A hands-free session's errors popped up as critical notifications.
"""
import blitztext.daemon as daemon_mod
from blitztext.config import Config
from blitztext.daemon import Daemon
def _make_daemon(monkeypatch):
# Don't probe real audio devices when constructing the daemon.
monkeypatch.setattr(daemon_mod, "detect_recorder", lambda pref="auto": "pw-record")
return Daemon(Config())
def test_dnotify_suppressed_in_silent_session(monkeypatch):
calls = []
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
d = _make_daemon(monkeypatch)
d._session_silent = True
d._dnotify("Recording", "")
assert calls == [], "per-dictation notifications must be suppressed for hands-free sessions"
d._session_silent = False
d._dnotify("Recording", "")
assert len(calls) == 1, "keyboard/GUI sessions should still notify"
def test_on_wakeword_starts_a_silent_session(monkeypatch):
started = {}
d = _make_daemon(monkeypatch)
monkeypatch.setattr(d, "start_dictation", lambda wf=None, silent=False: started.update(wf=wf, silent=silent))
d._on_wakeword()
assert started.get("silent") is True
assert started.get("wf") is d._route_workflow
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."""
calls = []
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
d = _make_daemon(monkeypatch)
d._prepared = True # model "loaded"
d._busy = True # still transcribing the previous clip
d._on_wakeword()
assert calls == [], "a wakeword hit while busy must not pop a 'Busy' notification"
assert d.is_recording is False, "no new session should start while busy"