Version: as implemented, August 2026 (project days 1–3).
Normative source: the Python reference implementation in sounding/
(probe_common.py, modem_common.py, tile_common.py, tx_encode.py,
rx_decode.py, rx_live.py) and the JSON files it reads (spec.json,
spec_wide.json, allocation*.json, closedloop.json). Where this document
and the code disagree, the code wins. All constants below are quoted with the
symbol names under which they appear in the source.
This document specifies everything a second, independent implementation needs to interoperate with ours: the canvas geometry a transmitter must render and a receiver must expect, the exact spectrum and bit-mapping rules, the byte chain from file to frame, and the live WebSocket transport including the closed-loop rate-adaptation messages. The narrative report with the measurement campaign is REPORT.md; the measured numbers cited here are registered in FACTS.md.
We use RFC-style must / should informally; anything described as a constant is normative unless explicitly marked (informative).
The modem transmits a file from a screen to a camera as a sequence of 2-D OFDM symbols. Each displayed frame is one symbol:
- The file is packed into a self-describing byte stream (
MAGIC = b"OMD1"), split intoKequal blocks. - Each frame carries one LT fountain symbol (an XOR of source blocks, chosen deterministically from the frame counter), protected by Reed–Solomon over GF(256).
- The coded bytes become bits, the bits become constellation symbols placed on the canonical bins of a 256×256 spatial-frequency grid, together with known pilots and a repeated BPSK header.
- The Hermitian-completed spectrum is inverse-FFT'd, scaled with a fixed power rule, upsampled 3× and composed onto a canvas with ArUco markers and an ID strip.
The receiver detects the markers, rectifies the canvas by homography, block- averages back to 256×256 samples, FFTs, equalizes on the pilots, decodes header → payload → RS → LT. Any sufficient subset of frames reconstructs the file; lost or corrupted frames are simply missing fountain symbols. The link is unidirectional at the physical layer; the optional closed loop (section 8.5) feeds rate decisions back through the co-located receiver.
Two canvas geometries exist: the single canvas (1000×1000, one tile) and the wide canvas (1880×994, two tiles side by side). A frame may be grayscale (luma) or color (three independent spectra on the display R, G, B channels). All four combinations are defined below.
S = 256(spec["S"]): the pattern is anS × Sgrid of samples; its 2-D DFT is anS × Scomplex spectrumX[u, v].- Signed integer frequencies come from
freq_grids(S):f = np.fft.fftfreq(S) * S, i.e. row/column indexumaps to signed frequencyuforu < S/2andu − Sotherwise. - The rendered pattern is real, so the spectrum is Hermitian: bin
(u, v)and its partner((−u) mod S, (−v) mod S)are complex conjugates.
hermitian_masks(S) partitions the grid:
- self-conjugate bins satisfy
u == (−u) mod Sandv == (−v) mod S(forS = 256: the four bins withu, v ∈ {0, 128}); they must carry real values; - canonical bins are the self-conjugate bins plus the lexicographic first
of each conjugate pair (
(u < cu) | ((u == cu) & (v < cv))).
Only canonical bins carry chosen values; the transmitter mirrors the
conjugate onto the partner bin (build_spectrum, last step). DC (0, 0) is
always zero — mean luminance lives in the canvas background, not the
spectrum, and the receiver removes the sample mean before its FFT.
ring_edges(spec) divides the radial frequency range (0, S/2] into
n_rings = 24 equal bands (width 128/24 ≈ 5.333 bins). ring_map(spec)
assigns each bin the index of its band by radius r = hypot(fu, fv); bands
are half-open (lo, hi] (np.digitize(r, edges, right=True)), so a radius
falling exactly on an edge belongs to the lower ring:
| ring id | meaning |
|---|---|
-1 |
DC (never used) |
0 … 23 |
linear radial bands covering (0, 128] |
24 |
"corner" bins with r > S/2 (beyond the isotropic Nyquist) |
Rings are the granularity of both channel measurement (SNR per ring) and bit loading (constellation per ring).
null_mask(spec) marks a deterministic pseudorandom subset of bins as
permanently silent, used by the sounding receiver to measure noise in-band:
- draw
rng = np.random.default_rng(spec["null_seed"])withnull_seed = 999; - mark canonical bins where
rng.random((S, S)) < null_fractionwithnull_fraction = 0.1; - mirror the mask Hermitianly; force DC out of the mask.
For the reference spec.json this yields 6,589 null bins out of 65,536;
the complement leaves 58,946 active bins, of which 29,474 are
canonical (test vectors, section 10). Null bins are zero in every probe
and every data frame: the layout of section 4 is built only on active
canonical bins, so the null mask is part of the wire format.
reference_spectrum(spec, probe_index) defines the sounding probes:
rng = np.random.default_rng(spec["probe_seed_base"] + probe_index)withprobe_seed_base = 100; the reference set hasn_probes = 8(indices 0–7);- the RNG makes exactly two full-matrix draws, in this order:
k = rng.integers(0, 4, (S, S)), thenr = rng.random((S, S)); - non-self-conjugate canonical active bins carry unit-modulus QPSK at phases
π/4 + k·π/2; - self-conjugate bins carry
−1wherer < 0.5,+1otherwise; - null bins and DC are zero; the rest is the Hermitian mirror.
render_probe maps the probe to pixels with peak normalization:
x = ifft2(X).real; x /= max|x|, then
img = clip(round(127.5 + 127.5 · mod_depth · x), 0, 255) with
mod_depth = 0.85. (Data frames use a different, fixed-σ scaling —
section 3.6.)
Color probes (generate_color_probes.py): for display channel
c ∈ {0,1,2} = {R,G,B} and seed s ∈ {0,1}, the strip index is
idx = color_probe_base + c*2 + s with color_probe_base = 8 (so 8–13),
and the probe spectrum is reference_spectrum(spec, idx) — probe index =
strip index, i.e. RNG seed probe_seed_base + idx. The peak-normalized
pattern is rendered on display channel c only; in the pattern area the
other two channels stay flat at 128 (markers and strip appear on all three
channels). The gray baseline frame (flat-128 pattern) uses strip index
gray_index = 255.
All pixel coordinates are (x, y) with the origin at the top-left of the
canvas, x rightward, y downward. The canvas background is mid-gray
(value 128). The pattern area is the S × S sample grid upsampled by
px_per_sample = 3 (nearest-neighbor block replication, upsample()), i.e.
768×768 pixels; the receiver inverts this with a 3×3 block mean
(block_mean()).
| field | value |
|---|---|
canvas |
1000 × 1000 px |
pattern_size |
768 |
pattern_offset |
116 → pattern rect (116, 116, 768, 768) (pattern_rect) |
marker_dict |
DICT_4X4_50 |
marker_ids |
[0, 1, 2, 3] |
marker_size_px |
90 |
marker_margin_px |
12 |
marker_quiet_px |
10 |
strip_cells / strip_cell_px / strip_y_px |
10 / 32 / 34 |
Marker top-left corners (probe_common.marker_positions), with
m = marker_margin_px, ms = marker_size_px, C = canvas:
| id | position | corner (x0, y0) |
|---|---|---|
| 0 | top-left | (12, 12) |
| 1 | top-right | (898, 12) = (C−m−ms, m) |
| 2 | bottom-right | (898, 898) |
| 3 | bottom-left | (12, 898) |
Each marker is surrounded by a white (255) quiet zone extending
marker_quiet_px = 10 px on every side. The nominal corner coordinates the
receiver matches against are the integer pixel corners
(x0, y0), (x0+ms, y0), (x0+ms, y0+ms), (x0, y0+ms) — see the convention
note in 3.3.
The ID strip is horizontally centered: strip_rect = (340, 34, 320, 32)
(10 cells of 32×32 px).
| field | value |
|---|---|
canvas_w × canvas_h |
1880 × 994 px |
tiles |
2 |
tile_x |
[100, 1012], pattern_y = 113 → tile rects (100, 113, 768, 768) and (1012, 113, 768, 768) |
marker_ids |
[0, 1, 2, 3, 4, 5] |
strip_x / strip_y_px |
400 / 34 → strip rect (400, 34, 320, 32) |
Six markers (_marker_tl): the four corners as in 3.1 (with
H = canvas_h, so bottom row at y0 = 892), plus two mid-edge markers at
xc = (W − ms)//2 = 895: id 4 at (895, 12) (top center) and id 5 at
(895, 892) (bottom center). All other geometry fields (S,
px_per_sample, mod_depth, null_seed, null_fraction, n_rings,
marker size/margin/quiet, strip cells) are identical to spec.json — each
tile reuses the entire per-tile math unchanged.
OpenCV's ArUco detector reports marker corners on pixel edges: a marker
occupying pixel columns x0 … x0+ms−1 has its outer corner at x0 − 0.5.
The wide-canvas nominal positions honor this
(tile_common.marker_positions_wide):
a, b = x0 - 0.5, y0 - 0.5
corners = [(a, b), (a + ms, b), (a + ms, b + ms), (a, b + ms)] # TL TR BR BL
so, e.g., marker 0 on the wide canvas is nominally at (11.5, 11.5).
The single-canvas nominal positions (probe_common.marker_positions) predate
this fix and use the integer corners ((12, 12) etc.) without the −0.5
offset. This asymmetry is frozen: probe_common.py's header warns that
changing the generation functions makes already-recorded patterns
unanalyzable, and the resulting constant sub-pixel bias is compensated
downstream by the pilot-based shift estimator (section 6). An interoperable
implementation must reproduce each geometry's convention exactly as stated.
Rectification (informative but recommended): detect markers, match all
detected corners of known ids against the nominal corners, require at least
3 markers, and estimate a homography with RANSAC (reprojection threshold
3.0, cv2.findHomography(src, dst, cv2.RANSAC, 3.0) in
analyze.rectify_to_canvas / tile_common.find_homography_wide).
Two receiver-side notes, learned from an independent port and worth having before you spend a day on them:
- Marker matching is exact, not error-correcting.
DICT_4X4_50hasmaxCorrectionBits = 1, and OpenCV's detector applieserrorCorrectionRate = 0.6, soint(1 × 0.6) = 0bits are corrected: a candidate must match a dictionary entry exactly in one of its four rotations. Accepting a Hamming distance of 1–2 admits false positives. findHomographywith RANSAC is not invariant to correspondence order. When two competing inlier sets exist — which happens on noisy frames with few visible markers — the order in which correspondences are supplied decides which one RANSAC settles on, and therefore the resulting matrix. A receiver aiming for bit-exact agreement with the reference must feed the correspondences in the detector's own output order. This affects only the degraded cases; on clean frames the consensus is all-inlier and the estimate is order-independent.
The strip is 10 cells of strip_cell_px = 32 px, black (0) or white (255),
encoding one byte per frame (probe_common.strip_bits):
cells = [1, 0] + bits(index, MSB first) # 2 sync cells + 8 data cells
Decoding (decode_strip): threshold each cell mean at
(max + min) / 2, require contrast max − min ≥ min_contrast = 30.0
and the sync pattern 1, 0, else reject the frame.
Strip index assignments:
| index | meaning |
|---|---|
0 … 7 |
sounding probes (probe_index) |
8 … 13 |
color probes (color_probe_base + c*2 + s) |
200 |
data frame (modem_common.DATA_STRIP_INDEX) |
255 |
gray baseline frame (gray_index) |
A receiver must ignore any frame whose strip index is not
DATA_STRIP_INDEX when demodulating data.
tx_encode.compose_canvas (single) / tile_common.compose_wide (wide):
fill background 128, blit the upsampled pattern(s) into the pattern rect(s),
draw the white quiet zones and ArUco markers
(cv2.aruco.generateImageMarker), draw the strip. Color frames replicate
markers and strip identically on all three BGR channels; the pattern area
carries the per-channel patterns (section 5).
Probes are peak-normalized per frame; data frames must not be, because the
receiver's absolute scale reference is the pilots. render_data_frame uses a
fixed statistical scale (CLIP_SIGMA = 4.0):
x = ifft2(X).real
sigma = sqrt(sum |X[u,v]|^2) / S^2 # = RMS amplitude of x (Parseval)
scale = 127.5 * mod_depth / (CLIP_SIGMA * sigma)
img = clip(round(127.5 + scale * x), 0, 255)
round is NumPy rounding (ties to even), here and in render_probe.
Amplitudes beyond ±4σ clip at 0/255 (rare by construction). The consequence
is the total-power constraint that shaped the whole bit-loading design:
the rendered spatial RMS is pinned at 127.5 · mod_depth / CLIP_SIGMA
(≈ 27.1 gray levels) regardless of how many bins are occupied. Occupying
more bins therefore dilutes per-bin power by
10 · log10(N_new / N_old) dB
where N counts all non-zero bins of the channel's spectrum (payload +
pilots + header). We measured this the hard way (621 → 1041 B/frame cost
−3 dB per bin and dropped frame success from 100% to 31%); every allocation
built from live EVM feedback applies this correction before thresholding
(see the note field of allocation_live2.json). Bit loading against this
channel is waterfilling under a total-power constraint, not per-bin SNR
thresholding.
canonical_active_bins(spec) produces the single ordered list from which
every role is assigned:
- take all canonical, active (non-null), non-DC bins;
- sort by
np.lexsort((v, u, radius))— radius first, thenu, thenv(withu, vthe array indices, 0…255).
For the reference spec this list has 29,474 entries and begins
(0,1), (1,0), (1,1), (1,255), (0,2), (2,0), … (all ring 0).
is_pilot[::PILOT_STRIDE] = TruewithPILOT_STRIDE = 20: list indices 0, 20, 40, … are pilots → 1,474 pilots (~5%).- Pilot values: unit-modulus QPSK,
mod_points("qpsk")[rng.integers(0, 4, n_pilots)]withrng = np.random.default_rng(PILOT_SEED),PILOT_SEED = 777.
Pilots are known symbols used for shift estimation and per-ring complex gain equalization (section 6).
HEADER_BITS = 56 bits, transmitted BPSK with HEADER_REPEAT = 4 block
repetitions → 224 header bins: the first 224 list entries that are not
pilots and have ring ≥ 1 (ring 0 is excluded — its bins sit under
low-frequency illumination gradients; we measured ring-0 noise at 130× ring
1). The repetition is block-wise: the 56-bit word is tiled 4 times
(np.tile(header_bits, HEADER_REPEAT)), bins in list order.
Field packing (pack_header / unpack_header), MSB first:
| bits | field | range |
|---|---|---|
| 55–44 | session (12) |
0 … 4095 |
| 43–24 | counter (20) |
0 … 2^20−1 (frame counter = LT seed) |
| 23–8 | K (16) |
1 … 65535 (number of LT source blocks) |
| 7–0 | crc8 (8) |
see below |
word48 = (session << 36) | (counter << 16) | K
crc8 = zlib.crc32(struct.pack(">Q", word48)) & 0xFF # _crc8()
word56 = (word48 << 8) | crc8
Worked example: session=3, counter=17, K=1200 →
word48 = 0x30001104B0, crc8 = 0x20, word56 = 0x30001104B020.
The receiver soft-combines the four repetitions (mean of the equalized real parts) before hard slicing and CRC check.
Every remaining bin (not pilot, not header) whose ring has a constellation
other than "off" in the allocation carries payload. Constellations
(MOD_BITS = {"off": 0, "bpsk": 1, "qpsk": 2, "qam16": 4}), all unit average
power, Gray-mapped, bits MSB first within each symbol
(bits_to_symbols):
| name | mapping (mod_points) |
|---|---|
bpsk |
bit 0 → +1, bit 1 → −1 |
qpsk |
points [1+1j, −1+1j, 1−1j, −1−1j]/√2; first bit selects the imaginary sign (0→+), second bit the real sign (0→+) |
qam16 |
axis levels [−3, −1, 3, 1]/√10 indexed by 2 bits (Gray: 00→−3, 01→−1, 11→+1, 10→+3); first 2 bits → real axis, last 2 → imaginary axis |
Bits are consumed in list order, MOD_BITS[mod] bits per bin; the coded
bitstream is zero-padded up to layout["payload_bits"]. Byte↔bit
conversion is np.unpackbits/np.packbits (MSB first within each byte).
Edge case (informative): a payload or pilot symbol assigned to a
self-conjugate bin (exactly 2 such bins are active in the reference spec:
(0,128) and (128,128))
physically transmits only its real part, since the rendered signal is real.
The code does not special-case this; the resulting error on ≤ 2 bins is
absorbed by RS.
frame_capacity(layout, nsym): gross = payload_bits // 8 bytes;
net = rs_sizes(gross, nsym)[0] (section 7.3). Example, reference
allocation.json (nsym 32): 1,568 payload bins → 1,875 bits → 234 gross →
202 net bytes/frame.
A color frame is three spectra, one per display channel R, G, B, rendered
per channel with the section 3.6 rule and stacked BGR
(render_color_frame). Layout rules:
- Pilots are the same global 1,474 positions/values as v1, split by list
position: pilot
i(index within the pilot list) belongs to channelciffi % 3 == cwith(R, G, B) = (0, 1, 2)→ 492/491/491 pilots. The split is exclusive: on a pilot bin of channelc, the other two channels transmit 0. This is what makes the receiver's per-ring 3×3 crosstalk matrix identifiable. - Header: identical bins to v1, transmitted only in G and exclusive
(R and B transmit 0 there).
layouts["R"]["header"]and["B"]are empty. - Payload: the remaining bins, per channel, according to that channel's
ring allocation (
alloc["channels"][name]). The same bin may carry payload in all three channels simultaneously (that is the 3× multiplexing; the measured 3×3 crosstalk is nearly diagonal, 0.99 vs 0.19–0.25).
A color frame is still one LT symbol: the RS code runs over the
concatenation R || G || B of the payload bits
(color_frame_capacity: gross = (bits_R + bits_G + bits_B) // 8), and the
encoder splits the coded bits back into the three channels in R, G, B order
(encode_frame_color).
Each tile is an independent 256×256 spectrum (luma) or triple (color), with its own pilots/header/payload on the same per-tile layout. Frame-level rules:
- one LT symbol per frame, RS over the whole concatenation:
gross = tiles * per_tile_bits // 8; - bit order across the frame is fixed:
- luma:
tile0 || tile1; - color:
tile0(R || G || B) || tile1(R || G || B);
- luma:
- the header is identical in every tile (same 56-bit word); the receiver uses the first tile whose CRC passes;
- each tile is equalized independently at the receiver.
| allocation | geometry | rs_nsym | per-tile payload bits | gross | net |
|---|---|---|---|---|---|
allocation.json (v1) |
single, luma | 32 | 1,875 | 234 | 202 |
allocation_color_v2.json |
single, color | 48 | 6,947 | 868 | 676 |
allocation_wide_v2.json ("safe") |
wide, color | 48 | 3,135 | 783 | 621 |
allocation_wide_4k.json ("hot") |
wide, color | 48 | 5,317 | 1,329 | 1,041 |
allocation_live2.json (record) |
wide, color | 48 | 8,075 | 2,018 | 1,634 |
(Derived by running frame_layout/color_frame_layout + rs_sizes on the
shipped JSON files; matches the project fact log.)
Interoperability only requires the transmitter rules above; this summary of
rx_decode.py documents what the reference receiver assumes.
- Rectify (3.3), check strip == 200, crop pattern rect, 3×3 block-mean to
S × Ssamples, subtract the mean, FFT (fft2; the color path usesrfft2plus a Hermitian gather of only the used bins,fft_color_bins). - Shift estimation (
_shift_from_pilots): sub-sample residual translation(dy, dx)from the pilots — integer part via the peak of the cyclic cross-correlation (IFFT ofY·conj(pilot)on pilot bins), fractional part via a weighted least-squares fit of the residual phase ramp. Correction:Y *= exp(+2jπ(fu·dy + fv·dx)/S). - Equalization: per-ring complex gain = mean of
Y·conj(pilot)over the ring's pilots (equalize); color: per-ring 3×3 matrixH[r][m][c]= mean of camera-channelmresponse on the exclusive pilots of display channelc, payload recovered asx = H⁻¹ yper bin (equalize_color,demap_payload_color). - Header decoded from G (color) with
H[r][G][G]; the session field dispatches among configured profiles (demod_stateaccepts a{session: (spec, alloc)}map; unknown sessions are counted, stage"unknown_session"). - RS fail-fast: pilot RMS EVM computed on the pilots of payload-carrying
rings only (
pmask = isin(pring, unique(yring))); if it exceedsRS_GATE = 0.55the frame is dropped before demap+RS (stage"rs_skip"). - Retry semantics: an LT counter is marked "seen" only after RS succeeds; a frame whose RS fails may be recovered from a later capture of the same counter.
- RS decoding is
reedsolo-compatible;fast_rs.pyis a bit-identical vectorized decoder (verified againstreedsoloon 4,000/4,000 blocks, 800/800 failure cases and 200/200 full frames, zero divergences).
The transmitted stream is:
| offset | size | content |
|---|---|---|
| 0 | 4 | MAGIC = b"OMD1" |
| 4 | 1 | len(name) |
| 5 | n ≤ 255 | file name, UTF-8 (basename, truncated to 255 bytes) |
| 5+n | 8 | raw_len, big-endian u64 (struct ">QQ") |
| 13+n | 8 | comp_len, big-endian u64 |
| 21+n | 32 | SHA-256 digest of the raw file |
| 53+n | comp_len | zlib.compress(raw, 9) |
The receiver verifies len(decompress) == raw_len and the SHA-256; sha_ok
is the project's end-to-end success criterion (22/22 real transfers
verified). v1 limit: the packed stream must not exceed
MAX_PAYLOAD_BYTES = 1 << 20 (1 MiB, tx_encode.py).
Deflate output is not byte-normative. Only the decompressed result is
part of the contract: comp_len and the compressed bytes may legitimately
differ between zlib implementations at the same level (deflate does not
standardise which matches an encoder emits). A conforming transmitter may
therefore produce a stream that is not byte-identical to the reference
implementation's, and every conforming receiver must still decode it. This
was observed in practice between zlib 1.3.1 and pako 3 on the same input.
split_blocks(stream, block_size): zero-pad to a multiple of
block_size = net (the per-frame net capacity) and split into K blocks,
K = ceil(len/block_size). Constraint: K < 65536 (16-bit header field,
K > 0).
Each frame with counter c carries lt_encode(blocks, c): the XOR of the
source blocks selected by lt_indices(K, seed=c):
rng = np.random.default_rng(np.uint64(0x5EED0000) + np.uint64(seed))
dist = robust_soliton(K, c=0.03, delta=0.5) # _robust_soliton
d = clamp(rng.choice(len(dist), p=dist), 1, K) # degree
idx = np.sort(rng.choice(K, size=d, replace=False))
_robust_soliton is the standard robust soliton — exact formulas (needed
bit-for-bit; probabilities over degrees 0 … K, p[0] = 0):
R = c · ln(K/δ) · √K
ρ[1] = 1/K ; ρ[d] = 1/(d·(d−1)) d = 2 … K
cap = max(int(round(K/R)), 1) (Python round, ties-to-even)
τ[i] = R/(i·K) i = 1 … min(cap, K+1)−1
τ[cap] = R·ln(R/δ)/K if cap ≤ K and R > δ (0 if R ≤ δ)
p = (ρ + τ) / Σ(ρ + τ)
The constants c = 0.03, delta = 0.5 and the seed offset 0x5EED0000
are wire-format constants: both sides must generate identical index sets
from the counter. NumPy's default_rng (PCG64) integers / choice /
random semantics are therefore part of the contract; a non-NumPy
implementation must replicate them exactly.
Two floating-point details are load-bearing. They are not obvious, and a
port that gets them wrong still passes the test vectors in section 10 while
silently diverging on other values of K — we hit both while porting the
engine to TypeScript:
lnmust be correctly rounded. NumPy'slogis; several language runtimes' are not (V8'sMath.logis off by 1 ULP on a measurable fraction of inputs). A 1-ULP error inR = c · ln(K/δ) · √Kshifts the whole degree distribution: in our port it changedrobust_solitonfor K = 24, 154, 173, 340 and 468 among the first 700, whilelt_indicesstill matched every published vector.Σis NumPy's pairwise summation, not a sequential loop.p.sum()isnp.add.reduce: 8 accumulators, block size 128, recursive halving with the halves trimmed to multiples of 8. (By contrast thecumsuminsidechoiceis sequential.)
An implementation that cannot guarantee both should not compute the
distribution at run time: the reference tables it needs may be precomputed
from the reference implementation and shipped as data — this is the approach
the TypeScript port takes for the null mask, pilot values and canonical bin
order, leaving lt_indices as the only run-time PRNG dependency.
Design note for future versions: pinning wire-format behaviour to one library's PRNG and floating-point semantics is a barrier to independent implementation. A revised protocol should specify its own PRNG and derive the degree distribution in a way that is exactly reproducible in integer arithmetic.
The transmitter emits counters 0 … N−1 with
N = ceil(K · (1 + extra)) + 8 (default --extra 0.6) and the display page
loops the set; the decoder (LTDecoder) runs belief-propagation peeling plus
GF(2) Gaussian elimination (try_gauss) on the residual system. The live
receiver triggers try_gauss whenever more than K unique symbols have been
absorbed without completing; the offline decoder (rx_decode._run_decode)
runs it once at end of stream.
Per frame, the LT symbol (net bytes) is RS-encoded blockwise to fill the
gross capacity. rs_sizes(gross_bytes, nsym) computes the block plan
greedily:
while remaining > 0:
blk = min(255, remaining)
if blk <= nsym: break # tail too short to carry data: dropped
sizes.append(blk); net += blk - nsym; remaining -= blk
Each block is an RS(blk, blk − nsym) codeword (a shortened RS(255)).
Any residual tail ≤ nsym bytes of gross capacity is unused padding (the
"trailing byte" pitfall we fixed in step 6c: implementations must not map
those bytes to RS data).
RS parameters are the reedsolo defaults, restated in fast_rs.py:
prim 0x11D, generator 2, fcr 0, c_exp 8 (GF(256)). Parity bytes:
RS_NSYM = 32 by default, overridden per allocation by "rs_nsym"
(load_allocation; all deployed color/live allocations use 48).
Worked example (allocation_live2.json, wide color):
per-tile bits 8,075 → gross = 2·8075 // 8 = 2018 bytes →
sizes = [255×7, 233] (8 blocks) → net = 7·(255−48) + (233−48) = 1634
bytes/frame.
sym = lt_encode(blocks, counter) # net bytes
coded = RS-encode per rs_sizes plan # gross-ish bytes
bits = np.unpackbits(coded), zero-padded to the frame's payload_bits
(split R||G||B per channel; tile0||tile1 across tiles)
X = build_spectrum(spec, layout, pack_header(session, counter, K), bits)
img = render_data_frame(spec, X) → upsample ×3 → compose canvas
tx_encode.py writes the frames as PNGs plus a manifest.js:
window.TX_MANIFEST = {canvas | canvas_w, canvas_h, wide, n_frames, session, K, files, fps_target: 30}; the display pages (display/tx*.html) drive the
screen from it.
One aiohttp server, single HTTPS/WSS port (default --port 8443), TLS with
an auto-generated self-signed certificate (CN = LAN IP, SAN
localhost/127.0.0.1/LAN IP, dir livecert/); the URL is printed as a
terminal QR code. Routes:
| route | role |
|---|---|
GET / |
camera page display/rx_cam.html |
GET /stats |
JSON runtime snapshot |
GET /ws |
camera WebSocket (binary frames + JSON control) |
GET /tx |
(closed loop) transmitter page display/tx_live.html |
GET /txcfg |
(closed loop) set configuration JSON |
GET /txctl |
(closed loop) transmitter control WebSocket |
GET /assets/{key}/{tail} |
(closed loop) frame sets, path-confined |
WebSocket max message size: WS_MAX_MSG = 20 * 1024 * 1024.
The phone camera page captures via getUserMedia — frames never traverse
the phone's video recording pipeline (the codec bottleneck of the
recorded-video path); each frame is individually encoded as a JPEG or a
self-contained all-intra H.264 chunk and sent as a binary WS message.
Demodulation runs in a ProcessPoolExecutor (spawn) with a freshest-first,
depth-1 mailbox dispatcher: stale frames are dropped and counted (drops),
at most one job in flight per worker.
The first byte of each binary message selects the payload codec
(PFX_JPEG/PFX_H264, CONTRACT9):
| prefix | payload |
|---|---|
0x4A ('J') |
JPEG image |
0x48 ('H') |
H.264 Annex-B chunk, all-intra, self-contained (SPS/PPS + IDR in every chunk) |
| anything else | legacy: the whole message is a JPEG (old pages; no clash — JPEG starts 0xFF 0xD8) |
H.264 chunks are decoded by a persistent per-worker av.CodecContext
(h264, low-delay); if PyAV is missing the JPEG path is unaffected.
Client encoder rules (rx_cam.html):
- JPEG: quality
?q=(default 0.9 — never adaptively lowered; the measured lesson is "reduce fps, never quality": an adaptive controller that dropped to q0.7 landed at EVM 40% and 2.9 KB/s, versus EVM 25–36% and 9.21 KB/s at fixed q0.9 — a 3.1× difference); - H.264 via WebCodecs
VideoEncoder, configh264Config():codec: "avc1.640028",bitrate: ?vbr(default 30,000,000; the record run used?vbr=25000000),avc: {format: "annexb"},latencyMode: "realtime",hardwareAcceleration: "prefer-hardware", andencode(frame, {keyFrame: true})on every frame; ?enc=jpegforces the JPEG path;?fps=sets the send target (default 30);?workers=controls the JPEG encode worker pool (0 = in-thread);- backpressure (informative): WS buffer cap 3 MiB; sustained high buffer
→ fps −20% steps down to a floor of 10 fps; recovery +33% steps; quality
constant. On iOS Safari, worker (
OffscreenCanvas) JPEG encode is auto-benchmarked (10 frames, threshold 150 ms) and falls back to the main thread, where encoding is hardware-backed.
Client → server:
Server → client:
// every STATUS_PERIOD_S = 1.0 s
{ "type": "status",
"fps_in": 29.8, "fps_dec": 28.5,
"used": 120, "rs_fail": 6, // LT symbols absorbed / RS failures
"symbols": 120, "K": 127, // progress toward completion
"evm": { "ring": {"1": 24.3}, "channel": {"R": 25.1}, "tile": {"0": 24.9} },
"drops": 3,
"quality": "good" } // "good" | "ok" | "bad"
// once, when the LT decode completes
{ "type": "done",
"sha_ok": true, "file": "demo_payload6.zip",
"seconds": 6.1, "kbs": 33.2 }quality labels (_quality_label over a QUALITY_WINDOW_S = 3.0 s window
of RS outcomes): good if the recent RS-ok rate > 0.70, ok if ≥ 0.30,
else bad. done.seconds is wall-clock from the first received frame to
file verification — the throughput metric used for the 33.20 KB/s record.
{mode, wide, alloc, workers, uptime, frames_in, frames_dec, chunk_kb, drops, bad_jpeg, fps_in, fps_dec, client, used, rs_fail, symbols, K, session, counters, evm, quality}, plus sessions and closed_loop in
closed-loop mode and done after completion. Counter names follow
rx_decode: read, no_rect, bad_strip, not_data, bad_header, mismatch, duplicate, rs_fail, rs_skip, used (+ unknown_session in closed loop).
Configuration file (closedloop.json schema, _load_closedloop):
{ "sets": {
"hot": { "dir": "txframes_ab_hot", "alloc": "allocation_wide_4k.json", "session": 15 },
"safe": { "dir": "txframes_ab_safe", "alloc": "allocation_wide_v2.json", "session": 14 } },
"start": "hot" }At least 2 sets; sessions unique in 0…4095; set names "hot"/"safe" map
onto the two roles (otherwise roles are inferred: safe = first non-start
set). Each set's frame directory is served at /assets/<basename(dir)>/
(basenames must therefore be unique across sets); GET /txcfg returns
{"sets": {<name>: {"dir": <basename>, "session": <n>}}, "start": <name>}.
The demodulator runs multi-profile: one demod_state per session,
header-first dispatch on the session field; all profiles must share the
canvas geometry.
/txctl messages, server → transmitter page:
// on connect
{ "type": "set", "name": "hot", "reason": "init",
"sets": { "hot": {"dir": "txframes_ab_hot", "session": 15},
"safe": {"dir": "txframes_ab_safe", "session": 14} } }
// on a policy switch; reason is human-readable, format
// "rs_ok %d%% < %d%% (%d frame in %gs)" or "rs_ok > %d%% per %.1f s su safe"
{ "type": "set", "name": "safe", "reason": "rs_ok 33% < 40% (...)" }
// transfer complete: stop looping
{ "type": "stop" }Transmitter page → server: {"type": "tx_hello"} on connect,
{"type": "showing", "name": "<set>"} when it actually switches the
displayed set.
Switch policy (_SwitchPolicy), constants normative:
| constant | value | meaning |
|---|---|---|
CL_WINDOW_S |
3.0 s | sliding window of per-frame RS outcomes |
CL_MIN_FRAMES |
4 | minimum frames in window before acting |
CL_LOW |
0.40 | on the hot set: RS-ok rate below 40% → switch to safe |
CL_HIGH |
0.85 | on the safe set: rate above 85%… |
CL_DWELL_S |
5.0 s | …held for ≥ 5 s → back to hot; also the minimum time between switches |
RS_GATE |
0.55 | pilot-EVM fail-fast threshold (rx_decode.RS_GATE), masked to payload rings |
Only frames that reach the RS stage (ok / rs / rs_skip) enter the
policy window, attributed to the set whose session the header carried (a
defensive fallback attributes an unmapped session to the current set);
frames rejected earlier — rectification, strip, header CRC — never enter
the window. Aggregation keeps one LTDecoder per session (block sizes
differ); the first session to complete wins. This
loop produced, as far as we could verify, the first documented closed-loop
rate-adapted screen–camera link (live switch hot→safe→hot with sha_ok).
Luma (v1):
{ "source": "out/reale60/rings.csv", // informative provenance
"margin_db": 2.0, // informative
"rs_nsym": 32, // optional, default RS_NSYM = 32
"rings": [ { "ring": 0, "mod": "qpsk", "snr_db": 26.5 }, ... ] }Color:
{ "source": "...", "margin_db": 1.0,
"xtalk_penalty_db": 0.0, // informative
"note": "...", // informative
"rs_nsym": 48,
"channels": {
"R": [ { "ring": 0, "mod": "off", "snr_db": null }, ... ], // 25 entries
"G": [ ... ], "B": [ ... ] } }Normative content: the presence of "channels" (selects the color layout,
is_color_alloc), rs_nsym, and per ring entry the pair
ring (0 … n_rings = 24) → mod ("off" | "bpsk" | "qpsk" | "qam16").
snr_db and the top-level provenance fields are documentation (may be
null); load_allocation validates only the mod names. Rings absent
from the list are "off" (mod_by_ring.get(ring, "off")).
The same allocation file must be loaded by transmitter and receiver: it determines which bins carry payload and at what constellation, hence the entire bit ordering of the frame.
rs_nsym: 48; active rings (all others "off"):
| channel | ring 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| R | qpsk | off | qpsk | qpsk | qpsk | qpsk |
| G | qpsk | qpsk | qpsk | qpsk | qpsk | qpsk |
| B | qpsk | off | qpsk | bpsk | bpsk | bpsk |
(R and B keep ring 2 off — a display notch measured near 0 dB in sounding;
B rings 4–6 stay BPSK and ring 7 was switched off in v2 after measured RS
failures — see the file's note for the dilution-corrected reasoning.)
Derivation on the wide canvas (spec_wide.json), per tile:
canonical active bins 29,474
pilots (stride 20) 1,474 → R 492, G 491, B 491 (i % 3)
header bins (G only) 224
payload bins R 1,530 G 1,608 B 1,530
payload bits R 3,060 G 3,216 B 1,799 (Σ = 8,075 bits/tile)
gross = 2 tiles · 8,075 // 8 = 2,018 bytes
rs_sizes(2018, 48) → blocks [255 ×7, 233]
net = 7·(255−48) + (233−48) = 1,634 bytes/frame
At 30 symbols/s this is 1,634 × 30 = 49,020 B/s ≈ 47.9 KB/s of net frame capacity; the measured end-to-end record was 206,310 bytes in 6.1 s wall-clock = 33.20 KB/s (89.5% frame success, 15 RS failures out of 143).
Deterministic values any implementation must reproduce (reference
spec.json, S = 256):
| quantity | value |
|---|---|
null bins (null_mask, seed 999, 10%) |
6,589 of 65,536 |
| active bins / canonical active bins | 58,946 / 29,474 |
first 6 canonical active bins (u, v) |
(0,1) (1,0) (1,1) (1,255) (0,2) (2,0) |
| pilots (stride 20) | 1,474; first three at list indices 0, 20, 40 = bins (0,1) (3,2) (5,1) |
| first 4 pilot values ×√2 (seed 777) | −1−1j, +1−1j, −1+1j, −1+1j |
| first 3 header bins (v1 layout) | (2,5) (2,251) (5,2) |
pack_header(3, 17, 1200) |
56-bit word 0x30001104B020 (crc8 0x20) |
lt_indices(K=1200, seed=0) |
[68, 137, 213, 509, 669, 672, 725, 884, 1041, 1058, 1092, 1105] |
lt_indices(K=8, seed=5) |
[2] |
rs_sizes(2018, 48) |
net 1634, blocks [255,255,255,255,255,255,255,233] |
rs_sizes(234, 32) |
net 202, blocks [234] |
Changing any of the following breaks interoperability with recorded frames and/or between endpoints. Grouped by blast radius:
Breaks every frame ever rendered (probes and data):
S,px_per_sample,n_rings,mod_depth;null_seed(999),null_fraction(0.1) — the null mask defines which bins exist;- canvas geometry:
canvas/canvas_w,canvas_h,pattern_offset/tile_x,pattern_y,pattern_size, marker dictionary, ids, size, margin; the strip position/cell size; - the marker-corner conventions of section 3.3 — including the asymmetry (integer corners on the single canvas, −0.5 pixel-edge corners on the wide canvas). "Fixing" the single-canvas convention silently shifts every homography by half a pixel;
probe_seed_base(100) for sounding probes.
Breaks tx/rx data-frame agreement (the header comment in
modem_common.py says exactly this):
DATA_STRIP_INDEX(200),PILOT_SEED(777),PILOT_STRIDE(20),HEADER_REPEAT(4),HEADER_BITS(56) and the field packing/CRC8,RS_NSYM(32) and any allocation'srs_nsym,CLIP_SIGMA(4.0),MAGIC(OMD1);- the canonical-bin sort key
(radius, u, v)— any reordering permutes every pilot/header/payload assignment; - the header placement rule (first non-pilot bins with ring ≥ 1);
- constellation point sets and bit-to-point mappings (section 4.4), MSB-first bit packing;
- the color rules: pilot split
i % 3and its exclusivity, header in G only, RS overR || G || B, channel order; - the wide rules: concatenation order
tile0(R||G||B) || tile1(...), duplicated header; - the LT constants: robust soliton
c = 0.03,delta = 0.5, seed offset0x5EED0000, and NumPydefault_rng(PCG64)choicesemantics — a reimplementation with a different RNG generates different symbol index sets from the same counters; - RS field parameters (prim
0x11D, generator 2, fcr 0) and thers_sizesblock plan including the dropped-tail rule; - the allocation file contents: tx and rx must load byte-equivalent ring → mod maps (the session field exists precisely so multiple allocations can coexist on air).
Breaks the live transport only:
- binary prefixes
0x4A/0x48and the legacy-JPEG fallback rule; - the requirement that H.264 chunks be all-intra and self-contained (SPS/PPS + IDR per chunk) — the receiver decodes each chunk statelessly in whichever worker receives it, so inter-frame dependencies would be undecodable by design;
- JSON message types and field names of
hello/telemetry/status/doneand the/txctlset/stop/tx_hello/showingmessages; - the closed-loop config schema (
sets/dir/alloc/session/start).
Safe to change (per-deployment knobs, not wire format):
- session id,
--extraoverprovisioning, tx frame rate (fps_targetis a display hint), JPEG quality /?vbrbitrate /?fps, worker counts,RS_GATEand theCL_*policy constants (they tune behavior on one side; both ends still interoperate — though policy changes alter the documented switching behavior),/statscontents, EVM reporting.
A future version that must change any breaking constant should bump the
strip index (a new DATA_STRIP_INDEX value) or the stream MAGIC, so that
mixed deployments fail loudly instead of demodulating garbage.