Siddhant Kumar
Project 002 · Smart Home

Voice-Controlled Home Hub.

A wake-word voice assistant that runs entirely on a ₹900 microcontroller — no cloud, no account, no audio leaving the house — switching lights, fans and appliances from a spoken command.

Advanced 20–30 hours 72 min read VoiceAutomationMQTT
Jump to source Bill of materials
Voice-Controlled Home Hub — reference build illustration ESP32 USB
Difficulty
Advanced
Build time
20–30 hours
Indicative cost
₹3,600 – ₹4,800
Platform
ESP32-S3 DevKitC-1
Category
Smart Home
Last updated
28 July 2026
Contents — 28 sections

Project Overview

A wake-word voice assistant that runs entirely on a ₹900 microcontroller — no cloud, no account, no audio leaving the house — switching lights, fans and appliances from a spoken command.

Commercial voice assistants work by streaming a continuous audio buffer to a data centre. That is a reasonable engineering choice and an unreasonable privacy one. This project takes the opposite position: the microphone data never leaves the ESP32-S3. A small neural network trained on your own recordings runs on-device, and the only thing that ever reaches the network is a two-word MQTT message such as light/on.

That constraint drives every design decision. You cannot run a general speech recogniser in 512 KB of RAM, so the system is built as a keyword spotter — it recognises a fixed vocabulary of perhaps a dozen commands rather than transcribing arbitrary speech. In practice that is what a home hub actually needs. "Lights on", "fan off", "scene movie" covers the real usage; free-form dictation does not.

The signal chain is worth understanding because it is the same chain used in every production keyword spotter. Audio arrives from an INMP441 MEMS microphone over I²S at 16 kHz. A 30 ms sliding window is transformed to a Mel-frequency spectrogram — a compact time-frequency image that discards phase and most of the fine spectral detail humans do not use for phoneme identity. That image feeds a small depthwise-separable convolutional network, quantised to int8, which outputs a probability per keyword. The whole inference costs about 15 ms on the ESP32-S3, which has vector instructions specifically for this workload.

The ESP32-S3 rather than a plain ESP32 is a deliberate choice. The S3 adds 8 MB of PSRAM (enough for audio ring buffers and the model arena) and SIMD-like vector extensions that roughly triple int8 convolution throughput. On a classic ESP32 the same model runs at around 45 ms per inference, which is usable but leaves much less headroom for the audio front end.

Two failure modes dominate real deployments, and both are addressed here. False accepts — the hub switching the lights because the television said something similar — are handled with a confidence threshold plus a required run of consecutive positive frames. False rejects in a noisy kitchen are handled by training on your own room, with your own voice, including recordings of the background noise you actually have.

An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32-class development board. The S3 variant used here adds 8 MB of PSRAM and vector instructions aimed at exactly this kind of int8 inference workload. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Continuously listens for a wake word entirely on-device, at about 0.4 W total power.
  • Recognises a trained vocabulary of 8–12 command phrases with per-keyword confidence scores.
  • Publishes the recognised intent as an MQTT message that Home Assistant, Node-RED or a relay board can act on.
  • Switches four mains channels directly through an opto-isolated relay board when running standalone.
  • Gives immediate audible and visual feedback so you always know whether a command landed.
  • Logs every recognition — including rejected low-confidence ones — so you can tune the threshold from real data.
  • Never transmits audio. The microphone buffer is overwritten in place and never written to flash or the network.

Real-World Applications

SettingHow it is used
Accessible home controlFor someone with limited mobility, a reliable local voice switch is genuinely enabling — and unlike a cloud assistant it keeps working when the broadband does not.
Privacy-sensitive householdsHomes where a cloud microphone is unacceptable — therapy practices, legal offices, or simply a matter of principle.
Industrial and workshop controlHands-free control while wearing gloves or holding a workpiece, in an environment with no network access.
Hotel and hospital roomsA fixed, auditable vocabulary is far easier to certify than a general-purpose assistant.
Teaching TinyMLThe complete loop — collect data, train, quantise, deploy, measure — inside one weekend project.
Kiosk and appliance interfacesThe same firmware pattern gives a washing machine or coffee machine a small, dependable voice interface.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Fully offline inference using TensorFlow Lite for Microcontrollers with an int8-quantised model under 60 KB.
  • Mel-spectrogram front end computed with a fixed-point FFT, so feature extraction costs about 4 ms per window.
  • Two-stage detection — a cheap always-on wake-word model gates a larger command model, cutting average power by roughly 60 %.
  • Confidence hysteresis: three consecutive frames above threshold to accept, which nearly eliminates television false triggers.
  • Per-keyword thresholds tunable at runtime over MQTT, because "off" is inherently harder to detect than "kitchen".
  • Ring-buffered I²S capture on a dedicated FreeRTOS task pinned to core 0, so inference on core 1 never drops samples.
  • Adaptive noise floor — the detector's energy gate tracks the room's ambient level over a 30 s window.
  • WS2812 status ring giving a listening / thinking / accepted / rejected visual state.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelAdvanced
Estimated completion time20–30 hours
Indicative build cost₹3,600 – ₹4,800
Primary disciplineSmart Home
Reference platformESP32-S3 DevKitC-1

Skills you should have (or will pick up)

  • Comfortable C++ including pointers, buffers and fixed-size arrays
  • Basic digital signal processing intuition — sampling rate, windowing, spectrograms
  • Enough Python to run a training notebook and read a confusion matrix
  • Understanding of quantisation: why int8 and what it costs in accuracy
  • FreeRTOS basics — tasks, cores, queues
  • Mains wiring competence if you drive relays directly (or use a separate certified smart plug instead)

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32-S3 DevKitC-1
The vector extensions roughly triple TinyML inference speed over the original ESP32.
Dual-core Xtensa LX7 @ 240 MHz, 512 KB SRAM + 8 MB PSRAM, vector instructions for ML, Wi-Fi + BLE 51₹900
INMP441 I²S MEMS microphone
Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting.
61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output1₹220
4-channel opto-isolated relay board
All four coils energised draw ~280 mA — do not power from the MCU 5 V pin.
4 × SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel1₹280
WS2812B addressable RGB LED strip (60 LED/m)
Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data.
5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel1₹900
Active piezo buzzer 5 V
Active buzzers make tone on DC; passive ones need a PWM carrier.
85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter1₹25
LM2596 adjustable buck converter module
Set the output voltage with no load connected before wiring the board.
4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency1₹90
5 V 3 A regulated SMPS adapter
Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.
100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection1₹350
Double-sided perfboard 7 × 9 cm + headers
Solder female headers so the MCU can be swapped without desoldering.
FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid1₹60
IP65 ABS junction enclosure 158 × 90 × 60 mm
Fit cable glands, not drilled holes, or the IP rating means nothing.
IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs1₹260
Small 4 Ω 3 W speaker + PAM8403 amplifier
Optional — for spoken confirmation rather than beeps.
Class-D, 3 W per channel, 5 V1₹220
Acoustic foam / felt pad
Decouples the microphone from enclosure vibration; noticeably reduces handling noise.
10 mm open-cell, self-adhesive1₹120

Estimated total: ₹3,425, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter — continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32-S3 DevKitC-1Dual-core Xtensa LX7 @ 240 MHz, 512 KB SRAM + 8 MB PSRAM, vector instructions for ML, Wi-Fi + BLE 53.3 V logic / 5 V USBUSB-OTG, SPI, I²C, I²S, LCD/camera busDatasheet
INMP441 I²S MEMS microphone61 dB SNR, −26 dBFS sensitivity, 60 Hz–15 kHz, 24-bit I²S output1.8–3.3 VI²SDatasheet
4-channel opto-isolated relay board4 × SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel5 V coil4× digitalDatasheet
WS2812B addressable RGB LED strip (60 LED/m)5 V, 60 mA per LED at full white, 800 kHz single-wire protocol, 8-bit per channel5 V1-wire timed protocolDatasheet
Active piezo buzzer 5 V85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter3–5 VDigital / PWMDatasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32-S3 DevKitC-13.3 V logic / 5 V USB180The vector extensions roughly triple TinyML inference speed over the original ESP32.
INMP441 I²S MEMS microphone1.8–3.3 V1.4Digital output means no analogue noise pickup — far better than an MAX9814 for keyword spotting.
4-channel opto-isolated relay board5 V coil280All four coils energised draw ~280 mA — do not power from the MCU 5 V pin.
WS2812B addressable RGB LED strip (60 LED/m)5 V60Budget 60 mA × LED count; add a 1000 µF cap and a 330 Ω series resistor on data.
Active piezo buzzer 5 V3–5 V30Active buzzers make tone on DC; passive ones need a PWM carrier.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
5 V 3 A regulated SMPS adapter5 V3000Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.

Summed typical draw is 3559.4 mA. With a 1.5× design margin the supply should deliver at least 5400 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 core 3.x (board: ESP32S3 Dev Module, PSRAM enabled) + Python 3.11 for training. Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools → Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
  • Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
