orion-sdr 0.0.45

DSP/SDR block library targeting HF-to-EHF, satellites, and Python bindings. Roadmap inside.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
// Copyright (c) 2026 G & R Associates LLC
// SPDX-License-Identifier: MIT OR Apache-2.0

// src/demodulate/ofdm_frame.rs
//
// The OFDM frame (MAC-layer) demodulator: the exact inverse of
// `modulate::ofdm_frame`. This release provides the *batch* path
// [`demodulate_frame`] — a frame at a KNOWN start (no acquisition/streaming
// yet; that is the next release). It runs the concatenated COFDM decode chain:
//
//   IQ → soft-demap (LLRs) → inner-deinterleave (LLR) → inner-decode →
//        outer-deinterleave (byte) → outer-decode → descramble → strip CRC
//
// The header is decoded first with the fixed built-in scheme (BPSK + rate-1/2
// LDPC) to recover `mcs_index`/`payload_len`/`sequence_num`/`flags`/seed, then
// the payload is decoded at the MCS the header selected.

use crate::core::Block;
use crate::demodulate::ofdm::{
    EqualizerMethod, OfdmDemod, OfdmEqualizer, OfdmRxFrame, OfdmSoftDemod,
};
use crate::dsp::Rotator;
use crate::fec::{
    BlockInterleaver, CrcKind, DecodeRule, FrameMetadata, FramePacket, HeaderFormat, InnerFec,
    InterleaverKind, OuterFec, RxError, ScramblerKind, ScramblerPos, viterbi_decode_soft,
};
use crate::modulate::ofdm::{ConstellationOrder, OfdmConfig};
use crate::modulate::ofdm_frame::{
    BCH_INFO_BITS, BlockPlan, CodecCache, HEADER_CONSTELLATION, HEADER_FIELD_BYTES, HEADER_LDPC,
    McsTable, bits_to_bytes, block_plan, build_scrambler, bytes_to_bits, check_and_strip_crc,
    symbol_config, symbols_for_coded_bits,
};
use crate::multicarrier::{CarrierGrid, CyclicPrefixRemove, FftBlock, GridExtract};
use crate::sync::{OfdmPreamble, ofdm_sync};
use num_complex::Complex32 as C32;
use std::sync::Arc;

/// Soft-demaps `n_symbols` OFDM symbols starting at `iq[0]` into a flat LLR
/// vector (one `f32` per coded bit, `+ ⇒ bit 0`). Returns `None` if `iq` is
/// too short.
///
/// With `equalizer = None` this is the flat-channel path (`OfdmDemod →
/// OfdmSoftDemod`, no per-bin correction) used by the batch entry point. With
/// an equalizer whose channel estimate is already set (from a training
/// symbol), it runs the full `CyclicPrefixRemove → FftBlock → OfdmEqualizer →
/// GridExtract → OfdmSoftDemod` chain, correcting a frequency-selective
/// channel — the streaming receiver's path.
fn soft_demap(
    base: &OfdmConfig,
    constellation: ConstellationOrder,
    iq: &[C32],
    n_symbols: usize,
    equalizer: Option<&mut OfdmEqualizer>,
) -> Option<Vec<f32>> {
    let cfg = symbol_config(base, constellation);
    let sps = cfg.samples_per_ofdm_symbol();
    if iq.len() < n_symbols * sps {
        return None;
    }
    let n_data = cfg.carrier_plan.data_carriers().len();
    let bps = cfg.bits_per_ofdm_symbol();
    let mut soft = OfdmSoftDemod::new(&cfg);
    let mut symbols = vec![C32::default(); n_data];
    let mut llrs = vec![0.0f32; n_symbols * bps];

    match equalizer {
        None => {
            let mut demod = OfdmDemod::new(&cfg);
            let mut in_off = 0;
            let mut out_off = 0;
            for _ in 0..n_symbols {
                let dw = demod.process(&iq[in_off..], &mut symbols);
                if dw.out_written != n_data {
                    return None;
                }
                let sw = soft.process(&symbols, &mut llrs[out_off..out_off + bps]);
                if sw.out_written != bps {
                    return None;
                }
                in_off += sps;
                out_off += bps;
            }
        }
        Some(eq) => {
            let n_fft = cfg.carrier_plan.n_fft();
            let cp_len = cfg.carrier_plan.cp_len();
            let grid = CarrierGrid::from_plan(&cfg.carrier_plan);
            let mut cp_remove = CyclicPrefixRemove::new(n_fft, cp_len);
            let mut fft = FftBlock::new(n_fft);
            let mut grid_extract = GridExtract::new(grid);
            let mut time = vec![C32::default(); n_fft];
            let mut freq = vec![C32::default(); n_fft];
            let mut equalized = vec![C32::default(); n_fft];
            let mut in_off = 0;
            let mut out_off = 0;
            for _ in 0..n_symbols {
                if cp_remove.process(&iq[in_off..], &mut time).out_written != n_fft {
                    return None;
                }
                if fft.process(&time, &mut freq).out_written != n_fft {
                    return None;
                }
                if eq.process(&freq, &mut equalized).out_written != n_fft {
                    return None;
                }
                if grid_extract.process(&equalized, &mut symbols).out_written != n_data {
                    return None;
                }
                let sw = soft.process(&symbols, &mut llrs[out_off..out_off + bps]);
                if sw.out_written != bps {
                    return None;
                }
                in_off += sps;
                out_off += bps;
            }
        }
    }
    Some(llrs)
}

