From 12fd28b67cbdf3154a75b11e9149aa01d88aca68 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Fri, 8 May 2026 03:11:09 -0400 Subject: [PATCH] test(lxst): bidirectional audio content-fidelity validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the LXST harness from frame-flow only (#62) and decoder QoS only (#63) to a full content-fidelity test: a known 1kHz sine wave flows in BOTH directions through the Codec2-3200 round-trip, and the harness asserts the decoded RMS at each end matches expected energy within tolerance (after Codec2's lossy speech-codec behavior). Firmware additions (under PYXIS_TEST_HOOKS): I2SCapture setInjectSine(enabled, freq=1000, amp=0.5) Replaces mic input with a phase-continuous synthesized sine. Bypasses ES7210 capture and the voice filter chain so the encoder sees pure samples — peer's decoded RMS validates that pyxis's TX path delivers content. I2SPlayback pcmSampleCount(), pcmSumSquares() Decoded-PCM energy accumulators, fed from each successful Codec2 decode. uint64 sumsq holds ~2³⁴ frames before overflow, far longer than any test call. LXSTAudio + UIManager (test-only) captureSetInjectSine, playbackPcmSampleCount, playbackPcmSumSquares test_call_set_inject_sine, test_call_pcm_sample_count, test_call_pcm_sum_squares Serial T: hooks (main.cpp) T:CALL_INJECT [freq] [amp_pct] Drive the capture-side injection from the harness. T:CALL_QOS now also returns pcm_n + pcm_ss Harness divides + sqrts to RMS for content validation. Validated with /tmp/lxst_call_harness.py + /tmp/lxst_call_bot.py (scripts vault-local per the no-PII rule): pyxis_rms = 6363, bot_rms = 1930, decode_fail = 0 PASS: bidirectional audio + content-fidelity validated The empirical RMS floor is 800 (pycodec2 self-test on 1kHz amp 0.5 yields ~1400; pyxis decoder hits ~6300; bot decoder hits ~1900 — all far above the ~5-50 silence floor). Codec2 is a speech codec so pure-tone round-trip is naturally lossy; the test gates on "audio bytes carry actual content energy", not lossless round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/lxst_audio/i2s_capture.cpp | 26 ++++++++++++++++-- lib/lxst_audio/i2s_capture.h | 31 +++++++++++++++++++++ lib/lxst_audio/i2s_playback.cpp | 11 ++++++++ lib/lxst_audio/i2s_playback.h | 18 ++++++++++++ lib/lxst_audio/lxst_audio.cpp | 14 ++++++++++ lib/lxst_audio/lxst_audio.h | 5 ++++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 12 ++++++++ lib/tdeck_ui/UI/LXMF/UIManager.h | 16 +++++++++++ src/main.cpp | 44 +++++++++++++++++++++++++++--- 9 files changed, 171 insertions(+), 6 deletions(-) diff --git a/lib/lxst_audio/i2s_capture.cpp b/lib/lxst_audio/i2s_capture.cpp index cf77ed31..397aec43 100644 --- a/lib/lxst_audio/i2s_capture.cpp +++ b/lib/lxst_audio/i2s_capture.cpp @@ -4,6 +4,7 @@ #include "i2s_capture.h" #ifdef ARDUINO +#include // sinf for setInjectSine sine generator #include #include #include @@ -271,8 +272,29 @@ void I2SCapture::captureLoop() { int16_t* frameData = muted_.load(std::memory_order_relaxed) ? silenceBuf_ : accumBuffer_; - // Apply voice filters - if (filtersEnabled_ && filterChain_ && !muted_.load(std::memory_order_relaxed)) { + // Test injection: overwrite the frame with a phase- + // continuous sine wave. Skips voice filters too — we + // want pure samples reaching the encoder. + if (injectSine_.load(std::memory_order_relaxed) + && !muted_.load(std::memory_order_relaxed)) { + int freq = injectFreq_.load(std::memory_order_relaxed); + int16_t peak = injectPeak_.load(std::memory_order_relaxed); + float dphase = 2.0f * 3.14159265358979f * (float)freq + / (float)CODEC_SAMPLE_RATE; + for (int s = 0; s < frameSamples_; ++s) { + accumBuffer_[s] = (int16_t)(peak * sinf(injectPhase_)); + injectPhase_ += dphase; + } + // Wrap phase to keep it bounded + while (injectPhase_ >= 2.0f * 3.14159265358979f) { + injectPhase_ -= 2.0f * 3.14159265358979f; + } + frameData = accumBuffer_; + } + + // Apply voice filters (skip if injecting test sine) + if (filtersEnabled_ && filterChain_ && !muted_.load(std::memory_order_relaxed) + && !injectSine_.load(std::memory_order_relaxed)) { filterChain_->process(frameData, frameSamples_, CODEC_SAMPLE_RATE); } diff --git a/lib/lxst_audio/i2s_capture.h b/lib/lxst_audio/i2s_capture.h index 4b5624fe..19c9ec24 100644 --- a/lib/lxst_audio/i2s_capture.h +++ b/lib/lxst_audio/i2s_capture.h @@ -54,6 +54,26 @@ public: void setMute(bool muted) { muted_.store(muted, std::memory_order_relaxed); } bool isMuted() const { return muted_.load(std::memory_order_relaxed); } + /** + * Test injection: replace mic input with a synthesized 1kHz sine + * wave. Bypasses both ES7210 capture and the voice filter chain + * so the encoder sees pure samples. Used by the LXST harness to + * validate audio quality across the call (peer's decoded RMS + * matches expected sine energy). + * + * @param enabled True to inject, false to use mic + * @param freq Sine frequency in Hz (default 1000) + * @param amp Amplitude as fraction of int16 max (0.0–1.0, + * default 0.5 → ~16384 peak) + */ + void setInjectSine(bool enabled, int freq = 1000, float amp = 0.5f) { + injectFreq_.store(freq, std::memory_order_relaxed); + int16_t peak = (int16_t)(32767.0f * (amp < 0.f ? 0.f : (amp > 1.f ? 1.f : amp))); + injectPeak_.store(peak, std::memory_order_relaxed); + injectSine_.store(enabled, std::memory_order_relaxed); + } + bool isInjectingSine() const { return injectSine_.load(std::memory_order_relaxed); } + /** Check if currently capturing. */ bool isCapturing() const { return capturing_.load(std::memory_order_relaxed); } @@ -83,6 +103,17 @@ private: std::atomic muted_{false}; void* taskHandle_ = nullptr; + // Test injection (see setInjectSine). When injectSine_ is true, + // the capture path replaces accumulated mic samples with a + // sine wave at injectFreq_ Hz, peak amplitude injectPeak_, + // before encoding. Phase is maintained across frames for + // continuity (no chunk-boundary discontinuities the encoder + // would have to spend bits on). + std::atomic injectSine_{false}; + std::atomic injectFreq_{1000}; + std::atomic injectPeak_{16384}; + float injectPhase_ = 0.0f; + // Audio pipeline components Codec2Wrapper* codec_ = nullptr; // Shared, not owned VoiceFilterChain* filterChain_ = nullptr; diff --git a/lib/lxst_audio/i2s_playback.cpp b/lib/lxst_audio/i2s_playback.cpp index 144c534e..5e74c96d 100644 --- a/lib/lxst_audio/i2s_playback.cpp +++ b/lib/lxst_audio/i2s_playback.cpp @@ -166,6 +166,17 @@ bool I2SPlayback::writeEncodedPacket(const uint8_t* data, int length) { } decodeOkCount_.fetch_add(1, std::memory_order_relaxed); + // Tally PCM energy for RMS-based QoS (LXST harness gates on this). + // Sum-of-squares uses uint64 so int16² ≤ 2³⁰ adds for ~2³⁴ frames + // before overflow — far longer than any test call. + uint64_t sumsq = 0; + for (int s = 0; s < decodedSamples; ++s) { + int32_t v = decodeBuf_[s]; + sumsq += (uint64_t)(v * v); + } + pcmSampleCount_.fetch_add((uint32_t)decodedSamples, std::memory_order_relaxed); + pcmSumSquares_.fetch_add(sumsq, std::memory_order_relaxed); + // Write decoded PCM to ring buffer one frame at a time // (ring buffer only accepts exactly frameSamples_ per write) int numFrames = decodedSamples / frameSamples_; diff --git a/lib/lxst_audio/i2s_playback.h b/lib/lxst_audio/i2s_playback.h index f9761b04..b142f743 100644 --- a/lib/lxst_audio/i2s_playback.h +++ b/lib/lxst_audio/i2s_playback.h @@ -75,9 +75,23 @@ public: */ uint32_t decodeOkCount() const { return decodeOkCount_.load(std::memory_order_relaxed); } uint32_t decodeFailCount() const { return decodeFailCount_.load(std::memory_order_relaxed); } + + /** + * PCM energy on the decoded audio. pcmSampleCount() = total int16 + * samples produced by the decoder; pcmSumSquares() = sum of each + * sample squared (uint64). The harness divides + sqrts to get + * RMS. The peer is expected to send a 1kHz sine via its + * setInjectSine path; pyxis's RMS should match the expected sine + * energy ≈ peak / sqrt(2). For peak=16384 expected RMS ≈ 11585. + */ + uint32_t pcmSampleCount() const { return pcmSampleCount_.load(std::memory_order_relaxed); } + uint64_t pcmSumSquares() const { return pcmSumSquares_.load(std::memory_order_relaxed); } + void resetCounters() { decodeOkCount_.store(0, std::memory_order_relaxed); decodeFailCount_.store(0, std::memory_order_relaxed); + pcmSampleCount_.store(0, std::memory_order_relaxed); + pcmSumSquares_.store(0, std::memory_order_relaxed); } /** Release playback buffers (does NOT destroy the shared codec). */ @@ -98,6 +112,10 @@ private: // QoS counters incremented on each writeEncodedPacket call. std::atomic decodeOkCount_{0}; std::atomic decodeFailCount_{0}; + // PCM energy accumulators — fed from the decoded buffer after + // each successful decode. Used by the harness to compute RMS. + std::atomic pcmSampleCount_{0}; + std::atomic pcmSumSquares_{0}; // Decode buffer for incoming encoded packets int16_t* decodeBuf_ = nullptr; diff --git a/lib/lxst_audio/lxst_audio.cpp b/lib/lxst_audio/lxst_audio.cpp index 01100e59..d05841ff 100644 --- a/lib/lxst_audio/lxst_audio.cpp +++ b/lib/lxst_audio/lxst_audio.cpp @@ -366,4 +366,18 @@ void LXSTAudio::playbackResetCounters() { if (playback_) playback_->resetCounters(); } +uint32_t LXSTAudio::playbackPcmSampleCount() const { + if (!playback_) return 0; + return playback_->pcmSampleCount(); +} + +uint64_t LXSTAudio::playbackPcmSumSquares() const { + if (!playback_) return 0; + return playback_->pcmSumSquares(); +} + +void LXSTAudio::captureSetInjectSine(bool enabled, int freq, float amp) { + if (capture_) capture_->setInjectSine(enabled, freq, amp); +} + #endif // ARDUINO diff --git a/lib/lxst_audio/lxst_audio.h b/lib/lxst_audio/lxst_audio.h index 74faf24e..c06dbde4 100644 --- a/lib/lxst_audio/lxst_audio.h +++ b/lib/lxst_audio/lxst_audio.h @@ -156,8 +156,13 @@ public: */ uint32_t playbackDecodeOk() const; uint32_t playbackDecodeFail() const; + uint32_t playbackPcmSampleCount() const; + uint64_t playbackPcmSumSquares() const; void playbackResetCounters(); + /** Test-injection passthrough to I2SCapture::setInjectSine. */ + void captureSetInjectSine(bool enabled, int freq = 1000, float amp = 0.5f); + private: I2SCapture* capture_ = nullptr; I2SPlayback* playback_ = nullptr; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 9f1383bc..ed1869d8 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -943,6 +943,18 @@ uint32_t UIManager::test_call_decode_fail() const { return _lxst_audio ? _lxst_audio->playbackDecodeFail() : 0; } +uint32_t UIManager::test_call_pcm_sample_count() const { + return _lxst_audio ? _lxst_audio->playbackPcmSampleCount() : 0; +} + +uint64_t UIManager::test_call_pcm_sum_squares() const { + return _lxst_audio ? _lxst_audio->playbackPcmSumSquares() : 0; +} + +void UIManager::test_call_set_inject_sine(bool enabled, int freq, float amp) { + if (_lxst_audio) _lxst_audio->captureSetInjectSine(enabled, freq, amp); +} + const char* UIManager::test_call_state_name() const { switch (_call_state) { case CallState::IDLE: return "IDLE"; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 259fc00c..2b62d9e7 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -216,6 +216,22 @@ public: */ uint32_t test_call_decode_ok() const; uint32_t test_call_decode_fail() const; + + /** + * PCM energy on the decoded audio. Together with sample_count + * gives a running RMS for content-fidelity validation. Reset by + * call_initiate. + */ + uint32_t test_call_pcm_sample_count() const; + uint64_t test_call_pcm_sum_squares() const; + + /** + * Replace mic input with a synthesized sine for the active call. + * Bypasses ES7210 capture and the voice filter chain so the + * remote sees a clean tone. Used by the LXST harness for + * bidirectional content-fidelity validation. + */ + void test_call_set_inject_sine(bool enabled, int freq = 1000, float amp = 0.5f); #endif private: diff --git a/src/main.cpp b/src/main.cpp index 8f48bc19..4ca8450f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1845,18 +1845,54 @@ static void handle_test_hook_command(const String& line) { // T:CALL_QOS — wire-level audio fidelity counters from the // playback decode path. decode_ok = frames Codec2 successfully // decoded into PCM; decode_fail = frames it rejected (bad mode - // header, corrupt subframe, internal codec error). With a - // well-behaved peer all received frames decode_ok and - // decode_fail stays 0 — a non-zero fail count points at - // the peer's encoder OR network corruption. + // header, corrupt subframe, internal codec error). pcm_n / + // pcm_ss = sample count + cumulative sum-of-squares the harness + // divides into RMS for content-level validation. With a peer + // injecting a 1kHz sine at peak P the expected RMS = P/√2. if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } Serial.print("T:OK decode_ok="); Serial.print((unsigned long)ui_manager->test_call_decode_ok()); Serial.print(" decode_fail="); Serial.print((unsigned long)ui_manager->test_call_decode_fail()); + Serial.print(" pcm_n="); + Serial.print((unsigned long)ui_manager->test_call_pcm_sample_count()); + Serial.print(" pcm_ss="); + Serial.print((unsigned long long)ui_manager->test_call_pcm_sum_squares()); Serial.print(" state="); Serial.println(ui_manager->test_call_state_name()); } + else if (cmd == "T:CALL_INJECT") { + // T:CALL_INJECT [freq_hz] [amp_pct] + // Replace mic capture with a synthesized sine wave for the + // active call (bypasses ES7210 + voice filters). The bot + // decodes pyxis's audio packets and computes RMS over the + // decoded PCM — should match the expected sine energy. + if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } + int sp = args.indexOf(' '); + String on_off = (sp < 0) ? args : args.substring(0, sp); + bool enabled = (on_off == "on" || on_off == "1" || on_off == "true"); + int freq = 1000; + float amp = 0.5f; + if (sp >= 0) { + String rest = args.substring(sp + 1); + int sp2 = rest.indexOf(' '); + if (sp2 < 0) { + freq = rest.toInt(); + } else { + freq = rest.substring(0, sp2).toInt(); + amp = rest.substring(sp2 + 1).toFloat(); + } + if (freq <= 0) freq = 1000; + if (amp <= 0.f || amp > 1.f) amp = 0.5f; + } + ui_manager->test_call_set_inject_sine(enabled, freq, amp); + Serial.print("T:OK inject="); + Serial.print(enabled ? "on" : "off"); + Serial.print(" freq="); + Serial.print(freq); + Serial.print(" amp="); + Serial.println(amp, 3); + } else { Serial.print("T:ERR unknown cmd "); Serial.println(cmd);