Receive and decode GMDSS Digital Selective Calling (DSC) on VHF channel 70 with an RTL-SDR dongle and pure Python.
DSC is the digital calling system used by ships and coast stations worldwide as part of the Global Maritime Distress and Safety System (GMDSS). On VHF it lives on channel 70 (156.525 MHz) — a channel reserved exclusively for digital calls: routine test calls, safety announcements, individual calls, and (rarely, hopefully) distress alerts.
This repository contains everything needed to go from antenna to decoded JSON on a Raspberry Pi or any Linux machine:
recorder/— an unattended RTL-SDR monitor that detects DSC bursts in software and saves them as WAV files (with pre-buffer, so the start of the message is never lost)dsc_decoder/— a complete ITU-R M.493 decoder: FSK demodulation, symbol decoding with parity validation, phasing search, DX/RX time-diversity parsing, and field extraction (MMSI, category, telecommands, position, ...)examples/— a synthetic-signal generator so you can verify the whole decoder without owning a radio
Everything is plain Python + NumPy/SciPy. No GNU Radio, no compiled DSP code — the point is to be readable, so you can learn how DSC actually works.
flowchart LR
A[VHF antenna] --> B[RTL-SDR dongle]
B -->|USB, IQ samples| C["rtl_fm<br/>(NBFM demod, 156.525 MHz)"]
C -->|16-bit PCM 22050 Hz| D["dsc_monitor.py<br/>software trigger"]
D -->|"DSC burst detected"| E[("WAV files<br/>dsc_YYYYMMDD_HHMMSSZ.wav")]
E --> F["dsc_decoder<br/>(python -m dsc_decoder)"]
F --> G["Decoded message<br/>(JSON or text)"]
The split into record and decode is deliberate: the raw WAV is always kept, so when the decoder improves you can re-decode old recordings. Disk is cheap; a missed distress-test call is gone forever.
Verify the decoder end-to-end with a synthesized transmission:
git clone https://github.com/tomastnc/vhf-dsc-decoder.git
cd vhf-dsc-decoder
python3 -m venv .venv && source .venv/bin/activate # Python 3.9–3.12
pip install -r requirements.txt
# Generate a synthetic DSC test call (fictional MMSIs) and decode it
python examples/generate_test_signal.py test_call.wav
python -m dsc_decoder test_call.wav --output jsonExpected output:
{
"format": "Individual call",
"format_code": 120,
"address_mmsi": "123456789",
"category": "Safety",
"self_id_mmsi": "987654321",
"tc1": "Test",
"tc2": "No information",
"eos_marker": "RQ",
"ecc_valid": true,
"is_valid": true,
"confidence": 1.0
}If you see this, the entire chain works: FSK modulation → demodulation → symbol decoding → parity checks → message parsing → ECC validation. The generator builds a bit-exact M.493 burst (dot pattern, phasing block, time diversity, error-check character), so it exercises the same code paths as a real off-air recording.
⚠️ The generated audio is for decoder testing only. Never transmit it. Channel 70 is a protected distress and calling channel.
| Part | Notes |
|---|---|
| RTL-SDR dongle | Any RTL2832U-based stick works; ones with a TCXO (temperature-compensated oscillator) hold frequency better |
| VHF antenna | Anything resonant near 156 MHz helps a lot. Marine-band whip, 2 m amateur antenna, or a simple λ/4 ground plane (~45 cm element) |
| Computer | Raspberry Pi 3/4/5/Zero 2 W, or any Linux machine. The recorder is lightweight (the Pi only records; decoding can happen anywhere) |
Range on VHF is essentially line-of-sight. Near a coast you will hear coast stations and ships tens of nautical miles away; inland you may still catch elevated coast-station transmitters. Expect mostly test calls (ships verifying their DSC equipment against coast stations) and safety announcements — that is normal, healthy GMDSS traffic.
sudo apt update
sudo apt install rtl-sdr python3-numpy git
git clone https://github.com/tomastnc/vhf-dsc-decoder.gitThe recorder needs only
numpy(Debian'spython3-numpyis fine) — no SciPy, no pip, no venv. That keeps a Raspberry Pi Zero happy.
Sanity-check the dongle:
rtl_test -t # should find your device (only ONE program can use it at a time)Run the monitor in the foreground first:
python3 vhf-dsc-decoder/recorder/dsc_monitor.py --debugYou should see a status line every 30 s. When a DSC burst arrives:
[TRIGGER] Sustained DSC signal (3 chunks, amp=0.0312, SNR=18.4x)
[END] Signal ended, saving...
[SAVED] dsc_20260101_120001Z.wav (9.3s) - File #1
Then install it as a service so it survives reboots:
sudo cp vhf-dsc-decoder/recorder/dsc-monitor.service /etc/systemd/system/
# edit User= and paths to match your setup
sudo systemctl daemon-reload
sudo systemctl enable --now dsc-monitor
journalctl -u dsc-monitor -frtl_fm runs without squelch — the monitor sees a continuous audio stream
and decides in software when a DSC transmission is present. Every 100 ms chunk
is checked with the Goertzel algorithm
(a cheap single-bin DFT) at the two DSC tone frequencies, compared against the
background level at several non-DSC frequencies:
stateDiagram-v2
[*] --> Listening
Listening --> Listening : chunk into 3 s pre-buffer
Listening --> Recording : DSC tones in 3 consecutive chunks<br/>(amplitude above floor AND SNR above threshold)
Recording --> Recording : signal present → reset 5 s holdoff
Recording --> PostRecord : signal gone AND holdoff expired
PostRecord --> Recording : signal returns
PostRecord --> Saving : 5 s post-record elapsed
Recording --> Saving : 30 s max duration reached
Saving --> Listening : WAV written (pre-buffer + burst + tail)
Design decisions that took real-world debugging to arrive at:
- Two-condition trigger. SNR ratio alone false-triggers on near-silent chunks (a tiny numerator over a tinier denominator is still a big ratio). Requiring an absolute amplitude floor and an SNR ratio fixed that.
- Three consecutive chunks (0.3 s) of signal are required, so momentary clicks and ignition noise don't trigger a recording.
- Pre-buffer. By the time a trigger fires, the dot pattern has already been playing for a few hundred milliseconds. A 3 s circular buffer is prepended to every recording so the message start is intact.
- Holdoff. DSC transmissions can dip in signal strength mid-message; a 5 s holdoff keeps the recording rolling through fades instead of fragmenting one call into several files.
python -m dsc_decoder recording.wav # human-readable text
python -m dsc_decoder recording.wav --output json # machine-readable
python -m dsc_decoder recording.wav --all-messages # every repetition found
python -m dsc_decoder recording.wav --debug # per-stage diagnostics
python -m dsc_decoder recording.wav --demod bandpass # alternative demodulatorflowchart TD
A["Load audio<br/>(resample to 22050 Hz mono)"] --> B["Band detection<br/>try VHF (1200 Bd) and HF (100 Bd)<br/>parameters, keep whichever<br/>demodulates into valid symbols"]
B --> C["FSK demodulation<br/>Goertzel energy at mark/space<br/>per bit period → bitstream"]
C --> D["Symbol decoding<br/>10-bit patterns → symbols 0–127<br/>tried at all 10 bit offsets<br/>+ parity validation"]
D --> E["Phasing search<br/>find DX=125 / RX=111…104<br/>pattern in every symbol stream"]
E --> F["Message parsing<br/>extract DX info stream,<br/>MMSIs, category, telecommands,<br/>position/time if present"]
F --> G["ECC check + dedup<br/>XOR check character,<br/>merge the ~5 repetitions"]
G --> H["Best message<br/>(highest confidence + most fields)"]
Two details make this robust on weak signals:
- All 10 bit offsets are decoded in parallel. Instead of committing to one clock alignment, the decoder tries every offset and keeps the streams that contain a valid phasing pattern. Real message structure — not signal strength — decides what's a message.
- The parity code doubles as a sync detector. Only 128 of 1024 possible 10-bit patterns are valid symbols; a misaligned stream immediately shows a low validity ratio.
| Field | Meaning |
|---|---|
format / format_code |
Call type per M.493 Table 3 (102 geographic, 112 distress, 114 group, 116 all ships, 120 individual, 123 automatic) |
address_mmsi |
Called station (9 digits; ALL STATIONS for distress) |
self_id_mmsi |
Calling station. MMSIs starting with 00 are coast stations |
category |
Routine / Safety / Urgency / Distress |
tc1, tc2 |
Telecommands — e.g. Test, F3E/G3E All modes TP (voice), Distress acknowledgement |
distress_mmsi, distress_nature |
Only in distress alerts/relays |
position, time_utc |
Only present when the message carries them (most routine traffic does not) |
frequency |
Proposed working channel, e.g. VHF channel for subsequent voice contact |
eos_marker |
RQ (ack requested) / BQ (ack) / EOS |
ecc_valid |
Whether the transmitted check character matches |
confidence |
Fraction of symbols with valid parity (0–1) |
Physical layer, VHF: audio-frequency FSK inside a normal NBFM channel at 1200 baud. Mark ("B", binary 1) = 1300 Hz, space ("Y", binary 0) = 2100 Hz. Getting this polarity right matters — see lessons below. (HF/MF DSC uses 100 baud with 170 Hz shift instead; the decoder auto-detects which one it is looking at.)
Symbols: each symbol carries 7 information bits (LSB first) followed by a
3-bit parity field that encodes the number of zeros in the information bits
(MSB first). 10 bits per symbol, values 0–127. Values 0–99 encode two decimal
digits (56 → "56"); 100–127 are control codes (format specifiers,
telecommands, EOS markers).
Frame structure (as transmitted on air):
┌────────────────┬──────────────────────────────┬──────────────────────────────┐
│ Dot pattern │ Phasing block (16 symbols) │ Message (DX/RX interleaved) │
│ 1010101010… │ DX: 125 125 125 125 125 125 │ address ─ category ─ selfID │
│ (bit sync) │ fmt fmt │ ─ telecommands ─ [payload] │
│ │ RX: 111 110 109 108 107 106 │ ─ ECC ─ EOS │
│ │ 105 104 │ │
└────────────────┴──────────────────────────────┴──────────────────────────────┘
Time diversity: every symbol is transmitted twice. The stream alternates between the DX ("direct") position and the RX ("repeat") position, and the RX slot repeats the DX symbol sent 5 on-air positions earlier (≈ 42 ms at 1200 baud). A short burst of interference therefore corrupts only one of the two copies:
on-air pos: 16 17 18 19 20 21 22
DX RX DX RX DX RX DX
symbol: info[0] echo(fmt) info[1] echo(fmt) info[2] echo(i0) info[3]
↑
repeat of info[0] from pos 16
Integrity: the message ends with an ECC symbol — the XOR of the format specifier and all information symbols — followed by an end-of-sequence marker (117 = ack requested, 122 = ack, 127 = other). On top of that, each individual symbol carries its own parity field.
examples/generate_test_signal.py implements the transmit side of all of the
above in ~150 lines and is a good place to see the structure concretely.
vhf-dsc-decoder/
├── recorder/
│ ├── dsc_monitor.py # RTL-SDR soft-trigger recorder (numpy only)
│ └── dsc-monitor.service # systemd unit template
├── dsc_decoder/
│ ├── audio/loader.py # WAV loading via scipy (librosa only for MP3/OGG)
│ ├── demodulation/
│ │ ├── band_detector.py # VHF vs HF auto-detection (by trial demodulation)
│ │ ├── fsk_demod.py # Goertzel / bandpass FSK demodulators
│ │ └── clock_recovery.py
│ ├── decoding/
│ │ ├── symbol_decoder.py # 10-bit → symbol + parity validation
│ │ ├── sync_detector.py # bit-alignment and message-structure search
│ │ └── message_parser.py # phasing, DX/RX, field extraction, ECC
│ ├── protocol/
│ │ ├── constants.py # M.493 tables: parity, formats, telecommands
│ │ └── fields.py # MMSI / position / time field decoding
│ ├── output/formatter.py # text + JSON output
│ └── cli.py # python -m dsc_decoder …
├── examples/
│ └── generate_test_signal.py # synthesize a valid DSC burst as WAV
├── requirements.txt
└── README.md
Things that cost real debugging time — shared so you don't repeat them:
-
A consistent numeric ceiling is a code bug, not a radio problem. Decoder confidence plateaued at ~40–54 % on every recording. The cause: the parity check reversed the 3 parity bits (
format(n,'03b')[::-1]) when M.493 wants them MSB-first — so only 44 of 128 symbols (the palindromic ones) ever validated. If your metric hits the same ceiling on every input, inspect the code before blaming the antenna. -
100 % parity validity does not prove your bit polarity is right. The M.493 parity table is complement-symmetric, so with mark/space swapped everything still validates — you just decode wrong symbols. Per M.493 §1.1.5 on VHF: 1300 Hz = "B" = 1, 2100 Hz = "Y" = 0. Check decoded MMSIs against a known coast station, not just the parity ratio.
-
Verify your format-code table against M.493 Table 3. Several tables floating around online swap 114/116 or mislabel 120. If all your local traffic decodes as "Distress relay", it is far more likely your table is wrong than that ships are relaying distress all day (real traffic is dominated by format 120 individual test calls).
-
Search for message structure, not just a preamble. Weak signals mangle the dot pattern. Hunting for the phasing pattern (125/111…104) inside every decoded symbol stream at every bit offset finds messages that a preamble-first detector misses.
-
One process per dongle.
rtl_fm,rtl_test, SDR GUIs — only one can own the RTL-SDR at a time. Stop the monitor service before manual tests.
- Positions are rare in practice. Routine/test/safety calls usually carry no position field. Don't expect a map full of ships from DSC alone — that's what AIS is for.
- The message parser can occasionally pick the wrong sync candidate when a recording contains several overlapping valid phasings (DSC repeats each call, and repetitions can overlap in the parse window).
- HF DSC (100 baud) is auto-detected and demodulated, but the field parsing focuses on VHF-style calls; HF frequency encodings are only partially interpreted.
- Frequency/channel decoding covers the common VHF channel encodings
(
9000xx/9100xx); exotic encodings from M.493 Annex 3 are reported raw.
- Start with
--debugand watch the amplitude/SNR status lines. Background should idle around amplitude0.0005; real DSC bursts typically reach0.01+. - If you get no triggers: check the antenna first, then raise gain (
-g), then lower--min-power. If you get junk recordings: raise--min-poweror the SNR--threshold. - Cheap dongles drift with temperature. If decoding is marginal, try a ppm
correction (add
-p <ppm>to thertl_fmcommand indsc_monitor.py) or a TCXO dongle. - Decode confidence below ~0.7 usually means the audio is too weak/distorted;
try
--demod bandpass, which behaves differently on noisy signals.
Receiving maritime radio is legal in many countries, but the rules on disclosing intercepted (non-broadcast) communications vary — several jurisdictions prohibit publishing the content of communications not addressed to you, and decoded messages contain real vessel identities (MMSIs). Know your local law before sharing decoded traffic publicly. And never transmit on channel 70 — it is a protected distress and calling channel worldwide.
- ITU-R M.493 — Digital selective-calling system for use in the maritime mobile service (the authoritative spec; free to download from the ITU)
- ITU-R M.541 — operational procedures for DSC
- GMDSS on sigidwiki — signal identification, sample recordings you can feed straight to this decoder
- MID country codes — the first 3 digits of a ship MMSI identify its flag state
Public domain under The Unlicense — use it however you like, no attribution required, no warranty of any kind.
Written by SA6BJU.