/// Inverse of the block interleaver, in the LLR (`f32`) domain.
fn deinterleave_llrs(il: InterleaverKind, llrs: &[f32]) -> Vec<f32> {
    match il {
        InterleaverKind::None => llrs.to_vec(),
        InterleaverKind::Block { rows, cols } => {
            let block = rows * cols;
            let bi = BlockInterleaver::new(rows, cols);
            let mut out = Vec::with_capacity(llrs.len());
            let mut restored = vec![0.0f32; block]; // reused across full chunks
            for chunk in llrs.chunks(block) {
                if chunk.len() < block {
                    out.extend_from_slice(chunk);
                    continue;
                }
                bi.deinterleave(chunk, &mut restored);
                out.extend_from_slice(&restored);
            }
            out
        }
    }
}

/// Inverse of the block interleaver, in the hard-bit (`u8`) domain.
fn deinterleave_bits(il: InterleaverKind, bits: &[u8]) -> Vec<u8> {
    match il {
        InterleaverKind::None => bits.to_vec(),
        InterleaverKind::Block { rows, cols } => {
            let block = rows * cols;
            let bi = BlockInterleaver::new(rows, cols);
            let mut out = Vec::with_capacity(bits.len());
            let mut restored = vec![0u8; block]; // reused across full chunks
            for chunk in bits.chunks(block) {
                if chunk.len() < block {
                    out.extend_from_slice(chunk);
                    continue;
                }
                bi.deinterleave(chunk, &mut restored);
                out.extend_from_slice(&restored);
            }
            out
        }
    }
}

/// Inner-decodes an LLR stream into hard info bits (mirroring `inner_encode`).
/// `info_len` is the number of information bits the inner code protects (needed
/// by the convolutional Viterbi, which is variable-rate). Returns the info bits
/// and whether every block converged.
fn inner_decode(
    inner: InnerFec,
    coded_llrs: &[f32],
    info_len: usize,
    cache: &CodecCache,
    ldpc_rule: DecodeRule,
) -> (Vec<u8>, bool) {
    match inner {
        InnerFec::None => {
            // Hard-decide the LLRs directly.
            (
                coded_llrs.iter().map(|&l| u8::from(l <= 0.0)).collect(),
                true,
            )
        }
        InnerFec::Ldpc(code) => {
            let ldpc = cache.ldpc(code);
            let n = ldpc.n();
            let mut info = Vec::new();
            let mut all_ok = true;
            for chunk in coded_llrs.chunks(n) {
                if chunk.len() < n {
                    all_ok = false;
                    break;
                }
                let (msg, unsat) = ldpc.decode_soft_with(chunk, 50, ldpc_rule);
                if unsat != 0 {
                    all_ok = false;
                }
                info.extend_from_slice(&msg);
            }
            (info, all_ok)
        }
        InnerFec::Convolutional { rate } => {
            // Soft Viterbi over the whole block; the outer code / CRC below
            // decides success, so no per-block convergence flag here.
            let info = viterbi_decode_soft(coded_llrs, info_len, rate);
            (info, true)
        }
    }
}

