Skip to main content

rlx_core/dsp/
gain.rs

1//! Running normalization: turns raw magnitudes into "loud relative to this
2//! track's recent past" (ADR-0049).
3//!
4//! Raw band means on real music sit at 0.006-0.040 while the authoring stimuli
5//! reached 0.187-0.8, so every threshold in the shipped library was a magic
6//! number against a table that moved three times in one week. Dividing each
7//! signal by its own slowly-decaying running peak makes `> 0.5` mean the same
8//! thing on every track, at every gain setting, on every stimulus.
9//!
10//! Three properties, each pinned by a test rather than by a comment:
11//!
12//! - **Instant attack.** A new peak is adopted on the hop it arrives, so a hit
13//!   reads high immediately instead of fading in.
14//! - **Slow release.** The peak decays with a seconds-scale time constant, so a
15//!   quiet passage lifts gradually rather than pumping bar to bar.
16//! - **Silence floor.** Below a floor the output is *zero*, not amplified noise
17//!   — the difference between a quiet room and a loud one must not be a
18//!   full-scale visual.
19//!
20//! **The ceiling is reached routinely, and that is what these properties buy.**
21//! The reading is `raw / peak` against the signal's *own* peak, so it is exactly
22//! 1.0 on any hop that is the loudest since the peak last released — on periodic
23//! material, every kick, at any input level. It is scale-invariant for the same
24//! reason: halve the input and both terms halve, so no input gain moves it. A
25//! consumer that wants a magnitude rather than an excitation must read the raw
26//! value beside it; one that reads a levelled scalar as a dimmer sees a term
27//! pinned at its ceiling and no gain control that can unpin it.
28//!
29//! Pure and allocation-free after construction: state is a fixed set of floats
30//! and every step is arithmetic on the input, so the same sequence always yields
31//! the same output (NFR section 6).
32//!
33//! **What is levelled here**, each against its own running peak: the 64-band
34//! `spectrum` array ([`BandNormalizer`], one shared peak so the array's internal
35//! ratios survive), the `bass`/`mid`/`treb` scalars and the `onset` envelope
36//! ([`PeakNormalizer`]), and the `waveform` trace ([`TraceNormalizer`],
37//! ADR-0139). The trace is the odd one of the set on two counts: it is a signal
38//! rather than a magnitude, so it is divided rather than rectified and clamps to
39//! `-1..=1`; and its divisor is **published** as `AnalysisFrame::waveform_gain`
40//! rather than discarded, making it the one levelled output whose raw amplitude
41//! a consumer can reconstruct. Levelling it is what makes the two frontends
42//! agree, since the plugin taps its stream before the output volume and the
43//! standalone taps loopback after it.
44//!
45//! **Where this sits matters.** Normalization is applied at the *published*
46//! frame boundary only. The onset detector, the tempo tracker and the novelty
47//! detector all keep reading raw values, because each is tuned against raw
48//! magnitudes and would be actively harmed by an AGC: autocorrelating a
49//! peak-normalized envelope distorts the very periodicity the tempo tracker
50//! looks for, and per-band normalization flattens exactly the spectral-shape
51//! difference novelty exists to measure.
52
53// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
54#![deny(
55    clippy::unwrap_used,
56    clippy::expect_used,
57    clippy::indexing_slicing,
58    clippy::panic,
59    clippy::unreachable
60)]
61
62use super::{HOP_SIZE, SPECTRUM_BINS, WAVE_SAMPLES};
63
64/// Release time constant of the running peak, in seconds.
65///
66/// **Provisional**: ADR-0049 fixes the *properties* and leaves the feel to Plan
67/// 0048 Phase 6's listening test, which is the phase allowed to move this. Too
68/// fast reads as pumping (the level chases every bar), too slow as numbness (a
69/// quiet section never recovers). 2.5 s is a few musical bars at ordinary
70/// tempos, which is the scale "recent past" should mean.
71const RELEASE_TAU_SECS: f32 = 2.5;
72
73/// Silence floor for the band scalars and the 64-band array.
74///
75/// **The floor is the one place an absolute magnitude survives**, so it is the
76/// one place gain-portability can break: a band whose running peak sits under
77/// the floor reads 0 where a louder copy of the same track reads 1. That makes
78/// the margin, not the value, the thing to get right.
79///
80/// Measured against `signal::dynamic_groove`: the three band scalars peak at
81/// 0.020-0.109 and **every one** of the 64 bands peaks above 0.011. At 1e-4 that
82/// is a 110x-1000x margin, so the same material still clears the floor a decade
83/// *below* -20 dB. An earlier draft used 1e-3 and was wrong for exactly this
84/// reason — only ~6x over real mid/treb means, so a -20 dB track lost its mid
85/// and treble entirely and read as bass-only, defeating the portability this
86/// whole change exists to buy.
87///
88/// Downward, a -80 dBFS room noise floor spreads its energy across bins and
89/// lands well under 1e-4, so it is still suppressed rather than amplified.
90pub const BAND_FLOOR: f32 = 1e-4;
91
92/// Silence floor for `onset`. An order of magnitude lower because spectral flux
93/// is an order of magnitude smaller: the same groove peaks at 0.0167 with a
94/// 0.0016 mean, so this keeps the same ~1000x margin.
95pub const ONSET_FLOOR: f32 = 1e-5;
96
97/// Per-hop release coefficient for `RELEASE_TAU_SECS` at `sample_rate`.
98fn release_per_hop(sample_rate: u32) -> f32 {
99    let hop_dt = HOP_SIZE as f32 / sample_rate.max(1) as f32;
100    (-hop_dt / RELEASE_TAU_SECS).exp()
101}
102
103/// One signal's running peak, and the normalized reading it produces.
104///
105/// Instant attack, exponential release, floored. Kept a struct rather than a
106/// closure so the 64-band variant can share the step exactly.
107pub struct PeakNormalizer {
108    peak: f32,
109    release: f32,
110    floor: f32,
111}
112
113impl PeakNormalizer {
114    /// A normalizer for `sample_rate`, reporting zero until the signal clears
115    /// `floor`.
116    pub fn new(sample_rate: u32, floor: f32) -> Self {
117        Self {
118            peak: 0.0,
119            release: release_per_hop(sample_rate),
120            floor,
121        }
122    }
123
124    /// Advance one hop and return `raw` as a 0..1 fraction of its recent peak.
125    pub fn normalize(&mut self, raw: f32) -> f32 {
126        step(&mut self.peak, raw, self.release, self.floor)
127    }
128}
129
130/// The 64-band array's normalizer: **one** running peak, tracking the loudest
131/// band, applied as a uniform gain across the whole array.
132///
133/// One shared peak rather than 64 independent ones, and the reason is that the
134/// array is a *spectrum* — its values only mean anything relative to each other.
135/// Normalizing each band against its own peak makes every band that is not
136/// literally silent climb to full scale: a pure tone's Hann leakage four bands
137/// out, some 60 dB down, would report 1.0 because that leakage is its own recent
138/// maximum. Two things break at once. The array stops describing spectral shape,
139/// and how many bands light up becomes a function of the silence floor — an
140/// absolute magnitude, which is the exact dependence ADR-0049 exists to remove.
141///
142/// It would also have silently destroyed the timbre idiom two shipped presets
143/// are built on: `attractor_clifford` and `fragment_aurora` both read
144/// `bin(0.84) - bin(0.14)` as a contrast between two probes. Per-band
145/// normalization leaves both terms near their own peaks, so the difference
146/// degenerates to noise — information destroyed in the analyzer, where no
147/// preset-level retune could recover it.
148///
149/// A uniform gain keeps every ratio in the array exact while still making
150/// thresholds portable: `bin(x) > 0.5` means "half as loud as this track's
151/// recent loudest band", on any track at any gain.
152///
153/// This is a deliberate deviation from Plan 0048 Phase 2's "per-band and
154/// per-scalar" wording and ADR-0049's diagram. The four *scalars* do keep
155/// independent peaks — they are separate signals, not one distribution.
156pub struct BandNormalizer {
157    peak: f32,
158    release: f32,
159    floor: f32,
160}
161
162impl BandNormalizer {
163    /// A normalizer for the whole band array at `sample_rate`.
164    pub fn new(sample_rate: u32) -> Self {
165        Self {
166            peak: 0.0,
167            release: release_per_hop(sample_rate),
168            floor: BAND_FLOOR,
169        }
170    }
171
172    /// Advance one hop, normalizing `bands` in place against their shared peak.
173    pub fn normalize(&mut self, bands: &mut [f32; SPECTRUM_BINS]) {
174        let loudest = bands
175            .iter()
176            .copied()
177            .filter(|v| v.is_finite())
178            .fold(0.0f32, f32::max);
179        match advance(&mut self.peak, loudest, self.release, self.floor) {
180            Some(peak) => {
181                for band in bands.iter_mut() {
182                    let raw = if band.is_finite() { band.max(0.0) } else { 0.0 };
183                    *band = (raw / peak).clamp(0.0, 1.0);
184                }
185            }
186            // Under the floor the whole array is silence, not something to
187            // amplify into a full-scale display of a quiet room.
188            None => *bands = [0.0; SPECTRUM_BINS],
189        }
190    }
191}
192
193/// Advance a running peak one hop: adopt a louder value instantly, release
194/// exponentially otherwise. `None` while the peak sits at or under `floor`,
195/// which is the caller's cue to report silence rather than divide.
196///
197/// Non-finite input is treated as silence rather than propagated. A NaN reaching
198/// `peak` would be absorbing — `raw > released` is false for every subsequent
199/// `raw`, so the peak would never recover and the signal would be dead for the
200/// rest of the run. Plan 0038 Phase 9 paid for that lesson in `Easing::step`.
201fn advance(peak: &mut f32, raw: f32, release: f32, floor: f32) -> Option<f32> {
202    let raw = if raw.is_finite() { raw.max(0.0) } else { 0.0 };
203    let released = *peak * release;
204    *peak = if raw > released { raw } else { released };
205    if *peak <= floor { None } else { Some(*peak) }
206}
207
208/// One signal's normalized reading, sharing [`advance`]'s state machine.
209fn step(peak: &mut f32, raw: f32, release: f32, floor: f32) -> f32 {
210    let clean = if raw.is_finite() { raw.max(0.0) } else { 0.0 };
211    match advance(peak, raw, release, floor) {
212        Some(p) => (clean / p).clamp(0.0, 1.0),
213        None => 0.0,
214    }
215}
216
217/// Silence floor for the waveform trace, in **signal amplitude** — a different
218/// quantity from [`BAND_FLOOR`], which is a band mean, and deliberately an order
219/// of magnitude above it.
220///
221/// A band mean spreads a signal's energy across bins, so a -80 dBFS room lands
222/// well under `1e-4` there. A time-domain peak does no such spreading: -80 dBFS
223/// **is** an amplitude of `1e-4`, so `BAND_FLOOR` copied here would sit exactly
224/// at room noise and amplify it to a full-scale trace — the one failure a floor
225/// exists to prevent.
226///
227/// Measured against `signal::dynamic_groove` at 48 kHz, per 120 bpm beat: the
228/// trace peaks at `0.900` through the loud phrase and at `0.206` in the two
229/// resting beats, whose `0.04` phrase scale is the quietest material the
230/// stimulus contains. So `1e-3` (-60 dBFS) leaves the quietest real material
231/// **206x** clear of the floor, and the same material a decade down the fader
232/// still **21x** clear — levelled rather than zeroed. Downward it suppresses a
233/// -80 dBFS room by a decade and 16-bit dither (LSB `3e-5`) by two.
234///
235/// `the_floor_clears_real_material_by_two_orders_of_magnitude` is the margin,
236/// asserted as a ratio rather than as this paragraph's readings.
237pub const WAVE_FLOOR: f32 = 1e-3;
238
239/// The waveform's normalizer: **one** running peak of the trace's magnitude,
240/// applied as a uniform gain to the whole trace.
241///
242/// One shared peak for the same reason [`BandNormalizer`] uses one — the samples
243/// of a trace only mean anything relative to each other, and a per-sample gain
244/// would erase the shape the trace exists to show. Two differences from that
245/// type, both forced by the quantity:
246///
247/// - **The peak tracks `|x|` and the output stays signed.** A trace is a signal,
248///   not a magnitude, so it is divided rather than rectified and clamps to
249///   `-1..=1` instead of `0..=1`.
250/// - **The divisor is returned rather than discarded**, because it is published
251///   as `AnalysisFrame::waveform_gain` (ADR-0139): the raw amplitude is
252///   `waveform[i] * waveform_gain`, which is the escape hatch a consumer that
253///   genuinely wants absolute level reaches for.
254pub struct TraceNormalizer {
255    peak: f32,
256    release: f32,
257    floor: f32,
258}
259
260impl TraceNormalizer {
261    /// A normalizer for the trace at `sample_rate`.
262    pub fn new(sample_rate: u32) -> Self {
263        Self {
264            peak: 0.0,
265            release: release_per_hop(sample_rate),
266            floor: WAVE_FLOOR,
267        }
268    }
269
270    /// Advance one hop, normalizing `trace` in place, and return the divisor
271    /// removed — `0.0` while the tracked peak sits under the floor, where the
272    /// trace is zeroed rather than amplified.
273    pub fn normalize(&mut self, trace: &mut [f32; WAVE_SAMPLES]) -> f32 {
274        let loudest = trace
275            .iter()
276            .copied()
277            .filter(|v| v.is_finite())
278            .fold(0.0f32, |m, v| m.max(v.abs()));
279        match advance(&mut self.peak, loudest, self.release, self.floor) {
280            Some(peak) => {
281                for sample in trace.iter_mut() {
282                    let raw = if sample.is_finite() { *sample } else { 0.0 };
283                    *sample = (raw / peak).clamp(-1.0, 1.0);
284                }
285                peak
286            }
287            None => {
288                *trace = [0.0; WAVE_SAMPLES];
289                0.0
290            }
291        }
292    }
293
294    /// The same, over **two** traces at once and with **one** divisor tracked
295    /// over the larger of their magnitudes.
296    ///
297    /// One divisor rather than two, and that is the whole reason this exists
298    /// beside [`normalize`](Self::normalize): a hard-panned signal has a silent
299    /// side, and levelling that side against its own recent peak would amplify
300    /// whatever noise is in it up to full scale. The pair would then read as two
301    /// loud channels, and an x-y figure built from them would lose the aspect
302    /// that says where the sound is.
303    pub fn normalize_pair(
304        &mut self,
305        left: &mut [f32; WAVE_SAMPLES],
306        right: &mut [f32; WAVE_SAMPLES],
307    ) -> f32 {
308        let loudest = left
309            .iter()
310            .chain(right.iter())
311            .copied()
312            .filter(|v| v.is_finite())
313            .fold(0.0f32, |m, v| m.max(v.abs()));
314        match advance(&mut self.peak, loudest, self.release, self.floor) {
315            Some(peak) => {
316                for trace in [left, right] {
317                    for sample in trace.iter_mut() {
318                        let raw = if sample.is_finite() { *sample } else { 0.0 };
319                        *sample = (raw / peak).clamp(-1.0, 1.0);
320                    }
321                }
322                peak
323            }
324            None => {
325                *left = [0.0; WAVE_SAMPLES];
326                *right = [0.0; WAVE_SAMPLES];
327                0.0
328            }
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335
336    use super::*;
337
338    const SR: u32 = 48_000;
339    /// Hops per second at `SR` — the release is specified in seconds, so the
340    /// tests count in seconds too.
341    const HOPS_PER_SEC: usize = SR as usize / HOP_SIZE;
342
343    #[test]
344    fn a_new_peak_is_adopted_on_the_hop_it_arrives() {
345        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
346        // Instant attack: the very first loud hop already reads full scale, so a
347        // kick is not a fade-in.
348        assert_eq!(n.normalize(0.5), 1.0);
349        // And a *louder* hop still reads 1.0 rather than overshooting.
350        assert_eq!(n.normalize(0.9), 1.0);
351    }
352
353    #[test]
354    fn the_peak_releases_over_seconds_not_hops() {
355        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
356        n.normalize(1.0);
357        // A quiet-but-audible signal right after a peak reads low...
358        let immediately = n.normalize(0.2);
359        assert!(
360            immediately < 0.25,
361            "just after a peak, 0.2 should still read low, got {immediately}"
362        );
363        // ...and the same signal reads high once the peak has released. One tau
364        // is a factor of e, so hold for a few and it must be most of the way.
365        for _ in 0..(4 * HOPS_PER_SEC) {
366            n.normalize(0.2);
367        }
368        let later = n.normalize(0.2);
369        assert!(
370            later > 0.9,
371            "after 4 s of 0.2 the peak should have released to it, got {later}"
372        );
373
374        // Non-vacuity on the *time scale*: the release must not be so fast that
375        // it happens within a fraction of a second, which is what "seconds" is
376        // guarding against. A fresh normalizer, one peak, then a tenth of a
377        // second of quiet.
378        let mut fast = PeakNormalizer::new(SR, BAND_FLOOR);
379        fast.normalize(1.0);
380        for _ in 0..(HOPS_PER_SEC / 10) {
381            fast.normalize(0.2);
382        }
383        let after_100ms = fast.normalize(0.2);
384        assert!(
385            after_100ms < 0.3,
386            "a seconds-scale release must barely move in 100 ms, got {after_100ms}"
387        );
388    }
389
390    #[test]
391    fn silence_reads_zero_and_room_noise_is_not_amplified() {
392        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
393        // True silence: zero in, zero out, for as long as you like.
394        for _ in 0..(3 * HOPS_PER_SEC) {
395            assert_eq!(n.normalize(0.0), 0.0);
396        }
397        // Low-level noise well under the floor stays at zero rather than being
398        // lifted to full scale — the whole point of the floor.
399        let mut noisy = PeakNormalizer::new(SR, BAND_FLOOR);
400        let mut seen: f32 = 0.0;
401        for i in 0..(3 * HOPS_PER_SEC) {
402            // Deterministic pseudo-noise around 1e-5, an order under the floor.
403            let dust = 1e-5 * (1.0 + 0.5 * (i as f32 * 0.7).sin());
404            seen = seen.max(noisy.normalize(dust));
405        }
406        assert_eq!(
407            seen, 0.0,
408            "sub-floor dust must never be amplified, peaked at {seen}"
409        );
410
411        // Counter-assertion: the floor is not simply swallowing everything.
412        // Content an order of magnitude above it normalizes as usual.
413        let mut real = PeakNormalizer::new(SR, BAND_FLOOR);
414        assert_eq!(real.normalize(1e-3), 1.0);
415    }
416
417    #[test]
418    fn the_same_dynamics_normalize_alike_at_any_absolute_level() {
419        // The portability property, and the reason the whole change is worth a
420        // library retune: the *shape* of the level over time is what survives,
421        // not the gain it arrived at. A steady tone would pass this trivially
422        // (everything steady reads 1.0), so the fixture has real dynamics.
423        let pattern: Vec<f32> = (0..(6 * HOPS_PER_SEC))
424            .map(|i| {
425                let t = i as f32 / HOPS_PER_SEC as f32;
426                // A slow swell with a beat riding on it.
427                (0.35 + 0.3 * (t * 0.8).sin()) * (1.0 + 0.6 * (t * 6.0).sin().max(0.0))
428            })
429            .collect();
430
431        let run = |gain: f32| -> Vec<f32> {
432            let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
433            pattern.iter().map(|v| n.normalize(v * gain)).collect()
434        };
435
436        let full = run(1.0);
437        // -20 dB is a factor of 10 in amplitude.
438        let quiet = run(0.1);
439        let worst = full
440            .iter()
441            .zip(quiet.iter())
442            .map(|(a, b)| (a - b).abs())
443            .fold(0.0f32, f32::max);
444        assert!(
445            worst < 1e-5,
446            "a -20 dB copy must normalize to the same series, worst divergence {worst}"
447        );
448
449        // And the series is genuinely varied, so the agreement above is not two
450        // constant runs matching.
451        let spread = full.iter().copied().fold(0.0f32, f32::max)
452            - full.iter().copied().fold(1.0f32, f32::min);
453        assert!(
454            spread > 0.4,
455            "fixture should exercise a real range, got {spread}"
456        );
457    }
458
459    #[test]
460    fn the_array_keeps_its_shape_under_one_shared_peak() {
461        let mut n = BandNormalizer::new(SR);
462        // Band 1 loud, band 2 a hundredth of it. The ratio between them is the
463        // spectrum's whole content, so it has to survive normalization exactly.
464        let mut bands = [0.0f32; SPECTRUM_BINS];
465        for _ in 0..HOPS_PER_SEC {
466            bands = [0.0; SPECTRUM_BINS];
467            if let Some(loud) = bands.get_mut(1) {
468                *loud = 0.5;
469            }
470            if let Some(quiet) = bands.get_mut(2) {
471                *quiet = 0.005;
472            }
473            n.normalize(&mut bands);
474        }
475        assert_eq!(
476            bands.get(1).copied(),
477            Some(1.0),
478            "the loudest band anchors at 1.0"
479        );
480        assert_eq!(
481            bands.get(2).copied(),
482            Some(0.01),
483            "a band a hundredth as loud must still read a hundredth — per-band \
484             normalization would have lifted it to 1.0 and destroyed the contrast"
485        );
486        assert_eq!(
487            bands.get(3).copied(),
488            Some(0.0),
489            "a silent band stays silent"
490        );
491    }
492
493    #[test]
494    fn a_bin_contrast_survives_normalization() {
495        // The property two shipped presets depend on: `bin(hi) - bin(lo)` as a
496        // timbre signal. Under a shared peak the difference is preserved up to
497        // the gain; under per-band peaks it would collapse toward zero because
498        // both probes would sit at their own maxima.
499        let mut n = BandNormalizer::new(SR);
500        let mut last = [0.0f32; SPECTRUM_BINS];
501        for _ in 0..HOPS_PER_SEC {
502            last = [0.0; SPECTRUM_BINS];
503            // A bright frame: high probe well above the low one.
504            if let Some(lo) = last.get_mut(10) {
505                *lo = 0.02;
506            }
507            if let Some(hi) = last.get_mut(50) {
508                *hi = 0.08;
509            }
510            n.normalize(&mut last);
511        }
512        let contrast = last.get(50).copied().unwrap_or(0.0) - last.get(10).copied().unwrap_or(0.0);
513        assert!(
514            (contrast - 0.75).abs() < 1e-6,
515            "the 0.08-vs-0.02 contrast should normalize to 0.75, got {contrast}"
516        );
517    }
518
519    #[test]
520    fn a_non_finite_input_cannot_poison_the_peak() {
521        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
522        n.normalize(0.5);
523        assert_eq!(n.normalize(f32::NAN), 0.0);
524        assert_eq!(n.normalize(f32::INFINITY), 0.0);
525        // Recovery is the real claim: a poisoned peak would leave every later
526        // hop dead for the rest of the run.
527        assert_eq!(n.normalize(0.5), 1.0);
528    }
529
530    /// A trace of `WAVE_SAMPLES` at `peak`, shaped so it has both signs and a
531    /// structure a uniform gain must preserve.
532    fn trace_at(peak: f32) -> [f32; WAVE_SAMPLES] {
533        std::array::from_fn(|i| {
534            let t = i as f32 / WAVE_SAMPLES as f32;
535            peak * (std::f32::consts::TAU * 3.0 * t).sin() * (0.4 + 0.6 * t)
536        })
537    }
538
539    /// The floor is an **amplitude**, so the margin that matters is against the
540    /// quietest passage of real material rather than against a band mean.
541    ///
542    /// `dynamic_groove`'s resting beats are its `0.04` phrase scale — the
543    /// quietest thing it contains — and they must stay far enough over the floor
544    /// that the same material a decade down the fader is still levelled instead
545    /// of zeroed. Asserted as ratios; the readings themselves are in
546    /// [`WAVE_FLOOR`]'s derivation.
547    #[test]
548    fn the_floor_clears_real_material_by_two_orders_of_magnitude() {
549        let format = crate::audio::AudioFormat {
550            sample_rate: SR,
551            channels: 1,
552        };
553        let pcm = crate::signal::dynamic_groove(120.0, 8.0, format);
554        let beat = SR as usize / 2;
555        let quietest = pcm
556            .chunks(beat)
557            .map(|c| c.iter().fold(0.0f32, |m, v| m.max(v.abs())))
558            .fold(f32::INFINITY, f32::min);
559        assert!(
560            quietest > 100.0 * WAVE_FLOOR,
561            "the quietest beat peaks at {quietest}, only {}x the floor",
562            quietest / WAVE_FLOOR
563        );
564        // ...and a decade down the fader it is still material, not silence.
565        assert!(
566            quietest * 0.1 > 10.0 * WAVE_FLOOR,
567            "a decade down, the quietest beat is {}x the floor",
568            quietest * 0.1 / WAVE_FLOOR
569        );
570    }
571
572    /// The whole point: the input gain cancels.
573    ///
574    /// The normalizer divides by a peak that scales with its input, so it is
575    /// homogeneous of degree zero above the floor — the same signal at any
576    /// fader position produces the same trace. Not bit-exact for an arbitrary
577    /// `k`, because `(k*x)/(k*p)` rounds differently from `x/p`; exact for a
578    /// power of two, which is asserted separately.
579    #[test]
580    fn a_trace_normalizes_to_the_same_shape_at_any_input_gain() {
581        let reference = {
582            let mut t = trace_at(0.9);
583            TraceNormalizer::new(SR).normalize(&mut t);
584            t
585        };
586        for k in [0.18f32, 0.4, 3.0] {
587            let mut scaled = trace_at(0.9 * k);
588            let gain = TraceNormalizer::new(SR).normalize(&mut scaled);
589            assert!(
590                gain > 0.0,
591                "gain 0 at k={k}: the stimulus fell under the floor"
592            );
593            let worst = reference
594                .iter()
595                .zip(scaled.iter())
596                .fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
597            assert!(worst < 1e-6, "k={k} moved the trace by {worst}");
598        }
599        let mut halved = trace_at(0.45);
600        TraceNormalizer::new(SR).normalize(&mut halved);
601        assert_eq!(
602            halved, reference,
603            "a power-of-two gain change must cancel exactly"
604        );
605    }
606
607    /// Silence is reported as silence rather than amplified into a full-scale
608    /// display of a quiet room — the same rule [`BAND_FLOOR`] holds for the band
609    /// array, at an amplitude the floor's own derivation names.
610    #[test]
611    fn a_sub_floor_trace_reads_as_silence_and_publishes_no_gain() {
612        let mut silent = [0.0f32; WAVE_SAMPLES];
613        assert_eq!(TraceNormalizer::new(SR).normalize(&mut silent), 0.0);
614        assert!(silent.iter().all(|v| *v == 0.0));
615
616        let mut whisper = trace_at(WAVE_FLOOR * 0.5);
617        assert_eq!(TraceNormalizer::new(SR).normalize(&mut whisper), 0.0);
618        assert!(
619            whisper.iter().all(|v| *v == 0.0),
620            "a sub-floor trace must be zeroed, not scaled up"
621        );
622    }
623
624    /// The published divisor is a real escape hatch: multiplying it back gives
625    /// the amplitude the analyzer read (ADR-0139).
626    #[test]
627    fn the_published_gain_reconstructs_the_raw_trace() {
628        let raw = trace_at(0.62);
629        let mut normalized = raw;
630        let gain = TraceNormalizer::new(SR).normalize(&mut normalized);
631        let worst = raw
632            .iter()
633            .zip(normalized.iter())
634            .fold(0.0f32, |m, (r, n)| m.max((r - n * gain).abs()));
635        assert!(worst < 1e-6, "reconstruction is off by {worst}");
636    }
637
638    /// A NaN sample must not become an absorbing peak — the trap `advance`'s
639    /// doc names, reached here through the trace's own magnitude scan.
640    #[test]
641    fn a_non_finite_sample_does_not_poison_the_running_peak() {
642        let mut n = TraceNormalizer::new(SR);
643        let mut poisoned = trace_at(0.5);
644        poisoned[7] = f32::NAN;
645        let gain = n.normalize(&mut poisoned);
646        assert!(gain.is_finite() && gain > 0.0, "gain went bad: {gain}");
647        assert!(poisoned.iter().all(|v| v.is_finite()));
648        let mut after = trace_at(0.5);
649        assert!(n.normalize(&mut after) > 0.0, "the peak never recovered");
650    }
651}