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}