A wake word is the always-on front door to a voice stack: it runs cheaply on every audio frame and only wakes the expensive pipeline behind it once it hears its name. theLAB's name is "hey addie", and we built the detector with openWakeWord — a small classifier trained on top of frozen audio-embedding models. The output is a ~15 KB .onnx (plus a .tflite) that we serve over a WebSocket and hand off to Parakeet ASR.
This article covers both halves: producing hey_addie.onnx from a pile of utterances, and the FastAPI service (server.py) that streams 80 ms audio frames from the browser and fires on detection. It's one piece of the larger fully local voice stack.
01How openWakeWord training works
You never train the audio model itself. openWakeWord ships pre-trained melspectrogram and embedding ONNX models, freezes them, and trains a tiny classifier head on their output. To make a new wake word you supply positives — the target phrase spoken many ways — and lean on the bundled negatives (general speech, noise, and music) so the head learns to reject everything that isn't your phrase.
hey_addie.onnx is about 15 KB — small enough to run on every incoming frame with no measurable CPU cost.
02Build the positives
Quantity and variety of positives is what makes a wake word robust. For hey_addie we generated hundreds of synthetic utterances with Kokoro TTS across many voices — different pitches, accents, and speaking rates — then added ~30 real microphone recordings of the target speaker to anchor the model to the actual person and room. The synthetic set gives breadth; the real recordings keep it from drifting toward a generic TTS timbre. (Kokoro is the same engine behind our OmniVoice TTS setup.)
Training consumes the positives and the bundled negatives and writes out both formats:
# positives → frozen-embedding head → tiny classifier
# outputs: hey_addie.onnx (~15 KB) and hey_addie.tflite
03Serve it over a WebSocket
The service is a FastAPI app exposing a /ws endpoint. The browser captures microphone audio, downsamples to 16 kHz mono Int16 PCM, and ships it as binary frames of 1280 samples (80 ms each). We load the model with the openwakeword library pointed at our ONNX file.
from openwakeword import Model
oww = Model(
wakeword_models=["models/hey_addie.onnx"],
inference_framework="onnx",
)
# first Model() call lazy-downloads the melspectrogram + embedding
# ONNX into ~/.cache/openwakeword
Model instance. If two WebSocket clients share one instance, their frames interleave and the scores corrupt each other. Construct a fresh Model(...) inside the connection handler so every socket gets its own.
04The detection loop
oww.predict(frame) returns a per-word score dict; we take the max. When it crosses WAKE_THRESHOLD (default 0.5, env-tunable), we fire and start capturing speech until the user goes quiet, then hand the buffer to ASR.
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
SILENCE_FRAMES_NEEDED = 12 # ~960 ms quiet ends capture (12 * 80ms)
COOLDOWN_FRAMES = 25 # ~2 s lockout to avoid re-fire
@app.websocket("/ws")
async def ws(socket):
await socket.accept()
oww = Model(wakeword_models=[MODEL_PATH],
inference_framework="onnx") # per-connection
while True:
frame = await socket.receive_bytes() # 1280 Int16 = 80 ms
pcm = np.frombuffer(frame, dtype=np.int16)
score = max(oww.predict(pcm).values())
if score >= WAKE_THRESHOLD:
oww.reset() # clear history
await capture_until_silence(socket) # → Parakeet
After a detection we call oww.reset() to wipe the stateful history so the next utterance starts clean, then enforce COOLDOWN_FRAMES (~2 s) before the detector is allowed to fire again.
WAKE_THRESHOLD toward 0.6–0.7. If it misses the wake word, lower it toward 0.4. The threshold is a runtime knob — you do not need to retrain the model to change it. Only retrain when you're adding new voices or a new room the positives never covered.
05Run it as a service
The whole thing runs as a systemd unit, wakeword-api.service, so it comes back on boot and restarts on crash. On wake, the captured 16 kHz buffer goes straight to Parakeet for transcription downstream.
# /etc/systemd/system/wakeword-api.service (excerpt)
ExecStart=/usr/bin/python3 -m uvicorn server:app --host 0.0.0.0 --port 8090
Verify it works
journalctl -u wakeword-apishows the embedding ONNX downloading into~/.cache/openwakewordon first start.- Say "hey addie" at a normal distance — the score should jump well past 0.5 and the capture should begin.
- Talk about Addie without addressing her; scores should stay low. If they don't, nudge the threshold up.