Product name BikeAudio -> "Resound" (the device isn't bike-specific — works at
home, backyard, camping, parties, group rides). This is the A2DP advertise name
the phone connects to: sink.start("Resound"). All banners/comments + README
updated. (After reflashing the sink, the phone must forget "BikeAudio" and
connect to "Resound".)
Firmware vocabulary clarified (the old hub/sink/source was confusing —
"source" read backwards since those boards SEND audio):
hub_s3.cpp / env hub_s3 -> hud.cpp / env hud
board_sink.cpp -> sink.cpp (env sink)
board_source.cpp -> broadcaster.cpp (envs broadcaster_headset
0x11, broadcaster_speaker1 0x10,
broadcaster_guest 0x12)
hub_proto.h -> bus_proto.h
default_envs = sink + the three broadcasters. All envs build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
5.1 KiB
Markdown
104 lines
5.1 KiB
Markdown
# Resound — 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 **"Resound"**.
|
||
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.
|