The Free Encyclopedia

Custom Wake Word with openWakeWord

Revision as of Jun 25, 2026 23:03 by migration.

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.

Why it's so small Because the heavy feature extraction lives in the frozen shared models, the trainable head is minuscule. The exported 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
One Model per connection — never share openWakeWord keeps stateful per-stream audio history inside each 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.

Tuning the threshold, not the model If the detector false-fires on background speech, raise 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-api shows the embedding ONNX downloading into ~/.cache/openwakeword on 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.