Sync via capacitive touch (drop Wi-Fi) — fixes BT starvation

The Wi-Fi/web/ESP-NOW sync approach was unviable: Wi-Fi + Bluetooth-A2DP +
the delay buffer exhausted RAM on the classic ESP32 (~20 KB free), and the
Bluetooth stack was so starved the source boards couldn't even connect to a
speaker. Confirmed on hardware: with Wi-Fi up the JBL would not connect; with
Wi-Fi removed it connects instantly (~65 KB free).

- board_source.cpp: remove Wi-Fi/ESP-NOW. Keep the I2S-reader-task + ring
  delay line (now 200 ms; plenty of RAM without Wi-Fi). Adjust delay live by
  ear via two capacitive-touch pads — "+" on GPIO4 (T0), "-" on GPIO27 (T7);
  tap = 5 ms step, hold = ramp. Persisted to flash (debounced).
- board_sink.cpp: reverted to the simple A2DP sink + I2S master (no Wi-Fi).
- platformio.ini: drop SPEAKER_ID. Remove relay_config.h.

All three boards connect reliably with healthy heap. Touch pins read ~120
untouched; threshold 40.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
blue — ESP32/PlatformIO firmware 2026-06-10 14:07:43 -04:00
parent 0b474b172b
commit 2a34ed5abe
4 changed files with 107 additions and 200 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"' -DSPEAKER_ID=0
build_flags = '-DTARGET_SPEAKER="JBL Charge 5"'
; --- Board C: I2S slave -> A2DP source -> Cardo (Tangerine EDGE) --------------
[env:source_cardo]
build_src_filter = +<board_source.cpp>
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DSPEAKER_ID=1
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"'

View File

@ -1,30 +1,28 @@
/**
* BikeAudio Board A : A2DP SINK -> I2S MASTER + setup UI
* BikeAudio Board A : A2DP SINK -> I2S MASTER
*
* 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.
* 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.
*
* 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
* iPhone ))BT)) [Board A: sink] ==I2S==> [Board B: src] ))BT)) JBL
* \====> [Board C: src] ))BT)) Cardo
*
* 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.
* 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.
*
* Build: pio run -e sink
* 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)
*/
#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
@ -32,130 +30,67 @@
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} };
static uint16_t current_sample_rate = 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;
if (rate == 0) rate = 44100; // SBC default before negotiation
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; 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; // Board A clocks the whole bus
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);
}
void write_pcm_to_i2s(const uint8_t *data, uint32_t len) { i2s.write(data, len); }
// 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 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 — SINK -> I2S master + setup UI ===");
Serial.println("=== BikeAudio Board A — A2DP SINK -> I2S master ===");
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() {
server.handleClient();
// Follow the negotiated sample rate (iPhone usually 44100; reconfigure if not).
uint16_t sr = sink.sample_rate();
if (sr != 0 && sr != current_sample_rate) {
Serial.printf("[SINK] sample rate %u -> %u\n", current_sample_rate, sr);
Serial.printf("[SINK] sample rate changed %u -> %u, reconfiguring I2S\n",
current_sample_rate, sr);
start_i2s(sr);
}
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();
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();
}
delay(2);
delay(100);
}

View File

