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>
470 lines
18 KiB
C++
470 lines
18 KiB
C++
/**
|
|
* Resound — Boards B & C : I2S SLAVE -> [FIFO delay] -> A2DP SOURCE
|
|
*
|
|
* 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 + 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 wire/pad)
|
|
*/
|
|
|
|
#include <Arduino.h>
|
|
#include <string.h>
|
|
|
|
#include "AudioTools.h"
|
|
#include "BluetoothA2DPSource.h"
|
|
#include <Preferences.h>
|
|
#include <Wire.h>
|
|
|
|
#include "bus_proto.h"
|
|
|
|
#ifndef TARGET_SPEAKER
|
|
#define TARGET_SPEAKER "Resound-Speaker"
|
|
#endif
|
|
|
|
// I2C slave address (which speaker this board controls). Set per-env via build
|
|
// flag; default to the JBL address if unspecified.
|
|
#ifndef HUB_I2C_ADDR
|
|
#define HUB_I2C_ADDR HUB_I2C_ADDR_JBL
|
|
#endif
|
|
|
|
#define I2C_SDA_PIN 32
|
|
#define I2C_SCL_PIN 33
|
|
|
|
#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
|
|
|
|
#define SR_HZ 44100
|
|
#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
|
|
#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; // 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;
|
|
|
|
// Current playback volume (0..100). Mirrored into status_buf for the hub.
|
|
static uint8_t current_volume = 100;
|
|
static bool vol_save_pending = false;
|
|
|
|
// --- I2C slave control bus (hub = master) -------------------------------
|
|
// Callbacks run in ISR/Wire context: they must be LIGHT — no Serial, NVS, or
|
|
// blocking calls. They only stash requests into volatile globals; loop() acts.
|
|
static volatile bool have_delay = false;
|
|
static volatile uint16_t pending_delay = 0;
|
|
static volatile bool have_volume = false;
|
|
static volatile uint8_t pending_volume = 0;
|
|
|
|
// Status snapshot served on HUB_CMD_GET_STATUS reads; refreshed by loop().
|
|
// [0]=connected [1]=delay lo [2]=delay hi [3]=volume
|
|
// [4]=scanning [5]=scan_count [6]=has_device (Phase 3)
|
|
static volatile uint8_t status_buf[HUB_STATUS_LEN] = {0, 0, 0, 100, 0, 0, 0};
|
|
|
|
// --- Phase 3: BT speaker discovery / selection --------------------------
|
|
// A discovered device collected during an inquiry scan.
|
|
struct ScanDev {
|
|
char name[HUB_NAME_MAX + 1];
|
|
uint8_t mac[6];
|
|
int8_t rssi;
|
|
};
|
|
static ScanDev scan_list[HUB_MAX_SCAN];
|
|
static volatile uint8_t scan_count = 0;
|
|
static volatile bool scanning = false;
|
|
|
|
// Currently selected / saved speaker.
|
|
static char cur_name[HUB_NAME_MAX + 1] = {0};
|
|
static uint8_t cur_mac[6] = {0};
|
|
static bool has_device = false;
|
|
|
|
// I2C request flags set by the Wire callbacks, applied in loop().
|
|
static volatile bool req_scan_start = false;
|
|
static volatile bool req_scan_stop = false;
|
|
static volatile bool req_select = false;
|
|
static volatile uint8_t pending_select = 0;
|
|
static volatile bool req_forget = false;
|
|
|
|
// Which read buffer the next onRequest should serve. The master sets it by
|
|
// writing the read command byte just before issuing the read. Multiplexed in
|
|
// on_i2c_request() so GET_STATUS / GET_SCANITEM / GET_CURNAME can share the bus.
|
|
static volatile uint8_t last_read_cmd = HUB_CMD_GET_STATUS;
|
|
static volatile uint8_t pending_scanitem = 0; // index requested via GET_SCANITEM
|
|
// Pre-filled in the callback (light memcpy) and handed back on the read.
|
|
static volatile uint8_t scanitem_buf[HUB_SCANITEM_LEN] = {0};
|
|
|
|
// Per-device callback fired during a scan (plain C fn ptr — no captures).
|
|
// Stash discovered audio sinks into scan_list; never connect (return false).
|
|
static bool on_ssid(const char *ssid, esp_bd_addr_t addr, int rssi) {
|
|
if (!scanning) return false;
|
|
if (scan_count >= HUB_MAX_SCAN) return false;
|
|
// Dedupe by MAC.
|
|
for (uint8_t i = 0; i < scan_count; i++) {
|
|
if (memcmp(scan_list[i].mac, addr, 6) == 0) return false;
|
|
}
|
|
ScanDev &d = scan_list[scan_count];
|
|
if (ssid) {
|
|
strncpy(d.name, ssid, HUB_NAME_MAX);
|
|
d.name[HUB_NAME_MAX] = 0;
|
|
} else {
|
|
d.name[0] = 0;
|
|
}
|
|
memcpy(d.mac, addr, 6);
|
|
if (rssi > 127) rssi = 127;
|
|
if (rssi < -128) rssi = -128;
|
|
d.rssi = (int8_t)rssi;
|
|
scan_count++;
|
|
return false; // keep scanning; collect everything
|
|
}
|
|
|
|
// Discovery state changes. The library auto-restarts discovery after STOPPED if
|
|
// nothing matched, so explicitly cancel to halt the loop.
|
|
static void on_disc_state(esp_bt_gap_discovery_state_t state) {
|
|
if (state == ESP_BT_GAP_DISCOVERY_STOPPED) {
|
|
scanning = false;
|
|
source.cancel_discovery();
|
|
}
|
|
}
|
|
|
|
// Master wrote [cmd][args...]; parse into request flags only.
|
|
static void on_i2c_receive(int n) {
|
|
if (n < 1) return;
|
|
uint8_t cmd = Wire.read();
|
|
last_read_cmd = cmd; // remember for the following onRequest (if any)
|
|
switch (cmd) {
|
|
case HUB_CMD_SET_DELAY:
|
|
if (Wire.available() >= 2) {
|
|
uint8_t lo = Wire.read();
|
|
uint8_t hi = Wire.read();
|
|
pending_delay = (uint16_t)lo | ((uint16_t)hi << 8);
|
|
have_delay = true;
|
|
}
|
|
break;
|
|
case HUB_CMD_SET_VOLUME:
|
|
if (Wire.available() >= 1) {
|
|
pending_volume = Wire.read();
|
|
have_volume = true;
|
|
}
|
|
break;
|
|
case HUB_CMD_SCAN_START:
|
|
req_scan_start = true;
|
|
break;
|
|
case HUB_CMD_SCAN_STOP:
|
|
req_scan_stop = true;
|
|
break;
|
|
case HUB_CMD_SELECT:
|
|
if (Wire.available() >= 1) {
|
|
pending_select = Wire.read();
|
|
req_select = true;
|
|
}
|
|
break;
|
|
case HUB_CMD_FORGET:
|
|
req_forget = true;
|
|
break;
|
|
case HUB_CMD_GET_SCANITEM: {
|
|
// Prep scanitem_buf NOW from the list (light memcpy is OK here).
|
|
uint8_t idx = Wire.available() >= 1 ? Wire.read() : 0xFF;
|
|
pending_scanitem = idx;
|
|
uint8_t *b = (uint8_t *)scanitem_buf;
|
|
memset(b, 0, HUB_SCANITEM_LEN);
|
|
if (idx < scan_count) {
|
|
ScanDev &d = scan_list[idx];
|
|
b[0] = 1; // valid
|
|
b[1] = (uint8_t)d.rssi; // rssi (int8)
|
|
memcpy(&b[2], d.mac, 6); // MAC
|
|
uint8_t nl = (uint8_t)strnlen(d.name, HUB_NAME_MAX);
|
|
b[8] = nl; // name length
|
|
memcpy(&b[9], d.name, nl); // name bytes
|
|
}
|
|
break;
|
|
}
|
|
case HUB_CMD_GET_CURNAME:
|
|
// Nothing to do: onRequest serves cur_name (NUL-padded).
|
|
break;
|
|
case HUB_CMD_GET_STATUS:
|
|
// Nothing to do: onRequest serves the pre-filled status_buf.
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
while (Wire.available()) Wire.read(); // drain any extra bytes
|
|
}
|
|
|
|
// Master issued a read; pick the buffer keyed by the last command byte.
|
|
static void on_i2c_request() {
|
|
if (last_read_cmd == HUB_CMD_GET_SCANITEM) {
|
|
Wire.write((uint8_t *)scanitem_buf, HUB_SCANITEM_LEN);
|
|
} else if (last_read_cmd == HUB_CMD_GET_CURNAME) {
|
|
uint8_t nbuf[HUB_NAME_MAX];
|
|
uint8_t nl = (uint8_t)strnlen(cur_name, HUB_NAME_MAX);
|
|
memcpy(nbuf, cur_name, nl);
|
|
if (nl < HUB_NAME_MAX) memset(nbuf + nl, 0, HUB_NAME_MAX - nl);
|
|
Wire.write(nbuf, HUB_NAME_MAX);
|
|
} else {
|
|
Wire.write((uint8_t *)status_buf, HUB_STATUS_LEN);
|
|
}
|
|
}
|
|
|
|
// 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 uint8_t buf[1024];
|
|
static int rem = 0;
|
|
for (;;) {
|
|
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] = s[i * 2];
|
|
ring[w * 2 + 1] = s[i * 2 + 1];
|
|
write_frames++;
|
|
}
|
|
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 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 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];
|
|
}
|
|
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) {
|
|
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;
|
|
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);
|
|
}
|
|
|
|
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("=== Resound 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;
|
|
current_volume = (uint8_t)prefs.getUShort("vol", 100);
|
|
if (current_volume > 100) current_volume = 100;
|
|
|
|
// I2C control bus: slave at HUB_I2C_ADDR on SDA=32 / SCL=33.
|
|
Wire.begin((uint8_t)HUB_I2C_ADDR, I2C_SDA_PIN, I2C_SCL_PIN, 100000);
|
|
Wire.onReceive(on_i2c_receive);
|
|
Wire.onRequest(on_i2c_request);
|
|
Serial.printf("[SRC %s] I2C slave @ 0x%02X (SDA=%d SCL=%d), vol=%u\n",
|
|
TARGET_SPEAKER, (unsigned)HUB_I2C_ADDR, I2C_SDA_PIN, I2C_SCL_PIN,
|
|
current_volume);
|
|
|
|
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_volume(current_volume);
|
|
|
|
// Load any saved/selected speaker (Phase 3). "spk_mac" is a 6-byte blob.
|
|
uint8_t savedMac[6];
|
|
size_t got = prefs.getBytes("spk_mac", savedMac, 6);
|
|
if (got == 6) {
|
|
memcpy(cur_mac, savedMac, 6);
|
|
String sn = prefs.getString("spk_name", "");
|
|
strncpy(cur_name, sn.c_str(), HUB_NAME_MAX);
|
|
cur_name[HUB_NAME_MAX] = 0;
|
|
has_device = true;
|
|
source.set_auto_reconnect(cur_mac); // remember + auto-reconnect to it
|
|
source.start();
|
|
Serial.printf("[SRC] Reconnecting to saved '%s' (%02X:%02X:%02X:%02X:%02X:%02X) — trim %u ms\n",
|
|
cur_name, cur_mac[0], cur_mac[1], cur_mac[2],
|
|
cur_mac[3], cur_mac[4], cur_mac[5], delay_ms_current);
|
|
} else {
|
|
// No saved device — fall back to the hardcoded target by name.
|
|
has_device = false;
|
|
cur_name[0] = 0;
|
|
source.set_auto_reconnect(true, 5);
|
|
source.start(TARGET_SPEAKER);
|
|
Serial.printf("[SRC] Connecting to fallback '%s' — trim %u ms; touch + GPIO4, - GPIO27\n",
|
|
TARGET_SPEAKER, delay_ms_current);
|
|
}
|
|
}
|
|
|
|
void loop() {
|
|
unsigned long now = millis();
|
|
|
|
// Apply I2C requests stashed by the Wire callbacks (heavy work runs here).
|
|
if (have_delay) {
|
|
have_delay = false;
|
|
set_delay((int)pending_delay);
|
|
}
|
|
if (have_volume) {
|
|
have_volume = false;
|
|
uint8_t v = pending_volume;
|
|
if (v > 100) v = 100;
|
|
if (v != current_volume) {
|
|
current_volume = v;
|
|
source.set_volume(current_volume);
|
|
vol_save_pending = true;
|
|
last_change_ms = now;
|
|
Serial.printf("[SRC %s] volume = %u\n", TARGET_SPEAKER, current_volume);
|
|
}
|
|
}
|
|
|
|
// --- Phase 3: apply discovery / selection requests (heavy work here) ---
|
|
if (req_scan_start) {
|
|
req_scan_start = false;
|
|
scan_count = 0;
|
|
memset(scan_list, 0, sizeof(scan_list));
|
|
scanning = true;
|
|
source.disconnect();
|
|
source.set_auto_reconnect(false); // force a fresh discovery
|
|
source.set_ssid_callback(on_ssid);
|
|
source.set_discovery_mode_callback(on_disc_state);
|
|
source.start(); // begin inquiry scan
|
|
Serial.printf("[SRC %s] scan started\n", TARGET_SPEAKER);
|
|
}
|
|
if (req_scan_stop) {
|
|
req_scan_stop = false;
|
|
source.cancel_discovery();
|
|
scanning = false;
|
|
Serial.printf("[SRC %s] scan stopped (%u found)\n", TARGET_SPEAKER, scan_count);
|
|
}
|
|
if (req_select) {
|
|
req_select = false;
|
|
uint8_t idx = pending_select;
|
|
if (idx < scan_count) {
|
|
source.cancel_discovery();
|
|
scanning = false;
|
|
memcpy(cur_mac, scan_list[idx].mac, 6);
|
|
strncpy(cur_name, scan_list[idx].name, HUB_NAME_MAX);
|
|
cur_name[HUB_NAME_MAX] = 0;
|
|
source.connect_to(cur_mac);
|
|
source.set_auto_reconnect(cur_mac);
|
|
prefs.putBytes("spk_mac", cur_mac, 6);
|
|
prefs.putString("spk_name", cur_name);
|
|
has_device = true;
|
|
Serial.printf("[SRC %s] selected '%s' (%02X:%02X:%02X:%02X:%02X:%02X)\n",
|
|
TARGET_SPEAKER, cur_name, cur_mac[0], cur_mac[1], cur_mac[2],
|
|
cur_mac[3], cur_mac[4], cur_mac[5]);
|
|
}
|
|
}
|
|
if (req_forget) {
|
|
req_forget = false;
|
|
source.disconnect();
|
|
prefs.remove("spk_mac");
|
|
prefs.remove("spk_name");
|
|
has_device = false;
|
|
cur_name[0] = 0;
|
|
memset(cur_mac, 0, 6);
|
|
Serial.printf("[SRC %s] forgot saved device\n", TARGET_SPEAKER);
|
|
}
|
|
|
|
// Refresh the status snapshot served on GET_STATUS reads.
|
|
status_buf[0] = source.is_connected() ? 1 : 0;
|
|
status_buf[1] = (uint8_t)(delay_ms_current & 0xFF);
|
|
status_buf[2] = (uint8_t)(delay_ms_current >> 8);
|
|
status_buf[3] = current_volume;
|
|
status_buf[4] = scanning ? 1 : 0;
|
|
status_buf[5] = scan_count;
|
|
status_buf[6] = has_device ? 1 : 0;
|
|
|
|
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; }
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (vol_save_pending && now - last_change_ms > 1500) {
|
|
prefs.putUShort("vol", current_volume);
|
|
vol_save_pending = false;
|
|
Serial.printf("[SRC %s] saved vol %u to flash\n", TARGET_SPEAKER, current_volume);
|
|
}
|
|
|
|
static unsigned long last_st = 0;
|
|
if (now - last_st > 5000) {
|
|
Serial.printf("[SRC %s] connected=%s trim=%ums fill=%u heap=%u\n",
|
|
TARGET_SPEAKER, source.is_connected() ? "YES" : "no", delay_ms_current,
|
|
(unsigned)(write_frames - read_frames), ESP.getFreeHeap());
|
|
last_st = now;
|
|
}
|
|
delay(20);
|
|
}
|