Skip to main content

mfsk_core/core/
sync.rs

1//! Protocol-agnostic synchronisation primitives.
2//!
3//! Coarse sync searches the 2D (freq, lag) plane for candidate frames by
4//! correlating per-symbol power spectra against the protocol's sync-block
5//! tone patterns. Fine sync refines the timing on the downsampled complex
6//! baseband signal.
7//!
8//! Ported from WSJT-X `sync8.f90` + `sync8d.f90`; generalised so the same
9//! code handles FT8 (3 identical Costas-7 blocks) and FT4 (4 different
10//! Costas-4 blocks) by iterating over `FrameLayout::SYNC_BLOCKS`.
11
12use super::Protocol;
13use num_complex::Complex;
14use rustfft::FftPlanner;
15use std::f32::consts::PI;
16
17/// One synchronisation candidate.
18#[derive(Debug, Clone)]
19pub struct SyncCandidate {
20    /// Carrier (tone-0) frequency in Hz.
21    pub freq_hz: f32,
22    /// Time offset relative to the protocol's nominal TX_START_OFFSET_S, in seconds.
23    pub dt_sec: f32,
24    /// Normalised sync score (larger = better).
25    pub score: f32,
26}
27
28// ──────────────────────────────────────────────────────────────────────────
29// Per-protocol DSP parameter bundle (all derived from P at compile time)
30// ──────────────────────────────────────────────────────────────────────────
31
32/// Static-per-protocol parameters used throughout sync. Derived from the
33/// `Protocol` trait; inlined by the compiler.
34#[derive(Copy, Clone, Debug)]
35pub struct SyncDims {
36    /// Per-symbol FFT length (= NSPS · NFFT_PER_SYMBOL_FACTOR).
37    pub nfft1: usize,
38    /// Coarse-sync time-step in samples (= NSPS / NSTEP_PER_SYMBOL).
39    pub nstep: usize,
40    /// Samples per symbol at 12 kHz.
41    pub nsps: usize,
42    /// Steps per symbol (= NSTEP_PER_SYMBOL).
43    pub nssy: usize,
44    /// Frequency oversampling factor (= NFFT_PER_SYMBOL_FACTOR).
45    pub nfos: usize,
46    /// Slot length in samples at 12 kHz.
47    pub nmax: usize,
48    /// Time-spectra column count = NMAX / NSTEP - 3.
49    pub nhsym: usize,
50    /// Positive-frequency bins NFFT1 / 2.
51    pub nh1: usize,
52    /// Frequency resolution (Hz/bin) = 12_000 / NFFT1.
53    pub df: f32,
54    /// Time step (s) between coarse-sync columns.
55    pub tstep: f32,
56    /// Symbol offset (in NSTEP steps) of the nominal frame start.
57    /// = round(TX_START_OFFSET_S / tstep).
58    pub jstrt: i32,
59    /// Max search lag in NSTEP steps (±2.5 s by convention).
60    pub jz: i32,
61    /// Downsampled samples per symbol (= NSPS / NDOWN).
62    pub ds_spb: usize,
63    /// Downsampled sample rate (Hz) = 12_000 / NDOWN.
64    pub ds_rate: f32,
65}
66
67impl SyncDims {
68    #[inline]
69    pub const fn of<P: Protocol>() -> Self {
70        let nsps = P::NSPS as usize;
71        let nstep = nsps / P::NSTEP_PER_SYMBOL as usize;
72        let nfft1 = nsps * P::NFFT_PER_SYMBOL_FACTOR as usize;
73        let nmax = (P::T_SLOT_S * 12_000.0) as usize;
74        let ndown = P::NDOWN as usize;
75        Self {
76            nfft1,
77            nstep,
78            nsps,
79            nssy: P::NSTEP_PER_SYMBOL as usize,
80            nfos: P::NFFT_PER_SYMBOL_FACTOR as usize,
81            nmax,
82            nhsym: nmax / nstep - 3,
83            nh1: nfft1 / 2,
84            df: 12_000.0 / nfft1 as f32,
85            tstep: nstep as f32 / 12_000.0,
86            jstrt: (P::TX_START_OFFSET_S / (nstep as f32 / 12_000.0)) as i32,
87            jz: (2.5 / (nstep as f32 / 12_000.0)) as i32,
88            ds_spb: nsps / ndown,
89            ds_rate: 12_000.0 / ndown as f32,
90        }
91    }
92}
93
94// ──────────────────────────────────────────────────────────────────────────
95// Coarse sync
96// ──────────────────────────────────────────────────────────────────────────
97
98/// Flat (n_freq × n_time) spectrogram stored row-major by frequency.
99pub struct Spectrogram {
100    pub n_freq: usize,
101    pub n_time: usize,
102    data: Vec<f32>,
103}
104
105impl Spectrogram {
106    #[inline]
107    fn get(&self, freq: usize, time: usize) -> f32 {
108        self.data[freq * self.n_time + time]
109    }
110}
111
112/// Compute per-time-step power spectra from raw 12 kHz PCM.
113pub fn compute_spectra<P: Protocol>(audio: &[i16]) -> Spectrogram {
114    let d = SyncDims::of::<P>();
115    let fac = 1.0f32 / 300.0;
116    let mut planner = FftPlanner::<f32>::new();
117    let fft = planner.plan_fft_forward(d.nfft1);
118
119    let mut data = vec![0.0f32; d.nh1 * d.nhsym];
120    let mut buf = vec![Complex::new(0.0f32, 0.0); d.nfft1];
121
122    for j in 0..d.nhsym {
123        let ia = j * d.nstep;
124        for (k, c) in buf.iter_mut().enumerate() {
125            *c = if k < d.nsps {
126                let sample = if ia + k < audio.len() {
127                    audio[ia + k] as f32 * fac
128                } else {
129                    0.0
130                };
131                Complex::new(sample, 0.0)
132            } else {
133                Complex::new(0.0, 0.0)
134            };
135        }
136        fft.process(&mut buf);
137        for i in 0..d.nh1 {
138            data[i * d.nhsym + j] = buf[i].norm_sqr();
139        }
140    }
141
142    Spectrogram {
143        n_freq: d.nh1,
144        n_time: d.nhsym,
145        data,
146    }
147}
148
149/// Coarse sync: search audio for candidate frames.
150///
151/// Matches the sync shape of the protocol's `SYNC_BLOCKS`. Returns up to
152/// `max_cand` candidates, sorted by score (best first); if `freq_hint` is
153/// supplied, nearby candidates are promoted.
154pub fn coarse_sync<P: Protocol>(
155    audio: &[i16],
156    freq_min: f32,
157    freq_max: f32,
158    sync_min: f32,
159    freq_hint: Option<f32>,
160    max_cand: usize,
161) -> Vec<SyncCandidate> {
162    let d = SyncDims::of::<P>();
163    let s = compute_spectra::<P>(audio);
164    let ntones = P::NTONES as usize;
165    let pattern_len = P::SYNC_MODE.blocks()[0].pattern.len();
166
167    // Leave room for NTONES-1 tones above the candidate bin.
168    let ia = (freq_min / d.df).round() as usize;
169    let headroom = d.nfos * (ntones - 1) + 1;
170    let ib = ((freq_max / d.df).round() as usize).min(d.nh1.saturating_sub(headroom));
171    if ib < ia {
172        return Vec::new();
173    }
174
175    let n_freq = ib - ia + 1;
176    let n_lag = (2 * d.jz + 1) as usize;
177    let mut sync2d = vec![0.0f32; n_freq * n_lag];
178    let idx = |fi: usize, lag: i32| fi * n_lag + (lag + d.jz) as usize;
179
180    // Per-block (t_block_k, t0_block_k) accumulators. All-blocks score =
181    // Σ t/Σ t0_mean. Trailing-(N-1)-blocks score excludes block 0 (the
182    // FT8 heuristic that a late start can still sync on blocks 1..).
183    let num_blocks = P::SYNC_MODE.blocks().len();
184
185    for (fi, i) in (ia..=ib).enumerate() {
186        for lag in -d.jz..=d.jz {
187            // Accumulate per-sync-block correlation power.
188            let mut t_blocks = vec![0.0f32; num_blocks];
189            let mut t0_blocks = vec![0.0f32; num_blocks];
190
191            for (bk, block) in P::SYNC_MODE.blocks().iter().enumerate() {
192                let block_offset = d.nssy as i32 * block.start_symbol as i32;
193                for (n, &costas_n) in block.pattern.iter().enumerate() {
194                    let m = lag + d.jstrt + block_offset + (d.nssy * n) as i32;
195                    let tone_bin = i + d.nfos * costas_n as usize;
196                    if m >= 0 && (m as usize) < d.nhsym && tone_bin < d.nh1 {
197                        let m = m as usize;
198                        t_blocks[bk] += s.get(tone_bin, m);
199                        // Reference: sum over all NTONES tones at this time slot.
200                        t0_blocks[bk] += (0..ntones)
201                            .map(|k| s.get((i + d.nfos * k).min(d.nh1 - 1), m))
202                            .sum::<f32>();
203                    }
204                }
205            }
206
207            // All blocks combined.
208            let t_all: f32 = t_blocks.iter().sum();
209            let t0_all: f32 = t0_blocks.iter().sum();
210            // Reference excludes the signal energy: normalise by
211            // (t0_total - t_total) / (NTONES - 1).
212            let t0_ref = (t0_all - t_all) / (ntones as f32 - 1.0);
213            let sync_all = if t0_ref > 0.0 { t_all / t0_ref } else { 0.0 };
214
215            // Trailing N-1 blocks (drop the first), to tolerate an early-block loss.
216            let score = if num_blocks > 1 {
217                let t_tail: f32 = t_blocks[1..].iter().sum();
218                let t0_tail: f32 = t0_blocks[1..].iter().sum();
219                let t0_tail_ref = (t0_tail - t_tail) / (ntones as f32 - 1.0);
220                let sync_tail = if t0_tail_ref > 0.0 {
221                    t_tail / t0_tail_ref
222                } else {
223                    0.0
224                };
225                sync_all.max(sync_tail)
226            } else {
227                sync_all
228            };
229
230            sync2d[idx(fi, lag)] = score;
231        }
232    }
233
234    // Per-frequency peak detection — non-maximum suppression.
235    //
236    // The previous implementation kept one or two peaks per
237    // frequency bin (best in ±MLAG, plus best in ±jz when
238    // distinct). That works for slot-based protocols (FT8, FT4,
239    // WSPR, JT9/65, Q65) where one transmitter occupies one
240    // (freq, slot) cell. It silently drops most frames for
241    // chained-frame protocols where many frames sit at the same
242    // audio centre, separated only in time.
243    //
244    // The multi-peak NMS below is a strict superset: for slot-
245    // based protocols the second-best lag scores below sync_min
246    // after normalisation and is filtered out, recovering the
247    // previous behaviour. For chained-frame protocols every frame
248    // whose Costas peak survives MLAG-spacing NMS is emitted as
249    // its own candidate.
250    const MLAG: i32 = 10;
251
252    // First compute the per-bin best score (still needed for the
253    // 40-percentile noise-floor normalisation that anchors sync_min).
254    let mut red = vec![0.0f32; n_freq];
255    for fi in 0..n_freq {
256        red[fi] = (-d.jz..=d.jz)
257            .map(|lag| sync2d[idx(fi, lag)])
258            .fold(0.0f32, f32::max);
259    }
260
261    let pct = |xs: &[f32]| {
262        let mut sorted = xs.to_vec();
263        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
264        let pct_idx = (0.40 * n_freq as f32) as usize;
265        sorted[pct_idx.min(n_freq - 1)].max(f32::EPSILON)
266    };
267    let base = pct(&red);
268
269    let mut cands: Vec<SyncCandidate> = Vec::new();
270    for fi in 0..n_freq {
271        let i = ia + fi;
272        let freq_hz = i as f32 * d.df;
273
274        // Collect every (lag, normalised_score) pair above the
275        // threshold within the full ±jz lag range.
276        let mut peaks: Vec<(i32, f32)> = (-d.jz..=d.jz)
277            .filter_map(|lag| {
278                let raw = sync2d[idx(fi, lag)];
279                let norm = raw / base;
280                if norm.is_finite() && norm >= sync_min {
281                    Some((lag, norm))
282                } else {
283                    None
284                }
285            })
286            .collect();
287        peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
288
289        // Greedy NMS: each pick suppresses every neighbour within
290        // ±MLAG. Two genuine frames at the same audio centre are
291        // separated by ≥ frame airtime in lag steps, far more than
292        // MLAG, so they survive as distinct candidates.
293        let mut picked: Vec<i32> = Vec::new();
294        'outer: for (lag, score) in peaks {
295            for &pl in &picked {
296                if (lag - pl).abs() <= MLAG {
297                    continue 'outer;
298                }
299            }
300            picked.push(lag);
301            cands.push(SyncCandidate {
302                freq_hz,
303                dt_sec: (lag as f32 - 0.5) * d.tstep,
304                score,
305            });
306            // Cap per-bin candidates so a noisy bin can't crowd out
307            // the rest of the spectrum.
308            if picked.len() >= 8 {
309                break;
310            }
311        }
312    }
313    let _ = pattern_len; // currently unused; kept for future scoring weights
314
315    // De-duplicate: within 4 Hz and 40 ms, keep highest score.
316    for i in 1..cands.len() {
317        for j in 0..i {
318            let fdiff = (cands[i].freq_hz - cands[j].freq_hz).abs();
319            let tdiff = (cands[i].dt_sec - cands[j].dt_sec).abs();
320            if fdiff < 4.0 && tdiff < 0.04 {
321                if cands[i].score >= cands[j].score {
322                    cands[j].score = 0.0;
323                } else {
324                    cands[i].score = 0.0;
325                }
326            }
327        }
328    }
329    cands.retain(|c| c.score >= sync_min);
330
331    if let Some(fhint) = freq_hint {
332        cands.sort_by(|a, b| {
333            let a_near = (a.freq_hz - fhint).abs() <= 10.0;
334            let b_near = (b.freq_hz - fhint).abs() <= 10.0;
335            match (a_near, b_near) {
336                (true, false) => std::cmp::Ordering::Less,
337                (false, true) => std::cmp::Ordering::Greater,
338                _ => b.score.partial_cmp(&a.score).unwrap(),
339            }
340        });
341    } else {
342        cands.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
343    }
344
345    cands.truncate(max_cand);
346    cands
347}
348
349// ──────────────────────────────────────────────────────────────────────────
350// Fine sync (Costas correlation on downsampled complex baseband)
351// ──────────────────────────────────────────────────────────────────────────
352
353/// Build complex sinusoidal references (one per Costas tone) for a sync block.
354pub fn make_costas_ref(pattern: &[u8], ds_spb: usize) -> Vec<Vec<Complex<f32>>> {
355    pattern
356        .iter()
357        .map(|&tone| {
358            let dphi = 2.0 * PI * tone as f32 / ds_spb as f32;
359            let mut waves = vec![Complex::new(0.0f32, 0.0); ds_spb];
360            let mut phi = 0.0f32;
361            for w in waves.iter_mut() {
362                *w = Complex::new(phi.cos(), phi.sin());
363                phi = (phi + dphi) % (2.0 * PI);
364            }
365            waves
366        })
367        .collect()
368}
369
370/// Correlate a single Costas block starting at sample `array_start` in `cd0`.
371pub fn score_costas_block(
372    cd0: &[Complex<f32>],
373    csync: &[Vec<Complex<f32>>],
374    ds_spb: usize,
375    array_start: usize,
376) -> f32 {
377    let np2 = cd0.len();
378    csync
379        .iter()
380        .enumerate()
381        .map(|(k, ref_tone)| {
382            let start = array_start + k * ds_spb;
383            if start + ds_spb <= np2 {
384                cd0[start..start + ds_spb]
385                    .iter()
386                    .zip(ref_tone.iter())
387                    .map(|(&s, &r)| s * r.conj())
388                    .sum::<Complex<f32>>()
389                    .norm_sqr()
390            } else {
391                0.0
392            }
393        })
394        .sum()
395}
396
397/// Sum of Costas correlation powers across all sync blocks.
398pub fn fine_sync_power<P: Protocol>(cd0: &[Complex<f32>], i0: usize) -> f32 {
399    fine_sync_power_per_block::<P>(cd0, i0).into_iter().sum()
400}
401
402/// Per-block Costas correlation powers for diagnostics and the FT8 double-sync.
403pub fn fine_sync_power_per_block<P: Protocol>(cd0: &[Complex<f32>], i0: usize) -> Vec<f32> {
404    let d = SyncDims::of::<P>();
405    P::SYNC_MODE
406        .blocks()
407        .iter()
408        .map(|block| {
409            let csync = make_costas_ref(block.pattern, d.ds_spb);
410            let start = i0 + block.start_symbol as usize * d.ds_spb;
411            score_costas_block(cd0, &csync, d.ds_spb, start)
412        })
413        .collect()
414}
415
416/// Parabolic peak interpolation: returns `(subsample_offset in [-0.5, 0.5], interpolated_peak)`.
417pub fn parabolic_peak(y_neg: f32, y_0: f32, y_pos: f32) -> (f32, f32) {
418    let denom = y_neg - 2.0 * y_0 + y_pos;
419    if denom.abs() < f32::EPSILON {
420        return (0.0, y_0);
421    }
422    let offset = 0.5 * (y_neg - y_pos) / denom;
423    let peak = y_0 - 0.25 * (y_neg - y_pos) * offset;
424    (offset.clamp(-0.5, 0.5), peak)
425}
426
427/// Refine timing by scanning ±`search_steps` downsampled samples, then
428/// applying parabolic sub-sample interpolation around the peak for a
429/// fractional-sample refinement. The sub-sample shift is used to report a
430/// more accurate `dt_sec` but the returned score is the integer peak
431/// (interpolating correlation peaks biases small values downward).
432pub fn refine_candidate<P: Protocol>(
433    cd0: &[Complex<f32>],
434    candidate: &SyncCandidate,
435    search_steps: i32,
436) -> SyncCandidate {
437    let d = SyncDims::of::<P>();
438    let nominal_i0 = ((candidate.dt_sec + P::TX_START_OFFSET_S) * d.ds_rate).round() as i32;
439    let (best_i0, best_score) = (-search_steps..=search_steps)
440        .map(|delta| {
441            let i0 = (nominal_i0 + delta).max(0) as usize;
442            let score = fine_sync_power::<P>(cd0, i0);
443            (i0, score)
444        })
445        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
446        .unwrap_or((0, 0.0));
447
448    // Parabolic sub-sample refinement around the integer peak.
449    let frac = if best_i0 >= 1 {
450        let y_neg = fine_sync_power::<P>(cd0, best_i0 - 1);
451        let y_pos = fine_sync_power::<P>(cd0, best_i0 + 1);
452        let (f, _) = parabolic_peak(y_neg, best_score, y_pos);
453        f
454    } else {
455        0.0
456    };
457
458    SyncCandidate {
459        freq_hz: candidate.freq_hz,
460        dt_sec: (best_i0 as f32 + frac) / d.ds_rate - P::TX_START_OFFSET_S,
461        score: best_score,
462    }
463}
464
465/// Diagnostic result for double-sync refinement.
466#[derive(Debug, Clone)]
467pub struct FineSyncDetail {
468    pub candidate: SyncCandidate,
469    /// Per-block Costas correlation powers at the averaged timing.
470    pub per_block_scores: Vec<f32>,
471    /// Time drift across the first and last sync blocks (seconds).
472    /// Near zero for real signals, large for ghosts.
473    pub drift_dt_sec: f32,
474}
475
476/// Refine a candidate using independent first-block / last-block peak search.
477///
478/// Generalises the FT8 "double sync" idea to any number of sync blocks: scan
479/// the first block and the last block independently, compute a parabolic
480/// sub-sample refinement, and report their disagreement as `drift_dt_sec`.
481pub fn refine_candidate_double<P: Protocol>(
482    cd0: &[Complex<f32>],
483    candidate: &SyncCandidate,
484    search_steps: i32,
485) -> FineSyncDetail {
486    let d = SyncDims::of::<P>();
487    let blocks = P::SYNC_MODE.blocks();
488    let first = &blocks[0];
489    let last = &blocks[blocks.len() - 1];
490    let csync_first = make_costas_ref(first.pattern, d.ds_spb);
491    let csync_last = make_costas_ref(last.pattern, d.ds_spb);
492
493    let nominal_i0 = ((candidate.dt_sec + P::TX_START_OFFSET_S) * d.ds_rate).round() as i32;
494
495    let best_for = |pattern: &[u8], csync: &[Vec<Complex<f32>>], block_start: u32| {
496        let _ = pattern;
497        let (best_i0, _) = (-search_steps..=search_steps)
498            .map(|delta| {
499                let i0 = (nominal_i0 + delta).max(0) as usize;
500                let off = i0 + block_start as usize * d.ds_spb;
501                (i0, score_costas_block(cd0, csync, d.ds_spb, off))
502            })
503            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
504            .unwrap_or((nominal_i0.max(0) as usize, 0.0));
505        // Parabolic sub-sample
506        let frac = if best_i0 > 0 {
507            let off_neg = (best_i0 - 1) + block_start as usize * d.ds_spb;
508            let off_0 = best_i0 + block_start as usize * d.ds_spb;
509            let off_pos = (best_i0 + 1) + block_start as usize * d.ds_spb;
510            let (f, _) = parabolic_peak(
511                score_costas_block(cd0, csync, d.ds_spb, off_neg),
512                score_costas_block(cd0, csync, d.ds_spb, off_0),
513                score_costas_block(cd0, csync, d.ds_spb, off_pos),
514            );
515            f
516        } else {
517            0.0
518        };
519        (best_i0, frac)
520    };
521
522    let (best_i0_a, frac_a) = best_for(first.pattern, &csync_first, first.start_symbol);
523    let (best_i0_c, frac_c) = best_for(last.pattern, &csync_last, last.start_symbol);
524
525    let dt_a = best_i0_a as f32 / d.ds_rate + frac_a / d.ds_rate - P::TX_START_OFFSET_S;
526    let dt_c = best_i0_c as f32 / d.ds_rate + frac_c / d.ds_rate - P::TX_START_OFFSET_S;
527    let drift_dt_sec = dt_c - dt_a;
528
529    let avg_i0 = ((best_i0_a + best_i0_c) as f32 * 0.5).round() as usize;
530    let per_block_scores = fine_sync_power_per_block::<P>(cd0, avg_i0);
531    let total: f32 = per_block_scores.iter().sum();
532
533    FineSyncDetail {
534        candidate: SyncCandidate {
535            freq_hz: candidate.freq_hz,
536            dt_sec: (dt_a + dt_c) * 0.5,
537            score: total,
538        },
539        per_block_scores,
540        drift_dt_sec,
541    }
542}