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
@@ -113,6 +113,58 @@ pub fn rotary(
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let response = dial(ui, value, range.clone(), default, size, color);
|
||||
dial_caption(ui, name, size);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// A rotary whose value field shows custom text, for values that are not a plain number
|
||||
/// with a unit (a ratio, a mapped knob position).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rotary_text(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
format: impl Fn(f32) -> String,
|
||||
parse: impl Fn(&str) -> Option<f32>,
|
||||
) -> egui::Response {
|
||||
let response = dial(ui, value, range.clone(), default, size, color);
|
||||
dial_caption(ui, name, size);
|
||||
let speed = (*range.end() - *range.start()) as f64 / 200.;
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(speed)
|
||||
.custom_formatter(|v, _| format(v as f32))
|
||||
.custom_parser(|s| parse(s).map(f64::from)),
|
||||
);
|
||||
response
|
||||
}
|
||||
fn dial_caption(ui: &mut egui::Ui, name: &str, size: f32) {
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
}
|
||||
/// The knob itself: drag, keys, double-click reset, and drawing.
|
||||
fn dial(
|
||||
ui: &mut egui::Ui,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
@@ -212,17 +264,6 @@ pub fn rotary(
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
|
||||
@@ -3,14 +3,10 @@ mod pitch;
|
||||
mod settings;
|
||||
use pitch::PitchProbe;
|
||||
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
|
||||
pub use settings::{focus_offset, knee, Focus, Settings};
|
||||
pub use settings::{focus_offset, knee, Focus, Knee, Settings, RATIO_INFINITE};
|
||||
use std::{f32::consts::PI, sync::Arc};
|
||||
|
||||
pub const GRID: usize = 512;
|
||||
/// dB of cut per dB of prominence above the threshold.
|
||||
const SLOPE: f32 = 1.5;
|
||||
/// Fixed soft-knee width, in dB of prominence.
|
||||
const KNEE: f32 = 3.;
|
||||
/// Neighboring peaks closer than this (Hz, or one octave) are joined by a
|
||||
/// straight line in dB, so harmonic gaps do not count as expected level.
|
||||
const JOIN_HZ: f32 = 800.;
|
||||
@@ -463,7 +459,9 @@ impl Engine {
|
||||
let fall = 1. - (-frame_time / 0.04).exp();
|
||||
let awake = self.sample_power / self.hop as f32 > 1e-9;
|
||||
self.sample_power = 0.;
|
||||
let threshold = self.settings.threshold();
|
||||
let threshold = self.settings.threshold;
|
||||
let slope = self.settings.slope();
|
||||
let knee_width = self.settings.knee_width();
|
||||
self.target.fill(0.);
|
||||
self.frame.input.fill(-200.);
|
||||
self.frame.envelope.fill(-200.);
|
||||
@@ -588,7 +586,8 @@ impl Engine {
|
||||
// material is never cut.
|
||||
let live = awake && content[i] && level > -130.;
|
||||
let target = if live {
|
||||
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
|
||||
(knee(excess, knee_width) * slope).min(self.settings.depth)
|
||||
* (1. - self.tone[i])
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
@@ -609,7 +608,7 @@ impl Engine {
|
||||
}
|
||||
let cut = self.target;
|
||||
smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1);
|
||||
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp();
|
||||
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack_time() * 0.001)).exp();
|
||||
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
|
||||
for i in 0..GRID {
|
||||
let target = self.target[i];
|
||||
|
||||
@@ -19,15 +19,34 @@ impl Default for Focus {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Shape of each band's gain curve around the threshold.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Knee {
|
||||
/// Wide knee: cuts fade in gradually around the threshold.
|
||||
#[default]
|
||||
Soft,
|
||||
/// Narrow knee and a faster attack: cuts start right at the threshold.
|
||||
Hard,
|
||||
}
|
||||
/// Ratio at or above this value is treated as ∞:1.
|
||||
pub const RATIO_INFINITE: f32 = 20.;
|
||||
/// dB of cut per dB of detected prominence at ∞:1. The detector smooths a bump across
|
||||
/// frequency and reads it lower than it is; this restores the true height.
|
||||
const CALIBRATION: f32 = 1.5;
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(from = "Stored")]
|
||||
pub struct Settings {
|
||||
/// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper.
|
||||
/// Largest cut in dB per band.
|
||||
pub depth: f32,
|
||||
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
|
||||
pub sharpness: f32,
|
||||
/// 0 % treats any bump; 100 % treats only the most prominent bumps.
|
||||
pub selectivity: f32,
|
||||
/// How far (dB) a band must rise above its local floor before it is cut.
|
||||
pub threshold: f32,
|
||||
/// Cut per dB above the threshold: at n:1 a bump ends 1/n as far above the threshold.
|
||||
/// `RATIO_INFINITE` and above flatten bumps to the threshold.
|
||||
pub ratio: f32,
|
||||
pub knee: Knee,
|
||||
pub attack: f32,
|
||||
pub release: f32,
|
||||
pub transient_guard: f32,
|
||||
@@ -39,15 +58,68 @@ pub struct Settings {
|
||||
pub focus: [Focus; 4],
|
||||
/// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note
|
||||
/// (a harmonic the sound is built on) while bumps fixed in Hz are still cut.
|
||||
#[serde(default)]
|
||||
pub tone_guard: f32,
|
||||
}
|
||||
/// Stored form. Settings saved before 0.5 have Selectivity (%) instead of Threshold, and no
|
||||
/// Ratio, Knee or Tone guard.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Stored {
|
||||
depth: f32,
|
||||
sharpness: f32,
|
||||
#[serde(default)]
|
||||
threshold: Option<f32>,
|
||||
#[serde(default)]
|
||||
selectivity: Option<f32>,
|
||||
#[serde(default)]
|
||||
ratio: Option<f32>,
|
||||
#[serde(default)]
|
||||
knee: Knee,
|
||||
attack: f32,
|
||||
release: f32,
|
||||
transient_guard: f32,
|
||||
low: f32,
|
||||
high: f32,
|
||||
mix: f32,
|
||||
output: f32,
|
||||
bypass: bool,
|
||||
focus: [Focus; 4],
|
||||
#[serde(default)]
|
||||
tone_guard: f32,
|
||||
}
|
||||
impl From<Stored> for Settings {
|
||||
fn from(s: Stored) -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
depth: s.depth,
|
||||
sharpness: s.sharpness,
|
||||
threshold: s
|
||||
.threshold
|
||||
.or(s.selectivity.map(|v| 0.5 + v * 0.05))
|
||||
.unwrap_or(d.threshold),
|
||||
ratio: s.ratio.unwrap_or(d.ratio),
|
||||
knee: s.knee,
|
||||
attack: s.attack,
|
||||
release: s.release,
|
||||
transient_guard: s.transient_guard,
|
||||
low: s.low,
|
||||
high: s.high,
|
||||
mix: s.mix,
|
||||
output: s.output,
|
||||
bypass: s.bypass,
|
||||
focus: s.focus,
|
||||
tone_guard: s.tone_guard,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
depth: 6.,
|
||||
sharpness: 40.,
|
||||
selectivity: 50.,
|
||||
threshold: 3.,
|
||||
ratio: RATIO_INFINITE,
|
||||
knee: Knee::Soft,
|
||||
attack: 5.,
|
||||
release: 80.,
|
||||
transient_guard: 30.,
|
||||
@@ -66,7 +138,8 @@ impl Settings {
|
||||
for (v, low, high) in [
|
||||
(self.depth, 0., 20.),
|
||||
(self.sharpness, 0., 100.),
|
||||
(self.selectivity, 0., 100.),
|
||||
(self.threshold, 0., 18.),
|
||||
(self.ratio, 1., RATIO_INFINITE),
|
||||
(self.attack, 1., 200.),
|
||||
(self.release, 10., 1000.),
|
||||
(self.transient_guard, 0., 100.),
|
||||
@@ -104,9 +177,27 @@ impl Settings {
|
||||
pub fn reference_width(&self) -> f32 {
|
||||
(self.envelope_width() * 16.).clamp(2., 3.)
|
||||
}
|
||||
/// Prominence threshold in dB above the local reference, from Selectivity.
|
||||
pub fn threshold(&self) -> f32 {
|
||||
0.5 + self.selectivity * 0.05
|
||||
/// dB of cut per dB of detected prominence above the threshold.
|
||||
pub fn slope(&self) -> f32 {
|
||||
if self.ratio >= RATIO_INFINITE {
|
||||
CALIBRATION
|
||||
} else {
|
||||
CALIBRATION * (1. - 1. / self.ratio)
|
||||
}
|
||||
}
|
||||
/// Soft-knee width in dB of prominence.
|
||||
pub fn knee_width(&self) -> f32 {
|
||||
match self.knee {
|
||||
Knee::Soft => 6.,
|
||||
Knee::Hard => 1.,
|
||||
}
|
||||
}
|
||||
/// Attack time actually used: the hard knee grips twice as fast.
|
||||
pub fn attack_time(&self) -> f32 {
|
||||
match self.knee {
|
||||
Knee::Soft => self.attack,
|
||||
Knee::Hard => (self.attack * 0.5).max(0.5),
|
||||
}
|
||||
}
|
||||
pub const PRESETS: [&'static str; 5] = [
|
||||
"Gentle cleanup",
|
||||
@@ -121,18 +212,21 @@ impl Settings {
|
||||
1 => {
|
||||
s.low = 1500.;
|
||||
s.high = 12000.;
|
||||
s.depth = 8.;
|
||||
s.depth = 10.;
|
||||
s.sharpness = 55.;
|
||||
s.selectivity = 40.;
|
||||
s.threshold = 2.5;
|
||||
s.ratio = 3.;
|
||||
s.attack = 3.;
|
||||
s.release = 60.;
|
||||
}
|
||||
2 => {
|
||||
s.low = 250.;
|
||||
s.high = 6000.;
|
||||
s.depth = 8.;
|
||||
s.depth = 12.;
|
||||
s.sharpness = 75.;
|
||||
s.selectivity = 35.;
|
||||
s.threshold = 2.5;
|
||||
s.ratio = 6.;
|
||||
s.knee = Knee::Hard;
|
||||
s.attack = 10.;
|
||||
s.release = 150.;
|
||||
s.transient_guard = 80.;
|
||||
@@ -140,14 +234,18 @@ impl Settings {
|
||||
3 => {
|
||||
s.low = 2000.;
|
||||
s.high = 16000.;
|
||||
s.depth = 9.;
|
||||
s.sharpness = 25.;
|
||||
s.selectivity = 45.;
|
||||
s.threshold = 2.5;
|
||||
s.ratio = 2.5;
|
||||
}
|
||||
4 => {
|
||||
s.low = 150.;
|
||||
s.high = 12000.;
|
||||
s.depth = 8.;
|
||||
s.sharpness = 50.;
|
||||
s.selectivity = 45.;
|
||||
s.threshold = 2.5;
|
||||
s.ratio = 3.;
|
||||
s.tone_guard = 80.;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -105,6 +105,15 @@ impl Plugin for Patina {
|
||||
state.gestures[i] = false;
|
||||
}
|
||||
}
|
||||
if edited.knee != before.knee {
|
||||
let knee = match edited.knee {
|
||||
patina_core::Knee::Soft => params::KneeParam::Soft,
|
||||
patina_core::Knee::Hard => params::KneeParam::Hard,
|
||||
};
|
||||
setter.begin_set_parameter(¶ms.knee);
|
||||
setter.set_parameter(¶ms.knee, knee);
|
||||
setter.end_set_parameter(¶ms.knee);
|
||||
}
|
||||
if edited.bypass != before.bypass {
|
||||
setter.begin_set_parameter(¶ms.bypass);
|
||||
setter.set_parameter(¶ms.bypass, edited.bypass);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use patina_core::{Focus, Settings};
|
||||
use patina_core::{Focus, Knee, Settings, RATIO_INFINITE};
|
||||
use std::sync::Arc;
|
||||
#[derive(Params)]
|
||||
pub struct FocusParams {
|
||||
@@ -38,6 +38,13 @@ impl Default for FocusParams {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum KneeParam {
|
||||
#[id = "soft"]
|
||||
Soft,
|
||||
#[id = "hard"]
|
||||
Hard,
|
||||
}
|
||||
#[derive(Params)]
|
||||
pub struct PatinaParams {
|
||||
#[persist = "editor"]
|
||||
@@ -46,8 +53,12 @@ pub struct PatinaParams {
|
||||
pub depth: FloatParam,
|
||||
#[id = "sharpness"]
|
||||
pub sharpness: FloatParam,
|
||||
#[id = "selectivity"]
|
||||
pub selectivity: FloatParam,
|
||||
#[id = "threshold"]
|
||||
pub threshold: FloatParam,
|
||||
#[id = "ratio"]
|
||||
pub ratio: FloatParam,
|
||||
#[id = "knee"]
|
||||
pub knee: EnumParam<KneeParam>,
|
||||
#[id = "attack"]
|
||||
pub attack: FloatParam,
|
||||
#[id = "release"]
|
||||
@@ -76,7 +87,36 @@ impl Default for PatinaParams {
|
||||
editor: EguiState::from_size(1180, 760),
|
||||
depth: linear("Depth", d.depth, 0., 20., " dB"),
|
||||
sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"),
|
||||
selectivity: linear("Selectivity", d.selectivity, 0., 100., " %"),
|
||||
threshold: linear("Threshold", d.threshold, 0., 18., " dB"),
|
||||
ratio: FloatParam::new(
|
||||
"Ratio",
|
||||
d.ratio,
|
||||
FloatRange::Skewed {
|
||||
min: 1.,
|
||||
max: RATIO_INFINITE,
|
||||
factor: FloatRange::skew_factor(-1.5),
|
||||
},
|
||||
)
|
||||
.with_value_to_string(Arc::new(ratio_text))
|
||||
.with_string_to_value(Arc::new(|s| {
|
||||
let s = s
|
||||
.trim()
|
||||
.trim_end_matches(":1")
|
||||
.trim_end_matches(": 1")
|
||||
.trim();
|
||||
if s == "∞" || s.eq_ignore_ascii_case("inf") {
|
||||
Some(RATIO_INFINITE)
|
||||
} else {
|
||||
s.parse().ok()
|
||||
}
|
||||
})),
|
||||
knee: EnumParam::new(
|
||||
"Knee",
|
||||
match d.knee {
|
||||
Knee::Soft => KneeParam::Soft,
|
||||
Knee::Hard => KneeParam::Hard,
|
||||
},
|
||||
),
|
||||
attack: log("Attack", d.attack, 1., 200., " ms"),
|
||||
release: log("Release", d.release, 10., 1000., " ms"),
|
||||
transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"),
|
||||
@@ -95,7 +135,7 @@ impl PatinaParams {
|
||||
[
|
||||
&self.depth,
|
||||
&self.sharpness,
|
||||
&self.selectivity,
|
||||
&self.threshold,
|
||||
&self.attack,
|
||||
&self.release,
|
||||
&self.transient_guard,
|
||||
@@ -120,13 +160,19 @@ impl PatinaParams {
|
||||
&self.focus[3].width,
|
||||
&self.focus[3].sensitivity,
|
||||
&self.tone_guard,
|
||||
&self.ratio,
|
||||
]
|
||||
}
|
||||
pub fn settings(&self) -> Settings {
|
||||
let mut s = Settings {
|
||||
depth: self.depth.value(),
|
||||
sharpness: self.sharpness.value(),
|
||||
selectivity: self.selectivity.value(),
|
||||
threshold: self.threshold.value(),
|
||||
ratio: self.ratio.value(),
|
||||
knee: match self.knee.value() {
|
||||
KneeParam::Soft => Knee::Soft,
|
||||
KneeParam::Hard => Knee::Hard,
|
||||
},
|
||||
attack: self.attack.value(),
|
||||
release: self.release.value(),
|
||||
transient_guard: self.transient_guard.value(),
|
||||
@@ -150,14 +196,22 @@ impl PatinaParams {
|
||||
s
|
||||
}
|
||||
}
|
||||
/// Host float parameters: ten main controls, four per focus point, then Tone guard.
|
||||
pub const COUNT: usize = 27;
|
||||
/// Ratio as the host shows it: "4.0:1", and "∞:1" at the top of the range.
|
||||
fn ratio_text(v: f32) -> String {
|
||||
if v >= RATIO_INFINITE {
|
||||
"∞:1".into()
|
||||
} else {
|
||||
format!("{v:.1}:1")
|
||||
}
|
||||
}
|
||||
/// Host float parameters: ten main controls, four per focus point, Tone guard, then Ratio.
|
||||
pub const COUNT: usize = 28;
|
||||
pub fn values(s: &Settings) -> [f32; COUNT] {
|
||||
let mut v = [0.; COUNT];
|
||||
v[..10].copy_from_slice(&[
|
||||
s.depth,
|
||||
s.sharpness,
|
||||
s.selectivity,
|
||||
s.threshold,
|
||||
s.attack,
|
||||
s.release,
|
||||
s.transient_guard,
|
||||
@@ -175,5 +229,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] {
|
||||
]);
|
||||
}
|
||||
v[26] = s.tone_guard;
|
||||
v[27] = s.ratio;
|
||||
v
|
||||
}
|
||||
@@ -114,6 +114,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
envelope
|
||||
.params
|
||||
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
|
||||
envelope
|
||||
.params
|
||||
.insert("threshold".into(), ParamValue::F32(6.));
|
||||
envelope.params.insert("ratio".into(), ParamValue::F32(3.));
|
||||
envelope
|
||||
.params
|
||||
.insert("knee".into(), ParamValue::String("hard".into()));
|
||||
let encoded = serde_json::to_vec(&envelope).unwrap();
|
||||
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
|
||||
framed.extend_from_slice(&encoded);
|
||||
|
||||
+153
-40
@@ -2,7 +2,9 @@
|
||||
pub use common_ui::style;
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
|
||||
use patina_core::{
|
||||
focus_offset, frequency, knee, AnalyzerFrame, Focus, Knee, Settings, GRID, RATIO_INFINITE,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct State {
|
||||
@@ -30,7 +32,7 @@ impl Default for State {
|
||||
gesture_active: false,
|
||||
gestures: [false; 30],
|
||||
last_audio: Instant::now(),
|
||||
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
||||
help: "Set Depth, then raise Threshold until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
||||
drag: None,
|
||||
}
|
||||
}
|
||||
@@ -85,6 +87,66 @@ fn hint(response: egui::Response, state: &mut State, text: &str) {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
/// The Ratio knob is square-law, so 1:1 to 6:1 gets half its travel.
|
||||
fn ratio_position(ratio: f32) -> f32 {
|
||||
((ratio - 1.) / (RATIO_INFINITE - 1.)).clamp(0., 1.).sqrt() * 100.
|
||||
}
|
||||
fn ratio_from_position(position: f32) -> f32 {
|
||||
let p = (position / 100.).clamp(0., 1.);
|
||||
1. + (RATIO_INFINITE - 1.) * p * p
|
||||
}
|
||||
fn ratio_text(ratio: f32) -> String {
|
||||
if ratio >= RATIO_INFINITE {
|
||||
"∞:1".into()
|
||||
} else {
|
||||
format!("{ratio:.1}:1")
|
||||
}
|
||||
}
|
||||
fn parse_ratio(text: &str) -> Option<f32> {
|
||||
let t = text.trim().trim_end_matches(":1").trim();
|
||||
if t == "∞" || t.eq_ignore_ascii_case("inf") {
|
||||
Some(RATIO_INFINITE)
|
||||
} else {
|
||||
t.parse::<f32>().ok().map(|r| r.clamp(1., RATIO_INFINITE))
|
||||
}
|
||||
}
|
||||
/// Gain curve of one band: prominence above the floor in, cut out. Shows Threshold, Knee,
|
||||
/// Ratio and the Depth limit together.
|
||||
fn transfer(ui: &mut egui::Ui, settings: &Settings) {
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 62.), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 3., SCREEN);
|
||||
let r = rect.shrink2(Vec2::new(6., 6.));
|
||||
const IN: f32 = 18.;
|
||||
const OUT: f32 = 20.;
|
||||
let x = |e: f32| r.left() + e / IN * r.width();
|
||||
let y = |c: f32| r.top() + c.min(OUT) / OUT * r.height();
|
||||
let t = settings.threshold;
|
||||
p.line_segment(
|
||||
[Pos2::new(x(t), r.top()), Pos2::new(x(t), r.bottom())],
|
||||
Stroke::new(1.0_f32, DIM),
|
||||
);
|
||||
let d = settings.depth;
|
||||
p.line_segment(
|
||||
[Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))],
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(48, 50, 56)),
|
||||
);
|
||||
let points: Vec<Pos2> = (0..=90)
|
||||
.map(|i| {
|
||||
let e = IN * i as f32 / 90.;
|
||||
let cut = (knee(e - t, settings.knee_width()) * settings.slope()).min(d);
|
||||
Pos2::new(x(e), y(cut))
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, Stroke::new(1.5_f32, ACCENT)));
|
||||
p.text(
|
||||
r.right_top(),
|
||||
egui::Align2::RIGHT_TOP,
|
||||
"CUT PER BUMP HEIGHT",
|
||||
FontId::proportional(10.),
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
fn note(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(MUTED));
|
||||
}
|
||||
@@ -122,6 +184,7 @@ fn control(
|
||||
"Low frequency" => defaults.low,
|
||||
"High frequency" => defaults.high,
|
||||
"Mix" => defaults.mix,
|
||||
"Output" => defaults.output,
|
||||
_ => *value,
|
||||
};
|
||||
}
|
||||
@@ -249,7 +312,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let body_height = (ui.available_height() - 40.).max(480.);
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(176.);
|
||||
ui.set_width(236.);
|
||||
ui.set_min_height(body_height - 28.);
|
||||
ui.vertical_centered(|ui| {
|
||||
caption(ui, "DETECTION");
|
||||
@@ -262,12 +325,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
0. ..=20.,
|
||||
defaults.depth,
|
||||
" dB",
|
||||
108.,
|
||||
88.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Largest cut. 0 dB does nothing.");
|
||||
ui.add_space(8.);
|
||||
ui.columns(2, |columns| {
|
||||
ui.columns(3, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
@@ -284,47 +347,76 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
columns[1].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Selectivity",
|
||||
&mut settings.selectivity,
|
||||
0. ..=100.,
|
||||
defaults.selectivity,
|
||||
" %",
|
||||
"Threshold",
|
||||
&mut settings.threshold,
|
||||
0. ..=18.,
|
||||
defaults.threshold,
|
||||
" dB",
|
||||
58.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Low: treat any bump. High: only the worst.");
|
||||
hint(
|
||||
knob,
|
||||
state,
|
||||
"How far a band must rise above its neighbours before it is cut. \
|
||||
Raise it until only the harsh parts are cut.",
|
||||
);
|
||||
});
|
||||
columns[2].vertical_centered(|ui| {
|
||||
let mut position = ratio_position(settings.ratio);
|
||||
let knob = rotary_text(
|
||||
ui,
|
||||
"Ratio",
|
||||
&mut position,
|
||||
0. ..=100.,
|
||||
ratio_position(defaults.ratio),
|
||||
58.,
|
||||
ACCENT,
|
||||
|p| ratio_text(ratio_from_position(p)),
|
||||
|s| parse_ratio(s).map(ratio_position),
|
||||
);
|
||||
if position != ratio_position(settings.ratio) {
|
||||
settings.ratio = ratio_from_position(position);
|
||||
}
|
||||
hint(
|
||||
knob,
|
||||
state,
|
||||
"Cut per dB above the threshold. Low ratios leave small bumps \
|
||||
nearly alone and still cut big ones; ∞:1 flattens every bump to \
|
||||
the threshold.",
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.);
|
||||
ui.separator();
|
||||
ui.add_space(4.);
|
||||
caption(ui, "OUTPUT");
|
||||
ui.add_space(4.);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
rotary(
|
||||
ui,
|
||||
"Mix",
|
||||
&mut settings.mix,
|
||||
0. ..=100.,
|
||||
100.,
|
||||
" %",
|
||||
58.,
|
||||
STEEL,
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
rotary(
|
||||
ui,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
0.,
|
||||
" dB",
|
||||
58.,
|
||||
STEEL,
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
caption(ui, "KNEE");
|
||||
for (knee, label, help) in [
|
||||
(
|
||||
Knee::Soft,
|
||||
"SOFT",
|
||||
"Soft knee: cuts fade in gradually around the threshold.",
|
||||
),
|
||||
(
|
||||
Knee::Hard,
|
||||
"HARD",
|
||||
"Hard knee: cuts start right at the threshold and grip twice \
|
||||
as fast.",
|
||||
),
|
||||
] {
|
||||
let r = ui.selectable_label(
|
||||
settings.knee == knee,
|
||||
RichText::new(label).size(13.),
|
||||
);
|
||||
if r.clicked() {
|
||||
settings.knee = knee;
|
||||
}
|
||||
if r.hovered() {
|
||||
state.help = help.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
transfer(ui, settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -437,8 +529,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
});
|
||||
ui.add_space(6.);
|
||||
egui::Grid::new("range-controls")
|
||||
.num_columns(2)
|
||||
.spacing([22., 8.])
|
||||
.num_columns(4)
|
||||
.spacing([16., 12.])
|
||||
.min_col_width((ui.available_width() - 48.) / 4.)
|
||||
.show(ui, |ui| {
|
||||
control(
|
||||
ui,
|
||||
@@ -460,6 +553,26 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
"Upper processing boundary, also limited by Nyquist.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Mix",
|
||||
&mut settings.mix,
|
||||
0. ..=100.,
|
||||
" %",
|
||||
false,
|
||||
"Blend of processed and delayed dry signal.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
" dB",
|
||||
false,
|
||||
"Output trim, for level-matched comparison.",
|
||||
state,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user