FreeRTOS (ESP-IDF) bundledTask scheduling so networking never blocks sensor sampling.Bundled with the ESP32 core
TensorFlow Lite for Microcontrollers 2.4.0-alphaInt8 neural-network inference inside 200 KB of RAM.Library Manager → "TensorFlowLite_ESP32"
Edge Impulse Arduino SDK per-project exportDeployable C++ bundle of a trained TinyML classifier.Sketch → Include Library → Add .ZIP from the Edge Impulse export
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
FastLED 3.6.xTiming-exact WS2812B driver with colour-correction and palettes.Library Manager → "FastLED"
Python 3.11+Runtime for the analysis, training and service code.sudo apt install python3 python3-venv python3-pip
TensorFlow / Keras 2.17+High-level model building and the TFLite converter.pip install tensorflow
NumPy 1.26+Vectorised array maths underpinning every other library here.pip install numpy
librosa 0.10+Audio loading, resampling, MFCC and spectrogram features.pip install librosa

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Voice-Controlled Home Hub — system block diagramFunctional block diagram of the Voice-Controlled Home Hub system. CaptureINMP441 micI²S 16 kHz 24-bitRing buffercore 0 taskFeaturesPre-emphasis + Hannfixed-pointMel spectrogram40 bands × 49 framesInferenceWake-word model18 KB, always onCommand model58 KB, gatedActionHysteresis filter3 framesRelay / MQTTpublish intent30 ms windowint8 tensorintent + score
Voice-Controlled Home Hub — system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

Voice-Controlled Home Hub — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32-S3 DevKitC-13.3 V logic / 5 V USBINMP441 microphoneGPIO 14I²S bit clockINMP441 microphoneGPIO 15I²S word selectINMP441 microphoneGPIO 32I²S serial dataINMP441 microphoneGNDSelects the leftchannelMode / mute buttonGPIO 0Also the BOOT pinWS2812 status ring (12 px)GPIO 48800 kHz, 330 Ωseries4-channel relay boardGPIO 4 5 6 7Active-lowPiezo buzzerGPIO 17LEDC PWM tone
Voice-Controlled Home Hub — wiring schematic
PeripheralPeripheral pinController pinSignal
INMP441 microphoneSCK (BCLK)GPIO 14I²S bit clock
INMP441 microphoneWS (LRCL)GPIO 15I²S word select
INMP441 microphoneSD (DOUT)GPIO 32I²S serial data
INMP441 microphoneL/RGNDSelects the left channel
Mode / mute buttonNO contactGPIO 0Also the BOOT pin
WS2812 status ring (12 px)DINGPIO 48800 kHz, 330 Ω series
4-channel relay boardIN1–IN4GPIO 4 5 6 7Active-low
Piezo buzzer+GPIO 17LEDC PWM tone

Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • The INMP441 is an I²S digital microphone, not analogue. There is no ADC involved on the ESP32 side — the peripheral clocks 24-bit samples straight out of the microphone, which is why the noise floor is so much lower than an analogue electret plus op-amp.
  • Tie the microphone L/R pin to ground to select the left channel. Left floating, the microphone may output on either slot and you will read silence half the time.
  • Keep the three I²S lines short (under 15 cm) and run a ground wire alongside them. The bit clock is 1.024 MHz at 16 kHz × 32 bits × 2 channels and it radiates.
  • Place the microphone port on the enclosure face with a 2–3 mm hole and a fabric mesh behind it. Do not cover it with anything solid, and do not glue it — mechanical coupling to the case turns every knock into a false trigger.
  • The WS2812 ring wants 5 V data ideally, but works reliably from a 3.3 V ESP32-S3 for short runs. If the first pixel misbehaves, add a level shifter or sacrifice one pixel as a buffer.
  • The relay board must be powered from the 5 V rail, not the ESP32 3V3 pin. Four energised coils draw about 280 mA — far beyond what the on-board regulator will supply.
A schematic of a feed-forward artificial neural network
A schematic feed-forward neural network. The keyword spotter here is a convolutional variant, but the same layer-and-weights structure underlies it. Photograph sourced from Wikimedia Commons — Artificial neural network.svg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

Voice-Controlled Home Hub — architecture stackLayered architecture from hardware to user interface. Acoustic front endINMP441 MEMS mic · I²S peripheral · DMA ring bufferFeature extractionpre-emphasis · Hann window · fixed-point FFT · Mel filterbank · logcompressionInferenceTFLite Micro interpreter · int8 DS-CNN · tensor arena in PSRAMDecisionsoftmax · per-keyword threshold · consecutive-frame hysteresis · debouncetimerActuation & transportrelay driver · MQTT intent publish · WS2812 feedback
Voice-Controlled Home Hub — architecture stack

Working Principle

Speech recognition on a microcontroller is an exercise in throwing information away intelligently. Raw audio at 16 kHz is 16 000 numbers per second; a keyword decision needs perhaps 2 000 numbers per second of useful information. The Mel spectrogram is the standard way of making that reduction, and it is worth understanding why it works.

Start with a 30 ms window of samples — 480 at 16 kHz. Speech is roughly stationary over that span: a vowel does not change identity in 30 ms. Multiply by a Hann window to taper the edges, because an abrupt cut produces spectral leakage that smears energy across every frequency bin. Take the magnitude of the FFT and discard the phase; for keyword identity, phase carries almost nothing. That leaves 256 magnitude bins.

Now compress those 256 bins into about 40, using triangular filters spaced on the Mel scale. The Mel scale is approximately linear below 1 kHz and logarithmic above, which mirrors how the cochlea resolves frequency — we discriminate 200 Hz from 300 Hz easily, and 5000 Hz from 5100 Hz not at all. Filters that follow that curve preserve the information the ear uses and throw away the rest. Take the logarithm of each filter output, because loudness perception is roughly logarithmic and because it compresses the dynamic range into something an int8 network can represent.

Slide that window forward 20 ms at a time and stack the results, and after one second you have a 49 × 40 image. That image is what the network actually sees. The word "kitchen" produces a visually distinctive pattern — a burst of high-frequency energy for the /k/, a formant structure for the vowel, another burst for the /tʃ/ — and a convolutional network is extremely good at learning those patterns.

The network itself is a depthwise-separable CNN, the architecture Google published as DS-CNN in their Hello Edge work and which remains the practical default for this task. A standard convolution over C input channels with K output channels and a 3 × 3 kernel costs 9·C·K multiply-accumulates per output pixel. A depthwise-separable convolution splits that into a 3 × 3 spatial filter per channel (9·C) followed by a 1 × 1 mix across channels (C·K), which is roughly 8–9× cheaper for typical channel counts at nearly the same accuracy. That factor is exactly what makes the difference between fitting in a microcontroller and not.

Finally, quantisation. Training happens in float32; deployment does not. Post-training quantisation maps each tensor to int8 using a per-axis scale and zero point, so a weight w is stored as round(w / scale) + zero_point. The model shrinks 4× and integer arithmetic runs several times faster on hardware with no FPU-heavy vector unit. Typical accuracy cost for this class of model is well under one percentage point, provided you supply a representative dataset during conversion so the converter can measure the real activation ranges.

The maths behind it

Mel scale conversion

plainMel scale conversion
m = 2595 · log10(1 + f / 700)
f = 700 · (10^(m / 2595) − 1)

40 filters spanning 80 Hz – 7600 Hz:
  m_low  = 2595 · log10(1 + 80/700)   ≈  120.4 mel
  m_high = 2595 · log10(1 + 7600/700) ≈ 2762.4 mel
  spacing = (2762.4 − 120.4) / 41     ≈   64.4 mel

Filter centres are evenly spaced in mel and therefore unevenly spaced in hertz — narrow and closely packed at low frequency, wide and sparse at high frequency.

Int8 quantisation

plainInt8 quantisation
real  = scale × (quantised − zero_point)
scale = (real_max − real_min) / 255
zero_point = round(−real_min / scale) − 128

Example activation range [−6.0, +2.0]:
  scale      = 8.0 / 255      = 0.03137
  zero_point = round(6.0/0.03137) − 128 = 63

Every tensor carries its own scale and zero point, which is why a representative calibration dataset matters: the converter derives these constants from observed activations.

Inference cost

plainInference cost
Standard 3×3 conv:  9 · C_in · C_out       MACs / pixel
Depthwise-separable: 9 · C_in + C_in · C_out

C_in = C_out = 64:
  standard    = 9 · 64 · 64 = 36 864 MACs
  separable   = 9 · 64 + 64 · 64 = 4 672 MACs
  reduction   = 7.9×

