How to Build a Local Voice Satellite for Hermes or OpenClaw
A Nest Mini-style ESP32-S3 mic and speaker puck for LAN agent chats
Updated

What you'll build
Build a small voice satellite: an ESP32-S3 board wired to an INMP441 I2S microphone, a MAX98357A I2S amplifier, a compact speaker, a push-to-talk button, and a status LED. Press the button and the satellite records three seconds of 16 kHz mono audio, HTTP-POSTs it as a WAV file to a bridge URL you set, then plays the WAV reply through the amplifier. The bridge machine — your laptop or a Pi — handles speech recognition, calls your Hermes or OpenClaw agent, synthesises the reply, and returns it. Agent credentials stay on the bridge, not in the microcontroller.
Hermes exposes an OpenAI-compatible API server when its gateway is enabled on port 8642; OpenClaw's gateway is commonly on port 18789. The firmware only needs your bridge's LAN address and the endpoint path — edit BRIDGE_URL before flashing. If the bridge is unreachable, the firmware prints a clear diagnostic to Serial and blinks the status LED four times rather than hanging.
This is a practical base for a local voice interface. Start with push-to-talk, a trusted LAN or Tailnet, and a visible recording LED. Once the bridge is reliable you can extend it with wake-word detection, a nicer enclosure, acoustic echo handling, longer recordings, or alternative STT/TTS choices such as local faster-whisper, Piper, or whatever your bridge already supports.
The firmware has four jobs: record 3 s of 16 kHz mono WAV from the INMP441 while the PTT button is held; HTTP-POST the WAV to BRIDGE_URL and receive a WAV reply; play the reply WAV through the MAX98357A amplifier; and blink the status LED to signal recording, ready, and bridge-error states. Setting up the bridge server and writing the STT/TTS pipeline are deliberately out of scope — the satellite firmware is standalone and testable with any endpoint that accepts a WAV POST and returns a WAV body.
Upload and calibrate
Open the sketch in Schematik and edit three constants near the top before flashing:
WIFI_SSIDandWIFI_PASSWORD— your network credentials.BRIDGE_URL— the full URL of your bridge endpoint, for examplehttp://192.168.1.50:8787/voice. Adjust the IP, port, and path to match your bridge machine.
Flash via USB at 115200 baud. Open Serial Monitor at 115200 to watch boot progress. Within a few seconds you should see [Hermes Satellite] booting… followed by a Wi-Fi connection message and finally Hermes Satellite ready. The status LED stays off while idle.
Hold the PTT button. The LED lights solid while the satellite records. Release the button to stop recording and trigger the HTTP POST. Watch Serial Monitor for the bridge response. When the bridge replies with a WAV, the LED turns off and the speaker plays the synthesised audio. If the bridge is unreachable within the 20-second timeout, Serial prints a diagnostic and the LED blinks four times.
Troubleshooting
- LED lights but no audio plays after release. Serial will show whether the HTTP POST failed or returned a non-200 status. Check that
BRIDGE_URLmatches the running bridge address and that the bridge machine is reachable on the same network. - Mic records silence (all-zero WAV). Confirm L/R is tied to GND, check INMP441 SCK/WS/SD pin order, and verify the mic is on 3.3 V not 5 V.
- Distorted or clipped audio playback. The bridge should return 16 kHz mono WAV; very high bit-rates can stall playback. Reduce bridge TTS output quality if needed.
- ESP32-S3 fails to boot into normal mode. GPIO 0 strapping conflicts can occur if the PTT button is held at power-on. Release the button, press Reset, and re-power.
- LED blinks four times after every PTT press. Bridge is unreachable. Check network connectivity, firewall rules on the bridge host, and that the bridge service is running on the expected port.
- Low recording volume. Keep the INMP441 within 20 cm. Room echo can be reduced by placing the mic inside a small enclosure with sound-absorbing foam.
Going further
Once push-to-talk is reliable, the natural extension is wake-word detection: add a lightweight on-device model so you can say a trigger phrase instead of pressing a button. You can also make the satellite bidirectional — have the bridge push unsolicited audio notifications back to it over a persistent WebSocket. For multi-room setups, each satellite only needs a different BRIDGE_URL; all conversation logic stays on the bridge.
Wiring diagram
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| Waveshare ESP32-S3-LCD-1.47 | board | 1 | $12.99 |
| HiLetgo INMP441 I2S Microphone Module | sensor | 1 | €5.96 |
| Adafruit I2S 3W Class D Amplifier Breakout - MAX98357A | actuator | 1 | $5.95 |
| 2030 Cavity Speaker, 8Ω 2W (4PIN PH1.25) | actuator | 1 | $2.99 |
| 16mm Panel Mount Momentary Pushbutton - Yellow | other | 1 | $0.95 |
| 3mm LED Pack (50 PCS) | actuator | 1 | $3.45 |
Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Wire the INMP441 I2S microphone
Connect the INMP441 VCC to the ESP32-S3 3.3 V rail and GND to the GND rail. Wire SCK to GPIO4, WS to GPIO5, and SD to GPIO6. Tie the L/R pin on the INMP441 to GND to select the left channel.
- The INMP441 is 3.3 V only — do not connect VCC to 5 V.
- Keep mic wires short (under 15 cm) to reduce high-frequency noise pickup.
- If the L/R pin is left floating, the mic may output nothing or intermittent silence.
Wire the MAX98357A I2S amplifier
Connect MAX98357A VIN to the 5 V rail (or 3.3 V — the module works at both) and GND to GND. Wire BCLK to GPIO7, LRC to GPIO15, and DIN to GPIO16. Leave GAIN and SD (shutdown) floating for default 9 dB gain and always-on operation.
- Powering the amp from the USB 5 V rail gives louder output than 3.3 V.
- Tie the SD pin to 3.3 V if you want an explicit enable; floating defaults to on.
- Do not leave the speaker terminals open while audio is playing — the amp is stable into a load.
Connect the speaker
Solder or clip the compact 8 Ω speaker leads to the MAX98357A OUT+ and OUT− terminals. Polarity affects phase, not volume — match the +/− markings on the speaker if present.
- A small enclosure or baffle behind the speaker noticeably improves bass response.
- 2 W into 8 Ω is the rated maximum; keep volume reasonable to avoid thermal shutdown.
Wire the push-to-talk button and status LED
Connect one leg of the push-to-talk button to GPIO0 and the other to GND — the firmware enables the internal pull-up. Connect the status LED anode (long leg) to GPIO2 through a 220 Ω current-limiting resistor, and the cathode (short leg) to GND.
- GPIO0 is a strapping pin on ESP32-S3; the pull-up keeps it HIGH at boot so the board programs normally. Pressing PTT only after boot is the correct use.
- Omitting the 220 Ω resistor on the LED will exceed the GPIO current limit.
Edit firmware credentials and upload
Open the sketch and set WIFI_SSID, WIFI_PASSWORD, and BRIDGE_URL to match your network and bridge machine IP (for example http://192.168.1.50:8787/voice). Flash via USB at 115200 baud, then open Serial Monitor to confirm 'Hermes Satellite ready'.
- Keep agent API keys on the bridge machine, not in the firmware — the ESP32 only talks to your local bridge.
- Hermes gateway is typically on port 8642; OpenClaw is typically on port 18789 — check your bridge config.
Test push-to-talk recording and playback
With the bridge server running, hold the PTT button — the status LED lights while the satellite records 3 seconds of audio. Release the button to stop. Watch Serial Monitor for the HTTP POST result. When the bridge replies, the LED turns off and the speaker plays the synthesized response.
- If the LED blinks in an error pattern, check the Serial Monitor for the specific diagnostic message.
- If audio plays but sounds distorted, confirm the bridge returns a 16 kHz mono WAV; other rates are auto-detected from the WAV header.
- If the bridge is unreachable, the firmware prints a clear serial diagnostic and blinks the LED 4 times — it will not hang.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | inmp441-mic VCC | POWER |
| GND | inmp441-mic GND | GROUND |
| GPIO 4 | inmp441-mic SCK | DATA |
| GPIO 5 | inmp441-mic WS | DATA |
| GPIO 6 | inmp441-mic SD | DATA |
| 5V | max98357a-amp VIN | POWER |
| GND | max98357a-amp GND | GROUND |
| GPIO 7 | max98357a-amp BCLK | DATA |
| GPIO 15 | max98357a-amp LRC | DATA |
| GPIO 16 | max98357a-amp DIN | DATA |
| VCC | speaker SPEAKER_POS → MAX98357A OUT+ | POWER |
| GND | speaker SPEAKER_NEG → MAX98357A OUT- | POWER |
| GPIO 0 | push-button SIGNAL | DIGITAL |
| GND | push-button GND | GROUND |
| GPIO 2 | status-led ANODE | DIGITAL |
| GND | status-led CATHODE | GROUND |
Code
/*
* Hermes Voice Satellite
* ESP32-S3 push-to-talk voice satellite
*
* Records 3 s of 16 kHz mono WAV via INMP441 I2S mic,
* HTTP POSTs it to a local bridge server,
* plays the returned WAV reply through a MAX98357A I2S amp.
*
* Edit WIFI_SSID, WIFI_PASSWORD, and BRIDGE_URL before flashing.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <driver/i2s.h>
// ── User-editable settings ─────────────────────────────────────────────────
const char* WIFI_SSID = "YOUR_WIFI_NAME"; // edit this
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; // edit this
// Bridge endpoint — adjust IP and port to match your bridge machine.
// Example: Hermes gateway on port 8642, OpenClaw on port 18789.
const char* BRIDGE_URL = "http://192.168.1.50:8787/voice";
// ── Pin definitions (must match pin_definitions.json) ─────────────────────
#define MIC_BCLK_PIN 4
#define MIC_WS_PIN 5
#define MIC_DATA_PIN 6
#define AMP_BCLK_PIN 7
#define AMP_WS_PIN 15
#define AMP_DATA_PIN 16
#define PTT_PIN 0
#define STATUS_LED_PIN 2
// ── Audio constants ────────────────────────────────────────────────────────
#define SAMPLE_RATE 16000
#define RECORD_SECONDS 3
#define BITS_PER_SAMPLE 16
#define NUM_CHANNELS 1 // mono
// Total 16-bit samples for the recording
#define TOTAL_SAMPLES (SAMPLE_RATE * RECORD_SECONDS * NUM_CHANNELS)
// Total PCM bytes
#define PCM_BYTES (TOTAL_SAMPLES * (BITS_PER_SAMPLE / 8))
// Complete WAV file = 44-byte header + PCM data
#define WAV_HEADER_SIZE 44
#define WAV_TOTAL_BYTES (WAV_HEADER_SIZE + PCM_BYTES)
// I2S driver port assignments
#define I2S_MIC_PORT I2S_NUM_0
#define I2S_AMP_PORT I2S_NUM_1
// HTTP timeout in ms — give the bridge time to run STT + LLM + TTS
#define BRIDGE_TIMEOUT_MS 20000
// ── LED helper states ──────────────────────────────────────────────────────
// OFF = idle / ready
// ON = recording
// BLINK = bridge error
// ── Forward declarations ───────────────────────────────────────────────────
void setupWiFi();
bool setupMicI2S();
bool setupAmpI2S();
void recordWAV(uint8_t* buf, size_t bufSize);
void buildWAVHeader(uint8_t* header, uint32_t pcmBytes);
bool postAndReceiveWAV(const uint8_t* wavBuf, size_t wavLen,
uint8_t** replyBuf, size_t* replyLen);
void playWAV(const uint8_t* wavBuf, size_t wavLen);
void blinkError(int times);
// ── Setup ──────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("[Hermes Satellite] booting…");
pinMode(STATUS_LED_PIN, OUTPUT);
digitalWrite(STATUS_LED_PIN, LOW);
// PTT button: internal pull-up; button should short to GND
pinMode(PTT_PIN, INPUT_PULLUP);
setupWiFi();
if (!setupMicI2S()) {
Serial.println("[ERROR] INMP441 I2S driver failed — check wiring on "
"GPIO4(BCLK) GPIO5(WS) GPIO6(DATA)");
while (true) { blinkError(3); delay(2000); }
}
if (!setupAmpI2S()) {
Serial.println("[ERROR] MAX98357A I2S driver failed — check wiring on "
"GPIO7(BCLK) GPIO15(LRC) GPIO16(DIN)");
while (true) { blinkError(5); delay(2000); }
}
Serial.println("[Hermes Satellite] ready — hold PTT to record");
}
// ── Main loop ──────────────────────────────────────────────────────────────
void loop() {
// Wait for PTT press (active LOW with internal pull-up)
if (digitalRead(PTT_PIN) != LOW) return;
// Debounce
delay(30);
if (digitalRead(PTT_PIN) != LOW) return;
Serial.println("[Satellite] PTT pressed — recording…");
// Allocate WAV buffer in PSRAM if available, heap otherwise
uint8_t* wavBuf = (uint8_t*)ps_malloc(WAV_TOTAL_BYTES);
if (!wavBuf) {
wavBuf = (uint8_t*)malloc(WAV_TOTAL_BYTES);
}
if (!wavBuf) {
Serial.printf("[ERROR] Cannot allocate %u bytes for WAV buffer\n",
(unsigned)WAV_TOTAL_BYTES);
blinkError(6);
return;
}
// LED ON = recording
digitalWrite(STATUS_LED_PIN, HIGH);
recordWAV(wavBuf, WAV_TOTAL_BYTES);
// LED OFF while waiting on bridge
digitalWrite(STATUS_LED_PIN, LOW);
Serial.printf("[Satellite] sending %u-byte WAV to %s\n",
(unsigned)WAV_TOTAL_BYTES, BRIDGE_URL);
uint8_t* replyBuf = nullptr;
size_t replyLen = 0;
bool ok = postAndReceiveWAV(wavBuf, WAV_TOTAL_BYTES, &replyBuf, &replyLen);
free(wavBuf);
if (!ok || replyLen < WAV_HEADER_SIZE) {
Serial.println("[ERROR] Bridge unreachable or empty reply — check BRIDGE_URL "
"and that the bridge machine is on the same LAN");
blinkError(4);
if (replyBuf) free(replyBuf);
return;
}
Serial.printf("[Satellite] playing %u-byte reply WAV\n", (unsigned)replyLen);
playWAV(replyBuf, replyLen);
free(replyBuf);
Serial.println("[Satellite] done — ready for next PTT");
// Wait for button release before re-arming
while (digitalRead(PTT_PIN) == LOW) delay(10);
}
// ── Wi-Fi ──────────────────────────────────────────────────────────────────
void setupWiFi() {
Serial.printf("[WiFi] connecting to \"%s\"…\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long t = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - t > 15000) {
Serial.println("[WiFi] connection timed out — continuing without network; "
"bridge calls will fail");
return;
}
delay(500);
Serial.print('.');
}
Serial.printf("\n[WiFi] connected, IP: %s\n", WiFi.localIP().toString().c_str());
}
// ── I2S microphone (INMP441) ───────────────────────────────────────────────
bool setupMicI2S() {
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, // INMP441 delivers 32-bit frames
.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 = false,
.tx_desc_auto_clear = false,
.fixed_mclk = 0,
};
i2s_pin_config_t pins = {
.bck_io_num = MIC_BCLK_PIN,
.ws_io_num = MIC_WS_PIN,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = MIC_DATA_PIN,
};
esp_err_t err = i2s_driver_install(I2S_MIC_PORT, &cfg, 0, nullptr);
if (err != ESP_OK) {
Serial.printf("[ERROR] i2s_driver_install (mic) failed: %d\n", err);
return false;
}
err = i2s_set_pin(I2S_MIC_PORT, &pins);
if (err != ESP_OK) {
Serial.printf("[ERROR] i2s_set_pin (mic) failed: %d\n", err);
return false;
}
i2s_zero_dma_buffer(I2S_MIC_PORT);
return true;
}
// ── I2S amplifier (MAX98357A) ──────────────────────────────────────────────
bool setupAmpI2S() {
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.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 = false,
.tx_desc_auto_clear = true, // output silence on underrun
.fixed_mclk = 0,
};
i2s_pin_config_t pins = {
.bck_io_num = AMP_BCLK_PIN,
.ws_io_num = AMP_WS_PIN,
.data_out_num = AMP_DATA_PIN,
.data_in_num = I2S_PIN_NO_CHANGE,
};
esp_err_t err = i2s_driver_install(I2S_AMP_PORT, &cfg, 0, nullptr);
if (err != ESP_OK) {
Serial.printf("[ERROR] i2s_driver_install (amp) failed: %d\n", err);
return false;
}
err = i2s_set_pin(I2S_AMP_PORT, &pins);
if (err != ESP_OK) {
Serial.printf("[ERROR] i2s_set_pin (amp) failed: %d\n", err);
return false;
}
i2s_zero_dma_buffer(I2S_AMP_PORT);
return true;
}
// ── Recording ─────────────────────────────────────────────────────────────
void buildWAVHeader(uint8_t* h, uint32_t pcmBytes) {
uint32_t byteRate = SAMPLE_RATE * NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
uint16_t blockAlign = NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
uint32_t chunkSize = 36 + pcmBytes; // RIFF chunk size
// RIFF header
h[0]='R'; h[1]='I'; h[2]='F'; h[3]='F';
h[4]=(chunkSize)&0xFF; h[5]=(chunkSize>>8)&0xFF;
h[6]=(chunkSize>>16)&0xFF; h[7]=(chunkSize>>24)&0xFF;
h[8]='W'; h[9]='A'; h[10]='V'; h[11]='E';
// fmt sub-chunk
h[12]='f'; h[13]='m'; h[14]='t'; h[15]=' ';
h[16]=16; h[17]=0; h[18]=0; h[19]=0; // sub-chunk size = 16
h[20]=1; h[21]=0; // PCM format
h[22]=(uint8_t)NUM_CHANNELS; h[23]=0;
h[24]=(SAMPLE_RATE)&0xFF; h[25]=(SAMPLE_RATE>>8)&0xFF;
h[26]=(SAMPLE_RATE>>16)&0xFF; h[27]=(SAMPLE_RATE>>24)&0xFF;
h[28]=(byteRate)&0xFF; h[29]=(byteRate>>8)&0xFF;
h[30]=(byteRate>>16)&0xFF; h[31]=(byteRate>>24)&0xFF;
h[32]=(blockAlign)&0xFF; h[33]=(blockAlign>>8)&0xFF;
h[34]=(uint8_t)BITS_PER_SAMPLE; h[35]=0;
// data sub-chunk
h[36]='d'; h[37]='a'; h[38]='t'; h[39]='a';
h[40]=(pcmBytes)&0xFF; h[41]=(pcmBytes>>8)&0xFF;
h[42]=(pcmBytes>>16)&0xFF; h[43]=(pcmBytes>>24)&0xFF;
}
void recordWAV(uint8_t* buf, size_t bufSize) {
// Write the 44-byte WAV header first
buildWAVHeader(buf, PCM_BYTES);
uint8_t* pcmDst = buf + WAV_HEADER_SIZE;
// INMP441 gives 32-bit samples (18-bit data left-justified).
// We read in 32-bit words and keep the top 16 bits for 16-bit PCM.
const size_t DMA_CHUNK = 256; // samples per read
int32_t raw32[DMA_CHUNK];
int16_t* dst16 = (int16_t*)pcmDst;
size_t samplesLeft = TOTAL_SAMPLES;
while (samplesLeft > 0) {
size_t want = min(samplesLeft, DMA_CHUNK);
size_t bytes = want * sizeof(int32_t);
size_t readBytes = 0;
i2s_read(I2S_MIC_PORT, raw32, bytes, &readBytes, portMAX_DELAY);
size_t got = readBytes / sizeof(int32_t);
for (size_t i = 0; i < got; i++) {
// Shift right 14 bits: INMP441 data is in bits 31..14 of the 32-bit frame
dst16[TOTAL_SAMPLES - samplesLeft + i] = (int16_t)(raw32[i] >> 14);
}
samplesLeft -= got;
}
Serial.printf("[Rec] captured %u samples at %u Hz\n",
(unsigned)TOTAL_SAMPLES, (unsigned)SAMPLE_RATE);
}
// ── HTTP POST + receive ────────────────────────────────────────────────────
bool postAndReceiveWAV(const uint8_t* wavBuf, size_t wavLen,
uint8_t** replyBuf, size_t* replyLen) {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("[HTTP] Wi-Fi not connected — cannot reach bridge");
return false;
}
HTTPClient http;
http.begin(BRIDGE_URL);
http.setTimeout(BRIDGE_TIMEOUT_MS);
http.addHeader("Content-Type", "audio/wav");
int httpCode = http.POST((uint8_t*)wavBuf, wavLen);
if (httpCode <= 0) {
Serial.printf("[HTTP] bridge unreachable — error %d (%s)\n",
httpCode, http.errorToString(httpCode).c_str());
Serial.printf("[HTTP] check BRIDGE_URL: %s\n", BRIDGE_URL);
http.end();
return false;
}
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[HTTP] bridge returned HTTP %d\n", httpCode);
http.end();
return false;
}
// Read the response WAV into a heap buffer
size_t len = (size_t)http.getSize();
if (len == 0) {
// Chunked / unknown length — collect via stream
WiFiClient* stream = http.getStreamPtr();
size_t cap = 65536;
uint8_t* rb = (uint8_t*)malloc(cap);
if (!rb) { http.end(); return false; }
size_t pos = 0;
unsigned long deadline = millis() + BRIDGE_TIMEOUT_MS;
while (millis() < deadline) {
if (stream->available()) {
if (pos + 1 > cap) {
cap *= 2;
uint8_t* tmp = (uint8_t*)realloc(rb, cap);
if (!tmp) { free(rb); http.end(); return false; }
rb = tmp;
}
rb[pos++] = (uint8_t)stream->read();
} else if (!http.connected()) {
break;
}
}
*replyBuf = rb;
*replyLen = pos;
} else {
uint8_t* rb = (uint8_t*)malloc(len);
if (!rb) { http.end(); return false; }
http.getStream().readBytes(rb, len);
*replyBuf = rb;
*replyLen = len;
}
http.end();
return true;
}
// ── Playback ───────────────────────────────────────────────────────────────
void playWAV(const uint8_t* wavBuf, size_t wavLen) {
if (wavLen <= WAV_HEADER_SIZE) {
Serial.println("[Play] WAV too short — skipping");
return;
}
// Parse sample rate and bits from WAV header so we adapt to the bridge reply
uint32_t sr = wavBuf[24] | ((uint32_t)wavBuf[25] << 8)
| ((uint32_t)wavBuf[26] << 16) | ((uint32_t)wavBuf[27] << 24);
uint16_t bits = wavBuf[34] | ((uint16_t)wavBuf[35] << 8);
uint16_t ch = wavBuf[22] | ((uint16_t)wavBuf[23] << 8);
Serial.printf("[Play] WAV: %u Hz, %u bit, %u ch\n", sr, bits, ch);
// Reconfigure amp sample rate if it differs from our recording rate
if (sr != SAMPLE_RATE) {
i2s_set_clk(I2S_AMP_PORT, sr,
(i2s_bits_per_sample_t)bits,
ch == 2 ? I2S_CHANNEL_STEREO : I2S_CHANNEL_MONO);
}
const uint8_t* pcm = wavBuf + WAV_HEADER_SIZE;
size_t pcmLen = wavLen - WAV_HEADER_SIZE;
const size_t CHUNK = 2048;
size_t offset = 0;
while (offset < pcmLen) {
size_t toWrite = min(CHUNK, pcmLen - offset);
size_t written = 0;
i2s_write(I2S_AMP_PORT, pcm + offset, toWrite, &written, portMAX_DELAY);
offset += written;
}
// Restore amp sample rate for potential next playback at original rate
if (sr != SAMPLE_RATE) {
i2s_set_clk(I2S_AMP_PORT, SAMPLE_RATE,
I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO);
}
}
// ── LED error blink ───────────────────────────────────────────────────────
void blinkError(int times) {
for (int i = 0; i < times; i++) {
digitalWrite(STATUS_LED_PIN, HIGH);
delay(120);
digitalWrite(STATUS_LED_PIN, LOW);
delay(120);
}
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the Hermes Voice Satellite.
Open in Schematik →