use crate::engine::{DecodeContext, FecCodec, FecOpts, MessageCodec};
use crate::fec::ConvFano232;
use crate::msg::{Jt72Codec, Jt72Message};
use super::Jt9Result;
use super::softsym::{AudioFft, FSAMPLE_DOWN, afc9, llrs_from_c5, peakdt9, twkfreq_poly};
const SYNC_GATE: f32 = 1.5;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Jt9Depth {
#[default]
Fast,
Normal,
Deep,
Max,
}
impl Jt9Depth {
fn max_cycles_per_bit(self) -> u64 {
match self {
Jt9Depth::Fast => 5_000,
Jt9Depth::Normal => 10_000,
Jt9Depth::Deep => 30_000,
Jt9Depth::Max => 100_000,
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn decode_at_baseband_with_fft(big_fft: &AudioFft, freq_hz: f32) -> Option<Jt9Result> {
decode_at_baseband_with_fft_depth(big_fft, freq_hz, Jt9Depth::default())
}
pub fn decode_at_baseband_with_fft_depth(
big_fft: &AudioFft,
freq_hz: f32,
depth: Jt9Depth,
) -> Option<Jt9Result> {
if freq_hz <= 0.0 {
return None;
}
let c2 = big_fft.downsam9(freq_hz);
let (lagpk, sync_score, mut c3) = peakdt9(&c2);
if !sync_score.is_finite() || sync_score < SYNC_GATE {
return None;
}
let afc = afc9(&mut c3);
twkfreq_poly(&mut c3, [afc.a0, afc.a1, 0.0]);
let sync = (afc.syncpk + 1.0) / 4.0;
if !sync.is_finite() || sync < 1.0 {
return None;
}
let (schk, llrs, snr_db) = llrs_from_c5(&c3);
if !schk.is_finite() || schk < 1.5 {
return None;
}
let opts = FecOpts {
max_cycles_per_bit: Some(depth.max_cycles_per_bit()),
..FecOpts::default()
};
let res = ConvFano232.decode_soft(&llrs, &opts)?;
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
let msg = Jt72Codec::default().unpack(&payload, &DecodeContext::default())?;
match &msg {
Jt72Message::Standard { .. } => {}
_ => return None,
}
let start_sample = lag_to_audio_sample(lagpk);
let freq_corrected = freq_hz - afc.a0;
Some(Jt9Result {
message: msg,
freq_hz: freq_corrected,
start_sample,
snr_db,
})
}
#[cfg(test)]
#[allow(dead_code)]
pub fn decode_at_baseband(
audio: &[f32],
_sample_rate: u32,
_start_sample: usize,
freq_hz: f32,
) -> Option<Jt9Result> {
let big = AudioFft::build(audio);
decode_at_baseband_with_fft(&big, freq_hz)
}
#[cfg(test)]
mod depth_tests {
use super::Jt9Depth;
#[test]
fn cycle_budgets_match_wsjtx_ndepth_ladder() {
assert_eq!(Jt9Depth::Fast.max_cycles_per_bit(), 5_000);
assert_eq!(Jt9Depth::Normal.max_cycles_per_bit(), 10_000);
assert_eq!(Jt9Depth::Deep.max_cycles_per_bit(), 30_000);
assert_eq!(Jt9Depth::Max.max_cycles_per_bit(), 100_000);
}
#[test]
fn default_is_fast() {
assert_eq!(Jt9Depth::default(), Jt9Depth::Fast);
}
}
fn lag_to_audio_sample(lagpk: i64) -> usize {
let i0 = 5 * 16i64;
let nspsd = 16i64;
let ndown = 432i64;
let c2_offset = lagpk - i0 - nspsd + 1;
let sample = c2_offset.max(0) * ndown;
let _ = FSAMPLE_DOWN; sample as usize
}
#[cfg(test)]
#[allow(dead_code)]
mod gate_diag {
use super::*;
use crate::engine::DecodeContext;
use crate::msg::Jt72Codec;
use std::path::Path;
#[test]
#[ignore]
fn probe_missing_goldens() {
let path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
));
if !path.exists() {
eprintln!("skipping — sample not found");
return;
}
let bytes = std::fs::read(path).unwrap();
let dl = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]) as usize;
let audio: Vec<f32> = bytes[44..44 + dl]
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32_768.0)
.collect();
let big_fft = AudioFft::build(&audio);
for &(label, nominal_freq) in &[
("CQ GM7GAX IO75", 1119.0f32),
("CQ M0WAY IO82", 1290.0f32),
("TF3G N7MQ CN84 (control: passes)", 1186.0f32),
("K1JT KF4RWA 73 (control: passes)", 1224.0f32),
] {
eprintln!("\n=== {} @ {:.1} Hz ===", label, nominal_freq);
for df_i in -4..=4i32 {
let freq = nominal_freq + df_i as f32 * 0.5;
let c2 = big_fft.downsam9(freq);
let (_lagpk, peakdt_score, mut c3) = super::super::softsym::peakdt9(&c2);
if !peakdt_score.is_finite() || peakdt_score < 0.3 {
continue;
}
let afc = super::super::softsym::afc9(&mut c3);
super::super::softsym::twkfreq_poly(&mut c3, [afc.a0, afc.a1, 0.0]);
let sync = (afc.syncpk + 1.0) / 4.0;
let (schk, llrs, _snr_db) = super::super::softsym::llrs_from_c5(&c3);
let mut hits = Vec::new();
for &lim in &[10_000u64, 30_000, 100_000] {
let opts = FecOpts {
max_cycles_per_bit: Some(lim),
..FecOpts::default()
};
let dec = ConvFano232.decode_soft(&llrs, &opts);
let s = match dec {
Some(r) => {
let mut p = [0u8; 72];
p.copy_from_slice(&r.info);
let m = Jt72Codec::default().unpack(&p, &DecodeContext::default());
format!(
"limit={} hard={} msg={:?}",
lim,
r.hard_errors,
m.map(|x| x.to_string())
)
}
None => format!("limit={} no-converge", lim),
};
hits.push(s);
}
eprintln!(
" freq={:.1} peakdt={:.3} sync={:.3} schk={:.3} | {}",
freq,
peakdt_score,
sync,
schk,
hits.join(" / ")
);
}
}
}
#[test]
#[ignore]
fn probe_exact_candidate_freq_m26_files() {
use super::super::search::{SearchParams, coarse_search};
use super::Jt9Depth;
fn load_wav(path: &str) -> Vec<f32> {
let bytes = std::fs::read(path).unwrap();
let mut i = 12;
loop {
let id = &bytes[i..i + 4];
let len =
u32::from_le_bytes([bytes[i + 4], bytes[i + 5], bytes[i + 6], bytes[i + 7]])
as usize;
if id == b"data" {
let start = i + 8;
let samples: &[u8] = &bytes[start..start + len];
return samples
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
}
i += 8 + len + (len % 2);
}
}
for n in [9, 13, 17] {
let path = format!(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/jt9_sweep/jt9_awgn_m26_{:02}.wav"
),
n
);
if !std::path::Path::new(&path).exists() {
eprintln!("skipping m26_{n:02}.wav — sample not found (gitignored local corpus)");
continue;
}
let mut audio = load_wav(&path);
audio.resize(720_000, 0.0);
let sp = SearchParams {
score_threshold: 0.001,
max_candidates: 50_000,
..Default::default()
};
let mut cands = coarse_search(&audio, 12_000, 0, &sp);
cands.sort_unstable_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
cands.truncate(5);
let big_fft = AudioFft::build(&audio);
eprintln!("\n=== m26_{n:02}.wav: top candidates ===");
for c in &cands {
let mut results = Vec::new();
for depth in [
Jt9Depth::Fast,
Jt9Depth::Normal,
Jt9Depth::Deep,
Jt9Depth::Max,
] {
let r = super::decode_at_baseband_with_fft_depth(&big_fft, c.freq_hz, depth);
results.push(format!("{depth:?}={}", r.is_some()));
}
eprintln!(
" freq={:.3} score={:.4} | {}",
c.freq_hz,
c.score,
results.join(" ")
);
}
}
}
#[test]
#[ignore]
fn probe_missing_m26_files() {
fn load_wav(path: &str) -> Vec<f32> {
let bytes = std::fs::read(path).unwrap();
let mut i = 12;
loop {
let id = &bytes[i..i + 4];
let len =
u32::from_le_bytes([bytes[i + 4], bytes[i + 5], bytes[i + 6], bytes[i + 7]])
as usize;
if id == b"data" {
let start = i + 8;
let samples: &[u8] = &bytes[start..start + len];
return samples
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
}
i += 8 + len + (len % 2);
}
}
for n in [9, 13, 17] {
let path = format!(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/jt9_sweep/jt9_awgn_m26_{:02}.wav"
),
n
);
if !std::path::Path::new(&path).exists() {
eprintln!("skipping m26_{n:02}.wav — sample not found (gitignored local corpus)");
continue;
}
eprintln!("\n=== jt9_awgn_m26_{n:02}.wav ===");
let mut audio = load_wav(&path);
audio.resize(720_000, 0.0);
let big_fft = AudioFft::build(&audio);
for df_i in -6..=6i32 {
let freq = 1400.0 + df_i as f32 * 0.5;
let c2 = big_fft.downsam9(freq);
let (_lagpk, peakdt_score, mut c3) = peakdt9(&c2);
if !peakdt_score.is_finite() || peakdt_score < 0.3 {
continue;
}
let afc = afc9(&mut c3);
twkfreq_poly(&mut c3, [afc.a0, afc.a1, 0.0]);
let sync = (afc.syncpk + 1.0) / 4.0;
let (schk, llrs, _snr_db) = llrs_from_c5(&c3);
let mut hits = Vec::new();
for &lim in &[5_000u64, 10_000, 30_000, 100_000] {
let opts = FecOpts {
max_cycles_per_bit: Some(lim),
..FecOpts::default()
};
let dec = ConvFano232.decode_soft(&llrs, &opts);
let s = match dec {
Some(r) => {
let mut p = [0u8; 72];
p.copy_from_slice(&r.info);
let m = Jt72Codec::default().unpack(&p, &DecodeContext::default());
format!(
"limit={} hard={} msg={:?}",
lim,
r.hard_errors,
m.map(|x| x.to_string())
)
}
None => format!("limit={lim} no-converge"),
};
hits.push(s);
}
eprintln!(
" freq={freq:.1} peakdt={peakdt_score:.3} sync={sync:.3} schk={schk:.3} | {}",
hits.join(" / ")
);
}
}
}
}