The saving grows with channel count, which is why every mobile and microcontroller vision or audio model uses this decomposition.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Voice-Controlled Home Hub — firmware flowchartControl flow through the main program loop. Boot: init I²S, load models,join Wi-FiCore 0 fills the audio ringbufferEnergy above adaptivenoise floor?yessleep 10 msCompute Mel spectrogram forthis windowRun the wake-word modelWake word detected?yesback to listeningRun the command model on thenext 1 sScore > threshold for 3frames?acceptflash red, log rejectionSwitch relay and publish MQTTintentReturn to listening
Voice-Controlled Home Hub — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

  1. Bring up the microphone first

    Wire only the INMP441 and run the I²S capture sketch from step 1. Print the RMS of each buffer and confirm it rises when you speak and falls in silence. A microphone that reads a constant value has its L/R pin floating or its data line on the wrong GPIO.

  2. Mount the microphone acoustically, not structurally

    Stick the INMP441 board to a small foam pad rather than directly to the enclosure. Line up the microphone port hole in the PCB with a 2.5 mm hole in the case and leave a 1 mm air gap. Cover the outside with acoustic mesh, never with tape.

  3. Add the status ring and buzzer

    The WS2812 ring is the user interface. Fit it behind a diffuser — a disc of 1 mm white acrylic or even printer paper — so it reads as a glow rather than twelve point sources.

  4. Wire the relay board on the far side of the enclosure

    Keep the mains section physically separated from the microphone and the ESP32-S3, with a solid barrier if the enclosure allows. Relay switching transients are broadband electrical noise, and the I²S clock lines are the last thing you want them coupling into.

  5. Close up and re-test acoustically

    Recognition accuracy measured with the lid off is not the accuracy you will get with it on. The enclosure changes the frequency response. Always do your final threshold tuning with the case fully assembled and the unit in its final position.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Capture audio over I²S and measure the level

    This sketch does nothing but read the microphone and print a level meter. Get it right before anything else — every later problem is easier to diagnose when you trust the audio.

    cpp01-i2s-capture.ino
    #include <driver/i2s.h>
    
    #define I2S_BCLK  14
    #define I2S_LRCL  15
    #define I2S_DOUT  32
    #define SAMPLE_RATE 16000
    #define FRAME_LEN   512
    
    int32_t raw[FRAME_LEN];        // INMP441 delivers 24-bit left-justified in 32
    
    void setup() {
      Serial.begin(115200);
    
      i2s_config_t cfg = {
        .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
        .sample_rate = SAMPLE_RATE,
        .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
        .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
        .communication_format = I2S_COMM_FORMAT_STAND_I2S,
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
        .dma_buf_count = 8,        // 8 x 512 samples = 256 ms of slack
        .dma_buf_len = FRAME_LEN,
        .use_apll = true           // cleaner clock than the PLL divider
      };
      i2s_pin_config_t pins = {
        .bck_io_num = I2S_BCLK,  .ws_io_num = I2S_LRCL,
        .data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_DOUT
      };
      i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
      i2s_set_pin(I2S_NUM_0, &pins);
    }
    
    void loop() {
      size_t got = 0;
      i2s_read(I2S_NUM_0, raw, sizeof(raw), &got, portMAX_DELAY);
      int n = got / sizeof(int32_t);
    
      // Shift right by 11 to land 24-bit audio in a signed 16-bit range.
      double sumSq = 0;
      for (int i = 0; i < n; i++) {
        int16_t s = (int16_t)(raw[i] >> 11);
        sumSq += (double)s * s;
      }
      float rms = sqrt(sumSq / n);
      float db  = 20.0f * log10f(rms / 32768.0f + 1e-9f);
    
      Serial.printf("rms %7.1f  %6.1f dBFS  ", rms, db);
      for (int i = 0; i < (int)((db + 80) / 2); i++) Serial.print('#');
      Serial.println();
    }
    I2S_BITS_PER_SAMPLE_32BITThe INMP441 is a 24-bit part but transmits in 32-bit slots. Configuring 16-bit here is the classic mistake — you get half of each sample and the audio sounds like static.
    raw[i] >> 11The 24 bits sit left-justified in the 32-bit word. Shifting right by 11 keeps the top 16 bits with a little headroom, which is a good working level for speech without clipping on a loud word.
    use_apll = trueThe audio PLL generates a much lower-jitter bit clock than dividing the main PLL. Jitter shows up as a raised noise floor, which directly costs you recognition accuracy in a quiet room.
    dma_buf_count = 8Eight buffers give 256 ms of slack. If inference occasionally overruns, DMA keeps filling buffers rather than dropping samples — dropped samples corrupt the spectrogram and produce mysterious mis-detections.
  2. Collect your own training data

    Public keyword datasets are a starting point, not a solution. Record in the room the device will live in, with the people who will use it. Aim for 60–100 utterances per keyword across at least three speakers, plus five minutes of pure background noise from that room, plus a "not a command" class made of ordinary conversation and television audio.

    pythonrecord_samples.py
    #!/usr/bin/env python3
    """Record labelled 1-second keyword clips at 16 kHz.
    
        python3 record_samples.py --label lights_on --count 60
    """
    import argparse
    import pathlib
    import queue
    import sys
    
    import numpy as np
    import sounddevice as sd
    import soundfile as sf
    
    RATE = 16_000
    CLIP = 1.0  # seconds — must match the model's input length
    
    
    def record_one(seconds: float = CLIP) -> np.ndarray:
        frames: queue.Queue = queue.Queue()
        with sd.InputStream(samplerate=RATE, channels=1, dtype="int16",
                            callback=lambda d, *_: frames.put(d.copy())):
            sd.sleep(int(seconds * 1000))
        chunks = []
        while not frames.empty():
            chunks.append(frames.get())
        clip = np.concatenate(chunks).flatten()
        # Pad or trim to exactly CLIP seconds so every example has one shape.
        want = int(RATE * seconds)
        return np.pad(clip, (0, max(0, want - len(clip))))[:want]
    
    
    def main() -> None:
        ap = argparse.ArgumentParser()
        ap.add_argument("--label", required=True)
        ap.add_argument("--count", type=int, default=60)
        ap.add_argument("--out", default="dataset")
        args = ap.parse_args()
    
        out = pathlib.Path(args.out) / args.label
        out.mkdir(parents=True, exist_ok=True)
        start = len(list(out.glob("*.wav")))
    
        print(f"Recording {args.count} clips for '{args.label}'.")
        print("Vary your distance, angle and speed. Enter to record, q to stop.")
        for i in range(args.count):
            if input(f"[{i + 1}/{args.count}] > ").strip().lower() == "q":
                break
            clip = record_one()
            peak = np.abs(clip).max() / 32768
            if peak < 0.02:
                print("  too quiet — discarded, move closer")
                continue
            if peak > 0.98:
                print("  clipped — discarded, move back")
                continue
            sf.write(out / f"{args.label}_{start + i:04d}.wav", clip, RATE)
            print(f"  saved  peak {peak:.2f}")
    
    
    if __name__ == "__main__":
        sys.exit(main())
    peak < 0.02 / > 0.98Automatic quality gates. Clips that are near-silent or clipped teach the model nothing useful and actively hurt — rejecting them at capture time is far cheaper than cleaning the dataset later.
    np.pad(...)[:want]Every example must be exactly one second. A model with a fixed input shape cannot accept variable-length audio, and silently truncating during training is a subtle source of label noise.
    "Vary your distance, angle and speed"This is the most important line in the script. A dataset recorded at one distance in one tone of voice produces a model that only works at that distance in that tone of voice.
  3. Train and quantise the model

    The architecture is small enough to train on a laptop CPU in about fifteen minutes. Resist the temptation to make it bigger — accuracy on this task is bounded by your data, not your parameter count.

    pythontrain_kws.py
    #!/usr/bin/env python3
    """Train a depthwise-separable CNN keyword spotter and export int8 TFLite."""
    import pathlib
    
    import numpy as np
    import tensorflow as tf
    
    RATE, CLIP = 16_000, 1.0
    N_MELS, N_FRAMES = 40, 49
    LABELS = ["_background", "_unknown", "lights_on", "lights_off",
              "fan_on", "fan_off", "scene_movie", "all_off"]
    
    
    def log_mel(waveform: tf.Tensor) -> tf.Tensor:
        """480-sample window, 320-sample hop -> 49 x 40 log-mel image."""
        stft = tf.signal.stft(waveform, frame_length=480, frame_step=320, fft_length=512)
        spec = tf.abs(stft)
        mel_w = tf.signal.linear_to_mel_weight_matrix(
            num_mel_bins=N_MELS, num_spectrogram_bins=stft.shape[-1],
            sample_rate=RATE, lower_edge_hertz=80.0, upper_edge_hertz=7600.0)
        mel = tf.tensordot(spec, mel_w, 1)
        return tf.math.log(mel + 1e-6)[..., tf.newaxis]
    
    
    def build_model() -> tf.keras.Model:
        inp = tf.keras.Input(shape=(N_FRAMES, N_MELS, 1))
        x = tf.keras.layers.Conv2D(32, (3, 3), strides=(2, 2), padding="same",
                                   use_bias=False)(inp)
        x = tf.keras.layers.BatchNormalization()(x)
        x = tf.keras.layers.ReLU()(x)
    
        for _ in range(4):                       # 4 depthwise-separable blocks
            x = tf.keras.layers.DepthwiseConv2D((3, 3), padding="same", use_bias=False)(x)
            x = tf.keras.layers.BatchNormalization()(x)
            x = tf.keras.layers.ReLU()(x)
            x = tf.keras.layers.Conv2D(32, (1, 1), padding="same", use_bias=False)(x)
            x = tf.keras.layers.BatchNormalization()(x)
            x = tf.keras.layers.ReLU()(x)
    
        x = tf.keras.layers.GlobalAveragePooling2D()(x)
        x = tf.keras.layers.Dropout(0.25)(x)
        out = tf.keras.layers.Dense(len(LABELS), activation="softmax")(x)
        return tf.keras.Model(inp, out)
    
    
    def load_dataset(root="dataset"):
        xs, ys = [], []
        for idx, label in enumerate(LABELS):
            for wav in pathlib.Path(root, label).glob("*.wav"):
                audio, _ = tf.audio.decode_wav(tf.io.read_file(str(wav)),
                                               desired_channels=1,
                                               desired_samples=int(RATE * CLIP))
                xs.append(log_mel(tf.squeeze(audio, -1)))
                ys.append(idx)
        return np.stack(xs), np.array(ys)
    
    
    def main() -> None:
        x, y = load_dataset()
        print(f"{len(x)} examples, {len(LABELS)} classes")
    
        perm = np.random.permutation(len(x))
        x, y = x[perm], y[perm]
        split = int(0.85 * len(x))
        x_tr, y_tr, x_va, y_va = x[:split], y[:split], x[split:], y[split:]
    
        model = build_model()
        model.compile(optimizer=tf.keras.optimizers.Adam(1e-3),
                      loss="sparse_categorical_crossentropy",
                      metrics=["accuracy"])
        model.fit(x_tr, y_tr, validation_data=(x_va, y_va),
                  epochs=60, batch_size=64,
                  callbacks=[tf.keras.callbacks.EarlyStopping(
                      patience=8, restore_best_weights=True)])
    
        # ---- int8 quantisation -------------------------------------------
        def representative():
            for i in range(min(300, len(x_tr))):
                yield [x_tr[i:i + 1].astype(np.float32)]
    
        conv = tf.lite.TFLiteConverter.from_keras_model(model)
        conv.optimizations = [tf.lite.Optimize.DEFAULT]
        conv.representative_dataset = representative
        conv.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
        conv.inference_input_type = tf.int8
        conv.inference_output_type = tf.int8
        tflite = conv.convert()
    
        pathlib.Path("kws_int8.tflite").write_bytes(tflite)
        print(f"model size: {len(tflite) / 1024:.1f} KB")
    
        # Emit a C array the sketch can include directly.
        with open("model_data.h", "w") as f:
            f.write("alignas(16) const unsigned char g_model[] = {\n")
            for i in range(0, len(tflite), 12):
                f.write("  " + ", ".join(f"0x{b:02x}" for b in tflite[i:i + 12]) + ",\n")
            f.write("};\nconst unsigned int g_model_len = %d;\n" % len(tflite))
    
    
    if __name__ == "__main__":
        main()
    log_mel()Feature extraction lives in the training script and is mirrored exactly in the firmware. Any mismatch — a different window length, a different mel range — silently destroys accuracy at deployment while training metrics still look perfect.
    DepthwiseConv2D + Conv2D 1×1This pair is the depthwise-separable block: spatial filtering per channel, then a pointwise mix across channels. It is what makes the model roughly eight times cheaper than plain convolutions.
    GlobalAveragePooling2DReplaces a flatten-plus-dense head. It removes tens of thousands of parameters and makes the model tolerant of small time shifts in the keyword.
    representative_datasetThe converter runs these samples through the float model to observe real activation ranges and pick per-tensor scales. Skip it and every activation gets a crude default range, which typically costs 5–15 points of accuracy.
    _background and _unknown classesTwo negative classes, not one. "_background" is room noise; "_unknown" is speech that is not a command. Merging them makes the model confuse silence with conversation and raises false accepts sharply.
  4. Run inference on the device

    The firmware mirrors the training front end exactly, then runs the interpreter and applies hysteresis before acting.

    cpp02-inference-core.ino
    #include <TensorFlowLite_ESP32.h>
    #include "tensorflow/lite/micro/micro_interpreter.h"
    #include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
    #include "tensorflow/lite/schema/schema_generated.h"
    #include "model_data.h"
    
    constexpr int  N_FRAMES = 49, N_MELS = 40, N_LABELS = 8;
    constexpr int  ARENA_SIZE = 70 * 1024;
    
    static uint8_t *arena;                     // allocated in PSRAM
    static tflite::MicroInterpreter *interp;
    static TfLiteTensor *input, *output;
    
    const char *LABELS[N_LABELS] = {
      "_background", "_unknown", "lights_on", "lights_off",
      "fan_on", "fan_off", "scene_movie", "all_off"
    };
    // "off" words are acoustically weaker; they need a lower bar.
    const float THRESH[N_LABELS] = { 1.0f, 1.0f, 0.85f, 0.78f, 0.85f, 0.78f, 0.88f, 0.90f };
    
    void inferenceBegin() {
      arena = (uint8_t *)heap_caps_malloc(ARENA_SIZE, MALLOC_CAP_SPIRAM);
    
      static tflite::MicroMutableOpResolver<8> resolver;
      resolver.AddConv2D();
      resolver.AddDepthwiseConv2D();
      resolver.AddRelu();
      resolver.AddAveragePool2D();
      resolver.AddReshape();
      resolver.AddFullyConnected();
      resolver.AddSoftmax();
      resolver.AddQuantize();
    
      const tflite::Model *model = tflite::GetModel(g_model);
      static tflite::MicroInterpreter s(model, resolver, arena, ARENA_SIZE);
      interp = &s;
      interp->AllocateTensors();
      input  = interp->input(0);
      output = interp->output(0);
    
      Serial.printf("arena used: %u bytes\n", (unsigned)interp->arena_used_bytes());
    }
    
    // features[] holds the float log-mel image; quantise it into the tensor.
    int classify(const float *features, float *bestScore) {
      const float  s  = input->params.scale;
      const int    zp = input->params.zero_point;
      int8_t      *dst = input->data.int8;
    
      for (int i = 0; i < N_FRAMES * N_MELS; i++) {
        int v = (int)lroundf(features[i] / s) + zp;
        dst[i] = (int8_t)(v < -128 ? -128 : (v > 127 ? 127 : v));
      }
    
      if (interp->Invoke() != kTfLiteOk) return -1;
    
      const float os  = output->params.scale;
      const int   ozp = output->params.zero_point;
      int   best = 0;
      float bestP = -1;
      for (int i = 0; i < N_LABELS; i++) {
        float p = os * (output->data.int8[i] - ozp);
        if (p > bestP) { bestP = p; best = i; }
      }
      *bestScore = bestP;
      return best;
    }
    
    // Three consecutive agreeing frames above threshold before we act.
    int stableIntent(int label, float score) {
      static int   lastLabel = -1;
      static int   run = 0;
      static uint32_t lastFire = 0;
    
      if (label != lastLabel) { lastLabel = label; run = 0; }
      run = (score >= THRESH[label]) ? run + 1 : 0;
    
      if (run >= 3 && millis() - lastFire > 1500) {   // 1.5 s command debounce
        run = 0;
        lastFire = millis();
        return label;
      }
      return -1;
    }
    heap_caps_malloc(..., MALLOC_CAP_SPIRAM)The 70 KB tensor arena goes into the S3's external PSRAM rather than internal SRAM, leaving internal memory free for the I²S DMA buffers and the Wi-Fi stack — which are both latency-critical and must not be in PSRAM.
    MicroMutableOpResolver<8>Registers only the eight operators this model actually uses. The all-ops resolver pulls in every kernel TFLite Micro knows about and adds roughly 100 KB of flash for no benefit.
    lroundf(features[i] / s) + zpManual quantisation of the input. The scale and zero point come from the model file itself, so this code stays correct if you retrain and the ranges change.
    THRESH[] per labelUnvoiced fricatives such as the /f/ in "off" carry far less energy than a plosive, so a single global threshold either misses "off" or over-triggers on "on". Per-keyword thresholds are the single cheapest accuracy improvement available.
    run >= 3 && millis() - lastFire > 1500Two independent guards. The run counter rejects momentary spikes from a television; the debounce timer stops one long utterance from firing the same command three times.

