diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 6dd98e8..2b658c3 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] +## [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 diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index b8eea89..dd4f01e 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__ = "2.03.28" +__version__ = "2.03.29" diff --git a/linux/blitztext/audio.py b/linux/blitztext/audio.py index ddc800d..addaf7c 100644 --- a/linux/blitztext/audio.py +++ b/linux/blitztext/audio.py @@ -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: diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 76d58e1..3cdbf10 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -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(" 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. diff --git a/linux/blitztext/transcribe.py b/linux/blitztext/transcribe.py index e0a9271..b70dc19 100644 --- a/linux/blitztext/transcribe.py +++ b/linux/blitztext/transcribe.py @@ -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.