#!/usr/bin/env python3 """Pre-roll buffer: the audio just before a wake fire is preserved (issue #30). stream_detect_wake consumes every frame up to and including the one whose score crosses threshold, and there is detection latency, so when a request is spoken with no pause after the wake word the first fraction of the request is consumed during detection and lost. The fix keeps a small ring buffer of the most recent frames in the detector and prepends it to the captured request. These checks are model-free and deterministic: they exercise the stream_detect_wake -> capture_request handoff with synthetic frames and a tiny fake model, so they run fast with no wake model and no whisper. The end-to-end "first word survives transcription" proof depends on the real wake-detection latency for a given mic and is a hardware voice-test; here we prove the plumbing that makes it possible -- that frames consumed during detection are kept and prepended, and that an abandoned wake (no speech) still captures nothing. Run: .venv/bin/python test_preroll.py Exit code is 0 only if every check passes. """ from __future__ import annotations import collections import sys from pathlib import Path import numpy as np import pipeline from pipeline import FRAME_SIZE TEST_DIR = Path(__file__).resolve().parent / "test_audio" TEST_DIR.mkdir(exist_ok=True) PASS, FAIL = "PASS", "FAIL" results: list[tuple[str, str]] = [] def check(name: str, ok: bool, detail: str) -> bool: results.append((PASS if ok else FAIL, name)) print(f" [{PASS if ok else FAIL}] {name}: {detail}") return ok def frame(value: int) -> np.ndarray: """A constant int16 frame whose fill value tags it for identity checks.""" return np.full(FRAME_SIZE, value, dtype=np.int16) def loud(n: int) -> list[np.ndarray]: return [np.full(FRAME_SIZE, 4000, dtype=np.int16) for _ in range(n)] def silent(n: int) -> list[np.ndarray]: return [np.zeros(FRAME_SIZE, dtype=np.int16) for _ in range(n)] def nframes(pcm: np.ndarray) -> int: return len(pcm) // FRAME_SIZE class _FakePreproc: """The preprocessor surface _reset_oww touches, nothing more.""" def __init__(self): self.melspectrogram_buffer = np.zeros(1, dtype=np.float32) self.feature_buffer = np.zeros(1, dtype=np.float32) self.raw_data_buffer = collections.deque() self.accumulated_samples = 0 class _FiringModel: """Scores 0 until the fire_on-th predict, then 1.0. Carries just enough surface for _reset_oww, so stream_detect_wake can reset and drive it like a real model without loading onnxruntime.""" def __init__(self, fire_on: int): self.fire_on = fire_on self.calls = 0 self.reset_calls = 0 self.preprocessor = _FakePreproc() def reset(self): self.reset_calls += 1 def predict(self, frame_in): self.calls += 1 return {"w": 1.0 if self.calls >= self.fire_on else 0.0} def main() -> int: # ----- detection audio keeps separate bounded views ------------------- # print("=== detection audio keeps short pre-roll and bounded recovery history ===") wake_audio = pipeline.WakeAudioBuffer() wake_audio.extend(frame(i) for i in range(pipeline._WAKE_HISTORY_FRAMES + 5)) check( "normal pre-roll stays at its tuned two-frame size", len(wake_audio.preroll) == pipeline._PREROLL_FRAMES, f"len={len(wake_audio.preroll)} want {pipeline._PREROLL_FRAMES}", ) check( "recovery history is independently bounded and ends at the same frame", len(wake_audio.history) == pipeline._WAKE_HISTORY_FRAMES and int(wake_audio.history[-1][0]) == int(wake_audio.preroll[-1][0]), f"history={len(wake_audio.history)} last={int(wake_audio.history[-1][0])}", ) wake_audio.clear() check( "a capture boundary clears both stale detection views", not wake_audio.preroll and not wake_audio.history, f"preroll={len(wake_audio.preroll)} history={len(wake_audio.history)}", ) # ----- stream_detect_wake keeps a pre-roll ring buffer ----------------- # print("\n=== stream_detect_wake fills a caller-owned pre-roll buffer ===") n = pipeline._PREROLL_FRAMES fire_on = n + 5 # fire well after the ring buffer has filled model = _FiringModel(fire_on=fire_on) frames = iter([frame(i) for i in range(fire_on + 10)]) preroll = collections.deque(maxlen=n) score = pipeline.stream_detect_wake(frames, model, 0.5, preroll=preroll) fired_frame_value = ( fire_on - 1 ) # 0-based: the fire_on-th predict is frame index fire_on-1 check("detector fires and returns the peak score", score == 1.0, f"score={score}") check( "pre-roll holds exactly _PREROLL_FRAMES recent frames", len(preroll) == n, f"len={len(preroll)} want {n}", ) check( "pre-roll ends with the firing frame (the consumed-during-detection frame)", len(preroll) > 0 and int(preroll[-1][0]) == fired_frame_value, f"last pre-roll frame value={int(preroll[-1][0]) if preroll else None} " f"want {fired_frame_value}", ) check( "pre-roll starts _PREROLL_FRAMES back from the firing frame", len(preroll) > 0 and int(preroll[0][0]) == fired_frame_value - n + 1, f"first pre-roll frame value={int(preroll[0][0]) if preroll else None} " f"want {fired_frame_value - n + 1}", ) # ----- stream_detect_wake resets the warm model once, not per frame ---- # # Issue #9 AC #2: a continuous stream reuses the warm model with a SINGLE reset, # unlike detect_wake's per-clip reset+pad. The reset is what separates the two # paths; if _reset_oww ever moved inside stream_detect_wake's frame loop, the # model's ~2 s context would be wiped every 80 ms and the live wake path would # silently stop firing. Feed a long non-firing stream and pin reset==1 while # predict still runs once per frame. print("\n=== stream_detect_wake resets the model once for the whole stream ===") n_frames = 25 warm = _FiringModel(fire_on=10_000) # threshold never reached within the stream none_score = pipeline.stream_detect_wake( iter([frame(i) for i in range(n_frames)]), warm, 0.5 ) check("a non-firing stream returns None", none_score is None, f"score={none_score}") check( "the warm model is reset exactly once for the whole stream, not per frame", warm.reset_calls == 1, f"reset_calls={warm.reset_calls} want 1", ) check( "every frame is scored against that one warm model", warm.calls == n_frames, f"predict calls={warm.calls} want {n_frames}", ) # ----- capture_request prepends the pre-roll seed ---------------------- # print("\n=== capture_request prepends a pre-roll seed ===") seed = [frame(9), frame(9), frame(9)] cap = pipeline.capture_request(iter(loud(5) + silent(15)), preroll=seed) # Without pre-roll this is 5 speech + 10 trailing-silence endpoint = 15 frames; # the 3 seed frames go in front, so 18. check( "captured request includes the pre-roll frames", nframes(cap) == 18, f"got {nframes(cap)} frames, want 18 (3 seed + 15)", ) check( "pre-roll frames sit at the very front, in order", cap.size >= 3 * FRAME_SIZE and np.array_equal(cap[: 3 * FRAME_SIZE], np.concatenate(seed)), "first 3 frames equal the seed", ) # The endpoint/onset decision must ignore the seed: an abandoned wake (a wake # with no speech after it) still returns empty, so the wake word held in the # pre-roll is never transcribed as a phantom request. cap_empty = pipeline.capture_request(iter(silent(60)), preroll=loud(3)) check( "abandoned wake stays empty even with a pre-roll seed", cap_empty.size == 0, f"got {cap_empty.size} samples, want 0", ) # A transient after the wake -- a click, a cough edge, a speaker echo -- is a frame # or two of energy, not speech. It must not count as speech onset: otherwise a # wake-only turn would prepend its pre-roll (which holds the wake word) and whisper # could decode a confident phantom command the mishear guard cannot catch. Onset # requires a sustained run of speech frames, so a transient leaves the wake abandoned # and empty (energy gate is not a speech gate -- found reviewing #54). for k in (1, 2): cap_transient = pipeline.capture_request( iter(loud(k) + silent(30)), preroll=loud(3) ) check( f"a {k}-frame transient after the wake stays empty (no phantom command)", cap_transient.size == 0, f"got {cap_transient.size} samples, want 0", ) # The fix must not over-reject: a short but sustained command (3 frames) still # captures, with its pre-roll seed prepended. cap_short = pipeline.capture_request(iter(loud(3) + silent(15)), preroll=loud(2)) check( "a sustained 3-frame command still captures with its pre-roll", nframes(cap_short) == 15, f"got {nframes(cap_short)} frames, want 15 (2 seed + 3 + 10)", ) # A pre-roll of None or [] behaves exactly like the original (no prepend). cap_none = pipeline.capture_request(iter(loud(5) + silent(15)), preroll=None) check( "no pre-roll behaves like the original capture", nframes(cap_none) == 15, f"got {nframes(cap_none)} frames, want 15", ) # ----- capture_request confirms speech (VAD) before prepending --------- # # The energy onset rejects a 1-2 frame transient, but a *sustained* >=240 ms noise # (a long cough, a fan spinning up, room rumble) clears the energy gate exactly like # speech does -- energy cannot tell them apart. When a vad_threshold is supplied, # capture_request runs voice-activity detection over the POST-FIRE audio only and # keeps the pre-roll (which holds the wake word) only if real speech is confirmed, # closing the phantom path the energy gate leaves open (#54). The VAD verdict is # faked here so the check stays model-free; the real-model proof is test_vad_gate.py. print( "\n=== capture_request confirms speech (VAD) before prepending the pre-roll ===" ) real_confirm = pipeline._confirm_speech try: pipeline._confirm_speech = lambda pcm, threshold: True cap_ok = pipeline.capture_request( iter(loud(5) + silent(15)), preroll=loud(2), vad_threshold=0.5 ) check( "confirmed speech captures and keeps its pre-roll", nframes(cap_ok) == 17, f"got {nframes(cap_ok)} frames, want 17 (2 seed + 5 + 10)", ) pipeline._confirm_speech = lambda pcm, threshold: False cap_noise = pipeline.capture_request( iter(loud(5) + silent(15)), preroll=loud(2), vad_threshold=0.5 ) check( "sustained noise the VAD rejects stays empty (no phantom command)", cap_noise.size == 0 and cap_noise.empty_reason == pipeline._EMPTY_VAD_REJECTED, f"got {cap_noise.size} samples, reason={cap_noise.empty_reason!r}", ) # The crux: the gate must run on the command audio ONLY, never the pre-roll. The # pre-roll holds the wake word, which IS speech, so feeding it to the VAD would # always-confirm and defeat the gate. seen = {} pipeline._confirm_speech = lambda pcm, threshold: ( seen.update(n=len(pcm)) or True ) pipeline.capture_request( iter(loud(5) + silent(15)), preroll=loud(2), vad_threshold=0.5 ) check( "VAD sees only the post-fire command audio, not the pre-roll", seen.get("n") == 15 * FRAME_SIZE, f"VAD saw {seen.get('n')} samples, want {15 * FRAME_SIZE} (5 speech + 10 endpoint, no pre-roll)", ) # vad_threshold=None (the default) skips the gate entirely, so the energy-only # callers above keep their existing behavior. pipeline._confirm_speech = lambda pcm, threshold: ( False ) # would reject if consulted cap_skip = pipeline.capture_request(iter(loud(5) + silent(15)), preroll=loud(2)) check( "no vad_threshold skips the VAD gate (energy-only path unchanged)", nframes(cap_skip) == 17, f"got {nframes(cap_skip)} frames, want 17", ) finally: pipeline._confirm_speech = real_confirm # ----- run_turn wires detection pre-roll into capture ------------------ # print("\n=== run_turn threads the detection pre-roll into capture_request ===") seen = {"preroll": "unset"} real = ( pipeline.stream_detect_wake, pipeline.capture_request, pipeline.transcribe_detailed, pipeline.guard_transcript, pipeline.brain, pipeline.speak, pipeline._get_oww_model, pipeline._resolve_wake_path, ) def fake_detect(frames, model, threshold, preroll=None): if preroll is not None: preroll.extend(loud(2)) # two frames "consumed during detection" return 0.9 def fake_capture(frames, preroll=None, vad_threshold=None, **_): seen["preroll"] = preroll return np.full(4 * FRAME_SIZE, 4000, dtype=np.int16) pipeline._resolve_wake_path = lambda name: "x" pipeline._get_oww_model = lambda path: object() pipeline.stream_detect_wake = fake_detect pipeline.capture_request = fake_capture pipeline.transcribe_detailed = lambda p: pipeline.Transcript( "file an issue", 0.0, 0.0 ) pipeline.guard_transcript = lambda heard, cfg: (True, "") pipeline.brain = lambda t, **_: "done" pipeline.speak = lambda text, out: out try: r = pipeline.run_turn( iter(loud(1)), model_name="x", threshold=0.5, out_wav_path=str(TEST_DIR / "preroll_reply.wav"), ) finally: ( pipeline.stream_detect_wake, pipeline.capture_request, pipeline.transcribe_detailed, pipeline.guard_transcript, pipeline.brain, pipeline.speak, pipeline._get_oww_model, pipeline._resolve_wake_path, ) = real got = seen["preroll"] check( "run_turn passes the frames gathered during detection to capture_request", r is not None and got not in ("unset", None) and len(got) == 2, f"capture_request saw preroll={('unset' if got == 'unset' else (None if got is None else len(got)))}", ) # ----- fully consumed short commands recover without phantom dispatch -- # print("\n=== empty post-fire capture recovers only wake-prefixed commands ===") real = ( pipeline.stream_detect_wake, pipeline.capture_request, pipeline.transcribe_detailed, pipeline.guard_transcript, pipeline.brain, pipeline.speak, pipeline._get_oww_model, pipeline._resolve_wake_path, ) state = {"heard": None, "brain_arg": None} def consumed_detect(frames, model, threshold, preroll=None): if preroll is not None: preroll.extend([frame(i) for i in range(30)]) return 0.9 capture_reason = {"value": pipeline._EMPTY_NO_ONSET} def empty_capture(frames, **_): return pipeline._empty_capture(capture_reason["value"]) pipeline._resolve_wake_path = lambda name: "x" pipeline._get_oww_model = lambda path: object() pipeline.stream_detect_wake = consumed_detect pipeline.capture_request = empty_capture pipeline.guard_transcript = lambda heard, cfg: (True, "") pipeline.brain = lambda text, **_: state.update(brain_arg=text) or "done" pipeline.speak = lambda text, out: out try: pipeline.transcribe_detailed = lambda pcm: pipeline.Transcript( "Computer, stop.", -0.2, 0.01 ) recovered = pipeline.run_turn( iter([]), model_name="computah", threshold=0.5, out_wav_path=str(TEST_DIR / "short_command_reply.wav"), ) check( "a fully consumed no-pause command is stripped and dispatched", recovered is not None and recovered["transcript"] == "stop." and state["brain_arg"] == "stop.", f"result={recovered!r} brain_arg={state['brain_arg']!r}", ) for transcript in ("Computer.", "please stop"): state["brain_arg"] = None pipeline.transcribe_detailed = lambda pcm, text=transcript: ( pipeline.Transcript(text, -0.2, 0.01) ) rejected = pipeline.run_turn( iter([]), model_name="computah", threshold=0.5, out_wav_path=str(TEST_DIR / "short_command_reply.wav"), ) check( f"{transcript!r} dispatches nothing after an empty capture", rejected is None and state["brain_arg"] is None, f"result={rejected!r} brain_arg={state['brain_arg']!r}", ) # Friendly wake names can legitimately contain `_v`; that is not a model-file # version delimiter here. Truncating `hey_vera` to `hey` would let the false # transcript below pass the prefix check and dispatch a destructive command. state["brain_arg"] = None pipeline.transcribe_detailed = lambda pcm: pipeline.Transcript( "Hey delete everything", -0.2, 0.01 ) rejected = pipeline.run_turn( iter([]), model_name="hey_vera", threshold=0.5, out_wav_path=str(TEST_DIR / "short_command_reply.wav"), ) check( "a custom `_v` wake name is not truncated into a weaker false-wake prefix", rejected is None and state["brain_arg"] is None, f"result={rejected!r} brain_arg={state['brain_arg']!r}", ) pipeline.transcribe_detailed = lambda pcm: pipeline.Transcript( "Hey Vera, stop.", -0.2, 0.01 ) recovered = pipeline.run_turn( iter([]), model_name="hey_vera", threshold=0.5, out_wav_path=str(TEST_DIR / "short_command_reply.wav"), ) check( "the complete custom `_v` wake phrase still recovers its command", recovered is not None and state["brain_arg"] == "stop.", f"result={recovered!r} brain_arg={state['brain_arg']!r}", ) capture_reason["value"] = pipeline._EMPTY_VAD_REJECTED state["brain_arg"] = None pipeline.transcribe_detailed = lambda pcm: pipeline.Transcript( "Computer, delete everything.", -0.2, 0.01 ) rejected = pipeline.run_turn( iter([]), model_name="computah", threshold=0.5, out_wav_path=str(TEST_DIR / "short_command_reply.wav"), ) check( "VAD-rejected post-wake noise never enters short-command recovery", rejected is None and state["brain_arg"] is None, f"result={rejected!r} brain_arg={state['brain_arg']!r}", ) finally: ( pipeline.stream_detect_wake, pipeline.capture_request, pipeline.transcribe_detailed, pipeline.guard_transcript, pipeline.brain, pipeline.speak, pipeline._get_oww_model, pipeline._resolve_wake_path, ) = real n_pass = sum(1 for x in results if x[0] == PASS) n_total = len(results) print(f"\n=== {n_pass}/{n_total} checks passed ===") return 0 if n_pass == n_total else 1 if __name__ == "__main__": sys.exit(main())