use num_complex::Complex;
use rustfft::FftPlanner;
use super::interleave::deinterleave_llrs;
use super::sync_pattern::JT9_ISYNC;
pub const NFFT1: usize = 653_184;
pub const NFFT2: usize = 1512;
pub const NSPSD: usize = 16;
pub const NZ3: usize = 1360;
pub const NDOWN: usize = NFFT1 / NFFT2;
pub const FSAMPLE_DOWN: f32 = 12_000.0 / NDOWN as f32;
pub const TONE_SPACING: f32 = 12_000.0 / 6912.0;
const SCALE: f32 = 10.0;
const LLR_CLAMP: f32 = 127.0;
pub struct AudioFft {
pub c1: Vec<Complex<f32>>,
pub envelope: Vec<f32>,
}
impl AudioFft {
pub fn build(audio: &[f32]) -> Self {
let n = audio.len().min(NFFT1);
let mut buf: Vec<Complex<f32>> = vec![Complex::new(0.0, 0.0); NFFT1];
for i in 0..n {
buf[i].re = audio[i] * 32_768.0;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(NFFT1);
let mut scratch = vec![Complex::new(0.0, 0.0); fft.get_inplace_scratch_len()];
fft.process_with_scratch(&mut buf, &mut scratch);
buf.truncate(NFFT1 / 2 + 1);
let df1 = 12_000.0 / NFFT1 as f32;
let nadd = (1.0 / df1).round() as usize;
let env_len = 5000usize;
let mut envelope = vec![0.0f32; env_len];
for i in 0..env_len {
let j_start = ((i as f32) / df1).round() as usize;
for n_off in 0..nadd {
let j = j_start + n_off;
if j < buf.len() {
envelope[i] += buf[j].norm_sqr();
}
}
}
Self { c1: buf, envelope }
}
pub fn downsam9(&self, fpk: f32) -> Vec<Complex<f32>> {
let df1 = 12_000.0 / NFFT1 as f32;
let i0 = (fpk / df1) as i64;
let nh2 = (NFFT2 / 2) as i64;
let nf = fpk.round() as i64;
let ia = (nf - 100).max(1) as usize;
let ib = (nf + 100).min(self.envelope.len() as i64 - 1) as usize;
let mut env_slice: Vec<f32> = self.envelope[ia..=ib].to_vec();
env_slice.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pcl = env_slice.len() * 40 / 100;
let avenoise = env_slice[pcl.min(env_slice.len() - 1)].max(1e-6);
let fac = (1.0 / avenoise).sqrt();
let mut c2 = vec![Complex::new(0.0, 0.0); NFFT2];
let c1_len = self.c1.len() as i64;
for i in 0..NFFT2 as i64 {
let mut j = i0 + i;
if i > nh2 {
j -= NFFT2 as i64;
}
if j >= 0 && j < c1_len {
c2[i as usize] = self.c1[j as usize] * fac;
}
}
let mut planner = FftPlanner::<f32>::new();
let ifft = planner.plan_fft_inverse(NFFT2);
let mut scratch = vec![Complex::new(0.0, 0.0); ifft.get_inplace_scratch_len()];
ifft.process_with_scratch(&mut c2, &mut scratch);
c2
}
}
pub fn peakdt9(c2: &[Complex<f32>]) -> (i64, f32, Vec<Complex<f32>>) {
assert_eq!(c2.len(), NFFT2);
let mut p = vec![0.0f32; NFFT2 + 5 * NSPSD];
let i0 = 5 * NSPSD;
for i in 0..NFFT2 {
let lo = (i + 1).saturating_sub(NSPSD);
let mut z = Complex::new(0.0f32, 0.0);
for k in lo..=i {
z += c2[k];
}
z *= 1e-3;
p[i0 + i] = z.norm_sqr();
}
let lag0: i64 = 123;
let lag1: i64 = 39;
let lag2: i64 = 291;
let mut smax = f32::NEG_INFINITY;
let mut lagpk = lag0;
for lag in lag1..=lag2 {
let mut sum0 = 0.0f32;
let mut sum1 = 0.0f32;
for sym in 0..85usize {
let idx = (sym * NSPSD) as i64 + lag;
if idx < 0 || idx as usize >= p.len() {
continue;
}
let v = p[idx as usize];
if JT9_ISYNC[sym] == 1 {
sum1 += v;
} else {
sum0 += v;
}
}
if sum0 <= 0.0 {
continue;
}
let ss = (sum1 / 16.0) / (sum0 / 69.0) - 1.0;
if ss > smax {
smax = ss;
lagpk = lag;
}
}
let mut c3 = vec![Complex::new(0.0, 0.0); NZ3];
for i in 0..NZ3 as i64 {
let j = i + lagpk - i0 as i64 - NSPSD as i64 + 1;
if j >= 0 && (j as usize) < NFFT2 {
c3[i as usize] = c2[j as usize];
}
}
(lagpk, smax, c3)
}
fn compute_ss2(c5: &[Complex<f32>]) -> [[f32; 85]; 9] {
assert_eq!(c5.len(), NZ3);
let mut ss2 = [[0.0f32; 85]; 9];
let mut work: Vec<Complex<f32>> = c5.to_vec();
let dphi = -2.0 * std::f32::consts::PI * TONE_SPACING / FSAMPLE_DOWN;
let step = Complex::new(dphi.cos(), dphi.sin());
for i in 0..9usize {
if i >= 1 {
let mut w = Complex::new(1.0f32, 0.0);
for s in work.iter_mut() {
*s = w * *s;
w *= step;
}
}
for j in 0..85usize {
let lo = j * NSPSD;
let mut z = Complex::new(0.0f32, 0.0);
for k in 0..NSPSD {
z += work[lo + k];
}
ss2[i][j] = z.norm_sqr();
}
}
ss2
}
pub fn chkss2(ss2: &[[f32; 85]; 9]) -> f32 {
let mut total = 0.0f32;
for col in ss2.iter() {
for &v in col {
total += v;
}
}
let ave = (total / (9.0 * 85.0)).max(1e-9);
let mut s1 = 0.0f32;
for j in 0..85 {
if JT9_ISYNC[j] == 1 {
s1 += ss2[0][j] / ave - 1.0;
}
}
s1 / 16.0
}
fn symspec2_from_ss2(ss2: &[[f32; 85]; 9]) -> [f32; 207] {
let mut ss3 = [[0.0f32; 69]; 8];
for i in 1..9usize {
let mut m = 0usize;
for j in 0..85usize {
if JT9_ISYNC[j] == 0 {
ss3[i - 1][m] = ss2[i][j];
m += 1;
}
}
}
let mut ss_total = 0.0f32;
for j in 0..69 {
let mut smax = 0.0f32;
let mut col_sum = 0.0f32;
for i in 0..8 {
let v = ss3[i][j];
if v > smax {
smax = v;
}
col_sum += v;
}
ss_total += col_sum - smax;
}
let ave = (ss_total / (69.0 * 7.0)).max(1e-9);
for col in ss3.iter_mut() {
for v in col.iter_mut() {
*v /= ave;
}
}
let mut out_207 = [0.0f32; 207];
let mut k = 0usize;
for j in 0..69usize {
for m in (0..3i32).rev() {
let (r1, r0) = match m {
2 => (
[ss3[4][j], ss3[5][j], ss3[6][j], ss3[7][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
[ss3[0][j], ss3[1][j], ss3[2][j], ss3[3][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
),
1 => (
[ss3[2][j], ss3[3][j], ss3[4][j], ss3[5][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
[ss3[0][j], ss3[1][j], ss3[6][j], ss3[7][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
),
_ => (
[ss3[1][j], ss3[2][j], ss3[4][j], ss3[7][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
[ss3[0][j], ss3[3][j], ss3[5][j], ss3[6][j]]
.iter()
.cloned()
.fold(f32::NEG_INFINITY, f32::max),
),
};
let llr = SCALE * (r0 - r1);
out_207[k] = llr.clamp(-LLR_CLAMP, LLR_CLAMP);
k += 1;
}
}
out_207
}
pub fn llrs_from_c5(c5: &[Complex<f32>]) -> (f32, [f32; 206]) {
let ss2 = compute_ss2(c5);
let schk = chkss2(&ss2);
let s207 = symspec2_from_ss2(&ss2);
let mut s206 = [0f32; 206];
s206.copy_from_slice(&s207[..206]);
deinterleave_llrs(&mut s206);
(schk, s206)
}
pub fn twkfreq_poly(buf: &mut [Complex<f32>], a: [f32; 3]) {
let n = buf.len() as f32;
let x0 = 0.5 * (n + 1.0);
let s = 2.0 / n;
let two_pi_over_fs = 2.0 * std::f32::consts::PI / FSAMPLE_DOWN;
let mut w = Complex::new(1.0f32, 0.0);
for (i, slot) in buf.iter_mut().enumerate() {
let xi = s * ((i as f32 + 1.0) - x0);
let p2 = 1.5 * xi * xi - 0.5;
let dphi = (a[0] + xi * a[1] + p2 * a[2]) * two_pi_over_fs;
let wstep = Complex::new(dphi.cos(), dphi.sin());
w *= wstep;
*slot = w * *slot;
}
}
fn shft(c3a: &[Complex<f32>], n: i32) -> Vec<Complex<f32>> {
let len = c3a.len();
let mut c3 = vec![Complex::new(0.0, 0.0); len];
if n == 0 {
c3.copy_from_slice(c3a);
return c3;
}
let abs_n = n.unsigned_abs() as usize;
if abs_n >= len {
return c3; }
if n > 0 {
c3[..len - abs_n].copy_from_slice(&c3a[abs_n..]);
} else {
c3[abs_n..].copy_from_slice(&c3a[..len - abs_n]);
}
c3
}
fn fchisq(c3: &[Complex<f32>], a: [f32; 3]) -> f32 {
let mut work: Vec<Complex<f32>> = c3.to_vec();
twkfreq_poly(&mut work, a);
let mut sum1 = 0.0f32;
for j in 0..85usize {
let lo = j * NSPSD;
let mut z = Complex::new(0.0f32, 0.0);
for k in 0..NSPSD {
z += work[lo + k];
}
if JT9_ISYNC[j] == 1 {
sum1 += z.norm_sqr();
}
}
-sum1 / 10_000.0
}
#[derive(Copy, Clone, Debug)]
#[allow(dead_code)]
pub struct Afc9Result {
pub a0: f32,
pub a1: f32,
pub time_shift: i32,
pub syncpk: f32,
}
pub fn afc9(c3a: &mut Vec<Complex<f32>>) -> Afc9Result {
let mut a = [0.0f32; 3];
let mut deltaa = [TONE_SPACING, TONE_SPACING, 1.0f32];
let nterms = 3usize;
let mut c3 = c3a.clone();
let mut a3_applied = 0.0f32;
let mut chisqr = 0.0f32;
let mut chisqr0 = 1.0e6f32;
for _iter in 0..4 {
for j in 0..nterms {
if (a[2] - a3_applied).abs() > f32::EPSILON {
a3_applied = a[2];
c3 = shft(c3a, a3_applied.round() as i32);
}
let mut chisq1 = fchisq(&c3, a);
let mut fn_count = 0.0f32;
let mut delta = deltaa[j];
let mut chisq2;
loop {
a[j] += delta;
if j == 2 && (a[2] - a3_applied).abs() > f32::EPSILON {
a3_applied = a[2];
c3 = shft(c3a, a3_applied.round() as i32);
}
chisq2 = fchisq(&c3, a);
if chisq2 != chisq1 {
break;
}
}
if chisq2 > chisq1 {
delta = -delta;
a[j] += delta;
core::mem::swap(&mut chisq1, &mut chisq2);
}
let mut chisq3;
loop {
fn_count += 1.0;
a[j] += delta;
if j == 2 && (a[2] - a3_applied).abs() > f32::EPSILON {
a3_applied = a[2];
c3 = shft(c3a, a3_applied.round() as i32);
}
chisq3 = fchisq(&c3, a);
if chisq3 < chisq2 {
chisq1 = chisq2;
chisq2 = chisq3;
continue;
}
break;
}
let frac = 1.0 / (1.0 + (chisq1 - chisq2) / (chisq3 - chisq2)) + 0.5;
let new_delta = delta * frac;
a[j] -= new_delta;
if j < 2 {
deltaa[j] *= fn_count / 3.0;
}
}
if (a[2] - a3_applied).abs() > f32::EPSILON {
a3_applied = a[2];
c3 = shft(c3a, a3_applied.round() as i32);
}
chisqr = fchisq(&c3, a);
if chisqr0.abs() > 1e-12 && chisqr / chisqr0 > 0.99 {
break;
}
chisqr0 = chisqr;
}
*c3a = c3;
Afc9Result {
a0: a[0],
a1: a[1],
time_shift: a3_applied.round() as i32,
syncpk: -chisqr,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{DecodeContext, FecCodec, FecOpts, MessageCodec};
use crate::fec::ConvFano232;
use crate::jt9::tx::synthesize_standard;
use crate::msg::{Jt72Codec, Jt72Message};
#[test]
fn softsym_golden_grid_roundtrips() {
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 mut padded = vec![0f32; 720_000];
let n = audio.len().min(padded.len());
padded[..n].copy_from_slice(&audio[..n]);
let big = AudioFft::build(&padded);
let c2_buf = big.downsam9(1346.0);
let (_lag, sc, c3) = peakdt9(&c2_buf);
assert!(
sc > 0.5,
"sync score for {} {} {} too low: {}",
c1,
c2,
grid,
sc
);
let (_schk, llrs) = llrs_from_c5(&c3);
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 {} {} {}", c1, c2, grid));
match msg {
Jt72Message::Standard {
call1,
call2,
grid_or_report,
} => {
assert_eq!(call1, c1);
assert_eq!(call2, c2);
assert_eq!(
grid_or_report, grid,
"grid mismatch for {} {} {}",
c1, c2, grid
);
}
other => panic!(
"expected Standard for {} {} {}, got {:?}",
c1, c2, grid, other
),
}
}
}
fn encode_with_gray_dir(c1: &str, c2: &str, grid: &str, invert_gray: bool) -> [u8; 85] {
use crate::core::FecCodec;
use crate::fec::ConvFano232;
use crate::jt9::interleave::interleave;
use crate::jt9::sync_pattern::JT9_ISYNC;
use crate::msg::jt72::pack_standard;
let fwd_gray3 = |n: u8| -> u8 { (n ^ (n >> 1)) & 0x7 };
let inv_gray3 = |g: u8| -> u8 {
let mut n = g & 0x7;
n ^= n >> 1;
n ^= n >> 2;
n & 0x7
};
let words = pack_standard(c1, c2, grid).expect("pack");
let mut info = [0u8; 72];
for (i, b) in info.iter_mut().enumerate() {
*b = (words[i / 6] >> (5 - (i % 6))) & 1;
}
let mut cw206 = vec![0u8; 206];
ConvFano232.encode(&info, &mut cw206);
let mut bits206 = [0u8; 206];
bits206.copy_from_slice(&cw206);
interleave(&mut bits206);
let mut bits207 = [0u8; 207];
bits207[..206].copy_from_slice(&bits206);
let mut tones = [0u8; 85];
let mut j = 0usize;
for (i, slot) in tones.iter_mut().enumerate() {
if JT9_ISYNC[i] == 1 {
*slot = 0;
} else {
let b0 = bits207[3 * j];
let b1 = bits207[3 * j + 1];
let b2 = bits207[3 * j + 2];
let raw = (b0 << 2) | (b1 << 1) | b2;
let gc = if invert_gray {
inv_gray3(raw)
} else {
fwd_gray3(raw)
};
*slot = gc + 1;
j += 1;
}
}
tones
}
fn synth_audio(tones: &[u8; 85], freq: f32, amp: f32) -> Vec<f32> {
const NSPS: usize = 6912;
const SR: f32 = 12_000.0;
let spacing: f32 = SR / NSPS as f32;
let mut out = Vec::with_capacity(NSPS * 85);
let mut phase = 0.0f32;
for &sym in tones {
let f = freq + sym as f32 * spacing;
let dphi = 2.0 * std::f32::consts::PI * f / SR;
for _ in 0..NSPS {
out.push(amp * phase.cos());
phase += dphi;
if phase > 2.0 * std::f32::consts::PI {
phase -= 2.0 * std::f32::consts::PI;
}
}
}
out
}
fn write_wav(audio: &[f32], path: &str) {
let mut bytes: Vec<u8> = Vec::with_capacity(44 + audio.len() * 2);
let data_len = (audio.len() * 2) as u32;
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36u32 + data_len).to_le_bytes());
bytes.extend_from_slice(b"WAVE");
bytes.extend_from_slice(b"fmt ");
bytes.extend_from_slice(&16u32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&12_000u32.to_le_bytes());
bytes.extend_from_slice(&24_000u32.to_le_bytes());
bytes.extend_from_slice(&2u16.to_le_bytes());
bytes.extend_from_slice(&16u16.to_le_bytes());
bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_len.to_le_bytes());
for &s in audio {
let v = (s * 32_767.0).round().clamp(-32_768.0, 32_767.0) as i16;
bytes.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(path, &bytes).expect("write WAV");
}
#[test]
#[ignore]
fn dump_em41_gray_ab() {
for (invert, label, fname) in [
(false, "FORWARD gray (current)", "/tmp/260506_1300.wav"),
(true, "INVERSE gray (test)", "/tmp/260506_1400.wav"),
] {
let tones = encode_with_gray_dir("K1JT", "N5KDV", "EM41", invert);
let signal = synth_audio(&tones, 1500.0, 0.3);
let mut audio = vec![0f32; 12_000 * 60];
let off = 1_200usize;
let n = signal.len().min(audio.len() - off);
audio[off..off + n].copy_from_slice(&signal[..n]);
write_wav(&audio, fname);
eprintln!("Wrote {} → {}", label, fname);
eprintln!(" first 20 tones: {:?}", &tones[..20]);
}
eprintln!("\nDecode BOTH in WSJT-X (mode JT9). Whichever produces");
eprintln!("'K1JT N5KDV EM41' is the WSJT-X-compatible direction.");
}
#[test]
#[ignore]
fn dump_synth_em41() {
let mut audio = vec![0f32; 12_000 * 60];
let signal =
synthesize_standard("K1JT", "N5KDV", "EM41", 12_000, 1500.0, 0.3).expect("synth");
let off = 12_000usize; let n = signal.len().min(audio.len() - off);
audio[off..off + n].copy_from_slice(&signal[..n]);
let path = "/tmp/260506_1200.wav";
let mut bytes: Vec<u8> = Vec::with_capacity(44 + audio.len() * 2);
let data_len = (audio.len() * 2) as u32;
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36u32 + data_len).to_le_bytes());
bytes.extend_from_slice(b"WAVE");
bytes.extend_from_slice(b"fmt ");
bytes.extend_from_slice(&16u32.to_le_bytes()); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&12_000u32.to_le_bytes()); bytes.extend_from_slice(&24_000u32.to_le_bytes()); bytes.extend_from_slice(&2u16.to_le_bytes()); bytes.extend_from_slice(&16u16.to_le_bytes()); bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_len.to_le_bytes());
for &s in &audio {
let v = (s * 32_767.0).round().clamp(-32_768.0, 32_767.0) as i16;
bytes.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(path, &bytes).expect("write WAV");
eprintln!("Wrote noiseless 'K1JT N5KDV EM41' synth → {}", path);
eprintln!(" carrier = 1500 Hz, dt = +1.0 s, 60 s slot, mono PCM-16 12 kHz");
eprintln!(" amplitude = 0.3 (~ -10 dBFS peak)");
eprintln!(" Decode with WSJT-X (mode=JT9) and report what it says.");
}
#[test]
fn synth_softsym_roundtrip() {
let freq = 1500.0f32;
let audio = synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("synth");
let mut padded = vec![0f32; 720_000];
let n = audio.len().min(padded.len());
padded[..n].copy_from_slice(&audio[..n]);
let big_fft = AudioFft::build(&padded);
let c2 = big_fft.downsam9(freq);
let (_lag, sc, c3) = peakdt9(&c2);
eprintln!("synth roundtrip peakdt9 score = {}", sc);
assert!(
sc > 0.5,
"sync score for clean synth should be high; got {}",
sc
);
let (_schk, llrs) = llrs_from_c5(&c3);
let res = ConvFano232
.decode_soft(&llrs, &FecOpts::default())
.expect("Fano must converge on clean synth via WSJT-X-faithful pipeline");
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),
}
}
}