Fix a desktop-session freeze (forced logout/reboot) caused by the overlay's AT-SPI caret tracker: it subscribed to the high-frequency object:text-caret-moved signal and made synchronous, blocking AT-SPI reads from inside the event handler, re-entering the a11y dispatcher and getting stormed by the app's own xdotool typing until GNOME stopped responding. Now track focus changes only and read the caret rectangle lazily, once, when the overlay shows — never on the hot path. Fuse voice-routing feedback into the overlay instead of a desktop notification: show the matched preset's emoji, name, and spoken keyword on a banner, narrate the phase (Transcribing -> Rewriting), and stream the LLM rewrite into the bubble token-by-token. Redundant per-dictation notifications are suppressed when the overlay is present (errors still notify); headless/overlay-off is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""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_manual_cues_gated_by_master_switch(monkeypatch):
|
|
import blitztext.sound as sound_mod
|
|
plays = []
|
|
monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k)))
|
|
d = _make_daemon(monkeypatch)
|
|
d._session_silent = False # manual (keyboard) session
|
|
|
|
d.cfg.sounds_enabled = False
|
|
d._play_cue("before")
|
|
d._play_sound("device-removed")
|
|
assert plays == [], "manual cues should be silent when 'Play audio cues' is off"
|
|
|
|
d.cfg.sounds_enabled = True
|
|
d._play_cue("before")
|
|
d._play_sound("device-removed")
|
|
assert len(plays) == 2, "manual cues should play when enabled"
|
|
|
|
|
|
def test_wakeword_cues_independent_of_master_switch(monkeypatch):
|
|
"""Regression: the manual 'Play audio cues' switch must NOT silence the
|
|
hands-free wakeword sounds (the bug where enabled=false killed the beeps)."""
|
|
import blitztext.sound as sound_mod
|
|
plays = []
|
|
monkeypatch.setattr(sound_mod, "play", lambda *a, **k: plays.append((a, k)))
|
|
d = _make_daemon(monkeypatch)
|
|
d._session_silent = True # hands-free session
|
|
d.cfg.sounds_enabled = False # manual cues off
|
|
d.cfg.wakeword_sound_detected = "/snd/beep_start.wav"
|
|
d.cfg.wakeword_sound_done = "/snd/beep_stop.wav"
|
|
|
|
d._play_cue("before")
|
|
d._play_cue("after")
|
|
assert [p[0][0] for p in plays] == ["/snd/beep_start.wav", "/snd/beep_stop.wav"], \
|
|
"wakeword cues must play regardless of the manual master switch"
|
|
|
|
# Empty wakeword sound = silent (no system-sound fallback).
|
|
plays.clear()
|
|
d.cfg.wakeword_sound_detected = ""
|
|
d._play_cue("before")
|
|
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_overlay_fuses_notifications(monkeypatch):
|
|
"""With an overlay (routing_cb wired), the on-screen bubble narrates every
|
|
phase, so informational desktop notifications are suppressed — but errors
|
|
still pop a bubble, and the routing match is shown on the overlay, not a
|
|
notification."""
|
|
calls = []
|
|
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
|
|
monkeypatch.setattr(daemon_mod, "detect_recorder", lambda pref="auto": "pw-record")
|
|
d = Daemon(Config(), routing_cb=lambda *a: None)
|
|
assert d._overlay is True
|
|
d._session_silent = False
|
|
|
|
d._dnotify("⌛ Nicer email", "Rewriting…") # informational
|
|
assert calls == [], "informational notifications fuse into the overlay"
|
|
|
|
d._dnotify("Error", "boom", urgency="critical") # errors still notify
|
|
assert len(calls) == 1, "errors must still pop a desktop bubble"
|
|
|
|
calls.clear()
|
|
d.cfg.notify_routing = True
|
|
d._rnotify("⚡ Nicer email", "matched: “nicer email”")
|
|
assert calls == [], "the match is shown on the overlay banner, not as a notification"
|
|
|
|
|
|
def test_no_overlay_keeps_notifications(monkeypatch):
|
|
"""Headless / overlay-off: notifications remain the only feedback."""
|
|
calls = []
|
|
monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k)))
|
|
d = _make_daemon(monkeypatch) # no routing_cb
|
|
assert d._overlay is False
|
|
d._session_silent = False
|
|
d._dnotify("⌛ Nicer email", "Rewriting…")
|
|
assert len(calls) == 1, "without an overlay, informational notifications still show"
|
|
|
|
|
|
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"
|