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"