use crate::engine::pipeline::scan_dedup_match_cross;
use crate::engine::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode};
use crate::fec::Rs63_12;
use crate::msg::Jt72Codec;
pub mod chase;
pub mod gray;
pub mod interleave;
pub mod rx;
pub mod search;
pub mod sync_pattern;
pub mod tx;
pub use chase::{ChaseParams, decode_at_with_chase};
pub use gray::{gray6, inv_gray6};
pub use interleave::{deinterleave, interleave};
pub use rx::{
demodulate_aligned, demodulate_aligned_with_confidence, demodulate_aligned_with_runnerup,
};
pub use sync_pattern::{JT65_DATA_POSITIONS, JT65_NPRC, JT65_SYNC_BLOCKS, JT65_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, MessageCodec};
let received = rx::demodulate_aligned(audio, sample_rate, start_sample, base_freq_hz)?;
let rs = Rs63_12::new();
let (info, _nerr) = rs.decode_jt65(&received)?;
let mut payload = [0u8; 72];
for (i, bit) in payload.iter_mut().enumerate() {
let word = info[i / 6];
let shift = 5 - (i % 6);
*bit = (word >> shift) & 1;
}
crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())
}
fn decode_at_with_snr(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<(crate::msg::Jt72Message, f32)> {
use crate::engine::{DecodeContext, MessageCodec};
let (received, _conf, snr_db) = rx::demodulate_aligned_with_confidence_and_snr(
audio,
sample_rate,
start_sample,
base_freq_hz,
)?;
let rs = Rs63_12::new();
let (info, _nerr) = rs.decode_jt65(&received)?;
let mut payload = [0u8; 72];
for (i, bit) in payload.iter_mut().enumerate() {
let word = info[i / 6];
let shift = 5 - (i % 6);
*bit = (word >> shift) & 1;
}
let msg = crate::msg::Jt72Codec::default().unpack(&payload, &DecodeContext::default())?;
Some((msg, snr_db))
}
pub fn decode_at_with_erasures(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
attempts: &[usize],
) -> Option<crate::msg::Jt72Message> {
use crate::engine::{DecodeContext, MessageCodec};
let (symbols, conf) =
rx::demodulate_aligned_with_confidence(audio, sample_rate, start_sample, base_freq_hz)?;
let order = chase::confidence_order(&conf);
let rs = Rs63_12::new();
let codec = crate::msg::Jt72Codec::default();
let ctx = DecodeContext::default();
for &n_eras in attempts {
let n_eras = n_eras.min(51); let eras: Vec<u32> = order.iter().take(n_eras).map(|&i| i as u32).collect();
let mut sent = [0u8; 63];
sent.copy_from_slice(&symbols);
if let Some((info, _nerr)) = rs.decode_jt65_erasures(&sent, &eras) {
let mut payload = [0u8; 72];
for (i, bit) in payload.iter_mut().enumerate() {
let word = info[i / 6];
let shift = 5 - (i % 6);
*bit = (word >> shift) & 1;
}
if let Some(msg) = codec.unpack(&payload, &ctx) {
return Some(msg);
}
}
}
None
}
#[derive(Clone, Debug)]
pub struct Jt65Result {
pub message: crate::msg::Jt72Message,
pub freq_hz: f32,
pub start_sample: usize,
pub dt_sec: f32,
pub snr_db: f32,
}
pub fn decode_scan(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
) -> Vec<Jt65Result> {
decode_scan_inner(audio, sample_rate, nominal_start_sample, params, None)
}
pub fn decode_scan_streaming(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
on_result: &(dyn Fn(&Jt65Result) + Sync),
) -> Vec<Jt65Result> {
decode_scan_inner(
audio,
sample_rate,
nominal_start_sample,
params,
Some(on_result),
)
}
fn pad_for_early_frames(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
time_tolerance_sec: f32,
) -> Option<(Vec<f32>, usize)> {
let want = (time_tolerance_sec.max(0.0) * sample_rate as f32).round() as usize;
let pad = want.saturating_sub(nominal_start_sample);
if pad == 0 {
return None;
}
let mut padded = vec![0.0f32; pad];
padded.extend_from_slice(audio);
Some((padded, pad))
}
fn decode_scan_inner(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &search::SearchParams,
on_result: Option<&(dyn Fn(&Jt65Result) + Sync)>,
) -> Vec<Jt65Result> {
use crate::engine::ModulationParams;
let nsps = (sample_rate as f32 * <Jt65 as ModulationParams>::SYMBOL_DT).round() as usize;
let padding = pad_for_early_frames(
audio,
sample_rate,
nominal_start_sample,
params.time_tolerance_sec,
);
let (audio, pad) = match &padding {
Some((buf, pad)) => (buf.as_slice(), *pad),
None => (audio, 0),
};
let nominal_start_sample = nominal_start_sample + pad;
let cands = search::coarse_search(audio, sample_rate, nominal_start_sample, params);
let mut seen: Vec<Jt65Result> = Vec::new();
for c in cands {
let Some((msg, snr_db)) = decode_at_with_snr(audio, sample_rate, c.start_sample, c.freq_hz)
else {
continue;
};
let dup = scan_dedup_match_cross(
&seen,
&(msg.clone(), c.freq_hz, c.start_sample as i64),
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
|(m, _, _)| m,
|(_, f, _)| *f,
|(_, _, t)| *t,
2.0,
nsps as i64,
);
if !dup {
let result = Jt65Result {
message: msg,
freq_hz: c.freq_hz,
start_sample: c.start_sample.saturating_sub(pad),
dt_sec: (c.start_sample as f32 - pad as f32) / sample_rate as f32,
snr_db,
};
if let Some(cb) = on_result {
cb(&result);
}
seen.push(result);
}
}
seen
}
pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Jt65Result> {
decode_scan(audio, sample_rate, 0, &search::SearchParams::default())
}
pub fn decode_scan_chase(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
search_params: &search::SearchParams,
chase_params: &chase::ChaseParams,
) -> Vec<Jt65Result> {
decode_scan_chase_inner(
audio,
sample_rate,
nominal_start_sample,
search_params,
chase_params,
None,
)
}
pub fn decode_scan_chase_streaming(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
search_params: &search::SearchParams,
chase_params: &chase::ChaseParams,
on_result: &(dyn Fn(&Jt65Result) + Sync),
) -> Vec<Jt65Result> {
decode_scan_chase_inner(
audio,
sample_rate,
nominal_start_sample,
search_params,
chase_params,
Some(on_result),
)
}
fn decode_scan_chase_inner(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
search_params: &search::SearchParams,
chase_params: &chase::ChaseParams,
on_result: Option<&(dyn Fn(&Jt65Result) + Sync)>,
) -> Vec<Jt65Result> {
use crate::engine::ModulationParams;
let nsps = (sample_rate as f32 * <Jt65 as ModulationParams>::SYMBOL_DT).round() as usize;
let padding = pad_for_early_frames(
audio,
sample_rate,
nominal_start_sample,
search_params.time_tolerance_sec,
);
let (audio, pad) = match &padding {
Some((buf, pad)) => (buf.as_slice(), *pad),
None => (audio, 0),
};
let nominal_start_sample = nominal_start_sample + pad;
let cands = search::coarse_search(audio, sample_rate, nominal_start_sample, search_params);
let mut seen: Vec<Jt65Result> = Vec::new();
for c in cands {
let Some((msg, snr_db)) = chase::decode_at_with_chase_and_snr(
audio,
sample_rate,
c.start_sample,
c.freq_hz,
chase_params,
) else {
continue;
};
let dup = scan_dedup_match_cross(
&seen,
&(msg.clone(), c.freq_hz, c.start_sample as i64),
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
|(m, _, _)| m,
|(_, f, _)| *f,
|(_, _, t)| *t,
2.0,
nsps as i64,
);
if !dup {
let result = Jt65Result {
message: msg,
freq_hz: c.freq_hz,
start_sample: c.start_sample.saturating_sub(pad),
dt_sec: (c.start_sample as f32 - pad as f32) / sample_rate as f32,
snr_db,
};
if let Some(cb) = on_result {
cb(&result);
}
seen.push(result);
}
}
seen
}
pub fn decode_scan_chase_default(audio: &[f32], sample_rate: u32) -> Vec<Jt65Result> {
decode_scan_chase(
audio,
sample_rate,
0,
&search::SearchParams::default(),
&chase::ChaseParams::default(),
)
}
#[derive(Copy, Clone, Debug, Default)]
pub struct Jt65;
impl ModulationParams for Jt65 {
const NTONES: u32 = 66;
const BITS_PER_SYMBOL: u32 = 6;
const NSPS: u32 = 4460;
const SYMBOL_DT: f32 = 4460.0 / 12_000.0;
const TONE_SPACING_HZ: f32 = 12_000.0 / 4460.0; const GRAY_MAP: &'static [u8] = &IDENTITY_66;
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 = 4;
}
const IDENTITY_66: [u8; 66] = {
let mut m = [0u8; 66];
let mut i = 0usize;
while i < 66 {
m[i] = i as u8;
i += 1;
}
m
};
impl FrameLayout for Jt65 {
const N_DATA: u32 = 63;
const N_SYNC: u32 = 63;
const N_SYMBOLS: u32 = 126;
const N_RAMP: u32 = 0;
const SYNC_MODE: SyncMode = SyncMode::Block(&JT65_SYNC_BLOCKS);
const T_SLOT_S: f32 = 60.0;
const TX_START_OFFSET_S: f32 = 0.0;
}
impl Protocol for Jt65 {
type Fec = Rs63_12;
type Msg = Jt72Codec;
const ID: ProtocolId = ProtocolId::Jt65;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::msg::Jt72Message;
#[test]
fn erasure_assisted_decode_recovers_under_moderate_noise() {
let freq = 1270.0;
let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
let msg = decode_at_with_erasures(&audio, 12_000, 0, freq, &[0, 8, 16, 24, 32])
.expect("erasure-aware path must decode clean synth");
assert!(matches!(
msg,
Jt72Message::Standard { ref call1, ref call2, ref grid_or_report }
if call1 == "CQ" && call2 == "K1ABC" && grid_or_report == "FN42"
));
}
#[test]
fn decode_scan_streaming_matches_batch_exactly() {
use std::sync::Mutex;
let freq = 1500.0;
let audio = synthesize_standard("CQ", "JL1NIE", "PM95", 12_000, freq, 0.3).expect("synth");
let mut slot = vec![0.0f32; 12_000 + audio.len()];
slot[12_000..12_000 + audio.len()].copy_from_slice(&audio);
let streamed_acc: Mutex<Vec<Jt72Message>> = Mutex::new(Vec::new());
let on_result = |r: &Jt65Result| streamed_acc.lock().unwrap().push(r.message.clone());
let batch = decode_scan_streaming(
&slot,
12_000,
0,
&search::SearchParams::default(),
&on_result,
);
let streamed = streamed_acc.into_inner().unwrap();
let batch_msgs: Vec<Jt72Message> = batch.iter().map(|d| d.message.clone()).collect();
assert_eq!(
streamed, batch_msgs,
"JT65 decode_scan_streaming: streamed callback deliveries must \
exactly match the batch result, same order (sequential, no \
early exit, no parallelism — no divergence mechanism exists)"
);
assert!(
!streamed.is_empty(),
"expected at least one streamed decode on the synth signal"
);
assert!(matches!(
&streamed[0],
Jt72Message::Standard { call1, call2, grid_or_report }
if call1 == "CQ" && call2 == "JL1NIE" && grid_or_report == "PM95"
));
}
#[test]
fn decode_scan_chase_finds_signal_via_search() {
let freq = 1500.0;
let audio = synthesize_standard("CQ", "JL1NIE", "PM95", 12_000, freq, 0.3).expect("synth");
let mut slot = vec![0.0f32; 12_000 + audio.len()];
slot[12_000..12_000 + audio.len()].copy_from_slice(&audio);
let results = decode_scan_chase_default(&slot, 12_000);
assert!(
!results.is_empty(),
"expected at least one chase-decoded result on the synth signal"
);
assert!(matches!(
&results[0].message,
Jt72Message::Standard { call1, call2, grid_or_report }
if call1 == "CQ" && call2 == "JL1NIE" && grid_or_report == "PM95"
));
}
#[test]
#[ignore]
fn decode_scan_chase_never_false_decodes_on_noise() {
struct NoiseGen(u32);
impl NoiseGen {
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() as f32) / (u32::MAX as f32)
}
fn gaussian(&mut self) -> f32 {
let u1 = self.next_f32().max(1e-9);
let u2 = self.next_f32();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
}
}
const NSAMPLES: usize = 60 * 12_000; for seed in 1..=5u32 {
let mut rng = NoiseGen(seed.wrapping_mul(2_654_435_761) | 1);
let audio: Vec<f32> = (0..NSAMPLES).map(|_| 0.3 * rng.gaussian()).collect();
let results = decode_scan_chase_default(&audio, 12_000);
assert!(
results.is_empty(),
"decode_scan_chase must not decode pure noise (seed={seed}), got {results:?}"
);
}
}
#[test]
fn jt65_trait_surface() {
assert_eq!(<Jt65 as ModulationParams>::NTONES, 66);
assert_eq!(<Jt65 as ModulationParams>::BITS_PER_SYMBOL, 6);
assert_eq!(<Jt65 as ModulationParams>::NSPS, 4460);
assert_eq!(<Jt65 as FrameLayout>::N_SYMBOLS, 126);
assert_eq!(<Jt65 as FrameLayout>::N_DATA, 63);
assert_eq!(<Jt65 as FrameLayout>::N_SYNC, 63);
match <Jt65 as FrameLayout>::SYNC_MODE {
SyncMode::Block(blocks) => {
assert_eq!(blocks.len(), 63);
for b in blocks {
assert_eq!(b.pattern, &[0u8]);
}
}
SyncMode::Interleaved { .. } => panic!("JT65 must use Block sync"),
}
let _fec = Rs63_12::default();
}
}