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

+52 -11
View File
@@ -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.
+7 -8
View File
@@ -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];
+114 -16
View File
@@ -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.;
}
_ => {}
+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);
}
+9
View File
@@ -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(&params.knee);
setter.set_parameter(&params.knee, knee);
setter.end_set_parameter(&params.knee);
}
if edited.bypass != before.bypass {
setter.begin_set_parameter(&params.bypass);
setter.set_parameter(&params.bypass, edited.bypass);
+64 -9
View File
@@ -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
}
+7
View File
@@ -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
View File
@@ -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,
);
});
});
});