- 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>
129 lines
5.3 KiB
Markdown
129 lines
5.3 KiB
Markdown
# 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.
|
|
|
|
```mermaid
|
|
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`.
|
|
* **`app.py`**: A lightweight Flask wrapper for `faster-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 to
|
|
`voice/status` on 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), so `voice_bridge`
|
|
treats it as silence and ignores it.
|
|
- **Presence.** The device sets a retained Last Will `offline` on `voice/status`
|
|
and publishes retained `online` on 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.h` and `led_control.h` are stale early drafts and are **not** included
|
|
> by the sketch. `voice_assistant.ino` is 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)
|
|
|
|
## 🚀 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):
|
|
|
|
```bash
|
|
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**
|
|
```bash
|
|
cd ~/voice_bridge
|
|
docker compose up -d
|
|
```
|
|
|
|
**3. Monitor Logs**
|
|
```bash
|
|
docker compose logs -f voice_bridge
|
|
```
|
|
|
|
**4. Monitor Audio Stream (Debug)**
|
|
```bash
|
|
mosquitto_sub -h 192.168.20.13 -u "MQTT_USER_REDACTED" -P "pass" -t "voice/audio_stream" | awk '{printf "."}'
|
|
```
|