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.
278 lines
8.2 KiB
Rust
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()
|
|
}
|