diff --git a/README_RELAY.md b/README_RELAY.md new file mode 100644 index 0000000..3de810d --- /dev/null +++ b/README_RELAY.md @@ -0,0 +1,84 @@ +# BikeAudio — 3-board Bluetooth relay + +Relays iPhone audio to **two** Bluetooth speakers (JBL Charge 5 + Cardo "Tangerine EDGE") +at the same time. + +## Why three boards? + +One ESP32 **cannot** do this alone: + +1. **Sink + source can't coexist.** To receive from the iPhone the ESP32 must be an + A2DP *sink*; to play through a speaker it must be an A2DP *source*. The ESP32's + classic-Bluetooth stack registers only one A2DP role at a time (the ESP32-A2DP + library keeps a single global instance that each role overwrites). Running a sink + and a source together orphans the sink — the iPhone can't even see it. +2. **A source reaches only one speaker.** An A2DP source holds a single outgoing + link, so one board can drive one speaker, not two. + +So the job is split: one board receives, one board per speaker sends, and they pass +audio between them over a short digital **I2S** wire bus. + +``` + iPhone ))BT)) ┌──────────────┐ I2S bus (BCK/WS/DATA + GND) + │ Board A │═══════════════╦═══════════════╗ + │ A2DP SINK │ ║ ║ + │ I2S MASTER │ ▼ ▼ + └──────────────┘ ┌────────────┐ ┌────────────┐ + │ Board B │ │ Board C │ + │ A2DP SOURCE│ │ A2DP SOURCE│ + │ I2S SLAVE │ │ I2S SLAVE │ + └─────┬──────┘ └─────┬──────┘ + ))BT)) ))BT)) + JBL Charge 5 Tangerine EDGE (Cardo) +``` + +## Wiring + +Board A is the I2S **master** (it generates the clocks). Boards B and C are +**slaves** that listen to A's bus in parallel. Tie the three signals from A to the +matching input pins on **both** B and C, and tie **all grounds together**. + +| Signal | Board A (master, out) | Board B (slave, in) | Board C (slave, in) | +|-------------|-----------------------|---------------------|---------------------| +| Bit clock | **GPIO5** (BCK) | GPIO19 | GPIO19 | +| Word select | **GPIO25** (WS/LRCK) | GPIO18 | GPIO18 | +| Data | **GPIO23** (DATA out) | GPIO22 (in) | GPIO22 (in) | +| Ground | **GND** | GND | GND | + +- A·GPIO5 → B·GPIO19 **and** C·GPIO19 +- A·GPIO25 → B·GPIO18 **and** C·GPIO18 +- A·GPIO23 → B·GPIO22 **and** C·GPIO22 +- A·GND → B·GND **and** C·GND (mandatory — shared clock reference) + +Each board can be powered from its own USB/5V; only the grounds must be common. + +## Build & flash + +``` +pio run # builds all three +pio run -e sink # Board A +pio run -e source_jbl # Board B (target "JBL Charge 5") +pio run -e source_cardo # Board C (target "Tangerine EDGE") +``` + +Flash each board with the matching environment's artifacts +(`.pio/build//{bootloader,partitions,firmware}.bin`). + +**Power-on order:** bring up **Board A first** so the I2S bus is clocking before +B and C start reading it. + +## First-time pairing + +1. Put the **JBL** and **Cardo** in pairing mode. +2. Power Board B and Board C — each connects to its speaker by name + (auto-reconnects on later power-ups). +3. Power Board A; on the iPhone, connect to **"BikeAudio"**. +4. Play audio — both speakers should output together. + +## Known limitations + +- **The two speakers are not sample-synchronized.** JBL and Cardo each have their + own Bluetooth buffering, so one may lag the other by some tens of milliseconds. + Fine for music/intercom; not suitable for tight stereo L/R separation. +- Audio is fixed at the SBC standard **44.1 kHz / 16-bit / stereo**. +- If Board A reboots, the slave boards' audio pauses until A is clocking again. diff --git a/platformio.ini b/platformio.ini index 28629df..735c581 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,27 +1,39 @@ -; PlatformIO project configuration for BikeAudio -; Converted from Arduino IDE sketch (BikeAudio.ino). +; BikeAudio — 3-board relay (iPhone -> Board A sink -> I2S -> Boards B/C sources -> JBL + Cardo) ; -; Board: ESP32 Dev Module (DevKitC v4) -> esp32dev -; ESP32 Arduino core: 2.0.17 (provided by platform espressif32 @ ~6.6.0) -; Do NOT move to core 3.x — BT stack regression. -; Partition scheme: Huge APP (3MB No OTA / 1MB SPIFFS) -> huge_app.csv -; Serial monitor: 115200 baud +; One ESP32 cannot be an A2DP sink and source at once, and an A2DP source can +; reach only one speaker — so the work is split across three boards that share +; an I2S bus. See README.md for wiring and the flash order. +; +; Build all: pio run +; Build one board: pio run -e sink | -e source_jbl | -e source_cardo +; +; Common specs preserved from the original sketch: ESP32 Arduino core 2.0.x via +; espressif32 ~6.6.0, esp32dev, huge_app partition (BT stack), 115200 monitor. -[env:esp32dev] +[platformio] +default_envs = sink, source_jbl, source_cardo + +[env] platform = espressif32 @ ~6.6.0 board = esp32dev framework = arduino - -; Huge APP partition table — required for the Bluetooth stack size. board_build.partitions = huge_app.csv - monitor_speed = 115200 - -; Phil Schatzmann's libraries (referenced by git URL, as in the .ino header — -; not published in the PlatformIO registry under these names). -; ESP32-A2DP depends on arduino-audio-tools. Pinned to exact commits for -; reproducible builds (ESP32-A2DP 1.8.11, audio-tools 1.2.4); src/main.cpp -; was adapted to match this API surface. +; pschatzmann libs pinned to exact commits (ESP32-A2DP 1.8.11, audio-tools 1.2.4) lib_deps = https://github.com/pschatzmann/ESP32-A2DP#42601717cd70d5300c9b519f3c2bf1d64d77ea2b https://github.com/pschatzmann/arduino-audio-tools#64b64dcb9bde18a0a17766eeb6529c3a53d920a8 + +; --- Board A: A2DP sink (iPhone) -> I2S master -------------------------------- +[env:sink] +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"' + +; --- Board C: I2S slave -> A2DP source -> Cardo (Tangerine EDGE) -------------- +[env:source_cardo] +build_src_filter = + +build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"' diff --git a/src/board_sink.cpp b/src/board_sink.cpp new file mode 100644 index 0000000..f5b67bd --- /dev/null +++ b/src/board_sink.cpp @@ -0,0 +1,96 @@ +/** + * BikeAudio — Board A : A2DP SINK -> I2S MASTER + * + * 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. + * + * iPhone ))BT)) [Board A: sink] ==I2S==> [Board B: src] ))BT)) JBL + * \====> [Board C: src] ))BT)) Cardo + * + * 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. + * + * 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" + +#define I2S_BCK_PIN 5 +#define I2S_WS_PIN 25 +#define I2S_DATA_PIN 23 + +I2SStream i2s; +BluetoothA2DPSink sink; + +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; // 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; // 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); +} + +// 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"); +} + +void setup() { + Serial.begin(115200); + delay(500); + 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"); + + Serial.println("[SINK] Advertising 'BikeAudio' — connect from iPhone"); +} + +void loop() { + // 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 changed %u -> %u, reconfiguring I2S\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(); + } + delay(100); +} diff --git a/src/board_source.cpp b/src/board_source.cpp new file mode 100644 index 0000000..0050eab --- /dev/null +++ b/src/board_source.cpp @@ -0,0 +1,88 @@ +/** + * BikeAudio — Boards B & C : I2S SLAVE -> 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" + * + * 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 + * + * Board A must be powered and clocking the bus for audio to flow (it clocks + * continuously once booted, outputting silence until the iPhone plays). + */ + +#include + +#include "AudioTools.h" +#include "BluetoothA2DPSource.h" + +#ifndef TARGET_SPEAKER +#define TARGET_SPEAKER "BikeAudio-Speaker" // overridden by build_flags per env +#endif + +#define I2S_BCK_PIN 19 +#define I2S_WS_PIN 18 +#define I2S_DATA_PIN 22 + +I2SStream i2s; +BluetoothA2DPSource source; + +// 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; + } + return frame_count; +} + +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); + else if (state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) + Serial.printf("[SRC %s] disconnected — will retry\n", TARGET_SPEAKER); +} + +void setup() { + Serial.begin(115200); + delay(500); + Serial.printf("=== BikeAudio Source -> '%s' (I2S slave -> A2DP) ===\n", TARGET_SPEAKER); + + // 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; + i2s.begin(cfg); + + source.set_data_callback_in_frames(read_i2s_frames); + 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); +} + +void loop() { + 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()); + last = millis(); + } + delay(100); +} diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 4cf94aa..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,261 +0,0 @@ -/** - * BikeAudio — ESP32 DevKitC v4 Bluetooth Audio Relay - * - * iPhone --> [ESP32 A2DP SINK] --> [A2DP SOURCE x2] --> JBL Charge 5 + Cardo Packtalk Edge - * - * Libraries required (install via Arduino IDE Library Manager or .zip): - * - ESP32-A2DP by Phil Schatzmann https://github.com/pschatzmann/ESP32-A2DP - * - arduino-audio-tools by Phil Schatzmann https://github.com/pschatzmann/arduino-audio-tools - * - * Board: ESP32 Dev Module - * Partition scheme: Huge APP (3MB No OTA/1MB SPIFFS) <-- required for BT stack size - * ESP32 Arduino core: 2.0.17 (do NOT use 3.x — BT stack regression) - * - * HOW IT WORKS: - * 1. ESP32 boots and connects to JBL Charge 5 and Cardo as A2DP sources - * 2. Then advertises itself as "BikeAudio" for the iPhone to connect to - * 3. Audio received from iPhone is forwarded to both speakers in real time - * 4. Auto-reconnect on power cycle — just turn everything on and it finds each other - * - * FIRST TIME SETUP: - * - Forget JBL and Cardo from your iPhone - * - Put JBL in pairing mode (hold Bluetooth button) - * - Put Cardo in pairing mode (check Cardo manual — usually hold phone button) - * - Flash this sketch, open Serial Monitor at 115200 - * - ESP32 will find and pair with both devices on first boot - * - On iPhone, go to Bluetooth settings and connect to "BikeAudio" - * - Done — play audio, both speakers output simultaneously - */ - -#include - -#include "AudioTools.h" -#include "BluetoothA2DPSink.h" -#include "BluetoothA2DPSource.h" -#include "BluetoothA2DPCommon.h" - -// Forward declaration — defined below. Required in .cpp builds: the Arduino IDE -// auto-generates prototypes for .ino files, but PlatformIO compiles .cpp directly -// and print_status() is called in setup()/loop() before its definition. -void print_status(); - -// ─── CONFIGURATION ──────────────────────────────────────────────────────────── - -// Name this device shows to iPhone -#define SINK_NAME "BikeAudio" - -// Exact Bluetooth names of your speakers (must match exactly, case sensitive) -#define JBL_NAME "JBL Charge 5" -#define CARDO_NAME "Tangerine EDGE" - -// Retry interval if a speaker disconnects (ms) -#define RECONNECT_MS 5000 - -// Audio buffer size — larger = more stable, slightly more latency -#define BUFFER_SIZE (4 * 1024) - -// ─── GLOBALS ────────────────────────────────────────────────────────────────── - -BluetoothA2DPSink sink; // receives audio FROM iPhone -BluetoothA2DPSource src_jbl; // sends audio TO JBL -BluetoothA2DPSource src_cardo; // sends audio TO Cardo - -// Shared ring buffer — sink writes, sources read -RingBuffer ring_buf(BUFFER_SIZE * 2); - -// Connection state -volatile bool jbl_connected = false; -volatile bool cardo_connected = false; -volatile bool iphone_connected = false; - -unsigned long last_reconnect_jbl = 0; -unsigned long last_reconnect_cardo = 0; - -// ─── AUDIO CALLBACK (iPhone → buffer) ──────────────────────────────────────── - -/** - * Called by the A2DP sink every time a new audio frame arrives from iPhone. - * We write raw PCM into the shared ring buffer. - * Both sources pull from this buffer simultaneously. - */ -void audio_received_cb(const uint8_t *data, uint32_t len) { - // Write to ring buffer — non-blocking, drop if full (prevents deadlock) - for (uint32_t i = 0; i < len; i++) { - if (!ring_buf.isFull()) { - ring_buf.write(data[i]); - } - } -} - -// ─── SOURCE DATA CALLBACK (buffer → JBL / Cardo) ───────────────────────────── - -/** - * Called by each A2DP source when it needs audio data to send. - * Both JBL and Cardo call this — they share the same buffer read pointer - * via a duplicated/mirrored buffer approach. - * - * We use a simple approach: one primary reader (JBL) drains the buffer, - * Cardo gets the same data via a mirrored write in audio_received_cb. - */ - -// Second ring buffer mirroring data for Cardo -RingBuffer ring_buf_cardo(BUFFER_SIZE * 2); - -void audio_received_mirror_cb(const uint8_t *data, uint32_t len) { - // Write to BOTH ring buffers — JBL gets ring_buf, Cardo gets ring_buf_cardo - for (uint32_t i = 0; i < len; i++) { - if (!ring_buf.isFull()) ring_buf.write(data[i]); - if (!ring_buf_cardo.isFull()) ring_buf_cardo.write(data[i]); - } -} - -int32_t get_audio_for_jbl(uint8_t *data, int32_t len) { - int32_t bytes_read = 0; - uint8_t b; - while (bytes_read < len && ring_buf.read(b)) { - data[bytes_read++] = b; - } - // Pad with silence if buffer underrun - if (bytes_read < len) { - memset(data + bytes_read, 0, len - bytes_read); - } - return len; -} - -int32_t get_audio_for_cardo(uint8_t *data, int32_t len) { - int32_t bytes_read = 0; - uint8_t b; - while (bytes_read < len && ring_buf_cardo.read(b)) { - data[bytes_read++] = b; - } - if (bytes_read < len) { - memset(data + bytes_read, 0, len - bytes_read); - } - return len; -} - -// ─── CONNECTION CALLBACKS ───────────────────────────────────────────────────── - -void sink_connected_cb(esp_a2d_connection_state_t state, void *obj) { - if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) { - Serial.println("[SINK] iPhone connected"); - iphone_connected = true; - } else { - Serial.println("[SINK] iPhone disconnected"); - iphone_connected = false; - } -} - -void jbl_connected_cb(esp_a2d_connection_state_t state, void *obj) { - if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) { - Serial.println("[JBL] Connected"); - jbl_connected = true; - } else { - Serial.println("[JBL] Disconnected — will retry"); - jbl_connected = false; - last_reconnect_jbl = millis(); - } -} - -void cardo_connected_cb(esp_a2d_connection_state_t state, void *obj) { - if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) { - Serial.println("[CARDO] Connected"); - cardo_connected = true; - } else { - Serial.println("[CARDO] Disconnected — will retry"); - cardo_connected = false; - last_reconnect_cardo = millis(); - } -} - -// ─── SETUP ──────────────────────────────────────────────────────────────────── - -void setup() { - Serial.begin(115200); - delay(500); - Serial.println("=== BikeAudio Booting ==="); - - // ── Step 1: Connect to JBL as A2DP source ───────────────────────────────── - Serial.println("[JBL] Connecting..."); - src_jbl.set_data_callback(get_audio_for_jbl); - src_jbl.set_on_connection_state_changed(jbl_connected_cb); - src_jbl.set_auto_reconnect(true); - src_jbl.start(JBL_NAME); // auto-reconnect handled by set_auto_reconnect() above - - // Give it time to connect before starting second source - // (Bluedroid needs sequential connection setup) - uint32_t t = millis(); - while (!jbl_connected && millis() - t < 10000) { - delay(100); - } - if (jbl_connected) { - Serial.println("[JBL] Ready"); - } else { - Serial.println("[JBL] Not found yet — will retry in background"); - } - - // ── Step 2: Connect to Cardo as A2DP source ──────────────────────────────── - Serial.println("[CARDO] Connecting..."); - src_cardo.set_data_callback(get_audio_for_cardo); - src_cardo.set_on_connection_state_changed(cardo_connected_cb); - src_cardo.set_auto_reconnect(true); - src_cardo.start(CARDO_NAME); - - t = millis(); - while (!cardo_connected && millis() - t < 10000) { - delay(100); - } - if (cardo_connected) { - Serial.println("[CARDO] Ready"); - } else { - Serial.println("[CARDO] Not found yet — will retry in background"); - } - - // ── Step 3: Start sink — advertise "BikeAudio" to iPhone ────────────────── - Serial.println("[SINK] Advertising as '" SINK_NAME "' ..."); - sink.set_stream_reader(audio_received_mirror_cb); - sink.set_on_connection_state_changed(sink_connected_cb); - sink.start(SINK_NAME); - Serial.println("[SINK] Ready — connect iPhone to 'BikeAudio'"); - - Serial.println("=== BikeAudio Ready ==="); - print_status(); -} - -// ─── LOOP ───────────────────────────────────────────────────────────────────── - -void loop() { - // Auto-reconnect JBL if lost - if (!jbl_connected && millis() - last_reconnect_jbl > RECONNECT_MS) { - Serial.println("[JBL] Retrying connection..."); - src_jbl.start(JBL_NAME); - last_reconnect_jbl = millis(); - } - - // Auto-reconnect Cardo if lost - if (!cardo_connected && millis() - last_reconnect_cardo > RECONNECT_MS) { - Serial.println("[CARDO] Retrying connection..."); - src_cardo.start(CARDO_NAME); - last_reconnect_cardo = millis(); - } - - // Print status every 10 seconds - static unsigned long last_status = 0; - if (millis() - last_status > 10000) { - print_status(); - last_status = millis(); - } - - delay(100); -} - -// ─── HELPERS ────────────────────────────────────────────────────────────────── - -void print_status() { - Serial.println("--- Status ---"); - Serial.printf(" iPhone : %s\n", iphone_connected ? "CONNECTED" : "waiting..."); - Serial.printf(" JBL : %s\n", jbl_connected ? "CONNECTED" : "waiting..."); - Serial.printf(" Cardo : %s\n", cardo_connected ? "CONNECTED" : "waiting..."); - Serial.printf(" Heap : %d bytes free\n", ESP.getFreeHeap()); - Serial.println("--------------"); -}