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)
}
pub fn twkfreq_const(buf: &mut [Complex<f32>], df_hz: f32) {
let dphi = -2.0 * std::f32::consts::PI * df_hz / FSAMPLE_DOWN;
let step = Complex::new(dphi.cos(), dphi.sin());
let mut w = Complex::new(1.0f32, 0.0);
for s in buf.iter_mut() {
*s = w * *s;
w *= step;
}
}
pub fn symspec2(c5: &[Complex<f32>]) -> [f32; 207] {
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();
}
}
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; 206] {
let mut s207 = symspec2(c5);
let mut s206 = [0f32; 206];
s206.copy_from_slice(&s207[..206]);
deinterleave_llrs(&mut s206);
let _ = &mut s207[206]; s206
}
pub fn afc_simple(c3: &[Complex<f32>]) -> f32 {
let mut best_df = 0.0f32;
let mut best_score = f32::NEG_INFINITY;
let mut work: Vec<Complex<f32>> = vec![Complex::new(0.0, 0.0); NZ3];
let trial: Vec<f32> = (-20i32..=20)
.map(|k| k as f32 * 0.1 * TONE_SPACING)
.collect();
for &df in &trial {
work.copy_from_slice(c3);
twkfreq_const(&mut work, df);
let mut s = 0.0f32;
for j in 0..85usize {
if JT9_ISYNC[j] == 0 {
continue;
}
let lo = j * NSPSD;
let mut z = Complex::new(0.0f32, 0.0);
for k in 0..NSPSD {
z += work[lo + k];
}
s += z.norm_sqr();
}
if s > best_score {
best_score = s;
best_df = df;
}
}
best_df
}
#[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 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 = 1_200usize; 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 = +0.1 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 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),
}
}
}