1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
//! Spectrum baseline estimator — historical FT8 entry point.
//!
//! The polynomial-fit implementation moved to
//! [`crate::engine::baseline`] in 2026-05 (slice 1 of issue #18) so that
//! FT4's `coarse_sync` can normalise its candidate spectrum the same
//! way WSJT-X does (`ft4_baseline.f90`). The algorithm is identical
//! across FT8 / FT4 / FST4 in WSJT-X — keeping a single Rust port
//! avoids drift.
//!
//! This module is now a thin re-export plus the FT8-specific
//! [`avg_spectrum`] helper that consumes the embedded
//! [`crate::ft8::decode_block::Spectrogram`].
pub use cratefit_baseline;
/// Compute the average linear power per FFT bin from a `Spectrogram`.
/// `out.len()` must equal `spec.n_freq`. FT8-specific because it
/// targets the embedded `decode_block::Spectrogram` layout.
/// WSJT-X `get_spectrum_baseline.f90`-faithful power spectrum, purpose-
/// built for [`fit_baseline`]'s noise-floor estimate — **not**
/// [`crate::ft8::decode_block::compute_spectrogram`]'s rectangular
/// window, which is deliberately tuned so a signal's own tones don't
/// leak onto each other (see that function's doc comment) but is
/// undefended against far sidelobes bleeding in from *other*,
/// frequency-distant signals. On an isolated single signal that's
/// harmless; on a busy/crowded band (`qso3_busy.wav`, many concurrent
/// FT8 stations) it systematically inflates the "lower envelope"
/// `fit_baseline`'s percentile step reads the noise floor from,
/// pushing `xbase` up and `xsnr2` down.
///
/// WSJT-X never reuses `sync8.f90`'s own rectangular-window spectrum
/// for this — `get_spectrum_baseline.f90` builds a dedicated one with
/// a 4-term Nuttall window (`a0=0.3635819, a1=-0.4891775,
/// a2=0.1365995, a3=-0.0106411`) at 50% frame overlap, specifically
/// for its much lower far-sidelobe leakage. This is that pipeline:
/// full `NFFT_SPEC`-length frames (no zero-pad — unlike
/// `compute_spectrogram`'s NSPS-active-plus-zero-tail), `NFFT_SPEC/2`
/// hop. The window normalisation (`window/sum(window)*NSPS*2/300.0`,
/// `NSPS*2 == NFFT_SPEC`) folds in the same `1/300` linear pre-scale
/// `compute_spectrogram` applies separately, so raw `i16` samples feed
/// in directly below with no extra scale factor.
///
/// **Not averaged across frames.** `get_spectrum_baseline.f90`'s
/// `savg=savg+s(1:NH1,j)` inner loop is a *raw sum* over all `NF≈93`
/// frames — its own "Average spectrum" comment is misleading, there is
/// no `/NF` anywhere in the real subroutine. A first version of this
/// port added that missing-looking division, which is wrong: it made
/// every bin read `10*log10(93) ≈ 19.7 dB` too low (verified against a
/// real `jt9` run's own `SNRAUDIT_PROBE` instrumentation on a clean,
/// isolated synthetic signal, 2026-08-10 — real `sbase` was ~67.6 dB
/// flat across the band; the averaged version read ~49 dB, the
/// unaveraged one ~68-69 dB, within ~1-2 dB of ground truth).
///
/// **Must be paired with a matching `xsig`** computed from the WSJT-X
/// `cd0`/per-symbol-FFT pipeline (`fill_symbol_spectra`,
/// `ft8b.f90:154-161`), *not* from `compute_spectrogram`'s rectangular
/// spectrum — the `3e6`/`-27dB` calibration in `recompute_snr_xsnr2`
/// is fit to that specific pipeline pair, and the two mismatches don't
/// cancel when only one side is corrected (verified 2026-08-10).