Add per-speaker delay sync with Wi-Fi/web UI + ESP-NOW

Adds runtime speaker-sync control to the relay:

- src/relay_config.h: shared Wi-Fi AP creds + ESP-NOW RelayDelayMsg
  (delay_ms[JBL], delay_ms[Cardo]) on a fixed channel.
- board_sink.cpp (Board A): hosts a Wi-Fi AP "BikeAudio-Setup" + a small
  web UI (two sliders, no app) at 192.168.4.1, and broadcasts the chosen
  per-speaker delays to the source boards over ESP-NOW. Still A2DP sink +
  I2S master. (Tune while parked — Wi-Fi/BT coexist on one radio.)
- board_source.cpp (Boards B/C): inserts an adjustable delay line between
  I2S in and A2DP out — a dedicated reader task fills a ring buffer (up to
  ~250 ms) and the A2DP callback reads delay_frames behind the write head.
  Delay arrives via ESP-NOW (per SPEAKER_ID) and is persisted to flash
  (Preferences), so it survives power cycles.
- platformio.ini: source envs get -DSPEAKER_ID (0=JBL, 1=Cardo).

Lets the rider trim JBL vs Cardo timing to sync the two speakers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
blue — ESP32/PlatformIO firmware 2026-06-10 13:11:41 -04:00
parent baa3ef7690
commit 0b474b172b
4 changed files with 247 additions and 80 deletions

View File

@ -31,9 +31,9 @@ build_src_filter = +<board_sink.cpp>
; --- Board B: I2S slave -> A2DP source -> JBL Charge 5 ------------------------
[env:source_jbl]
build_src_filter = +<board_source.cpp>
build_flags = '-DTARGET_SPEAKER="JBL Charge 5"'
build_flags = '-DTARGET_SPEAKER="JBL Charge 5"' -DSPEAKER_ID=0
; --- Board C: I2S slave -> A2DP source -> Cardo (Tangerine EDGE) --------------
[env:source_cardo]
build_src_filter = +<board_source.cpp>
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"'
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DSPEAKER_ID=1

View File

