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:
commit
276489cb17
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
252
BikeAudio.ino
Normal 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
146
README.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user