From 080e7297dd4b764fd7eae2fbfb4c5f93afe1ec65 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 6 Jun 2026 23:10:48 +0200 Subject: [PATCH] wakeword: stop hands-free notifications leaking when away; surface paused state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- linux/CHANGELOG.md | 10 +++++- linux/blitztext/daemon.py | 29 ++++++++++------ linux/tests/test_daemon_notify.py | 58 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 linux/tests/test_daemon_notify.py diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index a960661..aaacabd 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,7 +18,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ### Fixed - **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 - 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 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. diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 8b54b59..a5dd20b 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -48,12 +48,16 @@ class Daemon: self._wakeword_listener = None # When a session is started hands-free by the wakeword, its desktop # notifications are suppressed (kept quiet in the background). - self._wake_pending = False self._session_silent = False def _init_wakeword(self): 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( uri=self.cfg.wakeword_uri, model=self.cfg.wakeword_model, @@ -63,10 +67,13 @@ class Daemon: self._wakeword_listener.start() def _on_wakeword(self): - if not self.is_recording: - # Hands-free trigger: keep this session's notifications quiet. - self._wake_pending = True - self.toggle(self._route_workflow) + # Hands-free trigger: start a quiet (notification-suppressed) session. + # We call start_dictation directly rather than toggle() so that a busy + # or not-yet-ready state is ignored silently instead of popping a + # "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 ------------------------------------------------------------- def _notify(self, title: str, body: str = "", urgency: str = "normal") -> None: @@ -158,14 +165,13 @@ class Daemon: sound.play(custom, fallback="complete") # -- 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 streamer: RivaRealtimeStreamer | None = None with self._lock: if not self.ready or self._busy or self.is_recording: return - self._session_silent = self._wake_pending - self._wake_pending = False + self._session_silent = silent self._target_window = active_window_id() self._active_workflow = wf if wf.mode == "stream": @@ -384,7 +390,8 @@ class Daemon: ) except LLMError as 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 if not text: @@ -405,7 +412,7 @@ class Daemon: self._dnotify(f"✓ {label}", text[:80] + ("…" if len(text) > 80 else "")) except Exception as exc: # noqa: BLE001 - surface any failure self._emit("error", label, str(exc)) - self._notify("Error", str(exc), "critical") + self._dnotify("Error", str(exc), "critical") log(f"ERROR ({label}): {exc}") finally: audio_path.unlink(missing_ok=True) diff --git a/linux/tests/test_daemon_notify.py b/linux/tests/test_daemon_notify.py new file mode 100644 index 0000000..6fcf030 --- /dev/null +++ b/linux/tests/test_daemon_notify.py @@ -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"