Files
codename-206/src/dsp/compressor.rs
T
irene 057d630c5b Switch license from GPL-3.0 to Apache-2.0
- Replace LICENSE with properly filled Apache-2.0 (Mikkeli Matlock 2026)
- Update Cargo.toml license field
- Add SPDX-License-Identifier headers to all .rs source files
2026-08-21 01:06:46 +09:00

432 lines
18 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.
// SPDX-License-Identifier: Apache-2.0
//! 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;
/// Silence-floor anchor for the below-threshold shaping: at/below this level the gain change is 0
/// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
/// the editor gain-curve's display floor.
const LOW_ANCHOR_DB: f32 = -60.0;
/// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
/// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
/// both ends (silence floor and the knee), so it never moves those anchors.
const LOW_BULGE_MAX_DB: f32 = 12.0;
/// 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,
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
/// Reshapes the level the compressor sees (serial), anchored at the floor.
pub low_slope: f32,
/// Low shaper curvature, 0..1 (0 = straight line, 1 = max bend toward bounded saturation).
pub low_curve: 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,
/// Dry/wet blend, 0..=1. 1 = fully compressed (incl. makeup), 0 = dry passthrough (bypass).
/// Parallel: dry and wet share the same delayed input, so the mix is phase-aligned.
pub mix: f32,
}
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, in dB of attenuation (signed: usually >= 0, but can go
/// negative = boost when `low_slope < 1`). The `max()` recurrence makes cut fast / boost slow.
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()
}
}
/// Pure compressor transfer (threshold / ratio / quadratic soft knee). Returns gain reduction
/// in dB (<= 0) for an input `level_db`.
fn comp_gain_db(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 {
let x = over + knee_db * 0.5; // 0..knee
slope * x * x / (2.0 * knee_db)
} else if over > 0.0 {
slope * over
} else {
0.0
}
}
/// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
/// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
/// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
/// bulges up (boost the quiet middle), negative down (suppress). Reshapes the level the
/// compressor then sees.
fn low_gain_db(level_db: f32, threshold_db: f32, low_slope: f32, low_curve: f32) -> f32 {
let d = level_db - LOW_ANCHOR_DB;
if d <= 0.0 {
return 0.0;
}
let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
let t = (d / span).min(1.0); // normalized position, clamped at the knee
// Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
let slope_line = (low_slope - 1.0) * span * t;
// Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
let bulge = low_curve * LOW_BULGE_MAX_DB * 4.0 * t * (1.0 - t);
slope_line + bulge
}
/// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
/// threshold sees the shaped level. Returns total gain in dB (signed: negative = cut, positive
/// = boost). `gain = low + comp(level + low)`. Shared with the editor's gain-curve display —
/// single source of truth.
pub fn gain_computer(
level_db: f32,
threshold_db: f32,
ratio: f32,
knee_db: f32,
low_slope: f32,
low_curve: f32,
) -> f32 {
let low = Self::low_gain_db(level_db, threshold_db, low_slope, low_curve);
low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
}
/// The plugin's fixed reported latency in samples (the constant audio delay).
pub fn latency(&self) -> u32 {
self.fixed_delay as u32
}
/// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering.
/// This is the smoothed detector output `yl`, so it tracks the visible needle, not the
/// instantaneous static curve.
pub fn gain_reduction_db(&self) -> f32 {
self.yl
}
/// 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. The detector ALWAYS runs (even at mix 0) so metering
// reflects the wet gain reduction regardless of the dry/wet blend.
// 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,
set.low_slope,
set.low_curve,
);
// 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 wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0);
// 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the
// blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
let mix = set.mix.clamp(0.0, 1.0);
let blend = (1.0 - mix) + mix * wet_gain;
for ch in 0..n {
output[ch] = self.delay[ch][out_pos] * blend;
}
// 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,
mix: 1.0,
low_slope: 1.0,
low_curve: 0.0,
}
}
#[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, 1.0, 0.0), 0.0);
}
#[test]
fn low_shaper_serial_slope_and_bipolar_bulge() {
let thr = -18.0;
// Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
// comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
// Cut (slope 0.5) -> -15 dB; shaped to -45, still below threshold -> net -15.
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
// Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
let mid = -39.0; // middle of [-60, -18]
let flat = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 0.0);
let up = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 1.0);
let down = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, -1.0);
assert!(up > flat && flat > down, "bipolar bulge expected: {down} < {flat} < {up}");
// Endpoints are unaffected by curvature (silence anchored).
assert_close(Compressor::gain_computer(-60.0, thr, 4.0, 0.0, 1.0, 1.0), 0.0, 1e-6);
}
#[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, 1.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, 1.0, 0.0);
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, 1.0, 0.0), 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.mix = 0.0; // dry passthrough -> 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);
}
}
}
}
}