Notebook · Speech 10 min read

A wake word that lives in the browser

How we trained "Hei Alpha", a 415 KB wake-word model that listens in five languages, without recording a single human voice. It took synthetic speech, an hour of GPU time and about five dollars.

Say "Hei Alpha" and the assistant wakes up. There's no button to press and no server listening. The model that hears the phrase is 415 KB and runs in a Web Worker inside the browser tab, so the audio stays on the machine until you've said the word.

We trained it without recording anyone. Every positive example is synthetic speech, and most of the negatives are features computed once from thousands of hours of everyday audio. The whole recipe fits in one Docker image, and once the data is in place a GPU run takes about a quarter of an hour.

  • 415 KBmodel sizeabout 100k parameters on top of two shared feature models
  • ~14 minper training runone RTX 5090, once the data is downloaded
  • 5languagesNorwegian, Swedish, Danish, Finnish and English
  • 0.16 sto wakeafter the phrase ends, in the browser

Three small models in a row#

The detector is built on openWakeWord, which splits the problem into three ONNX models. Only the last one is ours:

  1. A mel spectrogram model (1.1 MB) turns 16 kHz audio into 32-band mel frames.
  2. Google's pre-trained speech embedding (1.3 MB) turns 76 mel frames into 96 features. It already knows what speech sounds like, which is why the last model can be so small.
  3. The wake-word classifier (415 KB) is a small fully connected network. It reads the last 16 embedding rows, about 1.3 seconds of audio, and outputs a score from 0 to 1.

Audio streams in 80 ms chunks of 1,280 samples, and each chunk moves the window forward by one row. The first two models are the same for every wake word, so a new phrase only needs a new classifier.

In the browser the models run on onnxruntime-web (WASM, one thread) inside a Web Worker. The microphone signal goes through the browser's own echo cancellation, noise suppression and gain control, and a simple downsampler takes it to 16 kHz. We ported the feature pipeline to TypeScript and ran it against openWakeWord's Python on the same audio. After the warm-up frames the scores matched exactly. Do this check first. If the browser computes features even slightly differently from training, the model quietly gets worse and nothing tells you why.

One high score doesn't wake the assistant. The score has to stay above a threshold for several frames in a row:

Sensitivity Threshold Frames in a row
Low 0.70 3
Normal 0.50 2
High 0.35 1

After a wake there is a 2.5-second cooldown, and the audio that follows is kept, so "Hei Alpha, what's on today?" works in one breath.

No human voices#

The hard part of a wake word is the data. You need thousands of people saying the phrase in different rooms and at different speeds, and even more audio of people saying something else. We generated the first and downloaded the second.

Positives from three kinds of synthetic voice#

openWakeWord ships a Piper model trained on LibriTTS-R, which has more than 900 English speakers. It generated up to 30,000 English clips of "hey alpha", "hei alpha", "hi alpha" and a few other spellings, so the phonemizer tries different pronunciations.

The English voices can't say "Hei" the way a Norwegian does, so we added five Nordic Piper voices: one Norwegian, two Swedish, one Danish and one Finnish. Each made between 1,500 and 4,000 clips, with different punctuation ("Hei, Alpha?"). Every clip has a random speaking rate (0.8 to 1.3×), random values for Piper's two noise settings, and a pitch shift from resampling (0.9 to 1.12×). The aim was to sound like many speakers instead of five.

The third source was a commercial TTS through OpenRouter. Two multilingual voices each said the phrase 1,500 times in Norwegian, Swedish, Danish and Finnish, and twelve English voices with US, UK, Australian and Indian accents said it 500 times each. That made 27,720 clips for about five dollars. Each clip was trimmed to the speech, retried if it ran longer than 1.25 s, and pitch-shifted.

Negatives: 2,000 hours you don't have to process#

openWakeWord publishes precomputed embedding features for 2,000 hours of general audio from the ACAV100M dataset, about 17 GB. These are the negatives. They contain speech, music, noise and everything else a microphone picks up, already in the 96-feature format the classifier reads. A separate precomputed set of about 11 hours measures false wakes per hour.

The phrases that cause trouble sound almost right, and general audio has few of them. So we added adversarial negatives from three sources:

  • openWakeWord's own generator, which makes phonetic near-misses of the target phrase;
  • our own list of greetings and names people actually say, such as "hey", "hey there", "here we are" and names that share a syllable with the wake word;
  • near-misses in each Nordic language, made with the same voices as the positives.

Every training batch has 1,024 general negatives, 50 adversarial negatives and 50 positives.

Augmentation#

Clean TTS sounds nothing like a laptop microphone. Each clip gets reverb from real room impulse responses (the MIT environmental IR set) and is mixed with one of 2,000 music clips from the Free Music Archive. Training never hears the phrase without a room and something else playing.

Training#

Everything runs in one Docker image with PyTorch and CUDA, here on an RTX 5090 under Docker Desktop and WSL2. One script runs the steps in order, and each step skips work that is already done:

  1. Download the negatives, impulse responses and background music.
  2. Generate the Nordic Piper clips.
  3. Generate the TTS clips. This step is skipped when there is no API key.
  4. Top up with English Piper clips, augment everything and compute the features.
  5. Train, then export to ONNX.

These are the settings that mattered:

First version Shipped version
Hidden layer width 32 64
Training steps 50,000 75,000
Maximum negative weight 1,500 500
Target false wakes per hour 0.2 0.2

Training runs in three sequences and raises the weight on negatives between them. At the end it merges the checkpoints in the top 10%. A first run takes one to two hours, and nearly all of that is downloading. A retrain on data already in place took 13 minutes 45 seconds.

Four things that broke#

