orion-sdr 0.0.61

Composable SDR/DSP block library targeting HF-to-EHF: analog and single-carrier digital modes, FT8/FT4, PSK31, OFDM/COFDM, and DVB-T/NB-DVB-T, with Python bindings.
Documentation
// Copyright (c) 2026 G & R Associates LLC
// SPDX-License-Identifier: MIT OR Apache-2.0

// src/demodulate/dvb_t_super_frame.rs
//
// The conformant DVB-T OFDM SUPER-FRAME demodulator (ETSI EN 300 744 §4.4/§4.6):
// the inverse of `modulate::dvb_t_super_frame`. It decodes the four consecutive
// frames, checks the super-frame structure (frame-number sequence 0..3 and the
// alternating TPS sync word, implied by the recovered frame numbers), reassembles
// the 16-bit cell identifier from its two byte halves, and concatenates the four
// frames' payloads.
//
// A per-standard ORCHESTRATOR over the single-frame `DvbTFrameDemod`; it adds no
// new PHY, only the multi-frame sequencing and cross-frame checks. The optional
// integer-CFO correction is a construction-time flag delegated to each frame
// demod.

use super::dvb_t_frame::{DvbTFrameDemod, DvbTRxError};
use crate::modulate::dvb_t_super_frame::{DVB_T_FRAMES_PER_SUPER_FRAME, DvbTSuperFrameParams};
use crate::waveform::dvb_t::DVB_T_N_FFT;
use num_complex::Complex32 as C32;

/// The recovered contents of a DVB-T super-frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DvbTRxSuperFrame {
    /// The four frames' payloads, concatenated in order.
    pub payload: Vec<u8>,
    /// The 16-bit cell identifier, reassembled from the high byte (frames 1 & 3)
    /// and low byte (frames 2 & 4).
    pub cell_id: u16,
}

/// Errors from [`DvbTSuperFrameDemod::decode`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum DvbTRxSuperFrameError {
    /// A constituent frame failed to acquire or decode. Carries the frame index
    /// (0..3) and the underlying single-frame error.
    #[error("super-frame: frame {frame} failed: {source}")]
    Frame {
        frame: usize,
        #[source]
        source: DvbTRxError,
    },
    /// The recovered frame numbers were not the expected 0,1,2,3 sequence (the
    /// super-frame is misaligned or a TPS word was mis-decoded).
    #[error("super-frame: frame numbers out of sequence (got {got:?}, expected 0..3)")]
    FrameSequence {
        got: [u8; DVB_T_FRAMES_PER_SUPER_FRAME],
    },
    /// The buffer is too short to hold the whole super-frame.
    #[error("super-frame: too few samples for four frames")]
    Incomplete,
}

/// A conformant DVB-T super-frame demodulator: the multi-frame driver over
/// [`DvbTFrameDemod`]. Constructed with the super-frame's cold-start parameters;
/// [`decode`](Self::decode) recovers one four-frame super-frame per call.
///
/// Integer-CFO correction is a construction-time flag
/// ([`with_integer_cfo_correction`](Self::with_integer_cfo_correction)) delegated
/// to each constituent frame demod; off by default.
#[derive(Debug, Clone)]
pub struct DvbTSuperFrameDemod {
    params: DvbTSuperFrameParams,
    integer_cfo: bool,
    rx_window_backoff: usize,
}

impl DvbTSuperFrameDemod {
    /// Builds a super-frame demodulator with the given cold-start parameters.
    /// Integer-CFO correction is **off** by default.
    pub fn new(params: DvbTSuperFrameParams) -> Self {
        Self {
            params,
            integer_cfo: false,
            rx_window_backoff: 0,
        }
    }

    /// Enables (or disables) internal integer-CFO correction on every constituent
    /// frame — a link-constant knob set once at construction (see
    /// [`DvbTFrameDemod::with_integer_cfo_correction`]).
    pub fn with_integer_cfo_correction(mut self, on: bool) -> Self {
        self.integer_cfo = on;
        self
    }

