466ab478c9
Commit 057d630 switched this repo from GPL-3.0 to Apache-2.0, which the
build contradicts: src/lib.rs calls nih_export_vst3! unconditionally and
nih_plug is taken with default features, so every build links vst3-sys —
GPLv3-or-later by its own authors' choice. deploy.ps1 then installs that
binary. The declared licence and the artifact's actual terms disagreed,
and the original GPL-3.0 was correct.
Given the intent to distribute non-profit and open-source, GPL costs
nothing here, so this restores it rather than dropping VST3 to keep a
permissive licence.
Also adds the ISC notice owed to Robbert van der Helm: the plugin
scaffolding derives from the NIH-plug cookiecutter template and example
plugins, whose one condition is that the copyright and permission notice
travel with all copies. The README credited the template under Reference
Material, which is courtesy, not compliance. ISC is GPL-compatible, so the
notice is retained in NOTICE rather than superseded.
Corrects the README's claim that Steinberg's SDK licence must be accepted
before distributing. No SDK source is vendored or redistributed, so there
is nothing to accept at build time. The binding constraint is vst3-sys's
GPLv3; Steinberg's trademark in "VST" is a separate regime that no source
licence resolves.
Copyright in the Irene <irene@novoyuuparosk.org> commits is held by the
repository owner (an agent identity), so no third-party agreement was
needed for this relicense.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
533 lines
23 KiB
Rust
533 lines
23 KiB
Rust
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
//! 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 the level conversion away from zero without affecting audible levels.
|
||
const LEVEL_EPS: f32 = 1e-12;
|
||
|
||
/// `ln(x) * LOG10_GAIN_TO_DB == log10(x) * 20`, but a bare `ln()` runs faster than `log10()` on
|
||
/// most architectures (mirrors nih-plug's `util::gain_to_db_fast`). Used in the per-sample level
|
||
/// detector, called 4x/sample (once per band + 'All').
|
||
const LOG10_GAIN_TO_DB: f32 = std::f32::consts::LOG10_E * 20.0;
|
||
|
||
/// `exp(x * DB_TO_GAIN_EXP) == 10.0f32.powf(x / 20.0)`, but `exp()` runs faster than a general
|
||
/// `powf()` on most architectures (mirrors nih-plug's `util::db_to_gain_fast`) — `powf` has to
|
||
/// handle an arbitrary runtime base, while this bakes `ln(10)` in as a constant. Used for the
|
||
/// per-sample wet gain, called 4x/sample.
|
||
const DB_TO_GAIN_EXP: f32 = std::f32::consts::LN_10 / 20.0;
|
||
|
||
/// 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.
|
||
/// Default RMS averaging time constant for tests that do not care which band they model.
|
||
/// Real instances pass a per-band value - see the `RMS_WINDOW_MS` table in `lib.rs`.
|
||
#[cfg(test)]
|
||
const RMS_WINDOW_DEFAULT_MS: f32 = 20.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()`.
|
||
/// `rms_window_ms` is the time constant of the RMS detector's one-pole average. It is a
|
||
/// design constant per band rather than a user param: the squared signal carries a component
|
||
/// at twice the fundamental, so a window that does not span a cycle leaves the detector
|
||
/// rippling at 2f and the gain intermodulating with the bass. The needed window is therefore
|
||
/// set by how low the band goes, which the band - not the compressor - knows.
|
||
pub fn prepare(
|
||
&mut self,
|
||
sample_rate: f32,
|
||
num_channels: usize,
|
||
max_lookahead_ms: f32,
|
||
rms_window_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.
|
||
// `wrap_add` instead of `%`: capacity is rarely a power of two, so a plain modulo forces
|
||
// an integer division here; a conditional subtract is cheaper and behaves identically
|
||
// given the add is always <= capacity (true for every offset computed in this function).
|
||
let out_pos = wrap_add(self.write_pos, self.capacity - self.fixed_delay, self.capacity);
|
||
let det_pos =
|
||
wrap_add(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 = (detector + LEVEL_EPS).ln() * LOG10_GAIN_TO_DB;
|
||
// 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 = ((set.makeup_db - self.yl) * DB_TO_GAIN_EXP).exp();
|
||
|
||
// 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 = wrap_add(self.write_pos, 1, self.capacity);
|
||
}
|
||
}
|
||
|
||
/// Wrap `pos + add` into `[0, capacity)`, assuming `add <= capacity` — a conditional subtract in
|
||
/// place of `%`, since `capacity` is rarely a power of two (so `%` costs a real integer division).
|
||
#[inline]
|
||
fn wrap_add(pos: usize, add: usize, capacity: usize) -> usize {
|
||
let sum = pos + add;
|
||
if sum >= capacity {
|
||
sum - capacity
|
||
} else {
|
||
sum
|
||
}
|
||
}
|
||
|
||
#[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, RMS_WINDOW_DEFAULT_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, RMS_WINDOW_DEFAULT_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, RMS_WINDOW_DEFAULT_MS); // 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Measure peak-to-peak gain-reduction ripple for a steady sine, with the ballistics
|
||
/// disabled so the gain follows the detector directly. Squaring puts a component at 2f in
|
||
/// the detector, so a window that does not span a cycle leaves the gain wobbling at 2f -
|
||
/// which is intermodulation, heard as growl on bass.
|
||
fn gr_ripple_db(rms_window_ms: f32, freq: f32) -> f32 {
|
||
let mut c = Compressor::new();
|
||
c.prepare(SR, 1, MAX_LOOKAHEAD_MS, rms_window_ms);
|
||
let set = CompressorSettings {
|
||
threshold_db: -40.0, ratio: 8.0, knee_db: 0.0,
|
||
// coef 0 = no attack/release smoothing, so yl == the detector's target.
|
||
attack_coef: 0.0, release_coef: 0.0,
|
||
makeup_db: 0.0, mix: 1.0, lookahead_samples: 0,
|
||
use_rms: true, low_slope: 1.0, low_curve: 0.0,
|
||
};
|
||
let mut out = [0.0f32];
|
||
// Settle well past the longest window under test.
|
||
let settle = (SR * 0.5) as usize;
|
||
for i in 0..settle {
|
||
let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin();
|
||
c.process(&[x], &mut out, &set);
|
||
}
|
||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||
for i in settle..settle + (SR / freq * 4.0) as usize {
|
||
let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin();
|
||
c.process(&[x], &mut out, &set);
|
||
let gr = c.gain_reduction_db();
|
||
lo = lo.min(gr);
|
||
hi = hi.max(gr);
|
||
}
|
||
hi - lo
|
||
}
|
||
|
||
/// The per-band RMS windows in `lib.rs` exist to keep this ripple down; without a test the
|
||
/// next person to "tune" them has nothing telling them what they are for.
|
||
#[test]
|
||
fn rms_window_suppresses_low_frequency_ripple() {
|
||
// The LOW band's 50 ms window against the lowest content it can be asked to carry.
|
||
let low_band = gr_ripple_db(50.0, 30.0);
|
||
assert!(low_band < 1.0, "50 ms window rippled {low_band:.2} dB at 30 Hz, expected < 1 dB");
|
||
|
||
// A short window on the same signal must be visibly worse - this is what the per-band
|
||
// table is avoiding, and it keeps the assertion above from passing for the wrong reason.
|
||
let short = gr_ripple_db(5.0, 30.0);
|
||
assert!(
|
||
short > 3.0 * low_band,
|
||
"5 ms ({short:.2} dB) should ripple far more than 50 ms ({low_band:.2} dB)"
|
||
);
|
||
|
||
// Ripple must fall monotonically as the window grows.
|
||
let mut prev = f32::MAX;
|
||
for w in [5.0, 10.0, 20.0, 35.0, 50.0] {
|
||
let r = gr_ripple_db(w, 30.0);
|
||
assert!(r < prev, "ripple did not fall from the previous window at {w} ms ({r:.2} dB)");
|
||
prev = r;
|
||
}
|
||
|
||
// The HIGH band's short window is fine because nothing up there ripples.
|
||
let high_band = gr_ripple_db(10.0, 5_000.0);
|
||
assert!(high_band < 0.1, "10 ms window rippled {high_band:.2} dB at 5 kHz");
|
||
}
|
||
} |