Fix robotic audio + crash: proper FIFO delay line
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>
This commit is contained in:
parent
66f56f1e09
commit
0b1c34074f
@ -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
|
* Reads PCM from the shared I2S bus (clocked by Board A) into a FIFO, and an
|
||||||
* adjustable delay line, and streams it to ONE Bluetooth speaker. The delay lets
|
* A2DP source drains the FIFO to one Bluetooth speaker. The FIFO sits a fixed
|
||||||
* you sync this speaker against the other one, tuned live BY EAR with two
|
* jitter cushion (BASE_DELAY_MS) plus an adjustable trim behind the I2S write
|
||||||
* capacitive-touch pads (+ / -). The value is saved to flash (survives reboots).
|
* 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
|
* No Wi-Fi: Wi-Fi + Bluetooth + buffer don't fit in RAM on the classic ESP32.
|
||||||
* 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").
|
* Per-env build flag: TARGET_SPEAKER ("JBL Charge 5" / "Tangerine EDGE").
|
||||||
*
|
*
|
||||||
* Wiring:
|
* Wiring:
|
||||||
* I2S in (from Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
|
* I2S in (from Board A): BCK=GPIO19 WS=GPIO18 DATA=GPIO22 + GND
|
||||||
* Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7)
|
* Touch "+" : GPIO4 (T0) Touch "-" : GPIO27 (T7) (attach a wire/pad)
|
||||||
* (attach a short wire or a bit of foil to each; tap = one step, hold = ramp)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
#include "AudioTools.h"
|
#include "AudioTools.h"
|
||||||
#include "BluetoothA2DPSource.h"
|
#include "BluetoothA2DPSource.h"
|
||||||
@ -34,57 +33,82 @@
|
|||||||
|
|
||||||
#define TOUCH_PLUS T0 // GPIO4
|
#define TOUCH_PLUS T0 // GPIO4
|
||||||
#define TOUCH_MINUS T7 // GPIO27
|
#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 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 DELAY_STEP_MS 5
|
||||||
#define TOUCH_REPEAT_MS 150 // tap = one step; hold = a step every 150 ms
|
#define TOUCH_REPEAT_MS 150
|
||||||
#define RING_FRAMES (((uint32_t)SR_HZ * (MAX_DELAY_MS + 20)) / 1000)
|
#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;
|
I2SStream i2s;
|
||||||
BluetoothA2DPSource source;
|
BluetoothA2DPSource source;
|
||||||
Preferences prefs;
|
Preferences prefs;
|
||||||
|
|
||||||
|
// FIFO of interleaved L,R int16. Producer = i2s_task, consumer = A2DP callback.
|
||||||
static int16_t ring[RING_FRAMES * 2];
|
static int16_t ring[RING_FRAMES * 2];
|
||||||
static volatile uint32_t write_frames = 0;
|
static volatile uint32_t write_frames = 0; // producer position (monotonic)
|
||||||
static volatile uint32_t delay_frames = 0;
|
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 volatile uint16_t delay_ms_current = 0;
|
||||||
static bool save_pending = false;
|
static bool save_pending = false;
|
||||||
static unsigned long last_change_ms = 0;
|
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 void i2s_task(void *arg) {
|
||||||
static int16_t tmp[256 * 2];
|
static uint8_t buf[1024];
|
||||||
|
static int rem = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
size_t bytes = i2s.readBytes((uint8_t *)tmp, sizeof(tmp));
|
size_t got = i2s.readBytes(buf + rem, sizeof(buf) - rem);
|
||||||
int frames = bytes / 4;
|
int total = rem + (int)got;
|
||||||
|
int frames = total / 4;
|
||||||
|
int16_t *s = (int16_t *)buf;
|
||||||
for (int i = 0; i < frames; i++) {
|
for (int i = 0; i < frames; i++) {
|
||||||
uint32_t w = write_frames % RING_FRAMES;
|
uint32_t w = write_frames % RING_FRAMES;
|
||||||
ring[w * 2] = tmp[i * 2];
|
ring[w * 2] = s[i * 2];
|
||||||
ring[w * 2 + 1] = tmp[i * 2 + 1];
|
ring[w * 2 + 1] = s[i * 2 + 1];
|
||||||
write_frames++;
|
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.
|
// A2DP drains the FIFO sequentially, kept (BASE_FRAMES + trim) behind the write head.
|
||||||
int32_t read_delayed(Frame *data, int32_t frame_count) {
|
int32_t read_delayed(Frame *data, int32_t fc) {
|
||||||
uint32_t d = delay_frames;
|
uint32_t w = write_frames;
|
||||||
if (d < (uint32_t)frame_count) d = frame_count; // never read past the write head
|
uint32_t target = BASE_FRAMES + trim_frames; // desired gap behind write head
|
||||||
uint32_t w = write_frames;
|
|
||||||
if (w < d) {
|
if (!primed) {
|
||||||
for (int32_t i = 0; i < frame_count; i++) { data[i].channel1 = 0; data[i].channel2 = 0; }
|
if (w < target + (uint32_t)fc) { // not buffered enough yet -> silence
|
||||||
return frame_count;
|
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 avail = w - read_frames; // frames available to read
|
||||||
uint32_t idx = (start + i) % RING_FRAMES;
|
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].channel1 = ring[idx * 2];
|
||||||
data[i].channel2 = ring[idx * 2 + 1];
|
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) {
|
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 (ms > MAX_DELAY_MS) ms = MAX_DELAY_MS;
|
||||||
if ((uint16_t)ms == delay_ms_current) return;
|
if ((uint16_t)ms == delay_ms_current) return;
|
||||||
delay_ms_current = (uint16_t)ms;
|
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;
|
save_pending = true;
|
||||||
last_change_ms = millis();
|
last_change_ms = millis();
|
||||||
Serial.printf("[SRC %s] delay = %d ms\n", TARGET_SPEAKER, ms);
|
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() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(500);
|
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);
|
prefs.begin("bikeaudio", false);
|
||||||
set_delay(prefs.getUShort("delay_ms", 0));
|
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);
|
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.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.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_auto_reconnect(true, 5);
|
||||||
source.set_volume(100);
|
source.set_volume(100);
|
||||||
source.start(TARGET_SPEAKER);
|
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);
|
TARGET_SPEAKER, delay_ms_current);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|
||||||
// Touch +/- (tap = one step, hold = ramp every TOUCH_REPEAT_MS).
|
|
||||||
static unsigned long last_touch = 0;
|
static unsigned long last_touch = 0;
|
||||||
if (now - last_touch >= TOUCH_REPEAT_MS) {
|
if (now - last_touch >= TOUCH_REPEAT_MS) {
|
||||||
bool plus = touchRead(TOUCH_PLUS) < TOUCH_THRESH;
|
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; }
|
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) {
|
if (save_pending && now - last_change_ms > 1500) {
|
||||||
prefs.putUShort("delay_ms", delay_ms_current);
|
prefs.putUShort("delay_ms", delay_ms_current);
|
||||||
save_pending = false;
|
save_pending = false;
|
||||||
@ -153,9 +176,9 @@ void loop() {
|
|||||||
|
|
||||||
static unsigned long last_st = 0;
|
static unsigned long last_st = 0;
|
||||||
if (now - last_st > 5000) {
|
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,
|
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;
|
last_st = now;
|
||||||
}
|
}
|
||||||
delay(20);
|
delay(20);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user