//! 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, #[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., 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() }