    /// Sets the receiver FFT-window back-off on every constituent frame (see
    /// [`DvbTFrameDemod::with_rx_window_backoff`]). `0` (the default) is the
    /// standard CP-boundary window.
    pub fn with_rx_window_backoff(mut self, backoff: usize) -> Self {
        self.rx_window_backoff = backoff;
        self
    }

    /// The super-frame parameters this demodulator was built with.
    pub fn params(&self) -> DvbTSuperFrameParams {
        self.params
    }

    /// Whether internal integer-CFO correction is enabled.
    pub fn integer_cfo_correction(&self) -> bool {
        self.integer_cfo
    }

    /// The receiver FFT-window back-off in samples applied to every constituent
    /// frame (`0` = the standard CP-boundary window).
    pub fn rx_window_backoff(&self) -> usize {
        self.rx_window_backoff
    }

    /// Demodulates one conformant DVB-T super-frame from `iq`, starting at the
    /// first frame's first sample (each frame GI-acquires from its own cyclic
    /// prefix, so no preamble is needed). `symbols_per_frame` and
    /// `frame_payload_lens` come from the paired
    /// [`DvbTSuperFrameMod`](crate::modulate::DvbTSuperFrameMod)'s
    /// [`DvbTSuperFrame`].
    ///
    /// Verifies the frame-number sequence is `0,1,2,3` (which, via
    /// [`DvbTSuperFrameParams::frame`], also implies the correct alternating TPS
    /// sync word), reassembles the 16-bit cell id, and concatenates the payloads.
    pub fn decode(
        &self,
        iq: &[C32],
        symbols_per_frame: usize,
        frame_payload_lens: [usize; DVB_T_FRAMES_PER_SUPER_FRAME],
    ) -> Result<DvbTRxSuperFrame, DvbTRxSuperFrameError> {
        let params = self.params;
        let cp_len = params.frame(0).config().carrier_plan.cp_len();
        let sps = DVB_T_N_FFT + cp_len;
        let frame_samples = symbols_per_frame * sps;

        let mut payload = Vec::new();
        let mut frame_numbers = [0u8; DVB_T_FRAMES_PER_SUPER_FRAME];
        let mut cell_hi = 0u8;
        let mut cell_lo = 0u8;

        for f in 0..DVB_T_FRAMES_PER_SUPER_FRAME {
            // Each frame carries lead-in room for its own GI search: give the RX
            // the sub-buffer from this frame's start to the end (the single-frame
            // RX finds the CP boundary within the first symbol period).
            let start = f * frame_samples;
            let sub = iq.get(start..).ok_or(DvbTRxSuperFrameError::Incomplete)?;
            let rx = DvbTFrameDemod::new(params.frame(f as u8))
                .with_integer_cfo_correction(self.integer_cfo)
                .with_rx_window_backoff(self.rx_window_backoff)
                .decode(sub, symbols_per_frame, frame_payload_lens[f])
                .map_err(|source| DvbTRxSuperFrameError::Frame { frame: f, source })?;

            frame_numbers[f] = rx.tps.frame_number;
            // Reassemble the cell id from the byte the frame's parity carries.
            if f.is_multiple_of(2) {
                cell_hi = rx.tps.cell_id; // frames 1 & 3 → high byte
            } else {
                cell_lo = rx.tps.cell_id; // frames 2 & 4 → low byte
            }
            payload.extend_from_slice(&rx.payload);
        }

        // The super-frame's frame numbers must be exactly 0,1,2,3.
        if frame_numbers != [0, 1, 2, 3] {
            return Err(DvbTRxSuperFrameError::FrameSequence { got: frame_numbers });
        }

        let cell_id = ((cell_hi as u16) << 8) | cell_lo as u16;
        Ok(DvbTRxSuperFrame { payload, cell_id })
    }
}