Skip to main content

mfsk_core/ft8/
decode.rs

1/// High-level FT8 decode pipeline.
2///
3/// Chains: downsample → coarse_sync → fine_sync → LLR → BP decode
4#[cfg(feature = "parallel")]
5use rayon::prelude::*;
6
7pub use super::equalizer::EqMode;
8use super::{
9    downsample::{build_fft_cache, downsample},
10    equalizer,
11    ldpc::{
12        bp::{bp_decode, check_crc14},
13        osd::{osd_decode, osd_decode_deep, osd_decode_deep4},
14    },
15    llr::{compute_llr, compute_snr_db, symbol_spectra, sync_quality},
16    message::pack28,
17    params::{BP_MAX_ITER, LDPC_N},
18    subtract::subtract_signal_weighted,
19    sync::{SyncCandidate, coarse_sync, fine_sync_power_split, refine_candidate},
20    wave_gen::message_to_tones,
21};
22
23// ────────────────────────────────────────────────────────────────────────────
24// Public types
25
26/// Opaque FFT cache produced by [`decode_frame_with_cache`] (Phase 1),
27/// consumed by [`decode_frame_subtract_with_known`] (Phase 2).
28pub type FftCache = Vec<num_complex::Complex<f32>>;
29
30/// Decoding depth: which LLR sets and passes to attempt.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum DecodeDepth {
33    /// Belief-propagation only, using nsym=1 metrics (fast).
34    Bp,
35    /// BP with all four metric variants (a, b, c, d).
36    BpAll,
37    /// BP (all four variants) then OSD order-1 fallback when BP fails.
38    BpAllOsd,
39}
40
41/// Decode strictness: controls false-positive vs sensitivity trade-off.
42///
43/// Adjusts OSD hard_errors thresholds, AP hard_errors thresholds, and
44/// the minimum sync score required for OSD fallback entry.
45/// Actual numeric values are placeholders pending benchmark calibration.
46#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub enum DecodeStrictness {
48    /// Minimise false positives — tighter thresholds.
49    Strict,
50    /// Balanced (current behaviour).
51    #[default]
52    Normal,
53    /// Maximum sensitivity — looser thresholds, more FP risk.
54    Deep,
55}
56
57impl DecodeStrictness {
58    /// Maximum hard_errors for non-AP OSD decode.
59    ///
60    /// Calibrated from real WAV bench (2026-04-07):
61    ///   - BP pass 0: errors 0–8 (all clean)
62    ///   - OSD real signals: errors 19, 23
63    ///   - OSD false positive: errors 29
64    pub fn osd_max_errors(self, osd_depth: u8) -> u32 {
65        match (self, osd_depth) {
66            // Strict: high-confidence OSD (e19 real → keep, e23+ → cut)
67            (Self::Strict, 3) => 20,
68            (Self::Strict, 4) => 24,
69            (Self::Strict, _) => 22,
70            // Normal: catches errors=29 FP, keeps errors=23 real decode
71            (Self::Normal, 3) => 26,
72            (Self::Normal, 4) => 30,
73            (Self::Normal, _) => 29,
74            // Deep: previous defaults — maximum sensitivity
75            (Self::Deep, 3) => 30,
76            (Self::Deep, 4) => 36,
77            (Self::Deep, _) => 40,
78        }
79    }
80
81    /// Maximum hard_errors for AP decode passes.
82    ///
83    /// Calibrated from synthetic QSO scenario:
84    ///   - REPORT AP at -18 dB: 15% FP rate with old thresholds (30/36)
85    pub fn ap_max_errors(self, locked_bits: usize) -> u32 {
86        match (self, locked_bits >= 55) {
87            (Self::Strict, true) => 20,
88            (Self::Strict, false) => 24,
89            (Self::Normal, true) => 25,
90            (Self::Normal, false) => 30,
91            // Deep: previous defaults
92            (Self::Deep, true) => 30,
93            (Self::Deep, false) => 36,
94        }
95    }
96
97    /// Minimum coarse-sync score to enter OSD fallback.
98    pub fn osd_score_min(self) -> f32 {
99        match self {
100            Self::Strict => 3.0,
101            Self::Normal => 2.2,
102            Self::Deep => 2.0,
103        }
104    }
105}
106
107/// One successfully decoded FT8 message.
108#[derive(Debug, Clone)]
109pub struct DecodeResult {
110    /// Decoded message: 77 bits packed as bytes (LSB first within each byte).
111    pub message77: [u8; 77],
112    /// Carrier frequency (Hz)
113    pub freq_hz: f32,
114    /// Time offset from the nominal 0.5 s start (seconds)
115    pub dt_sec: f32,
116    /// Number of hard-decision errors in the final codeword
117    pub hard_errors: u32,
118    /// Sync quality score from fine sync
119    pub sync_score: f32,
120    /// Which LLR variant decoded successfully (0=llra, 1=llrb, 2=llrc, 3=llrd)
121    pub pass: u8,
122    /// Coefficient of variation of the three Costas-array powers (score_a/b/c).
123    ///
124    /// Near zero for a stable channel; elevated (> 0.3) when QSB or strong
125    /// time-varying fading is present.  Used by `decode_frame_subtract` to
126    /// apply partial subtraction gain when the amplitude estimate is unreliable.
127    pub sync_cv: f32,
128    /// WSJT-X compatible SNR estimate (dB).
129    ///
130    /// Computed from decoded tone power vs. opposite-tone noise power:
131    /// `10 log10(xsig/xnoi − 1) − 27 dB`.  Floor is −24 dB (same as WSJT-X).
132    pub snr_db: f32,
133}
134
135// ────────────────────────────────────────────────────────────────────────────
136// A Priori (AP) hint for sniper-mode decode
137
138/// A Priori information for assisted decoding.
139///
140/// Known callsigns are converted to 28-bit packed tokens and injected as
141/// high-confidence LLR values into the BP decoder, effectively reducing the
142/// number of unknown bits.  This lowers the decode threshold by several dB.
143///
144/// # Example
145/// ```
146/// use mfsk_core::ft8::decode::ApHint;
147/// // "I'm calling 3Y0Z, expecting a reply to my CQ"
148/// let ap = ApHint::new().with_call1("CQ").with_call2("3Y0Z");
149/// ```
150#[derive(Debug, Clone, Default)]
151pub struct ApHint {
152    /// Known first callsign (e.g. "CQ", "JA1ABC").
153    /// Locks message bits 0–28 (28-bit call + 1-bit flag).
154    pub call1: Option<String>,
155    /// Known second callsign (e.g. "3Y0Z").
156    /// Locks message bits 29–57 (28-bit call + 1-bit flag).
157    pub call2: Option<String>,
158    /// Known grid locator (e.g. "JD34").
159    /// Locks message bits 58 (ir=0) + 59–73 (15-bit grid).
160    pub grid: Option<String>,
161    /// Known report/response token (e.g. "RRR", "RR73", "73").
162    /// Locks bits 58–73 (ir flag + 15-bit report field) for full 77-bit lock.
163    pub report: Option<String>,
164}
165
166impl ApHint {
167    /// Construct an empty `ApHint` — no fields pre-filled.
168    pub fn new() -> Self {
169        Self::default()
170    }
171    /// Pre-fill the first callsign (`CALL1` in a standard FT8 message).
172    pub fn with_call1(mut self, call: &str) -> Self {
173        self.call1 = Some(call.to_string());
174        self
175    }
176    /// Pre-fill the second callsign (`CALL2`).
177    pub fn with_call2(mut self, call: &str) -> Self {
178        self.call2 = Some(call.to_string());
179        self
180    }
181    /// Pre-fill the 4-character Maidenhead grid.
182    pub fn with_grid(mut self, grid: &str) -> Self {
183        self.grid = Some(grid.to_string());
184        self
185    }
186    /// Pre-fill the signal report (e.g. `"-12"`, `"R+05"`, `"73"`).
187    pub fn with_report(mut self, rpt: &str) -> Self {
188        self.report = Some(rpt.to_string());
189        self
190    }
191
192    /// Returns true if any a-priori information is available.
193    pub fn has_info(&self) -> bool {
194        self.call1.is_some() || self.call2.is_some()
195    }
196
197    /// Build AP mask and LLR overrides for the 174-bit LDPC codeword.
198    ///
199    /// `apmag` — magnitude to assign to known bits (typically `max(|llr|) * 1.01`).
200    ///
201    /// Returns `(ap_mask, ap_llr)` where:
202    /// - `ap_mask[i] = true` means bit `i` is a-priori known (frozen in BP)
203    /// - `ap_llr[i]` is the LLR override for known bits (±apmag)
204    pub fn build_ap(&self, apmag: f32) -> ([bool; LDPC_N], [f32; LDPC_N]) {
205        let mut mask = [false; LDPC_N];
206        let mut ap_llr = [0.0f32; LDPC_N];
207
208        // Helper: write 28-bit packed call + 1-bit flag (=0) into AP arrays
209        let mut set_call_bits = |call: &str, start: usize| {
210            if let Some(n28) = pack28(call) {
211                // Write 28 bits of the packed callsign
212                for i in 0..28 {
213                    let bit = ((n28 >> (27 - i)) & 1) as u8;
214                    mask[start + i] = true;
215                    ap_llr[start + i] = if bit == 1 { apmag } else { -apmag };
216                }
217                // Flag bit (ipa/ipb) = 0 for standard calls
218                mask[start + 28] = true;
219                ap_llr[start + 28] = -apmag; // bit=0 → negative LLR
220            }
221        };
222
223        if let Some(ref c1) = self.call1 {
224            set_call_bits(c1, 0); // bits 0–28
225        }
226        if let Some(ref c2) = self.call2 {
227            set_call_bits(c2, 29); // bits 29–57
228        }
229
230        // Lock grid field (bits 58–73: ir=0 + 15-bit grid) if known
231        if let Some(ref grid) = self.grid
232            && let Some(igrid) = super::message::pack_grid4(grid)
233        {
234            mask[58] = true;
235            ap_llr[58] = -apmag; // ir=0
236            for i in 0..15 {
237                let bit = ((igrid >> (14 - i)) & 1) as u8;
238                mask[59 + i] = true;
239                ap_llr[59 + i] = if bit == 1 { apmag } else { -apmag };
240            }
241        }
242
243        // Lock report field (bits 58–73) for known responses: RRR, RR73, 73
244        if let Some(ref rpt) = self.report {
245            // Type 1: igrid values for special responses
246            let igrid_val: Option<u32> = match rpt.as_str() {
247                "RRR" => Some(32_400 + 2),
248                "RR73" => Some(32_400 + 3),
249                "73" => Some(32_400 + 4),
250                _ => None,
251            };
252            if let Some(igrid) = igrid_val {
253                mask[58] = true;
254                ap_llr[58] = -apmag; // ir=0
255                for i in 0..15 {
256                    let bit = ((igrid >> (14 - i)) & 1) as u8;
257                    mask[59 + i] = true;
258                    ap_llr[59 + i] = if bit == 1 { apmag } else { -apmag };
259                }
260            }
261        }
262
263        // Lock message type i3=1 (Type 1 standard) if any call is known
264        if self.has_info() {
265            // bits 74-76 = i3 = 001 (Type 1)
266            mask[74] = true;
267            ap_llr[74] = -apmag; // bit=0
268            mask[75] = true;
269            ap_llr[75] = -apmag; // bit=0
270            mask[76] = true;
271            ap_llr[76] = apmag; // bit=1
272        }
273
274        (mask, ap_llr)
275    }
276}
277
278// ────────────────────────────────────────────────────────────────────────────
279// Main decode entry point
280
281/// Decode one 15-second FT8 audio frame.
282///
283/// # Arguments
284/// * `audio`      — 16-bit PCM samples at 12 000 Hz, length ≤ 180 000
285/// * `freq_min`   — lower edge of search band (Hz)
286/// * `freq_max`   — upper edge of search band (Hz)
287/// * `sync_min`   — minimum coarse-sync score (typical: 1.0–2.0)
288/// * `freq_hint`  — optional preferred frequency; matching candidates are tried first
289/// * `depth`      — decoding depth
290/// * `max_cand`   — maximum number of sync candidates to evaluate
291///
292/// Returns all successfully decoded messages (deduplicated by `message77`).
293pub fn decode_frame(
294    audio: &[i16],
295    freq_min: f32,
296    freq_max: f32,
297    sync_min: f32,
298    freq_hint: Option<f32>,
299    depth: DecodeDepth,
300    max_cand: usize,
301) -> Vec<DecodeResult> {
302    decode_frame_inner(
303        audio,
304        freq_min,
305        freq_max,
306        sync_min,
307        freq_hint,
308        depth,
309        max_cand,
310        DecodeStrictness::Normal,
311        &[],
312        EqMode::Off,
313        None,
314    )
315    .0
316}
317
318/// Like [`decode_frame`] but also returns the 192k-point FFT cache for
319/// reuse by a subsequent [`decode_frame_subtract_with_known`] call.
320///
321/// This is the Phase 1 entry point for pipelined decoding.
322pub fn decode_frame_with_cache(
323    audio: &[i16],
324    freq_min: f32,
325    freq_max: f32,
326    sync_min: f32,
327    freq_hint: Option<f32>,
328    depth: DecodeDepth,
329    max_cand: usize,
330) -> (Vec<DecodeResult>, FftCache) {
331    decode_frame_inner(
332        audio,
333        freq_min,
334        freq_max,
335        sync_min,
336        freq_hint,
337        depth,
338        max_cand,
339        DecodeStrictness::Normal,
340        &[],
341        EqMode::Off,
342        None,
343    )
344}
345
346// ────────────────────────────────────────────────────────────────────────────
347// Per-candidate decode helper (used by both inner and sniper paths)
348
349/// Decode a single sync candidate: downsample → refine → LLR → BP/OSD.
350///
351/// `fft_cache` — pre-computed 192 000-point forward FFT of the full audio
352///   (from [`build_fft_cache`]), shared read-only across parallel calls.
353/// `known`     — messages decoded in earlier subtract passes; prevents OSD
354///   from running on frequencies that already have a result.
355///
356/// Returns `Some(DecodeResult)` on the first successful decode, `None` if the
357/// candidate yields no valid message.
358fn process_candidate(
359    cand: &SyncCandidate,
360    audio: &[i16],
361    fft_cache: &[num_complex::Complex<f32>],
362    depth: DecodeDepth,
363    strictness: DecodeStrictness,
364    known: &[DecodeResult],
365    eq_mode: EqMode,
366    ap_hint: Option<&ApHint>,
367) -> Option<DecodeResult> {
368    let osd_score_min = strictness.osd_score_min();
369    let (cd0, _) = downsample(audio, cand.freq_hz, Some(fft_cache));
370
371    let refined = refine_candidate(&cd0, cand, 10);
372    let i_start = ((refined.dt_sec + 0.5) * 200.0).round() as usize;
373    let cs_raw = symbol_spectra(&cd0, i_start);
374    let nsync = sync_quality(&cs_raw);
375    if nsync <= 6 {
376        return None;
377    }
378
379    let sync_cv = {
380        let (sa, sb, sc) = fine_sync_power_split(&cd0, i_start);
381        let mean = (sa + sb + sc) / 3.0;
382        if mean > f32::EPSILON {
383            let sq = (sa - mean).powi(2) + (sb - mean).powi(2) + (sc - mean).powi(2);
384            sq.sqrt() / mean
385        } else {
386            0.0
387        }
388    };
389
390    let try_decode = |cs: &[[num_complex::Complex<f32>; 8]; 79],
391                      use_ap: bool|
392     -> Option<DecodeResult> {
393        let llr_set = compute_llr(cs);
394
395        let llr_variants: &[(&[f32; LDPC_N], u8)] = match depth {
396            DecodeDepth::Bp => &[(&llr_set.llra, 0)],
397            DecodeDepth::BpAll | DecodeDepth::BpAllOsd => &[
398                (&llr_set.llra, 0),
399                (&llr_set.llrb, 1),
400                (&llr_set.llrc, 2),
401                (&llr_set.llrd, 3),
402            ],
403        };
404
405        // BP decode (no AP)
406        for &(llr, pass_id) in llr_variants {
407            if let Some(bp) = bp_decode(llr, None, BP_MAX_ITER, Some(check_crc14)) {
408                let itone = message_to_tones(&bp.message77);
409                let snr_db = compute_snr_db(cs, &itone);
410                return Some(DecodeResult {
411                    message77: bp.message77,
412                    freq_hz: cand.freq_hz,
413                    dt_sec: refined.dt_sec,
414                    hard_errors: bp.hard_errors,
415                    sync_score: refined.score,
416                    pass: pass_id,
417                    sync_cv,
418                    snr_db,
419                });
420            }
421        }
422
423        // OSD fallback
424        if depth == DecodeDepth::BpAllOsd && nsync >= 12 && cand.score >= osd_score_min {
425            let freq_dup = known
426                .iter()
427                .any(|r| (r.freq_hz - cand.freq_hz).abs() < 20.0);
428            if !freq_dup {
429                let osd_depth: u8 = if nsync >= 18 { 3 } else { 2 };
430                for llr_osd in [&llr_set.llra, &llr_set.llrb, &llr_set.llrc, &llr_set.llrd] {
431                    let osd_result = if osd_depth == 3 {
432                        osd_decode_deep(llr_osd, 3, Some(check_crc14))
433                    } else {
434                        osd_decode(llr_osd)
435                    };
436                    if let Some(osd) = osd_result {
437                        let max_errors = strictness.osd_max_errors(osd_depth);
438                        if osd.hard_errors >= max_errors {
439                            continue;
440                        }
441                        let itone = message_to_tones(&osd.message77);
442                        let snr_db = compute_snr_db(cs, &itone);
443                        return Some(DecodeResult {
444                            message77: osd.message77,
445                            freq_hz: cand.freq_hz,
446                            dt_sec: refined.dt_sec,
447                            hard_errors: osd.hard_errors,
448                            sync_score: refined.score,
449                            pass: if osd_depth == 3 { 5 } else { 4 },
450                            sync_cv,
451                            snr_db,
452                        });
453                    }
454                }
455                // OSD depth-4 (Top-K pruning): same sync gate as depth-3.
456                // k4_limit=30 → C(30,4)=27,405 extra candidates at depth-3 cost.
457                if nsync >= 18 {
458                    for llr_osd in [&llr_set.llra, &llr_set.llrb, &llr_set.llrc, &llr_set.llrd] {
459                        if let Some(osd4) = osd_decode_deep4(llr_osd, 30, Some(check_crc14)) {
460                            let max_errors = strictness.osd_max_errors(4);
461                            if osd4.hard_errors >= max_errors {
462                                continue;
463                            }
464                            let itone = message_to_tones(&osd4.message77);
465                            let snr_db = compute_snr_db(cs, &itone);
466                            return Some(DecodeResult {
467                                message77: osd4.message77,
468                                freq_hz: cand.freq_hz,
469                                dt_sec: refined.dt_sec,
470                                hard_errors: osd4.hard_errors,
471                                sync_score: refined.score,
472                                pass: 13,
473                                sync_cv,
474                                snr_db,
475                            });
476                        }
477                    }
478                }
479            }
480        }
481
482        // Multi-pass AP (similar to WSJT-X a1..a7)
483        // Try progressively deeper AP configurations:
484        //   pass 6: call2 only (original)
485        //   pass 7: CQ + call2 (locks ~61 bits for CQ messages)
486        //   pass 8: call1 + call2 (locks ~61 bits for directed messages)
487        if use_ap
488            && let Some(ap) = ap_hint
489            && ap.has_info()
490        {
491            let apmag = llr_set.llra.iter().map(|v| v.abs()).fold(0.0f32, f32::max) * 1.01;
492
493            // Build multiple AP configurations (deepest first)
494            let mut ap_passes: Vec<(ApHint, u8)> = Vec::new();
495
496            // Pass 9/10/11: full 77-bit lock (call1+call2+response)
497            // Equivalent to WSJT-X a4/a5/a6 for QSO in progress
498            if ap.call1.is_some() && ap.call2.is_some() {
499                for (rpt, pid) in [("RRR", 9u8), ("RR73", 10), ("73", 11)] {
500                    let ap_full = ap.clone().with_report(rpt);
501                    ap_passes.push((ap_full, pid));
502                }
503            }
504
505            // Pass 7: CQ + call2 (expect "CQ DXCALL GRID", ~61 bits)
506            if ap.call2.is_some() && ap.call1.is_none() {
507                let ap7 = ap.clone().with_call1("CQ");
508                ap_passes.push((ap7, 7));
509            }
510
511            // Pass 8: mycall + call2 (~61 bits)
512            if ap.call1.is_some() && ap.call2.is_some() {
513                ap_passes.push((ap.clone(), 8));
514            }
515
516            // Pass 6: call2 only (~33 bits, fallback)
517            ap_passes.push((ap.clone(), 6));
518
519            for (ap_cfg, pass_id) in &ap_passes {
520                let (ap_mask, ap_llr_override) = ap_cfg.build_ap(apmag);
521                let locked_bits = ap_mask.iter().filter(|&&m| m).count();
522                let max_errors: u32 = strictness.ap_max_errors(locked_bits);
523
524                for &(base_llr, _) in llr_variants {
525                    let mut llr_ap = *base_llr;
526                    for i in 0..LDPC_N {
527                        if ap_mask[i] {
528                            llr_ap[i] = ap_llr_override[i];
529                        }
530                    }
531
532                    // Helper: validate AP decode result
533                    let check_result =
534                        |msg77: [u8; 77], hard_errors: u32| -> Option<DecodeResult> {
535                            if hard_errors >= max_errors {
536                                return None;
537                            }
538                            let text = super::message::unpack77(&msg77)?;
539                            if !super::message::is_plausible_message(&text) {
540                                return None;
541                            }
542                            // Verify AP-locked callsigns appear in decoded message
543                            let upper = text.to_uppercase();
544                            if let Some(ref c1) = ap_cfg.call1
545                                && !upper.contains(&c1.to_uppercase())
546                            {
547                                return None;
548                            }
549                            if let Some(ref c2) = ap_cfg.call2
550                                && !upper.contains(&c2.to_uppercase())
551                            {
552                                return None;
553                            }
554                            let itone = message_to_tones(&msg77);
555                            let snr_db = compute_snr_db(cs, &itone);
556                            Some(DecodeResult {
557                                message77: msg77,
558                                freq_hz: cand.freq_hz,
559                                dt_sec: refined.dt_sec,
560                                hard_errors,
561                                sync_score: refined.score,
562                                pass: *pass_id,
563                                sync_cv,
564                                snr_db,
565                            })
566                        };
567
568                    // AP + BP
569                    if let Some(bp) =
570                        bp_decode(&llr_ap, Some(&ap_mask), BP_MAX_ITER, Some(check_crc14))
571                        && let Some(r) = check_result(bp.message77, bp.hard_errors)
572                    {
573                        return Some(r);
574                    }
575                    // AP + OSD fallback
576                    if depth == DecodeDepth::BpAllOsd
577                        && let Some(osd) = osd_decode_deep(&llr_ap, 2, Some(check_crc14))
578                        && let Some(r) = check_result(osd.message77, osd.hard_errors)
579                    {
580                        return Some(r);
581                    }
582                }
583            }
584        }
585
586        None
587    };
588
589    match eq_mode {
590        EqMode::Off => try_decode(&cs_raw, true),
591        EqMode::Local => {
592            let mut cs_eq = cs_raw.clone();
593            equalizer::equalize_local(&mut cs_eq);
594            try_decode(&cs_eq, true)
595        }
596        EqMode::Adaptive => {
597            let mut cs_eq = cs_raw.clone();
598            equalizer::equalize_local(&mut cs_eq);
599            if let Some(r) = try_decode(&cs_eq, true) {
600                return Some(r);
601            }
602            try_decode(&cs_raw, true)
603        }
604    }
605}
606
607// ────────────────────────────────────────────────────────────────────────────
608
609/// Inner decode loop shared by [`decode_frame`] and [`decode_frame_subtract`].
610///
611/// `known`           — messages already decoded in earlier passes (skipped).
612/// `precomputed_fft` — optional pre-computed 192k-point FFT cache; when `None`
613///                     the cache is built internally from `audio`.
614///
615/// Returns `(decoded_results, fft_cache)`.  Callers that don't need the cache
616/// can simply ignore the second element.
617fn decode_frame_inner(
618    audio: &[i16],
619    freq_min: f32,
620    freq_max: f32,
621    sync_min: f32,
622    freq_hint: Option<f32>,
623    depth: DecodeDepth,
624    max_cand: usize,
625    strictness: DecodeStrictness,
626    known: &[DecodeResult],
627    eq_mode: EqMode,
628    precomputed_fft: Option<&[num_complex::Complex<f32>]>,
629) -> (Vec<DecodeResult>, Vec<num_complex::Complex<f32>>) {
630    let candidates = coarse_sync(audio, freq_min, freq_max, sync_min, freq_hint, max_cand);
631    if candidates.is_empty() {
632        let fft_cache = match precomputed_fft {
633            Some(c) => c.to_vec(),
634            None => build_fft_cache(audio),
635        };
636        return (Vec::new(), fft_cache);
637    }
638
639    let fft_cache = match precomputed_fft {
640        Some(c) => c.to_vec(),
641        None => build_fft_cache(audio),
642    };
643
644    #[cfg(feature = "parallel")]
645    let raw: Vec<DecodeResult> = candidates
646        .par_iter()
647        .filter_map(|cand| {
648            process_candidate(
649                cand, audio, &fft_cache, depth, strictness, known, eq_mode, None,
650            )
651        })
652        .collect();
653    #[cfg(not(feature = "parallel"))]
654    let raw: Vec<DecodeResult> = candidates
655        .iter()
656        .filter_map(|cand| {
657            process_candidate(
658                cand, audio, &fft_cache, depth, strictness, known, eq_mode, None,
659            )
660        })
661        .collect();
662
663    // Deduplicate: preserve first occurrence; drop messages already in `known`.
664    let mut results: Vec<DecodeResult> = Vec::new();
665    for r in raw {
666        if !known.iter().any(|k| k.message77 == r.message77)
667            && !results.iter().any(|x| x.message77 == r.message77)
668        {
669            results.push(r);
670        }
671    }
672    (results, fft_cache)
673}
674
675// ────────────────────────────────────────────────────────────────────────────
676// Multi-pass decode with signal subtraction
677
678/// Decode a 15-second FT8 frame using successive signal subtraction.
679///
680/// Runs three decode passes with decreasing sync thresholds.  After each
681/// pass every newly decoded signal is subtracted from the residual audio,
682/// revealing weaker signals that were previously hidden.
683///
684/// | Pass | sync_min factor | OSD score min | Purpose |
685/// |------|----------------|---------------|---------|
686/// | 1    | 1.0×           | 2.5           | Strong signals (BP + OSD) |
687/// | 2    | 0.75×          | 2.5           | Medium signals on residual |
688/// | 3    | 0.5×           | 2.0           | Weak / spurious signals |
689///
690/// Pass 3 uses a lower OSD score threshold (`2.0` vs the normal `2.5`) to
691/// also subtract signals that are marginal but have valid CRC — even if they
692/// were questionable in the original audio, subtracting their reconstructed
693/// waveform from the already-cleaned residual does more good than harm.
694pub fn decode_frame_subtract(
695    audio: &[i16],
696    freq_min: f32,
697    freq_max: f32,
698    sync_min: f32,
699    freq_hint: Option<f32>,
700    depth: DecodeDepth,
701    max_cand: usize,
702    strictness: DecodeStrictness,
703) -> Vec<DecodeResult> {
704    let mut residual = audio.to_vec();
705    let mut all_results: Vec<DecodeResult> = Vec::new();
706
707    let passes: &[f32] = &[1.0, 0.75, 0.5];
708
709    for &factor in passes {
710        let (new, _) = decode_frame_inner(
711            &residual,
712            freq_min,
713            freq_max,
714            sync_min * factor,
715            freq_hint,
716            depth,
717            max_cand,
718            strictness,
719            &all_results,
720            EqMode::Off,
721            None,
722        );
723
724        for r in &new {
725            // QSB gate: if Costas-array power CV > 0.3 the channel is time-varying
726            // and the amplitude estimate is less accurate — use half gain to avoid
727            // over-subtraction artefacts that would corrupt later passes.
728            let sub_gain = if r.sync_cv > 0.3 { 0.5 } else { 1.0 };
729            subtract_signal_weighted(&mut residual, r, sub_gain);
730        }
731        all_results.extend(new);
732    }
733
734    all_results
735}
736
737/// Phase-2 subtract decode: accepts Phase-1 results as `known` and an
738/// optional pre-computed FFT cache for the first pass.
739///
740/// Internally runs three subtract passes (sync_min × 1.0 / 0.75 / 0.5).
741/// The first pass reuses `precomputed_fft` when available; subsequent
742/// passes recompute the FFT from the post-subtraction residual.
743///
744/// Returns only **newly** decoded messages (those not in `known`).
745pub fn decode_frame_subtract_with_known(
746    audio: &[i16],
747    freq_min: f32,
748    freq_max: f32,
749    sync_min: f32,
750    freq_hint: Option<f32>,
751    depth: DecodeDepth,
752    max_cand: usize,
753    strictness: DecodeStrictness,
754    known: &[DecodeResult],
755    precomputed_fft: Option<FftCache>,
756) -> Vec<DecodeResult> {
757    let mut residual = audio.to_vec();
758    let mut all_results: Vec<DecodeResult> = known.to_vec();
759    let known_count = known.len();
760
761    let passes: &[f32] = &[1.0, 0.75, 0.5];
762
763    for (i, &factor) in passes.iter().enumerate() {
764        // Reuse the pre-computed FFT cache only for the first pass
765        // (the audio hasn't been modified yet).
766        let fft = if i == 0 {
767            precomputed_fft.as_deref()
768        } else {
769            None
770        };
771
772        let (new, _) = decode_frame_inner(
773            &residual,
774            freq_min,
775            freq_max,
776            sync_min * factor,
777            freq_hint,
778            depth,
779            max_cand,
780            strictness,
781            &all_results,
782            EqMode::Off,
783            fft,
784        );
785
786        for r in &new {
787            let sub_gain = if r.sync_cv > 0.3 { 0.5 } else { 1.0 };
788            subtract_signal_weighted(&mut residual, r, sub_gain);
789        }
790        all_results.extend(new);
791    }
792
793    // Return only the newly decoded messages (exclude `known`).
794    all_results.split_off(known_count)
795}
796
797// ────────────────────────────────────────────────────────────────────────────
798// Convenience: sniper-mode decode (single target frequency, narrow band)
799
800/// Sniper-mode decode: search only within ±250 Hz of `target_freq`.
801///
802/// Intended for use after a 500 Hz hardware BPF.  The search band is
803/// narrowed to `target_freq ± 250 Hz` and `sync_min` is lowered to 0.8
804/// because the BPF removes strong adjacent signals that would otherwise
805/// raise the noise floor.
806///
807/// `sync_cv` (Costas-array power coefficient of variation) is computed for
808/// each decoded result and can be used downstream as a channel-quality
809/// indicator for the Phase 3 adaptive equaliser.
810pub fn decode_sniper(
811    audio: &[i16],
812    target_freq: f32,
813    depth: DecodeDepth,
814    max_cand: usize,
815) -> Vec<DecodeResult> {
816    decode_sniper_eq(audio, target_freq, depth, max_cand, EqMode::Off)
817}
818
819/// Sniper-mode decode with configurable equalizer.
820///
821/// Same as [`decode_sniper`] but allows enabling the adaptive equalizer
822/// to correct BPF edge distortion.
823pub fn decode_sniper_eq(
824    audio: &[i16],
825    target_freq: f32,
826    depth: DecodeDepth,
827    max_cand: usize,
828    eq_mode: EqMode,
829) -> Vec<DecodeResult> {
830    decode_sniper_ap(audio, target_freq, depth, max_cand, eq_mode, None)
831}
832
833/// Sniper-mode decode with equalizer and A Priori hints.
834///
835/// The full sniper pipeline: hardware BPF simulation + adaptive EQ +
836/// AP-assisted BP decode.  When `ap_hint` provides known callsigns,
837/// the BP decoder locks those bits at high confidence, effectively
838/// reducing the number of unknown bits and lowering the decode threshold.
839///
840/// # Example
841/// ```ignore
842/// let ap = ApHint::new().with_call1("CQ").with_call2("3Y0Z");
843/// let results = decode_sniper_ap(
844///     &audio, 1000.0, DecodeDepth::BpAllOsd, 20,
845///     EqMode::Adaptive, Some(&ap),
846/// );
847/// ```
848pub fn decode_sniper_ap(
849    audio: &[i16],
850    target_freq: f32,
851    depth: DecodeDepth,
852    max_cand: usize,
853    eq_mode: EqMode,
854    ap_hint: Option<&ApHint>,
855) -> Vec<DecodeResult> {
856    decode_sniper_inner(audio, target_freq, depth, max_cand, eq_mode, ap_hint, 0.8)
857}
858
859/// Sniper-mode decode with in-band Successive Interference Cancellation (SIC).
860///
861/// Pass 1 decodes all signals in ±250 Hz.  Any decoded signal more than 25 Hz
862/// away from `target_freq` is subtracted from the audio.  Pass 2 then
863/// re-decodes the residual with a relaxed sync threshold, recovering targets
864/// that were masked by in-band interferers.
865///
866/// This is particularly effective when 2–3 stronger stations reside within the
867/// 500 Hz BPF window alongside the target.  Falls back to a single-pass result
868/// when no interferers are found (zero extra cost).
869pub fn decode_sniper_sic(
870    audio: &[i16],
871    target_freq: f32,
872    depth: DecodeDepth,
873    max_cand: usize,
874    eq_mode: EqMode,
875    ap_hint: Option<&ApHint>,
876) -> Vec<DecodeResult> {
877    // Pass 1: decode everything in ±250 Hz at normal sync threshold.
878    let pass1 = decode_sniper_inner(audio, target_freq, depth, max_cand, eq_mode, ap_hint, 0.8);
879
880    // Subtract non-target signals (those > 25 Hz away from target_freq).
881    let mut residual: Vec<i16> = audio.to_vec();
882    let mut subtracted = false;
883    for r in &pass1 {
884        if (r.freq_hz - target_freq).abs() > 25.0 {
885            // QSB gate: partial subtraction for time-varying channels.
886            let gain = if r.sync_cv > 0.3 { 0.5 } else { 1.0 };
887            subtract_signal_weighted(&mut residual, r, gain);
888            subtracted = true;
889        }
890    }
891
892    if !subtracted {
893        return pass1;
894    }
895
896    // Pass 2: re-decode residual with relaxed sync_min to catch the target.
897    let pass2 = decode_sniper_inner(
898        &residual,
899        target_freq,
900        depth,
901        max_cand,
902        eq_mode,
903        ap_hint,
904        0.6,
905    );
906
907    // Merge, deduplicating by message77.
908    let mut results = pass1;
909    for r in pass2 {
910        if !results.iter().any(|x| x.message77 == r.message77) {
911            results.push(r);
912        }
913    }
914    results
915}
916
917fn decode_sniper_inner(
918    audio: &[i16],
919    target_freq: f32,
920    depth: DecodeDepth,
921    max_cand: usize,
922    eq_mode: EqMode,
923    ap_hint: Option<&ApHint>,
924    sync_min: f32,
925) -> Vec<DecodeResult> {
926    let freq_min = (target_freq - 250.0).max(100.0);
927    let freq_max = (target_freq + 250.0).min(5900.0);
928
929    let candidates = coarse_sync(
930        audio,
931        freq_min,
932        freq_max,
933        sync_min,
934        Some(target_freq),
935        max_cand,
936    );
937    if candidates.is_empty() {
938        return Vec::new();
939    }
940
941    let fft_cache = build_fft_cache(audio);
942
943    #[cfg(feature = "parallel")]
944    let raw: Vec<DecodeResult> = candidates
945        .par_iter()
946        .filter_map(|cand| {
947            process_candidate(
948                cand,
949                audio,
950                &fft_cache,
951                depth,
952                DecodeStrictness::Normal,
953                &[],
954                eq_mode,
955                ap_hint,
956            )
957        })
958        .collect();
959    #[cfg(not(feature = "parallel"))]
960    let raw: Vec<DecodeResult> = candidates
961        .iter()
962        .filter_map(|cand| {
963            process_candidate(
964                cand,
965                audio,
966                &fft_cache,
967                depth,
968                DecodeStrictness::Normal,
969                &[],
970                eq_mode,
971                ap_hint,
972            )
973        })
974        .collect();
975
976    let mut results: Vec<DecodeResult> = Vec::new();
977    for r in raw {
978        if !results.iter().any(|x| x.message77 == r.message77) {
979            results.push(r);
980        }
981    }
982    results
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    /// Silence produces no decoded messages and does not panic.
990    #[test]
991    fn silence_no_decode() {
992        let audio = vec![0i16; 15 * 12_000];
993        let results = decode_frame(&audio, 200.0, 2800.0, 1.0, None, DecodeDepth::Bp, 10);
994        assert!(results.is_empty(), "silence should decode nothing");
995    }
996
997    /// Sniper mode on silence also produces no decoded messages.
998    #[test]
999    fn sniper_silence_no_decode() {
1000        let audio = vec![0i16; 15 * 12_000];
1001        let results = decode_sniper(&audio, 1000.0, DecodeDepth::Bp, 10);
1002        assert!(results.is_empty());
1003    }
1004
1005    /// Verify DT accuracy: a signal placed at exactly dt=0 (0.5s into buffer)
1006    /// should decode with DT close to 0.
1007    #[test]
1008    fn dt_accuracy_at_nominal_start() {
1009        use super::super::message::pack77_type1;
1010        use super::super::wave_gen::{message_to_tones, tones_to_f32};
1011
1012        let msg = pack77_type1("CQ", "JA1ABC", "PM95").unwrap();
1013        let itone = message_to_tones(&msg);
1014        let pcm = tones_to_f32(&itone, 1000.0, 1.0);
1015
1016        let mut audio_f32 = vec![0.0f32; 180_000];
1017        let start = (0.5 * 12000.0) as usize; // 6000 samples
1018        for (i, &s) in pcm.iter().enumerate() {
1019            if start + i < audio_f32.len() {
1020                audio_f32[start + i] = s;
1021            }
1022        }
1023        let audio: Vec<i16> = audio_f32
1024            .iter()
1025            .map(|&s| (s * 20000.0).clamp(-32767.0, 32767.0) as i16)
1026            .collect();
1027
1028        let results = decode_frame(&audio, 100.0, 3000.0, 1.0, None, DecodeDepth::BpAllOsd, 200);
1029        assert!(!results.is_empty(), "should decode the signal");
1030        let dt = results[0].dt_sec;
1031        eprintln!("DT = {dt:+.3} s (expected ≈ 0.0)");
1032        assert!(dt.abs() < 0.5, "DT={dt} is too far from 0");
1033    }
1034}