Files
bass-foundry/plugins/quench/core/tests/dsp.rs
T
nvrl 3d316cbe49 feat(quench): add Quench sub-safe clipper
Quench splits the signal with a Linkwitz-Riley crossover and lets the sub
pass unclipped. The top is clipped symmetrically at the ceiling minus the
sub's held peak, so it does not pump at the sub's rate and the sum stays
under the ceiling. What the clip removes is high-passed at the split, so its
products stay out of the low band. Two correction passes and an oversampled
hard clip hold the ceiling; a safety clip catches the downsampler's ringing.

- Controls: Drive, Ceiling, Split, Hold, Soft, Bypass.
- Listen to Quench, a plain clipper at the same settings, or the delta.
- Editor meter compares how much the low band changed for Quench and for a
  plain clip, with a scope of the output, the sub and the top's room.
- Measured: sub level +0.04 dB against -2.2 dB for a plain clip, low-band
  products -35 dB against -16 dB, at 0.5-1.2 dB less RMS.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 15:51:36 +01:00

301 lines
9.5 KiB
Rust

use quench_core::{shape, Engine, Monitor, Settings};
use std::f64::consts::TAU;
const RATE: f32 = 48000.;
/// Partials of the top: not harmonics of the sub, so their clipping products can be told
/// apart from the sub's own harmonics.
const TOPS: [f64; 5] = [437., 611., 1013., 1597., 2399.];
fn top(t: f64) -> f64 {
TOPS.iter()
.enumerate()
.map(|(i, f)| (TAU * f * t + i as f64).sin())
.sum::<f64>()
/ 4.2
}
/// Mono render of `seconds` of `signal`, returned as the left channel.
fn render(
engine: &mut Engine,
seconds: f32,
monitor: Monitor,
signal: impl Fn(f64) -> f64,
) -> Vec<f32> {
let n = (seconds * RATE) as usize;
(0..n)
.map(|i| {
let v = signal(i as f64 / RATE as f64) as f32;
engine.process_sample([v, v], monitor)[0]
})
.collect()
}
fn engine(s: Settings) -> Engine {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e
}
/// Amplitude of `hz` over a whole number of its periods at the end of `x`.
fn amplitude(x: &[f32], hz: f64) -> f64 {
let n = (RATE as f64 / hz * (0.5 * hz).floor()) as usize;
let x = &x[x.len() - n..];
let (mut re, mut im) = (0., 0.);
for (i, &v) in x.iter().enumerate() {
let a = TAU * hz * i as f64 / RATE as f64;
re += v as f64 * a.cos();
im += v as f64 * a.sin();
}
2. * (re * re + im * im).sqrt() / n as f64
}
/// Hann-windowed spectrum magnitude of the last half second, 2 Hz bins up to `top_bin`.
fn spectrum(x: &[f32], top_bin: usize) -> Vec<f64> {
let n = RATE as usize / 2;
let x = &x[x.len() - n..];
let w: Vec<f64> = (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f64 / n as f64).cos())
.collect();
let norm: f64 = w.iter().sum();
(0..=top_bin)
.map(|b| {
let (mut re, mut im) = (0., 0.);
for i in 0..n {
let a = TAU * b as f64 * i as f64 / n as f64;
re += x[i] as f64 * w[i] * a.cos();
im += x[i] as f64 * w[i] * a.sin();
}
2. * (re * re + im * im).sqrt() / norm
})
.collect()
}
/// Everything below 110 Hz except the 50 Hz sub, against the sub, in dB.
fn low_junk(x: &[f32]) -> f64 {
let s = spectrum(x, 55);
let junk: f64 = s
.iter()
.enumerate()
.filter(|(b, _)| !(22..=28).contains(b) && *b > 0)
.map(|(_, v)| v * v)
.sum();
let sub: f64 = s[22..=28].iter().map(|v| v * v).sum();
10. * (junk / sub).log10()
}
fn db(r: f64) -> f64 {
20. * r.log10()
}
#[test]
fn below_the_ceiling_the_signal_passes_unchanged() {
let mut e = engine(Settings::default());
let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 0.2 * (TAU * 1000. * t).sin();
let out = render(&mut e, 1., Monitor::Quench, signal);
for (hz, a) in [(50., 0.3), (1000., 0.2)] {
let got = db(amplitude(&out, hz) / a);
assert!(got.abs() < 0.02, "{hz} Hz: {got:.3} dB");
}
let f = e.take_analyzer();
assert_eq!(f.top_clip, 0.);
assert_eq!(f.safety, 0.);
let mut e = engine(Settings::default());
let delta = render(&mut e, 0.2, Monitor::Delta, signal);
assert!(delta.iter().all(|v| v.abs() < 1e-4));
}
#[test]
fn the_output_never_passes_the_ceiling() {
let mut seed = 1u32;
let noise: Vec<f64> = (0..RATE as usize)
.map(|_| {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
(seed >> 8) as f64 / (1u32 << 24) as f64 * 2. - 1.
})
.collect();
for ceiling in [-6., -0.3, 0.] {
for soft in [0., 50., 100.] {
for monitor in [Monitor::Quench, Monitor::Plain] {
let mut e = engine(Settings {
drive: 24.,
ceiling,
soft,
..Settings::default()
});
let out = render(&mut e, 1., monitor, |t| {
let i = ((t * RATE as f64) as usize).min(noise.len() - 1);
0.4 * (TAU * 41. * t).sin() + 0.3 * top(t) + 0.2 * noise[i]
});
let limit = 10f32.powf(ceiling / 20.) * (1. + 1e-6);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(
peak <= limit,
"{ceiling} dB, soft {soft}, {monitor:?}: {peak}"
);
}
}
}
}
#[test]
fn the_sub_keeps_its_level_and_the_low_band_stays_clean() {
// A quiet sub under a top that peaks far over the ceiling: the hard case for a clipper.
let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 2. * top(t);
let mut results = Vec::new();
for monitor in [Monitor::Quench, Monitor::Plain] {
let mut e = engine(Settings::default());
let out = render(&mut e, 1.5, monitor, signal);
let sub = db(amplitude(&out, 50.) / 0.3);
results.push((sub, low_junk(&out)));
}
let [(q_sub, q_junk), (p_sub, p_junk)] = [results[0], results[1]];
assert!(q_sub.abs() < 0.2, "Quench changes the sub by {q_sub:.2} dB");
assert!(
p_sub < -0.8,
"a plain clip should squash the sub: {p_sub:.2} dB"
);
assert!(
q_junk < p_junk - 10.,
"low-band products: Quench {q_junk:.1} dB, plain {p_junk:.1} dB"
);
}
#[test]
fn the_meter_shows_how_much_less_the_low_band_changes() {
let mut e = engine(Settings::default());
render(&mut e, 1.5, Monitor::Quench, |t| {
0.3 * (TAU * 50. * t).sin() + 2. * top(t)
});
let f = e.take_analyzer();
assert!(
f.low_quench < f.low_plain - 10.,
"Quench {:.1} dB, plain {:.1} dB",
f.low_quench,
f.low_plain
);
assert!(f.top_clip > 6.);
// The oversampled clip leaves the safety clip after the downsampler almost idle.
assert!(f.safety < 0.2, "{}", f.safety);
let mut e = engine(Settings::default());
render(&mut e, 0.5, Monitor::Quench, |t| {
0.5 * (TAU * 1000. * t).sin()
});
assert!(
e.take_analyzer().low_quench.is_nan(),
"no sub, nothing to compare"
);
}
#[test]
fn a_sub_over_the_ceiling_leaves_no_room_and_is_still_limited() {
let mut e = engine(Settings {
ceiling: -6.,
..Settings::default()
});
let out = render(&mut e, 1., Monitor::Quench, |t| {
0.8 * (TAU * 50. * t).sin() + 0.3 * top(t)
});
let f = e.take_analyzer();
assert!(f.sub_peak > -6., "{}", f.sub_peak);
assert!(f.safety > 0.);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(peak <= 0.5012 * (1. + 1e-6));
let room = f.scope_room.iter().fold(0f32, |m, v| m.max(*v));
assert_eq!(room, 0., "the held sub peak is over the ceiling");
}
#[test]
fn the_held_peak_keeps_the_tops_room_steady_through_the_cycle() {
let room_ripple = |hold: f32, hz: f64| {
let mut e = engine(Settings {
hold,
..Settings::default()
});
render(&mut e, 1., Monitor::Quench, |t| {
0.5 * (TAU * hz * t).sin() + 0.3 * top(t)
});
let f = e.take_analyzer();
let (lo, hi) = f
.scope_room
.iter()
.fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
(hi - lo) / hi
};
// 30 Hz: the sub peaks every 16.7 ms.
let held = room_ripple(60., 30.);
let short = room_ripple(10., 30.);
assert!(held < 0.01, "{held}");
assert!(short > 0.1, "{short}");
}
#[test]
fn bypass_is_the_input_delayed_by_the_latency() {
let mut e = engine(Settings {
bypass: true,
drive: 12.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let x: Vec<f32> = (0..4800)
.map(|i| (0.9 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32 + 0.5)
.collect();
let out: Vec<f32> = x
.iter()
.map(|&v| e.process_sample([v, v], Monitor::Quench)[0])
.collect();
for i in latency..x.len() {
assert_eq!(out[i], x[i - latency]);
}
}
#[test]
fn mono_works_and_the_shape_never_passes_its_limit() {
let mut e = Engine::new(44100., 1).unwrap();
e.set_settings(Settings {
drive: 12.,
..Settings::default()
})
.unwrap();
for i in 0..44100 {
let v = (TAU * 55. * i as f64 / 44100.).sin() as f32;
let o = e.process_sample([v, 0.], Monitor::Quench);
assert!(o[0].abs() <= 0.9661 && o[1] == 0.);
}
for knee in [0., 0.3, 1.] {
for h in [-5., -1., -0.2, 0., 0.5, 0.99, 3.] {
let y = shape(h, 0.8, knee);
assert!(y.abs() <= 0.8 && y * h >= 0.);
}
}
assert_eq!(shape(0.5, 0.8, 0.), 0.5);
assert_eq!(shape(0.5, 0., 0.), 0.);
}
#[test]
fn settings_are_validated_and_serialise() {
assert!(Settings::default().validate().is_ok());
for bad in [
Settings {
drive: -1.,
..Settings::default()
},
Settings {
ceiling: 0.5,
..Settings::default()
},
Settings {
split: 20.,
..Settings::default()
},
Settings {
hold: f32::NAN,
..Settings::default()
},
] {
assert!(bad.validate().is_err());
assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err());
}
let s = Settings {
drive: 9.,
soft: 40.,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"drive":0,"oops":1}"#).is_err());
}