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}