mod auto_ap_strategy;
mod coarse_sync;
mod fill_symbol_spectra;
mod osd_strategy;
mod process_candidates;
mod spectrogram;
mod types;
pub use coarse_sync::{
coarse_allsum_len, coarse_sync, coarse_sync_with_allsum, precompute_coarse_allsum,
precompute_coarse_allsum_into,
};
#[cfg(not(feature = "fixed-point"))]
pub use fill_symbol_spectra::fill_symbol_spectra_generic;
#[cfg(feature = "fixed-point")]
pub use fill_symbol_spectra::{
BASIS_SCRATCH_LEN, fill_symbol_spectra_generic, fill_symbol_spectra_into,
fill_symbol_spectra_into_generic, symbol_spectra_direct_into,
};
pub use fill_symbol_spectra::{
SymMask, fill_symbol_spectra, fill_symbol_spectra_goertzel, goertzel_window_end_sample,
symbol_spectra_direct,
};
pub use process_candidates::process_candidates_into_with_cs_scratch_tuned_with_fill;
pub use process_candidates::{RefinedCandidate, decode_block, decode_block_tuned, xsnr2_db_simple};
#[cfg(feature = "fixed-point")]
pub use process_candidates::{
decode_block_into, decode_block_into_tuned, process_candidates_into,
process_candidates_into_tuned, process_candidates_into_with_cs_scratch,
process_candidates_into_with_cs_scratch_tuned, refine_candidates_into,
};
#[cfg(feature = "fft-rustfft")]
pub use process_candidates::{decode_block_with_ap, decode_block_with_ap_tuned};
#[doc(hidden)]
pub use process_candidates::{process_candidates, process_candidates_tuned, sync_quality_block0};
pub(in crate::ft8) use process_candidates::process_one_candidate_inner;
pub use spectrogram::{SpecCell, Spectrogram, compute_spectrogram};
pub use types::{AudioSample, DEFAULT_Q_THRESH, NFFT_SPEC};
#[cfg(test)]
mod tests {
use super::super::decode::DecodeDepth;
use super::super::params::{NMAX, NTONES};
use super::types::{SAMPLE_RATE_HZ, TONE_SPACING_HZ, TX_START_OFFSET_S};
use super::*;
use crate::core::scalar::Cmplx;
use crate::core::{MessageCodec, MessageFields};
use crate::ft8::wave_gen::{message_to_tones, tones_to_f32};
use crate::msg::Wsjt77Message;
fn pack_cq() -> [u8; 77] {
let bits = Wsjt77Message
.pack(&MessageFields {
call1: Some("CQ".into()),
call2: Some("JA1ABC".into()),
grid: Some("PM95".into()),
..Default::default()
})
.unwrap();
let mut out = [0u8; 77];
out.copy_from_slice(&bits);
out
}
fn synth_clean(msg77: &[u8; 77], freq_hz: f32) -> Vec<i16> {
let itone = message_to_tones(msg77);
let pcm = tones_to_f32(&itone, freq_hz, 0.5);
let mut slot = vec![0.0f32; NMAX];
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize;
let n = pcm.len().min(NMAX - start);
slot[start..start + n].copy_from_slice(&pcm[..n]);
slot.iter()
.map(|&s| (s * 25_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect()
}
#[test]
fn fill_coarse_allsum_column_by_column_matches_oneshot() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax) = (1550.0_f32, 3000.0_f32);
let oneshot = precompute_coarse_allsum(&spec, fmin, fmax);
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (fmin / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (fmax / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
let n_freq = ib - ia + 1;
let n_time = spec.n_time;
let mut col: Vec<f32> = vec![0.0; n_freq * n_time];
for m in 0..n_time {
for fi in 0..n_freq {
let i_carrier = ia + fi;
let mut s = 0.0_f32;
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(nh1 - 1);
s += spec.power_acc(bin, m);
}
col[fi * n_time + m] = s;
}
}
for fi in 0..n_freq {
for m in 0..n_time {
let a = oneshot[fi * n_time + m];
let b = col[fi * n_time + m];
assert!(
(a - b).abs() < 1e-3,
"fi={fi} m={m}: oneshot={a} col-by-col={b}"
);
}
}
}
#[test]
fn coarse_sync_with_allsum_tail_band_only() {
let msg = pack_cq();
let audio = synth_clean(&msg, 2000.0); let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (1550.0_f32, 3000.0_f32, 1.0_f32, 30usize);
let a = coarse_sync(&spec, fmin, fmax, smin, ncand);
let allsum = precompute_coarse_allsum(&spec, fmin, fmax);
let b = coarse_sync_with_allsum(&spec, fmin, fmax, smin, ncand, &allsum);
assert_eq!(
a.len(),
b.len(),
"tail band candidate count A={} B={}",
a.len(),
b.len()
);
for (ai, bi) in a.iter().zip(b.iter()) {
assert!(
(ai.freq_hz - bi.freq_hz).abs() < 1e-3 && (ai.score - bi.score).abs() < 1e-4,
"tail-band mismatch: A={ai:?} B={bi:?}"
);
}
}
#[test]
fn coarse_sync_split_with_allsum_per_half_busy_band() {
let msg = pack_cq();
let freqs = [400.0_f32, 1100.0, 1700.0, 2200.0, 2700.0];
let mut mix = vec![0.0f32; NMAX];
for (i, &f) in freqs.iter().enumerate() {
let itone = message_to_tones(&msg);
let pcm = tones_to_f32(&itone, f, 0.5);
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize + i * 100;
let n = pcm.len().min(NMAX - start);
for k in 0..n {
mix[start + k] += pcm[k];
}
}
let audio: Vec<i16> = mix
.iter()
.map(|&s| (s * 5_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect();
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3_000.0_f32, 1.0_f32, 30usize);
let mid = 0.5 * (fmin + fmax);
let head_a = coarse_sync(&spec, fmin, mid, smin, ncand);
let tail_a = coarse_sync(&spec, mid, fmax, smin, ncand);
let allsum_head = precompute_coarse_allsum(&spec, fmin, mid);
let allsum_tail = precompute_coarse_allsum(&spec, mid, fmax);
let head_b = coarse_sync_with_allsum(&spec, fmin, mid, smin, ncand, &allsum_head);
let tail_b = coarse_sync_with_allsum(&spec, mid, fmax, smin, ncand, &allsum_tail);
assert_eq!(head_a.len(), head_b.len());
assert_eq!(tail_a.len(), tail_b.len());
for (a, b) in head_a.iter().zip(head_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3 && (a.score - b.score).abs() < 1e-4,
"head per-half mismatch: A={a:?} B={b:?}"
);
}
for (a, b) in tail_a.iter().zip(tail_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3 && (a.score - b.score).abs() < 1e-4,
"tail per-half mismatch: A={a:?} B={b:?}"
);
}
}
#[test]
fn coarse_sync_split_with_allsum_busy_band() {
let msg = pack_cq();
let freqs = [400.0_f32, 1100.0, 1700.0, 2200.0, 2700.0];
let mut mix = vec![0.0f32; NMAX];
for (i, &f) in freqs.iter().enumerate() {
let itone = message_to_tones(&msg);
let pcm = tones_to_f32(&itone, f, 0.5);
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize + i * 100;
let n = pcm.len().min(NMAX - start);
for k in 0..n {
mix[start + k] += pcm[k];
}
}
let audio: Vec<i16> = mix
.iter()
.map(|&s| (s * 5_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect();
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3_000.0_f32, 1.0_f32, 30usize);
let mid = 0.5 * (fmin + fmax);
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let head_a = coarse_sync(&spec, fmin, mid, smin, ncand);
let tail_a = coarse_sync(&spec, mid, fmax, smin, ncand);
let allsum_full = precompute_coarse_allsum(&spec, fmin, fmax);
let allsum_ia = (fmin / df).round() as usize;
let head_len = coarse_allsum_len(spec.n_freq, spec.n_time, fmin, mid);
let tail_len = coarse_allsum_len(spec.n_freq, spec.n_time, mid, fmax);
let ia_head = (fmin / df).round() as usize;
let ia_tail = (mid / df).round() as usize;
let head_off = (ia_head - allsum_ia) * spec.n_time;
let tail_off = (ia_tail - allsum_ia) * spec.n_time;
let head_slice = &allsum_full[head_off..head_off + head_len];
let tail_slice = &allsum_full[tail_off..tail_off + tail_len];
let head_b = coarse_sync_with_allsum(&spec, fmin, mid, smin, ncand, head_slice);
let tail_b = coarse_sync_with_allsum(&spec, mid, fmax, smin, ncand, tail_slice);
assert_eq!(
head_a.len(),
head_b.len(),
"head candidate count differs: A={} B={}",
head_a.len(),
head_b.len()
);
assert_eq!(
tail_a.len(),
tail_b.len(),
"tail candidate count differs: A={} B={}",
tail_a.len(),
tail_b.len()
);
for (a, b) in head_a.iter().zip(head_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"head mismatch: A={a:?} B={b:?}"
);
}
for (a, b) in tail_a.iter().zip(tail_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"tail mismatch: A={a:?} B={b:?}"
);
}
}
#[test]
fn coarse_sync_with_allsum_matches_internal() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3000.0_f32, 1.0_f32, 30usize);
let internal = coarse_sync(&spec, fmin, fmax, smin, ncand);
let allsum = precompute_coarse_allsum(&spec, fmin, fmax);
assert_eq!(
allsum.len(),
coarse_allsum_len(spec.n_freq, spec.n_time, fmin, fmax),
"allsum length must match coarse_allsum_len"
);
let external = coarse_sync_with_allsum(&spec, fmin, fmax, smin, ncand, &allsum);
assert_eq!(internal.len(), external.len(), "candidate count differs");
for (a, b) in internal.iter().zip(external.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"candidate mismatch: internal={a:?} external={b:?}"
);
}
}
#[test]
fn roundtrip_clean_signal() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let results = decode_block(&audio, 100.0, 3000.0, 1.0, DecodeDepth::BpAll, 30);
assert!(
results.iter().any(|r| r.message77 == msg),
"decode_block should recover clean CQ; got {} results",
results.len()
);
}
#[test]
fn fill_q14i16_autogain_recovers_costas() {
use crate::core::scalar::Q14i16;
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let mut cs_q14: alloc::boxed::Box<[[Cmplx<Q14i16>; 8]; 79]> =
alloc::vec![[Cmplx::<Q14i16>::default(); 8]; 79]
.try_into()
.unwrap();
#[cfg(not(feature = "fixed-point"))]
fill_symbol_spectra_generic::<Q14i16, i16>(
&mut cs_q14,
&audio,
1500.0,
0.0,
SymMask::SyncBlock0,
);
#[cfg(feature = "fixed-point")]
fill_symbol_spectra_generic::<Q14i16, i16>(
&mut cs_q14,
&audio,
1500.0,
0.0,
SymMask::SyncBlock0,
);
let nonzero = cs_q14.iter().flatten().any(|c| c.re.0 != 0 || c.im.0 != 0);
assert!(nonzero, "Q14 autogain produced all-zero cs");
let q = sync_quality_block0(&cs_q14);
assert_eq!(q, 7, "expected perfect Costas block-0 hit, got q={q}");
}
}