perf: remove per-sample waste from the compressor/limiter hot path
Found by reading the audio-thread code, not by profiling - no profiler is set up in this project yet. All four are algorithmic or arithmetic wins, none change the DSP result; the existing tests still pass. - oversampler: max_true_peak recomputed its circular-history tap index (including a % TAPS_PER_PHASE) inside the phase x channel loop, though the index depends only on pos/k - 8x redundant. Hoisted to a per-sample precomputed index array. This runs unconditionally every sample (it feeds the limiter's true-peak detector, not gated by metering), so it was the highest-value fix. - limiter: the sliding-window max rescanned the entire look-ahead ring (~73 samples at 48kHz/1.5ms) every sample - O(window) per sample. Replaced with an O(1)-amortized monotonic-deque sliding max, backed by preallocated ring arrays and RT-safe. Covered by a new test against a naive brute-force reference. Also precomputed the constant true-peak margin gain in prepare() instead of a powf every sample. - compressor: replaced per-sample log10/powf(10, x/20) with the equivalent ln/exp forms, mirroring nih-plug's own gain_to_db_fast / db_to_gain_fast. Called 4x/sample (3 bands + 'All'). - both: replaced % capacity ring-index math (a real integer division, since capacity is not a power of two) with a conditional-subtract wrap_add helper. Left the oversampler's % TAPS_PER_PHASE alone - that divisor is a compile-time constant LLVM already turns into a multiply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
This commit is contained in:
+33
-6
@@ -24,9 +24,20 @@ 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.
|
||||
/// ~ -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.
|
||||
@@ -230,8 +241,12 @@ impl Compressor {
|
||||
|
||||
// 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;
|
||||
// `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;
|
||||
@@ -245,7 +260,7 @@ impl Compressor {
|
||||
// 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();
|
||||
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,
|
||||
@@ -262,7 +277,7 @@ impl Compressor {
|
||||
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);
|
||||
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.
|
||||
@@ -273,7 +288,19 @@ impl Compressor {
|
||||
}
|
||||
|
||||
// 6) Advance the write head.
|
||||
self.write_pos = (self.write_pos + 1) % self.capacity;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-13
@@ -28,17 +28,97 @@ fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// O(1)-amortized fixed-window sliding maximum (the classic monotonic-deque algorithm), in place
|
||||
/// of rescanning the whole window every sample. Values are pushed one at a time; each push returns
|
||||
/// the max over the last `window` pushes (inclusive of the one just pushed).
|
||||
///
|
||||
/// Backed by two preallocated ring buffers sized to `window` (never grows past that: entries are
|
||||
/// evicted once their age reaches `window`, and a strictly-decreasing run of un-evicted entries
|
||||
/// still ages out within `window` pushes) — so `push` never allocates, keeping it RT-safe.
|
||||
struct SlidingMax {
|
||||
val: Vec<f32>,
|
||||
seq: Vec<u64>,
|
||||
cap: usize,
|
||||
head: usize,
|
||||
len: usize,
|
||||
next_seq: u64,
|
||||
}
|
||||
|
||||
impl SlidingMax {
|
||||
fn new() -> Self {
|
||||
Self { val: Vec::new(), seq: Vec::new(), cap: 0, head: 0, len: 0, next_seq: 0 }
|
||||
}
|
||||
|
||||
fn prepare(&mut self, window: usize) {
|
||||
self.cap = window.max(1);
|
||||
self.val = vec![0.0; self.cap];
|
||||
self.seq = vec![0; self.cap];
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.head = 0;
|
||||
self.len = 0;
|
||||
self.next_seq = 0;
|
||||
}
|
||||
|
||||
/// Push a new value; return the maximum over the last `cap` pushes (including this one).
|
||||
fn push(&mut self, value: f32) -> f32 {
|
||||
let t = self.next_seq;
|
||||
|
||||
// Values can never be the max while the new one is still in the window, so drop them.
|
||||
while self.len > 0 {
|
||||
let back = (self.head + self.len - 1) % self.cap;
|
||||
if self.val[back] <= value {
|
||||
self.len -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let back = (self.head + self.len) % self.cap;
|
||||
self.val[back] = value;
|
||||
self.seq[back] = t;
|
||||
self.len += 1;
|
||||
|
||||
// Evict entries that have aged out of the window.
|
||||
while self.seq[self.head] + self.cap as u64 <= t {
|
||||
self.head = (self.head + 1) % self.cap;
|
||||
self.len -= 1;
|
||||
}
|
||||
|
||||
self.next_seq = t + 1;
|
||||
self.val[self.head]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Limiter {
|
||||
/// Per-channel audio delay ring.
|
||||
delay: Vec<Vec<f32>>,
|
||||
/// Linked `|x|` history, same length as the delay ring (for the sliding maximum).
|
||||
peaks: Vec<f32>,
|
||||
/// O(1)-amortized sliding-window maximum over the linked `|x|` history (replaces an O(window)
|
||||
/// full-rescan every sample).
|
||||
sliding_max: SlidingMax,
|
||||
capacity: usize,
|
||||
write_pos: usize,
|
||||
fixed_delay: usize,
|
||||
/// Current smoothed gain (<= 1).
|
||||
gain: f32,
|
||||
attack_coef: f32,
|
||||
/// `10^(-TRUE_PEAK_MARGIN_DB/20)`, precomputed once (the margin is a fixed constant, so
|
||||
/// there's no reason to recompute this `powf` on every sample).
|
||||
margin_gain: f32,
|
||||
/// 4× interpolator for true-peak (inter-sample) detection.
|
||||
oversampler: Oversampler,
|
||||
}
|
||||
@@ -47,12 +127,13 @@ impl Default for Limiter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delay: Vec::new(),
|
||||
peaks: Vec::new(),
|
||||
sliding_max: SlidingMax::new(),
|
||||
capacity: 0,
|
||||
write_pos: 0,
|
||||
fixed_delay: 0,
|
||||
gain: 1.0,
|
||||
attack_coef: 0.0,
|
||||
margin_gain: 1.0,
|
||||
oversampler: Oversampler::new(),
|
||||
}
|
||||
}
|
||||
@@ -68,9 +149,10 @@ impl Limiter {
|
||||
self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize;
|
||||
self.capacity = self.fixed_delay + 1;
|
||||
self.attack_coef = time_to_coef(ATTACK_MS, sample_rate);
|
||||
self.margin_gain = 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0);
|
||||
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||
self.peaks = vec![0.0; self.capacity];
|
||||
self.sliding_max.prepare(self.capacity);
|
||||
self.oversampler.prepare(channels);
|
||||
self.reset();
|
||||
}
|
||||
@@ -79,7 +161,7 @@ impl Limiter {
|
||||
for ch in &mut self.delay {
|
||||
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||
}
|
||||
self.peaks.iter_mut().for_each(|p| *p = 0.0);
|
||||
self.sliding_max.reset();
|
||||
self.oversampler.reset();
|
||||
self.write_pos = 0;
|
||||
self.gain = 1.0;
|
||||
@@ -111,21 +193,17 @@ impl Limiter {
|
||||
let peak = sample_peak.max(self.oversampler.max_true_peak(&input[..n]));
|
||||
|
||||
// Target a hair below the ceiling so the (slightly under-read) true peak stays under it.
|
||||
let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0);
|
||||
let target_ceiling = ceiling * self.margin_gain;
|
||||
|
||||
// Write into the ring.
|
||||
for ch in 0..n {
|
||||
self.delay[ch][self.write_pos] = input[ch];
|
||||
}
|
||||
self.peaks[self.write_pos] = peak;
|
||||
|
||||
// Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample
|
||||
// (the one we output now) is in this window, `ceiling / window_max` applied to it can never
|
||||
// exceed the ceiling, and the gain has pre-dropped for any louder sample still to come.
|
||||
let mut window_max = 0.0f32;
|
||||
for &p in &self.peaks {
|
||||
window_max = window_max.max(p);
|
||||
}
|
||||
let window_max = self.sliding_max.push(peak);
|
||||
let target = if window_max > target_ceiling {
|
||||
target_ceiling / window_max
|
||||
} else {
|
||||
@@ -140,12 +218,14 @@ impl Limiter {
|
||||
};
|
||||
|
||||
// Output the delayed sample, clamped to the ceiling as the hard guarantee.
|
||||
let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago
|
||||
// Conditional subtract instead of `%`: capacity is rarely a power of two, and the add
|
||||
// here (1) is always <= capacity, so a plain compare-and-subtract is enough.
|
||||
let out_pos = wrap_add(self.write_pos, 1, self.capacity); // oldest sample = fixed_delay ago
|
||||
for ch in 0..n {
|
||||
output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling);
|
||||
}
|
||||
|
||||
self.write_pos = (self.write_pos + 1) % self.capacity;
|
||||
self.write_pos = wrap_add(self.write_pos, 1, self.capacity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +240,31 @@ mod tests {
|
||||
time_to_coef(50.0, SR)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_max_matches_naive_full_rescan() {
|
||||
// A small xorshift-ish PRNG (no extra deps) exercising both rising and falling runs.
|
||||
let mut state = 0x1234_5678u32;
|
||||
let mut next = || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
(state % 1000) as f32 / 1000.0
|
||||
};
|
||||
|
||||
let window = 7usize;
|
||||
let mut sm = SlidingMax::new();
|
||||
sm.prepare(window);
|
||||
let mut history: Vec<f32> = Vec::new();
|
||||
|
||||
for _ in 0..2_000 {
|
||||
let v = next();
|
||||
history.push(v);
|
||||
let start = history.len().saturating_sub(window);
|
||||
let expected = history[start..].iter().cloned().fold(0.0f32, f32::max);
|
||||
assert_eq!(sm.push(v), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_exceeds_ceiling_on_spikes() {
|
||||
// Mostly silence with occasional large spikes — output must never exceed the ceiling.
|
||||
|
||||
+10
-4
@@ -97,15 +97,21 @@ impl Oversampler {
|
||||
self.history[ch][slot] = input[ch];
|
||||
}
|
||||
|
||||
// The tap index only depends on `pos` and `k`, not on `phase` or `ch` — hoist it out of
|
||||
// those loops instead of recomputing (and re-dividing) it PHASES*MAX_CHANNELS times over.
|
||||
let mut idx = [0usize; TAPS_PER_PHASE];
|
||||
for (k, slot) in idx.iter_mut().enumerate() {
|
||||
// k = 0 is the newest sample, increasing k goes back in time.
|
||||
*slot = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
|
||||
}
|
||||
|
||||
let mut peak = 0.0f32;
|
||||
for ch in 0..n {
|
||||
let hist = &self.history[ch];
|
||||
for phase in &self.coeffs {
|
||||
let mut acc = 0.0f32;
|
||||
for (k, &c) in phase.iter().enumerate() {
|
||||
// k = 0 is the newest sample, increasing k goes back in time.
|
||||
let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
|
||||
acc += c * hist[idx];
|
||||
for (&c, &i) in phase.iter().zip(idx.iter()) {
|
||||
acc += c * hist[i];
|
||||
}
|
||||
peak = peak.max(acc.abs());
|
||||
}
|
||||
|
||||
+4
-2
@@ -241,7 +241,9 @@ impl Plugin for Codename206 {
|
||||
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
|
||||
summed[..n].fill(0.0);
|
||||
for b in 0..3 {
|
||||
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next());
|
||||
// `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
|
||||
// never reaches, in exchange for a cheaper exp() instead of a general powf().
|
||||
let pre = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
|
||||
for ch in 0..n {
|
||||
band_in[b][ch] *= pre;
|
||||
}
|
||||
@@ -272,7 +274,7 @@ impl Plugin for Codename206 {
|
||||
}
|
||||
|
||||
// 'All' aggregate channel over the summed bands (driven before its compressor).
|
||||
let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next());
|
||||
let all_pre = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
|
||||
for ch in 0..n {
|
||||
summed[ch] *= all_pre;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user