From 1b8a479390db6e9f0e07ddd45d13f686967a6335 Mon Sep 17 00:00:00 2001 From: Ryan Turner Date: Sat, 21 Mar 2026 12:20:04 -0500 Subject: [PATCH] tests: add end-to-end demodulator lock test Add a test that generates 10 consecutive RRC-shaped stream frames and asserts the demodulator stays locked throughout. Co-authored-by: GitHub Copilot <175728472+github-copilot[bot]@users.noreply.github.com> Signed-off-by: Ryan Turner --- tests/unit/M17_demodulator.cpp | 115 +++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/unit/M17_demodulator.cpp b/tests/unit/M17_demodulator.cpp index 882e9390..e1b76aa5 100644 --- a/tests/unit/M17_demodulator.cpp +++ b/tests/unit/M17_demodulator.cpp @@ -13,6 +13,7 @@ #include "protocols/M17/Correlator.hpp" #include "protocols/M17/Constants.hpp" +#include "protocols/M17/Demodulator.hpp" #include "protocols/M17/DSP.hpp" #include "protocols/M17/Synchronizer.hpp" #include "core/fir.hpp" @@ -174,3 +175,117 @@ TEST_CASE("RRC 24kHz filter has unity DC gain", "[m17][demodulator]") REQUIRE(std::abs(sum - 1.0f) < 1e-3f); } + +// --------------------------------------------------------------------------- +// End-to-end demodulator lock tests +// --------------------------------------------------------------------------- + +// Stream syncword symbols: 0xFF5D → dibits 11 11 11 11 01 01 11 01 +// Mapped to ±3/±1: +3→11, +1→01, -1→10, -3→00 → -3,-3,-3,-3,+3,+3,-3,+3 +static constexpr std::array STREAM_SW_SYMS = { + -3, -3, -3, -3, +3, +3, -3, +3 +}; + +// Generate an RRC-shaped baseband signal from a sequence of M17 symbols. +// Each symbol is placed at sample-rate position (one every SAMPLES_PER_SYM +// samples) with zero-fill in between, then shaped by the RRC transmit filter. +static std::vector rrcBaseband(const std::vector &symbols, + float amplitude = 2000.0f) +{ + static constexpr size_t SPS = SAMPLES_PER_SYM; + static constexpr size_t NTAPS = M17::rrc_taps_24k.size(); + + Fir txRrc(M17::rrc_taps_24k); + + std::vector out; + out.reserve(symbols.size() * SPS); + + for (size_t i = 0; i < symbols.size(); i++) { + // First sample of the symbol period carries the impulse + float imp = static_cast(symbols[i]) * amplitude; + out.push_back(static_cast(txRrc(imp) * SPS)); + + // Remaining SPS-1 samples are zero (interpolation) + for (size_t s = 1; s < SPS; s++) + out.push_back(static_cast(txRrc(0.0f) * SPS)); + } + + return out; +} + +// Build the symbol sequence for one complete M17 stream frame (192 symbols): +// 8 sync-word symbols + 184 payload symbols (alternating +1/-1 pattern). +static std::vector makeStreamFrame() +{ + std::vector syms(M17::FRAME_SYMBOLS); + for (size_t i = 0; i < STREAM_SW_SYMS.size(); i++) + syms[i] = STREAM_SW_SYMS[i]; + for (size_t i = STREAM_SW_SYMS.size(); i < syms.size(); i++) + syms[i] = (i % 2 == 0) ? +1 : -1; + return syms; +} + +TEST_CASE("Demodulator maintains lock across multiple consecutive stream frames", + "[m17][demodulator]") +{ + // --- Build the synthetic baseband signal --- + // Preamble: silence long enough to pass the INIT state (480 samples) + // plus RRC filter settling time. + static constexpr size_t PREAMBLE_SYMS = 200; // 200 * 5 = 1000 samples + static constexpr size_t NUM_FRAMES = 10; + + std::vector allSyms; + + // Preamble: alternating +3/-3 to build up correlator energy + for (size_t i = 0; i < PREAMBLE_SYMS; i++) + allSyms.push_back((i % 2 == 0) ? +3 : -3); + + // Concatenate NUM_FRAMES complete stream frames + auto oneFrame = makeStreamFrame(); + for (size_t f = 0; f < NUM_FRAMES; f++) + allSyms.insert(allSyms.end(), oneFrame.begin(), oneFrame.end()); + + // Trailing silence so the last frame can finish processing + for (size_t i = 0; i < PREAMBLE_SYMS; i++) + allSyms.push_back(0); + + std::vector baseband = rrcBaseband(allSyms); + + // --- Feed samples through the demodulator --- + M17::Demodulator demod; + demod.init(); + + bool everLocked = false; + size_t lockSample = 0; + bool lostLock = false; + size_t lostSample = 0; + + for (size_t i = 0; i < baseband.size(); i++) { + demod.sample(baseband[i]); + + if (!everLocked && demod.isLocked()) { + everLocked = true; + lockSample = i; + } + + // Once locked, the demodulator must stay locked for at least the + // duration of the remaining frames (until the signal ends). + // Allow a grace zone at the very end where the trailing silence + // causes a natural unlock (last ~3 frame-lengths). + size_t endGrace = baseband.size() + - 3 * M17::FRAME_SYMBOLS * SAMPLES_PER_SYM; + if (everLocked && !demod.isLocked() && i < endGrace) { + lostLock = true; + lostSample = i; + break; // No need to keep going + } + } + + INFO("Lock first acquired at sample " << lockSample); + REQUIRE(everLocked); + + INFO("Lock lost at sample " << lostSample << " (" + << (lostSample - lockSample) + << " samples after lock)"); + REQUIRE_FALSE(lostLock); +}