use crate::engine::dsp::ddc::DdcCascadeConfig;
const SYMBOL_DT_S: f32 = 0.324;
pub fn choose_k(bandwidth_hz: f32) -> u32 {
let raw = (bandwidth_hz * SYMBOL_DT_S).ceil() as u32;
raw.max(1).next_power_of_two()
}
#[derive(Copy, Clone, Debug)]
pub struct Fst4DdcGrid {
pub k: u32,
pub fs_c: f32,
pub nfft1: usize,
}
pub fn grid_for(bandwidth_hz: f32) -> Fst4DdcGrid {
let k = choose_k(bandwidth_hz);
Fst4DdcGrid {
k,
fs_c: k as f32 / SYMBOL_DT_S,
nfft1: 2 * k as usize,
}
}
const SNIPER_STAGE1_NTAPS: usize = 49;
const SNIPER_STAGE1_DECIM: usize = 3;
const SNIPER_STAGE1_FC_NORM: f32 = 300.0 / 12_000.0;
const SNIPER_RESAMPLER_L: u32 = 16;
const SNIPER_RESAMPLER_M: u32 = 81;
const SNIPER_RESAMPLER_NTAPS: usize = 1707;
const WIDEBAND_RESAMPLER_L: u32 = 64;
const WIDEBAND_RESAMPLER_M: u32 = 243;
const WIDEBAND_RESAMPLER_NTAPS: usize = 10_241;
const HIST_MARGIN: usize = 512;
pub fn sniper_cascade(center_hz: f32) -> DdcCascadeConfig {
DdcCascadeConfig {
center_hz,
input_rate_hz: 12_000.0,
int_stages: alloc::vec![(
SNIPER_STAGE1_NTAPS,
SNIPER_STAGE1_DECIM,
SNIPER_STAGE1_FC_NORM
)],
resampler: (
SNIPER_RESAMPLER_L,
SNIPER_RESAMPLER_M,
SNIPER_RESAMPLER_NTAPS,
),
hist_margin: HIST_MARGIN,
}
}
pub fn wideband_cascade(center_hz: f32) -> DdcCascadeConfig {
DdcCascadeConfig {
center_hz,
input_rate_hz: 12_000.0,
int_stages: alloc::vec::Vec::new(),
resampler: (
WIDEBAND_RESAMPLER_L,
WIDEBAND_RESAMPLER_M,
WIDEBAND_RESAMPLER_NTAPS,
),
hist_margin: HIST_MARGIN,
}
}
const REFINE_L: u32 = 9;
const REFINE_M_SNIPER: u32 = 64;
const REFINE_M_WIDEBAND: u32 = 256;
const REFINE_NTAPS_SNIPER: usize = 949;
const REFINE_NTAPS_WIDEBAND: usize = 3793;
pub fn sniper_refine_recenter(
candidate_freq_hz: f32,
coarse_center_hz: f32,
) -> crate::engine::dsp::ddc::StreamingComplexRecenter {
let fs_c = grid_for(600.0).fs_c;
crate::engine::dsp::ddc::StreamingComplexRecenter::new(
candidate_freq_hz - coarse_center_hz,
fs_c,
REFINE_L,
REFINE_M_SNIPER,
REFINE_NTAPS_SNIPER,
HIST_MARGIN,
)
}
pub fn wideband_refine_recenter(
candidate_freq_hz: f32,
coarse_center_hz: f32,
) -> crate::engine::dsp::ddc::StreamingComplexRecenter {
let fs_c = grid_for(2900.0).fs_c;
crate::engine::dsp::ddc::StreamingComplexRecenter::new(
candidate_freq_hz - coarse_center_hz,
fs_c,
REFINE_L,
REFINE_M_WIDEBAND,
REFINE_NTAPS_WIDEBAND,
HIST_MARGIN,
)
}
pub const REFINE_DS_RATE_HZ: f32 = 111.111_11;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn symbol_dt_matches_fst4s60() {
use crate::engine::protocol::ModulationParams;
assert!(
(SYMBOL_DT_S - <super::super::Fst4s60 as ModulationParams>::SYMBOL_DT).abs() < 1e-6
);
}
#[test]
fn choose_k_matches_design_doc_table() {
assert_eq!(choose_k(600.0), 256, "sniper");
assert_eq!(choose_k(2900.0), 1024, "wideband");
}
#[test]
fn grid_for_matches_design_doc_table() {
let sniper = grid_for(600.0);
assert_eq!(sniper.k, 256);
assert_eq!(sniper.nfft1, 512);
assert!((sniper.fs_c - 790.123).abs() < 0.01, "{}", sniper.fs_c);
let wideband = grid_for(2900.0);
assert_eq!(wideband.k, 1024);
assert_eq!(wideband.nfft1, 2048);
assert!((wideband.fs_c - 3160.494).abs() < 0.01, "{}", wideband.fs_c);
}
#[test]
fn cascade_configs_hit_their_target_fs_c() {
let sniper = sniper_cascade(1500.0);
let sniper_grid = grid_for(600.0);
let sniper_decim: usize = sniper.int_stages.iter().map(|&(_, d, _)| d).product();
let sniper_fs_c = sniper.input_rate_hz / sniper_decim as f32 * SNIPER_RESAMPLER_L as f32
/ SNIPER_RESAMPLER_M as f32;
assert!(
(sniper_fs_c - sniper_grid.fs_c).abs() < 0.01,
"sniper cascade Fs_c {sniper_fs_c} != grid Fs_c {}",
sniper_grid.fs_c
);
let wideband = wideband_cascade(1500.0);
assert!(wideband.int_stages.is_empty());
let wideband_grid = grid_for(2900.0);
let wideband_fs_c =
wideband.input_rate_hz * WIDEBAND_RESAMPLER_L as f32 / WIDEBAND_RESAMPLER_M as f32;
assert!(
(wideband_fs_c - wideband_grid.fs_c).abs() < 0.01,
"wideband cascade Fs_c {wideband_fs_c} != grid Fs_c {}",
wideband_grid.fs_c
);
}
#[test]
fn ddc_coarse_sync_matches_real_path_on_synth_signal() {
use crate::engine::dsp::ddc::ddc_block;
use crate::engine::sync::{AudioSource, RxGrid, coarse_sync};
use crate::fst4::Fst4s60;
use crate::fst4::encode::{message_to_tones, tones_to_i16};
use crate::msg::wsjt77::pack77;
let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack77");
let tones = message_to_tones(&msg77);
let target_freq = 1500.0f32;
let audio = tones_to_i16(&tones, target_freq, 10_000);
let mut slot = alloc::vec![0i16; 60 * 12_000];
let offset = 12_000;
let copy_len = audio.len().min(slot.len() - offset);
slot[offset..offset + copy_len].copy_from_slice(&audio[..copy_len]);
let real_cands = coarse_sync::<Fst4s60>(
AudioSource::Real(&slot),
target_freq - 250.0,
target_freq + 250.0,
0.0,
None,
10,
RxGrid::real(12_000.0),
);
let real_best = real_cands
.iter()
.max_by(|a, b| a.score.partial_cmp(&b.score).unwrap())
.expect("real path found no candidate at all");
let cfg = sniper_cascade(target_freq);
let (audio_i, audio_q) = ddc_block(&slot, &cfg);
let grid = grid_for(600.0);
let ddc_cands = coarse_sync::<Fst4s60>(
AudioSource::Complex(&audio_i, &audio_q),
target_freq - 250.0,
target_freq + 250.0,
0.0,
None,
10,
RxGrid::complex(grid.fs_c, target_freq),
);
let ddc_best = ddc_cands
.iter()
.max_by(|a, b| a.score.partial_cmp(&b.score).unwrap())
.expect("DDC path found no candidate at all");
assert!(
(ddc_best.freq_hz - real_best.freq_hz).abs() < 2.0,
"real best freq={} Hz, DDC best freq={} Hz",
real_best.freq_hz,
ddc_best.freq_hz
);
let noise_floor_headroom = 20.0;
assert!(
ddc_best.score > noise_floor_headroom,
"DDC best score={} looks noise-floor-scale, not a detected signal",
ddc_best.score
);
assert!(
ddc_best.score > real_best.score * 0.2,
"real best score={}, DDC best score={} — margin cost far past what §4.3's \
estimated tap counts should plausibly produce",
real_best.score,
ddc_best.score
);
}
#[test]
fn wideband_cascade_single_tone_lands_near_dc() {
use crate::engine::dsp::ddc::ddc_block;
let center = 1550.0f32;
let cfg = wideband_cascade(center);
let n = 240_000; let w = 2.0 * core::f64::consts::PI * center as f64 / 12_000.0;
let audio: Vec<i16> = (0..n)
.map(|k| (8000.0 * (w * k as f64).cos()) as i16)
.collect();
let (i, q) = ddc_block(&audio, &cfg);
let grid = grid_for(2900.0);
let expected_len = (n as f32 * grid.fs_c / 12_000.0) as usize;
assert!(
i.len().abs_diff(expected_len) < expected_len / 4,
"got {} outputs, expected ~{expected_len}",
i.len()
);
let settle = i.len() / 3;
let span = settle..(i.len() - settle);
let peak = span
.map(|k| (i[k] * i[k] + q[k] * q[k]).sqrt())
.fold(0.0f32, f32::max);
assert!(peak > 500.0, "centre tone peak magnitude too small: {peak}");
}
#[test]
fn refine_ratio_hits_ds_rate() {
let sniper_out = grid_for(600.0).fs_c * REFINE_L as f32 / REFINE_M_SNIPER as f32;
assert!(
(sniper_out - REFINE_DS_RATE_HZ).abs() < 0.01,
"sniper refine out = {sniper_out}"
);
let wideband_out = grid_for(2900.0).fs_c * REFINE_L as f32 / REFINE_M_WIDEBAND as f32;
assert!(
(wideband_out - REFINE_DS_RATE_HZ).abs() < 0.01,
"wideband refine out = {wideband_out}"
);
}
#[test]
fn ddc_refine_matches_downsample_cached_reference() {
use crate::engine::dsp::ddc::StreamingComplexDdc;
use crate::engine::dsp::downsample::{build_fft_cache, downsample_cached};
use crate::engine::sync::{SyncCandidate, refine_candidate};
use crate::fst4::Fst4s60;
use crate::fst4::decode::FST4_60A_DOWNSAMPLE;
use crate::fst4::encode::{message_to_tones, tones_to_i16};
use crate::msg::wsjt77::pack77;
let msg77 = pack77("CQ", "JA1ABC", "PM95").expect("pack77");
let tones = message_to_tones(&msg77);
let target_freq = 1500.0f32;
let audio = tones_to_i16(&tones, target_freq, 10_000);
let mut slot = alloc::vec![0i16; 60 * 12_000];
let offset = 12_000; let copy_len = audio.len().min(slot.len() - offset);
slot[offset..offset + copy_len].copy_from_slice(&audio[..copy_len]);
let fft_cache = build_fft_cache(&slot, &FST4_60A_DOWNSAMPLE);
let cd0_ref = downsample_cached(&fft_cache, target_freq, &FST4_60A_DOWNSAMPLE);
const SEARCH_STEPS: i32 = 200;
let ref_refined = refine_candidate::<Fst4s60>(
&cd0_ref,
&SyncCandidate {
freq_hz: target_freq,
dt_sec: 0.0,
score: 0.0,
},
SEARCH_STEPS,
);
let coarse_cfg = sniper_cascade(target_freq);
let mut coarse = StreamingComplexDdc::new(&coarse_cfg);
let mut coarse_i = Vec::new();
let mut coarse_q = Vec::new();
coarse.push_i16(&slot, &mut coarse_i, &mut coarse_q);
coarse.flush(&mut coarse_i, &mut coarse_q);
let coarse_delay_orig = coarse.group_delay_input_samples();
let mut refine = sniper_refine_recenter(target_freq, target_freq);
let mut cd0_ddc_i = Vec::new();
let mut cd0_ddc_q = Vec::new();
refine.push(&coarse_i, &coarse_q, &mut cd0_ddc_i, &mut cd0_ddc_q);
refine.flush(&mut cd0_ddc_i, &mut cd0_ddc_q);
let refine_delay_out = refine.group_delay_output_samples();
let total_delay_out = (coarse_delay_orig as f32 * REFINE_DS_RATE_HZ / 12_000.0).round()
as usize
+ refine_delay_out;
let trim = total_delay_out.min(cd0_ddc_i.len());
let cd0_ddc: Vec<num_complex::Complex<f32>> = cd0_ddc_i[trim..]
.iter()
.zip(cd0_ddc_q[trim..].iter())
.map(|(&i, &q)| num_complex::Complex::new(i, q))
.collect();
let ddc_refined = refine_candidate::<Fst4s60>(
&cd0_ddc,
&SyncCandidate {
freq_hz: target_freq,
dt_sec: 0.0,
score: 0.0,
},
SEARCH_STEPS,
);
assert!(
(ddc_refined.dt_sec - ref_refined.dt_sec).abs() < 0.1,
"reference dt_sec={}, DDC dt_sec={} (trim={trim} samples)",
ref_refined.dt_sec,
ddc_refined.dt_sec
);
use crate::engine::sync::{SyncDims, fine_sync_power};
let ds_rate = SyncDims::of::<Fst4s60>(12_000.0).ds_rate;
let peak_i0 = ((ddc_refined.dt_sec
+ <Fst4s60 as crate::engine::FrameLayout>::TX_START_OFFSET_S)
* ds_rate)
.round() as i32;
let off_peak = fine_sync_power::<Fst4s60>(&cd0_ddc, peak_i0 + 30);
assert!(
ddc_refined.score > off_peak * 3.0,
"DDC peak score={} not distinguishable from off-peak score={off_peak}",
ddc_refined.score
);
}
}