blitztext-app-linux/linux/tests/test_daemon_notify.py
mARTin-B78 ce0079e728 wakeword: independent cues + configurable auto-stop silence; add MANUAL.md
Reported issues from hands-free use:

- Wakeword WAVs didn't play: the new "Play audio cues" master switch
  ([sounds] enabled) also gated the hands-free Sound: detected/captured cues,
  so enabled=false silenced them. The cues live in a separate UI section, so
  this was surprising. Wakeword cues are now independent of that switch: they
  play whenever a file is set, and an empty field means silent (no system-chime
  fallback) — which is also how you turn a hands-free cue off. The master switch
  now governs only the manual (keyboard) before/after chimes.

- Clarified the four sound fields' tooltips/labels (detected/captured = hands-free
  only; before/after = manual only) and the empty-field behaviour.

- New "Silence to stop (s)" setting (Settings → Input → Hands-free, or
  [wakeword] silence_seconds, default 2.0): user-defined trailing-silence
  timeout for hands-free auto-stop (was hard-coded to 2.5 s).

- Add MANUAL.md documenting every setting in every tab; link it from the README.

Tests: cue independence + manual-gating + roundtrip (17 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:36:35 +02:00

101 lines
3.8 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_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"