use crate::engine::ModulationParams;
use crate::engine::spectrogram;
use crate::engine::sync::refine_freq_hz_log_power;
use super::Jt9;
use super::sync_pattern::JT9_SYNC_POSITIONS;
pub type Spectrogram = spectrogram::Spectrogram;
const NSTEP_PER_SYMBOL: usize = 4;
fn build_spectrogram(audio: &[f32], sample_rate: u32) -> Spectrogram {
Spectrogram::build_for::<Jt9>(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_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: 4000.0,
time_tolerance_sec: 1.728,
score_threshold: DEFAULT_SCORE_THRESHOLD,
max_candidates: 8,
}
}
}
const ROWS_PER_SYMBOL: usize = NSTEP_PER_SYMBOL;
fn sync_power_at_bin(spec: &Spectrogram, start_row: usize, bin: usize) -> f32 {
spectrogram::sync_power_at_bin(spec, start_row, bin, &JT9_SYNC_POSITIONS, ROWS_PER_SYMBOL)
}
pub fn score_candidate(spec: &Spectrogram, start_row: usize, base_bin: usize) -> f32 {
spectrogram::score_candidate(
spec,
start_row,
base_bin,
&JT9_SYNC_POSITIONS,
ROWS_PER_SYMBOL,
)
}
fn refine_freq_hz(spec: &Spectrogram, start_row: usize, base_bin: usize, df: f32) -> f32 {
refine_freq_hz_log_power(base_bin, spec.n_freq, df, |bin| {
sync_power_at_bin(spec, start_row, bin)
})
}
pub fn coarse_search(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &SearchParams,
) -> Vec<SyncCandidate> {
let spec = build_spectrogram(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 * <Jt9 as ModulationParams>::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let t_span_rows =
(params.time_tolerance_sec * sample_rate as f32 / spec.t_step.max(1) as f32).round() 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 fb in fmin_bin..=fmax_bin {
if fb < 0 || (fb as usize) + 9 > spec.n_freq {
continue;
}
let mut best_row: i64 = -1;
let mut best_score = f32::NEG_INFINITY;
for row in row_min..=row_max {
if row < 0 {
continue;
}
let row_u = row as usize;
if row_u + 84 * ROWS_PER_SYMBOL >= spec.n_time {
continue;
}
let score = score_candidate(spec, row_u, fb as usize);
if score > best_score {
best_score = score;
best_row = row;
}
}
if best_row >= 0 && best_score >= params.score_threshold {
out.push(SyncCandidate {
start_sample: best_row as usize * spec.t_step,
freq_hz: refine_freq_hz(spec, best_row as usize, fb as usize, df),
score: best_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
}
#[cfg(test)]
mod tests {
use super::super::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(), "expected at least one candidate");
let best = cands[0];
assert!(
(best.freq_hz - 1500.0).abs() <= 3.0,
"best freq {} should be near 1500 Hz",
best.freq_hz
);
assert_eq!(best.start_sample, 0);
assert!(best.score > 0.5, "clean score was {}", best.score);
}
}
#[cfg(test)]
mod diag_tests {
use super::*;
use std::path::Path;
#[test]
#[ignore]
fn jt9_coarse_diag() {
let path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
));
if !path.exists() {
eprintln!("WAV not found");
return;
}
let bytes = std::fs::read(path).unwrap();
let data_len = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]) as usize;
let data = &bytes[44..44 + data_len];
let audio: Vec<f32> = data
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
let params = SearchParams {
freq_min_hz: 1050.0,
freq_max_hz: 1500.0,
time_tolerance_sec: 1.728,
score_threshold: 0.001,
max_candidates: 5000,
};
let cands = coarse_search(&audio, 12_000, 0, ¶ms);
eprintln!("Total candidates above 0.001: {}", cands.len());
for golden_hz in &[1119.0f32, 1186.0, 1224.0, 1290.0, 1346.0] {
let near: Vec<_> = cands
.iter()
.filter(|c| (c.freq_hz - golden_hz).abs() < 5.0)
.collect();
eprintln!("Near {} Hz: {} cands", golden_hz, near.len());
for c in near.iter().take(3) {
eprintln!(
" freq={:.1} start_s={:.2} score={:.4}",
c.freq_hz,
c.start_sample as f32 / 12000.0,
c.score
);
}
}
eprintln!("Top 20:");
for c in cands.iter().take(20) {
eprintln!(
" freq={:.1} start_s={:.2} score={:.4}",
c.freq_hz,
c.start_sample as f32 / 12000.0,
c.score
);
}
}
}