@ -1,28 +1,30 @@
/**
* BikeAudio Board A : A2DP SINK -> I2S MASTER
* BikeAudio Board A : A2DP SINK -> I2S MASTER + setup UI
*
* Part of the 3-board relay. The iPhone connects to this board over Bluetooth
* (A2DP name "BikeAudio"). This board decodes the audio to PCM and clocks it
* out on a shared I2S bus as the MASTER. Boards B and C (A2DP sources) listen
* to this same bus as slaves and stream it to the JBL / Cardo speakers.
* Receives audio from the iPhone (A2DP "BikeAudio"), clocks decoded PCM onto the
* shared I2S bus as master, AND hosts a Wi-Fi access point with a browser-based
* setup UI to adjust each speaker's delay (for syncing JBL vs Cardo). Delay
* values are pushed to the source boards over ESP-NOW.
*
* iPhone ))BT)) [Board A: sink] ==I2S==> [Board B: src] ))BT)) JBL
* \====> [Board C: src] ))BT)) Cardo
* I2S OUTPUT (drives the bus): BCK=GPIO5 WS=GPIO25 DATA=GPIO23 + GND
* Setup UI: join Wi-Fi "BikeAudio-Setup" (pass "bikeaudio"), open http://192.168.4.1
*
* Why a separate board: one ESP32 cannot be an A2DP sink and source at once
* (single Bluedroid A2DP role), and an A2DP source can hold only one outgoing
* link so the sink and each speaker need their own chip. See README.
* NOTE: Wi-Fi + Bluetooth share the radio tune the delays while parked; the
* source boards persist the values, so Wi-Fi need not be used while riding.
*
* I2S OUTPUT pins (this board DRIVES the bus wire these to B and C):
* BCK = GPIO5 WS/LRCK = GPIO25 DATA(out) = GPIO23 + common GND
*
* Build: pio run -e sink (compiled via build_src_filter in platformio.ini)
* Build: pio run -e sink
*/
#include <Arduino.h>
#include "AudioTools.h"
#include "BluetoothA2DPSink.h"
#include <WiFi.h>
#include <WebServer.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include "relay_config.h"
#define I2S_BCK_PIN 5
#define I2S_WS_PIN 25
@ -30,67 +32,130 @@
I2SStream i2s;
BluetoothA2DPSink sink;
WebServer server(80);
static uint16_t current_sample_rate = 0;
static uint8_t bcast_addr[6] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
static RelayDelayMsg delays = { RELAY_MAGIC, {0, 0} };
// Configure / reconfigure the I2S bus as master TX at the given rate.
static void start_i2s(uint16_t rate) {
if (rate == 0) rate = 44100; // SBC default before negotiation
if (rate == 0) rate = 44100;
auto cfg = i2s.defaultConfig(TX_MODE);
cfg.pin_bck = I2S_BCK_PIN;
cfg.pin_ws = I2S_WS_PIN;
cfg.pin_data = I2S_DATA_PIN;
cfg.sample_rate = rate;
cfg.channels = 2;
cfg.bits_per_sample = 16;
cfg.is_master = true; // Board A clocks the whole bus
cfg.buffer_count = 8;
cfg.buffer_size = 512;
cfg.pin_bck = I2S_BCK_PIN; cfg.pin_ws = I2S_WS_PIN; cfg.pin_data = I2S_DATA_PIN;
cfg.sample_rate = rate; cfg.channels = 2; cfg.bits_per_sample = 16;
cfg.is_master = true; cfg.buffer_count = 8; cfg.buffer_size = 512;
i2s.begin(cfg);
current_sample_rate = rate;
Serial.printf("[SINK] I2S master @ %u Hz / 16-bit / stereo\n", rate);
}
// Called from the BT task with decoded PCM. Keep it cheap — just push to I2S.
void write_pcm_to_i2s(const uint8_t *data, uint32_t len) {
i2s.write(data, len);
}
void write_pcm_to_i2s(const uint8_t *data, uint32_t len) { i2s.write(data, len); }
void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) Serial.println("[SINK] iPhone CONNECTED");
else if (state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) Serial.println("[SINK] iPhone disconnected");
}
static void send_delays() {
esp_now_send(bcast_addr, (uint8_t *)&delays, sizeof(delays));
}
static const char PAGE[] PROGMEM = R"HTML(<!doctype html><html><head>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>BikeAudio Sync</title><style>
body{font-family:sans-serif;background:#111;color:#eee;margin:0;padding:18px}
h2{margin:6px 0 18px}.row{margin:22px 0}label{display:block;font-size:18px;margin-bottom:6px}
input[type=range]{width:100%}.v{font-weight:bold;color:#4ad}.hint{color:#888;font-size:13px;margin-top:24px}
</style></head><body><h2>BikeAudio speaker sync</h2>
<div class=row><label>JBL delay: <span class=v id=jv>0</span> ms</label>
<input type=range min=0 max=250 value=0 id=js oninput="set(0,this.value)"></div>
<div class=row><label>Cardo delay: <span class=v id=cv>0</span> ms</label>
<input type=range min=0 max=250 value=0 id=cs oninput="set(1,this.value)"></div>
<div class=hint>Raise the delay on whichever speaker is <i>early</i> until they line up.
Values are saved on each speaker.</div>
<script>
function set(i,v){document.getElementById(i?'cv':'jv').innerText=v;
fetch('/set?i='+i+'&v='+v)}
fetch('/get').then(r=>r.json()).then(d=>{js.value=d.jbl;jv.innerText=d.jbl;
cs.value=d.cardo;cv.innerText=d.cardo})
</script></body></html>)HTML";
static void handleRoot() { server.send_P(200, "text/html", PAGE); }
static void handleSet() {
if (server.hasArg("i") && server.hasArg("v")) {
int i = server.arg("i").toInt();
int v = server.arg("v").toInt();
if (v < 0) v = 0;
if (v > RELAY_MAX_DELAY_MS) v = RELAY_MAX_DELAY_MS;
if (i == 0 || i == 1) {
delays.delay_ms[i] = (uint16_t)v;
send_delays();
Serial.printf("[SINK] set %s delay = %d ms\n", i ? "Cardo" : "JBL", v);
}
}
server.send(200, "text/plain", "ok");
}
static void handleGet() {
char buf[64];
snprintf(buf, sizeof(buf), "{\"jbl\":%u,\"cardo\":%u}",
delays.delay_ms[SPEAKER_JBL], delays.delay_ms[SPEAKER_CARDO]);
server.send(200, "application/json", buf);
}
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("=== BikeAudio Board A — A2DP SINK -> I2S master ===");
Serial.println("=== BikeAudio Board A — SINK -> I2S master + setup UI ===");
start_i2s(44100);
// false => the sink does NOT run its own I2S; we forward PCM ourselves.
sink.set_stream_reader(write_pcm_to_i2s, false);
sink.set_on_connection_state_changed(on_conn_state);
sink.set_auto_reconnect(true);
sink.start("BikeAudio");
// Wi-Fi AP (setup UI) + ESP-NOW (push delays to source boards), shared channel.
WiFi.mode(WIFI_AP);
WiFi.softAP(RELAY_AP_SSID, RELAY_AP_PASS, RELAY_WIFI_CHANNEL);
esp_wifi_set_channel(RELAY_WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() == ESP_OK) {
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, bcast_addr, 6);
peer.channel = RELAY_WIFI_CHANNEL;
peer.encrypt = false;
esp_now_add_peer(&peer);
Serial.println("[SINK] ESP-NOW ready (broadcasting delays)");
} else {
Serial.println("[SINK] ESP-NOW init FAILED");
}
server.on("/", handleRoot);
server.on("/set", handleSet);
server.on("/get", handleGet);
server.begin();
Serial.printf("[SINK] Setup UI: join Wi-Fi '%s' -> http://192.168.4.1\n", RELAY_AP_SSID);
Serial.println("[SINK] Advertising 'BikeAudio' — connect from iPhone");
}
void loop() {
// Follow the negotiated sample rate (iPhone usually 44100; reconfigure if not).
server.handleClient();
uint16_t sr = sink.sample_rate();
if (sr != 0 && sr != current_sample_rate) {
Serial.printf("[SINK] sample rate changed %u -> %u, reconfiguring I2S\n",
current_sample_rate, sr);
Serial.printf("[SINK] sample rate %u -> %u\n", current_sample_rate, sr);
start_i2s(sr);
}
static unsigned long last = 0;
if (millis() - last > 5000) {
Serial.printf("[SINK] iPhone=%s heap=%u\n",
sink.is_connected() ? "YES" : "no", ESP.getFreeHeap());
last = millis();
static unsigned long lastb = 0;
if (millis() - lastb > 2000) { send_delays(); lastb = millis(); } // keep source boards in sync
static unsigned long lasts = 0;
if (millis() - lasts > 5000) {
Serial.printf("[SINK] iPhone=%s heap=%u JBL=%ums Cardo=%ums\n",
sink.is_connected() ? "YES" : "no", ESP.getFreeHeap(),
delays.delay_ms[SPEAKER_JBL], delays.delay_ms[SPEAKER_CARDO]);
lasts = millis();
}
delay(100);
delay(2);
}

View File

@ -1,48 +1,106 @@
/**
* BikeAudio Boards B & C : I2S SLAVE -> A2DP SOURCE
* BikeAudio Boards B & C : I2S SLAVE -> [delay buffer] -> A2DP SOURCE
*
* Reads PCM from the shared I2S bus (clocked by Board A) and streams it to ONE
* Bluetooth speaker. The target speaker name is fixed per build environment via
* the TARGET_SPEAKER macro:
* pio run -e source_jbl -> TARGET_SPEAKER = "JBL Charge 5"
* pio run -e source_cardo -> TARGET_SPEAKER = "Tangerine EDGE"
* Reads PCM from the shared I2S bus (clocked by Board A), passes it through an
* adjustable delay line, and streams it to ONE Bluetooth speaker. The delay lets
* you sync this speaker against the other one; Board A's web UI pushes the value
* over ESP-NOW and it is saved to flash (survives power cycles).
*
* I2S INPUT pins (this board LISTENS to Board A's bus wire to Board A):
* BCK = GPIO19 WS/LRCK = GPIO18 DATA(in) = GPIO22 + common GND
* Per-env build flags (platformio.ini):
* source_jbl -> TARGET_SPEAKER="JBL Charge 5" SPEAKER_ID=0
* source_cardo -> TARGET_SPEAKER="Tangerine EDGE" SPEAKER_ID=1
*
* Board A must be powered and clocking the bus for audio to flow (it clocks
* continuously once booted, outputting silence until the iPhone plays).
* I2S INPUT (listens to Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
*/
#include <Arduino.h>
#include "AudioTools.h"
#include "BluetoothA2DPSource.h"
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <Preferences.h>
#include "relay_config.h"
#ifndef SPEAKER_ID
#define SPEAKER_ID 0
#endif
#ifndef TARGET_SPEAKER
#define TARGET_SPEAKER "BikeAudio-Speaker" // overridden by build_flags per env
#define TARGET_SPEAKER "BikeAudio-Speaker"
#endif
#define I2S_BCK_PIN 19
#define I2S_WS_PIN 18
#define I2S_DATA_PIN 22
#define SR_HZ 44100
// Ring buffer holds max delay + headroom, in frames (1 frame = L+R int16 = 4 bytes).
#define RING_FRAMES (((uint32_t)SR_HZ * (RELAY_MAX_DELAY_MS + 50)) / 1000)
I2SStream i2s;
BluetoothA2DPSource source;
Preferences prefs;
// The BT task pulls audio frames; read them off the I2S bus.
// 1 Frame = 4 bytes (left int16 + right int16). Always return the full count,
// padding with silence on underrun so the A2DP stream never stalls.
int32_t read_i2s_frames(Frame *data, int32_t frame_count) {
size_t bytes = i2s.readBytes((uint8_t *)data, frame_count * sizeof(Frame));
int32_t frames = bytes / sizeof(Frame);
for (int32_t i = frames; i < frame_count; i++) {
data[i].channel1 = 0;
data[i].channel2 = 0;
// Delay ring buffer (interleaved L,R int16). Written by i2s_task, read by the A2DP callback.
static int16_t ring[RING_FRAMES * 2];
static volatile uint32_t write_frames = 0; // monotonic frame counter (producer)
static volatile uint32_t delay_frames = 0; // current delay, in frames
static volatile uint16_t delay_ms_current = 0; // for logging / persistence
static volatile bool save_pending = false;
// Continuously pull I2S into the ring (paced by Board A's clock).
static void i2s_task(void *arg) {
static int16_t tmp[256 * 2];
for (;;) {
size_t bytes = i2s.readBytes((uint8_t *)tmp, sizeof(tmp));
int frames = bytes / 4;
for (int i = 0; i < frames; i++) {
uint32_t w = write_frames % RING_FRAMES;
ring[w * 2] = tmp[i * 2];
ring[w * 2 + 1] = tmp[i * 2 + 1];
write_frames++;
}
if (frames == 0) vTaskDelay(1); // no clock yet (Board A down) — don't spin
}
}
// A2DP pulls frames; serve them delayed by delay_frames behind the write head.
int32_t read_delayed(Frame *data, int32_t frame_count) {
uint32_t d = delay_frames;
if (d < (uint32_t)frame_count) d = frame_count; // never read past the write head
uint32_t w = write_frames;
if (w < d) { // buffer not filled yet -> silence
for (int32_t i = 0; i < frame_count; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
return frame_count;
}
uint32_t start = w - d;
for (int32_t i = 0; i < frame_count; i++) {
uint32_t idx = (start + i) % RING_FRAMES;
data[i].channel1 = ring[idx * 2];
data[i].channel2 = ring[idx * 2 + 1];
}
return frame_count;
}
static void apply_delay(uint16_t ms) {
if (ms > RELAY_MAX_DELAY_MS) ms = RELAY_MAX_DELAY_MS;
if (ms == delay_ms_current) return;
delay_ms_current = ms;
delay_frames = ((uint32_t)ms * SR_HZ) / 1000;
save_pending = true;
Serial.printf("[SRC %s] delay -> %u ms\n", TARGET_SPEAKER, ms);
}
void on_recv(const uint8_t *mac, const uint8_t *data, int len) {
if (len < (int)sizeof(RelayDelayMsg)) return;
RelayDelayMsg m;
memcpy(&m, data, sizeof(m));
if (m.magic != RELAY_MAGIC) return;
apply_delay(m.delay_ms[SPEAKER_ID]);
}
void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
if (state == ESP_A2D_CONNECTION_STATE_CONNECTED)
Serial.printf("[SRC %s] CONNECTED\n", TARGET_SPEAKER);
@ -53,36 +111,53 @@ void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
void setup() {
Serial.begin(115200);
delay(500);
Serial.printf("=== BikeAudio Source -> '%s' (I2S slave -> A2DP) ===\n", TARGET_SPEAKER);
Serial.printf("=== BikeAudio Source -> '%s' (delay buffer, id %d) ===\n", TARGET_SPEAKER, SPEAKER_ID);
// Restore saved delay.
prefs.begin("bikeaudio", false);
apply_delay(prefs.getUShort("delay_ms", 0));
save_pending = false; // loading isn't a change to persist
// Wi-Fi (STA, not connected) + ESP-NOW receive on the shared channel.
WiFi.mode(WIFI_STA);
esp_wifi_set_channel(RELAY_WIFI_CHANNEL, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() == ESP_OK) {
esp_now_register_recv_cb(on_recv);
Serial.println("[SRC] ESP-NOW ready (listening for delay updates)");
} else {
Serial.println("[SRC] ESP-NOW init FAILED");
}
// I2S slave RX — follows Board A's clock.
auto cfg = i2s.defaultConfig(RX_MODE);
cfg.pin_bck = I2S_BCK_PIN;
cfg.pin_ws = I2S_WS_PIN;
cfg.pin_data = I2S_DATA_PIN;
cfg.sample_rate = 44100;
cfg.channels = 2;
cfg.bits_per_sample = 16;
cfg.is_master = false; // slave: clocked by Board A
cfg.buffer_count = 8;
cfg.buffer_size = 512;
cfg.pin_bck = I2S_BCK_PIN; cfg.pin_ws = I2S_WS_PIN; cfg.pin_data = I2S_DATA_PIN;
cfg.sample_rate = SR_HZ; cfg.channels = 2; cfg.bits_per_sample = 16;
cfg.is_master = false; cfg.buffer_count = 8; cfg.buffer_size = 512;
i2s.begin(cfg);
source.set_data_callback_in_frames(read_i2s_frames);
xTaskCreatePinnedToCore(i2s_task, "i2s_reader", 4096, nullptr, 5, nullptr, 0);
source.set_data_callback_in_frames(read_delayed);
source.set_on_connection_state_changed(on_conn_state);
source.set_auto_reconnect(true, 5);
source.set_volume(100);
source.start(TARGET_SPEAKER);
Serial.printf("[SRC] Connecting to '%s' — reading I2S slave @44.1k/16/stereo\n", TARGET_SPEAKER);
Serial.printf("[SRC] Connecting to '%s' — I2S slave @44.1k, delay %u ms\n",
TARGET_SPEAKER, delay_ms_current);
}
void loop() {
if (save_pending) {
prefs.putUShort("delay_ms", delay_ms_current);
save_pending = false;
Serial.printf("[SRC %s] saved delay %u ms to flash\n", TARGET_SPEAKER, delay_ms_current);
}
static unsigned long last = 0;
if (millis() - last > 5000) {
Serial.printf("[SRC %s] connected=%s heap=%u\n", TARGET_SPEAKER,
source.is_connected() ? "YES" : "no", ESP.getFreeHeap());
Serial.printf("[SRC %s] connected=%s delay=%ums heap=%u\n", TARGET_SPEAKER,
source.is_connected() ? "YES" : "no", delay_ms_current, ESP.getFreeHeap());
last = millis();
}
delay(100);
delay(50);
}

27
src/relay_config.h Normal file
View File

@ -0,0 +1,27 @@
/**
* Shared config for the BikeAudio relay included by Board A (sink) and the
* source boards. Defines the Wi-Fi AP for the setup UI and the ESP-NOW message
* Board A broadcasts to push per-speaker delay values to the source boards.
*/
#pragma once
#include <stdint.h>
// Wi-Fi AP that Board A hosts for the browser-based setup UI.
#define RELAY_AP_SSID "BikeAudio-Setup"
#define RELAY_AP_PASS "bikeaudio" // WPA2 needs >= 8 chars
#define RELAY_WIFI_CHANNEL 1 // all boards share this channel for ESP-NOW
// Per-speaker delay range (ms). 250 ms costs ~44 KB of ring buffer per source board.
#define RELAY_MAX_DELAY_MS 250
// Speaker indices into RelayDelayMsg.delay_ms[].
#define SPEAKER_JBL 0
#define SPEAKER_CARDO 1
#define RELAY_MAGIC 0xB10E
// Broadcast Board A -> source boards. delay_ms[SPEAKER_JBL] / [SPEAKER_CARDO].
typedef struct __attribute__((packed)) {
uint16_t magic; // RELAY_MAGIC sentinel
uint16_t delay_ms[2]; // [0]=JBL, [1]=Cardo
} RelayDelayMsg;