Patrick Lidstone
Self-hosted

DAB / DAB+: a native clean-room Eureka-147 Mode I receiver

A from-scratch NumPy implementation of the DAB physical and channel layers — 2048-carrier OFDM with differential QPSK, punctured K=7 Viterbi, the Fast Information Channel, the 16-CIF time interleaver, and the DAB+ Reed-Solomon superframe — matching ETSI EN 300 401 / TS 102 563 to the constant.

Rafe project · app/radio/dab/{ofdm,fec,fic,msc,audio}.py · experimental, receiver-oriented, codec-gated


Abstract

This is a clean-room, dependency-free implementation of the DAB (Digital Audio Broadcasting) and DAB+ receive chain, built entirely in NumPy from the ETSI specifications with no reference to any existing DAB stack. It covers the full signal path a software radio must run to turn a 2.048 MHz complex baseband capture of a DAB Transmission Mode I ensemble into decoded service bytes: the OFDM physical layer (null-symbol frame synchronisation, cyclic-prefix removal, 2048-point FFT, differential QPSK demodulation against the phase-reference symbol, and the pseudo-random frequency de-interleave); the shared channel coding (the constraint-length-7, rate-1/4 mother convolutional code with a 64-state Viterbi decoder, depuncturing, and the x⁹+x⁵+1 energy-dispersal scrambler); the Fast Information Channel parser (FIB CRC, and the FIG 0/1, 0/2, 1/0, 1/1 extensions that yield the ensemble label and a SId → SubChId → (start CU, size, protection) service map); the Main Service Channel (subchannel extraction from a CIF, the 16-CIF convolutional time de-interleaver in both batch and streaming forms, and the depuncture → Viterbi → de-scramble subchannel FEC); and the DAB+ superframe (a GF(256) Reed-Solomon RS(120,110) corrector, the superframe-header AAC access-unit boundary parse, and a library-gated hook to an external HE-AACv2 / MP2 decoder). Every stage is unit-tested for round-trip correctness and, where meaningful, for error-correction capability at its design point (20 dB OFDM AWGN, ~8 % raw convolutional BER, 5 RS byte errors). The one thing this code does not contain is an entropy/audio decoder: AAC and MP2 decode are delegated to libfaad2 / libmpg123 exactly as the rest of the project bridges to libcodec2, and the bridge no-ops cleanly when the library is absent. The novelty is coverage: the entire DAB framing spine — the part that is genuinely DAB rather than generic audio — is reproduced natively.


1. Motivation and scope

DAB is the one broadcast mode in this project that the target hardware cannot actually receive: the note in app/radio/dab/__init__.py is blunt — "Needs a ~1.536 MHz SDR; the 705 can't reach a DAB ensemble." The implementation therefore exists as a reference decoder and a proving ground: a place to implement the Eureka-147 framing from the standard, unit-test it end to end, and have it ready for a wideband front-end (an RTL-SDR, an Airspy, a LimeSDR) rather than the Icom's narrow IF.

Because of that framing, the code is organised as five independent, individually testable stages rather than one monolithic streaming receiver. Each .py is a faithful transcription of one clause-range of EN 300 401; each test_dab_*.py exercises that stage by encoding with the same module (or a companion RS encoder in the test) and checking the decode inverts it, plus an error-injection case at the stage's design point. There is deliberately no top-level driver wiring OFDM → FIC/MSC demux → audio in the repository (confirmed: nothing outside app/radio/dab/ imports these modules, and __init__.py pulls in only ofdm); the CIF/FIB assembly that would glue the physical bits to the channel decoders is the one integration seam left open (§8). What is present is every algorithm and every constant table needed to build that driver.

One convention used throughout: constants quoted as "computed" were obtained by importing and running the real module, not by re-deriving them by hand.


2. Background

2.1 Eureka-147 and Transmission Mode I

DAB (the Eureka-147 system, ETSI EN 300 401) multiplexes many audio services and data streams into one wideband ensemble carried on a single OFDM signal roughly 1.536 MHz wide. The standard defines four transmission modes (I–IV) that trade FFT size against guard interval for different propagation environments; Mode I — the one this code implements — uses a 2048-point FFT and is the mode used for terrestrial VHF Band III (174–240 MHz) single-frequency networks. Its large symbol and guard make it robust to the long echoes of an SFN.

A Mode I signal is sampled at fs = 2.048 MHz. Everything downstream is counted in samples at that rate.

2.2 OFDM with differential QPSK

DAB carries data as orthogonal frequency-division multiplexing: 1536 active subcarriers, each modulated independently, transmitted in parallel as one 2048- point inverse-FFT block per symbol with a cyclic-prefix guard interval prepended. The guard absorbs multipath so that, after the receiver strips it and FFTs the useful part, each subcarrier is a clean complex sample.

