feat(patina): rebuild resonance detector, simplify controls, new look
Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s

Patina 0.2.0. The old detector compared raw FFT bins with a one-octave
dB mean, so every harmonic of a tonal sound looked like a resonance and
real harsh bumps were barely touched. The new detector:

- averages power over each log band and smooths it over time
- joins harmonic tops into an envelope, smoothed by Sharpness
- compares it with a robust, tilt-aware local line fit that ignores
  empty regions and cannot be lifted by the bump itself

Six overlapping controls become Depth, Sharpness and Selectivity; focus
points now read "up cuts more, down protects". The analysis window is
halved to 2048 samples (about 43 ms delay). Behaviour tests cover plain
harmonic tones, resonant bumps, narrow rings, level independence,
flicker on noise and high sample rates.

Both editors get a dark metallic look with #FF0160 as the single
accent colour; screenshots and docs are updated. Projects saved with
Patina 0.1 load the new controls at their defaults.
This commit is contained in:
nvrl committed 2026-11-06 01:38:17 +01:00
1 parent 15853e61ac
commit 17c6fe3ab9
25 files changed
+1074 -539

No files matched your search

+248 -61
View File
@@ -5,9 +5,111 @@ pub use settings::{focus_offset, knee, Focus, Settings};
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.;
/// Detector floor, far below any audible content.
const FLOOR_DB: f32 = -160.;
/// Cells this far below the reference count as empty, not as a dip.
const GAP_DB: f32 = 9.;
/// Envelope is clipped this far above the previous fit on later passes.
const CLIP_DB: f32 = 2.;
/// Cells this far below the loudest nearby envelope are empty, not content.
const EMPTY_DB: f32 = 24.;
/// Steepest spectral tilt the reference follows, in dB per octave.
const MAX_TILT: f32 = 12.;
fn grid_step() -> f32 {
1000f32.log2() / (GRID - 1) as f32
}
/// Box radius whose two-pass (triangular) response has the given FWHM in octaves.
fn radius(octaves: f32) -> usize {
(((octaves / grid_step()) - 1.) * 0.5)
.round()
.clamp(0., (GRID / 2) as f32) as usize
}
/// Two box passes over non-negative values (power, or dB above the floor). f64 prefix sums keep quiet cells exact
/// next to loud ones. Edges average only the cells that exist.
fn smooth(src: &[f32; GRID], dst: &mut [f32; GRID], radius: usize, passes: usize) {
let mut prefix = [0f64; GRID + 1];
*dst = *src;
for _ in 0..passes {
for i in 0..GRID {
prefix[i + 1] = prefix[i] + dst[i] as f64;
}
for (i, value) in dst.iter_mut().enumerate() {
let low = i.saturating_sub(radius);
let high = (i + radius + 1).min(GRID);
*value = ((prefix[high] - prefix[low]) / (high - low) as f64).max(0.) as f32;
}
}
}
/// Maximum over `i - radius..=i + radius` for each cell, in O(GRID) with a
/// monotonic queue of candidate indices.
fn local_max(y: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) {
let mut queue = [0usize; GRID];
let (mut head, mut tail) = (0, 0);
let mut next = 0;
for (i, value) in out.iter_mut().enumerate() {
while next < GRID && next <= i + radius {
while tail > head && y[queue[tail - 1]] <= y[next] {
tail -= 1;
}
queue[tail] = next;
tail += 1;
next += 1;
}
while queue[head] + radius < i {
head += 1;
}
*value = y[queue[head]];
}
}
/// Weighted least-squares line through each cell's neighborhood, evaluated at
/// that cell. The slope is limited to `MAX_TILT`, so an edge of the content
/// (a steep rise below a fundamental, a filter cutoff) is not taken as tilt.
fn fit(y: &[f32; GRID], w: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) {
let max_slope = (MAX_TILT * grid_step()) as f64;
let mut sums = [[0f64; 5]; GRID + 1];
for j in 0..GRID {
let (x, wj, yj) = (j as f64, w[j] as f64, y[j] as f64);
let s = sums[j];
sums[j + 1] = [
s[0] + wj,
s[1] + wj * x,
s[2] + wj * x * x,
s[3] + wj * yj,
s[4] + wj * x * yj,
];
}
for (i, value) in out.iter_mut().enumerate() {
let low = i.saturating_sub(radius);
let high = (i + radius + 1).min(GRID);
let [sw, sx, sxx, sy, sxy] = std::array::from_fn(|k| sums[high][k] - sums[low][k]);
if sw < 1. {
*value = y[i];
continue;
}
let det = sw * sxx - sx * sx;
let mean = sy / sw;
let slope = if det > 1e-6 * sw * sw {
((sw * sxy - sx * sy) / det).clamp(-max_slope, max_slope)
} else {
0.
};
*value = (mean + slope * (i as f64 - sx / sw)) as f32;
}
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
pub input: [f32; GRID],
/// What the detector compares: the smoothed spectral envelope, in dB.
pub envelope: [f32; GRID],
/// Level the envelope is compared with, in dB.
pub reference: [f32; GRID],
pub reduction: [f32; GRID],
pub focus: [f32; GRID],
pub input_peak: f32,
@@ -21,13 +123,15 @@ impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
input: [-120.; GRID],
envelope: [-120.; GRID],
reference: [-120.; GRID],
reduction: [0.; GRID],
focus: [0.; GRID],
input_peak: 0.,
output_peak: 0.,
guard: 0.,
sample_rate: 48000.,
latency: 4096,
latency: 2048,
channels: 2,
}
}
@@ -54,15 +158,20 @@ pub struct Engine {
scratch: Vec<Complex32>,
forward: Arc<dyn Fft<f32>>,
inverse: Arc<dyn Fft<f32>>,
levels: [[f32; GRID]; 2],
/// Per-cell power, smoothed over frames.
power: [[f32; GRID]; 2],
target: [f32; GRID],
reduction: [f32; GRID],
curve: [f32; GRID],
mask: [f32; GRID],
map: [(usize, f32); GRID],
/// Per cell: FFT bin range `start..end`, or interpolation at `start + frac`
/// when the cell is narrower than one bin.
bands: [(usize, usize, f32); GRID],
bin_map: Vec<(usize, f32)>,
kernel: [f32; 65],
radius: usize,
/// Per cell: how many cells away the next peak may be and still be joined.
join: [usize; GRID],
envelope_radius: usize,
reference_radius: usize,
settings: Settings,
frame: AnalyzerFrame,
fresh: bool,
@@ -93,7 +202,7 @@ impl Engine {
} else {
1
};
let n = 4096 * scale;
let n = 2048 * scale;
let hop = n / 8;
let mut planner = FftPlanner::new();
let forward = planner.plan_fft_forward(n);
@@ -110,9 +219,17 @@ impl Engine {
.collect();
let power_scale = 2. / window.iter().sum::<f32>().powi(2);
let nyquist = rate * 0.5;
let map = std::array::from_fn(|i| {
let bin = (frequency(i).min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32);
(bin.floor() as usize, bin.fract())
let half_step = 2f32.powf(grid_step() * 0.5);
let bands = std::array::from_fn(|i| {
let to_bin = |f: f32| (f.min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32);
let start = to_bin(frequency(i) / half_step).ceil() as usize;
let end = to_bin(frequency(i) * half_step).floor() as usize + 1;
if end > start + 1 {
(start, end, 0.)
} else {
let bin = to_bin(frequency(i));
(bin.floor() as usize, bin.floor() as usize, bin.fract())
}
});
let bin_map = (0..=n / 2)
.map(|k| {
@@ -129,6 +246,11 @@ impl Engine {
)
})
.collect();
let join = std::array::from_fn(|i| {
let f = frequency(i);
let octaves = ((f + JOIN_HZ) / f).log2().max(1.);
(octaves / grid_step()).round() as usize
});
let mut engine = Self {
rate,
channels,
@@ -143,15 +265,16 @@ impl Engine {
scratch: vec![Complex32::default(); scratch_len],
forward,
inverse,
levels: [[-120.; GRID]; 2],
power: [[0.; GRID]; 2],
target: [0.; GRID],
reduction: [0.; GRID],
curve: [0.; GRID],
mask: [0.; GRID],
map,
bands,
bin_map,
kernel: [0.; 65],
radius: 0,
join,
envelope_radius: 0,
reference_radius: 0,
settings: Settings::default(),
frame: AnalyzerFrame::default(),
fresh: false,
@@ -186,7 +309,7 @@ impl Engine {
let rebuild = settings.focus != self.settings.focus
|| settings.low != self.settings.low
|| settings.high != self.settings.high
|| settings.cut_width != self.settings.cut_width;
|| settings.sharpness != self.settings.sharpness;
self.settings = settings;
if rebuild {
self.rebuild();
@@ -201,12 +324,8 @@ impl Engine {
Ok(())
}
fn rebuild(&mut self) {
let step = 1000f32.log2() / (GRID - 1) as f32;
self.radius = ((self.settings.cut_width / step * 1.3).ceil() as usize).clamp(1, 64);
let sigma = self.settings.cut_width / step / 2.355;
for j in 0..=self.radius {
self.kernel[j] = (-0.5 * (j as f32 / sigma).powi(2)).exp();
}
self.envelope_radius = radius(self.settings.envelope_width());
self.reference_radius = radius(self.settings.reference_width());
for i in 0..GRID {
let f = frequency(i);
self.curve[i] = focus_offset(f, &self.settings.focus);
@@ -224,6 +343,7 @@ impl Engine {
channel.fill(0.);
}
self.reduction.fill(0.);
self.power = [[0.; GRID]; 2];
self.pos = 0;
self.hop_pos = 0;
self.fast = 0.;
@@ -291,7 +411,28 @@ impl Engine {
}
result
}
// Per-cell loops read several parallel spectra; indices are clearer here.
#[allow(clippy::needless_range_loop)]
fn process_window(&mut self) {
let frame_time = self.hop as f32 / self.rate;
// Detector power follows rises in about 5 ms and decays in about 40 ms,
// so cuts hold on a stable ring instead of flickering with each frame.
let rise = 1. - (-frame_time / 0.005).exp();
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();
self.target.fill(0.);
self.frame.input.fill(-200.);
self.frame.envelope.fill(-200.);
self.frame.reference.fill(-200.);
let mut levels = [0f32; GRID];
let mut tops;
let mut envelope = [0f32; GRID];
let mut reference = [0f32; GRID];
let mut clipped = [0f32; GRID];
let mut weight = [0f32; GRID];
let mut content = [false; GRID];
for c in 0..self.channels {
for i in 0..self.n {
self.spectra[c][i] =
@@ -300,53 +441,104 @@ impl Engine {
self.forward
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
for i in 0..GRID {
let (k, w) = self.map[i];
let power = self.spectra[c][k].norm_sqr() * (1. - w)
+ self.spectra[c][k + 1].norm_sqr() * w;
self.levels[c][i] = 10. * (power * self.power_scale).max(1e-20).log10();
let (start, end, w) = self.bands[i];
let power = if end > start {
self.spectra[c][start..end]
.iter()
.map(|z| z.norm_sqr())
.sum::<f32>()
/ (end - start) as f32
} else {
self.spectra[c][start].norm_sqr() * (1. - w)
+ self.spectra[c][start + 1].norm_sqr() * w
} * self.power_scale;
let held = &mut self.power[c][i];
*held += (power - *held) * if power > *held { rise } else { fall };
if *held < 1e-30 {
*held = 0.;
}
self.frame.input[i] = self.frame.input[i].max(10. * power.max(1e-20).log10());
}
}
let awake = self.sample_power / self.hop as f32 > 1e-9;
self.sample_power = 0.;
let step = 1000f32.log2() / (GRID - 1) as f32;
let radius = (self.settings.neighborhood / step * 0.5).round() as usize;
self.target.fill(0.);
for c in 0..self.channels {
let mut prefix = [0.; GRID + 1];
// 1. Upper envelope: join neighboring peaks (harmonic tops) with
// straight lines in dB, so the gaps between harmonics do not count
// as "expected level". Regions without nearby peaks keep their level.
for i in 0..GRID {
prefix[i + 1] = prefix[i] + self.levels[c][i];
levels[i] = (10. * self.power[c][i].max(1e-30).log10()).max(FLOOR_DB);
}
tops = levels;
let mut last: Option<usize> = None;
for i in 1..GRID - 1 {
if levels[i] >= levels[i - 1] && levels[i] > levels[i + 1] {
if let Some(a) = last.filter(|a| i - a <= self.join[*a]) {
let span = (i - a) as f32;
for j in a + 1..i {
let t = (j - a) as f32 / span;
let line = levels[a] + (levels[i] - levels[a]) * t;
tops[j] = tops[j].max(line);
}
}
last = Some(i);
}
}
for value in &mut tops {
*value = 10f32.powf(*value / 10.);
}
// 2. Smooth it across Sharpness width: this is what the detector "hears".
smooth(&tops, &mut envelope, self.envelope_radius, 2);
for value in &mut envelope {
*value = (10. * value.max(1e-30).log10()).max(FLOOR_DB);
}
// 3. Robust reference: a local straight-line fit in dB, so spectral
// tilt is expected rather than prominent. Each pass clips the
// envelope to the previous fit, so a bump cannot raise its own
// reference. Cells far below the fit (empty regions, filter slopes,
// below the fundamental) are left out instead of dragging it down.
local_max(&envelope, self.reference_radius, &mut clipped);
for i in 0..GRID {
content[i] = envelope[i] > clipped[i] - EMPTY_DB;
weight[i] = f32::from(content[i]);
}
fit(&envelope, &weight, self.reference_radius, &mut reference);
for pass in 0..8 {
for i in 0..GRID {
weight[i] = f32::from(content[i] && envelope[i] > reference[i] - GAP_DB);
// First find the content, then stop bumps from lifting the line.
clipped[i] = if pass < 2 {
envelope[i]
} else {
envelope[i].min(reference[i] + CLIP_DB)
};
}
let previous = reference;
fit(&clipped, &weight, self.reference_radius, &mut reference);
for i in 0..GRID {
if !reference[i].is_finite() {
reference[i] = previous[i];
}
}
}
for i in 0..GRID {
let low = i.saturating_sub(radius);
let high = (i + radius + 1).min(GRID);
let e0 = i.saturating_sub(1).max(low);
let e1 = (i + 2).min(high);
let count = (high - low) - (e1 - e0);
let baseline =
(prefix[high] - prefix[low] - (prefix[e1] - prefix[e0])) / count.max(1) as f32;
let excess =
self.levels[c][i] - baseline + self.curve[i] - self.settings.sensitivity;
let target = if awake {
knee(excess, self.settings.knee) * self.settings.amount * 0.02
let level = envelope[i];
self.frame.envelope[i] = self.frame.envelope[i].max(level);
self.frame.reference[i] = self.frame.reference[i].max(reference[i]);
let excess = level - reference[i] + self.curve[i] - threshold;
// Only real content can ring: leakage far below nearby
// material is never cut.
let target = if awake && content[i] && level > -130. {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth)
} else {
0.
};
self.target[i] = self.target[i].max(target.min(self.settings.max_cut));
// Linked stereo: the stronger channel decides.
self.target[i] = self.target[i].max(target);
}
}
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 release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
for i in 0..GRID {
let low = i.saturating_sub(self.radius);
let high = (i + self.radius + 1).min(GRID);
let mut sum = 0.;
let mut weight = 0.;
for j in low..high {
let w = self.kernel[i.abs_diff(j)];
sum += self.target[j] * w;
weight += w;
}
let target = sum / weight;
let target = self.target[i];
let alpha = if target > self.reduction[i] {
attack
} else {
@@ -359,13 +551,8 @@ impl Engine {
self.frame.reduction[i] = (self.reduction[i]
* self.mask[i]
* (1. - self.protect * self.settings.transient_guard / 100.))
.clamp(0., self.settings.max_cut);
.clamp(0., self.settings.depth);
self.frame.focus[i] = self.curve[i];
self.frame.input[i] = self.levels[0][i].max(if self.channels == 2 {
self.levels[1][i]
} else {
-200.
});
}
for c in 0..self.channels {
for k in 0..=self.n / 2 {
+49 -41
View File
@@ -22,12 +22,12 @@ impl Default for Focus {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub amount: f32,
pub sensitivity: f32,
pub max_cut: f32,
pub neighborhood: f32,
pub cut_width: f32,
pub knee: f32,
/// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper.
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,
pub attack: f32,
pub release: f32,
pub transient_guard: f32,
@@ -41,17 +41,14 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
amount: 35.,
sensitivity: 6.,
max_cut: 3.,
neighborhood: 1.,
cut_width: 0.08,
knee: 3.,
attack: 15.,
release: 120.,
transient_guard: 50.,
low: 120.,
high: 12000.,
depth: 6.,
sharpness: 40.,
selectivity: 50.,
attack: 5.,
release: 80.,
transient_guard: 30.,
low: 200.,
high: 16000.,
mix: 100.,
output: 0.,
bypass: false,
@@ -62,12 +59,9 @@ impl Default for Settings {
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
for (v, low, high) in [
(self.amount, 0., 100.),
(self.sensitivity, 0., 24.),
(self.max_cut, 0., 18.),
(self.neighborhood, 0.25, 2.),
(self.cut_width, 0.03, 0.5),
(self.knee, 0., 12.),
(self.depth, 0., 20.),
(self.sharpness, 0., 100.),
(self.selectivity, 0., 100.),
(self.attack, 1., 200.),
(self.release, 10., 1000.),
(self.transient_guard, 0., 100.),
@@ -96,37 +90,51 @@ impl Settings {
}
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.)
}
/// Prominence threshold in dB above the local reference, from Selectivity.
pub fn threshold(&self) -> f32 {
0.5 + self.selectivity * 0.05
}
pub const PRESETS: [&'static str; 4] = [
"Gentle cleanup",
"Bass whistle",
"Snare tail",
"Bright melodic layer",
"Vocal harshness",
"Snare ring",
"Bright synth",
];
pub fn preset(index: usize) -> Self {
let mut s = Self::default();
match index {
1 => {
s.low = 700.;
s.high = 10000.;
s.sensitivity = 5.;
s.max_cut = 6.;
s.cut_width = 0.05;
s.attack = 8.;
s.low = 1500.;
s.high = 12000.;
s.depth = 8.;
s.sharpness = 55.;
s.selectivity = 40.;
s.attack = 3.;
s.release = 60.;
}
2 => {
s.low = 250.;
s.high = 6000.;
s.attack = 25.;
s.release = 180.;
s.transient_guard = 85.;
s.max_cut = 5.;
s.depth = 8.;
s.sharpness = 75.;
s.selectivity = 35.;
s.attack = 10.;
s.release = 150.;
s.transient_guard = 80.;
}
3 => {
s.low = 1800.;
s.high = 15000.;
s.sensitivity = 4.;
s.max_cut = 4.;
s.cut_width = 0.14;
s.low = 2000.;
s.high = 16000.;
s.sharpness = 25.;
s.selectivity = 45.;
}
_ => {}
}