/// Outer-decodes hard bits into message bits, fragmenting into shortened-BCH
/// codeword blocks (mirroring `outer_encode`). Returns the message bits and
/// whether every block decoded.
fn outer_decode(outer: OuterFec, coded_bits: &[u8], cache: &CodecCache) -> (Vec<u8>, bool) {
    match outer {
        OuterFec::None => (coded_bits.to_vec(), true),
        OuterFec::Bch { t } => {
            let code = cache.bch(t, BCH_INFO_BITS);
            let n = code.n();
            let mut msg = Vec::new();
            let mut all_ok = true;
            for chunk in coded_bits.chunks(n) {
                if chunk.len() < n {
                    all_ok = false;
                    break;
                }
                match code.decode(chunk) {
                    Ok(block) => msg.extend_from_slice(&block),
                    Err(_) => {
                        all_ok = false;
                        // Fall back to the systematic prefix so downstream CRC
                        // can still run (and fail) rather than aborting here.
                        msg.extend_from_slice(&chunk[..code.k()]);
                    }
                }
            }
            (msg, all_ok)
        }
        OuterFec::ReedSolomon { n, n_parity } => {
            // Byte-domain: pack coded bits to bytes, decode each n-byte codeword.
            let rs = cache.rs(n, n_parity);
            let coded_bytes = bits_to_bytes(coded_bits);
            let mut msg_bytes = Vec::new();
            let mut all_ok = true;
            for chunk in coded_bytes.chunks(n) {
                if chunk.len() < n {
                    all_ok = false;
                    break;
                }
                match rs.decode(chunk) {
                    Ok(block) => msg_bytes.extend_from_slice(&block),
                    Err(_) => {
                        all_ok = false;
                        msg_bytes.extend_from_slice(&chunk[..rs.k()]);
                    }
                }
            }
            (bytes_to_bits(&msg_bytes), all_ok)
        }
    }
}

/// Decodes one logical block's coded LLRs back to its info bytes, checking the
/// CRC. Returns `Ok((bytes, crc_ok))` or an error if the structure is invalid.
#[allow(clippy::too_many_arguments)]
fn decode_chain(
    coded_llrs: &[f32],
    plan: &BlockPlan,
    crc: CrcKind,
    outer: OuterFec,
    inner: InnerFec,
    outer_il: InterleaverKind,
    inner_il: InterleaverKind,
    scrambler: ScramblerKind,
    scrambler_pos: ScramblerPos,
    per_frame_seed: u32,
    cache: &CodecCache,
    ldpc_rule: DecodeRule,
) -> Result<(Vec<u8>, bool), RxError> {
    // 1. Trim to the exact coded-bit count, then invert the after-inner
    //    scramble (bit domain) if configured.
    let mut llrs = coded_llrs.to_vec();
    llrs.truncate(plan.coded_bits);

    // After-inner scrambling was applied to hard bits; to invert in the LLR
    // domain we flip the LLR sign where the PN bit is 1 (XOR by 1 negates the
    // bit ⇒ negate the LLR).
    let sc = build_scrambler(scrambler, per_frame_seed);
    if scrambler_pos == ScramblerPos::AfterInnerFec
        && let Some(ref s) = sc
    {
        apply_pn_to_llrs(s, &mut llrs);
    }

    // 2. Inner deinterleave (LLR), then inner decode.
    let inner_de = deinterleave_llrs(inner_il, &llrs);
    let inner_de = &inner_de[..plan.inner_coded_bits.min(inner_de.len())];
    let (mut outer_il_bits, inner_ok) =
        inner_decode(inner, inner_de, plan.outer_il_bits, cache, ldpc_rule);
    outer_il_bits.truncate(plan.outer_il_bits);

    // 3. Outer deinterleave (byte/bit domain), then outer decode.
    let outer_de = deinterleave_bits(outer_il, &outer_il_bits);
    let outer_de = &outer_de[..plan.outer_coded_bits.min(outer_de.len())];
    let (mut framed_bits, outer_ok) = outer_decode(outer, outer_de, cache);
    framed_bits.truncate(plan.framed_bytes * 8);

    if framed_bits.len() < plan.framed_bytes * 8 {
        return Err(RxError::MalformedHeader);
    }
    let mut framed = bits_to_bytes(&framed_bits);

    // 4. Invert the before-outer scramble (byte domain).
    if scrambler_pos == ScramblerPos::BeforeOuterFec
        && let Some(ref s) = sc
    {
        s.scramble(&mut framed);
    }

    // 5. Strip and check the CRC.
    let (bytes, crc_ok) = check_and_strip_crc(crc, &framed).ok_or(RxError::MalformedHeader)?;
    Ok((bytes, crc_ok && inner_ok && outer_ok))
}

