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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
// SPDX-License-Identifier: GPL-3.0-or-later
//! Q65's real *displayed* SNR — port of `q65_snr` (`q65.f90:744`,
//! issue #255 §5).
//!
//! WSJT-X's Q65 decoder computes two entirely different "SNR"
//! quantities: an `esnodb`-based value inside `q65_dec_q3`/
//! `q65_dec_q012` (the value [`crate::fec::qra::fast_fading::
//! esnodb_fast_fading`] already faithfully ports, currently dead
//! code), and `q65_snr`'s own value — which the caller
//! (`q65_decode.f90:329,441`) always computes *after* decode and
//! passes to the display callback, unconditionally overwriting
//! whatever `esnodb`-based number the decode itself produced. Only
//! `q65_snr`'s value ever reaches a user. This module ports that one.
//!
//! ## The algorithm
//!
//! Unlike FT8/FT4/FST4's SNR formulas (signal power at the one
//! decoded tone vs. a baseline), `q65_snr` builds a **composite,
//! tone-aligned spectrum**: for every one of the 85 symbols (22 sync +
//! 63 data), take that symbol's full power spectrum and shift it so
//! the symbol's *actual transmitted tone* lands on a common reference
//! bin axis, then sum across all 85 symbols. A real signal produces a
//! sharp peak on that composite axis (every symbol's energy stacks
//! coherently at the same reference bin); noise doesn't. The baseline
//! is then a flat mean of two frequency guard bands flanking that
//! peak, and the reported quantity is the integrated excess power
//! (`sig_area`) over the region between them, converted to WSJT-X's
//! 2500 Hz reference bandwidth.
//!
//! Because this stacks the *full* spectrum (not just the tone bins),
//! it needs the raw audio and an FFT per symbol — [`extract_data_energies`]
//! and friends only keep the 64 tone-bin energies per data symbol,
//! not enough to rebuild the composite spectrum. Hence this module's
//! own extraction ([`q65_composite_spectrum`]) rather than reusing
//! that machinery.
//!
//! ## Why not `q65::search::Spectrogram`?
//!
//! WSJT-X's own `q65_snr` reuses the *same* coarse `s1` array its
//! sync search already built (`NSTEP=8` time bins per symbol,
//! `q65.f90:3`) rather than computing anything fresh — an efficiency
//! choice, not a precision requirement (see `q65_symspec`,
//! `q65.f90:265-302` — it also applies `smo121` frequency-domain
//! smoothing and a 2×-time-subsample-then-interpolate trick that
//! `Spectrogram::build_for` doesn't). This module instead computes a
//! fresh FFT at the exact decoded `(start_sample, base_freq_hz)` per
//! symbol, matching [`extract_data_energies`]'s own full-precision
//! convention (already proven correct for the BP decode itself)
//! rather than WSJT-X's `NSTEP=8`-quantised-and-smoothed grid. The
//! precision difference doesn't appear to change the reported dB by a
//! measurable amount in practice: the four real recordings verified
//! below span `mode_q65` = 1, 8, 8, 16 (i.e. WSJT-X's own `nsmo` =
//! 1, 32, 32, 128 for those same signals — `smo121`'s pass count
//! grows with `mode_q65²`) and all four land within 0.6-0.8 dB of
//! `jt9` regardless; if smoothing/interpolation mattered for the
//! final number, the `mode_q65=16` case would be expected to diverge
//! the most, and it doesn't (issue #255 / #256 discussion).
//!
//! ## Verification
//!
//! Checked against a real local `jt9 -3 -d3` build's own displayed
//! SNR on four real off-air Q65 recordings (issue #255's stated
//! verification discipline) — see [`q65_snr_db`]'s doc comment for
//! the numbers.
//!
//! ## Multi-period averaging (`iavg=1,2`)
//!
//! [`super::rx`]'s three `decode_averaged_*`/`decode_fading_with_energies`
//! functions (`super::rx::decode_multi_period_for`'s candidate loop)
//! use [`q65_snr_db_averaged`] instead of [`q65_snr_db`]. WSJT-X's own
//! `iavg` path (`q65_dec0`, `q65.f90:141-142`: `s1 = s1a(:,:,iseq)`)
//! reads its module-level `s1` from an accumulated array rather than a
//! fresh single-slot one, built via the exact same EMA weight
//! (`u = 1.0/min(navg,4)`, `q65_symspec` `q65.f90:298-304`) this
//! crate's own `Spectrogram`-based coarse search already implements
//! for `decode_multi_period_for`'s running-average sync search.
//! [`q65_composite_spectrum_averaged`] applies that identical EMA
//! directly to the *composite* spectrum rather than to the underlying
//! per-symbol energies first — mathematically equivalent, since the
//! "sum 85 symbols' shifted spectra" step the composite spectrum is
//! built from is linear, so averaging its output commutes with
//! averaging its input.
extern crate alloc;
use vec;
use Vec;
use Complex;
use FftPlanner;
use crateModulationParams;
use Q65_SYNC_POSITIONS;
/// Build the composite, tone-aligned power spectrum `q65_snr`
/// (`q65.f90:744-766`) sums across all 85 symbols.
///
/// `codeword` is the 63-symbol re-encoded channel codeword (values
/// `0..64`, e.g. from [`crate::fec::qra::Q65Codec::encode`] on the
/// decoded info symbols — the same array [`super::rx`]'s
/// `snr_db_narrow`/`snr_db_wide` already consume).
///
/// Returns `(spec, nsum)`: `spec[i]` is the composite power at
/// absolute FFT bin `ia + i` (`ia = base_bin - 2·nsum`, clamped to
/// `[0, nsps/2)`), and `nsum` is the guard-band width in bins
/// (`q65_snr`'s own `nsum = max(10·mode_q65, round(50/df))`) — both
/// needed by [`q65_snr_from_spectrum`] to find the guard bands and
/// integration region within `spec`.
///
/// Returns `None` if `audio` doesn't span the full 85-symbol frame at
/// `start_sample`, or the guard-band window doesn't fit inside the
/// spectrum (candidate too close to DC or Nyquist).
pub
/// Guard-band baseline + integrated-excess-power SNR from a composite
/// spectrum ([`q65_composite_spectrum`]'s output) — `q65_snr`'s own
/// `q65.f90:768-782`:
///
/// ```text
/// sum1 = Σ spec[0..nsum) ! low guard band
/// sum2 = Σ spec[width-nsum..width) ! high guard band
/// avg = (sum1+sum2) / (2·nsum) ! baseline level (→ 1.0 after normalising)
/// sig_area = Σ (spec[nsum..width-nsum] / avg - 1.0)
/// snr2 = 10·log10(max(1, sig_area)) - 10·log10(2500/df)
/// ```
///
/// Not this crate's own invention: the same shape as
/// [`crate::engine::baseline`]'s percentile+polyfit baseline in
/// *purpose* (estimate the noise floor from the data itself), but a
/// distinct, simpler algorithm — a flat two-guard-band mean, not a
/// polynomial fit — since `q65_snr`'s composite spectrum is already a
/// single sharp peak on a flat floor, not a slowly-varying spectral
/// shape. Do not try to route this through `BaselineParams`.
/// Q65's real displayed SNR (`q65.f90:744-793`). Composes
/// [`q65_composite_spectrum`] + [`q65_snr_from_spectrum`]; falls back
/// to `fallback_db` (callers pass the existing `snr_db_narrow`/
/// `snr_db_wide` adjacent-tone estimate) when the composite spectrum
/// can't be built (candidate too close to a band edge) rather than a
/// hardcoded sentinel, since unlike FST4's `-99.9` WSJT-X convention
/// there is no equivalent "invalid" marker in `q65_snr` itself to
/// port faithfully.
///
/// Verified against a real local `jt9 -3 -d3` build's own displayed
/// SNR on four real off-air Q65 recordings, one per sub-mode, all
/// decoded via [`crate::q65::rx::decode_at_fading_for`]'s fast-fading
/// path (`tests/q65_wsjtx_samples.rs`'s existing golden tests) —
/// landed within ~1 dB on the *first* implementation attempt, no
/// scale-factor archaeology needed (unlike FST4's own port, issue
/// #255 §4 — Q65's `extract_data_energies`/this module's own
/// extraction both FFT the raw audio directly, no shared downsample/
/// RMS-normalisation pipeline in between to introduce a mismatch):
///
/// | sub-mode | file | jt9 | this module | diff |
/// |----------|------|-----|-------------|------|
/// | Q65-60D | `60D_EME_10GHz/201212_1838.wav` (VK7MO K6QPV) | -15 dB | -14.33 dB | 0.67 dB |
/// | Q65-120D | `120D_Rainscatter_10_GHz/210117_0920.wav` (VK3WE VK7MO) | -16 dB | -16.59 dB | 0.59 dB |
/// | Q65-120E | `120E_Ionoscatter_6m/210130_1442.wav` (KB7IJ N0AN) | -17 dB | -16.39 dB | 0.61 dB |
/// | Q65-300A | `300A_Optical_Scatter/201210_0505.wav` (VK7MO VK7PD) | -34 dB | -33.18 dB | 0.82 dB |
pub
/// EMA-average [`q65_composite_spectrum`] across `audio_slots`,
/// matching WSJT-X's own `s1a` accumulation (`q65.f90:298-304`):
/// `weight = 1.0/min(navg,4)` where `navg` is the 1-based slot index,
/// capped at 4 (the time constant saturates after the 4th slot, so
/// older history decays at a fixed ~25%/slot rate — same formula
/// `q65::search::Spectrogram`-based `decode_multi_period_for` already
/// uses for its own coarse-search EMA).
///
/// Averages the *composite* spectrum directly rather than the
/// underlying per-symbol energies (WSJT-X's own `s1`) — see this
/// module's own doc comment for why that's mathematically equivalent
/// here (the composite-spectrum sum is linear in the per-symbol
/// energies, so EMA-averaging commutes through it).
///
/// `start_sample`/`base_freq_hz`/`codeword` are assumed constant
/// across `audio_slots` (the same candidate position and decoded
/// message recurring slot to slot) — the same assumption
/// `decode_multi_period_for`'s own EMA-on-spectrogram coarse search
/// already makes. A slot whose own [`q65_composite_spectrum`] returns
/// `None` (e.g. too short) or whose geometry (`nsum`/window width)
/// doesn't match the running average is skipped for the composite
/// step but doesn't abort the whole accumulation — best-effort, same
/// spirit as [`super::rx::averaged_data_energies`]'s own per-slot
/// `continue`-on-failure loop.
pub
/// [`q65_snr_db`]'s multi-period-averaging counterpart — see this
/// module's own doc comment (`## Multi-period averaging`) for the
/// derivation. Used by [`super::rx`]'s `decode_averaged_*`/
/// `decode_fading_with_energies` (`decode_multi_period_for`'s
/// candidate loop).
pub