RDS: a native Radio Data System decoder for the FM broadcast subcarrier¶
A clean-room, dependency-light demodulator + decoder for the 57 kHz RDS subcarrier — coherent DBPSK/biphase PHY, the (26,16) shortened cyclic block code with single-bit error correction, and group parsing to PI / PS / PTY / RadioText / clock-time / alternate frequencies.
Rafe project · app/radio/rds.py, tests in test_rds.py · native (NumPy + SciPy
signal only)
1. Abstract¶
RDS (Radio Data System, IEC 62106 / EN 50067) is the low-rate digital sideband that
rides on FM broadcast stations and carries the station identity: the PI programme
identifier, the 8-character PS programme-service name, the PTY programme-type code,
the TP/TA traffic flags, 64-character RadioText, the clock-time, and the list of
alternate frequencies. app/radio/rds.py is a self-contained implementation of the
full stack, written clean-room from the standard: a coherent DBPSK/biphase physical
layer that recovers the 57 kHz subcarrier from the 19 kHz stereo pilot, the (26,16)
shortened cyclic block code with its five offset words for block synchronisation,
error detection, and single-bit error correction, and a group parser that assembles
the decoded 16-bit words into station state.
The code is exact and small. The generator polynomial is one integer constant
(_G = 0x5B9, rds.py), the five offset words are one dictionary (OFFSET,
rds.py), and the block sync exploits an elegant algebraic identity: a correctly
received block's 10-bit syndrome equals its offset word, so detection, error
checking, and block-boundary alignment are all the same comparison. The
single-bit-error correction table (_ERR1) is generated in one comprehension from
that same syndrome function. The PHY (below the block layer) recovers a coherent
57 kHz reference by cubing the 19 kHz pilot to synthesise its third harmonic,
coherently down-converts the RDS band, runs a biphase (Manchester) matched filter,
picks symbol timing by maximum energy, hard-decides, and differentially decodes.
Decoded group types are 0A/0B (PI/PS/PTY/TP/TA, plus alternate frequencies in
0A), 2A/2B (RadioText with its text A/B flag), and 4A (clock-time). It is
validated end-to-end by an in-repo MPX modem round-trip (test_rds.py): synthesise
a 19 kHz-pilot + 57 kHz-DSB-SC composite, add noise, and recover "RADIO 1" /
"BBC R4" and RadioText through the full chain, plus unit tests for the single-bit
correction, the 4A clock decode, and the 0A AF decode.
The distinguishing property, as with the rest of the native stack, is no external
binary: this is the same job redsea does, done in ~310 lines of NumPy/SciPy that
run on the prod box.
2. Background¶
2.1 Where RDS lives — the FM composite (MPX)¶
A stereo FM broadcast is transmitted as a composite baseband signal (the "MPX",
multiplex) that frequency-modulates the main carrier. Below ~15 kHz sits the
L+R mono sum; a pure 19 kHz pilot tone marks the station as stereo; the
L−R difference is amplitude-modulated (DSB-SC) onto a suppressed 38 kHz
subcarrier (= 2 × 19 kHz); and RDS occupies a third harmonic slot at 57 kHz
(= 3 × 19 kHz). Anchoring every subcarrier to integer multiples of the same 19 kHz
pilot is what makes coherent recovery possible — the receiver regenerates 38 kHz
and 57 kHz references directly from the pilot rather than running free-running
oscillators.
rds.py states this dependency plainly in its module docstring:
Needs the FM composite (an SDR doing wideband FM demod); the narrow demodulated audio off a communications rx does not contain the 57 kHz subcarrier.
So the decoder's input is the demodulated MPX from a wideband FM discriminator (an SDR), sampled fast enough to contain 57 kHz — not the ~3 kHz audio a communications receiver would hand you.
2.2 The 57 kHz waveform — biphase over DBPSK¶
RDS data is carried at 1187.5 bit/s. Each data bit is first differentially
encoded (so the decoder is immune to the 180° phase ambiguity of BPSK carrier
recovery — an unavoidable ambiguity because the 57 kHz reference is regenerated
without an absolute phase), then shaped as a biphase symbol (a Manchester
symbol: a half-bit of one polarity followed by a half-bit of the opposite
polarity). The biphase waveform has no DC component and puts its energy in two
lobes either side of 57 kHz, so it coexists cleanly with the 38 kHz stereo
subcarrier below it. The biphase symbols amplitude-modulate the 57 kHz subcarrier
as DSB-SC (double-sideband suppressed carrier) — equivalently, BPSK on 57 kHz.
The chain, from synth_mpx (rds.py), is:
2.3 The framing — blocks and groups¶
The bitstream is organised into a rigid two-level frame (module docstring, rds.py):
one GROUP = 104 bits = 4 BLOCKS × 26 bits ; each BLOCK = 16 info + 10 check
┌───────────────┬───────────────┬────────────────┬───────────────┐
│ BLOCK 1 │ BLOCK 2 │ BLOCK 3 │ BLOCK 4 │
│ 16 info+10ck │ 16 info+10ck │ 16 info+10ck │ 16 info+10ck │
│ offset A │ offset B │ offset C / C' │ offset D │
│ = PI code │ = type+flags │ = payload │ = payload │
└───────────────┴───────────────┴────────────────┴───────────────┘
syndrome=0FC syndrome=198 168 / 350 syndrome=1B4
Each 26-bit block is a codeword of a (26,16) shortened cyclic code: 16 information bits followed by 10 check bits, the check bits computed from the info bits by the RDS generator polynomial and then XOR-ed with a per-position offset word (A, B, C, C′, or D). The offset word is what marks which block-in-group you are looking at, and — because of the algebraic identity in §5.1 — recovering it simultaneously verifies the check bits and locks the block boundary. Four blocks in the fixed sequence A, B, C-or-C′, D form one group; the group type (in block 2) says how to interpret blocks 3 and 4.
3. Signal structure — the exact numbers¶
Every constant below is taken verbatim from rds.py.
| Quantity | Value | Source |
|---|---|---|
Subcarrier FC |
57 000.0 Hz | rds.py |
Bit rate BAUD |
1187.5 bit/s | rds.py |
Stereo pilot PILOT |
19 000.0 Hz (FC = 3·PILOT) |
rds.py |
| Block length | 26 bits (16 info + 10 check) | rds.py |
| Group length | 104 bits = 4 × 26 | rds.py |
Generator _G |
0x5B9 = x¹⁰+x⁸+x⁷+x⁵+x⁴+x³+1 | rds.py |
Two exact ratios pin everything together (confirmed numerically):
- 1187.5 = 57000 / 48 — there are exactly 48 subcarrier cycles per data
bit. Equivalently
BAUD = FC/48 = 3·PILOT/48. - The group rate is
1187.5 / 104 ≈ 11.42groups/s; a full 8-char PS name (four 0A groups) therefore repeats a few times per second.
Sampling. The reference sample rate used throughout the tests and the
default arguments is fs = 171000.0 Hz (synth_mpx/demod_bits/decode_mpx
defaults, rds.py). This is deliberately 3 × FC, which makes the
samples-per-bit an exact integer:
sps = round(fs / BAUD) = round(171000 / 1187.5) = 144 # rds.py
half = sps // 2 = 72 # rds.py (half-bit = one biphase lobe)
So one bit spans 144 samples and one biphase lobe spans 72. (In the demodulator
sps = fs / BAUD is kept as the float 144.0, rds.py.) At fs = 171000 the
57 kHz subcarrier is only 3 samples/cycle, which is above Nyquist for the
57000 ± 2400 RDS band and is all the synthetic test needs; a real SDR MPX feed
would be sampled far higher and the same code applies unchanged.
Occupied bandwidth. The demodulator treats RDS as 57000 ± 2400 Hz (the
_bandpass in rds.py) and, after coherent down-conversion, low-passes the
baseband at 2400 Hz. The biphase spectrum's main lobes fall comfortably inside this
±2.4 kHz window.
4. Demodulation — MPX to data bits, step by step¶
This is demod_bits(mpx, fs=171000.0) (rds.py). The whole receiver PHY is
coherent and driven off the pilot:
MPX ─┬─▶ BP 19k±500 ──▶ (·)³ ──▶ BP 57k±800 ──▶ RMS-normalise ──▶ ref (coherent 57 kHz)
│ │
└─▶ BP 57k±2400 ─────────────────────────▶ × ref ──▶ LP 2.4k ─▶ MF[+1..−1] ─▶ timing ─▶ sign ─▶ diff-dec ─▶ bits
4.1 Coherent 57 kHz recovery — cube the pilot¶
The 57 kHz subcarrier was transmitted coherently with (locked to) the 19 kHz pilot, so the receiver regenerates it rather than tracking it with a PLL:
pilot = _bandpass(mpx, fs, PILOT - 500, PILOT + 500) # isolate 19 kHz
ref = _bandpass(pilot ** 3, fs, FC - 800, FC + 800) # 3rd harmonic → 57 kHz
ref = ref / (_np.sqrt(_np.mean(ref ** 2)) + 1e-12) # unit-RMS
Cubing a tone generates its third harmonic: cos³θ = ¾cosθ + ¼cos3θ, and the
cos3θ term is exactly the 57 kHz component, phase-locked to the pilot. Band-pass
around 57000 ± 800 isolates it and it is RMS-normalised to unit power so the
subsequent product has a stable scale. ref is the coherent local carrier.
4.2 Coherent down-conversion (synchronous DSB-SC detection)¶
rds = _bandpass(mpx, fs, FC - 2400, FC + 2400) # isolate RDS band
bb = rds * ref # synchronous demod
b, a = _sig.butter(4, 2400 / (fs / 2), "low")
bb = _sig.filtfilt(b, a, bb) # keep baseband lobe
Multiplying the RDS band-pass by the coherent 57 kHz reference is textbook
synchronous DSB-SC detection: the product produces a baseband copy of the biphase
waveform plus an image near 114 kHz; the order-4 Butterworth low-pass at 2400 Hz
(filtfilt, zero-phase) discards the image and leaves the baseband biphase signal
bb. Because differential decoding follows, the arbitrary ± sign of ref (the
BPSK 180° ambiguity) does not matter here.
4.3 Biphase (Manchester) matched filter¶
Each data bit is a biphase symbol: +1 for the first half-bit, −1 for the
second (or the inverse). The matched filter is that very shape:
sps = fs / BAUD # = 144.0
mf = _np.concatenate([_np.ones(int(sps // 2)), # [+1 ×72, −1 ×72]
-_np.ones(int(sps // 2))])
corr = _sig.fftconvolve(bb, mf[::-1], "same") # correlate
Correlating bb with the reversed template (mf[::-1], so the convolution
computes a cross-correlation) yields a corr whose value, sampled at each symbol
centre, is large-positive for one biphase polarity and large-negative for the
other — the classic bipolar decision statistic for Manchester symbols.
4.4 Symbol timing — maximum-energy clock¶
There is no separate clock-recovery loop; timing is chosen by brute-force energy maximisation over the one-bit span:
nsym = int(mpx.size / sps) - 2
best_off, best_e = 0, -1
for off in range(int(sps)): # try every phase 0..143
idx = (off + _np.arange(nsym) * sps).astype(int)
idx = idx[idx < corr.size]
e = _np.sum(_np.abs(corr[idx])) # |corr| energy at this phase
if e > best_e:
best_e, best_off = e, off
The correct sampling phase is the one where the matched-filter output is largest in
magnitude (the eye is widest open), so the offset maximising Σ|corr[idx]| over
all nsym symbols wins. idx is then the vector of symbol-centre sample indices.
4.5 Hard decision + differential decode¶
syms = corr[idx]
diff = (syms > 0).astype(int) # hard bits (still differential)
# wrong-polarity guard — a no-op placeholder (`pass`)
data = [(diff[i] ^ diff[i - 1]) for i in range(1, len(diff))] # differential decode
return data
diff is the hard-decided differentially encoded bit stream. The RDS
differential decode is simply the XOR of each bit with its predecessor, which both
undoes the transmit differential encoder (_diff_encode, rds.py) and cancels any
global inversion introduced by the sign of ref — this is why the receiver needs
no absolute phase. (The if _np.mean(diff) > 0.9 or < 0.1: "wrong polarity guard"
has body pass; it is an inert placeholder and does nothing — the real polarity
insurance is the two-pass loop in decode_bitstream, §5.2.)
The returned data is the raw RDS data-bit stream, ready for block sync.
5. Decoding — bits to station state, step by step¶
5.1 The (26,16) code, the syndrome identity, and single-bit correction¶
The block code is defined by a handful of tiny functions.
Syndrome — remainder of the 26-bit block under g(x), computed by the usual
shift/XOR LFSR division (rds.py):
def syndrome(block26: int) -> int:
reg = 0
for i in range(25, -1, -1):
reg = (reg << 1) | ((block26 >> i) & 1)
if reg & 0x400: # bit 10 set → subtract g(x)
reg ^= _G # _G = 0x5B9
return reg & 0x3FF # 10-bit remainder
Checkword and make_block (used by the encoder and the tests, rds.py):
def checkword(info16, offset): # 10-bit check bits for info + named offset
return syndrome(info16 << 10) ^ OFFSET[offset]
def make_block(info16, offset):
return (info16 << 10) | checkword(info16, offset)
syndrome(info16 << 10) is the pure cyclic-code remainder of the systematic
codeword (16 info bits in the high positions, 10 zero check positions); XOR-ing the
offset word produces the transmitted check field.
The identity that powers block sync. For a correctly received block,
exactly. The proof is two lines and is what the module comment states: the syndrome
is linear, so syndrome(info<<10 | (crc ⊕ off)) = syndrome(info<<10|crc) ⊕
syndrome(off). The first term is 0 (a valid codeword of the un-offset code), and
the second is off itself because any value < 2¹⁰ is its own remainder under a
degree-10 generator. Hence recovering the offset word is verifying the check bits.
match_offset (rds.py) computes the syndrome once and returns whichever offset
word it equals, or None:
def match_offset(block26):
s = syndrome(block26)
for name, sv in OFFSET.items():
if s == sv:
return name
return None
match_offset is the strict, exact test used for acquisition — a single bit
error changes the syndrome and makes it match no offset word, returning None
(verified: a clean block matches A, the same block with one bit flipped matches
None). Worked validation vectors for info = 0x1234, all computed from the
module:
| offset | OFFSET |
checkword(0x1234) |
full 26-bit block | syndrome |
match_offset |
|---|---|---|---|---|---|
| A | 0x0FC | 0x06A | 0x048D06A | 0x0FC | A |
| B | 0x198 | 0x10E | 0x048D10E | 0x198 | B |
| C | 0x168 | 0x1FE | 0x048D1FE | 0x168 | C |
| C′ | 0x350 | 0x3C6 | 0x048D3C6 | 0x350 | C′ |
| D | 0x1B4 | 0x122 | 0x048D122 | 0x1B4 | D |
Single-bit error correction. Once a block's expected offset is known (during
tracking, §5.2), the decoder does more than detect: it repairs single-bit errors.
The tool is a precomputed table mapping the syndrome of each one-bit error pattern
to that pattern (rds.py):
# {syndrome of a 1-bit error -> that bit}, one entry per block position
_ERR1 = {syndrome(1 << i): (1 << i) for i in range(26)}
def correct_block(block26, offset):
"""Validate against the expected offset; exact if the syndrome matches,
else repair a single-bit error. Returns (info16, ok)."""
s = syndrome(block26) ^ OFFSET[offset]
if s == 0:
return block26 >> 10, True # clean codeword
e = _ERR1.get(s)
if e is not None:
return (block26 ^ e) >> 10, True # single-bit repair
return block26 >> 10, False # uncorrectable
The mechanism is again pure linearity. For a block received with its offset,
syndrome(block) ⊕ OFFSET = syndrome(error); if the error is a single flipped bit
1<<i, that value is _ERR1's key for position i, and XOR-ing the stored pattern
back into block26 removes it. All 26 error syndromes are distinct (verified), so
no false repair is possible for a single-bit fault. The full _ERR1 table is in
§6.4. The (26,16) code's minimum distance is 5, so it could in principle correct up
to 2 bit errors / trap a 5-bit burst; the implementation takes only the dominant
single-bit case, which is safe (it cannot mis-repair a clean block).
5.2 Block synchronisation and group assembly — acquire-then-track flywheel¶
decode_bitstream(bits) (rds.py) is a two-state machine: it acquires on an
exact four-block offset match, then tracks on the fixed 104-bit grid using
single-bit correction and tolerating one bad block per group.
seq = ["A", "B", ("C", "Cp"), "D"] # block 3 accepts C or C'
def read_block(stream, at):
blk = 0
for j in range(26): # read 26 bits MSB-first
blk = (blk << 1) | stream[at + j]
return blk
for pol in (0, 1): # try both bit polarities
stream = [b ^ pol for b in bits]
i = 0
synced = False
while i + 104 <= n:
blks = [read_block(stream, i + k * 26) for k in range(4)]
if not synced:
# ACQUIRE: exact match of all four offsets (no correction)
exact = all(
match_offset(blks[k]) in
(seq[k] if isinstance(seq[k], tuple) else (seq[k],))
for k in range(4))
if not exact:
i += 1
continue
synced = True
# TRACK: validate/correct each block against its expected offset
info, good = [], 0
for k, blk in enumerate(blks):
offs = seq[k] if isinstance(seq[k], tuple) else (seq[k],)
inf = blk >> 10
for off in offs:
ci, ok = correct_block(blk, off)
if ok:
inf, good = ci, good + 1
break
info.append(inf)
if good >= 3: # tolerate ONE bad block once locked
groups.group(info)
i += 104
else:
synced = False # lost sync → re-acquire
i += 1
if groups.pi is not None: # stop once locked
break
Key behaviours, exactly as written:
- Strict 4-block acquisition. Sync is acquired only when all four blocks match their offset syndromes exactly, with zero errors — ~40 check bits of agreement, so the false-lock probability at a random bit position is ≈ 2⁻⁴⁰. Block 3 accepts either C or C′ (C′ is the "version B" variant of block 3).
- Flywheel tracking with error correction. After acquisition the machine stays
syncedand steps the index by a whole group (104 bits) at a time. Each block is passed throughcorrect_blockagainst its expected offset, which repairs a single-bit error transparently, and a group is accepted as long as at least 3 of its 4 blocks validate (good >= 3) — i.e. it coasts through one uncorrectable block per group. Only when two or more blocks fail does it dropsyncedand fall back to sliding one bit at a time to re-acquire. This is the confidence-building flywheel that a strict per-group re-check lacks. - Polarity insurance. The outer
for pol in (0, 1)tries the stream and its bitwise inverse. Differential decoding already removes carrier-phase inversion in the modem path, so this mainly protects the raw-bitstream entry point (decode_bitstreamcalled directly on data bits) and is belt-and-suspenders otherwise. The loop stops as soon as a PI has been decoded.
decode_mpx (rds.py) is just decode_bitstream(demod_bits(mpx, fs)) — the whole
pipeline in one call.
5.3 Group parsing — RdsGroups.group¶
Each accepted group hands its four 16-bit info words (a, b, c, d) to
RdsGroups.group (rds.py). Block A is always the PI code, and block B always
carries the type/flags:
a, b, c, d = blocks
self.pi = a # block A = PI (every group)
gtype = (b >> 12) & 0xF # group type 0..15
version_b = (b >> 11) & 1 # 0 = A version, 1 = B version
self.tp = (b >> 10) & 1 # traffic-programme flag
self.pty = (b >> 5) & 0x1F # programme type (5 bits)
Block 2's 16-bit info word layout (what those shifts pick out):
bit 15 14 13 12 │ 11 │ 10 │ 9 8 7 6 5 │ 4 3 2 1 0
└─ group ──┘ ver TP └──── PTY ─────┘ └ group-specific ┘
type (4) (1) (1) (5) (5)
PI code / PTY / TP are therefore available from any decoded group. state()
(rds.py) formats them: PI as a 4-hex-digit string ("%04X"), PTY as the human
name from the _PTY table, TP/TA as ints.
Group 0A / 0B — PS name, TA, and (0A) alternate frequencies (rds.py):
if gtype == 0:
self.ta = (b >> 4) & 1 # traffic-announcement flag
idx = b & 0x3 # segment 0..3
self.ps[idx * 2] = chr((d >> 8) & 0xFF) # two PS chars from block D
self.ps[idx * 2 + 1] = chr(d & 0xFF)
if version_b == 0: # 0A only: block C = two AF codes
for f in (_af_freq((c >> 8) & 0xFF), _af_freq(c & 0xFF)):
if f is not None:
self.af.add(f)
The 8-character PS name arrives two characters at a time in block D, indexed by the
2-bit segment address in block B (four segments × 2 chars = 8). The TA flag is bit
4. In the A version (0A) block C carries two Alternative-Frequency codes,
one per byte, each mapped to an FM frequency by _af_freq (rds.py):
def _af_freq(code):
if 1 <= code <= 204:
return round(87.5 + code * 0.1, 1) # MHz
return None # 0, 205–224 (count/filler), etc.
So AF code n (for 1 ≤ n ≤ 204) is the frequency 87.5 + 0.1·n MHz — code 1 →
87.6 MHz, code 100 → 97.5 MHz, code 204 → 107.9 MHz — spanning the whole
87.6–107.9 MHz band in 100 kHz steps. Codes 0 and 205–224 (used by the standard as
"number of frequencies follows" counts and fillers) map to None and are dropped.
Recovered frequencies accumulate in a set (self.af) and state() returns them
sorted. The 0B version (block C repeats the PI) contributes no AF. The MS
(music/speech) and DI (decoder-identification) bits are not extracted.
Group 4A — clock-time and date (CT) (rds.py):
elif gtype == 4 and version_b == 0: # 4A only
mjd = ((b & 0x3) << 15) | (c >> 1) # 17-bit Modified Julian Day
hour = ((c & 1) << 4) | (d >> 12) # 5-bit hour
minute = (d >> 6) & 0x3F # 6-bit minute
sense = (d >> 5) & 1 # local-offset sign (1 = minus)
offset_hh = d & 0x1F # local offset in half-hours
if mjd and hour < 24 and minute < 60:
self.ct = _format_ct(mjd, hour, minute, sense, offset_hh)
The 4A field packing across blocks B/C/D:
block B: … │ b1 b0 │ = MJD[16:15] (top 2 bits of the 17-bit MJD)
block C: │ c15 … c1 │ c0 │ = MJD[14:0] (15 bits) │ hour[4] (top hour bit)
block D: │ d15..d12 │ d11..d6 │ d5 │ d4..d0 │
hour[3:0] minute(6) sense local offset (half-hours)
The date is recovered from the Modified Julian Day by the RDS/Annex conversion, and
the wall-clock string is assembled with the signed local-time offset (rds.py):
def _mjd_to_date(mjd): # MJD -> (year, month, day)
yp = int((mjd - 15078.2) / 365.25)
mp = int((mjd - 14956.1 - int(yp * 365.25)) / 30.6001)
day = mjd - 14956 - int(yp * 365.25) - int(mp * 30.6001)
k = 1 if mp in (14, 15) else 0
return yp + k + 1900, mp - 1 - k * 12, day # year, month, day
def _format_ct(mjd, hour, minute, sense, offset_hh):
y, m, d = _mjd_to_date(mjd)
sign = "-" if sense else "+"
return "%04d-%02d-%02d %02d:%02d %s%02d:%02d" % (
y, m, d, hour, minute, sign, offset_hh // 2, (offset_hh % 2) * 30)
The local offset is carried in half-hour units, so it is printed as
±HH:MM where HH = offset_hh // 2 and MM = (offset_hh % 2)·30. Worked
example (matches the unit test): MJD 60310 → (2024, 1, 1), and with hour 12,
minute 34, sense = 0, offset_hh = 0, _format_ct yields the string
"2024-01-01 12:34 +00:00". (An offset_hh of 2 would render +01:00; a
sense of 1 with offset_hh 3 would render -01:30.) The guard
mjd and hour < 24 and minute < 60 rejects the all-zero / out-of-range field that
a station uses to signal "no valid time".
Group 2A / 2B — RadioText (with the text A/B flag) (rds.py):
elif gtype == 2:
ab = (b >> 4) & 1 # text A/B flag
if ab != self._rt_ab: # flag toggled → new message
self.rt = [" "] * 64 # clear the 64-char buffer
self._rt_ab = ab
idx = b & 0xF # text segment 0..15
if version_b == 0: # 2A: 4 chars from blocks C and D
for k, ch in enumerate(((c >> 8) & 0xFF, c & 0xFF,
(d >> 8) & 0xFF, d & 0xFF)):
self.rt[idx * 4 + k] = chr(ch)
else: # 2B: 2 chars from block D only
self.rt[idx * 2] = chr((d >> 8) & 0xFF)
self.rt[idx * 2 + 1] = chr(d & 0xFF)
RadioText is up to 64 characters. In 2A (version A) each group delivers four
characters — two from block C, two from block D — at idx·4 (16 segments × 4 =
64). In 2B (version B) each group delivers only two characters from block D at
idx·2 (16 segments × 2 = 32; block C in 2B repeats the PI and is not used for
text). The text A/B flag (bit 4 of block B) is the message-change signal: when
it toggles, the 64-char buffer is wiped so a new RadioText message does not blend
into the previous one. state() returns the buffer right-stripped
("".join(self.rt).rstrip()).
State snapshot. state() (rds.py) is the decoder's public output:
{"pi": "%04X" or None, "ps": "".join(ps).strip(), "pty": _PTY[pty] or None,
"tp": int or None, "ta": int or None, "rt": "".join(rt).rstrip(),
"ct": str or None, "af": sorted(af) or []}
6. Constants & tables¶
Everything here is transcribed directly from rds.py; nothing is invented.
6.1 Generator polynomial¶
0x5B9 = 0b101_1011_1001; the set bits (10,8,7,5,4,3,0) are exactly the polynomial
terms above. This is the standard RDS/RBDS generator for the (26,16) shortened
cyclic code (a shortening of the (341,331) code), minimum distance 5.
6.2 Offset words¶
| name | hex | decimal | binary (10-bit) | role |
|---|---|---|---|---|
| A | 0x0FC | 252 | 0011111100 |
block 1 — PI code |
| B | 0x198 | 408 | 0110011000 |
block 2 — group type + flags |
| C | 0x168 | 360 | 0101101000 |
block 3 — version A groups |
| C′ | 0x350 | 848 | 1101010000 |
block 3 — version B groups (Cp) |
| D | 0x1B4 | 436 | 0110110100 |
block 4 |
These are the exact offset words of the RDS standard. Confirmed numerically:
syndrome(OFFSET[x]) == OFFSET[x] for all five (the identity of §5.1).
6.3 The (26,16) generator / parity matrix¶
The systematic code is G = [ I₁₆ | P ], where each row of the 16×10 parity
matrix P is the 10-bit check contribution of one information bit, i.e.
P_row(i) = syndrome((1<<i) << 10). Computed from the module (info bit b15 is the
MSB, transmitted first):
| info bit | parity row (MSB→LSB) | hex | info bit | parity row | hex |
|---|---|---|---|---|---|
| b15 | 0001110111 |
0x077 | b7 | 0001101110 |
0x06E |
| b14 | 1011100111 |
0x2E7 | b6 | 0000110111 |
0x037 |
| b13 | 1110101111 |
0x3AF | b5 | 1011000111 |
0x2C7 |
| b12 | 1100001011 |
0x30B | b4 | 1110111111 |
0x3BF |
| b11 | 1101011001 |
0x359 | b3 | 1100000011 |
0x303 |
| b10 | 1101110000 |
0x370 | b2 | 1101011101 |
0x35D |
| b9 | 0110111000 |
0x1B8 | b1 | 1101110010 |
0x372 |
| b8 | 0011011100 |
0x0DC | b0 | 0110111001 |
0x1B9 |
These sixteen rows are the canonical RDS parity matrix. The check field of any
block is the XOR of the rows for its set info bits, XOR the offset word — e.g. for
info = 0xC201, ⊕ parity rows = 0x291 = syndrome(0xC201<<10), and
checkword(0xC201, "A") = 0x291 ⊕ 0x0FC = 0x26D. (This linear reconstruction is
not a separate code path in rds.py; syndrome/checkword compute it directly,
but the matrix is the reproducible ground truth.)
6.4 Single-bit error-correction syndrome table (_ERR1)¶
The tracker corrects single-bit errors via a 26-entry table keyed by the syndrome
of each one-bit error pattern. Regeneration recipe (one line, rds.py):
The full table — bit position i (0 = LSB of the 26-bit block), the error pattern
1<<i, and its syndrome — computed from the module:
block bit i |
error 1<<i |
syndrome | block bit i |
error 1<<i |
syndrome | |
|---|---|---|---|---|---|---|
| 0 (chk) | 0x0000001 | 0x001 = 0000000001 |
13 (info b3) | 0x0002000 | 0x303 = 1100000011 |
|
| 1 (chk) | 0x0000002 | 0x002 = 0000000010 |
14 (info b4) | 0x0004000 | 0x3BF = 1110111111 |
|
| 2 (chk) | 0x0000004 | 0x004 = 0000000100 |
15 (info b5) | 0x0008000 | 0x2C7 = 1011000111 |
|
| 3 (chk) | 0x0000008 | 0x008 = 0000001000 |
16 (info b6) | 0x0010000 | 0x037 = 0000110111 |
|
| 4 (chk) | 0x0000010 | 0x010 = 0000010000 |
17 (info b7) | 0x0020000 | 0x06E = 0001101110 |
|
| 5 (chk) | 0x0000020 | 0x020 = 0000100000 |
18 (info b8) | 0x0040000 | 0x0DC = 0011011100 |
|
| 6 (chk) | 0x0000040 | 0x040 = 0001000000 |
19 (info b9) | 0x0080000 | 0x1B8 = 0110111000 |
|
| 7 (chk) | 0x0000080 | 0x080 = 0010000000 |
20 (info b10) | 0x0100000 | 0x370 = 1101110000 |
|
| 8 (chk) | 0x0000100 | 0x100 = 0100000000 |
21 (info b11) | 0x0200000 | 0x359 = 1101011001 |
|
| 9 (chk) | 0x0000200 | 0x200 = 1000000000 |
22 (info b12) | 0x0400000 | 0x30B = 1100001011 |
|
| 10 (info b0) | 0x0000400 | 0x1B9 = 0110111001 |
23 (info b13) | 0x0800000 | 0x3AF = 1110101111 |
|
| 11 (info b1) | 0x0000800 | 0x372 = 1101110010 |
24 (info b14) | 0x1000000 | 0x2E7 = 1011100111 |
|
| 12 (info b2) | 0x0001000 | 0x35D = 1101011101 |
25 (info b15) | 0x2000000 | 0x077 = 0001110111 |
Two structural facts fall straight out of the recipe: for the 10 check-bit
positions (i = 0..9) the syndrome equals the error pattern itself
(0x001..0x200), because any value < 2¹⁰ is its own remainder; and for the 16
info-bit positions (i = 10..25) the syndrome is exactly the corresponding
parity-matrix row of §6.3 (block bit 10 = info b0, …, block bit 25 = info b15), so
_ERR1 is the parity matrix re-indexed and augmented with the trivial identity
block. All 26 syndromes are distinct (verified), so a single-bit fault is always
uniquely located; correct_block (§5.1) looks the syndrome up and XORs the pattern
back out.
6.5 PTY (programme type) table¶
The 5-bit PTY code indexes _PTY (rds.py) — the European RDS (EN 50067 /
IEC 62106) programme-type names, not the North-American RBDS set:
| # | name | # | name | # | name | # | name |
|---|---|---|---|---|---|---|---|
| 0 | None | 8 | Science | 16 | Weather | 24 | Jazz |
| 1 | News | 9 | Varied | 17 | Finance | 25 | Country |
| 2 | Current Affairs | 10 | Pop Music | 18 | Children | 26 | National Music |
| 3 | Information | 11 | Rock Music | 19 | Social Affairs | 27 | Oldies |
| 4 | Sport | 12 | Easy Listening | 20 | Religion | 28 | Folk Music |
| 5 | Education | 13 | Light Classical | 21 | Phone In | 29 | Documentary |
| 6 | Drama | 14 | Serious Classical | 22 | Travel | 30 | Alarm Test |
| 7 | Culture | 15 | Other Music | 23 | Leisure | 31 | Alarm |
6.6 Character set¶
RDS characters are decoded by a direct chr(byte) on the raw 8-bit values (for
PS and for RadioText, rds.py). There is no lookup into the RDS G0/G1/G2 code
tables (IEC 62106 Annex E). For the printable ASCII range 0x20–0x7F — which covers
essentially all English PS/RadioText — the RDS default G0 repertoire coincides with
ASCII, so chr() is correct. Bytes in 0x80–0xFF (the RDS accented-letter region)
are interpreted as Latin-1 code points, which does not match the RDS tables;
non-ASCII station text will be mis-rendered. This is a deliberate simplification,
called out in §8.
6.7 AF code → frequency, and MJD → date¶
Two small decode maps, inlined here for completeness (both from rds.py):
- Alternate-frequency code → MHz. For an AF code n,
_af_freq(n) = round(87.5 + 0.1·n, 1)MHz when1 ≤ n ≤ 204, elseNone. Endpoints: code 1 → 87.6 MHz, code 204 → 107.9 MHz; codes 0 and 205–224 are count/filler and returnNone. - Modified Julian Day → (year, month, day). The RDS Annex conversion, computed with integer truncation:
yp = int((mjd - 15078.2) / 365.25)
mp = int((mjd - 14956.1 - int(yp·365.25)) / 30.6001)
day = mjd - 14956 - int(yp·365.25) - int(mp·30.6001)
k = 1 if mp in (14, 15) else 0
year = yp + k + 1900
month = mp - 1 - k·12
Sanity vector: MJD 60310 → 2024-01-01. The wall-clock string is then
"%04d-%02d-%02d %02d:%02d %s%02d:%02d" with the local-offset sign from the sense
bit and HH:MM = (offset_hh//2):(offset_hh%2)·30 — e.g.
"2024-01-01 12:34 +00:00" (§5.3).
7. Interoperability & validation¶
test_rds.py exercises the stack at several levels; it is self-checking against the
module's own encoder (make_block, synth_mpx) and against the published
constants.
Block code + offsets (test_offsets_and_syndrome, test_rds.py): round-trips
make_block → match_offset for all five offsets, and asserts that a single
flipped bit no longer matches offset A — i.e. it pins the syndrome identity of §5.1
and the strict-acquisition detection property (match_offset, unlike
correct_block, does not attempt repair).
Single-bit correction (test_error_correction, test_rds.py): builds
make_block(0x1234, "A"), flips one bit at several positions (0, 7, 15, 25), and
asserts correct_block returns ok=True and the exact original info 0x1234 — the
direct test of _ERR1 / §6.4.
Group parsing (test_group_parsing, test_rds.py): builds a raw bit stream of
0A groups spelling "RADIO 1 " (PI 0xC201, TP 1, PTY 10) plus 2A groups carrying
"HELLO RDS WORLD", feeds it to decode_bitstream, and asserts pi == "C201",
ps == "RADIO 1", pty == "Pop Music", tp == 1, and the exact RadioText. This
validates the bit-field layout of block B, the PS segment indexing, and the
RadioText assembly with the A/B flag.
Clock-time 4A (test_clock_time_4a, test_rds.py): packs MJD 60310 / 12:34
into a 4A group's B/C/D fields, calls RdsGroups.group, and asserts
state()["ct"] == "2024-01-01 12:34 +00:00" — the direct test of the MJD/CT
formulae of §5.3.
Alternate frequencies 0A (test_alt_frequencies_0a, test_rds.py): feeds a 0A
group with AF codes 12 and 100 in block C and asserts state()["af"] ==
[88.7, 97.5] — the direct test of _af_freq.
Full MPX modem round-trip, clean (test_mpx_modem_roundtrip_clean,
test_rds.py): synth_mpx builds a real 19 kHz-pilot + 57 kHz-DSB-SC composite
from the 0A stream, and decode_mpx recovers "RADIO 1" through the entire PHY
(pilot cube → coherent down-conversion → biphase MF → timing → differential decode
→ block sync). This is the end-to-end proof that the modem and the decoder agree.
Full MPX modem round-trip, noisy (test_mpx_modem_roundtrip_noisy,
test_rds.py): the same chain at snr_db=15 with PS "BBC R4" and RadioText
"THE NATIVE STACK", asserting the PS recovers exactly and the RadioText substring
survives. The AWGN is added in synth_mpx relative to MPX power, so this tests the
coherent receiver's noise tolerance and the correcting flywheel's ability to hold
lock through bit errors.
External conformance. The constants are the RDS standard's own: the generator
0x5B9, the five offset words, the (26,16) parity matrix of §6.3, the single-bit
syndrome table of §6.4, and the European PTY names all match IEC 62106 / EN 50067,
so a bit stream produced by this encoder is interoperable with any conformant RDS
receiver and, conversely, the decoder will lock any conformant transmitter for the
group types it handles. The natural next-step cross-check — decoding a real off-air
MPX capture, or comparing against the reference open-source decoder redsea on
shared vectors — is not wired into the repo today (there is no redsea dependency
or captured-vector fixture); in-repo validation is the synthetic round-trip above.
Adding a captured-MPX WAV and a redsea diff would be the obvious hardening step.
8. Limitations¶
An honest inventory of what this decoder does and does not do, all by design of the current source:
- Group types decoded: 0A/0B, 2A/2B, and 4A.
groupdispatches ongtype == 0(PS + AF),gtype == 4version A (clock-time), andgtype == 2(RadioText). All other group types are parsed only for their common fields (PI from block A, and group-type/TP/PTY from block B) and their type-specific payload is dropped. Notable omissions among the undecoded types are 1A (programme-item / slow labelling), 3A (open-data application declarations), 8A (TMC), and 14A/14B (EON) — each a bounded addition on the same block/group core. - No MS / DI bits. The music-speech and decoder-identification bits of 0A/0B are not extracted.
- Single-bit correction only. The (26,16) code is used to detect errors during
acquisition and to correct single-bit errors during tracking (
correct_block/_ERR1, §5.1). The standard's fuller burst-error correction (the code's minimum distance 5 could trap a 5-bit burst) is not implemented, so performance in deep fades is worse than a maximal correcting decoder — but the common single-bit case is handled and the flywheel tolerates one uncorrectable block per group. - Character set is ASCII-only in practice (
chr(byte), §6.6); RDS accented-character code tables are not applied. - PTY table is the European set, not RBDS — US station PTY numbers will map to the wrong names.
- Timing recovery is block-batch, not streaming.
demod_bitssearches the whole buffer for one best sampling phase (§4.4); it assumes a stable clock across the buffer rather than tracking drift symbol-by-symbol, which is fine for the test-length captures but is not a continuous real-time tracker.
None of these are architectural dead-ends — the remaining group types, fuller error correction, and a proper G0/G1 character map are each a bounded addition on top of the working block/group core.
9. Implementation map & reproduction¶
| symbol | role | file |
|---|---|---|
_G, OFFSET, _PTY |
generator, offset words, PTY names | rds.py |
syndrome |
(26,16) LFSR remainder | rds.py |
checkword, make_block |
encoder check bits / block builder | rds.py |
match_offset |
syndrome → offset name (strict sync + detect) | rds.py |
_ERR1, correct_block |
single-bit error correction (track) | rds.py |
_mjd_to_date, _format_ct |
4A MJD → date, clock-time string | rds.py |
_af_freq |
0A AF code → FM frequency (MHz) | rds.py |
RdsGroups |
group parser → station state | rds.py |
_bandpass, _diff_encode |
Butterworth BP; differential encoder | rds.py |
synth_mpx |
data bits → 57 kHz DSB-SC + 19 kHz pilot MPX | rds.py |
demod_bits |
MPX → data bits (coherent PHY) | rds.py |
decode_mpx |
MPX → station state (one call) | rds.py |
decode_bitstream |
offset-word acquire + correcting-flywheel track | rds.py |
Runtime shape:
from app.radio import rds
mpx = rds.synth_mpx(data_bits, snr_db=15) # encode a test composite (fs=171000)
state = rds.decode_mpx(mpx, 171000.0) # PHY + block sync + group parse
# state = {"pi": "C201", "ps": "RADIO 1", "pty": "Pop Music",
# "tp": 1, "ta": 0, "rt": "...", "ct": "2024-01-01 12:34 +00:00",
# "af": [88.7, 97.5]}
For a real signal, replace synth_mpx with the demodulated MPX from a wideband-FM
SDR sampled fast enough to contain 57 kHz (fs ≥ ~2·(57000+2400) ≈ 120 kHz; the
tests use 171000), and call decode_mpx(mpx, fs). Everything is NumPy + SciPy
signal; there is no external RDS binary and no data file.
10. References¶
- IEC 62106, Specification of the radio data system (RDS) for VHF/FM sound broadcasting in the frequency range from 87.5 to 108.0 MHz — the offset words, the (26,16) generator polynomial and parity matrix, the block/group framing, the biphase/DBPSK PHY on the 57 kHz subcarrier, the group-type/PTY definitions, the 4A clock-time (MJD) format, and the AF code table.
- EN 50067 (CENELEC), the European RDS standard IEC 62106 derives from — origin
of the PTY name table used in
_PTY. - NRSC-4 / RBDS — the North-American variant (same PHY and block code, a
different PTY name table); noted here because
rds.pyuses the European PTY names, not RBDS. redsea(O. Keränen) — the reference open-source RDS decoder; the natural external cross-check for off-air validation (not currently wired into this repo, §7).- Module docstring and constants,
app/radio/rds.py— the in-repo statement of scope, layering, and the syndrome/offset identity.