rlx_core/dsp/bands.rs
1//! Band-energy split: fixed Hz cutoffs over the linear FFT magnitudes into
2//! three mean energies — bass, mid, treble — for scenes and presets.
3//!
4//! Pure and allocation-free after construction: the linear-bin range of each
5//! band is precomputed from the sample rate, and each split is an iterator
6//! mean over those bins, so the same magnitudes always yield the same triple
7//! (determinism, NFR 6).
8
9// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18use super::WINDOW_SIZE;
19
20/// Linear magnitude bins available (one-sided spectrum).
21const MAG_BINS: usize = WINDOW_SIZE / 2;
22/// Band cutoffs in Hz. Bass starts above DC/rumble; treble tops out below the
23/// air band and is clamped to Nyquist on low sample rates.
24const BASS_LO_HZ: f32 = 20.0;
25/// `pub(crate)` so `fft`'s layout tests can assert the dual-resolution crossover
26/// still lands near the bass split at other sample rates, against the constant
27/// itself rather than a copy of the number.
28pub(crate) const BASS_HI_HZ: f32 = 250.0;
29const MID_HI_HZ: f32 = 4_000.0;
30const TREB_HI_HZ: f32 = 18_000.0;
31
32/// Precomputed `(lo, hi)` linear-bin ranges (half-open) for the three bands.
33pub struct BandSplitter {
34 bass: (usize, usize),
35 mid: (usize, usize),
36 treb: (usize, usize),
37}
38
39impl BandSplitter {
40 /// Precompute the per-band bin ranges for `sample_rate`.
41 pub fn new(sample_rate: u32) -> Self {
42 let bin_hz = sample_rate as f32 / WINDOW_SIZE as f32;
43 // Bin nearest a frequency, kept in [1, MAG_BINS] (bin 0 is DC).
44 let bin = |hz: f32| ((hz / bin_hz).round() as usize).clamp(1, MAG_BINS);
45 let bass_lo = bin(BASS_LO_HZ);
46 let bass_hi = bin(BASS_HI_HZ);
47 let mid_hi = bin(MID_HI_HZ);
48 let treb_hi = bin(TREB_HI_HZ);
49 Self {
50 bass: (bass_lo, bass_hi),
51 mid: (bass_hi, mid_hi),
52 treb: (mid_hi, treb_hi),
53 }
54 }
55
56 /// `(bass, mid, treb)` mean magnitude over each band's linear bins.
57 pub fn split(&self, mags: &[f32]) -> (f32, f32, f32) {
58 (
59 band_mean(mags, self.bass),
60 band_mean(mags, self.mid),
61 band_mean(mags, self.treb),
62 )
63 }
64}
65
66/// Mean of `mags` over the half-open bin range `[lo, hi)`, via iterators so no
67/// indexing pragma escape is needed. Empty range reads 0.
68fn band_mean(mags: &[f32], (lo, hi): (usize, usize)) -> f32 {
69 let n = hi.saturating_sub(lo);
70 if n == 0 {
71 return 0.0;
72 }
73 let sum: f32 = mags.iter().skip(lo).take(n).sum();
74 sum / n as f32
75}