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}