@ -1,54 +1,57 @@
/**
* BikeAudio Boards B & C : I2S SLAVE -> [delay buffer] -> A2DP SOURCE
* BikeAudio Boards B & C : I2S SLAVE -> [touch-adjustable delay] -> A2DP SOURCE
*
* 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).
* you sync this speaker against the other one, tuned live BY EAR with two
* capacitive-touch pads (+ / -). The value is saved to flash (survives reboots).
*
* 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
* No Wi-Fi: Wi-Fi + Bluetooth-A2DP + the audio buffer don't fit in RAM on this
* chip (the BT stack gets starved and won't connect), so the control is local
* touch rather than a phone UI. With Wi-Fi gone there's ~120 KB of heap free.
*
* I2S INPUT (listens to Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
* Per-env build flag: TARGET_SPEAKER ("JBL Charge 5" / "Tangerine EDGE").
*
* Wiring:
* I2S in (from Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
* Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7)
* (attach a short wire or a bit of foil to each; tap = one step, hold = ramp)
*/
#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"
#endif
#define I2S_BCK_PIN 19
#define I2S_WS_PIN 18
#define I2S_DATA_PIN 22
#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)
#define TOUCH_PLUS T0 // GPIO4
#define TOUCH_MINUS T7 // GPIO27
#define TOUCH_THRESH 40 // touchRead below this = touched (calibrate via serial)
#define SR_HZ 44100
#define MAX_DELAY_MS 200
#define DELAY_STEP_MS 5
#define TOUCH_REPEAT_MS 150 // tap = one step; hold = a step every 150 ms
#define RING_FRAMES (((uint32_t)SR_HZ * (MAX_DELAY_MS + 20)) / 1000)
I2SStream i2s;
BluetoothA2DPSource source;
Preferences prefs;
// 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;
static volatile uint32_t write_frames = 0;
static volatile uint32_t delay_frames = 0;
static volatile uint16_t delay_ms_current = 0;
static bool save_pending = false;
static unsigned long last_change_ms = 0;
// Continuously pull I2S into the ring (paced by Board A's clock).
static void i2s_task(void *arg) {
@ -71,7 +74,7 @@ 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
if (w < d) {
for (int32_t i = 0; i < frame_count; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
return frame_count;
}
@ -84,21 +87,15 @@ int32_t read_delayed(Frame *data, int32_t frame_count) {
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;
static void set_delay(int ms) {
if (ms < 0) ms = 0;
if (ms > MAX_DELAY_MS) ms = MAX_DELAY_MS;
if ((uint16_t)ms == delay_ms_current) return;
delay_ms_current = (uint16_t)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]);
last_change_ms = millis();
Serial.printf("[SRC %s] delay = %d ms\n", TARGET_SPEAKER, ms);
}
void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
@ -111,23 +108,12 @@ void on_conn_state(esp_a2d_connection_state_t state, void *obj) {
void setup() {
Serial.begin(115200);
delay(500);
Serial.printf("=== BikeAudio Source -> '%s' (delay buffer, id %d) ===\n", TARGET_SPEAKER, SPEAKER_ID);
Serial.printf("=== BikeAudio Source -> '%s' (touch-adjustable delay) ===\n", TARGET_SPEAKER);
// Restore saved delay.
prefs.begin("bikeaudio", false);
apply_delay(prefs.getUShort("delay_ms", 0));
set_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;
@ -142,22 +128,35 @@ void setup() {
source.set_auto_reconnect(true, 5);
source.set_volume(100);
source.start(TARGET_SPEAKER);
Serial.printf("[SRC] Connecting to '%s' — I2S slave @44.1k, delay %u ms\n",
Serial.printf("[SRC] Connecting to '%s' — delay %u ms; touch + on GPIO4, - on GPIO27\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);
unsigned long now = millis();
// Touch +/- (tap = one step, hold = ramp every TOUCH_REPEAT_MS).
static unsigned long last_touch = 0;
if (now - last_touch >= TOUCH_REPEAT_MS) {
bool plus = touchRead(TOUCH_PLUS) < TOUCH_THRESH;
bool minus = touchRead(TOUCH_MINUS) < TOUCH_THRESH;
if (plus && !minus) { set_delay(delay_ms_current + DELAY_STEP_MS); last_touch = now; }
else if (minus && !plus) { set_delay(delay_ms_current - DELAY_STEP_MS); last_touch = now; }
}
static unsigned long last = 0;
if (millis() - last > 5000) {
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();
// Persist to flash 1.5 s after the last change (avoids wear while ramping).
if (save_pending && now - last_change_ms > 1500) {
prefs.putUShort("delay_ms", delay_ms_current);
save_pending = false;
Serial.printf("[SRC %s] saved %u ms to flash\n", TARGET_SPEAKER, delay_ms_current);
}
delay(50);
static unsigned long last_st = 0;
if (now - last_st > 5000) {
Serial.printf("[SRC %s] connected=%s delay=%ums heap=%u touch+=%u touch-=%u\n",
TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current,
ESP.getFreeHeap(), touchRead(TOUCH_PLUS), touchRead(TOUCH_MINUS));
last_st = now;
}
delay(20);
}

View File

@ -1,27 +0,0 @@
/**
* 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;