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}