//! A clipper that leaves the sub alone. //! //! A plain clipper clips the sum of sub and top. Near the sub's peaks only one side of the top //! gets clipped, so the clipping takes energy out at the sub's rate: the sub loses level and //! the low band fills with products that were not in the mix. //! //! Quench splits the signal with a Linkwitz–Riley crossover. The sub passes untouched. The //! top is clipped symmetrically at the room the sub leaves under the ceiling: the ceiling //! minus the sub's held peak. Holding the peak through the sub's cycle keeps the top's clip //! level steady, so the top is not pumped at the sub's rate. What the clip removes is then //! high-passed at the split, so its products below the split do not reach the low band. //! High-passing lets a few peaks overshoot; two correction passes clip those and high-pass the //! correction again. A hard clip at the ceiling, still oversampled, takes the small rest, and a //! safety clip after the downsampler catches its ringing. //! //! Everything runs at 4× oversampling. Plain clipping of the same signal runs alongside, for //! the Plain monitor and for the meter that compares how much each changes the low band. mod settings; use common_dsp::oversample::Oversampler; use common_dsp::svf::{Svf, SvfCoef}; use common_dsp::{coefficient, db_to_gain, gain_to_db}; pub use settings::{Settings, CEILING_MIN, DRIVE_MAX, HOLD_MAX, HOLD_MIN, SPLIT_MAX, SPLIT_MIN}; const Q: f32 = std::f32::consts::FRAC_1_SQRT_2; const FACTOR: usize = Oversampler::FACTOR; /// Correction passes after the top's clip. pub const PASSES: usize = 2; /// Points in the scope, each the extremes of `SCOPE_STEP` samples. pub const SCOPE: usize = 256; const SCOPE_STEP: usize = 8; const SCOPE_LEN: usize = SCOPE * SCOPE_STEP; /// Averaging time of the low-band meter. const METER_MS: f32 = 300.; const SMOOTH_MS: f32 = 20.; const BYPASS_MS: f32 = 10.; pub const SILENT: f32 = -120.; /// Below this sub energy (−50 dB RMS) the low-band meter has nothing to compare against. const SUB_FLOOR: f32 = 1e-5; /// What the output plays. Monitors are not saved. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Monitor { #[default] Quench, /// A plain clipper at the same Drive, Ceiling and Soft, for comparison. Plain, /// What Quench takes away: its output minus the unclipped input. Delta, } #[derive(Clone, Copy, Debug)] pub struct AnalyzerFrame { /// Peak after Drive, in dBFS, since the last frame. pub input_peak: f32, pub output_peak: f32, /// Peak of the sub band after Drive. pub sub_peak: f32, /// How far the top's loudest peak went past its clip level, in dB. 0 when nothing clipped. pub top_clip: f32, /// How far the final safety clip reached, in dB. 0 when it did nothing. pub safety: f32, /// Change of the low band below the split, against the sub, in dB: Quench and a plain clip /// of the same signal. NaN without a sub. pub low_quench: f32, pub low_plain: f32, pub ceiling: f32, pub latency: u32, pub sample_rate: f32, /// Latest output of the selected monitor (oldest first), as extremes per point. pub scope_min: [f32; SCOPE], pub scope_max: [f32; SCOPE], /// The sub band and the room it leaves the top, at the same points. pub scope_sub: [f32; SCOPE], pub scope_room: [f32; SCOPE], } impl Default for AnalyzerFrame { fn default() -> Self { Self { input_peak: SILENT, output_peak: SILENT, sub_peak: SILENT, top_clip: 0., safety: 0., low_quench: f32::NAN, low_plain: f32::NAN, ceiling: Settings::default().ceiling, latency: Oversampler::latency() as u32, sample_rate: 48000., scope_min: [0.; SCOPE], scope_max: [0.; SCOPE], scope_sub: [0.; SCOPE], scope_room: [0.; SCOPE], } } } /// Clips `h` to ±`limit`. `knee` (0–1) is the share of the range below `limit` that bends: /// 0 is a hard clip. Never exceeds `limit`. #[inline] pub fn shape(h: f32, limit: f32, knee: f32) -> f32 { let a = limit * (1. - knee); let m = h.abs(); if m <= a { return h; } let w = limit - a; let y = if w <= 1e-9 { limit } else { a + w * ((m - a) / w).tanh() }; y.min(limit).copysign(h) } /// One band of a Linkwitz–Riley crossover: two Butterworth stages. #[inline] fn lr4(f: &mut [Svf; 2], c: &SvfCoef, v: f32) -> f32 { let v = f[0].process(c, v); f[1].process(c, v) } #[derive(Clone, Copy)] struct Coefs { low: SvfCoef, high: SvfCoef, /// Held-peak length in oversampled samples, and its release per sample after that. hold: usize, release: f32, knee: f32, } impl Coefs { fn new(rate: f32, s: &Settings) -> Self { let fast = rate * FACTOR as f32; Self { low: SvfCoef::lowpass(fast, s.split, Q), high: SvfCoef::highpass(fast, s.split, Q), hold: (s.hold * 0.001 * fast) as usize, release: 1. - coefficient(fast, s.hold), knee: s.soft * 0.01, } } } struct Channel { os: Oversampler, low: [Svf; 2], high: [Svf; 2], /// High-passes what the top's clip removed. error: Svf, passes: [Svf; PASSES], env: f32, hold_left: usize, meter_quench: [Svf; 2], meter_plain: [Svf; 2], /// The input delayed by the latency, for bypass. dry: Vec, } impl Channel { fn new(latency: usize) -> Self { Self { os: Oversampler::new(), low: [Svf::default(); 2], high: [Svf::default(); 2], error: Svf::default(), passes: [Svf::default(); PASSES], env: 0., hold_left: 0, meter_quench: [Svf::default(); 2], meter_plain: [Svf::default(); 2], dry: vec![0.; latency + 1], } } fn reset(&mut self) { self.os.reset(); for f in self .low .iter_mut() .chain(self.high.iter_mut()) .chain(self.passes.iter_mut()) .chain(self.meter_quench.iter_mut()) .chain(self.meter_plain.iter_mut()) { f.reset(); } self.error.reset(); self.env = 0.; self.hold_left = 0; self.dry.fill(0.); } } /// Peaks and energies gathered between two analyzer frames. #[derive(Clone, Copy, Default)] struct Gather { input: f32, output: f32, sub: f32, /// Largest ratio of the top to its room. top: f32, safety: f32, } pub struct Engine { rate: f32, channels: usize, settings: Settings, coefs: Coefs, channel: [Channel; 2], drive: f32, ceiling: f32, bypass: f32, primed: bool, smooth: f32, fade: f32, meter: f32, dry_at: usize, energy_quench: f32, energy_plain: f32, energy_sub: f32, gather: Gather, scope: [[f32; 4]; SCOPE_LEN], scope_at: usize, } impl Engine { pub fn new(rate: f32, channels: usize) -> Result { if !(8000.0..=384000.).contains(&rate) { return Err("unsupported sample rate"); } if !(1..=2).contains(&channels) { return Err("unsupported channel count"); } let settings = Settings::default(); let latency = Oversampler::latency(); Ok(Self { rate, channels, settings, coefs: Coefs::new(rate, &settings), channel: [Channel::new(latency), Channel::new(latency)], drive: 1., ceiling: db_to_gain(settings.ceiling), bypass: 0., primed: false, smooth: coefficient(rate, SMOOTH_MS), fade: coefficient(rate, BYPASS_MS), meter: coefficient(rate * FACTOR as f32, METER_MS), dry_at: 0, energy_quench: 0., energy_plain: 0., energy_sub: 0., gather: Gather::default(), scope: [[0.; 4]; SCOPE_LEN], scope_at: 0, }) } pub fn channels(&self) -> usize { self.channels } pub fn latency_samples(&self) -> u32 { Oversampler::latency() as u32 } pub fn settings(&self) -> Settings { self.settings } pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> { s.validate()?; if s != self.settings || !self.primed { self.coefs = Coefs::new(self.rate, &s); } self.settings = s; if !self.primed { // The first settings after a start take effect at once instead of ramping. self.drive = db_to_gain(s.drive); self.ceiling = db_to_gain(s.ceiling); self.bypass = if s.bypass { 1. } else { 0. }; self.primed = true; } Ok(()) } pub fn reset(&mut self) { for c in &mut self.channel { c.reset(); } self.dry_at = 0; self.energy_quench = 0.; self.energy_plain = 0.; self.energy_sub = 0.; self.gather = Gather::default(); self.scope = [[0.; 4]; SCOPE_LEN]; self.scope_at = 0; self.primed = false; let s = self.settings; let _ = self.set_settings(s); } pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] { let s = self.settings; self.drive += self.smooth * (db_to_gain(s.drive) - self.drive); self.ceiling += self.smooth * (db_to_gain(s.ceiling) - self.ceiling); self.bypass += self.fade * (if s.bypass { 1. } else { 0. } - self.bypass); let (c, k, drive) = (self.ceiling, self.coefs, self.drive); let mut out = [0.; 2]; let mut point = [f32::MAX, f32::MIN, 0., 0.]; let n = self.channel[0].dry.len(); for (ch, &input) in x.iter().enumerate().take(self.channels) { let chan = &mut self.channel[ch]; let driven = input * drive; self.gather.input = self.gather.input.max(driven.abs()); let up = chan.os.up(driven); let mut picked = [0.; FACTOR]; for (j, &v) in up.iter().enumerate() { let low = lr4(&mut chan.low, &k.low, v); let high = lr4(&mut chan.high, &k.high, v); // The sub's held peak decides the top's room. let a = low.abs(); if a >= chan.env { chan.env = a; chan.hold_left = k.hold; } else if chan.hold_left > 0 { chan.hold_left -= 1; } else { chan.env = a.max(chan.env * k.release); } let room = (c - chan.env).max(0.); let top = shape(high, room, k.knee); let reference = low + high; let mut y = reference + chan.error.process(&k.high, top - high); for p in &mut chan.passes { y += p.process(&k.high, y.clamp(-c, c) - y); } // What the passes leave over the ceiling is small; clipping it here, still // oversampled, leaves the safety clip after the downsampler almost idle. let y = y.clamp(-c, c); let plain = shape(reference, c, k.knee); let lq = lr4(&mut chan.meter_quench, &k.low, y - reference); let lp = lr4(&mut chan.meter_plain, &k.low, plain - reference); self.energy_quench += self.meter * (lq * lq - self.energy_quench); self.energy_plain += self.meter * (lp * lp - self.energy_plain); self.energy_sub += self.meter * (low * low - self.energy_sub); self.gather.sub = self.gather.sub.max(a); if high.abs() > room { let ratio = high.abs() / room.max(c * 1e-3); self.gather.top = self.gather.top.max(ratio); } picked[j] = match monitor { Monitor::Quench => y, Monitor::Plain => plain, Monitor::Delta => y - reference, }; if ch == 0 { point[0] = point[0].min(picked[j]); point[1] = point[1].max(picked[j]); point[2] = low; point[3] = room; } } let mut o = chan.os.down(picked); if monitor != Monitor::Delta && o.abs() > c { self.gather.safety = self.gather.safety.max(o.abs() / c); o = o.clamp(-c, c); } let dry = chan.dry[(self.dry_at + 1) % n]; chan.dry[self.dry_at] = input; o = if self.bypass >= 1. - 1e-6 { dry } else { o + self.bypass * (dry - o) }; self.gather.output = self.gather.output.max(o.abs()); out[ch] = o; } self.dry_at = (self.dry_at + 1) % n; self.scope[self.scope_at] = point; self.scope_at = (self.scope_at + 1) % SCOPE_LEN; out } /// Readings since the last call. Never allocates. pub fn take_analyzer(&mut self) -> AnalyzerFrame { let g = std::mem::take(&mut self.gather); let db = |v: f32| if v > 0. { gain_to_db(v) } else { SILENT }; let relative = |e: f32| { if self.energy_sub > SUB_FLOOR { 10. * (e.max(1e-20) / self.energy_sub).log10() } else { f32::NAN } }; let mut f = AnalyzerFrame { input_peak: db(g.input), output_peak: db(g.output), sub_peak: db(g.sub), top_clip: if g.top > 1. { gain_to_db(g.top) } else { 0. }, safety: if g.safety > 1. { gain_to_db(g.safety) } else { 0. }, low_quench: relative(self.energy_quench), low_plain: relative(self.energy_plain), ceiling: self.settings.ceiling, latency: self.latency_samples(), sample_rate: self.rate, ..AnalyzerFrame::default() }; for i in 0..SCOPE { let (mut lo, mut hi) = (f32::MAX, f32::MIN); let mut last = [0.; 4]; for j in 0..SCOPE_STEP { let v = self.scope[(self.scope_at + i * SCOPE_STEP + j) % SCOPE_LEN]; lo = lo.min(v[0]); hi = hi.max(v[1]); last = v; } f.scope_min[i] = if lo <= hi { lo } else { 0. }; f.scope_max[i] = if lo <= hi { hi } else { 0. }; f.scope_sub[i] = last[2]; f.scope_room[i] = last[3]; } f } }