Files
bass-foundry/plugins/patina/core/src/lib.rs
T
nvrl 53bc26d14d feat(patina): Tone guard for bass lines
Patina detects the played note (30-500 Hz) and remembers, over about
4 s, how high each spectral bump is at a fixed frequency and relative
to the note. Bumps that follow the note (a boosted harmonic the patch
is built on) get less cut; bumps fixed in Hz (formants, room modes)
are cut as before. On a saw line F1-F2 with harmonic 6 boosted, the
cut on that harmonic drops from 5.78 dB to 0.44 dB at 100 %, while a
fixed formant at 1.5 kHz stays at 5.78 dB. One held note is cut as
normal, since the two cases cannot be told apart.

- New parameter tone-guard (0-100 %, default 0), appended at the end;
  0 % is the old behaviour and old settings load unchanged.
- New Bass line preset.
- Editor: Tone guard control, NOTE readout and a strip over the
  spectrum where cuts are held back.
- No added latency, no allocation on the audio thread; patina-core now
  depends on common-dsp.
2026-11-06 12:24:11 +01:00

668 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Independently designed spectral attenuation. No host or GUI dependencies.
mod pitch;
mod settings;
use pitch::PitchProbe;
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
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.;
/// Tone guard memory: bump heights are averaged over about this many seconds.
const TONE_MEMORY: f32 = 4.;
/// Tone guard starts to protect a bump this much (as a share of its height) more consistent
/// relative to the note than in Hz, and protects it fully at `TONE_FULL`.
const TONE_START: f32 = 0.2;
const TONE_FULL: f32 = 0.6;
/// Bumps lower than this (dB) do not count for the tone guard comparison.
const TONE_MIN_DB: f32 = 1.;
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],
/// Share of the cut that Tone guard holds back, 0–1.
pub tone: [f32; GRID],
/// Fundamental the Tone guard follows, Hz; 0 when none.
pub pitch: f32,
pub input_peak: f32,
pub output_peak: f32,
pub guard: f32,
pub sample_rate: f32,
pub latency: u32,
pub channels: usize,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
input: [-120.; GRID],
envelope: [-120.; GRID],
reference: [-120.; GRID],
reduction: [0.; GRID],
focus: [0.; GRID],
tone: [0.; GRID],
pitch: 0.,
input_peak: 0.,
output_peak: 0.,
guard: 0.,
sample_rate: 48000.,
latency: 2048,
channels: 2,
}
}
}
pub fn frequency(index: usize) -> f32 {
20. * 1000f32.powf(index as f32 / (GRID - 1) as f32)
}
/// Grid position of a frequency, fractional.
pub fn cell(hz: f32) -> f32 {
(hz.max(20.) / 20.).log2() / 1000f32.log2() * (GRID - 1) as f32
}
#[derive(Clone, Copy, Default)]
pub struct Monitor {
pub delta: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
n: usize,
hop: usize,
pos: usize,
hop_pos: usize,
input: [Vec<f32>; 2],
output: [Vec<f32>; 2],
spectra: [Vec<Complex32>; 2],
window: Vec<f32>,
scratch: Vec<Complex32>,
forward: Arc<dyn Fft<f32>>,
inverse: Arc<dyn Fft<f32>>,
/// Per-cell power, smoothed over frames.
power: [[f32; GRID]; 2],
target: [f32; GRID],
reduction: [f32; GRID],
curve: [f32; GRID],
mask: [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)>,
/// 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,
/// Tone guard: pitch of the analysis window and two slow maps of bump height, one fixed
/// in Hz and one relative to the fundamental (index = cell + GRID − fundamental cell).
probe: PitchProbe,
fixed: [f32; GRID],
relative: [f32; 2 * GRID],
tone: [f32; GRID],
frame: AnalyzerFrame,
fresh: bool,
fast: f32,
slow: f32,
protect: f32,
sample_power: f32,
mix: f32,
gain: f32,
bypass: f32,
delta: f32,
mix_initialized: bool,
power_scale: f32,
in_peak: f32,
out_peak: f32,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if ![44100., 48000., 88200., 96000., 176400., 192000.].contains(&rate)
|| !(1..=2).contains(&channels)
{
return Err("Supported rates: 44.1/48/88.2/96/176.4/192 kHz; mono or stereo");
}
let scale = if rate > 96000. {
4
} else if rate > 48000. {
2
} else {
1
};
let n = 2048 * scale;
let hop = n / 8;
let mut planner = FftPlanner::new();
let forward = planner.plan_fft_forward(n);
let inverse = planner.plan_fft_inverse(n);
let scratch_len = forward
.get_inplace_scratch_len()
.max(inverse.get_inplace_scratch_len());
let window: Vec<_> = (0..n)
.map(|i| {
(0.5 - 0.5 * (2. * PI * i as f32 / n as f32).cos())
.max(0.)
.sqrt()
})
.collect();
let power_scale = 2. / window.iter().sum::<f32>().powi(2);
let nyquist = rate * 0.5;
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| {
let grid = ((k as f32 * rate / n as f32).max(20.) / 20.).log2() / 1000f32.log2()
* (GRID - 1) as f32;
let grid = grid.clamp(0., (GRID - 1) as f32);
(
(grid.floor() as usize).min(GRID - 2),
if grid >= (GRID - 1) as f32 {
1.
} else {
grid.fract()
},
)
})
.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,
n,
hop,
pos: 0,
hop_pos: 0,
input: std::array::from_fn(|_| vec![0.; n]),
output: std::array::from_fn(|_| vec![0.; n]),
spectra: std::array::from_fn(|_| vec![Complex32::default(); n]),
window,
scratch: vec![Complex32::default(); scratch_len],
forward,
inverse,
power: [[0.; GRID]; 2],
target: [0.; GRID],
reduction: [0.; GRID],
curve: [0.; GRID],
mask: [0.; GRID],
bands,
bin_map,
join,
envelope_radius: 0,
reference_radius: 0,
settings: Settings::default(),
probe: PitchProbe::new(rate),
fixed: [0.; GRID],
relative: [0.; 2 * GRID],
tone: [0.; GRID],
frame: AnalyzerFrame::default(),
fresh: false,
fast: 0.,
slow: 0.,
protect: 0.,
sample_power: 0.,
mix: 1.,
gain: 1.,
bypass: 0.,
delta: 0.,
mix_initialized: false,
power_scale,
in_peak: 0.,
out_peak: 0.,
};
engine.rebuild();
Ok(engine)
}
pub fn latency_samples(&self) -> u32 {
self.n as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, settings: Settings) -> Result<(), &'static str> {
settings.validate()?;
if settings != self.settings {
let rebuild = settings.focus != self.settings.focus
|| settings.low != self.settings.low
|| settings.high != self.settings.high
|| settings.sharpness != self.settings.sharpness;
self.settings = settings;
if rebuild {
self.rebuild();
}
}
if !self.mix_initialized {
self.mix = settings.mix / 100.;
self.gain = 10f32.powf(settings.output / 20.);
self.bypass = f32::from(settings.bypass);
self.mix_initialized = true;
}
Ok(())
}
fn rebuild(&mut self) {
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);
let low = (f / self.settings.low).log2() / 0.15;
let high = (self.settings.high.min(self.rate * 0.49) / f).log2() / 0.15;
let edge = low.min(high).clamp(0., 1.);
self.mask[i] = edge * edge * (3. - 2. * edge);
}
}
pub fn reset(&mut self) {
for channel in &mut self.input {
channel.fill(0.);
}
for channel in &mut self.output {
channel.fill(0.);
}
self.reduction.fill(0.);
self.power = [[0.; GRID]; 2];
self.probe.reset();
self.fixed.fill(0.);
self.relative.fill(0.);
self.tone.fill(0.);
self.pos = 0;
self.hop_pos = 0;
self.fast = 0.;
self.slow = 0.;
self.protect = 0.;
self.sample_power = 0.;
self.in_peak = 0.;
self.out_peak = 0.;
self.fresh = false;
self.frame = AnalyzerFrame {
sample_rate: self.rate,
latency: self.n as u32,
..Default::default()
};
self.mix_initialized = false;
self.delta = 0.;
}
pub fn take_analyzer(&mut self) -> Option<AnalyzerFrame> {
if self.fresh {
self.fresh = false;
Some(self.frame)
} else {
None
}
}
/// Sample scheduling is independent of the caller's block partition.
pub fn process_sample(&mut self, sample: [f32; 2], monitor: Monitor) -> [f32; 2] {
let mut result = [0.; 2];
let mut energy = 0f32;
let smooth = 1. - (-1. / (self.rate * 0.01)).exp();
self.mix += (self.settings.mix / 100. - self.mix) * smooth;
self.gain += (10f32.powf(self.settings.output / 20.) - self.gain) * smooth;
self.bypass += (f32::from(self.settings.bypass) - self.bypass) * smooth;
self.delta += (f32::from(monitor.delta) - self.delta) * smooth;
for c in 0..self.channels {
let x = if sample[c].is_finite() {
sample[c].clamp(-64., 64.)
} else {
0.
};
let dry = self.input[c][self.pos];
let wet = self.output[c][self.pos];
self.output[c][self.pos] = 0.;
let x = if x.abs() < 1e-20 { 0. } else { x };
self.input[c][self.pos] = x;
let mixed = (dry + (wet - dry) * self.mix) * self.gain;
let normal = mixed + (dry - mixed) * self.bypass;
let audible = normal + ((dry - wet) - normal) * self.delta;
result[c] = if audible.is_finite() { audible } else { 0. };
self.in_peak = self.in_peak.max(x.abs());
self.out_peak = self.out_peak.max(result[c].abs());
energy = energy.max(x * x);
}
self.fast += (energy - self.fast) * (1. - (-1. / (self.rate * 0.001)).exp());
self.slow += (energy - self.slow) * (1. - (-1. / (self.rate * 0.05)).exp());
let onset =
((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.);
self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp());
self.sample_power += energy;
if self.settings.tone_guard > 0. {
let mono = if self.channels == 2 {
0.5 * (self.input[0][self.pos] + self.input[1][self.pos])
} else {
self.input[0][self.pos]
};
self.probe.push(mono);
}
self.pos = (self.pos + 1) % self.n;
self.hop_pos += 1;
if self.hop_pos == self.hop {
self.hop_pos = 0;
self.process_window();
}
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];
let mut bumps = [0f32; GRID];
// Tone guard: protect bumps that have followed the note more than they stayed in Hz.
let guard = self.settings.tone_guard / 100.;
let pitch = if guard > 0. && awake {
self.probe.estimate()
} else {
0.
};
let shift = cell(pitch).round() as usize;
for i in 0..GRID {
self.tone[i] = if pitch > 0. {
let rel = self.relative[i + GRID - shift];
let fixed = self.fixed[i];
let score = (rel - fixed) / rel.max(fixed).max(TONE_MIN_DB);
let t = ((score - TONE_START) / (TONE_FULL - TONE_START)).clamp(0., 1.);
t * t * (3. - 2. * t) * guard
} else {
0.
};
}
for c in 0..self.channels {
for i in 0..self.n {
self.spectra[c][i] =
Complex32::new(self.input[c][(self.pos + i) % self.n] * self.window[i], 0.);
}
self.forward
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
for i in 0..GRID {
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());
}
// 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 {
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 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 live = awake && content[i] && level > -130.;
let target = if live {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
} else {
0.
};
if live {
bumps[i] = bumps[i].max(level - reference[i]);
}
// Linked stereo: the stronger channel decides.
self.target[i] = self.target[i].max(target);
}
}
if pitch > 0. {
let alpha = 1. - (-frame_time / TONE_MEMORY).exp();
for i in 0..GRID {
self.fixed[i] += (bumps[i] - self.fixed[i]) * alpha;
let r = &mut self.relative[i + GRID - shift];
*r += (bumps[i] - *r) * alpha;
}
}
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 target = self.target[i];
let alpha = if target > self.reduction[i] {
attack
} else {
release
};
self.reduction[i] = alpha * self.reduction[i] + (1. - alpha) * target;
if self.reduction[i] < 1e-12 {
self.reduction[i] = 0.;
}
self.frame.reduction[i] = (self.reduction[i]
* self.mask[i]
* (1. - self.protect * self.settings.transient_guard / 100.))
.clamp(0., self.settings.depth);
self.frame.focus[i] = self.curve[i];
self.frame.tone[i] = self.tone[i];
}
for c in 0..self.channels {
for k in 0..=self.n / 2 {
let (i, w) = self.bin_map[k];
let d = self.frame.reduction[i] * (1. - w) + self.frame.reduction[i + 1] * w;
let f = k as f32 * self.rate / self.n as f32;
let d = if f >= self.settings.low && f <= self.settings.high.min(self.rate * 0.49) {
d
} else {
0.
};
let g = 10f32.powf(-d / 20.);
self.spectra[c][k] *= g;
if k > 0 && k < self.n / 2 {
self.spectra[c][self.n - k] *= g;
}
}
self.inverse
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
// Eight overlaps of a periodic Hann sum to four.
for i in 0..self.n {
self.output[c][(self.pos + i) % self.n] +=
self.spectra[c][i].re * self.window[i] / (self.n as f32 * 4.);
}
}
self.frame.input_peak = self.in_peak;
self.frame.output_peak = self.out_peak;
self.frame.guard = self.protect;
self.frame.pitch = pitch;
self.frame.sample_rate = self.rate;
self.frame.latency = self.n as u32;
self.frame.channels = self.channels;
self.in_peak = 0.;
self.out_peak = 0.;
self.fresh = true;
}
}