diff --git a/lib/ble_interface/BLEInterface.cpp b/lib/ble_interface/BLEInterface.cpp index 293eaefa..b545969c 100644 --- a/lib/ble_interface/BLEInterface.cpp +++ b/lib/ble_interface/BLEInterface.cpp @@ -1177,7 +1177,15 @@ bool BLEInterface::start_task(int priority, int core) { BaseType_t result = xTaskCreatePinnedToCore( ble_task, "ble", - 12288, // 12KB stack (string ops in debug logs need headroom) + 24576, // 24KB — this task's loop() processes deferred BLE-received RNS + // packets inline: Transport inbound -> destination/path resolution + // -> microStore `get` (a ~1KB value buffer) -> filestore read, and + // can reach Resource::assemble()'s bz2 decompress. That is the SAME + // deep chain loopTask and the capture task run, both sized at 24576. + // At the old 12288 a path lookup during an active call (BLE-routed, + // ~50 pkt/s) overflowed the stack ("stack overflow in task ble" -> + // PANIC ~seconds into a call); idle it survived because path lookups + // are rare. Size for the workload, matching the other RNS tasks. this, priority, &_task_handle, diff --git a/lib/lxst_audio/i2s_capture.cpp b/lib/lxst_audio/i2s_capture.cpp index 15992e44..182522e9 100644 --- a/lib/lxst_audio/i2s_capture.cpp +++ b/lib/lxst_audio/i2s_capture.cpp @@ -14,6 +14,7 @@ #include "audio_filters.h" #include "encoded_ring_buffer.h" #include +#include using namespace Hardware::TDeck; @@ -139,6 +140,16 @@ bool I2SCapture::configureEncoder(Codec2Wrapper* codec, bool enableFilters) { bool I2SCapture::start() { if (!i2sInitialized_ || !codec_ || capturing_.load()) return false; + if (taskExited_) { + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; + } + taskExited_ = static_cast(xSemaphoreCreateBinary()); + if (!taskExited_) { + ESP_LOGE(TAG, "Failed to create capture-exit semaphore"); + return false; + } + // Set capturing BEFORE starting task to avoid race (same pattern as LXST-kt) capturing_.store(true, std::memory_order_relaxed); @@ -150,6 +161,8 @@ bool I2SCapture::start() { if (ret != pdPASS) { ESP_LOGE(TAG, "Failed to create capture task"); capturing_.store(false, std::memory_order_relaxed); + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; return false; } @@ -158,16 +171,27 @@ bool I2SCapture::start() { } void I2SCapture::stop() { - if (!capturing_.load()) return; - capturing_.store(false, std::memory_order_relaxed); - // Wait for task to exit - if (taskHandle_) { - vTaskDelay(pdMS_TO_TICKS(50)); + // Stop I2S first so a task blocked in i2s_read() wakes immediately, then + // wait for explicit task-exit acknowledgement before releasing its driver, + // codec, ring, or owning object. + TaskHandle_t task = static_cast(taskHandle_); + if (task && i2sInitialized_) i2s_stop(I2S_NUM_1); + if (task && taskExited_) { + if (xSemaphoreTake(static_cast(taskExited_), + pdMS_TO_TICKS(500)) != pdTRUE) { + ESP_LOGE(TAG, "Capture task exit timed out; force deleting task"); + vTaskDelete(task); + } taskHandle_ = nullptr; } + if (taskExited_) { + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; + } + if (i2sInitialized_) { i2s_stop(I2S_NUM_1); i2s_driver_uninstall(I2S_NUM_1); @@ -193,6 +217,10 @@ void I2SCapture::releaseBuffers() { void I2SCapture::captureTask(void* param) { auto* self = static_cast(param); self->captureLoop(); + self->capturing_.store(false, std::memory_order_relaxed); + self->taskHandle_ = nullptr; + auto done = static_cast(self->taskExited_); + if (done) xSemaphoreGive(done); vTaskDelete(NULL); } diff --git a/lib/lxst_audio/i2s_capture.h b/lib/lxst_audio/i2s_capture.h index db4245b5..b3b74b09 100644 --- a/lib/lxst_audio/i2s_capture.h +++ b/lib/lxst_audio/i2s_capture.h @@ -102,6 +102,7 @@ private: std::atomic capturing_{false}; std::atomic muted_{false}; void* taskHandle_ = nullptr; + void* taskExited_ = nullptr; // FreeRTOS binary semaphore; task signals before delete // Test injection (see setInjectSine). When injectSine_ is true, // the capture path replaces accumulated mic samples with a diff --git a/lib/lxst_audio/i2s_playback.cpp b/lib/lxst_audio/i2s_playback.cpp index 6476b231..f8979b7f 100644 --- a/lib/lxst_audio/i2s_playback.cpp +++ b/lib/lxst_audio/i2s_playback.cpp @@ -11,6 +11,7 @@ #include "codec_wrapper.h" #include "packet_ring_buffer.h" #include +#include using namespace Hardware::TDeck; @@ -63,6 +64,16 @@ bool I2SPlayback::configureDecoder(Codec2Wrapper* codec) { bool I2SPlayback::start() { if (!codec_ || playing_.load()) return false; + if (taskExited_) { + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; + } + taskExited_ = static_cast(xSemaphoreCreateBinary()); + if (!taskExited_) { + ESP_LOGE(TAG, "Failed to create playback-exit semaphore"); + return false; + } + // Caller (LXSTAudio) is responsible for calling tone_deinit() first. // Defensively uninstall in case it wasn't done. i2s_driver_uninstall(I2S_NUM_0); @@ -118,6 +129,8 @@ bool I2SPlayback::start() { playing_.store(false, std::memory_order_relaxed); i2s_driver_uninstall(I2S_NUM_0); i2sInitialized_ = false; + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; return false; } @@ -126,15 +139,26 @@ bool I2SPlayback::start() { } void I2SPlayback::stop() { - if (!playing_.load()) return; - playing_.store(false, std::memory_order_relaxed); - if (taskHandle_) { - vTaskDelay(pdMS_TO_TICKS(50)); + // Stop I2S to unblock a pending i2s_write(), then join the playback task + // before uninstalling the driver or releasing task-owned buffers. + TaskHandle_t task = static_cast(taskHandle_); + if (task && i2sInitialized_) i2s_stop(I2S_NUM_0); + if (task && taskExited_) { + if (xSemaphoreTake(static_cast(taskExited_), + pdMS_TO_TICKS(500)) != pdTRUE) { + ESP_LOGE(TAG, "Playback task exit timed out; force deleting task"); + vTaskDelete(task); + } taskHandle_ = nullptr; } + if (taskExited_) { + vSemaphoreDelete(static_cast(taskExited_)); + taskExited_ = nullptr; + } + if (i2sInitialized_) { // Write silence to flush DMA int16_t silence[128] = {0}; @@ -222,6 +246,10 @@ int I2SPlayback::bufferedFrames() const { void I2SPlayback::playbackTask(void* param) { auto* self = static_cast(param); self->playbackLoop(); + self->playing_.store(false, std::memory_order_relaxed); + self->taskHandle_ = nullptr; + auto done = static_cast(self->taskExited_); + if (done) xSemaphoreGive(done); vTaskDelete(NULL); } diff --git a/lib/lxst_audio/i2s_playback.h b/lib/lxst_audio/i2s_playback.h index b142f743..b4f786a8 100644 --- a/lib/lxst_audio/i2s_playback.h +++ b/lib/lxst_audio/i2s_playback.h @@ -105,6 +105,7 @@ private: std::atomic playing_{false}; std::atomic muted_{false}; void* taskHandle_ = nullptr; + void* taskExited_ = nullptr; // FreeRTOS binary semaphore; task signals before delete Codec2Wrapper* codec_ = nullptr; // Shared, not owned PacketRingBuffer* pcmRing_ = nullptr; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 19582b6a..b203df72 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -107,6 +107,10 @@ UIManager::~UIManager() { } delete _lxst_audio; + // This pointer owns the long-lived incoming-destination callback, not only + // the current call. Clear it only when the manager itself is destroyed. + if (s_call_instance == this) s_call_instance = nullptr; + if (_conversation_list_screen) delete _conversation_list_screen; if (_chat_screen) delete _chat_screen; if (_compose_screen) delete _compose_screen; @@ -940,7 +944,6 @@ void UIManager::call_initiate(const Bytes& peer_hash) { _call_peer_hash = peer_hash; _call_muted = false; - s_call_instance = this; lxst_breadcrumb(2, ESP.getFreeHeap()); @@ -948,7 +951,6 @@ void UIManager::call_initiate(const Bytes& peer_hash) { Identity peer_identity = Identity::recall(peer_hash); if (!peer_identity) { WARNING("LXST: Peer identity not known, cannot establish link"); - s_call_instance = nullptr; return; } @@ -1061,9 +1063,9 @@ 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. + // from accessing _lxst_audio after we delete it. s_call_instance remains + // installed because it owns the long-lived incoming destination callback. _call_state = CallState::IDLE; - s_call_instance = nullptr; // Stop audio if (_lxst_audio) { @@ -1521,9 +1523,9 @@ 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. + // from accessing _lxst_audio after we delete it. s_call_instance remains + // installed because it owns the long-lived incoming destination callback. _call_state = CallState::IDLE; - s_call_instance = nullptr; // Stop audio if (_lxst_audio) { diff --git a/src/main.cpp b/src/main.cpp index 203359d7..9ffd3e05 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -619,6 +619,11 @@ void load_app_settings() { // Interfaces app_settings.tcp_enabled = prefs.getBool("tcp_en", true); +#ifdef PYXIS_TEST_HOOKS + // The test transport must be enabled after reading NVS; setting this beside + // the earlier host/port override would be overwritten by tcp_en above. + app_settings.tcp_enabled = true; +#endif app_settings.lora_enabled = prefs.getBool("lora_en", false); app_settings.lora_frequency = prefs.getFloat("lora_freq", 927.25f); app_settings.lora_bandwidth = prefs.getFloat("lora_bw", 50.0f); @@ -2062,7 +2067,10 @@ static void handle_test_hook_command(const String& line) { if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } RNS::Bytes dest_hash = parse_hex_arg(args); if (dest_hash.size() != 16) { Serial.println("T:ERR bad hex"); return; } - ui_manager->test_call_initiate(dest_hash); + // Serial hooks run on loopTask, outside the LVGL task. The production + // call path mutates screens immediately, so hold the same LVGL lock as + // other cross-task UI operations. + { LVGL_LOCK(); ui_manager->test_call_initiate(dest_hash); } Serial.println(String("T:OK calling=") + args); } else if (cmd == "T:CALL_STATE") { @@ -2074,7 +2082,9 @@ static void handle_test_hook_command(const String& line) { else if (cmd == "T:CALL_HANGUP") { // T:CALL_HANGUP — tear down the active call. if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } - ui_manager->test_call_hangup(); + // call_hangup() refreshes/deletes LVGL objects; invoking it unlocked + // from loopTask corrupts LVGL's event list once playback is active. + { LVGL_LOCK(); ui_manager->test_call_hangup(); } Serial.println("T:OK hung_up"); } else if (cmd == "T:CALL_ANSWER") { diff --git a/tools/voice_test/README.md b/tools/voice_test/README.md index adc58a05..42dcb095 100644 --- a/tools/voice_test/README.md +++ b/tools/voice_test/README.md @@ -37,3 +37,34 @@ summary table. Lower-bitrate profiles (700C) score lower by design; compare profiles and before/after firmware changes. The venv was created with `uv venv --python 3.12` + `numpy scipy soundfile sounddevice pyserial pystoi`. + +## Sideband/LXST end-to-end regression + +`sideband_e2e.py` uses the Mac's real RNS/LXST/Sideband Python stack and the +T-Deck serial hooks to verify: + +- Pyxis calls Sideband and exchanges synthetic Codec2 audio in both directions. +- Sideband calls Pyxis and exchanges synthetic Codec2 audio in both directions. +- Pyxis still accepts another incoming call after both calls and hangups. +- Every Pyxis decode succeeds and produces non-zero PCM. + +Build the test firmware with the Mac TCP server baked in, then upload it: + +```bash +export PYXIS_TEST_TCP_HOST=10.0.0.145 PYXIS_TEST_TCP_PORT=4242 +/opt/homebrew/bin/pio run -e tdeck -t upload --upload-port /dev/cu.usbmodem101 +``` + +Run from the Mac with its Reticulum venv (defaults to the local TCP server on +`127.0.0.1:4242`): + +```bash +~/.reticulum-host/venv/bin/python tools/voice_test/sideband_e2e.py +``` + +Optional overrides: `PYXIS_SERIAL_PORT`, `PYXIS_RNS_HOST`, +`PYXIS_RNS_PORT`, and `PYXIS_CALL_SECONDS`. Each run uses a fresh Sideband +identity and isolated RNS storage so stale cached paths cannot false-pass setup. +On Apple Silicon the harness re-executes itself with `/opt/homebrew/lib` on +`DYLD_LIBRARY_PATH`, allowing LXST/PyOgg to load Homebrew `libopus` for incoming +calls. diff --git a/tools/voice_test/sideband_e2e.py b/tools/voice_test/sideband_e2e.py new file mode 100644 index 00000000..9853fc86 --- /dev/null +++ b/tools/voice_test/sideband_e2e.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +import glob, math, os, re, sys, threading, time +from types import SimpleNamespace + +# Homebrew's arm64 libopus is not in dyld's default search path for the +# Xcode-provided Python used by the Mac Reticulum venv. Re-exec before LXST is +# imported so PyOgg can construct the default incoming-call codec. +if sys.platform == "darwin" and not os.environ.get("PYXIS_DYLD_READY"): + env = os.environ.copy() + paths = [p for p in env.get("DYLD_LIBRARY_PATH", "").split(":") if p] + if "/opt/homebrew/lib" not in paths: + paths.insert(0, "/opt/homebrew/lib") + env["DYLD_LIBRARY_PATH"] = ":".join(paths) + env["PYXIS_DYLD_READY"] = "1" + os.execve(sys.executable, [sys.executable] + sys.argv, env) + +HOME = os.path.expanduser("~") +sys.path.insert(0, os.path.join(HOME, "repos", "LXST")) +sys.path.insert(0, os.path.join(HOME, "repos", "Sideband", "sbapp")) +sys.path.append(os.path.join(HOME, "Library", "Python", "3.9", "lib", "python", "site-packages")) + +import numpy as np +import serial +import RNS +import LXST.Sources as Sources +import LXST.Sinks as Sinks +from LXST.Codecs.Codec2 import Codec2 +from LXST.Primitives.Telephony import Profiles +from sideband.voice import ReticulumTelephone + +PORT = os.environ.get("PYXIS_SERIAL_PORT") or sorted(glob.glob("/dev/cu.usbmodem*"))[0] +PROFILE = Profiles.BANDWIDTH_ULTRA_LOW +RUN_SECONDS = float(os.environ.get("PYXIS_CALL_SECONDS", "7")) + +class Stats: + lock = threading.Lock() + encodes = 0 + encode_bytes = 0 + decodes = 0 + decode_samples = 0 + decode_sumsq = 0.0 + sink_frames = 0 + sink_samples = 0 + +orig_encode = Codec2.encode +orig_decode = Codec2.decode + +def counted_encode(self, frame): + out = orig_encode(self, frame) + with Stats.lock: + Stats.encodes += 1 + Stats.encode_bytes += len(out) + return out + +def counted_decode(self, frame): + out = orig_decode(self, frame) + with Stats.lock: + Stats.decodes += 1 + Stats.decode_samples += int(out.size) + Stats.decode_sumsq += float(np.sum(np.square(out.astype(np.float64)))) + return out + +Codec2.encode = counted_encode +Codec2.decode = counted_decode + +class FakeRecorder: + def __init__(self): + self.phase = 0 + def __enter__(self): return self + def __exit__(self, *args): return False + def record(self, numframes): + n = int(numframes) + idx = np.arange(n, dtype=np.float64) + self.phase + t = idx / 8000.0 + env = 0.55 + 0.45*np.sin(2*math.pi*120*t) + x = env*(0.55*np.sin(2*math.pi*730*t) + 0.30*np.sin(2*math.pi*1095*t) + 0.15*np.sin(2*math.pi*2409*t)) + self.phase += n + time.sleep(n/8000.0) + return (0.35*x).astype(np.float32).reshape(-1,1) + +class FakeSourceBackend: + SAMPLERATE = 8000 + def __init__(self, preferred_device=None, samplerate=8000): + self.samplerate = 8000 + self.channels = 1 + self.bitdepth = 32 + self.device = SimpleNamespace(channels=1) + def get_recorder(self, samples_per_frame=None): return FakeRecorder() + def release_recorder(self): pass + def flush(self): pass + def all_microphones(self): return [] + def default_microphone(self): return None + +class FakePlayer: + def __enter__(self): return self + def __exit__(self, *args): return False + def play(self, frame): + with Stats.lock: + Stats.sink_frames += 1 + Stats.sink_samples += int(frame.size) + +class FakeSinkBackend: + SAMPLERATE = 8000 + def __init__(self, preferred_device=None, samplerate=8000): + self.samplerate = 8000 + self.device = SimpleNamespace(channels=1) + def get_player(self, samples_per_frame=None, low_latency=None): return FakePlayer() + def release_player(self): pass + def flush(self): pass + def all_speakers(self): return [] + def default_speaker(self): return None + +Sources.Backend = FakeSourceBackend +Sinks.Backend = FakeSinkBackend + +class Dev: + def __init__(self): + self.s = serial.Serial(PORT, 115200, timeout=0.12) + self.crash_trace = False + time.sleep(0.4) + self.s.reset_input_buffer() + def cmd(self, line, timeout=4): + self.s.write((line+"\n").encode()); self.s.flush() + deadline = time.time()+timeout; lines=[] + while time.time()0 else 0 + print(f"{label} SIDEBAND_DELTA={d} sideband_decode_rms={rms:.5f}",flush=True) + print(f"{label} PYXIS_QOS={qos}",flush=True) + print(f"{label} PYXIS_STATS={stat}",flush=True) + ok=(d["encodes"]>0 and d["decodes"]>0 and d["decode_samples"]>0 and + val(qos,"decode_ok")>0 and val(qos,"decode_fail")==0 and val(qos,"pcm_n")>0) + return ok,{"sideband":d,"qos":qos,"stats":stat,"rms":rms} + +def main(): + print(f"PORT={PORT}",flush=True) + dev=Dev() + owner=Owner() + # Use a fresh identity and isolated RNS storage on every run so cached paths + # cannot make wait_path_rns() pass before the current Pyxis announce arrives. + identity=RNS.Identity() + config_dir=f"/tmp/pyxis-sideband-rns-{os.getpid()}" + os.makedirs(config_dir,exist_ok=True) + rns_host=os.environ.get("PYXIS_RNS_HOST","127.0.0.1") + rns_port=int(os.environ.get("PYXIS_RNS_PORT","4242")) + with open(os.path.join(config_dir,"config"),"w") as f: + f.write(f"""[reticulum]\nenable_transport = No\nshare_instance = No\nshared_instance_port = 48428\ninstance_control_port = 48429\n\n[logging]\nloglevel = 5\n\n[interfaces]\n [[Pyxis troubleshooting hub]]\n type = TCPClientInterface\n enabled = yes\n target_host = {rns_host}\n target_port = {rns_port}\n""") + print(f"RNS_TEST_HUB={rns_host}:{rns_port}",flush=True) + reticulum=RNS.Reticulum(configdir=config_dir,loglevel=5) + phone=ReticulumTelephone(identity, owner=owner) + phone.telephone.auto_answer=0.6 + phone.announce() + side_id=identity.hash.hex(); side_dest=phone.telephone.destination.hash.hex() + py_id=(dev.cmd("T:ID")[0] or "").split()[-1] + py_dest=(dev.cmd("T:LXSTDEST")[0] or "").split()[-1] + print(f"SIDE_ID={side_id} SIDE_DEST={side_dest}",flush=True) + print(f"PYXIS_ID={py_id} PYXIS_DEST={py_dest}",flush=True) + dev.cmd("T:BLE off",timeout=5) + dev.cmd("T:CALL_PROFILE 0x10") + dev.cmd("T:ANNLXST") + phone.announce() + results=[] + try: + # Pyxis -> Sideband first. This proves Pyxis learned the fresh peer + # announce before testing the reciprocal incoming route. + phone.announce(); dev.cmd("T:ANNLXST") + assert wait_path_pyxis(dev,side_dest,45),"Pyxis did not learn Sideband LXST path" + print("TEST1 dialing Pyxis -> Sideband",flush=True) + print("TEST1 call",dev.cmd("T:CALL "+side_dest)[0],flush=True) + ok,seen=dev.wait_state("ACTIVE",35); print("TEST1 states_to_active",seen,flush=True); assert ok + deadline=time.time()+15 + while not phone.is_in_call and time.time() Pyxis after a completed outgoing call. + dev.cmd("T:ANNLXST"); phone.announce() + assert wait_path_rns(bytes.fromhex(py_dest),45),"Sideband did not learn Pyxis LXST path" + print("TEST2 dialing Sideband -> Pyxis",flush=True) + assert phone.dial(bytes.fromhex(py_id),profile=PROFILE)!="no_path" + ok,seen=dev.wait_state("INCOMING_RINGING",25); print("TEST2 states_to_ring",seen,flush=True); assert ok + print("TEST2 answer",dev.cmd("T:CALL_ANSWER")[0],flush=True) + ok,seen=dev.wait_state("ACTIVE",25); print("TEST2 states_to_active",seen,flush=True); assert ok + deadline=time.time()+15 + while not phone.is_in_call and time.time() Pyxis after completed calls",flush=True) + r=phone.dial(bytes.fromhex(py_id),profile=PROFILE) + ok,seen=dev.wait_state("INCOMING_RINGING",15); print("TEST3 states_to_ring",seen,flush=True) + results.append(("incoming_after_hangups",ok,{"dial":r,"states":seen})) + if ok: dev.cmd("T:CALL_HANGUP") + elif phone.telephone.active_call: phone.hangup() + finally: + try: dev.cmd("T:CALL_INJECT off") + except Exception: pass + try: + if phone.telephone and phone.telephone.active_call: phone.hangup() + phone.stop() + except Exception as e: print("PHONE_CLEANUP",repr(e),flush=True) + try: dev.cmd("T:BLE on",timeout=5) + except Exception: pass + dev.close() + print("RESULTS",results,flush=True) + return 0 if all(x[1] for x in results) else 1 + +if __name__=="__main__": + try: raise SystemExit(main()) + except Exception as e: + import traceback; traceback.print_exc(); raise SystemExit(2)