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
+6 -6
View File
@@ -34,9 +34,9 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
``` ```
Input Input
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq) └─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
├─ Band 1 (low) → 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 ─┤ (bypassable) ├─ 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 ─┤ (bypassable) └─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
Sum of bands ◄─────────────────────────────────────────────────────------┘ Sum of bands ◄─────────────────────────────────────────────────────------┘
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup └─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
@@ -71,7 +71,7 @@ a first-class mode, not an afterthought.
### 'All' Aggregate Channel ### '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 - 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 - 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 - 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 ### 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 - 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` - `release_ms`
- `knee_db` — soft knee width - `knee_db` — soft knee width
- `makeup_db` — makeup gain (24…+24 dB) - `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. 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)* ### Stage 5 — Basic egui UI *(basic version done early)*
- [x] Add `nih_plug_egui` editor - [x] Add `nih_plug_egui` editor
- [x] Sliders for all current parameters (`ParamSlider` grid) - [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 - [x] Confirm UI controls update DSP in real time
### Stage 6 — Custom visualisations ### Stage 6 — Custom visualisations
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster) - [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
+25 -25
View File
@@ -50,7 +50,9 @@ pub struct CompressorSettings {
pub lookahead_samples: usize, pub lookahead_samples: usize,
/// `true` = RMS detection (running power average), `false` = naive sample peak. /// `true` = RMS detection (running power average), `false` = naive sample peak.
pub use_rms: bool, 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 { pub struct Compressor {
@@ -188,32 +190,30 @@ impl Compressor {
peak = peak.max(self.delay[ch][det_pos].abs()); peak = peak.max(self.delay[ch][det_pos].abs());
} }
// 4) Gain computer + ballistics. On bypass we keep the delay aligned (so toggling // 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
// bypass doesn't shift timing) but apply unity gain and no makeup. // reflects the wet gain reduction regardless of the dry/wet blend.
let gain_lin = if set.bypass { // RMS = running mean of the linked squared level over a fixed window. Updated whenever
1.0 // active (regardless of mode) so switching peak<->RMS is seamless.
} else { self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
// RMS = running mean of the linked squared level over a fixed window. Updated let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
// whenever active (regardless of mode) so switching peak<->RMS is seamless. let level_db = 20.0 * (detector + LEVEL_EPS).log10();
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak; // Desired attenuation in dB, as a positive quantity.
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak }; let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db);
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: // Smooth, decoupled peak detector (Giannoulis eq. 1718) on the attenuation:
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold") // y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
// yl = attack-smoothed y1 // yl = attack-smoothed y1
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target); 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; self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
let total_db = set.makeup_db - self.yl; let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0);
10.0f32.powf(total_db / 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 { 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. // 6) Advance the write head.
@@ -241,7 +241,7 @@ mod tests {
makeup_db: 0.0, makeup_db: 0.0,
lookahead_samples: 0, lookahead_samples: 0,
use_rms: false, use_rms: false,
bypass: false, mix: 1.0,
} }
} }
@@ -336,7 +336,7 @@ mod tests {
for &l in &[0usize, d / 2, d] { for &l in &[0usize, d / 2, d] {
comp.reset(); comp.reset();
let mut set = settings(0.0, 1.0, 0.0); 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; set.lookahead_samples = l;
let mut out = [0.0f32]; let mut out = [0.0f32];
+2 -1
View File
@@ -62,7 +62,8 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter)); ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter));
ui.label("Makeup"); ui.label("Makeup");
ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter)); 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 // Resizable window; vertical scroll so every control stays reachable even when the
+5 -2
View File
@@ -237,6 +237,7 @@ impl Plugin for Codename206 {
band_in[b][ch] *= pre; band_in[b][ch] *= pre;
} }
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next(); 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]); self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
for ch in 0..n { for ch in 0..n {
summed[ch] += band_out[b][ch]; 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 in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
let out_l = band_out[b][0].abs(); let out_l = band_out[b][0].abs();
let out_r = band_out[b][r].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_l[b] = lvl_l[b].max(out_l);
lvl_r[b] = lvl_r[b].max(out_r); lvl_r[b] = lvl_r[b].max(out_r);
gr[b] = gr[b].max(g); gr[b] = gr[b].max(g);
@@ -263,6 +265,7 @@ impl Plugin for Codename206 {
summed[ch] *= all_pre; summed[ch] *= all_pre;
} }
all_set.makeup_db = self.params.all.makeup_db.smoothed.next(); 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); self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
// Output brickwall limiter. // Output brickwall limiter.
@@ -272,7 +275,7 @@ impl Plugin for Codename206 {
let in_mono = summed[0].abs().max(summed[r].abs()); let in_mono = summed[0].abs().max(summed[r].abs());
let out_l = out_frame[0].abs(); let out_l = out_frame[0].abs();
let out_r = out_frame[r].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_l[ALL] = lvl_l[ALL].max(out_l);
lvl_r[ALL] = lvl_r[ALL].max(out_r); lvl_r[ALL] = lvl_r[ALL].max(out_r);
gr[ALL] = gr[ALL].max(g); gr[ALL] = gr[ALL].max(g);
+8 -4
View File
@@ -74,8 +74,9 @@ pub struct CompressorParams {
pub release_ms: FloatParam, pub release_ms: FloatParam,
#[id = "makeup"] #[id = "makeup"]
pub makeup_db: FloatParam, pub makeup_db: FloatParam,
#[id = "bypass"] /// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass).
pub bypass: BoolParam, #[id = "mix"]
pub mix: FloatParam,
} }
impl Default for Codename206Params { impl Default for Codename206Params {
@@ -188,7 +189,10 @@ impl Default for CompressorParams {
.with_unit(" dB") .with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)), .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, makeup_db: 0.0,
lookahead_samples: lookahead, lookahead_samples: lookahead,
use_rms: p.detection.value() == DetectionMode::Rms, use_rms: p.detection.value() == DetectionMode::Rms,
bypass: p.bypass.value(), mix: p.mix.value(),
} }
} }