blitztext-app-linux/linux/tests/test_voice_cancel.py
mARTin-B78 1567202e1a routing: spoken cancel keyword to discard a clip; Release 1.7.0
Add a configurable voice cancel: saying "abbrechen" (or "cancel") at the start
or end of a clip discards the whole dictation — it is never routed onward,
rewritten, or typed. The rescue for accidentally triggered (e.g. wakeword)
recordings. Matched the same edge-anchored, ASR-tolerant way as routing keywords
via routing.is_cancel(), so the word buried mid-sentence won't trip it; checked
in Daemon._process right after transcription, before routing/rewrite/delivery.
Configurable via [routing] cancel_keywords (default ["abbrechen", "cancel"];
empty disables) and Settings -> Mic/Cues -> "Cancel words". The overlay briefly
shows "Abgebrochen". Docs (both READMEs) and CHANGELOG updated; tests cover the
matcher, the config round-trip, and the discard/deliver pipeline branches.

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

80 lines
3.5 KiB
Python

"""Spoken abort: a cancel word discards a clip before it is acted on.
Rescues accidentally triggered (e.g. wakeword) dictations — the transcript is
recognised but nothing is routed, rewritten, or typed.
"""
import blitztext.daemon as dm
from blitztext.config import Config, Workflow
from blitztext.daemon import Daemon
from blitztext.routing import is_cancel
# -- matcher --------------------------------------------------------------------
def test_is_cancel_matches_edges_only():
kws = ["abbrechen", "cancel"]
assert is_cancel("abbrechen", kws) == "abbrechen" # whole utterance
assert is_cancel("ok das ganze bitte abbrechen", kws) == "abbrechen" # end edge
assert is_cancel("cancel this please", kws) == "cancel" # start edge
assert is_cancel("abbrechn", ["abbrechen"]) == "abbrechen" # ASR drift, fuzzy
# Word buried mid-sentence must NOT cancel a legitimate dictation.
assert is_cancel("ich will den vorgang abbrechen weil es spaet ist", ["abbrechen"]) is None
assert is_cancel("hallo welt", ["abbrechen"]) is None
assert is_cancel("abbrechen", []) is None # disabled
assert is_cancel("", ["abbrechen"]) is None
def test_cancel_keywords_round_trip(tmp_path):
from blitztext.config import load, save
p = tmp_path / "config.toml"
cfg = load(p)
assert cfg.cancel_keywords == ["abbrechen", "cancel"] # shipped default
cfg.cancel_keywords = ["nein doch nicht", "scrap that"]
save(cfg, p)
assert load(p).cancel_keywords == ["nein doch nicht", "scrap that"]
# -- pipeline -------------------------------------------------------------------
def _wire_clean_pipeline(monkeypatch):
monkeypatch.setattr(dm, "detect_recorder", lambda pref="auto": "pw-record")
monkeypatch.setattr(dm, "notify", lambda *a, **k: None)
monkeypatch.setattr(dm.quality, "analyze_wav", lambda p: (2.0, 0.5))
monkeypatch.setattr(dm.quality, "too_quiet", lambda *a, **k: False)
monkeypatch.setattr(dm.quality, "clean", lambda t, **k: t)
monkeypatch.setattr(dm.quality, "is_hallucination", lambda *a, **k: False)
delivered = []
monkeypatch.setattr(dm, "deliver", lambda *a, **k: delivered.append((a, k)))
return delivered
def test_process_discards_when_cancel_spoken(monkeypatch, tmp_path):
delivered = _wire_clean_pipeline(monkeypatch)
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "ok das ganze bitte abbrechen")
d = Daemon(Config())
d._prepared = True
d.cfg.cancel_keywords = ["abbrechen", "cancel"]
audio = tmp_path / "clip.wav"
audio.write_bytes(b"x")
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
assert delivered == [], "a voice-cancelled clip must never be typed"
assert not audio.exists(), "the temp clip is still cleaned up afterwards"
assert d._busy is False
def test_process_delivers_without_cancel_word(monkeypatch, tmp_path):
"""Control: the same path with no cancel word still types normally."""
delivered = _wire_clean_pipeline(monkeypatch)
monkeypatch.setattr(dm.stt, "transcribe", lambda *a, **k: "hallo welt")
d = Daemon(Config())
d._prepared = True
d.cfg.cancel_keywords = ["abbrechen", "cancel"]
audio = tmp_path / "clip.wav"
audio.write_bytes(b"x")
d._process(audio, Workflow(name="Transcribe", hotkey="", mode="transcribe"), None)
assert len(delivered) == 1, "a normal clip must still be delivered"
assert delivered[0][0][0] == "hallo welt"