From 0b474b172b1cc9124a879832e9f64cb5a5551ba0 Mon Sep 17 00:00:00 2001 From: blue Date: Wed, 10 Jun 2026 13:11:41 -0400 Subject: [PATCH] Add per-speaker delay sync with Wi-Fi/web UI + ESP-NOW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- platformio.ini | 4 +- src/board_sink.cpp | 149 +++++++++++++++++++++++++++++++------------ src/board_source.cpp | 147 +++++++++++++++++++++++++++++++----------- src/relay_config.h | 27 ++++++++ 4 files changed, 247 insertions(+), 80 deletions(-) create mode 100644 src/relay_config.h diff --git a/platformio.ini b/platformio.ini index 735c581..73276b7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -31,9 +31,9 @@ build_src_filter = + ; --- Board B: I2S slave -> A2DP source -> JBL Charge 5 ------------------------ [env:source_jbl] build_src_filter = + -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 = + -build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' +build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DSPEAKER_ID=1 diff --git a/src/board_sink.cpp b/src/board_sink.cpp index f5b67bd..b83db6e 100644 --- a/src/board_sink.cpp +++ b/src/board_sink.cpp @@ -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 #include "AudioTools.h" #include "BluetoothA2DPSink.h" +#include +#include +#include +#include + +#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 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( + +BikeAudio Sync

BikeAudio — speaker sync

+
+
+
+
+
Raise the delay on whichever speaker is early until they line up. +Values are saved on each speaker.
+)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); } diff --git a/src/board_source.cpp b/src/board_source.cpp index 0050eab..c6a758d 100644 --- a/src/board_source.cpp +++ b/src/board_source.cpp @@ -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 #include "AudioTools.h" #include "BluetoothA2DPSource.h" +#include +#include +#include +#include +#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); } diff --git a/src/relay_config.h b/src/relay_config.h new file mode 100644 index 0000000..5d4b1a5 --- /dev/null +++ b/src/relay_config.h @@ -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 + +// 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;