//! 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; /// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be /// promoted to a parameter later. const RMS_WINDOW_MS: f32 = 5.0; // Denormals (the exponentially-decaying envelope/RMS tails and the IIR filter state) are handled // by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its // `ScopedFtz` guard (x86 MXCSR / AArch64 FPCR). So no per-value flushing is needed here. /// 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, /// `true` = RMS detection (running power average), `false` = naive sample peak. pub use_rms: bool, 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, /// RMS detector state: running mean of the squared (linked) level, plus its coefficient. mean_sq: f32, rms_coef: f32, /// 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, mean_sq: 0.0, rms_coef: 0.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; self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate); 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.mean_sq = 0.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 { // 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; 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, use_rms: false, 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 rms_compresses_a_sine_less_than_peak() { // For a sine, RMS level is ~3 dB below the peak (A/√2), so RMS detection sees a lower // level and applies less gain reduction -> louder output than peak detection. use std::f32::consts::PI; fn output_rms(use_rms: bool) -> f32 { let mut comp = Compressor::new(); comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); let mut set = settings(-30.0, 4.0, 0.0); set.use_rms = use_rms; set.attack_coef = Compressor::time_to_coef(1.0, SR); set.release_coef = Compressor::time_to_coef(50.0, SR); let (amp, freq) = (0.5f32, 2000.0f32); let total = SR as usize; let mut out = [0.0f32]; let (mut acc, mut cnt) = (0.0f64, 0u32); for i in 0..total { let x = amp * (2.0 * PI * freq * i as f32 / SR).sin(); comp.process(&[x], &mut out, &set); if i >= total - 4800 { // measure RMS over the last 0.1 s, after settling acc += (out[0] * out[0]) as f64; cnt += 1; } } (acc / cnt as f64).sqrt() as f32 } assert!( output_rms(true) > output_rms(false), "RMS detection should compress a sine less than peak" ); } #[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); } } } } }