feat: per-channel dry/wet mix (parallel compression) replacing bypass

Replace the per-channel bypass toggle with a smoothed dry/wet `mix` (0..100%,
default 100%). The blend is applied at the compressor output:
  out = delayed_input * ((1 - mix) + mix * wet_gain)
Dry and wet share the same delayed input, so it's phase-aligned (parallel
compression, no comb filtering). mix=0 is bit-identical to the old bypass.

The detector now runs even at mix 0, so the GR meter shows the wet gain
reduction regardless of mix, while the level/plot out trace reads the mixed
output. "Bands at 0% = simple full-band comp via All" still holds.

Update README + parameter docs (bypass -> mix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-24 19:25:38 +09:00
parent eee94379bc
commit 42c5f7dcd2
5 changed files with 46 additions and 38 deletions
+25 -25
View File
@@ -50,7 +50,9 @@ pub struct CompressorSettings {
pub lookahead_samples: usize,
/// `true` = RMS detection (running power average), `false` = naive sample peak.
pub use_rms: bool,
pub bypass: bool,
/// Dry/wet blend, 0..=1. 1 = fully compressed (incl. makeup), 0 = dry passthrough (bypass).
/// Parallel: dry and wet share the same delayed input, so the mix is phase-aligned.
pub mix: f32,
}
pub struct Compressor {
@@ -188,32 +190,30 @@ impl Compressor {
peak = peak.max(self.delay[ch][det_pos].abs());
}
// 4) Gain computer + ballistics. On bypass we keep the delay aligned (so toggling
// bypass doesn't shift timing) but apply unity gain and no makeup.
let gain_lin = if set.bypass {
1.0
} else {
// 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 = 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);
// 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
// reflects the wet gain reduction regardless of the dry/wet blend.
// 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 = 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);
// Smooth, decoupled peak detector (Giannoulis eq. 1718) on the attenuation:
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
// yl = attack-smoothed y1
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
// Smooth, decoupled peak detector (Giannoulis eq. 1718) on the attenuation:
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
// yl = attack-smoothed y1
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
let total_db = set.makeup_db - self.yl;
10.0f32.powf(total_db / 20.0)
};
let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0);
// 5) Output = delayed input (always `fixed_delay` old) * gain.
// 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the
// blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
let mix = set.mix.clamp(0.0, 1.0);
let blend = (1.0 - mix) + mix * wet_gain;
for ch in 0..n {
output[ch] = self.delay[ch][out_pos] * gain_lin;
output[ch] = self.delay[ch][out_pos] * blend;
}
// 6) Advance the write head.
@@ -241,7 +241,7 @@ mod tests {
makeup_db: 0.0,
lookahead_samples: 0,
use_rms: false,
bypass: false,
mix: 1.0,
}
}
@@ -336,7 +336,7 @@ mod tests {
for &l in &[0usize, d / 2, d] {
comp.reset();
let mut set = settings(0.0, 1.0, 0.0);
set.bypass = true; // unity gain -> isolate the delay behaviour
set.mix = 0.0; // dry passthrough -> isolate the delay behaviour
set.lookahead_samples = l;
let mut out = [0.0f32];