mfsk-core 0.8.1

Pure-Rust WSJT-family decoders + synthesisers (FT8 FT4 FST4 WSPR JT9 JT65 Q65) behind a zero-cost Protocol trait. Host (rustfft) or no_std embedded (ESP32-S3, RP2350, Cortex-M) via a pluggable FFT backend; fixed-point hot path for FPU-less MCUs. Ships with embedded-poc/m5stack-s3-app, a working M5StickS3 FT8 controller (LCD UI, BLE CI-V to IC-705, acoustic mic, QSO FSM) decoding real on-air signals in ~1.2 s post-SlotEnd on Xtensa LX7.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! MSK144 joint (CFO, symbol-timing) search via matched-filter
//! correlation.
//!
//! Ported from WSJT-X `msk144_freq_search.f90` + `msk144sync.f90`.
//! Given a multi-frame analytic-signal buffer at (or near) a candidate
//! center frequency `fc`, searches a small residual-CFO grid and, for
//! each trial: mixes the buffer to baseband, coherently sums the
//! frames selected by `navmask`, and correlates the two embedded 8-bit
//! sync words simultaneously against every one of the 864 possible
//! symbol-timing offsets. A circular shift by `ish` samples over one
//! `NSPM`-periodic buffer is the same as slicing a period-repeated
//! (doubled) buffer at offset `ish`, which is how WSJT-X avoids an
//! explicit per-shift rotation. The CFO trial with the largest
//! correlation peak wins.
//!
//! WSJT-X's OpenMP frequency-range partitioning across up to 4 threads
//! (`msk144sync.f90:59-86`) is a pure max-reduction over independent
//! CFO trials — this port runs the whole range in one pass, which is
//! numerically identical (no thread-dependent variation to
//! replicate).

use alloc::vec;
use alloc::vec::Vec;
use num_complex::Complex32;
#[cfg(not(feature = "std"))]
use num_traits::Float;

use crate::engine::dsp::msk::{NSPM, sync_waveform};

/// Frequency-shift (NCO mixdown) an analytic-signal buffer by `f0_hz`,
/// at the fixed MSK144 sample rate of 12 kHz. Matches WSJT-X
/// `tweak1.f90`: the phase accumulator's first output sample already
/// carries one step of rotation (`w = w*wstep` runs *before* the
/// first multiply in the Fortran loop, so `cb(1) = wstep^1 * ca(1)`,
/// not `wstep^0`) — ported literally (`out[0] = wstep^1 * input[0]`)
/// rather than "simplified" to the more obvious `exp(i*dphi*k)`
/// starting at `k=0`. Only the per-frame *coherent averaging* and
/// *sync correlation* consume the result, both self-consistent under
/// either convention, but literal fidelity keeps this cross-checkable
/// against WSJT-X test vectors later.
pub fn tweak1(input: &[Complex32], f0_hz: f32) -> Vec<Complex32> {
    let dphi = 2.0 * core::f32::consts::PI * f0_hz / 12_000.0;
    let wstep = Complex32::new(dphi.cos(), dphi.sin());
    let mut out = Vec::with_capacity(input.len());
    let mut w = Complex32::new(1.0, 0.0);
    for &x in input {
        w *= wstep;
        out.push(w * x);
    }
    out
}

/// Result of [`msk144_freq_search`]: the coherently-averaged,
/// frequency-corrected 864-sample frame at the best-scoring CFO
/// trial, plus the full correlation-magnitude array (unscaled `|cc|`,
/// matching WSJT-X's `xccs` — see `msk144_freq_search.f90:39-46`)
/// used to peak-pick sync candidates.
#[derive(Clone)]
pub struct FreqSearchResult {
    /// Coherently-averaged 864-sample complex frame, mixed to the
    /// winning trial frequency. Length always [`NSPM`].
    pub frame: Vec<Complex32>,
    /// Correlation magnitude (unscaled) at each of the 864 candidate
    /// symbol-timing shifts, for the winning trial. Length always
    /// [`NSPM`].
    pub xcc: Vec<f32>,
    /// Best correlation-peak metric found across the CFO grid
    /// (normalized by pulse energy and averaged-frame count; compare
    /// against the WSJT-X detection threshold of 1.3).
    pub xmax: f32,
    /// Winning frequency estimate (Hz): `fc + ferr`.
    pub fest: f32,
}

