Files
codename-206/src/dsp/compressor.rs
T
Mikkeli Matlock d776e564fd Denormals: rely on NIH-plug's FTZ, drop redundant in-code flush
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>
2026-06-18 01:34:56 +09:00

348 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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. 1718) 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);
}
}
}
}
}