/// Applies a PN sequence to LLRs by negating each LLR whose PN bit is 1.
fn apply_pn_to_llrs(s: &crate::fec::PnScrambler, llrs: &mut [f32]) {
    // The scrambler XORs bits; recover the PN bit-stream by scrambling a
    // zeroed byte buffer of the right length, then negate LLRs at PN==1.
    let n_bytes = llrs.len().div_ceil(8);
    let mut pn = vec![0u8; n_bytes];
    s.scramble(&mut pn);
    let pn_bits = bytes_to_bits(&pn);
    for (l, &p) in llrs.iter_mut().zip(pn_bits.iter()) {
        if p != 0 {
            *l = -*l;
        }
    }
}

/// Distinguishes "waiting for more samples" from a genuine decode failure, so
/// the streaming receiver can hold a partial frame rather than mis-report it.
enum BodyError {
    /// Not enough buffered samples for the header or the (now-known-length)
    /// payload — hold and retry after more input.
    Incomplete,
    /// A real decode failure (bad header CRC, payload CRC, or FEC).
    Failed(RxError),
}

/// Decodes a frame body (header + payload) from `iq[0]` — the first sample
/// AFTER the preamble+training, already CFO-corrected. When
/// `channel_estimate` is `Some(n_fft freq bins)` the soft-demap equalizes each
/// symbol against it (multipath); `None` is the flat-channel path.
///
/// Returns the recovered [`FramePacket`] and the number of IQ samples the
/// header+payload occupied (so a streaming caller can advance its buffer), or a
/// [`BodyError`] distinguishing "incomplete" from a genuine failure.
fn decode_frame_body(
    cfg: &OfdmConfig,
    mcs_table: &McsTable,
    iq: &[C32],
    channel_estimate: Option<&[C32]>,
    cache: &CodecCache,
) -> Result<(FramePacket, usize), BodyError> {
    let mut cursor = 0usize;

    // Builds a fresh equalizer for `constellation` carrying the shared channel
    // estimate, or `None` for the flat path.
    let make_eq = |constellation: ConstellationOrder| -> Option<OfdmEqualizer> {
        channel_estimate.map(|est| {
            let symcfg = symbol_config(cfg, constellation);
            let mut eq = OfdmEqualizer::new(&symcfg, EqualizerMethod::TrainingSymbolHold);
            eq.estimate_from_training_symbol(est);
            eq
        })
    };

    // 1. Header (unless NoHeader).
    let (metadata, per_frame_seed, payload_len) = if cfg.header_format == HeaderFormat::OrionSdr {
        let hplan = block_plan(
            HEADER_FIELD_BYTES,
            cfg.header_crc,
            OuterFec::None,
            InnerFec::Ldpc(HEADER_LDPC),
            InterleaverKind::None,
            InterleaverKind::None,
            cache,
        );
        let n_sym = symbols_for_coded_bits(cfg, HEADER_CONSTELLATION, hplan.coded_bits);
        let mut eq = make_eq(HEADER_CONSTELLATION);
        // Too few samples for the header ⇒ incomplete, not malformed.
        let llrs = soft_demap(cfg, HEADER_CONSTELLATION, &iq[cursor..], n_sym, eq.as_mut())
            .ok_or(BodyError::Incomplete)?;
        let (fields, ok) = decode_chain(
            &llrs,
            &hplan,
            cfg.header_crc,
            OuterFec::None,
            InnerFec::Ldpc(HEADER_LDPC),
            InterleaverKind::None,
            InterleaverKind::None,
            ScramblerKind::None,
            ScramblerPos::BeforeOuterFec,
            0,
            cache,
            // The header is decoded first to learn the MCS and must be as robust
            // as possible, so it always uses exact sum-product regardless of the
            // payload's configured rule.
            DecodeRule::SumProduct,
        )
        .map_err(BodyError::Failed)?;
        if !ok {
            return Err(BodyError::Failed(RxError::HeaderCrcMismatch));
        }
        if fields.len() < HEADER_FIELD_BYTES {
            return Err(BodyError::Failed(RxError::MalformedHeader));
        }
        let mcs_index = fields[0];
        let payload_len = u32::from_be_bytes([fields[1], fields[2], fields[3], fields[4]]) as usize;
        let sequence_num = u32::from_be_bytes([fields[5], fields[6], fields[7], fields[8]]);
        let flags = fields[9];
        let seed = u32::from_be_bytes([fields[10], fields[11], fields[12], fields[13]]);

        let sps = symbol_config(cfg, HEADER_CONSTELLATION).samples_per_ofdm_symbol();
        cursor += n_sym * sps;
        (
            FrameMetadata {
                sequence_num,
                mcs_index,
                flags,
            },
            seed,
            payload_len,
        )
    } else {
        // NoHeader: the caller must convey MCS/length out-of-band. Not
        // supported by this entry point yet.
        return Err(BodyError::Failed(RxError::MalformedHeader));
    };

    // 2. Payload, decoded per the MCS the header selected.
    let mcs = mcs_table
        .get(metadata.mcs_index)
        .ok_or(BodyError::Failed(RxError::MalformedHeader))?;
    let pplan = block_plan(
        payload_len,
        cfg.payload_crc,
        mcs.outer_fec,
        mcs.inner_fec,
        cfg.outer_interleaver,
        cfg.inner_interleaver,
        cache,
    );
    let n_sym = symbols_for_coded_bits(cfg, mcs.constellation, pplan.coded_bits);
    let mut eq = make_eq(mcs.constellation);
    // Too few samples for the (now-known-length) payload ⇒ incomplete.
    let llrs = soft_demap(cfg, mcs.constellation, &iq[cursor..], n_sym, eq.as_mut())
        .ok_or(BodyError::Incomplete)?;
    let (bytes, ok) = decode_chain(
        &llrs,
        &pplan,
        cfg.payload_crc,
        mcs.outer_fec,
        mcs.inner_fec,
        cfg.outer_interleaver,
        cfg.inner_interleaver,
        cfg.scrambler,
        cfg.scrambler_pos,
        per_frame_seed,
        cache,
        // The payload honors the configured LDPC decode rule (opt-in min-sum).
        cfg.ldpc_decode_rule,
    )
    .map_err(BodyError::Failed)?;
    if !ok {
        return Err(BodyError::Failed(RxError::CrcMismatch));
    }
    let payload_sps = symbol_config(cfg, mcs.constellation).samples_per_ofdm_symbol();
    cursor += n_sym * payload_sps;
    // Trim to the declared payload length (coding blocks are zero-padded).
    let payload = bytes
        .get(..payload_len)
        .map(|s| s.to_vec())
        .unwrap_or(bytes);

    Ok((FramePacket { metadata, payload }, cursor))
}