Complete Source Code

The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.

cppvoice-home-hub.ino
/* ═══════════════════════════════════════════════════════════════
   Offline Voice-Controlled Home Hub — ESP32-S3 + INMP441 + TFLite Micro

   Audio never leaves the device. A dedicated FreeRTOS task on core 0
   fills a ring buffer from I²S; core 1 computes a log-mel spectrogram
   and runs an int8 keyword-spotting model. Only the resulting intent
   is published over MQTT.

   Board: ESP32S3 Dev Module, PSRAM: OPI, Flash: 8 MB
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <driver/i2s.h>
#include <FastLED.h>
#include <math.h>

#include <TensorFlowLite_ESP32.h>
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "model_data.h"

/* ── configuration ──────────────────────────────────────────── */
#define WIFI_SSID   "YOUR_WIFI"
#define WIFI_PASS   "YOUR_PASSWORD"
#define MQTT_HOST   "192.168.1.50"
#define MQTT_PORT   1883
#define DEVICE_ID   "voice-hub"

#define I2S_BCLK    14
#define I2S_LRCL    15
#define I2S_DOUT    32
#define PIN_LEDS    48
#define PIN_BUZZER  17
#define N_LEDS      12

const uint8_t RELAY_PIN[4] = { 4, 5, 6, 7 };

