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
|
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
@@ -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. 17–18) on the attenuation:
|
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) 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
@@ -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
@@ -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
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user