/// Joint (CFO, symbol-timing) matched-filter search.
///
/// `cdat` is a `nframes * NSPM`-sample analytic-signal buffer (still
/// at its native passband frequency, *not* yet mixed to baseband).
/// `fc` is a candidate center frequency (Hz); the search tries every
/// `ferr = ifr * delf` for `ifr` in `if1..=if2` and returns the trial
/// that maximizes the sync-word correlation peak. `navmask[i]`
/// selects which of the `nframes` frames to coherently sum before
/// correlating.
///
/// Ported from `msk144_freq_search.f90`.
pub fn msk144_freq_search(
    cdat: &[Complex32],
    fc: f32,
    if1: i32,
    if2: i32,
    delf: f32,
    nframes: usize,
    navmask: &[bool],
) -> FreqSearchResult {
    assert_eq!(
        cdat.len(),
        nframes * NSPM,
        "cdat must hold exactly nframes*NSPM samples"
    );
    assert_eq!(navmask.len(), nframes, "navmask must have nframes entries");

    let navg = navmask.iter().filter(|&&b| b).count().max(1);
    let fac = 1.0 / (48.0 * (navg as f32).sqrt());
    let cb = sync_waveform();

    let mut best_xmax = 0.0f32;
    let mut best_fest = fc;
    let mut best_frame = vec![Complex32::new(0.0, 0.0); NSPM];
    let mut best_xcc = vec![0.0f32; NSPM];

    for ifr in if1..=if2 {
        let ferr = ifr as f32 * delf;
        let mixed = tweak1(cdat, -(fc + ferr));

        // Coherent sum of the selected frames.
        let mut c = vec![Complex32::new(0.0, 0.0); NSPM];
        for i in 0..nframes {
            if navmask[i] {
                let ib = i * NSPM;
                for k in 0..NSPM {
                    c[k] += mixed[ib + k];
                }
            }
        }

        // Doubled buffer: a circular shift by `ish` samples over the
        // NSPM-periodic `c` is a plain slice of `[c, c]` at offset `ish`.
        let mut ct2 = vec![Complex32::new(0.0, 0.0); 2 * NSPM];
        ct2[0..NSPM].copy_from_slice(&c);
        ct2[NSPM..2 * NSPM].copy_from_slice(&c);

        // Correlate both embedded sync words (42-sample template, 336
        // samples = 56 symbols apart) simultaneously at every shift.
        let mut xcc = vec![0.0f32; NSPM];
        let mut xmax_this = 0.0f32;
        for ish in 0..NSPM {
            let mut cc = Complex32::new(0.0, 0.0);
            for k in 0..42 {
                let x = ct2[ish + k] + ct2[336 + ish + k];
                // Fortran DOT_PRODUCT(a, b) = sum(conjg(a) * b).
                cc += x.conj() * cb[k];
            }
            let mag = cc.norm();
            xcc[ish] = mag;
            if mag > xmax_this {
                xmax_this = mag;
            }
        }

        let xb = xmax_this * fac;
        if xb > best_xmax {
            best_xmax = xb;
            best_fest = fc + ferr;
            best_frame = c;
            best_xcc = xcc;
        }
    }

    FreqSearchResult {
        frame: best_frame,
        xcc: best_xcc,
        xmax: best_xmax,
        fest: best_fest,
    }
}

/// One sync-timing candidate: a peak in the correlation-magnitude
/// array.
#[derive(Clone, Copy, Debug)]
pub struct SyncPeak {
    /// 0-based sample shift within the coherently-averaged frame where
    /// the sync-word correlation peaks (i.e. rotating the frame left
    /// by this many samples aligns bit 1 to sample 0 — see
    /// [`rotate_to_shift`]).
    pub shift: usize,
    /// Correlation magnitude (unscaled `|cc|`) at that peak.
    pub amplitude: f32,
}

/// Result of a full sync search: [`msk144_freq_search`] plus
/// peak-picking and the WSJT-X detection threshold.
#[derive(Clone)]
pub struct SyncResult {
    /// Coherently-averaged, frequency-corrected frame at the
    /// winning CFO trial. Length always [`NSPM`].
    pub frame: Vec<Complex32>,
    pub xmax: f32,
    pub fest: f32,
    /// Up to `npeaks` strongest, mutually-exclusive (>=8 samples
    /// apart — see below) correlation peaks, strongest first.
    pub peaks: Vec<SyncPeak>,
    /// `xmax >= 1.3` (`msk144sync.f90:98`).
    pub success: bool,
}

