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>
4.1 KiB
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:
- 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.
- 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
- Put the JBL and Cardo in pairing mode.
- Power Board B and Board C — each connects to its speaker by name (auto-reconnects on later power-ups).
- Power Board A; on the iPhone, connect to "BikeAudio".
- 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.