Config-driven pipeline for training custom wake word models using the microWakeWord framework.
microWakeWord trains a MixedNet/Inception streaming CNN that is exported as a fully quantised TFLite model. The final model is 50–300 KB and is designed to run with pymicro_wakeword (used by linux-voice-assistant) and ESPHome on microcontrollers.
I built this because I wanted a custom Russian wake word — "Джарвис" (Jarvis) — for my home voice assistant. Russian isn't supported by any of the off-the-shelf wake word solutions I found, and recording hundreds of your own voice clips felt like the wrong approach. I wanted a fully synthetic training pipeline: pick your phrases, pick your voices, run the script, get a model.
I started with openWakeWord and got something working, but the gap between training accuracy and real-world recall was frustrating. Switching to microWakeWord's streaming CNN architecture gave much better results, and the model is a fraction of the size.
The pipeline is fully language-agnostic — the Russian example configs are just that. Any language with a Piper voice model should work the same way.
Trained on 50k Piper TTS clips across 4 Russian voices (Dmitri, Denis, Irina, Ruslan) plus ElevenLabs positives (~314k positive / ~353k negative feature windows). Evaluated on real audio held out of training: 50 organic activations recorded during daily use, and 140 mined hard false-positive clips (TV speech, clicks, knocks):
| Metric | Result |
|---|---|
| Recall — 50 real activations (held out of training) | 47 / 50 (94%) |
| False positives — 140 mined hard cases | 1 / 140 |
| Previous model on the same test sets | 50/50 recall, but 91/140 FP |
| Production cutoff in daily use | 0.9 |
| Model size | 82.5 KB (streaming, uint8 quantised) |
| Val accuracy | 99.77% |
The model runs continuously in production. The biggest win came from FP-mining: collecting 208 real false-positive clips from daily use and re-extracting negative features with dense windowing (1 window per 10 ms) dropped hard-case false positives from 91/140 to 1/140. Lowering the cutoff from 0.97 to 0.9 then recovered 5 more real activations at no measurable FP cost — the 3 remaining misses sit far below the threshold (~0.2–0.5) and are not recoverable by tuning alone.
- Linux (tested on Ubuntu 22.04, Debian 12, WSL2)
- Python 3.9+ with pip
- ~15 GB disk space for training data
- NVIDIA GPU strongly recommended (training takes 8–14 hours on CPU)
- Internet access for HuggingFace downloads
# Clone the repo and set up the environment (one time only)
git clone https://github.com/interkelstar/microwakeword-trainer.git
cd microwakeword-trainer
chmod +x setup_mww.sh && ./setup_mww.sh
# Copy the example config and customise it for your wake word
cp example_ru_jarvis.yaml my_wakeword.yaml
# Edit my_wakeword.yaml: set model_name, target_phrases, voices, negatives
# Run the full training pipeline
./run_mww.sh --config my_wakeword.yamlOutput files created at the project root after training:
<model_name>_mww.tflite— TFLite model ready for pymicro_wakeword<model_name>_mww.json— Deployment manifest with inference parameters
The pipeline runs these phases in order:
| Phase | What it does | Time (CPU) |
|---|---|---|
setup |
Verify microwakeword, TF, pymicro_features are installed | seconds |
voices |
Download Piper ONNX voice models from HuggingFace | 1–5 min |
generate |
Synthesise positive + adversarial negative TTS clips via piper | 6–10 hours |
features |
Extract pymicro_features spectrograms + download HF negatives | 1–3 hours |
train |
Build and train the MixedNet model with SpecAugment | 0.5–3 hours |
export |
Convert to streaming TFLite (uint8 quantised) + write JSON manifest | 5–15 min |
test |
Evaluate TFLite model on test clips (optional, not run in "all") | minutes |
Run individual phases with --phase <name>:
./run_mww.sh --config my_wakeword.yaml --phase train
./run_mww.sh --config my_wakeword.yaml --phase export
./run_mww.sh --config my_wakeword.yaml --phase testSee example_ru_jarvis.yaml for a fully annotated example. Key sections:
model_name: ru_jarvis # Output filename prefix (ASCII, no spaces)
target_phrases: # What the model should fire on
- Джарвис
- Джарвис!
- Джарвис?
training:
n_samples: 50000 # TTS clips for training
n_samples_val: 10000 # TTS clips for validation
voices:
primary: # For positive clips + same-language negatives
base_url: https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU
models:
ru_RU-dmitri-medium: dmitri/medium
secondary: # For cross-language negative phrases (optional)
base_url: https://huggingface.co/rhasspy/piper-voices/resolve/main
models:
en_US-amy-medium: en/en_US/amy/medium
negative_phrases: # Acoustically similar words to reject
- Джар
- Алиса
- Jarring # Latin script → synthesised with secondary voices
microwakeword:
architecture: mixednet # mixednet (default) or inception
spectrogram_length: 204 # input frames per inference window
stride: 3 # frames consumed per streaming step (3 × 10ms = 30ms)
training_steps: 10000 # gradient steps
batch_size: 128
learning_rate: 0.001
positive_class_weight: 1.5 # raise if recall is too low
negative_class_weight: 1 # raise if false positive rate is too high
probability_cutoff: 0.9 # detection threshold (used at inference time)
sliding_window_size: 10 # averaging window size (used at inference time)microWakeWord uses pymicro_features to extract 40-bin mel spectrogram frames
at 10 ms intervals. The model processes stride frames per inference call
and maintains a ring buffer of internal state.
The spectrogram_length determines how many frames the model can look back
when making each decision. Larger values give more context but increase model
size and training memory.
probability_cutoff and sliding_window_size are used only at inference
time — re-run --phase export after changing them.
Copy both output files to your linux-voice-assistant config directory:
cp ru_jarvis_mww.tflite /path/to/linux-voice-assistant/models/
cp ru_jarvis_mww.json /path/to/linux-voice-assistant/models/The JSON manifest format:
{
"type": "micro",
"wake_word": "Джарвис",
"model": "ru_jarvis_mww.tflite",
"micro": {
"probability_cutoff": 0.9,
"sliding_window_size": 10,
"feature_step_size": 10
}
}The type: micro field is required — the linux-voice-assistant loader uses it
to select the pymicro_wakeword inference backend. The exporter also writes
author, trained_languages, version, micro.tensor_arena_size, and
micro.minimum_esphome_version for ESPHome compatibility.
To change the detection threshold without retraining, edit probability_cutoff
in the JSON file directly, or change it in your YAML and re-run --phase export.
The most effective way to reduce false positives is to collect negative examples from your real environment and retrain.
Workflow:
- Run the assistant for a day and note when it triggers incorrectly.
- Record the audio that caused false positives as a WAV file (16 kHz, mono).
- Copy the WAV to
training/output/<model_name>/user_negatives/. - Re-run feature extraction and training:
./run_mww.sh --config my_wakeword.yaml --phase features
./run_mww.sh --config my_wakeword.yaml --phase train
./run_mww.sh --config my_wakeword.yaml --phase exportThe user_negatives/ directory is processed with dense windowing (1 window
per 10 ms) to maximise coverage of the triggering audio.
Important: Do NOT put actual wake word recordings in user_negatives/.
Only put audio that triggered incorrectly (TV speech, background noise, etc.).
Use test_mww.py to evaluate a trained model against WAV files. The script
simulates the exact pymicro_wakeword inference loop including the sliding
window averaging:
# Test against all WAV files in record/
.venv/bin/python3 test_mww.py
# Test specific files
.venv/bin/python3 test_mww.py record/sample1.wav record/sample2.wav
# Output shows per-frame probabilities and a TRIGGERED / no trigger resultThe default model and config paths are set at the top of test_mww.py;
override them with --model my_model_mww.tflite (the JSON manifest is
found automatically next to the model).
Two companion tools help with false-positive mining:
# Batch-test a directory of FP clips at the production cutoff
.venv/bin/python3 batch_test_fps.py --model my_model_mww.tflite fp_clips/
# A/B compare two model versions on the same FP clips
.venv/bin/python3 ab_test_fps.py old_mww.tflite new_mww.tflite fp_clips/generate_elevenlabs.py generates additional high-quality TTS clips using the
ElevenLabs API (requires an API key with credits). These are automatically
picked up during feature extraction and can improve the naturalness of the
positive class:
.venv/bin/python generate_elevenlabs.py --api-key YOUR_KEY --config my_wakeword.yamlClips are saved to training/output/<model_name>/elevenlabs_positive/.
After a full training run, the training/ directory contains:
training/piper_binary/ # piper executable + shared libs (~150 MB)
training/piper_models/ # ONNX voice models (~280 MB for 4 voices)
training/output/<model_name>/
positive_train/ # TTS positive clips (~4 GB for 50k)
positive_test/ # TTS validation clips (~800 MB for 10k)
negative_train/ # Adversarial negative TTS clips
negative_test/
elevenlabs_positive/ # ElevenLabs clips (optional)
user_negatives/ # Real environment negatives (optional)
mww_features/ # Extracted spectrograms + HF negatives
model/ # Saved Keras model weights
To clean up after training:
rm -rf .venv training/microWakeWord uses the pymicro_features frontend (matching the on-device inference pipeline on microcontrollers):
- Audio is chunked into 10 ms frames of 160 samples at 16 kHz.
- Each chunk is processed by
MicroFrontendfrom thepymicro-featurespackage, producing a 40-bin log-mel spectrogram frame. - The streaming model processes
strideframes per call, maintaining internal convolutional state across calls. - A sliding window of
sliding_window_sizeconsecutive probabilities is averaged; when the mean exceedsprobability_cutoff, the wake word fires.
Feature scale: pymicro_features outputs values in [0, ~26] (float32).
This range must match at training time and inference time. The HuggingFace
negative dataset (kahrendt/microwakeword) uses a uint16 scale and is
automatically rescaled to match during feature extraction.
MIT