HamDRM: native amateur DRM / EasyPal digital SSTV (DSSTV)
A clean-room, dependency-free implementation of narrowband Digital Radio Mondiale as used for digital SSTV over HF SSB — the "HamDRM" / DSSTV / EasyPal waveform — encoding an image file into a ~2.5 kHz COFDM signal and decoding it back.
Rafe project · app/radio/hamdrm/ · validated in loopback, on-air interop pending
Abstract
HamDRM is the amateur-radio dialect of ETSI ES 201 980 Digital Radio Mondiale, narrowed from the broadcast 4.5/5/10 kHz channels to the ~2.3–2.5 kHz an SSB transceiver passes, and repurposed from streaming audio to file transfer — in practice, JPEG/PNG images, hence "digital SSTV". It is the waveform behind EasyPal (VK4AES) and QSSTV's DRM mode. This document specifies the native Rafe implementation in app/radio/hamdrm/: a coded OFDM (COFDM) physical layer with three robustness modes (A, B, E) and two ham spectrum occupancies, a scattered-pilot channel with time/frequency reference cells, a ham-reduced 48-bit Fast Access Channel (FAC) for in-band mode signalling, a Main Service Channel (MSC) carrying 4/16/64-QAM under Multi-Level Coding (MLC) built on a constraint-length-7 rate-¼ convolutional mother code with the 13 DRM puncturing classes, and above the physical layer a DRM packet mode + DAB-MOT file carousel plus the EasyPal-specific Reed-Solomon repair containers (.rs1– .rs4) and BSR retransmission requests. Everything is pure NumPy at runtime; the numeric protocol tables (pilot phases, puncturing, carrier occupancies) are mechanically generated from the QSSTV reference by scripts/gen_hamdrm_constants.py and committed as constants.py. In loopback the modem decodes bit-perfect across every mode/occupancy/QAM/protection combination at 10 dB SNR and ±8 Hz offset, and a full image round-trips through synthesized audio. This spec is written so the waveform can be re-implemented from scratch: it gives every constant (value or exact regeneration recipe) and both directions of the algorithm as the code actually runs.
1. Background
1.1 DRM, and its narrowband ham dialect
Digital Radio Mondiale (ETSI ES 201 980) is the open standard for digital broadcasting in the bands below 30 MHz. Its physical layer is COFDM: hundreds of QAM-modulated subcarriers with a guard-interval (cyclic prefix) that absorbs multipath, scattered pilots for channel estimation, and a fast signalling channel (the FAC) that tells a cold receiver how to demodulate everything else. DRM defines a family of robustness modes (A/B/C/D/E) trading throughput against tolerance of delay/Doppler spread, and spectrum occupancies that select how many carriers are active.
The amateur adaptation — variously "HamDRM", "DSSTV" (digital SSTV) or by the program names EasyPal and WinDRM/QSSTV — takes the same COFDM machinery but
- narrows it to the ~2.3–2.5 kHz an SSB transmitter passes (a handful of dozen carriers, not hundreds),
- keeps only modes A, B, E (the ones that fit a narrow HF channel),
- replaces the broadcast SDC/FAC signalling with a single 48-bit FAC (no Service Description Channel at all), and
- carries a file (an image) over a DAB-MOT object carousel rather than a continuous audio programme.
This implementation follows the two reference lineages inside QSSTV (ON4QZ): the RXAMADRM receiver (drmrx, natively 12 kHz, itself derived from PA0MBO / Diorama) and the Dream-derived transmitter (drmtx, natively 48 kHz). Tables present on both sides are cross-checked and emitted once, canonically at 12 kHz. No reference source is copied, ported or linked — only the numeric protocol facts are transcribed (see NOTICE.md).
1.2 COFDM in one paragraph
An OFDM symbol is a block of Tu time-domain samples formed by an inverse FFT of a spectrum in which each used FFT bin k holds one complex QAM value (a "cell"). A cyclic prefix of Tg samples — the last Tg samples of the block copied to its front — is prepended, giving a total symbol length Ts = Tu + Tg. The prefix means that as long as the channel's impulse response is shorter than Tg, inter-symbol interference stays inside the prefix and each subcarrier survives as a single complex multiply by the channel H[k], which pilots let the receiver estimate. A group of OFDM symbols is a frame; three frames are a superframe, the unit over which the pilot pattern and the interleaver repeat.
1.3 MLC and the FAC/MSC split
DRM protects the payload with Multi-Level Coding: rather than one code across a QAM symbol, each bit level of the constellation gets its own convolutional code at its own rate (higher bits, which are more reliable, are coded weaker), and the decoder refines the higher levels using the already-decoded lower ones (multi-stage decoding). All levels come from a single rate-¼, K=7 convolutional mother code, punctured to the desired rate.
The bitstream is split into two logical channels multiplexed onto the same OFDM cells:
- the FAC (Fast Access Channel) — a tiny, always-QPSK, heavily-coded field carrying how to decode the rest: robustness mode implied by acquisition, occupancy, interleaver depth, constellation, protection level, and (ham-specific) three characters of the sender's callsign. A cold receiver decodes the FAC first.
- the MSC (Main Service Channel) — the bulk payload, in the FAC-signalled constellation and code rate.
2. System overview
ENCODE (TX) app/radio/hamdrm/
image bytes ─▶ [RS wrap?] ─▶ MOT header+body segments ─▶ data groups (CRC16)
rs.py mot.py
─▶ DRM packets (CRC16) ─▶ unpack to frame bit-arrays (capacity each)
mot.py codec.py
─▶ per frame: energy-disperse ─▶ MLC (conv+puncture+interleave) ─▶ QAM cells
fec.py
─▶ MSC cell-interleave ─▶ place in cell map + pilots + FAC ─▶ IFFT+CP+DC shift
modem.Interleaver cellmap.py / fac.py modem._synth
─▶ real 12 kHz audio ─▶ (up to 48 kHz) ─▶ s16le PCM ─▶ SSB TX
DECODE (RX)
audio ─▶ analytic ─▶ guard-corr mode+frac-freq ─▶ baseband shift ─▶ symbol FFTs
modem._analytic / detect_mode modem.demod
─▶ integer carrier offset (freq pilots) ─▶ frame sync (time refs)
─▶ pilot channel est + equalize ─▶ FAC decode (params, callsign)
cellmap/modem fac.py
─▶ MSC de-interleave ─▶ MLC multi-stage Viterbi ─▶ frame bits
modem.Deinterleaver fec.mlc_decode
─▶ DRM packet reassembly ─▶ MOT reassembly ─▶ [RS repair?] ─▶ image file
mot.PacketAssembler mot.MotAssembler rs.py
Canonical runtime sample rate is 12 000 Hz (SAMPLE_RATE, constants.py); the app carries audio at 48 kHz and integer-resamples 4:1 at the edges. The unit of transmission is the superframe = 3 frames (FRAMES_PER_SUPERFRAME = 3, constants.py).
3. Signal, frame and cell structure
3.1 Robustness modes and OFDM parameters
constants.py (MODES = {mode: (Ts, Tu, symbols/frame)}), all in 12 kHz samples. Guard Tg is computed as Ts − Tu (the reference's stated Tg_C=64 for Mode E is a known inconsistency, so the effective guard is always Ts−Tu; constants.py):
| Mode | Ts | Tu (=FFT) | Tg (=CP) | Tg/Ts | symbols/frame | symbol dur | frame dur | carrier spacing 12000/Tu |
|---|---|---|---|---|---|---|---|---|
| A | 320 | 288 | 32 | 10 % | 15 | 26.67 ms | 400 ms | 41.667 Hz |
| B | 320 | 256 | 64 | 20 % | 15 | 26.67 ms | 400 ms | 46.875 Hz |
| E | 240 | 160 | 80 | 33 % | 20 | 20.00 ms | 400 ms | 75.000 Hz |
Every mode's frame is 400 ms, so a superframe is 1.2 s. Larger Tg/Ts (A→B→E) buys more delay-spread immunity at the cost of throughput. Mode E's short FFT and wide spacing make it the most Doppler/multipath-robust and the slowest.
3.2 Spectrum occupancy and carriers
The used-carrier range (Kmin, Kmax) depends on mode and one of two ham occupancies, SO_0 (~2.3 kHz) and SO_1 (~2.5 kHz); CARRIERS, constants.py. Carriers 0 … Kmin−1 around DC are unused (UNUSED_CARRIERS = (2, 1, 1) for A/B/E, constants.py). Cell/carrier counts per superframe are derived by CellMap (see §3.4):
| Mode | Occ | Kmin | Kmax | # carriers | spacing | audio band (Hz)¹ | pilot cells | FAC cells | MSC cells | N_MUX |
|---|---|---|---|---|---|---|---|---|---|---|
| A | SO_0 | 2 | 54 | 53 | 41.667 | 433–2600 | 309 | 135 | 1941 | 647 |
| A | SO_1 | 2 | 58 | 57 | 41.667 | 433–2767 | 318 | 135 | 2112 | 704 |
| B | SO_0 | 1 | 45 | 45 | 46.875 | 397–2459 | 525 | 135 | 1365 | 455 |
| B | SO_1 | 1 | 51 | 51 | 46.875 | 397–2741 | 570 | 135 | 1590 | 530 |
| E | SO_0 | 1 | 29 | 29 | 75.000 | 425–2525 | 588 | 135 | 1017 | 339 |
| E | SO_1 | 1 | 31 | 31 | 75.000 | 425–2675 | 618 | 135 | 1107 | 369 |
¹ Audio band = DC_HZ + K·spacing for K = Kmin … Kmax, with the EasyPal virtual DC placement DC_HZ = 350 Hz (modem.py). The physical carrier k sits at 350 + k·spacing. Occupancy is a property of Kmax, not the pilot layout — the pilot phase law only depends on carrier index, so SO_0 is SO_1 with the top carriers removed. N_MUX = MSC cells ÷ 3 = the number of MSC cells carrying one logical frame's payload (§3.4); with these tables n_dummy = 0 for all six combinations (MSC cells are exactly divisible by 3).
3.3 The pilot / reference cell scheme
Three kinds of known ("pilot") cells are laid down before any payload. Each carries a unit-ish complex value whose phase is quantized to 1024ths of a turn: define p1024[θ] = exp(2πi·θ/1024) for θ ∈ 0…1023 (cellmap.py).
(a) Scattered pilots (SCATTER, constants.py). Within a frame, symbol s (0-based, frame_sym = abs_sym mod symbols/frame) carries pilots at carriers
k = k0 + x·n + (x·y)·p , n = frame_sym mod y , m = frame_sym // y , p = 0,1,2,…
stepping k by x·y up to Kmax. Each pilot's phase (in 1024ths) and gain are (cellmap.py):
phase = ( 4·Z[n][m] + p·W[n][m] + p²·(1 + frame_sym)·q ) mod 1024
gain = 2.0 if k in the boost set, else √2
value = gain · p1024[phase]
W and Z are per-mode integer tables (dimensions y × (symbols/frame ÷ y)); x, y, k0, q and the boost set are per-mode. The staggering k0 + x·n moves the pilot comb by x carriers each symbol so that, over y symbols, every carrier is piloted — the classic DRM diagonal scatter that lets the receiver interpolate H in both time and frequency.
| Mode | x | y | k0 | q | W,Z dims | boost set (SO_0 / SO_1) |
|---|---|---|---|---|---|---|
| A | 4 | 5 | 2 | 36 | 5×3 | (2,4,50,54) / (2,6,54,58) |
| B | 2 | 3 | 1 | 12 | 3×5 | (1,3,43,45) / (1,3,49,51) |
| E | 1 | 4 | 1 | 10 | 4×5 | (1,29) / (1,31) |
The W/Z tables are given in full below (verbatim from constants.py): w[n] and z[n] are indexed by n = frame_sym mod y (row) then m = frame_sym // y (column); boost is (SO_0 set, SO_1 set), the carriers whose pilot gain is 2 instead of √2.
SCATTER = {
'A': { # W,Z dims 5×3
'x': 4, 'y': 5, 'k0': 2, 'q': 36,
'w': ((228, 341, 455), (455, 569, 683), (683, 796, 910),
(910, 0, 114), (114, 228, 341)),
'z': ((0, 81, 248), (18, 106, 106), (122, 116, 31),
(129, 129, 39), (33, 32, 111)),
'boost': ((2, 4, 50, 54), (2, 6, 54, 58)),
},
'B': { # W,Z dims 3×5
'x': 2, 'y': 3, 'k0': 1, 'q': 12,
'w': ((512, 0, 512, 0, 512), (0, 512, 0, 512, 0), (512, 0, 512, 0, 512)),
'z': ((0, 57, 164, 64, 12), (168, 255, 161, 106, 118), (25, 232, 132, 233, 38)),
'boost': ((1, 3, 43, 45), (1, 3, 49, 51)),
},
'E': { # W,Z dims 4×5
'x': 1, 'y': 4, 'k0': 1, 'q': 10,
'w': ((512, 0, 512, 0, 512), (0, 512, 0, 512, 0),
(512, 0, 512, 0, 512), (0, 512, 0, 512, 0)),
'z': ((0, 57, 164, 64, 12), (168, 255, 161, 106, 118),
(25, 232, 132, 233, 38), (168, 255, 161, 106, 118)),
'boost': ((1, 29), (1, 31)),
},
}
(b) Time-reference cells (TIME_REF) appear only on symbol 0 of each frame, at a fixed list of (carrier, phase/1024) pairs, value √2 · p1024[phase]. They give the receiver a rigid pattern to correlate against for frame synchronisation. They override any scattered pilot at the same cell (cellmap.py, reference writes last). The full lists (verbatim from constants.py):
TIME_REF = {
'A': ((6,973),(7,205),(11,717),(12,264),(15,357),(16,357),(23,952),(29,440),
(30,856),(33,88),(34,88),(38,68),(39,836),(41,836),(45,836),(46,1008)),
'B': ((6,304),(10,331),(11,108),(14,620),(17,192),(18,704),(27,44),(28,432),
(30,588),(33,844),(34,651),(38,651),(40,651),(41,460),(44,944)),
'E': ((7,432),(8,331),(13,108),(14,620),(21,192),(22,704),(26,44),(27,304)),
}
(16 cells for A, 15 for B, 8 for E; only those with Kmin ≤ k ≤ Kmax for the active occupancy are actually laid down.)
(c) Frequency-reference cells (FREQ_REF, constants.py) appear on every symbol, three per mode, again (carrier, phase/1024), value √2 · p1024[phase], and override both scattered and time pilots (cellmap.py). They are the only cells with constant magnitude on every symbol, so the receiver uses their carriers to lock the integer carrier offset. Special case: in Mode E the first two frequency pilots get +512/1024 extra phase on odd symbols (cellmap.py).
| Mode | freq-ref cells (k, θ) |
|---|---|
| A | (9,205) (27,836) (36,215) |
| B | (8,331) (24,651) (32,555) |
| E | (5,788) (15,1014) (20,332) |
3.4 Cell map: how a superframe's grid is filled
CellMap._build (cellmap.py) classifies every used cell of the (symbols/frame × 3) × num_carriers superframe grid, in this precedence (later writes win):
- scattered pilots (the diagonal comb, §3.3a);
- time references on symbol 0 (override pilots);
- frequency references on every symbol (override both);
- FAC cells — from
FAC_CELLS[mode](constants.py), a fixed list of(symbol_in_frame, carrier), 45 per frame, placed only where the cell is not already a pilot (cellmap.py); - everything else is MSC.
The MSC cells are then enumerated in symbol-major, carrier-minor scan order across the whole superframe (cellmap.py). The total is split into three equal logical frames: N_MUX = (total MSC cells) // 3 (cellmap.py); any remainder n_dummy = total − 3·N_MUX cells at the end of the scan carry dummy values (cellmap.py; zero for all standard tables here). Crucially, a logical MSC frame is N_MUX consecutive cells of this superframe-wide scan, so its boundaries cross OFDM symbols — this is why the whole superframe must be assembled before MSC frames can be sliced out.
FAC cells are likewise gathered per frame into fac_order (cellmap.py), asserted to be exactly 45 each.
4. Encode / TX, step by step
Two layers: the data pipeline (image → frame bit-arrays), in codec.py/mot.py /rs.py, and the modem (frame bits → audio), in modem.py/fec.py/fac.py /cellmap.py.
4.1 Image → MOT object → data groups (mot.py)
- (Optional) RS wrap (
codec.encode_image_pcm,codec.py): if a repair type is requested, the raw image is first wrapped into a.rsNcontainer (§4.6) and the filename extension changed to.rs1…4. - MOT header (
mot.mot_header,mot.py): a DAB-MOT header object —BodySize(28) HeaderSize(13) ContentType(6)=2(image) ContentSubType(9)then three MOT parameter extensions: TriggerTime (PLI 2 → 4 zero bytes, "Now"), Version (PLI 1 → 1 byte), and ContentName (PLI 3, id 12: a charset byte + up to 80 filename bytes). ContentSubType comes from the extension:{gif:0, jpg/jpeg/jp2/rsN:1, bmp:2, png:3}(mot.py). - Segmentation (
mot.build_object_groups,mot.py): the header is cut intoHEADER_PARTITION = 98-byte segments; the body intomax(packet_len − BODY_OVERHEAD(14), 16)-byte segments. A header carousel is emitted: the full header at the start, again after everyMOT_HEADER_EVERY = 50body segments, and once more at the end — so a receiver joining mid-transmission still learns the filename/size. - Each segment becomes a data group (
mot.datagroup,mot.py) with a MSC data-group header:Ext=0 CRC=1 Seg=1 UA=1 Type(4)(3 = MOT header, 4 = MOT body),CI(4) RI(4)=0,Last(1) SegNum(15),Rfa(3)=0 TIdFlag=1 LenInd(4)=2,TransportID(16),RepCount(3)=0 SegSize(13), the segment bytes, then a 2-byte CRC-16. The TransportID is a hash of the filename (mot.transport_id,mot.py).
4.2 Data groups → DRM packets (mot.packetize, mot.py)
Each data group is sliced into packets of packet_len body bytes, each with a 3-byte packet header First(1) Last(1) PacketID(2) PPI(1) CI(3) and a 2-byte CRC-16. When the remaining group is shorter than a full packet, PPI=1 and the body is prefixed with a useful-byte count then zero-padded. codec.py sets packet_len = capacity//8 − 3 so exactly one full packet fits one MLC frame's byte capacity minus the 3-byte header.
4.3 Packets → frame bit-arrays (codec.py)
Each packet's bytes are unpacked MSB-first (np.unpackbits) into a bit array of length capacity (Transmitter.capacity, the MSC bits/frame, §4.5). Frames are padded with all-zero frames until their count is a multiple of 3 (codec.py), so each superframe gets exactly three logical frames.
4.4 The FAC (fac.py)
For each of the 3 frames of a superframe, Transmitter.superframe (modem.py) builds a 48-bit FAC and lays its 45 QAM4 cells into the grid.
Bit layout (fac.pack_fac, fac.py), MSB-first per field:
| bits | field | encoding |
|---|---|---|
| 0–1 | identity | frame-in-superframe: {0→3, 1→1, 2→2} |
| 2 | SO | occupancy (0 = SO_0, 1 = SO_1) |
| 3 | interleaver | 0 if long depth, 1 if short |
| 4 | MSC mode | 0 if 64-QAM, 1 otherwise |
| 5 | protection | protection & 1 |
| 6 | service | 1 (data service) |
| 7–8 | packet id | 2 bits |
| 9 | ext-4QAM | 1 if 4-QAM else 0 |
| 10–30 | callsign | 3 chars × 7-bit ASCII (this frame's slice of a 9-char label) |
| 31–39 | pad | zeros to bit 40 |
| 40–47 | CRC-8 | over bits 0–39, transmitted inverted |
The constellation is thus signalled by two bits: MSC-mode 0 ⇒ 64-QAM, else ext-4QAM 1 ⇒ 4-QAM else 16-QAM (fac.py). The callsign (up to 9 chars) is split three chars per frame, so a full superframe reconstructs it. CRC-8 uses poly 0x1D, init 0xFF (fac.crc8, fac.py; CRC8_POLY, constants.py); it is transmitted bit-inverted so the 48-bit re-check yields 0.
FAC channel coding (fac.encode_fac_cells, fac.py): energy-disperse the 48 bits (§4.5.1) → convolutional-encode with puncturing class 6 (rate 3/5) using is_fac=True (the body pattern runs through the tail — FAC uses no tail pattern) → 54 steps → 90 coded bits → block bit-interleave with t0 = 21 → map bit pairs to 45 QAM4 cells.
4.5 The MSC — Multi-Level Coding (fec.py)
For each logical frame, Transmitter.superframe (modem.py) calls fec.mlc_encode to turn capacity info bits into N_MUX complex QAM cells.
Capacity. The number of bit levels is set by the constellation: 4-QAM → 1 level, 16-QAM → 2, 64-QAM → 3 (fec.msc_levels, fec.py). Each level uses one of the 13 puncturing classes chosen by (qam_bits, protection) from RATE_4QAM/RATE_16QAM/RATE_64QAM (constants.py, via fec.rate_classes, fec.py). A level carrying class κ over N_MUX cells holds
M = groups · floor((2·N_MUX − 12) / ones) info bits (fec.level_bits, fec.py)
and produces exactly 2·N_MUX coded bits (asserted, fec.py). Total MSC capacity per frame is the sum over levels (fec.msc_capacity, fec.py). Worked values (SO_1):
| Mode | N_MUX | 4-QAM | 16-QAM p0 | 16-QAM p1 | 64-QAM p0 | 64-QAM p1 | 64-QAM p2 | 64-QAM p3 |
|---|---|---|---|---|---|---|---|---|
| A | 704 | 837 b | 1395 | 1745 | 2094 | 2511 | 2963 | 3286 |
| B | 530 | 627 b | 1047 | 1310 | 1572 | 1883 | 2227 | 2462 |
| E | 369 | 435 b | 726 | 906 | 1087 | 1306 | 1536 | 1704 |
(e.g. Mode B / SO_1 / 16-QAM / prot 0 = 1047 bits = 130 bytes/frame; classes (2,7), per-level [349, 698].) The per-level class tuples are 4QAM:(6), 16QAM: p0 (2,7) p1 (4,9), 64QAM: p0 (0,4,9) p1 (2,7,10) p2 (4,9,11) p3 (7,10,12).
4.5.1 Energy dispersal
Before coding, info bits are XORed with a PRBS (fec.energy_dispersal, fec.py): polynomial x⁹ + x⁵ + 1, register initialised all-ones (reg = 511; PRBS_POLY=(9,5), PRBS_INIT=511, constants.py). The feedback bit is ((reg>>4) ^ (reg>>8)) & 1. It is involutive (same routine undoes it).
4.5.2 The convolutional mother code
K = 7, rate 1/4, four generator polynomials CONV_POLYS = (109, 79, 83, 109) (constants.py) — these are the bit-reversed octal (155, 117, 123, 155), i.e. the standard (133, 171, 145, 133) read in DRM's bit order. A 7-bit shift register (64 states) produces four output bits per input bit, each the parity of register & poly_g (fec._CONV_OUT, fec.py).
Puncturing (fec.puncture_table + fec.conv_encode, fec.py). Each of the 13 classes (PUNCT_PATTERNS, constants.py) is (groups, ones, code₀ … ): groups body pattern-codes applied cyclically to the M info-bit steps, and (for MSC, is_fac=False) a DRM tail pattern for the 6 tail steps, chosen by tail_idx = (2·N_MUX − 12) mod ones from PUNCT_TAIL (constants.py). A pattern code selects which of the 4 mother outputs survive (fec._PAT_BITS, fec.py): 1→1111, 2→0111, 3→0011, 4→0001, 5→0101 (written MSB = mother output g₃ … LSB = g₀; _PAT_BITS = {1:(0,1,2,3), 2:(0,1,2), 3:(0,1), 4:(0,), 5:(0,2)}).
The full puncturing tables (verbatim from constants.py). Each PUNCT_PATTERNS row is (groups, ones, code₀ … code₇) with unused trailing codes 0; the rate is groups/ones. Each PUNCT_TAIL row is the 6 tail-step pattern codes, selected by tail_idx = (2·N_MUX − 12) mod ones:
# class: (groups, ones, body pattern codes ...)
PUNCT_PATTERNS = (
(1, 4, 1, 0, 0, 0, 0, 0, 0, 0), # class 0 rate 1/4
(3, 10, 1, 2, 2, 0, 0, 0, 0, 0), # class 1 rate 3/10
(1, 3, 2, 0, 0, 0, 0, 0, 0, 0), # class 2 rate 1/3
(4, 11, 2, 2, 2, 3, 0, 0, 0, 0), # class 3 rate 4/11
(1, 2, 3, 0, 0, 0, 0, 0, 0, 0), # class 4 rate 1/2
(4, 7, 3, 5, 3, 4, 0, 0, 0, 0), # class 5 rate 4/7
(3, 5, 3, 4, 3, 0, 0, 0, 0, 0), # class 6 rate 3/5 (used by the FAC)
(2, 3, 3, 4, 0, 0, 0, 0, 0, 0), # class 7 rate 2/3
(8, 11, 3, 4, 4, 3, 4, 4, 3, 4), # class 8 rate 8/11
(3, 4, 3, 4, 4, 0, 0, 0, 0, 0), # class 9 rate 3/4
(4, 5, 3, 4, 4, 4, 0, 0, 0, 0), # class 10 rate 4/5
(7, 8, 3, 4, 4, 4, 4, 4, 4, 0), # class 11 rate 7/8
(8, 9, 3, 4, 4, 4, 4, 4, 4, 4), # class 12 rate 8/9
)
PUNCT_TAIL = (
(3, 3, 3, 3, 3, 3),
(2, 3, 3, 3, 3, 3),
(2, 3, 3, 2, 3, 3),
(2, 2, 3, 2, 3, 3),
(2, 2, 3, 2, 2, 3),
(2, 2, 2, 2, 2, 3),
(2, 2, 2, 2, 2, 2),
(1, 2, 2, 2, 2, 2),
(1, 2, 2, 1, 2, 2),
(1, 1, 2, 1, 2, 2),
(1, 1, 2, 1, 2, 1),
(1, 1, 1, 1, 2, 1),
)
The 13 class rates (groups/ones) and reduced denominators RY (RY = (4, 10, 3, 11, 2, 7, 5, 3, 11, 4, 5, 8, 9), i.e. ones per class):
| class | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| rate | 1/4 | 3/10 | 1/3 | 4/11 | 1/2 | 4/7 | 3/5 | 2/3 | 8/11 | 3/4 | 4/5 | 7/8 | 8/9 |
| RY | 4 | 10 | 3 | 11 | 2 | 7 | 5 | 3 | 11 | 4 | 5 | 8 | 9 |
4.5.3 Bit interleaving and QAM mapping
Per level, the 2·N_MUX coded bits are optionally passed through the DRM block bit-interleaver (fec.bit_interleave / make_interleave_table, fec.py) with a per-level t0. INTERL_* flags (constants.py) select t0: 0→13, 1→21, -1→none (_T0 = (13, 21), fec.py). 4QAM:(1), 16QAM:(0,1), 64QAM:(-1,0,1).
The permutation table (make_interleave_table, fec.py): let s be the next power of two ≥ size, q = s/4 − 1; iterate v ← (t0·v + q) mod s, skipping any v ≥ size, to fill the table for indices 1 … size−1 (index 0 stays 0).
QAM map (fec.mlc_encode, fec.py): the constellation is a per-axis PAM — I and Q are modulated independently. For cell i, the real-axis amplitude index takes bit enc[level][2i] from each level with level 0 as MSB, and the imaginary axis takes bit enc[level][2i+1]. The levels-bit index selects one of 2^levels amplitudes from the mapping table:
QAM4(1 bit):[+0.70711, −0.70711](constants.py)QAM16(2 bits):[0.94868, −0.31623, 0.31623, −0.94868](constants.py)QAM64(3 bits):[1.08012, −0.15430, 0.46291, −0.77152, 0.77152, −0.46291, 0.15430, −1.08012](constants.py)
So a cell is tab[idx_re] + 1j·tab[idx_im], giving 4/16/64 constellation points. These amplitudes are the DRM SM (Standard Mapping) values (unit average power).
4.6 MSC cell (symbol) interleaving (modem.Interleaver, modem.py)
The three frames' QAM cells are passed through a cell interleaver of depth D = 5 (long) or D = 1 (short/off): out[t][i] = frame[t − (i mod D)][table[i]], where table = make_interleave_table(N_MUX, 5). Long depth spreads a fading burst across 5 frames (2 s) at the cost of 4 frames of decode latency. codec.py uses short interleaving for images (interleave_long=False, codec.py).
4.7 Assembling and synthesising the superframe (modem.py)
Transmitter.superframe:
grid = CellMap.pilots(all pilot/reference cells pre-loaded).- Write the three frames' FAC cells at
fac_orderpositions (modem.py). - MLC-encode + interleave each frame, concatenate the three cell blocks, and write them into
msc_orderscan positions; positions past3·N_MUXget alternating dummy cells_DUMMY[qam_bits](modem.py, 119-125). _synth(modem.py): for each of thesymbols/frame × 3symbols, placegrid[s, ci]into FFT bin(Kmin + ci) mod Tu,IFFT × Tu, prepend the lastTgsamples as cyclic prefix, up-convert by a phase-continuous carrier atDC_HZ = 350 Hz(w = 2π·350/12000, phase carried across symbols), take the real part. Finally normalise by1.05 × peak.
codec.encode_image_pcm (codec.py) prepends 2400 samples of silence (0.2 s) and appends 12000 (1 s tail so the final superframe demodulates whole), then integer-upsamples 12 k → 48 k by zero-padded rfft/irfft, clips to ±1, scales by 32000, and emits little-endian s16 mono PCM.
5. Decode / RX, step by step (modem.py)
Receiver.demod runs the whole batch over a recording (the streaming wrapper in §7 just re-runs it over a growing window).
5.1 Analytic signal and mode detection
- Analytic signal (
modem._analytic,modem.py): FFT-domain Hilbert transform → complex baseband-readyz. - Mode + fractional frequency + coarse timing (
modem.detect_mode,modem.py): for each candidate mode, correlate each sample against the sampleTuahead (z·conj(z shifted by Tu)) — the cyclic prefix makes this correlation peak where a guard interval aligns. Accumulate the product and the average power over up to 20 symbols, and for every candidate offseto ∈ 0…Ts−1sum aTg-length window; the metric is|Σ product| / Σ power. The best (mode, offset) wins; the fractional carrier error is−angle(corr)/(2π)·(Fs/Tu).
5.2 Frequency correction and symbol FFTs
- Remove the known 350 Hz virtual-DC residual to recover the true fractional error (
modem.py), then shift to baseband so carrierklands on FFT bink:zb = z · exp(−2πi·(350 + f_err)·n/Fs)(modem.py). - Symbol FFTs at coarse timing: for each symbol take
fft(zb[i0 : i0+Tu]) / Tu, skipping the guard (i0 = t_off + s·Ts + Tg,modem.py).
5.3 Integer carrier offset and frame sync
- Integer carrier offset (
modem.py): the three frequency-reference carriers are the only cells with constant magnitude on every symbol; over the first ≤30 symbols, pick the integer shifto ∈ −16…16maximising the summed power at those carriers.cell(s, k)thereafter reads bin(k + k_off) mod Tu. - Frame sync (
modem.py): for each candidatesym0 ∈ 0…symbols/frame−1, correlate the symbol-0 time-reference cells against their known phases (Σ cell(s,k)·conj(p1024[θ])), averaged over up to 6 frames; the peak is the symbol-0 alignment.
5.4 Channel estimation and equalization
_decode_with_map(modem.py) is tried at occupancy 1 then 0 (modem.py). Over the whole record, for every cell the map marks as a pilot,H = received / known_pilot(modem.py).His then interpolated (real and imag separately,np.interp): first in time per carrier over the pilot-bearing symbols, then in frequency per symbol over the carriers (modem.py). Equalized cell =received / H(modem.py).
5.5 FAC decode
- For each frame, equalize its 45 FAC cells and decode (
fac.decode_fac_cells,fac.py): soft metrics−real/−imag(QAM4: bit 0 ⇒+1/√2), block de-interleave (t0=21), Viterbi over the class-6 punctured code, energy-disperse, parse + CRC-8 check. From the good FACs the receiver readsqam_bits,protection,occupancy,interleave_long, and reconstructs the callsign from the three per-frame slices (modem.py). A signalled occupancy mismatch forces a retry with the other map (modem.py).
5.6 MSC decode
Superframe alignment is found from the FAC
frame_ids (modem.py). Because a logical MSC frame isN_MUXconsecutive cells of the superframe-wide scan (crossing OFDM symbols), the receiver assembles whole superframes, slices each into threeN_MUX-cell blocks, feeds them to the cell de-interleaver (modem.Deinterleaver,modem.py;D-frame latency), and MLC-decodes each recovered frame (modem.py).MLC multi-stage decoding (
fec.mlc_decode,fec.py): iterate (3 times for multi-level, once for 4-QAM). Each pass computes per-level soft metrics with_demap_soft(fec.py) — a max-log LLR over the constellation subset consistent with the other levels' already-decoded bits (multi-stage refinement) — then de-interleaves, runs the Viterbi decoder (fec.viterbi_decode,fec.py; 64-state, NumPy-vectorised, hard traceback from state 0 since the 6 tail bits force the zero state), re-encodes to feed the next level. Finally energy-disperse to recover the frame's info bits.
5.7 Data reassembly
- Each recovered frame is repacked to bytes (
np.packbits, first(plen+3)·8bits), fed tomot.PacketAssembler(mot.py; concatenateFirst…Last, CRC-checked) to rebuild data groups, thenmot.MotAssembler(mot.py) collects header + all body segments per TransportID, parses the MOT header for name/size, and emits the completed file when every body segment is present (mot.py). Missing body segments are reportable (mot.missing,mot.py) for a BSR request.
5.8 Reed-Solomon repair (rs.py)
If the completed file is a .rsN container, rs.rs_decode_file (rs.py) recovers the original: de-interleave (distribute reverse), map any known-missing container bytes to erasures through the de-interleave, and run an erasure-only RS(255,K) decoder per block. Because distribute (rs.py) spreads contiguous file bytes across many RS blocks, a burst of lost MOT segments becomes a few erasures per block — well within the 255−K erasure budget.
RS engine (constants.py): RS(255, K) over GF(2⁸) with primitive poly 0x11D, first consecutive root B0 = 1, K ∈ {224,192,160,128} for .rs1…rs4. rs_encode_block is a systematic LFSR remainder (rs.py); rs_decode_block computes syndromes, an erasure locator Λ(x) = ∏(1 − X_j x), Ω = SΛ mod xⁿᵖᵃʳ, and Forney magnitudes e_j = Ω(X_j⁻¹)/Λ'(X_j⁻¹) (rs.py). The container prefix is 7 bytes: surplus, chunks_lo, chunks_hi, ext[3], 0 (rs.py).
6. Constants and tables
All in app/radio/hamdrm/constants.py, auto-generated from the QSSTV reference by scripts/gen_hamdrm_constants.py. Every table is reproduced in full here or in the sections cross-referenced below — no value in this spec requires the source.
6.1 Global
| constant | value | ref |
|---|---|---|
SAMPLE_RATE |
12000 | constants.py |
FRAMES_PER_SUPERFRAME |
3 | constants.py |
MODE_INDEX |
{A:0, B:1, E:2} |
constants.py |
DC_HZ (virtual DC) |
350.0 | modem.py |
6.2 OFDM / cell structure
MODES, CARRIERS, UNUSED_CARRIERS — tabulated in §3.1–3.2. SCATTER W/Z/x/y/k0/q/boost and TIME_REF in full in §3.3; FREQ_REF in §3.3. The FAC_CELLS map — the 45 (symbol_in_frame, carrier) FAC positions per frame — in full (verbatim from constants.py):
FAC_CELLS = {
'A': ((1,10),(1,22),(1,30),(1,50),(2,14),(2,26),(2,34),(3,18),(3,30),
(3,38),(4,22),(4,34),(4,42),(5,18),(5,26),(5,38),(5,46),(6,22),
(6,30),(6,42),(6,50),(7,26),(7,34),(7,46),(8,10),(8,30),(8,38),
(8,50),(9,14),(9,34),(9,42),(10,18),(10,38),(10,46),(11,10),(11,22),
(11,42),(11,50),(12,14),(12,26),(12,46),(13,18),(13,30),(14,22),(14,34)),
'B': ((0,21),(1,11),(1,23),(1,35),(2,13),(2,25),(2,37),(3,15),(3,27),
(3,39),(4,5),(4,17),(4,29),(4,41),(5,7),(5,19),(5,31),(6,9),
(6,21),(6,33),(7,11),(7,23),(7,35),(8,13),(8,25),(8,37),(9,15),
(9,27),(9,39),(10,5),(10,17),(10,29),(10,41),(11,7),(11,19),(11,31),
(12,9),(12,21),(12,33),(13,11),(13,23),(13,35),(14,13),(14,25),(14,37)),
'E': ((1,7),(1,23),(2,8),(2,16),(2,24),(3,9),(3,17),(4,10),(4,18),
(5,11),(5,19),(6,4),(6,12),(7,13),(7,21),(8,6),(8,14),(8,22),
(9,7),(9,23),(10,8),(10,16),(10,24),(11,9),(11,13),(11,17),(12,10),
(12,18),(13,11),(13,19),(14,4),(14,12),(14,16),(15,13),(15,21),(16,6),
(16,14),(16,22),(17,7),(17,23),(18,8),(18,16),(18,24),(19,9),(19,17)),
}
(45 cells per mode.)
6.3 Convolutional code + puncturing
CONV_K = 7, CONV_POLYS = (109, 79, 83, 109). PUNCT_PATTERNS (13 classes) and PUNCT_TAIL (12 rows) are printed in full in §4.5.2, along with RY = (4, 10, 3, 11, 2, 7, 5, 3, 11, 4, 5, 8, 9). Rate-class combinations (verbatim):
RATE_4QAM = 6
RATE_16QAM = ((2, 7, 3), (4, 9, 4))
RATE_64QAM = ((0, 4, 9, 4), (2, 7, 10, 15), (4, 9, 11, 8), (7, 10, 12, 45))
rate_classes(qam_bits, protection) uses the leading 2 (16-QAM) or 3 (64-QAM) entries of the selected protection row as the per-level puncture classes; the trailing value in each row is unused. Interleaver flags INTERL_4QAM = (1,), INTERL_16QAM = (0, 1), INTERL_64QAM = (-1, 0, 1). Rates/RY tabulated in §4.5.2.
6.4 Constellations
QAM4/QAM16/QAM64 (constants.py) — per-axis PAM amplitudes, §4.5.3.
6.5 FEC scalars
Energy-dispersal PRBS x⁹+x⁵+1, init 511 (PRBS_POLY=(9,5), PRBS_INIT=511, constants.py). FAC CRC-8 0x1D/init 0xFF inverted; packet/MOT CRC-16 CCITT 0x1021/init 0xFFFF/complemented (X.25) (constants.py). Bit interleaver seeds t0 ∈ {13, 21} (fec.py; FAC uses 21, fac.py).
6.6 Data layer
MOT_HEADER_PARTITION=98, MOT_RUN_IN=24, MOT_RUN_OUT=10, MOT_HEADER_EVERY=50, PACKET_HEADER_BITS=24 (constants.py). BODY_OVERHEAD=14 (mot.py). Content-subtype map (mot.py). Wire formats: packet, data group, MOT header — §4.1–4.2, docstring mot.py.
6.7 Reed-Solomon
RS(255, K) GF(2⁸) poly 0x11D, RS_BLOCK=255, RS_K={rs1:224, rs2:192, rs3:160, rs4:128} (constants.py, rs.py). GF tables built at import (rs.py). BSR text format — §5.7 / rs.py.
7. App integration (summary)
Two thin adapters wire the codec into Rafe's digimode manager (app/radio/digimodes.py, app/radio/manager.py); the mode id is hamdrm (shown as a "DSSTV" chip).
RX (app/radio/hamdrm_mode.py): HamDrmDecoder receives 48 kHz audio, decimates it 4:1 to 12 kHz (digimodes.py), and feeds codec.StreamDecoder (codec.py). StreamDecoder keeps a sliding 120 s window (WINDOW_S), re-runs the batch Receiver.demod every 3 s of new audio (STEP_S), threads the decoded frames through PacketAssembler/MotAssembler, applies RS repair for .rsN files, and emits status / progress / file events exactly once each. Received images are saved to recordings/ (hamdrm_mode.py), the same surface as SSTV/WEFAX. QUICK_TUNE presets: 14233/7058/3733/21343/28323 kHz (hamdrm_mode.py).
TX (manager.hamdrm_tx, manager.py, driven by the hamdrm_tx websocket command in app/main.py): base64 image (≤200 kB) + callsign + mode (A/B/E) + QAM (4/16/64 → qam_bits 2/4/6) + protection + optional RS type → codec.encode_image_pcm renders the whole transmission to 48 kHz PCM off-thread, then keys PTT and streams it. Defaults: Mode B, 16-QAM, short interleaving.
8. Interoperability and validation
8.1 Loopback / regression tests (test_hamdrm.py, 13 tests)
The suite exercises every layer against the code's own inverse and end-to-end through synthesized audio:
- FEC —
conv_encode/viterbi_decoderound-trip for all 13 puncture classes (coded length asserted 800 for M @ N=400);bit_interleaveinverse for botht0; energy-dispersal involutive (test_hamdrm.py). - MLC —
mlc_encode→noise→mlc_decodebit-exact for every constellation and protection level (4/16/64-QAM × protections) at N=500 with 0.05 complex noise. - FAC — round-trip for all frame ids × constellations with 0.1 noise on the 45 cells; CRC rejects a single-bit corruption.
- Cell map — all six mode/occupancy grids: 45 FAC cells/frame,
N_MUX > 300, every used cell classified. - Modem matrix — full TX→RX loopback for
(A,SO1,16QAM),(B,SO1,64QAM),(B,SO0,16QAM,p1),(E,SO1,4QAM): all 3 frames recovered bit-exact, callsign intact. - Noise + offset — Mode B, 16-QAM at ~12 dB SNR with a +6 Hz carrier offset: all 3 frames recovered.
- Data layer — MOT file round-trip (name + bytes + TransportID); missing-segment reporting (drop body segs 7, 9 → exactly
{7,9}reported); RS repair of a 600-byte erasure burst + BSR build/parse. - End-to-end — a 2500-byte image → MOT → packets → superframes → audio →
Receiver.demod→ reassembled bit-exact.
The native docs summarise the outcome: "Loopback decodes bit-perfect across every mode/occupancy/QAM/protection combination, at 10 dB SNR and ±8 Hz offset." (docs/native-digimodes.md).
8.2 Against EasyPal / QSSTV
The waveform is built to be the same one EasyPal (VK4AES) and QSSTV's DRM mode transmit: amateur DRM modes A/B/E, the ham 48-bit FAC, DAB-MOT carousel, the EasyPal .rs1–.rs4 repair containers and BSR fix-requests. The constant tables are mechanically extracted from the QSSTV reference and cross-checked between its two lineages (drmrx/RXAMADRM receiver and drmtx/Dream transmitter). Three reference-internal discrepancies are pinned with the TX side canonical: Mode A SO_0 boost set, Mode B time-pilot phase (944 vs 950), Mode E W/Z dimensions (docs/native-digimodes.md, NOTICE.md). On-air interop against a live EasyPal/QSSTV station is the remaining real-world validation step — everything to date is internal loopback.
9. Limitations
- No verified on-air interop yet. Bit-exact self-consistency and the standard tables strongly imply compatibility, but a real EasyPal/QSSTV exchange has not been run. The pinned reference discrepancies (§8.2) are the most likely interop friction points.
- Batch, re-run receiver. RX re-demodulates a growing 120 s window every 3 s rather than tracking incrementally — simple and robust for file transfer, but O(window) work per poll and a hard 120 s ceiling on a single image.
- Erasure-only RS.
rs_decode_blockcorrects erasures (known-missing positions from absent MOT segments), not unknown-position errors (rs.py); a CRC-passing but wrong byte is not repaired. This matches the reference's use of RS purely for lost-segment recovery. - Hard-feedback MSD. Multi-stage decoding uses 3 iterations with hard bit feedback between levels (
fec.py), not full iterative soft (turbo-like) exchange; near threshold this leaves some coding gain on the table. - Frequency-only integer search ±16 bins. Acquisition assumes the signal is within ±16 carriers of nominal and within one guard of timing; far-off tuning won't lock.
- Simple pilot interpolation. Channel estimation is linear
np.interpin time then frequency (modem.py) — fine for the benign HF cases tested, weaker than a 2-D Wiener estimator on fast-fading paths, especially Mode A (10 % guard). - No FreeDV-style waveform reuse. This is strictly the DRM/EasyPal image mode; it does not carry the project's voice codecs.
10. Implementation & reproduction
| file | role |
|---|---|
app/radio/hamdrm/constants.py |
all protocol tables (auto-generated) |
app/radio/hamdrm/cellmap.py |
pilot / FAC / MSC cell layout per superframe |
app/radio/hamdrm/modem.py |
COFDM modulator + demodulator, cell interleaver |
app/radio/hamdrm/fec.py |
mother code, puncturing, Viterbi, MLC, dispersal |
app/radio/hamdrm/fac.py |
48-bit FAC pack/parse + coding + CRC-8 |
app/radio/hamdrm/mot.py |
DRM packet mode + DAB-MOT file carousel |
app/radio/hamdrm/rs.py |
RS(255,K) repair containers + BSR requests |
app/radio/hamdrm/codec.py |
public API: encode_image_pcm, StreamDecoder |
app/radio/hamdrm_mode.py |
app RX adapter (HamDrmDecoder) |
scripts/gen_hamdrm_constants.py |
generates constants.py from QSSTV |
test_hamdrm.py |
13-test regression suite |
Runtime API (app/radio/hamdrm/__init__.py):
from app.radio.hamdrm import encode_image_pcm, StreamDecoder, Transmitter, Receiver
# TX: image file bytes -> 48 kHz s16le PCM for the whole transmission
pcm = encode_image_pcm(open("pic.jpg","rb").read(), "pic.jpg", "M0ABC",
mode="B", occupancy=1, qam_bits=4, protection=0, rs_type="rs2")
# RX (batch): 12 kHz float audio -> {mode, qam_bits, callsign, frames, …}
res = Receiver().demod(audio12k)
# RX (streaming): feed 12 kHz chunks, poll for status/progress/file events
dec = StreamDecoder(); dec.feed(chunk12k); events = dec.poll()
Regenerate the constant tables from the reference (transcription, run once):
git clone --depth 1 https://github.com/ON4QZ/QSSTV refs/qsstv_src
python scripts/gen_hamdrm_constants.py # -> app/radio/hamdrm/constants.py
Everything at runtime is NumPy; the only committed data artefact is constants.py, mechanically extracted exactly as the other native modes' tables.
11. References
- ETSI ES 201 980, Digital Radio Mondiale (DRM); System Specification — the COFDM physical layer, robustness modes, FAC/MSC/MLC, puncturing and pilot scheme.
- ETSI EN 300 401 / TS 101 756, Digital Audio Broadcasting (DAB) — the MOT (Multimedia Object Transport) and packet-mode data-group formats.
- QSSTV by ON4QZ (GPL-3.0) — the reference implementation whose
drmrx(from RXAMADRM / Diorama, PA0MBO) anddrmtx(from Dream, TU Darmstadt) the constant tables are extracted from. https://github.com/ON4QZ/QSSTV - EasyPal / DigTRX by Erik Sundstrup VK4AES — the de-facto amateur DSSTV program defining the
.rs1–.rs4repair containers and BSR retransmission dialect. - Dream DRM software radio, TU Darmstadt — the DRM transmitter lineage.
docs/native-digimodes.md(§HamDRM) andNOTICE.md— provenance, the pinned reference discrepancies, and licensing.