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>
The source boards now scan for nearby Bluetooth speakers and connect to a
user-chosen one; the hub screen drives it. Hardcoded TARGET_SPEAKER remains
only as a first-boot fallback when nothing is saved.
- hub_proto.h: status payload -> 7 bytes (adds scanning, scan_count,
has_device). New cmds: SCAN_START/STOP, SELECT(index), FORGET,
GET_SCANITEM(index)->33-byte item (valid,rssi,mac,namelen,name),
GET_CURNAME->24-byte name.
- board_source.cpp: set_ssid_callback collects nearby sinks (dedupe by MAC,
COD-filtered to audio), set_discovery_mode_callback stops the scan loop,
connect_to(mac) + set_auto_reconnect(mac) on SELECT, MAC+name persisted to
NVS (spk_mac/spk_name). Boot connects to saved MAC or falls back to the
name. onRequest multiplexes status/scanitem/curname by last read command.
- hub_s3.cpp: MAIN screen now shows each speaker's selected device name + a
"pick" button; SCREEN_SCAN shows the live discovery list (RSSI-sorted,
scrollable) with tap-to-select, Rescan, Back. All I2C on Wire1.
Built by two parallel sub-agents against hub_proto.h; all three envs build
clean first try. Needs the I2C bus wired to test on hardware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wired control path so the S3 hub can set delay/volume on the source boards
and read their status, over an I2C bus (separate from the audio I2S bus).
- src/hub_proto.h: shared protocol — addresses (JBL 0x10, Cardo 0x11),
commands SET_DELAY (u16), SET_VOLUME (u8), GET_STATUS (4-byte payload:
connected, delay LE, volume).
- board_source.cpp (B/C): I2C slave on GPIO32/33 (addr via -DHUB_I2C_ADDR).
Light onReceive/onRequest callbacks stash requests; loop() applies them
(reuses set_delay; adds current_volume -> source.set_volume, NVS-persisted).
Relay/FIFO/touch untouched.
- hub_s3.cpp: I2C master on Wire1 (GPIO15/16) — peripheral 1, since the
CST816S touch owns peripheral 0. Polls both boards ~1Hz; LovyanGFX UI shows
each speaker's connect/delay/volume with touch +/- zones that send commands.
- platformio.ini: -DHUB_I2C_ADDR per source env.
Written by two parallel sub-agents (one per side) against hub_proto.h; fixed
the Wire->Wire1 peripheral conflict. All three envs build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ESP32-S3-Touch-LCD-1.28 first light: GC9A01 round display + backlight + CST816S
touch working via LovyanGFX. Draws a label and red dots on touch.
- src/hub_s3.cpp: LovyanGFX LGFX config for this board (GC9A01 SPI
SCLK10/MOSI11/MISO12/CS9/DC8/RST14, backlight GPIO2; CST816S touch I2C
SDA6/SCL7 polled, addr 0x15).
- platformio.ini: new [env:hub_s3] (esp32-s3-devkitc-1, 4MB, LovyanGFX).
Not in default_envs. Flash: esp32s3, bootloader@0x0 +boot_app0@0xe000.
Foundation for the on-device UI hub. PSRAM not yet enabled (not needed for
bring-up; will enable for LVGL). Next: I2C control bus to Boards B/C.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous delay buffer re-derived the read position from the write head on
every A2DP pull (read = write - delay), so it sampled the buffer at the
Bluetooth pull-rate while positioning by the I2S write-rate — skipping/
repeating samples (robotic) and, with no cushion at delay=0, constantly
under/overrunning until it destabilized and crashed.
Replace with a real FIFO: a sequential read pointer that advances by the
frames consumed, held BASE_DELAY_MS (40 ms jitter cushion) + the touch trim
behind the I2S write head, with underrun (pad silence) and overrun (resync)
handling. Also carry partial frames across I2S reads so L/R never slips.
Verified on hardware: clean audio to both speakers, stable for hours,
touch-pad sync aligns JBL and Cardo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Wi-Fi/web/ESP-NOW sync approach was unviable: Wi-Fi + Bluetooth-A2DP +
the delay buffer exhausted RAM on the classic ESP32 (~20 KB free), and the
Bluetooth stack was so starved the source boards couldn't even connect to a
speaker. Confirmed on hardware: with Wi-Fi up the JBL would not connect; with
Wi-Fi removed it connects instantly (~65 KB free).
- board_source.cpp: remove Wi-Fi/ESP-NOW. Keep the I2S-reader-task + ring
delay line (now 200 ms; plenty of RAM without Wi-Fi). Adjust delay live by
ear via two capacitive-touch pads — "+" on GPIO4 (T0), "-" on GPIO27 (T7);
tap = 5 ms step, hold = ramp. Persisted to flash (debounced).
- board_sink.cpp: reverted to the simple A2DP sink + I2S master (no Wi-Fi).
- platformio.ini: drop SPEAKER_ID. Remove relay_config.h.
All three boards connect reliably with healthy heap. Touch pins read ~120
untouched; threshold 40.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds runtime speaker-sync control to the relay:
- src/relay_config.h: shared Wi-Fi AP creds + ESP-NOW RelayDelayMsg
(delay_ms[JBL], delay_ms[Cardo]) on a fixed channel.
- board_sink.cpp (Board A): hosts a Wi-Fi AP "BikeAudio-Setup" + a small
web UI (two sliders, no app) at 192.168.4.1, and broadcasts the chosen
per-speaker delays to the source boards over ESP-NOW. Still A2DP sink +
I2S master. (Tune while parked — Wi-Fi/BT coexist on one radio.)
- board_source.cpp (Boards B/C): inserts an adjustable delay line between
I2S in and A2DP out — a dedicated reader task fills a ring buffer (up to
~250 ms) and the A2DP callback reads delay_frames behind the write head.
Delay arrives via ESP-NOW (per SPEAKER_ID) and is persisted to flash
(Preferences), so it survives power cycles.
- platformio.ini: source envs get -DSPEAKER_ID (0=JBL, 1=Cardo).
Lets the rider trim JBL vs Cardo timing to sync the two speakers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>