Skip to main content

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}