mirror of
https://github.com/f4exb/sdrangel.git
synced 2026-10-08 00:00:42 -04:00
Packet Demod: add native G3RUH 9600 receive mode
This commit is contained in:
@@ -14,6 +14,7 @@ set(demodpacket_HEADERS
|
||||
packetdemodsettings.h
|
||||
packetdemodbaseband.h
|
||||
packetdemodsink.h
|
||||
packetdemodg3ruh.h
|
||||
packetdemodcore.h
|
||||
packetdemodframer.h
|
||||
packetdemodtonecorrelator.h
|
||||
@@ -67,6 +68,21 @@ target_link_libraries(${TARGET_NAME} PRIVATE
|
||||
|
||||
install(TARGETS ${TARGET_NAME} DESTINATION ${INSTALL_FOLDER})
|
||||
|
||||
option(PACKETDEMOD_BUILD_TESTS "Build Packet Demod sink integration tests" OFF)
|
||||
if(PACKETDEMOD_BUILD_TESTS AND SERVER_MODE)
|
||||
add_executable(packetdemod_sink_test
|
||||
tests/test_sink.cpp
|
||||
packetdemodsink.cpp
|
||||
packetdemodsettings.cpp)
|
||||
target_include_directories(packetdemod_sink_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(packetdemod_sink_test PRIVATE Qt::Core sdrbase ${TARGET_NAME})
|
||||
option(PACKETDEMOD_TEST_SANITIZERS "Sanitize the Packet Demod sink test" OFF)
|
||||
if(PACKETDEMOD_TEST_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(packetdemod_sink_test PRIVATE -fsanitize=address,undefined,float-cast-overflow -fno-omit-frame-pointer)
|
||||
target_link_options(packetdemod_sink_test PRIVATE -fsanitize=address,undefined,float-cast-overflow)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install debug symbols
|
||||
if (WIN32)
|
||||
install(FILES $<TARGET_PROPERTY:${TARGET_NAME},RUNTIME_OUTPUT_DIRECTORY>/${TARGET_NAME}stripped.pdb CONFIGURATIONS Release DESTINATION ${INSTALL_FOLDER} RENAME ${TARGET_NAME}.pdb )
|
||||
|
||||
@@ -387,7 +387,12 @@ int PacketDemod::webapiSettingsPutPatch(
|
||||
SWGSDRangel::SWGChannelSettings& response,
|
||||
QString& errorMessage)
|
||||
{
|
||||
(void) errorMessage;
|
||||
if (channelSettingsKeys.contains("mode")
|
||||
&& !PacketDemodSettings::isValidMode(response.getPacketDemodSettings()->getMode()))
|
||||
{
|
||||
errorMessage = "mode must be 0 (1200 AFSK) or 1 (9600 G3RUH)";
|
||||
return 400;
|
||||
}
|
||||
PacketDemodSettings settings = m_settings;
|
||||
webapiUpdateChannelSettings(settings, channelSettingsKeys, response);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (C) 2026 SDRangel contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Peak/valley tracking and transition-directed clock recovery follow the approach
|
||||
// in Dire Wolf's demod_9600.c, Copyright (C) 2011, 2012, 2013, 2015, 2019,
|
||||
// 2021 John Langner, WB2OSZ (GPL-2.0-or-later). This implementation uses a bounded
|
||||
// floating-point phase and interpolates both crossings and symbol samples.
|
||||
|
||||
#ifndef INCLUDE_PACKETDEMODG3RUH_H
|
||||
#define INCLUDE_PACKETDEMODG3RUH_H
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "dsp/firfilter.h"
|
||||
|
||||
// FM discriminator samples in; descrambled NRZI levels out. HDLC/NRZI decoding
|
||||
// remains in PacketDemodFramer. No allocations in process(), no per-frame reset:
|
||||
// the G3RUH scrambler also runs during flags and synchronizes after 17 symbols.
|
||||
class PacketDemodG3RUH
|
||||
{
|
||||
public:
|
||||
static constexpr int SampleRate = 38400;
|
||||
static constexpr int BaudRate = 9600;
|
||||
|
||||
PacketDemodG3RUH() { reset(); }
|
||||
|
||||
void reset()
|
||||
{
|
||||
m_lowpass.create(31, SampleRate, 6500.0f);
|
||||
m_peak = 0.0f;
|
||||
m_valley = 0.0f;
|
||||
m_previous = 0.0f;
|
||||
m_phase = 0.0f;
|
||||
m_history = 0;
|
||||
}
|
||||
|
||||
bool process(float sample, int& symbol)
|
||||
{
|
||||
const float filtered = m_lowpass.filter(sample);
|
||||
// The midpoint tracks discriminator DC (residual carrier offset); the
|
||||
// span normalizes deviation, so no separate FM deviation setting is needed.
|
||||
// Decay over about 26 ms: RF-filter startup transients must not bias the
|
||||
// slicer for the remainder of a short packet preamble.
|
||||
m_peak += (filtered > m_peak ? 0.08f : 0.001f) * (filtered - m_peak);
|
||||
m_valley += (filtered < m_valley ? 0.08f : 0.001f) * (filtered - m_valley);
|
||||
const float span = std::max(m_peak - m_valley, 1.0e-6f);
|
||||
const float value = (2.0f * filtered - m_peak - m_valley) / span;
|
||||
|
||||
constexpr float step = static_cast<float>(BaudRate) / SampleRate;
|
||||
m_phase += step;
|
||||
const bool ready = m_phase >= 0.5f;
|
||||
|
||||
if (ready)
|
||||
{
|
||||
// Sample the eye center, including when it lies between input samples.
|
||||
const float fraction = (m_phase - 0.5f) / step;
|
||||
const float decision = value + fraction * (m_previous - value);
|
||||
const uint32_t received = decision >= 0.0f ? 1u : 0u;
|
||||
symbol = (received ^ (m_history >> 11) ^ (m_history >> 16)) & 1u;
|
||||
// Feed back RECEIVED scrambled levels, not descrambler output.
|
||||
m_history = ((m_history << 1) | received) & 0x1ffffu;
|
||||
m_phase -= 1.0f;
|
||||
}
|
||||
|
||||
if ((value > 0.0f && m_previous < 0.0f)
|
||||
|| (value < 0.0f && m_previous > 0.0f))
|
||||
{
|
||||
// Phase zero is a symbol boundary. Interpolate the crossing so the
|
||||
// four-sample clock does not simply lock to the next input sample.
|
||||
const float target = step * value / (value - m_previous);
|
||||
m_phase += 0.2f * (target - m_phase);
|
||||
}
|
||||
|
||||
m_previous = value;
|
||||
return ready;
|
||||
}
|
||||
|
||||
private:
|
||||
Lowpass<Real> m_lowpass;
|
||||
float m_peak;
|
||||
float m_valley;
|
||||
float m_previous;
|
||||
float m_phase;
|
||||
uint32_t m_history;
|
||||
};
|
||||
|
||||
#endif // INCLUDE_PACKETDEMODG3RUH_H
|
||||
@@ -17,6 +17,7 @@
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#include <QDebug>
|
||||
#include <QSignalBlocker>
|
||||
#include <QMessageBox>
|
||||
#include <QAction>
|
||||
#include <QRegularExpression>
|
||||
@@ -265,9 +266,13 @@ void PacketDemodGUI::on_deltaFrequency_changed(qint64 value)
|
||||
|
||||
void PacketDemodGUI::on_mode_currentIndexChanged(int value)
|
||||
{
|
||||
(void) value;
|
||||
QString mode = ui->mode->currentText();
|
||||
// TODO: Support 9600 FSK
|
||||
if (!PacketDemodSettings::isValidMode(value)) {
|
||||
return;
|
||||
}
|
||||
m_settings.m_mode = static_cast<PacketDemodSettings::Mode>(value);
|
||||
m_settings.m_rfBandwidth = m_settings.isAFSK() ? 12500.0f : 20000.0f;
|
||||
displaySettings();
|
||||
applySettings(QStringList({"mode", "rfBandwidth"}));
|
||||
}
|
||||
|
||||
void PacketDemodGUI::on_rfBW_valueChanged(int value)
|
||||
@@ -574,6 +579,10 @@ void PacketDemodGUI::displaySettings()
|
||||
ui->rfBW->setValue(m_settings.m_rfBandwidth / 100.0);
|
||||
|
||||
|
||||
const QSignalBlocker modeBlocker(ui->mode);
|
||||
ui->mode->setCurrentIndex(static_cast<int>(m_settings.m_mode));
|
||||
ui->mlse->setEnabled(m_settings.isAFSK());
|
||||
ui->chase->setEnabled(m_settings.isAFSK());
|
||||
ui->mlse->setChecked(m_settings.m_mlse);
|
||||
ui->chase->setValue(m_settings.m_chase);
|
||||
|
||||
|
||||
@@ -231,6 +231,11 @@
|
||||
<string>1200 AFSK</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>9600 G3RUH</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
@@ -107,6 +107,7 @@ QByteArray PacketDemodSettings::serialize() const
|
||||
s.writeBool(31, m_useFileTime);
|
||||
s.writeS32(32, m_chase);
|
||||
s.writeBool(33, m_mlse);
|
||||
s.writeS32(41, static_cast<int>(m_mode));
|
||||
|
||||
for (int i = 0; i < PACKETDEMOD_COLUMNS; i++) {
|
||||
s.writeS32(100 + i, m_columnIndexes[i]);
|
||||
@@ -192,6 +193,9 @@ bool PacketDemodSettings::deserialize(const QByteArray& data)
|
||||
d.readBool(31, &m_useFileTime, false);
|
||||
d.readS32(32, &m_chase, 6);
|
||||
d.readBool(33, &m_mlse, true);
|
||||
qint32 mode;
|
||||
d.readS32(41, &mode, ModeAFSK1200);
|
||||
m_mode = isValidMode(mode) ? static_cast<Mode>(mode) : ModeAFSK1200;
|
||||
|
||||
for (int i = 0; i < PACKETDEMOD_COLUMNS; i++) {
|
||||
d.readS32(100 + i, &m_columnIndexes[i], i);
|
||||
@@ -381,5 +385,5 @@ QString PacketDemodSettings::getDebugString(const QStringList& settingsKeys, boo
|
||||
|
||||
int PacketDemodSettings::getBaudRate() const
|
||||
{
|
||||
return 1200;
|
||||
return m_mode == ModeG3RUH9600 ? 9600 : 1200;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ class Serializable;
|
||||
struct PacketDemodSettings
|
||||
{
|
||||
enum Mode {
|
||||
ModeAFSK1200
|
||||
ModeAFSK1200 = 0,
|
||||
ModeG3RUH9600 = 1
|
||||
};
|
||||
|
||||
qint32 m_inputFrequencyOffset;
|
||||
@@ -132,6 +133,9 @@ struct PacketDemodSettings
|
||||
void applySettings(const QStringList& settingsKeys, const PacketDemodSettings& settings);
|
||||
QString getDebugString(const QStringList& settingsKeys, bool force=false) const;
|
||||
int getBaudRate() const;
|
||||
bool isAFSK() const { return m_mode == ModeAFSK1200; }
|
||||
bool isMLSEEnabled() const { return isAFSK() && m_mlse; }
|
||||
static bool isValidMode(int mode) { return mode == ModeAFSK1200 || mode == ModeG3RUH9600; }
|
||||
};
|
||||
|
||||
#endif /* INCLUDE_PACKETDEMODSETTINGS_H */
|
||||
|
||||
@@ -116,7 +116,16 @@ void PacketDemodSink::processOneSample(Complex &ci)
|
||||
}
|
||||
m_magsqCount++;
|
||||
|
||||
if (m_settings.m_mlse)
|
||||
if (!m_settings.isAFSK())
|
||||
{
|
||||
int symbol;
|
||||
if (m_g3ruh.process(fmDemod, symbol)) {
|
||||
// Descrambling spreads a radio-symbol error to positions n, n+12,
|
||||
// n+17. AFSK Chase cannot replay that error model.
|
||||
m_framer.process(m_deframer, symbol, symbol ? 1.0f : -1.0f, false);
|
||||
}
|
||||
}
|
||||
else if (m_settings.isMLSEEnabled())
|
||||
{
|
||||
// Detect on the complex baseband. The discriminator output is still produced
|
||||
// above; the symbol rate estimator decodes its tone transitions to keep the
|
||||
@@ -157,7 +166,10 @@ void PacketDemodSink::processOneSample(Complex &ci)
|
||||
}
|
||||
}
|
||||
|
||||
m_demodBuffer[m_demodBufferFill++] = fmDemod * std::numeric_limits<int16_t>::max();
|
||||
// G3RUH deviation and residual carrier offset can exceed the analyzer's
|
||||
// normalized range. Saturate the trace before narrowing; leave DSP untouched.
|
||||
const Real trace = std::max(-1.0f, std::min(1.0f, fmDemod));
|
||||
m_demodBuffer[m_demodBufferFill++] = trace * std::numeric_limits<int16_t>::max();
|
||||
|
||||
if (m_demodBufferFill >= m_demodBuffer.size())
|
||||
{
|
||||
@@ -188,7 +200,7 @@ bool PacketDemodSink::sendPacket(const QByteArray& packet, quint64 stamp)
|
||||
// same transmission, a symbol period or so apart. Report it once. Genuine retransmissions
|
||||
// are seconds to minutes apart, and a digipeated repeat has the via path marked, so it
|
||||
// does not compare equal.
|
||||
if (m_settings.m_mlse)
|
||||
if (m_settings.isMLSEEnabled())
|
||||
{
|
||||
const quint64 rate = PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE;
|
||||
|
||||
@@ -268,7 +280,7 @@ static PacketDemodCore::Config coreConfig(const PacketDemodSettings& settings)
|
||||
{
|
||||
PacketDemodCore::Config cfg;
|
||||
cfg.m_chase = settings.m_chase;
|
||||
cfg.m_mlse = settings.m_mlse;
|
||||
cfg.m_mlse = settings.isMLSEEnabled();
|
||||
cfg.m_baudRate = settings.getBaudRate();
|
||||
|
||||
return cfg;
|
||||
@@ -293,38 +305,43 @@ void PacketDemodSink::applySettings(const QStringList& settingsKeys, const Packe
|
||||
m_core.setChaseDepth(settings.m_chase);
|
||||
}
|
||||
|
||||
if (settingsKeys.contains("mlse") || force) {
|
||||
m_framer.setRequirePlausible(settings.m_mlse);
|
||||
PacketDemodSettings next = m_settings;
|
||||
if (force) {
|
||||
next = settings;
|
||||
} else {
|
||||
next.applySettings(settingsKeys, settings);
|
||||
}
|
||||
|
||||
if (force)
|
||||
const bool rebuild = force || next.m_mode != m_settings.m_mode
|
||||
|| next.isMLSEEnabled() != m_settings.isMLSEEnabled();
|
||||
m_settings = next;
|
||||
m_framer.setRequirePlausible(m_settings.isMLSEEnabled());
|
||||
|
||||
if (rebuild)
|
||||
{
|
||||
// Deviation is a constant now, so the discriminator scaling is set once
|
||||
static_assert(PacketDemodG3RUH::SampleRate == PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE,
|
||||
"G3RUH clock must match the channel rate");
|
||||
// Amplitude normalization in G3RUH makes the existing scaling sufficient.
|
||||
m_phaseDiscri.setFMScaling(PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE
|
||||
/ (2.0f * PacketDemodSettings::PACKETDEMOD_FM_DEVIATION));
|
||||
m_phaseDiscri.reset();
|
||||
|
||||
m_correlationLength = PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE/settings.getBaudRate();
|
||||
// Always build the AFSK path for 1200; it must also work after returning
|
||||
// from 9600 without a forced settings update.
|
||||
m_correlationLength = PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE / 1200;
|
||||
m_correlator.create(m_correlationLength,
|
||||
PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE,
|
||||
2200.0, 1200.0);
|
||||
|
||||
m_lowpassF1.create(PACKETDEMOD_LOWPASS_TAPS, PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE, settings.getBaudRate() * 1.1f);
|
||||
m_lowpassF0.create(PACKETDEMOD_LOWPASS_TAPS, PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE, settings.getBaudRate() * 1.1f);
|
||||
PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE, 2200.0, 1200.0);
|
||||
m_lowpassF1.create(PACKETDEMOD_LOWPASS_TAPS,
|
||||
PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE, 1200.0f * 1.1f);
|
||||
m_lowpassF0.create(PACKETDEMOD_LOWPASS_TAPS,
|
||||
PacketDemodSettings::PACKETDEMOD_CHANNEL_SAMPLE_RATE, 1200.0f * 1.1f);
|
||||
m_g3ruh.reset();
|
||||
m_deframer.reset();
|
||||
m_samplePrev = 0;
|
||||
m_syncCount = 0;
|
||||
m_settings = settings;
|
||||
m_core.applyConfig(coreConfig(settings));
|
||||
}
|
||||
else
|
||||
{
|
||||
bool rebuild = settingsKeys.contains("mlse")
|
||||
|| settingsKeys.contains("mode");
|
||||
|
||||
m_settings.applySettings(settingsKeys, settings);
|
||||
|
||||
if (rebuild) {
|
||||
m_core.applyConfig(coreConfig(m_settings));
|
||||
}
|
||||
m_recent.clear();
|
||||
// isMLSEEnabled() releases the AFSK detector's chains/replay storage in
|
||||
// G3RUH mode even if an API client leaves the saved MLSE preference true.
|
||||
m_core.applyConfig(coreConfig(m_settings));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include "packetdemodframer.h"
|
||||
#include "packetdemodtonecorrelator.h"
|
||||
#include "packetdemodcore.h"
|
||||
#include "packetdemodg3ruh.h"
|
||||
|
||||
// Post correlation filter length. The envelope being filtered changes at the baud rate, so
|
||||
// this only has to be long enough to smooth it; 301 taps per tone is 602 multiply
|
||||
@@ -134,6 +135,7 @@ private:
|
||||
|
||||
// The detector, estimator and replay live in PacketDemodCore, shared verbatim with
|
||||
// the offline test harness rather than mirrored by it.
|
||||
PacketDemodG3RUH m_g3ruh;
|
||||
PacketDemodCore m_core;
|
||||
std::deque<std::pair<QByteArray, quint64>> m_recent; // Recently reported, to deduplicate
|
||||
|
||||
|
||||
@@ -47,7 +47,12 @@ int PacketDemodWebAPIAdapter::webapiSettingsPutPatch(
|
||||
QString& errorMessage)
|
||||
{
|
||||
(void) force;
|
||||
(void) errorMessage;
|
||||
if (channelSettingsKeys.contains("mode")
|
||||
&& !PacketDemodSettings::isValidMode(response.getPacketDemodSettings()->getMode()))
|
||||
{
|
||||
errorMessage = "mode must be 0 (1200 AFSK) or 1 (9600 G3RUH)";
|
||||
return 400;
|
||||
}
|
||||
PacketDemod::webapiUpdateChannelSettings(m_settings, channelSettingsKeys, response);
|
||||
|
||||
return 200;
|
||||
|
||||
@@ -26,7 +26,9 @@ Average total power in dB relative to a +/- 1.0 amplitude signal received in the
|
||||
|
||||
<h3>4: Modulation</h3>
|
||||
|
||||
This specifies the baud rate and modulation that is used for the packet transmission. Currently 1200 baud AFSK is supported.
|
||||
This specifies the baud rate and modulation that is used for the packet transmission. Supported modes are 1200 baud AFSK and 9600 baud G3RUH scrambled FSK (AX.25). The latter descrambles with `1 + x^12 + x^17` before NRZI decoding. It does not decode arbitrary 9600-baud satellite telemetry or protocols with different framing/scrambling.
|
||||
|
||||
Selecting a mode in the GUI sets the recommended RF bandwidth (12.5 kHz for AFSK, 20 kHz for G3RUH). Adjust this for the transmitter and residual carrier offset. REST clients select `mode: 1` and should also set `rfBandwidth: 20000`; changing the mode through REST preserves the explicitly configured bandwidth. Old presets without a mode load as 1200 AFSK.
|
||||
|
||||
<h3>5: RF Bandwidth</h3>
|
||||
|
||||
@@ -34,6 +36,8 @@ This specifies the bandwidth of a LPF that is applied to the input signal to lim
|
||||
|
||||
<h3>6: MLSE - Maximum likelihood sequence estimation</h3>
|
||||
|
||||
Available in 1200 AFSK mode only. Its saved preference is retained while using 9600 G3RUH.
|
||||
|
||||
Detects on the complex baseband instead of running the tone correlators on the output of an FM discriminator. Worth about 9.5 dB of sensitivity. Enabled by default; unchecking it reverts to the standard demodulator.
|
||||
|
||||
A discriminator takes the argument of every sample, and below roughly 8 dB carrier to noise in the channel it starts producing phase slips, at which point its output degrades far faster than its input does - the FM threshold effect. Nothing downstream can recover what has already been destroyed, which is why the standard demodulator falls off a cliff rather than degrading gracefully.
|
||||
@@ -69,6 +73,8 @@ Fading remains hard for it, as it is for any coherent detector. The branch metri
|
||||
|
||||
<h3>7: Chase - Chase decoding depth</h3>
|
||||
|
||||
Available in 1200 AFSK mode only. G3RUH descrambling spreads each erroneous radio symbol to three descrambled symbols; the existing independent-symbol Chase search does not model this. G3RUH uses normal CRC validation with no Chase retries.
|
||||
|
||||
When a frame reaches a closing flag but fails its CRC, it is usually only a few marginal symbol decisions away from being correct. The demodulator keeps how confident each decision was, and on a CRC failure retries the frame with the least confident symbols inverted, accepting the result if the CRC then passes.
|
||||
|
||||
Because AX.25 has no forward error correction and a 16-bit CRC lets roughly 1 in 65536 wrong frames through, recovered frames are additionally required to have valid callsigns - 1 to 6 uppercase alphanumeric characters - which random data essentially never satisfies. This check is applied only to frames recovered this way, never to frames that decode normally.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(packet_g3ruh_tests LANGUAGES CXX)
|
||||
enable_testing()
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Gui)
|
||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui)
|
||||
get_filename_component(SDRANGEL_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../.." ABSOLUTE)
|
||||
add_executable(packet_g3ruh_test
|
||||
test_g3ruh.cpp
|
||||
../packetdemodsettings.cpp
|
||||
${SDRANGEL_ROOT}/sdrbase/dsp/firfilter.cpp
|
||||
${SDRANGEL_ROOT}/sdrbase/util/ax25.cpp
|
||||
${SDRANGEL_ROOT}/sdrbase/util/crc.cpp
|
||||
${SDRANGEL_ROOT}/sdrbase/util/lfsr.cpp
|
||||
${SDRANGEL_ROOT}/sdrbase/util/simpleserializer.cpp)
|
||||
target_include_directories(packet_g3ruh_test PRIVATE .. ${SDRANGEL_ROOT}/exports ${SDRANGEL_ROOT}/sdrbase)
|
||||
target_link_libraries(packet_g3ruh_test PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui)
|
||||
option(PACKET_TEST_SANITIZERS "Enable address and undefined behaviour sanitizers" OFF)
|
||||
if(PACKET_TEST_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(packet_g3ruh_test PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
|
||||
target_link_options(packet_g3ruh_test PRIVATE -fsanitize=address,undefined)
|
||||
endif()
|
||||
add_test(NAME packet_g3ruh COMMAND packet_g3ruh_test)
|
||||
@@ -0,0 +1,89 @@
|
||||
# Packet Demod regression tests
|
||||
|
||||
The tests compile the production detector, framer, CRC, settings, and (for the
|
||||
integration target) sink. They do not substitute a second receive implementation.
|
||||
The synthetic transmitter uses independent HDLC stuffing and bitwise CRC, plus
|
||||
SDRangel's existing Packet Modulator LFSR. Waveform shaping is an analytic
|
||||
Gaussian integral, independent of the receive filter and clock recovery.
|
||||
|
||||
## DSP, framing and settings
|
||||
|
||||
From the repository root, with CMake, a C++17 compiler and Qt Core/Gui development
|
||||
packages (Qt 5 or Qt 6):
|
||||
|
||||
```sh
|
||||
cmake -S plugins/channelrx/demodpacket/tests -B build-packet-tests \
|
||||
-DCMAKE_BUILD_TYPE=Debug -DPACKET_TEST_SANITIZERS=ON
|
||||
cmake --build build-packet-tests --parallel 2
|
||||
UBSAN_OPTIONS=halt_on_error=1 ctest --test-dir build-packet-tests --output-on-failure
|
||||
```
|
||||
|
||||
Coverage: 36 cases, each comparing 40 complete frames including FCS. Rectangular
|
||||
and BT=0.5 Gaussian symbols, both polarities, five fractional starting phases,
|
||||
clock errors through +/-1000 ppm, carrier offsets through +/-1500 Hz with 20 Hz/s
|
||||
drift, four deviations from 1.5 to 4.5 kHz, and deterministic noise (sigma=0.03 per
|
||||
unit-amplitude I/Q component). These are correctness cases, not a sensitivity or
|
||||
packet-error-rate benchmark. Also checks reset/reacquisition, shared flags,
|
||||
stuffing-heavy/binary payloads, bad CRC rejection, wrong-mode rejection, old and
|
||||
invalid presets, idle framing, and AFSK framing/Chase recovery.
|
||||
|
||||
## Full sink and plugin integration
|
||||
|
||||
Use the normal SDRangel build dependencies. This builds the server plugin and a
|
||||
test which drives its actual `feed()` path, including NCO, RF filtering,
|
||||
resampling, FM discrimination and the packet message queue:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build-packet-server -DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_GUI=OFF -DBUILD_SERVER=ON -DBUILD_BENCH=OFF \
|
||||
-DENABLE_EXTERNAL_LIBRARIES=OFF -DENABLE_LIBUNWIND=OFF \
|
||||
-DENABLE_FEATURE=OFF -DENABLE_CHANNELTX=OFF -DENABLE_CHANNELMIMO=OFF \
|
||||
-DPACKETDEMOD_BUILD_TESTS=ON -DPACKETDEMOD_TEST_SANITIZERS=ON
|
||||
cmake --build build-packet-server --target demodpacketsrv packetdemod_sink_test --parallel 2
|
||||
UBSAN_OPTIONS=halt_on_error=1 ./build-packet-server/packetdemod_sink_test
|
||||
```
|
||||
|
||||
Eight cases compare ten frames each at input rates 32, 38.4, 48 and 96 kHz,
|
||||
residual offsets +/-1 kHz, and 250 ppm clock error. The same sink then switches
|
||||
to both AFSK correlator and MLSE reception (three frames each), and back to
|
||||
G3RUH (ten frames each), using partial settings updates. The AFSK test includes
|
||||
noise before/after its burst because the MLSE activity gate needs a noise-floor
|
||||
measurement. API adapter checks exercise valid modes, rejection of invalid
|
||||
modes without mutation, and preservation of RF bandwidth on mode-only updates.
|
||||
|
||||
Sanitizers instrument the test and its compiled sink/settings sources; linked
|
||||
SDRangel/Qt libraries use the normal build flags. In containers where LeakSanitizer
|
||||
cannot enumerate processes, set `ASAN_OPTIONS=detect_leaks=0`. This leaves address,
|
||||
undefined-behaviour and float-conversion checks enabled, but does not test leaks.
|
||||
|
||||
## Independent modem interoperability
|
||||
|
||||
With Dire Wolf `gen_packets` and `atest` on PATH:
|
||||
|
||||
```sh
|
||||
python3 plugins/channelrx/demodpacket/tests/verify_direwolf.py \
|
||||
build-packet-tests/packet_g3ruh_test
|
||||
```
|
||||
|
||||
This generates G3RUH audio with Dire Wolf and compares all four decoded packets
|
||||
against `atest`, byte for byte. Dire Wolf is only a test oracle, not a plugin
|
||||
dependency. Tested locally with Dire Wolf 1.7.
|
||||
|
||||
## Public IQ capture
|
||||
|
||||
The [cubehub sample](https://github.com/cubehub/samples/blob/0f4ecdf503d52fe9d9a17610c8eef04ac6e97033/ax25_fsk9600_1024k_i16.wav)
|
||||
is a 1.024 Msps int16 IQ recording, identified as ESTCube-1 in the
|
||||
[original discussion](https://github.com/jgaeddert/liquid-dsp/issues/9).
|
||||
Download that file separately; it is not redistributed in this PR. The preparation
|
||||
script requires NumPy and SciPy and verifies the original file's SHA-256.
|
||||
|
||||
```sh
|
||||
python3 plugins/channelrx/demodpacket/tests/replay_estcube.py \
|
||||
/path/to/ax25_fsk9600_1024k_i16.wav build-packet-server/packetdemod_sink_test
|
||||
```
|
||||
|
||||
The script applies a fixed -14 kHz translation, anti-aliased 3/80 resampling and
|
||||
fixed gain, then passes IQ through the production sink. Expected result: four
|
||||
237-byte AX.25 frames, including valid FCS, matching `estcube-expected.hex`.
|
||||
One short capture is an interoperability regression fixture, not evidence of
|
||||
performance across all satellites, weak-signal conditions or receiver hardware.
|
||||
@@ -0,0 +1,4 @@
|
||||
8aa66a8a8640628aa66a8a40407703f0020600d7022c20d301000b30880100c800000030d97325b6335dc381ed355b33058b2e17e2d5772dd7a2e9852dd892ed912f5862ed9c319ac331ad2e16c29d5a2a1642d5722d57c2ad692d1833059931da8329b2329b4319a931d9e2f9872f9922d5612c9782d9802c1602994928d4b2a550281422bd9930d9b2f9832fd832cd6d2a54f28d582b997329ab309962d1822d9702b96e2b56f2b1712c97b2c1602a94b28547275362793f275372693f281482794c2854329d632b14e2a16228d4c2855e285452714c27d2625d352653b269432654a27d3b27124239350f72
|
||||
8aa66a8a8640628aa66a8a40407703f0020600d7022c20d301000bf8880100c80000002553f2493a25d3223523204fa21d0b239222593325526241282553523d2425120251172291721d0b221062190d2311822d2523517235112250b2250022916219242390f2210921d0b214f8204f1204fd2050c21d12231252452e2514727d4027d47261352551f2393b2551e2615a2995a2b97a2e5a02f1a633dc1305792b970285392612c265502612e251272452522128231182311321d17221102251021914221021fcf71f4f11f0ef200f01f0f51f4f51e8de1e8f12151922119235362754e289382411523932d0bd
|
||||
8aa66a8a8640628aa66a8a40407703f0020600d7022c20d301000bc0890100c80000002250e211011f8ff208fe201052110620905209002010a2150f2151321116211172311f24d2624d362412a24d212392c2311b2250f21115219021ecef1f0f71e4de1c8db1c0d01b0d91c4e01d8e11c0da1c0d01acc91b8cc1b0d11c4de1e4f61f11722125c43ffc27ffc33ffbcfffbf3ffb93ffbc7ffb63ffb57ffb1bff9f7e2a57ca983e79a3e9a6ffca43f3a0fff9ffffa17a49b3ffaebfba07c39afc69d3e49e7eba2bf2a23eba0ff29b7d0997d198bb394ba5913a79438591b928f7cf983ffa2bffa1fffa37ff77ac
|
||||
8aa66a8a8640628aa66a8a40407703f0020600d7022c20d301000b888a0100c8000000a13f3a0fffa8bffa3bffa5fffa3bffa07ff9f7e19c7df99bd8957ae94fc3957ae93be196fc7947a18c3638273a7d307786f079f2b7df5c8435c83b52887768977f8874d8573b7db0d7c2fc79f137bb3e8073b7fb397e3227b6fa70acb6c2c268ead68e9867a8a67eb8706e073f0073703732e971ace70ad66d6b2672a3686af6e2cb6e6e874ef873ec96f2c97130673ef87d74a83b307a729783027930a77ef271eeb73acd6e2b26ba8d62aac626665aa4f5be7361a8d69ab5676ba6c2d1702cb68e8e6568e61e683e8b
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (C) 2026 SDRangel contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "dsp/dsptypes.h"
|
||||
#include "util/lfsr.h"
|
||||
|
||||
namespace PacketTest {
|
||||
constexpr double pi = 3.14159265358979323846;
|
||||
void require(bool ok, const char* message)
|
||||
{
|
||||
if (!ok) { throw std::runtime_error(message); }
|
||||
}
|
||||
|
||||
// Independent bit-at-a-time CRC-16/X.25 oracle (not the production CRC table).
|
||||
QByteArray frame(int index, int length)
|
||||
{
|
||||
QByteArray bytes;
|
||||
for (const char c : std::string("APRS ")) { bytes.append(char(c << 1)); }
|
||||
bytes.append(char(0x60));
|
||||
for (const char c : std::string("N0CALL")) { bytes.append(char(c << 1)); }
|
||||
bytes.append(char(0x61));
|
||||
bytes.append(char(0x03));
|
||||
bytes.append(char(0xf0));
|
||||
std::mt19937 rng(1234 + index);
|
||||
for (int i = 0; i < length; ++i) {
|
||||
bytes.append(char(i % 7 == 0 ? 0xff : rng() & 0xff));
|
||||
}
|
||||
uint16_t crc = 0xffff;
|
||||
for (const unsigned char b : bytes) {
|
||||
crc ^= b;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
crc = (crc >> 1) ^ ((crc & 1) ? 0x8408 : 0);
|
||||
}
|
||||
}
|
||||
crc ^= 0xffff;
|
||||
bytes.append(char(crc & 0xff));
|
||||
bytes.append(char(crc >> 8));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::vector<int> hdlc(const std::vector<QByteArray>& frames)
|
||||
{
|
||||
std::vector<int> bits;
|
||||
auto flag = [&]() { for (int j = 0; j < 8; ++j) { bits.push_back((0x7e >> j) & 1); } };
|
||||
for (int j = 0; j < 64; ++j) { flag(); }
|
||||
for (const auto& bytes : frames)
|
||||
{
|
||||
int ones = 0;
|
||||
for (const unsigned char b : bytes) {
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
const int bit = (b >> j) & 1;
|
||||
bits.push_back(bit);
|
||||
ones = bit ? ones + 1 : 0;
|
||||
if (ones == 5) { bits.push_back(0); ones = 0; }
|
||||
}
|
||||
}
|
||||
flag(); // shared closing/opening flag, no reset of scrambler/NRZI
|
||||
}
|
||||
for (int j = 0; j < 16; ++j) { flag(); }
|
||||
return bits;
|
||||
}
|
||||
|
||||
std::vector<int> radioSymbols(const std::vector<int>& bits, bool scrambled)
|
||||
{
|
||||
LFSR tx(0x10800, 0); // The actual SDRangel Packet Modulator scrambler.
|
||||
int level = 0;
|
||||
std::vector<int> symbols;
|
||||
for (const int bit : bits) {
|
||||
level ^= !bit;
|
||||
symbols.push_back(scrambled ? tx.scramble(level) : level);
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
struct Channel
|
||||
{
|
||||
double sampleRate = 38400.0;
|
||||
double timing = 0.0; // sample offset, includes fractions of a sample
|
||||
double ppm = 0.0;
|
||||
double offset = 0.0; // Hz
|
||||
double drift = 0.0; // Hz/second
|
||||
double deviation = 3000.0;
|
||||
double noise = 0.0; // standard deviation per I/Q component at unit amplitude
|
||||
bool inverted = false;
|
||||
bool gaussian = true;
|
||||
};
|
||||
|
||||
// Sample an independently generated Gaussian-filtered NRZ waveform at an
|
||||
// arbitrary clock. Integrate the Gaussian over each symbol (BT=0.5). This does
|
||||
// not reuse the receive FIR or PLL and can reveal clock/ordering errors.
|
||||
std::vector<Complex> waveform(const std::vector<int>& symbols, const Channel& c)
|
||||
{
|
||||
const double samplesPerSymbol = (c.sampleRate / 9600.0) / (1.0 + c.ppm * 1e-6);
|
||||
const double a = std::sqrt(2.0) * pi * 0.5 / std::sqrt(std::log(2.0));
|
||||
const size_t count = static_cast<size_t>(symbols.size() * samplesPerSymbol);
|
||||
std::vector<Complex> iq;
|
||||
iq.reserve(count);
|
||||
std::mt19937 rng(0x9600);
|
||||
std::normal_distribution<float> normal(0.0f, 1.0f);
|
||||
double phase = 0.4;
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
const double t = (i + c.timing) / samplesPerSymbol;
|
||||
const int center = static_cast<int>(std::floor(t));
|
||||
double value = 0.0;
|
||||
if (c.gaussian) {
|
||||
for (int j = center - 3; j <= center + 3; ++j) {
|
||||
const int p = std::max(0, std::min(j, static_cast<int>(symbols.size()) - 1));
|
||||
value += (symbols[p] ? 1.0 : -1.0)
|
||||
* 0.5 * (std::erf(a * (t - j)) - std::erf(a * (t - j - 1)));
|
||||
}
|
||||
} else {
|
||||
const int p = std::max(0, std::min(center, static_cast<int>(symbols.size()) - 1));
|
||||
value = symbols[p] ? 1.0 : -1.0;
|
||||
}
|
||||
phase += 2.0 * pi * (c.offset + c.drift * i / c.sampleRate
|
||||
+ (c.inverted ? -1.0 : 1.0) * c.deviation * value) / c.sampleRate;
|
||||
iq.emplace_back(std::cos(phase) + c.noise * normal(rng),
|
||||
std::sin(phase) + c.noise * normal(rng));
|
||||
}
|
||||
return iq;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replay the public cubehub capture through the production PacketDemodSink.
|
||||
|
||||
Requires numpy/scipy for capture preparation only; the plugin has no new deps.
|
||||
Download separately (not redistributed here):
|
||||
https://raw.githubusercontent.com/cubehub/samples/0f4ecdf503d52fe9d9a17610c8eef04ac6e97033/ax25_fsk9600_1024k_i16.wav
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("capture", type=Path)
|
||||
parser.add_argument("sink_test", type=Path)
|
||||
args = parser.parse_args()
|
||||
digest = hashlib.sha256(args.capture.read_bytes()).hexdigest()
|
||||
expected_digest = "a74fd9ba869d02b4668c84bae3de6a1b096c960613823bc24f6fd04416b6cdb8"
|
||||
if digest != expected_digest:
|
||||
raise SystemExit("Capture SHA-256 does not match the pinned fixture")
|
||||
rate, samples = wavfile.read(args.capture)
|
||||
if rate != 1024000 or samples.ndim != 2 or samples.shape[1] != 2 or samples.dtype != np.int16:
|
||||
raise SystemExit("Unexpected capture format")
|
||||
iq = (samples[:, 0] + 1j * samples[:, 1]) / 32768.0
|
||||
# The four bursts are centered near +14 kHz. Apply one fixed tuning correction,
|
||||
# then an anti-aliased rational resample. No symbol decisions or frame gating.
|
||||
iq *= np.exp(-2j * np.pi * 14000 * np.arange(len(iq)) / rate)
|
||||
iq = resample_poly(iq, 3, 80)
|
||||
# Fixed gain uses the integer sink's dynamic range without clipping.
|
||||
packed = (np.column_stack((iq.real, iq.imag)) * 100).astype("<f4")
|
||||
if np.max(np.abs(packed)) >= 1:
|
||||
raise SystemExit("Unexpected amplitude: fixture would clip")
|
||||
with tempfile.TemporaryDirectory(prefix="packet-estcube-") as directory:
|
||||
path = Path(directory) / "capture.cf32"
|
||||
packed.tofile(path)
|
||||
result = subprocess.run([str(args.sink_test.resolve()), str(path)],
|
||||
check=True, text=True, capture_output=True)
|
||||
actual = [s for s in result.stdout.splitlines() if re.fullmatch(r"[0-9a-f]+", s)]
|
||||
expected = Path(__file__).with_name("estcube-expected.hex").read_text().splitlines()
|
||||
if actual != expected:
|
||||
raise SystemExit(f"Replay mismatch: decoded {len(actual)} frames; expected {len(expected)} exact frames")
|
||||
print("PASS: ESTCube fixture, 4 exact 237-byte AX.25 frames including FCS")
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (C) 2026 SDRangel contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#include "packetdemodg3ruh.h"
|
||||
#include "packetdemodframer.h"
|
||||
#include "packetdemodsettings.h"
|
||||
#include "dsp/phasediscri.h"
|
||||
#include "util/lfsr.h"
|
||||
#include "util/simpleserializer.h"
|
||||
|
||||
#include "packettestsignal.h"
|
||||
|
||||
namespace {
|
||||
using namespace PacketTest;
|
||||
class Receiver
|
||||
{
|
||||
public:
|
||||
PacketDemodG3RUH detector;
|
||||
PacketDemodFramer framer;
|
||||
PacketDemodFramer::State state;
|
||||
PhaseDiscriminators discriminator;
|
||||
std::vector<QByteArray> received;
|
||||
Receiver()
|
||||
{
|
||||
discriminator.setFMScaling(38400.0f / (2.0f * 2500.0f));
|
||||
framer.setRequirePlausible(false);
|
||||
framer.setFrameHandler([this](const QByteArray& packet, bool viaChase) {
|
||||
require(!viaChase, "G3RUH must not invoke independent-symbol Chase");
|
||||
received.push_back(packet);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
void feed(const std::vector<Complex>& iq)
|
||||
{
|
||||
// Deliberately vary feed boundaries. State must survive arbitrary chunks.
|
||||
size_t pos = 0;
|
||||
while (pos < iq.size()) {
|
||||
const size_t end = std::min(iq.size(), pos + 1 + (pos * 17) % 997);
|
||||
for (; pos < end; ++pos) {
|
||||
double power;
|
||||
Real deviation;
|
||||
const float sample = discriminator.phaseDiscriminatorDelta(iq[pos], power, deviation);
|
||||
int symbol;
|
||||
if (detector.process(sample, symbol)) {
|
||||
framer.process(state, symbol, symbol ? 1.0f : -1.0f, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void reset()
|
||||
{
|
||||
detector.reset(); state.reset(); discriminator.reset(); received.clear();
|
||||
}
|
||||
};
|
||||
|
||||
void settingsTest()
|
||||
{
|
||||
PacketDemodSettings settings;
|
||||
require(settings.getBaudRate() == 1200 && settings.isMLSEEnabled(), "AFSK defaults changed");
|
||||
settings.m_mode = PacketDemodSettings::ModeG3RUH9600;
|
||||
settings.m_rfBandwidth = 23000;
|
||||
settings.m_chase = 4;
|
||||
PacketDemodSettings copy;
|
||||
require(copy.deserialize(settings.serialize()), "settings deserialize failed");
|
||||
require(copy.getBaudRate() == 9600 && !copy.isMLSEEnabled(), "mode round-trip failed");
|
||||
require(copy.m_mlse && copy.m_chase == 4 && copy.m_rfBandwidth == 23000, "saved preferences lost");
|
||||
SimpleSerializer legacy(1);
|
||||
require(copy.deserialize(legacy.final()) && copy.isAFSK(), "old preset must reset an active 9600 mode");
|
||||
for (int invalid : {-1, 2, 9600, 2147483647}) {
|
||||
SimpleSerializer bad(1); bad.writeS32(41, invalid);
|
||||
require(copy.deserialize(bad.final()) && copy.isAFSK(), "invalid preset mode not normalized");
|
||||
require(!PacketDemodSettings::isValidMode(invalid), "invalid API mode accepted");
|
||||
}
|
||||
copy.applySettings(QStringList{"mode"}, settings);
|
||||
require(copy.getBaudRate() == 9600 && copy.m_rfBandwidth == 12500, "mode-only update changed bandwidth");
|
||||
require(!copy.deserialize(QByteArray("invalid")) && copy.isAFSK(), "invalid preset fallback failed");
|
||||
}
|
||||
|
||||
void run()
|
||||
{
|
||||
settingsTest();
|
||||
std::vector<QByteArray> expected;
|
||||
for (int i = 0; i < 40; ++i) { expected.push_back(frame(i, i % 4 == 0 ? 256 : 20 + i)); }
|
||||
const auto symbols = radioSymbols(hdlc(expected), true);
|
||||
int cases = 0;
|
||||
for (bool shaped : {false, true}) {
|
||||
for (bool inverted : {false, true}) {
|
||||
for (double timing : {0.0, 0.37, 1.25, 2.5, 3.75}) {
|
||||
Channel c; c.gaussian = shaped; c.inverted = inverted; c.timing = timing;
|
||||
Receiver rx; rx.feed(waveform(symbols, c));
|
||||
require(rx.received == expected, "clean waveform / phase / polarity failure");
|
||||
++cases;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (double ppm : {-1000.0, -250.0, 250.0, 1000.0}) {
|
||||
for (double offset : {-1500.0, 0.0, 1500.0}) {
|
||||
Channel c; c.ppm = ppm; c.offset = offset; c.timing = 0.61; c.drift = 20.0;
|
||||
Receiver rx; rx.feed(waveform(symbols, c));
|
||||
require(rx.received == expected, "clock offset / carrier drift failure"); ++cases;
|
||||
}
|
||||
}
|
||||
for (double deviation : {1500.0, 2500.0, 3000.0, 4500.0}) {
|
||||
Channel c; c.deviation = deviation; c.offset = 700; c.noise = 0.03;
|
||||
Receiver rx; rx.feed(waveform(symbols, c));
|
||||
require(rx.received == expected, "deviation / IQ noise failure"); ++cases;
|
||||
}
|
||||
Receiver rx;
|
||||
Channel c;
|
||||
const auto signal = waveform(symbols, c);
|
||||
rx.feed(std::vector<Complex>(signal.begin(), signal.begin() + signal.size() / 3));
|
||||
rx.reset(); rx.feed(signal);
|
||||
require(rx.received == expected, "reset / reacquisition failure");
|
||||
rx.reset();
|
||||
// Idle and arbitrary noise must not overflow HDLC counters or emit packets.
|
||||
for (int i = 0; i < 100000; ++i) {
|
||||
rx.framer.process(rx.state, 0, -1.0f, false);
|
||||
}
|
||||
require(rx.received.empty(), "idle carrier emitted a frame");
|
||||
rx.reset();
|
||||
rx.feed(waveform(radioSymbols(hdlc(expected), false), c));
|
||||
require(rx.received.empty(), "unscrambled stream accepted as G3RUH");
|
||||
auto damaged = expected; damaged[0][18] = char(damaged[0][18] ^ 1);
|
||||
rx.reset(); rx.feed(waveform(radioSymbols(hdlc(damaged), true), c));
|
||||
require(rx.received == std::vector<QByteArray>(expected.begin() + 1, expected.end()), "bad CRC was accepted or following frames lost");
|
||||
// Ensure the actual common framer still decodes plain AFSK NRZI levels.
|
||||
rx.reset();
|
||||
for (int level : radioSymbols(hdlc(expected), false)) {
|
||||
rx.framer.process(rx.state, level, level ? 1.0f : -1.0f, true);
|
||||
}
|
||||
require(rx.received == expected, "AFSK framing regression");
|
||||
PacketDemodFramer chase;
|
||||
PacketDemodFramer::State chaseState;
|
||||
std::vector<QByteArray> corrected;
|
||||
int recovered = 0;
|
||||
chase.setFrameHandler([&](const QByteArray& packet, bool viaChase) {
|
||||
corrected.push_back(packet);
|
||||
recovered += viaChase ? 1 : 0;
|
||||
return true;
|
||||
});
|
||||
const auto afskLevels = radioSymbols(hdlc(expected), false);
|
||||
for (size_t i = 0; i < afskLevels.size(); ++i) {
|
||||
const bool error = i == 600;
|
||||
const int level = afskLevels[i] ^ error;
|
||||
const float confidence = error ? 0.0001f : 1.0f;
|
||||
chase.process(chaseState, level, level ? confidence : -confidence, true);
|
||||
}
|
||||
require(corrected == expected && recovered == 1, "AFSK Chase recovery regression");
|
||||
std::cout << "PASS: " << cases << " waveform cases x 40 exact frames; settings, reset, CRC rejection, wrong mode and AFSK framing/Chase\n";
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
try {
|
||||
if (argc == 2) {
|
||||
// Optional independent-modem fixture: mono signed 16-bit PCM at
|
||||
// 38400 Hz, without a WAV header (e.g. Dire Wolf gen_packets).
|
||||
std::ifstream input(argv[1], std::ios::binary);
|
||||
require(input.good(), "cannot open PCM fixture");
|
||||
Receiver rx;
|
||||
char bytes[2];
|
||||
while (input.read(bytes, 2)) {
|
||||
const uint16_t raw = static_cast<unsigned char>(bytes[0])
|
||||
| (static_cast<unsigned char>(bytes[1]) << 8);
|
||||
const float sample = (raw < 32768 ? int(raw) : int(raw) - 65536) / 16384.0f;
|
||||
int symbol;
|
||||
if (rx.detector.process(sample, symbol)) {
|
||||
rx.framer.process(rx.state, symbol, symbol ? 1.0f : -1.0f, false);
|
||||
}
|
||||
}
|
||||
for (const auto& p : rx.received) { std::cout << p.toHex().constData() << '\n'; }
|
||||
require(!rx.received.empty(), "independent fixture decoded no frames");
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
catch (const std::exception& e) { std::cerr << "FAIL: " << e.what() << '\n'; return 1; }
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright (C) 2026 SDRangel contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include "packettestsignal.h"
|
||||
#include "packetdemodsink.h"
|
||||
#include "packetdemodwebapiadapter.h"
|
||||
#include "SWGChannelSettings.h"
|
||||
#include "maincore.h"
|
||||
|
||||
using namespace PacketTest;
|
||||
|
||||
namespace {
|
||||
void feed(PacketDemodSink& sink, const std::vector<Complex>& iq)
|
||||
{
|
||||
SampleVector samples;
|
||||
samples.reserve(iq.size());
|
||||
for (const auto& s : iq) {
|
||||
samples.emplace_back(static_cast<FixReal>(s.real() * SDR_RX_SCALEF * 0.5f),
|
||||
static_cast<FixReal>(s.imag() * SDR_RX_SCALEF * 0.5f));
|
||||
}
|
||||
for (size_t pos = 0; pos < samples.size();) {
|
||||
const size_t end = std::min(samples.size(), pos + 137);
|
||||
sink.feed(samples.begin() + pos, samples.begin() + end);
|
||||
pos = end;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<QByteArray> packets(MessageQueue& queue)
|
||||
{
|
||||
std::vector<QByteArray> result;
|
||||
while (Message* message = queue.pop()) {
|
||||
if (MainCore::MsgPacket::match(*message)) {
|
||||
result.push_back(static_cast<MainCore::MsgPacket*>(message)->getPacket());
|
||||
}
|
||||
delete message;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<Complex> afsk(const std::vector<QByteArray>& frames)
|
||||
{
|
||||
const auto symbols = radioSymbols(hdlc(frames), false);
|
||||
std::vector<Complex> iq;
|
||||
double audio = 0.0, carrier = 0.0;
|
||||
for (const int symbol : symbols) {
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
audio += 2.0 * pi * (symbol ? 1200 : 2200) / 38400.0;
|
||||
carrier += 2.0 * pi * 2500.0 * std::sin(audio) / 38400.0;
|
||||
iq.emplace_back(std::cos(carrier), std::sin(carrier));
|
||||
}
|
||||
}
|
||||
return iq;
|
||||
}
|
||||
|
||||
void run()
|
||||
{
|
||||
PacketDemodWebAPIAdapter adapter;
|
||||
SWGSDRangel::SWGChannelSettings request;
|
||||
request.setPacketDemodSettings(new SWGSDRangel::SWGPacketDemodSettings());
|
||||
request.getPacketDemodSettings()->init();
|
||||
QString error;
|
||||
for (int mode : {0, 1, 0, 1}) {
|
||||
request.getPacketDemodSettings()->setMode(mode);
|
||||
require(adapter.webapiSettingsPutPatch(false, QStringList{"mode"}, request, error) == 200,
|
||||
"valid mode rejected by API");
|
||||
SWGSDRangel::SWGChannelSettings response;
|
||||
adapter.webapiSettingsGet(response, error);
|
||||
require(response.getPacketDemodSettings()->getMode() == mode, "API mode round-trip failed");
|
||||
require(response.getPacketDemodSettings()->getRfBandwidth() == 12500.0f,
|
||||
"mode-only API update changed RF bandwidth");
|
||||
}
|
||||
for (int invalid : {-1, 2, 9600, 2147483647}) {
|
||||
request.getPacketDemodSettings()->setMode(invalid);
|
||||
require(adapter.webapiSettingsPutPatch(false, QStringList{"mode"}, request, error) == 400,
|
||||
"invalid mode accepted by API");
|
||||
SWGSDRangel::SWGChannelSettings response;
|
||||
adapter.webapiSettingsGet(response, error);
|
||||
require(response.getPacketDemodSettings()->getMode() == 1,
|
||||
"rejected API request modified the mode");
|
||||
}
|
||||
PacketDemodSink sink(nullptr);
|
||||
MessageQueue queue;
|
||||
sink.setMessageQueueToChannel(&queue);
|
||||
sink.setChannel(nullptr);
|
||||
PacketDemodSettings settings;
|
||||
settings.m_mode = PacketDemodSettings::ModeG3RUH9600;
|
||||
settings.m_rfBandwidth = 20000.0f;
|
||||
// Intentionally leave m_mlse=true and m_chase=6: mode must gate both in DSP.
|
||||
std::vector<QByteArray> expected;
|
||||
for (int i = 0; i < 10; ++i) { expected.push_back(frame(i, 32 + i * 17)); }
|
||||
// Identical consecutive frames must not inherit MLSE duplicate suppression.
|
||||
expected.back() = expected[expected.size() - 2];
|
||||
const auto symbols = radioSymbols(hdlc(expected), true);
|
||||
int cases = 0;
|
||||
int failures = 0;
|
||||
for (double rate : {32000.0, 38400.0, 48000.0, 96000.0}) {
|
||||
for (double offset : {-1000.0, 1000.0}) {
|
||||
Channel c; c.sampleRate = rate; c.offset = offset; c.ppm = 250; c.timing = 0.43;
|
||||
sink.applySettings(QStringList(), settings, true);
|
||||
sink.applyChannelSettings(static_cast<int>(rate), 0, true);
|
||||
feed(sink, waveform(symbols, c));
|
||||
const auto decoded = packets(queue);
|
||||
std::cout << "sink rate=" << rate << " offset=" << offset << " frames=" << decoded.size() << std::endl;
|
||||
if (decoded != expected) { ++failures; }
|
||||
++cases;
|
||||
}
|
||||
}
|
||||
require(failures == 0, "sink resampler / RF filter failed");
|
||||
// Switch the same running sink to AFSK and back using partial settings.
|
||||
// Test both old AFSK detectors, then return with m_mlse still true.
|
||||
const std::vector<QByteArray> afskExpected(expected.begin(), expected.begin() + 3);
|
||||
for (bool mlse : {false, true}) {
|
||||
settings.m_mode = PacketDemodSettings::ModeAFSK1200;
|
||||
settings.m_rfBandwidth = 12500.0f;
|
||||
settings.m_mlse = mlse;
|
||||
sink.applyChannelSettings(38400, 0, true);
|
||||
sink.applySettings(QStringList{"mode", "rfBandwidth", "mlse"}, settings);
|
||||
std::mt19937 rng(123);
|
||||
std::normal_distribution<float> normal(0.0f, 0.001f);
|
||||
std::vector<Complex> noise(38400);
|
||||
for (auto& x : noise) { x = Complex(normal(rng), normal(rng)); }
|
||||
feed(sink, noise); // MLSE's burst gate needs an observed noise floor.
|
||||
feed(sink, afsk(afskExpected));
|
||||
feed(sink, noise); // Close the burst and allow deferred decoding.
|
||||
feed(sink, noise);
|
||||
feed(sink, noise);
|
||||
const auto afskPackets = packets(queue);
|
||||
std::cout << "AFSK mlse=" << mlse << " frames=" << afskPackets.size() << std::endl;
|
||||
require(afskPackets == afskExpected, "9600 -> 1200 mode switch failed");
|
||||
settings.m_mode = PacketDemodSettings::ModeG3RUH9600;
|
||||
settings.m_rfBandwidth = 20000.0f;
|
||||
sink.applySettings(QStringList{"mode", "rfBandwidth"}, settings);
|
||||
feed(sink, waveform(symbols, Channel()));
|
||||
require(packets(queue) == expected, "1200 -> 9600 mode switch failed");
|
||||
}
|
||||
std::cout << "PASS: sink " << cases << " resampling cases x 10 exact frames; AFSK correlator/MLSE mode switching\n";
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
QCoreApplication application(argc, argv);
|
||||
try {
|
||||
if (argc == 2) {
|
||||
// Optional external capture: interleaved little-endian float32 IQ,
|
||||
// normalized to +/-1, at 38400 Hz and already centered on the signal.
|
||||
std::ifstream input(argv[1], std::ios::binary);
|
||||
require(input.good(), "cannot open IQ fixture");
|
||||
std::vector<Complex> iq;
|
||||
float pair[2];
|
||||
while (input.read(reinterpret_cast<char*>(pair), sizeof(pair))) {
|
||||
iq.emplace_back(pair[0], pair[1]);
|
||||
}
|
||||
PacketDemodSink sink(nullptr);
|
||||
MessageQueue queue;
|
||||
sink.setChannel(nullptr);
|
||||
sink.setMessageQueueToChannel(&queue);
|
||||
PacketDemodSettings settings;
|
||||
settings.m_mode = PacketDemodSettings::ModeG3RUH9600;
|
||||
settings.m_rfBandwidth = 20000.0f;
|
||||
sink.applySettings(QStringList(), settings, true);
|
||||
feed(sink, iq);
|
||||
const auto decoded = packets(queue);
|
||||
for (const auto& packet : decoded) { std::cout << packet.toHex().constData() << '\n'; }
|
||||
require(!decoded.empty(), "IQ fixture decoded no frames");
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
catch (const std::exception& e) { std::cerr << "FAIL: " << e.what() << '\n'; return 1; }
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-check G3RUH decoding with Dire Wolf's independent generator/decoder.
|
||||
|
||||
Usage: python3 verify_direwolf.py /path/to/packet_g3ruh_test
|
||||
Needs gen_packets and atest on PATH (Dire Wolf, test tools only).
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("test_binary", type=Path)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="packet-direwolf-") as directory:
|
||||
wav = Path(directory) / "generated.wav"
|
||||
pcm = Path(directory) / "generated.pcm"
|
||||
subprocess.run(["gen_packets", "-B", "9600", "-r", "38400", "-o", str(wav)],
|
||||
check=True, capture_output=True)
|
||||
with wave.open(str(wav)) as audio:
|
||||
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate()) != (1, 2, 38400):
|
||||
raise SystemExit("Unexpected generated WAV format")
|
||||
pcm.write_bytes(audio.readframes(audio.getnframes()))
|
||||
result = subprocess.run([str(args.test_binary.resolve()), str(pcm)],
|
||||
check=True, text=True, capture_output=True)
|
||||
actual = [s[:-4] for s in result.stdout.splitlines() if re.fullmatch(r"[0-9a-f]+", s)]
|
||||
oracle = subprocess.run(["atest", "-B", "9600", "-h", str(wav)],
|
||||
check=True, text=True, capture_output=True)
|
||||
text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", oracle.stdout)
|
||||
expected = []
|
||||
current = ""
|
||||
for line in text.splitlines():
|
||||
match = re.match(r" ([0-9a-f]{3}): ((?:[0-9a-f]{2} ?)+)", line)
|
||||
if match:
|
||||
if match[1] == "000" and current:
|
||||
expected.append(current)
|
||||
current = ""
|
||||
current += match[2].replace(" ", "")
|
||||
if current:
|
||||
expected.append(current)
|
||||
if len(expected) != 4 or actual != expected:
|
||||
raise SystemExit(f"Dire Wolf mismatch: receiver {len(actual)} frames, oracle {len(expected)}")
|
||||
print("PASS: 4 Dire Wolf generated frames match atest byte for byte (FCS excluded by atest)")
|
||||
Reference in New Issue
Block a user