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/// Hann taper value at sample `i` of an `n`-long window.
324fn hann_at(i: usize, n: usize) -> f32 {
325    let phase = i as f32 / (n.max(2) - 1) as f32;
326    0.5 - 0.5 * (std::f32::consts::TAU * phase).cos()
327}
328
329#[cfg(test)]
330#[allow(
331    clippy::indexing_slicing,
332    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"
333)]
334mod tests {
335    use super::*;
336    use crate::dsp::bands::BASS_HI_HZ;
337
338    const SR: f32 = 48_000.0;
339
340    /// The layout as it was before ADR-0049: one window, natural log edges, and
341    /// a cumulative fix-up that forced every collapsed edge to `previous + 1`.
342    /// Kept here as the reference the "nothing above the crossover moved" claim
343    /// is measured against, rather than as a description in a comment.
344    fn v1_edges(sr: f32) -> [usize; SPECTRUM_BINS + 1] {
345        let hi = BAND_HI_HZ.min(sr * 0.45);
346        let ratio = hi / BAND_LO_HZ;
347        let bin_hz = sr / WINDOW_SIZE as f32;
348        let mut edges = [0usize; SPECTRUM_BINS + 1];
349        for (k, edge) in edges.iter_mut().enumerate() {
350            let f = BAND_LO_HZ * ratio.powf(k as f32 / SPECTRUM_BINS as f32);
351            *edge = ((f / bin_hz).round() as usize).clamp(1, MAG_BINS);
352        }
353        for k in 1..edges.len() {
354            if edges[k] <= edges[k - 1] {
355                edges[k] = (edges[k - 1] + 1).min(MAG_BINS);
356            }
357        }
358        edges
359    }
360
361    #[test]
362    fn crossover_is_where_the_short_window_stops_resolving() {
363        let layout = BandLayout::new(48_000);
364        assert_eq!(
365            layout.crossover_band(),
366            20,
367            "at 48 kHz the 2048 window resolves from band 20 up"
368        );
369        // ~246 Hz: derived from the axis, yet it lands within 2 % of the
370        // independently chosen BASS_HI_HZ = 250. Worth pinning as a fact, not
371        // as a coincidence someone might "tidy".
372        let hz = layout.crossover_hz();
373        assert!(
374            (240.0..250.0).contains(&hz),
375            "crossover should sit just under the 250 Hz bass split, got {hz}"
376        );
377
378        // Non-vacuity: every band below the crossover really is narrower than a
379        // short-window bin, and every band above really is at least as wide.
380        let short_bin_hz = SR / WINDOW_SIZE as f32;
381        for k in 0..SPECTRUM_BINS {
382            let width = layout.edges_hz[k + 1] - layout.edges_hz[k];
383            if k < layout.crossover_band() {
384                assert!(
385                    width < short_bin_hz,
386                    "band {k} width {width} should be under one {short_bin_hz} Hz bin"
387                );
388            } else {
389                assert!(
390                    width >= short_bin_hz,
391                    "band {k} width {width} should reach one {short_bin_hz} Hz bin"
392                );
393            }
394        }
395    }
396
397    #[test]
398    fn above_the_chain_every_edge_is_bit_identical_to_v1() {
399        let layout = BandLayout::new(48_000);
400        let v1 = v1_edges(SR);
401
402        // The v1 fix-up chain overshot the log curve and only died at band 32,
403        // so v1's edges for bands 20..31 were artifacts of the collapse
404        // handling rather than of the layout. From 32 up, v1 *was* the natural
405        // curve, so v2 reproduces it there bit for bit. Half the axis.
406        for k in 32..SPECTRUM_BINS {
407            let (lo, hi) = layout.bins[k];
408            assert_eq!(
409                (lo, hi),
410                (v1[k], v1[k + 1]),
411                "band {k} sits above the v1 fix-up chain and must not have moved"
412            );
413        }
414
415        // And the counter-assertion that makes the above mean something: bands
416        // 20..31 *did* move, and by far more than rounding. Without this, the
417        // test would pass just as well if the crossover had swallowed the whole
418        // axis, or if the layout had simply reproduced v1.
419        let moved: Vec<usize> = (layout.crossover_band()..SPECTRUM_BINS)
420            .filter(|&k| layout.bins[k] != (v1[k], v1[k + 1]))
421            .collect();
422        assert_eq!(
423            moved,
424            (20..32).collect::<Vec<_>>(),
425            "exactly bands 20 to 31 should have left their v1 fix-up positions"
426        );
427        assert_eq!(
428            layout.bins[20],
429            (11, 12),
430            "band 20 should sit at its natural bins 11..12, not v1's forced 21..22"
431        );
432    }
433
434    #[test]
435    fn short_region_is_never_starved_and_the_low_region_is_measured() {
436        let layout = BandLayout::new(48_000);
437        // Every band is non-empty, whichever window it came from.
438        for k in 0..SPECTRUM_BINS {
439            let (lo, hi) = layout.bins[k];
440            assert!(
441                hi > lo,
442                "band {k} must span at least one bin, got {lo}..{hi}"
443            );
444        }
445        // The long window resolves all but the bottom handful; those are the
446        // ones physics does not allow at this window length, and the number is
447        // quoted in the docs rather than left vague.
448        assert_eq!(
449            layout.starved(),
450            8,
451            "at 8192 and 48 kHz exactly 8 sub-76 Hz bands stay one bin wide"
452        );
453    }
454
455    /// **The axis holds up at the sample rates we do not develop at.**
456    ///
457    /// Every layout test above is at 48 kHz. `AudioFormat` accepts 8 kHz-384 kHz,
458    /// and foobar hands the plugin **44.1 kHz** for CD material — the single most
459    /// common rate this engine will ever see, and the one no test looked at.
460    /// ADR-0049's stated benefit is literally *"the axis stops depending on sample
461    /// rate in its bottom half"*: that is the claim, and until now it was the one
462    /// claim no test could see.
463    ///
464    /// # What this found: the crossover is not rate-independent, and should not be
465    ///
466    /// The measured figures, which this test pins:
467    ///
468    /// | rate | crossover band | crossover | bin-starved bands |
469    /// |------|----------------|-----------|-------------------|
470    /// | 44.1 kHz | 19 | ~223 Hz | 8 |
471    /// | 48 kHz | 20 | ~246 Hz | 8 |
472    /// | 96 kHz | 27 | ~487 Hz | **21** |
473    ///
474    /// **Both windows are fixed in samples, not seconds**, so at 96 kHz each one
475    /// spans half the time and resolves half the frequency detail. The crossover
476    /// therefore rides `sample_rate / WINDOW_SIZE`, and the region the long window
477    /// still cannot resolve grows from 8 bands to **21 — a third of the axis**
478    /// (the widening cascades: `fill` chains `prev_hi`, so each widened band
479    /// pushes the next one's floor up).
480    ///
481    /// That is physics working as specified rather than a defect — a higher rate
482    /// buys time resolution and spends frequency resolution — and ADR-0049's claim
483    /// survives it intact, because the claim is about the band **edges in Hz**
484    /// below the crossover, which do not move at all. But a third of the axis
485    /// reading at one-bin resolution is a real difference in what a preset's
486    /// `bin()` sees on a 96 kHz device, and it was invisible before this test.
487    /// Fixing it would mean sizing the windows in **seconds** rather than samples,
488    /// which is an ADR, not a test. Recorded here so the next person to think
489    /// about it starts from the measurement.
490    ///
491    /// 44.1 kHz — the rate that actually matters, since foobar hands the plugin
492    /// CD material — is indistinguishable from 48 kHz: one band lower, same
493    /// starved count. That half of the sweep found nothing, which is the good
494    /// outcome.
495    ///
496    /// So this asserts the invariant rather than a number: the crossover is
497    /// wherever the axis reaches one short-window bin, at every rate. The
498    /// near-`BASS_HI_HZ` coincidence the 48 kHz test pins is asserted **only at
499    /// 44.1 kHz**, where it holds and where it matters — that is the rate foobar
500    /// hands the plugin for CD material.
501    ///
502    /// It also carries the release-mode half of a claim that currently has none:
503    /// `with_windows`'s `debug_assert_eq!(short_widened, 0)` is the only guard
504    /// that the crossover really keeps the short region unstarved, **and a
505    /// `debug_assert` does not run in release**. The width check below is the same
506    /// property stated on the axis rather than on the fill, so it runs in both.
507    #[test]
508    fn the_axis_holds_at_the_rates_we_do_not_develop_at() {
509        // (rate, expected bin-starved bands) — see the table above.
510        for (sr, expect_starved) in [(44_100u32, 8usize), (48_000, 8), (96_000, 21)] {
511            let layout = BandLayout::new(sr);
512            let short_bin_hz = sr as f32 / WINDOW_SIZE as f32;
513
514            // 1. No dead stripes: an empty band reads zero in every `bin()` a
515            //    preset takes, at every level of input.
516            for k in 0..SPECTRUM_BINS {
517                let (lo, hi) = layout.bins[k];
518                assert!(
519                    hi > lo,
520                    "at {sr} Hz band {k} spans no bins ({lo}..{hi}) — a dead stripe"
521                );
522            }
523
524            // 2. The crossover is exactly where the axis reaches one short-window
525            //    bin. This is the invariant; the hertz figure it lands on is a
526            //    consequence of the rate, not a constant.
527            let width = |k: usize| layout.edges_hz[k + 1] - layout.edges_hz[k];
528            let crossover = layout.crossover_band();
529            assert!(
530                crossover > 0 && crossover < SPECTRUM_BINS,
531                "at {sr} Hz the crossover swallowed or vacated the axis: {crossover}"
532            );
533            assert!(
534                width(crossover - 1) < short_bin_hz && width(crossover) >= short_bin_hz,
535                "at {sr} Hz the crossover at band {crossover} is not the one-short-bin \
536                 boundary: widths {} then {}, bin {short_bin_hz} Hz",
537                width(crossover - 1),
538                width(crossover)
539            );
540
541            // 3. The short region's width claim, stated on the axis so it runs in
542            //    release too — see this test's doc comment.
543            for k in crossover..SPECTRUM_BINS {
544                assert!(
545                    width(k) >= short_bin_hz,
546                    "at {sr} Hz band {k} is above the crossover but only {} Hz wide, under \
547                     one {short_bin_hz} Hz short-window bin — the fill would widen it, and \
548                     only a debug_assert would notice",
549                    width(k)
550                );
551            }
552
553            // 4. The bin-starved count, pinned per rate. It rises with the rate
554            //    because both windows are fixed in samples; a change here is a
555            //    change in how much sub-bass the long window resolves, which is
556            //    the thing ADR-0049 exists to protect.
557            assert_eq!(
558                layout.starved(),
559                expect_starved,
560                "at {sr} Hz the bin-starved sub-bass region changed size"
561            );
562        }
563
564        // At the rate foobar hands the plugin for CD material, the crossover still
565        // sits just under the bass split — the same near-coincidence the 48 kHz
566        // test pins, and the reason the axis behaves the same on that path.
567        let hz = BandLayout::new(44_100).crossover_hz();
568        assert!(
569            (BASS_HI_HZ * 0.85..BASS_HI_HZ).contains(&hz),
570            "at 44.1 kHz the crossover moved to {hz} Hz, away from just under the \
571             {BASS_HI_HZ} Hz bass split"
572        );
573    }
574
575    #[test]
576    fn the_long_window_was_chosen_by_measurement() {
577        // The rule: 4096 first, 8192 only if 4096 still leaves sub-bass
578        // bands bin-starved. This records the measurement that decided it,
579        // so a later reader can re-derive the choice instead of trusting a
580        // commit message.
581        let starved_at = |long: usize| BandLayout::with_windows(SR, WINDOW_SIZE, long).starved();
582        // 4096 widens *all twenty* low bands — it buys nothing the 2048 window
583        // did not already fail at, which is what made the choice unambiguous
584        // rather than a judgement call.
585        assert_eq!(starved_at(4096), 20, "4096 resolves none of the low region");
586        assert_eq!(
587            starved_at(8192),
588            8,
589            "8192 pulls the boundary down to ~76 Hz"
590        );
591        assert_eq!(
592            starved_at(16_384),
593            0,
594            "16384 would resolve all of it, at 171 ms of group delay"
595        );
596        assert!(
597            starved_at(LOW_WINDOW_SIZE) < starved_at(4096),
598            "the shipped window must beat the one the plan tried first"
599        );
600    }
601
602    #[test]
603    fn a_tone_reads_its_amplitude_on_either_side_of_the_crossover() {
604        // The two windows carry different norms (4/N each); if they disagreed,
605        // the axis would step in level at the crossover. 120 Hz is long-window
606        // territory, 400 Hz short-window, and a 0.8 sine must read ~0.8 in both.
607        //
608        // Read as the spectrum's peak rather than the band containing the tone:
609        // near the crossover a band is only one or two bins wide, so a tone
610        // sitting at a band edge legitimately splits its energy with its
611        // neighbour. The level is the claim here, not the placement — that is
612        // `band_for_freq_agrees_with_the_edge_table`'s job.
613        let mut readings = Vec::new();
614        for freq in [120.0f32, 400.0] {
615            let mut an = SpectrumAnalyzer::new(48_000);
616            let tone = |i: usize| 0.8 * (std::f32::consts::TAU * freq * i as f32 / SR).sin();
617            let short: [f32; WINDOW_SIZE] = std::array::from_fn(tone);
618            let long: Vec<f32> = (0..LOW_WINDOW_SIZE).map(tone).collect();
619            let bands = an.analyze(&short, &long);
620
621            let (peak_band, peak) = bands
622                .iter()
623                .enumerate()
624                .max_by(|a, b| a.1.total_cmp(b.1))
625                .map(|(k, &v)| (k, v))
626                .unwrap_or((0, 0.0));
627            assert!(
628                (0.6..=1.0).contains(&peak),
629                "a 0.8 sine at {freq} Hz should peak near 0.8, got {peak} in band {peak_band}"
630            );
631            // The peak is where the tone is, give or take the edge-splitting
632            // above — so this stays a real placement check without being brittle.
633            let expected = an.band_for_freq(freq);
634            assert!(
635                peak_band.abs_diff(expected) <= 1,
636                "a {freq} Hz tone should peak at or beside band {expected}, got {peak_band}"
637            );
638            readings.push(peak);
639        }
640        // The continuity claim proper: the two windows agree on level to within
641        // Hann edge-splitting, so nothing steps at the crossover.
642        let (lo, hi) = (readings[0], readings[1]);
643        assert!(
644            (lo - hi).abs() < 0.2,
645            "the two windows must agree on a 0.8 tone's level: long read {lo}, short read {hi}"
646        );
647    }
648
649    /// Plan 0048 Phase 1 / ADR-0049: the 808-collapse reproduction, inverted.
650    ///
651    /// A tone stepped across the sub-bass and bass region must climb the axis one
652    /// band at a time instead of parking in one or two. The bound it has to beat
653    /// is **derived, not chosen**: before the dual-resolution axis every band down
654    /// here was a single short-window bin, so the region could only ever resolve
655    /// as many distinct bands as there are `sample_rate / WINDOW_SIZE` bins in it.
656    ///
657    /// Two deliberate choices about the instrument. **Stepped tones, not a glide:**
658    /// the long window integrates 171 ms, so a fast sweep is genuinely smeared
659    /// across it — the physics ADR-0049 accepts — and measuring the *axis* means
660    /// holding each frequency steady. **Read here rather than through `Analyzer`:**
661    /// band placement is a property of the *layout*, and this is the layout's
662    /// test — going through the analyzer would put its normalizer's state, warm-up
663    /// and decay in the path of a question that has nothing to do with any of them.
664    /// (The published array would in fact preserve the argmax: ADR-0049 normalizes
665    /// the whole spectrum against one shared peak, and a uniform gain keeps the
666    /// ordering. It is the per-band normalization that would have destroyed it —
667    /// the draft that ADR was written to reject.)
668    #[test]
669    fn a_tone_stepped_through_the_bass_region_climbs_distinct_bands() {
670        const LO_HZ: f32 = 40.0;
671        const HI_HZ: f32 = 200.0;
672
673        let mut an = SpectrumAnalyzer::new(48_000);
674        let peak_band_at = |an: &mut SpectrumAnalyzer, freq: f32| -> usize {
675            let tone = |i: usize| 0.8 * (std::f32::consts::TAU * freq * i as f32 / SR).sin();
676            let short: [f32; WINDOW_SIZE] = std::array::from_fn(tone);
677            let long: Vec<f32> = (0..LOW_WINDOW_SIZE).map(tone).collect();
678            an.analyze(&short, &long)
679                .iter()
680                .enumerate()
681                .max_by(|a, b| a.1.total_cmp(b.1))
682                .map(|(k, _)| k)
683                .unwrap_or(0)
684        };
685
686        let steps = 64;
687        let visited: Vec<usize> = (0..=steps)
688            .map(|i| LO_HZ + (HI_HZ - LO_HZ) * i as f32 / steps as f32)
689            .map(|f| peak_band_at(&mut an, f))
690            .collect();
691
692        // Smooth: the peak band never walks backwards as the tone rises.
693        for pair in visited.windows(2) {
694            if let [a, b] = pair {
695                assert!(
696                    b >= a,
697                    "the peak band must not fall as frequency rises: {visited:?}"
698                );
699            }
700        }
701
702        let distinct = {
703            let mut v = visited.clone();
704            v.dedup();
705            v.len()
706        };
707
708        // The v1 ceiling: one short-window bin per band meant at most this many
709        // distinct bands were reachable between LO_HZ and HI_HZ, however many log
710        // bands nominally sat there.
711        let bin_hz = SR / WINDOW_SIZE as f32;
712        let v1_ceiling = (HI_HZ / bin_hz).floor() as usize - (LO_HZ / bin_hz).floor() as usize + 1;
713        assert_eq!(
714            v1_ceiling, 8,
715            "fixture sanity: the old axis could show 8 bands across this span"
716        );
717        assert!(
718            distinct > v1_ceiling,
719            "the dual-resolution axis should resolve more than the {v1_ceiling} bands a single \
720             {WINDOW_SIZE} window could, got {distinct}: {visited:?}"
721        );
722
723        // And the span really is spread across the axis rather than nudged: most
724        // of the log bands nominally covering 40-200 Hz should appear.
725        let layout = BandLayout::new(48_000);
726        let nominal = layout.band_for_freq(HI_HZ) - layout.band_for_freq(LO_HZ) + 1;
727        assert!(
728            distinct * 2 >= nominal,
729            "40-200 Hz spans {nominal} log bands and should light up most of them, \
730             got {distinct}: {visited:?}"
731        );
732    }
733
734    #[test]
735    fn band_for_freq_agrees_with_the_edge_table() {
736        let layout = BandLayout::new(48_000);
737        for k in 0..SPECTRUM_BINS {
738            // A frequency just inside band k's span must map back to k.
739            let lo = layout.edges_hz[k];
740            let hi = layout.edges_hz[k + 1];
741            let mid = (lo + hi) * 0.5;
742            assert_eq!(
743                layout.band_for_freq(mid),
744                k,
745                "midpoint of band {k} ({mid} Hz)"
746            );
747        }
748        // Below the axis clamps to band 0 rather than wrapping or panicking.
749        assert_eq!(layout.band_for_freq(1.0), 0);
750    }
751
752    /// The frequency a preset's `bin(x)` reads, in Hz.
753    ///
754    /// `bin(x)` addresses the band array by **normalized position**: it maps `x`
755    /// to `x * (SPECTRUM_BINS - 1)` and interpolates the two adjacent bands (see
756    /// `Variables::bin`). The frequency that lands on is the geometric centre of
757    /// the band at that position — geometric because the axis is logarithmic, so
758    /// the midpoint of a band in *pitch* is the square root of the product of
759    /// its edges, not their average.
760    ///
761    /// Reads `edges_hz` deliberately. This helper must move when the layout
762    /// moves; it is the literals below that must not.
763    fn bin_hz(layout: &BandLayout, x: f32) -> f32 {
764        #[allow(clippy::manual_clamp, reason = "mirrors Variables::bin's total form")]
765        let pos = x.max(0.0).min(1.0) * (SPECTRUM_BINS - 1) as f32;
766        let k = pos.floor() as usize;
767        let frac = pos - k as f32;
768        let centre = |k: usize| (layout.edges_hz[k] * layout.edges_hz[k + 1]).sqrt();
769        let a = centre(k.min(SPECTRUM_BINS - 1));
770        let b = centre((k + 1).min(SPECTRUM_BINS - 1));
771        // Interpolated in log space, matching the axis the two centres sit on.
772        a * (b / a).powf(frac)
773    }
774
775    /// The external anchor the axis had none of (ADR-0063).
776    ///
777    /// `band_for_freq_agrees_with_the_edge_table` above checks the lookup
778    /// function against the edge table — but Plan 0048 Phase 1 moved **both**,
779    /// together, and it passed through the rebuild unchanged. Two sources that
780    /// agree on every configuration we test cannot tell you which one the
781    /// *content* depended on. These literals can, because they are not derived
782    /// from either: they were measured on 2026-08-03 and written down.
783    ///
784    /// **If this test fails, nothing here is broken — the axis was relaid.** The
785    /// obligation it creates is a content sweep: every `bin()` position in
786    /// `presets/` now reads a different frequency than it did, and each one has
787    /// to be re-checked against the frequency its author's comment names. That
788    /// is exactly what went unnoticed in Plan 0048: `fragment_aurora`'s low
789    /// probe was `bin(0.14)` chosen for ~246 Hz, and the rebuilt axis turned it
790    /// into a ~84 Hz kick probe, inverting the one property the preset exists to
791    /// have. Update the literals **after** the sweep, not instead of it.
792    #[test]
793    fn bin_positions_resolve_to_the_frequencies_the_presets_were_written_against() {
794        let layout = BandLayout::new(48_000);
795        // (position, Hz) — measured, not computed. See the note above before
796        // touching the right-hand column.
797        let anchors = [
798            (0.00f32, 36.7f32),
799            // The two ADR-0063 names as the concrete damage: on the rebuilt axis
800            // `attractor_dejong`'s `bin(0.10)` came to read the ~65 Hz its own
801            // header calls out as the mistake, and `fragment_aurora`'s
802            // `bin(0.14)` — chosen for the ~246 Hz low-mid — became a kick probe.
803            (0.10, 67.9),
804            (0.14, 86.9),
805            (0.20, 125.6),
806            // The position that actually reads that ~246 Hz low-mid today.
807            (0.31, 246.9),
808            (0.50, 793.7),
809            (0.84, 6413.2),
810            (1.00, 17143.2),
811        ];
812        for (x, want) in anchors {
813            let got = bin_hz(&layout, x);
814            assert!(
815                (got / want - 1.0).abs() < 0.005,
816                "bin({x}) resolved to {got:.1} Hz, and this axis was pinned at {want:.1} Hz. \
817                 The layout moved: every bin() in presets/ needs re-checking against the \
818                 frequency its author named before these literals are updated"
819            );
820        }
821    }
822}