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
295#[cfg(test)]
296mod tests {
297
298    use super::*;
299
300    const SR: u32 = 48_000;
301    /// Hops per second at `SR` — the release is specified in seconds, so the
302    /// tests count in seconds too.
303    const HOPS_PER_SEC: usize = SR as usize / HOP_SIZE;
304
305    #[test]
306    fn a_new_peak_is_adopted_on_the_hop_it_arrives() {
307        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
308        // Instant attack: the very first loud hop already reads full scale, so a
309        // kick is not a fade-in.
310        assert_eq!(n.normalize(0.5), 1.0);
311        // And a *louder* hop still reads 1.0 rather than overshooting.
312        assert_eq!(n.normalize(0.9), 1.0);
313    }
314
315    #[test]
316    fn the_peak_releases_over_seconds_not_hops() {
317        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
318        n.normalize(1.0);
319        // A quiet-but-audible signal right after a peak reads low...
320        let immediately = n.normalize(0.2);
321        assert!(
322            immediately < 0.25,
323            "just after a peak, 0.2 should still read low, got {immediately}"
324        );
325        // ...and the same signal reads high once the peak has released. One tau
326        // is a factor of e, so hold for a few and it must be most of the way.
327        for _ in 0..(4 * HOPS_PER_SEC) {
328            n.normalize(0.2);
329        }
330        let later = n.normalize(0.2);
331        assert!(
332            later > 0.9,
333            "after 4 s of 0.2 the peak should have released to it, got {later}"
334        );
335
336        // Non-vacuity on the *time scale*: the release must not be so fast that
337        // it happens within a fraction of a second, which is what "seconds" is
338        // guarding against. A fresh normalizer, one peak, then a tenth of a
339        // second of quiet.
340        let mut fast = PeakNormalizer::new(SR, BAND_FLOOR);
341        fast.normalize(1.0);
342        for _ in 0..(HOPS_PER_SEC / 10) {
343            fast.normalize(0.2);
344        }
345        let after_100ms = fast.normalize(0.2);
346        assert!(
347            after_100ms < 0.3,
348            "a seconds-scale release must barely move in 100 ms, got {after_100ms}"
349        );
350    }
351
352    #[test]
353    fn silence_reads_zero_and_room_noise_is_not_amplified() {
354        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
355        // True silence: zero in, zero out, for as long as you like.
356        for _ in 0..(3 * HOPS_PER_SEC) {
357            assert_eq!(n.normalize(0.0), 0.0);
358        }
359        // Low-level noise well under the floor stays at zero rather than being
360        // lifted to full scale — the whole point of the floor.
361        let mut noisy = PeakNormalizer::new(SR, BAND_FLOOR);
362        let mut seen: f32 = 0.0;
363        for i in 0..(3 * HOPS_PER_SEC) {
364            // Deterministic pseudo-noise around 1e-5, an order under the floor.
365            let dust = 1e-5 * (1.0 + 0.5 * (i as f32 * 0.7).sin());
366            seen = seen.max(noisy.normalize(dust));
367        }
368        assert_eq!(
369            seen, 0.0,
370            "sub-floor dust must never be amplified, peaked at {seen}"
371        );
372
373        // Counter-assertion: the floor is not simply swallowing everything.
374        // Content an order of magnitude above it normalizes as usual.
375        let mut real = PeakNormalizer::new(SR, BAND_FLOOR);
376        assert_eq!(real.normalize(1e-3), 1.0);
377    }
378
379    #[test]
380    fn the_same_dynamics_normalize_alike_at_any_absolute_level() {
381        // The portability property, and the reason the whole change is worth a
382        // library retune: the *shape* of the level over time is what survives,
383        // not the gain it arrived at. A steady tone would pass this trivially
384        // (everything steady reads 1.0), so the fixture has real dynamics.
385        let pattern: Vec<f32> = (0..(6 * HOPS_PER_SEC))
386            .map(|i| {
387                let t = i as f32 / HOPS_PER_SEC as f32;
388                // A slow swell with a beat riding on it.
389                (0.35 + 0.3 * (t * 0.8).sin()) * (1.0 + 0.6 * (t * 6.0).sin().max(0.0))
390            })
391            .collect();
392
393        let run = |gain: f32| -> Vec<f32> {
394            let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
395            pattern.iter().map(|v| n.normalize(v * gain)).collect()
396        };
397
398        let full = run(1.0);
399        // -20 dB is a factor of 10 in amplitude.
400        let quiet = run(0.1);
401        let worst = full
402            .iter()
403            .zip(quiet.iter())
404            .map(|(a, b)| (a - b).abs())
405            .fold(0.0f32, f32::max);
406        assert!(
407            worst < 1e-5,
408            "a -20 dB copy must normalize to the same series, worst divergence {worst}"
409        );
410
411        // And the series is genuinely varied, so the agreement above is not two
412        // constant runs matching.
413        let spread = full.iter().copied().fold(0.0f32, f32::max)
414            - full.iter().copied().fold(1.0f32, f32::min);
415        assert!(
416            spread > 0.4,
417            "fixture should exercise a real range, got {spread}"
418        );
419    }
420
421    #[test]
422    fn the_array_keeps_its_shape_under_one_shared_peak() {
423        let mut n = BandNormalizer::new(SR);
424        // Band 1 loud, band 2 a hundredth of it. The ratio between them is the
425        // spectrum's whole content, so it has to survive normalization exactly.
426        let mut bands = [0.0f32; SPECTRUM_BINS];
427        for _ in 0..HOPS_PER_SEC {
428            bands = [0.0; SPECTRUM_BINS];
429            if let Some(loud) = bands.get_mut(1) {
430                *loud = 0.5;
431            }
432            if let Some(quiet) = bands.get_mut(2) {
433                *quiet = 0.005;
434            }
435            n.normalize(&mut bands);
436        }
437        assert_eq!(
438            bands.get(1).copied(),
439            Some(1.0),
440            "the loudest band anchors at 1.0"
441        );
442        assert_eq!(
443            bands.get(2).copied(),
444            Some(0.01),
445            "a band a hundredth as loud must still read a hundredth — per-band \
446             normalization would have lifted it to 1.0 and destroyed the contrast"
447        );
448        assert_eq!(
449            bands.get(3).copied(),
450            Some(0.0),
451            "a silent band stays silent"
452        );
453    }
454
455    #[test]
456    fn a_bin_contrast_survives_normalization() {
457        // The property two shipped presets depend on: `bin(hi) - bin(lo)` as a
458        // timbre signal. Under a shared peak the difference is preserved up to
459        // the gain; under per-band peaks it would collapse toward zero because
460        // both probes would sit at their own maxima.
461        let mut n = BandNormalizer::new(SR);
462        let mut last = [0.0f32; SPECTRUM_BINS];
463        for _ in 0..HOPS_PER_SEC {
464            last = [0.0; SPECTRUM_BINS];
465            // A bright frame: high probe well above the low one.
466            if let Some(lo) = last.get_mut(10) {
467                *lo = 0.02;
468            }
469            if let Some(hi) = last.get_mut(50) {
470                *hi = 0.08;
471            }
472            n.normalize(&mut last);
473        }
474        let contrast = last.get(50).copied().unwrap_or(0.0) - last.get(10).copied().unwrap_or(0.0);
475        assert!(
476            (contrast - 0.75).abs() < 1e-6,
477            "the 0.08-vs-0.02 contrast should normalize to 0.75, got {contrast}"
478        );
479    }
480
481    #[test]
482    fn a_non_finite_input_cannot_poison_the_peak() {
483        let mut n = PeakNormalizer::new(SR, BAND_FLOOR);
484        n.normalize(0.5);
485        assert_eq!(n.normalize(f32::NAN), 0.0);
486        assert_eq!(n.normalize(f32::INFINITY), 0.0);
487        // Recovery is the real claim: a poisoned peak would leave every later
488        // hop dead for the rest of the run.
489        assert_eq!(n.normalize(0.5), 1.0);
490    }
491
492    /// A trace of `WAVE_SAMPLES` at `peak`, shaped so it has both signs and a
493    /// structure a uniform gain must preserve.
494    fn trace_at(peak: f32) -> [f32; WAVE_SAMPLES] {
495        std::array::from_fn(|i| {
496            let t = i as f32 / WAVE_SAMPLES as f32;
497            peak * (std::f32::consts::TAU * 3.0 * t).sin() * (0.4 + 0.6 * t)
498        })
499    }
500
501    /// The floor is an **amplitude**, so the margin that matters is against the
502    /// quietest passage of real material rather than against a band mean.
503    ///
504    /// `dynamic_groove`'s resting beats are its `0.04` phrase scale — the
505    /// quietest thing it contains — and they must stay far enough over the floor
506    /// that the same material a decade down the fader is still levelled instead
507    /// of zeroed. Asserted as ratios; the readings themselves are in
508    /// [`WAVE_FLOOR`]'s derivation.
509    #[test]
510    fn the_floor_clears_real_material_by_two_orders_of_magnitude() {
511        let format = crate::audio::AudioFormat {
512            sample_rate: SR,
513            channels: 1,
514        };
515        let pcm = crate::signal::dynamic_groove(120.0, 8.0, format);
516        let beat = SR as usize / 2;
517        let quietest = pcm
518            .chunks(beat)
519            .map(|c| c.iter().fold(0.0f32, |m, v| m.max(v.abs())))
520            .fold(f32::INFINITY, f32::min);
521        assert!(
522            quietest > 100.0 * WAVE_FLOOR,
523            "the quietest beat peaks at {quietest}, only {}x the floor",
524            quietest / WAVE_FLOOR
525        );
526        // ...and a decade down the fader it is still material, not silence.
527        assert!(
528            quietest * 0.1 > 10.0 * WAVE_FLOOR,
529            "a decade down, the quietest beat is {}x the floor",
530            quietest * 0.1 / WAVE_FLOOR
531        );
532    }
533
534    /// The whole point: the input gain cancels.
535    ///
536    /// The normalizer divides by a peak that scales with its input, so it is
537    /// homogeneous of degree zero above the floor — the same signal at any
538    /// fader position produces the same trace. Not bit-exact for an arbitrary
539    /// `k`, because `(k*x)/(k*p)` rounds differently from `x/p`; exact for a
540    /// power of two, which is asserted separately.
541    #[test]
542    fn a_trace_normalizes_to_the_same_shape_at_any_input_gain() {
543        let reference = {
544            let mut t = trace_at(0.9);
545            TraceNormalizer::new(SR).normalize(&mut t);
546            t
547        };
548        for k in [0.18f32, 0.4, 3.0] {
549            let mut scaled = trace_at(0.9 * k);
550            let gain = TraceNormalizer::new(SR).normalize(&mut scaled);
551            assert!(
552                gain > 0.0,
553                "gain 0 at k={k}: the stimulus fell under the floor"
554            );
555            let worst = reference
556                .iter()
557                .zip(scaled.iter())
558                .fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
559            assert!(worst < 1e-6, "k={k} moved the trace by {worst}");
560        }
561        let mut halved = trace_at(0.45);
562        TraceNormalizer::new(SR).normalize(&mut halved);
563        assert_eq!(
564            halved, reference,
565            "a power-of-two gain change must cancel exactly"
566        );
567    }
568
569    /// Silence is reported as silence rather than amplified into a full-scale
570    /// display of a quiet room — the same rule [`BAND_FLOOR`] holds for the band
571    /// array, at an amplitude the floor's own derivation names.
572    #[test]
573    fn a_sub_floor_trace_reads_as_silence_and_publishes_no_gain() {
574        let mut silent = [0.0f32; WAVE_SAMPLES];
575        assert_eq!(TraceNormalizer::new(SR).normalize(&mut silent), 0.0);
576        assert!(silent.iter().all(|v| *v == 0.0));
577
578        let mut whisper = trace_at(WAVE_FLOOR * 0.5);
579        assert_eq!(TraceNormalizer::new(SR).normalize(&mut whisper), 0.0);
580        assert!(
581            whisper.iter().all(|v| *v == 0.0),
582            "a sub-floor trace must be zeroed, not scaled up"
583        );
584    }
585
586    /// The published divisor is a real escape hatch: multiplying it back gives
587    /// the amplitude the analyzer read (ADR-0139).
588    #[test]
589    fn the_published_gain_reconstructs_the_raw_trace() {
590        let raw = trace_at(0.62);
591        let mut normalized = raw;
592        let gain = TraceNormalizer::new(SR).normalize(&mut normalized);
593        let worst = raw
594            .iter()
595            .zip(normalized.iter())
596            .fold(0.0f32, |m, (r, n)| m.max((r - n * gain).abs()));
597        assert!(worst < 1e-6, "reconstruction is off by {worst}");
598    }
599
600    /// A NaN sample must not become an absorbing peak — the trap `advance`'s
601    /// doc names, reached here through the trace's own magnitude scan.
602    #[test]
603    fn a_non_finite_sample_does_not_poison_the_running_peak() {
604        let mut n = TraceNormalizer::new(SR);
605        let mut poisoned = trace_at(0.5);
606        poisoned[7] = f32::NAN;
607        let gain = n.normalize(&mut poisoned);
608        assert!(gain.is_finite() && gain > 0.0, "gain went bad: {gain}");
609        assert!(poisoned.iter().all(|v| v.is_finite()));
610        let mut after = trace_at(0.5);
611        assert!(n.normalize(&mut after) > 0.0, "the peak never recovered");
612    }
613}