Stage 3: 3-band LR4 crossover + per-band compressors into the 'All' channel
Splits the input into low/mid/high with a Linkwitz-Riley 24 dB/oct crossover, compresses each band, sums them, then runs the sum through a fourth 'All' compressor. Bypassing the three bands collapses the plugin to a simple full-band comp driven by 'All' (the crossover sums flat in magnitude). - src/dsp/biquad.rs: generic RBJ biquad (Transposed Direct Form II), LP/HP/AP - src/dsp/crossover.rs: 3-band LR4 filterbank; lower band all-pass-compensated at the higher crossover so the bands sum to flat magnitude (an all-pass, not a bit-exact null — that only holds for linear-phase FIR). Mirrors nih-plug's crossover plugin design. - src/lib.rs: 4 Compressor instances (low/mid/high/all) + Crossover; params restructured to 4 nested CompressorParams (id_prefix low/mid/high/all) plus global crossover_low_hz/crossover_high_hz/look_ahead_ms; 4-column lo|mid|hi|all egui UI; latency = two series stages (bands + all), constant, reported once - 10 unit tests (adds biquad LP/AP magnitude, crossover flat-magnitude reconstruction, band-split sanity) - README: Stage 3 marked done; corrected the 'sum flat' expectation to flat magnitude (IIR LR sums to an all-pass, not a time-domain null) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+168
-82
@@ -4,8 +4,15 @@ use std::sync::Arc;
|
||||
|
||||
mod dsp;
|
||||
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||
use dsp::crossover::Crossover;
|
||||
|
||||
/// Level-detection mode for the compressor's detector.
|
||||
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
||||
const LOW: usize = 0;
|
||||
const MID: usize = 1;
|
||||
const HIGH: usize = 2;
|
||||
const ALL: usize = 3;
|
||||
|
||||
/// Level-detection mode for a compressor's detector.
|
||||
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||
enum DetectionMode {
|
||||
#[id = "peak"]
|
||||
@@ -16,14 +23,17 @@ enum DetectionMode {
|
||||
Rms,
|
||||
}
|
||||
|
||||
/// Codename 206 — Stage 2: a single full-band compressor with look-ahead.
|
||||
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
||||
///
|
||||
/// The `CompressorParams` struct is `#[nested]` so the exact same controls + DSP can be
|
||||
/// reused for the three bands and the 'All' aggregate channel in later stages.
|
||||
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
||||
/// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
|
||||
/// driven by the 'All' channel (the crossover sums flat).
|
||||
struct Codename206 {
|
||||
params: Arc<Codename206Params>,
|
||||
sample_rate: f32,
|
||||
comp: Compressor,
|
||||
crossover: Crossover,
|
||||
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
|
||||
comps: [Compressor; 4],
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
@@ -31,15 +41,24 @@ struct Codename206Params {
|
||||
#[persist = "editor-state"]
|
||||
editor_state: Arc<EguiState>,
|
||||
|
||||
/// Look-ahead time: how far ahead the detector reads so gain reduction can lead
|
||||
/// transients. The reported latency is constant (the max look-ahead) regardless of this
|
||||
/// value, so it is safe to adjust during playback.
|
||||
/// Low/Mid crossover frequency.
|
||||
#[id = "xover_lo"]
|
||||
pub crossover_low_hz: FloatParam,
|
||||
/// Mid/High crossover frequency.
|
||||
#[id = "xover_hi"]
|
||||
pub crossover_high_hz: FloatParam,
|
||||
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
||||
#[id = "lookahead"]
|
||||
pub look_ahead_ms: FloatParam,
|
||||
|
||||
/// The full-band compressor controls (reused per band + 'All' channel later).
|
||||
#[nested(group = "Compressor")]
|
||||
pub comp: CompressorParams,
|
||||
#[nested(id_prefix = "low", group = "Low")]
|
||||
pub low: CompressorParams,
|
||||
#[nested(id_prefix = "mid", group = "Mid")]
|
||||
pub mid: CompressorParams,
|
||||
#[nested(id_prefix = "high", group = "High")]
|
||||
pub high: CompressorParams,
|
||||
#[nested(id_prefix = "all", group = "All")]
|
||||
pub all: CompressorParams,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
@@ -67,7 +86,8 @@ impl Default for Codename206 {
|
||||
Self {
|
||||
params: Arc::new(Codename206Params::default()),
|
||||
sample_rate: 48_000.0,
|
||||
comp: Compressor::new(),
|
||||
crossover: Crossover::new(),
|
||||
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +95,23 @@ impl Default for Codename206 {
|
||||
impl Default for Codename206Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor_state: EguiState::from_size(360, 360),
|
||||
editor_state: EguiState::from_size(760, 520),
|
||||
|
||||
crossover_low_hz: FloatParam::new(
|
||||
"Crossover Lo/Mid",
|
||||
200.0,
|
||||
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
crossover_high_hz: FloatParam::new(
|
||||
"Crossover Mid/Hi",
|
||||
2_500.0,
|
||||
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
look_ahead_ms: FloatParam::new(
|
||||
"Look-ahead",
|
||||
@@ -85,7 +121,10 @@ impl Default for Codename206Params {
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
comp: CompressorParams::default(),
|
||||
low: CompressorParams::default(),
|
||||
mid: CompressorParams::default(),
|
||||
high: CompressorParams::default(),
|
||||
all: CompressorParams::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,13 +152,9 @@ impl Default for CompressorParams {
|
||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||
})),
|
||||
|
||||
knee_db: FloatParam::new(
|
||||
"Knee",
|
||||
6.0,
|
||||
FloatRange::Linear { min: 0.0, max: 24.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 24.0 })
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
attack_ms: FloatParam::new(
|
||||
"Attack",
|
||||
@@ -132,28 +167,37 @@ impl Default for CompressorParams {
|
||||
release_ms: FloatParam::new(
|
||||
"Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
makeup_db: FloatParam::new(
|
||||
"Makeup",
|
||||
0.0,
|
||||
FloatRange::Linear { min: -12.0, max: 24.0 },
|
||||
)
|
||||
// Applied per sample, so smooth it to avoid zipper noise.
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -12.0, max: 24.0 })
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
bypass: BoolParam::new("Bypass", false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
||||
fn build_settings(p: &CompressorParams, lookahead: usize, sample_rate: f32) -> CompressorSettings {
|
||||
CompressorSettings {
|
||||
threshold_db: p.threshold_db.value(),
|
||||
ratio: p.ratio.value(),
|
||||
knee_db: p.knee_db.value(),
|
||||
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
||||
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: lookahead,
|
||||
use_rms: p.detection.value() == DetectionMode::Rms,
|
||||
bypass: p.bypass.value(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Codename206 {
|
||||
/// Look-ahead in samples for the current parameter value and sample rate.
|
||||
fn lookahead_samples(&self) -> usize {
|
||||
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||
}
|
||||
@@ -199,37 +243,41 @@ impl Plugin for Codename206 {
|
||||
(),
|
||||
|_, _| {},
|
||||
move |egui_ctx, setter, _state| {
|
||||
// One column of controls for a single compressor channel.
|
||||
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
|
||||
ui.strong(title);
|
||||
ui.add(widgets::ParamSlider::for_param(&p.detection, setter));
|
||||
ui.label("Threshold");
|
||||
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
|
||||
ui.label("Ratio");
|
||||
ui.add(widgets::ParamSlider::for_param(&p.ratio, setter));
|
||||
ui.label("Knee");
|
||||
ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter));
|
||||
ui.label("Attack");
|
||||
ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter));
|
||||
ui.label("Release");
|
||||
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));
|
||||
};
|
||||
|
||||
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
||||
ui.heading(Self::NAME);
|
||||
ui.separator();
|
||||
egui::Grid::new("params").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Detection");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.detection, setter));
|
||||
ui.end_row();
|
||||
ui.label("Threshold");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.threshold_db, setter));
|
||||
ui.end_row();
|
||||
ui.label("Ratio");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.ratio, setter));
|
||||
ui.end_row();
|
||||
ui.label("Knee");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.knee_db, setter));
|
||||
ui.end_row();
|
||||
ui.label("Attack");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.attack_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Release");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.release_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Makeup");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.makeup_db, setter));
|
||||
ui.end_row();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Xover Lo/Mid");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter));
|
||||
ui.label("Xover Mid/Hi");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter));
|
||||
ui.label("Look-ahead");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Bypass");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.bypass, setter));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
ui.columns(4, |cols| {
|
||||
band_col(&mut cols[0], "LOW", ¶ms.low);
|
||||
band_col(&mut cols[1], "MID", ¶ms.mid);
|
||||
band_col(&mut cols[2], "HIGH", ¶ms.high);
|
||||
band_col(&mut cols[3], "ALL", ¶ms.all);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -247,16 +295,29 @@ impl Plugin for Codename206 {
|
||||
.main_output_channels
|
||||
.map(NonZeroU32::get)
|
||||
.unwrap_or(2) as usize;
|
||||
self.comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||
|
||||
// Latency is constant (the fixed audio delay) and reported exactly once, so changing
|
||||
// the look-ahead knob during playback never renegotiates latency with the host.
|
||||
context.set_latency_samples(self.comp.latency());
|
||||
for comp in &mut self.comps {
|
||||
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||
}
|
||||
self.crossover.prepare(channels);
|
||||
self.crossover.update(
|
||||
self.sample_rate,
|
||||
self.params.crossover_low_hz.value(),
|
||||
self.params.crossover_high_hz.value(),
|
||||
);
|
||||
|
||||
// Two compressor stages in series (bands → 'All'), each with the same fixed look-ahead
|
||||
// delay. Reported once as a constant; see the look-ahead note in the compressor module.
|
||||
let total_latency = self.comps[LOW].latency() + self.comps[ALL].latency();
|
||||
context.set_latency_samples(total_latency);
|
||||
true
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.comp.reset();
|
||||
self.crossover.reset();
|
||||
for comp in &mut self.comps {
|
||||
comp.reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
@@ -265,33 +326,58 @@ impl Plugin for Codename206 {
|
||||
_aux: &mut AuxiliaryBuffers,
|
||||
_context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
// Look-ahead is a detector-tap offset within a fixed delay; it never changes latency.
|
||||
let lookahead = self.lookahead_samples();
|
||||
|
||||
// Block-rate compressor settings (these change slowly; makeup is smoothed per sample).
|
||||
let c = &self.params.comp;
|
||||
let mut set = CompressorSettings {
|
||||
threshold_db: c.threshold_db.value(),
|
||||
ratio: c.ratio.value(),
|
||||
knee_db: c.knee_db.value(),
|
||||
attack_coef: Compressor::time_to_coef(c.attack_ms.value(), self.sample_rate),
|
||||
release_coef: Compressor::time_to_coef(c.release_ms.value(), self.sample_rate),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: lookahead,
|
||||
use_rms: c.detection.value() == DetectionMode::Rms,
|
||||
bypass: c.bypass.value(),
|
||||
};
|
||||
// Crossover coefficients track the frequency params (recomputed per block — cheap).
|
||||
self.crossover.update(
|
||||
self.sample_rate,
|
||||
self.params.crossover_low_hz.value(),
|
||||
self.params.crossover_high_hz.value(),
|
||||
);
|
||||
|
||||
// Block-rate settings for the three bands + the 'All' channel.
|
||||
let band_params = [&self.params.low, &self.params.mid, &self.params.high];
|
||||
let mut band_set = [
|
||||
build_settings(&self.params.low, lookahead, self.sample_rate),
|
||||
build_settings(&self.params.mid, lookahead, self.sample_rate),
|
||||
build_settings(&self.params.high, lookahead, self.sample_rate),
|
||||
];
|
||||
let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate);
|
||||
|
||||
let mut in_frame = [0.0f32; 2];
|
||||
let mut band_in = [[0.0f32; 2]; 3];
|
||||
let mut band_out = [[0.0f32; 2]; 3];
|
||||
let mut summed = [0.0f32; 2];
|
||||
let mut out_frame = [0.0f32; 2];
|
||||
for mut frame in buffer.iter_samples() {
|
||||
set.makeup_db = c.makeup_db.smoothed.next();
|
||||
|
||||
for mut frame in buffer.iter_samples() {
|
||||
let n = frame.len().min(2);
|
||||
for ch in 0..n {
|
||||
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||
}
|
||||
self.comp.process(&in_frame[..n], &mut out_frame[..n], &set);
|
||||
|
||||
// Split each channel into low/mid/high.
|
||||
for ch in 0..n {
|
||||
let [lo, mid, hi] = self.crossover.split(ch, in_frame[ch]);
|
||||
band_in[LOW][ch] = lo;
|
||||
band_in[MID][ch] = mid;
|
||||
band_in[HIGH][ch] = hi;
|
||||
}
|
||||
|
||||
// Compress each band (per-sample smoothed makeup), then sum.
|
||||
summed[..n].fill(0.0);
|
||||
for b in 0..3 {
|
||||
band_set[b].makeup_db = band_params[b].makeup_db.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];
|
||||
}
|
||||
}
|
||||
|
||||
// 'All' aggregate channel over the summed bands.
|
||||
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||
|
||||
for ch in 0..n {
|
||||
*frame.get_mut(ch).unwrap() = out_frame[ch];
|
||||
}
|
||||
@@ -304,7 +390,7 @@ impl Plugin for Codename206 {
|
||||
impl ClapPlugin for Codename206 {
|
||||
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Multiband compressor/limiter (stage 2: full-band compressor)");
|
||||
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
|
||||
Reference in New Issue
Block a user