use alloc::vec::Vec;
#[cfg(not(feature = "std"))]
use num_traits::Float;
use crate::core::ModulationParams;
use super::Wspr;
use super::spectrogram::{Spectrogram, score_candidate};
#[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: 1400.0,
freq_max_hz: 1600.0,
time_tolerance_symbols: 8,
score_threshold: DEFAULT_SCORE_THRESHOLD,
max_candidates: 16,
}
}
}
pub fn coarse_search(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
let spec = Spectrogram::build(audio, sample_rate);
coarse_search_on_spec(&spec, sample_rate, nominal_start_sample, params)
}
pub fn coarse_search_on_spec(
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 * <Wspr as ModulationParams>::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let rows_per_symbol = 4usize;
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 mut out: Vec<SyncCandidate> = Vec::new();
for row in row_min..=row_max {
if row < 0 {
continue;
}
let row = row as usize;
if row + 161 * rows_per_symbol >= spec.n_time {
continue;
}
for fb in fmin_bin..=fmax_bin {
if fb < 0 {
continue;
}
let base_bin = fb as usize;
if base_bin + 4 > spec.n_freq {
continue;
}
let score = score_candidate(spec, row, base_bin);
if score >= params.score_threshold {
out.push(SyncCandidate {
start_sample: row * spec.t_step,
freq_hz: fb as f32 * df,
score,
});
}
}
}
out.sort_unstable_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(core::cmp::Ordering::Equal)
});
out.truncate(params.max_candidates);
out
}
#[cfg(test)]
mod tests {
use super::super::synthesize_type1;
use super::*;
#[test]
fn finds_aligned_tone_at_nominal_anchor() {
let freq = 1500.0;
let audio = synthesize_type1("K1ABC", "FN42", 37, 12_000, freq, 0.3).expect("synth");
let params = SearchParams::default();
let cands = coarse_search(&audio, 12_000, 0, ¶ms);
assert!(!cands.is_empty(), "should find at least one candidate");
let best = cands[0];
assert!(
(best.freq_hz - 1500.0).abs() <= 2.0,
"best freq {} should be near 1500 Hz",
best.freq_hz
);
assert_eq!(best.start_sample, 0, "alignment should land exactly at t=0");
assert!(best.score > 0.9, "clean synthesis should score near 1.0");
}
#[test]
fn finds_offset_start_within_tolerance() {
let freq = 1500.0;
let mut audio = vec![0f32; 3 * 8192];
let body = synthesize_type1("K9AN", "EN50", 33, 12_000, freq, 0.3).expect("synth");
audio.extend_from_slice(&body);
let params = SearchParams::default();
let cands = coarse_search(&audio, 12_000, 0, ¶ms);
assert!(!cands.is_empty(), "expected candidates with offset signal");
let best = cands[0];
assert_eq!(
best.start_sample,
3 * 8192,
"best candidate should land at 3-symbol offset"
);
}
}