From 276489cb173c1a43922bc4a4eb1c2bb622db7e92 Mon Sep 17 00:00:00 2001 From: Sebastien Vayrette Date: Tue, 9 Jun 2026 12:51:08 -0400 Subject: [PATCH] feat: initial BikeAudio firmware ESP32 DevKitC v4 Bluetooth audio relay. iPhone -> ESP32 (A2DP sink) -> JBL Charge 5 + Tangerine EDGE (dual A2DP source). Dual mirrored ring buffers for in-sync output, auto-reconnect on boot. --- .gitignore | 20 ++++ BikeAudio.ino | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 146 +++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 .gitignore create mode 100644 BikeAudio.ino create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ee61ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Arduino build artifacts +build/ +*.bin +*.elf +*.map + +# Arduino IDE cache +.arduino15/ +.cache/ + +# VS Code / PlatformIO +.vscode/ +.pio/ +.pioenvs/ + +# macOS +.DS_Store + +# Logs +*.log diff --git a/BikeAudio.ino b/BikeAudio.ino new file mode 100644 index 0000000..84b66c5 --- /dev/null +++ b/BikeAudio.ino @@ -0,0 +1,252 @@ +/** + * 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 "AudioTools.h" +#include "BluetoothA2DPSink.h" +#include "BluetoothA2DPSource.h" +#include "BluetoothA2DPCommon.h" + +// ─── 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; + while (bytes_read < len && !ring_buf.isEmpty()) { + data[bytes_read++] = ring_buf.read(); + } + // 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; + while (bytes_read < len && !ring_buf_cardo.isEmpty()) { + data[bytes_read++] = ring_buf_cardo.read(); + } + if (bytes_read < len) { + memset(data + bytes_read, 0, len - bytes_read); + } + return len; +} + +// ─── CONNECTION CALLBACKS ───────────────────────────────────────────────────── + +void sink_connected_cb(esp_bd_addr_t addr, 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_bd_addr_t addr, 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_bd_addr_t addr, 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, true); // true = reconnect if known device + + // 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, true); + + 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, true); + 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, true); + 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("--------------"); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e41d00 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# BikeAudio — Setup Guide + +iPhone → ESP32 DevKitC v4 → JBL Charge 5 + Cardo Packtalk Edge (wireless, no latency concern) + +--- + +## 1. Arduino IDE Setup + +### Install Arduino IDE +Download from https://www.arduino.cc/en/software (version 2.x) + +### Add ESP32 Board Support +1. Open Arduino IDE → Preferences +2. Add this URL to "Additional boards manager URLs": + https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json +3. Tools → Board → Boards Manager → search "esp32" → install "esp32 by Espressif Systems" VERSION 2.0.17 + IMPORTANT: Use 2.0.17, NOT 3.x — the Bluetooth stack has regressions in 3.x + +### Board Settings (Tools menu) + Board: ESP32 Dev Module + Partition Scheme: Huge APP (3MB No OTA/1MB SPIFFS) <-- CRITICAL + Upload Speed: 921600 + CPU Frequency: 240MHz + Flash Frequency: 80MHz + Flash Mode: QIO + Flash Size: 4MB (32Mb) + +--- + +## 2. Install Libraries + +### Option A — Arduino IDE Library Manager + 1. Sketch → Include Library → Manage Libraries + 2. Search "ESP32-A2DP" → install by Phil Schatzmann + 3. Search "arduino-audio-tools" → install by Phil Schatzmann + +### Option B — Manual (if Library Manager version is outdated) + Download ZIPs from: + https://github.com/pschatzmann/ESP32-A2DP/archive/refs/heads/main.zip + https://github.com/pschatzmann/arduino-audio-tools/archive/refs/heads/main.zip + Sketch → Include Library → Add .ZIP Library → select each ZIP + +--- + +## 3. First Time Pairing (do this once) + +The order matters. + + 1. Forget JBL Charge 5 and Cardo from your iPhone Bluetooth settings + (iPhone must NOT be connected to them — ESP32 needs to claim them) + + 2. Put JBL Charge 5 in pairing mode + Hold the Bluetooth button until you hear the pairing sound + + 3. Put Cardo Packtalk Edge in pairing mode + Hold the phone button for 3 seconds until LED flashes + + 4. Open BikeAudio.ino in Arduino IDE, plug in ESP32 via USB, flash it + + 5. Open Serial Monitor (115200 baud) — you'll see: + [JBL] Connecting... + [JBL] Connected + [CARDO] Connecting... + [CARDO] Connected + [SINK] Advertising as 'BikeAudio'... + + 6. On iPhone → Settings → Bluetooth → connect to "BikeAudio" + + 7. Play audio — both JBL and Cardo should output simultaneously + +After first pairing, all three devices remember each other. +On next boot, everything reconnects automatically within ~10 seconds. + +--- + +## 4. Daily Use (after first pairing) + + 1. Power on ESP32 (USB powerbank on bike, or small LiPo) + 2. Turn on JBL Charge 5 + 3. Power on Cardo helmet + 4. iPhone auto-reconnects to "BikeAudio" (or tap it in BT settings) + 5. Play music/nav audio — both speakers play + +No buttons, no app, no wires. + +--- + +## 5. Customization + +Edit these lines at the top of BikeAudio.ino: + + #define SINK_NAME "BikeAudio" // name iPhone sees + #define JBL_NAME "JBL Charge 5" // must match exactly + #define CARDO_NAME "Tangerine EDGE" // must match exactly + +To find exact BT name of a device: + iPhone → Settings → Bluetooth → tap the device name shown there + +--- + +## 6. Powering the ESP32 on the Bike + +Options (all wireless, no wires to phone): + A. USB powerbank in jacket pocket or tank bag — plug ESP32 via USB-C cable + B. Small LiPo 3.7V 1000mAh + TP4056 charging module (~$5 total) — fully self-contained + C. Tap 5V from bike's USB port if you have one + +The ESP32 draws ~200-300mA during active BT streaming. A 1000mAh LiPo lasts ~3-4 hours. + +--- + +## 7. Troubleshooting + +JBL or Cardo not connecting: + - Make sure they are NOT paired to your iPhone anymore + - Put them in pairing mode before powering the ESP32 + - Check exact name match in the #define lines + - Open Serial Monitor and watch the output + +Compilation errors: + - Double-check you're on ESP32 core 2.0.17 not 3.x + - Double-check Partition Scheme = Huge APP + - Make sure both pschatzmann libraries are installed + +Audio cutting out: + - Increase BUFFER_SIZE from 4*1024 to 8*1024 in the sketch + - Keep ESP32 away from other 2.4GHz sources (WiFi routers) + +Only one speaker plays: + - The second source connection may have failed on boot + - Check Serial Monitor — it will say which device is waiting + - Power cycle everything and let them reconnect + +--- + +## 8. How the Audio Path Works + +iPhone sends standard Bluetooth A2DP audio (SBC codec, 44.1kHz stereo 16-bit). +ESP32 receives it as a sink (like a Bluetooth speaker). +Each audio frame is written into two separate ring buffers simultaneously. +JBL source reads from buffer 1, Cardo source reads from buffer 2. +Both get identical audio at the same time = in sync. + +Latency added by the relay: ~50-150ms on top of normal BT latency. +Since you said latency is fine, this is not an issue. +Both speakers receive audio with the same added delay so they stay in sync with each other.