Files
codename-206/src/dsp/compressor.rs
T
Mikkeli Matlock b1477f7ec6 feat: static gain-curve display for the selected channel
Add editor/gain_curve.rs: a square panel beside the scrolling plot showing the
channel transfer (out vs in, -60..0 dB) for whichever channel the plot tab
selects. Plots the full wet path — out = (in + pre_gain) + gain_reduction + makeup
— using the shared Compressor::gain_computer (now pub) so it matches the DSP and
the GR meter. Unity-reference diagonal + threshold marker; mix not folded in.

Update README structure/status to reflect the editor/ widget module and the
completed Stage 6 visualisers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:08 +09:00

356 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,
/// 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, 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`. Also used by the editor's
/// gain-curve display, so it stays the single source of truth for the transfer shape.
pub 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
}
/// 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);
// 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 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,
}
}
#[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.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);
}
}
}
}
}