From 03edb171801fbd66a50fe39f934a13cae0494d06 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:19:32 +1000 Subject: [PATCH] F6: honour compressor.enabled in the DSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile schema accepted `[compressor] enabled = false` (and the `transparent` and `bypass-all` profiles set it) but the flag was parsed and dropped — `build_compressor_config()` never threaded it through to `CompressorConfig`, and `Compressor::process_frame` had no enable branch. Result: the "compressor and AGC bypassed" claim in `transparent.toml`'s description was a lie; the compressor ran on every sample regardless of the profile knob. Surfaced by Codex's review of the project. Changes - `headroom_dsp::CompressorConfig` gains `pub enabled: bool` (default true). `Compressor::process_frame` early-returns `(left, right)` and resets `last_gr_db = 0.0` when disabled, so bus meters / `gain_reduction_db()` report the truthful "compressor off" state instead of the stale last value. - `headroom_core::profile::Profile::build_compressor_config` threads `self.compressor.enabled` into the materialised `CompressorConfig`. Live profile reload picks this up automatically — the next `set_config` push from `setting.set` / `profile.use` flips the audio thread. - Regression unit test `disabled_compressor_passes_signal_through_unchanged`: drive a -6 dBFS sine that would compress hard with enabled + aggressive thresholds, assert output equals input exactly and GR is zero. What this does NOT change - **Limiter has no `enabled` flag** and intentionally remains always-on. It is the daemon's hard contract (the -0.1 dBTP ceiling on the processed route, advertised in the README and in PLAN §3). Users who don't want limiting should route bypass; the `bypass-all.toml` profile's own comment confirms the limiter is "still configured as a fail-safe in case a stream lands on the processed sink anyway." Verified 186 tests pass (+1 for the disable path); clippy clean at -D warnings --all-targets. Live A/B against `pw-cat /tmp/sine` (-6 dBFS sine into processed): default profile compresses at -4.5 dB GR; `headroom profile use transparent` flips to 0.00 dB GR exactly on the next meter tick. --- crates/headroom-core/src/profile.rs | 1 + crates/headroom-dsp/src/compressor.rs | 39 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) 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