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:
+25
-25
@@ -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. 17–18) 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. 17–18) 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];
|
||||
|
||||
Reference in New Issue
Block a user