Wake word — microWakeWord “Hey Artemis”

How the prebuilt ESP-SR WakeNet9 wake word (“Hi, ESP”) was replaced with a custom-trained microWakeWord (TFLite-Micro) “Hey Artemis” model — the decision record, the training recipe, and the flash/tuning steps. Shipped 2026-07-18 (on-device: recall 6/6, adversarial phrases rejected, cutoff 0.70 / sliding window 4). The model is speaker-adapted: to use your own phrase or voice, follow the same recipe with your own recordings.

Why this path (decision record, 2026-07-15)

Three routes were evaluated:

  1. Prebuilt wakenet swap (e.g. wn9_hifairy_tts2 “Hi, Fairy”): zero effort, but limited to Espressif’s phrase list; the _tts* models are synthetic-trained and a notch below the flagship models.
  2. MultiNet CustomWakeWord (in-tree, zero training — the phrase as an English speech command in phonemes): attempted and dead-ended on flash budget. Enabling any English MultiNet hard-links libflite_g2p.a (+610 KB app code — text→phoneme data, not gateable without patching the esp-sr prebuilt) and needs mn7_en (2.7 MB) in assets. The app binary went 5.00 → 5.62 MB and overflowed the 5.25 MB A/B OTA slots by 257 KB; fitting it required either dropping the A/B layout or squeezing to ~5 KB headroom. The config for that attempt (reverted, kept for reference): USE_CUSTOM_WAKE_WORD=y, CUSTOM_WAKE_WORD="hd LoNc" (g2p phonemes for “hey artemis” via managed_components/espressif__esp-sr/tool/multinet_g2p.py), SR_MN_EN_MULTINET7_QUANT=y, assets partition 4 M → 0x500000.
  3. microWakeWord (this plan): trained model is ~130 KB + a small TFLite-Micro runtime — fits the EXISTING partition layout with A/B OTA intact, and detection quality is the community-proven best of the three (it’s ESPHome’s production wake engine). Cost: training needs NVIDIA/Colab + real tuning effort.

Espressif’s own custom-word training (wn9_customword) remains a commercial, sales-mediated service (≥20k samples, 500+ speakers) — not an option. Verified 2026-07: no self-serve esp-sr path exists.

Implementation status (2026-07-15)

Training (host side)

Upstream is OHF-Voice/micro-wake-word (Open Home Foundation; formerly kahrendt/microWakeWord). Apache-2.0. Its basic_training_notebook.ipynb is labeled advanced-users-only — don’t run it raw; use the maintained wrapper.

Training host = user’s RTX 3090 (24 GB, Ampere / sm_86), decided 2026-07-15. Well-supported by mainline TF-GPU; Colab and AMD/ROCm are moot.

Trainer = TaterTotterson/microWakeWord-Trainer-Nvidia-Docker (the de facto standard as of mid-2026 — active weekly releases, web UI on :8789, supersedes the dead ClarePhang/stujenn lineage). It wraps piper + the kahrendt negatives + training + TFLite export behind one container.

# On the 3090 — STANDARD image (NOT the :vN-blackwell one; that's RTX 50-only)
docker run -d --gpus all --network host -e REC_PORT=8789 \
  -v $(pwd):/data ghcr.io/tatertotterson/microwakeword:latest   # pin :vN for repro
# then browse to http://<3090-host>:8789 → Trainer tab

Runtime (firmware side)

License verdict (verified 2026-07-15): the clean path is fully open. Every library we link is Apache-2.0; the only GPL code is ESPHome’s own runtime, which we treat as algorithm reference and never copy.

Add to poppet/main/idf_component.yml (our component, NOT the vendored one):

dependencies:
  espressif/esp-tflite-micro: "==1.3.7"
  esp-micro-speech-features:
    git: https://github.com/kahrendt/ESPMicroSpeechFeatures.git
    # pin to a specific commit hash once vendored, for reproducibility

Partition

Append to poppet/partitions.csv (256 KB comfortably holds the ~130 KB model + manifest; 1.3 MB is unallocated at the end of the 16 MB flash today):

mww_model, data, fat,    ,         0x40000,

Flash the model with parttool.py --port <PORT> write_partition --partition-name=mww_model --input model.tflite (or fold it into the assets pipeline later — assets has ~1.7 MB free).

Source layout

New files under poppet/main/stackchan/wake_word/:

microwakeword.h         // class MicroWakeWord : public WakeWord (mirror AfeWakeWord)
microwakeword.cc        // streaming inference task, tensor arena, threshold logic
mww_model_loader.cc     // mmap the mww_model partition into PSRAM

Mirror the WakeWord interface from poppet/vendor/stackchan-esp32/main/audio/wake_word.h (10 pure-virtual methods) so AudioService::SetModelsList can hold it behind the same std::unique_ptr<WakeWord>. Keep AFE (AEC/NS) in FRONT of the model:

Mic ──▶ AFE (AEC, NS, VAD) ──▶ MicroWakeWord (TFLite streaming) ──▶ wake event

Feed AFE-cleaned PCM, not raw mic. Fork AfeWakeWord and swap the detector: the whole pre-roll capture/encode/opus path (StoreWakeWordData, EncodeWakeWordData, GetWakeWordOpus) is copied verbatim — only AudioDetectionTask’s res->wakeup_state == WAKENET_DETECTED check is replaced with the TFLite streaming loop over res->data.

Confirmed wiring against the installed tree (esp-sr 2.3.0, verified 2026-07-15):

Kconfig

In poppet/main/Kconfig.projbuild (ours, not the vendored one):

config USE_MICROWAKEWORD
    bool "microWakeWord (custom Hey Artemis TFLite)"
    depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM
    select USE_AFE_WAKE_WORD

config MICROWAKEWORD_THRESHOLD
    int "detection threshold (0-99)"
    default 50
    range 1 99
    depends on USE_MICROWAKEWORD

The wake-word string sent via protocol_->SendWakeWordDetected() becomes "Hey Artemis"; the server treats it as informational (verified — arbitrary phrases already flow through).

Test plan

  1. Flash model to mww_model; boot log prints model size on successful mmap.
  2. “Hey Artemis” 10× from 1.5 m, quiet room → expect 10/10 at the manifest’s threshold.
  3. 1-hour silence + TV-on soak → ≤1 false accept.
  4. Hard negatives: “Hey Sam”, “Hey Arnaud” (French ar-NOH), similar names → 0.
  5. CPU/memory: baseline vs +MWW; budget ~5–8% core-1 CPU, ~250–300 KB PSRAM (tensor arena — benchmark SPIRAM vs internal for first-inference latency).
  6. A week of household soak; review server wake logs (msg=wake).

Rollback

USE_MICROWAKEWORD=n → prebuilt WakeNet9 path (SR_WN_WN9_*) untouched. The mww_model partition can stay flashed but unread.