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::{BP_MAX_ITER, COSTAS, COSTAS_POS, 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 = 4096;
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;
const Q_THRESH_DEFAULT: u32 = 12;
fn q_thresh() -> u32 {
#[cfg(feature = "std")]
{
if let Ok(s) = std::env::var("MFSK_Q_THRESH")
&& let Ok(v) = s.parse::<u32>()
{
return v;
}
}
Q_THRESH_DEFAULT
}
#[cfg(not(feature = "fixed-point"))]
type SpecCell = f32;
#[cfg(feature = "fixed-point")]
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,
data: Vec<SpecCell>,
}
#[cfg(feature = "fixed-point-coarse-i32")]
type CoarseAcc = i32;
#[cfg(not(feature = "fixed-point-coarse-i32"))]
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
}
}
fn hann_window_f32() -> [f32; NSPS] {
let mut w = [0.0f32; NSPS];
let denom = NSPS as f32;
for k in 0..NSPS {
w[k] = 0.5 * (1.0 - (core::f32::consts::TAU * k as f32 / denom).cos());
}
w
}
#[cfg(feature = "fixed-point")]
fn hann_window_q15() -> [i16; NSPS] {
let f = hann_window_f32();
let mut q = [0i16; NSPS];
for k in 0..NSPS {
q[k] = (f[k] * 32767.0).round() as i16;
}
q
}
#[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 hann = hann_window_f32();
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 * hann[k]
} 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;
}
shift = (shift + 1).min(8);
let mut planner = default_planner_16();
let fft = planner.plan_forward(NFFT_SPEC);
let hann = hann_window_q15();
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 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;
let mask = NFFT_SPEC - 1;
for k in 0..n_freq {
let kn = (NFFT_SPEC - k) & mask;
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> {
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 win = 2 * NTONES; let contiguous = (0..NTONES).all(|k| tone_bin_lo[k] == 2 * k);
let mut allsum: Vec<CoarseAcc> = vec![CoarseAcc::default(); n_freq * spec.n_time];
if contiguous {
let row0 = 0;
for &m in &needed_m {
let mut s: CoarseAcc = CoarseAcc::default();
for j in 0..win {
let bin = (ia + j).min(nh1 - 1);
s += spec.power_acc(bin, m);
}
allsum[row0 * spec.n_time + m] = s;
}
for fi in 1..n_freq {
let drop_bin = ia + fi - 1;
let add_bin = (ia + fi + win - 1).min(nh1 - 1);
let prev_off = (fi - 1) * spec.n_time;
let curr_off = fi * spec.n_time;
for &m in &needed_m {
allsum[curr_off + m] =
allsum[prev_off + m] - spec.power_acc(drop_bin, m) + spec.power_acc(add_bin, m);
}
}
} else {
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 {
let lo = (i_carrier + tone_bin_lo[k]).min(nh1 - 1);
let hi = (lo + 1).min(nh1 - 1);
s += spec.power_acc(lo, m) + spec.power_acc(hi, m);
}
allsum[row_off + m] = s;
}
}
}
#[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) + spec.power_acc(tbin_lo + 1, 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) + spec.power_acc(tbin_lo + 1, 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 - 1.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 - 1.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() >= 8 {
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(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);
}
#[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(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);
}
#[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> {
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(audio, pass1, max_cand);
process_candidates(audio, pass2, depth)
}
#[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> {
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(audio, pass2, depth, 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-llr")]
type LlrT = crate::core::scalar::Q11i16;
#[cfg(not(feature = "fixed-point-llr"))]
type LlrT = f32;
#[doc(hidden)]
pub fn process_candidates<S: AudioSample>(
audio: &[S],
cands: Vec<RefinedCandidate>,
depth: DecodeDepth,
) -> Vec<DecodeResult> {
process_candidates_with(audio, cands, depth, |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,
basis_re: &mut [i16],
basis_im: &mut [i16],
) -> Vec<DecodeResult> {
process_candidates_with(audio, cands, depth, |cs, cand, mask| {
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,
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, mut cs, _q_block0) in cands {
fill(&mut cs, &cand, SymMask::SyncBlocks12);
let q = sync_quality(&cs);
if q <= q_thr {
continue;
}
fill(&mut cs, &cand, SymMask::DataOnly);
let refined_dt = cand.dt_sec;
let mut accepted: Option<(crate::fec::ldpc::bp::BpResult, u8)> = None;
let llr_a_fast: super::llr::LlrSet<LlrT> = super::llr::compute_llr_fast(&cs);
let bp_step1 = crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<LlrT>(
&mut bp_scratch,
&llr_a_fast.llra,
None,
BP_MAX_ITER,
Some(check_crc14),
NMS_ALPHA,
);
if let Some(bp) = bp_step1 {
accepted = Some((bp, 0));
}
if accepted.is_none() && matches!(depth, DecodeDepth::BpAll | DecodeDepth::BpAllOsd) {
let bp_d = crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<LlrT>(
&mut bp_scratch,
&llr_a_fast.llrd,
None,
BP_MAX_ITER,
Some(check_crc14),
NMS_ALPHA,
);
if let Some(bp) = bp_d {
accepted = Some((bp, 3));
}
if accepted.is_none() {
let llrb_arr: [LlrT; LDPC_N] = super::llr::compute_llr_partial::<LlrT>(&cs, 2);
let bp_b = crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<LlrT>(
&mut bp_scratch,
&llrb_arr,
None,
BP_MAX_ITER,
Some(check_crc14),
NMS_ALPHA,
);
if let Some(bp) = bp_b {
accepted = Some((bp, 1));
}
}
if accepted.is_none() {
let llrc_arr: [LlrT; LDPC_N] = super::llr::compute_llr_partial::<LlrT>(&cs, 3);
let bp_c = crate::fec::ldpc::bp::bp_decode_nms_with_scratch::<LlrT>(
&mut bp_scratch,
&llrc_arr,
None,
BP_MAX_ITER,
Some(check_crc14),
NMS_ALPHA,
);
if let Some(bp) = bp_c {
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);
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 {
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, &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 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}");
}
}