Fix DSC decoder: eleven bugs preventing VHF decode - #241
Conversation
|
Reviewed this locally (checked out the branch, ran the test suite, traced the new decoder output through 1. Regression: distress/position-report GPS coordinates no longer reach the UI The old Downstream, 2. Dead code left behind by the parser rewrite
3. Pre-existing test not updated, currently failing on this branch This test still encodes the old HF/MF 200-bit / 100-alternation threshold. That's expected to fail given fix #3 in the description (VHF only needs a 20-bit pattern), but the test wasn't updated to match, so Separately — the PR description references attached WAV captures for validation, but I don't see any attachments on the PR itself (checked via the GitHub API, no asset URLs present), so I wasn't able to independently replay the real-world validation mentioned above. Might just be a paste artifact from wherever this description was drafted — worth re-attaching if they didn't make it through. |
Thanks for the genuinely thorough review — really appreciate you checking out the branch locally and running it against the test suite, that caught something real.
UPDATE: Dead code wasn't dead Dug into the 9 failing tests before just restoring the old methods to make them pass, and found something worth flagging directly rather than quietly resolving either way. "These identities are... transmitted as five characters C5C4C3C2C1, comprising the ten digits of [X1...X10]... whereas digit X10 is always the digit 0" The padding digit is X10 — the last digit — not the first. So the real MMSI should be digits 1-9, dropping the trailing digit, which is what my new implementation does. This isn't just spec-text pedantry either — it's why every MMSI this decoder has produced against real over-the-air traffic throughout testing has come back looking like an actual valid MMSI (e.g. 338... — a real US MID), consistently, across many independent captures. If the old drop-first-digit convention were correct, none of those would have looked right. Promote the inline decode_mmsi/decode_position closures already in the new _try_decode_message to proper class methods (_decode_mmsi/_decode_position) — addresses your point about testability directly, since right now they're only reachable as private closures. Wanted to flag this clearly and get your read before touching anything, since it's a bigger claim than a mechanical test update — let me know if you see it differently or want more detail on any part of this before I go implement it. |
…s instead of clamping/discarding; deduplicate methods
|
All three points addressed, plus one additional finding along the way. Full test suite (60/60) passing on this branch.
|
Fix DSC decoder: eleven bugs + nature-of-distress text, real-world validated across all major DSC call types
Problem
utils/dsc/decoder.py never synced on live over-the-air VHF DSC traffic, even with clean, correctly-leveled, correctly-toned audio, and even with --verbose logging active — no sync/debug output at all.
Verified this wasn't an RF/config problem before digging into the code: correct baud rate and tone pair (1200 baud / 1300-2100 Hz per ITU-R M.493 Annex 1 Sec 1.3.2), clean captured audio confirmed via spectrogram (FM capture-effect behavior, correct tone structure once rtl_fm -E deemp was added — VHF DSC is spec'd with 6dB/octave TX pre-emphasis), and confirmed stdin delivery / logging / argument parsing were all working. Fed known- clean audio straight into the decoder — still zero sync, zero output.
Traced this to eleven separate, compounding bugs, found and fixed one layer at a time as each was unblocked by the previous fix. All are confirmed against real over-the-air captures (Standard Horizon HX-series handhelds → RTL-SDR Blog V5, 156.525 MHz) and cross-checked against the ITU-R M.493 spec text directly (not from memory). The last several (8-11) were found only after initial live-traffic testing showed 0/20+ real calls decoding despite clean signal and correct sync, and after testing a wider range of real DSC call types (Distress, Distress Self-Cancel, Group, All Ships, Test Call, Position Report) beyond the initial Individual-call testing - see below.
The eleven bugs
1. Phase-sensitive correlator (_build_correlators/_demodulate_fsk) Single-phase sine correlator with no timing recovery - phase-sensitive, so correlation magnitude could collapse toward zero even with the correct tone present. Fixed with a proper quadrature (I/Q) matched filter (sine + cosine reference per tone, magnitude via sqrt(I^2+Q^2)), which is phase-independent. Validated: longest alternating bit run on real captures went from 10-13 bits (old) to 23-27 bits (new) - the fix is what makes 20-bit VHF sync reachable at all.
2. Inverted bit polarity Per ITU-R M.493 Annex 1 Sec 1.4, 2100 Hz (mark) = binary 0 (B-state), 1300 Hz (space) = binary 1 (Y-state). Code had this backwards.
3. Wrong dot-pattern length (_detect_dot_pattern) Required 200 bits / 100 consecutive alternations - that's the HF/MF number (spec Sec 3.4.1). VHF uses a 20-bit dot pattern (spec Sec 3.4.2, confirmed by ETSI EN 300 338-3: "dot pattern length to 20 bits for all transmitted DSC messages" on VHF). Requiring 100 alternations made VHF sync structurally unreachable - real VHF traffic only ever produces ~20-27 alternating bits. Fixed to require 20 bits (24-bit window for margin).
4. Wrong check-bit algorithm (_bits_to_symbol) Validated check bits as simple even parity. Per ITU-R M.493 Annex 1 Sec 2, the 3 check bits (MSB-first) actually encode, as a 3-bit number, the count of "B" (binary-0) elements among the 7 info bits (LSB-first) - not parity. Verified against the spec's own worked example (a BYY check sequence indicates exactly 3 B-elements). This made essentially every real symbol fail the check.
5. No fine bit-alignment (_process_bit) Coarse dot-pattern detection fired the instant 20 alternating bits were seen, then immediately treated the next bit as symbol position 0 - with no verification this actually landed on the transmitter's true 10-bit symbol grid (arbitrary relative to the sample buffer). Off by even a few bits, every check-bit test fails downstream. Confirmed against real bit streams: brute-force offset search found a position with 10/10 valid, spec-correct phasing symbols a few bits from where the old code started collecting. Fixed with a "fine sync" step: buffer 40 bits after coarse detection, test offsets 0-9 against the check-bit test, commit to whichever offset produces valid symbols.
6. Duplicate bits from buffer overlap (process_audio) Re-filtered and re-demodulated an overlapping buffer every call, yielding ALL resulting bits each time - including bits already emitted in the previous call. Confirmed: 218 duplicate bits injected into a real capture, compounding with every chunk and progressively desyncing frame alignment even after a successful fine sync. Also ~13 bit errors from the bandpass filter resetting to zero state every call. Fixed by carrying the filter's internal state (scipy.signal.lfilter's zi) across calls instead of overlapping samples. Confirmed bit-for-bit identical to a single continuous one-shot filter pass (zero differences, vs. 218+13 before).
7. Wrong phasing-symbol range (_try_decode_message) Phasing-strip loop checked 120 <= sym <= 126 to identify leading phasing symbols. Per ITU-R M.493 Annex 1 Sec 3.2 (confirmed directly against spec text across multiple revisions): the DX phasing character is symbol 125, but the RX phasing characters are symbols 111, 110, 109, 108, 107, 106, 105, 104 - a completely different range the old check never covered. Since the first real symbol is always an RX phasing value and the strip loop breaks on the first non-matching symbol, phasing was never actually stripped, for any message, on any capture. Fixed to also match 104-111; bound raised from 7 to 16 to accommodate the full DX+RX sequence.
8. Backwards category mapping (_decode_symbols) if format_code == 120: category = "DISTRESS" / elif format_code == 112: category = "INDIVIDUAL" - exactly reversed. Per ITU-R M.493 Table 8, confirmed directly against spec text: symbol 112 is Distress, symbol 120 is Individual station call. Superseded by fix 9's full parser rewrite, listed separately as a distinct identified defect.
9. Flat symbol-stream parsing instead of de-interleaving (_try_decode_message) - THE BIG ONE After fixes 1-7, sync worked and check-bits validated correctly, but live traffic still failed to decode: 0 successes across 20+ real test calls, with repeated "symbol check bit failure" despite excellent, independently- confirmed signal quality (15/15 valid symbols on brute-force verification). Root cause: per ITU-R M.493 Annex 1 Sec 1.2, apart from phasing, every character in a DSC call is transmitted twice - DX then RX, ~5 symbol- positions apart (confirmed: "the first transmission (DX) of a specific character is followed by the transmission of four other characters before the re-transmission (RX)... 33⅓ ms for VHF"). This is the actual wire format, not an optional error-correction extra. The old parser read the symbol stream as one flat sequence, which can never correctly find field boundaries since two different fields' DX/RX copies are genuinely interleaved in time.
Fixed by replacing the entire message parser with a de-interleaving implementation: the format specifier is additionally doubled at the field level (Sec 4.1: "2 identical characters"), giving a reliable anchor
the first position where a real format-specifier value repeats exactly 2 symbol-positions later. From that anchor, every other symbol (stride 2) is the real, de-interleaved message content. Verified against ITU-R M.493 Annex 1 Figure 1 (call sequence construction) and empirically validated against two independent real captures.
Validation: all 4 real bursts across both captures decode correctly and consistently:
Note: one early live test showed spread, non-tonal spectral energy (rather than clean 1300/2100 Hz tones) when testing Position Report/Request at very close range (~2 ft) with a full-power rubber duck antenna - traced to SDR front-end overload, not a decoder issue. Confirmed by retesting at distance (30 ft, two walls) with a clean result. Included here since it's a real, reproducible gotcha for anyone else testing this decoder at close range.
Known limitation / follow-up
Diversity-combining (fix 10) currently falls back to Track B only when Track A's copy fails its check-bit test outright. It doesn't yet handle the case where both copies pass check bits but disagree with each other (a scenario the ten-unit code can't itself detect, since both would individually look valid) - ITU-R M.493's error-check character (ECC, Annex 1 Sec 10) exists partly to catch this at the whole-message level, and using it is not yet implemented here. Also, format-specifier- specific field layouts beyond individual/distress/all-ships/group/area calls (e.g. semi-automatic connection details, distress relay, position- reply-specific message structure) are simplified or approximate - real traffic exercising those paths should be used to refine FIELD_LAYOUTS further.
Test files
A privacy note on attachments: DSC Position Request/Report replies and Distress-type messages carry real position coordinates in their payload (confirmed by decoding the raw fields directly). Raw WAV captures of those call types are not attached to this PR to avoid publishing a real location. Their correctness is instead evidenced by the decoded JSON output quoted in the Validation section above and the per-call-type table below - the decoded output does not include the position field, so it's safe to share even though the underlying audio isn't.
Attached (verified to carry no location data):
dsc_Test_Call_g20.wav - DSC Test Call, telecommand 118, position field confirmed empty ("no information")
dsc_allships_safety_g20_b.wav - All Ships Safety broadcast; its trailing field carries a proposed VHF working channel, not geographic coordinates
156.525 MHz, 48kHz/16-bit signed mono PCM, rtl_fm -M fm -E dc -E deemp.
Changes
utils/dsc/decoder.py: _build_correlators, _demodulate_fsk, _detect_dot_pattern, _bits_to_symbol, _process_bit, process_audio, and _try_decode_message (fully rewritten - phasing-strip logic replaced entirely by a de-interleaving parser with diversity-combining fallback) all modified. Each change documented inline with the relevant spec citation.