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.
This commit is contained in:
Sebastien Vayrette 2026-06-09 12:51:08 -04:00
commit 276489cb17
3 changed files with 418 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@ -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

252
BikeAudio.ino Normal file
View File

@ -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<uint8_t> 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<uint8_t> 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("--------------");
}

146
README.md Normal file
View File

@ -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.