Skip to main content

mfsk_core/wspr/
mod.rs

1//! # `wspr` — WSPR decoder and synthesiser
2//!
3//! WSPR (Weak Signal Propagation Reporter) is a very-weak-signal propagation
4//! beacon mode. Unlike FT8 / FT4 / FST4, WSPR uses:
5//!
6//! * **4-FSK at 1.4648 Hz** tone spacing, 162 symbols over ~110.6 s
7//! * **Convolutional r=1/2 K=32** with Fano sequential decoder
8//! * **50-bit message payload** (callsign + grid4 + power, or hashed variants)
9//! * **Per-symbol interleaved sync**: the LSB of every 4-FSK symbol
10//!   reproduces a fixed 162-bit pseudorandom vector (the "npr3 sync"), so
11//!   sync is not a block Costas array — the decoder recovers timing by
12//!   correlating every symbol's LSB against the known vector.
13//!
14//! All protocol-invariant pieces (FFT/downsample DSP, generic pipeline
15//! scaffolding, FEC codec, message codec) are shared with the other modes.
16//! This module provides the [`Wspr`] ZST plus WSPR-specific TX/RX helpers
17//! that handle the interleaver and sync-bit embedding.
18//!
19//! ## Quick example
20//!
21//! ```no_run
22//! use mfsk_core::wspr::decode::decode_scan_default;
23//!
24//! # let audio: Vec<f32> = vec![];
25//! // `audio` is ~1.44M f32 samples at 12 kHz (120 s slot).
26//! for r in decode_scan_default(&audio, 12_000) {
27//!     println!("{:+7.1} Hz  start={:>8} sample  {}",
28//!              r.freq_hz, r.start_sample, r.message);
29//! }
30//! ```
31
32use crate::core::{FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode};
33use crate::fec::ConvFano;
34use crate::msg::Wspr50Message;
35
36pub mod decode;
37pub mod rx;
38pub mod search;
39pub mod spectrogram;
40pub mod sync_vector;
41pub mod tx;
42
43pub use decode::{WsprDecode, decode_at};
44pub use rx::demodulate_aligned;
45pub use search::{SearchParams, SyncCandidate, coarse_search};
46pub use sync_vector::WSPR_SYNC_VECTOR;
47pub use tx::{synthesize_audio, synthesize_type1};
48
49// ─────────────────────────────────────────────────────────────────────────
50// Protocol ZST
51// ─────────────────────────────────────────────────────────────────────────
52
53/// WSPR-2 (the standard 2-minute slot variant). WSPR-15 differs in slot
54/// length and NSPS; a separate ZST can be added later sharing everything
55/// except the few timing constants.
56#[derive(Copy, Clone, Debug, Default)]
57pub struct Wspr;
58
59impl ModulationParams for Wspr {
60    const NTONES: u32 = 4;
61    const BITS_PER_SYMBOL: u32 = 2;
62    /// 8192 samples at 12 kHz = 0.6827 s per symbol. WSJT-X demodulates at
63    /// 375 Hz after a 32× decimation (12000/32 = 375), where one symbol is
64    /// 256 samples; we keep the pipeline-standard 12 kHz convention here.
65    const NSPS: u32 = 8192;
66    const SYMBOL_DT: f32 = 8192.0 / 12_000.0;
67    const TONE_SPACING_HZ: f32 = 12_000.0 / 8192.0; // ≈ 1.4648
68    /// Gray map for 4-FSK. WSPR tones map naturally (no Gray conversion in
69    /// the WSJT-X reference), so this is the identity — the data bit just
70    /// picks the top bit of the tone index.
71    const GRAY_MAP: &'static [u8] = &[0, 1, 2, 3];
72    // WSPR uses MSK-ish continuous-phase shaping; GFSK is close enough for
73    // coarse modelling (WSJT-X genwspr.f90 applies a raised-cosine pulse
74    // rather than a Gaussian). BT=1.0 is a reasonable stand-in here.
75    const GFSK_BT: f32 = 1.0;
76    const GFSK_HMOD: f32 = 1.0;
77    const NFFT_PER_SYMBOL_FACTOR: u32 = 1; // sync correlation windows = 1 symbol
78    const NSTEP_PER_SYMBOL: u32 = 16; // WSJT-X scans 16 sub-symbol offsets
79    const NDOWN: u32 = 32; // 12000 / 32 = 375 Hz baseband
80}
81
82impl FrameLayout for Wspr {
83    const N_DATA: u32 = 162; // every symbol is both data and sync
84    const N_SYNC: u32 = 0;
85    const N_SYMBOLS: u32 = 162;
86    const N_RAMP: u32 = 0;
87    const SYNC_MODE: SyncMode = SyncMode::Interleaved {
88        sync_bit_pos: 0, // LSB of 4-FSK tone = sync bit, MSB = data bit
89        vector: &WSPR_SYNC_VECTOR,
90    };
91    /// Nominal slot length — the "2" in "WSPR-2". Matches WSJT-X's 120-s
92    /// schedule. The actual frame transmission is ≈ 110.6 s inside this
93    /// slot.
94    const T_SLOT_S: f32 = 120.0;
95    /// Frame begins ~1 s after the slot boundary (WSJT-X convention).
96    const TX_START_OFFSET_S: f32 = 1.0;
97}
98
99impl Protocol for Wspr {
100    type Fec = ConvFano;
101    type Msg = Wspr50Message;
102    const ID: ProtocolId = ProtocolId::Wspr;
103}
104
105// ─────────────────────────────────────────────────────────────────────────
106// WSPR-specific interleaver
107// ─────────────────────────────────────────────────────────────────────────
108
109/// 8-bit bit-reversal by SWAR magic-constant multiplication — the
110/// identity used by WSJT-X's interleaver (and a classic Hacker's Delight
111/// trick). Input `i` only needs to be considered modulo 256.
112#[inline]
113fn bit_reverse_8(i: u8) -> u8 {
114    // Matches `j = ((i * 0x80200802) & 0x0884422110) * 0x0101010101 >> 32`
115    // from wsprsim_utils.c, with the implicit truncation to `unsigned char`
116    // made explicit via `as u8` on the final result.
117    let i64 = i as u64;
118    (((i64 * 0x8020_0802u64) & 0x0884_4221_10u64).wrapping_mul(0x0101_0101_01u64) >> 32) as u8
119}
120
121/// Permute the 162-symbol stream using WSJT-X's bit-reversal interleaver:
122/// position `p` goes to position `j = bit_reverse_8(i)` where `i` walks
123/// from 0 counting only those where `j < 162`.
124pub fn interleave(bits: &mut [u8; 162]) {
125    let mut tmp = [0u8; 162];
126    let mut p = 0u8;
127    let mut i = 0u8;
128    while p < 162 {
129        let j = bit_reverse_8(i) as usize;
130        if j < 162 {
131            tmp[j] = bits[p as usize];
132            p += 1;
133        }
134        i = i.wrapping_add(1);
135    }
136    bits.copy_from_slice(&tmp);
137}
138
139/// Inverse interleaver — walks the same (p, j) sequence but gathers
140/// `tmp[p] = bits[j]`. `deinterleave(interleave(x)) == x`.
141pub fn deinterleave(bits: &mut [u8; 162]) {
142    let mut tmp = [0u8; 162];
143    let mut p = 0u8;
144    let mut i = 0u8;
145    while p < 162 {
146        let j = bit_reverse_8(i) as usize;
147        if j < 162 {
148            tmp[p as usize] = bits[j];
149            p += 1;
150        }
151        i = i.wrapping_add(1);
152    }
153    bits.copy_from_slice(&tmp);
154}
155
156// ─────────────────────────────────────────────────────────────────────────
157// TX pipeline: message → 162 channel symbols
158// ─────────────────────────────────────────────────────────────────────────
159
160/// Encode a 50-bit WSPR message into 162 4-FSK channel symbols (values 0..3).
161/// Mirrors WSJT-X `get_wspr_channel_symbols`: FEC encode → interleave →
162/// combine with sync vector as `symbol = 2·data_bit + sync_bit`.
163pub fn encode_channel_symbols(info_bits: &[u8; 50]) -> [u8; 162] {
164    use crate::core::FecCodec;
165
166    let codec = ConvFano;
167    let mut cw = vec![0u8; ConvFano::N];
168    codec.encode(info_bits, &mut cw);
169
170    // Interleave.
171    let mut channel_bits = [0u8; 162];
172    channel_bits.copy_from_slice(&cw);
173    interleave(&mut channel_bits);
174
175    // Combine with sync vector: symbol = 2·data + sync.
176    let mut symbols = [0u8; 162];
177    for i in 0..162 {
178        symbols[i] = 2 * channel_bits[i] + WSPR_SYNC_VECTOR[i];
179    }
180    symbols
181}
182
183/// RX counterpart: given 162 per-symbol LLRs for the **data bit** (MSB of
184/// the 4-FSK tone) already de-interleaved, run Fano and unpack.
185///
186/// Real decoders would first demodulate the 4-FSK tones, extract the
187/// data-bit LLR per symbol, then de-interleave. This function is the
188/// last mile of that pipeline and the entry point we exercise in tests.
189pub fn decode_from_deinterleaved_llrs(data_llrs: &[f32; 162]) -> Option<crate::msg::WsprMessage> {
190    use crate::core::{FecCodec, FecOpts, MessageCodec};
191
192    let codec = ConvFano;
193    let fec = codec.decode_soft(data_llrs, &FecOpts::default())?;
194    let msg = Wspr50Message;
195    let mut info_bits = [0u8; 50];
196    info_bits.copy_from_slice(&fec.info);
197    msg.unpack(&info_bits, &crate::core::DecodeContext::default())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::core::FecCodec;
204
205    #[test]
206    fn wspr_trait_surface() {
207        assert_eq!(<Wspr as ModulationParams>::NTONES, 4);
208        assert_eq!(<Wspr as ModulationParams>::NSPS, 8192);
209        assert_eq!(<Wspr as FrameLayout>::N_SYMBOLS, 162);
210        assert_eq!(<Wspr as FrameLayout>::T_SLOT_S, 120.0);
211        match <Wspr as FrameLayout>::SYNC_MODE {
212            SyncMode::Interleaved {
213                sync_bit_pos,
214                vector,
215            } => {
216                assert_eq!(sync_bit_pos, 0);
217                assert_eq!(vector.len(), 162);
218            }
219            SyncMode::Block(_) => panic!("WSPR must use interleaved sync"),
220        }
221        assert_eq!(<<Wspr as Protocol>::Fec as FecCodec>::N, 162);
222        assert_eq!(<<Wspr as Protocol>::Fec as FecCodec>::K, 50);
223    }
224
225    #[test]
226    fn interleave_is_involution() {
227        let mut bits = [0u8; 162];
228        for i in 0..162 {
229            bits[i] = ((i * 7 + 13) & 1) as u8;
230        }
231        let original = bits;
232        interleave(&mut bits);
233        assert_ne!(bits, original, "interleave must permute");
234        let once = bits;
235        // deinterleave(interleave(x)) == x
236        deinterleave(&mut bits);
237        assert_eq!(bits, original);
238        // Also: interleave(interleave(x)) restores bits touched by the
239        // fixed-point permutation but need not be identity overall —
240        // check that calling interleave twice is NOT identity in general.
241        let mut bits2 = once;
242        interleave(&mut bits2);
243        // Not an involution on arbitrary input — this is what forces us
244        // to keep deinterleave separate.
245        let _ = bits2;
246    }
247
248    #[test]
249    fn roundtrip_k1abc_fn42_37() {
250        use crate::msg::{WsprMessage, wspr::pack_type1};
251
252        let info_bits = pack_type1("K1ABC", "FN42", 37).expect("pack");
253        let symbols = encode_channel_symbols(&info_bits);
254
255        // Verify the sync vector LSB is reproduced.
256        for i in 0..162 {
257            assert_eq!(
258                symbols[i] & 1,
259                WSPR_SYNC_VECTOR[i],
260                "sync LSB mismatch at {}",
261                i
262            );
263            assert!(symbols[i] < 4);
264        }
265
266        // Recover the data bits (MSB of each 4-FSK tone).
267        let mut data_bits = [0u8; 162];
268        for i in 0..162 {
269            data_bits[i] = (symbols[i] >> 1) & 1;
270        }
271        // De-interleave back to the Fano-input order.
272        deinterleave(&mut data_bits);
273        // Build perfect LLRs (+8 for bit 0, -8 for bit 1).
274        let mut llrs = [0f32; 162];
275        for i in 0..162 {
276            llrs[i] = if data_bits[i] == 0 { 8.0 } else { -8.0 };
277        }
278        let msg = decode_from_deinterleaved_llrs(&llrs).expect("decode");
279        assert_eq!(
280            msg,
281            WsprMessage::Type1 {
282                callsign: "K1ABC".into(),
283                grid: "FN42".into(),
284                power_dbm: 37,
285            }
286        );
287    }
288}