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

+13 -1
View File
@@ -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
+17
View File
@@ -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
+28 -13
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 417 KiB

+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,
);
});
});
});
+1 -1
View File
@@ -71,7 +71,7 @@ const COPY: Record<string, Copy> = {
'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.',
],