#define SAMPLE_RATE 16000
#define WIN_LEN     480          // 30 ms
#define HOP_LEN     320          // 20 ms
#define FFT_LEN     512
#define N_MELS      40
#define N_FRAMES    49           // 49 hops ≈ 1.0 s
#define RING_LEN    (SAMPLE_RATE)   // 1 s of int16 audio
#define ARENA_SIZE  (70 * 1024)

const char *LABELS[] = { "_background", "_unknown", "lights_on", "lights_off",
                         "fan_on", "fan_off", "scene_movie", "all_off" };
const float THRESH[] = { 1.0f, 1.0f, 0.85f, 0.78f, 0.85f, 0.78f, 0.88f, 0.90f };
#define N_LABELS (sizeof(LABELS) / sizeof(LABELS[0]))

/* ── globals ────────────────────────────────────────────────── */
WiFiClient   net;
PubSubClient mqtt(net);
CRGB         leds[N_LEDS];

static int16_t  *ring;            // PSRAM audio ring buffer
static volatile uint32_t ringHead = 0;
static float    *melFilters;      // N_MELS x (FFT_LEN/2+1), precomputed
static float    *features;        // N_FRAMES x N_MELS
static float     noiseFloor = 0.002f;

static uint8_t  *arena;
static tflite::MicroInterpreter *interp;
static TfLiteTensor *inputT, *outputT;

/* ── mel filterbank (computed once at boot) ─────────────────── */
static float hzToMel(float hz)  { return 2595.0f * log10f(1.0f + hz / 700.0f); }
static float melToHz(float mel) { return 700.0f * (powf(10.0f, mel / 2595.0f) - 1.0f); }

void buildMelFilters() {
  const int bins = FFT_LEN / 2 + 1;
  melFilters = (float *)heap_caps_calloc(N_MELS * bins, sizeof(float), MALLOC_CAP_SPIRAM);

  float mLow = hzToMel(80.0f), mHigh = hzToMel(7600.0f);
  float edges[N_MELS + 2];
  for (int i = 0; i < N_MELS + 2; i++)
    edges[i] = melToHz(mLow + (mHigh - mLow) * i / (N_MELS + 1));

  for (int m = 0; m < N_MELS; m++) {
    float f0 = edges[m], f1 = edges[m + 1], f2 = edges[m + 2];
    for (int k = 0; k < bins; k++) {
      float f = (float)k * SAMPLE_RATE / FFT_LEN;
      float w = 0.0f;
      if (f >= f0 && f <= f1)      w = (f - f0) / (f1 - f0);
      else if (f > f1 && f <= f2)  w = (f2 - f) / (f2 - f1);
      melFilters[m * bins + k] = w;
    }
  }
}

/* ── radix-2 in-place FFT (real input, complex output) ──────── */
void fft(float *re, float *im, int n) {
  for (int i = 1, j = 0; i < n; i++) {          // bit-reversal permutation
    int bit = n >> 1;
    for (; j & bit; bit >>= 1) j ^= bit;
    j ^= bit;
    if (i < j) { float t = re[i]; re[i] = re[j]; re[j] = t;
                 t = im[i]; im[i] = im[j]; im[j] = t; }
  }
  for (int len = 2; len <= n; len <<= 1) {
    float ang = -2.0f * (float)M_PI / len;
    float wr = cosf(ang), wi = sinf(ang);
    for (int i = 0; i < n; i += len) {
      float cr = 1.0f, ci = 0.0f;
      for (int k = 0; k < len / 2; k++) {
        float ur = re[i + k],           ui = im[i + k];
        float vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
        float vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
        re[i + k] = ur + vr;            im[i + k] = ui + vi;
        re[i + k + len / 2] = ur - vr;  im[i + k + len / 2] = ui - vi;
        float nr = cr * wr - ci * wi;
        ci = cr * wi + ci * wr;         cr = nr;
      }
    }
  }
}

/* ── log-mel spectrogram over the last 1 s of the ring ──────── */
void computeFeatures() {
  static float re[FFT_LEN], im[FFT_LEN];
  const int bins = FFT_LEN / 2 + 1;
  uint32_t start = (ringHead + RING_LEN - (N_FRAMES - 1) * HOP_LEN - WIN_LEN) % RING_LEN;

  for (int f = 0; f < N_FRAMES; f++) {
    memset(re, 0, sizeof(re));
    memset(im, 0, sizeof(im));

    float prev = 0;
    for (int n = 0; n < WIN_LEN; n++) {
      float s = ring[(start + f * HOP_LEN + n) % RING_LEN] / 32768.0f;
      float pe = s - 0.97f * prev;                     // pre-emphasis
      prev = s;
      float w = 0.5f - 0.5f * cosf(2.0f * (float)M_PI * n / (WIN_LEN - 1)); // Hann
      re[n] = pe * w;
    }
    fft(re, im, FFT_LEN);

    for (int m = 0; m < N_MELS; m++) {
      float acc = 0;
      const float *row = &melFilters[m * bins];
      for (int k = 0; k < bins; k++)
        if (row[k] > 0) acc += row[k] * sqrtf(re[k] * re[k] + im[k] * im[k]);
      features[f * N_MELS + m] = logf(acc + 1e-6f);
    }
  }
}

/* ── model ──────────────────────────────────────────────────── */
void inferenceBegin() {
  arena = (uint8_t *)heap_caps_malloc(ARENA_SIZE, MALLOC_CAP_SPIRAM);

  static tflite::MicroMutableOpResolver<8> resolver;
  resolver.AddConv2D();       resolver.AddDepthwiseConv2D();
  resolver.AddRelu();         resolver.AddAveragePool2D();
  resolver.AddReshape();      resolver.AddFullyConnected();
  resolver.AddSoftmax();      resolver.AddQuantize();

  static tflite::MicroInterpreter s(tflite::GetModel(g_model), resolver,
                                    arena, ARENA_SIZE);
  interp = &s;
  interp->AllocateTensors();
  inputT  = interp->input(0);
  outputT = interp->output(0);
  Serial.printf("arena used %u B, model %u B\n",
                (unsigned)interp->arena_used_bytes(), g_model_len);
}

int classify(float *bestScore) {
  const float s = inputT->params.scale;
  const int  zp = inputT->params.zero_point;
  for (int i = 0; i < N_FRAMES * N_MELS; i++) {
    int v = (int)lroundf(features[i] / s) + zp;
    inputT->data.int8[i] = (int8_t)(v < -128 ? -128 : (v > 127 ? 127 : v));
  }
  if (interp->Invoke() != kTfLiteOk) return -1;

  const float os = outputT->params.scale;
  const int  ozp = outputT->params.zero_point;
  int best = 0; float bestP = -1;
  for (size_t i = 0; i < N_LABELS; i++) {
    float p = os * (outputT->data.int8[i] - ozp);
    if (p > bestP) { bestP = p; best = (int)i; }
  }
  *bestScore = bestP;
  return best;
}

/* ── audio capture task (core 0) ────────────────────────────── */
void audioTask(void *) {
  static int32_t raw[256];
  size_t got;
  for (;;) {
    i2s_read(I2S_NUM_0, raw, sizeof(raw), &got, portMAX_DELAY);
    int n = got / sizeof(int32_t);
    for (int i = 0; i < n; i++) {
      ring[ringHead] = (int16_t)(raw[i] >> 11);
      ringHead = (ringHead + 1) % RING_LEN;
    }
  }
}

float ringRms() {
  double acc = 0;
  for (int i = 0; i < 1600; i++) {                 // last 100 ms
    int16_t s = ring[(ringHead + RING_LEN - 1 - i) % RING_LEN];
    acc += (double)s * s;
  }
  return sqrtf(acc / 1600) / 32768.0f;
}

/* ── feedback ───────────────────────────────────────────────── */
void ledState(CRGB c, uint8_t brightness) {
  fill_solid(leds, N_LEDS, c);
  FastLED.setBrightness(brightness);
  FastLED.show();
}

