From 42c5f7dcd22796c8806cd680aba31382aae61e69 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 24 Jun 2026 19:25:38 +0900 Subject: [PATCH] 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 --- README.md | 12 +++++------ src/dsp/compressor.rs | 50 +++++++++++++++++++++---------------------- src/editor/mod.rs | 3 ++- src/lib.rs | 7 ++++-- src/params.rs | 12 +++++++---- 5 files changed, 46 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2a5fceb..c5b0c0c 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI. ``` Input └─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq) - ├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (bypassable) - ├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable) - └─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable) + ├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix) + ├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix) + └─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix) │ Sum of bands ◄─────────────────────────────────────────────────────------┘ └─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup @@ -71,7 +71,7 @@ a first-class mode, not an afterthought. ### 'All' Aggregate Channel - Structurally **identical to a per-band compressor** — reuse the same comp/lim code/params, just fed the summed signal instead of a filtered band - Runs after the three bands are summed, before the output brickwall limiter -- Bands are individually bypassable; with all three bypassed the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim +- Bands have a per-channel dry/wet **mix** (parallel compression); at 0% (or all three dry) the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim - Has its own look-ahead; the plugin reports a single **constant** total latency (the fixed band + 'All' look-ahead), set once — see Latency below ### Output Limiter - Brickwall, ceiling = 0 dBFS or user-defined (`output_ceiling`). Look-ahead + sliding-max peak detection + a ceiling clamp guarantee the output never exceeds the ceiling @@ -106,7 +106,7 @@ a first-class mode, not an afterthought. - `release_ms` - `knee_db` — soft knee width - `makeup_db` — makeup gain (−24…+24 dB) -- `bypass` — per-channel bypass (bypassing low+mid+high = simple full-band comp via the 'all' channel) +- `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum. --- @@ -230,7 +230,7 @@ display and draggable crossover handles, then replace the placeholder slider UI. ### Stage 5 — Basic egui UI *(basic version done early)* - [x] Add `nih_plug_egui` editor - [x] Sliders for all current parameters (`ParamSlider` grid) -- [x] Per-band bypass toggles +- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle) - [x] Confirm UI controls update DSP in real time ### Stage 6 — Custom visualisations - [x] Per-channel level meters (output level, `|L | GR | R|` cluster) diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index 60310d8..82b0329 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -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]; diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 22096e9..7bc15d4 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -62,7 +62,8 @@ pub(crate) fn create(params: Arc, meters: Arc) -> Opt ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter)); ui.label("Makeup"); ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter)); - ui.add(widgets::ParamSlider::for_param(&p.bypass, setter)); + ui.label("Mix"); + ui.add(widgets::ParamSlider::for_param(&p.mix, setter)); }; // Resizable window; vertical scroll so every control stays reachable even when the diff --git a/src/lib.rs b/src/lib.rs index ea9d151..7da4e97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -237,6 +237,7 @@ impl Plugin for Codename206 { band_in[b][ch] *= pre; } band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next(); + band_set[b].mix = band_params[b].mix.smoothed.next(); self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]); for ch in 0..n { summed[ch] += band_out[b][ch]; @@ -245,7 +246,8 @@ impl Plugin for Codename206 { let in_mono = band_in[b][0].abs().max(band_in[b][r].abs()); let out_l = band_out[b][0].abs(); let out_r = band_out[b][r].abs(); - let g = if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() }; + // Wet gain reduction (what the comp computes), independent of the mix. + let g = self.comps[b].gain_reduction_db(); lvl_l[b] = lvl_l[b].max(out_l); lvl_r[b] = lvl_r[b].max(out_r); gr[b] = gr[b].max(g); @@ -263,6 +265,7 @@ impl Plugin for Codename206 { summed[ch] *= all_pre; } all_set.makeup_db = self.params.all.makeup_db.smoothed.next(); + all_set.mix = self.params.all.mix.smoothed.next(); self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set); // Output brickwall limiter. @@ -272,7 +275,7 @@ impl Plugin for Codename206 { let in_mono = summed[0].abs().max(summed[r].abs()); let out_l = out_frame[0].abs(); let out_r = out_frame[r].abs(); - let g = if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() }; + let g = self.comps[ALL].gain_reduction_db(); lvl_l[ALL] = lvl_l[ALL].max(out_l); lvl_r[ALL] = lvl_r[ALL].max(out_r); gr[ALL] = gr[ALL].max(g); diff --git a/src/params.rs b/src/params.rs index 47073c6..3fee7db 100644 --- a/src/params.rs +++ b/src/params.rs @@ -74,8 +74,9 @@ pub struct CompressorParams { pub release_ms: FloatParam, #[id = "makeup"] pub makeup_db: FloatParam, - #[id = "bypass"] - pub bypass: BoolParam, + /// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass). + #[id = "mix"] + pub mix: FloatParam, } impl Default for Codename206Params { @@ -188,7 +189,10 @@ impl Default for CompressorParams { .with_unit(" dB") .with_value_to_string(formatters::v2s_f32_rounded(1)), - bypass: BoolParam::new("Bypass", false), + mix: FloatParam::new("Mix", 1.0, FloatRange::Linear { min: 0.0, max: 1.0 }) + .with_smoother(SmoothingStyle::Linear(20.0)) + .with_value_to_string(formatters::v2s_f32_percentage(0)) + .with_string_to_value(formatters::s2v_f32_percentage()), } } } @@ -208,6 +212,6 @@ pub fn build_settings( makeup_db: 0.0, lookahead_samples: lookahead, use_rms: p.detection.value() == DetectionMode::Rms, - bypass: p.bypass.value(), + mix: p.mix.value(), } }