Compare commits
No commits in common. "blue/ble-spike" and "main" have entirely different histories.
blue/ble-s
...
main
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("--------------");
|
||||
}
|
||||
103
README_RELAY.md
103
README_RELAY.md
@ -1,103 +0,0 @@
|
||||
# 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.
|
||||
|
||||
### Sync (touch pads on each source board)
|
||||
|
||||
JBL and Cardo have independent Bluetooth buffering, so one lags the other. Each
|
||||
source board has an **adjustable delay** (0–200 ms) you trim live, by ear, with two
|
||||
capacitive-touch pads — raise the delay on whichever speaker is *early* until they
|
||||
line up. The value is saved to flash (survives power-cycles).
|
||||
|
||||
| Touch pad | Pin | Action |
|
||||
|-----------|-------|--------------------------------|
|
||||
| **+** | GPIO4 | tap = +5 ms, hold = ramp up |
|
||||
| **−** | GPIO27 | tap = −5 ms, hold = ramp down |
|
||||
|
||||
Attach a short wire or a bit of foil to GPIO4 and GPIO27 on each source board and
|
||||
touch the end. (There is intentionally **no Wi-Fi/phone UI**: Wi-Fi + Bluetooth +
|
||||
the audio buffer don't fit in RAM on the classic ESP32 — it starves the Bluetooth
|
||||
stack — so the control is local touch.)
|
||||
|
||||
## 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 lags the other. The per-board touch delay (above)
|
||||
lets you trim a fixed offset to line them up by ear; it is not continuous
|
||||
sample-lock, so slow drift over long sessions is possible. 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,39 +0,0 @@
|
||||
; BikeAudio — 3-board relay (iPhone -> Board A sink -> I2S -> Boards B/C sources -> JBL + Cardo)
|
||||
;
|
||||
; 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.
|
||||
|
||||
[platformio]
|
||||
default_envs = sink, source_jbl, source_cardo
|
||||
|
||||
[env]
|
||||
platform = espressif32 @ ~6.6.0
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
board_build.partitions = huge_app.csv
|
||||
monitor_speed = 115200
|
||||
; 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_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"'
|
||||
@ -1,109 +0,0 @@
|
||||
/**
|
||||
* BLE-COEXISTENCE SPIKE (throwaway) — Board A : A2DP SINK + I2S master + BLE GATT
|
||||
*
|
||||
* Purpose: prove the classic ESP32 can run the A2DP sink (iPhone audio) AND a
|
||||
* BLE GATT server at the same time, in dual mode (BTDM), with healthy heap.
|
||||
* This is the gate for the phone-app control channel. NOT final firmware.
|
||||
*
|
||||
* Checks at runtime: BLE advertises ("BikeAudio-Ctl"), iPhone still connects to
|
||||
* A2DP "BikeAudio", I2S master clocks, and heap stays sane.
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "AudioTools.h"
|
||||
#include "BluetoothA2DPSink.h"
|
||||
#include <BLEDevice.h>
|
||||
#include <BLEServer.h>
|
||||
#include <BLEUtils.h>
|
||||
|
||||
#define I2S_BCK_PIN 5
|
||||
#define I2S_WS_PIN 25
|
||||
#define I2S_DATA_PIN 23
|
||||
|
||||
#define SVC_UUID "a1000001-b10e-4c2a-9b00-000000000001"
|
||||
#define CHR_UUID "a1000002-b10e-4c2a-9b00-000000000002"
|
||||
|
||||
I2SStream i2s;
|
||||
BluetoothA2DPSink sink;
|
||||
static uint16_t current_sample_rate = 0;
|
||||
static BLECharacteristic *ctl_char = nullptr;
|
||||
static volatile bool ble_connected = false;
|
||||
|
||||
static void start_i2s(uint16_t rate) {
|
||||
if (rate == 0) rate = 44100;
|
||||
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; cfg.buffer_count = 8; cfg.buffer_size = 512;
|
||||
i2s.begin(cfg);
|
||||
current_sample_rate = rate;
|
||||
Serial.printf("[SINK] I2S master @ %u Hz\n", rate);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
class SrvCB : public BLEServerCallbacks {
|
||||
void onConnect(BLEServer *) override { ble_connected = true; Serial.println("[BLE] central connected"); }
|
||||
void onDisconnect(BLEServer *s) override { ble_connected = false; Serial.println("[BLE] central disconnected"); s->getAdvertising()->start(); }
|
||||
};
|
||||
|
||||
class ChrCB : public BLECharacteristicCallbacks {
|
||||
void onWrite(BLECharacteristic *c) override {
|
||||
Serial.printf("[BLE] write: '%s'\n", c->getValue().c_str());
|
||||
}
|
||||
};
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("=== BLE SPIKE — A2DP sink + BLE GATT (BTDM) ===");
|
||||
Serial.printf("[boot] heap=%u\n", ESP.getFreeHeap());
|
||||
|
||||
// BLE first: btStart() brings the controller up in dual mode (BTDM), then
|
||||
// bluedroid + BLE host. A2DP (also bluedroid) then registers on top.
|
||||
BLEDevice::init("BikeAudio-Ctl");
|
||||
Serial.printf("[BLE] init done heap=%u\n", ESP.getFreeHeap());
|
||||
BLEServer *srv = BLEDevice::createServer();
|
||||
srv->setCallbacks(new SrvCB());
|
||||
BLEService *svc = srv->createService(SVC_UUID);
|
||||
ctl_char = svc->createCharacteristic(
|
||||
CHR_UUID,
|
||||
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY);
|
||||
ctl_char->setValue("ready");
|
||||
svc->start();
|
||||
BLEAdvertising *adv = BLEDevice::getAdvertising();
|
||||
adv->addServiceUUID(SVC_UUID);
|
||||
adv->setScanResponse(true);
|
||||
BLEDevice::startAdvertising();
|
||||
Serial.printf("[BLE] advertising 'BikeAudio-Ctl' heap=%u\n", ESP.getFreeHeap());
|
||||
|
||||
// Now the A2DP sink in dual mode.
|
||||
start_i2s(44100);
|
||||
sink.set_default_bt_mode(ESP_BT_MODE_BTDM);
|
||||
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.printf("[SINK] A2DP started heap=%u\n", ESP.getFreeHeap());
|
||||
Serial.println("[SINK] Advertising 'BikeAudio' — connect iPhone");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
uint16_t sr = sink.sample_rate();
|
||||
if (sr != 0 && sr != current_sample_rate) start_i2s(sr);
|
||||
|
||||
static unsigned long last = 0;
|
||||
if (millis() - last > 5000) {
|
||||
Serial.printf("[STATUS] iPhone=%s BLE=%s heap=%u\n",
|
||||
sink.is_connected() ? "YES" : "no",
|
||||
ble_connected ? "YES" : "no", ESP.getFreeHeap());
|
||||
last = millis();
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
/**
|
||||
* BikeAudio — Boards B & C : I2S SLAVE -> [FIFO delay] -> A2DP SOURCE
|
||||
*
|
||||
* Reads PCM from the shared I2S bus (clocked by Board A) into a FIFO, and an
|
||||
* A2DP source drains the FIFO to one Bluetooth speaker. The FIFO sits a fixed
|
||||
* jitter cushion (BASE_DELAY_MS) plus an adjustable trim behind the I2S write
|
||||
* head; the trim (0..MAX_DELAY_MS) is set live BY EAR with two capacitive-touch
|
||||
* pads to align this speaker against the other one, and is saved to flash.
|
||||
*
|
||||
* No Wi-Fi: Wi-Fi + Bluetooth + buffer don't fit in RAM on the classic ESP32.
|
||||
*
|
||||
* Per-env build flag: TARGET_SPEAKER ("JBL Charge 5" / "Tangerine EDGE").
|
||||
*
|
||||
* Wiring:
|
||||
* I2S in (from Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
|
||||
* Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7) (attach a wire/pad)
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "AudioTools.h"
|
||||
#include "BluetoothA2DPSource.h"
|
||||
#include <Preferences.h>
|
||||
|
||||
#ifndef TARGET_SPEAKER
|
||||
#define TARGET_SPEAKER "BikeAudio-Speaker"
|
||||
#endif
|
||||
|
||||
#define I2S_BCK_PIN 19
|
||||
#define I2S_WS_PIN 18
|
||||
#define I2S_DATA_PIN 22
|
||||
|
||||
#define TOUCH_PLUS T0 // GPIO4
|
||||
#define TOUCH_MINUS T7 // GPIO27
|
||||
#define TOUCH_THRESH 40 // touchRead below this = touched
|
||||
|
||||
#define SR_HZ 44100
|
||||
#define BASE_DELAY_MS 40 // fixed jitter cushion (applied to both speakers)
|
||||
#define MAX_DELAY_MS 200 // adjustable trim on top of the cushion
|
||||
#define DELAY_STEP_MS 5
|
||||
#define TOUCH_REPEAT_MS 150
|
||||
#define RING_MS (BASE_DELAY_MS + MAX_DELAY_MS + 40) // + headroom
|
||||
#define RING_FRAMES ((uint32_t)SR_HZ * RING_MS / 1000)
|
||||
#define BASE_FRAMES ((uint32_t)SR_HZ * BASE_DELAY_MS / 1000)
|
||||
|
||||
I2SStream i2s;
|
||||
BluetoothA2DPSource source;
|
||||
Preferences prefs;
|
||||
|
||||
// FIFO of interleaved L,R int16. Producer = i2s_task, consumer = A2DP callback.
|
||||
static int16_t ring[RING_FRAMES * 2];
|
||||
static volatile uint32_t write_frames = 0; // producer position (monotonic)
|
||||
static volatile uint32_t read_frames = 0; // consumer position (monotonic)
|
||||
static volatile uint32_t trim_frames = 0; // adjustable delay (frames)
|
||||
static volatile bool primed = false; // FIFO has reached target fill
|
||||
static volatile uint16_t delay_ms_current = 0;
|
||||
static bool save_pending = false;
|
||||
static unsigned long last_change_ms = 0;
|
||||
|
||||
// Continuously pull I2S into the FIFO (paced by Board A's master clock).
|
||||
// Carry any partial frame across reads so L/R never slips out of alignment.
|
||||
static void i2s_task(void *arg) {
|
||||
static uint8_t buf[1024];
|
||||
static int rem = 0;
|
||||
for (;;) {
|
||||
size_t got = i2s.readBytes(buf + rem, sizeof(buf) - rem);
|
||||
int total = rem + (int)got;
|
||||
int frames = total / 4;
|
||||
int16_t *s = (int16_t *)buf;
|
||||
for (int i = 0; i < frames; i++) {
|
||||
uint32_t w = write_frames % RING_FRAMES;
|
||||
ring[w * 2] = s[i * 2];
|
||||
ring[w * 2 + 1] = s[i * 2 + 1];
|
||||
write_frames++;
|
||||
}
|
||||
rem = total - frames * 4;
|
||||
if (rem > 0) memmove(buf, buf + frames * 4, rem);
|
||||
if (got == 0) vTaskDelay(1); // no clock yet (Board A down) — don't spin
|
||||
}
|
||||
}
|
||||
|
||||
// A2DP drains the FIFO sequentially, kept (BASE_FRAMES + trim) behind the write head.
|
||||
int32_t read_delayed(Frame *data, int32_t fc) {
|
||||
uint32_t w = write_frames;
|
||||
uint32_t target = BASE_FRAMES + trim_frames; // desired gap behind write head
|
||||
|
||||
if (!primed) {
|
||||
if (w < target + (uint32_t)fc) { // not buffered enough yet -> silence
|
||||
for (int32_t i = 0; i < fc; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
|
||||
return fc;
|
||||
}
|
||||
read_frames = w - target;
|
||||
primed = true;
|
||||
}
|
||||
|
||||
uint32_t avail = w - read_frames; // frames available to read
|
||||
if (avail > RING_FRAMES) { // producer lapped us (big drift) -> resync
|
||||
read_frames = (w > target) ? (w - target) : 0;
|
||||
avail = w - read_frames;
|
||||
}
|
||||
|
||||
int32_t n = ((uint32_t)fc <= avail) ? fc : (int32_t)avail;
|
||||
for (int32_t i = 0; i < n; i++) {
|
||||
uint32_t idx = (read_frames + i) % RING_FRAMES;
|
||||
data[i].channel1 = ring[idx * 2];
|
||||
data[i].channel2 = ring[idx * 2 + 1];
|
||||
}
|
||||
read_frames += n;
|
||||
for (int32_t i = n; i < fc; i++) { data[i].channel1 = 0; data[i].channel2 = 0; } // pad underrun
|
||||
return fc;
|
||||
}
|
||||
|
||||
static void set_delay(int ms) {
|
||||
if (ms < 0) ms = 0;
|
||||
if (ms > MAX_DELAY_MS) ms = MAX_DELAY_MS;
|
||||
if ((uint16_t)ms == delay_ms_current) return;
|
||||
delay_ms_current = (uint16_t)ms;
|
||||
trim_frames = ((uint32_t)ms * SR_HZ) / 1000;
|
||||
primed = false; // re-establish the FIFO gap at the new delay
|
||||
save_pending = true;
|
||||
last_change_ms = millis();
|
||||
Serial.printf("[SRC %s] delay = %d ms\n", TARGET_SPEAKER, ms);
|
||||
}
|
||||
|
||||
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' (FIFO delay, %ums cushion) ===\n",
|
||||
TARGET_SPEAKER, BASE_DELAY_MS);
|
||||
|
||||
prefs.begin("bikeaudio", false);
|
||||
set_delay(prefs.getUShort("delay_ms", 0));
|
||||
save_pending = false;
|
||||
|
||||
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 = SR_HZ; cfg.channels = 2; cfg.bits_per_sample = 16;
|
||||
cfg.is_master = false; cfg.buffer_count = 8; cfg.buffer_size = 512;
|
||||
i2s.begin(cfg);
|
||||
|
||||
xTaskCreatePinnedToCore(i2s_task, "i2s_reader", 4096, nullptr, 5, nullptr, 0);
|
||||
|
||||
source.set_data_callback_in_frames(read_delayed);
|
||||
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' — trim %u ms; touch + GPIO4, - GPIO27\n",
|
||||
TARGET_SPEAKER, delay_ms_current);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
|
||||
static unsigned long last_touch = 0;
|
||||
if (now - last_touch >= TOUCH_REPEAT_MS) {
|
||||
bool plus = touchRead(TOUCH_PLUS) < TOUCH_THRESH;
|
||||
bool minus = touchRead(TOUCH_MINUS) < TOUCH_THRESH;
|
||||
if (plus && !minus) { set_delay(delay_ms_current + DELAY_STEP_MS); last_touch = now; }
|
||||
else if (minus && !plus) { set_delay(delay_ms_current - DELAY_STEP_MS); last_touch = now; }
|
||||
}
|
||||
|
||||
if (save_pending && now - last_change_ms > 1500) {
|
||||
prefs.putUShort("delay_ms", delay_ms_current);
|
||||
save_pending = false;
|
||||
Serial.printf("[SRC %s] saved %u ms to flash\n", TARGET_SPEAKER, delay_ms_current);
|
||||
}
|
||||
|
||||
static unsigned long last_st = 0;
|
||||
if (now - last_st > 5000) {
|
||||
Serial.printf("[SRC %s] connected=%s trim=%ums fill=%u heap=%u\n",
|
||||
TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current,
|
||||
(unsigned)(write_frames - read_frames), ESP.getFreeHeap());
|
||||
last_st = now;
|
||||
}
|
||||
delay(20);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user