Merge branch 'fix/per-band-rms-window'

This commit is contained in:
Mikkeli Matlock
2026-08-31 22:10:52 +09:00
2 changed files with 99 additions and 9 deletions
+81 -6
View File
@@ -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");
}
}
+17 -2
View File
@@ -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(