/// Full sync search: [`msk144_freq_search`] over `ferr` in
/// `[-ntol, +ntol]` (step `delf`), then pick the `npeaks` strongest
/// correlation peaks. Ported from `msk144sync.f90`.
///
/// Peak exclusion window: WSJT-X zeroes out `xcc(max(0,ic2-7)
/// .. min(NSPM-1,ic2+7))` where `ic2` is `MAXLOC`'s **1-based
/// position** (`ic2 = shift + 1` for our 0-based `shift`) — so in
/// 0-based terms the excluded window is `[shift-6, shift+8]`, not the
/// symmetric `[shift-7, shift+7]` the comment suggests. This is very
/// likely an accidental artifact of Fortran's 1-based `MAXLOC` mixed
/// with the array's explicit `0:NSPM-1` declaration, not deliberate
/// design — but this port replicates it exactly rather than
/// "fixing" it, matching this crate's convention of literal fidelity
/// to WSJT-X's actual (if quirky) behaviour.
pub fn msk144_sync(
    cdat: &[Complex32],
    nframes: usize,
    ntol: f32,
    delf: f32,
    navmask: &[bool],
    npeaks: usize,
    fc: f32,
) -> SyncResult {
    let n_half = (ntol / delf).round() as i32;
    let search = msk144_freq_search(cdat, fc, -n_half, n_half, delf, nframes, navmask);

    let mut xcc = search.xcc.clone();
    let mut peaks = Vec::with_capacity(npeaks);
    for _ in 0..npeaks {
        let (idx, &amp) = xcc
            .iter()
            .enumerate()
            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
            .expect("xcc is non-empty");
        peaks.push(SyncPeak {
            shift: idx,
            amplitude: amp,
        });
        let lo = idx.saturating_sub(6);
        let hi = (idx + 8).min(NSPM - 1);
        for v in &mut xcc[lo..=hi] {
            *v = 0.0;
        }
    }

    let success = search.xmax >= 1.3;

    SyncResult {
        frame: search.frame,
        xmax: search.xmax,
        fest: search.fest,
        peaks,
        success,
    }
}

