Merge branch 'fix/per-band-rms-window'
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
|
||||
/// 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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user