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
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
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. ```