Fix LXST TX audio wire format to match Columba's expected batch size

Columba's native OboePlaybackEngine ring buffer expects exactly
frameSamples (1600 for Codec2 3200 mode) decoded samples per
writeEncodedPacket call = 10 sub-frames of 160 samples each.

Changes:
- Batch exactly 10 sub-frames per fixarray element (82 bytes each:
  codec_type + mode_header + 10*8 raw bytes)
- Up to 2 batches per msgpack packet, matching Columba C2C format
- Proper fixarray wrapping for multi-batch, bare bin8 for single
- Add codec_type byte (0x02) prefix per batch element
- Respond to PREFERRED_PROFILE negotiation with LBW (Codec2 3200)
- Add capture diagnostics (raw PCM peaks, I2S dump, rate logging)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2026-02-25 10:57:14 -05:00
co-authored by Claude Opus 4.6
parent 6e47cb808b
commit ddd19a04db
5 changed files with 201 additions and 105 deletions
+37 -23
View File
@@ -18,6 +18,9 @@ using namespace Hardware::TDeck;
static const char* TAG = "LXST:Capture";
// Defined in main.cpp — sends to both Serial and UDP
extern "C" void pyxis_log(const char* msg);
I2SCapture::I2SCapture() = default;
I2SCapture::~I2SCapture() {
@@ -211,13 +214,18 @@ void I2SCapture::captureLoop() {
static constexpr int16_t LIMITER_THRESHOLD = 16000;
Serial.printf("[CAP] Capture task on core %d, I2S=%dHz, codec=%dHz, FIR=%d-tap\n",
xPortGetCoreID(), I2S_SAMPLE_RATE, CODEC_SAMPLE_RATE, FIR_TAPS);
{
char logbuf[96];
snprintf(logbuf, sizeof(logbuf), "[CAP] Capture task on core %d, I2S=%dHz, codec=%dHz, FIR=%d-tap, stack=%d",
xPortGetCoreID(), I2S_SAMPLE_RATE, CODEC_SAMPLE_RATE, FIR_TAPS, CAPTURE_TASK_STACK);
pyxis_log(logbuf);
}
uint32_t framesEncoded = 0;
uint32_t totalDsSamples = 0; // Total mono samples after decimation
uint32_t rateCheckMs = millis(); // For sample rate measurement
int16_t runningPeakDs = 0; // Peak of decimated samples per interval
int16_t runningPeakRaw = 0; // Peak of raw I2S samples per interval
uint32_t ringDrops = 0; // Ring buffer overflow counter
while (capturing_.load(std::memory_order_relaxed)) {
// Read samples from I2S DMA (at 16kHz)
@@ -227,15 +235,15 @@ void I2SCapture::captureLoop() {
int samplesRead = bytesRead / sizeof(int16_t);
// One-time dump of first raw I2S samples to see TDM channel layout
if (framesEncoded == 0 && samplesRead >= 16) {
static bool dumped = false;
if (!dumped) {
dumped = true;
Serial.printf("[CAP] Raw I2S first 16 samples (%d total): ", samplesRead);
for (int d = 0; d < 16; d++) Serial.printf("%d ", readBuf[d]);
Serial.println();
}
// Dump first raw I2S samples on each capture start to see TDM channel layout
// Resets per capture start (not per boot) since framesEncoded resets to 0
if (framesEncoded == 0 && samplesRead >= 16 && totalDsSamples == 0) {
char rawdump[192];
int pos = snprintf(rawdump, sizeof(rawdump),
"[CAP] Raw I2S (%d read, %zu bytes): ", samplesRead, bytesRead);
for (int d = 0; d < 16 && pos < 180; d++)
pos += snprintf(rawdump + pos, sizeof(rawdump) - pos, "%d ", readBuf[d]);
pyxis_log(rawdump);
}
// Track raw I2S peak (all channels)
@@ -291,9 +299,13 @@ void I2SCapture::captureLoop() {
uint32_t elapsed = now - rateCheckMs;
if (elapsed >= 2000) {
uint32_t rate = (totalDsSamples * 1000) / elapsed;
Serial.printf("[CAP] rate=%luHz frames=%lu rawPeak=%d dsPeak=%d\n",
(unsigned long)rate, (unsigned long)framesEncoded,
runningPeakRaw, runningPeakDs);
{
char logbuf[128];
snprintf(logbuf, sizeof(logbuf), "[CAP] rate=%luHz frames=%lu rawPeak=%d dsPeak=%d ringDrops=%lu",
(unsigned long)rate, (unsigned long)framesEncoded,
runningPeakRaw, runningPeakDs, (unsigned long)ringDrops);
pyxis_log(logbuf);
}
totalDsSamples = 0;
rateCheckMs = now;
runningPeakRaw = 0;
@@ -321,16 +333,18 @@ void I2SCapture::captureLoop() {
filterChain_->process(frameData, frameSamples_, CODEC_SAMPLE_RATE);
}
// Log PCM levels for first few frames (pre-filter)
// Log PCM levels for first few frames and periodically
if (framesEncoded < 5 || (framesEncoded % 500 == 0)) {
int16_t maxVal = 0;
for (int s = 0; s < frameSamples_; s++) {
int16_t v = accumBuffer_[s] < 0 ? -accumBuffer_[s] : accumBuffer_[s];
if (v > maxVal) maxVal = v;
}
Serial.printf("[CAP] PCM peak=%d (first=%d,%d,%d,%d)\n",
maxVal, accumBuffer_[0], accumBuffer_[1],
accumBuffer_[2], accumBuffer_[3]);
char logbuf[96];
snprintf(logbuf, sizeof(logbuf), "[CAP] PCM peak=%d (first=%d,%d,%d,%d)",
maxVal, accumBuffer_[0], accumBuffer_[1],
accumBuffer_[2], accumBuffer_[3]);
pyxis_log(logbuf);
}
// Encode
@@ -339,19 +353,19 @@ void I2SCapture::captureLoop() {
if (encodedLen > 0) {
framesEncoded++;
if (framesEncoded <= 3 || (framesEncoded % 500 == 0)) {
char logbuf[128];
char hex[64];
int hpos = 0;
for (int h = 0; h < encodedLen && h < 20 && hpos < 60; h++)
hpos += snprintf(hex + hpos, 64 - hpos, "%02X ", encodeBuf_[h]);
Serial.printf("[CAP] Encoded #%lu: %d bytes: %s\n",
(unsigned long)framesEncoded, encodedLen, hex);
snprintf(logbuf, sizeof(logbuf), "[CAP] Encoded #%lu: %d bytes: %s",
(unsigned long)framesEncoded, encodedLen, hex);
pyxis_log(logbuf);
}
}
if (encodedLen > 0 && encodedRing_) {
if (!encodedRing_->write(encodeBuf_, encodedLen)) {
// Ring full — drop this frame (TX pump will drain)
// NOTE: Do NOT call read() here — this is SPSC and
// the TX pump is the sole consumer on another core.
ringDrops++;
}
}
+2 -2
View File
@@ -103,9 +103,9 @@ private:
static constexpr int I2S_SAMPLE_RATE = 16000; // I2S runs at 16kHz (matches T-Deck Plus reference)
static constexpr int CODEC_SAMPLE_RATE = 8000; // Codec2 expects 8kHz — we downsample 2:1
static constexpr int ENCODED_RING_SLOTS = 32;
static constexpr int ENCODED_RING_SLOTS = 128;
static constexpr int ENCODED_RING_MAX_BYTES = 256;
static constexpr int CAPTURE_TASK_STACK = 16384;
static constexpr int CAPTURE_TASK_STACK = 24576; // 24KB — pyxis_log→sendto uses ~4KB lwIP stack
static constexpr int CAPTURE_TASK_PRIORITY = 5;
static constexpr int CAPTURE_TASK_CORE = 0;
};
+32
View File
@@ -60,6 +60,27 @@ bool LXSTAudio::init(int codec2Mode, uint8_t micGain) {
Serial.printf("[AUDIO] ES7210 init warning: ret=%lu\n", (unsigned long)ret_val);
}
}
// Verify ES7210 configuration by reading back key registers
{
int reg00 = es7210_read_reg(ES7210_RESET_REG00);
int reg01 = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
int reg06 = es7210_read_reg(ES7210_POWER_DOWN_REG06);
int reg08 = es7210_read_reg(ES7210_MODE_CONFIG_REG08);
int reg43 = es7210_read_reg(ES7210_MIC1_GAIN_REG43);
int reg47 = es7210_read_reg(ES7210_MIC1_POWER_REG47);
int reg4b = es7210_read_reg(ES7210_MIC12_POWER_REG4B);
Serial.printf("[AUDIO] ES7210 regs: R00=0x%02X R01=0x%02X R06=0x%02X R08=0x%02X "
"GAIN1=0x%02X PWR1=0x%02X PWR12=0x%02X\n",
reg00, reg01, reg06, reg08, reg43, reg47, reg4b);
// Expected: R00=0x41 (normal), R06=0x00 (powered up), R08=0x00 (slave mode)
// PWR1=0x00, PWR12=0x00 (mics powered on)
if (reg06 != 0x00) {
Serial.printf("[AUDIO] WARNING: ES7210 POWER_DOWN=0x%02X (expected 0x00)\n", reg06);
}
if (reg47 != 0x00 || reg4b != 0x00) {
Serial.printf("[AUDIO] WARNING: ES7210 mic power not active! R47=0x%02X R4B=0x%02X\n", reg47, reg4b);
}
}
Serial.println("[AUDIO] ES7210 initialized OK");
// I2S capture init
@@ -72,6 +93,17 @@ bool LXSTAudio::init(int codec2Mode, uint8_t micGain) {
}
Serial.println("[AUDIO] I2S capture initialized (MCLK now running)");
// Re-issue ES7210 start with clocks now running.
// In slave mode, the ES7210 needs MCLK/BCLK/LRCK from the ESP32 I2S master
// to properly start its ADC — the initial start above ran before clocks were
// available. This second call ensures the ADC powers up correctly.
{
audio_hal_codec_config_t cfg2 = {};
cfg2.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE;
es7210_adc_ctrl_state(cfg2.codec_mode, AUDIO_HAL_CTRL_START);
Serial.println("[AUDIO] ES7210 re-started with I2S clocks active");
}
// Create separate Codec2 instances for encode and decode to avoid mutex
// contention during full-duplex calls (capture task + main thread decode)
encodeCodec_ = new Codec2Wrapper();
+127 -77
View File
@@ -899,7 +899,8 @@ void UIManager::call_send_signal(int signal) {
DEBUG(buf);
}
void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len, int frame_count) {
void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
int batch_count, int total_frames) {
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) {
if (_call_audio_tx_count == 0) {
char dbg[64];
@@ -910,60 +911,81 @@ void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
return;
}
// Msgpack: {0x01: bin8(codec_header + mode_header + raw_frames...)}
// batch_data = [mode_header] + [raw_frame1] + [raw_frame2] + ... (headers stripped)
uint8_t packet_buf[256];
int total_len = 1 + batch_len; // codec_header + batch_data
if (total_len > 250 || total_len < 1) return;
// Match LXST-kt (Columba) wire format exactly:
// {0x01: bin8(batch)} for single batch, or
// {0x01: fixarray(N)[bin8(b1), bin8(b2), ...]} for multiple batches.
// Each batch = [codec_type(0x02)] + [mode_header] + [10 * raw_codec2].
// Columba's native ring buffer expects exactly frameSamples (1600) decoded
// samples per writeEncodedPacket call. For Codec2 3200: 10 * 160 = 1600.
// batch_data contains batch_count concatenated batches of 82 bytes each.
static constexpr int BATCH_BYTES = 82; // codec_type(1) + mode(1) + 10*8
packet_buf[0] = 0x81; // fixmap(1)
packet_buf[1] = 0x01; // key: FIELD_FRAMES
packet_buf[2] = 0xC4; // bin8
packet_buf[3] = (uint8_t)total_len; // length
packet_buf[4] = LXST_CODEC_CODEC2; // codec header (0x02)
memcpy(packet_buf + 5, batch_data, batch_len);
uint8_t packet_buf[256];
int pos = 0;
packet_buf[pos++] = 0x81; // fixmap(1)
packet_buf[pos++] = 0x01; // key: FIELD_FRAMES
if (batch_count == 1) {
// Single batch: bare bin8
packet_buf[pos++] = 0xC4; // bin8
packet_buf[pos++] = (uint8_t)BATCH_BYTES;
memcpy(packet_buf + pos, batch_data, BATCH_BYTES);
pos += BATCH_BYTES;
} else {
// Multiple batches: fixarray(N) of bin8 entries
packet_buf[pos++] = 0x90 | (uint8_t)batch_count; // fixarray(N), N≤15
for (int b = 0; b < batch_count; b++) {
packet_buf[pos++] = 0xC4; // bin8
packet_buf[pos++] = (uint8_t)BATCH_BYTES;
memcpy(packet_buf + pos, batch_data + b * BATCH_BYTES, BATCH_BYTES);
pos += BATCH_BYTES;
}
}
// Hex dump first TX packet for wire format verification
if (_call_audio_tx_count < 2) {
int pkt_len = 5 + batch_len;
char hex[128];
int pos = 0;
for (int i = 0; i < pkt_len && i < 20 && pos < 120; i++) {
pos += snprintf(hex + pos, 128 - pos, "%02X ", packet_buf[i]);
int hpos = 0;
for (int i = 0; i < pos && i < 24 && hpos < 120; i++) {
hpos += snprintf(hex + hpos, 128 - hpos, "%02X ", packet_buf[i]);
}
char dbg[196];
snprintf(dbg, sizeof(dbg), "LXST: TX wire[%d] %d frames: %s", pkt_len, frame_count, hex);
snprintf(dbg, sizeof(dbg), "LXST: TX wire[%d] %d batches %d frames: %s",
pos, batch_count, total_frames, hex);
INFO(dbg);
}
Bytes audio_data(packet_buf, 5 + batch_len);
Bytes audio_data(packet_buf, pos);
Packet packet(_call_link, audio_data);
packet.send();
}
void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) {
// frame = [codec_header_byte, frame_data...]
uint8_t codec = frame[0];
const uint8_t* frame_data = frame + 1;
size_t frame_data_len = frame_len - 1;
// Wire format: [codec_type_byte] + [mode_header + codec2_subframes...]
// codec_type: 0x00=Raw, 0x01=Opus, 0x02=Codec2 (matches LXST Codecs/__init__.py)
// For Codec2: mode_header (0x00-0x06) + raw sub-frames
uint8_t codec_type = frame[0];
const uint8_t* codec_data = frame + 1;
size_t codec_data_len = frame_len - 1;
if (codec != LXST_CODEC_CODEC2) {
if (codec_type != LXST_CODEC_CODEC2) {
if (_call_audio_rx_count == 0) {
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: RX audio codec=0x%02X (expected 0x%02X), len=%d",
codec, LXST_CODEC_CODEC2, (int)frame_data_len);
snprintf(dbg, sizeof(dbg), "LXST: RX codec=0x%02X (need 0x02=Codec2), dropping",
codec_type);
WARNING(dbg);
}
return; // Can't decode Opus (0x01) or Raw (0x00) — only Codec2
}
if (_lxst_audio && _lxst_audio->isPlaying()) {
_lxst_audio->writeEncodedPacket(frame_data, frame_data_len);
_lxst_audio->writeEncodedPacket(codec_data, codec_data_len);
_call_audio_rx_count++;
if (_call_audio_rx_count <= 3) {
char dbg[80];
snprintf(dbg, sizeof(dbg), "LXST: RX audio #%lu codec=0x%02X len=%d first=0x%02X",
(unsigned long)_call_audio_rx_count, codec,
(int)frame_data_len, frame_data_len > 0 ? frame_data[0] : 0);
snprintf(dbg, sizeof(dbg), "LXST: RX audio #%lu mode=0x%02X len=%d",
(unsigned long)_call_audio_rx_count, codec_data[0], (int)codec_data_len);
INFO(dbg);
}
} else if (_call_audio_rx_count == 0) {
@@ -1021,11 +1043,17 @@ void UIManager::call_on_packet(const Bytes& data) {
return;
}
// Ignore PREFERRED_PROFILE signals (0xFF+) — profile negotiation not supported
if (signal >= 0xFF) {
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Ignoring profile signal 0x%03X", signal);
DEBUG(dbg);
// Handle PREFERRED_PROFILE signals (0xFF+)
// Remote sends PREFERRED_PROFILE + profile_id to request a codec profile.
// Pyxis only supports Codec2, so respond with LBW (Codec2 3200bps).
if (signal >= LXST_PREFERRED_PROFILE) {
int remote_profile = signal - LXST_PREFERRED_PROFILE;
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: Remote prefers profile 0x%02X, responding LBW (Codec2)",
remote_profile);
INFO(dbg);
// Send our preferred profile (LBW = Codec2 3200bps)
call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW);
return;
}
@@ -1118,11 +1146,11 @@ void UIManager::call_process_signal(uint8_t signal) {
case CallState::WAIT_RINGING:
if (signal == LXST_STATUS_RINGING) {
INFO("LXST: Remote is ringing");
// Tell remote we need Codec2 (LBW = 3200bps)
call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW);
_call_state = CallState::RINGING;
_call_timeout_ms = millis() + 60000;
_call_screen->set_state(CallScreen::CallState::RINGING);
// Send profile preference: LBW (Codec2 3200bps)
call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW);
} else if (signal == LXST_STATUS_BUSY || signal == LXST_STATUS_REJECTED) {
INFO("LXST: Call rejected or busy");
call_ended();
@@ -1353,57 +1381,79 @@ void UIManager::call_update() {
}
}
// Pump TX: batch exactly 8 codec frames into one packet.
// Pump TX: batch codec frames to match Columba's expected ring buffer slot size.
// Columba's native OboePlaybackEngine expects exactly frameSamples (1600 for 3200
// mode) decoded samples per writeEncodedPacket call = 10 sub-frames.
// Each encoded frame from ring buffer = [mode_header(1)] + [raw_codec2(8)] = 9 bytes.
// We send exactly 8 frames per packet: [mode_header] + [8 * raw_codec2] = 65 bytes.
// MUST be exactly 8: Columba's native ring buffer (frameSamples=2560) rejects
// partial writes (count != frameSamples), so 8*320=2560 samples is required.
static constexpr int TX_BATCH_SIZE = 10; // frames per packet — MUST match Columba's frameSamples/160 (LBW 3200)
// We pack 10 frames per batch: [codec_type(1)] + [mode(1)] + [10*raw(80)] = 82 bytes.
// Multiple batches go into a fixarray, matching Columba's wire format exactly.
static constexpr int FRAMES_PER_BATCH = 10; // 10 * 160 = 1600 = Columba's frameSamples
static constexpr int MAX_BATCHES = 2; // Up to 2 batches per packet (like Columba)
static constexpr int TX_MAX_FRAMES = FRAMES_PER_BATCH * MAX_BATCHES;
static constexpr uint32_t TX_INTERVAL_MS = 200; // Match LXST-kt LBW frame time
static uint32_t last_tx_ms = 0;
if (_lxst_audio && _lxst_audio->isCapturing()) {
// Send up to 3 batched packets per call_update() cycle
for (int batch = 0; batch < 3; batch++) {
// Check if we have enough frames before starting to read
if (_lxst_audio->capturePacketsAvailable() < TX_BATCH_SIZE) {
break; // Wait until we have a full batch
}
int available = _lxst_audio->capturePacketsAvailable();
bool time_to_send = (now - last_tx_ms) >= TX_INTERVAL_MS;
// Send if: enough time has passed AND we have frames,
// OR ring is getting full (>60 frames = 1.2s buffered)
if ((time_to_send && available >= FRAMES_PER_BATCH) || available > 60) {
int to_send = available < TX_MAX_FRAMES ? available : TX_MAX_FRAMES;
uint8_t batch_buf[128]; // [mode_header] + [N * raw_codec2_data]
int batch_len = 0;
int frame_count = 0;
uint8_t encoded_buf[64];
int encoded_len = 0;
// Read frames and pack into batches of 10.
// batch_data: concatenated batches, each 82 bytes:
// [codec_type(0x02)] + [mode_header] + [10 * 8 raw bytes]
uint8_t batch_data[MAX_BATCHES * 82];
int batch_count = 0;
int total_frames = 0;
uint8_t encoded_buf[16];
for (int i = 0; i < TX_BATCH_SIZE; i++) {
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
while (batch_count < MAX_BATCHES && to_send >= FRAMES_PER_BATCH) {
uint8_t* bp = batch_data + batch_count * 82;
bp[0] = LXST_CODEC_CODEC2; // codec_type = 0x02
int frames_in_batch = 0;
for (int i = 0; i < FRAMES_PER_BATCH; i++) {
int encoded_len = 0;
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
break;
}
if (encoded_len < 2) continue;
if (frames_in_batch == 0) {
// First frame: keep mode_header + raw
memcpy(bp + 1, encoded_buf, encoded_len);
} else {
// Subsequent: append raw only (strip mode_header)
memcpy(bp + 1 + 1 + frames_in_batch * 8, encoded_buf + 1, encoded_len - 1);
}
frames_in_batch++;
_call_audio_tx_count++;
to_send--;
}
if (frames_in_batch == FRAMES_PER_BATCH) {
batch_count++;
total_frames += frames_in_batch;
} else {
// Incomplete batch — put back? Can't, so just count what we got
total_frames += frames_in_batch;
if (frames_in_batch > 0) batch_count++;
break;
}
if (encoded_len < 2) continue; // need at least mode_header + 1 byte
if (frame_count == 0) {
// First frame: keep mode header + data
memcpy(batch_buf, encoded_buf, encoded_len);
batch_len = encoded_len;
} else {
// Subsequent frames: strip mode header, append raw data only
int raw_len = encoded_len - 1;
if (batch_len + raw_len > (int)sizeof(batch_buf)) break;
memcpy(batch_buf + batch_len, encoded_buf + 1, raw_len);
batch_len += raw_len;
}
frame_count++;
_call_audio_tx_count++;
}
if (frame_count == TX_BATCH_SIZE) {
call_send_audio_batch(batch_buf, batch_len, frame_count);
if (_call_audio_tx_count <= 16) {
char dbg[80];
snprintf(dbg, sizeof(dbg), "LXST: TX batch %d frames, %d bytes, total=%lu",
frame_count, batch_len, (unsigned long)_call_audio_tx_count);
if (batch_count > 0) {
call_send_audio_batch(batch_data, 82 * batch_count,
batch_count, total_frames);
last_tx_ms = now;
if (_call_audio_tx_count <= 50) {
char dbg[96];
snprintf(dbg, sizeof(dbg), "LXST: TX %d batches %d frames (%d avail), total=%lu",
batch_count, total_frames, available,
(unsigned long)_call_audio_tx_count);
INFO(dbg);
}
} else {
break; // couldn't fill batch (shouldn't happen after availablePackets check)
}
}
} else if (_call_audio_tx_count == 0) {
+3 -3
View File
@@ -246,7 +246,7 @@ private:
static constexpr uint8_t LXST_STATUS_CONNECTING = 0x05;
static constexpr uint8_t LXST_STATUS_ESTABLISHED = 0x06;
// LXST codec header byte
// LXST codec type bytes (match LXST Codecs/__init__.py)
static constexpr uint8_t LXST_CODEC_CODEC2 = 0x02;
// LXST profile negotiation
@@ -295,8 +295,8 @@ private:
// Send a signalling byte over the call link
void call_send_signal(int signal);
// Send batched encoded audio frames over the call link
void call_send_audio_batch(const uint8_t* batch_data, int batch_len, int frame_count);
// Send batched audio frames over the call link (10 sub-frames per batch)
void call_send_audio_batch(const uint8_t* batch_data, int batch_len, int batch_count, int total_frames);
// Process a single received audio frame (codec_header + data)
void call_rx_audio_frame(const uint8_t* frame, size_t frame_len);