Files
bass-foundry/plugins/patina/core/tests/dsp.rs
T
nvrl 9ca4e4f9dd feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
Tester feedback asked for a threshold and a ratio. Each band now cuts like a
small compressor: Threshold (dB above the local floor) replaces Selectivity,
Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a
Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut
out. Mix and Output move to the slider panel so the detection panel fits.
common-ui gains rotary_text for knobs with custom value text.
2026-11-06 17:29:54 +01:00

572 lines
18 KiB
Rust

use patina_core::*;
use std::f32::consts::TAU;
fn noise(i: usize) -> f32 {
let x = (i as u32).wrapping_mul(747796405).wrapping_add(2891336453);
let word = ((x >> ((x >> 28) + 4)) ^ x).wrapping_mul(277803737);
(((word >> 22) ^ word) as f32 / u32::MAX as f32 - 0.5) * 0.1
}
#[test]
fn delayed_identity_and_all_mix_positions_at_all_rates() {
for rate in [44100., 48000., 88200., 96000., 176400., 192000.] {
for mix in [0., 37., 100.] {
let mut e = Engine::new(rate, 2).unwrap();
e.set_settings(Settings {
depth: 0.,
mix,
..Default::default()
})
.unwrap();
let n = e.latency_samples() as usize;
let mut error = 0f64;
let mut power = 0f64;
for i in 0..n * 4 {
let x = if i < n * 2 { noise(i) } else { 0. };
let y = e.process_sample([x, -x], Monitor::default());
let expected = if i >= n && i < n * 3 {
noise(i - n)
} else {
0.
};
error += (y[0] as f64 - expected as f64).powi(2);
power += (expected as f64).powi(2);
assert!((y[0] + y[1]).abs() < 1e-6);
}
assert!(
10. * (error / power).max(1e-30).log10() < -100.,
"rate {rate}, mix {mix}: {error}"
);
}
}
}
#[test]
fn impulse_latency_delta_and_reset() {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings {
depth: 0.,
..Default::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
for i in 0..latency * 2 {
let y = e.process_sample([if i == 0 { 1. } else { 0. }, 0.], Monitor::default())[0];
assert!((y - if i == latency { 1. } else { 0. }).abs() < 1e-6);
}
e.reset();
for _ in 0..latency * 2 {
assert_eq!(e.process_sample([0., 0.], Monitor { delta: true }), [0.; 2]);
}
}
/// Band-limited sawtooth: every harmonic up to 18 kHz, amplitude 1/k.
fn saw(f0: f32, i: usize, rate: f32) -> f32 {
let t = i as f32 / rate;
(1..)
.map(|k| k as f32 * f0)
.take_while(|f| *f < 18000.)
.enumerate()
.map(|(k, f)| (TAU * f * t).sin() / (k + 1) as f32)
.sum::<f32>()
* 0.2
}
/// RBJ peaking biquad, used to put a known resonance on a test signal.
struct Peak {
b: [f32; 3],
a: [f32; 2],
z: [f32; 2],
}
impl Peak {
fn new(f: f32, q: f32, gain: f32, rate: f32) -> Self {
let a = 10f32.powf(gain / 40.);
let w = TAU * f / rate;
let alpha = w.sin() / (2. * q);
let a0 = 1. + alpha / a;
Self {
b: [
(1. + alpha * a) / a0,
-2. * w.cos() / a0,
(1. - alpha * a) / a0,
],
a: [-2. * w.cos() / a0, (1. - alpha / a) / a0],
z: [0.; 2],
}
}
fn tick(&mut self, x: f32) -> f32 {
let y = self.b[0] * x + self.z[0];
self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
self.z[1] = self.b[2] * x - self.a[1] * y;
y
}
}
fn cell(f: f32) -> usize {
(0..GRID)
.min_by(|a, b| {
(frequency(*a) - f)
.abs()
.total_cmp(&(frequency(*b) - f).abs())
})
.unwrap()
}
/// Run one second of a signal and return the last analyzer frame.
fn settle(settings: Settings, mut signal: impl FnMut(usize) -> f32) -> AnalyzerFrame {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(settings).unwrap();
let mut frame = AnalyzerFrame::default();
for i in 0..48000 {
e.process_sample([signal(i), 0.], Monitor::default());
if let Some(f) = e.take_analyzer() {
frame = f;
}
}
frame
}
fn peak_reduction(frame: &AnalyzerFrame, low: f32, high: f32) -> f32 {
(0..GRID)
.filter(|i| (low..=high).contains(&frequency(*i)))
.map(|i| frame.reduction[i])
.fold(0., f32::max)
}
#[test]
fn plain_harmonic_tone_is_left_alone() {
for f0 in [110., 220., 440.] {
let frame = settle(Settings::default(), |i| saw(f0, i, 48000.));
let peak = peak_reduction(&frame, 20., 20000.);
assert!(peak < 1., "{f0} Hz sawtooth cut by {peak} dB");
}
}
#[test]
fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
for f0 in [110., 220.] {
let mut bump = Peak::new(3000., 4., 12., 48000.);
let frame = settle(Settings::default(), |i| bump.tick(saw(f0, i, 48000.)));
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
assert!(near >= 4., "{f0} Hz: bump cut only {near} dB");
for f in [1000., 8000.] {
let away = frame.reduction[cell(f)];
assert!(away < 1., "{f0} Hz: {f} Hz cut by {away} dB");
}
assert!(peak_reduction(&frame, 20., 20000.) <= Settings::default().depth);
}
}
#[test]
fn depth_threshold_and_focus_each_do_one_thing() {
let run = |s: Settings| {
let mut bump = Peak::new(3000., 4., 12., 48000.);
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
frame.reduction[cell(3000.)]
};
let base = run(Settings::default());
let shallow = run(Settings {
depth: 2.,
..Default::default()
});
let picky = run(Settings {
threshold: 5.5,
..Default::default()
});
let mut protect = Settings::default();
protect.focus[0] = Focus {
enabled: true,
frequency: 3000.,
width: 0.5,
sensitivity: -12.,
};
let protected = run(protect);
assert!(shallow <= 2.001 && shallow < base, "{shallow} vs {base}");
assert!(picky < base - 1., "{picky} vs {base}");
assert!(protected < base - 2., "{protected} vs {base}");
}
#[test]
fn detection_is_level_independent() {
let s = Settings {
transient_guard: 0.,
..Default::default()
};
let run = |gain: f32| {
let mut bump = Peak::new(3000., 4., 12., 48000.);
settle(s, |i| bump.tick(saw(220., i, 48000.)) * gain)
};
let loud = run(1.);
let quiet = run(0.03);
for i in 0..GRID {
assert!(
(loud.reduction[i] - quiet.reduction[i]).abs() < 0.5,
"{} Hz: {} vs {}",
frequency(i),
loud.reduction[i],
quiet.reduction[i]
);
}
}
#[test]
fn noise_does_not_cause_flickering_cuts() {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings::default()).unwrap();
let mut frames = Vec::new();
for i in 0..96000 {
e.process_sample([noise(i) * 3., 0.], Monitor::default());
if let Some(f) = e.take_analyzer() {
if i > 24000 {
frames.push(f);
}
}
}
let mean = frames
.iter()
.map(|f| f.reduction.iter().sum::<f32>() / GRID as f32)
.sum::<f32>()
/ frames.len() as f32;
let jitter = frames
.windows(2)
.map(|w| {
(0..GRID)
.map(|i| (w[1].reduction[i] - w[0].reduction[i]).abs())
.sum::<f32>()
/ GRID as f32
})
.sum::<f32>()
/ (frames.len() - 1) as f32;
assert!(mean < 0.5, "white noise mean cut {mean} dB");
assert!(jitter < 0.1, "frame-to-frame change {jitter} dB");
}
#[test]
fn processing_range_is_respected_and_cuts_recover_in_silence() {
let s = Settings {
low: 500.,
high: 9000.,
transient_guard: 0.,
release: 20.,
..Default::default()
};
let mut e = Engine::new(48000., 2).unwrap();
e.set_settings(s).unwrap();
let mut bump = Peak::new(3000., 4., 12., 48000.);
let mut frame = AnalyzerFrame::default();
for i in 0..48000 {
let x = bump.tick(saw(220., i, 48000.));
e.process_sample([x, -x], Monitor::default());
if let Some(f) = e.take_analyzer() {
frame = f;
}
}
assert!(frame.reduction[cell(3000.)] > 3.);
for i in 0..GRID {
if frequency(i) < 500. || frequency(i) > 9000. {
assert_eq!(frame.reduction[i], 0.);
}
}
for _ in 0..48000 {
e.process_sample([0.; 2], Monitor::default());
}
let f = e.take_analyzer().unwrap();
assert!(f.reduction.iter().all(|d| *d < 0.001));
}
#[test]
fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
let mut e = Engine::new(48000., 1).unwrap();
let before = e.settings();
assert!(e
.set_settings(Settings {
attack: f32::NAN,
..before
})
.is_err());
assert_eq!(e.settings(), before);
assert!(Engine::new(8000., 2).is_err());
for _ in 0..10000 {
let y = e.process_sample([f32::NAN, f32::INFINITY], Monitor::default());
assert!(y.iter().all(|v| v.is_finite()));
}
}
#[test]
fn soft_knee_and_presets_are_valid() {
for i in 0..Settings::PRESETS.len() {
Settings::preset(i).validate().unwrap();
}
assert_eq!(knee(-2., 3.), 0.);
assert_eq!(knee(5., 3.), 5.);
assert_eq!(knee(-1., 0.), 0.);
let p = Focus {
enabled: true,
frequency: 2000.,
width: 1.,
sensitivity: -12.,
};
assert_eq!(focus_offset(2000., &[p; 4]), -48.);
}
#[test]
fn settled_delta_and_bypass_have_precise_taps() {
let s = Settings {
attack: 1.,
transient_guard: 0.,
..Default::default()
};
let mut wet = Engine::new(48000., 1).unwrap();
wet.set_settings(s).unwrap();
let mut delta = Engine::new(48000., 1).unwrap();
delta.set_settings(s).unwrap();
let mut bypass = Engine::new(48000., 1).unwrap();
bypass.set_settings(Settings { bypass: true, ..s }).unwrap();
let latency = wet.latency_samples() as usize;
for i in 0..24000 {
let x = noise(i);
let a = wet.process_sample([x, 0.], Monitor::default())[0];
let b = delta.process_sample([x, 0.], Monitor { delta: true })[0];
let c = bypass.process_sample([x, 0.], Monitor::default())[0];
if i > 12000 {
let expected = noise(i - latency);
assert!((a + b - expected).abs() < 1e-5);
assert!((c - expected).abs() < 1e-6);
}
}
}
#[test]
fn narrow_ring_on_noise_is_cut_and_sharpness_narrows_the_cut() {
let width = |sharpness: f32| {
let s = Settings {
sharpness,
..Default::default()
};
let frame = settle(s, |i| {
0.05 * (TAU * 5000. * i as f32 / 48000.).sin() + noise(i) * 3.
});
let peak = frame.reduction[cell(5000.)];
let wide = (0..GRID)
.filter(|i| frame.reduction[*i] > peak * 0.5)
.count();
(peak, wide)
};
let (soft, soft_width) = width(0.);
let (sharp, sharp_width) = width(100.);
assert!(soft > 3. && sharp > 3., "{soft} / {sharp}");
assert!(sharp_width < soft_width, "{sharp_width} vs {soft_width}");
}
#[test]
fn high_sample_rates_detect_the_same_bump() {
for rate in [96000., 192000.] {
let mut e = Engine::new(rate, 1).unwrap();
e.set_settings(Settings::default()).unwrap();
let mut bump = Peak::new(3000., 4., 12., rate);
let mut frame = AnalyzerFrame::default();
for i in 0..rate as usize {
e.process_sample([bump.tick(saw(220., i, rate)), 0.], Monitor::default());
if let Some(f) = e.take_analyzer() {
frame = f;
}
}
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
let away = peak_reduction(&frame, 20., 1500.);
assert!(near >= 4. && away < 1., "{rate}: {near} / {away}");
}
}
/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2).
const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31];
/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`.
fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec<f32> {
let rate = 48000.;
let n = (rate * seconds) as usize;
let mut x = vec![0f32; n];
for k in 1..=(16000. / f0) as usize {
let f = k as f32 * f0;
let a = 0.08 * shape(k, f) / k as f32;
let w = TAU * f / rate;
for (i, v) in x.iter_mut().enumerate() {
*v += a * (w * i as f32 + k as f32).sin();
}
}
x
}
/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz`
/// (largest within ±3 grid cells) over the second half of the last note.
fn tone_cut(
guard: f32,
notes: &[f32],
rounds: usize,
shape: &dyn Fn(usize, f32) -> f32,
hz: &dyn Fn(f32) -> f32,
) -> f32 {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings {
tone_guard: guard,
low: 150.,
..Default::default()
})
.unwrap();
let last = *notes.last().unwrap();
let at = patina_core::cell(hz(last)).round() as usize;
let (mut sum, mut count) = (0., 0);
for round in 0..rounds {
for &f0 in notes {
let x = bass(f0, 0.4, shape);
let measure = round + 1 == rounds && f0 == last;
for (i, v) in x.iter().enumerate() {
e.process_sample([*v, 0.], Monitor::default());
if let Some(frame) = e.take_analyzer() {
if measure && i > x.len() / 2 {
sum += frame.reduction[at - 3..=at + 3]
.iter()
.fold(0f32, |m, v| m.max(*v));
count += 1;
}
}
}
}
}
sum / count as f32
}
fn harmonic_six(k: usize, _: f32) -> f32 {
if k == 6 {
4.
} else {
1.
}
}
fn formant(_: usize, f: f32) -> f32 {
let z = (f / 1500.).log2() / 0.15;
1. + 3. * (-z * z).exp()
}
#[test]
fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() {
let h6 = |f0: f32| 6. * f0;
let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6);
let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6);
assert!(
plain > 4.,
"without guard the boosted harmonic is cut: {plain}"
);
assert!(
guarded < 0.25 * plain,
"guard keeps the harmonic: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_still_cuts_resonances_fixed_in_hz() {
let at = |_: f32| 1500.;
let plain = tone_cut(0., &LINE, 3, &formant, &at);
let guarded = tone_cut(100., &LINE, 3, &formant, &at);
assert!(plain > 4., "the formant is cut: {plain}");
assert!(
guarded > 0.85 * plain,
"guard still cuts it: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_needs_different_notes_to_tell_bumps_apart() {
let h6 = |f0: f32| 6. * f0;
let one = [LINE[2]];
let plain = tone_cut(0., &one, 10, &harmonic_six, &h6);
let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6);
assert!(
(guarded - plain).abs() < 0.5,
"one held note gives no evidence: {guarded} vs {plain}"
);
}
#[test]
fn old_settings_without_tone_guard_still_load() {
let mut json = serde_json::to_value(Settings::default()).unwrap();
json.as_object_mut().unwrap().remove("tone_guard");
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.tone_guard, 0.);
}
/// Cut at the bump for a resonance of `gain` dB (Q 4 at 3 kHz) on a 220 Hz saw.
fn bump_cut(s: Settings, gain: f32) -> f32 {
let mut bump = Peak::new(3000., 4., gain, 48000.);
let frame = settle(
Settings {
transient_guard: 0.,
..s
},
|i| bump.tick(saw(220., i, 48000.)),
);
peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26)
}
#[test]
fn ratio_spares_small_peaks_and_still_cuts_big_ones() {
let at = |ratio: f32, gain: f32| {
bump_cut(
Settings {
depth: 20.,
ratio,
..Default::default()
},
gain,
)
};
let (small_hard, big_hard) = (at(RATIO_INFINITE, 6.), at(RATIO_INFINITE, 18.));
let (small, big) = (at(2., 6.), at(2., 18.));
// At 2:1 each cut is about half the ∞:1 cut.
for (half, full) in [(small, small_hard), (big, big_hard)] {
assert!((half / full - 0.5).abs() < 0.15, "{half} vs {full}");
}
assert!(small < 1., "small bump cut by {small} dB at 2:1");
assert!(big > 4., "big bump cut only {big} dB at 2:1");
// Ratio between the two ends gives a cut between them.
let mid = at(4., 18.);
assert!(big < mid && mid < big_hard, "{big} < {mid} < {big_hard}");
}
#[test]
fn cut_grows_with_the_bump_until_depth() {
let s = Settings {
depth: 20.,
ratio: 3.,
..Default::default()
};
let cuts: Vec<f32> = [4., 8., 12., 18., 24.]
.iter()
.map(|g| bump_cut(s, *g))
.collect();
for w in cuts.windows(2) {
assert!(w[1] > w[0] + 0.5, "{cuts:?}");
}
let capped = bump_cut(Settings { depth: 4., ..s }, 24.);
assert!(capped <= 4.001 && capped > 3., "{capped}");
}
#[test]
fn threshold_moves_where_cuts_start() {
let low = bump_cut(Settings::default(), 9.);
let high = bump_cut(
Settings {
threshold: 9.,
..Default::default()
},
9.,
);
assert!(low > 2. && high < 0.5, "{low} / {high}");
}
#[test]
fn hard_knee_starts_cutting_closer_to_the_threshold() {
// A bump just under the threshold: the soft knee already cuts a little, the hard knee not.
let s = Settings {
threshold: 4.,
..Default::default()
};
let soft = bump_cut(s, 6.);
let hard = bump_cut(
Settings {
knee: Knee::Hard,
..s
},
6.,
);
assert!(soft > hard + 0.2, "soft {soft} vs hard {hard}");
assert_eq!(knee(0.25, 1.), 0.28125);
}
#[test]
fn settings_from_before_threshold_still_load() {
let mut json = serde_json::to_value(Settings::default()).unwrap();
let map = json.as_object_mut().unwrap();
for key in ["threshold", "ratio", "knee", "tone_guard"] {
map.remove(key);
}
map.insert("selectivity".into(), 100.into());
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.threshold, 5.5);
assert_eq!(s.ratio, RATIO_INFINITE);
assert_eq!(s.knee, Knee::Soft);
assert!(serde_json::from_str::<Settings>(r#"{"oops":1}"#).is_err());
let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}