diff --git a/README.md b/README.md
index ebf8439..452cf2c 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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) |
---
diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md
index fca6ce3..42829bc 100644
--- a/linux/CHANGELOG.md
+++ b/linux/CHANGELOG.md
@@ -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
diff --git a/linux/README.md b/linux/README.md
index 24f3ed5..703a226 100644
--- a/linux/README.md
+++ b/linux/README.md
@@ -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 —
diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py
index bb853e6..a22ecd3 100644
--- a/linux/blitztext/__init__.py
+++ b/linux/blitztext/__init__.py
@@ -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"
diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py
index b9f8db0..4d9f70f 100644
--- a/linux/blitztext/config.py
+++ b/linux/blitztext/config.py
@@ -75,6 +75,10 @@ class Config:
routing_hotkey: str = "++"
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", "++"),
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", "+"),
@@ -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 = "++"
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.
diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py
index e3fad25..0e79c2e 100644
--- a/linux/blitztext/daemon.py
+++ b/linux/blitztext/daemon.py
@@ -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)
diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py
index 3ec6793..6952707 100644
--- a/linux/blitztext/gtksettings.py
+++ b/linux/blitztext/gtksettings.py
@@ -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()
diff --git a/linux/blitztext/routing.py b/linux/blitztext/routing.py
index 7333ce1..b05d84e 100644
--- a/linux/blitztext/routing.py
+++ b/linux/blitztext/routing.py
@@ -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.
diff --git a/linux/tests/test_voice_cancel.py b/linux/tests/test_voice_cancel.py
new file mode 100644
index 0000000..5ff2c5e
--- /dev/null
+++ b/linux/tests/test_voice_cancel.py
@@ -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"