Skip to main content

orion_sdr/modulate/
ofdm_frame.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/modulate/ofdm_frame.rs
5//
6// The OFDM frame (MAC-layer) modulator: turns a `FramePacket` into a flat
7// stream of time-domain IQ, applying the concatenated COFDM coding chain and
8// prepending the acquisition preamble + a fixed, MCS-independent header.
9//
10// On-air layout (transmit order):
11//   [ S&C preamble + training symbol ][ header symbols ][ payload symbols ]
12//
13// The header is coded with a fixed built-in scheme (BPSK + rate-1/2 LDPC, no
14// interleaver, no scrambler) so the receiver can always decode it before it
15// knows the payload MCS. Its byte layout is `HEADER_FIELD_BYTES` of fields
16// followed by the configured `header_crc`. The payload is coded per the MCS
17// selected by `metadata.mcs_index` (constellation + inner/outer FEC from the
18// MCS table) plus the link-wide interleavers/scrambler/`payload_crc` from
19// `OfdmConfig`.
20//
21// This module owns the shared bit-domain coding chain (`encode_chain`,
22// `pack_*`) that the frame demodulator inverts; the demodulator imports these
23// so the two are exact mirrors.
24
25use super::ofdm::{ConstellationOrder, OfdmConfig, OfdmMod};
26use crate::codec::{crc16, crc32};
27use crate::core::Block;
28use crate::fec::{
29    Bch, CrcKind, FramePacket, InnerFec, InterleaverKind, Ldpc, LdpcCode, OuterFec, PnScrambler,
30    ReedSolomon, ScramblerKind, ScramblerPos, SeedMode, conv_encode_punctured_with,
31    punctured_coded_len_with,
32};
33use crate::multicarrier::{CarrierPlan, SymbolWindow};
34use crate::sync::{OfdmPreamble, generate_ofdm_preamble};
35use num_complex::Complex32 as C32;
36use std::sync::{Arc, Mutex};
37
38/// Memoizes the constructed FEC code objects a link reuses frame after frame.
39///
40/// Constructing a code — especially [`Ldpc::new`], whose sparse parity-check
41/// build with its 4-cycle guard costs milliseconds — is a pure function of the
42/// code's parameters, so the object is identical every frame. Without this the
43/// concatenated-FEC chain rebuilt its `Ldpc`/`Bch`/`ReedSolomon` on *every*
44/// frame (encode and decode); the cache builds each once per link and hands out
45/// shared references thereafter.
46///
47/// The key spaces are tiny (a link uses one header LDPC plus the handful of
48/// codes in its MCS table), so linear-scan association lists beat a hash map
49/// here. A `Mutex` gives lazy population behind the `&self` encode/decode entry
50/// points; the cached objects are handed out as `Arc`s so callers hold them
51/// without keeping the lock across the (potentially long) encode/decode call.
52///
53/// `Send + Sync` (via `Arc`/`Mutex`) so it can live inside an `OfdmFrameMod` /
54/// `OfdmFrameStreamDemod` exposed to the PyO3 bindings, which require their
55/// pyclasses to be thread-safe. Access is a handful of uncontended lookups per
56/// frame, so the lock is effectively free. The produced codes are bit-identical
57/// to freshly constructed ones — this changes speed, never output.
58///
59/// A tiny memo map keyed by `K`, holding shared code objects `V`.
60type CodeMemo<K, V> = Mutex<Vec<(K, Arc<V>)>>;
61
62#[derive(Debug, Default)]
63pub struct CodecCache {
64    ldpc: CodeMemo<LdpcCode, Ldpc>,
65    /// Shortened-BCH keyed by `(t, msg_bits)`.
66    bch: CodeMemo<(usize, usize), Bch>,
67    /// Reed–Solomon keyed by `(n, n_parity)`.
68    rs: CodeMemo<(usize, usize), ReedSolomon>,
69}
70
71impl Clone for CodecCache {
72    /// A cloned cache starts empty rather than copying entries — cache contents
73    /// are pure derivations of the codes used, rebuilt on demand, and this keeps
74    /// `Clone` free of a lock acquisition. (Only `OfdmFrameMod` derives `Clone`;
75    /// it is not exercised on a hot path.)
76    fn clone(&self) -> Self {
77        Self::default()
78    }
79}
80
81impl CodecCache {
82    /// A fresh, empty cache.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Returns the [`Ldpc`] for `code`, building and caching it on first use.
88    pub fn ldpc(&self, code: LdpcCode) -> Arc<Ldpc> {
89        let mut table = self.ldpc.lock().unwrap();
90        if let Some((_, c)) = table.iter().find(|(k, _)| *k == code) {
91            return Arc::clone(c);
92        }
93        let built = Arc::new(Ldpc::new(code));
94        table.push((code, Arc::clone(&built)));
95        built
96    }
97
98    /// Returns the shortened [`Bch`] correcting `t` errors with a `msg_bits`
99    /// message part, building and caching it on first use.
100    pub fn bch(&self, t: usize, msg_bits: usize) -> Arc<Bch> {
101        let key = (t, msg_bits);
102        let mut table = self.bch.lock().unwrap();
103        if let Some((_, c)) = table.iter().find(|(k, _)| *k == key) {
104            return Arc::clone(c);
105        }
106        let built = Arc::new(shortened_bch_for(t, msg_bits));
107        table.push((key, Arc::clone(&built)));
108        built
109    }
110
111    /// Returns the [`ReedSolomon`] code `(n, n_parity)`, building and caching it
112    /// on first use.
113    pub fn rs(&self, n: usize, n_parity: usize) -> Arc<ReedSolomon> {
114        let key = (n, n_parity);
115        let mut table = self.rs.lock().unwrap();
116        if let Some((_, c)) = table.iter().find(|(k, _)| *k == key) {
117            return Arc::clone(c);
118        }
119        let built = Arc::new(ReedSolomon::new(n, n_parity).expect("valid RS config"));
120        table.push((key, Arc::clone(&built)));
121        built
122    }
123}
124
125/// Number of header field bytes before the header CRC: `mcs_index` (1) +
126/// `payload_len` (4, big-endian) + `sequence_num` (4) + `flags` (1) +
127/// `scrambler_seed` (4) = 14 bytes.
128pub const HEADER_FIELD_BYTES: usize = 14;
129
130/// The fixed constellation used for header symbols (most robust).
131pub const HEADER_CONSTELLATION: ConstellationOrder = ConstellationOrder::Bpsk;
132
133/// The fixed inner code protecting the header — a rate-1/2 LDPC, independent of
134/// the payload MCS.
135pub const HEADER_LDPC: LdpcCode = LdpcCode::N512R12;
136
137/// A modulation-and-coding scheme: the payload's constellation plus its inner
138/// and outer FEC. Selected per-frame by `FrameMetadata::mcs_index` via an
139/// [`McsTable`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub struct Mcs {
142    pub constellation: ConstellationOrder,
143    pub inner_fec: InnerFec,
144    pub outer_fec: OuterFec,
145}
146
147impl Mcs {
148    pub const fn new(
149        constellation: ConstellationOrder,
150        inner_fec: InnerFec,
151        outer_fec: OuterFec,
152    ) -> Self {
153        Self {
154            constellation,
155            inner_fec,
156            outer_fec,
157        }
158    }
159}
160
161/// Maps an 8-bit `mcs_index` to an [`Mcs`]. The sender and receiver must share
162/// the same table.
163#[derive(Debug, Clone)]
164pub struct McsTable {
165    entries: Vec<Mcs>,
166}
167
168impl McsTable {
169    pub fn new(entries: Vec<Mcs>) -> Self {
170        assert!(
171            !entries.is_empty(),
172            "MCS table must have at least one entry"
173        );
174        Self { entries }
175    }
176
177    /// A small default ladder: increasing constellation order, all with a
178    /// rate-1/2 LDPC inner code and a BCH(t=8) outer code — the concatenated
179    /// COFDM baseline.
180    pub fn default_ladder() -> Self {
181        let inner = InnerFec::Ldpc(LdpcCode::N512R12);
182        let outer = OuterFec::Bch { t: 8 };
183        Self::new(vec![
184            Mcs::new(ConstellationOrder::Bpsk, inner, outer),
185            Mcs::new(ConstellationOrder::Qpsk, inner, outer),
186            Mcs::new(ConstellationOrder::Qam16, inner, outer),
187            Mcs::new(ConstellationOrder::Qam64, inner, outer),
188        ])
189    }
190
191    pub fn get(&self, mcs_index: u8) -> Option<Mcs> {
192        self.entries.get(mcs_index as usize).copied()
193    }
194
195    pub fn len(&self) -> usize {
196        self.entries.len()
197    }
198
199    pub fn is_empty(&self) -> bool {
200        self.entries.is_empty()
201    }
202}
203
204// ── Shared bit/byte helpers ────────────────────────────────────────────────
205
206/// Unpacks bytes into a bit vector, MSB-first per byte.
207pub fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
208    let mut bits = Vec::with_capacity(bytes.len() * 8);
209    for &b in bytes {
210        for i in (0..8).rev() {
211            bits.push((b >> i) & 1);
212        }
213    }
214    bits
215}
216
217/// Packs a bit slice (MSB-first per byte) back into bytes. The bit count must
218/// be a multiple of 8.
219pub fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
220    assert_eq!(
221        bits.len() % 8,
222        0,
223        "bit count must be a whole number of bytes"
224    );
225    let mut bytes = Vec::with_capacity(bits.len() / 8);
226    for chunk in bits.chunks(8) {
227        let mut b = 0u8;
228        for &bit in chunk {
229            b = (b << 1) | (bit & 1);
230        }
231        bytes.push(b);
232    }
233    bytes
234}
235
236/// Appends the selected CRC (over `data`) to `data`, big-endian.
237pub fn append_crc(crc: CrcKind, data: &[u8]) -> Vec<u8> {
238    let mut out = data.to_vec();
239    match crc {
240        CrcKind::None => {}
241        CrcKind::Crc16 => out.extend_from_slice(&crc16(data).to_be_bytes()),
242        CrcKind::Crc32 => out.extend_from_slice(&crc32(data).to_be_bytes()),
243    }
244    out
245}
246
247/// Splits `data` into (payload, crc-ok). Returns `None` if `data` is too short
248/// to hold the CRC field. With [`CrcKind::None`] the check is vacuously true.
249pub fn check_and_strip_crc(crc: CrcKind, data: &[u8]) -> Option<(Vec<u8>, bool)> {
250    let clen = crc.len_bytes();
251    if data.len() < clen {
252        return None;
253    }
254    let (payload, tail) = data.split_at(data.len() - clen);
255    let ok = match crc {
256        CrcKind::None => true,
257        CrcKind::Crc16 => crc16(payload).to_be_bytes()[..] == *tail,
258        CrcKind::Crc32 => crc32(payload).to_be_bytes()[..] == *tail,
259    };
260    Some((payload.to_vec(), ok))
261}
262
263/// Builds a [`PnScrambler`] from a [`ScramblerKind`] and an explicit seed value
264/// (for `PerFrameRandom`, the caller supplies the drawn seed). Returns `None`
265/// for [`ScramblerKind::None`].
266pub fn build_scrambler(kind: ScramblerKind, per_frame_seed: u32) -> Option<PnScrambler> {
267    match kind {
268        // `None` and DVB-T energy dispersal produce no generic `PnScrambler`:
269        // DVB-T's whitener is a distinct byte-domain routine applied separately
270        // (see `scramble_bytes`), not a parameterized additive LFSR.
271        ScramblerKind::None | ScramblerKind::DvbTEnergyDispersal => None,
272        ScramblerKind::Additive { poly, width, seed } => {
273            let raw = match seed {
274                SeedMode::Fixed(v) => v,
275                SeedMode::PerFrameRandom => per_frame_seed,
276            };
277            // Reduce the seed into the register width, and avoid the all-zero
278            // fixed point (an all-zero additive LFSR never advances). The
279            // receiver derives the same value from the header field, so this
280            // reduction must be deterministic.
281            let mask = if width >= 32 {
282                u32::MAX
283            } else {
284                (1u32 << width) - 1
285            };
286            let s = {
287                let m = raw & mask;
288                if m == 0 { 1 } else { m }
289            };
290            Some(PnScrambler::new(poly, width as u32, s))
291        }
292    }
293}
294
295/// Applies the byte-domain whitener for `kind` to `bytes` in place (self-inverse,
296/// so the same call scrambles and descrambles). Handles both the generic
297/// `Additive` LFSR and DVB-T energy dispersal; a no-op for `None`. The
298/// after-inner-FEC bit-domain scramble position uses the `PnScrambler` directly
299/// (DVB-T energy dispersal is byte-domain / before-FEC only).
300pub fn scramble_bytes(kind: ScramblerKind, per_frame_seed: u32, bytes: &mut [u8]) {
301    match kind {
302        ScramblerKind::None => {}
303        ScramblerKind::DvbTEnergyDispersal => {
304            crate::waveform::dvb_t::DvbTEnergyDispersal::new().feed_in_place(bytes);
305        }
306        ScramblerKind::Additive { .. } => {
307            if let Some(s) = build_scrambler(kind, per_frame_seed) {
308                s.scramble(bytes);
309            }
310        }
311    }
312}
313
314// ── Block-size bookkeeping (shared TX/RX) ──────────────────────────────────
315
316/// Deterministic size accounting for one logical block's coding chain, so the
317/// transmitter and receiver agree on every intermediate length (needed to trim
318/// interleaver/fragmentation zero-padding on receive) and on how many OFDM
319/// symbols the coded bits occupy.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub struct BlockPlan {
322    /// Raw payload/field byte count (before CRC).
323    pub info_bytes: usize,
324    /// Bytes after appending the CRC.
325    pub framed_bytes: usize,
326    /// Bits after the outer code (before outer interleave).
327    pub outer_coded_bits: usize,
328    /// Bits after outer interleave.
329    pub outer_il_bits: usize,
330    /// Bits after the inner code (before inner interleave).
331    pub inner_coded_bits: usize,
332    /// Final coded-bit count (after inner interleave) = symbols · bits/symbol.
333    pub coded_bits: usize,
334}
335
336/// Rounds `n` up to a whole number of `block`-sized units (identity if
337/// `block == 0`).
338fn round_up(n: usize, block: usize) -> usize {
339    if block == 0 {
340        n
341    } else {
342        n.div_ceil(block) * block
343    }
344}
345
346/// Bit count after the frame-mode streaming Forney interleaver: pack `n_bits`
347/// to whole bytes, round the byte count up to a multiple of `branches` (the
348/// feed alignment), add the round-trip delay `branches·(branches−1)·depth`
349/// (the flush drain), then back to bits. Mirrors the length growth in
350/// [`interleave_bits`]'s `Convolutional` arm so the deinterleaver sees the exact
351/// length and can trim the delay offset.
352fn conv_il_bits(n_bits: usize, branches: usize, depth: usize) -> usize {
353    let bytes =
354        round_up(n_bits.div_ceil(8), branches) + crate::fec::conv_roundtrip_delay(branches, depth);
355    bytes * 8
356}
357
358/// Computes the [`BlockPlan`] for `info_bytes` under the given coding chain,
359/// reusing constructed code objects from `cache` (their dimensions are all this
360/// needs, but sharing the cache avoids rebuilding them here and in the
361/// encode/decode passes).
362pub fn block_plan(
363    info_bytes: usize,
364    crc: CrcKind,
365    outer: OuterFec,
366    inner: InnerFec,
367    outer_il: InterleaverKind,
368    inner_il: InterleaverKind,
369    cache: &CodecCache,
370) -> BlockPlan {
371    let framed_bytes = info_bytes + crc.len_bytes();
372    let framed_bits = framed_bytes * 8;
373
374    let outer_coded_bits = match outer {
375        OuterFec::None => framed_bits,
376        OuterFec::Bch { t } => {
377            let code = cache.bch(t, BCH_INFO_BITS);
378            let n_blocks = framed_bits.div_ceil(BCH_INFO_BITS);
379            n_blocks * code.n()
380        }
381        OuterFec::ReedSolomon { n, n_parity } => {
382            // Byte-domain: whole k-byte info blocks → n-byte codewords.
383            let rs = cache.rs(n, n_parity);
384            let n_blocks = framed_bytes.div_ceil(rs.k());
385            n_blocks * rs.n() * 8
386        }
387    };
388
389    let outer_il_bits = match outer_il {
390        InterleaverKind::None => outer_coded_bits,
391        InterleaverKind::Block { rows, cols } => round_up(outer_coded_bits, rows * cols),
392        InterleaverKind::Convolutional { branches, depth } => {
393            conv_il_bits(outer_coded_bits, branches, depth)
394        }
395    };
396
397    let inner_coded_bits = match inner {
398        InnerFec::None => outer_il_bits,
399        InnerFec::Ldpc(code) => {
400            // LDPC dimensions come straight off the code point (no construction
401            // needed), but touch the cache so the object is warm for encode.
402            let ldpc = cache.ldpc(code);
403            let n_blocks = outer_il_bits.div_ceil(ldpc.k());
404            n_blocks * ldpc.n()
405        }
406        InnerFec::Convolutional { rate, code } => {
407            punctured_coded_len_with(code, outer_il_bits, rate)
408        }
409    };
410
411    let coded_bits = match inner_il {
412        InterleaverKind::None => inner_coded_bits,
413        InterleaverKind::Block { rows, cols } => round_up(inner_coded_bits, rows * cols),
414        InterleaverKind::Convolutional { branches, depth } => {
415            conv_il_bits(inner_coded_bits, branches, depth)
416        }
417    };
418
419    BlockPlan {
420        info_bytes,
421        framed_bytes,
422        outer_coded_bits,
423        outer_il_bits,
424        inner_coded_bits,
425        coded_bits,
426    }
427}
428
429/// Number of OFDM symbols a logical block occupies for a given constellation
430/// over the base plan.
431pub fn symbols_for_coded_bits(
432    base: &OfdmConfig,
433    constellation: ConstellationOrder,
434    bits: usize,
435) -> usize {
436    let bps = base.carrier_plan.data_carriers().len() * constellation.bits_per_symbol();
437    bits.div_ceil(bps)
438}
439
440// ── Coding chain (encode side) ─────────────────────────────────────────────
441
442/// Applies a block interleaver to `bits` in place-by-value: writes bit `i` of
443/// each padded block row-major and reads column-major. Returns the interleaved
444/// bits plus the block size used, so the deinterleaver can trim padding.
445pub fn interleave_bits(il: InterleaverKind, bits: &[u8]) -> Vec<u8> {
446    match il {
447        InterleaverKind::None => bits.to_vec(),
448        InterleaverKind::Block { rows, cols } => {
449            let block = rows * cols;
450            let bi = crate::fec::BlockInterleaver::new(rows, cols);
451            let mut out = Vec::with_capacity(bits.len().div_ceil(block) * block);
452            // Reused across chunks: the interleaver and both scratch buffers are
453            // built once instead of per chunk.
454            let mut padded = vec![0u8; block];
455            let mut permuted = vec![0u8; block];
456            for chunk in bits.chunks(block) {
457                padded[..chunk.len()].copy_from_slice(chunk);
458                padded[chunk.len()..].fill(0);
459                bi.interleave(&padded, &mut permuted);
460                out.extend_from_slice(&permuted);
461            }
462            out
463        }
464        InterleaverKind::Convolutional { branches, depth } => {
465            // Byte-domain streaming Forney interleaver, driven in FRAME mode:
466            // reset, feed the (byte-packed, `branches`-aligned) payload, then
467            // flush the delay lines. The output grows by the round-trip delay
468            // `branches·(branches−1)·depth`, which `block_plan`'s `conv_il_bits`
469            // mirrors so the deinterleaver knows the length and trims it.
470            let mut ci = crate::fec::ConvInterleaver::new(branches, depth);
471            let bytes = pack_bits_padded(bits);
472            let n = round_up(bytes.len(), branches);
473            let mut padded = bytes;
474            padded.resize(n, 0);
475            let mut out_bytes = ci.feed(&padded);
476            out_bytes.extend_from_slice(&ci.flush());
477            bytes_to_bits(&out_bytes)
478        }
479    }
480}
481
482/// Fixed information-bit block size for the outer BCH code (per shortened
483/// codeword). Chosen so one codeword fits comfortably within GF(2^8)'s length
484/// bound (n = k + parity ≤ 255) for the t values used here.
485pub const BCH_INFO_BITS: usize = 120;
486
487/// Encodes `message_bytes` through the outer code (byte domain), returning the
488/// coded bits (MSB-first). The message bit stream is fragmented into
489/// [`BCH_INFO_BITS`]-bit blocks, each encoded into one shortened BCH codeword;
490/// the final block is zero-padded. `None` outer code passes the bytes through
491/// as bits.
492pub fn outer_encode(outer: OuterFec, message_bytes: &[u8], cache: &CodecCache) -> Vec<u8> {
493    match outer {
494        OuterFec::None => bytes_to_bits(message_bytes),
495        OuterFec::Bch { t } => {
496            let msg_bits = bytes_to_bits(message_bytes);
497            let code = cache.bch(t, BCH_INFO_BITS);
498            let mut out = Vec::new();
499            for chunk in msg_bits.chunks(BCH_INFO_BITS) {
500                let mut block = chunk.to_vec();
501                block.resize(BCH_INFO_BITS, 0);
502                out.extend_from_slice(&code.encode(&block));
503            }
504            out
505        }
506        OuterFec::ReedSolomon { n, n_parity } => {
507            // RS is a byte-domain code: fragment into k-byte blocks, encode each
508            // into an n-byte codeword (final block zero-padded), then emit bits.
509            let rs = cache.rs(n, n_parity);
510            let k = rs.k();
511            let mut out_bytes = Vec::new();
512            for chunk in message_bytes.chunks(k) {
513                let mut block = chunk.to_vec();
514                block.resize(k, 0);
515                out_bytes.extend_from_slice(&rs.encode(&block));
516            }
517            bytes_to_bits(&out_bytes)
518        }
519    }
520}
521
522/// Encodes `info_bits` through the inner code, returning coded bits. The info
523/// bit stream is fragmented into K-bit blocks, each encoded into one N-bit
524/// codeword (final block zero-padded). `None` passes through.
525pub fn inner_encode(inner: InnerFec, info_bits: &[u8], cache: &CodecCache) -> Vec<u8> {
526    match inner {
527        InnerFec::None => info_bits.to_vec(),
528        InnerFec::Ldpc(code) => {
529            let ldpc = cache.ldpc(code);
530            let k = ldpc.k();
531            let mut out = Vec::new();
532            for chunk in info_bits.chunks(k) {
533                let mut msg = chunk.to_vec();
534                msg.resize(k, 0);
535                out.extend_from_slice(&ldpc.encode(&msg));
536            }
537            out
538        }
539        // The convolutional code terminates once per block (whole info stream +
540        // tail bits), not per fixed-size fragment.
541        InnerFec::Convolutional { rate, code } => conv_encode_punctured_with(code, info_bits, rate),
542    }
543}
544
545/// Constructs a BCH code correcting `t` errors, shortened so its message part
546/// holds exactly `msg_bits` information bits.
547pub fn shortened_bch_for(t: usize, msg_bits: usize) -> Bch {
548    // Parity length is fixed by t; choose n = msg_bits + parity_bits.
549    let full = Bch::new(t).expect("valid BCH t");
550    let parity = full.parity_bits();
551    Bch::shortened(msg_bits + parity, t).expect("valid shortened BCH")
552}
553
554/// The intermediate bit-streams the encode chain passes through, kept so a
555/// receiver can measure error rates against them.
556///
557/// Re-encoding a successfully decoded frame reconstructs exactly what the
558/// transmitter sent; comparing that against what actually arrived at each
559/// stage is what turns a pass/fail flag into a bit error *rate*.
560pub struct EncodedStages {
561    /// The outer decoder's expected output — what should have arrived at the
562    /// inner decoder's *output*, before outer deinterleaving.
563    pub outer_il_bits: Vec<u8>,
564    /// The fully coded bits as transmitted — what should have arrived at the
565    /// inner decoder's *input*.
566    pub coded: Vec<u8>,
567}
568
569/// [`encode_chain`], keeping the per-stage intermediates instead of only the
570/// final coded bits — see [`EncodedStages`].
571#[allow(clippy::too_many_arguments)]
572pub fn encode_chain_stages(
573    bytes: &[u8],
574    crc: CrcKind,
575    outer: OuterFec,
576    inner: InnerFec,
577    outer_il: InterleaverKind,
578    inner_il: InterleaverKind,
579    scrambler: ScramblerKind,
580    scrambler_pos: ScramblerPos,
581    per_frame_seed: u32,
582    cache: &CodecCache,
583) -> EncodedStages {
584    // 1. CRC over the raw bytes.
585    let mut framed = append_crc(crc, bytes);
586
587    // 2. Optional scramble before the outer code (byte domain — handles both the
588    //    generic additive LFSR and DVB-T energy dispersal).
589    let sc = build_scrambler(scrambler, per_frame_seed);
590    if scrambler_pos == ScramblerPos::BeforeOuterFec {
591        scramble_bytes(scrambler, per_frame_seed, &mut framed);
592    }
593
594    // 3. Outer FEC (byte → coded bits), then outer interleave (byte-domain,
595    //    but we operate on bits here for a single generic interleaver).
596    let outer_bits = outer_encode(outer, &framed, cache);
597    let outer_il_bits = interleave_bits(outer_il, &outer_bits);
598
599    // 4. Inner FEC (bits → coded bits), then inner interleave.
600    let inner_bits = inner_encode(inner, &outer_il_bits, cache);
601    let mut coded = interleave_bits(inner_il, &inner_bits);
602
603    // 5. Optional scramble after the inner code (bit domain).
604    if scrambler_pos == ScramblerPos::AfterInnerFec
605        && let Some(ref s) = sc
606    {
607        // Scramble whole bytes; pad to a byte boundary, scramble, trim.
608        scramble_bits(s, &mut coded);
609    }
610
611    EncodedStages {
612        outer_il_bits,
613        coded,
614    }
615}
616
617/// Runs the full encode chain for one logical block (header or payload):
618/// `bytes → CRC → [scramble if before] → outer → outer-interleave → inner →
619/// inner-interleave → [scramble if after]`, returning coded bits ready to map.
620#[allow(clippy::too_many_arguments)]
621pub fn encode_chain(
622    bytes: &[u8],
623    crc: CrcKind,
624    outer: OuterFec,
625    inner: InnerFec,
626    outer_il: InterleaverKind,
627    inner_il: InterleaverKind,
628    scrambler: ScramblerKind,
629    scrambler_pos: ScramblerPos,
630    per_frame_seed: u32,
631    cache: &CodecCache,
632) -> Vec<u8> {
633    encode_chain_stages(
634        bytes,
635        crc,
636        outer,
637        inner,
638        outer_il,
639        inner_il,
640        scrambler,
641        scrambler_pos,
642        per_frame_seed,
643        cache,
644    )
645    .coded
646}
647
648/// Scrambles a bit vector by packing to bytes (zero-padded), XORing the PN
649/// sequence, and unpacking — used for the after-inner-FEC bit-domain position.
650pub fn scramble_bits(s: &PnScrambler, bits: &mut [u8]) {
651    let mut bytes = pack_bits_padded(bits);
652    s.scramble(&mut bytes);
653    let unpacked = bytes_to_bits(&bytes);
654    bits.copy_from_slice(&unpacked[..bits.len()]);
655}
656
657/// Packs bits to bytes, zero-padding the final partial byte.
658fn pack_bits_padded(bits: &[u8]) -> Vec<u8> {
659    let mut padded = bits.to_vec();
660    let rem = padded.len() % 8;
661    if rem != 0 {
662        padded.resize(padded.len() + (8 - rem), 0);
663    }
664    bits_to_bytes(&padded)
665}
666
667/// Serializes the 14 header field bytes (before CRC), big-endian.
668pub fn pack_header_fields(
669    mcs_index: u8,
670    payload_len: u32,
671    sequence_num: u32,
672    flags: u8,
673    scrambler_seed: u32,
674) -> [u8; HEADER_FIELD_BYTES] {
675    let mut out = [0u8; HEADER_FIELD_BYTES];
676    out[0] = mcs_index;
677    out[1..5].copy_from_slice(&payload_len.to_be_bytes());
678    out[5..9].copy_from_slice(&sequence_num.to_be_bytes());
679    out[9] = flags;
680    out[10..14].copy_from_slice(&scrambler_seed.to_be_bytes());
681    out
682}
683
684/// Maps coded bits to IQ symbols by running `OfdmMod::modulate` with the given
685/// constellation over the shared carrier plan. Zero-pads the final partial
686/// OFDM symbol (as `OfdmMod::modulate` does).
687fn map_bits_to_iq(base: &OfdmConfig, constellation: ConstellationOrder, bits: &[u8]) -> Vec<C32> {
688    let cfg = symbol_config(base, constellation);
689    let mut modstage = OfdmMod::new(&cfg);
690    modstage.modulate(bits)
691}
692
693/// Scattered-pilot variant of [`map_bits_to_iq`] for DVB-T: maps `bits` through
694/// the four-phase grid rotation (`mapper`), so each OFDM symbol reserves the
695/// phase-appropriate continual/scattered/TPS pilot bins (EN 300 744 §4.5). The
696/// `mapper`'s symbol-phase counter carries across calls, so a whole frame's
697/// header-then-payload symbols form one continuous rotation (`l = 0` at the
698/// first symbol after [`ScatteredPilotMapper::reset`]).
699///
700/// Mirrors `OfdmMod`'s per-symbol pipeline (map → grid → IFFT → CP → gain) but
701/// swaps the static [`GridMap`] for the rotating grid. Baseband only
702/// (`rf_hz == 0.0`, which every DVB-T config uses); zero-pads a final partial
703/// symbol like `OfdmMod::modulate`.
704///
705/// Payload symbols on a DVB-T constellation (QPSK/16-QAM/64-QAM) are mapped with
706/// the DVB-T-exact Figure-9a mapping (`dvb_t_map_symbol`); a BPSK block (the
707/// `OrionSdr` header, not a DVB-T order) falls back to the generic mapper.
708fn map_bits_to_iq_scattered(
709    base: &OfdmConfig,
710    constellation: ConstellationOrder,
711    bits: &[u8],
712    mapper: &mut crate::waveform::dvb_t::ScatteredPilotMapper,
713) -> Vec<C32> {
714    use crate::waveform::dvb_t::{dvb_t_map_symbol, is_dvb_t_constellation};
715
716    let n_data = mapper.num_data_carriers();
717    let n_fft = mapper.n_fft();
718    let cp_len = base.carrier_plan.cp_len();
719    let vbits = constellation.bits_per_symbol();
720    let bps = n_data * vbits;
721    if bps == 0 {
722        return Vec::new();
723    }
724    let n_symbols = bits.len().div_ceil(bps);
725    let mut padded = bits.to_vec();
726    padded.resize(n_symbols * bps, 0);
727
728    let dvb_t_map = is_dvb_t_constellation(constellation);
729    let mut sym_mapper = crate::modulate::ofdm::ideal_symbol_mapper(constellation);
730    let mut ifft = crate::multicarrier::IfftBlock::new(n_fft);
731    let mut cp_insert = crate::multicarrier::CyclicPrefixInsert::new(n_fft, cp_len);
732    let mut symbols = vec![C32::default(); n_data];
733    let mut freq = vec![C32::default(); n_fft];
734    let mut time = vec![C32::default(); n_fft];
735    let sps = n_fft + cp_len;
736    let mut out = vec![C32::default(); n_symbols * sps];
737
738    let g = base.gain;
739    for s in 0..n_symbols {
740        let bit_off = s * bps;
741        let sym_bits = &padded[bit_off..bit_off + bps];
742        if dvb_t_map {
743            // DVB-T Figure-9a constellation, carrier by carrier.
744            for (c, chunk) in sym_bits.chunks(vbits).enumerate() {
745                symbols[c] = dvb_t_map_symbol(chunk).expect("DVB-T order");
746            }
747        } else {
748            sym_mapper.process(sym_bits, &mut symbols);
749        }
750        mapper.map_symbol(&symbols, &mut freq);
751        ifft.process(&freq, &mut time);
752        let cp_out = &mut out[s * sps..(s + 1) * sps];
753        cp_insert.process(&time, cp_out);
754        if g != 1.0 {
755            for v in cp_out.iter_mut() {
756                *v = C32::new(g * v.re, g * v.im);
757            }
758        }
759    }
760    out
761}
762
763/// Builds a bare per-symbol `OfdmConfig` (no frame fields) for a given
764/// constellation, sharing the base plan/fs/rf/gain. Used to drive `OfdmMod`
765/// for the header (BPSK) and payload (MCS) symbol streams.
766pub fn symbol_config(base: &OfdmConfig, constellation: ConstellationOrder) -> OfdmConfig {
767    // The bare symbol config drops the frame-layer FEC/interleaver settings (a
768    // single symbol carries no coded block), but must carry the RX window
769    // back-off: it is per-symbol demod geometry, and a reconstructed config that
770    // reset it to 0 would silently demodulate at the wrong window position.
771    OfdmConfig::new(
772        base.carrier_plan.clone(),
773        base.fs,
774        base.rf_hz,
775        base.gain,
776        constellation,
777    )
778    .with_rx_window_backoff(base.rx_window_backoff)
779}
780
781/// The OFDM frame modulator.
782#[derive(Debug, Clone)]
783pub struct OfdmFrameMod {
784    cfg: OfdmConfig,
785    mcs_table: McsTable,
786    preamble: OfdmPreamble,
787    /// FEC code cache, so a stream of frames builds each code once (see
788    /// [`CodecCache`]). Held behind `Arc` so it can be shared with a paired
789    /// demodulator (TX and RX then reuse the same built codes).
790    cache: Arc<CodecCache>,
791}
792
793/// Panics if `cfg` carries a nonzero `rf_hz`.
794///
795/// `rf_hz` is honoured by [`OfdmMod`](crate::modulate::OfdmMod), which rotates
796/// each symbol as it is produced. The **frame** layer cannot honour it, in
797/// three independent ways:
798///
799/// - [`TxLowpass`](crate::multicarrier::TxLowpass) is a low-pass centred on DC.
800///   Run over an already-upconverted stream it deletes the signal, so a
801///   spectral mask and a nonzero `rf_hz` cannot coexist.
802/// - `generate_ofdm_preamble` does not apply it, leaving the preamble at
803///   baseband while header and payload sit at the IF.
804/// - `map_bits_to_iq` builds a fresh `OfdmMod` per block, so each starts its
805///   rotator at phase 0 — a phase step at every header/payload and frame seam.
806///
807/// The receiver never applies it either: `rf_hz` appears nowhere in
808/// `demodulate`, so a frame modulated at an IF could not be decoded even if
809/// the transmit side were correct.
810///
811/// Modulate at `rf_hz = 0.0` and upconvert the whole burst yourself with one
812/// continuous [`Rotator`](crate::dsp::Rotator). That is the right ordering
813/// whenever a baseband shaping stage exists — shape first, upconvert once —
814/// and it keeps the rotator continuous across every seam.
815pub(crate) fn assert_baseband(cfg: &OfdmConfig) {
816    assert!(
817        cfg.rf_hz == 0.0,
818        "OFDM frame assembly is baseband-only: got rf_hz = {} Hz. Modulate at \
819         rf_hz = 0.0 and upconvert the whole burst with one continuous Rotator.",
820        cfg.rf_hz
821    );
822}
823
824impl OfdmFrameMod {
825    /// Creates a frame modulator over `cfg`, an `mcs_table`, and the
826    /// acquisition `preamble` (which should carry a training symbol sized to
827    /// the plan for the receiver's channel estimation). The modulator owns a
828    /// fresh, private [`CodecCache`]; use [`with_cache`](Self::with_cache) to
829    /// share one with a demodulator.
830    pub fn new(cfg: OfdmConfig, mcs_table: McsTable, preamble: OfdmPreamble) -> Self {
831        Self::with_cache(cfg, mcs_table, preamble, Arc::new(CodecCache::new()))
832    }
833
834    /// Like [`new`](Self::new), but reuses the caller-provided `cache` — share
835    /// one `Arc<CodecCache>` across a modulator/demodulator pair (or several
836    /// links on the same MCS) so each FEC code is constructed only once.
837    pub fn with_cache(
838        cfg: OfdmConfig,
839        mcs_table: McsTable,
840        preamble: OfdmPreamble,
841        cache: Arc<CodecCache>,
842    ) -> Self {
843        assert_baseband(&cfg);
844        Self {
845            cfg,
846            mcs_table,
847            preamble,
848            cache,
849        }
850    }
851
852    pub fn config(&self) -> &OfdmConfig {
853        &self.cfg
854    }
855
856    /// The training-symbol-carrying preamble prepended to every frame.
857    pub fn preamble(&self) -> &OfdmPreamble {
858        &self.preamble
859    }
860
861    /// Modulates a whole `FramePacket` into a flat IQ stream:
862    /// `[preamble+training][header][payload]`.
863    ///
864    /// `per_frame_seed` supplies the scrambler seed for a `PerFrameRandom`
865    /// configuration (ignored otherwise); it is recorded in the header so the
866    /// receiver can rebuild the descrambler.
867    pub fn modulate_frame(&self, frame: &FramePacket, per_frame_seed: u32) -> Vec<C32> {
868        let mut out = Vec::new();
869
870        // For a DVB-T scattered-pilot link, one grid-rotation orchestrator spans
871        // the whole frame's OFDM symbols (header then payload), so `l = 0` is the
872        // first header symbol and the phase carries through — matching the RX
873        // extractor's per-frame reset. `None` for every other link.
874        let mut scattered = self.cfg.dvb_t_scattered.then(|| {
875            let guard = crate::waveform::dvb_t::GuardInterval::from_cp_len_2k(
876                self.cfg.carrier_plan.cp_len(),
877            )
878            .expect("DVB-T scattered link requires a 2K guard interval");
879            crate::waveform::dvb_t::ScatteredPilotMapper::new(guard)
880        });
881
882        // Maps coded bits either through the rotating scattered grid (DVB-T) or
883        // the static plan.
884        let mut map = |constellation, bits: &[u8]| match scattered.as_mut() {
885            Some(m) => map_bits_to_iq_scattered(&self.cfg, constellation, bits, m),
886            None => map_bits_to_iq(&self.cfg, constellation, bits),
887        };
888
889        // 1. Preamble + training symbol.
890        out.extend_from_slice(&generate_ofdm_preamble(&self.preamble, &self.cfg));
891
892        // 2. Header (only OrionSdr prepends a dedicated header block; NoHeader
893        //    and DvbTps carry no separate header — DvbTps signals in-band on the
894        //    TPS carriers, handled by the dedicated DVB-T frame assembler).
895        if self.cfg.header_format.has_header_block() {
896            let fields = pack_header_fields(
897                frame.metadata.mcs_index,
898                frame.payload.len() as u32,
899                frame.metadata.sequence_num,
900                frame.metadata.flags,
901                per_frame_seed,
902            );
903            let header_bits = encode_chain(
904                &fields,
905                self.cfg.header_crc,
906                OuterFec::None,
907                InnerFec::Ldpc(HEADER_LDPC),
908                InterleaverKind::None,
909                InterleaverKind::None,
910                ScramblerKind::None,
911                ScramblerPos::BeforeOuterFec,
912                0,
913                &self.cache,
914            );
915            out.extend_from_slice(&map(HEADER_CONSTELLATION, &header_bits));
916        }
917
918        // 3. Payload, coded per the selected MCS.
919        let mcs = self
920            .mcs_table
921            .get(frame.metadata.mcs_index)
922            .expect("mcs_index must be in the MCS table");
923        let payload_bits = encode_chain(
924            &frame.payload,
925            self.cfg.payload_crc,
926            mcs.outer_fec,
927            mcs.inner_fec,
928            self.cfg.outer_interleaver,
929            self.cfg.inner_interleaver,
930            self.cfg.scrambler,
931            self.cfg.scrambler_pos,
932            per_frame_seed,
933            &self.cache,
934        );
935        out.extend_from_slice(&map(mcs.constellation, &payload_bits));
936
937        // 4. Optional TX symbol windowing (raised-cosine edge taper). Applied as
938        //    a post-pass over the assembled stream: every CP-bearing symbol from
939        //    the training symbol onward is windowed, but the raw S&C preamble
940        //    repeats (no CP, correlated raw by `ofdm_sync`) are skipped — see
941        //    the RX-transparency and preamble constraints in the windowing design.
942        self.apply_symbol_windowing(&mut out);
943
944        // 5. Optional TX baseband low-pass (spectral mask). Applied last, over
945        //    the whole assembled stream — spanning symbol boundaries, which is
946        //    what makes it a spectral filter rather than a per-symbol taper.
947        //    Unlike the taper this DOES include the S&C preamble: a real
948        //    transmitter band-limits everything it emits, and filtering only
949        //    part of the burst would put an unfiltered spectral step back in.
950        //    Periodicity — the property `ofdm_sync` correlates on — survives a
951        //    filter whose reach is short relative to `repeat_len`, since the
952        //    same taps see the same repeated samples; see `TxLowpass`.
953        if let Some(lowpass) = self.cfg.tx_lowpass {
954            lowpass.apply(&mut out);
955        }
956
957        out
958    }
959
960    /// In-place raised-cosine edge taper over the CP-bearing symbols of an
961    /// assembled frame. No-op when the carrier plan's `window_roll_off` is 0.
962    ///
963    /// The raw S&C preamble repeats (`num_repeats * repeat_len` leading samples)
964    /// carry no cyclic prefix and are correlated sample-for-sample by the
965    /// receiver's timing/CFO stage, so they must not be tapered. Everything from
966    /// the training symbol onward (training, header, payload) is a contiguous run
967    /// of `samples_per_ofdm_symbol()`-sized CP'd symbols and is windowed.
968    fn apply_symbol_windowing(&self, out: &mut [C32]) {
969        let roll_off = self.cfg.carrier_plan.window_roll_off();
970        if roll_off == 0 {
971            return;
972        }
973        let sps = self.cfg.samples_per_ofdm_symbol();
974        // Start of the first windowable (CP-bearing) symbol: past the raw S&C
975        // repeats. The training symbol (if any) is the first such symbol; without
976        // one, the first header/payload symbol sits here instead.
977        let start = self.preamble.num_repeats * self.preamble.repeat_len;
978        let mut win = SymbolWindow::new(sps, roll_off);
979        let mut off = start;
980        while off + sps <= out.len() {
981            // Window in place: read the symbol, write it back tapered.
982            let symbol: Vec<C32> = out[off..off + sps].to_vec();
983            win.process(&symbol, &mut out[off..off + sps]);
984            off += sps;
985        }
986    }
987}
988
989/// Convenience: the carrier plan cloned from a config (used by the demodulator).
990pub fn plan_of(cfg: &OfdmConfig) -> CarrierPlan {
991    cfg.carrier_plan.clone()
992}