2026-06-15 19:33:31 +09:00
|
|
|
|
//! 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;
|
|
|
|
|
|
|
2026-06-15 20:02:23 +09:00
|
|
|
|
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
|
|
|
|
|
/// promoted to a parameter later.
|
|
|
|
|
|
const RMS_WINDOW_MS: f32 = 5.0;
|
|
|
|
|
|
|
2026-06-18 01:34:56 +09:00
|
|
|
|
// 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.
|
2026-06-15 19:33:31 +09:00
|
|
|
|
|
|
|
|
|
|
/// 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,
|
2026-06-15 20:02:23 +09:00
|
|
|
|
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
|
|
|
|
|
pub use_rms: bool,
|
2026-06-15 19:33:31 +09:00
|
|
|
|
pub bypass: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct Compressor {
|
|
|
|
|
|
sample_rate: f32,
|
|
|
|
|
|
|
|
|
|
|
|
/// Per-channel circular delay line, each `capacity` samples long.
|
|
|
|
|
|
delay: Vec<Vec<f32>>,
|
|
|
|
|
|
capacity: usize,
|
|
|
|
|
|
write_pos: usize,
|
|
|
|
|
|
/// Constant audio delay applied to every sample == the reported plugin latency.
|
|
|
|
|
|
fixed_delay: usize,
|
|
|
|
|
|
|
2026-06-15 20:02:23 +09:00
|
|
|
|
/// RMS detector state: running mean of the squared (linked) level, plus its coefficient.
|
|
|
|
|
|
mean_sq: f32,
|
|
|
|
|
|
rms_coef: f32,
|
|
|
|
|
|
|
2026-06-15 19:33:31 +09:00
|
|
|
|
/// 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,
|
2026-06-15 20:02:23 +09:00
|
|
|
|
mean_sq: 0.0,
|
|
|
|
|
|
rms_coef: 0.0,
|
2026-06-15 19:33:31 +09:00
|
|
|
|
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;
|
2026-06-15 20:02:23 +09:00
|
|
|
|
self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate);
|
2026-06-15 19:33:31 +09:00
|
|
|
|
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;
|
2026-06-15 20:02:23 +09:00
|
|
|
|
self.mean_sq = 0.0;
|
2026-06-15 19:33:31 +09:00
|
|
|
|
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 {
|
2026-06-15 20:02:23 +09:00
|
|
|
|
// RMS = running mean of the linked squared level over a fixed window. Updated
|
|
|
|
|
|
// whenever active (regardless of mode) so switching peak<->RMS is seamless.
|
2026-06-18 01:34:56 +09:00
|
|
|
|
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
2026-06-15 20:02:23 +09:00
|
|
|
|
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
|
|
|
|
|
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
2026-06-15 19:33:31 +09:00
|
|
|
|
// 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
|
2026-06-18 01:34:56 +09:00
|
|
|
|
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;
|
2026-06-15 19:33:31 +09:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-15 20:02:23 +09:00
|
|
|
|
use_rms: false,
|
2026-06-15 19:33:31 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 20:02:23 +09:00
|
|
|
|
#[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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 19:33:31 +09:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|