Skip to main content

mfsk_core/wspr/
spectrogram.rs

1//! Precomputed quarter-symbol spectrogram.
2//!
3//! One overlapping FFT per quarter-symbol time step, cached as a flat
4//! row-major `|FFT|²` table. Coarse search then scores candidates in
5//! O(162) lookups instead of O(162) FFTs — a ~1000× speedup for the
6//! 120-s WSPR slot over the naive candidate-grid loop.
7//!
8//! Shape at 12 kHz sample rate:
9//! - NSPS = 8192, t_step = NSPS/4 = 2048 samples (~171 ms)
10//! - n_time ≈ audio_len / 2048 (≈ 700 rows for a full slot)
11//! - n_freq = NSPS/2 = 4096 bins (1.4648 Hz each, Nyquist at 6 kHz)
12//! - Storage: ~700 × 4096 × 4 bytes ≈ 11 MB per slot.
13
14use crate::core::ModulationParams;
15use num_complex::Complex;
16use rustfft::FftPlanner;
17
18use super::Wspr;
19
20/// Precomputed spectrogram of an audio slot.
21pub struct Spectrogram {
22    /// Row-major `|FFT|²` table: `mags_sqr[t * n_freq + f]`.
23    pub mags_sqr: Vec<f32>,
24    pub n_time: usize,
25    pub n_freq: usize,
26    /// Samples between consecutive time rows.
27    pub t_step: usize,
28    /// FFT window size (samples).
29    pub nsps: usize,
30    /// Frequency resolution (Hz per bin).
31    pub df: f32,
32    /// Mean squared-magnitude of "noise" bins (rough σ² estimator).
33    pub noise_per_bin: f32,
34}
35
36impl Spectrogram {
37    /// Build a quarter-symbol spectrogram matching WSPR's geometry at
38    /// `sample_rate`. Empty if the audio is shorter than one symbol.
39    pub fn build(audio: &[f32], sample_rate: u32) -> Self {
40        let nsps = (sample_rate as f32 * <Wspr as ModulationParams>::SYMBOL_DT).round() as usize;
41        let t_step = nsps / 4;
42        let n_freq = nsps / 2;
43        if audio.len() < nsps || t_step == 0 {
44            return Self {
45                mags_sqr: Vec::new(),
46                n_time: 0,
47                n_freq: 0,
48                t_step: 0,
49                nsps,
50                df: sample_rate as f32 / nsps as f32,
51                noise_per_bin: 1.0,
52            };
53        }
54        let n_time = (audio.len() - nsps) / t_step + 1;
55
56        let mut mags_sqr = vec![0f32; n_time * n_freq];
57        let mut planner = FftPlanner::<f32>::new();
58        let fft = planner.plan_fft_forward(nsps);
59        let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
60        let mut buf: Vec<Complex<f32>> = vec![Complex::new(0f32, 0f32); nsps];
61
62        for t in 0..n_time {
63            let start = t * t_step;
64            for (slot, &s) in buf.iter_mut().zip(&audio[start..start + nsps]) {
65                *slot = Complex::new(s, 0.0);
66            }
67            fft.process_with_scratch(&mut buf, &mut scratch);
68            let row = &mut mags_sqr[t * n_freq..(t + 1) * n_freq];
69            for (slot, c) in row.iter_mut().zip(buf.iter().take(n_freq)) {
70                *slot = c.norm_sqr();
71            }
72        }
73
74        // Noise reference: mean power across all bins and times,
75        // discarding the top 5 % to avoid strong signals dragging the
76        // estimate up. Cheap approximation of median-filter noise floor.
77        let mut sorted = mags_sqr.clone();
78        sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
79        let keep = (sorted.len() as f32 * 0.95) as usize;
80        let noise_per_bin = if keep > 0 {
81            sorted[..keep].iter().sum::<f32>() / keep as f32
82        } else {
83            1.0
84        };
85
86        Self {
87            mags_sqr,
88            n_time,
89            n_freq,
90            t_step,
91            nsps,
92            df: sample_rate as f32 / nsps as f32,
93            noise_per_bin: noise_per_bin.max(1e-6),
94        }
95    }
96
97    #[inline]
98    pub fn get(&self, t: usize, f: usize) -> f32 {
99        self.mags_sqr[t * self.n_freq + f]
100    }
101}
102
103/// Score a candidate alignment using precomputed spectrogram rows.
104/// `t_row` is the spectrogram row of symbol 0; consecutive symbols are
105/// four rows apart (because `t_step = nsps/4`). `base_bin` is the FFT
106/// bin of tone 0. Returns a score on the same scale as
107/// [`super::rx::sync_score`]: ≈ 1.0 at clean alignment, ≈ 0 for empty
108/// windows, negative when signal lands in the sync-inconsistent tones.
109pub fn score_candidate(spec: &Spectrogram, t_row: usize, base_bin: usize) -> f32 {
110    use super::WSPR_SYNC_VECTOR;
111    const ROWS_PER_SYMBOL: usize = 4;
112    let last_row = t_row + 161 * ROWS_PER_SYMBOL;
113    if last_row >= spec.n_time || base_bin + 4 > spec.n_freq {
114        return 0.0;
115    }
116    let mut sync_pwr = 0.0f32;
117    let mut off_pwr = 0.0f32;
118    for i in 0..162 {
119        let t = t_row + i * ROWS_PER_SYMBOL;
120        let m0 = spec.get(t, base_bin);
121        let m1 = spec.get(t, base_bin + 1);
122        let m2 = spec.get(t, base_bin + 2);
123        let m3 = spec.get(t, base_bin + 3);
124        if WSPR_SYNC_VECTOR[i] == 0 {
125            sync_pwr += m0 + m2;
126            off_pwr += m1 + m3;
127        } else {
128            sync_pwr += m1 + m3;
129            off_pwr += m0 + m2;
130        }
131    }
132    let noise_floor = spec.noise_per_bin * 162.0;
133    let denom = sync_pwr + off_pwr + noise_floor;
134    if denom > 0.0 {
135        (sync_pwr - off_pwr) / denom
136    } else {
137        0.0
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::super::synthesize_type1;
144    use super::*;
145
146    #[test]
147    fn spec_matches_direct_demod() {
148        // Sanity: score_candidate on the spectrogram should pick the
149        // same alignment as the per-candidate FFT loop. We just check
150        // that clean synthesis scores highest at the true alignment
151        // (bin ≈ 1024, t_row = 0) among a small neighbourhood.
152        let freq = 1500.0;
153        let audio = synthesize_type1("K1ABC", "FN42", 37, 12_000, freq, 0.3).expect("synth");
154        let spec = Spectrogram::build(&audio, 12_000);
155        assert!(spec.n_time > 0);
156        assert_eq!(spec.n_freq, 4096);
157
158        let true_bin = 1024;
159        let true_t = 0usize;
160        let best_score = score_candidate(&spec, true_t, true_bin);
161        // Nearby neighbours should all score lower.
162        for dt in [-2i32, -1, 1, 2] {
163            if let Ok(t) = (true_t as i32 + dt).try_into() {
164                let s = score_candidate(&spec, t, true_bin);
165                assert!(s < best_score, "dt={} scored {} >= {}", dt, s, best_score);
166            }
167        }
168        for df in [-2i32, -1, 1, 2] {
169            let b = (true_bin as i32 + df) as usize;
170            let s = score_candidate(&spec, true_t, b);
171            assert!(s < best_score, "df={} scored {} >= {}", df, s, best_score);
172        }
173    }
174}