use alloc::vec;
use alloc::vec::Vec;
#[cfg(not(feature = "std"))]
use num_traits::Float;
use super::super::params::{COSTAS, COSTAS_POS, NTONES};
use super::spectrogram::{CoarseAcc, Spectrogram};
use super::types::{
NFFT_SPEC, NSSY, NSTEP, SAMPLE_RATE_HZ, TONE_SPACING_HZ, TX_START_OFFSET_S, ratio_eps,
sync_lag_s,
};
use crate::engine::sync::SyncCandidate;
pub fn coarse_sync(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
) -> Vec<SyncCandidate> {
coarse_sync_inner(spec, freq_min, freq_max, sync_min, max_cand, None, None)
}
pub fn coarse_sync_with_lag(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
sync_lag_s: f32,
) -> Vec<SyncCandidate> {
coarse_sync_inner(
spec,
freq_min,
freq_max,
sync_min,
max_cand,
None,
Some(sync_lag_s),
)
}
pub fn coarse_sync_with_allsum(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
allsum: &[CoarseAcc],
) -> Vec<SyncCandidate> {
coarse_sync_inner(
spec,
freq_min,
freq_max,
sync_min,
max_cand,
Some(allsum),
None,
)
}
pub fn coarse_sync_with_allsum_and_lag(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
allsum: &[CoarseAcc],
sync_lag_s: f32,
) -> Vec<SyncCandidate> {
coarse_sync_inner(
spec,
freq_min,
freq_max,
sync_min,
max_cand,
Some(allsum),
Some(sync_lag_s),
)
}
pub fn coarse_allsum_len(
spec_n_freq: usize,
spec_n_time: usize,
freq_min: f32,
freq_max: f32,
) -> usize {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(spec_n_freq.saturating_sub(max_tone_off));
if ib < ia {
return 0;
}
let n_freq = ib - ia + 1;
n_freq * spec_n_time
}
pub fn precompute_coarse_allsum(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
) -> Vec<CoarseAcc> {
let mut buf =
vec![CoarseAcc::default(); coarse_allsum_len(spec.n_freq, spec.n_time, freq_min, freq_max)];
if !buf.is_empty() {
precompute_coarse_allsum_into(spec, freq_min, freq_max, &mut buf);
}
buf
}
pub fn precompute_coarse_allsum_into(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
dst: &mut [CoarseAcc],
) {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
if ib < ia {
return;
}
let n_freq = ib - ia + 1;
debug_assert_eq!(
dst.len(),
n_freq * spec.n_time,
"allsum buffer length mismatch"
);
fill_coarse_allsum(spec, ia, ib, n_freq, dst);
}
fn fill_allsum_row(spec: &Spectrogram, ia: usize, n_freq: usize, m: usize, dst: &mut [CoarseAcc]) {
if n_freq == 0 {
return;
}
let nh1 = spec.n_freq;
let upper = nh1 - 1;
let n_time = spec.n_time;
#[cfg(feature = "fixed-point")]
{
let mut prev = [CoarseAcc::default(); 2];
for parity in 0..2usize {
if parity >= n_freq {
break;
}
let i_carrier = ia + parity;
let mut s = CoarseAcc::default();
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(upper);
s += spec.power_acc(bin, m);
}
dst[parity * n_time + m] = s;
prev[parity] = s;
}
for fi in 2..n_freq {
let i_carrier = ia + fi;
let drop = (i_carrier - 2).min(upper);
let add = (i_carrier + 2 * (NTONES - 2)).min(upper);
let p = fi & 1;
#[allow(clippy::unnecessary_cast)]
let s = prev[p] + spec.power_acc(add, m) - spec.power_acc(drop, m);
dst[fi * n_time + m] = s;
prev[p] = s;
}
}
#[cfg(not(feature = "fixed-point"))]
{
for fi in 0..n_freq {
let i_carrier = ia + fi;
let mut s = CoarseAcc::default();
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(upper);
s += spec.power_acc(bin, m);
}
dst[fi * n_time + m] = s;
}
}
}
fn fill_coarse_allsum(
spec: &Spectrogram,
ia: usize,
ib: usize,
_n_freq: usize,
dst: &mut [CoarseAcc],
) {
let n_freq = ib - ia + 1;
for m in 0..spec.n_time {
fill_allsum_row(spec, ia, n_freq, m, dst);
}
}
fn coarse_sync_inner(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
external_allsum: Option<&[CoarseAcc]>,
sync_lag_override_s: Option<f32>,
) -> Vec<SyncCandidate> {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tstep = NSTEP as f32 / SAMPLE_RATE_HZ;
let jstrt = (TX_START_OFFSET_S / tstep).round() as i32;
let requested_jz = (sync_lag_override_s.unwrap_or_else(sync_lag_s) / tstep).round() as i32;
let Some(jz) = bounded_sync_lag_steps(spec.n_time, jstrt, requested_jz) else {
return Vec::new();
};
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
if ib < ia {
return Vec::new();
}
let n_freq = ib - ia + 1;
let n_lag = (2 * jz + 1) as usize;
let mut sync2d = vec![0.0f32; n_freq * n_lag];
let idx = |fi: usize, lag: i32| fi * n_lag + (lag + jz) as usize;
let ratio_eps = ratio_eps();
#[cfg(all(
feature = "profile-coarse",
not(all(target_arch = "wasm32", target_os = "unknown"))
))]
let t_setup = std::time::Instant::now();
let mut tone_bin_lo = [0usize; NTONES];
for k in 0..NTONES {
tone_bin_lo[k] = (k as f32 * tone_step_bins).floor() as usize;
}
let m_base: [[i32; COSTAS.len()]; COSTAS_POS.len()] = {
let mut t = [[0i32; COSTAS.len()]; COSTAS_POS.len()];
for (bk, &start_sym) in COSTAS_POS.iter().enumerate() {
let block_offset = NSSY * start_sym as i32;
for (n, _) in COSTAS.iter().enumerate() {
t[bk][n] = jstrt + block_offset + NSSY * n as i32;
}
}
t
};
let costas_off: [usize; COSTAS.len()] = {
let mut t = [0usize; COSTAS.len()];
for (n, &costas_n) in COSTAS.iter().enumerate() {
t[n] = tone_bin_lo[costas_n];
}
t
};
let needed_m: alloc::vec::Vec<usize> = {
let mut mark = alloc::vec![false; spec.n_time];
for bk in 0..COSTAS_POS.len() {
let lo = m_base[bk][0] - jz;
let hi = m_base[bk][COSTAS.len() - 1] + jz;
let lo_u = lo.max(0) as usize;
let hi_u = (hi.min(spec.n_time as i32 - 1)) as usize;
if lo_u <= hi_u {
#[allow(clippy::needless_range_loop)]
for m in lo_u..=hi_u {
mark[m] = true;
}
}
}
(0..spec.n_time).filter(|&m| mark[m]).collect()
};
let owned_allsum: Vec<CoarseAcc>;
let allsum: &[CoarseAcc] = if let Some(ext) = external_allsum {
debug_assert_eq!(
ext.len(),
n_freq * spec.n_time,
"external allsum length mismatch (expected n_freq * spec.n_time)"
);
ext
} else {
owned_allsum = {
let mut buf = vec![CoarseAcc::default(); n_freq * spec.n_time];
for &m in &needed_m {
fill_allsum_row(spec, ia, n_freq, m, &mut buf);
}
buf
};
&owned_allsum
};
#[cfg(all(
feature = "profile-coarse",
not(all(target_arch = "wasm32", target_os = "unknown"))
))]
let t_allsum = std::time::Instant::now();
debug_assert!(
m_base[1][0] - jz >= 0 && m_base[1][COSTAS.len() - 1] + jz < spec.n_time as i32,
"n_time too small for SYNC_LAG_S/jstrt"
);
let n_time = spec.n_time;
let mut tbin_lo_arr = [0usize; COSTAS.len()];
for (fi, i_carrier) in (ia..=ib).enumerate() {
for n in 0..COSTAS.len() {
tbin_lo_arr[n] = i_carrier + costas_off[n];
}
let allsum_row = &allsum[fi * n_time..(fi + 1) * n_time];
for lag in -jz..=jz {
let mut t_blocks: [CoarseAcc; 3] = [CoarseAcc::default(); 3];
let mut t0_blocks: [CoarseAcc; 3] = [CoarseAcc::default(); 3];
let bk0_n_start = {
let needed = -jstrt - lag;
if needed <= 0 {
0usize
} else {
((needed + NSSY - 1) / NSSY).min(COSTAS.len() as i32) as usize
}
};
for n in bk0_n_start..COSTAS.len() {
let m_u = (m_base[0][n] + lag) as usize;
let tbin_lo = tbin_lo_arr[n];
t_blocks[0] += spec.power_acc(tbin_lo, m_u);
t0_blocks[0] += allsum_row[m_u];
}
for n in 0..COSTAS.len() {
let m_u = (m_base[1][n] + lag) as usize;
let tbin_lo = tbin_lo_arr[n];
t_blocks[1] += spec.power_acc(tbin_lo, m_u);
t0_blocks[1] += allsum_row[m_u];
}
let bk2_n_end = valid_trailing_symbol_count(m_base[2][0], lag, n_time);
for n in 0..bk2_n_end {
let m_u = (m_base[2][n] + lag) as usize;
let tbin_lo = tbin_lo_arr[n];
t_blocks[2] += spec.power_acc(tbin_lo, m_u);
t0_blocks[2] += allsum_row[m_u];
}
let t_all: CoarseAcc = t_blocks[0] + t_blocks[1] + t_blocks[2];
let t0_all: CoarseAcc = t0_blocks[0] + t0_blocks[1] + t0_blocks[2];
#[allow(clippy::unnecessary_cast)]
let t_all_f = t_all as f32;
#[allow(clippy::unnecessary_cast)]
let t0_all_f = t0_all as f32;
let t0_ref = (t0_all_f - t_all_f) / (NTONES as f32 - 2.0);
let sync_all = t_all_f / (t0_ref + ratio_eps);
#[allow(clippy::unnecessary_cast)]
let t_tail_f = (t_blocks[1] + t_blocks[2]) as f32;
#[allow(clippy::unnecessary_cast)]
let t0_tail_f = (t0_blocks[1] + t0_blocks[2]) as f32;
let t0_tail_ref = (t0_tail_f - t_tail_f) / (NTONES as f32 - 2.0);
let sync_tail = t_tail_f / (t0_tail_ref + ratio_eps);
sync2d[idx(fi, lag)] = sync_all.max(sync_tail);
}
}
#[cfg(all(
feature = "profile-coarse",
not(all(target_arch = "wasm32", target_os = "unknown"))
))]
let t_score = std::time::Instant::now();
const MLAG: i32 = 10;
let primary_half = MLAG.min(jz);
let mut red_primary = vec![0.0f32; n_freq];
let mut jpeak_primary = vec![0i32; n_freq];
let mut red_secondary = vec![0.0f32; n_freq];
let mut jpeak_secondary = vec![0i32; n_freq];
for fi in 0..n_freq {
let mut best_p = f32::NEG_INFINITY;
let mut lag_p = -primary_half;
let mut best_s = f32::NEG_INFINITY;
let mut lag_s = -jz;
for lag in -jz..=jz {
let v = sync2d[idx(fi, lag)];
if v > best_s {
best_s = v;
lag_s = lag;
}
if (-primary_half..=primary_half).contains(&lag) && v > best_p {
best_p = v;
lag_p = lag;
}
}
red_primary[fi] = best_p;
jpeak_primary[fi] = lag_p;
red_secondary[fi] = best_s;
jpeak_secondary[fi] = lag_s;
}
let percentile_floor = |red: &[f32]| -> f32 {
let mut sorted = red.to_vec();
let pct_idx = ((0.40 * n_freq as f32) as usize).min(n_freq - 1);
sorted.select_nth_unstable_by(pct_idx, |a, b| {
a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)
});
sorted[pct_idx].max(f32::EPSILON)
};
let base_primary = percentile_floor(&red_primary);
let base_secondary = percentile_floor(&red_secondary);
let dt_quanta_at = |fi: usize, lag: i32| -> f32 {
if lag > -jz && lag < jz {
let y_lo = sync2d[idx(fi, lag - 1)];
let y_mi = sync2d[idx(fi, lag)];
let y_hi = sync2d[idx(fi, lag + 1)];
let denom = y_lo - 2.0 * y_mi + y_hi;
if denom.abs() > f32::EPSILON {
(0.5 * (y_lo - y_hi) / denom).clamp(-1.0, 1.0)
} else {
0.0
}
} else {
0.0
}
};
let mut cands: Vec<SyncCandidate> = Vec::new();
for fi in 0..n_freq {
let i_carrier = ia + fi;
let freq_hz = i_carrier as f32 * df;
let lag_p = jpeak_primary[fi];
let norm_p = red_primary[fi] / base_primary;
if norm_p.is_finite() && norm_p >= sync_min {
let dt_lag = lag_p as f32 + dt_quanta_at(fi, lag_p);
cands.push(SyncCandidate {
freq_hz,
dt_sec: (dt_lag - 0.5) * tstep,
score: norm_p,
});
}
let lag_s = jpeak_secondary[fi];
if lag_s != lag_p {
let norm_s = red_secondary[fi] / base_secondary;
if norm_s.is_finite() && norm_s >= sync_min {
let dt_lag = lag_s as f32 + dt_quanta_at(fi, lag_s);
cands.push(SyncCandidate {
freq_hz,
dt_sec: (dt_lag - 0.5) * tstep,
score: norm_s,
});
}
}
}
cands.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
let mut out: Vec<SyncCandidate> = Vec::with_capacity(max_cand);
for c in cands {
if c.score < sync_min {
break;
}
let near = out
.iter()
.any(|k| (c.freq_hz - k.freq_hz).abs() < 4.0 && (c.dt_sec - k.dt_sec).abs() < 0.04);
if near {
continue;
}
out.push(c);
if out.len() >= max_cand {
break;
}
}
let cands = out;
#[cfg(all(
feature = "profile-coarse",
not(all(target_arch = "wasm32", target_os = "unknown"))
))]
{
let t_end = std::time::Instant::now();
let allsum_us = (t_allsum - t_setup).as_micros();
let score_us = (t_score - t_allsum).as_micros();
let post_us = (t_end - t_score).as_micros();
let total_us = (t_end - t_setup).as_micros();
eprintln!(
"[coarse_sync prof] n_freq={n_freq} n_lag={n_lag} allsum={allsum_us} score={score_us} dedupe+sort={post_us} total={total_us} us"
);
}
cands
}
fn bounded_sync_lag_steps(
spec_n_time: usize,
start_step: i32,
requested_steps: i32,
) -> Option<i32> {
let requested_steps = requested_steps.max(0);
let second_block_base = start_step + NSSY * COSTAS_POS[1] as i32;
let second_block_end = second_block_base + NSSY * (COSTAS.len() as i32 - 1);
let max_positive_steps = (spec_n_time as i32 - 1).checked_sub(second_block_end)?;
let safe_steps = second_block_base.min(max_positive_steps);
(safe_steps >= 0).then(|| requested_steps.min(safe_steps))
}
fn valid_trailing_symbol_count(base_step: i32, lag: i32, n_time: usize) -> usize {
let available = (n_time as i32 - 1) - (base_step + lag);
if available < 0 {
0
} else {
(available / NSSY + 1).min(COSTAS.len() as i32) as usize
}
}
#[cfg(test)]
mod tests {
use super::{
Spectrogram, bounded_sync_lag_steps, coarse_sync_inner, valid_trailing_symbol_count,
};
#[test]
fn coarse_sync_accepts_wsjtx_lag_window_without_out_of_bounds_access() {
let n_freq = 128;
let n_time = 372;
let spec =
Spectrogram::from_parts(n_freq, n_time, vec![Default::default(); n_freq * n_time]);
let candidates = coarse_sync_inner(&spec, 200.0, 300.0, 1.5, 10, None, Some(2.5));
assert!(candidates.is_empty());
}
#[test]
fn sync_lag_preserves_wsjtx_window_when_middle_block_fits() {
assert_eq!(bounded_sync_lag_steps(372, 13, 63), Some(63));
assert_eq!(bounded_sync_lag_steps(372, 13, 500), Some(157));
assert_eq!(bounded_sync_lag_steps(181, 13, 25), None);
assert_eq!(bounded_sync_lag_steps(372, 13, -10), Some(0));
}
#[test]
fn trailing_block_skips_symbols_beyond_spectrogram() {
assert_eq!(valid_trailing_symbol_count(301, 0, 372), 7);
assert_eq!(valid_trailing_symbol_count(301, 63, 372), 2);
assert_eq!(valid_trailing_symbol_count(301, 80, 372), 0);
}
}