#![allow(dead_code)]
use num_complex::Complex;
use rustfft::FftPlanner;
use super::baseband::NSPS_BB;
use super::interleave::deinterleave_llrs;
use super::sync_pattern::JT9_ISYNC;
const LLR_CLAMP: f32 = 20.0;
pub fn sync_score(idat: &[f32], qdat: &[f32], start_bb: usize) -> f32 {
let n = idat.len();
let mut sync_sum = 0.0f32;
let mut data_sum = 0.0f32;
let mut ns = 0usize;
let mut nd = 0usize;
for sym_idx in 0..85usize {
let s0 = start_bb + sym_idx * NSPS_BB;
let s1 = s0 + NSPS_BB;
if s1 > n {
break;
}
let si: f32 = idat[s0..s1].iter().sum();
let sq: f32 = qdat[s0..s1].iter().sum();
let mag = (si * si + sq * sq).sqrt();
if JT9_ISYNC[sym_idx] == 1 {
sync_sum += mag;
ns += 1;
} else {
data_sum += mag;
nd += 1;
}
}
if ns == 0 {
return 0.0;
}
let sync_avg = sync_sum / ns as f32;
let data_avg = if nd > 0 { data_sum / nd as f32 } else { 0.0 };
sync_avg / (sync_avg + data_avg + 1e-6)
}
#[inline]
fn inv_gray3(g: u8) -> u8 {
let mut n = g & 0x7;
n ^= n >> 1;
n ^= n >> 2;
n & 0x7
}
pub fn demodulate(idat: &[f32], qdat: &[f32], start_bb: usize) -> [f32; 206] {
let n = idat.len();
let end = start_bb + 85 * NSPS_BB;
if end > n {
return [0f32; 206];
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(NSPS_BB);
let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
let mut llrs207 = [0f32; 207];
let mut noise_acc = 0.0f32;
let mut noise_count = 0u32;
let mut j = 0usize;
for sym_idx in 0..85usize {
let s0 = start_bb + sym_idx * NSPS_BB;
let mut buf: Vec<Complex<f32>> = (s0..s0 + NSPS_BB)
.map(|k| Complex::new(idat[k], qdat[k]))
.collect();
fft.process_with_scratch(&mut buf, &mut scratch);
for b in 9..14 {
noise_acc += buf[b].norm_sqr();
noise_count += 1;
}
if JT9_ISYNC[sym_idx] == 1 {
continue;
}
let mut mags = [0f32; 8];
for t in 0..8 {
mags[t] = buf[t + 1].norm();
}
for bit_pos in 0..3 {
let mask = 1u8 << (2 - bit_pos);
let mut max0 = f32::NEG_INFINITY;
let mut max1 = f32::NEG_INFINITY;
for tone in 0u8..8 {
let data_bits = inv_gray3(tone);
let p = mags[tone as usize] * mags[tone as usize];
if data_bits & mask == 0 {
max0 = max0.max(p);
} else {
max1 = max1.max(p);
}
}
llrs207[3 * j + bit_pos] = max0 - max1;
}
j += 1;
}
debug_assert_eq!(j, 69);
let noise_var = if noise_count > 0 {
(noise_acc / noise_count as f32).max(1e-6)
} else {
1.0
};
let mut out = [0f32; 206];
for i in 0..206 {
out[i] = (llrs207[i] / noise_var).clamp(-LLR_CLAMP, LLR_CLAMP);
}
deinterleave_llrs(&mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jt9::baseband::mix_to_baseband;
use crate::jt9::tx::synthesize_standard;
#[test]
fn synth_demod_bb_roundtrip() {
use crate::core::{DecodeContext, FecOpts, MessageCodec};
use crate::fec::{ConvFano232, FecCodec};
use crate::msg::{Jt72Codec, Jt72Message};
let freq = 1200.0f32;
let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
let (idat, qdat) = mix_to_baseband(&audio, freq);
let llrs = demodulate(&idat, &qdat, 0);
let codec = ConvFano232;
let res = codec
.decode_soft(&llrs, &FecOpts::default())
.expect("Fano must converge on clean synth");
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
let msg = Jt72Codec::default()
.unpack(&payload, &DecodeContext::default())
.expect("unpack");
match msg {
Jt72Message::Standard {
call1,
call2,
grid_or_report,
} => {
assert_eq!(call1, "CQ");
assert_eq!(call2, "K1ABC");
assert_eq!(grid_or_report, "FN42");
}
other => panic!("expected Standard, got {:?}", other),
}
}
#[test]
fn jt9_golden_grid_roundtrips() {
use crate::core::{DecodeContext, FecOpts, MessageCodec};
use crate::fec::{ConvFano232, FecCodec};
use crate::msg::{Jt72Codec, Jt72Message};
let cases = &[
("CQ", "GM7GAX", "IO75"),
("TF3G", "N7MQ", "CN84"),
("K1JT", "KF4RWA", "73"),
("CQ", "M0WAY", "IO82"),
("K1JT", "N5KDV", "EM41"),
];
for &(c1, c2, grid) in cases {
let audio = synthesize_standard(c1, c2, grid, 12_000, 1346.0, 0.5).expect("synth");
let (idat, qdat) = mix_to_baseband(&audio, 1346.0);
let llrs = demodulate(&idat, &qdat, 0);
let res = ConvFano232
.decode_soft(&llrs, &FecOpts::default())
.unwrap_or_else(|| panic!("Fano failed for {} {} {}", c1, c2, grid));
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
let msg = Jt72Codec::default()
.unpack(&payload, &DecodeContext::default())
.unwrap_or_else(|| {
panic!(
"unpack failed for {} {} {} hard={}",
c1, c2, grid, res.hard_errors
)
});
match msg {
Jt72Message::Standard {
call1,
call2,
grid_or_report,
} => {
assert_eq!(call1, c1, "call1 mismatch for {} {} {}", c1, c2, grid);
assert_eq!(call2, c2, "call2 mismatch for {} {} {}", c1, c2, grid);
assert_eq!(
grid_or_report, grid,
"grid mismatch: got {} for {} {} {}",
grid_or_report, c1, c2, grid
);
}
other => panic!(
"expected Standard for {} {} {}, got {:?}",
c1, c2, grid, other
),
}
}
}
}
#[cfg(test)]
mod sync_diag {
use super::*;
use crate::jt9::baseband::mix_to_baseband;
use std::path::Path;
fn load_wav(path: &Path) -> Option<Vec<f32>> {
let bytes = std::fs::read(path).ok()?;
let data_len = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]) as usize;
let data = &bytes[44..44 + data_len];
Some(
data.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect(),
)
}
#[test]
#[ignore]
fn print_sync_scores_dense() {
let path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
));
if !path.exists() {
eprintln!("WAV not found");
return;
}
let audio = load_wav(path).unwrap();
eprintln!("=== 1346 Hz: score at start_bb=1296..1566 for different mix freqs ===");
for &mix_freq in &[1345.5f32, 1345.934f32, 1346.0f32, 1346.434f32] {
let (idat, qdat) = mix_to_baseband(&audio, mix_freq);
let score_1320 = sync_score(&idat, &qdat, 1320);
let score_1512 = sync_score(&idat, &qdat, 1512);
let score_1566 = sync_score(&idat, &qdat, 1566);
eprintln!(
" mix={:.3} Hz: score@1320={:.4} score@1512={:.4} score@1566={:.4}",
mix_freq, score_1320, score_1512, score_1566
);
}
for &(freq, label) in &[
(1224.0f32, "1224.0 Hz (golden K1JT KF4RWA 73, dt=0.1)"),
(1345.5f32, "1345.5 Hz (golden K1JT N5KDV EM41, dt=0.1)"),
(1185.8f32, "1185.8 Hz (golden TF3G N7MQ CN84, dt=0.0)"),
(1289.9f32, "1289.9 Hz (golden CQ M0WAY IO82, dt=0.1)"),
(1119.0f32, "1119.0 Hz (golden CQ GM7GAX IO75, dt=0.0)"),
] {
let (idat, qdat) = mix_to_baseband(&audio, freq);
let mut peak_bb = 0usize;
let mut peak_score = 0.0f32;
for start_bb in (0..=2200usize).step_by(54) {
if start_bb + 85 * NSPS_BB > idat.len() {
break;
}
let score = sync_score(&idat, &qdat, start_bb);
if score > peak_score {
peak_score = score;
peak_bb = start_bb;
}
}
eprintln!(
"\n{label}: PEAK at start_bb={} ({:.3}s) score={:.4}",
peak_bb,
peak_bb as f32 / 1500.0,
peak_score
);
}
}
#[test]
#[ignore]
fn compare_tx_tones_vs_wav_tones() {
use crate::jt9::tx::{encode_channel_symbols, synthesize_standard};
use crate::msg::jt72::pack_standard;
use num_complex::Complex;
use rustfft::FftPlanner;
let words_em = pack_standard("K1JT", "N5KDV", "EM41").expect("pack EM");
let words_nm = pack_standard("K1JT", "N5KDV", "NM51").expect("pack NM");
let mut info_em = [0u8; 72];
let mut info_nm = [0u8; 72];
for (i, b) in info_em.iter_mut().enumerate() {
*b = (words_em[i / 6] >> (5 - (i % 6))) & 1;
}
for (i, b) in info_nm.iter_mut().enumerate() {
*b = (words_nm[i / 6] >> (5 - (i % 6))) & 1;
}
let tones_em = encode_channel_symbols(&info_em);
let tones_nm = encode_channel_symbols(&info_nm);
eprintln!("EM41 tones (first 30): {:?}", &tones_em[..30]);
eprintln!("NM51 tones (first 30): {:?}", &tones_nm[..30]);
let words = words_em;
let mut info_bits = [0u8; 72];
for (i, bit) in info_bits.iter_mut().enumerate() {
let word = words[i / 6];
let bit_in_word = 5 - (i % 6);
*bit = (word >> bit_in_word) & 1;
}
let expected_tones = encode_channel_symbols(&info_bits);
eprintln!("Expected tones (first 20): {:?}", &expected_tones[..20]);
let synth_audio =
synthesize_standard("K1JT", "N5KDV", "EM41", 12_000, 1346.0, 0.5).expect("synth");
let (synth_id, synth_qd) = mix_to_baseband(&synth_audio, 1346.0);
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(NSPS_BB);
let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
eprintln!("\n=== SYNTH 'K1JT N5KDV EM41' at 1346 Hz, start_bb=0 ===");
eprintln!("sym | expected | dominant_bin | top_3_mags");
for sym_idx in 0..15usize {
let s0 = sym_idx * NSPS_BB;
let mut buf: Vec<Complex<f32>> = (s0..s0 + NSPS_BB)
.map(|k| Complex::new(synth_id[k], synth_qd[k]))
.collect();
fft.process_with_scratch(&mut buf, &mut scratch);
let mut mags = [0f32; 9];
for t in 0..9 {
mags[t] = buf[t].norm();
}
let mut best = 0usize;
for t in 1..9 {
if mags[t] > mags[best] {
best = t;
}
}
eprintln!(
" {:2} | {} | {} | {:?}",
sym_idx,
expected_tones[sym_idx],
best,
mags.iter()
.map(|m| (*m * 1.0).round() / 1.0)
.collect::<Vec<_>>()
);
}
let wav_path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
));
if !wav_path.exists() {
eprintln!("WAV not found");
return;
}
let audio = load_wav(wav_path).unwrap();
let (wav_id, wav_qd) = mix_to_baseband(&audio, 1346.0);
let mut best_start_sync = 0usize;
let mut best_sync_sc = 0.0f32;
let mut best_start_pwr = 0usize;
let mut best_pwr = 0.0f32;
for start_bb in (0..(wav_id.len().saturating_sub(85 * NSPS_BB))).step_by(8) {
let s = sync_score(&wav_id, &wav_qd, start_bb);
if s > best_sync_sc {
best_sync_sc = s;
best_start_sync = start_bb;
}
let mut pwr = 0.0f32;
for sym_idx in 0..85usize {
let s0 = start_bb + sym_idx * NSPS_BB;
if s0 + NSPS_BB > wav_id.len() {
break;
}
let si: f32 = wav_id[s0..s0 + NSPS_BB].iter().sum();
let sq: f32 = wav_qd[s0..s0 + NSPS_BB].iter().sum();
pwr += si * si + sq * sq;
}
if pwr > best_pwr {
best_pwr = pwr;
best_start_pwr = start_bb;
}
}
eprintln!(
"\nSync-score peak: start_bb={} ({:.3}s) score={:.4}",
best_start_sync,
best_start_sync as f32 / 1500.0,
best_sync_sc
);
eprintln!(
"DC-power peak: start_bb={} ({:.3}s) pwr={:.0}",
best_start_pwr,
best_start_pwr as f32 / 1500.0,
best_pwr
);
let best_start = best_start_sync;
let best_sc = best_sync_sc;
eprintln!(
"\n=== WAV 1346 Hz at best sync, start_bb={} score={:.4} ===",
best_start, best_sc
);
eprintln!("EM41 first 10 expected tones: {:?}", &tones_em[..10]);
eprintln!("NM51 first 10 expected tones: {:?}", &tones_nm[..10]);
let words_1224 = pack_standard("K1JT", "KF4RWA", "73").expect("pack 1224");
let mut info_1224 = [0u8; 72];
for (i, b) in info_1224.iter_mut().enumerate() {
*b = (words_1224[i / 6] >> (5 - (i % 6))) & 1;
}
let tones_1224 = encode_channel_symbols(&info_1224);
let (wav_id_1224, wav_qd_1224) = mix_to_baseband(&audio, 1224.0);
let mut best_1224_start = 0usize;
let mut best_1224_sc = 0.0f32;
for start_bb in (0..(wav_id_1224.len().saturating_sub(85 * NSPS_BB))).step_by(8) {
let s = sync_score(&wav_id_1224, &wav_qd_1224, start_bb);
if s > best_1224_sc {
best_1224_sc = s;
best_1224_start = start_bb;
}
}
eprintln!(
"\n=== WAV 1224 Hz (correctly decoded reference) at start_bb={} score={:.4} ===",
best_1224_start, best_1224_sc
);
eprintln!("Expected tones (first 15): {:?}", &tones_1224[..15]);
eprintln!("sym | expected | dominant_bin | top mags");
for sym_idx in 0..15usize {
let s0 = best_1224_start + sym_idx * NSPS_BB;
if s0 + NSPS_BB > wav_id_1224.len() {
break;
}
let mut buf: Vec<Complex<f32>> = (s0..s0 + NSPS_BB)
.map(|k| Complex::new(wav_id_1224[k], wav_qd_1224[k]))
.collect();
fft.process_with_scratch(&mut buf, &mut scratch);
let mut mags = [0f32; 9];
for t in 0..9 {
mags[t] = buf[t].norm();
}
let mut best = 0usize;
for t in 1..9 {
if mags[t] > mags[best] {
best = t;
}
}
let exp = tones_1224[sym_idx];
let mark = if exp == best as u8 { "✓" } else { "✗" };
eprintln!(
" {:2} | {} | {} {} | {:?}",
sym_idx,
exp,
best,
mark,
mags.iter().map(|m| (*m).round()).collect::<Vec<_>>()
);
}
eprintln!("sym | expected | dominant_bin | top mags");
for sym_idx in 0..15usize {
let s0 = best_start + sym_idx * NSPS_BB;
if s0 + NSPS_BB > wav_id.len() {
break;
}
let mut buf: Vec<Complex<f32>> = (s0..s0 + NSPS_BB)
.map(|k| Complex::new(wav_id[k], wav_qd[k]))
.collect();
fft.process_with_scratch(&mut buf, &mut scratch);
let mut mags = [0f32; 9];
for t in 0..9 {
mags[t] = buf[t].norm();
}
let mut best = 0usize;
for t in 1..9 {
if mags[t] > mags[best] {
best = t;
}
}
let exp = expected_tones[sym_idx];
let mark = if exp == best as u8 { "✓" } else { "✗" };
eprintln!(
" {:2} | {} | {} {} | {:?}",
sym_idx,
exp,
best,
mark,
mags.iter().map(|m| (*m).round()).collect::<Vec<_>>()
);
}
}
#[test]
#[ignore]
fn fano_sweep_golden() {
use crate::core::{DecodeContext, FecOpts, MessageCodec};
use crate::fec::{ConvFano232, FecCodec};
use crate::msg::Jt72Codec;
let path = Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../embedded-poc/assets/130418_1742.wav"
));
if !path.exists() {
eprintln!("WAV not found");
return;
}
let audio = load_wav(path).unwrap();
for &(nom_freq, label) in &[
(1224.0f32, "1224 Hz K1JT KF4RWA 73 dt=0.1"),
(1346.0f32, "1346 Hz K1JT N5KDV EM41 dt=0.1"),
(1186.0f32, "1186 Hz TF3G N7MQ CN84 dt=0.0"),
(1290.0f32, "1290 Hz CQ M0WAY IO82 dt=0.1"),
(1119.0f32, "1119 Hz CQ GM7GAX IO75 dt=0.0"),
] {
eprintln!("\n=== {} ===", label);
let freq_deltas = [0.0f32, 0.434, -0.434, 0.868, -0.868];
for &df in &freq_deltas {
let freq = nom_freq + df;
let (idat, qdat) = mix_to_baseband(&audio, freq);
for start_bb in (900..=2200usize).step_by(54) {
if start_bb + 85 * NSPS_BB > idat.len() {
break;
}
let score = sync_score(&idat, &qdat, start_bb);
if score < 0.50 {
continue;
}
let llrs = demodulate(&idat, &qdat, start_bb);
if let Some(res) = ConvFano232.decode_soft(&llrs, &FecOpts::default()) {
let mut payload = [0u8; 72];
payload.copy_from_slice(&res.info);
if let Some(msg) =
Jt72Codec::default().unpack(&payload, &DecodeContext::default())
{
let t_sec = start_bb as f32 / 1500.0;
eprintln!(
" freq={:.3} Hz start_bb={} ({:.3}s) score={:.4} hard={}: {:?}",
freq, start_bb, t_sec, score, res.hard_errors, msg
);
}
}
}
}
}
}
}