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:
data bit ─▶ differential encode ─▶ biphase (Manchester) ─▶ × cos(2π·57k) = DSB-SC RDS
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,
syndrome(block) = OFFSET[name]
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
_G = 0x5B9 # rds.py x^10 + x^8 + x^7 + x^5 + x^4 + x^3 + 1
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
OFFSET = {"A": 0x0FC, "B": 0x198, "C": 0x168, "Cp": 0x350, "D": 0x1B4} # rds.py
| 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):
_ERR1 = {syndrome(1 << i): (1 << i) for i in range(26)}
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·12Sanity 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 andHH: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.