diff --git a/docs/ideas.md b/docs/ideas.md index a829feb..780c40d 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -237,7 +237,19 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla - **Knurl**: velocity or note selects the pattern. Stereo grit above a split only. Drift (planned for 0.3). - **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S - processing. + processing. Threshold, Ratio and Soft/Hard knee from tester feedback (done, + unreleased 0.5.0). Next, from the same feedback round: + - An absolute level gate (dBFS) so quiet passages are never cut, if the + tester meant a loudness threshold rather than a prominence threshold. + - Narrow rings on dense or noisy material are under-read: a +12 dB ring + (Q 25) on noise reads only 3–6 dB above the floor, so it is cut by 1–3 dB. + Two causes, measured: the joined-tops envelope lifts the floor to the noise + peaks (about 5 dB above the noise mean), and envelope smoothing loses about + 3 dB on a narrow ring. A floor that follows Sharpness (1/3–2 octaves) was + tried and rejected: it lost a resonant lowpass, absorbed broad bumps and cut + plain harmonic tones. Better candidates: join tops only across deep gaps + (harmonic spacing), or a mean-based floor for noise-like regions. + - A finer analysis grid (1024 cells) for narrow rings at high frequencies. - **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option that keeps the delay spread under a limit, for plucky layers. Alignment slots per note or per switch-up. The External diff --git a/docs/patina/CHANGELOG.md b/docs/patina/CHANGELOG.md index 370aa63..5bff4ba 100644 --- a/docs/patina/CHANGELOG.md +++ b/docs/patina/CHANGELOG.md @@ -1,5 +1,22 @@ # Patina changelog +## Unreleased (0.5.0) + +- Each band now works like a small compressor. Add **Ratio** (1:1 to ∞:1, + default ∞:1): low ratios leave small bumps nearly alone and still cut big ones + hard. ∞:1 is the old behaviour. +- Replace Selectivity (%) with **Threshold** (0–18 dB above the local floor, + default 3 dB, which equals the old default of 50 %). +- Add a **Soft/Hard knee** switch. Soft widens the knee from 3 dB to 6 dB; + Hard uses a 1 dB knee and half the Attack time. +- Show the gain curve (bump height in, cut out) under the knobs. +- Mix and Output move to the slider panel under the plot. +- Retune the Vocal harshness, Snare ring, Bright synth and Bass line presets + with Ratio and more Depth. +- Breaking: FL Studio projects saved earlier load Threshold, Ratio and Knee at + their defaults. Settings files for `patina-render` with `selectivity` still + load and convert. + ## 0.4.0 - Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and diff --git a/docs/patina/README.md b/docs/patina/README.md index 98ec2c1..f3c99c7 100644 --- a/docs/patina/README.md +++ b/docs/patina/README.md @@ -1,7 +1,7 @@ # Patina Patina smooths a sound’s rough surface without erasing its character. Formerly -**Resonance Compressor**; current version **0.4.0**. +**Resonance Compressor**; current version **0.4.0** (0.5.0 unreleased). A mixing effect that finds harsh, ringing regions as they happen and pulls them down, while plain harmonic tone passes. Independently designed in Rust; no claim @@ -20,18 +20,30 @@ redistributed packages. ## Work with it -Three controls decide what happens: +Each band works like a small compressor. It compares its level with the local floor +around it and cuts only the part that sticks out: -- **Depth** is the largest cut, in dB. 0 dB does nothing. +- **Depth** is the largest cut per band, in dB. 0 dB does nothing. - **Sharpness** sets what counts as a resonance. Low values follow broad, smooth bumps (honk, boxiness). High values target narrow rings and whistles. -- **Selectivity** sets how far a bump must stand out before it is cut. Low - values treat any bump; high values treat only the worst. +- **Threshold** is how far (dB) a band must rise above its neighbours before it + is cut. Low values treat any bump; high values treat only the worst. +- **Ratio** is how hard a bump above the threshold is cut. At n:1 a bump ends + 1/n as far above the threshold as it started. Low ratios leave small bumps + nearly alone and still cut big ones; ∞:1 flattens every bump to the + threshold (this is how Patina worked before 0.5). +- **Knee**: Soft fades cuts in around the threshold. Hard starts them right at + the threshold and uses half the Attack time. -Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show -cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep -the source’s body out of reach. Attack and Release set how quickly cuts grow and -recover; Transient guard briefly relaxes cuts on attacks to keep punch. +The small graph under the knobs shows the result: bump height above the floor +in, cut out, with the threshold and the Depth limit. + +Start with Depth around 6 dB. Raise Threshold until only the harsh parts show +cuts, then use Delta to hear what is removed. To hit big resonances hard and +leave small ones alone, raise Depth to 12–20 dB and set Ratio to 2:1–4:1. +Restrict Low/High frequency to keep the source’s body out of reach. Attack and +Release set how quickly cuts grow and recover; Transient guard briefly relaxes +cuts on attacks to keep punch. ### Tone guard for bass lines @@ -61,7 +73,7 @@ on the plot drag the processing limits. On the plot, the grey fill is the measured input. The bright line is what the detector hears (its smoothed spectral envelope); the dim line is what it expects there. Accent-coloured cuts appear where the bright line rises above the dim one -by more than Selectivity allows. The blue curve is the focus/protect shape. +by more than Threshold allows. The blue curve is the focus/protect shape. Delta plays delayed dry minus wet before output trim. Use it to inspect changes, then return to normal monitoring to decide whether the music improved. @@ -83,8 +95,9 @@ window size persist; Delta does not. 4. The reference is a robust local straight-line fit over 2–3 octaves: spectral tilt is expected, bumps cannot raise their own reference, and empty regions (below a fundamental, beyond a filter cutoff) are ignored. -5. Envelope minus reference, above the Selectivity threshold, becomes the cut, - limited by Depth, smoothed with Attack/Release, and linked across stereo. +5. Envelope minus reference is the band's height above its floor. Above + Threshold it becomes a cut through the Knee and Ratio curve, limited by + Depth, smoothed with Attack/Release, and linked across stereo. ## Timing and limits @@ -142,7 +155,9 @@ The CLAP ID is now `io.narl.bass-foundry.patina` (earlier builds used `io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so add it again in projects saved with an earlier build. Version 0.2 replaced Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth, -Sharpness, and Selectivity. Delete the old plugin file, place `Patina.clap`, and +Sharpness, and Selectivity; version 0.5 replaced Selectivity with Threshold +(dB), Ratio and Knee, so projects saved earlier load Threshold at its default. +Delete the old plugin file, place `Patina.clap`, and rescan FL Studio. Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See diff --git a/docs/patina/assets/editor.png b/docs/patina/assets/editor.png index f240349..46d4f4f 100644 Binary files a/docs/patina/assets/editor.png and b/docs/patina/assets/editor.png differ diff --git a/plugins/common/ui/src/lib.rs b/plugins/common/ui/src/lib.rs index 84bf837..6f8d752 100644 --- a/plugins/common/ui/src/lib.rs +++ b/plugins/common/ui/src/lib.rs @@ -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, + default: f32, + size: f32, + color: Color32, + format: impl Fn(f32) -> String, + parse: impl Fn(&str) -> Option, +) -> 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, + 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. diff --git a/plugins/patina/core/src/lib.rs b/plugins/patina/core/src/lib.rs index 08f6ad1..b80cc3e 100644 --- a/plugins/patina/core/src/lib.rs +++ b/plugins/patina/core/src/lib.rs @@ -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]; diff --git a/plugins/patina/core/src/settings.rs b/plugins/patina/core/src/settings.rs index 1058b3c..443fa0e 100644 --- a/plugins/patina/core/src/settings.rs +++ b/plugins/patina/core/src/settings.rs @@ -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, + #[serde(default)] + selectivity: Option, + #[serde(default)] + ratio: Option, + #[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 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.; } _ => {} diff --git a/plugins/patina/core/tests/dsp.rs b/plugins/patina/core/tests/dsp.rs index f1e7a31..45d9192 100644 --- a/plugins/patina/core/tests/dsp.rs +++ b/plugins/patina/core/tests/dsp.rs @@ -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 = [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::(r#"{"oops":1}"#).is_err()); + let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); +} diff --git a/plugins/patina/plugin/src/lib.rs b/plugins/patina/plugin/src/lib.rs index cbc9962..b93dfe3 100644 --- a/plugins/patina/plugin/src/lib.rs +++ b/plugins/patina/plugin/src/lib.rs @@ -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); diff --git a/plugins/patina/plugin/src/params.rs b/plugins/patina/plugin/src/params.rs index e8f3dad..41096a9 100644 --- a/plugins/patina/plugin/src/params.rs +++ b/plugins/patina/plugin/src/params.rs @@ -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, #[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 } diff --git a/plugins/patina/plugin/tests/host.rs b/plugins/patina/plugin/tests/host.rs index e838692..53194cd 100644 --- a/plugins/patina/plugin/tests/host.rs +++ b/plugins/patina/plugin/tests/host.rs @@ -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); diff --git a/plugins/patina/ui/src/lib.rs b/plugins/patina/ui/src/lib.rs index 91e1d78..0f0a7f9 100644 --- a/plugins/patina/ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -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 { + let t = text.trim().trim_end_matches(":1").trim(); + if t == "∞" || t.eq_ignore_ascii_case("inf") { + Some(RATIO_INFINITE) + } else { + t.parse::().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 = (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, + ); }); }); }); diff --git a/website/src/data/plugins.ts b/website/src/data/plugins.ts index 5053a59..8d495f6 100644 --- a/website/src/data/plugins.ts +++ b/website/src/data/plugins.ts @@ -71,7 +71,7 @@ const COPY: Record = { 'Distortion chains build up harsh, ringing resonances. A static EQ cut takes out the tone along with the ring.', points: [ 'Finds ringing regions as they happen and pulls only those down.', - 'Plain harmonic tone and transients pass; Selectivity sets what counts as harsh.', + 'Each band works like a small compressor: Threshold sets what counts as harsh, Ratio how hard it is cut.', 'Four focus points on the plot to cut more or protect a region.', 'Delta monitoring to hear exactly what is removed.', ],