use alloc::boxed::Box;
use alloc::vec;
use alloc::vec::Vec;
use num_complex::Complex;
#[cfg(not(feature = "std"))]
use num_traits::Float;
use super::decode::{DecodeDepth, DecodeResult};
use super::llr::sync_quality;
use super::message::unpack77;
use super::params::{COSTAS, COSTAS_POS, DEFAULT_BP_MAX_ITER, LDPC_N, NMAX, NN, NSPS, NTONES};
use super::wave_gen::message_to_tones;
#[cfg(not(feature = "fixed-point"))]
use crate::core::fft::default_planner;
use crate::core::scalar::Cmplx;
use crate::core::sync::SyncCandidate;
use crate::fec::ldpc::bp::check_crc14;
use crate::fec::ldpc::osd::{osd_decode, osd_decode_deep};
pub trait AudioSample: Copy {
fn to_f32(self) -> f32;
fn to_i16(self) -> i16;
}
impl AudioSample for i16 {
#[inline]
fn to_f32(self) -> f32 {
self as f32
}
#[inline]
fn to_i16(self) -> i16 {
self
}
}
impl AudioSample for i8 {
#[inline]
fn to_f32(self) -> f32 {
(self as i32 * 256) as f32
}
#[inline]
fn to_i16(self) -> i16 {
(self as i16) << 8
}
}
pub const NFFT_SPEC: usize = 3840;
#[cfg(not(feature = "nstep-half"))]
const NSTEP: usize = NSPS / 4;
#[cfg(feature = "nstep-half")]
const NSTEP: usize = NSPS / 2;
const NSSY: i32 = (NSPS / NSTEP) as i32;
const TONE_SPACING_HZ: f32 = 6.25;
const RATIO_EPS_DEFAULT: f32 = 0.5;
fn ratio_eps() -> f32 {
#[cfg(feature = "std")]
{
if let Ok(s) = std::env::var("MFSK_RATIO_EPS")
&& let Ok(v) = s.parse::<f32>()
{
return v;
}
}
RATIO_EPS_DEFAULT
}
const SAMPLE_RATE_HZ: f32 = 12_000.0;
const TX_START_OFFSET_S: f32 = 0.5;
const SYNC_LAG_S_DEFAULT: f32 = 1.0;
fn sync_lag_s() -> f32 {
#[cfg(feature = "std")]
{
if let Ok(s) = std::env::var("MFSK_SYNC_LAG_S")
&& let Ok(v) = s.parse::<f32>()
{
return v;
}
}
SYNC_LAG_S_DEFAULT
}
const NMS_ALPHA: f32 = 0.75;
pub const DEFAULT_Q_THRESH: u32 = 6;
#[cfg(not(feature = "fixed-point"))]
pub type SpecCell = f32;
#[cfg(feature = "fixed-point")]
pub type SpecCell = u16;
#[cfg(feature = "fixed-point")]
const FP_SPEC_SHIFT: u32 = 12;
#[doc(hidden)]
pub struct Spectrogram {
pub n_freq: usize,
pub n_time: usize,
pub data: Vec<SpecCell>,
}
impl Spectrogram {
pub fn from_parts(n_freq: usize, n_time: usize, data: Vec<SpecCell>) -> Self {
assert_eq!(
data.len(),
n_freq * n_time,
"Spectrogram::from_parts: data length must be n_freq * n_time"
);
Self {
n_freq,
n_time,
data,
}
}
}
type CoarseAcc = f32;
impl Spectrogram {
#[inline]
fn power_acc(&self, freq_bin: usize, time_idx: usize) -> CoarseAcc {
debug_assert!(freq_bin < self.n_freq);
debug_assert!(time_idx < self.n_time);
#[allow(clippy::unnecessary_cast)]
let v = self.data[time_idx * self.n_freq + freq_bin] as CoarseAcc;
v
}
}
#[doc(hidden)]
#[cfg(not(feature = "fixed-point"))]
pub fn compute_spectrogram<S: AudioSample>(audio: &[S], max_freq_hz: f32) -> Spectrogram {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let band_top_hz = max_freq_hz + (NTONES as f32) * TONE_SPACING_HZ;
let n_freq_full = NFFT_SPEC / 2;
let n_freq = ((band_top_hz / df).ceil() as usize + 1).min(n_freq_full);
let n_time = NMAX / NSTEP - 3;
let scale = 1.0f32 / 300.0;
let mut planner = default_planner();
let fft = planner.plan_forward(NFFT_SPEC);
let mut data = vec![0.0f32; n_freq * n_time];
let mut buf = vec![Complex::new(0.0f32, 0.0); NFFT_SPEC];
for j in 0..n_time {
let ia = j * NSTEP;
for (k, c) in buf.iter_mut().enumerate() {
*c = if k < NSPS {
let sample = if ia + k < audio.len() {
audio[ia + k].to_f32() * scale
} else {
0.0
};
Complex::new(sample, 0.0)
} else {
Complex::new(0.0, 0.0)
};
}
fft.process(&mut buf);
let row_base = j * n_freq;
for i in 0..n_freq {
data[row_base + i] = buf[i].norm_sqr();
}
}
Spectrogram {
n_freq,
n_time,
data,
}
}
#[doc(hidden)]
#[cfg(feature = "fixed-point")]
pub fn compute_spectrogram<S: AudioSample>(audio: &[S], max_freq_hz: f32) -> Spectrogram {
use crate::core::fft::default_planner_16;
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let band_top_hz = max_freq_hz + (NTONES as f32) * TONE_SPACING_HZ;
let n_freq_full = NFFT_SPEC / 2;
let n_freq = ((band_top_hz / df).ceil() as usize + 1).min(n_freq_full);
let n_time = NMAX / NSTEP - 3;
let target_peak: i32 = (NFFT_SPEC * 2) as i32;
let mut peak_abs: i32 = 1;
let n_scan = audio.len().min(NMAX);
for k in 0..n_scan {
let v = audio[k].to_i16() as i32;
let a = v.unsigned_abs() as i32;
if a > peak_abs {
peak_abs = a;
}
}
let mut shift: u32 = 0;
while peak_abs << shift < target_peak && shift < 8 {
shift += 1;
}
let mut planner = default_planner_16();
let fft = planner.plan_forward(NFFT_SPEC);
let mut data: Vec<u16> = vec![0u16; n_freq * n_time];
let mut buf: Vec<Complex<i16>> = vec![Complex::new(0i16, 0i16); NFFT_SPEC];
let n_pairs = n_time / 2;
let n_odd = n_time & 1;
let mut hann = [0i16; NSPS];
for n in 0..NSPS {
let phase = 2.0 * core::f32::consts::PI * (n as f32) / (NSPS as f32);
let w = 0.5 - 0.5 * phase.cos();
hann[n] = (w * 32767.0) as i16;
}
let pack = |buf: &mut [Complex<i16>], ia_a: usize, ia_b: Option<usize>| {
for (k, c) in buf.iter_mut().enumerate() {
let re = if k < NSPS && ia_a + k < audio.len() {
let raw = audio[ia_a + k].to_i16() as i32;
let scaled = (raw << shift).clamp(i16::MIN as i32, i16::MAX as i32);
((scaled * hann[k] as i32) >> 15) as i16
} else {
0
};
let im = match ia_b {
Some(ia_b) if k < NSPS && ia_b + k < audio.len() => {
let raw = audio[ia_b + k].to_i16() as i32;
let scaled = (raw << shift).clamp(i16::MIN as i32, i16::MAX as i32);
((scaled * hann[k] as i32) >> 15) as i16
}
_ => 0,
};
*c = Complex::new(re, im);
}
};
for jj in 0..n_pairs {
let j_a = 2 * jj;
let j_b = j_a + 1;
pack(&mut buf, j_a * NSTEP, Some(j_b * NSTEP));
fft.process(&mut buf);
let row_a = j_a * n_freq;
let row_b = j_b * n_freq;
for k in 0..n_freq {
let kn = if k == 0 { 0 } else { NFFT_SPEC - k };
let yk_re = buf[k].re as i32;
let yk_im = buf[k].im as i32;
let yn_re = buf[kn].re as i32;
let yn_im = buf[kn].im as i32;
let a_re = (yk_re + yn_re) >> 1;
let a_im = (yk_im - yn_im) >> 1;
let b_re = (yk_im + yn_im) >> 1;
let b_im = (yn_re - yk_re) >> 1;
let mag2_a = ((a_re * a_re + a_im * a_im) as u32) >> FP_SPEC_SHIFT;
let mag2_b = ((b_re * b_re + b_im * b_im) as u32) >> FP_SPEC_SHIFT;
data[row_a + k] = mag2_a as u16;
data[row_b + k] = mag2_b as u16;
}
}
if n_odd != 0 {
let j = 2 * n_pairs;
pack(&mut buf, j * NSTEP, None);
fft.process(&mut buf);
let row_base = j * n_freq;
for i in 0..n_freq {
let re = buf[i].re as i32;
let im = buf[i].im as i32;
let mag2 = ((re * re + im * im) as u32) >> FP_SPEC_SHIFT;
data[row_base + i] = mag2 as u16;
}
}
Spectrogram {
n_freq,
n_time,
data,
}
}
#[doc(hidden)]
pub fn coarse_sync(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
) -> Vec<SyncCandidate> {
coarse_sync_inner(spec, freq_min, freq_max, sync_min, max_cand, None)
}
#[doc(hidden)]
pub fn coarse_sync_with_allsum(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
allsum: &[CoarseAcc],
) -> Vec<SyncCandidate> {
coarse_sync_inner(spec, freq_min, freq_max, sync_min, max_cand, Some(allsum))
}
pub fn coarse_allsum_len(
spec_n_freq: usize,
spec_n_time: usize,
freq_min: f32,
freq_max: f32,
) -> usize {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(spec_n_freq.saturating_sub(max_tone_off));
if ib < ia {
return 0;
}
let n_freq = ib - ia + 1;
n_freq * spec_n_time
}
pub fn precompute_coarse_allsum(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
) -> Vec<CoarseAcc> {
let mut buf =
vec![CoarseAcc::default(); coarse_allsum_len(spec.n_freq, spec.n_time, freq_min, freq_max)];
if !buf.is_empty() {
precompute_coarse_allsum_into(spec, freq_min, freq_max, &mut buf);
}
buf
}
pub fn precompute_coarse_allsum_into(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
dst: &mut [CoarseAcc],
) {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
if ib < ia {
return;
}
let n_freq = ib - ia + 1;
debug_assert_eq!(
dst.len(),
n_freq * spec.n_time,
"allsum buffer length mismatch"
);
fill_coarse_allsum(spec, ia, ib, n_freq, dst);
}
fn fill_coarse_allsum(
spec: &Spectrogram,
ia: usize,
ib: usize,
_n_freq: usize,
dst: &mut [CoarseAcc],
) {
let nh1 = spec.n_freq;
for (fi, i_carrier) in (ia..=ib).enumerate() {
let row_off = fi * spec.n_time;
for m in 0..spec.n_time {
let mut s: CoarseAcc = CoarseAcc::default();
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(nh1 - 1);
s += spec.power_acc(bin, m);
}
dst[row_off + m] = s;
}
}
}
fn coarse_sync_inner(
spec: &Spectrogram,
freq_min: f32,
freq_max: f32,
sync_min: f32,
max_cand: usize,
external_allsum: Option<&[CoarseAcc]>,
) -> Vec<SyncCandidate> {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tstep = NSTEP as f32 / SAMPLE_RATE_HZ;
let jstrt = (TX_START_OFFSET_S / tstep).round() as i32;
let jz = (sync_lag_s() / tstep).round() as i32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (freq_min / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (freq_max / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
if ib < ia {
return Vec::new();
}
let n_freq = ib - ia + 1;
let n_lag = (2 * jz + 1) as usize;
let mut sync2d = vec![0.0f32; n_freq * n_lag];
let idx = |fi: usize, lag: i32| fi * n_lag + (lag + jz) as usize;
let ratio_eps = ratio_eps();
#[cfg(feature = "std")]
let prof = cfg!(feature = "profile-coarse") || std::env::var("MFSK_PROFILE_COARSE").is_ok();
#[cfg(feature = "std")]
let t_setup = std::time::Instant::now();
let mut tone_bin_lo = [0usize; NTONES];
for k in 0..NTONES {
tone_bin_lo[k] = (k as f32 * tone_step_bins).floor() as usize;
}
let m_base: [[i32; COSTAS.len()]; COSTAS_POS.len()] = {
let mut t = [[0i32; COSTAS.len()]; COSTAS_POS.len()];
for (bk, &start_sym) in COSTAS_POS.iter().enumerate() {
let block_offset = NSSY * start_sym as i32;
for (n, _) in COSTAS.iter().enumerate() {
t[bk][n] = jstrt + block_offset + NSSY * n as i32;
}
}
t
};
let costas_off: [usize; COSTAS.len()] = {
let mut t = [0usize; COSTAS.len()];
for (n, &costas_n) in COSTAS.iter().enumerate() {
t[n] = tone_bin_lo[costas_n];
}
t
};
let needed_m: alloc::vec::Vec<usize> = {
let mut mark = alloc::vec![false; spec.n_time];
for bk in 0..COSTAS_POS.len() {
let lo = m_base[bk][0] - jz;
let hi = m_base[bk][COSTAS.len() - 1] + jz;
let lo_u = lo.max(0) as usize;
let hi_u = (hi.min(spec.n_time as i32 - 1)) as usize;
if lo_u <= hi_u {
#[allow(clippy::needless_range_loop)]
for m in lo_u..=hi_u {
mark[m] = true;
}
}
}
(0..spec.n_time).filter(|&m| mark[m]).collect()
};
let owned_allsum: Vec<CoarseAcc>;
let allsum: &[CoarseAcc] = if let Some(ext) = external_allsum {
debug_assert_eq!(
ext.len(),
n_freq * spec.n_time,
"external allsum length mismatch (expected n_freq * spec.n_time)"
);
ext
} else {
owned_allsum = {
let mut buf = vec![CoarseAcc::default(); n_freq * spec.n_time];
for (fi, i_carrier) in (ia..=ib).enumerate() {
let row_off = fi * spec.n_time;
for &m in &needed_m {
let mut s: CoarseAcc = CoarseAcc::default();
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(nh1 - 1);
s += spec.power_acc(bin, m);
}
buf[row_off + m] = s;
}
}
buf
};
&owned_allsum
};
#[cfg(feature = "std")]
let t_allsum = std::time::Instant::now();
debug_assert!(
m_base[2][COSTAS.len() - 1] + jz < spec.n_time as i32,
"n_time too small for SYNC_LAG_S/jstrt"
);
let n_time = spec.n_time;
let mut tbin_lo_arr = [0usize; COSTAS.len()];
for (fi, i_carrier) in (ia..=ib).enumerate() {
for n in 0..COSTAS.len() {
tbin_lo_arr[n] = i_carrier + costas_off[n];
}
let allsum_row = &allsum[fi * n_time..(fi + 1) * n_time];
for lag in -jz..=jz {
let mut t_blocks: [CoarseAcc; 3] = [CoarseAcc::default(); 3];
let mut t0_blocks: [CoarseAcc; 3] = [CoarseAcc::default(); 3];
let bk0_n_start = {
let needed = -jstrt - lag;
if needed <= 0 {
0usize
} else {
((needed + NSSY - 1) / NSSY).min(COSTAS.len() as i32) as usize
}
};
for n in bk0_n_start..COSTAS.len() {
let m_u = (m_base[0][n] + lag) as usize;
let tbin_lo = tbin_lo_arr[n];
t_blocks[0] += spec.power_acc(tbin_lo, m_u);
t0_blocks[0] += allsum_row[m_u];
}
for bk in 1..COSTAS_POS.len() {
for n in 0..COSTAS.len() {
let m_u = (m_base[bk][n] + lag) as usize;
let tbin_lo = tbin_lo_arr[n];
t_blocks[bk] += spec.power_acc(tbin_lo, m_u);
t0_blocks[bk] += allsum_row[m_u];
}
}
let t_all: CoarseAcc = t_blocks[0] + t_blocks[1] + t_blocks[2];
let t0_all: CoarseAcc = t0_blocks[0] + t0_blocks[1] + t0_blocks[2];
#[allow(clippy::unnecessary_cast)]
let t_all_f = t_all as f32;
#[allow(clippy::unnecessary_cast)]
let t0_all_f = t0_all as f32;
let t0_ref = (t0_all_f - t_all_f) / (NTONES as f32 - 2.0);
let sync_all = t_all_f / (t0_ref + ratio_eps);
#[allow(clippy::unnecessary_cast)]
let t_tail_f = (t_blocks[1] + t_blocks[2]) as f32;
#[allow(clippy::unnecessary_cast)]
let t0_tail_f = (t0_blocks[1] + t0_blocks[2]) as f32;
let t0_tail_ref = (t0_tail_f - t_tail_f) / (NTONES as f32 - 2.0);
let sync_tail = t_tail_f / (t0_tail_ref + ratio_eps);
sync2d[idx(fi, lag)] = sync_all.max(sync_tail);
}
}
#[cfg(feature = "std")]
let t_score = std::time::Instant::now();
let mut red = vec![0.0f32; n_freq];
for fi in 0..n_freq {
red[fi] = (-jz..=jz)
.map(|lag| sync2d[idx(fi, lag)])
.fold(0.0f32, f32::max);
}
let base = {
let mut sorted = red.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let pct_idx = (0.40 * n_freq as f32) as usize;
sorted[pct_idx.min(n_freq - 1)].max(f32::EPSILON)
};
const MLAG: i32 = 10;
let mut cands: Vec<SyncCandidate> = Vec::new();
for fi in 0..n_freq {
let i_carrier = ia + fi;
let freq_hz = i_carrier as f32 * df;
let mut peaks: Vec<(i32, f32)> = (-jz..=jz)
.filter_map(|lag| {
let raw = sync2d[idx(fi, lag)];
let norm = raw / base;
if norm.is_finite() && norm >= sync_min {
Some((lag, norm))
} else {
None
}
})
.collect();
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let mut picked: Vec<i32> = Vec::new();
for (lag, score) in peaks {
if picked.iter().any(|&pl| (lag - pl).abs() <= MLAG) {
continue;
}
picked.push(lag);
let dt_quanta = if lag > -jz && lag < jz {
let y_lo = sync2d[idx(fi, lag - 1)];
let y_mi = sync2d[idx(fi, lag)];
let y_hi = sync2d[idx(fi, lag + 1)];
let denom = y_lo - 2.0 * y_mi + y_hi;
if denom.abs() > f32::EPSILON {
let off = 0.5 * (y_lo - y_hi) / denom;
off.clamp(-1.0, 1.0)
} else {
0.0
}
} else {
0.0
};
let dt_lag = lag as f32 + dt_quanta;
cands.push(SyncCandidate {
freq_hz,
dt_sec: (dt_lag - 0.5) * tstep,
score,
});
if picked.len() >= 2 {
break;
}
}
}
cands.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
let mut out: Vec<SyncCandidate> = Vec::with_capacity(max_cand);
for c in cands {
if c.score < sync_min {
break;
}
let near = out
.iter()
.any(|k| (c.freq_hz - k.freq_hz).abs() < 4.0 && (c.dt_sec - k.dt_sec).abs() < 0.04);
if near {
continue;
}
out.push(c);
if out.len() >= max_cand {
break;
}
}
let cands = out;
#[cfg(feature = "std")]
if prof {
let t_end = std::time::Instant::now();
let allsum_us = (t_allsum - t_setup).as_micros();
let score_us = (t_score - t_allsum).as_micros();
let post_us = (t_end - t_score).as_micros();
let total_us = (t_end - t_setup).as_micros();
eprintln!(
"[coarse_sync prof] n_freq={n_freq} n_lag={n_lag} allsum={allsum_us} score={score_us} dedupe+sort={post_us} total={total_us} us"
);
}
cands
}
#[doc(hidden)]
pub fn symbol_spectra_direct<S: AudioSample>(
audio: &[S],
freq_hz: f32,
dt_sec: f32,
sym_mask: SymMask,
) -> Box<[[Cmplx<f32>; 8]; 79]> {
let mut out: Box<[[Cmplx<f32>; 8]; 79]> =
vec![[Cmplx::<f32>::default(); 8]; 79].try_into().unwrap();
fill_symbol_spectra(&mut out, audio, freq_hz, dt_sec, sym_mask);
out
}
#[doc(hidden)]
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum SymMask {
SyncOnly,
SyncBlock0,
NotBlock0,
DataOnly,
SyncBlocks12,
}
#[inline]
fn sym_in_mask(sym: usize, mask: SymMask) -> bool {
let (in_block_a, in_block_b, in_block_c) = (
sym < COSTAS.len(), sym >= COSTAS_POS[1] && sym < COSTAS_POS[1] + COSTAS.len(), sym >= COSTAS_POS[2] && sym < COSTAS_POS[2] + COSTAS.len(), );
let is_sync = in_block_a || in_block_b || in_block_c;
match mask {
SymMask::SyncOnly => is_sync,
SymMask::SyncBlock0 => in_block_a,
SymMask::NotBlock0 => !in_block_a,
SymMask::DataOnly => !is_sync,
SymMask::SyncBlocks12 => in_block_b || in_block_c,
}
}
#[doc(hidden)]
#[cfg(feature = "fft-rustfft")]
pub fn fill_symbol_spectra<S: AudioSample>(
out: &mut [[Cmplx<f32>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
fill_symbol_spectra_via_cd0(out, audio, freq_hz, dt_sec, mask);
}
#[doc(hidden)]
#[cfg(all(not(feature = "fft-rustfft"), not(feature = "fixed-point")))]
pub fn fill_symbol_spectra<S: AudioSample>(
out: &mut [[Cmplx<f32>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
fill_symbol_spectra_generic::<f32, S>(out, audio, freq_hz, dt_sec, mask);
}
#[cfg(feature = "fft-rustfft")]
fn fill_symbol_spectra_via_cd0<S: AudioSample>(
out: &mut [[Cmplx<f32>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
use rustfft::FftPlanner;
extern crate alloc;
let audio_i16: alloc::vec::Vec<i16> = audio.iter().map(|s| s.to_i16()).collect();
let (cd0, _) = crate::ft8::downsample::downsample(&audio_i16, freq_hz, None);
let ibest = ((TX_START_OFFSET_S + dt_sec) * 200.0).round() as i32;
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(32);
let mut buf = [Complex::new(0.0_f32, 0.0); 32];
const CS_SCALE: f32 = 1.0 / 1000.0;
let np2 = cd0.len() as i32;
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
let i1 = ibest + (sym as i32) * 32;
if i1 >= 0 && i1 + 31 < np2 {
for j in 0..32 {
buf[j] = cd0[(i1 + j as i32) as usize];
}
} else {
for j in 0..32 {
buf[j] = Complex::new(0.0, 0.0);
}
}
fft.process(&mut buf);
for tone in 0..NTONES {
out[sym][tone] = Cmplx {
re: buf[tone].re * CS_SCALE,
im: buf[tone].im * CS_SCALE,
};
}
}
}
#[doc(hidden)]
#[cfg(not(feature = "fixed-point"))]
pub fn fill_symbol_spectra_generic<Sc: crate::core::scalar::SpecScalar, S: AudioSample>(
out: &mut [[Cmplx<Sc>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
let i0 = ((TX_START_OFFSET_S + dt_sec) * SAMPLE_RATE_HZ).round() as i64;
let two_pi_over_fs = core::f32::consts::TAU / SAMPLE_RATE_HZ;
let mut rotators = [Complex::new(0.0f32, 0.0); NTONES];
for tone in 0..NTONES {
let tone_freq = freq_hz + tone as f32 * TONE_SPACING_HZ;
let dphi = -two_pi_over_fs * tone_freq;
rotators[tone] = Complex::new(dphi.cos(), dphi.sin());
}
let mut sym_buf = [0.0f32; NSPS];
if !Sc::NEEDS_AUTOGAIN {
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
let sym_start = i0 + (sym as i64) * (NSPS as i64);
for k in 0..NSPS {
let idx = sym_start + k as i64;
sym_buf[k] = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize].to_f32()
} else {
0.0
};
}
for tone in 0..NTONES {
let rotator = rotators[tone];
let mut osc = Complex::new(1.0f32, 0.0);
let mut acc = Complex::new(0.0f32, 0.0);
for &s in sym_buf.iter() {
acc.re += s * osc.re;
acc.im += s * osc.im;
osc *= rotator;
}
out[sym][tone] = Cmplx {
re: Sc::from_f32(acc.re),
im: Sc::from_f32(acc.im),
};
}
}
return;
}
let mut tmp = [[Complex::new(0.0f32, 0.0); 8]; 79];
let mut peak: f32 = 0.0;
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
let sym_start = i0 + (sym as i64) * (NSPS as i64);
for k in 0..NSPS {
let idx = sym_start + k as i64;
sym_buf[k] = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize].to_f32()
} else {
0.0
};
}
for tone in 0..NTONES {
let rotator = rotators[tone];
let mut osc = Complex::new(1.0f32, 0.0);
let mut acc = Complex::new(0.0f32, 0.0);
for &s in sym_buf.iter() {
acc.re += s * osc.re;
acc.im += s * osc.im;
osc *= rotator;
}
tmp[sym][tone] = acc;
peak = peak.max(acc.re.abs()).max(acc.im.abs());
}
}
let scale = if peak > 1e-9 {
(i16::MAX as f32 * 0.95) / peak
} else {
0.0
};
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
for tone in 0..NTONES {
let c = tmp[sym][tone];
out[sym][tone] = Cmplx {
re: Sc::from_f32_scaled(c.re, scale),
im: Sc::from_f32_scaled(c.im, scale),
};
}
}
}
#[cfg(feature = "fixed-point")]
pub const BASIS_SCRATCH_LEN: usize = NTONES * NSPS;
#[doc(hidden)]
#[cfg(all(feature = "fixed-point", not(feature = "fft-rustfft")))]
pub fn fill_symbol_spectra<S: AudioSample>(
out: &mut [[Cmplx<f32>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
fill_symbol_spectra_generic::<f32, S>(out, audio, freq_hz, dt_sec, mask);
}
#[doc(hidden)]
#[cfg(feature = "fixed-point")]
pub fn fill_symbol_spectra_generic<Sc: crate::core::scalar::SpecScalar, S: AudioSample>(
out: &mut [[Cmplx<Sc>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
) {
let mut basis_re: Vec<i16> = alloc::vec![0i16; BASIS_SCRATCH_LEN];
let mut basis_im: Vec<i16> = alloc::vec![0i16; BASIS_SCRATCH_LEN];
fill_symbol_spectra_into_generic::<Sc, S>(
out,
audio,
freq_hz,
dt_sec,
mask,
&mut basis_re,
&mut basis_im,
);
}
#[doc(hidden)]
#[cfg(feature = "fixed-point")]
pub fn fill_symbol_spectra_into<S: AudioSample>(
out: &mut [[Cmplx<f32>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
basis_re: &mut [i16],
basis_im: &mut [i16],
) {
fill_symbol_spectra_into_generic::<f32, S>(
out, audio, freq_hz, dt_sec, mask, basis_re, basis_im,
);
}
#[doc(hidden)]
#[cfg(feature = "fixed-point")]
pub fn fill_symbol_spectra_into_generic<Sc: crate::core::scalar::SpecScalar, S: AudioSample>(
out: &mut [[Cmplx<Sc>; 8]; 79],
audio: &[S],
freq_hz: f32,
dt_sec: f32,
mask: SymMask,
basis_re: &mut [i16],
basis_im: &mut [i16],
) {
use crate::core::dotprod::dot_q15_i32;
debug_assert!(basis_re.len() >= BASIS_SCRATCH_LEN);
debug_assert!(basis_im.len() >= BASIS_SCRATCH_LEN);
let i0 = ((TX_START_OFFSET_S + dt_sec) * SAMPLE_RATE_HZ).round() as i64;
let two_pi_over_fs = core::f32::consts::TAU / SAMPLE_RATE_HZ;
for tone in 0..NTONES {
let tone_freq = freq_hz + tone as f32 * TONE_SPACING_HZ;
let dphi = -two_pi_over_fs * tone_freq;
let rot_re = (dphi.cos() * 32767.0).round() as i32;
let rot_im = (dphi.sin() * 32767.0).round() as i32;
let mut osc_re: i32 = 32767;
let mut osc_im: i32 = 0;
let base = tone * NSPS;
for k in 0..NSPS {
basis_re[base + k] = osc_re as i16;
basis_im[base + k] = osc_im as i16;
let new_re = ((osc_re * rot_re) - (osc_im * rot_im)) >> 15;
let new_im = ((osc_re * rot_im) + (osc_im * rot_re)) >> 15;
osc_re = new_re;
osc_im = new_im;
}
}
let mut sym_buf = [0i16; NSPS];
let mut tmp_re = [[0i32; 8]; 79];
let mut tmp_im = [[0i32; 8]; 79];
let mut peak: i32 = 0;
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
let sym_start = i0 + (sym as i64) * (NSPS as i64);
for k in 0..NSPS {
let idx = sym_start + k as i64;
sym_buf[k] = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize].to_i16()
} else {
0
};
}
for tone in 0..NTONES {
let base = tone * NSPS;
let basis_re_tone = &basis_re[base..base + NSPS];
let basis_im_tone = &basis_im[base..base + NSPS];
let acc_re = dot_q15_i32(&sym_buf, basis_re_tone);
let acc_im = dot_q15_i32(&sym_buf, basis_im_tone);
if !Sc::NEEDS_AUTOGAIN {
out[sym][tone] = Cmplx {
re: Sc::from_f32(acc_re as f32),
im: Sc::from_f32(acc_im as f32),
};
} else {
tmp_re[sym][tone] = acc_re;
tmp_im[sym][tone] = acc_im;
peak = peak.max(acc_re.unsigned_abs() as i32);
peak = peak.max(acc_im.unsigned_abs() as i32);
}
}
}
if Sc::NEEDS_AUTOGAIN {
let scale = if peak > 0 {
(i16::MAX as f32 * 0.95) / peak as f32
} else {
0.0
};
for sym in 0..NN {
if !sym_in_mask(sym, mask) {
continue;
}
for tone in 0..NTONES {
out[sym][tone] = Cmplx {
re: Sc::from_f32_scaled(tmp_re[sym][tone] as f32, scale),
im: Sc::from_f32_scaled(tmp_im[sym][tone] as f32, scale),
};
}
}
}
}
#[doc(hidden)]
#[cfg(feature = "fixed-point")]
pub fn symbol_spectra_direct_into<S: AudioSample>(
audio: &[S],
freq_hz: f32,
dt_sec: f32,
sym_mask: SymMask,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Box<[[Cmplx<f32>; 8]; 79]> {
let mut out: Box<[[Cmplx<f32>; 8]; 79]> =
vec![[Cmplx::<f32>::default(); 8]; 79].try_into().unwrap();
fill_symbol_spectra_into(
&mut out, audio, freq_hz, dt_sec, sym_mask, basis_re, basis_im,
);
out
}
pub fn decode_block<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
) -> Vec<DecodeResult> {
decode_block_multipass(
audio,
freq_min,
freq_max,
sync_min,
depth,
max_cand,
DEFAULT_BP_MAX_ITER,
)
}
#[allow(clippy::too_many_arguments)]
pub fn decode_block_tuned<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
bp_max_iter: u32,
) -> Vec<DecodeResult> {
decode_block_multipass(
audio,
freq_min,
freq_max,
sync_min,
depth,
max_cand,
bp_max_iter,
)
}
#[cfg(feature = "fft-rustfft")]
#[allow(clippy::too_many_arguments)]
fn decode_block_multipass<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
bp_max_iter: u32,
) -> Vec<DecodeResult> {
use alloc::vec::Vec as AllocVec;
let mut work: AllocVec<i16> = audio.iter().map(|s| s.to_i16()).collect();
let mut all: AllocVec<DecodeResult> = AllocVec::new();
let mut prev_total: usize = 0;
let mut sbase_and_spec: Option<(AllocVec<f32>, Spectrogram)> = None;
for ipass in 0..3 {
if ipass >= 1 && all.len() == prev_total {
break;
}
prev_total = all.len();
let spec = compute_spectrogram(work.as_slice(), freq_max);
if ipass == 0 {
let mut avg = alloc::vec![0.0_f32; spec.n_freq];
crate::ft8::baseline::avg_spectrum(&spec, &mut avg);
let sbase_v = crate::ft8::baseline::fit_baseline(&avg, 0, spec.n_freq - 1);
let spec_clone = Spectrogram {
n_freq: spec.n_freq,
n_time: spec.n_time,
data: spec.data.clone(),
};
sbase_and_spec = Some((sbase_v, spec_clone));
}
let cands = coarse_sync(&spec, freq_min, freq_max, sync_min, pass1_limit());
drop(spec);
let cands = fine_refine_pass1(work.as_slice(), cands);
let pass2 = refine_candidates(work.as_slice(), cands, max_cand);
#[cfg(feature = "std")]
let trace = std::env::var("MFSK_TRACE_PHANTOM").is_ok();
#[cfg(not(feature = "std"))]
let trace = false;
for cand in pass2 {
let single_results = process_candidates_tuned(
work.as_slice(),
alloc::vec![cand],
depth,
DEFAULT_Q_THRESH,
bp_max_iter,
);
for r in single_results {
if all.iter().any(|x| x.message77 == r.message77) {
continue;
}
if trace {
#[cfg(feature = "std")]
if let Some(text) = crate::msg::wsjt77::unpack77(&r.message77) {
eprintln!(
" TRACE pass={} freq={:>7.2} dt={:+.4} e={:>2} '{}'",
ipass, r.freq_hz, r.dt_sec, r.hard_errors, text,
);
}
}
crate::ft8::subtract::subtract_signal_lpf(work.as_mut_slice(), &r);
all.push(r);
}
}
}
#[cfg(not(feature = "fixed-point"))]
if let Some((sbase, spec)) = sbase_and_spec {
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tstep = NSTEP as f32 / SAMPLE_RATE_HZ;
let nsps_steps = (NSPS / NSTEP) as f32;
all.retain_mut(|r| {
r.snr_db = recompute_snr_xsnr2(r, &spec, &sbase, df, tstep, nsps_steps, 1.0);
let nsync = recompute_nsync(r, &spec, df, tstep, nsps_steps);
!(nsync <= 10 && r.snr_db < -24.0)
});
}
#[cfg(feature = "fixed-point")]
let _ = sbase_and_spec;
all
}
#[cfg(feature = "fft-rustfft")]
fn recompute_nsync(
result: &DecodeResult,
spec: &Spectrogram,
df: f32,
tstep: f32,
nsps_steps: f32,
) -> u32 {
use crate::ft8::params::COSTAS;
const NTONES: usize = 8;
let carrier_bin_f = result.freq_hz / df;
let tone_step = TONE_SPACING_HZ / df; let t0 = (TX_START_OFFSET_S + result.dt_sec) / tstep;
let mut count = 0u32;
for &block_off in &[0_usize, 36, 72] {
for (sym_in_block, &expected) in COSTAS.iter().enumerate() {
let k = block_off + sym_in_block;
let m_bin = (t0 + (k as f32) * nsps_steps).round() as i32;
if m_bin < 0 || m_bin as usize >= spec.n_time {
continue;
}
let m_bin = m_bin as usize;
let mut best_t = 0;
let mut best_p = f32::MIN;
for t in 0..NTONES {
let f_bin = (carrier_bin_f + (t as f32) * tone_step).round() as i32;
if f_bin < 0 || f_bin as usize >= spec.n_freq {
continue;
}
let p = spec.power_acc(f_bin as usize, m_bin);
if p > best_p {
best_p = p;
best_t = t;
}
}
if best_t == expected {
count += 1;
}
}
}
count
}
#[cfg(feature = "fft-rustfft")]
fn recompute_snr_xsnr2(
result: &DecodeResult,
spec: &Spectrogram,
sbase: &[f32],
df: f32,
tstep: f32,
nsps_steps: f32,
cell_scale: f32,
) -> f32 {
let itone = crate::ft8::wave_gen::message_to_tones(&result.message77);
let carrier_bin_f = result.freq_hz / df;
let tone_step = TONE_SPACING_HZ / df;
let t0 = (TX_START_OFFSET_S + result.dt_sec) / tstep;
let mut xsig = 0.0_f32;
for k in 0..79_usize {
let t = itone[k] as f32;
let f_bin = (carrier_bin_f + t * tone_step).round() as i32;
let m_bin = (t0 + (k as f32) * nsps_steps).round() as i32;
if f_bin < 0 || f_bin as usize >= spec.n_freq || m_bin < 0 || m_bin as usize >= spec.n_time
{
continue;
}
xsig += spec.power_acc(f_bin as usize, m_bin as usize);
}
xsig *= cell_scale;
let bin = carrier_bin_f.round() as i32;
let bin = bin.clamp(0, sbase.len() as i32 - 1) as usize;
let sbase_db_compensated = sbase[bin] + 10.0 * cell_scale.log10();
let xbase = 10f32.powf(0.1 * (sbase_db_compensated - 40.0));
let arg = xsig / xbase / 3.0e6 - 1.0;
if arg <= 0.1 {
return -24.0;
}
let snr = 10.0 * arg.log10() - 27.0;
snr.max(-24.0)
}
#[cfg(not(feature = "fft-rustfft"))]
#[allow(clippy::too_many_arguments)]
fn decode_block_multipass<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
bp_max_iter: u32,
) -> Vec<DecodeResult> {
let spec = compute_spectrogram(audio, freq_max);
let pass1 = coarse_sync(&spec, freq_min, freq_max, sync_min, pass1_limit());
drop(spec);
let pass1 = fine_refine_pass1(audio, pass1);
let pass2 = refine_candidates(audio, pass1, max_cand);
process_candidates_tuned(audio, pass2, depth, DEFAULT_Q_THRESH, bp_max_iter)
}
#[cfg(feature = "fft-rustfft")]
fn fine_refine_pass1<S: AudioSample>(
audio: &[S],
cands: alloc::vec::Vec<crate::core::sync::SyncCandidate>,
) -> alloc::vec::Vec<crate::core::sync::SyncCandidate> {
if cands.is_empty() {
return cands;
}
let audio_i16: alloc::vec::Vec<i16> = audio.iter().map(|s| s.to_i16()).collect();
let fft_cache = crate::ft8::downsample::build_fft_cache(&audio_i16);
cands
.into_iter()
.map(|c| {
let (cd0, _) =
crate::ft8::downsample::downsample(&audio_i16, c.freq_hz, Some(&fft_cache));
let r = crate::ft8::refine_fine::fine_refine_3stage(&cd0, c.dt_sec);
crate::core::sync::SyncCandidate {
freq_hz: c.freq_hz + r.delf_hz,
dt_sec: r.dt_sec,
score: c.score,
}
})
.collect()
}
#[cfg(not(feature = "fft-rustfft"))]
fn fine_refine_pass1<S: AudioSample>(
_audio: &[S],
cands: alloc::vec::Vec<crate::core::sync::SyncCandidate>,
) -> alloc::vec::Vec<crate::core::sync::SyncCandidate> {
cands
}
#[cfg(feature = "fixed-point")]
pub fn decode_block_into<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<DecodeResult> {
decode_block_into_tuned(
audio,
freq_min,
freq_max,
sync_min,
depth,
max_cand,
DEFAULT_BP_MAX_ITER,
basis_re,
basis_im,
)
}
#[cfg(feature = "fixed-point")]
#[allow(clippy::too_many_arguments)]
pub fn decode_block_into_tuned<S: AudioSample>(
audio: &[S],
freq_min: f32,
freq_max: f32,
sync_min: f32,
depth: DecodeDepth,
max_cand: usize,
bp_max_iter: u32,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<DecodeResult> {
let spec = compute_spectrogram(audio, freq_max);
let pass1 = coarse_sync(&spec, freq_min, freq_max, sync_min, pass1_limit());
drop(spec);
let pass2 = refine_candidates_into(audio, pass1, max_cand, basis_re, basis_im);
process_candidates_into_tuned(
audio,
pass2,
depth,
DEFAULT_Q_THRESH,
bp_max_iter,
basis_re,
basis_im,
)
}
const PASS1_LIMIT_DEFAULT: usize = 30;
fn pass1_limit() -> usize {
#[cfg(feature = "std")]
{
if let Ok(s) = std::env::var("MFSK_PASS1_LIMIT")
&& let Ok(v) = s.parse::<usize>()
{
return v;
}
}
PASS1_LIMIT_DEFAULT
}
pub type RefinedCandidate = (SyncCandidate, Box<[[Cmplx<f32>; 8]; 79]>, u32);
fn refine_candidates<S: AudioSample>(
audio: &[S],
cands: Vec<SyncCandidate>,
max_cand: usize,
) -> Vec<RefinedCandidate> {
refine_candidates_with(cands, max_cand, |c| {
symbol_spectra_direct(audio, c.freq_hz, c.dt_sec, SymMask::SyncBlock0)
})
}
#[cfg(feature = "fixed-point")]
#[doc(hidden)]
pub fn refine_candidates_into<S: AudioSample>(
audio: &[S],
cands: Vec<SyncCandidate>,
max_cand: usize,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<RefinedCandidate> {
refine_candidates_with(cands, max_cand, |c| {
symbol_spectra_direct_into(
audio,
c.freq_hz,
c.dt_sec,
SymMask::SyncBlock0,
basis_re,
basis_im,
)
})
}
fn refine_candidates_with<F>(
cands: Vec<SyncCandidate>,
max_cand: usize,
mut cs_for: F,
) -> Vec<RefinedCandidate>
where
F: FnMut(&SyncCandidate) -> Box<[[Cmplx<f32>; 8]; 79]>,
{
use alloc::collections::BinaryHeap;
use core::cmp::{Ordering, Reverse};
struct Slot {
q: u32,
idx: u32,
cand: SyncCandidate,
cs: Box<[[Cmplx<f32>; 8]; 79]>,
}
impl PartialEq for Slot {
fn eq(&self, other: &Self) -> bool {
self.q == other.q && self.idx == other.idx
}
}
impl Eq for Slot {}
impl Ord for Slot {
fn cmp(&self, other: &Self) -> Ordering {
self.q.cmp(&other.q).then_with(|| self.idx.cmp(&other.idx))
}
}
impl PartialOrd for Slot {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
let mut heap: BinaryHeap<Reverse<Slot>> = BinaryHeap::with_capacity(max_cand + 1);
for (idx, c) in cands.into_iter().enumerate() {
let cs = cs_for(&c);
let q = sync_quality_block0(&cs);
let slot = Slot {
q,
idx: idx as u32,
cand: c,
cs,
};
if heap.len() < max_cand {
heap.push(Reverse(slot));
} else if let Some(Reverse(top)) = heap.peek()
&& slot.q > top.q
{
heap.pop();
heap.push(Reverse(slot));
}
}
let mut out: Vec<RefinedCandidate> = heap
.into_iter()
.map(|r| {
let s = r.0;
(s.cand, s.cs, s.q)
})
.collect();
out.sort_by_key(|r| core::cmp::Reverse(r.2));
out
}
#[doc(hidden)]
pub fn sync_quality_block0<S: crate::core::scalar::SpecScalar>(cs: &[[Cmplx<S>; 8]; 79]) -> u32
where
S::Wide: PartialOrd,
{
let mut count = 0u32;
for (t, &expected) in COSTAS.iter().enumerate() {
let sym = t; let best = (0..NTONES)
.max_by(|&a, &b| {
let na = cs[sym][a].norm_sqr_wide();
let nb = cs[sym][b].norm_sqr_wide();
na.partial_cmp(&nb).unwrap_or(core::cmp::Ordering::Equal)
})
.unwrap_or(0);
if best == expected {
count += 1;
}
}
count
}
#[cfg(feature = "fixed-point")]
type LlrT = crate::core::scalar::Q3i8;
#[cfg(not(feature = "fixed-point"))]
type LlrT = f32;
#[cfg(all(feature = "fft-rustfft", not(feature = "fixed-point")))]
#[inline]
fn bp_step_select<T: crate::core::scalar::LlrScalar>(
bp_scratch: &mut crate::fec::ldpc::bp::BpScratch<crate::fec::ldpc::Ldpc174_91Params, T>,
llr: &[T; LDPC_N],
max_iter: u32,
verify: Option<fn(&[u8]) -> bool>,
) -> Option<crate::fec::ldpc::bp::BpResult> {
if std::env::var("MFSK_BP_KIND").as_deref() == Ok("nms") {
return crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<T>(
bp_scratch, llr, None, max_iter, verify, NMS_ALPHA,
);
}
let llr_f32: [f32; LDPC_N] = core::array::from_fn(|i| llr[i].to_f32());
crate::fec::ldpc::bp::bp_decode(&llr_f32, None, max_iter, verify)
}
#[cfg(any(not(feature = "fft-rustfft"), feature = "fixed-point"))]
#[inline]
fn bp_step_select<T: crate::core::scalar::LlrScalar>(
bp_scratch: &mut crate::fec::ldpc::bp::BpScratch<crate::fec::ldpc::Ldpc174_91Params, T>,
llr: &[T; LDPC_N],
max_iter: u32,
verify: Option<fn(&[u8]) -> bool>,
) -> Option<crate::fec::ldpc::bp::BpResult> {
crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<T>(
bp_scratch, llr, None, max_iter, verify, NMS_ALPHA,
)
}
#[doc(hidden)]
pub fn process_candidates<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
) -> Vec<DecodeResult> {
process_candidates_tuned(audio, cands, depth, q_thresh, DEFAULT_BP_MAX_ITER)
}
#[doc(hidden)]
pub fn process_candidates_tuned<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
bp_max_iter: u32,
) -> Vec<DecodeResult> {
let mut cs_scratch: alloc::boxed::Box<[[Cmplx<f32>; 8]; 79]> =
alloc::vec![[Cmplx::<f32>::default(); 8]; 79]
.try_into()
.unwrap();
process_candidates_with(
audio,
cands,
depth,
q_thresh,
bp_max_iter,
&mut cs_scratch,
|cs, cand, mask| {
fill_symbol_spectra(cs, audio, cand.freq_hz, cand.dt_sec, mask);
},
)
}
#[cfg(feature = "fixed-point")]
#[doc(hidden)]
pub fn process_candidates_into<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<DecodeResult> {
process_candidates_into_tuned(
audio,
cands,
depth,
q_thresh,
DEFAULT_BP_MAX_ITER,
basis_re,
basis_im,
)
}
#[cfg(feature = "fixed-point")]
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn process_candidates_into_tuned<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
bp_max_iter: u32,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<DecodeResult> {
let mut cs_scratch: alloc::boxed::Box<[[Cmplx<f32>; 8]; 79]> =
alloc::vec![[Cmplx::<f32>::default(); 8]; 79]
.try_into()
.unwrap();
process_candidates_into_with_cs_scratch_tuned(
audio,
cands,
depth,
q_thresh,
bp_max_iter,
basis_re,
basis_im,
&mut cs_scratch,
)
}
#[cfg(feature = "fixed-point")]
#[doc(hidden)]
pub fn process_candidates_into_with_cs_scratch<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
basis_re: &mut [i16],
basis_im: &mut [i16],
cs_scratch: &mut [[Cmplx<f32>; 8]; 79],
) -> Vec<DecodeResult> {
process_candidates_into_with_cs_scratch_tuned(
audio,
cands,
depth,
q_thresh,
DEFAULT_BP_MAX_ITER,
basis_re,
basis_im,
cs_scratch,
)
}
#[cfg(feature = "fixed-point")]
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn process_candidates_into_with_cs_scratch_tuned<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
bp_max_iter: u32,
basis_re: &mut [i16],
basis_im: &mut [i16],
cs_scratch: &mut [[Cmplx<f32>; 8]; 79],
) -> Vec<DecodeResult> {
process_candidates_with(
audio,
cands,
depth,
q_thresh,
bp_max_iter,
cs_scratch,
|cs, cand, mask| {
#[cfg(feature = "fft-rustfft")]
{
fill_symbol_spectra(cs, audio, cand.freq_hz, cand.dt_sec, mask);
}
#[cfg(not(feature = "fft-rustfft"))]
fill_symbol_spectra_into(
cs,
audio,
cand.freq_hz,
cand.dt_sec,
mask,
basis_re,
basis_im,
);
},
)
}
fn process_candidates_with<S: AudioSample, F>(
_audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
q_thresh: u32,
bp_max_iter: u32,
cs_scratch: &mut [[Cmplx<f32>; 8]; 79],
mut fill: F,
) -> Vec<DecodeResult>
where
F: FnMut(&mut [[Cmplx<f32>; 8]; 79], &SyncCandidate, SymMask),
{
let mut results: Vec<DecodeResult> = Vec::new();
let q_thr = q_thresh;
let mut bp_scratch =
crate::fec::ldpc::bp::BpScratch::<crate::fec::ldpc::params::Ldpc174_91Params, LlrT>::new();
for (cand, cs_box, _q_block0) in cands {
*cs_scratch = *cs_box;
drop(cs_box);
fill(cs_scratch, &cand, SymMask::SyncBlocks12);
let q = sync_quality(cs_scratch);
if q <= q_thr {
continue;
}
fill(cs_scratch, &cand, SymMask::DataOnly);
let refined_dt = cand.dt_sec;
let mut accepted: Option<(crate::fec::ldpc::bp::BpResult, u8)> = None;
const WSJTX_NHARDERRORS_MAX: u32 = 36;
let llr_a_fast: super::llr::LlrSet<LlrT> = super::llr::compute_llr_fast(cs_scratch);
let bp_step1 = bp_step_select::<LlrT>(
&mut bp_scratch,
&llr_a_fast.llra,
bp_max_iter,
Some(check_crc14),
);
if let Some(bp) = bp_step1
&& bp.hard_errors <= WSJTX_NHARDERRORS_MAX
{
accepted = Some((bp, 0));
}
if accepted.is_none() && matches!(depth, DecodeDepth::BpAll | DecodeDepth::BpAllOsd) {
let bp_d = bp_step_select::<LlrT>(
&mut bp_scratch,
&llr_a_fast.llrd,
bp_max_iter,
Some(check_crc14),
);
if let Some(bp) = bp_d
&& bp.hard_errors <= WSJTX_NHARDERRORS_MAX
{
accepted = Some((bp, 3));
}
if accepted.is_none() {
let llrb_arr: [LlrT; LDPC_N] =
super::llr::compute_llr_partial::<LlrT>(cs_scratch, 2);
let bp_b = bp_step_select::<LlrT>(
&mut bp_scratch,
&llrb_arr,
bp_max_iter,
Some(check_crc14),
);
if let Some(bp) = bp_b
&& bp.hard_errors <= WSJTX_NHARDERRORS_MAX
{
accepted = Some((bp, 1));
}
}
if accepted.is_none() {
let llrc_arr: [LlrT; LDPC_N] =
super::llr::compute_llr_partial::<LlrT>(cs_scratch, 3);
let bp_c = bp_step_select::<LlrT>(
&mut bp_scratch,
&llrc_arr,
bp_max_iter,
Some(check_crc14),
);
if let Some(bp) = bp_c
&& bp.hard_errors <= WSJTX_NHARDERRORS_MAX
{
accepted = Some((bp, 2));
}
}
}
if accepted.is_none() && matches!(depth, DecodeDepth::BpAllOsd) && q >= 12 {
let llr_full_f32: super::llr::LlrSet<f32> = super::llr::compute_llr(cs_scratch);
for (llr, pid) in [
(&llr_full_f32.llra, 4u8),
(&llr_full_f32.llrb, 5),
(&llr_full_f32.llrc, 6),
(&llr_full_f32.llrd, 7),
] {
let osd = if q >= 18 {
osd_decode_deep(llr, 3, Some(check_crc14))
} else {
osd_decode(llr)
};
if let Some(osd) = osd {
if osd.hard_errors > WSJTX_NHARDERRORS_MAX {
continue;
}
let bp = crate::fec::ldpc::bp::BpResult {
message77: osd.message77,
info: osd.info,
codeword: vec![0u8; LDPC_N],
hard_errors: osd.hard_errors,
iterations: 0,
};
accepted = Some((bp, pid));
break;
}
}
}
let Some((bp, pass_id)) = accepted else {
continue;
};
let Some(text) = unpack77(&bp.message77) else {
continue;
};
if !crate::msg::wsjt77::is_plausible_message(&text) {
continue;
}
if results.iter().any(|r| r.message77 == bp.message77) {
continue;
}
let itone = message_to_tones(&bp.message77);
let snr_db = super::llr::compute_snr_db(cs_scratch, &itone);
results.push(DecodeResult {
message77: bp.message77,
freq_hz: cand.freq_hz,
dt_sec: refined_dt,
hard_errors: bp.hard_errors,
sync_score: cand.score,
pass: pass_id,
sync_cv: 0.0,
snr_db,
});
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{MessageCodec, MessageFields};
use crate::ft8::wave_gen::{message_to_tones, tones_to_f32};
use crate::msg::Wsjt77Message;
fn pack_cq() -> [u8; 77] {
let bits = Wsjt77Message
.pack(&MessageFields {
call1: Some("CQ".into()),
call2: Some("JA1ABC".into()),
grid: Some("PM95".into()),
..Default::default()
})
.unwrap();
let mut out = [0u8; 77];
out.copy_from_slice(&bits);
out
}
fn synth_clean(msg77: &[u8; 77], freq_hz: f32) -> Vec<i16> {
let itone = message_to_tones(msg77);
let pcm = tones_to_f32(&itone, freq_hz, 0.5);
let mut slot = vec![0.0f32; NMAX];
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize;
let n = pcm.len().min(NMAX - start);
slot[start..start + n].copy_from_slice(&pcm[..n]);
slot.iter()
.map(|&s| (s * 25_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect()
}
#[test]
fn fill_coarse_allsum_column_by_column_matches_oneshot() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax) = (1550.0_f32, 3000.0_f32);
let oneshot = precompute_coarse_allsum(&spec, fmin, fmax);
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let tone_step_bins = TONE_SPACING_HZ / df;
let ia = (fmin / df).round() as usize;
let max_tone_off = ((NTONES - 1) as f32 * tone_step_bins).ceil() as usize + 1;
let nh1 = spec.n_freq;
let ib_unbounded = (fmax / df).round() as usize;
let ib = ib_unbounded.min(nh1.saturating_sub(max_tone_off));
let n_freq = ib - ia + 1;
let n_time = spec.n_time;
let mut col: Vec<f32> = vec![0.0; n_freq * n_time];
for m in 0..n_time {
for fi in 0..n_freq {
let i_carrier = ia + fi;
let mut s = 0.0_f32;
for k in 0..(NTONES - 1) {
let bin = (i_carrier + 2 * k).min(nh1 - 1);
s += spec.power_acc(bin, m);
}
col[fi * n_time + m] = s;
}
}
for fi in 0..n_freq {
for m in 0..n_time {
let a = oneshot[fi * n_time + m];
let b = col[fi * n_time + m];
assert!(
(a - b).abs() < 1e-3,
"fi={fi} m={m}: oneshot={a} col-by-col={b}"
);
}
}
}
#[test]
fn coarse_sync_with_allsum_tail_band_only() {
let msg = pack_cq();
let audio = synth_clean(&msg, 2000.0); let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (1550.0_f32, 3000.0_f32, 1.0_f32, 30usize);
let a = coarse_sync(&spec, fmin, fmax, smin, ncand);
let allsum = precompute_coarse_allsum(&spec, fmin, fmax);
let b = coarse_sync_with_allsum(&spec, fmin, fmax, smin, ncand, &allsum);
assert_eq!(
a.len(),
b.len(),
"tail band candidate count A={} B={}",
a.len(),
b.len()
);
for (ai, bi) in a.iter().zip(b.iter()) {
assert!(
(ai.freq_hz - bi.freq_hz).abs() < 1e-3 && (ai.score - bi.score).abs() < 1e-4,
"tail-band mismatch: A={ai:?} B={bi:?}"
);
}
}
#[test]
fn coarse_sync_split_with_allsum_per_half_busy_band() {
let msg = pack_cq();
let freqs = [400.0_f32, 1100.0, 1700.0, 2200.0, 2700.0];
let mut mix = vec![0.0f32; NMAX];
for (i, &f) in freqs.iter().enumerate() {
let itone = message_to_tones(&msg);
let pcm = tones_to_f32(&itone, f, 0.5);
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize + i * 100;
let n = pcm.len().min(NMAX - start);
for k in 0..n {
mix[start + k] += pcm[k];
}
}
let audio: Vec<i16> = mix
.iter()
.map(|&s| (s * 5_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect();
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3_000.0_f32, 1.0_f32, 30usize);
let mid = 0.5 * (fmin + fmax);
let head_a = coarse_sync(&spec, fmin, mid, smin, ncand);
let tail_a = coarse_sync(&spec, mid, fmax, smin, ncand);
let allsum_head = precompute_coarse_allsum(&spec, fmin, mid);
let allsum_tail = precompute_coarse_allsum(&spec, mid, fmax);
let head_b = coarse_sync_with_allsum(&spec, fmin, mid, smin, ncand, &allsum_head);
let tail_b = coarse_sync_with_allsum(&spec, mid, fmax, smin, ncand, &allsum_tail);
assert_eq!(head_a.len(), head_b.len());
assert_eq!(tail_a.len(), tail_b.len());
for (a, b) in head_a.iter().zip(head_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3 && (a.score - b.score).abs() < 1e-4,
"head per-half mismatch: A={a:?} B={b:?}"
);
}
for (a, b) in tail_a.iter().zip(tail_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3 && (a.score - b.score).abs() < 1e-4,
"tail per-half mismatch: A={a:?} B={b:?}"
);
}
}
#[test]
fn coarse_sync_split_with_allsum_busy_band() {
let msg = pack_cq();
let freqs = [400.0_f32, 1100.0, 1700.0, 2200.0, 2700.0];
let mut mix = vec![0.0f32; NMAX];
for (i, &f) in freqs.iter().enumerate() {
let itone = message_to_tones(&msg);
let pcm = tones_to_f32(&itone, f, 0.5);
let start = (TX_START_OFFSET_S * SAMPLE_RATE_HZ) as usize + i * 100;
let n = pcm.len().min(NMAX - start);
for k in 0..n {
mix[start + k] += pcm[k];
}
}
let audio: Vec<i16> = mix
.iter()
.map(|&s| (s * 5_000.0).clamp(-32_768.0, 32_767.0) as i16)
.collect();
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3_000.0_f32, 1.0_f32, 30usize);
let mid = 0.5 * (fmin + fmax);
let df = SAMPLE_RATE_HZ / NFFT_SPEC as f32;
let head_a = coarse_sync(&spec, fmin, mid, smin, ncand);
let tail_a = coarse_sync(&spec, mid, fmax, smin, ncand);
let allsum_full = precompute_coarse_allsum(&spec, fmin, fmax);
let allsum_ia = (fmin / df).round() as usize;
let head_len = coarse_allsum_len(spec.n_freq, spec.n_time, fmin, mid);
let tail_len = coarse_allsum_len(spec.n_freq, spec.n_time, mid, fmax);
let ia_head = (fmin / df).round() as usize;
let ia_tail = (mid / df).round() as usize;
let head_off = (ia_head - allsum_ia) * spec.n_time;
let tail_off = (ia_tail - allsum_ia) * spec.n_time;
let head_slice = &allsum_full[head_off..head_off + head_len];
let tail_slice = &allsum_full[tail_off..tail_off + tail_len];
let head_b = coarse_sync_with_allsum(&spec, fmin, mid, smin, ncand, head_slice);
let tail_b = coarse_sync_with_allsum(&spec, mid, fmax, smin, ncand, tail_slice);
assert_eq!(
head_a.len(),
head_b.len(),
"head candidate count differs: A={} B={}",
head_a.len(),
head_b.len()
);
assert_eq!(
tail_a.len(),
tail_b.len(),
"tail candidate count differs: A={} B={}",
tail_a.len(),
tail_b.len()
);
for (a, b) in head_a.iter().zip(head_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"head mismatch: A={a:?} B={b:?}"
);
}
for (a, b) in tail_a.iter().zip(tail_b.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"tail mismatch: A={a:?} B={b:?}"
);
}
}
#[test]
fn coarse_sync_with_allsum_matches_internal() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let spec = compute_spectrogram(&audio, 3_000.0);
let (fmin, fmax, smin, ncand) = (100.0_f32, 3000.0_f32, 1.0_f32, 30usize);
let internal = coarse_sync(&spec, fmin, fmax, smin, ncand);
let allsum = precompute_coarse_allsum(&spec, fmin, fmax);
assert_eq!(
allsum.len(),
coarse_allsum_len(spec.n_freq, spec.n_time, fmin, fmax),
"allsum length must match coarse_allsum_len"
);
let external = coarse_sync_with_allsum(&spec, fmin, fmax, smin, ncand, &allsum);
assert_eq!(internal.len(), external.len(), "candidate count differs");
for (a, b) in internal.iter().zip(external.iter()) {
assert!(
(a.freq_hz - b.freq_hz).abs() < 1e-3
&& (a.dt_sec - b.dt_sec).abs() < 1e-6
&& (a.score - b.score).abs() < 1e-4,
"candidate mismatch: internal={a:?} external={b:?}"
);
}
}
#[test]
fn roundtrip_clean_signal() {
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let results = decode_block(&audio, 100.0, 3000.0, 1.0, DecodeDepth::BpAll, 30);
assert!(
results.iter().any(|r| r.message77 == msg),
"decode_block should recover clean CQ; got {} results",
results.len()
);
}
#[test]
fn fill_q14i16_autogain_recovers_costas() {
use crate::core::scalar::Q14i16;
let msg = pack_cq();
let audio = synth_clean(&msg, 1500.0);
let mut cs_q14: alloc::boxed::Box<[[Cmplx<Q14i16>; 8]; 79]> =
alloc::vec![[Cmplx::<Q14i16>::default(); 8]; 79]
.try_into()
.unwrap();
#[cfg(not(feature = "fixed-point"))]
fill_symbol_spectra_generic::<Q14i16, i16>(
&mut cs_q14,
&audio,
1500.0,
0.0,
SymMask::SyncBlock0,
);
#[cfg(feature = "fixed-point")]
fill_symbol_spectra_generic::<Q14i16, i16>(
&mut cs_q14,
&audio,
1500.0,
0.0,
SymMask::SyncBlock0,
);
let nonzero = cs_q14.iter().flatten().any(|c| c.re.0 != 0 || c.im.0 != 0);
assert!(nonzero, "Q14 autogain produced all-zero cs");
let q = sync_quality_block0(&cs_q14);
assert_eq!(q, 7, "expected perfect Costas block-0 hit, got q={q}");
}
}