/// Batch-demodulates a frame at a KNOWN start (`iq[0]` is the first sample
/// AFTER the preamble+training — the caller has already synchronized and, if
/// needed, equalized). Returns the recovered [`FramePacket`] or an [`RxError`].
///
/// This is the flat-channel, known-start entry point; the streaming
/// [`OfdmFrameStreamDemod`] runs `ofdm_sync`, CFO correction, and training-
/// symbol equalization for unknown start / CFO / multipath.
///
/// `cache` is an optional caller-owned [`CodecCache`]. Pass `Some(&cache)` when
/// decoding many known-start frames in a loop to build each FEC code once across
/// the whole batch (the `Ldpc` construction is milliseconds); pass `None` for a
/// one-shot decode, which builds a throwaway cache for that call (the header and
/// payload codes are still each built at most once within the call). The
/// decode output is identical either way — the cache only affects speed.
pub fn demodulate_frame(
    cfg: &OfdmConfig,
    mcs_table: &McsTable,
    iq: &[C32],
    cache: Option<&CodecCache>,
) -> Result<FramePacket, RxError> {
    // Borrow the caller's cache, or stand up a per-call one when none is given.
    let owned;
    let cache = match cache {
        Some(c) => c,
        None => {
            owned = CodecCache::new();
            &owned
        }
    };
    decode_frame_body(cfg, mcs_table, iq, None, cache)
        .map(|(frame, _)| frame)
        .map_err(|e| match e {
            // A batch caller has no "wait for more" option; a truncated buffer
            // is a malformed input here.
            BodyError::Incomplete => RxError::MalformedHeader,
            BodyError::Failed(err) => err,
        })
}

