//! 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; 2], output: [Vec; 2], spectra: [Vec; 2], window: Vec, scratch: Vec, forward: Arc>, inverse: Arc>, /// 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 { 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::().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 { 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::() / (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 = 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; } }