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:
@@ -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)
|
||||
|
||||
+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];
|
||||
|
||||
+2
-1
@@ -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.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
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+8
-4
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user