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.
668 lines
25 KiB
Rust
668 lines
25 KiB
Rust
//! 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;
|
||
}
|
||
}
|