diff --git a/crates/headroom-core/src/profile.rs b/crates/headroom-core/src/profile.rs index d20a00f..c065860 100644 --- a/crates/headroom-core/src/profile.rs +++ b/crates/headroom-core/src/profile.rs @@ -150,6 +150,7 @@ impl Profile { MakeupGain::Db(v) => Some(v), }; CompressorConfig { + enabled: self.compressor.enabled, threshold_db: self.compressor.threshold_db, ratio: self.compressor.ratio, knee_db: self.compressor.knee_db, diff --git a/crates/headroom-dsp/src/compressor.rs b/crates/headroom-dsp/src/compressor.rs index db2a462..a38f08d 100644 --- a/crates/headroom-dsp/src/compressor.rs +++ b/crates/headroom-dsp/src/compressor.rs @@ -19,6 +19,14 @@ pub enum Detector { /// Compressor parameters. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CompressorConfig { + /// Master enable. When `false`, [`Compressor::process_frame`] + /// returns the input unchanged and reports zero gain reduction. + /// The compressor's envelope state is *not* reset while disabled, + /// so a stale envelope can briefly affect the first few samples + /// after re-enabling — but with typical release time constants + /// (tens to hundreds of ms) any residual transient is below the + /// audibility threshold. + pub enabled: bool, /// Threshold in dBFS. Inputs above this start compressing. pub threshold_db: f32, /// Compression ratio (>= 1.0). @@ -40,6 +48,7 @@ pub struct CompressorConfig { impl Default for CompressorConfig { fn default() -> Self { Self { + enabled: true, threshold_db: -24.0, ratio: 2.5, knee_db: 6.0, @@ -120,6 +129,13 @@ impl Compressor { /// Process one stereo frame. pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) { + if !self.cfg.enabled { + // Pass through untouched and report no reduction, so the + // bus meters reflect "compressor off" rather than the + // last value before disable. + self.last_gr_db = 0.0; + return (left, right); + } let det_lin = match self.cfg.detector { Detector::Peak => left.abs().max(right.abs()), Detector::Rms => { @@ -256,6 +272,29 @@ mod tests { assert_eq!(cfg.ratio, 1.0); } + #[test] + fn disabled_compressor_passes_signal_through_unchanged() { + // Same hot input that would compress hard in the enabled + // test above. With `enabled: false`, output equals input + // exactly (no makeup gain, no reduction), and the reporter + // shows zero GR — so the `transparent` and `bypass-all` + // profiles actually do what their name claims. + let cfg = CompressorConfig { + enabled: false, + threshold_db: -20.0, + ratio: 4.0, + makeup_db: Some(12.0), + ..CompressorConfig::default() + }; + let mut c = Compressor::new(cfg, 48_000.0); + for _ in 0..1_000 { + let (l, r) = c.process_frame(0.5, 0.5); + assert_eq!(l, 0.5); + assert_eq!(r, 0.5); + } + assert_eq!(c.gain_reduction_db(), 0.0); + } + #[test] fn static_curve_at_threshold_with_soft_knee() { // At exactly threshold, soft knee contributes exactly half the