Merge branch 'perf/dsp-hot-path'

This commit is contained in:
Mikkeli Matlock
2026-08-31 21:19:29 +09:00
4 changed files with 165 additions and 25 deletions
+33 -6
View File
@@ -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). /// Most channels we ever process in one frame (our audio layouts are mono/stereo).
const MAX_CHANNELS: usize = 2; 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; 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-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 /// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
/// the editor gain-curve's display floor. /// 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 // 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. // `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; // `wrap_add` instead of `%`: capacity is rarely a power of two, so a plain modulo forces
let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity; // 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. // 3) Linked peak detector at the look-ahead tap.
let mut peak = 0.0f32; let mut peak = 0.0f32;
@@ -245,7 +260,7 @@ impl Compressor {
// active (regardless of mode) so switching peak<->RMS is seamless. // 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; 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 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. // Desired attenuation in dB, as a positive quantity.
let target = -Self::gain_computer( let target = -Self::gain_computer(
level_db, level_db,
@@ -262,7 +277,7 @@ impl Compressor {
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target); 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; 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 // 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. // blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
@@ -273,7 +288,19 @@ impl Compressor {
} }
// 6) Advance the write head. // 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
View File
@@ -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 { pub struct Limiter {
/// Per-channel audio delay ring. /// Per-channel audio delay ring.
delay: Vec<Vec<f32>>, delay: Vec<Vec<f32>>,
/// Linked `|x|` history, same length as the delay ring (for the sliding maximum). /// O(1)-amortized sliding-window maximum over the linked `|x|` history (replaces an O(window)
peaks: Vec<f32>, /// full-rescan every sample).
sliding_max: SlidingMax,
capacity: usize, capacity: usize,
write_pos: usize, write_pos: usize,
fixed_delay: usize, fixed_delay: usize,
/// Current smoothed gain (<= 1). /// Current smoothed gain (<= 1).
gain: f32, gain: f32,
attack_coef: 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. /// 4× interpolator for true-peak (inter-sample) detection.
oversampler: Oversampler, oversampler: Oversampler,
} }
@@ -47,12 +127,13 @@ impl Default for Limiter {
fn default() -> Self { fn default() -> Self {
Self { Self {
delay: Vec::new(), delay: Vec::new(),
peaks: Vec::new(), sliding_max: SlidingMax::new(),
capacity: 0, capacity: 0,
write_pos: 0, write_pos: 0,
fixed_delay: 0, fixed_delay: 0,
gain: 1.0, gain: 1.0,
attack_coef: 0.0, attack_coef: 0.0,
margin_gain: 1.0,
oversampler: Oversampler::new(), oversampler: Oversampler::new(),
} }
} }
@@ -68,9 +149,10 @@ impl Limiter {
self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize; self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize;
self.capacity = self.fixed_delay + 1; self.capacity = self.fixed_delay + 1;
self.attack_coef = time_to_coef(ATTACK_MS, sample_rate); 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); let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; 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.oversampler.prepare(channels);
self.reset(); self.reset();
} }
@@ -79,7 +161,7 @@ impl Limiter {
for ch in &mut self.delay { for ch in &mut self.delay {
ch.iter_mut().for_each(|s| *s = 0.0); 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.oversampler.reset();
self.write_pos = 0; self.write_pos = 0;
self.gain = 1.0; self.gain = 1.0;
@@ -111,21 +193,17 @@ impl Limiter {
let peak = sample_peak.max(self.oversampler.max_true_peak(&input[..n])); 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. // 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. // Write into the ring.
for ch in 0..n { for ch in 0..n {
self.delay[ch][self.write_pos] = input[ch]; 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 // 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 // (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. // exceed the ceiling, and the gain has pre-dropped for any louder sample still to come.
let mut window_max = 0.0f32; let window_max = self.sliding_max.push(peak);
for &p in &self.peaks {
window_max = window_max.max(p);
}
let target = if window_max > target_ceiling { let target = if window_max > target_ceiling {
target_ceiling / window_max target_ceiling / window_max
} else { } else {
@@ -140,12 +218,14 @@ impl Limiter {
}; };
// Output the delayed sample, clamped to the ceiling as the hard guarantee. // 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 { for ch in 0..n {
output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling); 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) 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] #[test]
fn never_exceeds_ceiling_on_spikes() { fn never_exceeds_ceiling_on_spikes() {
// Mostly silence with occasional large spikes — output must never exceed the ceiling. // Mostly silence with occasional large spikes — output must never exceed the ceiling.
+10 -4
View File
@@ -97,15 +97,21 @@ impl Oversampler {
self.history[ch][slot] = input[ch]; 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; let mut peak = 0.0f32;
for ch in 0..n { for ch in 0..n {
let hist = &self.history[ch]; let hist = &self.history[ch];
for phase in &self.coeffs { for phase in &self.coeffs {
let mut acc = 0.0f32; let mut acc = 0.0f32;
for (k, &c) in phase.iter().enumerate() { for (&c, &i) in phase.iter().zip(idx.iter()) {
// k = 0 is the newest sample, increasing k goes back in time. acc += c * hist[i];
let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
acc += c * hist[idx];
} }
peak = peak.max(acc.abs()); peak = peak.max(acc.abs());
} }
+4 -2
View File
@@ -241,7 +241,9 @@ impl Plugin for Codename206 {
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum. // Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
summed[..n].fill(0.0); summed[..n].fill(0.0);
for b in 0..3 { 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 { for ch in 0..n {
band_in[b][ch] *= pre; band_in[b][ch] *= pre;
} }
@@ -272,7 +274,7 @@ impl Plugin for Codename206 {
} }
// 'All' aggregate channel over the summed bands (driven before its compressor). // '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 { for ch in 0..n {
summed[ch] *= all_pre; summed[ch] *= all_pre;
} }