use alloc::string::{String, ToString};
use alloc::vec::Vec;
#[cfg(not(feature = "std"))]
use num_traits::Float;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
pub use super::equalizer::EqMode;
use super::{
downsample::build_fft_cache,
equalizer,
llr::sync_quality,
message::pack28,
params::{BP_MAX_ITER, LDPC_N},
subtract::subtract_signal_lpf,
sync::SyncCandidate,
};
pub type FftCache = Vec<num_complex::Complex<f32>>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DecodeDepth {
BpAll,
BpAllOsd,
BpAllNoNsym3,
BpVariantsAd,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum DecodeStrictness {
Strict,
#[default]
Normal,
Deep,
}
impl DecodeStrictness {
pub fn osd_max_errors(self, osd_depth: u8) -> u32 {
match (self, osd_depth) {
(Self::Strict, 3) => 20,
(Self::Strict, 4) => 24,
(Self::Strict, _) => 22,
(Self::Normal, 3) => 26,
(Self::Normal, 4) => 30,
(Self::Normal, _) => 29,
(Self::Deep, 3) => 30,
(Self::Deep, 4) => 36,
(Self::Deep, _) => 40,
}
}
pub fn ap_max_errors(self, locked_bits: usize) -> u32 {
match (self, locked_bits >= 55) {
(Self::Strict, true) => 20,
(Self::Strict, false) => 24,
(Self::Normal, true) => 25,
(Self::Normal, false) => 30,
(Self::Deep, true) => 30,
(Self::Deep, false) => 36,
}
}
pub fn osd_score_min(self) -> f32 {
match self {
Self::Strict => 3.0,
Self::Normal => 2.2,
Self::Deep => 2.0,
}
}
}
#[derive(Debug, Clone)]
pub struct DecodeResult {
pub message77: [u8; 77],
pub freq_hz: f32,
pub dt_sec: f32,
pub hard_errors: u32,
pub sync_score: f32,
pub pass: u8,
pub sync_cv: f32,
pub snr_db: f32,
}
#[derive(Debug, Clone, Default)]
pub struct ApHint {
pub call1: Option<String>,
pub call2: Option<String>,
pub grid: Option<String>,
pub report: Option<String>,
}
impl ApHint {
pub fn new() -> Self {
Self::default()
}
pub fn with_call1(mut self, call: &str) -> Self {
self.call1 = Some(call.to_string());
self
}
pub fn with_call2(mut self, call: &str) -> Self {
self.call2 = Some(call.to_string());
self
}
pub fn with_grid(mut self, grid: &str) -> Self {
self.grid = Some(grid.to_string());
self
}
pub fn with_report(mut self, rpt: &str) -> Self {
self.report = Some(rpt.to_string());
self
}
pub fn has_info(&self) -> bool {
self.call1.is_some() || self.call2.is_some()
}
pub fn build_ap(&self, apmag: f32) -> ([bool; LDPC_N], [f32; LDPC_N]) {
let mut mask = [false; LDPC_N];
let mut ap_llr = [0.0f32; LDPC_N];
let mut set_call_bits = |call: &str, start: usize| {
if let Some(n28) = pack28(call) {
for i in 0..28 {
let bit = ((n28 >> (27 - i)) & 1) as u8;
mask[start + i] = true;
ap_llr[start + i] = if bit == 1 { apmag } else { -apmag };
}
mask[start + 28] = true;
ap_llr[start + 28] = -apmag; }
};
if let Some(ref c1) = self.call1 {
set_call_bits(c1, 0); }
if let Some(ref c2) = self.call2 {
set_call_bits(c2, 29); }
if let Some(ref grid) = self.grid
&& let Some(igrid) = super::message::pack_grid4(grid)
{
mask[58] = true;
ap_llr[58] = -apmag; for i in 0..15 {
let bit = ((igrid >> (14 - i)) & 1) as u8;
mask[59 + i] = true;
ap_llr[59 + i] = if bit == 1 { apmag } else { -apmag };
}
}
if let Some(ref rpt) = self.report {
let igrid_val: Option<u32> = match rpt.as_str() {
"RRR" => Some(32_400 + 2),
"RR73" => Some(32_400 + 3),
"73" => Some(32_400 + 4),
_ => None,
};
if let Some(igrid) = igrid_val {
mask[58] = true;
ap_llr[58] = -apmag; for i in 0..15 {
let bit = ((igrid >> (14 - i)) & 1) as u8;
mask[59 + i] = true;
ap_llr[59 + i] = if bit == 1 { apmag } else { -apmag };
}
}
}
if self.has_info() {
mask[74] = true;
ap_llr[74] = -apmag; mask[75] = true;
ap_llr[75] = -apmag; mask[76] = true;
ap_llr[76] = apmag; }
(mask, ap_llr)
}
}
pub fn decode_frame(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
) -> Vec<DecodeResult> {
decode_frame_inner(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
DecodeStrictness::Normal,
&[],
EqMode::Off,
None,
None,
)
.0
}
pub fn decode_frame_with_ap(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
ap_hint: Option<&ApHint>,
) -> Vec<DecodeResult> {
decode_frame_with_ap_full(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
DecodeStrictness::Normal,
max_cand,
ap_hint,
)
.0
}
pub fn decode_frame_with_ap_full(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
strictness: DecodeStrictness,
max_cand: usize,
ap_hint: Option<&ApHint>,
) -> (Vec<DecodeResult>, FftCache) {
decode_frame_inner(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
strictness,
&[],
EqMode::Off,
None,
ap_hint,
)
}
pub fn decode_frame_with_cache(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
) -> (Vec<DecodeResult>, FftCache) {
decode_frame_inner(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
DecodeStrictness::Normal,
&[],
EqMode::Off,
None,
None,
)
}
fn process_candidate(
cand: &SyncCandidate,
audio: &[i16],
fft_cache: &[num_complex::Complex<f32>],
depth: DecodeDepth,
strictness: DecodeStrictness,
known: &[DecodeResult],
eq_mode: EqMode,
ap_hint: Option<&ApHint>,
) -> Option<DecodeResult> {
let _ = strictness; let mut cd0 = crate::core::dsp::downsample::downsample_cached(
fft_cache,
cand.freq_hz,
&crate::ft8::downsample::FT8_CFG,
);
let refine_result = crate::ft8::refine_fine::fine_refine_3stage(&cd0, cand.dt_sec);
let refined = SyncCandidate {
freq_hz: cand.freq_hz + refine_result.delf_hz,
dt_sec: refine_result.dt_sec,
score: refine_result.score,
};
if refine_result.delf_hz.abs() > f32::EPSILON {
let dt2 = 1.0_f32 / 200.0;
for (k, c) in cd0.iter_mut().enumerate() {
let phi = -core::f32::consts::TAU * refine_result.delf_hz * (k as f32) * dt2;
let rot = num_complex::Complex::new(phi.cos(), phi.sin());
*c *= rot;
}
}
let i_start = ((refined.dt_sec + 0.5) * 200.0).round() as i32;
let sync_cv = {
let scores = crate::core::sync::fine_sync_power_per_block::<crate::ft8::Ft8>(&cd0, i_start);
let sa = scores.first().copied().unwrap_or(0.0);
let sb = scores.get(1).copied().unwrap_or(0.0);
let sc = scores.get(2).copied().unwrap_or(0.0);
let mean = (sa + sb + sc) / 3.0;
if mean > f32::EPSILON {
let sq = (sa - mean).powi(2) + (sb - mean).powi(2) + (sc - mean).powi(2);
sq.sqrt() / mean
} else {
0.0
}
};
drop(cd0);
let mut cs_raw: alloc::boxed::Box<[[crate::core::scalar::Cmplx<f32>; 8]; 79]> =
alloc::vec![[crate::core::scalar::Cmplx::<f32>::default(); 8]; 79]
.try_into()
.unwrap();
crate::ft8::decode_block::fill_symbol_spectra(
&mut cs_raw,
audio,
refined.freq_hz,
refined.dt_sec,
crate::ft8::decode_block::SymMask::SyncOnly,
Some(fft_cache),
);
crate::ft8::decode_block::fill_symbol_spectra(
&mut cs_raw,
audio,
refined.freq_hz,
refined.dt_sec,
crate::ft8::decode_block::SymMask::DataOnly,
Some(fft_cache),
);
let nsync = sync_quality(&cs_raw);
if nsync <= 6 {
return None;
}
let try_decode =
|cs: &[[crate::core::scalar::Cmplx<f32>; 8]; 79], _use_ap: bool| -> Option<DecodeResult> {
#[cfg(feature = "fixed-point")]
let mut bp_scratch = crate::fec::ldpc::bp::BpScratch::<
crate::fec::ldpc::params::Ldpc174_91Params,
crate::core::scalar::Q11i16,
>::new();
#[cfg(not(feature = "fixed-point"))]
let mut bp_scratch = crate::fec::ldpc::bp::BpScratch::<
crate::fec::ldpc::params::Ldpc174_91Params,
f32,
>::new();
crate::ft8::decode_block::process_one_candidate_inner(
cs,
&refined,
refined.dt_sec,
nsync,
depth,
BP_MAX_ITER,
&mut bp_scratch,
known,
ap_hint,
strictness,
sync_cv,
)
};
match eq_mode {
EqMode::Off => try_decode(&cs_raw, true),
EqMode::Local => {
let mut cs_eq = cs_raw.clone();
equalizer::equalize_local(&mut cs_eq);
try_decode(&cs_eq, true)
}
}
}
fn decode_frame_inner(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
known: &[DecodeResult],
eq_mode: EqMode,
precomputed_fft: Option<&[num_complex::Complex<f32>]>,
ap_hint: Option<&ApHint>,
) -> (Vec<DecodeResult>, Vec<num_complex::Complex<f32>>) {
let _ = freq_hint;
let spec = crate::ft8::decode_block::compute_spectrogram(audio, freq_max);
let candidates =
crate::ft8::decode_block::coarse_sync(&spec, freq_min, freq_max, sync_min, max_cand);
let fft_cache = match precomputed_fft {
Some(c) => c.to_vec(),
None => build_fft_cache(audio),
};
if candidates.is_empty() {
return (Vec::new(), fft_cache);
}
#[cfg(feature = "parallel")]
let raw: Vec<DecodeResult> = candidates
.par_iter()
.filter_map(|cand| {
process_candidate(
cand, audio, &fft_cache, depth, strictness, known, eq_mode, ap_hint,
)
})
.collect();
#[cfg(not(feature = "parallel"))]
let raw: Vec<DecodeResult> = candidates
.iter()
.filter_map(|cand| {
process_candidate(
cand, audio, &fft_cache, depth, strictness, known, eq_mode, ap_hint,
)
})
.collect();
let mut results: Vec<DecodeResult> = Vec::new();
for r in raw {
if !known.iter().any(|k| k.message77 == r.message77)
&& !results.iter().any(|x| x.message77 == r.message77)
{
results.push(r);
}
}
(results, fft_cache)
}
pub fn decode_frame_subtract(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
) -> Vec<DecodeResult> {
decode_frame_subtract_with_ap(
audio, freq_min, freq_max, sync_min, freq_hint, depth, max_cand, strictness, None,
)
}
pub fn decode_frame_subtract_with_ap(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
ap_hint: Option<&ApHint>,
) -> Vec<DecodeResult> {
let _ = freq_hint;
let mut residual = audio.to_vec();
let mut all_results: Vec<DecodeResult> = Vec::new();
let mut prev_total: usize = 0;
for ipass in 0..3 {
if ipass >= 1 && all_results.len() == prev_total {
break;
}
prev_total = all_results.len();
let spec = crate::ft8::decode_block::compute_spectrogram(&residual, freq_max);
let candidates =
crate::ft8::decode_block::coarse_sync(&spec, freq_min, freq_max, sync_min, max_cand);
drop(spec);
if candidates.is_empty() {
continue;
}
let fft_cache = build_fft_cache(&residual);
for cand in &candidates {
let r = match process_candidate(
cand,
&residual,
&fft_cache,
depth,
strictness,
&all_results,
EqMode::Off,
ap_hint,
) {
Some(r) => r,
None => continue,
};
if all_results.iter().any(|x| x.message77 == r.message77) {
continue;
}
subtract_signal_lpf(&mut residual, &r);
all_results.push(r);
}
}
all_results
}
pub fn decode_frame_subtract_with_known(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
known: &[DecodeResult],
precomputed_fft: Option<FftCache>,
) -> Vec<DecodeResult> {
decode_frame_subtract_with_known_and_ap(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
strictness,
known,
precomputed_fft,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn decode_frame_subtract_with_known_and_ap(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
known: &[DecodeResult],
precomputed_fft: Option<FftCache>,
ap_hint: Option<&ApHint>,
) -> Vec<DecodeResult> {
let (results, _residual) = decode_frame_subtract_with_known_and_ap_inner(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
strictness,
known,
precomputed_fft,
ap_hint,
);
results
}
#[allow(clippy::too_many_arguments)]
fn decode_frame_subtract_with_known_and_ap_inner(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
known: &[DecodeResult],
precomputed_fft: Option<FftCache>,
ap_hint: Option<&ApHint>,
) -> (Vec<DecodeResult>, Vec<i16>) {
let mut residual = audio.to_vec();
let mut all_results: Vec<DecodeResult> = known.to_vec();
let known_count = known.len();
let passes: &[f32] = &[1.0, 0.75, 0.5];
let mut residual_dirty = false;
for (i, &factor) in passes.iter().enumerate() {
let fft = if i == 0 && !residual_dirty {
precomputed_fft.as_deref()
} else {
None
};
let (new, _) = decode_frame_inner(
&residual,
freq_min,
freq_max,
sync_min * factor,
freq_hint,
depth,
max_cand,
strictness,
&all_results,
EqMode::Off,
fft,
ap_hint,
);
if i == 0 {
for r in known {
subtract_signal_lpf(&mut residual, r);
}
if !known.is_empty() {
residual_dirty = true;
}
}
for r in &new {
subtract_signal_lpf(&mut residual, r);
}
if !new.is_empty() {
residual_dirty = true;
}
all_results.extend(new);
}
(all_results.split_off(known_count), residual)
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn decode_frame_subtract_with_known_and_ap_debug_residual(
audio: &[i16],
freq_min: f32,
freq_max: f32,
sync_min: f32,
freq_hint: Option<f32>,
depth: DecodeDepth,
max_cand: usize,
strictness: DecodeStrictness,
known: &[DecodeResult],
precomputed_fft: Option<FftCache>,
ap_hint: Option<&ApHint>,
) -> (Vec<DecodeResult>, Vec<i16>) {
decode_frame_subtract_with_known_and_ap_inner(
audio,
freq_min,
freq_max,
sync_min,
freq_hint,
depth,
max_cand,
strictness,
known,
precomputed_fft,
ap_hint,
)
}
pub fn decode_sniper(
audio: &[i16],
target_freq: f32,
depth: DecodeDepth,
max_cand: usize,
) -> Vec<DecodeResult> {
decode_sniper_eq(audio, target_freq, depth, max_cand, EqMode::Off)
}
pub fn decode_sniper_eq(
audio: &[i16],
target_freq: f32,
depth: DecodeDepth,
max_cand: usize,
eq_mode: EqMode,
) -> Vec<DecodeResult> {
decode_sniper_ap(audio, target_freq, depth, max_cand, eq_mode, None)
}
pub fn decode_sniper_ap(
audio: &[i16],
target_freq: f32,
depth: DecodeDepth,
max_cand: usize,
eq_mode: EqMode,
ap_hint: Option<&ApHint>,
) -> Vec<DecodeResult> {
decode_sniper_inner(audio, target_freq, depth, max_cand, eq_mode, ap_hint, 0.8)
}
pub fn decode_sniper_sic(
audio: &[i16],
target_freq: f32,
depth: DecodeDepth,
max_cand: usize,
eq_mode: EqMode,
ap_hint: Option<&ApHint>,
) -> Vec<DecodeResult> {
let pass1 = decode_sniper_inner(audio, target_freq, depth, max_cand, eq_mode, ap_hint, 0.8);
let mut residual: Vec<i16> = audio.to_vec();
let mut subtracted = false;
for r in &pass1 {
if (r.freq_hz - target_freq).abs() > 25.0 {
subtract_signal_lpf(&mut residual, r);
subtracted = true;
}
}
if !subtracted {
return pass1;
}
let pass2 = decode_sniper_inner(
&residual,
target_freq,
depth,
max_cand,
eq_mode,
ap_hint,
0.6,
);
let mut results = pass1;
for r in pass2 {
if !results.iter().any(|x| x.message77 == r.message77) {
results.push(r);
}
}
results
}
fn decode_sniper_inner(
audio: &[i16],
target_freq: f32,
depth: DecodeDepth,
max_cand: usize,
eq_mode: EqMode,
ap_hint: Option<&ApHint>,
sync_min: f32,
) -> Vec<DecodeResult> {
let freq_min = (target_freq - 250.0).max(100.0);
let freq_max = (target_freq + 250.0).min(5900.0);
let spec = crate::ft8::decode_block::compute_spectrogram(audio, freq_max);
let candidates =
crate::ft8::decode_block::coarse_sync(&spec, freq_min, freq_max, sync_min, max_cand);
if candidates.is_empty() {
return Vec::new();
}
let fft_cache = build_fft_cache(audio);
#[cfg(feature = "parallel")]
let raw: Vec<DecodeResult> = candidates
.par_iter()
.filter_map(|cand| {
process_candidate(
cand,
audio,
&fft_cache,
depth,
DecodeStrictness::Normal,
&[],
eq_mode,
ap_hint,
)
})
.collect();
#[cfg(not(feature = "parallel"))]
let raw: Vec<DecodeResult> = candidates
.iter()
.filter_map(|cand| {
process_candidate(
cand,
audio,
&fft_cache,
depth,
DecodeStrictness::Normal,
&[],
eq_mode,
ap_hint,
)
})
.collect();
let mut results: Vec<DecodeResult> = Vec::new();
for r in raw {
if !results.iter().any(|x| x.message77 == r.message77) {
results.push(r);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_frame_with_ap_round_trips_clean_signal() {
use crate::ft8::wave_gen::{message_to_tones, tones_to_i16};
use crate::msg::wsjt77::pack77;
let m77 = pack77("CQ", "K1ABC", "FN42").expect("pack77");
let tones = message_to_tones(&m77);
let samples = tones_to_i16(&tones, 1500.0, 20_000);
let mut audio = vec![0i16; 15 * 12_000];
let off = 6_000usize;
let len = samples.len().min(audio.len() - off);
audio[off..off + len].copy_from_slice(&samples[..len]);
let ap = ApHint::new().with_call1("CQ").with_call2("K1ABC");
let results = decode_frame_with_ap(
&audio,
100.0,
3000.0,
1.0,
None,
DecodeDepth::BpAllOsd,
50,
Some(&ap),
);
assert!(
results.iter().any(|r| r.message77 == m77),
"expected to decode the self-synthesized signal with matching AP hint"
);
}
#[test]
fn decode_frame_with_ap_none_matches_legacy() {
use crate::ft8::wave_gen::{message_to_tones, tones_to_i16};
use crate::msg::wsjt77::pack77;
let m77 = pack77("CQ", "W7VV", "CN87").expect("pack77");
let tones = message_to_tones(&m77);
let samples = tones_to_i16(&tones, 1200.0, 18_000);
let mut audio = vec![0i16; 15 * 12_000];
let off = 6_000usize;
let len = samples.len().min(audio.len() - off);
audio[off..off + len].copy_from_slice(&samples[..len]);
let r_legacy = decode_frame(&audio, 100.0, 3000.0, 1.0, None, DecodeDepth::BpAllOsd, 50);
let r_ap_none = decode_frame_with_ap(
&audio,
100.0,
3000.0,
1.0,
None,
DecodeDepth::BpAllOsd,
50,
None,
);
assert_eq!(
r_legacy.iter().map(|r| r.message77).collect::<Vec<_>>(),
r_ap_none.iter().map(|r| r.message77).collect::<Vec<_>>(),
"ap_hint=None must match legacy decode_frame exactly"
);
}
#[test]
fn decode_frame_with_ap_full_silence_shape() {
let audio = vec![0i16; 15 * 12_000];
let ap = ApHint::new().with_call1("CQ").with_call2("K1ABC");
let (r0, c0) = decode_frame_with_ap_full(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAll,
DecodeStrictness::Normal,
10,
None,
);
assert!(r0.is_empty());
assert!(!c0.is_empty(), "FFT cache should be returned");
let (r1, c1) = decode_frame_with_ap_full(
&audio,
200.0,
2800.0,
1.0,
Some(1500.0),
DecodeDepth::BpAllOsd,
DecodeStrictness::Strict,
10,
Some(&ap),
);
assert!(r1.is_empty());
assert!(!c1.is_empty());
}
#[test]
fn decode_frame_subtract_with_ap_silence_shape() {
let audio = vec![0i16; 15 * 12_000];
let ap = ApHint::new().with_call1("CQ").with_call2("W7VV");
let r_none = decode_frame_subtract_with_ap(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAll,
10,
DecodeStrictness::Normal,
None,
);
assert!(r_none.is_empty());
let r_some = decode_frame_subtract_with_ap(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAll,
10,
DecodeStrictness::Normal,
Some(&ap),
);
assert!(r_some.is_empty());
}
#[test]
fn decode_frame_subtract_with_known_and_ap_silence_shape() {
let audio = vec![0i16; 15 * 12_000];
let ap = ApHint::new().with_call1("CQ").with_call2("JA1ABC");
let known: Vec<DecodeResult> = Vec::new();
let r_none = decode_frame_subtract_with_known_and_ap(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAll,
10,
DecodeStrictness::Normal,
&known,
None,
None,
);
assert!(r_none.is_empty());
let r_some = decode_frame_subtract_with_known_and_ap(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAll,
10,
DecodeStrictness::Normal,
&known,
None,
Some(&ap),
);
assert!(r_some.is_empty());
}
#[test]
fn decode_frame_subtract_with_known_and_ap_subtracts_known_before_phase2() {
use crate::ft8::wave_gen::{message_to_tones, tones_to_i16};
use crate::msg::wsjt77::pack77;
let m_known = pack77("CQ", "K1ABC", "FN42").expect("pack77 known");
let tones_known = message_to_tones(&m_known);
let f0 = 1500.0_f32;
let mut audio = vec![0i16; 15 * 12_000];
let off = 6_000usize;
let buf = tones_to_i16(&tones_known, f0, 20_000);
let n_sig = buf.len().min(audio.len() - off);
audio[off..off + n_sig].copy_from_slice(&buf[..n_sig]);
let phase1 = decode_frame(&audio, 200.0, 2800.0, 1.0, None, DecodeDepth::BpAllOsd, 50);
let known_results: Vec<DecodeResult> = phase1
.iter()
.filter(|r| r.message77 == m_known)
.cloned()
.collect();
assert!(
!known_results.is_empty(),
"Phase 1 must decode the known signal for this test to be meaningful"
);
fn band_energy(samples: &[i16], f_lo: f32, f_hi: f32) -> f64 {
let n = samples.len();
let fs = 12_000.0_f64;
let k_lo = ((f_lo as f64) * (n as f64) / fs).floor() as usize;
let k_hi = ((f_hi as f64) * (n as f64) / fs).ceil() as usize;
let mut energy = 0.0_f64;
for k in k_lo..=k_hi {
let mut re = 0.0_f64;
let mut im = 0.0_f64;
let w = 2.0 * core::f64::consts::PI * (k as f64) / (n as f64);
for (i, &s) in samples.iter().enumerate() {
let phi = w * (i as f64);
re += (s as f64) * phi.cos();
im -= (s as f64) * phi.sin();
}
energy += re * re + im * im;
}
energy
}
let e_before = band_energy(&audio, f0 - 1.0, f0 + 1.0);
let (new_results, residual) = decode_frame_subtract_with_known_and_ap_debug_residual(
&audio,
200.0,
2800.0,
1.0,
None,
DecodeDepth::BpAllOsd,
50,
DecodeStrictness::Normal,
&known_results,
None,
None,
);
let _ = new_results;
let e_after = band_energy(&residual, f0 - 1.0, f0 + 1.0);
assert!(
e_after * 2.0 < e_before,
"expected residual band energy at known signal's frequency \
to drop by >2× after SIC; got e_before={e_before:.3e}, \
e_after={e_after:.3e} (fix not applied?)"
);
}
#[test]
fn silence_no_decode() {
let audio = vec![0i16; 15 * 12_000];
let results = decode_frame(&audio, 200.0, 2800.0, 1.0, None, DecodeDepth::BpAll, 10);
assert!(results.is_empty(), "silence should decode nothing");
}
#[test]
fn sniper_silence_no_decode() {
let audio = vec![0i16; 15 * 12_000];
let results = decode_sniper(&audio, 1000.0, DecodeDepth::BpAll, 10);
assert!(results.is_empty());
}
#[test]
fn dt_accuracy_at_nominal_start() {
use super::super::message::pack77_type1;
use super::super::wave_gen::{message_to_tones, tones_to_f32};
let msg = pack77_type1("CQ", "JA1ABC", "PM95").unwrap();
let itone = message_to_tones(&msg);
let pcm = tones_to_f32(&itone, 1000.0, 1.0);
let mut audio_f32 = vec![0.0f32; 180_000];
let start = (0.5 * 12000.0) as usize; for (i, &s) in pcm.iter().enumerate() {
if start + i < audio_f32.len() {
audio_f32[start + i] = s;
}
}
let audio: Vec<i16> = audio_f32
.iter()
.map(|&s| (s * 20000.0).clamp(-32767.0, 32767.0) as i16)
.collect();
let results = decode_frame(&audio, 100.0, 3000.0, 1.0, None, DecodeDepth::BpAllOsd, 200);
assert!(!results.is_empty(), "should decode the signal");
let dt = results[0].dt_sec;
eprintln!("DT = {dt:+.3} s (expected ≈ 0.0)");
assert!(dt.abs() < 0.5, "DT={dt} is too far from 0");
}
#[test]
#[ignore = "manual diagnostic — internal BP/OSD trace on CCIR losing trials (issue #72)"]
fn ft8_diag_internal_osd_trace() {
fn load_wav_i16(path: &std::path::Path) -> Option<alloc::vec::Vec<i16>> {
let bytes = std::fs::read(path).ok()?;
if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return None;
}
let mut i = 12usize;
let mut data_off = None;
let mut data_len = 0usize;
while i + 8 <= bytes.len() {
let id = &bytes[i..i + 4];
let sz = u32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()) as usize;
let body = i + 8;
if id == b"data" {
data_off = Some(body);
data_len = sz;
break;
}
match body.checked_add(sz).and_then(|s| s.checked_add(sz & 1)) {
Some(next) => i = next,
None => break,
}
}
let off = data_off?;
let end = off.saturating_add(data_len).min(bytes.len());
Some(
bytes[off..end]
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect(),
)
}
const GOLDEN_FREQ_HZ: f32 = 1500.0;
const FREQ_TOL_HZ: f32 = 5.0;
let manifest = env!("CARGO_MANIFEST_DIR");
let dir = std::path::Path::new(manifest).join("../embedded-poc/assets/ft8_sweep");
for &(chan, snr_tag, trial) in &[
("ccir_poor", "m18", 1u32),
("ccir_poor", "m18", 3),
("ccir_poor", "m17", 3),
("ccir_moderate", "m17", 11),
] {
let path = dir.join(format!("ft8_{chan}_{snr_tag}_{trial:02}.wav"));
let Some(audio) = load_wav_i16(&path) else {
eprintln!("skip {path:?}");
continue;
};
let spec = crate::ft8::decode_block::compute_spectrogram(&audio, 3000.0);
let candidates = crate::ft8::decode_block::coarse_sync(&spec, 100.0, 3000.0, 0.8, 50);
let fft_cache = build_fft_cache(&audio);
for c in candidates
.iter()
.filter(|c| (c.freq_hz - GOLDEN_FREQ_HZ).abs() <= FREQ_TOL_HZ)
{
eprintln!(
"{chan} {snr_tag} trial {trial}: cand freq={:.2} dt={:.3} score={:.4}",
c.freq_hz, c.dt_sec, c.score
);
let r = process_candidate(
c,
&audio,
&fft_cache,
DecodeDepth::BpAllOsd,
DecodeStrictness::default(),
&[],
EqMode::Off,
None,
);
eprintln!(" -> process_candidate result: {:?}", r.map(|d| d.pass));
}
}
}
}