Skip to main content

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}