Patrick Lidstone
Self-hosted

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.42 groups/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 synced and steps the index by a whole group (104 bits) at a time. Each block is passed through correct_block against 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 drop synced and 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_bitstream called 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 when 1 ≤ n ≤ 204, else None. Endpoints: code 1 → 87.6 MHz, code 204 → 107.9 MHz; codes 0 and 205–224 are count/filler and return None.

  • 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_blockmatch_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. group dispatches on gtype == 0 (PS + AF), gtype == 4 version A (clock-time), and gtype == 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_bits searches 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

  1. 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.
  2. EN 50067 (CENELEC), the European RDS standard IEC 62106 derives from — origin of the PTY name table used in _PTY.
  3. NRSC-4 / RBDS — the North-American variant (same PHY and block code, a different PTY name table); noted here because rds.py uses the European PTY names, not RBDS.
  4. 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).
  5. Module docstring and constants, app/radio/rds.py — the in-repo statement of scope, layering, and the syndrome/offset identity.