// ── Streaming receiver ─────────────────────────────────────────────────────

/// A successfully received frame plus its per-frame RX diagnostics.
#[derive(Debug, Clone, PartialEq)]
pub struct RxFrame {
    pub packet: FramePacket,
    /// Acquisition/quality diagnostics: `cfo_hz` and `timing_offset_samples`
    /// are populated by the streaming receiver; `evm_db`/`channel_mse` are
    /// left `None` here (measured by the per-symbol pipeline, not the frame
    /// layer).
    pub diagnostics: OfdmRxFrame,
}

/// Streaming OFDM frame receiver: push raw IQ with [`feed`](Self::feed), poll
/// completed frames (or typed errors). Mirrors `Ft8StreamDecoder`'s
/// accumulate-and-drain shape.
///
/// Each `feed` accumulates samples, searches the buffer for a preamble via
/// `ofdm_sync`, and — for a candidate with enough buffered samples — corrects
/// CFO (`Rotator`), estimates the channel from the training symbol
/// (`OfdmEqualizer`), decodes the frame, and drains its samples from the
/// buffer, looping to drain multiple frames. A frame whose payload has not
/// fully arrived is held until a later `feed` completes it.
pub struct OfdmFrameStreamDemod {
    cfg: OfdmConfig,
    mcs_table: McsTable,
    preamble: OfdmPreamble,
    fs: f32,
    buf: Vec<C32>,
    /// Minimum sync score to accept a candidate.
    score_threshold: f32,
    /// FEC code cache, warmed across the frames this receiver decodes (see
    /// [`CodecCache`]). Held behind `Arc` so it can be shared with a paired
    /// modulator.
    cache: Arc<CodecCache>,
}

impl OfdmFrameStreamDemod {
    /// Creates a streaming receiver with a fresh, private [`CodecCache`]; use
    /// [`with_cache`](Self::with_cache) to share one with a modulator.
    pub fn new(cfg: OfdmConfig, mcs_table: McsTable, preamble: OfdmPreamble) -> Self {
        Self::with_cache(cfg, mcs_table, preamble, Arc::new(CodecCache::new()))
    }

    /// Like [`new`](Self::new), but reuses the caller-provided `cache` so a
    /// modulator/demodulator pair sharing one `Arc<CodecCache>` builds each FEC
    /// code only once between them.
    pub fn with_cache(
        cfg: OfdmConfig,
        mcs_table: McsTable,
        preamble: OfdmPreamble,
        cache: Arc<CodecCache>,
    ) -> Self {
        let fs = cfg.fs;
        Self {
            cfg,
            mcs_table,
            preamble,
            fs,
            buf: Vec::new(),
            score_threshold: 0.5,
            cache,
        }
    }

    /// Overrides the sync-score acceptance threshold (default 0.5).
    pub fn with_score_threshold(mut self, t: f32) -> Self {
        self.score_threshold = t;
        self
    }

    /// Accumulated (not-yet-consumed) sample count.
    pub fn len(&self) -> usize {
        self.buf.len()
    }

    pub fn is_empty(&self) -> bool {
        self.buf.is_empty()
    }

    /// Read-only view of the accumulated IQ buffer.
    pub fn view_buf(&self) -> &[C32] {
        &self.buf
    }

    /// Discards all accumulated samples.
    pub fn clear(&mut self) {
        self.buf.clear();
    }

    /// Feeds IQ samples and returns any frames (or errors) that completed.
    pub fn feed(&mut self, iq: &[C32]) -> Vec<Result<RxFrame, RxError>> {
        self.buf.extend_from_slice(iq);
        self.drain()
    }

    /// Runs a final decode pass over the residual buffer (e.g. at end of
    /// stream). Same semantics as `feed` with no new samples.
    pub fn flush(&mut self) -> Vec<Result<RxFrame, RxError>> {
        self.drain()
    }

    /// Repeatedly locates and decodes frames from the front of the buffer,
    /// consuming their samples, until no further complete frame is present.
    fn drain(&mut self) -> Vec<Result<RxFrame, RxError>> {
        let mut out = Vec::new();
        while let FrameStep::Decoded(result, consume_to) = self.try_one_frame() {
            self.buf.drain(..consume_to);
            out.push(result);
        }
        out
    }

