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:
+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