use crate::engine::ModulationParams;
use crate::engine::spectrogram;
use super::sync_pattern::Q65_SYNC_POSITIONS;
pub type Spectrogram = spectrogram::Spectrogram;
pub const NSTEP_PER_SYMBOL: usize = 8;
pub fn build_spectrogram<P: ModulationParams>(audio: &[f32], sample_rate: u32) -> Spectrogram {
Spectrogram::build_for::<P>(audio, sample_rate, NSTEP_PER_SYMBOL)
}
#[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_early_sec: f32,
pub time_tolerance_late_sec: f32,
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_early_sec: 1.0,
time_tolerance_late_sec: 5.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);
spectrogram::score_candidate(
spec,
start_row,
base_bin,
&Q65_SYNC_POSITIONS,
rows_per_symbol,
)
}
pub fn coarse_search_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
let spec = build_spectrogram::<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::<super::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 rows_per_sec = sample_rate as f32 / spec.t_step.max(1) as f32;
let early_rows = (params.time_tolerance_early_sec.max(0.0) * rows_per_sec).round() as i64;
let late_rows = (params.time_tolerance_late_sec.max(0.0) * rows_per_sec).round() as i64;
let nominal_row = (nominal_start_sample / spec.t_step) as i64;
let row_min = (nominal_row - early_rows).max(0);
let row_max = nominal_row + late_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::<super::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");
}
}