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}