use num_complex::Complex;
#[cfg(not(feature = "std"))]
use num_traits::Float;
type CostasRefTable = [[Complex<f32>; 32]; 8];
fn build_costas_ref_table() -> CostasRefTable {
const DS_SPB: usize = 32;
let mut table = [[Complex::new(0.0_f32, 0.0); DS_SPB]; 8];
for (tone, row) in table.iter_mut().enumerate() {
let dphi = core::f32::consts::TAU * (tone as f32) / (DS_SPB as f32);
let mut phi = 0.0_f32;
for slot in row.iter_mut() {
*slot = Complex::new(phi.cos(), phi.sin());
phi += dphi;
if phi > core::f32::consts::PI {
phi -= core::f32::consts::TAU;
}
}
}
table
}
#[cfg(feature = "std")]
fn costas_ref_table() -> &'static CostasRefTable {
static TABLE: std::sync::OnceLock<CostasRefTable> = std::sync::OnceLock::new();
TABLE.get_or_init(build_costas_ref_table)
}
#[cfg(not(feature = "std"))]
fn costas_ref_table() -> CostasRefTable {
build_costas_ref_table()
}
fn fine_sync_power_signed(
cd0: &[Complex<f32>],
i: i32,
ref_table: &CostasRefTable,
tweak: Option<&[Complex<f32>; 32]>,
) -> f32 {
use crate::ft8::params::COSTAS;
const DS_SPB: i32 = 32;
let icos7: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
debug_assert_eq!(icos7.len(), COSTAS.len());
let np2 = cd0.len() as i32;
let mut total = 0.0_f32;
for block_off in [0_i32, 36, 72].iter().copied() {
let mut block_power = 0.0_f32;
for (k, &tone) in icos7.iter().enumerate() {
let start = i + block_off * DS_SPB + (k as i32) * DS_SPB;
if start < 0 || start + DS_SPB > np2 {
continue;
}
let ref_row = &ref_table[tone as usize];
let mut z = Complex::new(0.0_f32, 0.0);
for j in 0..DS_SPB {
let s = cd0[start as usize + j as usize];
let r = ref_row[j as usize];
let r = match tweak {
Some(t) => r * t[j as usize],
None => r,
};
z += s * r.conj();
}
block_power += z.norm_sqr();
}
total += block_power;
}
total
}
pub const CD0_LEN: usize = 3200;
pub const DS_RATE: f32 = 200.0;
#[derive(Debug, Clone, Copy)]
pub struct FineRefine {
pub dt_sec: f32,
pub delf_hz: f32,
pub score: f32,
}
fn build_tweak(delf_hz: f32) -> [Complex<f32>; 32] {
let dt2 = 1.0 / DS_RATE;
let dphi = core::f32::consts::TAU * delf_hz * dt2;
let mut tweak = [Complex::new(0.0_f32, 0.0); 32];
let mut phi = 0.0_f32;
for slot in tweak.iter_mut() {
*slot = Complex::new(phi.cos(), phi.sin());
phi += dphi;
if phi > core::f32::consts::PI {
phi -= core::f32::consts::TAU;
}
}
tweak
}
pub fn fine_refine_3stage(cd0: &[Complex<f32>], initial_dt_sec: f32) -> FineRefine {
debug_assert_eq!(cd0.len(), CD0_LEN);
#[cfg(feature = "std")]
let ref_table = costas_ref_table();
#[cfg(not(feature = "std"))]
let ref_table = &costas_ref_table();
let i0 = ((initial_dt_sec + 0.5) * DS_RATE).round() as i32;
let mut ibest_a = i0;
let mut smax_a = f32::MIN;
for delta in -10..=10_i32 {
let i = i0 + delta;
let s = fine_sync_power_signed(cd0, i, ref_table, None);
if s > smax_a {
smax_a = s;
ibest_a = i;
}
}
let mut delfbest = 0.0_f32;
let mut smax_b = fine_sync_power_signed(cd0, ibest_a, ref_table, None);
for ifr in -5..=5_i32 {
if ifr == 0 {
continue;
}
let delf = ifr as f32 * 0.5;
let tweak = build_tweak(delf);
let s = fine_sync_power_signed(cd0, ibest_a, ref_table, Some(&tweak));
if s > smax_b {
smax_b = s;
delfbest = delf;
}
}
let tweak_c = (delfbest.abs() > f32::EPSILON).then(|| build_tweak(delfbest));
let mut ibest_c = ibest_a;
let mut smax_c = f32::MIN;
for delta in -4..=4_i32 {
let i = ibest_a + delta;
let s = fine_sync_power_signed(cd0, i, ref_table, tweak_c.as_ref());
if s > smax_c {
smax_c = s;
ibest_c = i;
}
}
let dt_sec = (ibest_c as f32) / DS_RATE - 0.5;
FineRefine {
dt_sec,
delf_hz: delfbest,
score: smax_c,
}
}
#[cfg(all(test, feature = "fft-rustfft"))]
mod tests {
use super::*;
use crate::ft8::downsample::downsample;
use crate::ft8::params::COSTAS;
use crate::ft8::wave_gen::tones_to_f32;
use alloc::vec;
use alloc::vec::Vec;
fn fine_sync_power_signed_reference(cd0: &[Complex<f32>], i: i32) -> f32 {
const DS_SPB: i32 = 32;
let icos7: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
let np2 = cd0.len() as i32;
let mut total = 0.0_f32;
for block_off in [0_i32, 36, 72].iter().copied() {
let mut block_power = 0.0_f32;
for (k, &tone) in icos7.iter().enumerate() {
let start = i + block_off * DS_SPB + (k as i32) * DS_SPB;
if start < 0 || start + DS_SPB > np2 {
continue;
}
let dphi = core::f32::consts::TAU * (tone as f32) / (DS_SPB as f32);
let mut z = Complex::new(0.0_f32, 0.0);
let mut phi = 0.0_f32;
for j in 0..DS_SPB {
let s = cd0[start as usize + j as usize];
let r = Complex::new(phi.cos(), phi.sin());
z += s * r.conj();
phi += dphi;
if phi > core::f32::consts::PI {
phi -= core::f32::consts::TAU;
}
}
block_power += z.norm_sqr();
}
total += block_power;
}
total
}
#[test]
fn ref_table_matches_per_sample_reference() {
let ref_table = build_costas_ref_table();
let signal_slot = {
let tones = costas_only_tones();
let pcm_f32 = tones_to_f32(&tones, 1500.0, 0.5);
let mut slot = vec![0i16; 15 * 12_000];
let start = (0.5_f32 * 12_000.0).round() as usize;
for (i, &s) in pcm_f32.iter().enumerate() {
if start + i < slot.len() {
slot[start + i] = (s * 16_000.0) as i16;
}
}
slot
};
let (cd0_signal, _) = downsample(&signal_slot, 1500.0, None);
let noise_slot: Vec<i16> = (0..15 * 12_000)
.map(|n| {
let x = (n as i64 * 2_654_435_761) as u32;
((x >> 16) as i16).wrapping_sub(i16::MAX / 2)
})
.collect();
let (cd0_noise, _) = downsample(&noise_slot, 1500.0, None);
for (label, cd0) in [("signal", &cd0_signal), ("noise", &cd0_noise)] {
for i in [-10_i32, -3, 0, 5, 12, 1590, 1605] {
let expected = fine_sync_power_signed_reference(cd0, i);
let actual = fine_sync_power_signed(cd0, i, &ref_table, None);
assert_eq!(
expected, actual,
"{label} i={i}: table-lookup diverged from per-sample reference"
);
}
}
}
fn fine_sync_power_signed_via_data_shift_reference(
cd0: &[Complex<f32>],
i: i32,
delf_hz: f32,
) -> f32 {
let dt2 = 1.0 / DS_RATE;
let mut shifted = vec![Complex::new(0.0_f32, 0.0); cd0.len()];
for (k, (c, o)) in cd0.iter().zip(shifted.iter_mut()).enumerate() {
let phi = -core::f32::consts::TAU * delf_hz * (k as f32) * dt2;
let rot = Complex::new(phi.cos(), phi.sin());
*o = *c * rot;
}
fine_sync_power_signed_reference(&shifted, i)
}
#[test]
fn tweak_matches_data_shift_reference_within_tolerance() {
const TWEAK_MAX_ERROR: f32 = 1e-5;
let ref_table = build_costas_ref_table();
let tones = costas_only_tones();
let pcm_f32 = tones_to_f32(&tones, 1500.0, 0.5);
let mut slot = vec![0i16; 15 * 12_000];
let start = (0.5_f32 * 12_000.0).round() as usize;
for (i, &s) in pcm_f32.iter().enumerate() {
if start + i < slot.len() {
slot[start + i] = (s * 16_000.0) as i16;
}
}
let (cd0, _) = downsample(&slot, 1500.0, None);
for ifr in -5_i32..=5 {
if ifr == 0 {
continue;
}
let delf = ifr as f32 * 0.5;
let tweak = build_tweak(delf);
for i in [-10_i32, -3, 0, 5, 12] {
let expected = fine_sync_power_signed_via_data_shift_reference(&cd0, i, delf);
let actual = fine_sync_power_signed(&cd0, i, &ref_table, Some(&tweak));
let denom = expected.max(1.0);
let rel_err = (actual - expected).abs() / denom;
assert!(
rel_err < TWEAK_MAX_ERROR,
"delf={delf} i={i}: tweak score {actual} vs data-shift reference \
{expected}, relative error {rel_err} exceeds tolerance {TWEAK_MAX_ERROR}"
);
}
}
}
fn costas_only_tones() -> [u8; 79] {
let mut t = [0u8; 79];
for (i, &c) in COSTAS.iter().enumerate() {
t[i] = c as u8; t[36 + i] = c as u8; t[72 + i] = c as u8; }
t
}
fn synth_slot(freq_hz: f32, dt_sec: f32) -> Vec<i16> {
let tones = costas_only_tones();
let pcm_f32 = tones_to_f32(&tones, freq_hz, 0.5);
let mut slot = vec![0i16; 15 * 12_000];
let start = ((0.5 + dt_sec) * 12_000.0).round() as isize;
for (i, &s) in pcm_f32.iter().enumerate() {
let dst = start + i as isize;
if (0..slot.len() as isize).contains(&dst) {
slot[dst as usize] = (s * 16_000.0) as i16;
}
}
slot
}
#[test]
fn freq_snap_zero_offset() {
let slot = synth_slot(1500.0, 0.0);
let (cd0, _) = downsample(&slot, 1500.0, None);
let r = fine_refine_3stage(&cd0, 0.0);
assert!(
r.delf_hz.abs() <= 0.5,
"expected |delf| ≤ 0.5, got {}",
r.delf_hz,
);
assert!(
r.dt_sec.abs() <= 0.02,
"expected |dt| ≤ 20 ms, got {}",
r.dt_sec,
);
assert!(r.score > 0.0, "score should be positive on signal");
}
#[test]
fn freq_snap_positive_offset() {
let slot = synth_slot(1500.7, 0.0);
let (cd0, _) = downsample(&slot, 1500.0, None);
let r = fine_refine_3stage(&cd0, 0.0);
let close_to_grid = (r.delf_hz - 0.5).abs() < 0.01 || (r.delf_hz - 1.0).abs() < 0.01;
assert!(
close_to_grid,
"expected delf snap to +0.5 or +1.0, got {}",
r.delf_hz,
);
}
#[test]
fn freq_snap_negative_offset() {
let slot = synth_slot(1500.0 - 1.3, 0.0);
let (cd0, _) = downsample(&slot, 1500.0, None);
let r = fine_refine_3stage(&cd0, 0.0);
let close_to_grid = (r.delf_hz - (-1.5)).abs() < 0.01 || (r.delf_hz - (-1.0)).abs() < 0.01;
assert!(
close_to_grid,
"expected delf snap to -1.5 or -1.0, got {}",
r.delf_hz,
);
}
#[test]
fn dt_snap_positive() {
let slot = synth_slot(1500.0, 0.04);
let (cd0, _) = downsample(&slot, 1500.0, None);
let r = fine_refine_3stage(&cd0, 0.0);
assert!(
(r.dt_sec - 0.04).abs() <= 0.015,
"expected dt ≈ 0.04, got {}",
r.dt_sec,
);
}
#[test]
fn no_signal_low_score() {
let slot_signal = synth_slot(1500.0, 0.0);
let (cd0_signal, _) = downsample(&slot_signal, 1500.0, None);
let s_signal = fine_refine_3stage(&cd0_signal, 0.0).score;
let slot_noise = vec![0i16; 15 * 12_000];
let (cd0_noise, _) = downsample(&slot_noise, 1500.0, None);
let s_noise = fine_refine_3stage(&cd0_noise, 0.0).score;
assert!(
s_signal > 5.0 * s_noise,
"signal score {} should dominate noise score {}",
s_signal,
s_noise,
);
}
}