blue-repeat/src/board_source.cpp
blue 2a34ed5abe Sync via capacitive touch (drop Wi-Fi) — fixes BT starvation
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>
2026-06-10 14:07:43 -04:00

163 lines
6.1 KiB
C++

/**
* BikeAudio — Boards B & C : I2S SLAVE -> [touch-adjustable delay] -> A2DP SOURCE
*
* Reads PCM from the shared I2S bus (clocked by Board A), passes it through an
* adjustable delay line, and streams it to ONE Bluetooth speaker. The delay lets
* you sync this speaker against the other one, tuned live BY EAR with two
* capacitive-touch pads (+ / -). The value is saved to flash (survives reboots).
*
* No Wi-Fi: Wi-Fi + Bluetooth-A2DP + the audio buffer don't fit in RAM on this
* chip (the BT stack gets starved and won't connect), so the control is local
* touch rather than a phone UI. With Wi-Fi gone there's ~120 KB of heap free.
*
* 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 short wire or a bit of foil to each; tap = one step, hold = ramp)
*/
#include <Arduino.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 (calibrate via serial)
#define SR_HZ 44100
#define MAX_DELAY_MS 200
#define DELAY_STEP_MS 5
#define TOUCH_REPEAT_MS 150 // tap = one step; hold = a step every 150 ms
#define RING_FRAMES (((uint32_t)SR_HZ * (MAX_DELAY_MS + 20)) / 1000)
I2SStream i2s;
BluetoothA2DPSource source;
Preferences prefs;
static int16_t ring[RING_FRAMES * 2];
static volatile uint32_t write_frames = 0;
static volatile uint32_t delay_frames = 0;
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 ring (paced by Board A's clock).
static void i2s_task(void *arg) {
static int16_t tmp[256 * 2];
for (;;) {
size_t bytes = i2s.readBytes((uint8_t *)tmp, sizeof(tmp));
int frames = bytes / 4;
for (int i = 0; i < frames; i++) {
uint32_t w = write_frames % RING_FRAMES;
ring[w * 2] = tmp[i * 2];
ring[w * 2 + 1] = tmp[i * 2 + 1];
write_frames++;
}
if (frames == 0) vTaskDelay(1); // no clock yet (Board A down) — don't spin
}
}
// A2DP pulls frames; serve them delayed by delay_frames behind the write head.
int32_t read_delayed(Frame *data, int32_t frame_count) {
uint32_t d = delay_frames;
if (d < (uint32_t)frame_count) d = frame_count; // never read past the write head
uint32_t w = write_frames;
if (w < d) {
for (int32_t i = 0; i < frame_count; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
return frame_count;
}
uint32_t start = w - d;
for (int32_t i = 0; i < frame_count; i++) {
uint32_t idx = (start + i) % RING_FRAMES;
data[i].channel1 = ring[idx * 2];
data[i].channel2 = ring[idx * 2 + 1];
}
return frame_count;
}
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;
delay_frames = ((uint32_t)ms * SR_HZ) / 1000;
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' (touch-adjustable delay) ===\n", TARGET_SPEAKER);
prefs.begin("bikeaudio", false);
set_delay(prefs.getUShort("delay_ms", 0));
save_pending = false; // loading isn't a change to persist
// 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 = 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' — delay %u ms; touch + on GPIO4, - on GPIO27\n",
TARGET_SPEAKER, delay_ms_current);
}
void loop() {
unsigned long now = millis();
// Touch +/- (tap = one step, hold = ramp every TOUCH_REPEAT_MS).
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; }
}
// Persist to flash 1.5 s after the last change (avoids wear while ramping).
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 delay=%ums heap=%u touch+=%u touch-=%u\n",
TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current,
ESP.getFreeHeap(), touchRead(TOUCH_PLUS), touchRead(TOUCH_MINUS));
last_st = now;
}
delay(20);
}