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}