- Reconnect and re-subscribe on every (re)connect; log the subscribe result. - Heartbeat every 5 min on voice/audio_stream (valid silent PCM frame) so the Directive-1 watchdog can tell "device dead" from "nobody spoke". voice_bridge treats it as silence and ignores it. - Retained LWT "offline" / "online" presence on voice/status for layer attribution in the watchdog. - LED: green now means MQTT connected AND publishing. Wi-Fi down = red blink, Wi-Fi up but MQTT down = amber blink. Never green without MQTT. - Log the reason on every drop: PubSubClient state code, or the Wi-Fi disconnect reason from the Wi-Fi event. - Bound the I2S read (50 ms) so Wi-Fi/MQTT housekeeping cannot stall; reset a stale streaming state on disconnect (the stuck-green bug). - Document the MQTT/LED contract in README; flag config.h and led_control.h as stale drafts so they cannot creep back as a second config. - Credentials stay placeholders; nothing secret is committed. Not flashed/tested: no ESP32 and no arduino-cli is reachable from the build environment. Hardware-in-the-loop verification needs a human at the device. Co-Authored-By: Paperclip <noreply@paperclip.ing>
5.3 KiB
AI Voice Assistant Gateway (ESP32-S3 + Docker)
🎯 Project Objective
A private, local voice assistant system that separates high-bandwidth audio streaming from smart home command logic.
- Frontend: ESP32-S3 acts as a dumb "microphone-to-MQTT" bridge.
- Backend: Docker container performs Wake Word detection (OpenWakeWord) and Speech-to-Text (Faster-Whisper).
- Controller: Home Assistant receives clean text commands for execution.
🏗️ Architecture
The system uses a Dual-Broker strategy to prevent flooding Home Assistant with raw audio data.
graph LR
Mic[INMP441] -->|I2S| ESP32
ESP32 -->|Raw Audio (Voice Broker)| MQTT_Voice[Mosquitto .13]
subgraph Docker Server [.13]
MQTT_Voice -->|Sub Audio| Bridge[Python Bridge]
Bridge -->|HTTP| Whisper[Whisper API]
Whisper -->|Text| Bridge
end
Bridge -->|Text Command (HA Broker)| MQTT_HA[Mosquitto .30]
MQTT_HA -->|Trigger| HA[Home Assistant]
HA -->|Action| Lights/Scripts
📂 File Structure
1. Firmware (Client)
Location: ~/Documents/Arduino/voice_assistant/
voice_assistant.ino: Main ESP32 firmware. Handles I2S reading, VAD (Voice Activity Detection), and MQTT streaming.- Dependencies:
PubSubClient,Freenove_WS2812_Lib_for_ESP32.
2. Backend (Server)
Location: ~/voice_bridge/ (On Server .13)
docker-compose.yml: Orchestrates the Whisper and Bridge containers.mqtt_audio_bridge.py: The "Brain".- Listens to Audio on
.13. - Buffers audio and checks for Wake Word ("Hey Jarvis").
- Sends valid audio to Whisper.
- Publishes resulting text to Home Assistant Broker
.30.
- Listens to Audio on
app.py: A lightweight Flask wrapper forfaster-whisper(running in separate container).
🚦 LED Status Indicators (ESP32)
The LED Ring provides real-time feedback on the device state.
Green now means "MQTT connected and publishing", not merely "Wi-Fi joined". This is the Directive 3 fix: the old ring could sit on green (streaming) while the socket was dead and nothing reached the broker.
| Color | State | Trigger |
|---|---|---|
| Red blink | Wi-Fi down | Not joined to the access point. Never green. |
| Amber blink | MQTT down | Wi-Fi up but the broker link is down. Never green. |
| Green (steady) | Idle + healthy | MQTT connected, subscribed, heartbeats publishing. |
| Green (bright) | Recording | Sound detected above threshold, streaming audio. |
| Blue | Processing | Silence detected, end of stream sent to server, waiting for reply. |
| Yellow | Acknowledged | Server replied OK (command executed). |
🔌 MQTT reliability (Hub Fixes — Directive 3)
- Auto-reconnect + re-subscribe on every (re)connect. PubSubClient reopens
the socket but does not restore subscriptions.
connectMqtt()re-subscribes tovoice/statuson every connect and logs the result. Without this the client can hold a live socket with no subscription and go silently deaf. - Heartbeat every 5 minutes on
voice/audio_stream. The watchdog (Directive 1) watches that topic, so topic silence means "the publisher is dead", not "nobody spoke". The heartbeat is a valid silent PCM frame (256 bytes), sovoice_bridgetreats it as silence and ignores it. - Presence. The device sets a retained Last Will
offlineonvoice/statusand publishes retainedonlineon connect. This gives the watchdog a way to attribute a failure to the ESP32 layer. - Drop logging. On every connected→disconnected transition the serial console
prints the PubSubClient state code (for example
CONNECTION_LOST) or the Wi-Fi disconnect reason code.
⚙️ Configuration Details
Hardware Config (ESP32-S3)
- Mic SCK: GPIO 4
- Mic WS: GPIO 5
- Mic SD: GPIO 6
- LED Pin: GPIO 1
- VAD Threshold:
1000(Adjust based on noise floor) - Soft Gain:
x6(Multiplies raw input to match Whisper requirements)
config.handled_control.hare stale early drafts and are not included by the sketch.voice_assistant.inois the single source of truth. Do not add a second copy of the broker/topic/pin config.
Docker Environment
- Network: Internal bridge network between Bridge and Whisper.
- Models:
- Wake Word:
hey_jarvis(OpenWakeWord) - STT:
small.en(Faster-Whisper, int8 quantization)
- Wake Word:
🚀 Setup & Commands
1. Flash ESP32
Credentials in voice_assistant.ino are placeholders in this repo. Set the real
values locally before flashing, and never commit them. Flashing needs the ESP32
physically connected over USB to a machine with arduino-cli (this is the step
that cannot be done from the server):
arduino-cli upload -v -p /dev/ttyACM0 --fqbn esp32:esp32:esp32s3:CDCOnBoot=cdc,USBMode=hwcdc,PSRAM=disabled,FlashMode=dio,DebugLevel=info --input-dir ./build
Watch the serial console (arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200)
to confirm the [mqtt] re-subscribe and [heartbeat] lines.
2. Start Backend
cd ~/voice_bridge
docker compose up -d
3. Monitor Logs
docker compose logs -f voice_bridge
4. Monitor Audio Stream (Debug)
mosquitto_sub -h 192.168.20.13 -u "MQTT_USER_REDACTED" -P "pass" -t "voice/audio_stream" | awk '{printf "."}'