Skip to main content

orion_sdr/sync/
ofdm_sync.rs

1// Copyright (c) 2025-2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/sync/ofdm_sync.rs
5//
6// Packet sync and fractional/integer CFO plus timing acquisition for OFDM,
7// via a Schmidl & Cox-style repeated-segment preamble (generic, not tied to
8// any standard's specific preamble design), optionally followed by a
9// dedicated training symbol for wide-range integer-CFO recovery.
10//
11// Fractional stage (Release E): a preamble of `num_repeats` identical
12// length-`repeat_len` complex segments is transmitted before the OFDM data
13// symbols. At a candidate start `d`, adjacent repeated segments are
14// correlated:
15//
16//   P(d) = Σ_{i=0}^{repeat_len-1} conj(r[d+i]) · r[d+i+repeat_len]
17//   R(d) = Σ_{i=0}^{repeat_len-1} |r[d+i+repeat_len]|²
18//
19// summed over all `num_repeats - 1` adjacent segment pairs. The normalized
20// timing metric `M(d) = |P(d)|² / R(d)²` plateaus near the true preamble
21// start; its peak gives coarse timing. The correlation phase at the peak
22// gives the fractional CFO: `cfo_hz = angle(P) / (2π · repeat_len / fs)`,
23// unambiguous only within ±½ the subcarrier spacing (±`fs / (2·repeat_len)`)
24// — larger offsets alias.
25//
26// Integer stage (Release F): a dedicated training symbol — one full
27// `n_fft`+CP OFDM symbol with a known value on every subcarrier bin —
28// follows the S&C preamble. After the fractional CFO/timing found above is
29// corrected, the training symbol is FFT'd and correlated against its known
30// frequency-domain pattern across candidate integer bin shifts; the shift
31// maximizing correlation is the integer CFO
32// (`integer_cfo_bins · fs / n_fft`). The same training symbol is reused by
33// Release G's channel estimator.
34
35use crate::core::Block;
36use crate::dsp::Rotator;
37use crate::modulate::OfdmConfig;
38use crate::multicarrier::{CarrierPlan, SymbolFft};
39use num_complex::Complex32 as C32;
40
41/// Repeated-segment preamble parameters: `num_repeats` identical segments of
42/// `repeat_len` samples each, optionally followed by a dedicated training
43/// symbol for integer-CFO recovery (and, in a later release, channel
44/// estimation).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct OfdmPreamble {
47    pub num_repeats: usize,
48    pub repeat_len: usize,
49    /// Present once a caller opts into wide-range integer-CFO recovery
50    /// (Release F). `None` preserves Release E's fractional-only behavior.
51    pub training_symbol: Option<TrainingSymbol>,
52}
53
54impl OfdmPreamble {
55    pub fn new(num_repeats: usize, repeat_len: usize) -> Self {
56        Self {
57            num_repeats,
58            repeat_len,
59            training_symbol: None,
60        }
61    }
62
63    /// Opts into the integer-CFO training symbol, sized to `n_fft` +
64    /// `cp_len` from the caller's `CarrierPlan`.
65    pub fn with_training_symbol(mut self, n_fft: usize, cp_len: usize) -> Self {
66        self.training_symbol = Some(TrainingSymbol { n_fft, cp_len });
67        self
68    }
69
70    /// Total preamble length in samples, including the training symbol if
71    /// present.
72    pub fn total_len(&self) -> usize {
73        self.num_repeats * self.repeat_len + self.training_symbol.map_or(0, |t| t.total_len())
74    }
75}
76
77/// Dedicated training symbol used for integer-CFO recovery: one full
78/// `n_fft`-point OFDM symbol (plus cyclic prefix) with a known value on
79/// every subcarrier bin, maximizing discriminating structure for the
80/// integer-bin-shift search.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct TrainingSymbol {
83    pub n_fft: usize,
84    pub cp_len: usize,
85}
86
87impl TrainingSymbol {
88    pub fn total_len(&self) -> usize {
89        self.n_fft + self.cp_len
90    }
91}
92
93/// One packet-sync candidate.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct OfdmSyncResult {
96    /// Sample offset of the preamble's start.
97    pub start_sample: usize,
98    /// Fractional CFO estimate (Hz), unambiguous within ±½ the subcarrier
99    /// spacing (±`fs / (2 · repeat_len)`); larger offsets alias.
100    pub cfo_hz: f32,
101    /// Integer CFO estimate, in whole subcarrier-spacing units. `0` unless
102    /// `preamble.training_symbol` is present and the integer search ran.
103    /// Total CFO is `cfo_hz + integer_cfo_bins as f32 * subcarrier_spacing`.
104    pub integer_cfo_bins: i32,
105    /// Normalized timing-metric score in `[0, 1]`; higher is a better match.
106    pub score: f32,
107}
108
109/// Generates a repeated-segment preamble: a deterministic, reproducible
110/// pseudo-random unit-average-energy base sequence of `repeat_len` samples,
111/// tiled `num_repeats` times, followed by the training symbol (time-domain,
112/// CP included) if `preamble.training_symbol` is present.
113///
114/// The repeat base sequence and the training symbol's frequency-domain
115/// pattern are both generated from fixed seeds (not derived from `cfg`), so
116/// the same `OfdmPreamble` always produces the same preamble on both the TX
117/// and RX side without requiring shared external state.
118///
119/// **`cfg.gain` is applied to the result**, exactly as [`OfdmMod`] applies it
120/// to every data symbol. This is not cosmetic: the preamble and the payload
121/// must share one amplitude scale or the frame is undecodable.
122///
123/// - The Schmidl & Cox timing metric normalizes against received energy, so a
124///   preamble that is quiet relative to the payload collapses the score. At
125///   `gain = 121` with an unscaled preamble the best score falls from 1.00 to
126///   0.095 — below the streaming receiver's 0.5 acceptance threshold, so no
127///   candidate is ever accepted and nothing decodes.
128/// - `EqualizerMethod::TrainingSymbolHold` estimates the channel from the
129///   training symbol. If the training symbol is unscaled while the payload is
130///   not, the estimate omits the gain, the equalizer never divides it out, and
131///   the demapper's LLRs are miscalibrated by that factor.
132///
133/// `cfg`'s other fields remain unused here. In particular **`rf_hz` is still
134/// not applied** — a caller using a nonzero `rf_hz` gets a baseband preamble
135/// ahead of an upconverted body. Applying it correctly requires phase
136/// continuity with the symbols that follow, which the per-block modulator
137/// construction does not currently provide; modulate at `rf_hz = 0.0` and
138/// upconvert the whole burst with one continuous
139/// [`Rotator`](crate::dsp::Rotator) instead.
140///
141/// [`OfdmMod`]: crate::modulate::OfdmMod
142pub fn generate_ofdm_preamble(preamble: &OfdmPreamble, cfg: &OfdmConfig) -> Vec<C32> {
143    let n_fft = cfg.carrier_plan.n_fft();
144    let occupied_half = cfg.carrier_plan.occupied_half_carriers();
145    let n_data = cfg.carrier_plan.data_carriers().len();
146
147    let base = band_limited_repeat_base(preamble.repeat_len, n_fft, occupied_half, n_data)
148        .unwrap_or_else(|| {
149            // No usable band-limited construction (see the helper). Fall back to
150            // the wideband sequence rather than emitting nothing.
151            pseudo_random_unit_sequence(preamble.repeat_len, 0x4F46_444D_5052_4531)
152        });
153    let mut out = Vec::with_capacity(preamble.total_len());
154    for _ in 0..preamble.num_repeats {
155        out.extend_from_slice(&base);
156    }
157    if let Some(training) = preamble.training_symbol {
158        out.extend_from_slice(&generate_training_symbol_time_domain(
159            training,
160            &cfg.carrier_plan,
161        ));
162    }
163    let g = cfg.gain;
164    if g != 1.0 {
165        for s in &mut out {
166            s.re *= g;
167            s.im *= g;
168        }
169    }
170    out
171}
172
173/// Amplitude of the S&C repeats relative to a data symbol.
174///
175/// `ofdm_sync` ranks candidates by `score * (r / r_peak)` — the correlated
176/// window's energy against the loudest window anywhere in the search range —
177/// so a preamble at exactly data power is no longer the energy peak and its
178/// score is scaled down by whatever the payload happens to reach. Measured on
179/// a clean frame, parity with the data drops the score from 1.00 to 0.54,
180/// grazing the receiver's 0.5 acceptance threshold.
181///
182/// A boost restores it: 1.5x already returns a perfect 1.00, and 2x is taken
183/// for margin. Transmitting the preamble hot is ordinary practice — 802.11
184/// boosts its short training field for the same reason — and 6 dB costs
185/// almost nothing against the ~70 dB of out-of-band excess band-limiting
186/// removes.
187const SC_PREAMBLE_BOOST: f32 = 2.0;
188
189/// One period of a band-limited Schmidl & Cox base segment, or `None` when the
190/// geometry does not admit one.
191///
192/// Built in the **frequency domain**: loading only bins that are multiples of
193/// `k = n_fft / repeat_len` makes the inverse transform repeat with period
194/// `repeat_len` by construction, so the repetition S&C correlates on is exact
195/// rather than approximate. Restricting those bins to the plan's occupied span
196/// is what band-limits it.
197///
198/// Returns `None` unless `repeat_len` divides `n_fft` and at least one occupied
199/// bin falls on a multiple of `k` — a sparse or tiny plan can leave nothing to
200/// load.
201///
202/// Amplitude is matched to a data symbol's: an OFDM symbol loading `m` bins at
203/// unit magnitude lands at RMS `sqrt(m) / n_fft`, so the segment is scaled to
204/// the value `n_data` loaded bins would give. Equal preamble and payload power
205/// is the usual arrangement, and it keeps the S&C metric well conditioned.
206fn band_limited_repeat_base(
207    repeat_len: usize,
208    n_fft: usize,
209    occupied_half: usize,
210    n_data: usize,
211) -> Option<Vec<C32>> {
212    if repeat_len == 0 || n_fft == 0 || !n_fft.is_multiple_of(repeat_len) || occupied_half == 0 {
213        return None;
214    }
215    let k = n_fft / repeat_len;
216
217    // Signed carrier indices inside the occupied span that land on a multiple
218    // of `k`. DC is skipped whether or not the plan occupies it: a loaded bin 0
219    // is a constant offset across the segment, and a constant is identically
220    // self-similar at every lag, so it broadens the S&C timing plateau while
221    // adding nothing the estimator can localize on. The repeats are used only
222    // for timing and CFO — never for channel estimation — so unlike the
223    // training symbol they owe the plan no bin-for-bin agreement.
224    let loaded: Vec<usize> = (1..=occupied_half as i32)
225        .flat_map(|i| [i, -i])
226        .filter(|i| (i.unsigned_abs() as usize).is_multiple_of(k))
227        .map(|i| {
228            if i >= 0 {
229                i as usize
230            } else {
231                n_fft - i.unsigned_abs() as usize
232            }
233        })
234        .collect();
235    if loaded.is_empty() {
236        return None;
237    }
238
239    let values = pseudo_random_unit_sequence(loaded.len(), 0x4F46_444D_5052_4531);
240    let mut freq = vec![C32::default(); n_fft];
241    for (&bin, &v) in loaded.iter().zip(values.iter()) {
242        freq[bin] = v;
243    }
244
245    let mut ifft = crate::multicarrier::IfftBlock::new(n_fft);
246    let mut time = vec![C32::default(); n_fft];
247    ifft.process(&freq, &mut time);
248    time.truncate(repeat_len);
249
250    // Scale to a data symbol's RMS.
251    let rms = (time.iter().map(|c| c.norm_sqr()).sum::<f32>() / time.len() as f32).sqrt();
252    if rms > 0.0 {
253        let target = SC_PREAMBLE_BOOST * (n_data as f32).sqrt() / n_fft as f32;
254        let scale = target / rms;
255        for c in &mut time {
256            c.re *= scale;
257            c.im *= scale;
258        }
259    }
260    Some(time)
261}
262
263/// The training symbol's known frequency-domain pattern: one unit-magnitude
264/// pseudo-random value per FFT bin (natural rustfft bin order), from a fixed
265/// seed distinct from the S&C repeat base sequence's.
266///
267/// `pub(crate)` so `demodulate::ofdm::OfdmEqualizer` can reuse the exact same
268/// known pattern for `TrainingSymbolHold` channel estimation without
269/// duplicating (and risking a mismatched) generator.
270pub(crate) fn training_symbol_freq_pattern(n_fft: usize) -> Vec<C32> {
271    pseudo_random_unit_sequence(n_fft, 0x4F46_444D_5452_4E31)
272}
273
274/// IFFTs the training symbol's known frequency-domain pattern to a
275/// time-domain symbol and prepends its cyclic prefix, matching
276/// `OfdmMod`'s TX chain (`IfftBlock` then `CyclicPrefixInsert`) so the
277/// training symbol round-trips through the same channel as data symbols.
278///
279/// **The loaded bin set is exactly `plan.occupied_bins()`** — every data and
280/// pilot carrier, and nothing else. Taking the plan rather than a band edge is
281/// what makes that an invariant instead of an approximation: a symmetric
282/// occupied span cannot say whether DC is live, so it used to be nulled
283/// unconditionally while `with_contiguous_data(_, true)` handed it out as data.
284/// The receiver then divided a bin that was never transmitted by a nonzero
285/// reference and equalized the payload with the result.
286fn generate_training_symbol_time_domain(training: TrainingSymbol, plan: &CarrierPlan) -> Vec<C32> {
287    use crate::multicarrier::{CyclicPrefixInsert, IfftBlock};
288
289    // Transmit the known pattern only on the bins the plan occupies. The
290    // pattern itself is unchanged — the receiver still divides by the full-band
291    // reference — so the estimate on an occupied bin is exactly `H`, with no
292    // scale to divide back out.
293    //
294    // Band-limiting also amplitude-matches it: the symbol's RMS is
295    // `sqrt(loaded bins) / n_fft`, and a data symbol loads exactly this bin set
296    // at unit *average* energy (the constellations are normalized, and pilots
297    // are conventionally unit-magnitude), so the two levels agree by
298    // construction rather than by a span that happened to be close. Adding or
299    // removing DC moves the count by one and the level by half a bin's worth —
300    // the match holds because it is derived, not because one carrier is small.
301    //
302    // Bins the plan does not occupy are never extracted as data, so the
303    // estimate there going to zero is harmless: `OfdmEqualizer` erases a bin
304    // whose estimate falls under `EQUALIZER_FLOOR` rather than dividing by it.
305    let mut freq = training_symbol_freq_pattern(training.n_fft);
306    let occupied = plan.occupied_bins();
307    if !occupied.is_empty() {
308        // `bin < n_fft` only matters if the training symbol was sized
309        // independently of the plan, which no path that builds a preamble from
310        // an `OfdmConfig` does — the receiver would disagree about the FFT size
311        // too. Bounds-checking beats indexing out of a caller's mistake.
312        let mut load = vec![false; training.n_fft];
313        for bin in occupied {
314            if bin < training.n_fft {
315                load[bin] = true;
316            }
317        }
318        for (bin, v) in freq.iter_mut().enumerate() {
319            if !load[bin] {
320                *v = C32::default();
321            }
322        }
323    }
324    let mut ifft = IfftBlock::new(training.n_fft);
325    let mut time = vec![C32::default(); training.n_fft];
326    ifft.process(&freq, &mut time);
327
328    let mut cp_insert = CyclicPrefixInsert::new(training.n_fft, training.cp_len);
329    let mut out = vec![C32::default(); training.total_len()];
330    cp_insert.process(&time, &mut out);
331    out
332}
333
334/// Deterministic pseudo-random complex sequence, unit average energy.
335fn pseudo_random_unit_sequence(len: usize, seed: u64) -> Vec<C32> {
336    let mut state = seed;
337    let mut next_f32 = || -> f32 {
338        state ^= state << 13;
339        state ^= state >> 7;
340        state ^= state << 17;
341        (state as f32) / (u64::MAX as f32) - 0.5
342    };
343
344    let scale = std::f32::consts::FRAC_1_SQRT_2;
345    (0..len)
346        .map(|_| {
347            let re = if next_f32() >= 0.0 { scale } else { -scale };
348            let im = if next_f32() >= 0.0 { scale } else { -scale };
349            C32::new(re, im)
350        })
351        .collect()
352}
353
354/// Searches `iq[search_start..search_end)` for a repeated-segment preamble
355/// match, returning candidates sorted by descending score.
356///
357/// `search_end` is clamped so every candidate start has room for the full
358/// preamble (`2 * repeat_len` samples for the correlation window, extended
359/// across all `num_repeats` segments). Returns an empty `Vec` if the search
360/// range is too short to hold a full preamble.
361pub fn ofdm_sync(
362    iq: &[C32],
363    fs: f32,
364    preamble: &OfdmPreamble,
365    search_start: usize,
366    search_end: usize,
367) -> Vec<OfdmSyncResult> {
368    let repeat_len = preamble.repeat_len;
369    let num_repeats = preamble.num_repeats;
370    if repeat_len == 0 || num_repeats < 2 || fs <= 0.0 {
371        return Vec::new();
372    }
373
374    let preamble_len = preamble.total_len();
375    let end = search_end.min(iq.len().saturating_sub(preamble_len));
376    if search_start >= end {
377        return Vec::new();
378    }
379
380    // The correlation-phase timing metric alone (`score`) forms a plateau,
381    // not a sharp spike: a purely periodic preamble correlates against
382    // itself at any offset that keeps the window fully inside the repeated
383    // structure, not only at the true start. `R` — the correlated window's
384    // own energy, summed over all `num_repeats - 1` segment pairs — breaks
385    // the tie: it is maximized only where every correlated sample is real
386    // preamble signal, which (for a preamble bounded by non-periodic
387    // content on both sides) happens at exactly one offset, the true start.
388    // Candidates are ranked by `score * (r / r_peak)`, so a result must be
389    // both phase-coherent (S&C's actual acquisition criterion) and
390    // maximally in-window to rank first.
391    let mut all = Vec::with_capacity(end - search_start);
392    let mut r_peak = 0.0f32;
393    for d in search_start..end {
394        let mut p = C32::default();
395        let mut r = 0.0f32;
396
397        for seg in 0..num_repeats - 1 {
398            let a0 = d + seg * repeat_len;
399            let b0 = a0 + repeat_len;
400            let (seg_p, seg_r) = correlate_segment(iq, a0, b0, repeat_len);
401            p += seg_p;
402            r += seg_r;
403        }
404
405        if r <= 0.0 {
406            continue;
407        }
408        r_peak = r_peak.max(r);
409
410        let score = (p.norm_sqr() / (r * r)).clamp(0.0, 1.0);
411        let cfo_hz = p.im.atan2(p.re) / (core::f32::consts::TAU * repeat_len as f32 / fs);
412
413        all.push((
414            r,
415            OfdmSyncResult {
416                start_sample: d,
417                cfo_hz,
418                integer_cfo_bins: 0,
419                score,
420            },
421        ));
422    }
423
424    if all.is_empty() || r_peak <= 0.0 {
425        return Vec::new();
426    }
427
428    // Rank by `score * (r / r_peak)`, but **report the raw score**.
429    //
430    // The energy ratio is a tie-break: it picks the offset that is maximally
431    // in-window among a plateau of equally phase-coherent ones. It is not a
432    // measure of whether a preamble is present, and folding it into the score
433    // made acceptance depend on whatever else is loud in the search range —
434    // a preamble at ordinary signal level scores 0.54 rather than 1.00 merely
435    // because the payload matches it for energy, and any louder transient
436    // (a corrupted burst, an adjacent signal) suppresses a perfectly good
437    // candidate below the threshold entirely.
438    //
439    // Ordering by the product keeps the tie-break; thresholding on the raw
440    // score keeps acceptance a question about phase coherence, which is what
441    // Schmidl & Cox actually measures.
442    let mut ranked: Vec<(f32, OfdmSyncResult)> = all
443        .into_iter()
444        .map(|(r, result)| (result.score * (r / r_peak), result))
445        .collect();
446
447    ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
448    let mut results: Vec<OfdmSyncResult> = ranked.into_iter().map(|(_, r)| r).collect();
449
450    // Integer-CFO search runs only on a small number of the top timing
451    // candidates (bounding cost) using the dedicated training symbol
452    // immediately following the S&C repeats, if the caller opted in.
453    if let Some(training) = preamble.training_symbol {
454        let top_n = results.len().min(5);
455        for result in &mut results[..top_n] {
456            let training_start = result.start_sample + repeat_len * num_repeats;
457            result.integer_cfo_bins =
458                estimate_integer_cfo_bins(iq, fs, training, training_start, result.cfo_hz);
459        }
460    }
461
462    results
463}
464
465/// Picks the **earliest** accepted candidate from [`ofdm_sync`]'s output — the
466/// one a receiver draining a buffer front-to-back should decode next.
467///
468/// [`ofdm_sync`] ranks by *quality*, best first, which is what a caller
469/// answering "is there a preamble here, and where is it best aligned?" wants.
470/// A streaming receiver asks a different question: "which frame comes next?"
471/// Taking the best-ranked candidate answers the wrong one, and the consequence
472/// is silent data loss rather than a visible error — the receiver locks onto a
473/// frame further down the buffer and drains every frame before it without
474/// reporting anything.
475///
476/// Measured before this existed, on eight back-to-back frames at an excellent
477/// link (EVM ~-42 dB, BER 0): sequence numbers `[6, 7]` came back out of
478/// `0..=7`, with **zero** errors reported. Every preamble scored 1.000, so the
479/// quality ranking was decided by noise among equals. At zero noise the sort is
480/// stable and all eight arrive, which is why a clean-signal test cannot see it.
481///
482/// Selection is by earliest **cluster**, not earliest offset. The timing metric
483/// forms a plateau (see [`ofdm_sync`]), so one preamble occurrence yields a run
484/// of accepted offsets; `cluster_len` — pass the preamble's `total_len()` —
485/// groups them, and the best-ranked offset *within the earliest cluster* wins.
486/// Taking the earliest offset outright would systematically pick the leading
487/// edge of the plateau and give away timing accuracy for nothing.
488pub fn earliest_accepted(
489    results: Vec<OfdmSyncResult>,
490    score_threshold: f32,
491    cluster_len: usize,
492) -> Option<OfdmSyncResult> {
493    let accepted: Vec<OfdmSyncResult> = results
494        .into_iter()
495        .filter(|r| r.score >= score_threshold)
496        .collect();
497    let earliest = accepted.iter().map(|r| r.start_sample).min()?;
498    // `accepted` preserves `ofdm_sync`'s quality ranking, so the first entry
499    // falling inside the earliest cluster is that occurrence's best offset.
500    accepted
501        .into_iter()
502        .find(|r| r.start_sample - earliest < cluster_len.max(1))
503}
504
505/// Estimates the integer CFO (whole subcarrier-spacing units) from the
506/// dedicated training symbol at `training_start`: corrects the already-known
507/// fractional CFO, strips the cyclic prefix, FFTs the result, and searches
508/// candidate circular bin shifts for the one maximizing correlation against
509/// the training symbol's known frequency-domain pattern.
510///
511/// Returns `0` if `iq` doesn't have room for the full training symbol at
512/// `training_start`.
513fn estimate_integer_cfo_bins(
514    iq: &[C32],
515    fs: f32,
516    training: TrainingSymbol,
517    training_start: usize,
518    fractional_cfo_hz: f32,
519) -> i32 {
520    let total_len = training.total_len();
521    if training_start + total_len > iq.len() {
522        return 0;
523    }
524
525    let raw = &iq[training_start..training_start + total_len];
526    let mut corrected = vec![C32::default(); total_len];
527    let mut rot = Rotator::new(-fractional_cfo_hz, fs);
528    rot.rotate_block(raw, &mut corrected);
529
530    let n_fft = training.n_fft;
531    // Integer-CFO estimation uses the standard CP-boundary window (no back-off):
532    // it correlates the training symbol against a known frequency-domain pattern
533    // to detect a whole-subcarrier shift, independent of the data window.
534    let mut symbol_fft = SymbolFft::new(n_fft, training.cp_len);
535    let freq = match symbol_fft.demod_symbol(&corrected) {
536        Some(f) => f,
537        None => return 0,
538    };
539
540    let known = training_symbol_freq_pattern(n_fft);
541
542    // Search circular bin shifts within the signed carrier-index range
543    // (natural rustfft bin order: shift k means the received spectrum is
544    // rotated by k bins relative to the known pattern).
545    let max_shift = (n_fft / 2) as i32;
546    let mut best_shift = 0i32;
547    let mut best_corr = -1.0f32;
548    for shift in -max_shift..=max_shift {
549        let mut corr = C32::default();
550        for (bin, &k) in known.iter().enumerate() {
551            let src_bin = (bin as i32 + shift).rem_euclid(n_fft as i32) as usize;
552            corr += k.conj() * freq[src_bin];
553        }
554        let mag = corr.norm_sqr();
555        if mag > best_corr {
556            best_corr = mag;
557            best_shift = shift;
558        }
559    }
560
561    best_shift
562}
563
564/// Correlate two adjacent length-`len` segments starting at `a0`/`b0`:
565/// `P = Σ conj(iq[a0+i]) · iq[b0+i]`, `R = Σ |iq[b0+i]|²`.
566#[inline]
567fn correlate_segment(iq: &[C32], a0: usize, b0: usize, len: usize) -> (C32, f32) {
568    let mut p = C32::default();
569    let mut r = 0.0f32;
570    let mut i = 0;
571    let nn = len & !3;
572    while i < nn {
573        p += iq[a0 + i].conj() * iq[b0 + i];
574        r += iq[b0 + i].norm_sqr();
575        p += iq[a0 + i + 1].conj() * iq[b0 + i + 1];
576        r += iq[b0 + i + 1].norm_sqr();
577        p += iq[a0 + i + 2].conj() * iq[b0 + i + 2];
578        r += iq[b0 + i + 2].norm_sqr();
579        p += iq[a0 + i + 3].conj() * iq[b0 + i + 3];
580        r += iq[b0 + i + 3].norm_sqr();
581        i += 4;
582    }
583    while i < len {
584        p += iq[a0 + i].conj() * iq[b0 + i];
585        r += iq[b0 + i].norm_sqr();
586        i += 1;
587    }
588    (p, r)
589}