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:
parent
1c276e767b
commit
a77b15ccaf
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user