use crate::engine::pipeline::scan_dedup_match;
use crate::engine::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode};
use crate::fec::ConvFano232;
use crate::msg::Jt72Codec;
pub mod baseband;
pub(crate) mod decode;
pub mod interleave;
pub mod rx;
pub mod search;
pub(crate) mod softsym;
pub mod sync_pattern;
pub mod tx;
pub use decode::Jt9Depth;
pub use interleave::{deinterleave, deinterleave_llrs, interleave};
pub use rx::demodulate_aligned;
pub use search::{SearchParams, SyncCandidate, coarse_search};
pub use sync_pattern::{JT9_ISYNC, JT9_SYNC_BLOCKS, JT9_SYNC_POSITIONS};
pub use tx::{encode_channel_symbols, synthesize_audio, synthesize_standard};
pub fn decode_at(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<crate::msg::Jt72Message> {
use crate::engine::{DecodeContext, FecCodec, FecOpts, MessageCodec};
let llrs = rx::demodulate_aligned(audio, sample_rate, start_sample, base_freq_hz);
let codec = ConvFano232;
let res = codec.decode_soft(&llrs, &FecOpts::default())?;
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())
}
#[derive(Clone, Debug)]
pub struct Jt9Result {
pub message: crate::msg::Jt72Message,
pub freq_hz: f32,
pub start_sample: usize,
pub snr_db: f32,
}
pub fn decode_scan(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
) -> Vec<Jt9Result> {
decode_scan_inner(
audio,
sample_rate,
nominal_start_sample,
params,
Jt9Depth::default(),
None,
)
}
pub fn decode_scan_with_depth(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
depth: Jt9Depth,
) -> Vec<Jt9Result> {
decode_scan_inner(
audio,
sample_rate,
nominal_start_sample,
params,
depth,
None,
)
}
pub fn decode_scan_streaming(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
on_result: &(dyn Fn(&Jt9Result) + Sync),
) -> Vec<Jt9Result> {
decode_scan_inner(
audio,
sample_rate,
nominal_start_sample,
params,
Jt9Depth::default(),
Some(on_result),
)
}
pub fn decode_scan_streaming_with_depth(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
depth: Jt9Depth,
on_result: &(dyn Fn(&Jt9Result) + Sync),
) -> Vec<Jt9Result> {
decode_scan_inner(
audio,
sample_rate,
nominal_start_sample,
params,
depth,
Some(on_result),
)
}
fn decode_scan_inner(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
depth: Jt9Depth,
on_result: Option<&(dyn Fn(&Jt9Result) + Sync)>,
) -> Vec<Jt9Result> {
use crate::engine::ModulationParams;
let nsps = (sample_rate as f32 * <Jt9 as ModulationParams>::SYMBOL_DT).round() as usize;
let mut scan_params = *params;
scan_params.score_threshold = 0.001;
scan_params.max_candidates = 50_000;
let mut cands = search::coarse_search(audio, sample_rate, nominal_start_sample, &scan_params);
cands.sort_unstable_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
cands.truncate(params.max_candidates.max(32));
let big_fft = softsym::AudioFft::build(audio);
let mut seen: Vec<Jt9Result> = Vec::new();
for c in cands {
let Some(d) = decode::decode_at_baseband_with_fft_depth(&big_fft, c.freq_hz, depth) else {
continue;
};
let dup = scan_dedup_match(
&seen,
&d,
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
4.0,
nsps as i64,
);
if !dup {
if let Some(cb) = on_result {
cb(&d);
}
seen.push(d);
}
}
seen
}
pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Jt9Result> {
decode_scan(audio, sample_rate, 0, &search::SearchParams::default())
}
#[derive(Copy, Clone, Debug, Default)]
pub struct Jt9;
impl ModulationParams for Jt9 {
const NTONES: u32 = 9;
const BITS_PER_SYMBOL: u32 = 3; const NSPS: u32 = 6912;
const SYMBOL_DT: f32 = 6912.0 / 12_000.0;
const TONE_SPACING_HZ: f32 = 12_000.0 / 6912.0; const GRAY_MAP: &'static [u8] = &[0, 1, 3, 2, 6, 7, 5, 4];
const GFSK_BT: f32 = 0.0;
const GFSK_HMOD: f32 = 1.0;
const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
const NSTEP_PER_SYMBOL: u32 = 2;
const NDOWN: u32 = 8;
}
impl FrameLayout for Jt9 {
const N_DATA: u32 = 69;
const N_SYNC: u32 = 16;
const N_SYMBOLS: u32 = 85;
const N_RAMP: u32 = 0;
const SYNC_MODE: SyncMode = SyncMode::Block(&JT9_SYNC_BLOCKS);
const T_SLOT_S: f32 = 60.0;
const TX_START_OFFSET_S: f32 = 0.0;
}
impl Protocol for Jt9 {
type Fec = ConvFano232;
type Msg = Jt72Codec;
const ID: ProtocolId = ProtocolId::Jt9;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::FecCodec;
#[test]
fn jt9_trait_surface() {
assert_eq!(<Jt9 as ModulationParams>::NTONES, 9);
assert_eq!(<Jt9 as ModulationParams>::BITS_PER_SYMBOL, 3);
assert_eq!(<Jt9 as ModulationParams>::NSPS, 6912);
assert!((<Jt9 as ModulationParams>::SYMBOL_DT - 0.576).abs() < 1e-3,);
assert_eq!(<Jt9 as FrameLayout>::N_SYMBOLS, 85);
assert_eq!(<Jt9 as FrameLayout>::N_SYNC, 16);
assert_eq!(<Jt9 as FrameLayout>::N_DATA, 69);
assert_eq!(<Jt9 as FrameLayout>::T_SLOT_S, 60.0);
match <Jt9 as FrameLayout>::SYNC_MODE {
SyncMode::Block(blocks) => {
assert_eq!(blocks.len(), 16);
assert_eq!(blocks[0].start_symbol, 0);
assert_eq!(blocks[15].start_symbol, 84);
for b in blocks {
assert_eq!(b.pattern, &[0u8]);
}
}
SyncMode::Interleaved { .. } => panic!("JT9 must use Block sync"),
}
assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::N, 206);
assert_eq!(<<Jt9 as Protocol>::Fec as FecCodec>::K, 72);
}
#[test]
fn decode_scan_with_depth_finds_clean_signal_at_every_tier() {
let freq = 1500.0;
let audio =
tx::synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("pack+synth");
let params = search::SearchParams::default();
for depth in [
Jt9Depth::Fast,
Jt9Depth::Normal,
Jt9Depth::Deep,
Jt9Depth::Max,
] {
let decodes = decode_scan_with_depth(&audio, 12_000, 0, ¶ms, depth);
assert!(
!decodes.is_empty(),
"depth={depth:?} found no decodes on a clean synthetic signal"
);
}
}
#[test]
#[ignore = "manual diagnostic — phase breakdown probe for JT9's unexplained decode_scan cost"]
fn phase_breakdown_diag() {
use std::time::Instant;
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);
}
}
let files = [
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/jt9_sweep/jt9_awgn_m05_01.wav"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/jt9_sweep/jt9_awgn_m05_02.wav"
),
];
for path in files {
if !std::path::Path::new(path).exists() {
eprintln!("skipping {path} — sample not found (gitignored local corpus)");
continue;
}
let mut audio = load_wav(path);
audio.resize(720_000, 0.0);
let n = 10;
let t0 = Instant::now();
let mut n_cands = 0;
for _ in 0..n {
let sp = search::SearchParams {
score_threshold: 0.001,
max_candidates: 50_000,
..Default::default()
};
let mut c = search::coarse_search(&audio, 12000, 0, &sp);
c.truncate(32);
n_cands = c.len();
std::hint::black_box(&c);
}
let search_elapsed = t0.elapsed();
let t0 = Instant::now();
for _ in 0..n {
let fft = softsym::AudioFft::build(&audio);
std::hint::black_box(&fft);
}
let bigfft_elapsed = t0.elapsed();
let t0 = Instant::now();
let mut last_len = 0;
for _ in 0..n {
let r = decode_scan_default(&audio, 12000);
last_len = r.len();
}
let total_elapsed = t0.elapsed();
let search_ms = search_elapsed.as_secs_f64() * 1000.0 / n as f64;
let bigfft_ms = bigfft_elapsed.as_secs_f64() * 1000.0 / n as f64;
let total_ms = total_elapsed.as_secs_f64() * 1000.0 / n as f64;
eprintln!(
"{path}: candidates={n_cands} decodes={last_len} coarse_search={search_ms:.2}ms \
big_fft_build={bigfft_ms:.2}ms total={total_ms:.2}ms \
per_candidate_loop(rest)={:.2}ms",
total_ms - search_ms - bigfft_ms
);
}
}
#[test]
#[ignore = "manual diagnostic — JT9 candidate-loop stage breakdown on the real golden WAV"]
fn candidate_loop_stage_diag() {
use std::time::Instant;
use crate::engine::{DecodeContext, FecCodec, FecOpts, MessageCodec};
use crate::fec::ConvFano232;
use crate::msg::Jt72Codec;
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
);
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 sp = search::SearchParams {
freq_min_hz: 1050.0,
freq_max_hz: 1550.0,
time_tolerance_sec: 1.728,
score_threshold: 0.05,
max_candidates: 200,
};
let mut cands = search::coarse_search(&audio, 12_000, 0, &sp);
cands.sort_unstable_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
cands.truncate(sp.max_candidates.max(32));
let big_fft = softsym::AudioFft::build(&audio);
let (mut t_downsam_peak, mut t_afc, mut t_llrs, mut t_fano) = (0f64, 0f64, 0f64, 0f64);
let (mut t_fano_converged, mut t_fano_failed) = (0f64, 0f64);
let (mut n_total, mut n_sync_pass, mut n_schk_pass, mut n_fano_converge, mut n_msg_ok) =
(0usize, 0usize, 0usize, 0usize, 0usize);
for c in &cands {
n_total += 1;
if c.freq_hz <= 0.0 {
continue;
}
let t0 = Instant::now();
let c2 = big_fft.downsam9(c.freq_hz);
let (_lagpk, sync_score, mut c3) = softsym::peakdt9(&c2);
t_downsam_peak += t0.elapsed().as_secs_f64() * 1000.0;
if !sync_score.is_finite() || sync_score < 1.5 {
continue;
}
n_sync_pass += 1;
let t0 = Instant::now();
let afc = softsym::afc9(&mut c3);
softsym::twkfreq_poly(&mut c3, [afc.a0, afc.a1, 0.0]);
t_afc += t0.elapsed().as_secs_f64() * 1000.0;
let sync = (afc.syncpk + 1.0) / 4.0;
if !sync.is_finite() || sync < 1.0 {
continue;
}
let t0 = Instant::now();
let (schk, llrs, _snr_db) = softsym::llrs_from_c5(&c3);
t_llrs += t0.elapsed().as_secs_f64() * 1000.0;
if !schk.is_finite() || schk < 1.5 {
continue;
}
n_schk_pass += 1;
let t0 = Instant::now();
let res = ConvFano232.decode_soft(&llrs, &FecOpts::default());
let this_fano_ms = t0.elapsed().as_secs_f64() * 1000.0;
t_fano += this_fano_ms;
match &res {
Some(_) => t_fano_converged += this_fano_ms,
None => t_fano_failed += this_fano_ms,
}
let Some(res) = res else { continue };
n_fano_converge += 1;
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
if Jt72Codec::default()
.unpack(&payload, &DecodeContext::default())
.is_some()
{
n_msg_ok += 1;
}
}
eprintln!(
"candidates: total={n_total} sync_pass={n_sync_pass} schk_pass={n_schk_pass} \
fano_converge={n_fano_converge} msg_ok={n_msg_ok}"
);
eprintln!(
"stage time (ms): downsam9+peakdt9={t_downsam_peak:.2} afc9+twkfreq={t_afc:.2} \
llrs_from_c5={t_llrs:.2} fano_decode_soft={t_fano:.2} total={:.2}",
t_downsam_peak + t_afc + t_llrs + t_fano
);
eprintln!(
"fano_decode_soft split: converged={t_fano_converged:.2}ms (n={n_fano_converge}) \
failed={t_fano_failed:.2}ms (n={})",
n_schk_pass - n_fano_converge
);
}
}