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:
parent
93de455b01
commit
080e7297dd
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
58
linux/tests/test_daemon_notify.py
Normal file
58
linux/tests/test_daemon_notify.py
Normal 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"
|
||||||
Loading…
Reference in New Issue
Block a user