diff --git a/Cargo.toml b/Cargo.toml index 55fc4a7..9d55e19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,8 @@ license = "GPL-3.0-or-later" members = ["xtask"] [lib] -crate-type = ["cdylib"] +# `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests. +crate-type = ["cdylib", "lib"] [dependencies] # Pinned to the exact commit of the local ./nih-plug reference clone so the build diff --git a/README.md b/README.md index db6f940..fe97773 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ a first-class mode, not an afterthought. ### Global - `input_gain` — pre-gain before filterbank (dB) - `output_ceiling` — brickwall ceiling (dBFS, default 0.0) -- `look_ahead_ms` — look-ahead time (0–10 ms) +- `look_ahead_ms` — look-ahead time (0–5 ms). Reported latency is **constant** (the max look-ahead); the knob only moves the detector tap within that fixed delay, so it is safe to adjust during playback (changing reported latency mid-stream crashes some hosts, FL included) - `crossover_low_hz` — low/mid crossover frequency - `crossover_high_hz` — mid/high crossover frequency ### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused) diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs new file mode 100644 index 0000000..c044644 --- /dev/null +++ b/src/dsp/compressor.rs @@ -0,0 +1,301 @@ +//! Single full-band feed-forward compressor with look-ahead. +//! +//! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor +//! Design — A Tutorial and Analysis" (JAES 2012): +//! +//! * a **log-domain gain computer** with a quadratic **soft knee**, and +//! * a **smooth, decoupled peak detector** for the attack/release ballistics +//! (their preferred topology — avoids the artefacts of naive branching smoothers). +//! +//! Detection is **stereo-linked** (the control signal is `max(|ch|)` across channels) +//! so a single gain is applied to every channel and the stereo image is preserved. +//! +//! Look-ahead is a per-channel delay line on the audio path: the output sample is the +//! input from `L` samples ago, while the gain is computed from the *current* input — +//! so the gain reduction leads the audio by `L` samples. `L` is the plugin's latency. + +/// Maximum look-ahead. This is also the **fixed** latency the plugin reports: the audio is +/// always delayed by this much and the latency is reported once, so the look-ahead knob can be +/// adjusted during playback without ever renegotiating latency with the host (which crashes +/// some DAWs, FL included). The knob only moves where the detector taps within this window. +pub const MAX_LOOKAHEAD_MS: f32 = 5.0; + +/// Most channels we ever process in one frame (our audio layouts are mono/stereo). +const MAX_CHANNELS: usize = 2; + +/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels. +const LEVEL_EPS: f32 = 1e-12; + +/// Flush a decaying envelope value to zero once it is far below audibility, so the +/// exponential tail can't drift into denormal range (which causes CPU spikes). +#[inline] +fn flush_denormal(x: f32) -> f32 { + if x.abs() < 1e-30 { + 0.0 + } else { + x + } +} + +/// Per-block compressor settings. Cheap to copy; rebuilt each process block from params. +#[derive(Clone, Copy)] +pub struct CompressorSettings { + pub threshold_db: f32, + pub ratio: f32, + pub knee_db: f32, + /// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]). + pub attack_coef: f32, + /// One-pole coefficient for the release ramp. + pub release_coef: f32, + pub makeup_db: f32, + /// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`. + /// This does NOT change the reported latency — the audio delay is always `fixed_delay`. + pub lookahead_samples: usize, + pub bypass: bool, +} + +pub struct Compressor { + sample_rate: f32, + + /// Per-channel circular delay line, each `capacity` samples long. + delay: Vec>, + capacity: usize, + write_pos: usize, + /// Constant audio delay applied to every sample == the reported plugin latency. + fixed_delay: usize, + + /// Smooth decoupled peak-detector state, expressed as dB of **attenuation** (>= 0). + y1: f32, // release branch (peak-with-decay) + yl: f32, // attack-smoothed output +} + +impl Default for Compressor { + fn default() -> Self { + Self { + sample_rate: 48_000.0, + delay: Vec::new(), + capacity: 0, + write_pos: 0, + fixed_delay: 0, + y1: 0.0, + yl: 0.0, + } + } +} + +impl Compressor { + pub fn new() -> Self { + Self::default() + } + + /// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`, + /// where allocation is allowed — never from `process()`. + pub fn prepare(&mut self, sample_rate: f32, num_channels: usize, max_lookahead_ms: f32) { + self.sample_rate = sample_rate; + self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize; + // +1 so the oldest (output) sample and the newest (write) sample never alias. + self.capacity = self.fixed_delay + 1; + let channels = num_channels.clamp(1, MAX_CHANNELS); + self.delay = vec![vec![0.0; self.capacity]; channels]; + self.reset(); + } + + /// Clear all state. Real-time safe (no allocation). + pub fn reset(&mut self) { + for ch in self.delay.iter_mut() { + ch.iter_mut().for_each(|s| *s = 0.0); + } + self.write_pos = 0; + self.y1 = 0.0; + self.yl = 0.0; + } + + /// Convert an attack/release time in milliseconds to a one-pole smoothing coefficient. + /// + /// Convention: after `time_ms`, a step response reaches ~63% (1 − 1/e) of its target. + /// 0 ms (or less) yields coefficient 0 = instantaneous. + pub fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 { + if time_ms <= 0.0 { + 0.0 + } else { + (-1.0 / (time_ms * 0.001 * sample_rate)).exp() + } + } + + /// Static compressor curve. Returns gain reduction in dB (<= 0) for an input `level_db`. + /// Quadratic soft knee of width `knee_db`, centred on `threshold_db`. + fn gain_computer(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 { + let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1 + let over = level_db - threshold_db; + + if knee_db > 0.0 && 2.0 * over.abs() <= knee_db { + // Inside the knee: a parabola joining the two regions with a continuous slope. + let x = over + knee_db * 0.5; // 0..knee + slope * x * x / (2.0 * knee_db) + } else if over > 0.0 { + // Above the knee (also covers the hard-knee case): linear region. + slope * over + } else { + 0.0 + } + } + + /// The plugin's fixed reported latency in samples (the constant audio delay). + pub fn latency(&self) -> u32 { + self.fixed_delay as u32 + } + + /// Process one sample frame in place: `input[ch]` -> `output[ch]`. + /// + /// `input` and `output` are short stack slices (one value per channel), so this + /// performs no allocation. Detection is linked across the provided channels. + /// + /// The audio is always delayed by `fixed_delay`; `set.lookahead_samples` (0..=fixed_delay) + /// only chooses how far *ahead* of the output the detector taps, so changing it never + /// alters latency. + pub fn process(&mut self, input: &[f32], output: &mut [f32], set: &CompressorSettings) { + debug_assert_eq!(input.len(), output.len()); + let n = input.len().min(self.delay.len()); + let l = set.lookahead_samples.min(self.fixed_delay); + + // 1) Write the current input into the delay lines. + for ch in 0..n { + self.delay[ch][self.write_pos] = input[ch]; + } + + // 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads + // `l` samples newer than the output, i.e. `l` samples into the output's future. + let out_pos = (self.write_pos + self.capacity - self.fixed_delay) % self.capacity; + let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity; + + // 3) Linked peak detector at the look-ahead tap. + let mut peak = 0.0f32; + for ch in 0..n { + 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 { + let level_db = 20.0 * (peak + 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 = flush_denormal( + target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target), + ); + self.yl = flush_denormal(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) + }; + + // 5) Output = delayed input (always `fixed_delay` old) * gain. + for ch in 0..n { + output[ch] = self.delay[ch][out_pos] * gain_lin; + } + + // 6) Advance the write head. + self.write_pos = (self.write_pos + 1) % self.capacity; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: f32 = 48_000.0; + + fn assert_close(a: f32, b: f32, tol: f32) { + assert!((a - b).abs() <= tol, "expected {a} ≈ {b} (tol {tol})"); + } + + fn settings(threshold_db: f32, ratio: f32, knee_db: f32) -> CompressorSettings { + CompressorSettings { + threshold_db, + ratio, + knee_db, + attack_coef: Compressor::time_to_coef(1.0, SR), + release_coef: Compressor::time_to_coef(1.0, SR), + makeup_db: 0.0, + lookahead_samples: 0, + bypass: false, + } + } + + #[test] + fn below_threshold_is_untouched() { + // -30 dB input, -20 dB threshold -> no reduction. + assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0), 0.0); + } + + #[test] + fn above_knee_follows_ratio() { + // 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction. + let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0); + assert_close(r, -7.5, 1e-4); + } + + #[test] + fn knee_is_continuous_with_linear_region() { + // At the upper knee edge the soft-knee and linear formulas must agree. + let (t, ratio, knee) = (0.0, 4.0, 6.0); + let edge = t + knee / 2.0; + let knee_val = Compressor::gain_computer(edge, t, ratio, knee); + let linear_val = (1.0 / ratio - 1.0) * (edge - t); + assert_close(knee_val, linear_val, 1e-4); + // At the lower edge there is still no reduction. + assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee), 0.0, 1e-6); + } + + #[test] + fn steady_state_matches_static_curve() { + // Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp: + // reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20). + let mut comp = Compressor::new(); + comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); + let set = settings(-18.0, 2.0, 0.0); + let mut out = [0.0f32]; + for _ in 0..SR as usize { + comp.process(&[0.5], &mut out, &set); + } + let level_db = 20.0 * 0.5f32.log10(); + let reduction = (1.0 / 2.0 - 1.0) * (level_db - (-18.0)); + let expected = 0.5 * 10.0f32.powf(reduction / 20.0); + assert_close(out[0], expected, 1e-3); + } + + #[test] + fn latency_is_constant_regardless_of_lookahead() { + // Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead + // knob must NOT change that (this is what keeps live adjustment from renegotiating + // latency and crashing the host). + let mut comp = Compressor::new(); + comp.prepare(SR, 1, 0.5); // small max so the fixed delay is quick to test + let d = comp.latency() as usize; + assert!(d > 0); + + 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.lookahead_samples = l; + + let mut out = [0.0f32]; + for n in 0..(d + 5) { + let x = if n == 0 { 1.0 } else { 0.0 }; + comp.process(&[x], &mut out, &set); + if n == d { + assert_close(out[0], 1.0, 1e-6); // impulse always emerges after `d`, any L + } else { + assert_close(out[0], 0.0, 1e-6); + } + } + } + } +} diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs new file mode 100644 index 0000000..89a5a18 --- /dev/null +++ b/src/dsp/mod.rs @@ -0,0 +1,7 @@ +//! DSP building blocks for Codename 206. +//! +//! Stage 2 introduces the full-band compressor (also the engine that will be reused +//! 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 compressor; diff --git a/src/lib.rs b/src/lib.rs index f4f6d5c..36f0d5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,31 +2,59 @@ use nih_plug::prelude::*; use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState}; use std::sync::Arc; -/// Codename 206 — landmark build. +mod dsp; +use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS}; + +/// Codename 206 — Stage 2: a single full-band compressor with look-ahead. /// -/// This is intentionally tiny: a single full-band gain parameter exposed through one -/// egui slider. It is the foundation the multiband compressor/limiter (see README.md) -/// will be built on top of. The signal path is currently just `sample *= gain`. +/// 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. struct Codename206 { params: Arc, + sample_rate: f32, + comp: Compressor, } #[derive(Params)] struct Codename206Params { - /// The egui editor's window state (size). Persisted with the parameter state so a - /// resized window is restored on reload. #[persist = "editor-state"] editor_state: Arc, - /// Full-band output gain. Stored as a linear gain multiplier but displayed in dB. - #[id = "gain"] - pub gain: FloatParam, + /// 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. + #[id = "lookahead"] + pub look_ahead_ms: FloatParam, + + /// The full-band compressor controls (reused per band + 'All' channel later). + #[nested(group = "Compressor")] + pub comp: CompressorParams, +} + +#[derive(Params)] +struct CompressorParams { + #[id = "thresh"] + pub threshold_db: FloatParam, + #[id = "ratio"] + pub ratio: FloatParam, + #[id = "knee"] + pub knee_db: FloatParam, + #[id = "attack"] + pub attack_ms: FloatParam, + #[id = "release"] + pub release_ms: FloatParam, + #[id = "makeup"] + pub makeup_db: FloatParam, + #[id = "bypass"] + pub bypass: BoolParam, } impl Default for Codename206 { fn default() -> Self { Self { params: Arc::new(Codename206Params::default()), + sample_rate: 48_000.0, + comp: Compressor::new(), } } } @@ -34,28 +62,88 @@ impl Default for Codename206 { impl Default for Codename206Params { fn default() -> Self { Self { - editor_state: EguiState::from_size(300, 140), + editor_state: EguiState::from_size(360, 360), - // Stored as linear gain, displayed/edited in dB. Skewed so the slider feels - // linear in decibels across the -30..+30 dB range. - gain: FloatParam::new( - "Gain", - util::db_to_gain(0.0), - FloatRange::Skewed { - min: util::db_to_gain(-30.0), - max: util::db_to_gain(30.0), - factor: FloatRange::gain_skew_factor(-30.0, 30.0), - }, + look_ahead_ms: FloatParam::new( + "Look-ahead", + 2.0, + FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS }, ) - // Linear-gain storage needs logarithmic smoothing to avoid zipper noise. - .with_smoother(SmoothingStyle::Logarithmic(50.0)) - .with_unit(" dB") - .with_value_to_string(formatters::v2s_f32_gain_to_db(2)) - .with_string_to_value(formatters::s2v_f32_gain_to_db()), + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(2)), + + comp: CompressorParams::default(), } } } +impl Default for CompressorParams { + fn default() -> Self { + Self { + threshold_db: FloatParam::new( + "Threshold", + -18.0, + FloatRange::Linear { min: -60.0, max: 0.0 }, + ) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + + ratio: FloatParam::new( + "Ratio", + 2.0, + FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) }, + ) + .with_value_to_string(Arc::new(|v| format!("{v:.2} : 1"))) + .with_string_to_value(Arc::new(|s| { + 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)), + + attack_ms: FloatParam::new( + "Attack", + 10.0, + FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) }, + ) + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(2)), + + release_ms: FloatParam::new( + "Release", + 100.0, + FloatRange::Skewed { min: 1.0, max: 1000.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)), + + bypass: BoolParam::new("Bypass", false), + } + } +} + +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 + } +} + impl Plugin for Codename206 { const NAME: &'static str = "206 prototype"; const VENDOR: &'static str = "Novoyuuparosk"; @@ -97,25 +185,96 @@ impl Plugin for Codename206 { |_, _| {}, move |egui_ctx, setter, _state| { egui::CentralPanel::default().show(egui_ctx, |ui| { - ui.heading("Codename 206"); + ui.heading(Self::NAME); ui.separator(); - ui.label("Gain"); - ui.add(widgets::ParamSlider::for_param(¶ms.gain, setter)); + egui::Grid::new("params").num_columns(2).show(ui, |ui| { + 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.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(); + }); }); }, ) } + fn initialize( + &mut self, + audio_io_layout: &AudioIOLayout, + buffer_config: &BufferConfig, + context: &mut impl InitContext, + ) -> bool { + self.sample_rate = buffer_config.sample_rate; + let channels = audio_io_layout + .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()); + true + } + + fn reset(&mut self) { + self.comp.reset(); + } + fn process( &mut self, buffer: &mut Buffer, _aux: &mut AuxiliaryBuffers, _context: &mut impl ProcessContext, ) -> ProcessStatus { - for channel_samples in buffer.iter_samples() { - let gain = self.params.gain.smoothed.next(); - for sample in channel_samples { - *sample *= gain; + // 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, + bypass: c.bypass.value(), + }; + + let mut in_frame = [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(); + + 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); + for ch in 0..n { + *frame.get_mut(ch).unwrap() = out_frame[ch]; } } @@ -126,7 +285,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 (landmark: full-band gain)"); + Some("Multiband compressor/limiter (stage 2: full-band compressor)"); const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL); const CLAP_SUPPORT_URL: Option<&'static str> = None; const CLAP_FEATURES: &'static [ClapFeature] = &[