feat(tnc): AX.25 worker thread, AFSK profiles A/B/AB/A+/B+/AB+, HdlcCodec Phase 1a+1b#3466
feat(tnc): AX.25 worker thread, AFSK profiles A/B/AB/A+/B+/AB+, HdlcCodec Phase 1a+1b#3466K5PTB wants to merge 2 commits into
Conversation
…1a+1b
Squashed history (oldest → newest):
- feat(tnc): AX.25 worker thread, AFSK profiles A/B/D, HdlcCodec Phase 1a
- fix(tnc): repair merge artifacts from branch squash/combination
- refactor(tnc): remove phase diversity — extracted to feat/phase-diversity
- fix(afsk): MSVC compatibility for direwolf_afsk demodulators
- feat(tnc): HdlcCodec Phase 1b — integrate into AX.25 shim
Worker thread:
- AetherAx25LibmodemShim runs entirely on a dedicated QThread (m_shimThread)
in Ax25HfPacketDecodeDialog; all cross-thread calls use Qt::QueuedConnection
AFSK demodulator (third_party/direwolf_afsk):
- Profile A: original AetherAFSKDemod (9-correlator)
- Profile B: 9-correlator variant with wider tracking bandwidth
- Profile D: FM-discriminator demod (AetherFMDiscrimDemod)
- Mode selector: Off / A / B / D / AB / AB+ (A×9 + B×9)
- Phase diversity removed; checkbox and AppSettings key deleted
(extracted to separate feat/phase-diversity branch for independent review)
HdlcCodec (Phase 1a — src/core/tnc/HdlcCodec.{h,cpp}):
- Pure C++, no Qt, no libmodem; replaces lm::ax25::bitstream_state
- NRZI decode, bit stuffing, CRC-16-CCITT (0x8408), LSB-first byte assembly
- State machine: Searching → InPreamble → InFrame → Complete/Aborted
- 27/27 unit tests in tests/hdlc_codec_test.cpp
HdlcCodec Phase 1b — integrated into AetherAx25LibmodemShim:
- DecodeLane: bitstreamState + bitstreamBuffer + candidateFrameBytes
replaced by hdlcCodec (HdlcCodec)
- processBit(): try_decode_bitstream replaced by hdlcCodec.processBit()
- resetLaneBitstream(): calls hdlcCodec.reset()
- Diagnostics: lane.hdlcCodec.searching()/inFrame()/etc.
- libmodem kept for: try_decode_frame_no_fcs (AX.25 structure), TX path
MSVC compatibility:
- direwolf_afsk demodulators: replaced GNU __builtin_expect with
standard [[likely]]/[[unlikely]]; fixed VLA → std::vector
…lf_afsk std::cosf, std::sinf, std::fabsf, std::hypotf, std::atan2f are not guaranteed members of std:: on all compilers (GCC/Linux CI). Replace with std::cos, std::sin, std::fabs, std::hypot, std::atan2, which C++11 <cmath> overloads for float and returns float — no behavior change. Fixes CI build failure on feat/ax25-hdlc-thread PR aethersdr#3466. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Hi Paul, I'm excited to have you work on the AX.25 stack. I want to help you get these changes merged. A couple of things we need from you on this:
- We will need you to unsquash this PR and submit the threading, profile and codec work separately as standalone PRs.
- These should include individual test plans, and should support our Qt backend where it touches the UI.
- These PRs will need defensive testing across Windows, macOS and Linux, especially the threading model.
- Let's look at how we can make profile selection more user friendly for non-technical users. Can we pick this highest performing model and not expose the complexity to the end user? How can we make that easier?
- Because this now involves two AFSK demodulator stacks with different performance profiles across HF and VHF, there will be added maintenance complexity. Is this something you are considering maintaining after it is committed?
Let me know how I can help you with this, or if you have any questions.
|
I can maintain the VHF demod. But, I don't have the ability to test the HF demod, so I left it alone. My primary goal was to improve the performance on VHF, because I found libmodem was falling short. However, I don't know who uses AX.25 on HF now, as VARA seems to be the most popular.. but that's not cross-platform. I've done testing across all of the platforms, verifying the codec works. I just did the comparison testing on the Raspberry Pi, as I could let it run for long periods of time on an RPi I don't use for anything else. I could do a similar comparison between different platforms to validate they are all performing the same. I would need to add a payload field to the MQTT publish topic to identify the hostname originating the message. I'm not sure how to address the profile selection. Most will be happy with A+, as it matches Direwolf's default selection. Frankly, I didn't even know about the others until I started digging into the code. Do we drop all the profiles completely, or leave them in the code but preselect A+ and hold the others in reserve for the future, or move the setting somewhere else? If we decide to keep the selector, I will put together a Qt test. I will split these into separate features and PRs. Do you have a preferred ordering? I suggest:
|
|
@K5PTB I support your order proposal. I'll look to your guidance on profile simplification because I know you have a test harness and findings built for this already. Love to see you document your test setup as well, but you can save that for a rainy day. Let's see if we can get this merge done and then we can do an APRS bring up. Thank you for the hard work on this! |
|
@jensenpat I'm not sure how to do this administratively: Should I make this PR only for the separate thread, then submit a new PR for the codec? I'd like to get these in, I will hold back the profile selector until we decide on a direction for that. My comparator is a Python script. Setting it all up on Linux is a bit fiddly, but I have that documented. I can put it into the tests directory, unless you want me to convert it to C++ first. |
|
@K5PTB Go ahead and close this one out, ask your agent to split the commits with new standalone PRs for threading, codec and profile selector. It knows which commit IDs are tied to which purposes locally in your existing conversation. Then have it prepare a detailed PR for each with a test plan. You already have commit signing in place, so that's great. You can also reference and feed CONTRIBUTING.md to your agent, specifically see items 3-7. |
|
Closing in favor of the split PR approach per @jensenpat's direction. Replacing with separate focused PRs: feat/ax25-worker-thread (threading), feat/ax25-hdlc-codec (HDLC codec), feat/ax25-direwolf-demod (Direwolf AFSK profiles), and feat/ax25-direwolf-profiles (VHF mode selector UI). Each will have a full description and test plan per CONTRIBUTING.md. |
## Summary First of three focused PRs extracted from #3466 per @jensenpat's review guidance. Lays the threading groundwork for #2701 (Native Soft-TNC + APRS Integration) — the shim thread boundary is required before a more capable demodulator can be dropped in without blocking the GUI. ## Motivation `AetherAx25LibmodemShim` ran on the GUI thread. Audio callbacks, HDLC decode, and profile configuration all competed with paint events and user interaction, making it impossible to later add a CPU-heavier demodulator without degrading responsiveness. ## Changes - `AetherAx25LibmodemShim` moved to a dedicated `QThread` (`m_shimThread`) - All GUI→shim calls use `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` - `m_shimConfig` cache added to dialog — GUI thread never reads `m_impl->config` directly - Free functions extracted: `ax25DemodDescription`, `ax25BuildTransmitAudio`, `ax25BuildTransmitAudioFromFrame` - `startTransmit(const QString&)` extracted from `startTransmitFromUi` - Destructor: `m_shimThread.quit(); m_shimThread.wait()` - **ASAN fix (`b495dac9`):** `MainWindow::~MainWindow()` now explicitly deletes `m_ax25HfPacketDecodeDialog` before AudioEngine teardown. Pre-existing UAF: dialog destructor called `m_audio->setTncRxTapEnabled(false)` via raw pointer after AudioEngine was freed via `deleteLater()`. Same pattern as the existing `m_networkDiagnosticsDialog` fix in that destructor. Found on RPi 5 with ASAN; confirmed clean after fix. ## Test plan Tested on all three target platforms. 1. **Basic decode** — enabled modem on 144.390 MHz APRS; frames appeared in the log within seconds; disable/re-enable confirmed clean reset with no stale state. 2. **Profile switching under load** — switched HF↔VHF while audio was flowing, including rapid successive switches; no crash, new profile description appeared correctly each time. 3. **TX path** — transmitted a UI frame; confirmed key, send, and unkey with no crash or hang. KISS frame sent via `kiss_test.py` also keyed cleanly, exercising `ax25BuildTransmitAudioFromFrame` as a free function. **Hardware note:** transverter attenuated to prevent RF output — full software TX path exercised but no RF generated; iGate/APRS-IS verification deferred pending hardware modification. 4. **Clean shutdown** — closed dialog while modem was enabled; no hang or crash; reopened and re-initialized correctly. 5. **Extended run** — modem left running 10+ minutes on a busy APRS channel; no crashes, no memory growth, GUI remained responsive. 6. **ASAN (RPi 5)** — Debug+ASAN build clean after fix; no new leaks or UAF reported. **RX sensitivity note:** A single decode was achieved with an HT at close range. Consistent decode is limited by libmodem's single-slicer VHF sensitivity — a known pre-existing limitation addressed in the third PR in this series (Direwolf demodulator). This PR does not regress decode behavior. **300-baud HF decode** — not tested on-air; no 300-baud packet infrastructure available in the test environment. --- Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Win 11 i3 Extracted from #3466 (closed). **Note:** The follow-on PR introducing `HdlcCodec` ([feat/ax25-hdlc-codec](https://github.com/K5PTB/AetherSDR/tree/feat/ax25-hdlc-codec)) is ready but will be held until this PR merges, so it can be opened cleanly against `main`. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… HDLC framer (#3475) ## Summary Second of three focused PRs extracted from #3466 per @jensenpat's review guidance, implementing part of the design laid out in #2701 (Native Soft-TNC + APRS Integration). Introduces `HdlcCodec`, a self-contained pure-C++ HDLC framer with CRC-16-CCITT, and wires it into the VHF 1200-baud decode lane in place of libmodem's `bitstream_state`. libmodem is retained for HF demodulation and AX.25 frame parsing. Also addresses non-blocking review notes 1 and 2 from #3473: lane count restored in `ax25DemodDescription()` via `ax25DemodLaneCount()`, and two explanatory comments restored in `ax25BuildTransmitAudioFromFrame`. ## Motivation libmodem's `bitstream_state` is an opaque internal struct — not unit-testable in isolation and not replaceable without forking libmodem. Extracting HDLC framing into `HdlcCodec` makes the state machine independently testable, and establishes the boundary where the Direwolf demodulator (PR 3) will plug in. ## Changes - **New:** `src/core/tnc/HdlcCodec.h` / `HdlcCodec.cpp` — pure C++ HDLC bit-unstuffing and CRC-16-CCITT framer - **New:** `tests/hdlc_codec_test.cpp` — 27 tests covering state transitions, FCS validation cross-checked against libmodem, two-frame decode, long payload, and reset - `CMakeLists.txt`: added `HdlcCodec.cpp` to main build and shim test; added `hdlc_codec_test` target - `AetherAx25LibmodemShim`: `DecodeLane` replaces `bitstreamState` with `HdlcCodec hdlcCodec` - `ax25DemodLaneCount()` free function added; `ax25DemodDescription()` now includes lane count suffix - Two explanatory comments restored in `ax25BuildTransmitAudioFromFrame` (minimum-frame-size invariant; host-omits-FCS note) Squashed-from: #3475 Co-authored-by: Paul <274291579+K5PTB@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
## Summary First of three focused PRs extracted from aethersdr#3466 per @jensenpat's review guidance. Lays the threading groundwork for aethersdr#2701 (Native Soft-TNC + APRS Integration) — the shim thread boundary is required before a more capable demodulator can be dropped in without blocking the GUI. ## Motivation `AetherAx25LibmodemShim` ran on the GUI thread. Audio callbacks, HDLC decode, and profile configuration all competed with paint events and user interaction, making it impossible to later add a CPU-heavier demodulator without degrading responsiveness. ## Changes - `AetherAx25LibmodemShim` moved to a dedicated `QThread` (`m_shimThread`) - All GUI→shim calls use `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` - `m_shimConfig` cache added to dialog — GUI thread never reads `m_impl->config` directly - Free functions extracted: `ax25DemodDescription`, `ax25BuildTransmitAudio`, `ax25BuildTransmitAudioFromFrame` - `startTransmit(const QString&)` extracted from `startTransmitFromUi` - Destructor: `m_shimThread.quit(); m_shimThread.wait()` - **ASAN fix (`b495dac9`):** `MainWindow::~MainWindow()` now explicitly deletes `m_ax25HfPacketDecodeDialog` before AudioEngine teardown. Pre-existing UAF: dialog destructor called `m_audio->setTncRxTapEnabled(false)` via raw pointer after AudioEngine was freed via `deleteLater()`. Same pattern as the existing `m_networkDiagnosticsDialog` fix in that destructor. Found on RPi 5 with ASAN; confirmed clean after fix. ## Test plan Tested on all three target platforms. 1. **Basic decode** — enabled modem on 144.390 MHz APRS; frames appeared in the log within seconds; disable/re-enable confirmed clean reset with no stale state. 2. **Profile switching under load** — switched HF↔VHF while audio was flowing, including rapid successive switches; no crash, new profile description appeared correctly each time. 3. **TX path** — transmitted a UI frame; confirmed key, send, and unkey with no crash or hang. KISS frame sent via `kiss_test.py` also keyed cleanly, exercising `ax25BuildTransmitAudioFromFrame` as a free function. **Hardware note:** transverter attenuated to prevent RF output — full software TX path exercised but no RF generated; iGate/APRS-IS verification deferred pending hardware modification. 4. **Clean shutdown** — closed dialog while modem was enabled; no hang or crash; reopened and re-initialized correctly. 5. **Extended run** — modem left running 10+ minutes on a busy APRS channel; no crashes, no memory growth, GUI remained responsive. 6. **ASAN (RPi 5)** — Debug+ASAN build clean after fix; no new leaks or UAF reported. **RX sensitivity note:** A single decode was achieved with an HT at close range. Consistent decode is limited by libmodem's single-slicer VHF sensitivity — a known pre-existing limitation addressed in the third PR in this series (Direwolf demodulator). This PR does not regress decode behavior. **300-baud HF decode** — not tested on-air; no 300-baud packet infrastructure available in the test environment. --- Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Win 11 i3 Extracted from aethersdr#3466 (closed). **Note:** The follow-on PR introducing `HdlcCodec` ([feat/ax25-hdlc-codec](https://github.com/K5PTB/AetherSDR/tree/feat/ax25-hdlc-codec)) is ready but will be held until this PR merges, so it can be opened cleanly against `main`. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… HDLC framer (aethersdr#3475) ## Summary Second of three focused PRs extracted from aethersdr#3466 per @jensenpat's review guidance, implementing part of the design laid out in aethersdr#2701 (Native Soft-TNC + APRS Integration). Introduces `HdlcCodec`, a self-contained pure-C++ HDLC framer with CRC-16-CCITT, and wires it into the VHF 1200-baud decode lane in place of libmodem's `bitstream_state`. libmodem is retained for HF demodulation and AX.25 frame parsing. Also addresses non-blocking review notes 1 and 2 from aethersdr#3473: lane count restored in `ax25DemodDescription()` via `ax25DemodLaneCount()`, and two explanatory comments restored in `ax25BuildTransmitAudioFromFrame`. ## Motivation libmodem's `bitstream_state` is an opaque internal struct — not unit-testable in isolation and not replaceable without forking libmodem. Extracting HDLC framing into `HdlcCodec` makes the state machine independently testable, and establishes the boundary where the Direwolf demodulator (PR 3) will plug in. ## Changes - **New:** `src/core/tnc/HdlcCodec.h` / `HdlcCodec.cpp` — pure C++ HDLC bit-unstuffing and CRC-16-CCITT framer - **New:** `tests/hdlc_codec_test.cpp` — 27 tests covering state transitions, FCS validation cross-checked against libmodem, two-frame decode, long payload, and reset - `CMakeLists.txt`: added `HdlcCodec.cpp` to main build and shim test; added `hdlc_codec_test` target - `AetherAx25LibmodemShim`: `DecodeLane` replaces `bitstreamState` with `HdlcCodec hdlcCodec` - `ax25DemodLaneCount()` free function added; `ax25DemodDescription()` now includes lane count suffix - Two explanatory comments restored in `ax25BuildTransmitAudioFromFrame` (minimum-frame-size invariant; host-omits-FCS note) Squashed-from: aethersdr#3475 Co-authored-by: Paul <274291579+K5PTB@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
AetherSDR's VHF 1200 baud AX.25 decode rate was 27% of Direwolf's on identical audio. This PR brings it to 96% on the default A+ profile — matching and slightly exceeding both Direwolf and Graywolf — by replacing the libmodem sinc-correlator with a Direwolf-derived AFSK demodulator.
Summary
Three branches of development have been squashed into a single commit for review:
feat/aether-afsk-demod— Direwolf-derived AFSK demodulator, Profile Afeat/aether-afsk-profile-b— full 6-mode selector (A / B / AB / A+ / B+ / AB+)feat/ax25-shim-worker-thread— worker thread + full 9-slicer bank + HdlcCodec Phase 1a+1bAlso addresses #3387.
Relationship to issue #2701
Issue #2701 describes a phased roadmap for a native Soft-TNC. This PR lands the following pieces of that plan:
Phase 1a —
HdlcCodec(src/core/tnc/HdlcCodec.{h,cpp}+tests/hdlc_codec_test.cpp): the native HDLC framing layer called out in the plan. 27 unit tests; CRC-16-CCITT cross-checked against both Direwolf and graywolf as the plan specified. This is the first of the planned test suite to land.Phase 2 — AFSK demodulator: the plan described a "correlator-difference AFSK demod + early-late symbol clock" for a new
ClientTnc. Rather than build a new wrapper, we replaced the demodulator inside the existingAetherAx25LibmodemShim, producing a working VHF 1200 baud decode path without disrupting the surrounding architecture. The demodulator is Direwolf-derived (Profiles A/B/AB/A+/B+/AB+). AX.25 structure parsing remains on libmodem for now.Threading: The plan describes
ClientTncrunning inline on theAudioEnginethread. We chose a dedicatedQThreadinstead — see the Worker thread section below for the reasoning.Changes
1. Direwolf-derived AFSK demodulator (
third_party/direwolf_afsk/)Replaces
lm::sinc_corr_afsk_demodulatorfor VHF 1200 baud. HF 300 baud continues to use libmodem unchanged.Source location:
third_party/direwolf_afsk/, following thelibmodem_core/subset pattern.README.aethersdr.mdrecords the upstream release, referenced files, license compatibility rationale, and what was omitted.THIRD_PARTY_LICENSESupdated.License: Direwolf is GPL-2-or-later. AetherSDR is GPL-3. GPL-2-or-later is one-way compatible into GPL-3 (FSF). Source headers reflect GPL-3, not the previously-cited GPL-2+.
Six VHF modes exposed via a new
VhfModeenum and runtimeQComboBoxselector. TheAETHERSDR_USE_AETHERDEMODcompile-time switch from the initial draft has been removed — VHF 1200 always uses the Direwolf-derived path. The selector is disabled when HF 300 is active:Default is A+, matching Direwolf's
MODEM 1200default.Profile A (
AetherAFSKDemod): IQ-mix + RRC + DPLL, space-gain bank. The A+ 9-slicer values{0.500, 0.648, 0.841, 1.091, 1.414, 1.834, 2.378, 3.084, 4.000}are the exact geometric series from Direwolf'sdemod_afsk.c(MAX_SUBCHANS=9), compensating for VHF FM de-emphasis on the 2200 Hz space tone.Profile B (
AetherFMDiscrimDemod): FM discriminator — IQ-mix →atan2→ phase rate → normalize. Targets signals where amplitude balance between mark and space is unreliable (frequency-offset stations, Doppler, weak paths). Complements A+: each catches signals the other misses. The B+ 9 slice offsets{−0.500, −0.375, …, 0.500}match Direwolf'sMAX_SLICERS=9linear series.2. Worker thread
AetherAx25LibmodemShimnow runs entirely on a dedicatedQThread(m_shimThread) owned byAx25HfPacketDecodeDialog. Both the VHF (AetherAFSKDemod) and HF 300 baud (libmodem) paths run through the samefeedAudio()entry point, so both move to the worker thread automatically.Issue #2701 describes the long-term
ClientTncarchitecture running inline on theAudioEnginethread. We chose a dedicated worker thread instead, for two reasons:When the roadmap's
ClientTncis eventually built as a lightweight single-lane demodulator, the AudioEngine-thread approach may be appropriate for that path. The shim stays on its dedicated worker.Implementation details:
configure(),reset(),feedAudio(),setDiagnosticsLoggingEnabled()) dispatched viaQMetaObject::invokeMethodwithQt::QueuedConnectionAx25DemodConfigcache eliminates cross-thread reads on the hot pathframeDecoded,diagnosticsUpdated,statusChanged) useAutoConnection— Qt resolves these toQueuedConnectionautomaticallyAudio sampling is unaffected even when running under AddressSanitizer on the RPi 5.
3. HdlcCodec — Phase 1a (new file:
src/core/tnc/HdlcCodec.{h,cpp})Standalone native HDLC framing class replacing
lm::ax25::bitstream_statein the decode lanes. Pure C++, no Qt, no libmodem dependency.Implements: NRZI decode, bit-stuffing, CRC-16-CCITT (0x8408, poly bit-reversed, init/final XOR 0xFFFF), LSB-first byte assembly. State machine:
Searching → InPreamble → InFrame → Complete/Aborted.Verified algorithmically equivalent to both Direwolf (
hdlc_rec.c/fcs_calc.c) and libmodem before replacement. 27/27 unit tests intests/hdlc_codec_test.cpp.4. HdlcCodec — Phase 1b (integrated into
AetherAx25LibmodemShim)DecodeLanemembers replaced:lm::ax25::bitstream_state bitstreamState→HdlcCodec hdlcCodecstd::vector<uint8_t> bitstreamBuffer→ removed (internal to HdlcCodec)std::array<uint8_t, 1000> candidateFrameBytes→ removed (usehdlcCodec.frameData())libmodem is retained for
try_decode_frame_no_fcs(AX.25 structure parsing) and the TX packet builder. The HF 300 baud demodulator (lm::sinc_corr_afsk_demodulator) is also unchanged.5. MSVC compatibility
third_party/direwolf_afsk: GNU__builtin_expectreplaced with standard[[likely]]/[[unlikely]]; VLAs replaced withstd::vector.Addressing #3387 triage items
License (point 1): Confirmed — AetherSDR is GPL-3, not GPL-2+. Direwolf GPL-2-or-later is compatible. Source headers corrected.
Vendoring (point 2): Direwolf sources are in
third_party/direwolf_afsk/withREADME.aethersdr.md, matching thelibmodem_core/pattern.THIRD_PARTY_LICENSESupdated.HF 300 coverage (point 3): The Direwolf path is VHF 1200 only. The
VhfModeselector is disabled for HF 300. HF 300 parity is a separate future item.Framing layer scope (point 4): We went one step further than the triage suggested and also replaced the HDLC framing layer (Phase 1a+1b). Justification: analysis of Direwolf's
hdlc_rec.cand libmodem confirmed they are algorithmically identical — same NRZI formula, same bit-stuffing check, same CRC polynomial, same byte alignment rule. The decode gap is entirely in the AFSK demodulator. Replacing libmodem's HDLC gives clean ownership of the framing layer, enables future instrumentation, and is required for the Phase 2 roadmap. The blast radius is low: the AX.25 structure parser (try_decode_frame_no_fcs) and TX path remain on libmodem.Long-term build switch (point 5): The
AETHERSDR_USE_AETHERDEMODcompile switch is removed. VHF 1200 always uses the Direwolf-derived demodulator. The runtimeVhfModeselector (default A+) gives operators the tradeoff knob.Performance
The original comparison cited in #3387 was libmodem 777 vs Direwolf 2891 over the same audio (3.7×). Long-duration live comparisons on 144.390 MHz APRS on a Raspberry Pi 5 show AetherSDR at or above parity with both Direwolf and Graywolf on the default A+ profile:
A+ mode (default) — 987 packets heard:
AB+ mode (18 lanes) — 2,405 packets heard, 12-hour run:
On A+, AetherSDR leads both reference decoders. The AB+ result (4 points behind) is characterised by the confidence data: AetherSDR's average confidence on missed packets (GW+DW only: 23 frames, confidence 57–59) is marginally lower than on all-three packets (61), indicating conservative behaviour on the very weakest signals rather than systematic error. AetherSDR's false-positive rate is effectively zero in both datasets.
Comparison methodology
Decode performance was measured using a three-decoder comparison running continuously on a Raspberry Pi 5, listening to live 144.390 MHz APRS traffic via a FlexRadio 6600. All three decoders ran on separate slice receivers, each receiving the same 144.390 MHz signal. Direwolf and Graywolf listened to DAX audio streams from their respective slices; AetherSDR's internal decoder published decoded frames via MQTT.
Comparator: A purpose-built Python webapp subscribes to all three sources simultaneously — MQTT for AetherSDR, REST API polling for Graywolf, and subprocess stdout for Direwolf. Frames are matched within a 5-second window on content. Duplicate copies of the same RF burst (digipeated repeats) are counted once per decoder, making the totals directly comparable. A live browser UI and analysis endpoint show per-decoder counts and decode-combination breakdowns in real time.
Quality metrics: Each decoder was instrumented to report a signal quality value alongside each decoded frame — AetherSDR's existing
confidencefield (0–1, from the MQTT payload), Graywolf'sdecoded.qualityfield from its REST response, and Direwolf's per-bit EMA quality from itsq=Naudio level line. These were used to characterise the missed-packet population rather than as a pass/fail threshold.What does not change
try_decode_frame_no_fcs) — unchanged, still libmodemTesting
tests/hdlc_codec_test.cpp— 27 tests, all pass.Built against both
HdlcCodecand libmodem simultaneously, using libmodem as ground truth:testStateTransitionstestSingleFrametestFrameContentslm::ax25::try_decode_frame_no_fcs; callsigns and payload match the encoded packettestFcsMatchesLibmodemlm::ax25::compute_crc— both produce identical CRC-16-CCITT valuestestBadFcstestTwoConsecutiveFramestestPreambleCounttestResetreset()returns to Searching state; same bitstream decodes again cleanlytestLongPayloadThe
testFcsMatchesLibmodemtest is the key cross-check called out in the #2701 plan — it proves HdlcCodec and libmodem compute the same CRC-16-CCITT over identical frame bytes, ruling out the polynomial/init/final-XOR mismatch the plan specifically flagged as a risk.tests/ax25_libmodem_shim_test— 120 tests, all pass. Covers the full shim end-to-end: AFSK loopback (HF 300 and VHF 1200), TX/RX round-trips, KISS framing, diagnostics, and bad-FCS detection. This suite exercises HdlcCodec through the shim integration, confirming the Phase 1b replacement did not change observable behaviour.Live VHF decode confirmed on 144.390 MHz on macOS (Apple Silicon M2), Raspberry Pi 5, and Windows 11.
Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Windows 11 on Intel i3
Happy to split into separate PRs (demodulator / worker thread / HdlcCodec) if that makes review easier — the three areas are independent once the lane-management interface is stable.