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

+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.;
}
_ => {}