d776e564fd
Investigated the planned global FTZ for the IIR filters and found NIH-plug already handles it: process_wrapper wraps process()/reset() in a ScopedFtz guard that enables CPU Flush-To-Zero (x86 MXCSR bit 15 / AArch64 FPCR bit 24, via inline asm, restored on drop) on the vst3, clap, and standalone paths. SSE is baseline on x86_64 so FTZ is always active for our build. So adding our own guard would just duplicate the framework. Instead, removed the now-redundant flush_denormal() from compressor.rs (the biquads and envelope/RMS tails already relied on this FTZ) for a single consistent story, and rewrote the README 'Denormal flushing' note to document that the framework handles it (FTZ, not DAZ — sufficient for our feed-forward IIR). No functional change; 10 unit tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
348 lines
14 KiB
Rust
348 lines
14 KiB
Rust
//! 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<Vec<f32>>,
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|