diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index b1a402c..9c78890 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -50,7 +50,10 @@ const LOW_BULGE_MAX_DB: f32 = 12.0; /// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be /// promoted to a parameter later. -const RMS_WINDOW_MS: f32 = 5.0; +/// Default RMS averaging time constant for tests that do not care which band they model. +/// Real instances pass a per-band value - see the `RMS_WINDOW_MS` table in `lib.rs`. +#[cfg(test)] +const RMS_WINDOW_DEFAULT_MS: f32 = 20.0; // Denormals (the exponentially-decaying envelope/RMS tails and the IIR filter state) are handled // by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its @@ -125,12 +128,23 @@ impl Compressor { /// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`, /// where allocation is allowed — never from `process()`. - pub fn prepare(&mut self, sample_rate: f32, num_channels: usize, max_lookahead_ms: f32) { + /// `rms_window_ms` is the time constant of the RMS detector's one-pole average. It is a + /// design constant per band rather than a user param: the squared signal carries a component + /// at twice the fundamental, so a window that does not span a cycle leaves the detector + /// rippling at 2f and the gain intermodulating with the bass. The needed window is therefore + /// set by how low the band goes, which the band - not the compressor - knows. + pub fn prepare( + &mut self, + sample_rate: f32, + num_channels: usize, + max_lookahead_ms: f32, + rms_window_ms: f32, + ) { self.sample_rate = sample_rate; self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize; // +1 so the oldest (output) sample and the newest (write) sample never alias. self.capacity = self.fixed_delay + 1; - self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate); + self.rms_coef = Self::time_to_coef(rms_window_ms, sample_rate); let channels = num_channels.clamp(1, MAX_CHANNELS); self.delay = vec![vec![0.0; self.capacity]; channels]; self.reset(); @@ -379,7 +393,7 @@ mod tests { // Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp: // reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20). let mut comp = Compressor::new(); - comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); + comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS); let set = settings(-18.0, 2.0, 0.0); let mut out = [0.0f32]; for _ in 0..SR as usize { @@ -399,7 +413,7 @@ mod tests { fn output_rms(use_rms: bool) -> f32 { let mut comp = Compressor::new(); - comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); + comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS); let mut set = settings(-30.0, 4.0, 0.0); set.use_rms = use_rms; set.attack_coef = Compressor::time_to_coef(1.0, SR); @@ -433,7 +447,7 @@ mod tests { // knob must NOT change that (this is what keeps live adjustment from renegotiating // latency and crashing the host). let mut comp = Compressor::new(); - comp.prepare(SR, 1, 0.5); // small max so the fixed delay is quick to test + comp.prepare(SR, 1, 0.5, RMS_WINDOW_DEFAULT_MS); // small max so the fixed delay is quick to test let d = comp.latency() as usize; assert!(d > 0); @@ -455,4 +469,65 @@ mod tests { } } } -} + + /// Measure peak-to-peak gain-reduction ripple for a steady sine, with the ballistics + /// disabled so the gain follows the detector directly. Squaring puts a component at 2f in + /// the detector, so a window that does not span a cycle leaves the gain wobbling at 2f - + /// which is intermodulation, heard as growl on bass. + fn gr_ripple_db(rms_window_ms: f32, freq: f32) -> f32 { + let mut c = Compressor::new(); + c.prepare(SR, 1, MAX_LOOKAHEAD_MS, rms_window_ms); + let set = CompressorSettings { + threshold_db: -40.0, ratio: 8.0, knee_db: 0.0, + // coef 0 = no attack/release smoothing, so yl == the detector's target. + attack_coef: 0.0, release_coef: 0.0, + makeup_db: 0.0, mix: 1.0, lookahead_samples: 0, + use_rms: true, low_slope: 1.0, low_curve: 0.0, + }; + let mut out = [0.0f32]; + // Settle well past the longest window under test. + let settle = (SR * 0.5) as usize; + for i in 0..settle { + let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin(); + c.process(&[x], &mut out, &set); + } + let (mut lo, mut hi) = (f32::MAX, f32::MIN); + for i in settle..settle + (SR / freq * 4.0) as usize { + let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin(); + c.process(&[x], &mut out, &set); + let gr = c.gain_reduction_db(); + lo = lo.min(gr); + hi = hi.max(gr); + } + hi - lo + } + + /// The per-band RMS windows in `lib.rs` exist to keep this ripple down; without a test the + /// next person to "tune" them has nothing telling them what they are for. + #[test] + fn rms_window_suppresses_low_frequency_ripple() { + // The LOW band's 50 ms window against the lowest content it can be asked to carry. + let low_band = gr_ripple_db(50.0, 30.0); + assert!(low_band < 1.0, "50 ms window rippled {low_band:.2} dB at 30 Hz, expected < 1 dB"); + + // A short window on the same signal must be visibly worse - this is what the per-band + // table is avoiding, and it keeps the assertion above from passing for the wrong reason. + let short = gr_ripple_db(5.0, 30.0); + assert!( + short > 3.0 * low_band, + "5 ms ({short:.2} dB) should ripple far more than 50 ms ({low_band:.2} dB)" + ); + + // Ripple must fall monotonically as the window grows. + let mut prev = f32::MAX; + for w in [5.0, 10.0, 20.0, 35.0, 50.0] { + let r = gr_ripple_db(w, 30.0); + assert!(r < prev, "ripple did not fall from the previous window at {w} ms ({r:.2} dB)"); + prev = r; + } + + // The HIGH band's short window is fine because nothing up there ripples. + let high_band = gr_ripple_db(10.0, 5_000.0); + assert!(high_band < 0.1, "10 ms window rippled {high_band:.2} dB at 5 kHz"); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a774f79..330c4ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,21 @@ const MID: usize = 1; const HIGH: usize = 2; const ALL: usize = 3; +/// RMS detector time constants, one per channel, indexed by the consts above. +/// +/// Squaring the detector signal puts a component at twice the fundamental, so a one-pole that +/// does not span a cycle leaves the detector rippling and the gain intermodulating with the +/// bass. Measured peak-to-peak detector ripple at a 5 ms window was 4.4 dB at 30 Hz and 2.7 dB +/// at 50 Hz - squarely in the low band, whose crossover reaches down to 30 Hz. The cost of a +/// longer window is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so each +/// band gets only as much as its frequency range actually needs: +/// +/// * LOW - 50 ms: 30 Hz ripple drops to 0.46 dB, and slow is the wanted behaviour down here. +/// * MID - 20 ms: enough for body without dulling the band that carries most transients. +/// * HIGH - 10 ms: nothing up here ripples, so keep it responsive. +/// * ALL - 35 ms: full-band, so it must cover the low content too. +const RMS_WINDOW_MS: [f32; 4] = [50.0, 20.0, 10.0, 35.0]; + /// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel. /// /// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum → @@ -140,8 +155,8 @@ impl Plugin for Codename206 { self.scope_bucket_len = ((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1); - for comp in &mut self.comps { - comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS); + for (i, comp) in self.comps.iter_mut().enumerate() { + comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS, RMS_WINDOW_MS[i]); } self.crossover.prepare(channels); self.crossover.update(