Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s
Patina 0.2.0. The old detector compared raw FFT bins with a one-octave dB mean, so every harmonic of a tonal sound looked like a resonance and real harsh bumps were barely touched. The new detector: - averages power over each log band and smooths it over time - joins harmonic tops into an envelope, smoothed by Sharpness - compares it with a robust, tilt-aware local line fit that ignores empty regions and cannot be lifted by the bump itself Six overlapping controls become Depth, Sharpness and Selectivity; focus points now read "up cuts more, down protects". The analysis window is halved to 2048 samples (about 43 ms delay). Behaviour tests cover plain harmonic tones, resonant bumps, narrow rings, level independence, flicker on noise and high sample rates. Both editors get a dark metallic look with #FF0160 as the single accent colour; screenshots and docs are updated. Projects saved with Patina 0.1 load the new controls at their defaults.
362 lines
12 KiB
Rust
362 lines
12 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_selectivity_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 {
|
|
selectivity: 100.,
|
|
..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..4 {
|
|
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}");
|
|
}
|
|
}
|