diff --git a/platformio.ini b/platformio.ini index 73276b7..735c581 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"' -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 = + -build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' -DSPEAKER_ID=1 +build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' diff --git a/src/board_sink.cpp b/src/board_sink.cpp index b83db6e..f5b67bd 100644 --- a/src/board_sink.cpp +++ b/src/board_sink.cpp @@ -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 #include "AudioTools.h" #include "BluetoothA2DPSink.h" -#include -#include -#include -#include - -#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( - -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 — 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); } diff --git a/src/board_source.cpp b/src/board_source.cpp index c6a758d..fe29714 100644 --- a/src/board_source.cpp +++ b/src/board_source.cpp @@ -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 #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" #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); } diff --git a/src/relay_config.h b/src/relay_config.h deleted file mode 100644 index 5d4b1a5..0000000 --- a/src/relay_config.h +++ /dev/null @@ -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 - -// 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;