use alloc::vec;
use alloc::vec::Vec;
use core::f32::consts::PI;
#[cfg(not(feature = "std"))]
use num_traits::Float;
#[derive(Clone, Copy, Debug)]
pub struct SubtractCfg {
pub sample_rate: f32,
pub tone_spacing_hz: f32,
pub samples_per_symbol: usize,
pub base_offset_s: f32,
pub gfsk: Option<GfskParams>,
}
#[derive(Clone, Copy, Debug)]
pub struct GfskParams {
pub bt: f32,
pub hmod: f32,
pub ramp_samples: usize,
}
fn generate_iq(tones: &[u8], freq_hz: f32, cfg: &SubtractCfg) -> (Vec<f32>, Vec<f32>) {
let n = tones.len() * cfg.samples_per_symbol;
if let Some(g) = cfg.gfsk {
let gfsk_cfg = crate::engine::dsp::gfsk::GfskCfg {
sample_rate: cfg.sample_rate,
samples_per_symbol: cfg.samples_per_symbol,
bt: g.bt,
hmod: g.hmod,
ramp_samples: g.ramp_samples,
};
let mut w_cos = vec![0.0f32; n];
let mut w_sin = vec![0.0f32; n];
crate::engine::dsp::gfsk::synth_complex_f32_into(
&mut w_cos, &mut w_sin, tones, freq_hz, 1.0, &gfsk_cfg,
);
return (w_cos, w_sin);
}
let mut w_cos = vec![0.0f32; n];
let mut w_sin = vec![0.0f32; n];
let mut phase = 0.0f32;
for (sym, &tone) in tones.iter().enumerate() {
let freq = freq_hz + tone as f32 * cfg.tone_spacing_hz;
let dphi = 2.0 * PI * freq / cfg.sample_rate;
let base = sym * cfg.samples_per_symbol;
for j in 0..cfg.samples_per_symbol {
w_cos[base + j] = phase.cos();
w_sin[base + j] = phase.sin();
phase += dphi;
if phase > PI {
phase -= 2.0 * PI;
}
}
}
(w_cos, w_sin)
}
#[cfg(test)]
fn ls_amp_mag(audio: &[i16], tones: &[u8], freq_hz: f32, dt_sec: f32, cfg: &SubtractCfg) -> f32 {
let (w_cos, w_sin) = generate_iq(tones, freq_hz, cfg);
let signed_start = ((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as i64;
let (audio_off, ref_off) = if signed_start < 0 {
(0usize, (-signed_start) as usize)
} else {
(signed_start as usize, 0usize)
};
if ref_off >= w_cos.len() {
return 0.0;
}
let len = (w_cos.len() - ref_off).min(audio.len().saturating_sub(audio_off));
if len == 0 {
return 0.0;
}
let (mut na, mut nb, mut da, mut db) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
for i in 0..len {
let rx = audio[audio_off + i] as f64;
let c = w_cos[ref_off + i] as f64;
let s = w_sin[ref_off + i] as f64;
na += rx * c;
nb += rx * s;
da += c * c;
db += s * s;
}
let a = if da > 0.0 { na / da } else { 0.0 };
let b = if db > 0.0 { nb / db } else { 0.0 };
((a * a + b * b) as f32).sqrt()
}
const NCO_RESYNC_SAMPLES: usize = 256;
fn ls_amp_mag_tweaked(
audio: &[i16],
base_cos: &[f32],
base_sin: &[f32],
freq_hz: f32,
dt_sec: f32,
dt: f32,
cfg: &SubtractCfg,
) -> f32 {
let signed_start = ((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as i64;
let (audio_off, ref_off) = if signed_start < 0 {
(0usize, (-signed_start) as usize)
} else {
(signed_start as usize, 0usize)
};
if ref_off >= base_cos.len() {
return 0.0;
}
let len = (base_cos.len() - ref_off).min(audio.len().saturating_sub(audio_off));
if len == 0 {
return 0.0;
}
let step_phase = 2.0 * PI * freq_hz * dt;
let (step_cos, step_sin) = (step_phase.cos(), step_phase.sin());
let seed_phase = step_phase * ref_off as f32;
let (mut nco_re, mut nco_im) = (seed_phase.cos(), seed_phase.sin());
let (mut na, mut nb, mut da, mut db) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
for i in 0..len {
let bc = base_cos[ref_off + i];
let bs = base_sin[ref_off + i];
let c = bc * nco_re - bs * nco_im;
let s = bs * nco_re + bc * nco_im;
let rx = audio[audio_off + i] as f64;
na += rx * c as f64;
nb += rx * s as f64;
da += (c * c) as f64;
db += (s * s) as f64;
let (new_re, new_im) = (
nco_re * step_cos - nco_im * step_sin,
nco_re * step_sin + nco_im * step_cos,
);
nco_re = new_re;
nco_im = new_im;
if (i + 1) % NCO_RESYNC_SAMPLES == 0 {
let norm = (nco_re * nco_re + nco_im * nco_im).sqrt();
if norm > f32::EPSILON {
nco_re /= norm;
nco_im /= norm;
}
}
}
let a = if da > 0.0 { na / da } else { 0.0 };
let b = if db > 0.0 { nb / db } else { 0.0 };
((a * a + b * b) as f32).sqrt()
}
pub fn refine_freq(
audio: &[i16],
tones: &[u8],
freq_hz_init: f32,
dt_sec: f32,
cfg: &SubtractCfg,
radius_hz: f32,
step_hz: f32,
) -> f32 {
debug_assert!(cfg.gfsk.is_some(), "refine_freq requires GFSK shaping");
let (base_cos, base_sin) = generate_iq(tones, 0.0, cfg);
let dt = 1.0 / cfg.sample_rate;
let mut best_freq = freq_hz_init;
let mut best_amp =
ls_amp_mag_tweaked(audio, &base_cos, &base_sin, freq_hz_init, dt_sec, dt, cfg);
let mut df = -radius_hz;
while df <= radius_hz {
if df.abs() > f32::EPSILON {
let a = ls_amp_mag_tweaked(
audio,
&base_cos,
&base_sin,
freq_hz_init + df,
dt_sec,
dt,
cfg,
);
if a > best_amp {
best_amp = a;
best_freq = freq_hz_init + df;
}
}
df += step_hz;
}
best_freq
}
pub fn subtract_tones_lpf(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
endcorrection: bool,
) {
#[cfg(feature = "fft-rustfft")]
{
fft_lpf::subtract_tones_lpf_fft(
audio,
tones,
freq_hz,
dt_sec,
cfg,
lpf_half,
endcorrection,
);
}
#[cfg(not(feature = "fft-rustfft"))]
{
let _ = endcorrection;
subtract_tones_lpf_direct(audio, tones, freq_hz, dt_sec, cfg, lpf_half);
}
}
pub fn subtract_tones_lpf_refine_dt(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
endcorrection: bool,
) {
#[cfg(feature = "fft-rustfft")]
{
fft_lpf::subtract_tones_lpf_refine_dt_fft(
audio,
tones,
freq_hz,
dt_sec,
cfg,
lpf_half,
endcorrection,
);
}
#[cfg(not(feature = "fft-rustfft"))]
{
let _ = endcorrection;
subtract_tones_lpf_direct(audio, tones, freq_hz, dt_sec, cfg, lpf_half);
}
}
#[cfg(not(feature = "fft-rustfft"))]
fn subtract_tones_lpf_direct(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
) {
let nframe = tones.len() * cfg.samples_per_symbol;
let (cref_re, cref_im) = generate_iq(tones, freq_hz, cfg);
let signed_start = ((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as i64;
let (audio_off, ref_off) = if signed_start < 0 {
(0usize, (-signed_start) as usize)
} else {
(signed_start as usize, 0usize)
};
if ref_off >= nframe {
return;
}
let len = (nframe - ref_off).min(audio.len().saturating_sub(audio_off));
if len == 0 {
return;
}
let mut camp_re = vec![0.0f32; len];
let mut camp_im = vec![0.0f32; len];
for i in 0..len {
let rx = audio[audio_off + i] as f32;
camp_re[i] = rx * cref_re[ref_off + i];
camp_im[i] = -rx * cref_im[ref_off + i];
}
let nk = 2 * lpf_half + 1;
let nfilt = 2.0 * lpf_half as f32;
let mut kern = vec![0.0f32; nk];
let mut sumw = 0.0f32;
for j in 0..nk {
let x = (j as f32 - lpf_half as f32) * PI / nfilt;
let w = x.cos().powi(2);
kern[j] = w;
sumw += w;
}
for w in kern.iter_mut() {
*w /= sumw;
}
let mut cfilt_re = vec![0.0f32; len];
let mut cfilt_im = vec![0.0f32; len];
for i in 0..len {
let (mut sr, mut si, mut sw) = (0.0f32, 0.0f32, 0.0f32);
let lo = (i as i64 - lpf_half as i64).max(0) as usize;
let hi = (i + lpf_half + 1).min(len);
for j in lo..hi {
let k = j as i64 - i as i64 + lpf_half as i64;
if k < 0 || k as usize >= nk {
continue;
}
let w = kern[k as usize];
sr += w * camp_re[j];
si += w * camp_im[j];
sw += w;
}
if sw > f32::EPSILON {
cfilt_re[i] = sr / sw;
cfilt_im[i] = si / sw;
}
}
for i in 0..len {
let cr = cref_re[ref_off + i];
let ci = cref_im[ref_off + i];
let sub = 2.0 * (cfilt_re[i] * cr - cfilt_im[i] * ci);
let v = audio[audio_off + i] as f32 - sub;
audio[audio_off + i] = v.clamp(-32_768.0, 32_767.0) as i16;
}
}
#[cfg(feature = "fft-rustfft")]
mod fft_lpf {
use super::{SubtractCfg, generate_iq};
use core::f32::consts::PI;
use rustfft::FftPlanner;
use rustfft::num_complex::Complex32;
use std::sync::{Arc, Mutex, OnceLock};
use std::vec;
use std::vec::Vec;
struct CachedWindow {
nfft: usize,
lpf_half: usize,
cw: std::sync::Arc<Vec<Complex32>>,
}
static WINDOW_CACHE: OnceLock<Mutex<Vec<CachedWindow>>> = OnceLock::new();
fn normalized_kernel(lpf_half: usize) -> Vec<f32> {
let nk = 2 * lpf_half + 1;
let nfilt = 2.0 * lpf_half as f32;
let mut kern = vec![0.0f32; nk];
let mut sumw = 0.0f32;
for (j, k) in kern.iter_mut().enumerate() {
let x = (j as f32 - lpf_half as f32) * PI / nfilt;
let w = x.cos().powi(2);
*k = w;
sumw += w;
}
for w in kern.iter_mut() {
*w /= sumw;
}
kern
}
fn cached_window_fft(nfft: usize, lpf_half: usize) -> std::sync::Arc<Vec<Complex32>> {
let cache = WINDOW_CACHE.get_or_init(|| Mutex::new(Vec::new()));
{
let guard = cache.lock().unwrap();
if let Some(e) = guard
.iter()
.find(|e| e.nfft == nfft && e.lpf_half == lpf_half)
{
return e.cw.clone();
}
}
let kern = normalized_kernel(lpf_half);
let mut cw = vec![Complex32::new(0.0, 0.0); nfft];
for (j, &w) in kern.iter().enumerate() {
let offset = j as isize - lpf_half as isize; let idx = offset.rem_euclid(nfft as isize) as usize;
cw[idx] = Complex32::new(w, 0.0);
}
let mut planner = FftPlanner::<f32>::new();
planner.plan_fft_forward(nfft).process(&mut cw);
let fac = 1.0 / nfft as f32;
for c in cw.iter_mut() {
*c *= fac;
}
let arc = std::sync::Arc::new(cw);
let mut guard = cache.lock().unwrap();
if let Some(e) = guard
.iter()
.find(|e| e.nfft == nfft && e.lpf_half == lpf_half)
{
return e.cw.clone();
}
guard.push(CachedWindow {
nfft,
lpf_half,
cw: arc.clone(),
});
arc
}
struct CachedPlans {
nfft: usize,
forward: Arc<dyn rustfft::Fft<f32>>,
inverse: Arc<dyn rustfft::Fft<f32>>,
}
static PLAN_CACHE: OnceLock<Mutex<Vec<CachedPlans>>> = OnceLock::new();
fn cached_plans(nfft: usize) -> (Arc<dyn rustfft::Fft<f32>>, Arc<dyn rustfft::Fft<f32>>) {
let cache = PLAN_CACHE.get_or_init(|| Mutex::new(Vec::new()));
{
let guard = cache.lock().unwrap();
if let Some(e) = guard.iter().find(|e| e.nfft == nfft) {
return (e.forward.clone(), e.inverse.clone());
}
}
let mut planner = FftPlanner::<f32>::new();
let forward = planner.plan_fft_forward(nfft);
let inverse = planner.plan_fft_inverse(nfft);
let mut guard = cache.lock().unwrap();
if let Some(e) = guard.iter().find(|e| e.nfft == nfft) {
return (e.forward.clone(), e.inverse.clone());
}
guard.push(CachedPlans {
nfft,
forward: forward.clone(),
inverse: inverse.clone(),
});
(forward, inverse)
}
fn end_correction(lpf_half: usize) -> Vec<f32> {
let kern = normalized_kernel(lpf_half); let nk = kern.len();
let mut ec = vec![1.0f32; lpf_half + 1];
for (d, e) in ec.iter_mut().enumerate() {
let k_from = d + lpf_half; let s: f32 = kern[k_from..nk].iter().sum();
*e = 1.0 / (1.0 - s).max(1e-6);
}
ec
}
#[allow(clippy::too_many_arguments)]
pub(super) fn subtract_tones_lpf_fft(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
endcorrection: bool,
) {
apply_at_offset(
audio,
tones,
freq_hz,
dt_sec,
cfg,
lpf_half,
endcorrection,
0,
);
}
#[allow(clippy::too_many_arguments)]
fn apply_at_offset(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
endcorrection: bool,
idt_offset: i64,
) {
let nframe = tones.len() * cfg.samples_per_symbol;
let nfft = audio.len();
if nframe == 0 || nfft == 0 || lpf_half == 0 {
return;
}
let (cref_re, cref_im) = generate_iq(tones, freq_hz, cfg);
let signed_start =
((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as i64 + idt_offset;
let mut cfilt = vec![Complex32::new(0.0, 0.0); nfft];
for i in 0..nframe {
let j = signed_start + i as i64;
if j >= 0 && (j as usize) < audio.len() {
let rx = audio[j as usize] as f32;
cfilt[i] = Complex32::new(rx * cref_re[i], -rx * cref_im[i]);
}
}
let cw = cached_window_fft(nfft, lpf_half);
let (forward, inverse) = cached_plans(nfft);
forward.process(&mut cfilt);
for (c, w) in cfilt.iter_mut().zip(cw.iter()) {
*c *= *w;
}
inverse.process(&mut cfilt);
if endcorrection && lpf_half < nframe {
let ec = end_correction(lpf_half);
for (d, &factor) in ec.iter().enumerate() {
cfilt[d] *= factor;
let end_idx = nframe - 1 - d;
if end_idx != d {
cfilt[end_idx] *= factor;
}
}
}
for i in 0..nframe {
let j = signed_start + i as i64;
if j >= 0 && (j as usize) < audio.len() {
let z = cfilt[i] * Complex32::new(cref_re[i], cref_im[i]);
let sub = 2.0 * z.re;
let v = audio[j as usize] as f32 - sub;
audio[j as usize] = v.clamp(-32_768.0, 32_767.0) as i16;
}
}
}
fn residual_band_power(
audio: &[i16],
freq_hz: f32,
tone_spacing_hz: f32,
sample_rate: f32,
) -> f32 {
let nfft = audio.len();
if nfft == 0 {
return 0.0;
}
let mut buf: Vec<Complex32> = audio
.iter()
.map(|&s| Complex32::new(s as f32, 0.0))
.collect();
let (forward, _) = cached_plans(nfft);
forward.process(&mut buf);
let df = sample_rate / nfft as f32;
let ia = ((freq_hz - 1.5 * tone_spacing_hz) / df) as i64;
let ib = ((freq_hz + 8.5 * tone_spacing_hz) / df) as i64;
let lo = ia.max(0) as usize;
let hi = (ib.max(0) as usize).min(nfft.saturating_sub(1));
let mut sqq = 0.0f32;
for k in lo..=hi {
let c = buf[k];
sqq += c.re * c.re + c.im * c.im;
}
sqq
}
#[allow(clippy::too_many_arguments)]
pub(super) fn subtract_tones_lpf_refine_dt_fft(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
cfg: &SubtractCfg,
lpf_half: usize,
endcorrection: bool,
) {
const SEARCH_SAMPLES: i64 = 90;
let trial = |offset: i64| -> Vec<i16> {
let mut copy = audio.to_vec();
apply_at_offset(
&mut copy,
tones,
freq_hz,
dt_sec,
cfg,
lpf_half,
endcorrection,
offset,
);
copy
};
let cand_m = trial(-SEARCH_SAMPLES);
let cand_p = trial(SEARCH_SAMPLES);
let cand_0 = trial(0);
let sq_m = residual_band_power(&cand_m, freq_hz, cfg.tone_spacing_hz, cfg.sample_rate);
let sq_p = residual_band_power(&cand_p, freq_hz, cfg.tone_spacing_hz, cfg.sample_rate);
let sq_0 = residual_band_power(&cand_0, freq_hz, cfg.tone_spacing_hz, cfg.sample_rate);
let b = (sq_p - sq_m) / 2.0;
let c = (sq_p + sq_m - 2.0 * sq_0) / 2.0;
if c.abs() < f32::EPSILON {
audio.copy_from_slice(&cand_0);
return;
}
let dx = -b / (2.0 * c);
if dx.abs() > 1.0 {
return;
}
let best_offset = (SEARCH_SAMPLES as f32 * dx).round() as i64;
apply_at_offset(
audio,
tones,
freq_hz,
dt_sec,
cfg,
lpf_half,
endcorrection,
best_offset,
);
}
}
#[inline]
pub fn subtract_tones(
audio: &mut [i16],
tones: &[u8],
freq_hz: f32,
dt_sec: f32,
gain: f32,
cfg: &SubtractCfg,
) {
let (w_cos, w_sin) = generate_iq(tones, freq_hz, cfg);
let signed_start = ((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as i64;
let (audio_off, ref_off) = if signed_start < 0 {
(0usize, (-signed_start) as usize)
} else {
(signed_start as usize, 0usize)
};
if ref_off >= w_cos.len() {
return;
}
let len = (w_cos.len() - ref_off).min(audio.len().saturating_sub(audio_off));
if len == 0 {
return;
}
let (num_a, num_b, den_a, den_b) =
(0..len).fold((0.0f32, 0.0f32, 0.0f32, 0.0f32), |(na, nb, da, db), i| {
let rx = audio[audio_off + i] as f32;
let wc = w_cos[ref_off + i];
let ws = w_sin[ref_off + i];
(na + rx * wc, nb + rx * ws, da + wc * wc, db + ws * ws)
});
let a = if den_a > f32::EPSILON {
num_a / den_a
} else {
0.0
};
let b = if den_b > f32::EPSILON {
num_b / den_b
} else {
0.0
};
for i in 0..len {
let sub = gain * (a * w_cos[ref_off + i] + b * w_sin[ref_off + i]);
let new_val = audio[audio_off + i] as f32 - sub;
audio[audio_off + i] = new_val.clamp(-32_768.0, 32_767.0) as i16;
}
}
#[cfg(all(test, feature = "fft-rustfft"))]
mod refine_dt_tests {
use super::*;
use crate::engine::dsp::gfsk::{GfskCfg, synth_i16};
fn ft8_cfg() -> SubtractCfg {
SubtractCfg {
sample_rate: 12_000.0,
tone_spacing_hz: 6.25,
samples_per_symbol: 1920,
base_offset_s: 0.5,
gfsk: Some(GfskParams {
bt: 2.0,
hmod: 1.0,
ramp_samples: 1920 / 8,
}),
}
}
#[test]
fn refine_dt_beats_plain_subtract_when_dt_is_off() {
let cfg = ft8_cfg();
let gfsk_cfg = GfskCfg {
sample_rate: cfg.sample_rate,
samples_per_symbol: cfg.samples_per_symbol,
bt: cfg.gfsk.unwrap().bt,
hmod: cfg.gfsk.unwrap().hmod,
ramp_samples: cfg.gfsk.unwrap().ramp_samples,
};
let tones: Vec<u8> = (0..79).map(|k| (k % 8) as u8).collect();
let freq_hz = 1500.0f32;
let true_dt_sec = 0.2f32;
let samples = synth_i16(&tones, freq_hz, 5000, &gfsk_cfg);
let mut audio = vec![0i16; 180_000];
let start = ((cfg.base_offset_s + true_dt_sec) * cfg.sample_rate).round() as usize;
for (i, &s) in samples.iter().enumerate() {
audio[start + i] = s;
}
let reported_dt_sec = true_dt_sec + 40.0 / cfg.sample_rate;
let mut audio_plain = audio.clone();
subtract_tones_lpf(
&mut audio_plain,
&tones,
freq_hz,
reported_dt_sec,
&cfg,
2000,
true,
);
let energy_plain: f64 = audio_plain.iter().map(|&s| (s as f64).powi(2)).sum();
let mut audio_refined = audio.clone();
subtract_tones_lpf_refine_dt(
&mut audio_refined,
&tones,
freq_hz,
reported_dt_sec,
&cfg,
2000,
true,
);
let energy_refined: f64 = audio_refined.iter().map(|&s| (s as f64).powi(2)).sum();
assert!(
energy_refined < energy_plain,
"dt-refining subtract left more residual energy ({energy_refined:.0}) than the \
plain subtract ({energy_plain:.0}) at a 40-sample dt offset"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subtract_tones_negative_dt_aligns_via_ref_offset() {
let cfg = SubtractCfg {
sample_rate: 12_000.0,
tone_spacing_hz: 6.25,
samples_per_symbol: 1920,
base_offset_s: 0.5,
gfsk: None,
};
let tones: Vec<u8> = (0..79).map(|k| (k % 8) as u8).collect();
let (w_cos, _w_sin) = generate_iq(&tones, 1500.0, &cfg);
let shift = 3360usize;
let amp = 5000.0f32;
let mut audio: Vec<i16> = vec![0i16; 180_000];
let n = w_cos.len() - shift;
for i in 0..n.min(audio.len()) {
audio[i] = (amp * w_cos[shift + i]).clamp(-32_768.0, 32_767.0) as i16;
}
let pre_energy: f64 = audio.iter().map(|&s| (s as f64) * (s as f64)).sum();
let dt_sec = -(shift as f32) / cfg.sample_rate - cfg.base_offset_s;
subtract_tones(&mut audio, &tones, 1500.0, dt_sec, 1.0, &cfg);
let post_energy: f64 = audio.iter().map(|&s| (s as f64) * (s as f64)).sum();
let drop_db = 10.0 * (post_energy / pre_energy).log10();
assert!(
drop_db < -30.0,
"subtract_tones failed to remove signal at dt_sec={dt_sec:.3} \
(drop only {drop_db:.1} dB; expected < -30 dB). \
Pre-fix bug: `start as usize` saturated negative to 0."
);
}
#[test]
fn subtract_tones_positive_dt_works() {
let cfg = SubtractCfg {
sample_rate: 12_000.0,
tone_spacing_hz: 6.25,
samples_per_symbol: 1920,
base_offset_s: 0.5,
gfsk: None,
};
let tones: Vec<u8> = (0..79).map(|k| (k % 8) as u8).collect();
let (w_cos, _) = generate_iq(&tones, 1500.0, &cfg);
let dt_sec: f32 = 0.2;
let start = ((cfg.base_offset_s + dt_sec) * cfg.sample_rate).round() as usize;
let amp = 5000.0f32;
let mut audio: Vec<i16> = vec![0i16; 180_000];
for i in 0..w_cos.len() {
audio[start + i] = (amp * w_cos[i]).clamp(-32_768.0, 32_767.0) as i16;
}
let pre: f64 = audio.iter().map(|&s| (s as f64) * (s as f64)).sum();
subtract_tones(&mut audio, &tones, 1500.0, dt_sec, 1.0, &cfg);
let post: f64 = audio.iter().map(|&s| (s as f64) * (s as f64)).sum();
let drop_db = 10.0 * (post / pre).log10();
assert!(
drop_db < -30.0,
"positive-DT subtract drop only {drop_db:.1} dB"
);
}
fn ft4_like_cfg() -> SubtractCfg {
SubtractCfg {
sample_rate: 12_000.0,
tone_spacing_hz: 20.833,
samples_per_symbol: 576,
base_offset_s: 0.5,
gfsk: Some(GfskParams {
bt: 1.0,
hmod: 1.0,
ramp_samples: 576 / 8,
}),
}
}
fn ft8_like_cfg() -> SubtractCfg {
SubtractCfg {
sample_rate: 12_000.0,
tone_spacing_hz: 6.25,
samples_per_symbol: 1920,
base_offset_s: 0.5,
gfsk: Some(GfskParams {
bt: 2.0,
hmod: 1.0,
ramp_samples: 1920 / 8,
}),
}
}
#[test]
fn ls_amp_mag_tweaked_matches_full_resynthesis() {
const AMPLITUDE: f32 = 8000.0;
const ABS_TOL: f32 = 1e-3 * AMPLITUDE;
const REL_TOL: f32 = 3e-2;
let mut max_abs_err = 0.0f32;
let mut max_rel_err = 0.0f32;
for (cfg, radius_hz, nsym) in [(ft4_like_cfg(), 5.0f32, 103), (ft8_like_cfg(), 2.5f32, 79)]
{
let tones: Vec<u8> = (0..nsym).map(|k| ((k * 3 + 1) % 8) as u8).collect();
let samples = {
let n = tones.len() * cfg.samples_per_symbol;
let mut out = vec![0.0f32; n];
let gfsk_cfg = crate::engine::dsp::gfsk::GfskCfg {
sample_rate: cfg.sample_rate,
samples_per_symbol: cfg.samples_per_symbol,
bt: cfg.gfsk.unwrap().bt,
hmod: cfg.gfsk.unwrap().hmod,
ramp_samples: cfg.gfsk.unwrap().ramp_samples,
};
crate::engine::dsp::gfsk::synth_f32_into(
&mut out, &tones, 1500.0, AMPLITUDE, &gfsk_cfg,
);
out
};
let offset = (cfg.base_offset_s * cfg.sample_rate) as usize;
let mut audio = vec![0i16; samples.len() + offset + 4000];
for (i, &s) in samples.iter().enumerate() {
audio[offset + i] = s.clamp(-32_768.0, 32_767.0) as i16;
}
let (base_cos, base_sin) = generate_iq(&tones, 0.0, &cfg);
let dt = 1.0 / cfg.sample_rate;
for dt_sec in [0.0f32, 0.15, -0.2] {
let mut df = -radius_hz;
while df <= radius_hz {
let freq = 1500.0 + df;
let expected = ls_amp_mag(&audio, &tones, freq, dt_sec, &cfg);
let actual =
ls_amp_mag_tweaked(&audio, &base_cos, &base_sin, freq, dt_sec, dt, &cfg);
let abs_err = (actual - expected).abs();
let rel_err = abs_err / expected.abs().max(f32::EPSILON);
max_abs_err = max_abs_err.max(abs_err);
if abs_err > ABS_TOL && rel_err > REL_TOL {
panic!(
"df={df} dt_sec={dt_sec}: expected={expected} actual={actual} \
abs_err={abs_err} (tol {ABS_TOL}) rel_err={rel_err} (tol {REL_TOL})"
);
}
if abs_err > ABS_TOL {
max_rel_err = max_rel_err.max(rel_err);
}
df += 0.1;
}
}
}
eprintln!("MEASURED max_abs_err={max_abs_err} max_rel_err (away from nulls)={max_rel_err}");
}
#[test]
fn refine_freq_finds_true_offgrid_carrier() {
let cfg = ft4_like_cfg();
let tones: Vec<u8> = (0..103).map(|k| ((k * 5 + 2) % 8) as u8).collect();
let freq_hz_init = 1500.0f32;
let freq_true = freq_hz_init + 1.7;
let samples = {
let n = tones.len() * cfg.samples_per_symbol;
let mut out = vec![0.0f32; n];
let g = cfg.gfsk.unwrap();
let gfsk_cfg = crate::engine::dsp::gfsk::GfskCfg {
sample_rate: cfg.sample_rate,
samples_per_symbol: cfg.samples_per_symbol,
bt: g.bt,
hmod: g.hmod,
ramp_samples: g.ramp_samples,
};
crate::engine::dsp::gfsk::synth_f32_into(
&mut out, &tones, freq_true, 8000.0, &gfsk_cfg,
);
out
};
let offset = (cfg.base_offset_s * cfg.sample_rate) as usize;
let mut audio = vec![0i16; samples.len() + offset + 4000];
for (i, &s) in samples.iter().enumerate() {
audio[offset + i] = s.clamp(-32_768.0, 32_767.0) as i16;
}
let refined = refine_freq(&audio, &tones, freq_hz_init, 0.0, &cfg, 5.0, 0.1);
assert!(
(refined - freq_true).abs() <= 0.1 + 1e-3,
"refine_freq picked {refined}, expected within 0.1 Hz of true carrier {freq_true}"
);
}
}