Marine DSC — native Digital Selective Calling codec + modem
The calling and safety layer of marine radio (ITU-R M.493 / M.541): 10-bit symbols carrying 7 information bits plus a 3-bit zero-count check, DX/RX time-diversity error correction, MMSI addressing with an XOR error-check character, and a two-tone Goertzel AFSK modem for VHF channel 70.
Rafe project · app/radio/dsc.py, test_dsc.py · RX + TX · clean-room from ITU-R M.493 protocol facts; on-air interop (exact phasing/dotting) not yet field-verified
Abstract
DSC is the digital "doorbell" of marine radio: before (or instead of) speaking, a ship's radio transmits a short structured call — who is calling, whom, how urgently, and what for — which every DSC-equipped radio in range decodes automatically. Distress alerts, all-ships urgency/safety announcements, and routine individual calls all use the same format. On VHF channel 70 (156.525 MHz) it is AFSK at 1200 baud with tones at 1300/2100 Hz; on MF/HF (2187.5 kHz and the HF distress channels) the same symbol stream runs at 100 baud with 170 Hz shift.
The error-control design is the same philosophy as NAVTEX's SITOR-B, adapted for short bursts: every 10-bit symbol carries its own 3-bit check (the count of zero bits among its 7 information bits), every symbol is transmitted twice with a fixed 4-symbol stagger (the DX copy and the RX retransmission), and the whole call is sealed by an error-check character (bitwise XOR of all symbol values). A symbol whose internal check fails is replaced by its time-diverted twin; a call whose ECC fails is discarded.
This document specifies the Rafe implementation in app/radio/dsc.py exactly as built: the symbol codec, the call composition (format specifier, MMSI addresses, category, telecommands, EOS, ECC), the diversity framing, and the AFSK modem. Every constant is inlined. Deviations from the full M.493 signal (chiefly: no dot-pattern preamble, a simplified phasing run, and telecommands carried raw) are called out in §7 so the reader knows both what this code does and what a complete shipborne implementation would add.
1. Background
1.1 What a DSC call is
A DSC call is a sequence of 7-bit symbol values (0–127) with fixed roles:
phasing → format specifier → [address MMSI] → category → self-ID MMSI
→ telecommand 1 → telecommand 2 → EOS → ECC
Values 0–99 double as two BCD digits (00–99) for building MMSIs; values 100–127 are control/format codes. The call is short — a routine individual call is 15 logical symbols — because it must fit in a burst on a shared calling channel.
1.2 The three layers of protection
- Per-symbol check. Each symbol travels as 10 bits: the 7 value bits (LSB first) followed by a 3-bit count of the zero bits among them ("B elements" in M.493's A/B terminology, MSB first). Any single bit flip in the info bits changes the zero-count; a flip in the check bits breaks the match. This detects, but cannot correct.
- DX/RX time diversity. The symbol stream is sent twice, interleaved: position i carries the DX (first) copy of symbol i and the RX (repeat) copy of symbol i − 4. A burst that mutilates a DX symbol is repaired from its RX twin arriving 4 symbol-pairs later — correction by diversity, exactly as in NAVTEX, with the check of layer 1 deciding which copy to trust.
- End-to-end ECC. The final symbol is the XOR of all preceding 7-bit values. A call that survives layers 1–2 with a wrong symbol is caught here and rejected whole.
2. Signal structure — the exact numbers
2.1 Modulation (VHF channel 70 defaults)
| Parameter | Value | Code |
|---|---|---|
| Baud rate | 1200 | fsk_mod(..., baud=1200) |
Tone for bit 1 |
1300 Hz | f1=1300.0 |
Tone for bit 0 |
2100 Hz | f0=2100.0 |
| Sample rate (default) | 48 000 Hz | fs=48000 |
| Waveform | phase-continuous AFSK (cosine of integrated instantaneous frequency) | phase = 2π·cumsum(f)/fs |
MF/HF DSC (100 baud, 170 Hz shift, 1615/1785 Hz audio tones in USB) uses the same symbol layer; pass the appropriate baud/f1/f0 to the modem functions. The defaults implement channel-70 VHF.
2.2 The 10-bit symbol
Value \(v \in [0,127]\) encodes as:
bit 0..6 : v LSB-first (info bits)
bit 7..9 : (7 − popcount(v)) as a 3-bit number, MSB first (zero-count check)
Worked example — symbol 42 (= 0101010 LSB-first: bits 0,1,0,1,0,1,0): three ones → four zeros → check = 100. On-air bits: 0 1 0 1 0 1 0 | 1 0 0.
Decoding recomputes the count and reports (value, ok); ok=False marks the symbol for diversity repair (§4.3).
2.3 Symbol codes used
| Symbol | Value | Meaning |
|---|---|---|
FMT_GEOGRAPHIC |
102 | call to a geographic area |
FMT_DISTRESS |
112 | distress alert |
FMT_GROUP |
114 | call to a group MMSI |
FMT_ALL_SHIPS |
116 | all-ships call |
FMT_INDIVIDUAL |
120 | call to one MMSI |
CAT_ROUTINE |
100 | routine |
CAT_SAFETY |
108 | safety |
CAT_URGENCY |
110 | urgency |
CAT_DISTRESS |
112 | distress |
EOS_RQ |
117 | end-of-sequence, acknowledgement requested |
EOS_BQ |
122 | end-of-sequence, this is an acknowledgement |
EOS |
127 | end-of-sequence, no acknowledgement |
DX |
125 | phasing DX character |
RX_PHASE |
111, 110, 109, 108, 107, 106, 105, 104 | phasing RX sequence |
| filler | 126 | "no information" (default telecommands, diversity padding) |
_DIVERSITY |
4 | DX→RX stagger, in symbols |
(112 is deliberately both FMT_DISTRESS and CAT_DISTRESS — that is M.493's own assignment, not a collision.)
2.4 MMSI packing
A 9-digit MMSI becomes 5 symbols of two BCD digits each: the digit string is zero-padded to 9 digits, a trailing 0 is appended (M.493 sends 10 digits' worth of field), and consecutive digit pairs become symbol values 0–99:
"235012345" → "2350123450" → [23, 50, 12, 34, 50]
Decoding takes each symbol mod 100, formats as two digits, and keeps the first nine characters.
3. Call composition
build_call(to_mmsi, from_mmsi, fmt, category, tc1, tc2, eos) produces the logical symbol sequence:
| # | Field | Symbols | Notes |
|---|---|---|---|
| 1 | format specifier | 1 | from §2.3 |
| 2 | address MMSI | 5 | only for FMT_INDIVIDUAL, FMT_GROUP, FMT_GEOGRAPHIC — distress and all-ships calls are unaddressed |
| 3 | category | 1 | from §2.3 |
| 4 | self-ID MMSI | 5 | always present |
| 5 | telecommands | 2 | default 126, 126 ("no information") |
| 6 | EOS | 1 | 117 / 122 / 127 |
| 7 | ECC | 1 | XOR of the 7-bit values of all preceding symbols (format through EOS inclusive) |
A routine individual call is therefore 15 symbols; an all-ships call, 10.
parse_call() reverses this: it verifies the ECC first (mismatch → {"error": "ecc"}), reads the format, conditionally consumes the 5-symbol address, then category, self-ID, and treats everything between the self-ID and the final symbol as raw telecommand values. Telecommand semantics (subsequent communications type, distress nature, position, etc.) are not interpreted — see §7.
4. Framing and time diversity
4.1 Phasing preamble
frame() prefixes the message with eight DX/RX phasing pairs:
125 111 125 110 125 109 125 108 125 107 125 106 125 105 125 104
The receiver identifies the stream by the first (125, value-in-RX_PHASE) pair and skips all such pairs; the descending RX values tell a (compliant) receiver exactly where in the phasing run it joined.
4.2 Diversity encoding
After phasing, the logical sequence \(v_0..v_{n-1}\) is transmitted as pairs, one DX and one RX symbol per position, the RX stream lagging by _DIVERSITY = 4:
position i (0 .. n+3): DX = v[i] (126 filler once i ≥ n)
RX = v[i−4] (126 filler while i−4 < 0 or ≥ n)
on-air order: DX0 RX(-4) DX1 RX(-3) ... DXn+3 RX(n-1)
The tail runs 4 positions past the message end so every symbol's RX copy is transmitted. Total on-air symbols for an n-symbol call: 16 phasing + 2(n + 4).
4.3 Diversity decoding
The receiver splits the post-phasing stream into the DX (even) and RX (odd) sub-streams, realigns them (rx[i + 4] sits opposite dx[i]), and picks per position:
- DX copy if its 10-bit check passed;
- else the RX copy if its check passed;
- else the DX value anyway (best effort — the ECC will judge the result).
The unit test drives this at the extreme: with every DX copy corrupted, the call still decodes exactly from the RX copies.
5. The AFSK modem
Transmit (fsk_mod): each bit is held for fs/baud samples of instantaneous frequency f1 (bit 1) or f0 (bit 0); the output is the cosine of the running phase integral, so the waveform is phase-continuous across bit boundaries (no splatter — see the maths guide §3.2).
Receive (fsk_demod): for each candidate bit cell, a two-tone Goertzel measurement — single-bin DFT energy at f1 and at f0 — and the louder tone wins, exactly the NAVTEX discriminator at different constants. The demodulator knows neither the bit clock phase nor (in this implementation) the exact start of the capture, so it tries 8 sampling offsets across one bit period (steps of sps/8), scores each full decode by summed tone contrast \(\sum |g_1-g_0|/(g_1+g_0)\), and keeps the offset with the best confidence.
decode_audio() chains the whole receiver: demodulate → regroup into 10-bit symbols → deframe (find phasing, strip it, diversity-decode) → parse_call. The round-trip is exercised clean and with additive Gaussian noise at 0.3× signal amplitude in test_dsc.py.
6. Validation
| Test | Asserts |
|---|---|
test_symbol_codec_and_check |
all 128 symbols round-trip; a flipped info bit fails the zero-count check |
test_mmsi_roundtrip |
9-digit MMSIs survive symbol packing (incl. leading zeros) |
test_build_parse_individual |
format/addresses/category/EOS all recovered |
test_all_ships_and_distress_have_no_address |
unaddressed formats parse without a to field |
test_ecc_rejects_corruption |
one corrupted symbol value → {"error": "ecc"} |
test_time_diversity_corrects_dx_errors |
with all DX copies destroyed, RX copies reconstruct the call |
test_afsk_audio_roundtrip |
full TX→audio→RX chain, clean |
test_afsk_audio_noisy |
same chain with σ = 0.3 additive noise |
7. Limitations and interop caveats
Documented deviations from a full M.493/M.541 implementation — the honest list of what to check or add before treating on-air traffic as authoritative:
- No dot-pattern preamble. The real signal opens with a 20-bit (VHF) / 200-bit (MF/HF) alternating dot pattern for receiver bit-sync before phasing.
frame()starts at phasing;fsk_demodcompensates by searching sampling offsets, anddecode_audioassumes the capture starts near the frame (no continuous-stream acquisition loop yet). - Phasing run simplified. Eight DX/RX pairs with RX values 111→104 are transmitted; M.541's specified phasing sequence and entry points should be confirmed against a real transmission before claiming interop.
- Telecommands are raw. Values are carried and returned but not mapped to M.493 telecommand semantics; distress calls do not yet build the nature-of-distress/position/time fields (the symbol layer supports them — they are just more symbols before the EOS).
- ECC coverage is format-through-EOS as implemented; M.493's precise coverage (which excludes phasing, as here) should be re-verified against a captured call.
- No expansion (M.821) messages.
None of these affect the symbol codec, the diversity machinery, or the modem, which are the reusable core; all are message-layer additions.
Related: NAVTEX (the same time-diversity philosophy on a continuous broadcast), the Beginner's Tour stage 2, and the maths guide on detection and interleaving/diversity.