diff --git a/src/board_source.cpp b/src/board_source.cpp index fe29714..f85d850 100644 --- a/src/board_source.cpp +++ b/src/board_source.cpp @@ -1,24 +1,23 @@ /** - * BikeAudio — Boards B & C : I2S SLAVE -> [touch-adjustable delay] -> A2DP SOURCE + * BikeAudio — Boards B & C : I2S SLAVE -> [FIFO 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). + * 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-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. + * 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 short wire or a bit of foil to each; tap = one step, hold = ramp) + * Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7) (attach a wire/pad) */ #include +#include #include "AudioTools.h" #include "BluetoothA2DPSource.h" @@ -34,57 +33,82 @@ #define TOUCH_PLUS T0 // GPIO4 #define TOUCH_MINUS T7 // GPIO27 -#define TOUCH_THRESH 40 // touchRead below this = touched (calibrate via serial) +#define TOUCH_THRESH 40 // touchRead below this = touched #define SR_HZ 44100 -#define MAX_DELAY_MS 200 +#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 // tap = one step; hold = a step every 150 ms -#define RING_FRAMES (((uint32_t)SR_HZ * (MAX_DELAY_MS + 20)) / 1000) +#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; -static volatile uint32_t delay_frames = 0; +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 ring (paced by Board A's clock). +// 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 int16_t tmp[256 * 2]; + static uint8_t buf[1024]; + static int rem = 0; for (;;) { - size_t bytes = i2s.readBytes((uint8_t *)tmp, sizeof(tmp)); - int frames = bytes / 4; + 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] = tmp[i * 2]; - ring[w * 2 + 1] = tmp[i * 2 + 1]; + ring[w * 2] = s[i * 2]; + ring[w * 2 + 1] = s[i * 2 + 1]; write_frames++; } - if (frames == 0) vTaskDelay(1); // no clock yet (Board A down) — don't spin + 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 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; +// 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 start = w - d; - for (int32_t i = 0; i < frame_count; i++) { - uint32_t idx = (start + i) % RING_FRAMES; + + 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]; } - return frame_count; + 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) { @@ -92,7 +116,8 @@ static void set_delay(int ms) { 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; + 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); @@ -108,13 +133,13 @@ void on_conn_state(esp_a2d_connection_state_t state, void *obj) { void setup() { Serial.begin(115200); delay(500); - Serial.printf("=== BikeAudio Source -> '%s' (touch-adjustable delay) ===\n", TARGET_SPEAKER); + 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; // loading isn't a change to persist + save_pending = false; - // 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; @@ -128,14 +153,13 @@ void setup() { 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", + Serial.printf("[SRC] Connecting to '%s' — trim %u ms; touch + GPIO4, - 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; @@ -144,7 +168,6 @@ void loop() { 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; @@ -153,9 +176,9 @@ void loop() { 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", + Serial.printf("[SRC %s] connected=%s trim=%ums fill=%u heap=%u\n", TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current, - ESP.getFreeHeap(), touchRead(TOUCH_PLUS), touchRead(TOUCH_MINUS)); + (unsigned)(write_frames - read_frames), ESP.getFreeHeap()); last_st = now; } delay(20);