Back to DevLog

Wake Word Detection with Vosk Keyphrase Spotting on Windows

4 min read

I added hands-free wake word listening to Foreman using Vosk keyphrase spotting. The wake phrase is "hey foreman" by default and derives from a new wake_phrase setting, separate from the assistant name.

The detector boundary

I isolated the detector behind a small interface with three methods: phrase, reset(), and accept(pcm) -> bool. The swap point is _make_detector() in tools/wake.py. This means a dedicated acoustic wake model can replace Vosk later without touching the mic capture pipeline.

Vosk over openWakeWord

I chose Vosk because both the toolkit and the vosk-model-small-en-us-0.15 model are Apache 2.0 licensed. openWakeWord's code is also Apache 2.0, but its bundled pre-trained models are CC BY-NC-SA, which blocks commercial use.

One-breath wake commands

The initial implementation had a dead-mic window of 1.5 to 2.5 seconds between wake detection and the command mic opening. The stream closed after detection, then play_ack() blocked on audio playback, then Kokoro paid its ONNX cold start cost, then a 200ms settle, then a second stream opened. Any command spoken in one continuous breath was lost if the speaker paused before that window closed.

I rewrote the wake-to-capture handoff around one invariant: from the moment of wake, audio is always buffered until either a command completes or the ack path is chosen. The stream that heard the wake phrase stays open and runs straight into capture_after_wake(). Three cases now work: continuous speech becomes a buffered command with no spoken ack, a pause under 1.75 seconds keeps the buffer open and resumed speech joins the same capture, and real silence closes the mic so Foreman can speak before opening a fresh capture_utterance().

The acknowledgment tone fires non-blocking at the detector hit via start_ack_tone(). The spoken ack only fires on the silence path.

Deterministic reminder resolution

The router initially resolved reminder due times by calculating dates itself. Haiku reproducibly miscounted weekdays even with the correct date injected in the prompt. It resolved "Friday" from Thursday 2026-07-30 to 2026-08-01, a Saturday, in three out of three attempts.

I moved all calendar math into Python. The set_reminder tool now only extracts day and time words from the user's request. The tool description explicitly says "Do NOT calculate any date" and "copied not calculated." A new tools/when.py module does the actual resolution with resolve_due(day, time, now), which is pure and takes now as an argument so tests can pin a fixed moment.

Weekdays resolve to the next occurrence. Today counts only if the resolved time has not passed, otherwise it adds seven days. Morning defaults to 09:00, afternoon to 14:00, evening to 18:00. A bare single-digit hour of 1 through 6 with no am/pm resolves to PM, because the router extracted time: "2:30" for "tomorrow at 2:30" in live testing. A leading zero keeps 24-hour notation literal.

Dual VAD for onset gating

I added a Silero ONNX VAD running on a worker thread as a confirm stage behind the cheap RMS gate. It votes on speech onset only. Mid-command it is never consulted and end-of-utterance stays on the RMS energy rule, because a mid-command veto would let the silence counter climb and truncate the command.

I run Silero through onnxruntime directly on the model file in models/, not the silero-vad pip package. That package pulls torch and torchaudio, roughly 2 GB of dependencies for a 1.3 MB model. onnxruntime is already present via kokoro-onnx.

Silero v5+ ONNX needs the previous 64 samples prepended for a 576-sample input. The wrong shape does not raise an error. It silently returns approximately 0.001 for everything, including clear speech.

VAD warmup and dynamic pre-roll

WakeListener.warm_up() runs the detection path on silence before the mic arms. I measured 625ms for the first block cold versus 1.6ms after warmup in fresh processes.

The pre-roll now walks back from the wake instant to a stable silence boundary instead of using a fixed 200ms trim. It falls back to the old fixed trim when there is no boundary. The adaptive silence floor is clamped against the loudest block, because without that clamp quietest * 2.5 can exceed the speech level itself on a uniform-speech buffer and one continuous breath classifies as all-silence.

Attribution

Three techniques came from studying RealtimeSTT under its MIT license: the dual-VAD onset gating pattern, the walk-back pre-roll selection, and the warmup discipline. Each has an attribution comment at its definition in tools/wake.py.

RealtimeSTT itself has no voice barge-in, echo cancellation, or output-aware VAD. Its own interruptible example uses the spacebar. The one working barge-in in that ecosystem is RealtimeVoiceChat, which works because the browser sets echoCancellation: true in getUserMedia. The Python side never sees the echo.

Share this post