3-board relay firmware: sink + I2S + dual source
Implements the only architecture that can relay iPhone audio to two BT
speakers at once (one ESP32 cannot be A2DP sink+source, and a source holds
only one link):
iPhone ))BT)) [Board A: A2DP sink -> I2S master]
==I2S bus==> [Board B: I2S slave -> A2DP source] ))BT)) JBL
==I2S bus==> [Board C: I2S slave -> A2DP source] ))BT)) Cardo
- src/board_sink.cpp : A2DP sink "BikeAudio", forwards decoded PCM to an
I2S master bus (BCK=5, WS=25, DATA=23); follows negotiated sample rate.
- src/board_source.cpp : I2S slave (BCK=19, WS=18, DATA=22) -> A2DP source,
target speaker via TARGET_SPEAKER build flag; pads silence on underrun.
- platformio.ini : 3 envs (sink, source_jbl, source_cardo) sharing an
[env] base; sources differ only by TARGET_SPEAKER. build_src_filter selects
the per-board source file. Libs pinned as before.
- README_RELAY.md : wiring table, I2S bus topology, flash order, pairing,
and the speaker-sync limitation.
Replaces the single-board src/main.cpp (architecturally impossible). All
three envs build clean. Hardware flash + wiring next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04e7f20430
commit
baa3ef7690
84
README_RELAY.md
Normal file
84
README_RELAY.md
Normal file
@ -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/<env>/{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.
|
||||||
@ -1,27 +1,39 @@
|
|||||||
; PlatformIO project configuration for BikeAudio
|
; BikeAudio — 3-board relay (iPhone -> Board A sink -> I2S -> Boards B/C sources -> JBL + Cardo)
|
||||||
; Converted from Arduino IDE sketch (BikeAudio.ino).
|
|
||||||
;
|
;
|
||||||
; Board: ESP32 Dev Module (DevKitC v4) -> esp32dev
|
; One ESP32 cannot be an A2DP sink and source at once, and an A2DP source can
|
||||||
; ESP32 Arduino core: 2.0.17 (provided by platform espressif32 @ ~6.6.0)
|
; reach only one speaker — so the work is split across three boards that share
|
||||||
; Do NOT move to core 3.x — BT stack regression.
|
; an I2S bus. See README.md for wiring and the flash order.
|
||||||
; Partition scheme: Huge APP (3MB No OTA / 1MB SPIFFS) -> huge_app.csv
|
;
|
||||||
; Serial monitor: 115200 baud
|
; 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
|
platform = espressif32 @ ~6.6.0
|
||||||
board = esp32dev
|
board = esp32dev
|
||||||
framework = arduino
|
framework = arduino
|
||||||
|
|
||||||
; Huge APP partition table — required for the Bluetooth stack size.
|
|
||||||
board_build.partitions = huge_app.csv
|
board_build.partitions = huge_app.csv
|
||||||
|
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
|
; pschatzmann libs pinned to exact commits (ESP32-A2DP 1.8.11, audio-tools 1.2.4)
|
||||||
; 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.
|
|
||||||
lib_deps =
|
lib_deps =
|
||||||
https://github.com/pschatzmann/ESP32-A2DP#42601717cd70d5300c9b519f3c2bf1d64d77ea2b
|
https://github.com/pschatzmann/ESP32-A2DP#42601717cd70d5300c9b519f3c2bf1d64d77ea2b
|
||||||
https://github.com/pschatzmann/arduino-audio-tools#64b64dcb9bde18a0a17766eeb6529c3a53d920a8
|
https://github.com/pschatzmann/arduino-audio-tools#64b64dcb9bde18a0a17766eeb6529c3a53d920a8
|
||||||
|
|
||||||
|
; --- Board A: A2DP sink (iPhone) -> I2S master --------------------------------
|
||||||
|
[env:sink]
|
||||||
|
build_src_filter = +<board_sink.cpp>
|
||||||
|
|
||||||
|
; --- Board B: I2S slave -> A2DP source -> JBL Charge 5 ------------------------
|
||||||
|
[env:source_jbl]
|
||||||
|
build_src_filter = +<board_source.cpp>
|
||||||
|
build_flags = '-DTARGET_SPEAKER="JBL Charge 5"'
|
||||||
|
|
||||||
|
; --- Board C: I2S slave -> A2DP source -> Cardo (Tangerine EDGE) --------------
|
||||||
|
[env:source_cardo]
|
||||||
|
build_src_filter = +<board_source.cpp>
|
||||||
|
build_flags = '-DTARGET_SPEAKER="Tangerine EDGE"'
|
||||||
|
|||||||
96
src/board_sink.cpp
Normal file
96
src/board_sink.cpp
Normal file
@ -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 <Arduino.h>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
88
src/board_source.cpp
Normal file
88
src/board_source.cpp
Normal file
@ -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 <Arduino.h>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
261
src/main.cpp
261
src/main.cpp
@ -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 <Arduino.h>
|
|
||||||
|
|
||||||
#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<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;
|
|
||||||
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("--------------");
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user