NAVTEX — native SITOR-B / CCIR 476 maritime-safety decoder
A single-file receiver for the international maritime NAVTEX broadcast: 100-baud, 170-Hz-shift FSK carrying the CCIR 476 seven-bit constant-ratio code, demodulated with a two-tone Goertzel discriminator and character-phase-locked by exploiting the code's fixed four-marks-of-seven structure.
Rafe project · app/radio/navtex.py, test_navtex.py · RX only · CCIR 476 tables ported verbatim from fldigi navtex.cxx (GPL-3), demod/decode an independent implementation
Abstract
NAVTEX is the low-frequency maritime-safety text broadcast that every ship receives automatically: navigational and meteorological warnings, ice reports, search-and-rescue notices, keyed onto 518 kHz (international, English), 490 kHz (national languages) and 4209.5 kHz (tropical/HF), all listened to in USB. On the air it is SITOR-B — the collective (broadcast) mode of SITOR, identical in coding to amateur AMTOR FEC — which sends the CCIR 476 seven-bit alphabet at 100 baud over a 170 Hz FSK shift. Two error-control ideas do all the work: every valid character is a constant-ratio codeword with exactly four mark bits and three space bits, so any single bit-flip is detected as a broken 4/3 balance; and because a broadcast has no return path for retransmission requests, the mode substitutes forward error correction by time diversity — each character is transmitted twice, the direct (DX) copy and a repeat (RX) copy sent later, so a burst fade that destroys one copy usually spares the other.
This document specifies the Rafe decoder in app/radio/navtex.py exactly as it is built. Every constant is inlined in full below — tone frequencies, baud, the whole 35-word CCIR 476 code table, the control codes, the FEC-B diversity constants and the phase-lock thresholds — so the source is never needed to reproduce it. The decoder implements the full SITOR-B FEC-B time diversity: encode() produces the real DX/RX interleaved stream, acquisition locks bit + character + DX/RX-parity sync by correlating against the phasing sequence (necessary because every valid CCIR-476 code is weight-4, so a bit rotation stays "valid" and a weight test alone cannot find the boundary), and the decoder recombines each DX with its delayed RX repeat — repairing a mutilated DX from a good RX (verified by the error-repair test). The one part left out is that the ZCZC … NNNN message framing is not parsed in navtex.py; the decoder streams characters and the app layer breaks them into lines. This is documented here as protocol background and as a named limitation, so a reader can reimplement the current behaviour byte-for-byte and also see what a complete SITOR-B receiver would add.
1. Background
1.1 What NAVTEX is
NAVTEX (NAVigational TEleX) is part of the Global Maritime Distress and Safety System (GMDSS). Coast stations in each NAVAREA transmit Maritime Safety Information on a fixed schedule; a ship's dedicated NAVTEX receiver prints or displays the messages unattended, filtering by station and subject so the bridge only sees what is relevant to its area and to safety. The three carrier frequencies are wired into Rafe's quick-tune list:
"navtex": [490000, 518000, 4209500], # intl NAVTEX (USB)
— app/radio/digimodes.py
- 518 kHz — the international service, always English.
- 490 kHz — the national service, local language.
- 4209.5 kHz — an HF/"tropical" allocation used in some regions.
All three are received as USB audio: the SSB receiver translates the RF FSK into a pair of audio tones that the decoder discriminates.
1.2 SITOR-B: broadcast FEC, not ARQ
SITOR (Simplex Telex Over Radio; the maritime name for the CCIR 476/625 system, known to radio amateurs as AMTOR) has two modes:
Mode A (ARQ) — a point-to-point link. The information sending station transmits blocks of three characters and pauses; the receiving station replies with a control signal that either acknowledges the block or requests a repeat (Automatic Repeat reQuest). This needs a back-channel and an addressed partner.
Mode B (FEC / collective) — a broadcast. There is exactly one transmitter and an unknown number of listeners, so there is no return path and ARQ is impossible. Instead the transmitter unconditionally sends every character twice, separated in time, and each receiver independently accepts whichever copy survives. This is forward error correction: correction without feedback.
NAVTEX is Mode B. The module docstring states the mode and its coding directly:
NAVTEX is 100 baud FSK, 170 Hz shift, carrying CCIR 476 seven-bit codes
(exactly four mark bits, giving single-error detection) with forward error
correction by time diversity: each character is sent twice, the repeat
(RX) four character-positions after the original (DX). Received in USB.
— app/radio/navtex.py
1.3 The constant-ratio 4/3 code
CCIR 476 replaces the 5-bit ITA2 (Baudot/Murray) telegraph alphabet with a 7-bit alphabet chosen so that every legal character has exactly four 1 (mark) bits and three 0 (space) bits. There are exactly C(7,4) = 35 such words, which is precisely the number CCIR 476 needs (letters, figures, and the handful of control codes). The property is an error-detecting code by construction: a single bit-flip changes the mark count to 3 or 5, breaking the 4/3 balance, so it is detected with certainty. Any error pattern that does not preserve the exact weight of four is caught; only a balanced corruption (one mark lost and one gained simultaneously) can slip through undetected. In the code this is the whole validity test:
def valid_code(c):
return 0 <= c < 128 and _popcount(c) == 4
— app/radio/navtex.py
This constant-ratio structure does double duty in the Rafe decoder. It rejects corrupted characters (§5), and — because a correctly phased bitstream produces a run of 4/3-balanced words while a mis-phased one does not — it is also the signal the receiver uses to find the character boundary in the first place (§4.3). That second use is the elegant part: the same property that detects errors also bootstraps synchronisation, with no separate sync word.
1.4 Time-diversity FEC
Constant-ratio detection tells you a character is wrong but not what it should have been. SITOR-B recovers the character through time diversity: the DX (direct) copy and the RX (repeat) copy of each character are transmitted far enough apart that a fading burst or a click of interference is unlikely to hit both. A full SITOR-B receiver holds the DX character, waits for the matching RX character, and emits whichever of the two passes the 4/3 test (preferring the DX when both are good). That interleaving is what makes NAVTEX robust on a noisy LF/MF path.
The Rafe decoder implements both halves: the 4/3 detection test and the diversity recombination. The diversity depth is fixed by two module constants — FEC_DIVERSITY = 4 (the DX/RX separation) and _PHASING_PAIRS = 6 (the number of alpha/beta phasing-lead-in pairs) — so _combine() pairs each DX code with its RX repeat 2·FEC_DIVERSITY+1 = 9 code-slots later and emits DX if valid_code(DX) else RX if valid_code(RX) else drop — so a DX mutilated by a noise burst is repaired from its good RX copy (_combine/_emit_code in navtex.py):
c = dx if valid_code(dx) else (rx if valid_code(rx) else None)
if c is not None:
self._emit_code(c)
2. Signal structure — the exact numbers
2.1 Modulation
| Parameter | Value | Source |
|---|---|---|
| Modulation | 2-FSK (FSK / F1B), received as audio in USB | navtex.py |
| Symbol rate | 100 baud (bit = symbol) | navtex.py |
| Bit duration | 10 ms (1/100 s) | derived |
| Frequency shift | 170 Hz | navtex.py |
| Mark tone (bit = 1) | 1615.0 Hz | navtex.py |
| Space tone (bit = 0) | 1785.0 Hz | navtex.py |
| Audio centre | 1700 Hz (mid-point of mark/space) | derived |
| Character length | 7 bits = 70 ms | derived |
| Character rate | 100/7 ≈ 14.29 char/s | derived |
| Working sample rate | 12 000 Hz, s16le mono | navtex.py |
| Samples per bit | 120 (12000 / 100) | navtex.py |
| Samples per character | 840 (7 × 120) | derived |
The tone assignment is the decoder's default constructor:
def __init__(self, on_text, sr=12000, mark=1615.0, space=1785.0,
baud=100.0):
— app/radio/navtex.py
Note that mark is the lower tone (1615 Hz) and space the higher (1785 Hz); the 170 Hz shift is 1785 − 1615, and the audio centre 1700 Hz is where a NAVTEX signal sits when the SSB dial is tuned to the conventional offset. The app always constructs the decoder with these defaults — NavtexDecoder(self._on_navtex_char) (digimodes.py) — so 1615/1785 Hz at 100 baud is the operative configuration.
2.2 The audio path into the decoder
The radio delivers 48 kHz PCM. NAVTEX runs at 12 kHz, so the digimode manager decimates 4:1 (a 4-tap boxcar average, ÷4) before feeding the decoder:
elif self.mode == "navtex" and self._navtex:
self._navtex.feed(self._decimate_12k(pcm48k))
— app/radio/digimodes.py
def _decimate_12k(self, pcm48k: bytes) -> bytes:
data = self._carry + pcm48k
n = len(data) - len(data) % 8 # 4 samples -> 1
...
acc += int.from_bytes(data[j:j + 2], "little", signed=True)
...
out += struct.pack("<h", max(-32768, min(32767, acc // 4)))
— app/radio/digimodes.py
So the decoder's feed() always sees 12 kHz, signed-16-bit, little-endian, mono audio — exactly what test_navtex.py synthesises directly.
2.3 Bit / character / diversity layering
bit : 10 ms (120 samples @ 12 kHz)
character : 7 bits = 70 ms (840 samples) ── exactly 4 marks + 3 spaces
phasing : a lead-in of _PHASING_PAIRS = 6 (ALPHA, BETA) code pairs
= 12 codes = 84 bits precedes the traffic; the receiver
correlates against it to lock bit + character + DX/RX parity.
DX / RX : each character is sent twice. With FEC_DIVERSITY = 4 the DX
and RX copies are interleaved so the RX repeat lands
2·4+1 = 9 code-slots after its DX (four intervening DX
characters between them), i.e. 9 × 70 ms ≈ 630 ms apart —
enough separation that a short fade rarely lands on both.
Both halves are real in the current build: encode() produces exactly this stream (a phasing lead-in, then even slots = DX(char k), odd slots = RX(char k−4)), and the decoder's _combine() re-pairs each DX with the RX 9 slots later and emits the recombined character (§5.1–§5.2). The 9-slot spacing is the rxoff = 2·FEC_DIVERSITY+1 constant in _combine; the phasing lead-in is what the acquisition correlator locks onto (§4.3).
3. The CCIR 476 alphabet## 3. The CCIR 476 alphabet
CCIR 476 is a shifted alphabet, like ITA2: a LTRS (letters) control code and a FIGS (figures) control code toggle a persistent shift so the same 29 data words mean either a letter or a figure/symbol. Three data words — space, CR and LF — decode identically in both shifts. Six words are controls (LTRS, FIGS, and four phasing/idle/reserved codes). 29 data + 6 control = 35 = C(7,4), the entire constant-ratio space; there are no spare codewords.
3.1 Data codes
Bit order is MSB-first: the first bit received is bit 6, the last is bit 0, and the seven bits are packed code = (code << 1) | bit (navtex.py). Mark = 1.
| Hex | Binary (b6…b0) | LTRS | FIGS |
|---|---|---|---|
0x17 |
0010111 |
J | ' |
0x1B |
0011011 |
F | ! |
0x1D |
0011101 |
C | : |
0x1E |
0011110 |
K | ( |
0x27 |
0100111 |
W | 2 |
0x2B |
0101011 |
Y | 6 |
0x2D |
0101101 |
P | 0 |
0x2E |
0101110 |
Q | 1 |
0x35 |
0110101 |
G | & |
0x39 |
0111001 |
M | . |
0x3A |
0111010 |
X | / |
0x3C |
0111100 |
V | ; |
0x47 |
1000111 |
A | - |
0x4B |
1001011 |
S | BEL (\x07, suppressed) |
0x4D |
1001101 |
I | 8 |
0x4E |
1001110 |
U | 7 |
0x53 |
1010011 |
D | $ |
0x55 |
1010101 |
R | 4 |
0x56 |
1010110 |
E | 3 |
0x59 |
1011001 |
N | , |
0x5C |
1011100 |
space | space |
0x63 |
1100011 |
Z | " |
0x65 |
1100101 |
L | ) |
0x69 |
1101001 |
H | # |
0x6C |
1101100 |
LF (\n) |
LF (\n) |
0x71 |
1110001 |
O | 9 |
0x72 |
1110010 |
B | ? |
0x74 |
1110100 |
T | 5 |
0x78 |
1111000 |
CR (\r) |
CR (\r) |
This is transcribed exactly from the two dictionaries in the source (_CODE_TO_LTRS, navtex.py; _CODE_TO_FIGS, navtex.py). Every hex value above has a popcount of exactly 4 — verified programmatically against the tables. The FIGS entry for 0x4B is the ASCII BEL \x07; the decoder deliberately does not emit it (§5.3).
3.2 Control codes
| Constant | Hex | Binary | Meaning | Source |
|---|---|---|---|---|
CODE_LTRS |
0x5A |
1011010 |
shift to letters | navtex.py |
CODE_FIGS |
0x36 |
0110110 |
shift to figures | navtex.py |
CODE_ALPHA |
0x0F |
0001111 |
RQ / idle-alpha (phasing) | navtex.py |
CODE_BETA |
0x33 |
0110011 |
idle-beta / signal repetition partner | navtex.py |
CODE_CHAR32 |
0x6A |
1101010 |
reserved control (CCIR "char 32") | navtex.py |
CODE_REP |
0x66 |
1100110 |
idle-beta / phasing / rep | navtex.py |
CODE_LTRS = 0x5A
CODE_FIGS = 0x36
CODE_ALPHA = 0x0F # RQ / idle-alpha
CODE_BETA = 0x33
CODE_CHAR32 = 0x6A
CODE_REP = 0x66 # idle-beta (phasing/rep)
— app/radio/navtex.py
CODE_ALPHA (0x0F) is the "signal-repetition/idle" character sent during phasing; CODE_REP/CODE_BETA are the idle-beta partners. During the idle and phasing intervals the transmitter alternates these control codes, giving the receiver a steady stream of valid 4/3 words to lock onto without printing anything (they are filtered at navtex.py).
3.3 The 4/3 constant-ratio rule
Every one of the 35 words above — data and control alike — has Hamming weight 4. The decoder enforces this on every received character before doing anything with it (valid_code, navtex.py); characters that fail are silently dropped (navtex.py). This single rule is simultaneously the error detector and, aggregated over many characters, the synchronisation metric.
4. Demodulation, step by step
The receiver is NavtexDecoder (navtex.py). It is a streaming, push-driven object: audio arrives in arbitrary-sized byte chunks via feed(), and decoded characters leave through the on_text callback. State persists across chunks (a sample carry, a bit queue, the shift state, the lock state).
4.1 Byte framing and sample conversion
feed() prepends any half-sample carried from the previous call, converts each signed-16-bit little-endian sample to a float in ±1, and appends it to a sample buffer:
def feed(self, pcm12k):
data = self._carry + pcm12k
n = len(data) - (len(data) % 2)
self._carry = data[n:]
for i in range(0, n, 2):
v = int.from_bytes(data[i:i + 2], "little", signed=True) / 32768.0
self._buf.append(v)
— app/radio/navtex.py
4.2 FSK discrimination — a two-tone Goertzel per bit
There is no continuous PLL or filter bank. The buffer is chopped into fixed-length bit windows of sps = sr/baud = 120 samples, and each window is reduced to a single hard bit by comparing the power at the mark tone with the power at the space tone, both measured with a Goertzel evaluation over that window:
step = self.sps
while len(self._buf) >= int(step):
win = self._buf[:int(step)]
del self._buf[:int(step)]
mp, sp = self._goertzel_pair(win)
self._bitq.append(1 if mp >= sp else 0)
self._process_bits()
— app/radio/navtex.py
The Goertzel itself is the textbook second-order recurrence, returning the squared magnitude at freq:
@staticmethod
def _goertzel(samples, freq, n, sr=12000):
w = 2 * math.pi * freq / sr
c = 2 * math.cos(w)
s1 = s2 = 0.0
for x in samples:
s0 = x + c * s1 - s2
s2, s1 = s1, s0
return s1 * s1 + s2 * s2 - c * s1 * s2
— app/radio/navtex.py
_goertzel_pair (navtex.py) runs this twice, at self.mark (1615 Hz) and self.space (1785 Hz), over the same 120-sample window. The decision is a plain energy comparison: bit = 1 (mark) iff mark-power ≥ space-power. Because a Goertzel over 120 samples has an equivalent-noise bandwidth of ~100 Hz — comfortably narrower than the 170 Hz shift — the two bins are well separated and the hard decision is reliable well into single-digit SNR (see §7).
Implementation note: the static _goertzel defaults sr=12000 and the caller does not pass self.sr (navtex.py), so the tone bins are always computed for a 12 kHz rate. This is correct for the only rate the app ever uses (12 kHz), but it means the object's sr argument would have to stay 12000 for the Goertzel to be right — a coupling worth knowing if the class is reused at another rate (§8).
4.3 Bit clock and character/parity phasing
The bit clock is free-running: windows are exactly 120 samples, with no early/late gate or fractional-sample interpolation. The decoder does not track the transmitter's symbol phase at the bit level; all timing recovery happens at the character level. But the 4/3 constant-ratio property alone cannot find the boundary — because every valid codeword has weight 4, a whole-bit rotation of the stream still lands on weight-4 groups, so a weight test can tell neither the true 7-bit boundary from a rotated one nor which slot is DX vs RX. Acquisition therefore correlates against the known phasing sequence.
The phasing pattern. One phasing period is the seven bits of CODE_ALPHA (0x0F = 0001111) followed by the seven bits of CODE_BETA (0x33 = 0110011) — 14 bits, MSB first:
_PHASING_BITS = _code_bits(CODE_ALPHA) + _code_bits(CODE_BETA)
# = [0,0,0,1,1,1,1] + [0,1,1,0,0,1,1]
Acquisition matches four periods of it, pat = _PHASING_BITS * 4 (56 bits), sliding it over the queued hard bits and taking the minimum-Hamming-distance offset:
pat = _PHASING_BITS * 4
if len(bits) < len(pat) + 14: # need >= 70 bits queued
return
best_off, best_d = 0, len(pat) + 1
for off in range(len(bits) - len(pat)):
d = 0
for j in range(len(pat)):
if bits[off + j] != pat[j]:
d += 1
if d >= best_d: # early-out once worse than best
break
if d < best_d:
best_d, best_off = d, off
if best_d > len(pat) * 0.20: # > 11 mismatches -> not phasing; slide
if len(bits) > 4000:
del bits[:2000]
return
self._locked = True
self._rp = best_off # boundary aligned to an ALPHA (DX slot)
- Acquire: wait until at least
len(pat) + 14= 70 bits are queued, then slide the 56-bit window over the queue and take the offset of minimum Hamming distance to the alpha/beta pattern. A best distance ≤ 20 % of 56 = 11 mismatches declares lock, and the read pointer_rpis set to that offset. Because the pattern begins on an ALPHA and ALPHA occupies the DX slots, locking here aligns the 7-bit character boundary and the DX/RX slot parity at once — the whole reason the phasing correlator is used instead of a bare weight test. - Slip search: if no offset is close enough, the signal is not NAVTEX phasing yet; once the queue exceeds 4000 bits the oldest 2000 are dropped and the hunt continues.
Once locked, characters are read at the fixed 7-bit stride and handed to the FEC-B combiner (§4.4). Two housekeeping steps then run in _process_bits:
- Buffer trim: once the read pointer passes 700 bits the consumed prefix is deleted and
_rpreset to 0, preserving character phase. - Re-lock check: if the decoder has caught up (fewer than 7 unread bits) it scores the last 70 bits (10 characters) with
_best_offset— a helper that, over all 7 candidate offsets, sums +1 per valid 4/3 group and −1 per invalid group:
def _best_offset(self, bits):
best_off, best_score = 0, -1
for off in range(7):
score = 0
for base in range(off, len(bits) - 6, 7):
code = 0
for b in range(7):
code = (code << 1) | bits[base + b]
score += 1 if valid_code(code) else -1
if score > best_score:
best_score, best_off = score, off
return best_off, best_score
# used only for the re-lock garbage check:
if self._locked and len(bits) - self._rp < 7 and self._rp >= 7:
recent = bits[max(0, self._rp - 70):self._rp]
if recent:
_, sc = self._best_offset(recent)
if sc < -3:
self._locked = False
A best score below −3 over those 70 bits means the stream has drifted into noise, so _locked is cleared and the next feed re-acquires from the phasing correlator. Note the split of roles: the phasing correlator acquires (only it can resolve the weight-4 boundary ambiguity and the DX/RX parity), while _best_offset — the constant-ratio scorer — survives only as a cheap garbage detector for re-lock.
4.4 Character assembly
Once locked, characters are pulled at the fixed 7-bit stride, MSB first:
while len(bits) - self._rp >= 7:
code = 0
for b in range(7):
code = (code << 1) | bits[self._rp + b]
self._rp += 7
self._on_code(code)
Each assembled 7-bit value is handed to _on_code, which buffers it for the FEC-B time-diversity combiner; validation, control handling and shift-aware table lookup then happen in _combine / _emit_code (§5).
5. Decoding, step by step
5.1 _on_code -> _combine -> _emit_code — the character pipeline
Assembled codes are not decoded in place; they are buffered and run through the FEC-B time-diversity combiner. _on_code simply appends the code and drives the combiner:
def _on_code(self, c):
self._codes.append(c)
self._combine()
_combine first locks the DX/RX slot parity from the phasing lead-in (the ALPHA codes sit in the DX slots, so whichever slot-parity carries more ALPHA is the DX parity), then walks the buffer in DX/RX pairs — pairing each DX with the RX repeat rxoff = 2·FEC_DIVERSITY+1 = 9 slots later — and emits the DX if it is 4/3-valid, else its RX, else nothing:
def _combine(self):
codes = self._codes
if self._parity is None:
if len(codes) < 2 * self._div + 12: # need phasing + one span
return
ev = sum(1 for i in range(0, len(codes), 2) if codes[i] == CODE_ALPHA)
od = sum(1 for i in range(1, len(codes), 2) if codes[i] == CODE_ALPHA)
self._parity = 0 if ev >= od else 1
self._ci = self._parity
rxoff = 2 * self._div + 1 # = 9
while self._ci + rxoff < len(codes):
dx, rx = codes[self._ci], codes[self._ci + rxoff]
c = dx if valid_code(dx) else (rx if valid_code(rx) else None)
if c is not None:
self._emit_code(c)
self._ci += 2
if self._ci > 400: # trim consumed DX slots
del codes[:self._ci]
self._ci = 0
The recombined code is finally decoded by _emit_code, which drops controls, applies the LTRS/FIGS shift, and looks the code up in the current shift page:
def _emit_code(self, c):
if c in (CODE_REP, CODE_ALPHA, CODE_BETA, CODE_CHAR32):
return
if c == CODE_LTRS:
self._shift = False
return
if c == CODE_FIGS:
self._shift = True
return
table = _CODE_TO_FIGS if self._shift else _CODE_TO_LTRS
ch = table.get(c)
if ch and ch not in ("\x07",):
self.on_text(ch)
— app/radio/navtex.py
The order matters and each step is deliberate:
- 4/3 validity. The constant-ratio test is applied in
_combine:valid_codeaccepts only weight-4 codewords, and the combiner takesDX if valid else RX if valid else drop. This is the error-detection step, and it drives the time-diversity repair — a mutilated DX is silently replaced by a good RX (§5.2). - Idle / phasing / reserved controls.
CODE_REP(0x66),CODE_ALPHA(0x0F),CODE_BETA(0x33) andCODE_CHAR32(0x6A) carry no printable payload — they are the phasing/idle fill and reserved control — so_emit_codeconsumes them silently. They are still valid weight-4 codewords, which is why an idle NAVTEX carrier (a run of alpha/beta) keeps the decoder locked between messages. - Shift control.
CODE_LTRS(0x5A) setsself._shift = False(letters);CODE_FIGS(0x36) sets itTrue(figures). The shift is persistent across characters, exactly as in ITA2, until the next shift code.self._shiftstartsFalse(letters) atreset(). - Table lookup. The current shift selects
_CODE_TO_FIGSor_CODE_TO_LTRS; the codeword is looked up and, if present, emitted throughon_text. Space (0x5C), CR (0x78) and LF (0x6C) resolve to the same glyph in either table, so they print correctly regardless of shift.
5.2 Time-diversity FEC: how the repair works
The decoder holds each DX character, waits for its RX repeat (buffered 9 code-slots later), and emits whichever copy is 4/3-valid — repairing a broken DX from a good RX. _combine() does exactly this once the phasing correlation has locked the DX/RX parity (§4.3), so both time-diversity copies are used.
Two consequences worth stating:
- A single-pass decoder that printed every valid code would emit each character twice on a real DX/RX stream. The Rafe decoder consumes the stream in DX/RX pairs and emits one recombined character per pair (
_ci += 2per step), so it decodes genuine interleaved NAVTEX without doubling. - The repair is a hard DX-or-RX decision, not a soft combine: it takes the DX when the DX is weight-4, else the RX when the RX is weight-4, else it drops the character. The error-repair self-test (§7.1) mutilates one DX code and confirms the RX copy restores it.
encode()produces exactly the interleaved format the combiner expects (§6), and the DX↔︎RX offset is therxoff = 2·FEC_DIVERSITY+1 = 9slots documented in §2.3.
5.3 BEL suppression
The FIGS meaning of 0x4B is ASCII BEL (\x07) — historically the "who-are-you"/ alarm character. The decoder maps it in the table but filters it at emission time:
if ch and ch not in ("\x07",):
self.on_text(ch)
— app/radio/navtex.py
So 0x4B prints S in letters shift and prints nothing in figures shift.
5.4 Message framing — ZCZC B1B2B3B4 … NNNN
At the protocol level a NAVTEX message is wrapped in a fixed frame:
ZCZC B1 B2 B3 B4 <- phasing/idle precedes this
<message text …>
NNNN <- end of message
ZCZC— the start-of-message marker.B1— a single letter identifying the transmitting station (the coast station / transmitter identity within the NAVAREA).B2— the subject indicator: the message category (e.g.Anavigational warning,Bmeteorological warning,DSAR,Emet forecast,Lother nav warning, etc.). A ship's receiver can reject subjects it does not want — except the mandatory safety subjects, which cannot be suppressed.B3 B4— a two-digit serial number (01…99, wrapping) that lets the receiver skip a message it has already stored, so the same broadcast is not printed twice on successive schedules.NNNN— the end-of-message marker; the receiver returns to phasing/idle.
The Rafe decoder does not parse any of this. navtex.py has no ZCZC/NNNN detection and no B1–B4 handling; those tokens simply appear in the decoded character stream as ordinary letters. Downstream, the digimode manager only splits the stream into display lines:
def _on_navtex_char(self, ch):
self._navtex_line += ch
if ch in "\r\n" or len(self._navtex_line) >= 80:
line = self._navtex_line.strip()
self._navtex_line = ""
if line:
self._emit({"mode": "navtex", "text": line})
— app/radio/digimodes.py
A line is emitted on CR/LF or after 80 characters. So ZCZC, the B1B2B3B4 header, the body and NNNN reach the operator verbatim as text — correct and readable, but not structured; message selection by station/subject/serial is a documented gap (§8).
6. Encoder (self-test and reference TX format)
navtex.py carries an encoder used by the round-trip test (and as the reference SITOR-B TX format). It is two functions. _encode_chars turns text into a raw CCIR 476 code sequence with LTRS/FIGS shifts — no diversity, one code per character:
def _encode_chars(text):
codes = []
shift = None # None unknown, False letters, True figures
for ch in text.upper():
if ch == " ":
codes.append(0x5C)
continue
if ch in _LTRS_TO_CODE:
if shift is not False:
codes.append(CODE_LTRS)
shift = False
codes.append(_LTRS_TO_CODE[ch])
elif ch in _FIGS_TO_CODE:
if shift is not True:
codes.append(CODE_FIGS)
shift = True
codes.append(_FIGS_TO_CODE[ch])
return codes
encode then wraps that raw sequence in the full SITOR-B (FEC collective-B) on-air format — a phasing lead-in followed by the DX/RX time-diversity interleave:
FEC_DIVERSITY = 4 # DX and its RX repeat are 2*D+1 = 9 code-slots apart
_PHASING_PAIRS = 6 # alpha(DX)/beta(RX) lead-in so the RX locks the parity
def encode(text, diversity=FEC_DIVERSITY):
raw = _encode_chars(text)
stream = []
for _ in range(_PHASING_PAIRS): # phasing: alpha in DX, beta in RX
stream += [CODE_ALPHA, CODE_BETA]
n = len(raw)
for k in range(n + diversity): # +diversity to flush trailing RX
dx = raw[k] if k < n else CODE_BETA
rx = raw[k - diversity] if 0 <= k - diversity < n else CODE_BETA
stream += [dx, rx]
return stream
Behaviour to note when reimplementing:
- Phasing. The stream opens with
_PHASING_PAIRS = 6(ALPHA, BETA)pairs — 12 codes, ALPHA always in the even (DX) slot — so the receiver's correlator can lock bit, character and DX/RX parity before any traffic arrives. - Interleave. Thereafter even slots carry
DX = raw[k]and odd slots carryRX = raw[k − diversity], so every character's repeat is transmitted2·diversity+1 = 9slots after its first copy. The loop runsn + diversitytimes, padding the leading RX and trailing DX slots withCODE_BETAidle so no real character is lost at the ends. - Shifts and space.
_encode_charsupper-cases its input, emits space directly as0x5Cwith no shift change, and inserts a shift code (CODE_LTRS/CODE_FIGS) only when the required shift differs from the current one (shiftstartsNone, so the first non-space character always emits a shift). Characters in neither table are silently skipped. - The reverse maps
_LTRS_TO_CODE/_FIGS_TO_CODEare built by inverting the decode tables. This encoder is a code-stream generator (used by the loopback test); it is not an audio modulator — there is no NAVTEX TX path (§8).
7. Interoperability and validation
7.1 The round-trip self-test
test_navtex.py is a closed-loop AWGN test: it synthesises FSK audio from a full SITOR-B code stream, adds Gaussian noise at a chosen SNR, feeds it through NavtexDecoder, and checks the decoded text. It uses the same tone/rate constants as the decoder:
SR = 12000
BAUD = 100.0
MARK = 1615.0
SPACE = 1785.0
— test_navtex.py
The synthesiser (synth) calls encode(text), so the audio carries the real FEC-B stream — the 6-pair alpha/beta phasing lead-in and the DX/RX interleave, which is exactly what the phasing correlator needs to lock. It prepends a 16-bit all-mark idle run, emits each code as 7 MSB-first bits, and renders each bit as int(sps) = 120 samples of a continuous-phase sine at the mark or space frequency, plus random.gauss noise scaled to the requested SNR. An optional corrupt=k argument flips a bit in DX code k (codes[k] ^= 0x40) to exercise the RX repair. The PCM is fed to the decoder in 2400-byte chunks to exercise the streaming carry/lock logic across chunk boundaries.
Test vectors and thresholds:
tests = [
("ZCZC GALE WARNING", dict(snr_db=25)),
("TEST DE GNAVTEX", dict(snr_db=20)),
("SECURITE 12345", dict(snr_db=18)),
("HELLO WORLD", dict(snr_db=40, corrupt=20)), # DX mutilated -> RX repairs it
]
— test_navtex.py
The first three deliberately span both shifts (letters + the figures 12345) and include the ZCZC token, pushing the SNR down to 18 dB in AWGN. The fourth is the time-diversity error-repair case: at a clean 40 dB it mutilates DX code 20, so the character can only be recovered from its RX copy — a case a single-pass decoder fails by construction. The pass criterion is lenient about a possible leading shift artifact: it accepts either text as a substring of the decode, or a whitespace/CR/LF-normalised exact match. The script exits 0 only if all four pass.
7.2 Provenance of the tables (on-air interoperability)
The CCIR 476 code tables and control values are ported verbatim from fldigi's navtex.cxx (Dave Freese W1HKJ and contributors, GPL-3), with everything else an independent implementation:
The CCIR 476 code tables and control-code values are taken verbatim from
fldigi's navtex.cxx (Dave Freese W1HKJ and contributors, GPL-3 - see the
credits page). Everything else here is an independent implementation.
— app/radio/navtex.py
Sharing fldigi's alphabet is the interoperability guarantee at the character level: any station fldigi decodes, this table decodes identically. NAVTEX is a global, tightly standardised broadcast (ITU-R M.540/M.625), so there are no vendor dialects to chase — the alphabet, tones, baud and shift are fixed by treaty.
7.3 What is not validated in-repo
Be candid: the repository contains only the synthetic AWGN round-trip test. There is no committed real off-air capture and no third-party cross-check harness (e.g. against YaND or a recorded 518 kHz WAV) under version control — a search of the tree finds ZCZC only inside test_navtex.py and no NAVTEX audio fixtures. On-air interoperability therefore rests on (a) the fldigi-identical alphabet (§7.2) and (b) the standard being a single global specification, rather than on a checked-in real-signal regression. Adding a captured-signal fixture is a natural next validation step (§8).
8. Limitations
Honest inventory, roughly in order of on-air impact:
- DX/RX time-diversity recombination — implemented. The decoder pairs each DX with its RX repeat and repairs a mutilated DX from a good RX (
_combine), with phasing-correlation acquisition for the bit/character/parity sync. (Refinement still open: it takes a hard DX-or-RX decision; a soft-combine or per-bit majority across the two copies would squeeze out a little more low-SNR performance.) - No
ZCZC … NNNN/ B1–B4 framing. Messages are streamed as raw text and merely line-wrapped (digimodes.py); there is no start/end detection, no de-duplication by serial number, and no station/subject filtering — the selective- message features that define a real NAVTEX receiver. - Free-running bit clock. There is no sub-bit timing recovery — windows are a fixed 120 samples (
navtex.py). A transmitter/receiver sample-rate offset or baud error accumulates phase over a long message; robustness comes only from the character-level re-lock (navtex.py), not from tracking bit phase. - Hard-decision FSK only. The demod is a bare mark-vs-space energy comparison (
navtex.py); there is no soft-decision metric, no AGC, and no adaptive tone tracking, so it does not exploit how close a bit was to the threshold. sris effectively pinned to 12 kHz. The Goertzel'ssrdefaults to 12000 and the caller never overrides it (navtex.py), so constructing the decoder at a differentsrwould mis-place the tone bins. Fine for the app (always 12 kHz), but a reuse hazard.- Fixed tone pair. Mark/space default to 1615/1785 Hz (
navtex.py); there is no automatic centre-frequency search, so the operator must tune the SSB dial to the conventional offset for the 170 Hz pair to land on those bins. - RX only.
encodeexists but there is no NAVTEX/SITOR modulator or TX path (and transmitting on NAVTEX frequencies is not a licensed amateur activity in any case). - Validation is synthetic only. No real-capture regression fixture is committed (§7.3).
None of these change the architecture; each is a bounded addition on a working demod/ decode core.
9. References
- ITU-R Recommendation M.540 — Operational and technical characteristics for a system for the broadcast to ships of navigational and meteorological warnings and urgent information (NAVTEX). The NAVTEX service definition: 518/490/4209.5 kHz, message format, subject indicators.
- ITU-R Recommendation M.625 (formerly CCIR 476-5 / 625) — Direct-printing telegraph equipment employing automatic error correction in the maritime mobile service (SITOR). Defines the 7-bit constant-ratio alphabet, the Mode A (ARQ) and Mode B (FEC) procedures, and the DX/RX time-diversity interleave.
- ITU-R Recommendation M.476 — the original CCIR 476 recommendation from which the code alphabet used here descends.
- IMO NAVTEX Manual — operational rules: NAVAREA/coast-station schedules, the B1 station character, B2 subject indicators, B3B4 serial numbering, and the
ZCZC … NNNNmessage frame. - ITU-T Recommendation S.1 / ITA2 — the 5-bit International Telegraph Alphabet No. 2 (Baudot/Murray) that CCIR 476 extends to 7 bits with the LTRS/FIGS shift model.
- fldigi
navtex.cxx— Dave Freese W1HKJ and contributors (GPL-3); the source of the CCIR 476 code and control tables transcribed inapp/radio/navtex.py.
10. Implementation & reproduction
| file | role |
|---|---|
app/radio/navtex.py |
the whole decoder: CCIR 476 tables, Goertzel FSK demod, phasing-correlation acquisition, _combine FEC-B recombiner, shift-aware decode, plus the SITOR-B encode |
app/radio/digimodes.py |
app integration: 48→12 kHz decimation (_decimate_12k), audio feed, line-wrap of decoded text (_on_navtex_char), quick-tune frequencies |
test_navtex.py |
synthetic AWGN round-trip self-test (25/20/18 dB, plus a 40 dB DX-corruption / RX-repair case) |
Runtime API:
from app.radio.navtex import NavtexDecoder
def on_text(ch): # called per decoded character
print(ch, end="")
dec = NavtexDecoder(on_text) # defaults: sr=12000, mark=1615, space=1785, baud=100
dec.feed(pcm12k_bytes) # s16le mono @ 12 kHz; call repeatedly, any chunk size
Everything runs in pure Python with only the standard-library math module; the sole data artefact is the two 29-entry code tables (navtex.py), transcribed once from fldigi and committed like every other constant table in the project.