We had to fix four upstream problems before the pipeline ran cleanly, and you will probably hit them too:

  • The newer Piper sample generator changed its layout, and the training script expects the old one. Pin the generator to the older version.
  • PyTorch 2.6 loads weights only by default, and the bundled voice checkpoint is a pickle. We patched the load to weights_only=False. Only do that for files you trust.
  • The phonemizer model's download URL returns 403, and the error page got saved as the model file. We avoided the download by adding pronunciations for the wake-word words straight to the dictionary.
  • The training script's TFLite flag defaults to the string "False", which Python treats as true. We only needed ONNX, so we patched the conversion out.

What the numbers said#

openWakeWord reports its own recall, but it measures it on heavily augmented clips. So we built an end-to-end test that runs held-out TTS clips through the real browser code. Each clip is upsampled to 48 kHz the way a microphone would deliver it, padded with a second of noise on each side, and sent through the browser's downsampler, feature pipeline and trigger rule at normal sensitivity. The test set had 400 positive clips and 260 near-miss negatives.

Wakes on held-out phrases, end to end

  1. v1 · Piper voices only45%
  2. v2 · plus commercial TTS27%
  3. v3 · wider, less negative weight80.5%
400 held-out clips through the browser pipeline at normal sensitivity.

v1 did well on English, Norwegian and Danish voices but missed every clip from one Swedish voice and from the Finnish voice. Both of those voices are slow: they take about a second to say the phrase, where the others take 0.6 s. A quick test on commercial voices v1 had never heard went worse still, with 0 of 8 Danish and 0 of 8 Swedish clips caught. It had learned the Piper voices rather than the phrase.

v2 added the commercial TTS clips with the same settings and got worse. With more varied positives and the same heavy negative weighting, the model played it safe. Real phrases scored 0.3 to 0.6, below the trigger, and near-misses scored 0.00. Recall fell to 27% with no false wakes at all, so it woke on only about a quarter of the phrases.

v3 doubled the hidden layer, trained 50% longer and cut the maximum negative weight to a third. Recall rose to 80.5%, and most of the gain came from the voices v1 had struggled with:

Recall by voice group, v1 against v3

  • v1
  • v3
0%50%100%Piper voices · v1: 55.6%Piper voices · v3: 75%Slow Swedish + Finnish · v1: 0%Slow Swedish + Finnish · v3: 35%Nordic TTS · v1: 24.4%Nordic TTS · v3: 83.1%English TTS · v1: 68.3%English TTS · v3: 90%Piper voicesSlow Swedish + FinnishNordic TTSEnglish TTS
The TTS voices in the test are new takes from voices v3 trained on, so those two groups flatter it.

Waking more often came with more false wakes:

False wakes on 260 near-miss phrases

  1. v15
  2. v20
  3. v38
Near-misses are the hardest negatives there are. On the 11-hour everyday-audio set, all three versions stayed at zero false wakes per hour.

Eight false wakes out of 260 near-misses is about 3%, on phrases chosen to fool the model. For an assistant, waking on 80% of attempts is the right trade. A missed wake means saying the phrase again. An assistant that wakes by itself in a quiet room loses the user's trust, and on the everyday-audio set none of the versions did that.

The results have one important caveat. The TTS voices in the test are new takes from voices v3 trained on, so the real test of generalisation is a human voice, and this test has none. In everyday use it works with real speakers, but we haven't counted those wakes.

Speed was never a problem. The wake fires about 0.16 s after you finish the phrase, which is two 80 ms frames.

Train your own#

The recipe works for any short phrase. Pick a word that isn't a common name and that sounds the same in every language you need.

  1. Clone openWakeWord and pin it to a known commit, with the Piper sample generator at the version its training script expects. Put both in a Docker image on a PyTorch CUDA base, together with the four patches above.
  2. Write the config. It needs the model name, the target phrase with a few spelling variants (the phonemizer tries each one), and your own negative phrases: greetings, plus names and words that share syllables with your wake word. Start with a hidden layer of 64, 75,000 steps, a maximum negative weight of 500 and a target of 0.2 false wakes per hour.
  3. Add every word in your phrase that the pronunciation dictionary lacks, so nothing has to be downloaded or guessed.
  4. Generate positives in every language and accent you care about. Use Piper voices for volume, and vary the rate, noise and pitch per clip. Add a few hosted TTS voices for variety; a few dollars is plenty. Trim the silence, and drop clips longer than the model's window.
  5. Generate near-misses in the same languages with the same voices, so the model can't use the voice to tell them apart.
  6. Download the shared assets: the precomputed negative features, the false-positive validation features, a set of room impulse responses and some background music.
  7. Run openWakeWord's training script with --generate_clips, then --augment_clips, then --train_model, and copy out the ONNX file.
  8. Test the model the way it will run. Feed held-out clips through your own runtime code instead of the training library, with the same resampling, frame size and trigger rule. Score recall per voice group as well as overall, because an average can hide a voice the model can't hear at all.
  9. Read the failures and adjust. If one voice or language fails, add variety there. If everything scores low and nothing false-wakes, the negatives are too heavy, so lower the negative weight or widen the layer. If near-misses trigger it, add them as adversarial negatives.
  10. Ship the three models together, and let the user pick a sensitivity as a threshold plus a number of frames in a row.

Check the licences before you ship. openWakeWord's code is Apache 2.0, but the precomputed negatives come from a dataset that makes trained models non-commercial (CC BY-NC-SA 4.0). Read the terms of any hosted TTS before you train on its output, too. That is fine for a personal project, but a product will need to replace those pieces.

  • machine learning
  • speech
  • wake word
  • onnx
  • tts