LXST voice call stability: fix hangup crash, signal queue, TX pump, mic tuning

- Fix use-after-free crash on hangup: set _call_state=IDLE before deleting
  _lxst_audio, preventing pump_call_tx() (runs without LVGL lock) from
  accessing freed memory
- Replace single-slot _call_signal_pending with 8-element ring buffer queue
  to prevent signal loss when CONNECTING+ESTABLISHED arrive in rapid succession
- Extract TX pump into pump_call_tx() called right after reticulum->loop()
  for low-latency audio TX without LVGL lock dependency (was buried at step 10)
- Tune ES7210 mic gain to 21dB (was 15dB) to improve Codec2 input level
  without ADC clipping that occurred at 24dB
- I2S capture: use APLL for accurate 8kHz clock, direct 8kHz sampling
  (no more 16→8kHz decimation), DMA 16x64 for encode burst headroom
- Reduce Reticulum log verbosity to LOG_INFO (was LOG_TRACE)
- BLE: add ble_hs_sched_reset() tiered recovery before reboot on desync,
  widen supervision timeout to 4.0s for WiFi coexistence
- Add UDP multicast log broadcasting and OTA flash support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
torlando-techandClaude Opus 4.6 committed 2026-02-25 10:57:14 -05:00
1 parent ddd19a04db
commit 6744eb136d
12 files changed
+343 -264

No files matched your search

