use crate::engine::{DecodeContext, MessageCodec};
use crate::fec::Rs63_12;
use crate::msg::{Jt72Codec, Jt72Message};
use super::gray::gray6;
use super::interleave::interleave;
use super::rx;
#[derive(Clone, Debug)]
pub struct ChaseParams {
pub max_trials: usize,
pub seed: u32,
pub max_erasures: usize,
pub early_exit_nhard: u32,
pub early_exit_ntotal: f32,
pub nd0: u32,
pub r0: f32,
}
impl Default for ChaseParams {
fn default() -> Self {
Self {
max_trials: 1000,
seed: 1,
max_erasures: Rs63_12::NROOTS,
early_exit_nhard: 41,
early_exit_ntotal: 71.0,
nd0: 81,
r0: 0.87,
}
}
}
#[rustfmt::skip]
const PERR: [[f32; 8]; 8] = [
[ 4.0, 9.0, 11.0, 13.0, 14.0, 14.0, 15.0, 15.0],
[ 2.0, 20.0, 20.0, 30.0, 40.0, 50.0, 50.0, 50.0],
[ 7.0, 24.0, 27.0, 40.0, 50.0, 50.0, 50.0, 50.0],
[13.0, 25.0, 35.0, 46.0, 52.0, 70.0, 50.0, 50.0],
[17.0, 30.0, 42.0, 54.0, 55.0, 64.0, 71.0, 70.0],
[25.0, 39.0, 48.0, 57.0, 64.0, 66.0, 77.0, 77.0],
[32.0, 45.0, 54.0, 63.0, 66.0, 75.0, 78.0, 83.0],
[51.0, 58.0, 57.0, 66.0, 72.0, 77.0, 82.0, 86.0],
];
fn build_thresh0(order: &[usize], conf: &[f32; 63]) -> Vec<f32> {
order
.iter()
.enumerate()
.map(|(i, &pos)| {
let ratio = (1.0 - conf[pos]).clamp(0.0, 1.0);
let ii = ((7.999 * ratio) as usize).min(7);
let jj = ((62 - i) / 8).min(7);
1.3 * PERR[ii][jj]
})
.collect()
}
pub(super) fn confidence_order(conf: &[f32; 63]) -> Vec<usize> {
let mut order: Vec<usize> = (0..63).collect();
order.sort_by(|&a, &b| {
conf[a]
.partial_cmp(&conf[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
order
}
struct Lcg(u32);
impl Lcg {
fn new(seed: u32) -> Self {
Self(seed)
}
fn next_u32(&mut self) -> u32 {
self.0 = self.0.wrapping_mul(1_103_515_245).wrapping_add(12_345);
self.0
}
fn next_pct(&mut self) -> u32 {
let ir = (self.next_u32() / 65536) % 32768;
(100 * ir) / 32768
}
}
fn getpp(cand_sent: &[u8; 63], raw_pwr: &[[f32; 64]; 63]) -> f32 {
let mut a = *cand_sent;
interleave(&mut a);
for x in a.iter_mut() {
*x = gray6(*x);
}
let mut psum = 0.0f32;
for (j, &tone) in a.iter().enumerate() {
psum += raw_pwr[j][tone as usize];
}
psum / 63.0
}
fn unpack_jt72(info: &[u8; 12]) -> Option<Jt72Message> {
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;
}
Jt72Codec::default().unpack(&payload, &DecodeContext::default())
}
struct Best {
info: [u8; 12],
nhard: u32,
ntotal: f32,
}
pub fn decode_at_with_chase(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
params: &ChaseParams,
) -> Option<Jt72Message> {
decode_at_with_chase_and_snr(audio, sample_rate, start_sample, base_freq_hz, params)
.map(|(msg, _snr)| msg)
}
pub(super) fn decode_at_with_chase_and_snr(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
params: &ChaseParams,
) -> Option<(Jt72Message, f32)> {
let (symbols, conf, second_sym, rel, raw_pwr, snr_db) =
rx::demodulate_aligned_with_runnerup(audio, sample_rate, start_sample, base_freq_hz)?;
let rs = Rs63_12::new();
if let Some((info, _nerr)) = rs.decode_jt65_erasures(&symbols, &[])
&& let Some(msg) = unpack_jt72(&info)
{
return Some((msg, snr_db));
}
let nsum: f32 = rel.iter().sum();
if nsum <= 0.0 {
return None;
}
let order = confidence_order(&rel);
let thresh0 = build_thresh0(&order, &conf);
let max_erasures = params.max_erasures.min(Rs63_12::NROOTS);
let mut lcg = Lcg::new(params.seed);
let mut pp1 = 0.0f32;
let mut pp2 = 0.0f32;
let mut best: Option<Best> = None;
for _trial in 1..=params.max_trials {
let mut eras: Vec<u32> = Vec::with_capacity(max_erasures);
for (i, &pos) in order.iter().enumerate() {
let draw = lcg.next_pct();
if (draw as f32) < thresh0[i] && eras.len() < max_erasures {
eras.push(pos as u32);
}
}
let Some((info, _nerr)) = rs.decode_jt65_erasures(&symbols, &eras) else {
continue;
};
let cand_sent = rs.encode_jt65(&info);
let mut nhard = 0u32;
let mut nsoft_raw = 0.0f32;
for i in 0..63 {
if cand_sent[i] != symbols[i] {
nhard += 1;
if cand_sent[i] != second_sym[i] {
nsoft_raw += rel[i];
}
}
}
let nsoft = 63.0 * nsoft_raw / nsum;
let ntotal = nhard as f32 + nsoft;
let pp = getpp(&cand_sent, &raw_pwr);
if pp > pp1 {
pp2 = pp1;
pp1 = pp;
best = Some(Best {
info,
nhard,
ntotal,
});
} else if pp > pp2 && pp != pp1 {
pp2 = pp;
}
if let Some(b) = &best
&& b.nhard <= params.early_exit_nhard
&& b.ntotal <= params.early_exit_ntotal
{
break;
}
}
let best = best?;
let rtt = if pp1 > 0.0 { pp2 / pp1 } else { 0.0 };
if (best.ntotal > params.nd0 as f32) || (rtt > params.r0) {
return None;
}
let msg = unpack_jt72(&best.info)?;
Some((msg, snr_db))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jt65::tx::synthesize_standard;
#[test]
fn chase_decodes_clean_synth_via_fast_path() {
let freq = 1270.0;
let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
let msg = decode_at_with_chase(&audio, 12_000, 0, freq, &ChaseParams::default())
.expect("chase decoder must decode clean synth via the fast zero-erasure path");
assert!(matches!(
msg,
Jt72Message::Standard { ref call1, ref call2, ref grid_or_report }
if call1 == "CQ" && call2 == "K1ABC" && grid_or_report == "FN42"
));
}
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 * core::f32::consts::PI * u2).cos()
}
fn fill_noise(&mut self, buf: &mut [f32], amplitude: f32) {
for s in buf.iter_mut() {
*s = amplitude * self.gaussian();
}
}
}
#[test]
fn chase_never_false_decodes_on_pure_noise() {
const NSAMPLES: usize = 126 * 4460; for seed in 1..=20u32 {
let mut rng = NoiseGen(seed.wrapping_mul(2_654_435_761) | 1);
let mut audio = vec![0.0f32; NSAMPLES];
rng.fill_noise(&mut audio, 0.3);
let msg = decode_at_with_chase(&audio, 12_000, 0, 1270.0, &ChaseParams::default());
assert!(
msg.is_none(),
"chase decoder must not decode pure noise (seed={seed}), got {msg:?}"
);
}
}
#[test]
fn chase_never_false_decodes_below_floor_snr() {
let freq = 1270.0;
let clean = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
for seed in 1..=20u32 {
let mut rng = NoiseGen(seed.wrapping_mul(2_654_435_761) | 1);
let mut audio = clean.clone();
for s in audio.iter_mut() {
*s *= 0.01;
}
let mut noise = vec![0.0f32; audio.len()];
rng.fill_noise(&mut noise, 0.3);
for (s, n) in audio.iter_mut().zip(noise.iter()) {
*s += n;
}
let msg = decode_at_with_chase(&audio, 12_000, 0, freq, &ChaseParams::default());
assert!(
msg.is_none(),
"chase decoder must not decode a signal this deep below the floor (seed={seed}), got {msg:?}"
);
}
}
}