/* ── actions ────────────────────────────────────────────────── */
void applyIntent(int label) {
  const char *name = LABELS[label];

  if      (!strcmp(name, "lights_on"))  digitalWrite(RELAY_PIN[0], LOW);
  else if (!strcmp(name, "lights_off")) digitalWrite(RELAY_PIN[0], HIGH);
  else if (!strcmp(name, "fan_on"))     digitalWrite(RELAY_PIN[1], LOW);
  else if (!strcmp(name, "fan_off"))    digitalWrite(RELAY_PIN[1], HIGH);
  else if (!strcmp(name, "scene_movie")) {
    digitalWrite(RELAY_PIN[0], HIGH);
    digitalWrite(RELAY_PIN[2], LOW);
  } else if (!strcmp(name, "all_off")) {
    for (int i = 0; i < 4; i++) digitalWrite(RELAY_PIN[i], HIGH);
  }

  JsonDocument doc;
  doc["device"] = DEVICE_ID;
  doc["intent"] = name;
  doc["ts"]     = millis() / 1000;
  char buf[128];
  size_t n = serializeJson(doc, buf, sizeof(buf));
  mqtt.publish("home/voice/" DEVICE_ID "/intent", (const uint8_t *)buf, n, false);

  ledState(CRGB::Green, 80);
  tone(PIN_BUZZER, 2400, 90);
  delay(220);
  ledState(CRGB::Blue, 12);
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  for (int i = 0; i < 4; i++) { pinMode(RELAY_PIN[i], OUTPUT); digitalWrite(RELAY_PIN[i], HIGH); }
  pinMode(PIN_BUZZER, OUTPUT);

  FastLED.addLeds<WS2812B, PIN_LEDS, GRB>(leds, N_LEDS);
  ledState(CRGB::Orange, 30);

  ring     = (int16_t *)heap_caps_calloc(RING_LEN, sizeof(int16_t), MALLOC_CAP_SPIRAM);
  features = (float  *)heap_caps_calloc(N_FRAMES * N_MELS, sizeof(float), MALLOC_CAP_SPIRAM);
  buildMelFilters();

  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8, .dma_buf_len = 256, .use_apll = true
  };
  i2s_pin_config_t pins = { .bck_io_num = I2S_BCLK, .ws_io_num = I2S_LRCL,
                            .data_out_num = I2S_PIN_NO_CHANGE, .data_in_num = I2S_DOUT };
  i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pins);

  inferenceBegin();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  mqtt.setServer(MQTT_HOST, MQTT_PORT);

  // Capture pinned to core 0; inference runs on core 1 in loop().
  xTaskCreatePinnedToCore(audioTask, "audio", 4096, NULL, 5, NULL, 0);

  ledState(CRGB::Blue, 12);
  Serial.println("Voice hub listening — audio stays on this device");
}

void loop() {
  if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) mqtt.connect(DEVICE_ID);
  mqtt.loop();

  float rms = ringRms();
  noiseFloor = 0.999f * noiseFloor + 0.001f * rms;      // slow adaptation

  if (rms < noiseFloor * 3.0f) { delay(10); return; }   // energy gate

  ledState(CRGB::Cyan, 40);
  uint32_t t0 = micros();
  computeFeatures();
  uint32_t t1 = micros();
  float score;
  int label = classify(&score);
  uint32_t t2 = micros();

  static int lastLabel = -1, run = 0;
  static uint32_t lastFire = 0;
  if (label != lastLabel) { lastLabel = label; run = 0; }
  run = (label >= 2 && score >= THRESH[label]) ? run + 1 : 0;

  Serial.printf("%-12s %.2f  feat %lu us  inf %lu us  run %d\n",
                label >= 0 ? LABELS[label] : "?", score,
                (unsigned long)(t1 - t0), (unsigned long)(t2 - t1), run);

  if (run >= 3 && millis() - lastFire > 1500) {
    run = 0; lastFire = millis();
    applyIntent(label);
  } else {
    ledState(CRGB::Blue, 12);
  }
}
xTaskCreatePinnedToCore(audioTask, ..., 0)Capture gets its own core. If audio capture shared a core with a 20 ms inference, the DMA buffers would occasionally overflow and drop samples — which corrupts the spectrogram in ways that look like random mis-recognition.
noiseFloor = 0.999f * noiseFloor + 0.001f * rmsA single-pole low-pass with a time constant of roughly 30 s at this call rate. It tracks the room getting noisier (a fan switching on) without following a spoken word, which would defeat the gate.
rms < noiseFloor * 3.0fThe energy gate is what keeps average power low: the expensive FFT and inference only run when something is actually happening. In a quiet room the device spends over 95 % of its time in this early return.
pre-emphasis s − 0.97·prevA first-order high-pass that boosts high frequencies. Speech has roughly −6 dB/octave spectral tilt; flattening it gives the higher formants comparable weight to the fundamental, which measurably improves consonant discrimination.
Hann windowTapering the window to zero at both ends prevents spectral leakage. Without it, the discontinuity at the window edge spreads energy across all frequency bins and blurs the formant structure the model relies on.
label >= 2Classes 0 and 1 are the two negative classes. Requiring label ≥ 2 means background and unknown speech can never trigger an action no matter how confident the model is.

Configuration & Calibration

Configuration steps

  • Set Tools → PSRAM → OPI PSRAM in the Arduino IDE. Without it, heap_caps_malloc(..., MALLOC_CAP_SPIRAM) returns null and the device crashes in setup().
  • Set Tools → Partition Scheme → Huge APP (3 MB). The TFLite Micro runtime plus the model does not fit in the default 1.2 MB app partition.
  • Set Tools → CPU Frequency → 240 MHz. At 160 MHz the feature extraction takes about 40 % longer, which eats the headroom that keeps the energy gate cheap.
  • Regenerate model_data.h whenever you retrain, and update LABELS[] and THRESH[] to match the label order in train_kws.py. A mismatched label order produces a device that works confidently and wrongly.
  • Tune THRESH[] from the serial log, not from intuition. Speak each command twenty times, note the scores, and set the threshold a little below the tenth percentile of true positives.
  • Adjust the energy-gate multiplier (3.0) for your room. Higher misses quiet speech; lower burns power on every fridge compressor cycle.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Establish the room noise floor

    Leave the device running with the serial log open for ten minutes with nobody speaking. Note the RMS values. The steady-state noiseFloor should settle within about 20 % of the median idle RMS. If it keeps climbing, something in the room is periodically loud and you should raise the gate multiplier.

  2. Measure the true-positive score distribution

    Speak each command twenty times from the position you will normally use. Record the reported score for each. A well-trained model gives true positives clustered above 0.9 with occasional dips to 0.7. Set that keyword's threshold just below the lowest score you are willing to accept.

  3. Measure the false-accept rate against television

    Play an hour of television or radio at normal volume with nobody in the room and count triggers. Zero is achievable. If you get more than one per hour, the fix is almost always more _unknown training data recorded from that same television, not a higher threshold.

  4. Verify timing headroom

    The serial log prints feature and inference times. Feature extraction should be around 3–5 ms and inference around 12–18 ms on an ESP32-S3 at 240 MHz. If inference exceeds 40 ms, check that PSRAM is enabled and the CPU is at full clock.

Network Architecture & Connectivity

The networking here is deliberately thin. The device publishes an intent string and, optionally, subscribes to a threshold-tuning topic. That is all. Because no audio ever crosses the network, the security posture of the whole system is dramatically simpler than a cloud assistant's.

Voice-Controlled Home Hub — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsVoice hubESP32-S3Second room huboptionalWi-Fi 2.4 GHzHome routerIoT VLANMQTT 1883/8883Local brokerMosquitto on a PiHome AssistantautomationsRelay nodesother roomsLog / dashboardscore history
Voice-Controlled Home Hub — network topology

Communication protocol

Intents are published as JSON on home/voice/<device>/intent with QoS 0. QoS 0 is correct here: if a "lights on" message is lost, the user will simply say it again within two seconds, and a delayed duplicate arriving thirty seconds later would be worse than a loss.

The device also publishes a rolling score log on a separate topic at QoS 0. That stream is what you use to tune thresholds from real data rather than guesses, and it is cheap enough to leave on permanently.

Topic / endpointDirectionPayload
home/voice/voice-hub/intentdevice → brokerJSON: device, intent, ts
home/voice/voice-hub/scoredevice → brokerJSON: label, score, rms, noise_floor
home/voice/voice-hub/configbroker → deviceJSON: thresholds[], gate_multiplier
home/voice/voice-hub/statusdevice → broker (retained)"online" / "offline" (LWT) + model hash

Message contract between the device and the broker.

Cloud platform configuration

Nothing needs to leave your network. A Mosquitto broker and Home Assistant on the same Raspberry Pi is the complete backend. If you want remote access, expose Home Assistant through a reverse proxy with TLS rather than exposing the broker.

Dashboard setup

Feed the score topic into InfluxDB and plot score against label over time in Grafana. Two panels are enough: a scatter of accepted scores (which should cluster near 1.0) and a histogram of rejected scores (which tells you exactly how much threshold headroom you have).

Mobile app integration

Adding the device to Home Assistant as an MQTT sensor gives you the mobile app, history and automation engine without writing anything. An automation triggered on intent == "all_off" can then do far more than the four relays on the board — including telling you, via a phone notification, that it heard you.

Security considerations

  • No audio is transmitted or stored. The ring buffer is overwritten continuously and never written to flash — verify this yourself before trusting the claim on any voice device, including this one.
  • Use MQTT over TLS and per-device credentials if the broker is reachable from outside the LAN.
  • Put the hub on an IoT VLAN. It has a microphone; treat it as the most sensitive device on the network even though it does not transmit audio.
  • Enable ESP32-S3 flash encryption before deployment if physical access is a concern — the model and Wi-Fi credentials are both readable from an unencrypted flash image.
  • Provide a hardware mute. A physical switch that cuts the microphone supply is the only mute a user can actually verify, and it costs ₹30.

Dataset, Model & Training

Dataset

The dataset is the project. Everything else is plumbing. You need three kinds of audio, and the ratio between them matters more than the total count.

Positives: 60–100 clips per command word, spread across every person who will use the device, at varied distances (0.5 m to 4 m), angles, speaking rates and volumes. Include deliberately sloppy pronunciations — that is what real usage sounds like.