Crucially DAB is differential: the information on a carrier is not its absolute phase but the phase change from the same carrier in the previous OFDM symbol. That removes the need for absolute carrier-phase recovery — no pilot tones, no channel equaliser — at a small SNR cost. The spec calls its modulation π/4-DQPSK. This implementation encodes the four dibits as phase steps on the quadrant grid {0°, 90°, 180°, 270°} (i.e. differential QPSK / π/2 steps; see §4.4 for the exact mapping and the note on the spec's π/4 convention). The first data-bearing symbol needs an anchor to differentiate against: the phase- reference symbol (PRS), a fixed known QPSK pattern on every carrier, transmitted right after the null.

2.3 The FIC / MSC split

Every DAB frame is divided into two logical channels:

  • the Fast Information Channel (FIC) — a small, heavily protected, non-time-interleaved channel that carries the Multiplex Configuration Information: which subchannels exist, where they sit in the frame, how they are protected, what services they belong to, and the text labels. A receiver must decode the FIC first — it is the map. The FIC is carried as a stream of Fast Information Blocks (FIBs), each 32 bytes (30 data + a 16-bit CRC), packed with Fast Information Groups (FIGs).
  • the Main Service Channel (MSC) — the bulk payload, time-interleaved over 16 frames for burst-fade resistance, carrying the audio/data subchannels. The MSC is organised into Common Interleaved Frames (CIFs) of 864 Capacity Units (CU) of 64 bits each; a subchannel is a contiguous CU range within every CIF.

The FIC tells you where in each CIF a given service's subchannel lives and how to un-protect it; the MSC then gives you that subchannel's bytes.

2.4 DAB vs DAB+

The two differ only in what the subchannel bytes are:

  • DAB (original): the subchannel is a stream of MPEG-1 Audio Layer II (MP2) frames, directly.
  • DAB+ (ETSI TS 102 563): the subchannel is a superframe — five logical frames' worth of data (110 ms) protected by a Reed-Solomon RS(120,110) outer code applied across a byte-interleaved matrix, with a Firecode-protected header delimiting HE-AACv2 (AAC-LC + SBR + PS) access units. DAB+ is far more spectrum-efficient and is what modern ensembles use.

This implementation's audio.py does the DAB+ superframe RS + AU framing natively and leaves both the AAC and the MP2 audio decode to a linked library.


3. Signal and frame structure — exact numbers

All physical constants live at the top of ofdm.py:

FS = 2048000
TU = 2048                 # useful symbol (FFT size)
GUARD = 504
TS = TU + GUARD           # 2552
NULL = 2656
NSYM = 76                 # data-bearing symbols per frame (excl. null + PRS...)
K = 1536                  # active carriers
FRAME = NULL + (NSYM + 1) * TS    # null + PRS + 76 symbols

Interpreted, at fs = 2.048 MHz (all sample counts verified by running the module):

Quantity Symbol Value In time
Sample rate FS 2 048 000 Hz
Useful symbol / FFT size TU 2048 samples 1000.0 µs
Guard (cyclic prefix) GUARD 504 samples 246.1 µs
Total OFDM symbol TS = TU+GUARD 2552 samples 1246.1 µs
Null symbol NULL 2656 samples 1296.9 µs
Data symbols per frame NSYM 76
Active carriers K 1536
Frame length FRAME 199 160 samples 97.246 ms

The active carriers occupy logical indices −768 … +768 excluding 0 (the DC carrier is unused), i.e. 1536 carriers, ±768 either side of centre. Each carrier carries 2 bits (DQPSK), so one OFDM data symbol is 1536 × 2 = 3072 bits and a frame's data payload is 76 × 3072 = 233 472 hard bits — the shape demodulate() returns is (76, 3072).

Frame layout in time (ofdm.py, modulate()):

| NULL (2656) | PRS (2552) | data sym 1 (2552) | ... | data sym 76 (2552) |
 \____________/\__________/\_________________________________________________/
   silence      QPSK anchor         76 differential-QPSK OFDM symbols

so FRAME = 2656 + 77 × 2552 = 199 160.

Deviation from the spec, stated honestly. Real Mode I packs the phase reference symbol inside its count of 76 OFDM symbols (PRS = symbol 0, then 75 data-bearing symbols carrying FIC + MSC), giving a frame of NULL + 76×TS = 2656 + 193 952 = 196 608 samples = 96 ms exactly. This code instead treats NSYM = 76 as data symbols and adds the PRS as a 77th symbol, so its frame is 199 160 samples = 97.246 ms. The per-symbol parameters (TU, GUARD, TS, NULL) all match the standard to the sample; only the symbol count per frame differs (77 vs 76 total OFDM symbols → +2552 samples). The consequence: the code is fully self-consistent (its modulator and demodulator agree, and all tests pass), but a capture from a real transmitter has 76 total symbols, so a driver bridging to hardware must reconcile the count. The physical constants you would reuse are correct; the frame's symbol budget is the one number to adjust.

The null symbol is a deliberate gap — no carriers are modulated during it (out = [np.zeros(NULL, complex)], ofdm.py) — which is what makes it findable as a power dip and marks the frame boundary. In a real signal the null also carries the optional Transmitter Identification Information (TII); this implementation transmits pure silence there and uses it only for synchronisation.


4. Physical demodulation — step by step

The receive path is ofdm.demodulate() (ofdm.py), which calls find_null() then differentially demodulates. The stages:

4.1 Null detection → frame start (ofdm.py)

def find_null(iq) -> int:
    p = np.abs(iq) ** 2
    win = np.convolve(p, np.ones(NULL), "valid")
    return int(np.argmin(win)) + NULL

Compute instantaneous power |iq|², convolve with a length-NULL (2656) rectangular window (a moving sum of the energy in any 2656-sample span), and take the argmin — the window position with the least energy is the null symbol. Adding NULL steps past the null so the returned index is the start of the PRS, i.e. the frame start. test_null_sync (test_dab_ofdm.py) asserts that for a freshly modulated frame find_null(iq) == NULL exactly.

This is an energy-minimum detector; it presumes the null is the quietest 2656- sample stretch in view, which holds for a clean framed capture. find_null searches the whole buffer, so the AWGN test (test_dab_ofdm.py) pads the frame with noise on both sides and still locks correctly.

4.2 Per-symbol carrier extraction: guard removal + FFT (ofdm.py)

def carriers(sym_i):
    base = start + sym_i * TS + GUARD
    t = iq[base:base + TU]
    return np.fft.fft(t)[_BINS]

For OFDM symbol sym_i (0 = PRS, 1…76 = data), the symbol begins at start + sym_i·TS. Skip the first GUARD (504) samples — that is the cyclic prefix — and take the next TU (2048) samples, the useful part. A 2048-point forward FFT of that block recovers the complex value on every FFT bin. Index it by _BINS to pull out just the 1536 active carriers in logical order (§4.5).

No cyclic-prefix correlation for fine timing and no fractional-frequency correction is done — the guard is simply discarded from a nominal offset, which is exact for a sample-aligned, frequency-locked capture and is what the round- trip tests validate.

4.3 Differential demodulation (ofdm.py)

prev = carriers(0)                                # PRS
for s in range(NSYM):
    cur = carriers(s + 1)
    dphi = np.angle(cur * np.conj(prev)) % (2 * np.pi)
    q = np.round(dphi / (np.pi / 2)).astype(int) % 4      # phase-change quadrant
    inv = {0: (0, 0), 1: (0, 1), 2: (1, 1), 3: (1, 0)}
    for c in range(K):
        bits[s, 2 * c], bits[s, 2 * c + 1] = inv[q[c]]
    prev = cur

The anchor prev starts as the PRS (symbol 0). For each data symbol:

  1. cur · conj(prev) per carrier — this is the phase difference between this symbol's carrier and the previous symbol's same carrier (magnitude drops out of the angle), which is the differential-QPSK decision variable. No channel estimate is needed because both symbols passed through the same channel.
  2. np.angle(...) mod 2π → the phase change in [0, 2π).
  3. Round to the nearest quadrant q ∈ {0,1,2,3} (round(Δφ/(π/2)) mod 4).
  4. Map the quadrant back to its dibit via inv (§4.4), MSB-first per carrier.
  5. Advance the anchor: prev = cur.

The result is the (76, 3072) hard-bit array. On a clean modulated frame this is bit-exact: test_ofdm_roundtrip_clean (test_dab_ofdm.py) asserts demodulate(modulate(bits)) == bits.

4.4 The DQPSK dibit ↔︎ phase-step mapping

Encoder (ofdm.py): _DPHI = [0, 1, 3, 2] · (π/2), indexed by idx = b₀·2 + b₁, and cur = prev · e^{j·_DPHI[idx]}. Decoder inverts it via the inv dict. The complete, self-consistent table (both directions verified against the code):

Dibit (b₀,b₁) idx Phase step Δφ Quadrant q
0 0 0 0
0 1 1 90° (π/2) 1
1 1 3 180° (π) 2
1 0 2 270° (3π/2) 3

This is a Gray-coded differential QPSK: adjacent phase steps differ by one bit, so a single-quadrant slip corrupts only one of the two bits. Note the code's module docstring (ofdm.py) calls it "pi/4-DQPSK" after the spec, but the implemented steps are multiples of π/2 (a QPSK constellation differentially encoded), not the odd-multiples-of-π/4 rotating constellation of literal π/4- DQPSK. For a self-consistent decoder this is immaterial; for bit-compatibility with a real transmitter it is the second thing (after the symbol count, §3) a hardware bridge must reconcile.

4.5 Frequency de-interleaving (ofdm.py)

DAB scrambles the mapping from logical carrier to physical FFT bin with a fixed pseudo-random permutation, so that a narrowband interferer (which wipes out adjacent physical carriers) damages scattered logical carriers that the convolutional code can then repair. The generator (EN 300 401 §14.6):

def _freq_interleave():
    seq, v = [], 0
    for _ in range(2048):
        v = (13 * v + 511) % 2048
        if 256 <= v <= 1792 and v != 1024:
            seq.append(v - 1024)                  # logical carrier -768..768 excl 0
    return np.array(seq[:K])

PERM = _freq_interleave()
_BINS = np.where(PERM < 0, PERM + TU, PERM)       # negative -> wrap to top of spectrum

The recurrence v ← (13·v + 511) mod 2048 (seed v = 0) is stepped 2048 times; values landing in the retained band 256 ≤ v ≤ 1792, v ≠ 1024 are kept and re-centred to a signed logical carrier v − 1024 (range −768…+768, DC excluded). The first K = 1536 retained values are the permutation PERM. _BINS converts each signed logical carrier to an unsigned FFT bin (negatives wrap: bin = carrier + 2048), so spec[_BINS] in the modulator and fft(t)[_BINS] in the demodulator are inverses.

PERM is a true permutation of the 1536 active carriers — test_frame_parameters (test_dab_ofdm.py) asserts len(set(PERM)) == 1536 and 0 ∉ PERM. Regenerate it with the recipe above; the leading and trailing values (computed) are:

PERM[0:16]  = [-513, -14, 329, 692, -733, 13, 680, 273, -36, 43, 85,
               -432, -318, 473, 516, 150]
PERM[-8:]   = [637, 600, 119, 10, 641, 652, 606, 197]
_BINS[0:16] = [1535, 2034, 329, 692, 1315, 13, 680, 273, 2012, 43, 85,
               1616, 1730, 473, 516, 150]

De-interleaving is implicit: because both TX and RX index the FFT through the same _BINS, the demodulator already delivers carriers in logical order. A receiver written against a real transmitter uses exactly this PERM to place the 1536 recovered cells back into logical carrier order before differential demod.


5. Channel decoding — step by step

Once the physical layer has produced logical-carrier dibits, the FIC and MSC are each un-protected through the shared coding of fec.py and then parsed.

5.1 The mother convolutional code (fec.py)

DAB's inner code is a constraint-length K=7, rate-1/4 convolutional code with generator polynomials (octal) 133, 171, 145, 133:

G = [0o133, 0o171, 0o145, 0o133]      # = [91, 121, 101, 91] decimal
_NS = 64                              # 2^(K-1) states

The trellis is precomputed once (fec.py): for each of the 64 states and each input bit b, the 7-bit register is r = (b << 6) | state, the four output bits are the parities bit_count(r & gᵢ) & 1 for each generator, and the next state is r >> 1:

for _s in range(_NS):
    for _b in (0, 1):
        _r = (_b << 6) | _s
        _OUT[_s, _b] = [_parity(_r & g) for g in G]
        _NXT[_s, _b] = _r >> 1

(Computed: _OUT[0,0]=[0,0,0,0], _OUT[0,1]=[1,1,1,1], _NXT[0]=[0,32].) conv_encode (fec.py) runs the input through this trellis and appends 6 tail bits (zeros) to flush the register back to state 0, so the encoded length is 4·(len + 6).

5.2 The Viterbi decoder (fec.py)

A full soft-decision Viterbi over the 64-state trellis. Inputs are 4 soft values per trellis step (soft.reshape(-1, 4)); both the received values and the trellis outputs are mapped 0/1 → +1/−1 and the branch metric is squared Euclidean distance Σ (r − code)²:

code = 1 - 2 * _OUT.astype(float)      # 0/1 -> +1/-1
rx   = 1 - 2 * soft
...
m = pm[s] + np.sum((r - code[s, b]) ** 2)

Path metrics pm start at pm[0]=0, everything else +∞; at each step every surviving state extends both branches and keeps the minimum-metric predecessor in hist. Because the encoder is tail-terminated, traceback starts from state 0 (fec.py) and walks hist backwards, recovering each bit from whether the surviving predecessor's b=1 branch lands on the current state. The first nbits decoded bits are returned (the 6 tail bits are discarded).

Correctness and strength are both tested (test_dab_fec.py): a clean encode round-trips exactly, and flipping len(coded)//12 ≈ 8 % of the coded bits is still fully corrected — the rate-1/4 code is very strong.

5.3 Depuncturing (fec.py)

The rate-1/4 mother code is punctured up to a higher rate for transmission by dropping selected output bits; the decoder must re-insert erasures (soft value 0.5, exactly midway between the +1/−1 decisions so it contributes no bias) at the dropped positions before running Viterbi:

def depuncture(punctured, pattern, tail=None):
    out = []
    pi = 0
    for keep4 in _cycle(pattern, len(punctured)):
        for k in range(4):
            if keep4[k]:
                out.append(punctured[pi]); pi += 1
            else:
                out.append(0.5)
            ...

pattern is a list of integers, each read as a 4-bit keep-mask over one group of 4 mother-code output bits, most-significant bit first (_cycle, fec.py: keep = [(p >> (3-k)) & 1 for k in range(4)]), and the list is cycled to cover the whole block. So [0b1111] keeps every bit (true rate 1/4, no puncturing) and [0b1110] drops the 4th bit of every group. Verified behaviour (computed):

depuncture([1,2,3,4,5,6,7,8,9], [0b1110])
  = [1, 2, 3, 0.5,  4, 5, 6, 0.5,  7, 8, 9]

test_msc_fec_chain (test_dab_msc.py) drives the whole subchannel FEC with the unpunctured pattern [0b1111].

5.4 Energy dispersal / scrambling (fec.py)

DAB whitens both the FIC and MSC payloads with an additive scrambler — a PRBS from the polynomial x⁹ + x⁵ + 1, initialised all-ones — XORed onto the bits, so long runs and DC are broken up:

def prbs(n):
    reg = 0x1FF                                  # 9-bit shift reg, seed all ones
    out = np.zeros(n, np.int8)
    for i in range(n):
        b = ((reg >> 8) ^ (reg >> 4)) & 1        # taps 9 and 5
        out[i] = b
        reg = ((reg << 1) | b) & 0x1FF
    return out

def energy_disperse(bits):
    return bits ^ prbs(len(bits))

The feedback taps (reg>>8) ^ (reg>>4) are bits 9 and 5 of the 9-bit register (0-indexed 8 and 4), i.e. the polynomial x⁹+x⁵+1; the new bit is fed back in at the LSB. Being an XOR with a fixed sequence, dispersal is its own inverse: test_energy_dispersal_involutive (test_dab_fec.py) checks disperse(disperse(d)) == d. First 32 PRBS bits (computed):

0 0 0 0 0 1 1 1 1 0 1 1 1 1 1 0 0 0 1 0 1 1 1 0 0 1 1 0 0 1 0 0

5.5 FIC → FIG parse (fic.py)

The FIC arrives as FIBs — 32-byte blocks of 30 data bytes + a 16-bit CRC.

FIB CRC (fic.py): CRC-16-CCITT, polynomial 0x1021, init 0xFFFF, and — DAB-specifically — the transmitted CRC is the one's complement of the computed register:

def fib_crc_ok(fib):
    return crc16(fib[:30]) ^ 0xFFFF == (fib[30] << 8 | fib[31])

A FIB is a concatenation of FIGs. Each FIG begins with a 1-byte header: top 3 bits = FIG type, low 5 bits = length of the FIG body in bytes; 0xFF is the end/padding marker (fic.py):

head = fib[i]
if head == 0xFF: break
ftype, flen = head >> 5, head & 0x1F
self._fig(ftype, fib[i+1:i+1+flen])
i += 1 + flen

Four FIG type/extension combinations are parsed (bit fields read MSB-first by the _Bits helper, fic.py):

FIG type 0 (_fig0, fic.py) — after 3 header bits (C/N, OE, P/D) the 5-bit extension selects:

  • ext = 1 — subchannel organisation. Repeating records of SubChId (6 bits), start CU (10 bits), form (1 bit):
    • short form (form = 0, UEP): 2 reserved bits, then a 6-bit UEP table index whose size comes from _UEP_SIZE (§6.4); protection string "UEP-<tbl>".
    • long form (form = 1, EEP): 3-bit option, 2-bit protection level, 10-bit size in CU; protection string "EEP-<level+1><A|B>" where the letter is A if option = 0 else B. Stored as subch[SubChId] = {"start", "size", "prot"}.
  • ext = 2 — service organisation. Per service: 16-bit SId, a local flag + 7-bit component count, then per component a 2-bit TMId; TMId = 0 (MSC stream audio) is followed by a 6-bit ASCTy, the 6-bit SubChId, 2 bits, and the component's SubChId is attached to the service. (Non-audio components skip 14 bits.) Stored as services[SId]["subchid"].

FIG type 1 (_fig1, fic.py) — after 4 charset bits + 1 OE bit, a 3-bit extension selects a 16-character label (b.bytes(16), Latin-1, trailing-stripped):

  • ext = 0 — ensemble label: 16-bit EId + label → Ensemble.eid, Ensemble.label.
  • ext = 1 — programme service label: 16-bit SId + label → services[SId]["label"].

Ensemble.summary() (fic.py) then emits the assembled ensemble ID, name, and a per-service list joining SId → name → SubChId → {start, size, prot} — i.e. everything needed to tune a station and locate its subchannel. test_fig_parsing_builds_service_list (test_dab_fic.py) builds four FIBs (ensemble label, service label, subchannel org, service org) and checks the full join: eid="C181", ensemble="BBC NATIONAL DAB", service sid="C221", name="BBC Radio 4", subchid=1, prot="EEP-3A", size=90.

5.6 MSC → subchannel bytes (msc.py)

A CIF is CIF_CU × CU_BITS = 864 × 64 = 55 296 bits (msc.py). A subchannel is a contiguous CU range extracted directly (msc.py):

def extract_subchannel(cif_bits, start_cu, size_cu):
    a = start_cu * CU_BITS
    return np.asarray(cif_bits)[a:a + size_cu * CU_BITS]

(test_subchannel_extraction, test_dab_msc.py, confirms extract(cif, 10, 5) returns exactly cif[640:960].)

16-CIF convolutional time de-interleaver (msc.py). The MSC is spread over 16 consecutive CIFs so a fade that destroys one CIF only nicks each subchannel. Each bit position i is delayed by a branch delay given by the bit-reversal of i mod 16 scaled to CIF units:

def _br4(x):  return int(f"{x:04b}"[::-1], 2)
_DELAY = [_br4(i) for i in range(16)]   # [0,8,4,12,2,10,6,14,1,9,5,13,3,11,7,15]

The batch de-interleaver (msc.py) and the streaming TimeDeinterleaver (msc.py) both realise the standard convolutional interleaver with 16 branches. The streaming form keeps a 16-CIF history and, on each pushed CIF, emits the CIF that finished de-interleaving (branch delay _DELAY[i mod 16] from the oldest), returning the CIF from 15 frames ago once the pipeline fills:

class TimeDeinterleaver:
    def __init__(self, length):
        self.hist = [np.zeros(length, np.int8) for _ in range(16)]
    def push(self, cif_bits):
        self.hist.append(np.asarray(cif_bits, np.int8)); self.hist.pop(0)
        out = np.empty(self.L, np.int8)
        for i in range(self.L):
            out[i] = self.hist[_DELAY[i % 16]][i]
        return out

Both forms are proven to invert the interleaver and to agree with each other (test_time_deinterleave_roundtrip and test_streaming_deinterleaver_matches_ batch, test_dab_msc.py), reconstructing cifs[m-15] for m ≥ 15.

Subchannel FEC (decode_subchannel, msc.py) is the composition of §5.3–5.4:

def decode_subchannel(deint_bits, nbits, punct_pattern):
    soft = fec.depuncture(np.asarray(deint_bits, float), punct_pattern)
    decoded = fec.viterbi_decode(soft, nbits)
    data = fec.energy_disperse(decoded)
    return np.packbits(data)

depuncture → Viterbi → de-scramble → pack to bytes. The full chain round-trips: test_msc_fec_chain scrambles + convolutionally encodes 240 bits and recovers the original bytes through decode_subchannel(..., [0b1111]).

5.7 DAB+ superframe: RS + AU extraction (audio.py)

For DAB+, the recovered subchannel bytes form a superframe carrying a Reed-Solomon outer code and delimited AAC access units.

Reed-Solomon RS(120,110) over GF(256), primitive polynomial 0x11D, fcr = 0, NROOTS = 10 → corrects up to t = 5 byte errors per 120-byte codeword (audio.py). The field log/antilog tables are built by stepping the primitive element (audio.py):

_x = 1
for _i in range(255):
    _EXP[_i] = _x; _LOG[_x] = _i
    _x <<= 1
    if _x & 0x100: _x ^= 0x11D

(Computed: _EXP[0:8] = [1,2,4,8,16,32,64,128], _EXP[8]=29, _EXP[254]=142, and _EXP[255]==_EXP[0]==1.) rs_correct (audio.py) is a textbook syndrome → Berlekamp-Massey (err_loc) → Chien search (pos) → Forney magnitude (e_eval, prime) decoder; it returns the 110 data bytes if the corrected codeword has zero syndromes, else None. test_rs_120_110_correction (test_dab_audio.py) confirms it corrects exactly 5 injected errors and rejects (≠ data) 7 errors — i.e. it honours the t = 5 bound rather than mis-correcting.

AAC access-unit extraction (superframe_aus, audio.py) parses the superframe header to find the AAC AU boundaries:

header_bits = np.unpackbits(np.frombuffer(sf[:8], np.uint8))
dac_sbr = header_bits[5]
num_aus = {(0,0):4, (0,1):2, (1,0):6, (1,1):3}[(int(header_bits[2]), int(dac_sbr))]
starts[0] = (num_aus * 12 + 16 + 7) // 8            # first AU after the header
for k in range(1, num_aus):
    v = <12-bit start pointer>                      # big-endian, from bit 16
    starts[k] = v
starts[num_aus] = len(sf)
return [sf[starts[k]:starts[k+1]] for k in range(num_aus)]

The number of access units in a 110 ms superframe is set by the (dac_rate, sbr) pair via the table {(0,0):4, (0,1):2, (1,0):6, (1,1):3} — dac_rate picks the 48 kHz vs 32 kHz core sampling and SBR halves the AU count. Each of the num_aus − 1 interior boundaries is a 12-bit byte pointer read big-endian starting at header bit 16; AU 0 begins right after the header ((num_aus·12 + 16 + 7)//8 bytes) and the last AU runs to the superframe end. test_superframe_au_extraction (test_dab_audio.py) builds a header with dac_rate=0, sbr=1 and gets exactly 2 AUs.

Audio decode is delegated (Decoder, audio.py). The AAC (or, for plain DAB, MP2) bytes are handed to a linked library located by name at runtime — libfaad2 for AAC, libmpg123/libmad for MP2 — via ctypes.util.find_library (audio.py). When the library is absent, available() is False and decode() returns an empty array rather than raising; the actual ctypes binding is a documented TODO on the target host (raise NotImplementedError("bind faad2/mpg123 decode on the target host")). test_codec_lib_gated (test_dab_audio.py) asserts the absent-library no-op never raises.


6. Constants and tables

Every table needed to reproduce the decoder, with its source and the exact value or a regeneration recipe.

6.1 Physical layer (ofdm.py)

Constant Source Value
FS, TU, GUARD, TS, NULL ofdm.py 2 048 000; 2048; 504; 2552; 2656
NSYM, K, FRAME ofdm.py 76; 1536; 199 160
_DPHI (DQPSK steps) ofdm.py [0,1,3,2]·π/2 = {0°,90°,270°,180°}
PRS (phase reference) ofdm.py random-QPSK per carrier, np.random.default_rng(1), phase = randint(0,4)·π/2 — a fixed placeholder, not the spec CAZAC table (§8)

6.2 Frequency-interleaving permutation (ofdm.py)

Generator (regeneration recipe): v ← (13·v + 511) mod 2048, seed v = 0, stepped 2048 times; keep values with 256 ≤ v ≤ 1792, v ≠ 1024; emit v − 1024; take the first 1536. FFT-bin form _BINS = PERM + 2048 where PERM < 0.

PERM[0:16] = [-513,-14,329,692,-733,13,680,273,-36,43,85,-432,-318,473,516,150]
PERM[-8:]  = [637,600,119,10,641,652,606,197]

6.3 Convolutional code, puncturing, PRBS (fec.py)

Constant Source Value
Generators G fec.py octal 133,171,145,133 = [91,121,101,91]
States _NS fec.py 64 (K = 7), rate 1/4, 6 tail bits
PRBS polynomial fec.py x⁹+x⁵+1, seed 0x1FF, taps bits 9 & 5
Puncture patterns _PI fec.py {16: [1,1,1,1,1,1,1,0]} (see below)

The _PI dict is a stub, defined but never referenced in the pipeline (decode_subchannel takes the pattern as an argument; the tests pass [0b1111]). Its single entry 16: [1,1,1,1,1,1,1,0] does not match its "1 = keep 4-bit group" comment under the actual _cycle interpretation — because _cycle treats each list element as a 4-bit mask, a bare 1 decodes to [0,0,0,1] and a 0 to [0,0,0,0]. To puncture, pass a list of 4-bit integers (e.g. [0b1110] drops the 4th bit of each group; [0b1111] keeps all). Real DAB's UEP/EEP puncturing indices (EN 300 401 Table 29) would be transcribed here in that 4-bit form; the implementation supplies the mechanism and leaves the specific protection-profile tables to the caller.

6.4 UEP subchannel-size table (fic.py)

Short-form (UEP) subchannel sizes in CU, indexed by table id (EN 300 401 Table 8):

_UEP_SIZE = {0:16, 1:21, 2:24, 3:29, 4:35, 5:24, 6:29, 7:35, 8:42,
             9:52, 10:29, 11:35, 12:42, 13:52, 14:84, 63:0}

Long-form (EEP) subchannels carry their size explicitly (10-bit field), so no table is needed; the protection label is derived as EEP-<level+1><A|B>.

6.5 Time-interleaver branch delays (msc.py)

Bit-reversed 4-bit index (computed):

_DELAY = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15]

6.6 Reed-Solomon RS(120,110) parameters (audio.py)

Parameter Source Value
Field audio.py GF(256), primitive poly 0x11D
NROOTS audio.py 10 → t = 5 correctable byte errors
_FCR (first consecutive root) audio.py 0
Codeword / data (120, 110)
AU-count table audio.py {(0,0):4,(0,1):2,(1,0):6,(1,1):3} keyed by (dac_rate, sbr)

6.7 FIB CRC (fic.py)

CRC-16-CCITT, poly 0x1021, init 0xFFFF, transmitted complemented (crc16(data) ^ 0xFFFF).


7. Interoperability and validation

Because the target radio cannot receive a DAB ensemble (§1), validation is by self-consistent encode/decode round-trips plus error-injection at each stage's design point, not against off-air captures. Each test_dab_*.py module owns one .py:

Test file Stage What it proves
test_dab_ofdm.py ofdm.py Frame constants + PERM is a genuine 1536-permutation excluding DC; find_null returns exactly NULL; demodulate(modulate(bits)) is bit-exact clean; and with the frame embedded in noise on both sides + AWGN at 20 dB, BER < 1e-3.
test_dab_fec.py fec.py Convolutional encode→Viterbi is exact clean and fully corrects ~8 % flipped coded bits; energy dispersal is involutive.
test_dab_fic.py fic.py FIB CRC accepts a good block and rejects a 1-bit corruption; the four-FIG parse assembles the complete ensemble/service/subchannel join (EId, ensemble name, SId, service name, SubChId, EEP-3A, size 90).
test_dab_msc.py msc.py Batch and streaming 16-CIF de-interleavers both invert the interleaver and agree; subchannel extraction is byte-range-exact; the depuncture→Viterbi→de-scramble→pack chain round-trips.
test_dab_audio.py audio.py RS(120,110) corrects exactly 5 errors and rejects 7 (honours the t=5 bound); superframe header yields the right AU count; the absent-codec path is a clean no-op.

Methodology notes. The tests encode with the same module they decode with, so they validate internal invertibility and algorithmic correctness rather than bit-compatibility with a broadcast transmitter. Two specific places would need reconciliation before an off-air decode (both flagged in §3–4): the frame's total OFDM-symbol count (77 here vs 76 in the spec) and the DQPSK step set (π/2 quadrants here vs the spec's π/4-DQPSK), plus swapping the placeholder PRS (§6.1) for the standard CAZAC phase-reference sequence. The channel-coding, FIC-parsing, time- interleaving and RS layers are spec-faithful and would carry over unchanged.

