curry-leaves β open wake word model (6 words)
A small ONNX wake word detector that runs in realtime on CPU and listens for six wake words at once:
buddy boy Β· hey curry Β· hey assistant Β· hey buddy Β· hey clone Β· hey dude
One 804 KB model (207 KB quantized) detects all six and tells you which one
was spoken. Trained entirely on synthetic speech (macOS say, 21 voices Γ
4 speaking rates) with heavy augmentation.
β οΈ No human voice was used in training. The metrics below are synthetic voices testing a model trained on synthetic voices β treat them as an upper bound and read Limitations before deploying.
The six wake words
| # | wake word | notes |
|---|---|---|
| 0 | buddy boy | most distinct start (no "hey") |
| 1 | hey curry | |
| 2 | hey assistant | easiest to detect β long, distinct second word |
| 3 | hey buddy | occasionally confused with buddy boy (0.6%) |
| 4 | hey clone | |
| 5 | hey dude | shortest |
Five of six share the "hey" prefix, so the model discriminates almost
entirely on the second word. The output order above is the index order in
multi.onnx.json β the logit vector is [buddy boy, hey curry, hey assistant, hey buddy, hey clone, hey dude].
How it works
Internally the model is three stages, but they are fused into one file so you never see the seams:
audio 16 kHz mono
β
ββ melspectrogram raw audio β [frames, 32] mel bins
ββ speech embedding β [n, 96] (pretrained on 1000s of hours)
ββ classifier β [n, 6] logits, one per wake word
(only this stage was trained here β 205k params)
The frozen embedding stage β pretrained on thousands of hours β maps 76 mel
frames (~0.76 s) to 96 numbers that capture phonetic content while
discarding speaker identity and channel noise. That pretrained front end is
what makes training a usable classifier on a few thousand synthetic clips
feasible at all. It comes from
openWakeWord; everything is fused
into wakeword_allinone.onnx, verified byte-for-byte identical to running the
stages separately.
The six outputs are independent sigmoids, not a softmax: almost all audio is none of the six, and outputs forced to sum to 1 cannot express "none of the above."
Files in this repo
wakeword_allinone.onnx the model: raw 16 kHz audio β 6 scores 3.1 MB
multi.onnx.json thresholds + word order β REQUIRED, tiny
test_model.py self-test, file scoring, and live mic
README.md this file
The model takes raw 16 kHz mono audio and outputs one score per wake word. No feature extraction, chaining, or preprocessing on your side.
multi.onnx.json is never optional. An ONNX graph has nowhere to carry
detection thresholds or the word order, and the model is unusable without it.
Quick start
Only onnxruntime, numpy, and soundfile are needed β no PyTorch.
pip install onnxruntime numpy soundfile
The all-in-one model takes raw audio and returns the six scores. It expects exactly 31,840 samples (1.99 s) of 16 kHz mono audio, so pad or trim to that length:
import json
import numpy as np
import onnxruntime as ort
import soundfile as sf
model = ort.InferenceSession("wakeword_allinone.onnx",
providers=["CPUExecutionProvider"])
meta = json.load(open("multi.onnx.json"))
WORDS = meta["words"]
THRESHOLDS = np.array(meta["thresholds"], dtype=np.float32)
N_SAMPLES = model.get_inputs()[0].shape[1] # 31840
LEAD = 5_600 # 0.35 s of silence up front
def detect(path):
"""Return the detected wake word, or None."""
audio, sr = sf.read(path, dtype="float32")
assert sr == 16_000, "resample to 16 kHz first"
if audio.ndim > 1:
audio = audio.mean(axis=1)
# Place the clip 0.35 s in β the offset the model was trained on.
buf = np.zeros(N_SAMPLES, dtype=np.float32)
room = N_SAMPLES - LEAD
buf[LEAD : LEAD + min(len(audio), room)] = audio[:room]
logits = model.run(None, {model.get_inputs()[0].name: buf[None]})[0][0]
over = logits - THRESHOLDS
return WORDS[int(over.argmax())] if (over >= 0).any() else None
print(detect("test.wav")) # -> "hey dude"
That is the whole thing. One model, audio in, word out.
Two things that will otherwise trip you up
1. Use the 0.35 s lead-in. The model was trained with the phrase placed
~0.35 s into the window. Centre the clip instead and it scores below
threshold β a spoken word gives β39.8 instead of +25.9, and you get None
with no error. This is the single most common way to think the model is broken
when it is not.
2. Compare logits, not probabilities. The scores are raw logits, and the thresholds are calibrated on logits. Do not apply a sigmoid β it saturates to 1.0 for confident predictions and makes the thresholds meaningless.
Live microphone
test_model.py includes a --mic mode with a rolling buffer, smoothing, and
a refractory gap so one utterance fires once:
python test_model.py --mic
It opens the mic at its native rate and resamples to 16 kHz, so a 44.1/48 kHz microphone works without extra setup.
Advanced
If you need the individual stages (the raw feature models, or a quantized classifier) β for streaming pipelines or embedded targets β they are in the source repo, which also has the full streaming detector and training code.
Performance
Held-out synthetic speech, split by source clip so augmented variants of the same recording never straddle train and validation (a random split here inflates recall by ~40 points through memorization):
| word | recall @ 1% false-accept |
|---|---|
| buddy boy | 99.7% |
| hey curry | 99.9% |
| hey assistant | 99.6% |
| hey buddy | 99.2% |
| hey clone | 99.9% |
| hey dude | 99.3% |
Correct word chosen among the six: 99.7%
End-to-end through the streaming detector: 72/72 clips correct, 0 wrong-word attributions, 0 false fires on silence, room noise, or loud noise.
Inference: 2.3 ms per 80 ms chunk (~35Γ realtime) on a single CPU core. Model: 205,382 parameters.
Confusion matrix (spoken β detected, validation set)
The only meaningful cross-talk:
"hey buddy" β buddy boy 0.6%
"hey dude" β hey assistant 0.4%
"hey curry" β hey dude 0.2%
Everything else is below 0.2%. Notably, hey clone and hey dude β the pair that looks most similar by raw embedding distance β show zero confusion.
Limitations
Trained only on synthetic speech. These models have never heard a human voice, a real microphone, or a real room. Expect real-world performance to be worse, possibly much worse. The numbers above are an upper bound.
Two known false triggers. Words that are complete substrings of a wake word still fire on their own:
| said alone | detected as | rate |
|---|---|---|
| "boy" | buddy boy | 5/6 |
| "assistant" | hey assistant | 3/6 |
"boy" alone produces audio nearly identical to the end of "buddy boy", and the 2-second window is mostly silence either way. Adding counter-examples and oversampling them 5Γ fixed buddy, clone, dude, and curry β but not these two. This is a property of the phrase choice, not a fixable bug: avoid wake words containing a common word as a complete piece.
Thresholds are calibrated on synthetic audio and will likely need tuning
for your microphone and room. Raise a word's threshold in multi.onnx.json if
it over-fires; lower it if it misses you.
Fine-tuning on your own voice
The syntheticβreal gap is the dominant limitation, and recording ~60 positive and ~40 negative samples of your own voice (about ten minutes) closes most of it. The source repo includes the recording and training tooling.
Attribution & license
Apache 2.0.
wakeword_allinone.onnx embeds the melspectrogram and speech-embedding models
from openWakeWord (Apache 2.0,
Β© David Scripka) as its frozen feature front end β these were not trained
here. The classifier fused on top is original work under the same license.
Citation
@software{curry_leaves_wakeword,
author = {Ilaya Nambi},
title = {curry-leaves: open wake word model},
year = {2026},
url = {https://huggingface.co/ilayanambi/curry-leaves-open-wake-word-model}
}