diff --git a/README.md b/README.md index 2d413ac..03b517d 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ a first-class mode, not an afterthought. ### Crossover Filterbank - Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency - LR4 = two cascaded biquads (Butterworth LP or HP) -- Bands sum phase-coherently back to flat +- Bands sum phase-coherently to flat **magnitude** (the sum is an all-pass; lower bands get an all-pass at each later crossover to match phase — not a bit-exact time-domain null) - Crossover frequencies are user-adjustable parameters ### Per-Band Compressor - Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later) @@ -114,9 +114,9 @@ src/ dsp/ mod.rs # ✅ module declarations compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay - crossover.rs # (planned) LR4 filterbank (biquad chains) + crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation + biquad.rs # ✅ generic biquad (Transposed Direct Form II) limiter.rs # (planned) output true-peak brickwall limiter - biquad.rs # (planned) generic biquad (Direct Form II transposed) delay.rs # (planned) look-ahead delay (currently lives inside compressor.rs) oversampler.rs # (planned) 4x oversampler for true-peak detection editor/ @@ -170,10 +170,12 @@ is essential — without it FL silently skips a plugin it has seen before.) Work through these stages in order — each stage produces a loadable, audible plugin. -**Status (2026-06-15):** Stages 1–2 are complete. Look-ahead + latency reporting (from Stage 4) -and a basic slider UI (from Stage 5) were pulled forward and already work. **Next: Stage 3 — -crossover filterbank.** DSP currently lives in `src/dsp/compressor.rs`; params and the egui -editor are still inline in `src/lib.rs` (not yet split into `params.rs` / `editor/`). +**Status (2026-06-17):** Stages 1–3 complete — full-band compressor, peak/RMS detection, and now +the 3-band LR4 crossover feeding per-band compressors summed into the 'All' channel (4 reusable +`Compressor` instances). Look-ahead + latency (Stage 4) and a basic 4-column UI (Stage 5) are in. +**Next: Stage 4 — output brickwall limiter + oversampler.** DSP is in `src/dsp/` +(`biquad.rs`, `crossover.rs`, `compressor.rs`); params and the egui editor are still inline in +`src/lib.rs` (not yet split into `params.rs` / `editor/`). ### Stage 1 — Skeleton plugin ✅ - [x] NIH-plug "passthrough" compiling and loading in DAW @@ -186,17 +188,17 @@ editor are still inline in `src/lib.rs` (not yet split into `params.rs` / `edito - [x] Implement gain computer (threshold, ratio, soft knee) - [x] Implement attack/release envelope (smooth decoupled peak detector) - [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency) -### Stage 3 — Crossover filterbank ⬅ next -- [ ] Implement LR4 LP and HP biquad chains in `crossover.rs` -- [ ] Verify bands sum flat (null test: sum vs dry should be silence) -- [ ] Add per-band bypass; with all bands bypassed, output must null against dry (proves the "simple comp" mode path) -- [ ] Apply per-band compressor to each band -- [ ] Sum bands back together -- [ ] Run the summed signal through the 'All' channel comp/lim (reuse the per-band compressor) before output -### Stage 4 — Look-ahead + brickwall limiter *(look-ahead + latency done early)* -- [x] Look-ahead delay (circular buffer) — currently inside `compressor.rs`, no separate `delay.rs` yet +### Stage 3 — Crossover filterbank ✅ +- [x] Implement LR4 LP/HP biquad chains in `crossover.rs` (+ generic `biquad.rs`, Transposed Direct Form II) +- [x] Verify bands sum flat — for IIR LR4 the sum is an **all-pass** (flat *magnitude*, phase-shifted), not a bit-exact null; lower bands get an all-pass at each later crossover to phase-match. Tested via `bands_sum_to_flat_magnitude` +- [x] Per-band bypass — a bypassed band passes its delayed dry band; with all three bypassed the 'All' channel sees the flat-magnitude reconstruction = the simple-comp mode +- [x] Apply per-band compressor to each band +- [x] Sum bands back together +- [x] Run the summed signal through the 'All' channel compressor before output +### Stage 4 — Output brickwall limiter + oversampler ⬅ next *(look-ahead + latency already done)* +- [x] Look-ahead delay (circular buffer) — inside `compressor.rs`, no separate `delay.rs` - [x] Wire look-ahead: detector reads N samples ahead of the VCA -- [x] Report latency — via `context.set_latency_samples()`, reported once as a constant (see Latency note) +- [x] Report latency — `context.set_latency_samples()` once; now the constant two-stage total (bands + 'All') - [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc) - [ ] Implement brickwall output limiter with true-peak detection ### Stage 5 — Basic egui UI *(basic version done early)* diff --git a/src/dsp/biquad.rs b/src/dsp/biquad.rs new file mode 100644 index 0000000..eee7494 --- /dev/null +++ b/src/dsp/biquad.rs @@ -0,0 +1,148 @@ +//! Generic second-order IIR biquad, Transposed Direct Form II. +//! +//! Coefficient formulas are the RBJ Audio EQ Cookbook +//! (), prenormalised by `a0`. Scalar `f32`; we run +//! one filter per channel rather than SIMD to match the rest of the per-channel DSP. + +use std::f32::consts; + +/// Butterworth Q (= 1/√2). Two cascaded Butterworth sections make a 4th-order Linkwitz-Riley. +pub const NEUTRAL_Q: f32 = consts::FRAC_1_SQRT_2; + +/// Prenormalised biquad coefficients `[b0, b1, b2, a1, a2]` (already divided by `a0`). +#[derive(Clone, Copy)] +pub struct BiquadCoefficients { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, +} + +impl Default for BiquadCoefficients { + fn default() -> Self { + Self::identity() + } +} + +impl BiquadCoefficients { + /// Passes the signal through unchanged. + pub fn identity() -> Self { + Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 } + } + + pub fn lowpass(sample_rate: f32, frequency: f32, q: f32) -> Self { + let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q); + let a0 = 1.0 + alpha; + Self { + b0: ((1.0 - cos_w0) / 2.0) / a0, + b1: (1.0 - cos_w0) / a0, + b2: ((1.0 - cos_w0) / 2.0) / a0, + a1: (-2.0 * cos_w0) / a0, + a2: (1.0 - alpha) / a0, + } + } + + pub fn highpass(sample_rate: f32, frequency: f32, q: f32) -> Self { + let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q); + let a0 = 1.0 + alpha; + Self { + b0: ((1.0 + cos_w0) / 2.0) / a0, + b1: -(1.0 + cos_w0) / a0, + b2: ((1.0 + cos_w0) / 2.0) / a0, + a1: (-2.0 * cos_w0) / a0, + a2: (1.0 - alpha) / a0, + } + } + + pub fn allpass(sample_rate: f32, frequency: f32, q: f32) -> Self { + let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q); + let a0 = 1.0 + alpha; + Self { + b0: (1.0 - alpha) / a0, + b1: (-2.0 * cos_w0) / a0, + b2: (1.0 + alpha) / a0, + a1: (-2.0 * cos_w0) / a0, + a2: (1.0 - alpha) / a0, + } + } + + /// Shared intermediate terms: `(cos ω0, α)`. + fn omega(sample_rate: f32, frequency: f32, q: f32) -> (f32, f32) { + let w0 = consts::TAU * (frequency / sample_rate); + (w0.cos(), w0.sin() / (2.0 * q)) + } +} + +/// A biquad filter holding its two state variables. +#[derive(Clone, Copy, Default)] +pub struct Biquad { + coefficients: BiquadCoefficients, + s1: f32, + s2: f32, +} + +impl Biquad { + /// Replace the coefficients (keeps the state — fine for smooth coefficient changes). + pub fn set_coefficients(&mut self, coefficients: BiquadCoefficients) { + self.coefficients = coefficients; + } + + /// Process one sample (Transposed Direct Form II). + #[inline] + pub fn process(&mut self, x: f32) -> f32 { + let c = &self.coefficients; + let y = c.b0 * x + self.s1; + self.s1 = c.b1 * x - c.a1 * y + self.s2; + self.s2 = c.b2 * x - c.a2 * y; + y + } + + /// Clear the filter state. + pub fn reset(&mut self) { + self.s1 = 0.0; + self.s2 = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: f32 = 48_000.0; + + fn magnitude_at(mut coeffs_filter: Biquad, freq: f32) -> f32 { + use std::f32::consts::TAU; + let n = 16_000usize; + let mut acc = 0.0f64; + for i in 0..n { + let x = (TAU * freq * i as f32 / SR).sin(); + let y = coeffs_filter.process(x); + if i >= n - 8_000 { + acc += (y * y) as f64; + } + } + // RMS of a unit sine is 1/√2; divide it out to get the magnitude response. + ((acc / 8_000.0).sqrt() as f32) * std::f32::consts::SQRT_2 + } + + #[test] + fn lowpass_passes_dc_blocks_highs() { + let lp = { + let mut b = Biquad::default(); + b.set_coefficients(BiquadCoefficients::lowpass(SR, 1_000.0, NEUTRAL_Q)); + b + }; + assert!((magnitude_at(lp, 100.0) - 1.0).abs() < 0.05); // ~passband + assert!(magnitude_at(lp, 12_000.0) < 0.05); // ~stopband + } + + #[test] + fn allpass_is_unity_magnitude() { + for &f in &[100.0, 1_000.0, 8_000.0] { + let mut b = Biquad::default(); + b.set_coefficients(BiquadCoefficients::allpass(SR, 2_000.0, NEUTRAL_Q)); + assert!((magnitude_at(b, f) - 1.0).abs() < 0.02, "allpass not flat at {f} Hz"); + } + } +} diff --git a/src/dsp/crossover.rs b/src/dsp/crossover.rs new file mode 100644 index 0000000..3248465 --- /dev/null +++ b/src/dsp/crossover.rs @@ -0,0 +1,207 @@ +//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank. +//! +//! Each crossover splits into a low-passed band output and a high-passed remainder that feeds +//! the next crossover. Because higher bands pass through more filters, lower bands are phase- +//! compensated with an all-pass at every *later* crossover frequency so the three bands sum back +//! to flat **magnitude** (the sum is an all-pass of the input — phase-shifted, not bit-identical, +//! which is inherent to IIR Linkwitz-Riley). Approach mirrors NIH-plug's `crossover` plugin. +//! +//! For 3 bands there are two crossovers (low/mid at `f_lo`, mid/high at `f_hi`); only the low +//! band needs compensation (one all-pass at `f_hi`). + +use super::biquad::{Biquad, BiquadCoefficients, NEUTRAL_Q}; + +/// Mono/stereo only, matching the plugin's audio layouts. +const MAX_CHANNELS: usize = 2; + +/// One channel's worth of filter state for the 3-band split. +#[derive(Clone, Copy, Default)] +struct BandSplitter { + lp_lo: [Biquad; 2], // LR4 low-pass at f_lo (two cascaded Butterworth) + hp_lo: [Biquad; 2], // LR4 high-pass at f_lo + lp_hi: [Biquad; 2], // LR4 low-pass at f_hi + hp_hi: [Biquad; 2], // LR4 high-pass at f_hi + ap_low: Biquad, // all-pass at f_hi, phase-compensates the low band +} + +impl BandSplitter { + /// Split one sample into `[low, mid, high]`. + fn split(&mut self, x: f32) -> [f32; 3] { + // Crossover at f_lo: low-passed band + high-passed remainder. + let mut lp = x; + for f in &mut self.lp_lo { + lp = f.process(lp); + } + let mut hp = x; + for f in &mut self.hp_lo { + hp = f.process(hp); + } + + // Low band is phase-compensated for the f_hi crossover the upper bands pass through. + let low = self.ap_low.process(lp); + + // Crossover at f_hi splits the remainder into mid + high. + let mut mid = hp; + for f in &mut self.lp_hi { + mid = f.process(mid); + } + let mut high = hp; + for f in &mut self.hp_hi { + high = f.process(high); + } + + [low, mid, high] + } + + fn set_coefficients( + &mut self, + lp_lo: BiquadCoefficients, + hp_lo: BiquadCoefficients, + lp_hi: BiquadCoefficients, + hp_hi: BiquadCoefficients, + ap_low: BiquadCoefficients, + ) { + for f in &mut self.lp_lo { + f.set_coefficients(lp_lo); + } + for f in &mut self.hp_lo { + f.set_coefficients(hp_lo); + } + for f in &mut self.lp_hi { + f.set_coefficients(lp_hi); + } + for f in &mut self.hp_hi { + f.set_coefficients(hp_hi); + } + self.ap_low.set_coefficients(ap_low); + } + + fn reset(&mut self) { + for f in self + .lp_lo + .iter_mut() + .chain(&mut self.hp_lo) + .chain(&mut self.lp_hi) + .chain(&mut self.hp_hi) + { + f.reset(); + } + self.ap_low.reset(); + } +} + +pub struct Crossover { + channels: usize, + splitters: [BandSplitter; MAX_CHANNELS], +} + +impl Default for Crossover { + fn default() -> Self { + Self { + channels: 2, + splitters: [BandSplitter::default(); MAX_CHANNELS], + } + } +} + +impl Crossover { + pub fn new() -> Self { + Self::default() + } + + /// Set the active channel count and clear state. Call from `initialize()`. + pub fn prepare(&mut self, channels: usize) { + self.channels = channels.clamp(1, MAX_CHANNELS); + self.reset(); + } + + /// Recompute and apply crossover coefficients. Cheap enough to call once per block. + /// Frequencies are clamped to a valid range and forced monotonic (`f_lo <= f_hi`). + pub fn update(&mut self, sample_rate: f32, low_hz: f32, high_hz: f32) { + let max_hz = sample_rate * 0.49; + let f_lo = low_hz.clamp(20.0, max_hz); + let f_hi = high_hz.clamp(f_lo, max_hz); + + let lp_lo = BiquadCoefficients::lowpass(sample_rate, f_lo, NEUTRAL_Q); + let hp_lo = BiquadCoefficients::highpass(sample_rate, f_lo, NEUTRAL_Q); + let lp_hi = BiquadCoefficients::lowpass(sample_rate, f_hi, NEUTRAL_Q); + let hp_hi = BiquadCoefficients::highpass(sample_rate, f_hi, NEUTRAL_Q); + let ap_low = BiquadCoefficients::allpass(sample_rate, f_hi, NEUTRAL_Q); + + for s in &mut self.splitters { + s.set_coefficients(lp_lo, hp_lo, lp_hi, hp_hi, ap_low); + } + } + + pub fn reset(&mut self) { + for s in &mut self.splitters { + s.reset(); + } + } + + /// Split one sample of `channel` into `[low, mid, high]`. + #[inline] + pub fn split(&mut self, channel: usize, x: f32) -> [f32; 3] { + self.splitters[channel].split(x) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::TAU; + + const SR: f32 = 48_000.0; + + #[test] + fn bands_sum_to_flat_magnitude() { + // LR4 bands sum to an all-pass: the magnitude is flat at every frequency (including the + // crossovers), even though the time-domain signal is phase-shifted (so it is NOT a + // bit-exact null — that only holds for linear-phase FIR crossovers). + let mut xo = Crossover::new(); + xo.prepare(1); + xo.update(SR, 200.0, 2_500.0); + + for &f in &[50.0, 200.0, 1_000.0, 2_500.0, 9_000.0] { + xo.reset(); + let n = 24_000usize; + let (mut in_acc, mut out_acc) = (0.0f64, 0.0f64); + for i in 0..n { + let x = (TAU * f * i as f32 / SR).sin(); + let [lo, mid, hi] = xo.split(0, x); + let y = lo + mid + hi; + if i >= n - 8_000 { + in_acc += (x * x) as f64; + out_acc += (y * y) as f64; + } + } + let ratio = (out_acc / in_acc).sqrt() as f32; + assert!( + (ratio - 1.0).abs() < 0.06, + "reconstruction not flat at {f} Hz: {ratio}" + ); + } + } + + #[test] + fn bands_are_actually_split() { + // Sanity: the low band should keep lows and reject highs; the high band vice versa. + fn band_energy(band: usize, freq: f32) -> f64 { + let mut xo = Crossover::new(); + xo.prepare(1); + xo.update(SR, 200.0, 2_500.0); + let n = 24_000usize; + let mut acc = 0.0f64; + for i in 0..n { + let x = (TAU * freq * i as f32 / SR).sin(); + let bands = xo.split(0, x); + if i >= n - 8_000 { + acc += (bands[band] * bands[band]) as f64; + } + } + acc + } + assert!(band_energy(0, 50.0) > band_energy(0, 9_000.0) * 100.0); // low band: lows >> highs + assert!(band_energy(2, 9_000.0) > band_energy(2, 50.0) * 100.0); // high band: highs >> lows + } +} diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index 89a5a18..d4b87fd 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -4,4 +4,6 @@ //! per band and for the 'All' aggregate channel — see README.md). Later stages add the //! crossover filterbank, output limiter, and oversampler alongside it. +pub mod biquad; pub mod compressor; +pub mod crossover; diff --git a/src/lib.rs b/src/lib.rs index ef473cb..d0228eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, 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, - /// 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::().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, ) -> 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] = &[