The AWGN OFDM test is the most system-level: it pads the modulated frame with matched-power complex noise (1234 samples before, 3000 after), adds 20 dB AWGN over the whole capture, and still recovers find_null + differential demod to < 1e-3 BER — exercising synchronisation, guard removal, FFT and DQPSK together.


8. Limitations

Honest gaps, roughly in order of how much stands between this code and a working off-air receiver:

  • No audio decoder. AAC (DAB+) and MP2 (DAB) decode are delegated to libfaad2 / libmpg123 and the ctypes binding is an explicit TODO (audio.py). Without the library, Decoder.decode() returns empty. The DAB-specific framing around the codec — RS(120,110), superframe AU delimiting, MP2 sync — is native; the entropy decode is not.
  • No integration driver. The five stages are individually correct and tested but there is no top-level pipeline turning an OFDM (76,3072) bit array into FIBs and CIFs and feeding the FIC/MSC decoders. The FIC/MSC demultiplex (which OFDM symbols and CIF/CU ranges become the FIC vs each subchannel) is the open seam; fic.py/msc.py operate on already-assembled FIBs/CIFs.
  • Frame symbol count deviates by one symbol from Mode I (77 total OFDM symbols incl. PRS vs the spec's 76 → +2552 samples, 97.25 ms vs 96 ms). Per- symbol timing is exact (§3).
  • π/2-DQPSK, not π/4-DQPSK. The implemented differential mapping uses π/2 quadrant steps; the spec (and a real transmitter) uses π/4-DQPSK (§4.4). Self- consistent, but not bit-compatible off-air without change.
  • Placeholder phase-reference symbol. PRS is a fixed pseudo-random QPSK pattern (ofdm.py), not the spec's CAZAC reference; fine as a differential anchor for a self-consistent chain, wrong for real acquisition.
  • Synchronisation is idealised. find_null is a pure energy-minimum detector and demod assumes integer sample alignment and no residual carrier-frequency offset — there is no fractional-timing or CFO estimator. Adequate for clean captures/tests, not for a raw off-air stream.
  • Puncturing profiles are stubs. The depuncture mechanism is complete but the DAB UEP/EEP puncturing-index tables (EN 300 401 Table 29) are not populated (_PI is an unused placeholder, §6.3); the caller must supply the 4-bit keep patterns for a given protection profile.
  • FIG coverage is partial. Only FIG 0/1, 0/2, 1/0, 1/1 are parsed — enough to list stations and locate subchannels; FIC extensions for FEC over the FIC, service linking, announcements, DRM, etc. are not decoded.
  • Requires a wideband front-end. ~1.536 MHz of signal at 2.048 MHz sampling — outside the Icom 705's IF (__init__.py); an RTL-SDR-class or better SDR is needed to run it against a real ensemble.

None of these change the architecture; they are the integration and off-air- compatibility work on top of a validated per-stage core.


9. Implementation and reproduction

File Role Key symbols
app/radio/dab/ofdm.py Mode I OFDM PHY: framing, null sync, FFT, DQPSK, freq de-interleave FRAME, PERM, _BINS, find_null, modulate, demodulate
app/radio/dab/fec.py Shared channel coding: K=7 rate-1/4 conv + Viterbi, depuncture, PRBS G, conv_encode, viterbi_decode, depuncture, prbs, energy_disperse
app/radio/dab/fic.py FIC: FIB CRC + FIG 0/1, 0/2, 1/0, 1/1 → ensemble/service map crc16, fib_crc_ok, Ensemble, _UEP_SIZE
app/radio/dab/msc.py MSC: CIF subchannel extraction, 16-CIF time de-interleaver, subchannel FEC extract_subchannel, deinterleave, TimeDeinterleaver, decode_subchannel
app/radio/dab/audio.py DAB+ superframe: RS(120,110), AU extraction, codec bridge rs_correct, superframe_aus, Decoder
test_dab_{ofdm,fec,fic,msc,audio}.py Per-stage round-trip + error-injection tests

Minimal reproduction of the physical round-trip (pure NumPy):

import numpy as np
from app.radio.dab import ofdm
bits = np.random.default_rng(1).integers(0, 2, (ofdm.NSYM, 2 * ofdm.K))
iq   = ofdm.modulate(bits)                 # -> 199160-sample complex baseband frame
out  = ofdm.demodulate(iq)                 # null-sync + FFT + DQPSK + deinterleave
assert np.array_equal(out, bits)           # bit-exact

and of the DAB+ outer FEC:

from app.radio.dab import audio
data110 = bytes(range(110))
# ... encode with a GF(256) RS(120,110) systematic encoder (see test_dab_audio._rs_encode) ...
assert audio.rs_correct(codeword120) == data110   # corrects up to 5 byte errors

Everything at runtime is NumPy; the only external dependency is the audio codec library (libfaad2 / libmpg123), which is discovered at runtime and gated so its absence is a clean no-op.


10. References

  1. ETSI EN 300 401Radio Broadcasting Systems; Digital Audio Broadcasting (DAB) to mobile, portable and fixed receivers. The base standard: Mode I OFDM parameters (§14), differential modulation and frequency interleaving (§14.5, §14.6), the FIC/MSC structure and FIG definitions (§5–8), convolutional coding and puncturing (§11, Table 29), UEP protection profiles (Table 8), and time interleaving (§12).
  2. ETSI TS 102 563Digital Audio Broadcasting (DAB); DAB+ audio coding (MPEG HE-AACv2). The DAB+ superframe: RS(120,110) outer code, byte interleaving, Firecode header, and AAC access-unit framing.
  3. ETSI TS 101 756DAB; Registered Tables. The registered code tables (charsets, protection, service types) referenced by the FIC parser.
  4. ISO/IEC 11172-3MPEG-1 Audio Layer II (MP2), the audio format of original DAB subchannels.
  5. ISO/IEC 14496-3MPEG-4 Audio (HE-AACv2: AAC-LC + SBR + PS), the DAB+ audio format decoded downstream of superframe_aus.