Skip to main content

orion_sdr/modulate/
dvb_t_frame.rs

1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/modulate/dvb_t_frame.rs
5//
6// The conformant DVB-T on-air frame MODULATOR (ETSI EN 300 744): a preamble-less
7// OFDM frame carrying an MPEG-2 transport-stream payload, TPS signalling on the
8// 17 reserved carriers, and scattered/continual pilots. Unlike the generic
9// `OfdmFrameMod` (which prepends a Schmidl & Cox preamble + an `OrionSdr`
10// header), this emits exactly what a real DVB-T receiver expects — a stream of
11// OFDM symbols with the transmission parameters carried by TPS rather than a
12// prepended header. The `demodulate::dvb_t_frame` module is its exact inverse.
13//
14// This is a per-standard ORCHESTRATOR over the crate's shared, separately-
15// callable pipeline stages — it introduces no new generic abstraction:
16//   • payload FEC     — `encode_chain` (RS(204,188) + K=7 conv + Forney I=12);
17//   • energy dispersal— `waveform::dvb_t_ts` (188-byte TS packets);
18//   • constellation   — `dvb_t_map_symbol` (Figure-9a);
19//   • pilots/grid     — `ScatteredPilotMapper` (Phase 2);
20//   • TPS             — `dvb_t_tps::{TpsWord, TpsEncoder}`.
21// A future coherent-OFDM standard would get its own thin orchestrator over the
22// same stages.
23//
24// SCOPE. One OFDM frame is 68 symbols; a small payload occupies fewer, so a
25// single frame is padded to at least 68 symbols so a full TPS block is present.
26// The caller tracks the payload length (real DVB-T length is implicit in the
27// continuous TS). Multi-frame streaming and super-frame sync-word alternation are
28// left for the super-frame path (`dvb_t_super_frame`).
29
30use super::ofdm_frame::{CodecCache, encode_chain, symbols_for_coded_bits};
31use crate::core::Block;
32use crate::fec::{CrcKind, InterleaverKind, ScramblerKind, ScramblerPos};
33use crate::multicarrier::{CyclicPrefixInsert, IfftBlock, SymbolWindow, TxLowpass};
34use crate::waveform::dvb_t::{
35    DVB_T_DATA_CARRIERS, DVB_T_FRAME_OUTER, DVB_T_FRAME_OUTER_IL, DVB_T_KMAX, DVB_T_N_FFT,
36    DvbTFrameParams, ScatteredPilotMapper, dvb_t_coded_bits_with, dvb_t_frame_fill_with,
37    dvb_t_map_symbol, tps_carrier_bins,
38};
39use crate::waveform::dvb_t_tps::{TPS_SYMBOLS_PER_FRAME, TpsEncoder};
40use crate::waveform::dvb_t_ts::{
41    TS_PACKET_LEN, ts_energy_disperse, ts_packetize, ts_stuff_null_packets,
42};
43use num_complex::Complex32 as C32;
44
45/// A modulated DVB-T frame: the time-domain IQ plus the numerology a receiver
46/// needs to acquire it (all also recoverable from the signal; returned for
47/// caller/test convenience).
48#[derive(Debug, Clone)]
49pub struct DvbTFrame {
50    /// Time-domain baseband IQ (no preamble; a whole number of OFDM symbols).
51    pub iq: Vec<C32>,
52    /// Number of OFDM symbols in the frame.
53    pub n_symbols: usize,
54    /// Samples per OFDM symbol (`n_fft + cp_len`).
55    pub samples_per_symbol: usize,
56}
57
58/// A conformant, preamble-less DVB-T frame modulator. Constructed with the link's
59/// transmission parameters ([`DvbTFrameParams`] — guard interval, constellation,
60/// code rate, TPS-signalled frame number and cell id); [`modulate`](Self::modulate)
61/// produces one frame per call.
62#[derive(Debug, Clone)]
63pub struct DvbTFrameMod {
64    params: DvbTFrameParams,
65    /// TX symbol-window roll-off in samples (raised-cosine edge taper). `0`
66    /// (default) = no windowing, so the on-air frame is byte-identical.
67    window_roll_off: usize,
68    /// Optional TX baseband low-pass (spectral mask) over the assembled frame.
69    /// `None` (default) leaves the on-air frame byte-identical.
70    tx_lowpass: Option<TxLowpass>,
71}
72
73impl DvbTFrameMod {
74    /// Builds a modulator for a link with the given transmission parameters.
75    pub fn new(params: DvbTFrameParams) -> Self {
76        Self {
77            params,
78            window_roll_off: 0,
79            tx_lowpass: None,
80        }
81    }
82
83    /// Enables TX symbol windowing with a `roll_off`-sample raised-cosine taper
84    /// at each symbol edge, reducing out-of-band emission. `0` (the default)
85    /// disables it. DVB-T frames are preamble-less — every symbol is CP-bearing —
86    /// so every symbol is windowed. The taper is only RX-transparent when the
87    /// receiver's window back-off is paired to it (`roll_off ≤ cp_len/2` with
88    /// back-off `cp_len/2`); see
89    /// [`DvbTFrameDemod::with_rx_window_backoff`](crate::demodulate::DvbTFrameDemod::with_rx_window_backoff).
90    /// The continual/scattered/TPS pilots are unaffected — windowing touches only
91    /// the time-domain guard samples, not the subcarrier allocation.
92    pub fn with_symbol_window(mut self, roll_off: usize) -> Self {
93        self.window_roll_off = roll_off;
94        self
95    }
96
97    /// Enables a TX baseband low-pass (spectral mask) across the assembled
98    /// frame, applied after any symbol taper. `None`/absent (the default) leaves
99    /// the frame byte-identical.
100    ///
101    /// This is the DVB-T lever that **exceeds** the symbol-windowing ceiling: it
102    /// attenuates out-of-band energy directly in the frequency domain, and DVB-T
103    /// comes with room to do it in — 1705 of 2048 bins are active, so there is a
104    /// real null band for the transition. It changes nothing about how a
105    /// receiver decodes (the scattered-pilot equalizer absorbs the filter like
106    /// any other channel), but its group delay must land in guard the receiver
107    /// discards: pair it with
108    /// [`DvbTFrameDemod::with_rx_window_backoff`](crate::demodulate::DvbTFrameDemod::with_rx_window_backoff)
109    /// and keep `roll_off + group_delay ≤ min(cp_len − b, b)`
110    /// ([`TxLowpass::fits_guard`]). A **long guard** buys a sharper mask: G1/4
111    /// (`cp_len = 512`) affords eight times the filter length of G1/32.
112    ///
113    /// [`TxLowpass::for_null_band`] placed against DVB-T's own band edge is
114    /// [`for_dvb_t_2k`](Self::tx_lowpass_for_2k).
115    pub fn with_tx_lowpass(mut self, lowpass: TxLowpass) -> Self {
116        self.tx_lowpass = Some(lowpass);
117        self
118    }
119
120    /// A spectral mask sized for the fixed DVB-T 2K band edge (active carriers
121    /// `±852` of 2048), leaving `num_taps` and `stopband_db` to the caller —
122    /// `num_taps` is what the guard budget constrains.
123    /// [`TxLowpass::taps_for_null_band`] with the same arguments suggests a
124    /// length.
125    pub fn tx_lowpass_for_2k(num_taps: usize, stopband_db: f32) -> TxLowpass {
126        TxLowpass::for_null_band(DVB_T_N_FFT, DVB_T_KMAX / 2, num_taps, stopband_db)
127    }
128
129    /// The transmission parameters this modulator was built with.
130    pub fn params(&self) -> DvbTFrameParams {
131        self.params
132    }
133
134    /// Modulates `payload` (the MPEG-TS payload bytes) into one conformant,
135    /// preamble-less DVB-T frame: TS packetization + energy dispersal, the DVB-T
136    /// payload FEC, Figure-9a mapping through the four-phase scattered-pilot grid,
137    /// and the TPS word DBPSK-woven onto the 17 TPS carriers across the symbols.
138    /// The frame spans `max(payload symbols, 68)` OFDM symbols so a full TPS block
139    /// is present.
140    ///
141    /// A short payload that does not fill the frame is **stuffed with MPEG-2 null
142    /// packets** (PID `0x1FFF`) — §4.4 ("all symbols contain data") and §4.3.1
143    /// (randomization stays active with no program input): a compliant DVB-T
144    /// signal never leaves data carriers zeroed. The RX trims the recovered
145    /// payload back to `payload_len`, so the stuffing is transparent.
146    ///
147    /// Stuffing stops at the largest packet count whose coded stream still
148    /// **fits** ([`dvb_t_frame_fill`](crate::waveform::dvb_t::dvb_t_frame_fill)),
149    /// and the carriers past it repeat the coded stream's head. Nothing is
150    /// truncated, so a receiver reconstructing what was sent — the gate behind
151    /// [`DvbTFrameDemod::with_error_rates`](crate::demodulate::DvbTFrameDemod::with_error_rates)
152    /// — never asks its decoder for bits that never went on air. (Exact-fit with
153    /// no stuffing at all is a super-frame property — §4.7, Table 16 — handled by
154    /// the super-frame path, not here.)
155    pub fn modulate(&self, payload: &[u8]) -> DvbTFrame {
156        let params = self.params;
157        let cache = CodecCache::new();
158        let base = params.config();
159        let cp_len = base.carrier_plan.cp_len();
160        let n_fft = DVB_T_N_FFT;
161        let sps = n_fft + cp_len;
162        let vbits = params.constellation().bits_per_symbol();
163        let bits_per_sym = DVB_T_DATA_CARRIERS * vbits;
164
165        // 1. TS-packetize the real payload; decide the frame's symbol count from
166        //    it, padded to a full 68-symbol TPS block.
167        let mut ts = ts_packetize(payload);
168        let n_real_packets = ts.len() / TS_PACKET_LEN;
169        let payload_syms = symbols_for_coded_bits(
170            &base,
171            params.constellation(),
172            dvb_t_coded_bits_with(params, n_real_packets, &cache),
173        );
174        let n_symbols = payload_syms.max(TPS_SYMBOLS_PER_FRAME);
175
176        // 2. Stuff null packets up to the largest count whose coded stream still
177        //    FITS the frame's data carriers (the shared rule — see
178        //    `dvb_t_frame_fill`), then apply energy dispersal to the whole
179        //    (payload + null) stream.
180        let fill = dvb_t_frame_fill_with(params, n_real_packets.max(1), n_symbols, &cache);
181        // `n_symbols` was derived from the payload's own coded length, so the
182        // payload always fits and "largest that fits" never drops a real packet.
183        debug_assert!(
184            fill.n_ts_packets >= n_real_packets,
185            "frame filling must not drop real payload packets"
186        );
187        debug_assert_eq!(fill.capacity_bits, n_symbols * bits_per_sym);
188        ts_stuff_null_packets(&mut ts, fill.n_ts_packets);
189        ts_energy_disperse(&mut ts);
190
191        // 3. Payload FEC: RS(204,188) + K=7 conv + Forney interleaver. No extra
192        //    scrambler here — energy dispersal was applied at the TS layer.
193        let mut coded_bits = encode_chain(
194            &ts,
195            CrcKind::None, // RS(204,188) is the payload protection; TS carries its own framing
196            DVB_T_FRAME_OUTER,
197            params.inner(),
198            DVB_T_FRAME_OUTER_IL,
199            InterleaverKind::None,
200            ScramblerKind::None,
201            ScramblerPos::BeforeOuterFec,
202            0,
203            &cache,
204        );
205        debug_assert_eq!(coded_bits.len(), fill.coded_bits);
206
207        // 3b. Fill the carriers the coded stream leaves over by repeating its
208        //     head. §4.4 wants every data carrier modulated, and the per-packet
209        //     coded step and the frame's capacity coincide at no packet count, so
210        //     something has to cover the remainder — but nothing may be
211        //     truncated, because a receiver measuring the link re-encodes what it
212        //     recovered and would then be comparing against bits that were never
213        //     sent.
214        //
215        //     A REPEAT rather than zeros, because energy dispersal is applied at
216        //     the TS layer ahead of the FEC: the coded stream is already
217        //     whitened, so repeating any of it stays whitened. Zeros would not —
218        //     at QPSK r1/2 the remainder runs past 1512 bits, so a zero fill
219        //     would put an entire OFDM symbol on one constellation point.
220        //
221        //     These bits are never decoded: the receiver's block plan ends at
222        //     `fill.coded_bits`, which is where the repeat begins.
223        let filler = fill.filler_bits();
224        debug_assert!(
225            filler < coded_bits.len(),
226            "the remainder is under one packet's coded step, far below a frame"
227        );
228        coded_bits.extend_from_within(..filler);
229        debug_assert_eq!(coded_bits.len(), fill.capacity_bits);
230
231        // 4. Map symbols: data via the scattered grid + DVB-T constellation, then
232        //    overwrite the TPS carriers with the DBPSK cells for that symbol.
233        let mut mapper = ScatteredPilotMapper::new(params.guard());
234        let mut tps_enc = TpsEncoder::new();
235        let tps_block = params.tps_word().pack();
236        let tps_bins = tps_carrier_bins();
237
238        let mut ifft = IfftBlock::new(n_fft);
239        let mut cp_insert = CyclicPrefixInsert::new(n_fft, cp_len);
240        let mut data_syms = vec![C32::default(); DVB_T_DATA_CARRIERS];
241        let mut freq = vec![C32::default(); n_fft];
242        let mut time = vec![C32::default(); n_fft];
243        let mut iq = vec![C32::default(); n_symbols * sps];
244
245        for s in 0..n_symbols {
246            for (c, slot) in data_syms.iter_mut().enumerate() {
247                // `coded_bits` is exactly `capacity_bits` long after the filler,
248                // so every carrier has bits and none can be left zeroed — no
249                // bounds arm here, and none reachable.
250                let bit_base = s * bits_per_sym + c * vbits;
251                *slot =
252                    dvb_t_map_symbol(&coded_bits[bit_base..bit_base + vbits]).expect("DVB-T order");
253            }
254            mapper.map_symbol(&data_syms, &mut freq);
255            let tps_bit = tps_block[s % TPS_SYMBOLS_PER_FRAME];
256            let cells = tps_enc.next_symbol(tps_bit);
257            for (&bin, &cell) in tps_bins.iter().zip(cells.iter()) {
258                freq[bin] = cell;
259            }
260            if (s + 1) % TPS_SYMBOLS_PER_FRAME == 0 {
261                tps_enc.reset();
262            }
263            ifft.process(&freq, &mut time);
264            cp_insert.process(&time, &mut iq[s * sps..(s + 1) * sps]);
265        }
266
267        // Optional TX symbol windowing. DVB-T is preamble-less, so every symbol
268        // is a CP-bearing OFDM symbol and every one is tapered; the taper touches
269        // only guard samples, leaving the continual/scattered/TPS pilots intact.
270        if self.window_roll_off > 0 {
271            let mut win = SymbolWindow::new(sps, self.window_roll_off);
272            for s in 0..n_symbols {
273                let symbol: Vec<C32> = iq[s * sps..(s + 1) * sps].to_vec();
274                win.process(&symbol, &mut iq[s * sps..(s + 1) * sps]);
275            }
276        }
277
278        // Optional TX baseband low-pass, last and across the whole frame: it is
279        // a spectral filter spanning symbol boundaries, not a per-symbol taper.
280        // Same-length and group-delay-compensated, so the symbol grid a
281        // guard-interval receiver acquires is unmoved.
282        if let Some(lowpass) = self.tx_lowpass {
283            lowpass.apply(&mut iq);
284        }
285
286        DvbTFrame {
287            iq,
288            n_symbols,
289            samples_per_symbol: sps,
290        }
291    }
292}