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