/// Circularly rotate a coherently-averaged frame so the sample at
/// `shift` (as reported by [`SyncPeak::shift`]) lands at index 0 —
/// i.e. `aligned[k] = frame[(k + shift) % NSPM]`. Produces a frame
/// ready for [`crate::engine::dsp::msk::matched_filter_softbits`].
pub fn rotate_to_shift(frame: &[Complex32], shift: usize) -> Vec<Complex32> {
    assert_eq!(frame.len(), NSPM, "frame must hold exactly NSPM samples");
    let mut out = vec![Complex32::new(0.0, 0.0); NSPM];
    for k in 0..NSPM {
        out[k] = frame[(k + shift) % NSPM];
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::FecCodec;
    use crate::engine::dsp::msk::{build_bitseq, matched_filter_softbits, synth_frame};
    use crate::fec::Ldpc128_90;

    fn build_info_with_crc(pattern: impl Fn(usize) -> u8) -> [u8; 90] {
        let mut info = [0u8; 90];
        for i in 0..77 {
            info[i] = pattern(i);
        }
        let mut bytes = [0u8; 12];
        for (i, &b) in info[..77].iter().enumerate() {
            let byte_idx = i / 8;
            let bit_pos = 7 - (i % 8);
            bytes[byte_idx] |= (b & 1) << bit_pos;
        }
        let crc = crate::fec::ldpc_128_90::crc13(&bytes);
        for i in 0..13 {
            info[77 + i] = ((crc >> (12 - i)) & 1) as u8;
        }
        info
    }

    fn synth_clean_frame(pattern: impl Fn(usize) -> u8) -> ([u8; 90], Vec<Complex32>) {
        let info = build_info_with_crc(pattern);
        let mut codeword = [0u8; 128];
        Ldpc128_90.encode(&info, &mut codeword);
        let bitseq = build_bitseq(&codeword);
        let frame = synth_frame(&bitseq);
        (info, frame.to_vec())
    }

    fn upconvert(frame: &[Complex32], f0_hz: f32) -> Vec<Complex32> {
        tweak1(frame, f0_hz)
    }

    #[test]
    fn tweak1_matches_direct_nco() {
        let input: Vec<Complex32> = (0..10).map(|_| Complex32::new(1.0, 0.0)).collect();
        let out = tweak1(&input, 100.0);
        let dphi = 2.0 * core::f32::consts::PI * 100.0 / 12_000.0;
        for (k, &o) in out.iter().enumerate() {
            let expected_phase = dphi * (k as f32 + 1.0);
            let expected = Complex32::new(expected_phase.cos(), expected_phase.sin());
            assert!((o - expected).norm() < 1e-4, "sample {k}");
        }
    }

    /// Zero-CFO, zero-timing-offset sync: the frame is already
    /// exactly aligned and at exactly `fc`, so the search should find
    /// `shift == 0`, `fest` within one `delf` bin of `fc`, and
    /// `xmax >= 1.3`.
    #[test]
    fn sync_finds_aligned_burst_at_known_frequency() {
        let (_, frame) = synth_clean_frame(|i| ((i * 7 + 3) & 1) as u8);
        let fc_true = 1500.0f32;
        let cdat = upconvert(&frame, fc_true);

        let navmask = [true];
        let result = msk144_sync(&cdat, 1, 8.0, 2.0, &navmask, 2, fc_true);

        assert!(result.success, "xmax = {}", result.xmax);
        assert!(
            (result.fest - fc_true).abs() <= 2.0,
            "fest = {}, expected near {fc_true}",
            result.fest
        );
        assert_eq!(result.peaks[0].shift, 0, "expected zero timing offset");
    }

    /// A residual CFO error (search must find it within the trial
    /// grid) combined with a circular timing offset: confirms both
    /// axes of the joint search are recovered together, and that
    /// [`rotate_to_shift`] + [`matched_filter_softbits`] on the
    /// recovered (frame, shift) decodes the original message.
    #[test]
    fn sync_recovers_freq_and_timing_offset_end_to_end() {
        let (info, frame) = synth_clean_frame(|i| ((i * 3 + 5) & 1) as u8);

        let true_shift = 200usize;
        let mut rotated = vec![Complex32::new(0.0, 0.0); NSPM];
        for k in 0..NSPM {
            rotated[k] = frame[(k + NSPM - true_shift) % NSPM];
        }

        let fc_nominal = 1200.0f32;
        let true_cfo_err = 3.4f32; // within +/-8 Hz search tolerance
        let cdat = upconvert(&rotated, fc_nominal + true_cfo_err);

        let navmask = [true];
        let result = msk144_sync(&cdat, 1, 8.0, 2.0, &navmask, 2, fc_nominal);

        assert!(result.success, "xmax = {}", result.xmax);
        assert_eq!(
            result.peaks[0].shift, true_shift,
            "expected recovered shift to match the injected timing offset"
        );

        let aligned = rotate_to_shift(&result.frame, result.peaks[0].shift);
        let softbits = matched_filter_softbits(
            aligned
                .as_slice()
                .try_into()
                .expect("aligned frame has NSPM samples"),
        );

        let mut codeword = [0u8; 128];
        Ldpc128_90.encode(&info, &mut codeword);
        let bitseq = build_bitseq(&codeword);
        for i in 0..144 {
            assert_eq!(
                softbits[i] > 0.0,
                bitseq[i] == 1,
                "softbit {i} sign mismatch"
            );
        }
    }

    /// Pure noise (no embedded sync word) must not spuriously exceed
    /// the WSJT-X detection threshold.
    #[test]
    fn sync_rejects_pure_noise() {
        let noise: Vec<Complex32> = (0..NSPM)
            .map(|k| {
                let n1 = ((k as f32 * 12.9898).sin() * 43_758.547).fract();
                let n2 = ((k as f32 * 78.233).sin() * 12345.678).fract();
                Complex32::new(0.3 * (n1 - 0.5), 0.3 * (n2 - 0.5))
            })
            .collect();

        let navmask = [true];
        let result = msk144_sync(&noise, 1, 8.0, 2.0, &navmask, 2, 1500.0);
        assert!(
            !result.success,
            "noise should not exceed threshold: xmax = {}",
            result.xmax
        );
    }
}