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 <ryan@turnrye.com>
This commit is contained in:
Ryan Turner 2026-03-21 12:20:04 -05:00 committed by Silvano Seva
parent 60e8b780df
commit 1b8a479390

View file

@ -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<int8_t, M17::SYNCWORD_SYMBOLS> 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<int16_t> rrcBaseband(const std::vector<int8_t> &symbols,
float amplitude = 2000.0f)
{
static constexpr size_t SPS = SAMPLES_PER_SYM;
static constexpr size_t NTAPS = M17::rrc_taps_24k.size();
Fir<NTAPS> txRrc(M17::rrc_taps_24k);
std::vector<int16_t> 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<float>(symbols[i]) * amplitude;
out.push_back(static_cast<int16_t>(txRrc(imp) * SPS));
// Remaining SPS-1 samples are zero (interpolation)
for (size_t s = 1; s < SPS; s++)
out.push_back(static_cast<int16_t>(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<int8_t> makeStreamFrame()
{
std::vector<int8_t> 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<int8_t> 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<int16_t> 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);
}