use crate::engine::ModulationParams;
use num_complex::Complex;
use rustfft::FftPlanner;
use super::Q65a30;
use super::sync_pattern::Q65_SYNC_POSITIONS;
pub struct Spectrogram {
pub mags_sqr: Vec<f32>,
pub n_time: usize,
pub n_freq: usize,
pub t_step: usize,
pub nsps: usize,
pub df: f32,
pub noise_per_bin: f32,
}
impl Spectrogram {
pub fn build_for<P: ModulationParams>(audio: &[f32], sample_rate: u32) -> Self {
const NSTEP_PER_SYMBOL: usize = 8;
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let t_step = (nsps / NSTEP_PER_SYMBOL).max(1);
let n_freq = nsps / 2;
if audio.len() < nsps || t_step == 0 {
return Self {
mags_sqr: Vec::new(),
n_time: 0,
n_freq: 0,
t_step: 0,
nsps,
df: sample_rate as f32 / nsps as f32,
noise_per_bin: 1.0,
};
}
let n_time = (audio.len() - nsps) / t_step + 1;
let mut mags_sqr = vec![0f32; n_time * n_freq];
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(nsps);
let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
let mut buf: Vec<Complex<f32>> = vec![Complex::new(0f32, 0f32); nsps];
for t in 0..n_time {
let start = t * t_step;
for (slot, &s) in buf.iter_mut().zip(&audio[start..start + nsps]) {
*slot = Complex::new(s, 0.0);
}
fft.process_with_scratch(&mut buf, &mut scratch);
let row = &mut mags_sqr[t * n_freq..(t + 1) * n_freq];
for (slot, c) in row.iter_mut().zip(buf.iter().take(n_freq)) {
*slot = c.norm_sqr();
}
}
let mut sorted = mags_sqr.clone();
let keep = (sorted.len() as f32 * 0.95) as usize;
let noise_per_bin = if keep > 0 {
sorted.select_nth_unstable_by(keep - 1, |a, b| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
});
sorted[..keep].iter().sum::<f32>() / keep as f32
} else {
1.0
};
Self {
mags_sqr,
n_time,
n_freq,
t_step,
nsps,
df: sample_rate as f32 / nsps as f32,
noise_per_bin: noise_per_bin.max(1e-6),
}
}
pub fn build(audio: &[f32], sample_rate: u32) -> Self {
Self::build_for::<Q65a30>(audio, sample_rate)
}
#[inline]
pub fn get(&self, t: usize, f: usize) -> f32 {
self.mags_sqr[t * self.n_freq + f]
}
}
#[derive(Clone, Copy, Debug)]
pub struct SyncCandidate {
pub start_sample: usize,
pub freq_hz: f32,
pub score: f32,
}
pub const DEFAULT_SCORE_THRESHOLD: f32 = 0.1;
#[derive(Clone, Copy, Debug)]
pub struct SearchParams {
pub freq_min_hz: f32,
pub freq_max_hz: f32,
pub time_tolerance_symbols: u32,
pub score_threshold: f32,
pub max_candidates: usize,
}
impl Default for SearchParams {
fn default() -> Self {
Self {
freq_min_hz: 200.0,
freq_max_hz: 3_000.0,
time_tolerance_symbols: 5,
score_threshold: DEFAULT_SCORE_THRESHOLD,
max_candidates: 8,
}
}
}
pub fn score_candidate(spec: &Spectrogram, start_row: usize, base_bin: usize) -> f32 {
let rows_per_symbol = (spec.nsps / spec.t_step).max(1);
let last_row = start_row + (Q65_SYNC_POSITIONS[21] as usize) * rows_per_symbol;
if last_row >= spec.n_time || base_bin >= spec.n_freq {
return 0.0;
}
let mut sync_pwr = 0.0_f32;
for &sym_idx in &Q65_SYNC_POSITIONS {
let row = start_row + (sym_idx as usize) * rows_per_symbol;
sync_pwr += spec.get(row, base_bin);
}
let noise_floor = spec.noise_per_bin * Q65_SYNC_POSITIONS.len() as f32;
sync_pwr / (sync_pwr + noise_floor)
}
pub fn coarse_search_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
let spec = Spectrogram::build_for::<P>(audio, sample_rate);
coarse_search_on_spec_for::<P>(&spec, sample_rate, nominal_start_sample, params)
}
pub fn coarse_search(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
coarse_search_for::<Q65a30>(audio, sample_rate, nominal_start_sample, params)
}
pub fn coarse_search_on_spec_for<P: ModulationParams>(
spec: &Spectrogram,
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
if spec.n_time == 0 {
return Vec::new();
}
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let rows_per_symbol = (nsps / spec.t_step.max(1)).max(1);
let bins_per_tone = (P::TONE_SPACING_HZ / df).round() as usize;
let t_span_rows = params.time_tolerance_symbols as i64 * rows_per_symbol as i64;
let nominal_row = (nominal_start_sample / spec.t_step) as i64;
let row_min = (nominal_row - t_span_rows).max(0);
let row_max = nominal_row + t_span_rows;
let fmin_bin = (params.freq_min_hz / df).floor() as i64;
let fmax_bin = (params.freq_max_hz / df).ceil() as i64;
let fb_lo = fmin_bin.max(0) as usize;
let fb_hi = fmax_bin.max(fmin_bin) as usize;
let mut curve: Vec<f32> = vec![0.0; fb_hi.saturating_sub(fb_lo) + 1];
let mut rows: Vec<usize> = vec![0; curve.len()];
for fb in fb_lo..=fb_hi {
if fb + 64 * bins_per_tone + 1 > spec.n_freq {
continue;
}
let mut best: Option<(usize, f32)> = None;
for row in row_min..=row_max {
if row < 0 {
continue;
}
let row = row as usize;
if row + 84 * rows_per_symbol >= spec.n_time {
continue;
}
let score = score_candidate(spec, row, fb);
if best.is_none_or(|(_, best_score)| score > best_score) {
best = Some((row, score));
}
}
if let Some((row, score)) = best {
let idx = fb - fb_lo;
curve[idx] = score;
rows[idx] = row;
}
}
let ave = percentile(&curve, 50);
let base = percentile(&curve, 84);
let rms = base - ave;
let use_adaptive = rms.is_finite() && rms > 1e-6;
const SNR_ADMIT: f32 = 6.0;
let mut out: Vec<SyncCandidate> = Vec::new();
for (idx, &score) in curve.iter().enumerate() {
if score <= 0.0 {
continue;
}
let admitted =
score >= params.score_threshold || (use_adaptive && (score - ave) / rms >= SNR_ADMIT);
if !admitted {
continue;
}
let lo = idx.saturating_sub(bins_per_tone);
let hi = (idx + bins_per_tone).min(curve.len() - 1);
let is_local_max = curve[lo..=hi].iter().all(|&other| other <= score);
if !is_local_max {
continue;
}
out.push(SyncCandidate {
start_sample: rows[idx] * spec.t_step,
freq_hz: (fb_lo + idx) as f32 * df,
score,
});
}
out.sort_unstable_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
out.truncate(params.max_candidates);
out
}
fn percentile(values: &[f32], pct: u32) -> f32 {
if values.is_empty() {
return 0.0;
}
let mut sorted: Vec<f32> = values.to_vec();
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let j = ((n as f32 * 0.01 * pct as f32).round() as usize)
.max(1)
.min(n);
sorted[j - 1]
}
pub fn coarse_search_on_spec(
spec: &Spectrogram,
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
coarse_search_on_spec_for::<Q65a30>(spec, sample_rate, nominal_start_sample, params)
}
#[cfg(test)]
mod tests {
use super::super::tx::synthesize_standard;
use super::*;
#[test]
fn coarse_search_finds_clean_signal() {
let freq = 1500.0;
let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
let cands = coarse_search(&audio, 12_000, 0, &SearchParams::default());
assert!(!cands.is_empty(), "search should find a clean signal");
let best = cands[0];
assert!(
(best.freq_hz - freq).abs() <= 4.0,
"best freq {} should be near {freq} Hz",
best.freq_hz
);
assert_eq!(best.start_sample, 0, "clean synth starts at sample 0");
assert!(best.score > 0.5, "clean signal should score > 0.5");
}
}