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}