phoneme-wake-word / README.md
JoelAjitesh's picture
Phoneme wake word engine: student+teacher models, INT8 export, C engine, enrollment tooling
f6aec75 verified
|
Raw
History Blame Contribute Delete
9.25 kB
---
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}
}
```