rlx_core/dsp/fft.rs
1//! Windowed FFT producing linear magnitudes plus a log-frequency band
2//! spectrum for scenes.
3//!
4//! **Two windows, one axis** (ADR-0049). A single 2048 window cannot carry a
5//! 64-band log axis: its bins are 23.4 Hz apart at 48 kHz, while the lowest log
6//! bands are 3.6 Hz wide, so the bottom 20 bands were narrower than one bin and
7//! the old collapse fix-up spread them across single linear bins instead. The
8//! kick-and-sub region — the most-bound part of the axis — was its worst
9//! resolved.
10//!
11//! So the short [`WINDOW_SIZE`] window keeps feeding every band it can actually
12//! resolve, and a longer [`LOW_WINDOW_SIZE`] window feeds the bands below the
13//! crossover. **The crossover is derived, not chosen:** it is the first band
14//! whose width reaches one short-window bin (band 20, ~246 Hz at 48 kHz — which
15//! lands within 2 % of the independently-chosen `BASS_HI_HZ`). Above it nothing
16//! about the layout changed; below it the axis is genuinely logarithmic for the
17//! first time.
18//!
19//! The low bands inherit the long window's slower time response — 85 ms of
20//! Hann group delay at 8192 — and that is physics, stated rather than
21//! compensated away (ADR-0049). It does **not** move NFR section 3's
22//! beat-to-reaction budget: onset, beat and tempo all still read the short
23//! window's magnitudes, untouched.
24
25// Hot-path panic-denial pragma (Plan 0002 Phase 2).
26#![deny(
27 clippy::unwrap_used,
28 clippy::expect_used,
29 clippy::indexing_slicing,
30 clippy::panic,
31 clippy::unreachable
32)]
33
34use std::sync::Arc;
35
36use rustfft::num_complex::Complex;
37use rustfft::{Fft, FftPlanner};
38
39use super::{LOW_WINDOW_SIZE, SPECTRUM_BINS, WINDOW_SIZE};
40
41const MAG_BINS: usize = WINDOW_SIZE / 2;
42const LOW_MAG_BINS: usize = LOW_WINDOW_SIZE / 2;
43/// Log band range. The top is clamped below Nyquist for low sample rates.
44const BAND_LO_HZ: f32 = 35.0;
45const BAND_HI_HZ: f32 = 18_000.0;
46
47/// Which analysis window a band's magnitudes come from.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BandSource {
50 /// The long [`LOW_WINDOW_SIZE`] window — bands below the crossover.
51 Long,
52 /// The short [`WINDOW_SIZE`] window — bands at or above the crossover.
53 Short,
54}
55
56/// The 64-band log axis resolved against both analysis windows.
57///
58/// Pure: a function of the sample rate alone, built once at construction. Kept
59/// separate from [`SpectrumAnalyzer`] so the layout's properties are testable
60/// without planning an FFT.
61pub struct BandLayout {
62 /// Half-open bin range per band, within that band's own source window.
63 bins: [(usize, usize); SPECTRUM_BINS],
64 /// Band edge frequencies in Hz; band `k` spans `edges_hz[k]..edges_hz[k+1]`.
65 edges_hz: [f32; SPECTRUM_BINS + 1],
66 /// Bands `[0, crossover_band)` read the long window; the rest the short one.
67 crossover_band: usize,
68 /// Highest long-window bin any band needs — the long FFT's magnitudes are
69 /// only converted this far, since nothing above the crossover reads them.
70 long_bins_used: usize,
71 /// Low bands even the long window cannot resolve, widened to a one-bin floor
72 /// so they stay non-empty. Reported rather than hidden: at 8192 and 48 kHz
73 /// this is 8 bands, all below 76 Hz.
74 starved: usize,
75}
76
77impl BandLayout {
78 /// Lay the axis out for `sample_rate` across the two shipped window sizes.
79 pub fn new(sample_rate: u32) -> Self {
80 Self::with_windows(sample_rate as f32, WINDOW_SIZE, LOW_WINDOW_SIZE)
81 }
82
83 /// The layout proper, parameterized on both window lengths so the tests can
84 /// measure a candidate window without a rebuild.
85 #[allow(
86 clippy::indexing_slicing,
87 reason = "edges_hz is a fixed SPECTRUM_BINS+1 array and k+1 <= SPECTRUM_BINS in every loop; bins is SPECTRUM_BINS long and k stays below it"
88 )]
89 fn with_windows(sr: f32, short_window: usize, long_window: usize) -> Self {
90 let hi = BAND_HI_HZ.min(sr * 0.45);
91 let ratio = hi / BAND_LO_HZ;
92 let mut edges_hz = [0.0f32; SPECTRUM_BINS + 1];
93 for (k, edge) in edges_hz.iter_mut().enumerate() {
94 *edge = BAND_LO_HZ * ratio.powf(k as f32 / SPECTRUM_BINS as f32);
95 }
96
97 let short_bin_hz = sr / short_window as f32;
98 let long_bin_hz = sr / long_window as f32;
99 let short_mags = short_window / 2;
100 let long_mags = long_window / 2;
101
102 // The crossover: the first band the short window resolves on its own.
103 // Band width grows monotonically with k, so the unresolvable bands are
104 // a prefix and counting them is the same as finding the boundary.
105 let mut crossover_band = 0;
106 for k in 0..SPECTRUM_BINS {
107 if edges_hz[k + 1] - edges_hz[k] < short_bin_hz {
108 crossover_band = k + 1;
109 }
110 }
111
112 let to_bin =
113 |f: f32, bin_hz: f32, mags: usize| ((f / bin_hz).round() as usize).clamp(1, mags);
114
115 let mut bins = [(1usize, 2usize); SPECTRUM_BINS];
116 let mut starved = 0usize;
117
118 // Each region chains its own `prev_hi` so bands stay contiguous; the
119 // two chains are independent because their bins index different windows.
120 let mut fill = |range: std::ops::Range<usize>, bin_hz: f32, mags: usize| {
121 let mut prev_hi = to_bin(edges_hz[range.start], bin_hz, mags);
122 let mut widened = 0usize;
123 for k in range {
124 let lo = prev_hi.max(to_bin(edges_hz[k], bin_hz, mags));
125 let mut hi = to_bin(edges_hz[k + 1], bin_hz, mags);
126 if hi <= lo {
127 // A band narrower than one bin. Widened to stay non-empty;
128 // above the crossover this cannot fire, which is what the
129 // crossover *means* and what `short_region_is_never_starved`
130 // asserts.
131 hi = (lo + 1).min(mags);
132 widened += 1;
133 }
134 bins[k] = (lo, hi);
135 prev_hi = hi;
136 }
137 widened
138 };
139
140 starved += fill(0..crossover_band, long_bin_hz, long_mags);
141 let short_widened = fill(crossover_band..SPECTRUM_BINS, short_bin_hz, short_mags);
142 debug_assert_eq!(
143 short_widened, 0,
144 "the crossover guarantees every short-window band is at least one bin wide"
145 );
146
147 let long_bins_used = if crossover_band == 0 {
148 0
149 } else {
150 bins[crossover_band - 1].1
151 };
152
153 Self {
154 bins,
155 edges_hz,
156 crossover_band,
157 long_bins_used,
158 starved,
159 }
160 }
161
162 /// Where a band's magnitudes come from.
163 pub fn source(&self, band: usize) -> BandSource {
164 if band < self.crossover_band {
165 BandSource::Long
166 } else {
167 BandSource::Short
168 }
169 }
170
171 /// First band that reads the short window.
172 pub fn crossover_band(&self) -> usize {
173 self.crossover_band
174 }
175
176 /// Lower edge of the crossover band, in Hz.
177 pub fn crossover_hz(&self) -> f32 {
178 self.edges_hz
179 .get(self.crossover_band)
180 .copied()
181 .unwrap_or(BAND_HI_HZ)
182 }
183
184 /// Low bands the long window still cannot resolve — see [`Self::starved`]'s
185 /// field docs. Surfaced so the docs can quote a measurement.
186 pub fn starved(&self) -> usize {
187 self.starved
188 }
189
190 /// The log-frequency band that contains `hz`: the last band whose lower
191 /// edge is at or below it.
192 pub fn band_for_freq(&self, hz: f32) -> usize {
193 (0..SPECTRUM_BINS)
194 .rev()
195 .find(|&k| self.edges_hz.get(k).is_some_and(|&e| e <= hz))
196 .unwrap_or(0)
197 }
198}
199
200/// Windowed FFT plus a fixed log-frequency band mapping, reused every hop.
201pub struct SpectrumAnalyzer {
202 short_fft: Arc<dyn Fft<f32>>,
203 long_fft: Arc<dyn Fft<f32>>,
204 short_hann: [f32; WINDOW_SIZE],
205 /// The long window's Hann taper and buffers live on the heap: at 8192 they
206 /// are 32 KB apiece, and `Analyzer` is constructed and moved by value.
207 long_hann: Vec<f32>,
208 short_buf: Vec<Complex<f32>>,
209 short_scratch: Vec<Complex<f32>>,
210 long_buf: Vec<Complex<f32>>,
211 long_scratch: Vec<Complex<f32>>,
212 mags: [f32; MAG_BINS],
213 long_mags: Vec<f32>,
214 layout: BandLayout,
215 /// Scales a Hann-windowed peak magnitude back to sine amplitude
216 /// (Hann coherent gain 1/2, one-sided spectrum 2/N => 4/N). Per window, so
217 /// a tone reads the same amplitude on either side of the crossover.
218 short_norm: f32,
219 long_norm: f32,
220}
221
222impl SpectrumAnalyzer {
223 /// Plan both FFTs and precompute the Hann windows and band layout for
224 /// `sample_rate`.
225 pub fn new(sample_rate: u32) -> Self {
226 let mut planner = FftPlanner::new();
227 let short_fft = planner.plan_fft_forward(WINDOW_SIZE);
228 let long_fft = planner.plan_fft_forward(LOW_WINDOW_SIZE);
229 let short_scratch_len = short_fft.get_inplace_scratch_len();
230 let long_scratch_len = long_fft.get_inplace_scratch_len();
231
232 let mut short_hann = [0.0f32; WINDOW_SIZE];
233 for (i, w) in short_hann.iter_mut().enumerate() {
234 *w = hann_at(i, WINDOW_SIZE);
235 }
236 let long_hann: Vec<f32> = (0..LOW_WINDOW_SIZE)
237 .map(|i| hann_at(i, LOW_WINDOW_SIZE))
238 .collect();
239
240 Self {
241 short_fft,
242 long_fft,
243 short_hann,
244 long_hann,
245 short_buf: vec![Complex::new(0.0, 0.0); WINDOW_SIZE],
246 short_scratch: vec![Complex::new(0.0, 0.0); short_scratch_len],
247 long_buf: vec![Complex::new(0.0, 0.0); LOW_WINDOW_SIZE],
248 long_scratch: vec![Complex::new(0.0, 0.0); long_scratch_len],
249 mags: [0.0; MAG_BINS],
250 long_mags: vec![0.0; LOW_MAG_BINS],
251 layout: BandLayout::new(sample_rate),
252 short_norm: 4.0 / WINDOW_SIZE as f32,
253 long_norm: 4.0 / LOW_WINDOW_SIZE as f32,
254 }
255 }
256
257 /// FFT both windows and return the log-frequency band spectrum. Band value
258 /// is the peak bin in the band, so a pure tone reads near its amplitude
259 /// regardless of band width — and regardless of which window resolved it.
260 ///
261 /// `long` is expected to be [`LOW_WINDOW_SIZE`] samples; a shorter slice
262 /// simply leaves the tail of the transform zeroed rather than panicking.
263 #[allow(
264 clippy::indexing_slicing,
265 reason = "buf/mags are indexed within their own iterators' bounds, and every (lo, hi) comes from BandLayout, which clamps both to the source window's magnitude count"
266 )]
267 pub fn analyze(&mut self, short: &[f32; WINDOW_SIZE], long: &[f32]) -> [f32; SPECTRUM_BINS] {
268 for (i, (s, w)) in short.iter().zip(self.short_hann.iter()).enumerate() {
269 self.short_buf[i] = Complex::new(s * w, 0.0);
270 }
271 self.short_fft
272 .process_with_scratch(&mut self.short_buf, &mut self.short_scratch);
273 for (i, m) in self.mags.iter_mut().enumerate() {
274 *m = self.short_buf[i].norm() * self.short_norm;
275 }
276
277 // Only the bins below the crossover are ever read, so the magnitude
278 // conversion stops there — ~42 of 4096 bins at 48 kHz.
279 let used = self.layout.long_bins_used;
280 if used > 0 {
281 for (i, slot) in self.long_buf.iter_mut().enumerate() {
282 let s = long.get(i).copied().unwrap_or(0.0);
283 let w = self.long_hann.get(i).copied().unwrap_or(0.0);
284 *slot = Complex::new(s * w, 0.0);
285 }
286 self.long_fft
287 .process_with_scratch(&mut self.long_buf, &mut self.long_scratch);
288 for (i, m) in self.long_mags.iter_mut().enumerate().take(used) {
289 *m = self.long_buf[i].norm() * self.long_norm;
290 }
291 }
292
293 let mut bands = [0.0f32; SPECTRUM_BINS];
294 for (k, band) in bands.iter_mut().enumerate() {
295 let (lo, hi) = self.layout.bins[k];
296 let src = match self.layout.source(k) {
297 BandSource::Long => &self.long_mags[..],
298 BandSource::Short => &self.mags[..],
299 };
300 *band = src[lo..hi].iter().fold(0.0f32, |a, &b| a.max(b));
301 }
302 bands
303 }
304
305 /// Normalized linear magnitudes of the most recent `analyze` call, from the
306 /// **short** window — consumed by onset detection, whose transient response
307 /// is deliberately not slowed by the long window.
308 pub fn magnitudes(&self) -> &[f32; MAG_BINS] {
309 &self.mags
310 }
311
312 /// The band layout this analyzer resolved for its sample rate.
313 pub fn layout(&self) -> &BandLayout {
314 &self.layout
315 }
316
317 /// The log-frequency band index that contains `hz`.
318 pub fn band_for_freq(&self, hz: f32) -> usize {
319 self.layout.band_for_freq(hz)
320 }
321}
322
323/// One channel's **short**-window linear magnitudes, and nothing else.
324///
325/// The per-channel half of the stereo field (ADR-0215) reads its band energies
326/// through [`BandSplitter`](super::bands::BandSplitter), which reads the short
327/// window's magnitudes alone — so a per-channel pass needs neither the long
328/// window nor the log-band axis. A second [`SpectrumAnalyzer`] per channel would
329/// plan an 8192-point FFT and carry its ~120 kB of buffers for nothing.
330///
331/// Every buffer is heap-held, including the taper: [`Analyzer`](super::Analyzer)
332/// keeps two of these and is constructed and moved by value.
333pub struct ShortSpectrum {
334 fft: Arc<dyn Fft<f32>>,
335 hann: Vec<f32>,
336 buf: Vec<Complex<f32>>,
337 scratch: Vec<Complex<f32>>,
338 mags: Vec<f32>,
339 norm: f32,
340}
341
342impl ShortSpectrum {
343 /// Plan the short FFT and precompute its Hann taper.
344 pub fn new() -> Self {
345 let fft = FftPlanner::new().plan_fft_forward(WINDOW_SIZE);
346 let scratch_len = fft.get_inplace_scratch_len();
347 Self {
348 fft,
349 hann: (0..WINDOW_SIZE).map(|i| hann_at(i, WINDOW_SIZE)).collect(),
350 buf: vec![Complex::new(0.0, 0.0); WINDOW_SIZE],
351 scratch: vec![Complex::new(0.0, 0.0); scratch_len],
352 mags: vec![0.0; MAG_BINS],
353 norm: 4.0 / WINDOW_SIZE as f32,
354 }
355 }
356
357 /// FFT `window` and return its linear magnitudes, on the same amplitude
358 /// scale [`SpectrumAnalyzer::magnitudes`] publishes — so a band mean taken
359 /// from these is comparable with the mono one bin for bin.
360 ///
361 /// A `window` shorter than [`WINDOW_SIZE`] leaves the tail of the transform
362 /// zeroed rather than panicking, exactly as the long window's fill does.
363 pub fn analyze(&mut self, window: &[f32]) -> &[f32] {
364 for (i, slot) in self.buf.iter_mut().enumerate() {
365 let s = window.get(i).copied().unwrap_or(0.0);
366 let w = self.hann.get(i).copied().unwrap_or(0.0);
367 *slot = Complex::new(s * w, 0.0);
368 }
369 self.fft
370 .process_with_scratch(&mut self.buf, &mut self.scratch);
371 for (i, m) in self.mags.iter_mut().enumerate() {
372 *m = self.buf.get(i).map_or(0.0, |c| c.norm()) * self.norm;
373 }
374 &self.mags
375 }
376}
377
378impl Default for ShortSpectrum {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384/// Hann taper value at sample `i` of an `n`-long window.
385fn hann_at(i: usize, n: usize) -> f32 {
386 let phase = i as f32 / (n.max(2) - 1) as f32;
387 0.5 - 0.5 * (std::f32::consts::TAU * phase).cos()
388}
389
390#[cfg(test)]
391#[allow(
392 clippy::indexing_slicing,
393 reason = "the module's hot-path pragma also covers the tests; here every index is a literal or a loop bound inside a fixed SPECTRUM_BINS(+1) array, and a panic is the intended failure anyway"
394)]
395mod tests {
396 use super::*;
397 use crate::dsp::bands::BASS_HI_HZ;
398
399 const SR: f32 = 48_000.0;
400
401 /// The layout as it was before ADR-0049: one window, natural log edges, and
402 /// a cumulative fix-up that forced every collapsed edge to `previous + 1`.
403 /// Kept here as the reference the "nothing above the crossover moved" claim
404 /// is measured against, rather than as a description in a comment.
405 fn v1_edges(sr: f32) -> [usize; SPECTRUM_BINS + 1] {
406 let hi = BAND_HI_HZ.min(sr * 0.45);
407 let ratio = hi / BAND_LO_HZ;
408 let bin_hz = sr / WINDOW_SIZE as f32;
409 let mut edges = [0usize; SPECTRUM_BINS + 1];
410 for (k, edge) in edges.iter_mut().enumerate() {
411 let f = BAND_LO_HZ * ratio.powf(k as f32 / SPECTRUM_BINS as f32);
412 *edge = ((f / bin_hz).round() as usize).clamp(1, MAG_BINS);
413 }
414 for k in 1..edges.len() {
415 if edges[k] <= edges[k - 1] {
416 edges[k] = (edges[k - 1] + 1).min(MAG_BINS);
417 }
418 }
419 edges
420 }
421
422 #[test]
423 fn crossover_is_where_the_short_window_stops_resolving() {
424 let layout = BandLayout::new(48_000);
425 assert_eq!(
426 layout.crossover_band(),
427 20,
428 "at 48 kHz the 2048 window resolves from band 20 up"
429 );
430 // ~246 Hz: derived from the axis, yet it lands within 2 % of the
431 // independently chosen BASS_HI_HZ = 250. Worth pinning as a fact, not
432 // as a coincidence someone might "tidy".
433 let hz = layout.crossover_hz();
434 assert!(
435 (240.0..250.0).contains(&hz),
436 "crossover should sit just under the 250 Hz bass split, got {hz}"
437 );
438
439 // Non-vacuity: every band below the crossover really is narrower than a
440 // short-window bin, and every band above really is at least as wide.
441 let short_bin_hz = SR / WINDOW_SIZE as f32;
442 for k in 0..SPECTRUM_BINS {
443 let width = layout.edges_hz[k + 1] - layout.edges_hz[k];
444 if k < layout.crossover_band() {
445 assert!(
446 width < short_bin_hz,
447 "band {k} width {width} should be under one {short_bin_hz} Hz bin"
448 );
449 } else {
450 assert!(
451 width >= short_bin_hz,
452 "band {k} width {width} should reach one {short_bin_hz} Hz bin"
453 );
454 }
455 }
456 }
457
458 #[test]
459 fn above_the_chain_every_edge_is_bit_identical_to_v1() {
460 let layout = BandLayout::new(48_000);
461 let v1 = v1_edges(SR);
462
463 // The v1 fix-up chain overshot the log curve and only died at band 32,
464 // so v1's edges for bands 20..31 were artifacts of the collapse
465 // handling rather than of the layout. From 32 up, v1 *was* the natural
466 // curve, so v2 reproduces it there bit for bit. Half the axis.
467 for k in 32..SPECTRUM_BINS {
468 let (lo, hi) = layout.bins[k];
469 assert_eq!(
470 (lo, hi),
471 (v1[k], v1[k + 1]),
472 "band {k} sits above the v1 fix-up chain and must not have moved"
473 );
474 }
475
476 // And the counter-assertion that makes the above mean something: bands
477 // 20..31 *did* move, and by far more than rounding. Without this, the
478 // test would pass just as well if the crossover had swallowed the whole
479 // axis, or if the layout had simply reproduced v1.
480 let moved: Vec<usize> = (layout.crossover_band()..SPECTRUM_BINS)
481 .filter(|&k| layout.bins[k] != (v1[k], v1[k + 1]))
482 .collect();
483 assert_eq!(
484 moved,
485 (20..32).collect::<Vec<_>>(),
486 "exactly bands 20 to 31 should have left their v1 fix-up positions"
487 );
488 assert_eq!(
489 layout.bins[20],
490 (11, 12),
491 "band 20 should sit at its natural bins 11..12, not v1's forced 21..22"
492 );
493 }
494
495 #[test]
496 fn short_region_is_never_starved_and_the_low_region_is_measured() {
497 let layout = BandLayout::new(48_000);
498 // Every band is non-empty, whichever window it came from.
499 for k in 0..SPECTRUM_BINS {
500 let (lo, hi) = layout.bins[k];
501 assert!(
502 hi > lo,
503 "band {k} must span at least one bin, got {lo}..{hi}"
504 );
505 }
506 // The long window resolves all but the bottom handful; those are the
507 // ones physics does not allow at this window length, and the number is
508 // quoted in the docs rather than left vague.
509 assert_eq!(
510 layout.starved(),
511 8,
512 "at 8192 and 48 kHz exactly 8 sub-76 Hz bands stay one bin wide"
513 );
514 }
515
516 /// **The axis holds up at the sample rates we do not develop at.**
517 ///
518 /// Every layout test above is at 48 kHz. `AudioFormat` accepts 8 kHz-384 kHz,
519 /// and foobar hands the plugin **44.1 kHz** for CD material — the single most
520 /// common rate this engine will ever see, and the one no test looked at.
521 /// ADR-0049's stated benefit is literally *"the axis stops depending on sample
522 /// rate in its bottom half"*: that is the claim, and until now it was the one
523 /// claim no test could see.
524 ///
525 /// # What this found: the crossover is not rate-independent, and should not be
526 ///
527 /// The measured figures, which this test pins:
528 ///
529 /// | rate | crossover band | crossover | bin-starved bands |
530 /// |------|----------------|-----------|-------------------|
531 /// | 44.1 kHz | 19 | ~223 Hz | 8 |
532 /// | 48 kHz | 20 | ~246 Hz | 8 |
533 /// | 96 kHz | 27 | ~487 Hz | **21** |
534 ///
535 /// **Both windows are fixed in samples, not seconds**, so at 96 kHz each one
536 /// spans half the time and resolves half the frequency detail. The crossover
537 /// therefore rides `sample_rate / WINDOW_SIZE`, and the region the long window
538 /// still cannot resolve grows from 8 bands to **21 — a third of the axis**
539 /// (the widening cascades: `fill` chains `prev_hi`, so each widened band
540 /// pushes the next one's floor up).
541 ///
542 /// That is physics working as specified rather than a defect — a higher rate
543 /// buys time resolution and spends frequency resolution — and ADR-0049's claim
544 /// survives it intact, because the claim is about the band **edges in Hz**
545 /// below the crossover, which do not move at all. But a third of the axis
546 /// reading at one-bin resolution is a real difference in what a preset's
547 /// `bin()` sees on a 96 kHz device, and it was invisible before this test.
548 /// Fixing it would mean sizing the windows in **seconds** rather than samples,
549 /// which is an ADR, not a test. Recorded here so the next person to think
550 /// about it starts from the measurement.
551 ///
552 /// 44.1 kHz — the rate that actually matters, since foobar hands the plugin
553 /// CD material — is indistinguishable from 48 kHz: one band lower, same
554 /// starved count. That half of the sweep found nothing, which is the good
555 /// outcome.
556 ///
557 /// So this asserts the invariant rather than a number: the crossover is
558 /// wherever the axis reaches one short-window bin, at every rate. The
559 /// near-`BASS_HI_HZ` coincidence the 48 kHz test pins is asserted **only at
560 /// 44.1 kHz**, where it holds and where it matters — that is the rate foobar
561 /// hands the plugin for CD material.
562 ///
563 /// It also carries the release-mode half of a claim that currently has none:
564 /// `with_windows`'s `debug_assert_eq!(short_widened, 0)` is the only guard
565 /// that the crossover really keeps the short region unstarved, **and a
566 /// `debug_assert` does not run in release**. The width check below is the same
567 /// property stated on the axis rather than on the fill, so it runs in both.
568 #[test]
569 fn the_axis_holds_at_the_rates_we_do_not_develop_at() {
570 // (rate, expected bin-starved bands) — see the table above.
571 for (sr, expect_starved) in [(44_100u32, 8usize), (48_000, 8), (96_000, 21)] {
572 let layout = BandLayout::new(sr);
573 let short_bin_hz = sr as f32 / WINDOW_SIZE as f32;
574
575 // 1. No dead stripes: an empty band reads zero in every `bin()` a
576 // preset takes, at every level of input.
577 for k in 0..SPECTRUM_BINS {
578 let (lo, hi) = layout.bins[k];
579 assert!(
580 hi > lo,
581 "at {sr} Hz band {k} spans no bins ({lo}..{hi}) — a dead stripe"
582 );
583 }
584
585 // 2. The crossover is exactly where the axis reaches one short-window
586 // bin. This is the invariant; the hertz figure it lands on is a
587 // consequence of the rate, not a constant.
588 let width = |k: usize| layout.edges_hz[k + 1] - layout.edges_hz[k];
589 let crossover = layout.crossover_band();
590 assert!(
591 crossover > 0 && crossover < SPECTRUM_BINS,
592 "at {sr} Hz the crossover swallowed or vacated the axis: {crossover}"
593 );
594 assert!(
595 width(crossover - 1) < short_bin_hz && width(crossover) >= short_bin_hz,
596 "at {sr} Hz the crossover at band {crossover} is not the one-short-bin \
597 boundary: widths {} then {}, bin {short_bin_hz} Hz",
598 width(crossover - 1),
599 width(crossover)
600 );
601
602 // 3. The short region's width claim, stated on the axis so it runs in
603 // release too — see this test's doc comment.
604 for k in crossover..SPECTRUM_BINS {
605 assert!(
606 width(k) >= short_bin_hz,
607 "at {sr} Hz band {k} is above the crossover but only {} Hz wide, under \
608 one {short_bin_hz} Hz short-window bin — the fill would widen it, and \
609 only a debug_assert would notice",
610 width(k)
611 );
612 }
613
614 // 4. The bin-starved count, pinned per rate. It rises with the rate
615 // because both windows are fixed in samples; a change here is a
616 // change in how much sub-bass the long window resolves, which is
617 // the thing ADR-0049 exists to protect.
618 assert_eq!(
619 layout.starved(),
620 expect_starved,
621 "at {sr} Hz the bin-starved sub-bass region changed size"
622 );
623 }
624
625 // At the rate foobar hands the plugin for CD material, the crossover still
626 // sits just under the bass split — the same near-coincidence the 48 kHz
627 // test pins, and the reason the axis behaves the same on that path.
628 let hz = BandLayout::new(44_100).crossover_hz();
629 assert!(
630 (BASS_HI_HZ * 0.85..BASS_HI_HZ).contains(&hz),
631 "at 44.1 kHz the crossover moved to {hz} Hz, away from just under the \
632 {BASS_HI_HZ} Hz bass split"
633 );
634 }
635
636 #[test]
637 fn the_long_window_was_chosen_by_measurement() {
638 // The rule: 4096 first, 8192 only if 4096 still leaves sub-bass
639 // bands bin-starved. This records the measurement that decided it,
640 // so a later reader can re-derive the choice instead of trusting a
641 // commit message.
642 let starved_at = |long: usize| BandLayout::with_windows(SR, WINDOW_SIZE, long).starved();
643 // 4096 widens *all twenty* low bands — it buys nothing the 2048 window
644 // did not already fail at, which is what made the choice unambiguous
645 // rather than a judgement call.
646 assert_eq!(starved_at(4096), 20, "4096 resolves none of the low region");
647 assert_eq!(
648 starved_at(8192),
649 8,
650 "8192 pulls the boundary down to ~76 Hz"
651 );
652 assert_eq!(
653 starved_at(16_384),
654 0,
655 "16384 would resolve all of it, at 171 ms of group delay"
656 );
657 assert!(
658 starved_at(LOW_WINDOW_SIZE) < starved_at(4096),
659 "the shipped window must beat the one the plan tried first"
660 );
661 }
662
663 #[test]
664 fn a_tone_reads_its_amplitude_on_either_side_of_the_crossover() {
665 // The two windows carry different norms (4/N each); if they disagreed,
666 // the axis would step in level at the crossover. 120 Hz is long-window
667 // territory, 400 Hz short-window, and a 0.8 sine must read ~0.8 in both.
668 //
669 // Read as the spectrum's peak rather than the band containing the tone:
670 // near the crossover a band is only one or two bins wide, so a tone
671 // sitting at a band edge legitimately splits its energy with its
672 // neighbour. The level is the claim here, not the placement — that is
673 // `band_for_freq_agrees_with_the_edge_table`'s job.
674 let mut readings = Vec::new();
675 for freq in [120.0f32, 400.0] {
676 let mut an = SpectrumAnalyzer::new(48_000);
677 let tone = |i: usize| 0.8 * (std::f32::consts::TAU * freq * i as f32 / SR).sin();
678 let short: [f32; WINDOW_SIZE] = std::array::from_fn(tone);
679 let long: Vec<f32> = (0..LOW_WINDOW_SIZE).map(tone).collect();
680 let bands = an.analyze(&short, &long);
681
682 let (peak_band, peak) = bands
683 .iter()
684 .enumerate()
685 .max_by(|a, b| a.1.total_cmp(b.1))
686 .map(|(k, &v)| (k, v))
687 .unwrap_or((0, 0.0));
688 assert!(
689 (0.6..=1.0).contains(&peak),
690 "a 0.8 sine at {freq} Hz should peak near 0.8, got {peak} in band {peak_band}"
691 );
692 // The peak is where the tone is, give or take the edge-splitting
693 // above — so this stays a real placement check without being brittle.
694 let expected = an.band_for_freq(freq);
695 assert!(
696 peak_band.abs_diff(expected) <= 1,
697 "a {freq} Hz tone should peak at or beside band {expected}, got {peak_band}"
698 );
699 readings.push(peak);
700 }
701 // The continuity claim proper: the two windows agree on level to within
702 // Hann edge-splitting, so nothing steps at the crossover.
703 let (lo, hi) = (readings[0], readings[1]);
704 assert!(
705 (lo - hi).abs() < 0.2,
706 "the two windows must agree on a 0.8 tone's level: long read {lo}, short read {hi}"
707 );
708 }
709
710 /// Plan 0048 Phase 1 / ADR-0049: the 808-collapse reproduction, inverted.
711 ///
712 /// A tone stepped across the sub-bass and bass region must climb the axis one
713 /// band at a time instead of parking in one or two. The bound it has to beat
714 /// is **derived, not chosen**: before the dual-resolution axis every band down
715 /// here was a single short-window bin, so the region could only ever resolve
716 /// as many distinct bands as there are `sample_rate / WINDOW_SIZE` bins in it.
717 ///
718 /// Two deliberate choices about the instrument. **Stepped tones, not a glide:**
719 /// the long window integrates 171 ms, so a fast sweep is genuinely smeared
720 /// across it — the physics ADR-0049 accepts — and measuring the *axis* means
721 /// holding each frequency steady. **Read here rather than through `Analyzer`:**
722 /// band placement is a property of the *layout*, and this is the layout's
723 /// test — going through the analyzer would put its normalizer's state, warm-up
724 /// and decay in the path of a question that has nothing to do with any of them.
725 /// (The published array would in fact preserve the argmax: ADR-0049 normalizes
726 /// the whole spectrum against one shared peak, and a uniform gain keeps the
727 /// ordering. It is the per-band normalization that would have destroyed it —
728 /// the draft that ADR was written to reject.)
729 #[test]
730 fn a_tone_stepped_through_the_bass_region_climbs_distinct_bands() {
731 const LO_HZ: f32 = 40.0;
732 const HI_HZ: f32 = 200.0;
733
734 let mut an = SpectrumAnalyzer::new(48_000);
735 let peak_band_at = |an: &mut SpectrumAnalyzer, freq: f32| -> usize {
736 let tone = |i: usize| 0.8 * (std::f32::consts::TAU * freq * i as f32 / SR).sin();
737 let short: [f32; WINDOW_SIZE] = std::array::from_fn(tone);
738 let long: Vec<f32> = (0..LOW_WINDOW_SIZE).map(tone).collect();
739 an.analyze(&short, &long)
740 .iter()
741 .enumerate()
742 .max_by(|a, b| a.1.total_cmp(b.1))
743 .map(|(k, _)| k)
744 .unwrap_or(0)
745 };
746
747 let steps = 64;
748 let visited: Vec<usize> = (0..=steps)
749 .map(|i| LO_HZ + (HI_HZ - LO_HZ) * i as f32 / steps as f32)
750 .map(|f| peak_band_at(&mut an, f))
751 .collect();
752
753 // Smooth: the peak band never walks backwards as the tone rises.
754 for pair in visited.windows(2) {
755 if let [a, b] = pair {
756 assert!(
757 b >= a,
758 "the peak band must not fall as frequency rises: {visited:?}"
759 );
760 }
761 }
762
763 let distinct = {
764 let mut v = visited.clone();
765 v.dedup();
766 v.len()
767 };
768
769 // The v1 ceiling: one short-window bin per band meant at most this many
770 // distinct bands were reachable between LO_HZ and HI_HZ, however many log
771 // bands nominally sat there.
772 let bin_hz = SR / WINDOW_SIZE as f32;
773 let v1_ceiling = (HI_HZ / bin_hz).floor() as usize - (LO_HZ / bin_hz).floor() as usize + 1;
774 assert_eq!(
775 v1_ceiling, 8,
776 "fixture sanity: the old axis could show 8 bands across this span"
777 );
778 assert!(
779 distinct > v1_ceiling,
780 "the dual-resolution axis should resolve more than the {v1_ceiling} bands a single \
781 {WINDOW_SIZE} window could, got {distinct}: {visited:?}"
782 );
783
784 // And the span really is spread across the axis rather than nudged: most
785 // of the log bands nominally covering 40-200 Hz should appear.
786 let layout = BandLayout::new(48_000);
787 let nominal = layout.band_for_freq(HI_HZ) - layout.band_for_freq(LO_HZ) + 1;
788 assert!(
789 distinct * 2 >= nominal,
790 "40-200 Hz spans {nominal} log bands and should light up most of them, \
791 got {distinct}: {visited:?}"
792 );
793 }
794
795 #[test]
796 fn band_for_freq_agrees_with_the_edge_table() {
797 let layout = BandLayout::new(48_000);
798 for k in 0..SPECTRUM_BINS {
799 // A frequency just inside band k's span must map back to k.
800 let lo = layout.edges_hz[k];
801 let hi = layout.edges_hz[k + 1];
802 let mid = (lo + hi) * 0.5;
803 assert_eq!(
804 layout.band_for_freq(mid),
805 k,
806 "midpoint of band {k} ({mid} Hz)"
807 );
808 }
809 // Below the axis clamps to band 0 rather than wrapping or panicking.
810 assert_eq!(layout.band_for_freq(1.0), 0);
811 }
812
813 /// The frequency a preset's `bin(x)` reads, in Hz.
814 ///
815 /// `bin(x)` addresses the band array by **normalized position**: it maps `x`
816 /// to `x * (SPECTRUM_BINS - 1)` and interpolates the two adjacent bands (see
817 /// `Variables::bin`). The frequency that lands on is the geometric centre of
818 /// the band at that position — geometric because the axis is logarithmic, so
819 /// the midpoint of a band in *pitch* is the square root of the product of
820 /// its edges, not their average.
821 ///
822 /// Reads `edges_hz` deliberately. This helper must move when the layout
823 /// moves; it is the literals below that must not.
824 fn bin_hz(layout: &BandLayout, x: f32) -> f32 {
825 #[allow(clippy::manual_clamp, reason = "mirrors Variables::bin's total form")]
826 let pos = x.max(0.0).min(1.0) * (SPECTRUM_BINS - 1) as f32;
827 let k = pos.floor() as usize;
828 let frac = pos - k as f32;
829 let centre = |k: usize| (layout.edges_hz[k] * layout.edges_hz[k + 1]).sqrt();
830 let a = centre(k.min(SPECTRUM_BINS - 1));
831 let b = centre((k + 1).min(SPECTRUM_BINS - 1));
832 // Interpolated in log space, matching the axis the two centres sit on.
833 a * (b / a).powf(frac)
834 }
835
836 /// The external anchor the axis had none of (ADR-0063).
837 ///
838 /// `band_for_freq_agrees_with_the_edge_table` above checks the lookup
839 /// function against the edge table — but Plan 0048 Phase 1 moved **both**,
840 /// together, and it passed through the rebuild unchanged. Two sources that
841 /// agree on every configuration we test cannot tell you which one the
842 /// *content* depended on. These literals can, because they are not derived
843 /// from either: they were measured on 2026-08-03 and written down.
844 ///
845 /// **If this test fails, nothing here is broken — the axis was relaid.** The
846 /// obligation it creates is a content sweep: every `bin()` position in
847 /// `presets/` now reads a different frequency than it did, and each one has
848 /// to be re-checked against the frequency its author's comment names. That
849 /// is exactly what went unnoticed in Plan 0048: `fragment_aurora`'s low
850 /// probe was `bin(0.14)` chosen for ~246 Hz, and the rebuilt axis turned it
851 /// into a ~84 Hz kick probe, inverting the one property the preset exists to
852 /// have. Update the literals **after** the sweep, not instead of it.
853 #[test]
854 fn bin_positions_resolve_to_the_frequencies_the_presets_were_written_against() {
855 let layout = BandLayout::new(48_000);
856 // (position, Hz) — measured, not computed. See the note above before
857 // touching the right-hand column.
858 let anchors = [
859 (0.00f32, 36.7f32),
860 // The two ADR-0063 names as the concrete damage: on the rebuilt axis
861 // `attractor_dejong`'s `bin(0.10)` came to read the ~65 Hz its own
862 // header calls out as the mistake, and `fragment_aurora`'s
863 // `bin(0.14)` — chosen for the ~246 Hz low-mid — became a kick probe.
864 (0.10, 67.9),
865 (0.14, 86.9),
866 (0.20, 125.6),
867 // The position that actually reads that ~246 Hz low-mid today.
868 (0.31, 246.9),
869 (0.50, 793.7),
870 (0.84, 6413.2),
871 (1.00, 17143.2),
872 ];
873 for (x, want) in anchors {
874 let got = bin_hz(&layout, x);
875 assert!(
876 (got / want - 1.0).abs() < 0.005,
877 "bin({x}) resolved to {got:.1} Hz, and this axis was pinned at {want:.1} Hz. \
878 The layout moved: every bin() in presets/ needs re-checking against the \
879 frequency its author named before these literals are updated"
880 );
881 }
882 }
883}