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:
1 parent
7b989f7685
commit
9ca4e4f9dd
13 files changed
+568
-102
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user