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>
This commit is contained in:
mARTin-B78 2026-06-07 22:09:07 +02:00
parent ae01472b77
commit 1567202e1a
9 changed files with 156 additions and 4 deletions

View File

@ -4,7 +4,7 @@
Blitztext is a native Linux dictation tool that captures your voice, transcribes it locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), optionally rewrites the text through an LLM, and types the result directly into whatever application has focus. Think macOS Dictation, but open-source, extensible, and designed for power users who want full control over their speech-to-text pipeline.
> **Status:** Experimental open-source Linux/X11 desktop app (v1.5.1).
> **Status:** Experimental open-source Linux/X11 desktop app (v1.7.0).
> No hosted backend — bring your own models and endpoints.
<p align="center">
@ -55,9 +55,10 @@ Stream: hotkey → mic PCM chunks → Riva/NIM WebSocket → live words typed
- **Fully local STT.** Batch transcription via `faster-whisper` never leaves your machine. No cloud account needed for basic dictation.
- **Pluggable engines.** Configure multiple STT and LLM backends as named presets — local `faster-whisper`, remote OpenAI-compatible batch endpoints, Riva/NIM realtime WebSocket servers, and any OpenAI-compatible chat API (OpenAI, vLLM, llama-swap, Ollama, LM Studio, Groq, OpenRouter).
- **Voice-keyword routing.** One hotkey, multiple workflows. Say "nicer email" at the start or end of your speech and the email-rewrite preset activates automatically (fuzzy-matched, ASR-tolerant).
- **Spoken cancel.** Say "abbrechen" (or "cancel") at the start or end of a clip and the whole dictation is discarded — never routed, rewritten, or typed. The rescue for an accidentally triggered (e.g. wakeword) recording. Configurable in Settings; empty list disables it.
- **Quality gate.** Silent clips, too-short recordings, and Whisper hallucinations ("Thank you.", "Untertitel…") are caught and rejected before they reach your text field.
- **Realtime streaming.** Connect a Riva/NIM realtime STT server and see stable words typed live as you speak.
- **On-screen overlay at the cursor.** The moment you start dictating — by hotkey *or* wakeword — a translucent bubble pops up at the cursor with a pulsing microphone, a live waveform of your mic level, and the recognised text (live with a streaming engine, or as a brief confirmation otherwise). Its tail points at the text caret (via accessibility) and finally gives hands-free wakeword sessions visible feedback. Click-through, never steals focus; toggle in Settings → General.
- **On-screen overlay at the cursor.** The moment you start dictating — by hotkey *or* wakeword — a translucent bubble pops up at the cursor with a pulsing microphone, a live waveform of your mic level, and the recognised text. When a voice keyword routes to a preset it shows that preset's icon, name, and the matched keyword on a banner (instead of a desktop notification), and streams the LLM rewrite into the bubble token-by-token so you watch it write. Its tail points at the text caret (via accessibility) and finally gives hands-free wakeword sessions visible feedback. Click-through, never steals focus; toggle in Settings → General.
- **Built-in benchmarking.** Compare all your configured STT engines against a reference WAV + transcript to find the fastest and most accurate.
---
@ -241,6 +242,10 @@ Now try with voice routing:
1. Press `Ctrl+Alt+Space` → say **"nicer email** hey john can you send me the report"
2. Press `Ctrl` — Blitztext detects the keyword, runs the "Nicer email" rewrite, and types a polished email.
### 4b. Cancel by voice
Started a recording by accident (or changed your mind)? Just say **"abbrechen"** (or **"cancel"**) at the start or end of what you say. The whole clip is discarded — nothing is transcribed onward, routed, rewritten, or typed, and the overlay briefly shows *✗ Abgebrochen*. This is especially handy with the hands-free wakeword, where a stray trigger could otherwise type ambient speech. Tune the words under **Settings → Mic/Cues → "Cancel words"** (or `[routing] cancel_keywords`); clear the list to switch it off.
### 5. Explore Settings
Click the ⚙️ gear icon in the panel header, or right-click the tray → **Settings…**
@ -292,6 +297,7 @@ With the default `modifiers` input mode:
| `Ctrl` | Stop → transcribe → type |
| `Alt` | Stop → transcribe → type → press Enter |
| `Esc` | Cancel (discard recording) |
| say *"abbrechen"* / *"cancel"* | Cancel by voice — discard the clip (works hands-free too) |
---

View File

