feat: real-time cancel keyword detection during wakeword recording (v2.03.29)

CancelWatcher accumulates raw PCM from the VAD LevelMeter (via new
on_chunk callback) and runs a fast beam_size=1 transcription check every
~0.6s. When a cancel keyword is detected it immediately calls
cancel_dictation() without waiting for the silence timer to expire or
a full transcription to complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 11:47:10 +02:00
parent 1c276e767b
commit a77b15ccaf
5 changed files with 138 additions and 5 deletions

View File

@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased]
## [2.03.29] - 2026-06-10
### Changed
- **Cancel keywords now fire immediately during wakeword recording.** A
real-time `_CancelWatcher` accumulates raw PCM from the VAD level-meter,
then every ~0.6 s of new audio runs a fast `beam_size=1` local transcription
pass to check for cancel keywords. When one is found it calls
`cancel_dictation()` instantly — no waiting for the silence timer or a full
transcription of the whole clip. Falls back to the existing post-transcription
check if no local transcriber is loaded or no cancel keywords are configured.
## [2.03.28] - 2026-06-10
### Fixed

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__ = "2.03.28"
__version__ = "2.03.29"

View File

@ -72,9 +72,10 @@ class LevelMeter:
False if no recorder is available or the device can't be opened.
"""
def __init__(self, device: str = "", on_level=None, recorder: str = "auto"):
def __init__(self, device: str = "", on_level=None, recorder: str = "auto", on_chunk=None):
self.device = device or ""
self.on_level = on_level
self.on_chunk = on_chunk # optional: called with raw s16le PCM bytes each chunk
self._recorder = recorder
self._proc: subprocess.Popen | None = None
self._thread: threading.Thread | None = None
@ -117,6 +118,8 @@ class LevelMeter:
chunk = proc.stdout.read(_CHUNK_BYTES)
if not chunk:
break
if self.on_chunk:
self.on_chunk(chunk)
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0
level = float(np.sqrt(np.mean(np.square(samples)))) if samples.size else 0.0
if self.on_level:

View File

@ -34,6 +34,102 @@ StatusCallback = Callable[[str, str | None, str], None]
# stop speaking.
_VAD_COUNTDOWN_GRACE = 0.35
# Cancel-watcher: accumulate this much audio before the first check, then
# re-check every time this many new bytes arrive. 3200 bytes = 100 ms at
# 16 kHz s16le mono. 0.8 s min avoids false positives on the very first chunk;
# 0.6 s poll keeps latency low without hammering the transcriber.
_CANCEL_MIN_BYTES = int(0.8 * 16000 * 2) # 25600
_CANCEL_POLL_BYTES = int(0.6 * 16000 * 2) # 19200
def _pcm_to_wav(path: str, pcm: bytes) -> None:
"""Write raw s16le 16 kHz mono PCM bytes as a minimal RIFF WAV."""
import struct
data_len = len(pcm)
with open(path, "wb") as f:
f.write(b"RIFF")
f.write(struct.pack("<I", 36 + data_len))
f.write(b"WAVE")
f.write(b"fmt ")
f.write(struct.pack("<IHHIIHH", 16, 1, 1, 16000, 32000, 2, 16))
f.write(b"data")
f.write(struct.pack("<I", data_len))
f.write(pcm)
class _CancelWatcher:
"""Listens to in-progress VAD audio and triggers cancel if a keyword is heard.
PCM chunks (raw s16le 16 kHz mono) are fed via :meth:`feed` from the VAD
level-meter thread. Every _CANCEL_POLL_BYTES of *new* audio (after an
initial _CANCEL_MIN_BYTES warm-up), a fast beam_size=1 transcription of the
accumulated buffer runs in a background thread. If a cancel keyword is
found, the supplied ``on_cancel`` callback fires once and the watcher stops.
"""
def __init__(self, transcriber, cancel_keywords, language, threshold, on_cancel):
self._transcriber = transcriber
self._keywords = cancel_keywords
self._language = language
self._threshold = threshold
self._on_cancel = on_cancel
self._buf = bytearray()
self._new_bytes = 0
self._active = True
self._lock = threading.Lock()
self._checking = False # prevents overlapping check threads
def feed(self, chunk: bytes) -> None:
if not self._active:
return
with self._lock:
if not self._active:
return
self._buf.extend(chunk)
self._new_bytes += len(chunk)
ready = (len(self._buf) >= _CANCEL_MIN_BYTES
and self._new_bytes >= _CANCEL_POLL_BYTES
and not self._checking)
if ready:
self._new_bytes = 0
self._checking = True
snapshot = bytes(self._buf)
if ready:
threading.Thread(target=self._check, args=(snapshot,), daemon=True,
name="CancelWatcher").start()
def stop(self) -> None:
with self._lock:
self._active = False
def _check(self, audio: bytes) -> None:
import os
import tempfile
from pathlib import Path
fd, tmp = tempfile.mkstemp(prefix="bt-cw-", suffix=".wav")
try:
os.close(fd)
_pcm_to_wav(tmp, audio)
text = self._transcriber.transcribe(
Path(tmp), language=self._language, beam_size=1)
with self._lock:
if not self._active:
return
kw = is_cancel(text, self._keywords, threshold=self._threshold)
if kw:
with self._lock:
if not self._active:
return
self._active = False
log(f'[cancel-watcher] “{kw}” heard live — cancelling immediately.')
self._on_cancel()
except Exception: # noqa: BLE001 — watcher must never crash the daemon
pass
finally:
Path(tmp).unlink(missing_ok=True)
with self._lock:
self._checking = False
class Daemon:
def __init__(self, cfg: Config, status_cb: StatusCallback | None = None,
@ -219,7 +315,24 @@ class Daemon:
elif self.countdown_cb:
self.countdown_cb(None, silence)
self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level, recorder=self.recorder_name)
# Real-time cancel keyword watcher: accumulate PCM from the VAD meter
# and run quick transcription checks so cancel fires immediately rather
# than waiting for the full silence timeout + normal transcription pass.
on_chunk = None
if self.cfg.cancel_keywords and getattr(self, "transcriber", None) is not None:
self._cancel_watcher = _CancelWatcher(
self.transcriber,
self.cfg.cancel_keywords,
self.cfg.language,
self.cfg.routing_threshold,
on_cancel=lambda: GLib.idle_add(self.cancel_dictation),
)
on_chunk = self._cancel_watcher.feed
else:
self._cancel_watcher = None
self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level,
recorder=self.recorder_name, on_chunk=on_chunk)
ok = self._vad_meter.start()
# Safety net: if the LevelMeter fails to open the mic (e.g. device busy
@ -240,6 +353,9 @@ class Daemon:
if getattr(self, '_vad_meter', None) is not None:
self._vad_meter.stop()
self._vad_meter = None
if getattr(self, "_cancel_watcher", None) is not None:
self._cancel_watcher.stop()
self._cancel_watcher = None
def _ov_meter_start(self) -> None:
"""A level meter purely to drive the overlay waveform in streaming mode.

View File

@ -54,8 +54,11 @@ class Transcriber:
log(f"{dev} unavailable ({exc}); trying next device")
raise RuntimeError(f"Failed to load Whisper model '{model}': {last_err}")
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "") -> str:
kwargs = dict(language=language or None, beam_size=self.beam_size, vad_filter=True)
def transcribe(self, audio_path: Path, language: str = "", hotwords: str = "",
beam_size: int | None = None) -> str:
kwargs = dict(language=language or None,
beam_size=beam_size if beam_size is not None else self.beam_size,
vad_filter=True)
if hotwords:
# Bias recognition toward the routing keywords so they transcribe
# reliably. Older faster-whisper builds lack `hotwords`; fall back.