Merge branch 'perf/dsp-hot-path'
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).
|
/// 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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user