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.
This commit is contained in:
nvrl committed 2026-11-06 17:29:54 +01:00
1 parent 7b989f7685
commit 9ca4e4f9dd
13 files changed
+568 -102

No files matched your search

+103 -3
View File
@@ -148,7 +148,7 @@ fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
}
}
#[test]
fn depth_selectivity_and_focus_each_do_one_thing() {
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.)));
@@ -160,7 +160,7 @@ fn depth_selectivity_and_focus_each_do_one_thing() {
..Default::default()
});
let picky = run(Settings {
selectivity: 100.,
threshold: 5.5,
..Default::default()
});
let mut protect = Settings::default();
@@ -279,7 +279,7 @@ fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
}
#[test]
fn soft_knee_and_presets_are_valid() {
for i in 0..4 {
for i in 0..Settings::PRESETS.len() {
Settings::preset(i).validate().unwrap();
}
assert_eq!(knee(-2., 3.), 0.);
@@ -469,3 +469,103 @@ fn old_settings_without_tone_guard_still_load() {
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);
}