@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased]
## [1.7.0] - 2026-06-07
### Added
- **Spoken cancel keyword**: say a word like **"abbrechen"** (or "cancel") at the
start or end of a clip and the whole dictation is **discarded** — it is never
routed, rewritten, or typed anywhere. Mainly rescues an accidentally triggered
(e.g. wakeword) recording. Configure under Settings → Mic/Cues → "Cancel words",
or `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; empty list
disables it). Matched the same edge-anchored, ASR-tolerant way as routing
keywords, so the word buried mid-sentence won't trip it.
## [1.6.0] - 2026-06-07
### Fixed

View File

@ -39,6 +39,12 @@ Each normal hotkey **toggles**: press to start recording, press again to stop
then it transcribes, optionally rewrites, and types the result where your cursor
is. Streaming workflows type stable words live while you speak.
**Cancel by voice:** say *"abbrechen"* (or *"cancel"*) at the start or end of a
clip and the whole dictation is discarded — never routed, rewritten, or typed.
It's the rescue for an accidentally triggered (e.g. wakeword) recording. Set the
words under `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; an
empty list turns it off).
While you dictate, an optional **on-screen overlay** (Settings → General →
"Visual overlay", default on) shows a translucent bubble at the cursor with a
pulsing microphone, a live waveform of your mic level, and the recognised text —

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool.
"""
__version__ = "1.6.0"
__version__ = "1.7.0"

View File

@ -75,6 +75,10 @@ class Config:
routing_hotkey: str = "<ctrl>+<alt>+<space>"
routing_default: str = "" # preset name used when no keyword matches; "" = first
routing_threshold: float = 0.82
# Spoken abort: if one of these words is heard at the start/end of a clip, the
# dictation is discarded — never transcribed onward, routed, rewritten, or
# typed. Empty list = disabled. Mainly for accidental wakeword triggers.
cancel_keywords: list[str] = field(default_factory=lambda: ["abbrechen", "cancel"])
# speech-to-text engines (presets)
stt_engines: list[STTEngine] = field(default_factory=list)
stt_active: str = ""
@ -178,6 +182,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
routing_hotkey=rt.get("hotkey", "<ctrl>+<alt>+<space>"),
routing_default=rt.get("default", ""),
routing_threshold=float(rt.get("threshold", 0.82)),
cancel_keywords=list(rt.get("cancel_keywords", ["abbrechen", "cancel"])),
input_mode=inp.get("mode", "modifiers"),
push_to_talk=bool(inp.get("push_to_talk", False)),
key_start=inp.get("start", "<ctrl>+<cmd>"),
@ -292,6 +297,7 @@ def save(cfg: Config, path: Path = CONFIG_PATH) -> None:
"hotkey": cfg.routing_hotkey,
"default": cfg.routing_default,
"threshold": cfg.routing_threshold,
"cancel_keywords": cfg.cancel_keywords,
},
"quality": {
"min_speech_seconds": cfg.min_speech_seconds,
@ -429,6 +435,10 @@ enabled = true
hotkey = "<ctrl>+<alt>+<space>"
default = "Transcribe" # preset used when no keyword is recognised
threshold = 0.82 # 0..1 fuzzy-match strictness (higher = stricter)
# Say one of these at the start or end of a clip to DISCARD it — nothing is
# routed, rewritten, or typed. Handy when a wakeword fires by accident. Pick
# words you won't naturally end a real dictation with. Empty list = off.
cancel_keywords = ["abbrechen", "cancel"]
[wakeword]
# Hands-free dictation using an external wyoming-openwakeword server.

View File

@ -19,7 +19,7 @@ from .notify import notify
from .paste import active_window_id, deliver
from .streaming import RivaRealtimeStreamer
from .recorder import Recording, detect_recorder
from .routing import route
from .routing import is_cancel, route
from .transcribe import Transcriber
# status_cb(state, workflow_name, message)
@ -461,6 +461,18 @@ class Daemon:
log("Nothing heard — no speech detected.")
return
# Spoken abort: a configured cancel word heard at an edge discards the
# whole clip — nothing is routed, rewritten, or typed. This is the
# rescue for an accidentally triggered (e.g. wakeword) dictation.
cancel_kw = is_cancel(text, self.cfg.cancel_keywords, threshold=self.cfg.routing_threshold)
if cancel_kw:
log(f"✗ Discarded by voice keyword “{cancel_kw}”.")
if self.text_cb:
self.text_cb("✗ Abgebrochen")
self._emit("idle", label, "Cancelled")
self._dnotify("Abgebrochen", f"{cancel_kw}“ gehört — verworfen.", "low")
return
# Voice routing: pick the preset from a spoken keyword, strip it.
if workflow.mode == "route":
res = route(text, self.cfg.workflows, threshold=self.cfg.routing_threshold)

View File

@ -813,6 +813,13 @@ class SettingsDialog:
"after you stop speaking. Hands-free auto-stop — the wakeword "
"can't be released like a key. Default 2.0.")
self.cancel_keywords = _labeled(
page, "Cancel words (comma)",
_entry(", ".join(self.cfg.cancel_keywords), placeholder="abbrechen, cancel"),
tooltip="Say one of these at the start or end of a clip to DISCARD it — "
"nothing is transcribed onward, routed, rewritten, or typed. "
"Rescues an accidentally triggered dictation. Empty = off.")
self.ww_snd_detected = self._sound_field(
page, "Sound: detected", self.cfg.wakeword_sound_detected,
"HANDS-FREE ONLY. Plays the instant the wake word is recognised and recording starts "
@ -1205,6 +1212,7 @@ class SettingsDialog:
c.wakeword_sound_detected = self.ww_snd_detected.get_filename() or ""
c.wakeword_sound_done = self.ww_snd_done.get_filename() or ""
c.wakeword_silence_seconds = float(self.ww_silence.get_text())
c.cancel_keywords = [k.strip() for k in self.cancel_keywords.get_text().split(",") if k.strip()]
c.mic = self._selected_mic_name()
c.output = self.gen_output.get_active_text() or "type"
c.language = self.gen_lang.get_text().strip()

View File

@ -110,6 +110,26 @@ def route(
return RouteResult(name, cleaned, keyword, position, score)
def is_cancel(transcript: str, cancel_keywords, *, threshold: float = DEFAULT_THRESHOLD) -> str | None:
"""Return the cancel keyword that matches an edge of the transcript, else None.
Lets a spoken word like "abbrechen" abort an (often accidentally triggered)
dictation before it is routed, rewritten, or delivered. Matched the same
edge-anchored, ASR-tolerant way as routing keywords, so the word appearing
deep inside a sentence won't trigger it — only at the start or end.
"""
if not cancel_keywords:
return None
tokens = normalize(transcript)
if not tokens:
return None
for kw in cancel_keywords:
kw_tokens = normalize(kw)
if kw_tokens and _match_window(tokens, kw_tokens, threshold) is not None:
return kw
return None
def _strip_span(transcript: str, span_words: int, position: str) -> str:
"""Remove the matched keyword from the given edge of the original transcript.

View File

@ -0,0 +1,79 @@
"""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"