Background: five to ten minutes of the actual room with nobody speaking, captured at different times of day so it includes the fridge, the fan, traffic and the air conditioner.

Unknown speech: at least as many clips as all your positives combined, drawn from ordinary conversation, television and radio. This is the class most people under-collect, and it is the direct cause of a hub that switches the lights during dinner.

Google's Speech Commands v0.02 dataset is a useful supplement for the _unknown class — 105 000 one-second clips of 35 words under a permissive licence — but it will not substitute for recordings of your own room.

DatasetSizeLicenceUse here
Your own recordings~800 clips (≈15 min)YoursAll positive classes and room background — the decisive part of the dataset.
Google Speech Commands v0.02105 829 clips, 2.3 GBCC BY 4.0Bulk of the _unknown class and augmentation for robustness.
MS-SNSD noise corpus~10 hMITNoise mixing during augmentation to simulate a noisier room.

Data preprocessing

  • Resample everything to 16 kHz mono, 16-bit PCM. Mismatched sample rates between training and deployment are the most common silent failure in TinyML audio.
  • Trim or pad every clip to exactly 1.000 s, aligning the keyword roughly centrally but with deliberate jitter of ±100 ms so the model does not learn a fixed onset time.
  • Normalise each clip to a peak of about −3 dBFS, then apply random gain of ±6 dB during training. Peak normalisation alone teaches the model that loudness is a feature, which it should not be.
  • Augment with time shift (±100 ms), background mixing at 0–15 dB SNR, and mild time stretching (0.9–1.1×). Do not augment with pitch shift beyond about ±10 % — it distorts formants and creates examples that do not occur in reality.
  • Compute log-mel features with exactly the same parameters used in the firmware: 480-sample window, 320-sample hop, 512-point FFT, 40 mel bands from 80 Hz to 7600 Hz.
Voice-Controlled Home Hub — ML pipelineFrom raw data through training to deployed inference. 1Recordown room, own voices2Label & cleanreject clipped/silent3Augmentshift, noise, gain4Log-mel49 × 40 image5Train DS-CNN60 epochs, Adam6Quantise int8representative set7Export C arraymodel_data.h8Deploy & measureon-device scores
Voice-Controlled Home Hub — ML pipeline

Model architecture

The network is a depthwise-separable CNN in the DS-CNN family. An initial strided 3×3 convolution reduces the 49 × 40 input to 25 × 20 with 32 channels, then four depthwise-separable blocks each apply a 3×3 spatial filter per channel followed by a 1×1 pointwise mix. Global average pooling collapses the spatial dimensions, dropout at 0.25 regularises, and a dense softmax produces eight class probabilities.

Every convolution uses use_bias=False because it is immediately followed by batch normalisation, which has its own shift parameter — a bias term there is redundant and simply adds parameters. At conversion time TFLite folds the batch-norm parameters into the preceding convolution weights, so the deployed model has no batch-norm layers at all.

Layer / stageShape or configurationPurpose
Input(49, 40, 1) int8One second of log-mel spectrogram.
Conv2D 3×3 s2(25, 20, 32)Cheap spatial downsample and initial feature extraction.
DS block ×4(25, 20, 32)Depthwise 3×3 then pointwise 1×1; the bulk of the model capacity.
GlobalAveragePool(32,)Collapses time and frequency; gives shift tolerance for free.
Dropout 0.25(32,)Regularisation — essential with a dataset of only a few hundred clips.
Dense + softmax(8,)Per-keyword probability.

Hyperparameters

HyperparameterValueWhy
OptimiserAdam, lr 1e-3Converges reliably on this scale of data without a learning-rate schedule.
Batch size64Large enough for stable batch-norm statistics, small enough to fit a laptop CPU.
Epochs60 with early stopping (patience 8)The model typically peaks around epoch 30–40; early stopping prevents memorising the training set.
Dropout0.25Higher hurts on a small model; lower overfits a few-hundred-clip dataset.
Mel bands40The standard for keyword spotting. 32 loses consonant detail; 64 costs FFT time for no measurable gain.
Window / hop30 ms / 20 msSpeech is quasi-stationary over 30 ms; a 10 ms overlap keeps transients from falling between frames.
QuantisationFull int8, per-axis weightsPer-axis (per output channel) scales cost nothing at inference and recover most of the accuracy lost by per-tensor quantisation.

Training process

  • Split by speaker, not randomly. A random split puts clips of the same person saying the same word in both train and validation, which inflates validation accuracy by five to ten points and tells you nothing about how it will work for a guest.
  • Watch the confusion matrix, not the accuracy number. On this task the interesting failures are always specific pairs — "lights on" versus "lights off" — and the fix is more data for that pair, not more epochs.
  • Retrain after the first week of real use. Log every rejected utterance to the serial console, listen to the ones that should have worked, and add them to the dataset. Two rounds of this typically halves the false-reject rate.
  • Evaluate the quantised model, not the float one. Run the TFLite interpreter over your validation set and compare — if int8 costs more than about two points, your representative dataset is not representative.

Evaluation, Metrics & Deployment

Figures below are from a reference run: eight classes, roughly 900 own recordings plus 4 000 Speech Commands clips for the unknown class, speaker-disjoint split, measured on an ESP32-S3 at 240 MHz with PSRAM enabled.

MetricValueWhat it tells you
Validation accuracy (float32)95.8 %Speaker-disjoint split — the honest number, not the random-split one.
Validation accuracy (int8)95.1 %Quantisation cost of 0.7 points, which is typical when the representative dataset is drawn from real training data.
False accepts per hour (TV playing)0.4With three-frame hysteresis. Without hysteresis the same model gives about 6 per hour.
False rejects (normal speech, 2 m)3.9 %Rises to roughly 12 % at 4 m with a fan running — the practical range limit.
Model size58 KBInt8 TFLite flatbuffer, embedded as a C array in flash.
Tensor arena46 KB used of 70 KBAllocated in PSRAM; the headroom absorbs interpreter version changes.
Feature extraction4.1 ms49 frames of 512-point FFT plus mel projection, float on the S3 FPU.
Inference latency15.3 msEnd-to-end Invoke() on int8 with the S3 vector extensions.
Average power0.41 WIdle listening with the energy gate active; peaks near 0.9 W during inference and Wi-Fi transmit.

Figures from the reference training run described above — reproduce them before trusting your own changes.

Inference latency by platform (same int8 model)Benchmark figures recorded on the reference build. ESP32-S3 @ 240 MHz15.3 msESP32 @ 240 MHz44.8 msESP32 @ 160 MHz67.2 msRP2040 @ 133 MHz138 ms
Inference latency by platform (same int8 model)

Deployment

  • Export the int8 flatbuffer as a C array with 16-byte alignment. Unaligned model data causes hard faults on some cores and, worse, silently wrong results on others.
  • Register only the operators the model uses via MicroMutableOpResolver. The all-ops resolver adds roughly 100 KB of flash and no capability.
  • Put the tensor arena in PSRAM and the DMA buffers in internal SRAM. Getting this backwards halves throughput because DMA cannot reach PSRAM efficiently.
  • Print arena_used_bytes() once at boot and size the arena to that plus 30 %. Over-sizing wastes PSRAM; under-sizing fails at AllocateTensors() with an unhelpful message.
  • Version the model. Embed a build hash in the header and publish it on the MQTT status topic, so you can tell which model a given device is running when you are debugging six of them.

Inference example

pythontest_tflite.py
#!/usr/bin/env python3
"""Verify the quantised model on the host before flashing it."""
import numpy as np
import tensorflow as tf

interp = tf.lite.Interpreter(model_path="kws_int8.tflite")
interp.allocate_tensors()
inp, out = interp.get_input_details()[0], interp.get_output_details()[0]

in_scale, in_zp = inp["quantization"]
out_scale, out_zp = out["quantization"]
print(f"input  {inp['shape']} {inp['dtype'].__name__} scale={in_scale:.5f} zp={in_zp}")
print(f"output {out['shape']} {out['dtype'].__name__} scale={out_scale:.5f} zp={out_zp}")

# x_val holds float log-mel features produced by the same log_mel() used in training.
x_val = np.load("x_val.npy")
y_val = np.load("y_val.npy")

correct = 0
for x, y in zip(x_val, y_val):
    q = np.clip(np.round(x / in_scale) + in_zp, -128, 127).astype(np.int8)
    interp.set_tensor(inp["index"], q[np.newaxis, ...])
    interp.invoke()
    probs = out_scale * (interp.get_tensor(out["index"])[0].astype(np.int32) - out_zp)
    correct += int(np.argmax(probs) == y)

print(f"int8 accuracy: {correct / len(y_val):.4f}")

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.

