Skip to main content

mfsk_core/wspr/
decode.rs

1//! Top-level WSPR decode entry point.
2//!
3//! Given aligned audio, a candidate base frequency, and a target start
4//! sample, runs demod → deinterleave → Fano → message unpack. No coarse
5//! search here; a later module will wrap this with a (freq × time) scan.
6
7use crate::msg::WsprMessage;
8
9use super::search::{SearchParams, coarse_search};
10use super::{decode_from_deinterleaved_llrs, demodulate_aligned};
11
12/// One successful WSPR decode.
13#[derive(Clone, Debug)]
14pub struct WsprDecode {
15    /// Recovered message payload.
16    pub message: WsprMessage,
17    /// Base frequency (tone 0) used for demodulation.
18    pub freq_hz: f32,
19    /// Sample index at which symbol 0 started.
20    pub start_sample: usize,
21}
22
23/// Decode one WSPR frame at a known (freq, start_sample). Returns `None`
24/// if the Fano decoder fails to converge or the message doesn't unpack.
25pub fn decode_at(
26    audio: &[f32],
27    sample_rate: u32,
28    start_sample: usize,
29    freq_hz: f32,
30) -> Option<WsprDecode> {
31    let mut llrs = demodulate_aligned(audio, sample_rate, start_sample, freq_hz);
32    deinterleave_llrs(&mut llrs);
33    let message = decode_from_deinterleaved_llrs(&llrs)?;
34    Some(WsprDecode {
35        message,
36        freq_hz,
37        start_sample,
38    })
39}
40
41/// Scan an audio buffer for any number of WSPR frames, returning all
42/// successful decodes. Runs a coarse (freq, time) search with the given
43/// [`SearchParams`], then attempts [`decode_at`] on each candidate in
44/// score-descending order. Duplicate decodes (same message within ±5 Hz
45/// and ±1 symbol) are collapsed to the single earliest-candidate hit,
46/// so each transmission appears at most once in the output.
47pub fn decode_scan(
48    audio: &[f32],
49    sample_rate: u32,
50    nominal_start_sample: usize,
51    params: &SearchParams,
52) -> Vec<WsprDecode> {
53    let cands = coarse_search(audio, sample_rate, nominal_start_sample, params);
54    let mut seen: Vec<WsprDecode> = Vec::new();
55    const FREQ_DEDUP_HZ: f32 = 5.0;
56    const TIME_DEDUP_SAMPLES: i64 = 8192; // one WSPR symbol at 12 kHz
57    for c in cands {
58        let Some(d) = decode_at(audio, sample_rate, c.start_sample, c.freq_hz) else {
59            continue;
60        };
61        let dup = seen.iter().any(|prev| {
62            prev.message == d.message
63                && (prev.freq_hz - d.freq_hz).abs() <= FREQ_DEDUP_HZ
64                && (prev.start_sample as i64 - d.start_sample as i64).abs() <= TIME_DEDUP_SAMPLES
65        });
66        if !dup {
67            seen.push(d);
68        }
69    }
70    seen
71}
72
73/// Convenience: scan using [`SearchParams::default`].
74pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<WsprDecode> {
75    decode_scan(audio, sample_rate, 0, &SearchParams::default())
76}
77
78/// Deinterleave 162 LLRs in place (same permutation as [`deinterleave`]
79/// but for `f32` values).
80fn deinterleave_llrs(llrs: &mut [f32; 162]) {
81    let mut tmp = [0f32; 162];
82    let mut p = 0u8;
83    let mut i = 0u8;
84    while p < 162 {
85        // Inline the bit-reverse-8 to avoid exposing a pub helper.
86        let i64 = i as u64;
87        let j = ((((i64 * 0x8020_0802u64) & 0x0884_4221_10u64).wrapping_mul(0x0101_0101_01u64))
88            >> 32) as u8 as usize;
89        if j < 162 {
90            tmp[p as usize] = llrs[j];
91            p += 1;
92        }
93        i = i.wrapping_add(1);
94    }
95    *llrs = tmp;
96}
97
98#[cfg(test)]
99mod tests {
100    use super::super::search::SearchParams;
101    use super::super::synthesize_type1;
102    use super::*;
103    use crate::msg::WsprMessage;
104
105    #[test]
106    fn synth_decode_roundtrip_k1abc_fn42_37() {
107        let freq = 1500.0;
108        let audio =
109            synthesize_type1("K1ABC", "FN42", 37, 12_000, freq, 0.3).expect("valid message");
110        let r = decode_at(&audio, 12_000, 0, freq).expect("decode");
111        assert_eq!(
112            r.message,
113            WsprMessage::Type1 {
114                callsign: "K1ABC".into(),
115                grid: "FN42".into(),
116                power_dbm: 37,
117            }
118        );
119    }
120
121    #[test]
122    fn scan_recovers_message_without_freq_hint() {
123        let freq = 1500.0;
124        let audio = synthesize_type1("K1ABC", "FN42", 37, 12_000, freq, 0.3).expect("synth");
125        let decodes = decode_scan(
126            &audio,
127            12_000,
128            0,
129            &SearchParams {
130                freq_min_hz: 1450.0,
131                freq_max_hz: 1550.0,
132                ..SearchParams::default()
133            },
134        );
135        assert!(!decodes.is_empty(), "at least one decode");
136        let d = decodes.into_iter().next().unwrap();
137        assert_eq!(
138            d.message,
139            WsprMessage::Type1 {
140                callsign: "K1ABC".into(),
141                grid: "FN42".into(),
142                power_dbm: 37,
143            }
144        );
145        assert!((d.freq_hz - 1500.0).abs() <= 2.0);
146    }
147
148    #[test]
149    fn survives_moderate_awgn() {
150        use std::f32::consts::PI;
151
152        let freq = 1500.0;
153        let mut audio =
154            synthesize_type1("K9AN", "EN50", 33, 12_000, freq, 0.5).expect("valid message");
155
156        // Deterministic "noise": superposition of a handful of off-tone
157        // sinusoids plus a pseudorandom dither. This is a cheap AWGN
158        // stand-in that keeps the test free of rand dependencies.
159        let mut seed: u32 = 0x1234_5678;
160        for (i, s) in audio.iter_mut().enumerate() {
161            // Linear congruential pseudorandom for reproducible noise.
162            seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345);
163            let rnd = ((seed >> 16) as f32 / 32768.0 - 1.0) * 0.10;
164            let off = 0.05 * (2.0 * PI * 2345.7 * i as f32 / 12_000.0).sin();
165            *s += rnd + off;
166        }
167
168        let r = decode_at(&audio, 12_000, 0, freq).expect("decode under noise");
169        assert_eq!(
170            r.message,
171            WsprMessage::Type1 {
172                callsign: "K9AN".into(),
173                grid: "EN50".into(),
174                power_dbm: 33,
175            }
176        );
177    }
178}