F6: honour compressor.enabled in the DSP

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.
This commit is contained in:
atagen 2026-05-21 18:19:32 +10:00
parent 244367ccb9
commit 03edb17180
2 changed files with 40 additions and 0 deletions

View file

@ -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,

View file

@ -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