Quench splits the signal with a Linkwitz-Riley crossover and lets the sub pass unclipped. The top is clipped symmetrically at the ceiling minus the sub's held peak, so it does not pump at the sub's rate and the sum stays under the ceiling. What the clip removes is high-passed at the split, so its products stay out of the low band. Two correction passes and an oversampled hard clip hold the ceiling; a safety clip catches the downsampler's ringing. - Controls: Drive, Ceiling, Split, Hold, Soft, Bypass. - Listen to Quench, a plain clipper at the same settings, or the delta. - Editor meter compares how much the low band changed for Quench and for a plain clip, with a scope of the output, the sub and the top's room. - Measured: sub level +0.04 dB against -2.2 dB for a plain clip, low-band products -35 dB against -16 dB, at 0.5-1.2 dB less RMS. - Registered in the catalog, workflows, editor-check, README, chain and development docs, and the website copy.
423 lines
15 KiB
Rust
423 lines
15 KiB
Rust
//! 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<f32>,
|
||
}
|
||
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<Self, &'static str> {
|
||
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
|
||
}
|
||
}
|