Files
bass-foundry/plugins/patina/core/src/settings.rs
T
nvrl 9ca4e4f9dd 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.
2026-11-06 17:29:54 +01:00

278 lines
8.2 KiB
Rust

//! Stable, host-independent controls. Frequencies are Hz, times milliseconds.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Focus {
pub enabled: bool,
pub frequency: f32,
pub width: f32,
pub sensitivity: f32,
}
impl Default for Focus {
fn default() -> Self {
Self {
enabled: false,
frequency: 2000.,
width: 0.5,
sensitivity: 0.,
}
}
}
/// 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(from = "Stored")]
pub struct Settings {
/// Largest cut in dB per band.
pub depth: f32,
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
pub sharpness: 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,
pub low: f32,
pub high: f32,
pub mix: f32,
pub output: f32,
pub bypass: bool,
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.
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.,
threshold: 3.,
ratio: RATIO_INFINITE,
knee: Knee::Soft,
attack: 5.,
release: 80.,
transient_guard: 30.,
low: 200.,
high: 16000.,
mix: 100.,
output: 0.,
bypass: false,
focus: [Focus::default(); 4],
tone_guard: 0.,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
for (v, low, high) in [
(self.depth, 0., 20.),
(self.sharpness, 0., 100.),
(self.threshold, 0., 18.),
(self.ratio, 1., RATIO_INFINITE),
(self.attack, 1., 200.),
(self.release, 10., 1000.),
(self.transient_guard, 0., 100.),
(self.low, 20., 20000.),
(self.high, 20., 20000.),
(self.mix, 0., 100.),
(self.output, -12., 12.),
(self.tone_guard, 0., 100.),
] {
if !v.is_finite() || !(low..=high).contains(&v) {
return Err("Invalid resonance control value");
}
}
if self.low >= self.high {
return Err("Low frequency must be below high frequency");
}
for f in self.focus {
if !f.frequency.is_finite()
|| !(20. ..=20000.).contains(&f.frequency)
|| !f.width.is_finite()
|| !(0.05..=2.).contains(&f.width)
|| !f.sensitivity.is_finite()
|| !(-18. ..=18.).contains(&f.sensitivity)
{
return Err("Invalid focus control");
}
}
Ok(())
}
/// Detector envelope width in octaves (FWHM), from Sharpness.
pub fn envelope_width(&self) -> f32 {
(1. / 3.) * (1f32 / 8.).powf(self.sharpness / 100.)
}
/// Local reference width in octaves (FWHM). Much wider than the envelope.
pub fn reference_width(&self) -> f32 {
(self.envelope_width() * 16.).clamp(2., 3.)
}
/// 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",
"Vocal harshness",
"Snare ring",
"Bright synth",
"Bass line",
];
pub fn preset(index: usize) -> Self {
let mut s = Self::default();
match index {
1 => {
s.low = 1500.;
s.high = 12000.;
s.depth = 10.;
s.sharpness = 55.;
s.threshold = 2.5;
s.ratio = 3.;
s.attack = 3.;
s.release = 60.;
}
2 => {
s.low = 250.;
s.high = 6000.;
s.depth = 12.;
s.sharpness = 75.;
s.threshold = 2.5;
s.ratio = 6.;
s.knee = Knee::Hard;
s.attack = 10.;
s.release = 150.;
s.transient_guard = 80.;
}
3 => {
s.low = 2000.;
s.high = 16000.;
s.depth = 9.;
s.sharpness = 25.;
s.threshold = 2.5;
s.ratio = 2.5;
}
4 => {
s.low = 150.;
s.high = 12000.;
s.depth = 8.;
s.sharpness = 50.;
s.threshold = 2.5;
s.ratio = 3.;
s.tone_guard = 80.;
}
_ => {}
}
s
}
}
/// Soft knee on excess prominence, measured in dB.
pub fn knee(excess: f32, width: f32) -> f32 {
if width <= 0. {
excess.max(0.)
} else if excess <= -width * 0.5 {
0.
} else if excess >= width * 0.5 {
excess
} else {
(excess + width * 0.5).powi(2) / (2. * width)
}
}
pub fn focus_offset(f: f32, focus: &[Focus; 4]) -> f32 {
focus
.iter()
.filter(|p| p.enabled)
.map(|p| {
let z = (f / p.frequency).log2() / p.width;
p.sensitivity * (-4. * std::f32::consts::LN_2 * z * z).exp()
})
.sum()
}