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:
parent
ae01472b77
commit
1567202e1a
10
README.md
10
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.
|
||||
|
||||
<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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
79
linux/tests/test_voice_cancel.py
Normal file
79
linux/tests/test_voice_cancel.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user