Skip to main content

mfsk_core/ft8/
downsample.rs

1//! FT8-tuned wrapper around the generic downsampler in
2//! [`mod@crate::core::dsp::downsample`].
3//!
4//! Keeps the pre-existing `crate::ft8::downsample::{downsample, downsample_simple,
5//! build_fft_cache}` signatures so existing callers (`decode.rs`, WASM glue,
6//! benchmarks) continue working without change. The heavy lifting lives in
7//! `mfsk-core` so FT4 and future LDPC-family modes reuse it.
8
9use crate::core::dsp::downsample::{self as g, DownsampleCfg};
10use num_complex::Complex;
11
12/// FT8 downsample configuration: 12 kHz → 200 Hz, 8 tones spaced 6.25 Hz apart.
13pub const FT8_CFG: DownsampleCfg = DownsampleCfg {
14    input_rate: 12_000,
15    fft1_size: 192_000,
16    fft2_size: 3_200,
17    tone_spacing_hz: 6.25,
18    leading_pad_tones: 1.5,
19    trailing_pad_tones: 1.5,
20    ntones: 8,
21    edge_taper_bins: 101,
22};
23
24/// Downconvert and decimate `audio` to a complex baseband at 200 Hz centred
25/// on `f0`. Matches the pre-refactor signature: returns the result plus the
26/// forward-FFT cache so candidate loops can avoid recomputing it.
27#[inline]
28pub fn downsample(
29    audio: &[i16],
30    f0: f32,
31    fft_cache: Option<&[Complex<f32>]>,
32) -> (Vec<Complex<f32>>, Vec<Complex<f32>>) {
33    match fft_cache {
34        Some(cache) => (g::downsample_cached(cache, f0, &FT8_CFG), cache.to_vec()),
35        None => g::downsample(audio, f0, &FT8_CFG),
36    }
37}
38
39/// Compute only the forward FFT cache (192 000-point) — expensive, shared
40/// across all subsequent downsample calls for the same audio block.
41#[inline]
42pub fn build_fft_cache(audio: &[i16]) -> Vec<Complex<f32>> {
43    g::build_fft_cache(audio, &FT8_CFG)
44}
45
46/// No-cache convenience: returns only the 3200-sample baseband.
47#[inline]
48pub fn downsample_simple(audio: &[i16], f0: f32) -> Vec<Complex<f32>> {
49    downsample(audio, f0, None).0
50}
51
52#[cfg(test)]
53mod tests {
54    use super::super::params::NMAX;
55    use super::*;
56
57    const NFFT2: usize = 3_200;
58
59    #[test]
60    fn sine_at_f0_energy_at_dc() {
61        let f0 = 1000.0f32;
62        let audio: Vec<i16> = (0..NMAX)
63            .map(|n| {
64                let t = n as f32 / 12_000.0;
65                (10_000.0 * (2.0 * std::f32::consts::PI * f0 * t).sin()) as i16
66            })
67            .collect();
68
69        let out = downsample_simple(&audio, f0);
70
71        let mut spectrum = out.clone();
72        let mut planner = rustfft::FftPlanner::<f32>::new();
73        planner.plan_fft_forward(NFFT2).process(&mut spectrum);
74
75        let energy_near_dc: f32 = spectrum[..=10]
76            .iter()
77            .chain(spectrum[NFFT2 - 10..].iter())
78            .map(|c| c.norm_sqr())
79            .sum();
80        let total_energy: f32 = spectrum.iter().map(|c| c.norm_sqr()).sum();
81
82        assert!(total_energy > 0.0);
83        let frac = energy_near_dc / total_energy;
84        assert!(frac > 0.5, "energy near DC fraction = {frac:.3}");
85    }
86
87    #[test]
88    fn sine_offset_from_f0_not_at_dc() {
89        let f0 = 1000.0f32;
90        let audio: Vec<i16> = (0..NMAX)
91            .map(|n| {
92                let t = n as f32 / 12_000.0;
93                (10_000.0 * (2.0 * std::f32::consts::PI * (f0 + 100.0) * t).sin()) as i16
94            })
95            .collect();
96
97        let out = downsample_simple(&audio, f0);
98
99        let mut spectrum = out.clone();
100        let mut planner = rustfft::FftPlanner::<f32>::new();
101        planner.plan_fft_forward(NFFT2).process(&mut spectrum);
102
103        let energy_near_dc: f32 = spectrum[..=2].iter().map(|c| c.norm_sqr()).sum();
104        let total_energy: f32 = spectrum.iter().map(|c| c.norm_sqr()).sum();
105
106        let frac = energy_near_dc / total_energy;
107        assert!(frac < 0.1, "energy at DC fraction = {frac:.3}");
108    }
109
110    #[test]
111    fn output_length() {
112        let audio = vec![0i16; NMAX];
113        let out = downsample_simple(&audio, 1000.0);
114        assert_eq!(out.len(), NFFT2);
115    }
116
117    #[test]
118    fn silence_gives_zero_output() {
119        let audio = vec![0i16; NMAX];
120        let out = downsample_simple(&audio, 1500.0);
121        let max_abs = out.iter().map(|c| c.norm()).fold(0.0f32, f32::max);
122        assert!(max_abs < 1e-10);
123    }
124}