fix: give each band its own RMS window instead of a global 5 ms
5 ms was too short to be an RMS average at all. Squaring the detector signal
puts a component at twice the fundamental, and a one-pole that does not span a
cycle passes it straight through, so the detector ripples and the gain
intermodulates with the bass. Measured peak-to-peak detector ripple at 5 ms:
30 Hz 50 Hz 80 Hz 120 Hz 200 Hz 500 Hz 1 kHz
4.42 2.72 1.72 1.15 0.69 0.28 0.14 dB
That lands squarely on the low band, whose crossover reaches down to 30 Hz,
and attack is allowed to go to 0 ms so nothing downstream is guaranteed to
mask it. Above ~500 Hz the same window was so short that RMS mode barely
differed from peak, making the mode switch do less than it appeared to.
The fix is per-band rather than one longer constant, because the required
window is set by how low the band reaches and a multiband already knows that.
Cost is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so
each band gets only what it needs:
LOW 50 ms 30 Hz ripple 4.42 -> 0.46 dB; slow is wanted down here anyway
MID 20 ms body without dulling the band carrying most transients
HIGH 10 ms nothing up there ripples, so stay responsive
ALL 35 ms full-band, so it has to cover the low content too
RMS_WINDOW_MS moves from a compressor constant to a table in lib.rs indexed by
the band consts, and Compressor::prepare takes the window - the compressor has
no business knowing which band it is.
rms_window_suppresses_low_frequency_ripple pins the property: the LOW window
holds 30 Hz under 1 dB, a 5 ms window is far worse on the same signal (so the
bound cannot pass for the wrong reason), ripple falls monotonically with window
length, and the HIGH band's short window stays clean at 5 kHz.
This changes how the plugin sounds in RMS mode - needs an ear, not just tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
This commit is contained in:
+81
-6
@@ -50,7 +50,10 @@ const LOW_BULGE_MAX_DB: f32 = 12.0;
|
|||||||
|
|
||||||
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
||||||
/// promoted to a parameter later.
|
/// 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
|
// 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
|
// 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()`,
|
/// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`,
|
||||||
/// where allocation is allowed — never from `process()`.
|
/// 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.sample_rate = sample_rate;
|
||||||
self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize;
|
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.
|
// +1 so the oldest (output) sample and the newest (write) sample never alias.
|
||||||
self.capacity = self.fixed_delay + 1;
|
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);
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
self.delay = vec![vec![0.0; self.capacity]; channels];
|
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||||
self.reset();
|
self.reset();
|
||||||
@@ -379,7 +393,7 @@ mod tests {
|
|||||||
// Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp:
|
// 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).
|
// reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20).
|
||||||
let mut comp = Compressor::new();
|
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 set = settings(-18.0, 2.0, 0.0);
|
||||||
let mut out = [0.0f32];
|
let mut out = [0.0f32];
|
||||||
for _ in 0..SR as usize {
|
for _ in 0..SR as usize {
|
||||||
@@ -399,7 +413,7 @@ mod tests {
|
|||||||
|
|
||||||
fn output_rms(use_rms: bool) -> f32 {
|
fn output_rms(use_rms: bool) -> f32 {
|
||||||
let mut comp = Compressor::new();
|
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);
|
let mut set = settings(-30.0, 4.0, 0.0);
|
||||||
set.use_rms = use_rms;
|
set.use_rms = use_rms;
|
||||||
set.attack_coef = Compressor::time_to_coef(1.0, SR);
|
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
|
// knob must NOT change that (this is what keeps live adjustment from renegotiating
|
||||||
// latency and crashing the host).
|
// latency and crashing the host).
|
||||||
let mut comp = Compressor::new();
|
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;
|
let d = comp.latency() as usize;
|
||||||
assert!(d > 0);
|
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
@@ -23,6 +23,21 @@ const MID: usize = 1;
|
|||||||
const HIGH: usize = 2;
|
const HIGH: usize = 2;
|
||||||
const ALL: usize = 3;
|
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.
|
/// 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 →
|
/// 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.scope_bucket_len =
|
||||||
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
|
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
|
||||||
|
|
||||||
for comp in &mut self.comps {
|
for (i, comp) in self.comps.iter_mut().enumerate() {
|
||||||
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS, RMS_WINDOW_MS[i]);
|
||||||
}
|
}
|
||||||
self.crossover.prepare(channels);
|
self.crossover.prepare(channels);
|
||||||
self.crossover.update(
|
self.crossover.update(
|
||||||
|
|||||||
Reference in New Issue
Block a user