Skip to main content

mfsk_core/ft8/
sync.rs

1//! FT8 synchronisation — thin wrapper over the protocol-generic
2//! [`crate::core::sync`] module.
3//!
4//! The public free functions preserved here match the pre-refactor
5//! signatures so `decode`, the bench harness, and any out-of-tree callers
6//! keep working unchanged. All heavy lifting lives in `mfsk-core::sync`.
7
8use alloc::vec::Vec;
9
10use super::Ft8;
11use num_complex::Complex;
12
13pub use crate::core::sync::{
14    FineSyncDetail as GenericFineSyncDetail, SyncCandidate, make_costas_ref, parabolic_peak,
15    score_costas_block,
16};
17
18/// Per-array FT8 fine-sync detail. Matches the pre-refactor field set (three
19/// fixed Costas arrays) by projecting the generic per-block scores.
20#[derive(Debug, Clone)]
21pub struct FineSyncDetail {
22    pub candidate: SyncCandidate,
23    pub score_a: f32,
24    pub score_b: f32,
25    pub score_c: f32,
26    pub drift_dt_sec: f32,
27}
28
29impl From<GenericFineSyncDetail> for FineSyncDetail {
30    fn from(g: GenericFineSyncDetail) -> Self {
31        let mut it = g.per_block_scores.into_iter();
32        Self {
33            candidate: g.candidate,
34            score_a: it.next().unwrap_or(0.0),
35            score_b: it.next().unwrap_or(0.0),
36            score_c: it.next().unwrap_or(0.0),
37            drift_dt_sec: g.drift_dt_sec,
38        }
39    }
40}
41
42#[inline]
43pub fn coarse_sync(
44    audio: &[i16],
45    freq_min: f32,
46    freq_max: f32,
47    sync_min: f32,
48    freq_hint: Option<f32>,
49    max_cand: usize,
50) -> Vec<SyncCandidate> {
51    crate::core::sync::coarse_sync::<Ft8>(audio, freq_min, freq_max, sync_min, freq_hint, max_cand)
52}
53
54#[inline]
55pub fn compute_spectra(audio: &[i16]) -> crate::core::sync::Spectrogram {
56    crate::core::sync::compute_spectra::<Ft8>(audio)
57}
58
59#[inline]
60pub fn fine_sync_power(cd0: &[Complex<f32>], i0: usize) -> f32 {
61    crate::core::sync::fine_sync_power::<Ft8>(cd0, i0)
62}
63
64/// Backwards-compatible tuple form: (array_1, array_2, array_3).
65#[inline]
66pub fn fine_sync_power_split(cd0: &[Complex<f32>], i0: usize) -> (f32, f32, f32) {
67    let scores = crate::core::sync::fine_sync_power_per_block::<Ft8>(cd0, i0);
68    (
69        scores.first().copied().unwrap_or(0.0),
70        scores.get(1).copied().unwrap_or(0.0),
71        scores.get(2).copied().unwrap_or(0.0),
72    )
73}
74
75#[inline]
76pub fn refine_candidate(
77    cd0: &[Complex<f32>],
78    candidate: &SyncCandidate,
79    search_steps: i32,
80) -> SyncCandidate {
81    crate::core::sync::refine_candidate::<Ft8>(cd0, candidate, search_steps)
82}
83
84#[inline]
85pub fn refine_candidate_double(
86    cd0: &[Complex<f32>],
87    candidate: &SyncCandidate,
88    search_steps: i32,
89) -> FineSyncDetail {
90    crate::core::sync::refine_candidate_double::<Ft8>(cd0, candidate, search_steps).into()
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn parabolic_peak_at_center() {
99        let (offset, _) = parabolic_peak(1.0, 2.0, 1.0);
100        assert!(offset.abs() < 1e-6);
101    }
102
103    #[test]
104    fn parabolic_peak_offset_right() {
105        let (offset, _) = parabolic_peak(0.5, 1.5, 2.0);
106        assert!(offset > 0.0);
107    }
108
109    #[test]
110    fn fine_sync_silence_is_zero() {
111        let cd0 = vec![Complex::new(0.0f32, 0.0); 3200];
112        let sync = fine_sync_power(&cd0, 0);
113        assert_eq!(sync, 0.0);
114    }
115
116    #[test]
117    fn coarse_sync_on_silence_returns_empty_or_low() {
118        let audio = vec![0i16; 15 * 12000];
119        let cands = coarse_sync(&audio, 200.0, 2800.0, 1.0, None, 100);
120        assert!(cands.len() <= 100);
121    }
122
123    #[test]
124    fn fine_sync_split_silence_is_zero() {
125        let cd0 = vec![Complex::new(0.0f32, 0.0); 3200];
126        let (sa, sb, sc) = fine_sync_power_split(&cd0, 0);
127        assert_eq!(sa, 0.0);
128        assert_eq!(sb, 0.0);
129        assert_eq!(sc, 0.0);
130    }
131
132    #[test]
133    fn fine_sync_split_sum_equals_total() {
134        let mut cd0 = vec![Complex::new(0.0f32, 0.0); 3200];
135        for (i, c) in cd0.iter_mut().enumerate() {
136            let t = i as f32 / 200.0;
137            c.re = (2.0 * std::f32::consts::PI * 50.0 * t).cos() * 100.0;
138        }
139        let total = fine_sync_power(&cd0, 100);
140        let (sa, sb, sc) = fine_sync_power_split(&cd0, 100);
141        let diff = (total - (sa + sb + sc)).abs();
142        assert!(diff < 1e-3);
143    }
144
145    #[test]
146    fn refine_candidate_double_silence_no_panic() {
147        let cd0 = vec![Complex::new(0.0f32, 0.0); 3200];
148        let cand = SyncCandidate {
149            freq_hz: 1000.0,
150            dt_sec: 0.0,
151            score: 1.0,
152        };
153        let detail = refine_candidate_double(&cd0, &cand, 5);
154        assert!(detail.drift_dt_sec.is_finite());
155    }
156}