+58 -12
View File
@@ -30,6 +30,10 @@ extern "C" {
int ble_gap_adv_active(void);
int ble_gap_disc_active(void);
int ble_gap_conn_active(void);
// Host reset — enqueues a reset event that clears GAP state and resyncs
// with the BLE controller without touching heap-corrupting deinit paths.
void ble_hs_sched_reset(int reason);
}
namespace RNS { namespace BLE {
@@ -373,6 +377,30 @@ bool NimBLEPlatform::recoverBLEStack() {
return false; // Won't reach here
}
bool NimBLEPlatform::attemptHostReset() {
INFO("NimBLEPlatform: Attempting ble_hs_sched_reset (attempt " +
std::to_string(_host_reset_attempts + 1) + ")");
ble_hs_sched_reset(BLE_HS_ETIMEOUT);
// Poll for resync up to 3s
uint32_t start = millis();
while (!ble_hs_synced() && (millis() - start) < 3000) {
delay(50);
esp_task_wdt_reset();
}
if (ble_hs_synced()) {
unsigned long elapsed = millis() - start;
INFO("NimBLEPlatform: Host resync successful after " +
std::to_string(elapsed) + "ms");
return true;
}
WARNING("NimBLEPlatform: Host resync failed after 3s");
return false;
}
//=============================================================================
// State Machine Implementation
//=============================================================================
@@ -682,22 +710,39 @@ bool NimBLEPlatform::startScan(uint16_t duration_ms) {
WARNING("NimBLEPlatform: Host not synced, desync " +
std::to_string(desync_duration / 1000) + "s (fail " +
std::to_string(_scan_fail_count) + "/" +
std::to_string(SCAN_FAIL_RECOVERY_THRESHOLD) + ")");
std::to_string(SCAN_FAIL_RECOVERY_THRESHOLD) +
", resets=" + std::to_string(_host_reset_attempts) + ")");
// Only reboot after prolonged desync — brief desyncs self-recover.
// With active connections, give a bit more time (90s vs 60s) but
// don't wait too long — a desynced host can't actually communicate
// over those connections, so they're effectively zombie connections.
unsigned long reboot_threshold = HOST_DESYNC_REBOOT_MS; // 60s base
if (getConnectionCount() > 0) {
reboot_threshold = 90000; // 90s with connections (they're likely dead anyway)
}
if (desync_duration >= reboot_threshold) {
// Tiered recovery:
// 0-10s: Wait for natural self-recovery
// 10s: Try ble_hs_sched_reset() (first attempt)
// 30s: Try ble_hs_sched_reset() (second attempt)
// 60s+: Reboot (last resort)
if (desync_duration >= 10000 && _host_reset_attempts == 0) {
_host_reset_attempts++;
if (attemptHostReset()) {
_host_desync_since = 0;
_host_reset_attempts = 0;
_scan_fail_count = 0;
return false; // Synced — will succeed on next scan cycle
}
} else if (desync_duration >= 30000 && _host_reset_attempts == 1) {
_host_reset_attempts++;
if (attemptHostReset()) {
_host_desync_since = 0;
_host_reset_attempts = 0;
_scan_fail_count = 0;
return false;
}
} else if (desync_duration >= HOST_DESYNC_REBOOT_MS) {
ERROR("NimBLEPlatform: Host desynced for " +
std::to_string(desync_duration / 1000) + "s (conns=" +
std::to_string(getConnectionCount()) + "), rebooting");
std::to_string(getConnectionCount()) +
", resets=" + std::to_string(_host_reset_attempts) +
"), rebooting");
_scan_fail_count = 0;
_host_desync_since = 0;
_host_reset_attempts = 0;
recoverBLEStack();
}
return false;
@@ -709,6 +754,7 @@ bool NimBLEPlatform::startScan(uint16_t duration_ms) {
unsigned long recovery_time = millis() - _host_desync_since;
INFO("NimBLEPlatform: Host re-synced after " + std::to_string(recovery_time) + "ms");
_host_desync_since = 0;
_host_reset_attempts = 0;
}
// Log GAP hardware state before checking
@@ -1144,7 +1190,7 @@ bool NimBLEPlatform::connectNative(const BLEAddress& address, uint16_t timeout_m
}
client->setClientCallbacks(this, false);
client->setConnectionParams(24, 40, 0, 256); // 30-50ms interval, 2.56s timeout
client->setConnectionParams(24, 48, 0, 400); // 30-60ms interval, 4.0s supervision timeout
client->setConnectTimeout(timeout_ms); // milliseconds
// Suppress _on_connected in onConnect callback — we'll fire it from here
@@ -260,12 +260,14 @@ private:
uint8_t _conn_establish_fail_count = 0; // rc=574 connection establishment failures
unsigned long _last_full_recovery_time = 0;
unsigned long _host_desync_since = 0; // millis() when host first lost sync (0 = synced)
uint8_t _host_reset_attempts = 0; // ble_hs_sched_reset attempts since last sync
static constexpr uint8_t SCAN_FAIL_RECOVERY_THRESHOLD = 10;
static constexpr uint8_t LIGHTWEIGHT_RESET_MAX_FAILS = 3;
static constexpr uint8_t CONN_ESTABLISH_FAIL_THRESHOLD = 5;
static constexpr unsigned long FULL_RECOVERY_COOLDOWN_MS = 60000; // 60 seconds
static constexpr unsigned long HOST_DESYNC_REBOOT_MS = 60000; // Reboot after 60s desync (no connections)
bool recoverBLEStack();
bool attemptHostReset();
// NimBLE objects
NimBLEServer* _server = nullptr;
+1 -1
View File
@@ -10,7 +10,7 @@
#include "es7210.h"
#define I2S_DSP_MODE_A 0
#define MCLK_DIV_FRE 256
#define MCLK_DIV_FRE 512 // MCLK = sample_rate * 512 (4.096MHz at 8kHz)
#define ES7210_MCLK_SOURCE FROM_CLOCK_DOUBLE_PIN
#define FROM_PAD_PIN 0
+44 -101
View File
@@ -44,17 +44,19 @@ bool I2SCapture::init() {
// Settings match official LilyGO T-Deck Plus Microphone example
i2s_config_t i2s_config = {};
i2s_config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
i2s_config.sample_rate = I2S_SAMPLE_RATE; // 16kHz — downsample to 8kHz for Codec2
i2s_config.sample_rate = I2S_SAMPLE_RATE; // 8kHz — matches Codec2 directly
i2s_config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
i2s_config.channel_format = I2S_CHANNEL_FMT_ALL_LEFT;
i2s_config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
i2s_config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
i2s_config.dma_buf_count = 8;
// At 8kHz × 2 TDM channels = 16ksps. Filter+encode burst for 1600 samples
// takes ~20ms; 16 × 64 = 1024 samples = 64ms headroom prevents DMA overflow.
i2s_config.dma_buf_count = 16;
i2s_config.dma_buf_len = 64;
i2s_config.use_apll = false;
i2s_config.use_apll = true; // APLL gives accurate audio clocks (vs main PLL integer dividers)
i2s_config.tx_desc_auto_clear = true;
i2s_config.fixed_mclk = 0;
i2s_config.mclk_multiple = I2S_MCLK_MULTIPLE_256; // MCLK = 16kHz * 256 = 4.096MHz
i2s_config.fixed_mclk = 4096000; // Force 4.096MHz MCLK (matches ES7210 coeff table for 8kHz)
i2s_config.mclk_multiple = I2S_MCLK_MULTIPLE_256; // Ignored when fixed_mclk is set
i2s_config.bits_per_chan = I2S_BITS_PER_CHAN_16BIT;
// TDM channel mask — required for ES7210 on T-Deck Plus
i2s_config.chan_mask = static_cast<i2s_channel_t>(I2S_TDM_ACTIVE_CH0 | I2S_TDM_ACTIVE_CH1);
@@ -83,7 +85,7 @@ bool I2SCapture::init() {
i2sInitialized_ = true;
ESP_LOGI(TAG, "I2S capture initialized: %dHz 16-bit mono, MCLK=4.096MHz", I2S_SAMPLE_RATE);
ESP_LOGI(TAG, "I2S capture initialized: %dHz 16-bit TDM, MCLK=4.096MHz", I2S_SAMPLE_RATE);
return true;
}
@@ -96,7 +98,11 @@ bool I2SCapture::configureEncoder(Codec2Wrapper* codec, bool enableFilters) {
}
codec_ = codec;
frameSamples_ = codec_->samplesPerFrame();
// Accumulate FRAMES_PER_BATCH codec frames before filter+encode.
// Columba uses 200ms (1600 samples for Codec2 3200) so the AGC operates
// on meaningful block sizes. With only 160 samples (20ms) the AGC blocks
// are 16 samples and gain-pump, producing buzzy audio.
frameSamples_ = codec_->samplesPerFrame() * FRAMES_PER_BATCH;
filtersEnabled_ = enableFilters;
// Allocate ring buffer in PSRAM
@@ -111,13 +117,16 @@ bool I2SCapture::configureEncoder(Codec2Wrapper* codec, bool enableFilters) {
silenceBuf_ = static_cast<int16_t*>(
heap_caps_calloc(frameSamples_, sizeof(int16_t), MALLOC_CAP_SPIRAM));
// Filter chain: 1 channel (mono), voice band 300-3400Hz, AGC -12dB target, 12dB max
// Filter chain: 1 channel (mono), voice band 300-3400Hz, AGC -12dB target, 12dB max gain
// PGA gain is 21dB; loud speech peaks around -6dBFS, quiet around -20dBFS.
// AGC boosts quiet sections; 12dB max prevents noise pumping during silence.
if (enableFilters) {
filterChain_ = new VoiceFilterChain(1, 300.0f, 3400.0f, -12.0f, 12.0f);
}
ESP_LOGI(TAG, "Encoder configured: Codec2 mode %d, %d samples/frame, %d bytes/frame, filters=%d",
codec_->libraryMode(), frameSamples_, codec_->bytesPerFrame(), enableFilters);
ESP_LOGI(TAG, "Encoder configured: Codec2 mode %d, %d samples/batch (%d x %d), %d bytes/frame, filters=%d",
codec_->libraryMode(), frameSamples_, FRAMES_PER_BATCH,
codec_->samplesPerFrame(), codec_->bytesPerFrame(), enableFilters);
return true;
}
@@ -182,62 +191,35 @@ void I2SCapture::captureTask(void* param) {
}
void I2SCapture::captureLoop() {
// I2S read buffer: read in chunks (TDM interleaved, 2 channels at 16kHz each)
static constexpr int READ_SAMPLES = 256; // Larger buffer → more 8kHz samples per read
// I2S read buffer: TDM interleaved, 2 channels at 8kHz
static constexpr int READ_SAMPLES = 256;
int16_t readBuf[READ_SAMPLES];
// CH0 at 16kHz after TDM deinterleave (÷2)
// CH0 mono after TDM deinterleave (÷2)
int16_t ch0Buf[READ_SAMPLES / 2];
// After FIR decimation 16kHz→8kHz (÷2)
int16_t dsBuf[READ_SAMPLES / 4];
size_t bytesRead = 0;
// 15-tap half-band FIR filter for anti-aliased decimation by 2.
// Designed for Fs=16kHz, cutoff=4kHz (Nyquist of 8kHz output).
// Kaiser window beta=6, ~60dB stopband attenuation.
// Half-band property: every other coefficient is zero, so only 5 unique
// multiply-accumulate ops per output sample (symmetric coefficients).
//
// Float coefficients (symmetric, sum=1.0):
// h[0,14] = -0.000676 h[2,12] = +0.012712 h[4,10] = -0.062710
// h[6,8] = +0.300794 h[7] = +0.499759 (center)
// h[1,3,5,9,11,13] = 0 (half-band zeros)
//
// Q15 fixed-point (scaled by 32768):
static constexpr int FIR_TAPS = 15;
static const int16_t FIR_Q15[FIR_TAPS] = {
-22, 0, 417, 0, -2055, 0, 9856, 16376, 9856, 0, -2055, 0, 417, 0, -22
};
static constexpr int FIR_SHIFT = 15; // Q15 format: divide accumulator by 32768
// FIR delay line (persists across I2S reads)
int16_t firDelay[FIR_TAPS] = {0};
static constexpr int16_t LIMITER_THRESHOLD = 16000;
{
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);
snprintf(logbuf, sizeof(logbuf), "[CAP] Capture task on core %d, I2S=%dHz, codec=%dHz, stack=%d",
xPortGetCoreID(), I2S_SAMPLE_RATE, CODEC_SAMPLE_RATE, CAPTURE_TASK_STACK);
pyxis_log(logbuf);
}
uint32_t framesEncoded = 0;
uint32_t totalDsSamples = 0; // Total mono samples after decimation
uint32_t totalSamples = 0; // Total mono samples after deinterleave
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
int16_t runningPeak = 0; // Peak of mono 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)
// Read samples from I2S DMA (at 8kHz, TDM 2-ch)
esp_err_t err = i2s_read(I2S_NUM_1, readBuf, sizeof(readBuf), &bytesRead,
pdMS_TO_TICKS(100));
if (err != ESP_OK || bytesRead == 0) continue;
int samplesRead = bytesRead / sizeof(int16_t);
// 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) {
// Dump first raw I2S samples on each capture start
if (framesEncoded == 0 && samplesRead >= 16 && totalSamples == 0) {
char rawdump[192];
int pos = snprintf(rawdump, sizeof(rawdump),
"[CAP] Raw I2S (%d read, %zu bytes): ", samplesRead, bytesRead);
@@ -246,80 +228,41 @@ void I2SCapture::captureLoop() {
pyxis_log(rawdump);
}
// Track raw I2S peak (all channels)
for (int i = 0; i < samplesRead; i++) {
int16_t v = readBuf[i] < 0 ? -readBuf[i] : readBuf[i];
if (v > runningPeakRaw) runningPeakRaw = v;
}
// Step 1: TDM deinterleave — extract CH0 at 16kHz.
// readBuf is [CH0,CH1,CH0,CH1,...], so CH0 samples at even indices.
// TDM deinterleave — extract CH0 (mic) at 8kHz.
// readBuf is [CH0,CH1,CH0,CH1,...], CH0 at even indices.
int ch0Count = samplesRead / 2;
for (int i = 0; i < ch0Count; i++) {
ch0Buf[i] = readBuf[i * 2];
int16_t v = ch0Buf[i] < 0 ? -ch0Buf[i] : ch0Buf[i];
if (v > runningPeak) runningPeak = v;
}
// Step 2: 15-tap half-band FIR decimation by 2 (16kHz → 8kHz).
// For each output sample, push 2 input samples through the FIR delay
// line and compute the convolution sum. Half-band zeros mean only
// taps 0,2,4,6,7,8,10,12,14 are non-zero — exploited below.
int dsCount = ch0Count / 2;
for (int i = 0; i < dsCount; i++) {
// Push 2 input samples into the delay line (decimation by 2)
for (int k = 0; k < 2; k++) {
// Shift delay line right by 1
for (int d = FIR_TAPS - 1; d > 0; d--)
firDelay[d] = firDelay[d - 1];
firDelay[0] = ch0Buf[i * 2 + k];
}
// Compute FIR output — exploit symmetry and half-band zeros.
// Non-zero taps: 0,2,4,6,7,8,10,12,14
// Symmetric pairs: (0,14), (2,12), (4,10), (6,8), center=7
int32_t acc = 0;
acc += (int32_t)FIR_Q15[0] * ((int32_t)firDelay[0] + firDelay[14]);
acc += (int32_t)FIR_Q15[2] * ((int32_t)firDelay[2] + firDelay[12]);
acc += (int32_t)FIR_Q15[4] * ((int32_t)firDelay[4] + firDelay[10]);
acc += (int32_t)FIR_Q15[6] * ((int32_t)firDelay[6] + firDelay[8]);
acc += (int32_t)FIR_Q15[7] * (int32_t)firDelay[7]; // center tap
int16_t s = (int16_t)(acc >> FIR_SHIFT);
// Hard limiter: clamp to ±LIMITER_THRESHOLD to prevent ADC clipping artifacts
if (s > LIMITER_THRESHOLD) s = LIMITER_THRESHOLD;
else if (s < -LIMITER_THRESHOLD) s = -LIMITER_THRESHOLD;
dsBuf[i] = s;
int16_t v = s < 0 ? -s : s;
if (v > runningPeakDs) runningPeakDs = v;
}
// Measure actual sample rate: count deinterleaved samples per second
totalDsSamples += dsCount;
// Measure actual sample rate
totalSamples += ch0Count;
uint32_t now = millis();
uint32_t elapsed = now - rateCheckMs;
if (elapsed >= 2000) {
uint32_t rate = (totalDsSamples * 1000) / elapsed;
uint32_t rate = (totalSamples * 1000) / elapsed;
{
char logbuf[128];
snprintf(logbuf, sizeof(logbuf), "[CAP] rate=%luHz frames=%lu rawPeak=%d dsPeak=%d ringDrops=%lu",
snprintf(logbuf, sizeof(logbuf), "[CAP] rate=%luHz frames=%lu peak=%d ringDrops=%lu",
(unsigned long)rate, (unsigned long)framesEncoded,
runningPeakRaw, runningPeakDs, (unsigned long)ringDrops);
runningPeak, (unsigned long)ringDrops);
pyxis_log(logbuf);
}
totalDsSamples = 0;
totalSamples = 0;
rateCheckMs = now;
runningPeakRaw = 0;
runningPeakDs = 0;
runningPeak = 0;
}
// Accumulate downsampled samples into frame-sized buffer
// Accumulate mono samples into frame-sized buffer
int offset = 0;
while (offset < dsCount && capturing_.load(std::memory_order_relaxed)) {
while (offset < ch0Count && capturing_.load(std::memory_order_relaxed)) {
int needed = frameSamples_ - accumCount_;
int available = dsCount - offset;
int available = ch0Count - offset;
int toCopy = (available < needed) ? available : needed;
memcpy(accumBuffer_ + accumCount_, dsBuf + offset, toCopy * sizeof(int16_t));
memcpy(accumBuffer_ + accumCount_, ch0Buf + offset, toCopy * sizeof(int16_t));
accumCount_ += toCopy;
offset += toCopy;
+6 -2
View File
@@ -101,8 +101,12 @@ private:
bool filtersEnabled_ = true;
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 I2S_SAMPLE_RATE = 8000; // I2S runs at 8kHz — matches Codec2 directly, no resampling needed
static constexpr int CODEC_SAMPLE_RATE = 8000; // Codec2 expects 8kHz
// Accumulate this many codec frames before filter+encode.
// Matches Columba's 200ms batch (1600 samples for Codec2 3200).
// The AGC needs large blocks for stable gain tracking.
static constexpr int FRAMES_PER_BATCH = 10;
static constexpr int ENCODED_RING_SLOTS = 128;
static constexpr int ENCODED_RING_MAX_BYTES = 256;
static constexpr int CAPTURE_TASK_STACK = 24576; // 24KB — pyxis_log→sendto uses ~4KB lwIP stack
+1 -1
View File
@@ -44,7 +44,7 @@ bool LXSTAudio::init(int codec2Mode, uint8_t micGain) {
cfg.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE;
cfg.i2s_iface.mode = AUDIO_HAL_MODE_SLAVE;
cfg.i2s_iface.fmt = AUDIO_HAL_I2S_NORMAL;
cfg.i2s_iface.samples = AUDIO_HAL_16K_SAMPLES;
cfg.i2s_iface.samples = AUDIO_HAL_08K_SAMPLES;
cfg.i2s_iface.bits = AUDIO_HAL_BIT_LENGTH_16BITS;
uint32_t ret_val = ESP_OK;
+2 -2
View File
@@ -67,10 +67,10 @@ public:
* Does NOT start capture or playback.
*
* @param codec2Mode Codec2 library mode (default 1600)
* @param micGain ES7210 mic gain (0-14, default 8 = 24dB)
* @param micGain ES7210 mic gain (0-14, default 7 = 21dB)
* @return true on success
*/
bool init(int codec2Mode = CODEC2_MODE_1600, uint8_t micGain = 5);
bool init(int codec2Mode = CODEC2_MODE_1600, uint8_t micGain = 7);
/** Tear down everything and release all resources. */
void deinit();
@@ -73,7 +73,7 @@ void ConversationListScreen::create_header() {
// Title
lv_obj_t* title = lv_label_create(_header);
lv_label_set_text(title, "LXMF");
lv_label_set_text(title, "PYXIS");
lv_obj_align(title, LV_ALIGN_LEFT_MID, 8, 0);
lv_obj_set_style_text_color(title, Theme::textPrimary(), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
+101 -115
View File
@@ -61,9 +61,11 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me
_call_muted(false),
_call_answer_pending(false),
_call_link_closed_pending(false),
_call_signal_pending(0xFF),
_call_signal_write(0),
_call_signal_read(0),
_call_audio_rx_count(0),
_call_audio_tx_count(0) {
memset((void*)_call_signal_queue, 0, sizeof(_call_signal_queue));
}
UIManager::~UIManager() {
@@ -794,7 +796,8 @@ void UIManager::call_initiate(const Bytes& peer_hash) {
_call_audio_rx_count = 0;
_call_audio_tx_count = 0;
_call_link_closed_pending = false;
_call_signal_pending = 0xFF;
_call_signal_write = 0;
_call_signal_read = 0;
{
std::string dh = peer_dest.hash().toHex().substr(0, 16);
@@ -825,6 +828,11 @@ void UIManager::call_initiate(const Bytes& peer_hash) {
void UIManager::call_hangup() {
INFO("LXST: Hanging up");
// Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock)
// from accessing _lxst_audio after we delete it.
_call_state = CallState::IDLE;
s_call_instance = nullptr;
// Stop audio
if (_lxst_audio) {
_lxst_audio->stopCapture();
@@ -840,9 +848,7 @@ void UIManager::call_hangup() {
_call_link = Link(Type::NONE);
}
_call_state = CallState::IDLE;
_call_peer_hash = Bytes();
s_call_instance = nullptr;
// Return to chat screen
if (_call_screen) {
@@ -890,13 +896,19 @@ void UIManager::call_send_signal(int signal) {
len = 6;
}
Bytes signal_data(msgpack_buf, len);
Packet packet(_call_link, signal_data);
packet.send();
try {
Bytes signal_data(msgpack_buf, len);
Packet packet(_call_link, signal_data);
packet.send();
char buf[48];
snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%03X", signal);
DEBUG(buf);
char buf[48];
snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%03X", signal);
DEBUG(buf);
} catch (const std::exception& e) {
char dbg[128];
snprintf(dbg, sizeof(dbg), "LXST: Signal send exception: %s", e.what());
WARNING(dbg);
}
}
void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
@@ -956,12 +968,21 @@ void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
INFO(dbg);
}
Bytes audio_data(packet_buf, pos);
Packet packet(_call_link, audio_data);
packet.send();
try {
Bytes audio_data(packet_buf, pos);
Packet packet(_call_link, audio_data);
packet.send();
} catch (const std::exception& e) {
char dbg[128];
snprintf(dbg, sizeof(dbg), "LXST: TX send exception: %s", e.what());
WARNING(dbg);
}
}
void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) {
// Guard: packets can arrive after hangup from the network pipeline
if (!_lxst_audio || _call_state == CallState::IDLE) return;
// 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
@@ -1057,12 +1078,21 @@ void UIManager::call_on_packet(const Bytes& data) {
return;
}
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
DEBUG(dbg);
{
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
INFO(dbg);
}
// Queue for processing in call_update() under LVGL lock
_call_signal_pending = (uint8_t)signal;
// Enqueue for processing in call_update() under LVGL lock
uint8_t w = _call_signal_write;
uint8_t next_w = (w + 1) % SIGNAL_QUEUE_SIZE;
if (next_w != _call_signal_read) { // Not full
_call_signal_queue[w] = (uint8_t)signal;
_call_signal_write = next_w;
} else {
WARNING("LXST: Signal queue full, dropping signal!");
}
} else if (field == 0x01) {
// Audio: {0x01: value} where value is either:
@@ -1126,9 +1156,11 @@ void UIManager::call_on_packet(const Bytes& data) {
// Process received signal — runs under LVGL lock from call_update()
void UIManager::call_process_signal(uint8_t signal) {
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Processing signal 0x%02X (state=%d)", signal, (int)_call_state);
DEBUG(dbg);
{
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Processing signal 0x%02X (state=%d)", signal, (int)_call_state);
INFO(dbg);
}
switch (_call_state) {
case CallState::WAIT_AVAILABLE:
@@ -1234,6 +1266,11 @@ void UIManager::call_process_signal(uint8_t signal) {
void UIManager::call_ended() {
INFO("LXST: Call ended");
// Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock)
// from accessing _lxst_audio after we delete it.
_call_state = CallState::IDLE;
s_call_instance = nullptr;
// Stop audio
if (_lxst_audio) {
_lxst_audio->stopCapture();
@@ -1249,9 +1286,7 @@ void UIManager::call_ended() {
_call_link = Link(Type::NONE);
}
_call_state = CallState::IDLE;
_call_peer_hash = Bytes();
s_call_instance = nullptr;
_call_screen->set_state(CallScreen::CallState::ENDED);
@@ -1259,6 +1294,42 @@ void UIManager::call_ended() {
show_conversation_list();
}
void UIManager::pump_call_tx() {
if (_call_state == CallState::IDLE) return;
if (!_lxst_audio || !_lxst_audio->isCapturing()) return;
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return;
int available = _lxst_audio->capturePacketsAvailable();
// Drain all available batches — this runs on loopTask (core 1)
// and doesn't touch LVGL, so no lock needed.
while (available > 0) {
uint8_t encoded_buf[128];
int encoded_len = 0;
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
break;
}
if (encoded_len < 2) { available--; continue; }
// Prepend codec type byte: [0x02] + [encoded: mode_header + 10*8 raw]
uint8_t batch_data[128];
batch_data[0] = LXST_CODEC_CODEC2;
memcpy(batch_data + 1, encoded_buf, encoded_len);
int batch_len = 1 + encoded_len;
call_send_audio_batch(batch_data, batch_len, 1, encoded_len / 8);
_call_audio_tx_count++;
available--;
if (_call_audio_tx_count <= 10 || (_call_audio_tx_count % 100 == 0)) {
char dbg[96];
snprintf(dbg, sizeof(dbg), "LXST: TX batch #%lu (%d bytes, avail=%d)",
(unsigned long)_call_audio_tx_count, batch_len, available);
INFO(dbg);
}
}
}
void UIManager::call_update() {
uint32_t now = millis();
@@ -1269,11 +1340,11 @@ void UIManager::call_update() {
return;
}
// Process deferred signal (set by Reticulum packet callback, consumed here under LVGL lock)
uint8_t pending_sig = _call_signal_pending;
if (pending_sig != 0xFF) {
_call_signal_pending = 0xFF;
call_process_signal(pending_sig);
// Process all queued signals (set by Reticulum packet callback, consumed here under LVGL lock)
while (_call_signal_read != _call_signal_write) {
uint8_t sig = _call_signal_queue[_call_signal_read];
_call_signal_read = (_call_signal_read + 1) % SIGNAL_QUEUE_SIZE;
call_process_signal(sig);
if (_call_state == CallState::IDLE) return; // Signal caused call to end
}
@@ -1381,93 +1452,8 @@ void UIManager::call_update() {
}
}
// 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 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()) {
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;
// 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];
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 (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 if (_call_audio_tx_count == 0) {
// Log once why TX is not running
static uint32_t last_tx_warn = 0;
if (now - last_tx_warn > 2000) {
last_tx_warn = now;
char dbg[96];
snprintf(dbg, sizeof(dbg), "LXST: TX pump idle: audio=%p capturing=%d state=%d",
_lxst_audio, _lxst_audio ? (int)_lxst_audio->isCapturing() : -1,
_lxst_audio ? (int)_lxst_audio->state() : -1);
WARNING(dbg);
}
}
// TX pump — also called from main loop without LVGL lock for low latency
pump_call_tx();
}
}
+11 -1
View File
@@ -72,6 +72,12 @@ public:
*/
void update();
/**
* Pump TX audio without LVGL lock — call from main loop for low-latency TX.
* Safe to call on every loop iteration; no-ops when not in a call.
*/
void pump_call_tx();
/**
* Show conversation list screen
*/
@@ -276,7 +282,11 @@ private:
bool _call_muted;
volatile bool _call_answer_pending; // Set by LVGL task, consumed by main loop
volatile bool _call_link_closed_pending; // Set by link callback, consumed by call_update
volatile uint8_t _call_signal_pending; // 0xFF = none; set by packet callback
// Signal queue: written by Reticulum thread, consumed by call_update under LVGL lock
static constexpr int SIGNAL_QUEUE_SIZE = 8;
volatile uint8_t _call_signal_queue[SIGNAL_QUEUE_SIZE];
volatile uint8_t _call_signal_write; // Next write index (Reticulum thread)
volatile uint8_t _call_signal_read; // Next read index (main thread)
uint32_t _call_audio_rx_count; // Count of received audio frames (for diagnostics)
uint32_t _call_audio_tx_count; // Count of sent audio frames (for diagnostics)
+8 -10
View File
@@ -52,10 +52,9 @@ build_flags =
-std=gnu++11
-DBOARD_HAS_PSRAM
-DBOARD_ESP32
; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152)
; 16KB gives ~2.5x headroom for deep call chains
; (transport → link → resource → LXMF unpack → crypto → msgpack)
-DARDUINO_LOOP_STACK_SIZE=16384
; Arduino loop task stack — Codec2 decode (lpc_post_filter + kiss_fft)
; uses ~6KB stack on top of ~10KB normal peak. 24KB gives safe headroom.
-DARDUINO_LOOP_STACK_SIZE=24576
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DLV_CONF_INCLUDE_SIMPLE
@@ -133,10 +132,9 @@ build_flags =
-std=gnu++11
-DBOARD_HAS_PSRAM
-DBOARD_ESP32
; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152)
; 16KB gives ~2.5x headroom for deep call chains
; (transport → link → resource → LXMF unpack → crypto → msgpack)
-DARDUINO_LOOP_STACK_SIZE=16384
; Arduino loop task stack — Codec2 decode (lpc_post_filter + kiss_fft)
; uses ~6KB stack on top of ~10KB normal peak. 24KB gives safe headroom.
-DARDUINO_LOOP_STACK_SIZE=24576
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DLV_CONF_INCLUDE_SIMPLE
@@ -164,5 +162,5 @@ build_flags =
; Usage: pio run -e tdeck-ota -t upload
[env:tdeck-ota]
extends = env:tdeck
upload_protocol = espota
upload_port = pyxis-tdeck.local
upload_protocol = custom
upload_command = python3 tools/espota.py -i pyxis-tdeck.local -p 3232 -f $SOURCE
+108 -18
View File
@@ -69,8 +69,11 @@
// OTA flashing
#include <ArduinoOTA.h>
// UDP log broadcasting
#include <WiFiUdp.h>
// UDP log broadcasting (POSIX socket — avoids WiFiUDP's per-packet heap allocation)
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
// Memory instrumentation
#ifdef MEMORY_INSTRUMENTATION_ENABLED
@@ -130,21 +133,53 @@ volatile bool wifi_reconnect_pending = false;
String pending_wifi_ssid;
String pending_wifi_password;
// UDP log broadcasting (multicast avoids duplicate delivery on multi-homed hosts)
static WiFiUDP udp_log;
static const IPAddress UDP_LOG_GROUP(239, 0, 99, 99);
// UDP log broadcasting (POSIX socket — no per-packet heap allocation)
// WiFiUDP::beginPacket() does new char[1460] on every call, causing severe
// heap fragmentation over time. A raw POSIX socket with sendto() avoids this.
static int udp_log_sock = -1;
static struct sockaddr_in udp_log_dest;
static bool udp_log_ready = false;
// Helper: send a string via UDP broadcast (for Serial.printf diagnostics)
// Guards against sending during WiFi transitions and reentrant calls
static volatile bool udp_sending = false;
static void udp_log_init() {
if (udp_log_sock >= 0) close(udp_log_sock); // Re-init safe
udp_log_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (udp_log_sock < 0) return;
// Non-blocking so sendto() never stalls the log path
int flags = fcntl(udp_log_sock, F_GETFL, 0);
fcntl(udp_log_sock, F_SETFL, flags | O_NONBLOCK);
// Multicast TTL = 1 (local network only)
uint8_t ttl = 1;
setsockopt(udp_log_sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
// Bind multicast output to the WiFi station interface — without this,
// lwIP doesn't know which interface to send multicast packets on.
struct in_addr iface;
iface.s_addr = (uint32_t)WiFi.localIP();
setsockopt(udp_log_sock, IPPROTO_IP, IP_MULTICAST_IF, &iface, sizeof(iface));
memset(&udp_log_dest, 0, sizeof(udp_log_dest));
udp_log_dest.sin_family = AF_INET;
udp_log_dest.sin_port = htons(9999);
udp_log_dest.sin_addr.s_addr = inet_addr("239.0.99.99");
}
// UDP send — no locking needed. sendto() is non-blocking (O_NONBLOCK) and
// lwIP's internal TCPIP core lock serializes concurrent calls. Worst case
// on contention: EAGAIN/ENOMEM and the packet is dropped (acceptable for logs).
static void udp_send(const char* msg, size_t len) {
if (!udp_log_ready || udp_sending || WiFi.status() != WL_CONNECTED) return;
udp_sending = true;
udp_log.beginPacket(UDP_LOG_GROUP, 9999);
udp_log.write((const uint8_t*)msg, len);
udp_log.endPacket();
udp_sending = false;
if (udp_log_sock < 0 || !udp_log_ready || WiFi.status() != WL_CONNECTED) return;
sendto(udp_log_sock, msg, len, 0,
(struct sockaddr*)&udp_log_dest, sizeof(udp_log_dest));
}
// Global log function callable from any module (sends to UDP + Serial)
extern "C" void pyxis_log(const char* msg) {
Serial.println(msg);
if (udp_log_ready) {
udp_send(msg, strlen(msg));
}
}
// Forward declarations
@@ -419,7 +454,7 @@ void load_app_settings() {
app_settings.ble_enabled = prefs.getBool("ble_en", false);
// Advanced
app_settings.announce_interval = prefs.getULong("announce", 60);
app_settings.announce_interval = prefs.getULong("announce", 3600);
app_settings.sync_interval = prefs.getULong("sync_int", 3600); // Default 60 minutes
app_settings.gps_time_sync = prefs.getBool("gps_sync", true);
@@ -505,15 +540,58 @@ void setup_wifi() {
// Initialize ArduinoOTA for wireless flashing
ArduinoOTA.setHostname("pyxis-tdeck");
ArduinoOTA.onStart([]() { INFO("OTA: Update starting..."); });
ArduinoOTA.onStart([]() {
INFO("OTA: Update starting — suspending BLE for clean WiFi");
// Pause BLE to free the shared 2.4GHz radio for WiFi transfer
if (ble_interface_impl) {
ble_interface_impl->stop();
INFO("OTA: BLE stopped");
}
// Disconnect TCP to reduce WiFi contention
if (tcp_interface_impl) {
tcp_interface_impl->stop();
INFO("OTA: TCP stopped");
}
});
ArduinoOTA.onEnd([]() { INFO("OTA: Update complete, rebooting"); });
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
// Tight OTA service loop: feed WDT and yield to OTA networking
// without returning to the heavy main loop
esp_task_wdt_reset();
static unsigned int last_pct = 999;
unsigned int pct = progress * 100 / total;
if (pct != last_pct && pct % 10 == 0) {
last_pct = pct;
Serial.printf("OTA: %u%%\n", pct);
}
});
ArduinoOTA.onError([](ota_error_t error) { ERROR("OTA: Error"); });
ArduinoOTA.begin();
INFO("OTA: Ready");
// Initialize UDP log broadcasting (multicast group 239.0.99.99:9999)
udp_log_init();
udp_log_ready = true;
RNS::setLogCallback([](const char* msg, RNS::LogLevel level) {
// Suppress noisy per-packet LoRa/transport trace lines on UDP
// (still send to Serial for wired debugging)
bool suppress_udp = false;
if (level <= RNS::LOG_DEBUG) {
// Quick prefix checks for the noisiest log sources
if (strncmp(msg, "SX1262", 6) == 0 ||
strncmp(msg, "Transport::inbound", 18) == 0 ||
strncmp(msg, "AutoInterface:", 14) == 0 ||
strncmp(msg, "Packet::", 8) == 0 ||
strncmp(msg, "Creating packet", 15) == 0 ||
strncmp(msg, "Checking to see", 15) == 0 ||
strncmp(msg, "Caching packet", 14) == 0 ||
strncmp(msg, "Adding destination", 18) == 0 ||
strncmp(msg, "InterfaceImpl", 13) == 0 ||
strncmp(msg, "Identity::", 10) == 0 ||
strncmp(msg, "Dropped", 7) == 0) {
suppress_udp = true;
}
}
// Serial (preserve wired debugging)
Serial.print(RNS::getTimeString());
Serial.print(" [");
@@ -521,8 +599,8 @@ void setup_wifi() {
Serial.print("] ");
Serial.println(msg);
Serial.flush();
// UDP broadcast
if (udp_log_ready) {
// UDP broadcast (filtered)
if (udp_log_ready && !suppress_udp) {
char buf[512];
int len = snprintf(buf, sizeof(buf), "%s [%s] %s",
RNS::getTimeString(), RNS::getLevelName(level), msg);
@@ -610,6 +688,10 @@ void setup_reticulum() {
// Create Reticulum instance (no auto-init)
reticulum = new Reticulum();
// Reduce transport log verbosity — LOG_TRACE floods serial with
// token/link/announce details that drown out audio diagnostics.
RNS::loglevel(RNS::LOG_INFO);
// Load or create identity using NVS (Non-Volatile Storage)
// NVS is preserved across flashes unlike SPIFFS
Preferences prefs;
@@ -914,6 +996,7 @@ void setup_ui_manager() {
}
if (WiFi.status() == WL_CONNECTED) {
udp_log_init(); // Rebind to new WiFi interface IP
udp_log_ready = true; // Resume UDP logging
INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str());
} else {
@@ -1325,6 +1408,7 @@ void loop() {
}
if (WiFi.status() == WL_CONNECTED) {
udp_log_init(); // Rebind to new WiFi interface IP
udp_log_ready = true; // Resume UDP logging
INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str());
} else {
@@ -1338,6 +1422,12 @@ void loop() {
LOOP_STEP(4); // reticulum->loop()
reticulum->loop();
// Pump TX audio immediately after Reticulum — low-latency path that
// bypasses LVGL lock and all other loop steps. No-ops when not in a call.
if (ui_manager) {
ui_manager->pump_call_tx();
}
// Periodically persist identity/transport data (display names, paths, etc.)
// NOTE: Identity persistence writes 40-50 entries to SPIFFS flash, which
// involves sector erases (100ms each) and can take 5-15s total.