    /// Attempts to decode one frame at the front of the buffer.
    fn try_one_frame(&mut self) -> FrameStep {
        let n_fft = self.cfg.carrier_plan.n_fft();
        let cp_len = self.cfg.carrier_plan.cp_len();
        let pre_len = self.preamble.total_len();

        // Need at least a full preamble plus one header's worth before a search
        // can yield a decodable frame.
        if self.buf.len() < pre_len + (n_fft + cp_len) {
            return FrameStep::NeedMore;
        }

        let sync = ofdm_sync(&self.buf, self.fs, &self.preamble, 0, self.buf.len());
        let Some(best) = sync.into_iter().find(|r| r.score >= self.score_threshold) else {
            return FrameStep::NeedMore;
        };

        // Total CFO = fractional + integer·subcarrier-spacing.
        let subcarrier_spacing = self.fs / n_fft as f32;
        let total_cfo = best.cfo_hz + best.integer_cfo_bins as f32 * subcarrier_spacing;

        // CFO-correct from the preamble start onward into a scratch buffer.
        let region = &self.buf[best.start_sample..];
        let mut corrected = vec![C32::default(); region.len()];
        let mut rot = Rotator::new(-total_cfo, self.fs);
        rot.rotate_block(region, &mut corrected);

        // Channel estimate from the training symbol (if the preamble carries
        // one), located just after the S&C repeats.
        let channel_estimate = self.estimate_channel(&corrected);

        // The frame body begins right after the whole preamble (S&C + training).
        if corrected.len() < pre_len {
            return FrameStep::NeedMore;
        }
        let body = &corrected[pre_len..];

        match decode_frame_body(
            &self.cfg,
            &self.mcs_table,
            body,
            channel_estimate.as_deref(),
            &self.cache,
        ) {
            Ok((packet, body_samples)) => {
                let diagnostics = OfdmRxFrame {
                    bits: Vec::new(),
                    num_symbols: 0,
                    evm_db: None,
                    cfo_hz: Some(total_cfo),
                    timing_offset_samples: Some(best.start_sample as i32),
                    channel_mse: None,
                };
                let consume_to = best.start_sample + pre_len + body_samples;
                if consume_to > self.buf.len() {
                    // Shouldn't happen (decode succeeded), but guard the drain.
                    return FrameStep::NeedMore;
                }
                FrameStep::Decoded(
                    Ok(RxFrame {
                        packet,
                        diagnostics,
                    }),
                    consume_to,
                )
            }
            // The header or payload has not fully arrived yet — hold and retry
            // when more samples are fed. No buffer is consumed.
            Err(BodyError::Incomplete) => FrameStep::NeedMore,
            // A genuine decode failure on a fully-present frame: report it and
            // advance just past this preamble so the search continues past it
            // (avoids re-locking the same corrupt occurrence forever).
            Err(BodyError::Failed(e)) => {
                let skip = (best.start_sample + pre_len).min(self.buf.len());
                FrameStep::Decoded(Err(e), skip)
            }
        }
    }

    /// Estimates the per-bin channel from the training symbol in `corrected`
    /// (CFO-corrected, preamble-start-relative). Returns `None` if the preamble
    /// carries no training symbol.
    fn estimate_channel(&self, corrected: &[C32]) -> Option<Vec<C32>> {
        let training = self.preamble.training_symbol?;
        let n_fft = training.n_fft;
        let cp_len = training.cp_len;
        let training_start = self.preamble.num_repeats * self.preamble.repeat_len;
        let end = training_start + n_fft + cp_len;
        if corrected.len() < end {
            return None;
        }
        let mut cp_remove = CyclicPrefixRemove::new(n_fft, cp_len);
        let mut fft = FftBlock::new(n_fft);
        let mut time = vec![C32::default(); n_fft];
        if cp_remove
            .process(&corrected[training_start..end], &mut time)
            .out_written
            != n_fft
        {
            return None;
        }
        let mut freq = vec![C32::default(); n_fft];
        if fft.process(&time, &mut freq).out_written != n_fft {
            return None;
        }
        Some(freq)
    }
}

/// One step of the streaming drain loop.
enum FrameStep {
    /// A frame (or error) decoded; consume the buffer up to this index.
    Decoded(Result<RxFrame, RxError>, usize),
    /// Not enough buffered samples yet; wait for more.
    NeedMore,
}