Patrick Lidstone
Self-hosted

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

  1. 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.
  2. 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.
  3. 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:

  1. DX copy if its 10-bit check passed;
  2. else the RX copy if its check passed;
  3. 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_demod compensates by searching sampling offsets, and decode_audio assumes 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.