Add peak/RMS detection switch; sweep docs to match code
- Compressor: switchable peak / RMS detection (EnumParam<DetectionMode> in lib.rs -> use_rms bool in CompressorSettings; DSP stays framework-agnostic). RMS is a one-pole running mean of the linked squared level with a hardcoded 5 ms window, updated whenever active so peak<->RMS switching is seamless. New unit test (RMS compresses a sine less than peak); 6 tests total. - README: reconciled Implementation Order checklists with actual progress (Stages 1-2 done; look-ahead/latency + basic UI pulled forward), annotated the project structure (implemented vs planned), and corrected the Latency and Denormal-flushing notes to match the code (set_latency_samples once / constant latency; in-code denormal flush). Overview and goals left unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+58
-1
@@ -26,6 +26,10 @@ const MAX_CHANNELS: usize = 2;
|
||||
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
|
||||
const LEVEL_EPS: f32 = 1e-12;
|
||||
|
||||
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
||||
/// promoted to a parameter later.
|
||||
const RMS_WINDOW_MS: f32 = 5.0;
|
||||
|
||||
/// Flush a decaying envelope value to zero once it is far below audibility, so the
|
||||
/// exponential tail can't drift into denormal range (which causes CPU spikes).
|
||||
#[inline]
|
||||
@@ -51,6 +55,8 @@ pub struct CompressorSettings {
|
||||
/// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`.
|
||||
/// This does NOT change the reported latency — the audio delay is always `fixed_delay`.
|
||||
pub lookahead_samples: usize,
|
||||
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
||||
pub use_rms: bool,
|
||||
pub bypass: bool,
|
||||
}
|
||||
|
||||
@@ -64,6 +70,10 @@ pub struct Compressor {
|
||||
/// Constant audio delay applied to every sample == the reported plugin latency.
|
||||
fixed_delay: usize,
|
||||
|
||||
/// RMS detector state: running mean of the squared (linked) level, plus its coefficient.
|
||||
mean_sq: f32,
|
||||
rms_coef: f32,
|
||||
|
||||
/// Smooth decoupled peak-detector state, expressed as dB of **attenuation** (>= 0).
|
||||
y1: f32, // release branch (peak-with-decay)
|
||||
yl: f32, // attack-smoothed output
|
||||
@@ -77,6 +87,8 @@ impl Default for Compressor {
|
||||
capacity: 0,
|
||||
write_pos: 0,
|
||||
fixed_delay: 0,
|
||||
mean_sq: 0.0,
|
||||
rms_coef: 0.0,
|
||||
y1: 0.0,
|
||||
yl: 0.0,
|
||||
}
|
||||
@@ -95,6 +107,7 @@ impl Compressor {
|
||||
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);
|
||||
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||
self.reset();
|
||||
@@ -106,6 +119,7 @@ impl Compressor {
|
||||
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||
}
|
||||
self.write_pos = 0;
|
||||
self.mean_sq = 0.0;
|
||||
self.y1 = 0.0;
|
||||
self.yl = 0.0;
|
||||
}
|
||||
@@ -179,7 +193,13 @@ impl Compressor {
|
||||
let gain_lin = if set.bypass {
|
||||
1.0
|
||||
} else {
|
||||
let level_db = 20.0 * (peak + LEVEL_EPS).log10();
|
||||
// RMS = running mean of the linked squared level over a fixed window. Updated
|
||||
// whenever active (regardless of mode) so switching peak<->RMS is seamless.
|
||||
self.mean_sq = flush_denormal(
|
||||
self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak,
|
||||
);
|
||||
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
||||
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
||||
// Desired attenuation in dB, as a positive quantity.
|
||||
let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db);
|
||||
|
||||
@@ -224,6 +244,7 @@ mod tests {
|
||||
release_coef: Compressor::time_to_coef(1.0, SR),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: 0,
|
||||
use_rms: false,
|
||||
bypass: false,
|
||||
}
|
||||
}
|
||||
@@ -270,6 +291,42 @@ mod tests {
|
||||
assert_close(out[0], expected, 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_compresses_a_sine_less_than_peak() {
|
||||
// For a sine, RMS level is ~3 dB below the peak (A/√2), so RMS detection sees a lower
|
||||
// level and applies less gain reduction -> louder output than peak detection.
|
||||
use std::f32::consts::PI;
|
||||
|
||||
fn output_rms(use_rms: bool) -> f32 {
|
||||
let mut comp = Compressor::new();
|
||||
comp.prepare(SR, 1, MAX_LOOKAHEAD_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);
|
||||
set.release_coef = Compressor::time_to_coef(50.0, SR);
|
||||
|
||||
let (amp, freq) = (0.5f32, 2000.0f32);
|
||||
let total = SR as usize;
|
||||
let mut out = [0.0f32];
|
||||
let (mut acc, mut cnt) = (0.0f64, 0u32);
|
||||
for i in 0..total {
|
||||
let x = amp * (2.0 * PI * freq * i as f32 / SR).sin();
|
||||
comp.process(&[x], &mut out, &set);
|
||||
if i >= total - 4800 {
|
||||
// measure RMS over the last 0.1 s, after settling
|
||||
acc += (out[0] * out[0]) as f64;
|
||||
cnt += 1;
|
||||
}
|
||||
}
|
||||
(acc / cnt as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
assert!(
|
||||
output_rms(true) > output_rms(false),
|
||||
"RMS detection should compress a sine less than peak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latency_is_constant_regardless_of_lookahead() {
|
||||
// Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead
|
||||
|
||||
Reference in New Issue
Block a user