Phoneme wake word engine: student+teacher models, INT8 export, C engine, enrollment tooling
f6aec75 verified | license: apache-2.0 | |
| language: | |
| - en | |
| tags: | |
| - keyword-spotting | |
| - wake-word-detection | |
| - speech | |
| - phoneme-recognition | |
| - ctc | |
| - esp32 | |
| - tinyml | |
| - edge-ai | |
| - on-device | |
| library_name: pytorch | |
| pipeline_tag: automatic-speech-recognition | |
| # Wake Words Without Training β an open-vocabulary wake word engine for microcontrollers | |
| **A wake word here is not a model. It is ~50 bytes of configuration.** | |
| One small streaming phoneme recognizer is trained *once* on generic | |
| speech and never sees a wake word. Any phrase β typed, invented, | |
| whatever you like β becomes a detector in milliseconds via | |
| grapheme-to-phoneme lookup, optionally sharpened by five spoken | |
| examples. The whole runtime fits on an **ESP32-S3** at **340 KB INT8** | |
| and **14 ms per 40 ms of audio**, leaving wake words changeable | |
| without retraining, reflashing, or a cloud round-trip. | |
| | | Every per-word-trained system | This | | |
| |---|---|---| | |
| | New wake word costs | TTS synthesis + GPU training (minutesβhours) | a dictionary lookup (ms) | | |
| | Artifact per word | a 50β200 KB model | **~50 bytes** (phone ids + threshold) | | |
| | N simultaneous words | N models | 1 shared model + N tiny decoders | | |
| | Changing a word on device | reflash | send a few bytes | | |
| --- | |
| ## How it works | |
| **Runtime lane (wake-word-agnostic, always on):** | |
| `mic β 40-band log-mel + causal EMA normalization β streaming causal TCN | |
| β CTC phoneme posteriors every 20 ms β keyword-filler Viterbi decoder` | |
| **Enrollment lane (per word, no training):** | |
| `text β G2P β phone sequence` and/or `5 spoken examples β phoneme decode | |
| β pronunciation variants` β automatic per-variant threshold calibration | |
| β **config**, or an explicit *refusal* if the phrase cannot be separated | |
| from ordinary speech. | |
| The decoder is a filler-normalized Viterbi search with four gates, each | |
| closing a failure mode we hit in practice: | |
| 1. **Phone-frame normalization** β score is normalized by frames spent in | |
| *emitting* states only. Normalizing by total duration lets degenerate | |
| paths idle in blank states (free in silence) and fire rhythmically on | |
| nothing. Fixing this moved a clean operating point from 65% recall | |
| @ 12 FA/h to **95% @ 1.25 FA/h**. | |
| 2. **Strong-evidence gate** β β₯50% of phone frames must have their phone | |
| within 1.5 nats of the frame's best class. Noisy audio yields flat | |
| posteriors that score fine *on average* while containing nothing; | |
| this cut false alarms on noise-degraded speech from ~100/h to ~3/h. | |
| 3. **Duration bounds** β 60β200 ms per phone. At a looser cap we observed | |
| 1.5β2.3 s alignments crawling across background television. | |
| 4. **Acoustic energy veto** (on device) β the matched span must exceed the | |
| rolling noise floor by 5 dB. | |
| Detections use only *within-frame logit differences*, so the deployed | |
| engine never computes a softmax. | |
| ## Models | |
| | File | Params | dev-clean PER | Common Voice dev PER | Use | | |
| |---|---|---|---|---| | |
| | `phoneme_tcn_student.pt` | 358,504 | **0.229** | **0.402** | deploy this | | |
| | `phoneme_tcn_teacher.pt` | 3,337,768 | 0.150 | β | distillation teacher | | |
| **Student architecture** β causal TCN: stride-2 stem (k=5), 8 | |
| depthwise-separable causal blocks (k=5, dilations 1,2,4,8 Γ2, 192 ch, | |
| BatchNorm+ReLU, residual), 1Γ1 head over 40 classes (39 stress-free | |
| ARPAbet phones + CTC blank). 20 ms output frames, ~2.4 s receptive | |
| field. Every op is BatchNorm-foldable conv / ReLU / residual β chosen so | |
| post-training INT8 survives intact (**99.3%** frame-argmax agreement | |
| with float). | |
| **Training** β CTC over phonemized transcripts (CMUdict + neural G2P | |
| fallback) on **LibriSpeech 960 h + 1.10 M Common Voice 17 English clips** | |
| (~1,500 h, incl. 83k Indian-accented). On-GPU augmentation: synthetic | |
| room impulse responses (T60 0.1β0.6 s), additive noise (5β30 dB SNR), | |
| same-batch babble (10β25 dB), random gain, SpecAugment. The student was | |
| then distilled from the teacher with **speech-weighted KD** β frames are | |
| weighted by `1 β p(blank)` because ~75% of CTC frames are blank and | |
| uniform KD otherwise spends its budget teaching silence (uniform KD | |
| *degraded* the student; the weighted version improved it). | |
| ## Benchmarks | |
| Ten phrases, Piper LibriTTS-R positives **verified by Whisper** (raw TTS | |
| is unreliable: "tornado" β *"Pornado"*, "dakota" β *"Decoder"*), against | |
| 1.22 h LibriSpeech dev-clean + 0.82 h Common Voice dev negatives. Noisy | |
| conditions degrade positives *and* negatives identically so operating | |
| points stay condition-matched. | |
| | Condition | Recall | Notes | | |
| |---|---|---| | |
| | Text-only, speaker-independent | median **0.38** @ 0 FA | hardest case: arbitrary phrase, arbitrary speaker, zero examples | | |
| | Cross-speaker enrollment | repairs dictionary mismatch | tornado 0 β 0.35, "hey jarvis" 0.45 β 0.75 | | |
| | Personal enrollment (5 examples) | mean **0.554**, median **0.667** @ 3.3 FA/h | synthetic renditions vary more than a self-consistent human | | |
| | Universal auto-calibration | **83%** of arbitrary voices (`neptuno`) | 36 TTS voices, population-voted variants | | |
| | On-device, single user | ~**14/15** utterances | ESP32-S3 + INMP441, live session | | |
| **Safety property**: calibration *refuses* phrases it cannot separate. | |
| "norman" (one phone from "normal") was rejected for 5/6 voices and | |
| "dakota" for 6/6 β independently flagged by the text-only phrase scorer | |
| (`score_phrase.py`) before any audio existed. | |
| ## Deployment | |
| | Stage | Verification | | |
| |---|---| | |
| | BN folding + residual extraction | max err 7.6e-4 vs PyTorch | | |
| | INT8 simulation vs float | 99.3% frame agreement | | |
| | **C engine vs INT8 simulation** | **bit-exact (0.0)** | | |
| | C mel frontend vs training frontend | β€1e-3 log-mel | | |
| | ESP32-S3 step time | **14.2 ms** / 40 ms frame (1 core @ 240 MHz) | | |
| `engine_c/` is ~300 lines of dependency-free C with one INT8 ring buffer | |
| per layer, so each frame costs only its own ~350k MACs β no window | |
| recomputation. On ESP32 the binding constraint was **memory latency, not | |
| math**: moving weights out of memory-mapped flash (28.6 β 15.9 ms) and | |
| filling internal SRAM before PSRAM (β 14.2 ms) mattered far more than | |
| loop optimization. | |
| ## Files | |
| ``` | |
| phoneme_tcn_student.pt deployable model (float, PyTorch state dict) | |
| phoneme_tcn_teacher.pt distillation teacher | |
| model_int8.h INT8 weights + layer table (C header, 340 KB) | |
| act_scales.json activation scales from PTQ calibration | |
| frontend_data.h mel filterbank + Hann window (exact training values) | |
| engine_c/ streaming INT8 engine + decoder + mel frontend (C) | |
| phoneme_engine/ PyTorch model, decoder, enrollment, quantization, scorer | |
| examples/ a universal wake word config (~50 bytes of JSON) | |
| ``` | |
| ## Usage | |
| **Spot a typed phrase (Python):** | |
| ```python | |
| import torch | |
| from phoneme_engine.model import PhonemeTCN | |
| from phoneme_engine.features import LogMel | |
| from phoneme_engine.decoder import KeywordSpotter | |
| model = PhonemeTCN().eval() | |
| model.load_state_dict(torch.load("phoneme_tcn_student.pt", | |
| weights_only=True)["model"]) | |
| frontend = LogMel().eval() | |
| spotter = KeywordSpotter("hey orbit") # G2P -> HH EY AO R B AH T | |
| with torch.no_grad(): | |
| logp = torch.log_softmax(model(frontend(wav)).float(), 2)[0].numpy() | |
| for frame, score in spotter.run(logp): | |
| print(f"detected at {frame * 0.02:.2f}s (score {score:.2f})") | |
| ``` | |
| **Score a candidate phrase before committing to it:** | |
| ```bash | |
| python score_phrase.py "vucano" | |
| # low reliability: /v/ is acoustically weak and often lost on small mics | |
| ``` | |
| **A wake word config, in full:** | |
| ```json | |
| {"phrase": "neptuno", | |
| "spotters": [{"phones": ["N","EH","P","T","UW","N","OW"], | |
| "source": "dictionary", "threshold": -2.5}]} | |
| ``` | |
| **On a microcontroller:** compile `engine_c/` with `model_int8.h` and | |
| `frontend_data.h`, feed it 40-band mel frames, and pass the logits to | |
| `pww_spotter_step()`. Wake words are `uint8_t` arrays of phone ids plus a | |
| float threshold β swap them at runtime. | |
| ## Limitations | |
| - The 358k student is the binding constraint. Text-only speaker-independent | |
| recall is modest; 40% PER on real-world speech leaves little margin for | |
| accented, distant, or noisy input. Personal enrollment recovers much of it. | |
| - Benchmarks are TTS-based (Whisper-verified, but synthetic). Human | |
| multi-speaker evaluation is future work. | |
| - **Onset phonetics dominate word quality.** Nasals/stops/sibilants | |
| (N, M, K, T, S) work well; weak fricatives (V, F, TH, H) are often lost | |
| on small MEMS mics. The included scorer predicts this from text. | |
| - False-alarm rates are measured against *continuous speech* β a worst case | |
| versus mostly-quiet rooms. | |
| - Single-microphone. Far-field performance is a hardware question | |
| (beamforming arrays), not a decoder one. | |
| - English only, though nothing in the architecture is language-bound beyond | |
| the phone inventory and G2P. | |
| ## License | |
| Apache-2.0. Trained on LibriSpeech (CC BY 4.0) and Common Voice 17 (CC0). | |
| ## Citation | |
| ```bibtex | |
| @misc{wakewordswithouttraining2026, | |
| title = {Wake Words Without Training: Open-Vocabulary Wake Word | |
| Creation from Text and a Few Examples}, | |
| author = {IOTEverythin}, | |
| year = {2026}, | |
| url = {https://huggingface.co/IOTEverythin/phoneme-wake-word} | |
| } | |
| ``` | |