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:
1 parent
7b989f7685
commit
9ca4e4f9dd
13 files changed
+568
-102
No files matched your search
@@ -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];
|
||||
|
||||
@@ -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.;
|
||||
}
|
||||
_ => {}
|
||||
|
||||
Reference in new issue
Block a user