orion_sdr/demodulate/ofdm_frame.rs
1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/demodulate/ofdm_frame.rs
5//
6// The OFDM frame (MAC-layer) demodulator: the exact inverse of
7// `modulate::ofdm_frame`. The *batch* receiver [`OfdmFrameDemod`] decodes a frame
8// at a KNOWN start, and [`OfdmFrameStreamDemod`] handles the unknown-start /
9// streaming case. It runs the concatenated COFDM decode chain:
10//
11// IQ → soft-demap (LLRs) → inner-deinterleave (LLR) → inner-decode →
12// outer-deinterleave (byte) → outer-decode → descramble → strip CRC
13//
14// The header is decoded first with the fixed built-in scheme (BPSK + rate-1/2
15// LDPC) to recover `mcs_index`/`payload_len`/`sequence_num`/`flags`/seed, then
16// the payload is decoded at the MCS the header selected.
17
18use crate::core::Block;
19use crate::demodulate::ofdm::{
20 EqualizerMethod, OfdmDemod, OfdmEqualizer, OfdmRxFrame, OfdmSoftDemod,
21};
22use crate::demodulate::ofdm_probe::{OfdmRxProbe, ProbeMeta};
23use crate::dsp::Rotator;
24use crate::fec::{
25 BlockInterleaver, ConvDeinterleaver, ConvInterleaver, CrcKind, DecodeRule, FrameMetadata,
26 FramePacket, InnerFec, InterleaverKind, OuterFec, RxError, ScramblerKind, ScramblerPos,
27 viterbi_decode_soft_with,
28};
29use crate::modulate::ofdm::{ConstellationOrder, OfdmConfig};
30use crate::modulate::ofdm_frame::{
31 BCH_INFO_BITS, BlockPlan, CodecCache, HEADER_CONSTELLATION, HEADER_FIELD_BYTES, HEADER_LDPC,
32 McsTable, bits_to_bytes, block_plan, build_scrambler, bytes_to_bits, check_and_strip_crc,
33 encode_chain_stages, inner_encode, interleave_bits, scramble_bits, scramble_bytes,
34 symbol_config, symbols_for_coded_bits,
35};
36use crate::multicarrier::{CarrierGrid, GridExtract, SymbolFft};
37use crate::sync::{OfdmPreamble, earliest_accepted, ofdm_sync};
38use num_complex::Complex32 as C32;
39use std::sync::Arc;
40
41/// Soft-demaps `n_symbols` OFDM symbols starting at `iq[0]` into a flat LLR
42/// vector (one `f32` per coded bit, `+ ⇒ bit 0`). Returns `None` if `iq` is
43/// too short.
44///
45/// With `equalizer = None` this is the flat-channel path (`OfdmDemod →
46/// OfdmSoftDemod`, no per-bin correction) used by the batch entry point. With
47/// an equalizer whose channel estimate is already set (from a training
48/// symbol), it runs the full `CyclicPrefixRemove → FftBlock → OfdmEqualizer →
49/// GridExtract → OfdmSoftDemod` chain, correcting a frequency-selective
50/// channel — the streaming receiver's path.
51/// `symbol_sink`, when supplied, receives every equalized data-carrier symbol
52/// in demap order. EVM is measured by comparing these against the ideal points
53/// their own hard decisions map back to, so it needs the constellation-domain
54/// symbols the LLRs are derived from — which are otherwise consumed in place.
55fn soft_demap(
56 base: &OfdmConfig,
57 constellation: ConstellationOrder,
58 iq: &[C32],
59 n_symbols: usize,
60 equalizer: Option<&mut OfdmEqualizer>,
61 mut symbol_sink: Option<&mut Vec<C32>>,
62) -> Option<Vec<f32>> {
63 let cfg = symbol_config(base, constellation);
64 let sps = cfg.samples_per_ofdm_symbol();
65 if iq.len() < n_symbols * sps {
66 return None;
67 }
68 let n_data = cfg.carrier_plan.data_carriers().len();
69 let bps = cfg.bits_per_ofdm_symbol();
70 let mut soft = OfdmSoftDemod::new(&cfg);
71 let mut symbols = vec![C32::default(); n_data];
72 let mut llrs = vec![0.0f32; n_symbols * bps];
73
74 match equalizer {
75 None => {
76 let mut demod = OfdmDemod::new(&cfg);
77 let mut in_off = 0;
78 let mut out_off = 0;
79 for _ in 0..n_symbols {
80 let dw = demod.process(&iq[in_off..], &mut symbols);
81 if dw.out_written != n_data {
82 return None;
83 }
84 if let Some(sink) = symbol_sink.as_deref_mut() {
85 sink.extend_from_slice(&symbols);
86 }
87 let sw = soft.process(&symbols, &mut llrs[out_off..out_off + bps]);
88 if sw.out_written != bps {
89 return None;
90 }
91 in_off += sps;
92 out_off += bps;
93 }
94 }
95 Some(eq) => {
96 let n_fft = cfg.carrier_plan.n_fft();
97 let cp_len = cfg.carrier_plan.cp_len();
98 let grid = CarrierGrid::from_plan(&cfg.carrier_plan);
99 let mut symbol_fft =
100 SymbolFft::new(n_fft, cp_len).with_window_backoff(base.rx_window_backoff);
101 let mut grid_extract = GridExtract::new(grid);
102 let mut equalized = vec![C32::default(); n_fft];
103 // Every symbol is extracted before any is demapped: the phase
104 // tracker below needs the whole payload to fit a ramp across it,
105 // and the LLRs must come from the *corrected* symbols.
106 let mut all = vec![C32::default(); n_symbols * n_data];
107 let mut in_off = 0;
108 for k in 0..n_symbols {
109 let freq = symbol_fft.demod_symbol(&iq[in_off..])?;
110 if eq.process(freq, &mut equalized).out_written != n_fft {
111 return None;
112 }
113 if grid_extract.process(&equalized, &mut symbols).out_written != n_data {
114 return None;
115 }
116 all[k * n_data..(k + 1) * n_data].copy_from_slice(&symbols);
117 in_off += sps;
118 }
119
120 remove_common_phase_error(&cfg, &mut all, n_symbols);
121
122 let mut out_off = 0;
123 for k in 0..n_symbols {
124 let block = &all[k * n_data..(k + 1) * n_data];
125 if let Some(sink) = symbol_sink.as_deref_mut() {
126 sink.extend_from_slice(block);
127 }
128 let sw = soft.process(block, &mut llrs[out_off..out_off + bps]);
129 if sw.out_written != bps {
130 return None;
131 }
132 out_off += bps;
133 }
134 }
135 }
136 Some(llrs)
137}
138
139/// Minimum payload length, in OFDM symbols, worth running phase tracking over.
140///
141/// Below this the accumulated rotation is negligible and a two-point ramp fit
142/// is noise. The frame header (one or two symbols, immediately after the
143/// training symbol the channel was estimated from) falls under it by design.
144const CPE_MIN_SYMBOLS: usize = 4;
145
146/// Removes the residual **common phase error** accumulated across a frame's
147/// symbols, in place.
148///
149/// **Why this is not optional.** The Schmidl & Cox carrier estimate has
150/// variance, and `TrainingSymbolHold` measures the channel once from the
151/// training symbol and holds it for the whole frame — nothing revisits it. A
152/// residual offset `e` therefore integrates to `2*pi*e*T` of constellation
153/// rotation by the end of a frame of duration `T`, and the receiver never sees
154/// it happen. A few Hz of estimation error is already tens of degrees by the
155/// last symbol of a 50 ms frame, and QPSK's decision boundary is 45.
156///
157/// The concatenated FEC itself holds FER = 0 far below that (`snr::cofdm_fer`,
158/// batch demodulator at a known start), so without this the streaming receiver
159/// gives away link budget the FEC already paid for — and the resulting errors
160/// look exactly like an FEC cliff.
161///
162/// **A better initial estimate is not the fix.** S&C's variance is set by the
163/// preamble's total correlated energy, so correlating at lag `3L` instead of
164/// `L` triples the phase-to-frequency scaling and correlates a third as many
165/// samples — measured 10.95 Hz against 10.96 Hz, a gain of 1.0x. Nothing is won
166/// without spending more preamble.
167///
168/// **How.** Two passes over the equalized data symbols:
169///
170/// 1. A decision-directed tracking loop. Each symbol is de-rotated by the
171/// phase predicted from the symbols before it, demapped, and its hard
172/// decisions remapped to ideal constellation points; the residual
173/// `arg(sum(y * conj(y_ideal)))` drives a second-order loop. Prediction is
174/// what makes this work at all: a decision-directed estimate is only valid
175/// while decisions are, and they stop being valid past 45 degrees for QPSK
176/// — the very rotation this exists to remove. Tracking incrementally keeps
177/// the residual *at the point of decision* well under a degree.
178/// 2. A least-squares fit of the accumulated phase against symbol index. A
179/// carrier offset is a straight line by construction, so fitting one pools
180/// every symbol's estimate into two parameters instead of trusting each
181/// alone, and removes the loop's start-up transient from the correction
182/// actually applied.
183///
184/// Correcting per symbol rather than per frame moves the rotation budget from
185/// the frame duration to one symbol. Measured end to end on the standard COFDM
186/// test plan (53.8 ms frame), frame error rate against the known transmitted
187/// payload, 60 trials per point:
188///
189/// | In-band SNR | FER without | FER with |
190/// | --- | --- | --- |
191/// | 20 dB | 0.083 | **0.000** |
192/// | 15 dB | 0.350 | **0.017** |
193/// | 12 dB | 0.550 | **0.050** |
194/// | 10 dB | 0.717 | **0.133** |
195/// | 8 dB | 0.783 | **0.367** |
196///
197/// Error-free reception starts at 20 dB rather than 25, and every point below
198/// it improves several-fold. Reproduce with `snr::cofdm_stream_fer`.
199///
200fn remove_common_phase_error(cfg: &OfdmConfig, symbols: &mut [C32], n_symbols: usize) {
201 let n_data = cfg.carrier_plan.data_carriers().len();
202 if n_symbols < CPE_MIN_SYMBOLS || n_data == 0 || symbols.len() < n_symbols * n_data {
203 return;
204 }
205 // Loop gains: fast enough to lock inside a short payload, slow enough that
206 // per-symbol estimator noise does not drive the prediction. The fit below
207 // is what sets the accuracy of the applied correction, so these only have
208 // to keep the decisions valid.
209 const ALPHA: f32 = 0.5;
210 const BETA: f32 = 0.05;
211
212 let mut soft = OfdmSoftDemod::new(cfg);
213 let mut mapper = crate::modulate::ofdm::ideal_symbol_mapper(cfg.constellation);
214 let bps = cfg.bits_per_ofdm_symbol();
215 let mut llrs = vec![0.0f32; bps];
216 let mut bits = vec![0u8; bps];
217 let mut ideal = vec![C32::default(); n_data];
218 let mut rotated = vec![C32::default(); n_data];
219 let mut measured = vec![0.0f32; n_symbols];
220
221 let (mut phase, mut freq) = (0.0f32, 0.0f32);
222 for k in 0..n_symbols {
223 let block = &symbols[k * n_data..(k + 1) * n_data];
224 let (sin, cos) = (-phase).sin_cos();
225 let p = C32::new(cos, sin);
226 for (dst, src) in rotated.iter_mut().zip(block) {
227 *dst = src * p;
228 }
229 if soft.process(&rotated, &mut llrs).out_written != bps {
230 return;
231 }
232 for (b, l) in bits.iter_mut().zip(llrs.iter()) {
233 // Crate-wide LLR convention: positive means bit 0 is more likely.
234 *b = u8::from(*l <= 0.0);
235 }
236 if mapper.process(&bits, &mut ideal).out_written != n_data {
237 return;
238 }
239 let mut acc = C32::default();
240 for (y, r) in rotated.iter().zip(ideal.iter()) {
241 acc += y * r.conj();
242 }
243 let err = if acc.re == 0.0 && acc.im == 0.0 {
244 0.0
245 } else {
246 acc.im.atan2(acc.re)
247 };
248 // The total rotation observed on this symbol, before the loop moves on.
249 measured[k] = phase + err;
250 phase += freq + ALPHA * err;
251 freq += BETA * err;
252 }
253
254 // Least-squares line through (k, measured[k]).
255 let n = n_symbols as f32;
256 let sum_k = n * (n - 1.0) / 2.0;
257 let sum_k2 = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
258 let sum_y: f32 = measured.iter().sum();
259 let sum_ky: f32 = measured.iter().enumerate().map(|(k, y)| k as f32 * y).sum();
260 let denom = n * sum_k2 - sum_k * sum_k;
261 if denom.abs() <= f32::EPSILON {
262 return;
263 }
264 let slope = (n * sum_ky - sum_k * sum_y) / denom;
265 let intercept = (sum_y - slope * sum_k) / n;
266
267 for k in 0..n_symbols {
268 let (sin, cos) = (-(intercept + slope * k as f32)).sin_cos();
269 let p = C32::new(cos, sin);
270 for c in &mut symbols[k * n_data..(k + 1) * n_data] {
271 *c *= p;
272 }
273 }
274}
275
276/// Scattered-pilot variant of [`soft_demap`] for DVB-T: demaps `n_symbols` OFDM
277/// symbols through the four-phase grid rotation (`extractor`). For each symbol it
278/// runs `CyclicPrefixRemove → FftBlock`, installs that symbol's phase-`l`
279/// continual+scattered+TPS pilot set on a per-symbol-interpolating equalizer
280/// (`OfdmEqualizer::set_pilot_bins` + `PerSymbolPilotInterp`), equalizes,
281/// extracts the phase-`l` data bins, and soft-demaps. The `extractor`'s phase
282/// counter carries across calls, so a frame's header-then-payload symbols form
283/// one continuous rotation matching the TX (`l = 0` at the first symbol after
284/// [`ScatteredPilotExtractor::reset`]).
285///
286/// This is the conformant DVB-T channel-estimation path (dense scattered pilots
287/// per symbol), replacing the Phase-1 training-symbol hold. Returns `None` if
288/// `iq` is too short.
289fn soft_demap_scattered(
290 base: &OfdmConfig,
291 constellation: ConstellationOrder,
292 iq: &[C32],
293 n_symbols: usize,
294 extractor: &mut crate::waveform::dvb_t::ScatteredPilotExtractor,
295) -> Option<Vec<f32>> {
296 let cfg = symbol_config(base, constellation);
297 let sps = cfg.samples_per_ofdm_symbol();
298 if iq.len() < n_symbols * sps {
299 return None;
300 }
301 let n_fft = cfg.carrier_plan.n_fft();
302 let cp_len = cfg.carrier_plan.cp_len();
303 let n_data = extractor.num_data_carriers();
304 let vbits = constellation.bits_per_symbol();
305 let bps = n_data * vbits;
306
307 // Payload symbols on a DVB-T constellation get the DVB-T-exact soft LLRs
308 // (Figure-9a bit assignment); a BPSK header block uses the generic demapper.
309 let dvb_t_llr = crate::waveform::dvb_t::is_dvb_t_constellation(constellation);
310 let mut soft = OfdmSoftDemod::new(&cfg);
311 // A per-symbol-interpolating equalizer; its pilot set is re-installed for
312 // each symbol's phase before `process`.
313 let mut eq = OfdmEqualizer::new(&cfg, EqualizerMethod::PerSymbolPilotInterp);
314 let mut symbol_fft = SymbolFft::new(n_fft, cp_len).with_window_backoff(cfg.rx_window_backoff);
315 let mut equalized = vec![C32::default(); n_fft];
316 let mut symbols = vec![C32::default(); n_data];
317 let mut llrs = vec![0.0f32; n_symbols * bps];
318
319 let mut in_off = 0;
320 let mut out_off = 0;
321 for _ in 0..n_symbols {
322 let freq = symbol_fft.demod_symbol(&iq[in_off..])?;
323 // Install this symbol's phase-`l` pilots (bins + known TX values) and the
324 // phase's data bins to interpolate across, then equalize from them.
325 eq.set_pilot_bins(
326 extractor.phase(),
327 extractor.current_pilot_bins(),
328 extractor.data_bins(),
329 );
330 if eq.process(freq, &mut equalized).out_written != n_fft {
331 return None;
332 }
333 extractor.extract_symbol(&equalized, &mut symbols);
334 let sym_llrs = &mut llrs[out_off..out_off + bps];
335 if dvb_t_llr {
336 for (c, &sym) in symbols.iter().enumerate() {
337 let l = crate::waveform::dvb_t::dvb_t_soft_llr(sym, vbits).expect("DVB-T order");
338 sym_llrs[c * vbits..(c + 1) * vbits].copy_from_slice(&l);
339 }
340 } else {
341 let sw = soft.process(&symbols, sym_llrs);
342 if sw.out_written != bps {
343 return None;
344 }
345 }
346 in_off += sps;
347 out_off += bps;
348 }
349 Some(llrs)
350}
351
352/// Inverse of the interleaver, in the LLR (`f32`) domain.
353fn deinterleave_llrs(il: InterleaverKind, llrs: &[f32]) -> Vec<f32> {
354 match il {
355 InterleaverKind::None => llrs.to_vec(),
356 InterleaverKind::Block { rows, cols } => {
357 let block = rows * cols;
358 let bi = BlockInterleaver::new(rows, cols);
359 let mut out = Vec::with_capacity(llrs.len());
360 let mut restored = vec![0.0f32; block]; // reused across full chunks
361 for chunk in llrs.chunks(block) {
362 if chunk.len() < block {
363 out.extend_from_slice(chunk);
364 continue;
365 }
366 bi.deinterleave(chunk, &mut restored);
367 out.extend_from_slice(&restored);
368 }
369 out
370 }
371 // The Forney interleaver is byte-domain (DVB-T's *outer* interleaver); it
372 // is never configured as the inner (LLR-domain) interleaver. Pass through
373 // so a mis-configuration degrades gracefully; the TX side likewise only
374 // applies it byte-domain.
375 InterleaverKind::Convolutional { .. } => {
376 debug_assert!(false, "Convolutional interleaver is byte-domain only");
377 llrs.to_vec()
378 }
379 }
380}
381
382/// Inverse of the outer interleaver, in the hard-bit (`u8`) domain.
383fn deinterleave_bits(il: InterleaverKind, bits: &[u8]) -> Vec<u8> {
384 match il {
385 InterleaverKind::None => bits.to_vec(),
386 InterleaverKind::Block { rows, cols } => {
387 let block = rows * cols;
388 let bi = BlockInterleaver::new(rows, cols);
389 let mut out = Vec::with_capacity(bits.len());
390 let mut restored = vec![0u8; block]; // reused across full chunks
391 for chunk in bits.chunks(block) {
392 if chunk.len() < block {
393 out.extend_from_slice(chunk);
394 continue;
395 }
396 bi.deinterleave(chunk, &mut restored);
397 out.extend_from_slice(&restored);
398 }
399 out
400 }
401 InterleaverKind::Convolutional { branches, depth } => {
402 // Frame-mode inverse of `interleave_bits`'s Convolutional arm. The
403 // interleaved bit stream is `(n_padded + D)` whole bytes, `D` =
404 // round-trip delay. Deinterleave the whole thing; the recovered
405 // original bytes start at output offset `D` (the deinterleaver's
406 // startup delay) and run for `n_padded`.
407 let d = ConvInterleaver::new(branches, depth).roundtrip_delay();
408 let total = bits.len() / 8;
409 if total <= d {
410 return Vec::new();
411 }
412 let n_padded = total - d;
413 let bytes = bits_to_bytes(&bits[..total * 8]);
414 let mut di = ConvDeinterleaver::new(branches, depth);
415 let deint = di.feed(&bytes);
416 bytes_to_bits(&deint[d..d + n_padded])
417 }
418 }
419}
420
421/// Inner-decodes an LLR stream into hard info bits (mirroring `inner_encode`).
422/// `info_len` is the number of information bits the inner code protects (needed
423/// by the convolutional Viterbi, which is variable-rate). Returns the info bits
424/// and whether every block converged.
425fn inner_decode(
426 inner: InnerFec,
427 coded_llrs: &[f32],
428 info_len: usize,
429 cache: &CodecCache,
430 ldpc_rule: DecodeRule,
431) -> (Vec<u8>, bool) {
432 match inner {
433 InnerFec::None => {
434 // Hard-decide the LLRs directly.
435 (
436 coded_llrs.iter().map(|&l| u8::from(l <= 0.0)).collect(),
437 true,
438 )
439 }
440 InnerFec::Ldpc(code) => {
441 let ldpc = cache.ldpc(code);
442 let n = ldpc.n();
443 let mut info = Vec::new();
444 let mut all_ok = true;
445 for chunk in coded_llrs.chunks(n) {
446 if chunk.len() < n {
447 all_ok = false;
448 break;
449 }
450 let (msg, unsat) = ldpc.decode_soft_with(chunk, 50, ldpc_rule);
451 if unsat != 0 {
452 all_ok = false;
453 }
454 info.extend_from_slice(&msg);
455 }
456 (info, all_ok)
457 }
458 InnerFec::Convolutional { rate, code } => {
459 // Soft Viterbi over the whole block; the outer code / CRC below
460 // decides success, so no per-block convergence flag here.
461 let info = viterbi_decode_soft_with(code, coded_llrs, info_len, rate);
462 (info, true)
463 }
464 }
465}
466
467/// What the outer decoder produced: the message bits, whether every block
468/// decoded, and — for a byte-domain code — how many bytes it had to correct.
469struct OuterOutcome {
470 bits: Vec<u8>,
471 all_ok: bool,
472 /// Summed over every codeword the decoder corrected; `None` for a code with
473 /// no byte-domain correction count to report. See
474 /// [`ChainOutcome::outer_corrected_bytes`].
475 corrected_bytes: Option<u32>,
476}
477
478/// Outer-decodes hard bits into message bits, fragmenting into shortened-BCH
479/// codeword blocks (mirroring `outer_encode`).
480fn outer_decode(outer: OuterFec, coded_bits: &[u8], cache: &CodecCache) -> OuterOutcome {
481 match outer {
482 OuterFec::None => OuterOutcome {
483 bits: coded_bits.to_vec(),
484 all_ok: true,
485 corrected_bytes: None,
486 },
487 OuterFec::Bch { t } => {
488 let code = cache.bch(t, BCH_INFO_BITS);
489 let n = code.n();
490 let mut msg = Vec::new();
491 let mut all_ok = true;
492 for chunk in coded_bits.chunks(n) {
493 if chunk.len() < n {
494 all_ok = false;
495 break;
496 }
497 match code.decode(chunk) {
498 Ok(block) => msg.extend_from_slice(&block),
499 Err(_) => {
500 all_ok = false;
501 // Fall back to the systematic prefix so downstream CRC
502 // can still run (and fail) rather than aborting here.
503 msg.extend_from_slice(&chunk[..code.k()]);
504 }
505 }
506 }
507 OuterOutcome {
508 bits: msg,
509 all_ok,
510 // BCH is a *binary* code: a located error is a bit flip, so it
511 // has no byte-correction count to report. `None` rather than a
512 // bit count, so the field means one thing on every arm.
513 corrected_bytes: None,
514 }
515 }
516 OuterFec::ReedSolomon { n, n_parity } => {
517 // Byte-domain: pack coded bits to bytes, decode each n-byte codeword.
518 let rs = cache.rs(n, n_parity);
519 let coded_bytes = bits_to_bytes(coded_bits);
520 let mut msg_bytes = Vec::new();
521 let mut all_ok = true;
522 let mut corrected = 0u32;
523 for chunk in coded_bytes.chunks(n) {
524 if chunk.len() < n {
525 all_ok = false;
526 break;
527 }
528 match rs.decode_counted(chunk) {
529 Ok((block, n_fixed)) => {
530 msg_bytes.extend_from_slice(&block);
531 corrected += n_fixed as u32;
532 }
533 Err(_) => {
534 all_ok = false;
535 msg_bytes.extend_from_slice(&chunk[..rs.k()]);
536 }
537 }
538 }
539 OuterOutcome {
540 bits: bytes_to_bits(&msg_bytes),
541 all_ok,
542 corrected_bytes: Some(corrected),
543 }
544 }
545 }
546}
547
548/// The outcome of decoding one logical block: the recovered bytes plus each
549/// stage's success, reported **separately**.
550///
551/// The concatenated scheme has two independent decoders, and folding them into
552/// one flag destroys the only signal that distinguishes a marginal link from a
553/// failing one. Errors the inner code corrects never reach the outer code, so
554/// `inner_ok == false` with `outer_ok == true` is a link running hot but still
555/// delivering — precisely the state a pre-FEC error rate is meant to surface,
556/// and indistinguishable from success once folded.
557///
558/// Use [`is_valid`](Self::is_valid) to decide whether to accept the block;
559/// the individual flags are diagnostics.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub struct ChainOutcome {
562 /// The recovered info bytes, CRC stripped.
563 pub bytes: Vec<u8>,
564 /// Every inner-FEC block converged. Always `true` for
565 /// [`InnerFec::None`], and for the convolutional arm, whose soft Viterbi
566 /// has no per-block convergence flag — the outer code and CRC decide.
567 pub inner_ok: bool,
568 /// Every outer-FEC block decoded. Always `true` for [`OuterFec::None`].
569 pub outer_ok: bool,
570 /// The block's CRC checked.
571 pub crc_ok: bool,
572 /// Whether the configuration provides a CRC at all. Without this,
573 /// `crc_ok` is ambiguous: [`CrcKind::None`] reports `true` because there
574 /// was nothing to fail, not because anything was verified.
575 pub crc_present: bool,
576 /// Whether the configuration provides an outer code at all, on the same
577 /// reasoning as [`crc_present`](Self::crc_present).
578 pub outer_present: bool,
579 /// How many codeword bytes the **outer** decoder corrected across this
580 /// block, or `None` when the outer code reports no such count — every arm
581 /// but [`OuterFec::ReedSolomon`], since BCH is binary and corrects bits.
582 ///
583 /// The measurement [`outer_ok`](Self::outer_ok) cannot make. That flag
584 /// saturates — a codeword one error from the cliff and a pristine one both
585 /// read `true` — whereas this rises smoothly with the channel, so it shows
586 /// a link *approaching* failure while it is still delivering. It is the
587 /// count real DVB-T receivers report, and it costs nothing: the Forney
588 /// correction loop already computed every magnitude.
589 ///
590 /// **Counted only over codewords that decoded.** A block the code could not
591 /// correct contributes nothing, because a correction the decoder does not
592 /// trust is not a correction to report. So on a frame with
593 /// `outer_ok == false` this is a lower bound; read the two together.
594 pub outer_corrected_bytes: Option<u32>,
595 /// What the inner decoder produced, **untrimmed** — every bit it decided,
596 /// including the zero-padding tail of the final codeword that the block
597 /// plan discards before the outer decoder sees it.
598 ///
599 /// Kept rather than dropped so a caller can compare it against a re-encode
600 /// of the recovered message and obtain a post-inner-FEC bit error *rate*
601 /// instead of a pass/fail flag. Costs nothing: the vector is already
602 /// allocated, and is moved out rather than copied.
603 ///
604 /// Untrimmed because re-encoding a *trimmed* copy zero-pads that tail back
605 /// to zero, which silently asserts the decoder got the padding right. For a
606 /// 184-byte payload on the default ladder that is 168 bits of 5120 — enough
607 /// to paint a real decoder error `Clean` in a correction map, and far too
608 /// few to notice in one. Consumers that want only the bits the outer
609 /// decoder saw take `[..plan.outer_il_bits]`; [`bit_error_rate`] compares
610 /// over the shorter of its two inputs, so the trim is implicit there.
611 pub inner_out_bits: Vec<u8>,
612}
613
614impl ChainOutcome {
615 /// Whether the recovered bytes can be trusted, judged by the **strongest
616 /// end-to-end check the configuration actually provides**.
617 ///
618 /// `inner_ok` is deliberately not part of this. It reports whether the
619 /// inner decoder's parity checks converged — how hard it worked, not
620 /// whether the result is right. Requiring it discards frames whose payload
621 /// is verifiably correct: measured across a noise sweep, a CRC-carrying
622 /// link delivers byte-exact payloads with `inner_ok == false` over a wide
623 /// band, and rejecting those costs real sensitivity for nothing.
624 ///
625 /// The precedence:
626 ///
627 /// - **A CRC decides on its own.** It is computed over the recovered
628 /// payload end to end, so passing it means the bytes are right whatever
629 /// the stages beneath did — including an outer decoder that reported a
630 /// block it could not correct.
631 /// - **Otherwise the outer code decides.** DVB-T carries no CRC
632 /// ([`CrcKind::None`]), so its Reed–Solomon stage is the integrity
633 /// check; RS reports failure when it cannot correct a codeword.
634 /// - **Otherwise `inner_ok` is all there is.** A link with neither a CRC
635 /// nor an outer code has only the inner decoder's convergence to go on,
636 /// and dropping it there would accept anything.
637 pub fn is_valid(&self) -> bool {
638 if self.crc_present {
639 self.crc_ok
640 } else if self.outer_present {
641 self.outer_ok
642 } else {
643 self.inner_ok
644 }
645 }
646}
647
648/// Decodes one logical block's coded LLRs back to its info bytes, checking the
649/// CRC — the exact inverse of `modulate::ofdm_frame::encode_chain`. Public so
650/// per-standard frame assemblers (e.g. `waveform::dvb_t_frame`) reuse the shared
651/// FEC decode rather than duplicating it.
652#[allow(clippy::too_many_arguments)]
653pub fn decode_chain(
654 coded_llrs: &[f32],
655 plan: &BlockPlan,
656 crc: CrcKind,
657 outer: OuterFec,
658 inner: InnerFec,
659 outer_il: InterleaverKind,
660 inner_il: InterleaverKind,
661 scrambler: ScramblerKind,
662 scrambler_pos: ScramblerPos,
663 per_frame_seed: u32,
664 cache: &CodecCache,
665 ldpc_rule: DecodeRule,
666) -> Result<ChainOutcome, RxError> {
667 // 1. Trim to the exact coded-bit count, then invert the after-inner
668 // scramble (bit domain) if configured.
669 let mut llrs = coded_llrs.to_vec();
670 llrs.truncate(plan.coded_bits);
671
672 // After-inner scrambling was applied to hard bits; to invert in the LLR
673 // domain we flip the LLR sign where the PN bit is 1 (XOR by 1 negates the
674 // bit ⇒ negate the LLR).
675 let sc = build_scrambler(scrambler, per_frame_seed);
676 if scrambler_pos == ScramblerPos::AfterInnerFec
677 && let Some(ref s) = sc
678 {
679 apply_pn_to_llrs(s, &mut llrs);
680 }
681
682 // 2. Inner deinterleave (LLR), then inner decode.
683 let inner_de = deinterleave_llrs(inner_il, &llrs);
684 let inner_de = &inner_de[..plan.inner_coded_bits.min(inner_de.len())];
685 let (outer_il_bits, inner_ok) =
686 inner_decode(inner, inner_de, plan.outer_il_bits, cache, ldpc_rule);
687
688 // 3. Outer deinterleave (byte/bit domain), then outer decode. The plan trims
689 // the final codeword's zero padding here — as a borrow, so
690 // `ChainOutcome::inner_out_bits` below still carries every bit the inner
691 // decoder decided (see its docs for why that matters).
692 let trimmed = &outer_il_bits[..plan.outer_il_bits.min(outer_il_bits.len())];
693 let outer_de = deinterleave_bits(outer_il, trimmed);
694 let outer_de = &outer_de[..plan.outer_coded_bits.min(outer_de.len())];
695 let OuterOutcome {
696 bits: mut framed_bits,
697 all_ok: outer_ok,
698 corrected_bytes: outer_corrected_bytes,
699 } = outer_decode(outer, outer_de, cache);
700 framed_bits.truncate(plan.framed_bytes * 8);
701
702 if framed_bits.len() < plan.framed_bytes * 8 {
703 return Err(RxError::MalformedHeader);
704 }
705 let mut framed = bits_to_bytes(&framed_bits);
706
707 // 4. Invert the before-outer scramble (byte domain — the whitener is
708 // self-inverse, so the same call descrambles; handles DVB-T energy
709 // dispersal and the generic additive LFSR).
710 if scrambler_pos == ScramblerPos::BeforeOuterFec {
711 scramble_bytes(scrambler, per_frame_seed, &mut framed);
712 }
713
714 // 5. Strip and check the CRC.
715 let (bytes, crc_ok) = check_and_strip_crc(crc, &framed).ok_or(RxError::MalformedHeader)?;
716 Ok(ChainOutcome {
717 bytes,
718 inner_ok,
719 outer_ok,
720 crc_ok,
721 crc_present: crc != CrcKind::None,
722 outer_present: outer != OuterFec::None,
723 outer_corrected_bytes,
724 // `deinterleave_bits` only borrowed this, so it moves out here.
725 inner_out_bits: outer_il_bits,
726 })
727}
728
729/// Re-encodes the inner decoder's own output back into the coded-bit domain —
730/// steps 4 and 5 of `encode_chain_stages`, run on what the decoder decided
731/// rather than on the recovered payload — writing into `out` (cleared and
732/// refilled, so its capacity is reused across frames).
733///
734/// This is the third stream a correction map needs: comparing it against the
735/// re-encode of the CRC-verified payload says which bits the inner decoder got
736/// right, in the same index space the received hard decisions live in.
737///
738/// **Why re-encode rather than read the decoder's internal codeword.**
739/// `Ldpc::decode_soft_with` holds a full n-bit hard-decision vector and returns
740/// only its systematic prefix; exposing the rest would be cheaper. But the
741/// convolutional arm has no codeword to expose — soft Viterbi produces
742/// information bits and nothing else — so an accessor-based map would render on
743/// LDPC and come up blank on DVB-T, whose inner code is `ConvCode::DvbK7`. The
744/// re-encode costs one inner encode per frame and works on both.
745fn reencode_inner_output(
746 cfg: &OfdmConfig,
747 inner: InnerFec,
748 inner_out: &[u8],
749 coded_bits: usize,
750 per_frame_seed: u32,
751 cache: &CodecCache,
752 out: &mut Vec<u8>,
753) {
754 let inner_bits = inner_encode(inner, inner_out, cache);
755 out.clear();
756 match cfg.inner_interleaver {
757 InterleaverKind::None => out.extend_from_slice(&inner_bits),
758 il => out.extend_from_slice(&interleave_bits(il, &inner_bits)),
759 }
760 if cfg.scrambler_pos == ScramblerPos::AfterInnerFec
761 && let Some(ref s) = build_scrambler(cfg.scrambler, per_frame_seed)
762 {
763 scramble_bits(s, out);
764 }
765 out.truncate(coded_bits);
766}
767
768/// Applies a PN sequence to LLRs by negating each LLR whose PN bit is 1.
769fn apply_pn_to_llrs(s: &crate::fec::PnScrambler, llrs: &mut [f32]) {
770 // The scrambler XORs bits; recover the PN bit-stream by scrambling a
771 // zeroed byte buffer of the right length, then negate LLRs at PN==1.
772 let n_bytes = llrs.len().div_ceil(8);
773 let mut pn = vec![0u8; n_bytes];
774 s.scramble(&mut pn);
775 let pn_bits = bytes_to_bits(&pn);
776 for (l, &p) in llrs.iter_mut().zip(pn_bits.iter()) {
777 if p != 0 {
778 *l = -*l;
779 }
780 }
781}
782
783/// Distinguishes "waiting for more samples" from a genuine decode failure, so
784/// the streaming receiver can hold a partial frame rather than mis-report it.
785enum BodyError {
786 /// Not enough buffered samples for the header or the (now-known-length)
787 /// payload — hold and retry after more input.
788 Incomplete,
789 /// A real decode failure (bad header CRC, payload CRC, or FEC).
790 Failed(RxError),
791}
792
793/// Fraction of positions where two bit-streams differ, over their common
794/// length. `None` if either is empty.
795///
796/// `pub(crate)` so the per-standard frame assemblers that reuse
797/// [`decode_chain`] measure their BER rungs the same way rather than
798/// reimplementing the comparison — see `demodulate::dvb_t_frame`.
799pub(crate) fn bit_error_rate(a: &[u8], b: &[u8]) -> Option<f32> {
800 let n = a.len().min(b.len());
801 if n == 0 {
802 return None;
803 }
804 let errs = a[..n]
805 .iter()
806 .zip(b[..n].iter())
807 .filter(|(x, y)| x != y)
808 .count();
809 Some(errs as f32 / n as f32)
810}
811
812/// Per-frame working buffers reused across the frames one receiver decodes.
813///
814/// Both were allocated fresh on every call before, probing or not. Moving them
815/// here is a tidy-up rather than a speed-up — `decode_chain` still copies the
816/// LLRs, `deinterleave_llrs` allocates, `inner_decode` allocates, and the decode
817/// path stays far from allocation-free. Its real purpose is that the probe's
818/// symbol buffer has to be reused anyway, so having the EVM path share the same
819/// sink is cheaper than maintaining two.
820#[derive(Debug, Clone, Default)]
821struct FrameScratch {
822 /// Equalized payload symbols, in demap order — the sink `soft_demap` fills
823 /// when nothing is probing.
824 symbols: Vec<C32>,
825 /// The payload LLRs' hard decisions. EVM needs one per coded bit; the
826 /// channel BER and the correction map need the first `coded_bits` of them,
827 /// which is the same vector rather than a second one.
828 hard: Vec<u8>,
829}
830
831/// What one frame body yielded: the packet, how many samples it consumed, and
832/// the per-stage measurements taken along the way.
833struct DecodedBody {
834 packet: FramePacket,
835 /// IQ samples the header+payload occupied, so a streaming caller can
836 /// advance its buffer.
837 consumed: usize,
838 /// Payload EVM, measured against its own hard decisions before the FEC
839 /// stages consume the LLRs.
840 evm_db: Option<f32>,
841 /// The payload's inner- and outer-FEC convergence, kept apart — see
842 /// [`ChainOutcome`].
843 inner_ok: bool,
844 outer_ok: bool,
845 /// Bit error rate at the channel's output, i.e. the inner decoder's input.
846 channel_ber: Option<f32>,
847 /// Bit error rate at the inner decoder's output, before the outer decoder.
848 inner_ber: Option<f32>,
849}
850
851/// Decodes a frame body (header + payload) from `iq[0]` — the first sample
852/// AFTER the preamble+training, already CFO-corrected. When
853/// `channel_estimate` is `Some(n_fft freq bins)` the soft-demap equalizes each
854/// symbol against it (multipath); `None` is the flat-channel path.
855///
856/// Returns a [`DecodedBody`], or a [`BodyError`] distinguishing "incomplete"
857/// from a genuine failure.
858///
859/// `scratch` holds the per-frame working buffers (see [`FrameScratch`]). When
860/// `probe` is `Some`, the payload's equalized symbols are appended to it instead
861/// of to the scratch, and a decoded frame also gets a per-coded-bit correction
862/// map — an observation of this decode, never an input to it.
863#[allow(clippy::too_many_arguments)]
864fn decode_frame_body(
865 cfg: &OfdmConfig,
866 mcs_table: &McsTable,
867 iq: &[C32],
868 channel_estimate: Option<&[C32]>,
869 cache: &CodecCache,
870 measure_ber: bool,
871 scratch: &mut FrameScratch,
872 probe: Option<&mut OfdmRxProbe>,
873) -> Result<DecodedBody, BodyError> {
874 // The scattered-pilot path collects no symbols here, so a scattered link
875 // reports *no* probe frames rather than symbol-less ones. A record with an
876 // empty constellation would read as "nothing arrived" instead of "not
877 // measured here".
878 //
879 // This is a routing decision, not a gap: a DVB-T link is decoded by
880 // `demodulate::dvb_t_frame`, which carries its own probe
881 // (`demodulate::dvb_t_probe::DvbTRxProbe`, reached via
882 // `DvbTFrameStreamDemod::feed_probed`) and its own diagnostics ladder
883 // (`DvbTRxDiagnostics`). Instrument a DVB-T receiver there, not here.
884 let mut probe = probe.filter(|_| !cfg.dvb_t_scattered);
885 let mut cursor = 0usize;
886
887 // Builds a fresh equalizer for `constellation` carrying the shared channel
888 // estimate, or `None` for the flat path.
889 let make_eq = |constellation: ConstellationOrder| -> Option<OfdmEqualizer> {
890 channel_estimate.map(|est| {
891 let symcfg = symbol_config(cfg, constellation);
892 let mut eq = OfdmEqualizer::new(&symcfg, EqualizerMethod::TrainingSymbolHold);
893 eq.estimate_from_training_symbol(est);
894 eq
895 })
896 };
897
898 // For a DVB-T scattered-pilot link, one grid-rotation extractor spans the
899 // whole frame body (header then payload) so the RX symbol phase matches the
900 // TX (`l = 0` at the first header symbol). `None` for every other link, which
901 // takes the static-grid `soft_demap`.
902 let mut scattered = cfg.dvb_t_scattered.then(|| {
903 let guard =
904 crate::waveform::dvb_t::GuardInterval::from_cp_len_2k(cfg.carrier_plan.cp_len())
905 .expect("DVB-T scattered link requires a 2K guard interval");
906 crate::waveform::dvb_t::ScatteredPilotExtractor::new(guard)
907 });
908
909 // Soft-demaps `n_sym` symbols at `iq[off..]` through either the rotating
910 // scattered grid (DVB-T) or the static plan, whichever the config selects.
911 // `eq` is only consulted on the static path.
912 let mut demap = |constellation: ConstellationOrder,
913 iq: &[C32],
914 off: usize,
915 n_sym: usize,
916 eq: Option<&mut OfdmEqualizer>,
917 sink: Option<&mut Vec<C32>>|
918 -> Option<Vec<f32>> {
919 match scattered.as_mut() {
920 // The scattered path does not collect symbols here; a DVB-T link is
921 // instrumented by `demodulate::dvb_t_frame`, which has its own probe
922 // and diagnostics. See the note at the top of this function.
923 Some(x) => soft_demap_scattered(cfg, constellation, &iq[off..], n_sym, x),
924 None => soft_demap(cfg, constellation, &iq[off..], n_sym, eq, sink),
925 }
926 };
927
928 // 1. Header (only OrionSdr prepends a decodable header block here).
929 let (metadata, per_frame_seed, payload_len) = if cfg.header_format.has_header_block() {
930 let hplan = block_plan(
931 HEADER_FIELD_BYTES,
932 cfg.header_crc,
933 OuterFec::None,
934 InnerFec::Ldpc(HEADER_LDPC),
935 InterleaverKind::None,
936 InterleaverKind::None,
937 cache,
938 );
939 let n_sym = symbols_for_coded_bits(cfg, HEADER_CONSTELLATION, hplan.coded_bits);
940 let mut eq = make_eq(HEADER_CONSTELLATION);
941 // Too few samples for the header ⇒ incomplete, not malformed.
942 let llrs = demap(HEADER_CONSTELLATION, iq, cursor, n_sym, eq.as_mut(), None)
943 .ok_or(BodyError::Incomplete)?;
944 let header = decode_chain(
945 &llrs,
946 &hplan,
947 cfg.header_crc,
948 OuterFec::None,
949 InnerFec::Ldpc(HEADER_LDPC),
950 InterleaverKind::None,
951 InterleaverKind::None,
952 ScramblerKind::None,
953 ScramblerPos::BeforeOuterFec,
954 0,
955 cache,
956 // The header is decoded first to learn the MCS and must be as robust
957 // as possible, so it always uses exact sum-product regardless of the
958 // payload's configured rule.
959 DecodeRule::SumProduct,
960 )
961 .map_err(BodyError::Failed)?;
962 if !header.is_valid() {
963 return Err(BodyError::Failed(RxError::HeaderCrcMismatch));
964 }
965 let fields = header.bytes;
966 if fields.len() < HEADER_FIELD_BYTES {
967 return Err(BodyError::Failed(RxError::MalformedHeader));
968 }
969 let mcs_index = fields[0];
970 let payload_len = u32::from_be_bytes([fields[1], fields[2], fields[3], fields[4]]) as usize;
971 let sequence_num = u32::from_be_bytes([fields[5], fields[6], fields[7], fields[8]]);
972 let flags = fields[9];
973 let seed = u32::from_be_bytes([fields[10], fields[11], fields[12], fields[13]]);
974
975 let sps = symbol_config(cfg, HEADER_CONSTELLATION).samples_per_ofdm_symbol();
976 cursor += n_sym * sps;
977 (
978 FrameMetadata {
979 sequence_num,
980 mcs_index,
981 flags,
982 },
983 seed,
984 payload_len,
985 )
986 } else {
987 // NoHeader / DvbTps: this generic entry point has no in-band header to
988 // read the MCS/length from. DvbTps frames are decoded by the dedicated
989 // `waveform::dvb_t_frame` assembler (TPS-signalled, preamble-less); a
990 // NoHeader caller must convey MCS/length out-of-band. Not supported here.
991 return Err(BodyError::Failed(RxError::MalformedHeader));
992 };
993
994 // 2. Payload, decoded per the MCS the header selected.
995 let mcs = mcs_table
996 .get(metadata.mcs_index)
997 .ok_or(BodyError::Failed(RxError::MalformedHeader))?;
998 let pplan = block_plan(
999 payload_len,
1000 cfg.payload_crc,
1001 mcs.outer_fec,
1002 mcs.inner_fec,
1003 cfg.outer_interleaver,
1004 cfg.inner_interleaver,
1005 cache,
1006 );
1007 let n_sym = symbols_for_coded_bits(cfg, mcs.constellation, pplan.coded_bits);
1008 let mut eq = make_eq(mcs.constellation);
1009 // The equalized payload symbols go straight into whichever buffer will
1010 // outlive this call: the probe's (appended, so several frames from one
1011 // `feed` sit end to end) or the reused scratch. EVM reads the same span
1012 // back, so probing adds no second copy of the constellation.
1013 let sym_start = match probe.as_deref_mut() {
1014 Some(p) => p.symbols.len(),
1015 None => {
1016 scratch.symbols.clear();
1017 0
1018 }
1019 };
1020 // Too few samples for the (now-known-length) payload ⇒ incomplete.
1021 let llrs = {
1022 let sink: &mut Vec<C32> = match probe.as_deref_mut() {
1023 Some(p) => &mut p.symbols,
1024 None => &mut scratch.symbols,
1025 };
1026 demap(
1027 mcs.constellation,
1028 iq,
1029 cursor,
1030 n_sym,
1031 eq.as_mut(),
1032 Some(sink),
1033 )
1034 .ok_or(BodyError::Incomplete)?
1035 };
1036 // One hard decision per coded bit, taken once: EVM measures against these,
1037 // and the channel BER and correction map below are the first
1038 // `pplan.coded_bits` of the same vector.
1039 scratch.hard.clear();
1040 scratch
1041 .hard
1042 .extend(llrs.iter().map(|&l| u8::from(l <= 0.0)));
1043 // EVM against the payload's own hard decisions, measured before the FEC
1044 // stages consume the LLRs. `symbol_config` re-resolves the constellation so
1045 // the ideal-point mapper matches the one that produced these symbols.
1046 let evm_db = {
1047 let symbols: &[C32] = match probe.as_deref() {
1048 Some(p) => &p.symbols[sym_start..],
1049 None => &scratch.symbols,
1050 };
1051 crate::demodulate::ofdm::evm_db(
1052 &symbol_config(cfg, mcs.constellation),
1053 symbols,
1054 &scratch.hard,
1055 n_sym,
1056 )
1057 };
1058 let payload_outcome = match decode_chain(
1059 &llrs,
1060 &pplan,
1061 cfg.payload_crc,
1062 mcs.outer_fec,
1063 mcs.inner_fec,
1064 cfg.outer_interleaver,
1065 cfg.inner_interleaver,
1066 cfg.scrambler,
1067 cfg.scrambler_pos,
1068 per_frame_seed,
1069 cache,
1070 // The payload honors the configured LDPC decode rule (opt-in min-sum).
1071 cfg.ldpc_decode_rule,
1072 ) {
1073 Ok(outcome) if outcome.is_valid() => outcome,
1074 // The payload reached the demapper but did not verify, so there is no
1075 // ground truth and no map — but the symbols exist, and a constellation
1076 // is precisely what an operator looks at when frames stop decoding.
1077 rest => {
1078 if let Some(p) = probe.as_deref_mut() {
1079 p.push_undecoded(sym_start, mcs.constellation, Some(metadata.sequence_num));
1080 }
1081 return Err(BodyError::Failed(match rest {
1082 Err(e) => e,
1083 Ok(_) => RxError::CrcMismatch,
1084 }));
1085 }
1086 };
1087 // Take the flags before consuming the bytes, so the payload is moved rather
1088 // than cloned out of the outcome.
1089 let (inner_ok, outer_ok) = (payload_outcome.inner_ok, payload_outcome.outer_ok);
1090
1091 // True bit error rates, from a re-encode of what we just recovered.
1092 //
1093 // A frame that passed its CRC *is* the ground truth: re-running the encode
1094 // chain on it reconstructs exactly what the transmitter sent, so comparing
1095 // that against what arrived at each stage gives a rate rather than a
1096 // pass/fail flag. Crucially this needs no prior knowledge of the payload —
1097 // which is what makes it work over the air, where nothing about the
1098 // transmitted bits is known in advance.
1099 //
1100 // Off unless asked for: it costs one encode per frame. The probe's
1101 // correction map is built from the same re-encode, so asking for both pays
1102 // for it once.
1103 let stages = (measure_ber || probe.is_some()).then(|| {
1104 encode_chain_stages(
1105 &payload_outcome.bytes,
1106 cfg.payload_crc,
1107 mcs.outer_fec,
1108 mcs.inner_fec,
1109 cfg.outer_interleaver,
1110 cfg.inner_interleaver,
1111 cfg.scrambler,
1112 cfg.scrambler_pos,
1113 per_frame_seed,
1114 cache,
1115 )
1116 });
1117 let coded_bits = pplan.coded_bits.min(scratch.hard.len());
1118 let (channel_ber, inner_ber) = match stages.as_ref().filter(|_| measure_ber) {
1119 // The channel's output is the demapped LLRs hard-decided, compared
1120 // before any descrambling — `stages.coded` carries the scramble too.
1121 Some(s) => (
1122 bit_error_rate(&scratch.hard[..coded_bits], &s.coded),
1123 bit_error_rate(&payload_outcome.inner_out_bits, &s.outer_il_bits),
1124 ),
1125 None => (None, None),
1126 };
1127
1128 // The correction map: the same XOR the channel BER collapses to a scalar,
1129 // kept per bit, plus a third stream saying what the inner decoder made of
1130 // each one. Nothing new is measured or assumed — the ground truth is the
1131 // re-encode above, which a noise sweep and a regenerated payload already
1132 // vouch for.
1133 if let (Some(p), Some(s)) = (&mut probe, stages.as_ref()) {
1134 reencode_inner_output(
1135 cfg,
1136 mcs.inner_fec,
1137 &payload_outcome.inner_out_bits,
1138 pplan.coded_bits,
1139 per_frame_seed,
1140 cache,
1141 &mut p.estimate,
1142 );
1143 debug_assert_eq!(
1144 p.estimate.len(),
1145 pplan.coded_bits,
1146 "the re-encoded decoder estimate must span the whole coded block"
1147 );
1148 // A block code's `n`/`k` let a display draw codeword boundaries; a
1149 // convolutional code terminates once per frame and has none to draw.
1150 let (codeword_bits, codeword_info_bits) = match mcs.inner_fec {
1151 InnerFec::Ldpc(code) => (code.n(), code.k()),
1152 InnerFec::None | InnerFec::Convolutional { .. } => (0, 0),
1153 };
1154 p.push_decoded(
1155 sym_start,
1156 ProbeMeta {
1157 sequence_num: Some(metadata.sequence_num),
1158 constellation: mcs.constellation,
1159 codeword_bits,
1160 codeword_info_bits,
1161 },
1162 &s.coded[..coded_bits.min(s.coded.len())],
1163 &scratch.hard[..coded_bits],
1164 );
1165 }
1166
1167 let bytes = payload_outcome.bytes;
1168 let payload_sps = symbol_config(cfg, mcs.constellation).samples_per_ofdm_symbol();
1169 cursor += n_sym * payload_sps;
1170 // Trim to the declared payload length (coding blocks are zero-padded).
1171 let payload = bytes
1172 .get(..payload_len)
1173 .map(|s| s.to_vec())
1174 .unwrap_or(bytes);
1175
1176 Ok(DecodedBody {
1177 packet: FramePacket { metadata, payload },
1178 consumed: cursor,
1179 evm_db,
1180 inner_ok,
1181 outer_ok,
1182 channel_ber,
1183 inner_ber,
1184 })
1185}
1186
1187/// The batch OFDM frame demodulator — decodes a frame at a KNOWN start (`iq[0]`
1188/// is the first sample AFTER the preamble+training; the caller has already
1189/// synchronized and, if needed, equalized). The exact counterpart of
1190/// [`OfdmFrameMod`](crate::modulate::OfdmFrameMod), constructed the same way.
1191///
1192/// This is the flat-channel, known-start receiver; the streaming
1193/// [`OfdmFrameStreamDemod`] runs `ofdm_sync`, CFO correction, and training-
1194/// symbol equalization for unknown start / CFO / multipath.
1195#[derive(Debug, Clone)]
1196pub struct OfdmFrameDemod {
1197 cfg: OfdmConfig,
1198 mcs_table: McsTable,
1199 /// FEC code cache, so a stream of frames builds each code once (see
1200 /// [`CodecCache`]). Held behind `Arc` so it can be shared with a paired
1201 /// modulator (TX and RX then reuse the same built codes).
1202 cache: Arc<CodecCache>,
1203}
1204
1205impl OfdmFrameDemod {
1206 /// Creates a batch demodulator over `cfg` and an `mcs_table`. It owns a
1207 /// fresh, private [`CodecCache`] warmed across the frames it decodes; use
1208 /// [`with_cache`](Self::with_cache) to share one with a modulator.
1209 pub fn new(cfg: OfdmConfig, mcs_table: McsTable) -> Self {
1210 Self::with_cache(cfg, mcs_table, Arc::new(CodecCache::new()))
1211 }
1212
1213 /// Like [`new`](Self::new), but reuses the caller-provided `cache` — share
1214 /// one `Arc<CodecCache>` across a modulator/demodulator pair (or several
1215 /// links on the same MCS) so each FEC code is constructed only once.
1216 pub fn with_cache(cfg: OfdmConfig, mcs_table: McsTable, cache: Arc<CodecCache>) -> Self {
1217 crate::modulate::ofdm_frame::assert_baseband(&cfg);
1218 Self {
1219 cfg,
1220 mcs_table,
1221 cache,
1222 }
1223 }
1224
1225 pub fn config(&self) -> &OfdmConfig {
1226 &self.cfg
1227 }
1228
1229 /// Decodes one frame whose IQ begins at the first post-preamble sample.
1230 /// Returns the recovered [`FramePacket`] or an [`RxError`]. The internal
1231 /// [`CodecCache`] is reused across calls, so decoding many frames on one
1232 /// `OfdmFrameDemod` builds each FEC code only once.
1233 pub fn decode(&self, iq: &[C32]) -> Result<FramePacket, RxError> {
1234 // `&self`, so the working buffers are function-local rather than carried
1235 // on the receiver: one frame, one set, dropped on return. Unchanged
1236 // behaviour and unchanged cost against the per-call vectors this
1237 // replaces.
1238 let mut scratch = FrameScratch::default();
1239 decode_frame_body(
1240 &self.cfg,
1241 &self.mcs_table,
1242 iq,
1243 None,
1244 &self.cache,
1245 false,
1246 &mut scratch,
1247 None,
1248 )
1249 .map(|body| body.packet)
1250 .map_err(|e| match e {
1251 // A batch caller has no "wait for more" option; a truncated buffer
1252 // is a malformed input here.
1253 BodyError::Incomplete => RxError::MalformedHeader,
1254 BodyError::Failed(err) => err,
1255 })
1256 }
1257}
1258
1259// ── Streaming receiver ─────────────────────────────────────────────────────
1260
1261/// A successfully received frame plus its per-frame RX diagnostics.
1262#[derive(Debug, Clone, PartialEq)]
1263pub struct RxFrame {
1264 pub packet: FramePacket,
1265 /// Acquisition/quality diagnostics: `cfo_hz` and `timing_offset_samples`
1266 /// are populated by the streaming receiver; `evm_db`/`channel_mse` are
1267 /// left `None` here (measured by the per-symbol pipeline, not the frame
1268 /// layer).
1269 pub diagnostics: OfdmRxFrame,
1270}
1271
1272/// Streaming OFDM frame receiver: push raw IQ with [`feed`](Self::feed), poll
1273/// completed frames (or typed errors). Mirrors `Ft8StreamDecoder`'s
1274/// accumulate-and-drain shape.
1275///
1276/// Each `feed` accumulates samples, searches the buffer for a preamble via
1277/// `ofdm_sync`, and — for a candidate with enough buffered samples — corrects
1278/// CFO (`Rotator`), estimates the channel from the training symbol
1279/// (`OfdmEqualizer`), decodes the frame, and drains its samples from the
1280/// buffer, looping to drain multiple frames. A frame whose payload has not
1281/// fully arrived is held until a later `feed` completes it.
1282pub struct OfdmFrameStreamDemod {
1283 cfg: OfdmConfig,
1284 mcs_table: McsTable,
1285 preamble: OfdmPreamble,
1286 fs: f32,
1287 buf: Vec<C32>,
1288 /// Minimum sync score to accept a candidate.
1289 score_threshold: f32,
1290 /// Whether to carry the per-bin channel estimate on each frame's
1291 /// diagnostics. Off by default: it costs an `n_fft`-sized allocation per
1292 /// frame and only diagnostic consumers want it.
1293 want_channel_estimate: bool,
1294 /// Whether to measure true bit error rates by re-encoding each decoded
1295 /// frame. Off by default: it costs one encode per frame.
1296 want_error_rates: bool,
1297 /// FEC code cache, warmed across the frames this receiver decodes (see
1298 /// [`CodecCache`]). Held behind `Arc` so it can be shared with a paired
1299 /// modulator.
1300 cache: Arc<CodecCache>,
1301 /// Per-frame working buffers, reused across every frame this receiver
1302 /// decodes (see [`FrameScratch`]).
1303 scratch: FrameScratch,
1304}
1305
1306impl OfdmFrameStreamDemod {
1307 /// Creates a streaming receiver with a fresh, private [`CodecCache`]; use
1308 /// [`with_cache`](Self::with_cache) to share one with a modulator.
1309 pub fn new(cfg: OfdmConfig, mcs_table: McsTable, preamble: OfdmPreamble) -> Self {
1310 Self::with_cache(cfg, mcs_table, preamble, Arc::new(CodecCache::new()))
1311 }
1312
1313 /// Like [`new`](Self::new), but reuses the caller-provided `cache` so a
1314 /// modulator/demodulator pair sharing one `Arc<CodecCache>` builds each FEC
1315 /// code only once between them.
1316 pub fn with_cache(
1317 cfg: OfdmConfig,
1318 mcs_table: McsTable,
1319 preamble: OfdmPreamble,
1320 cache: Arc<CodecCache>,
1321 ) -> Self {
1322 crate::modulate::ofdm_frame::assert_baseband(&cfg);
1323 let fs = cfg.fs;
1324 Self {
1325 cfg,
1326 mcs_table,
1327 preamble,
1328 fs,
1329 buf: Vec::new(),
1330 score_threshold: 0.5,
1331 want_channel_estimate: false,
1332 want_error_rates: false,
1333 cache,
1334 scratch: FrameScratch::default(),
1335 }
1336 }
1337
1338 /// Overrides the sync-score acceptance threshold (default 0.5).
1339 pub fn with_score_threshold(mut self, t: f32) -> Self {
1340 self.score_threshold = t;
1341 self
1342 }
1343
1344 /// Carries the per-bin channel estimate on each frame's
1345 /// [`diagnostics`](RxFrame::diagnostics). Off by default — it costs an
1346 /// `n_fft`-sized allocation per frame, which only a caller measuring
1347 /// channel response wants to pay.
1348 ///
1349 /// Requires the preamble to carry a training symbol; without one there is
1350 /// nothing to estimate from and the field stays `None`.
1351 pub fn with_channel_estimate(mut self, on: bool) -> Self {
1352 self.want_channel_estimate = on;
1353 self
1354 }
1355
1356 /// Measures true bit error rates at the inner decoder's input and output,
1357 /// reported as [`channel_ber`](OfdmRxFrame::channel_ber) and
1358 /// [`inner_ber`](OfdmRxFrame::inner_ber). Off by default.
1359 ///
1360 /// Works by re-encoding each successfully decoded frame: a frame that
1361 /// passed its CRC is ground truth, so the re-encode reconstructs what the
1362 /// transmitter sent and the difference from what arrived is a real error
1363 /// rate. **No prior knowledge of the payload is required**, which is what
1364 /// makes this usable over the air rather than only against a known test
1365 /// vector.
1366 ///
1367 /// Only frames that decode are measured — an undecodable frame has no
1368 /// ground truth, so a rising error rate that suddenly stops reporting is
1369 /// itself the signal that the link has given up.
1370 ///
1371 /// Costs one encode per decoded frame.
1372 pub fn with_error_rates(mut self, on: bool) -> Self {
1373 self.want_error_rates = on;
1374 self
1375 }
1376
1377 /// Accumulated (not-yet-consumed) sample count.
1378 pub fn len(&self) -> usize {
1379 self.buf.len()
1380 }
1381
1382 pub fn is_empty(&self) -> bool {
1383 self.buf.is_empty()
1384 }
1385
1386 /// Read-only view of the accumulated IQ buffer.
1387 pub fn view_buf(&self) -> &[C32] {
1388 &self.buf
1389 }
1390
1391 /// Discards all accumulated samples.
1392 pub fn clear(&mut self) {
1393 self.buf.clear();
1394 }
1395
1396 /// Feeds IQ samples and returns any frames (or errors) that completed.
1397 pub fn feed(&mut self, iq: &[C32]) -> Vec<Result<RxFrame, RxError>> {
1398 self.buf.extend_from_slice(iq);
1399 self.drain(None)
1400 }
1401
1402 /// Runs a final decode pass over the residual buffer (e.g. at end of
1403 /// stream). Same semantics as `feed` with no new samples.
1404 pub fn flush(&mut self) -> Vec<Result<RxFrame, RxError>> {
1405 self.drain(None)
1406 }
1407
1408 /// [`feed`](Self::feed), additionally filling `probe` with each frame's
1409 /// equalized payload symbols and per-coded-bit correction map — the two
1410 /// quantities a constellation / decoder display needs. See [`OfdmRxProbe`].
1411 ///
1412 /// `probe` is **cleared first**, then refilled with everything this call
1413 /// produced; its allocations are retained, so probing a steady stream does
1414 /// not reallocate.
1415 ///
1416 /// **The gate is the choice of method, not a flag.** There is no
1417 /// `want_probe` field, so the unprobed [`feed`](Self::feed) gains no runtime
1418 /// branch and no receiver state can disagree with what the caller believes
1419 /// it enabled. A viewer that toggles its pane simply calls the other method.
1420 ///
1421 /// Costs, per frame: one encode chain (the same one
1422 /// [`with_error_rates`](Self::with_error_rates) runs — asking for both pays
1423 /// for it once), one further inner encode to re-derive what the decoder
1424 /// decided, and two buffer fills. Frames that fail their payload CRC still
1425 /// contribute their symbols, with an empty correction map; frames whose
1426 /// *header* fails never reach the payload demapper and contribute nothing.
1427 ///
1428 /// A `dvb_t_scattered` link reports **no** probe frames at all: its demap
1429 /// path collects no symbols here. That is routing rather than absence — a
1430 /// DVB-T receiver has its own equivalent, and this is the wrong object to
1431 /// instrument it with. Use
1432 /// [`DvbTFrameStreamDemod::feed_probed`](crate::demodulate::DvbTFrameStreamDemod::feed_probed)
1433 /// with a [`DvbTRxProbe`](crate::demodulate::DvbTRxProbe), and read its
1434 /// quality ladder from
1435 /// [`DvbTRxDiagnostics`](crate::demodulate::DvbTRxDiagnostics).
1436 pub fn feed_probed(
1437 &mut self,
1438 iq: &[C32],
1439 probe: &mut OfdmRxProbe,
1440 ) -> Vec<Result<RxFrame, RxError>> {
1441 probe.clear();
1442 self.buf.extend_from_slice(iq);
1443 self.drain(Some(probe))
1444 }
1445
1446 /// [`flush`](Self::flush) with the probe of [`feed_probed`](Self::feed_probed).
1447 pub fn flush_probed(&mut self, probe: &mut OfdmRxProbe) -> Vec<Result<RxFrame, RxError>> {
1448 probe.clear();
1449 self.drain(Some(probe))
1450 }
1451
1452 /// Repeatedly locates and decodes frames from the front of the buffer,
1453 /// consuming their samples, until no further complete frame is present.
1454 fn drain(&mut self, mut probe: Option<&mut OfdmRxProbe>) -> Vec<Result<RxFrame, RxError>> {
1455 let mut out = Vec::new();
1456 while let FrameStep::Decoded(result, consume_to) = self.try_one_frame(probe.as_deref_mut())
1457 {
1458 self.buf.drain(..consume_to);
1459 out.push(result);
1460 }
1461 out
1462 }
1463
1464 /// Attempts to decode one frame at the front of the buffer.
1465 fn try_one_frame(&mut self, mut probe: Option<&mut OfdmRxProbe>) -> FrameStep {
1466 let n_fft = self.cfg.carrier_plan.n_fft();
1467 let cp_len = self.cfg.carrier_plan.cp_len();
1468 let pre_len = self.preamble.total_len();
1469
1470 // Need at least a full preamble plus one header's worth before a search
1471 // can yield a decodable frame.
1472 if self.buf.len() < pre_len + (n_fft + cp_len) {
1473 return FrameStep::NeedMore;
1474 }
1475
1476 let sync = ofdm_sync(&self.buf, self.fs, &self.preamble, 0, self.buf.len());
1477 // The EARLIEST accepted candidate, not the best-ranked one: this drains
1478 // the buffer front-to-back, and locking onto a later frame discards
1479 // every frame before it with nothing reported. See `earliest_accepted`.
1480 let Some(best) = earliest_accepted(sync, self.score_threshold, pre_len) else {
1481 return FrameStep::NeedMore;
1482 };
1483
1484 // Total CFO = fractional + integer·subcarrier-spacing.
1485 let subcarrier_spacing = self.fs / n_fft as f32;
1486 let total_cfo = best.cfo_hz + best.integer_cfo_bins as f32 * subcarrier_spacing;
1487
1488 // CFO-correct from the preamble start onward into a scratch buffer.
1489 let region = &self.buf[best.start_sample..];
1490 let mut corrected = vec![C32::default(); region.len()];
1491 let mut rot = Rotator::new(-total_cfo, self.fs);
1492 rot.rotate_block(region, &mut corrected);
1493
1494 // Channel estimate from the training symbol (if the preamble carries
1495 // one), located just after the S&C repeats.
1496 let channel_estimate = self.estimate_channel(&corrected);
1497
1498 // The frame body begins right after the whole preamble (S&C + training).
1499 if corrected.len() < pre_len {
1500 return FrameStep::NeedMore;
1501 }
1502 let body = &corrected[pre_len..];
1503
1504 // A probe frame is committed as one unit: record where the buffers
1505 // stand, so an attempt that turns out to be `Incomplete` — which
1506 // consumes nothing and will be re-run from the header on the next
1507 // `feed` — leaves nothing behind to be reported a second time.
1508 let mark = probe.as_deref().map(|p| p.mark());
1509
1510 match decode_frame_body(
1511 &self.cfg,
1512 &self.mcs_table,
1513 body,
1514 channel_estimate.as_deref(),
1515 &self.cache,
1516 self.want_error_rates,
1517 &mut self.scratch,
1518 probe.as_deref_mut(),
1519 ) {
1520 Ok(body) => {
1521 let diagnostics = OfdmRxFrame {
1522 bits: Vec::new(),
1523 num_symbols: 0,
1524 evm_db: body.evm_db,
1525 cfo_hz: Some(total_cfo),
1526 timing_offset_samples: Some(best.start_sample as i32),
1527 // A scalar channel MSE needs a reference to measure
1528 // against, and a single-shot training estimate has none —
1529 // deriving one means separating channel from noise, which
1530 // is an estimator rather than an exposure. The per-bin
1531 // estimate below carries strictly more information.
1532 channel_mse: None,
1533 sync_score: Some(best.score),
1534 channel_estimate: self
1535 .want_channel_estimate
1536 .then(|| channel_estimate.as_deref().map(channel_from_training))
1537 .flatten(),
1538 inner_fec_ok: Some(body.inner_ok),
1539 outer_fec_ok: Some(body.outer_ok),
1540 channel_ber: body.channel_ber,
1541 inner_ber: body.inner_ber,
1542 };
1543 let consume_to = best.start_sample + pre_len + body.consumed;
1544 if consume_to > self.buf.len() {
1545 // Shouldn't happen (decode succeeded), but guard the drain.
1546 return FrameStep::NeedMore;
1547 }
1548 FrameStep::Decoded(
1549 Ok(RxFrame {
1550 packet: body.packet,
1551 diagnostics,
1552 }),
1553 consume_to,
1554 )
1555 }
1556 // The header or payload has not fully arrived yet — hold and retry
1557 // when more samples are fed. No buffer is consumed, and neither is
1558 // any probe state.
1559 Err(BodyError::Incomplete) => {
1560 if let (Some(p), Some(m)) = (&mut probe, mark) {
1561 p.rollback(m);
1562 }
1563 FrameStep::NeedMore
1564 }
1565 // A genuine decode failure on a fully-present frame: report it and
1566 // advance just past this preamble so the search continues past it
1567 // (avoids re-locking the same corrupt occurrence forever).
1568 Err(BodyError::Failed(e)) => {
1569 let skip = (best.start_sample + pre_len).min(self.buf.len());
1570 FrameStep::Decoded(Err(e), skip)
1571 }
1572 }
1573 }
1574
1575 /// Estimates the per-bin channel from the training symbol in `corrected`
1576 /// (CFO-corrected, preamble-start-relative). Returns `None` if the preamble
1577 /// carries no training symbol.
1578 fn estimate_channel(&self, corrected: &[C32]) -> Option<Vec<C32>> {
1579 let training = self.preamble.training_symbol?;
1580 let n_fft = training.n_fft;
1581 let cp_len = training.cp_len;
1582 let training_start = self.preamble.num_repeats * self.preamble.repeat_len;
1583 let end = training_start + n_fft + cp_len;
1584 if corrected.len() < end {
1585 return None;
1586 }
1587 // Estimate the channel at the same window position the data symbols use
1588 // (see `soft_demap`), or the held estimate would be applied at a
1589 // different window than it was measured at.
1590 let mut symbol_fft =
1591 SymbolFft::new(n_fft, cp_len).with_window_backoff(self.cfg.rx_window_backoff);
1592 let freq = symbol_fft.demod_symbol(&corrected[training_start..end])?;
1593 Some(freq.to_vec())
1594 }
1595}
1596
1597/// Converts a received training symbol's frequency bins to the channel
1598/// `H[k] = received[k] / known[k]`, matching what `OfdmEqualizer` does
1599/// internally. Exposed on the diagnostics because the known pattern is
1600/// crate-internal, so a caller cannot perform this division itself.
1601fn channel_from_training(received_freq: &[C32]) -> Vec<C32> {
1602 let known = crate::sync::ofdm_sync::training_symbol_freq_pattern(received_freq.len());
1603 received_freq
1604 .iter()
1605 .zip(known.iter())
1606 .map(|(&rx, &k)| rx / k)
1607 .collect()
1608}
1609
1610/// One step of the streaming drain loop.
1611enum FrameStep {
1612 /// A frame (or error) decoded; consume the buffer up to this index.
1613 Decoded(Result<RxFrame, RxError>, usize),
1614 /// Not enough buffered samples yet; wait for more.
1615 NeedMore,
1616}