TestWhat you should see
Run the I²S capture sketch and speakRMS rises from roughly −60 dBFS in silence to −30 dBFS at one metre. A flat or constant value means wiring or L/R pin trouble.
Boot the full firmwareSerial prints the model size, arena usage, and "Voice hub listening". The LED ring settles to a dim blue.
Stay silent for two minutesThe energy gate holds; almost no inference lines are printed and the noise floor value stabilises.
Say a trained command from one metreThe ring turns cyan while thinking, then green; a 2.4 kHz beep sounds, the relay clicks, and an intent message appears on the broker within roughly 100 ms of the word ending.
Say an untrained wordThe log shows _unknown with a high score and no action is taken. If the device fires, your unknown class is under-trained.
Play television audio for one hour with nobody presentZero or at most one false trigger. More than that means the threshold or the negative dataset needs work.
Check timing in the serial logFeature extraction 3–5 ms, inference 12–18 ms. Substantially slower means PSRAM is off or the CPU is not at 240 MHz.
Measure current draw at 5 VRoughly 70–90 mA idle listening, briefly 180 mA during inference plus Wi-Fi transmit.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

The serial log during a successful recognition, showing the energy gate opening, the feature and inference timings, and the hysteresis counter reaching three:

plainserial-monitor.txt
arena used 47104 B, model 59392 B
Voice hub listening — audio stays on this device

_background  0.97  feat 4102 us  inf 15281 us  run 0
_unknown     0.81  feat 4098 us  inf 15266 us  run 0
lights_on    0.71  feat 4110 us  inf 15302 us  run 0
lights_on    0.94  feat 4104 us  inf 15288 us  run 1
lights_on    0.97  feat 4099 us  inf 15274 us  run 2
lights_on    0.96  feat 4107 us  inf 15291 us  run 3
  -> intent lights_on  relay 0 ON  published

_background  0.99  feat 4101 us  inf 15279 us  run 0
fan_off      0.66  feat 4106 us  inf 15285 us  run 0   (below threshold 0.78)
fan_off      0.83  feat 4103 us  inf 15277 us  run 1
fan_off      0.88  feat 4108 us  inf 15294 us  run 2
fan_off      0.91  feat 4100 us  inf 15281 us  run 3
  -> intent fan_off   relay 1 OFF published
A Grafana time-series dashboard
A time-series dashboard of the kind used to plot recognition scores over time while tuning per-keyword thresholds. Photograph sourced from Wikimedia Commons — Grafana dashboard.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

The microphone reads a constant value or pure noise

Likely cause. Wrong I²S bit width, a floating L/R pin, or the data pin on a GPIO that cannot be routed to I²S input.

Fix. Set I2S_BITS_PER_SAMPLE_32BIT, not 16-bit — the INMP441 sends 24 bits in a 32-bit slot. Tie L/R firmly to GND. Verify the data pin: on the ESP32-S3 most GPIO can be routed via the matrix, but the strapping pins and the USB pins cannot.

<code>AllocateTensors()</code> fails, or the board reboots in setup()

Likely cause. PSRAM is not enabled in the board menu, so heap_caps_malloc(..., MALLOC_CAP_SPIRAM) returns null and the interpreter dereferences it.

Fix. Set Tools → PSRAM → OPI PSRAM and confirm with ESP.getPsramSize() at boot — it should print 8 388 608. Also check the arena is at least arena_used_bytes(); grow it in 8 KB steps until allocation succeeds.

Accuracy is excellent in training and terrible on the device

Likely cause. The firmware feature extraction does not match the training feature extraction. This is by far the most common TinyML audio failure.

Fix. Check every parameter against the training script: sample rate, window length, hop length, FFT size, number of mel bands, mel frequency range, log versus log10, and whether pre-emphasis is applied in both. Dump one spectrogram from the device over serial and compare it numerically against the Python output for the same WAV file — they should agree to three decimal places.

The hub triggers on the television

Likely cause. Insufficient negative training data, or hysteresis disabled.

Fix. Record fifteen minutes of that television and add it to the _unknown class, then retrain. Confirm the three-frame run requirement is active. Raising the threshold is the last resort — it trades false accepts for false rejects rather than fixing the model.

It works for you and not for anyone else in the house

Likely cause. Single-speaker training data. The model has learned your voice, not the words.

Fix. Every regular user needs to contribute recordings — ideally 30–50 clips per keyword each. This is not optional; it is the difference between a demo and a device.

Audio dropouts and erratic scores when Wi-Fi is busy

Likely cause. The Wi-Fi stack runs on core 0 by default and is competing with the audio task.

Fix. Raise the audio task priority to 5 or above, keep the DMA buffer count at 8 or more, and move the MQTT client work into loop() on core 1 as this sketch does. If it persists, reduce the MQTT publish rate — radio transmit bursts are the usual culprit.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • The energy gate is the single biggest power lever. Tuning it so the device runs inference on 5 % of frames rather than 100 % cuts average power by roughly six times.
  • Precompute the mel filterbank and the Hann window at boot rather than per frame — recomputing cosf() 480 times per frame costs more than the FFT.
  • Store the mel filterbank sparsely: most weights are zero, and skipping them (if (row[k] > 0)) roughly halves the mel projection cost.
  • For battery operation, add a two-stage cascade — a tiny 8 KB wake-word model gating the full command model — and put the CPU in light sleep between energy-gate checks.
  • Replace every delay() with a millis() comparison — blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
  • Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.

Safety Precautions

  • A voice-controlled relay can switch a load while nobody is watching. Never put a heater, an iron or anything with a thermal runaway mode on a voice-controlled channel without an independent thermal cut-out.
  • Fit a physical microphone mute switch. Software mute is not verifiable by the user, and a device with an unverifiable microphone in a bedroom is a reasonable thing for people to object to.
  • Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
  • Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
  • Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
  • Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Add a second microphone and simple delay-and-sum beamforming. Two INMP441s 60 mm apart give roughly 4–6 dB of directional gain, which is worth more in a noisy kitchen than any model improvement.
  • Implement two-stage cascade detection — a tiny always-on wake word gating the full command model — for a large reduction in average power.
  • Add speaker verification so the hub only accepts commands from enrolled household voices. A small embedding model plus cosine similarity is enough for a home threat model.
  • Add on-device text-to-speech confirmation using a small concatenative engine, so the hub can say "lights on" instead of beeping.
  • Support OTA model updates so retraining does not require a USB cable.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add over-the-air firmware updates so you never have to physically reach a deployed node again.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Can this run on a plain ESP32 instead of an S3?

Yes, with caveats. Inference goes from about 15 ms to about 45 ms, and without PSRAM you must shrink the model and the ring buffer to fit in internal SRAM — realistically that means four or five keywords instead of eight. It works, and it is a legitimate cheaper build, but the S3's vector extensions and PSRAM are exactly what this workload wants and the ₹450 difference buys a lot.

Why not just use Alexa or Google Assistant?

If you are happy with a cloud microphone, they are better at general speech than this will ever be. The reason to build this is that the audio genuinely does not leave the device — which you can verify, because you have the source. It also keeps working when your broadband does not, and it responds in about 120 ms rather than 800 ms because there is no round trip.

How many keywords can it handle?

Practically, eight to twelve. The model size grows only in the final dense layer, so the compute cost is nearly flat, but confusability grows quickly: adding "kitchen light on" alongside "kitchen fan on" means the model must discriminate on one word inside an otherwise identical phrase, and accuracy on that pair drops. Distinct-sounding commands are worth more than clever ones.

Do I have to use Edge Impulse?

No. Edge Impulse gives you the data pipeline, augmentation and deployment export in a browser, which is genuinely convenient and a reasonable first path. The TensorFlow script here does the same thing with full visibility into every step, which matters when something goes wrong. Both deploy to the same TFLite Micro runtime.

Why does "off" get recognised worse than "on"?

Acoustics. "On" is a voiced vowel with strong low-frequency energy; "off" ends in an unvoiced fricative that is quiet, broadband and easily masked by room noise. This is why per-keyword thresholds exist. If it remains a problem, change the command — "lights dark" is recognised far more reliably than "lights off" and users adapt within a day.

How much does the enclosure matter?

A great deal, and it is routinely underestimated. A microphone glued directly to a plastic case picks up every knock on the desk. A port hole that is too small acts as a low-pass filter and kills the consonant energy the model depends on. Always do final threshold tuning with the case closed and the unit in its permanent position.

Can I add a new command without retraining everything?

Not with this architecture — the output layer size and the class semantics are baked into the model. You retrain, which takes about fifteen minutes on a laptop once your dataset is in place. If you expect to add commands often, look at a few-shot approach using an embedding model plus a nearest-neighbour classifier, at the cost of a larger model and lower accuracy.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. Zhang et al., "Hello Edge: Keyword Spotting on Microcontrollers" (DS-CNN architecture)arXiv:1711.07128
  2. Warden, "Speech Commands: A Dataset for Limited-Vocabulary Speech Recognition"arXiv:1804.03209
  3. TensorFlow Lite for Microcontrollers — official guideGoogle AI Edge
  4. Post-training integer quantisation — TensorFlow documentationTensorFlow
  5. INMP441 omnidirectional MEMS microphone with I²S — datasheetTDK InvenSense
  6. ESP32-S3 Technical Reference Manual — I²S peripheral and vector instructionsEspressif Systems
  7. Davis & Mermelstein, "Comparison of parametric representations for monosyllabic word recognition" (origin of MFCC)IEEE TASSP, 1980
  8. Edge Impulse — keyword spotting tutorialEdge Impulse