diff --git a/lib/ble_interface/platforms/NimBLEPlatform.cpp b/lib/ble_interface/platforms/NimBLEPlatform.cpp index 2c1d4fa4..a3d2ff64 100644 --- a/lib/ble_interface/platforms/NimBLEPlatform.cpp +++ b/lib/ble_interface/platforms/NimBLEPlatform.cpp @@ -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 diff --git a/lib/ble_interface/platforms/NimBLEPlatform.h b/lib/ble_interface/platforms/NimBLEPlatform.h index b1ac518c..7982f082 100644 --- a/lib/ble_interface/platforms/NimBLEPlatform.h +++ b/lib/ble_interface/platforms/NimBLEPlatform.h @@ -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; diff --git a/lib/lxst_audio/es7210.cpp b/lib/lxst_audio/es7210.cpp index eebb84a5..62bbcb57 100644 --- a/lib/lxst_audio/es7210.cpp +++ b/lib/lxst_audio/es7210.cpp @@ -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 diff --git a/lib/lxst_audio/i2s_capture.cpp b/lib/lxst_audio/i2s_capture.cpp index d474b259..cf77ed31 100644 --- a/lib/lxst_audio/i2s_capture.cpp +++ b/lib/lxst_audio/i2s_capture.cpp @@ -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_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_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( 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; diff --git a/lib/lxst_audio/i2s_capture.h b/lib/lxst_audio/i2s_capture.h index 50335bac..4b5624fe 100644 --- a/lib/lxst_audio/i2s_capture.h +++ b/lib/lxst_audio/i2s_capture.h @@ -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 diff --git a/lib/lxst_audio/lxst_audio.cpp b/lib/lxst_audio/lxst_audio.cpp index 0848382e..b3142bdb 100644 --- a/lib/lxst_audio/lxst_audio.cpp +++ b/lib/lxst_audio/lxst_audio.cpp @@ -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; diff --git a/lib/lxst_audio/lxst_audio.h b/lib/lxst_audio/lxst_audio.h index 94c8bf0f..a82027a3 100644 --- a/lib/lxst_audio/lxst_audio.h +++ b/lib/lxst_audio/lxst_audio.h @@ -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(); diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp index 206deb57..cd33326e 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp @@ -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); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 709fd7dc..3e3efaea 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -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(); } } diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 6113be9f..da5d87ee 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -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) diff --git a/platformio.ini b/platformio.ini index 68e11a0d..02b7ee6c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/main.cpp b/src/main.cpp index c50e9f35..9d0563d2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -69,8 +69,11 @@ // OTA flashing #include -// UDP log broadcasting -#include +// UDP log broadcasting (POSIX socket — avoids WiFiUDP's per-packet heap allocation) +#include +#include +#include +#include // 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.