feat(quench): add Quench sub-safe clipper
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.
This commit is contained in:
1 parent
77ee4a9078
commit
3d316cbe49
28 files changed
+4397
-17
No files matched your search
@@ -0,0 +1,204 @@
|
||||
//! CLAP audio effect. All DSP state is owned by the processing thread.
|
||||
mod params;
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::{QuenchParams, FLOATS};
|
||||
use quench_core::{AnalyzerFrame, Engine, Monitor};
|
||||
use std::sync::{
|
||||
atomic::{AtomicU8, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
struct Shared {
|
||||
analyzer: ArrayQueue<AnalyzerFrame>,
|
||||
/// The editor's listen choice: 0 Quench, 1 Plain, 2 Delta.
|
||||
monitor: AtomicU8,
|
||||
}
|
||||
fn encode(m: Monitor) -> u8 {
|
||||
match m {
|
||||
Monitor::Quench => 0,
|
||||
Monitor::Plain => 1,
|
||||
Monitor::Delta => 2,
|
||||
}
|
||||
}
|
||||
fn decode(v: u8) -> Monitor {
|
||||
match v {
|
||||
1 => Monitor::Plain,
|
||||
2 => Monitor::Delta,
|
||||
_ => Monitor::Quench,
|
||||
}
|
||||
}
|
||||
struct EditorData {
|
||||
ui: quench_ui::State,
|
||||
gestures: [bool; FLOATS],
|
||||
}
|
||||
pub struct Quench {
|
||||
params: Arc<QuenchParams>,
|
||||
shared: Arc<Shared>,
|
||||
engine: Option<Engine>,
|
||||
publish_count: usize,
|
||||
publish_interval: usize,
|
||||
}
|
||||
impl Default for Quench {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(QuenchParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
analyzer: ArrayQueue::new(2),
|
||||
monitor: AtomicU8::new(0),
|
||||
}),
|
||||
engine: None,
|
||||
publish_count: 0,
|
||||
publish_interval: 1600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Quench {
|
||||
const NAME: &'static str = "Quench";
|
||||
const VENDOR: &'static str = "Bass Foundry";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: quench_ui::State::default(),
|
||||
gestures: [false; FLOATS],
|
||||
},
|
||||
|ctx, data| {
|
||||
data.ui.monitor = Monitor::Quench;
|
||||
quench_ui::style(ctx);
|
||||
},
|
||||
move |ctx, setter, data| {
|
||||
while let Some(frame) = shared.analyzer.pop() {
|
||||
data.ui.accept_frame(frame);
|
||||
}
|
||||
let before = params.settings();
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("quench-editor")
|
||||
.min_size(egui::vec2(
|
||||
quench_ui::WIDTH as f32 - 80.,
|
||||
quench_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
quench_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// The monitor is a listen choice only: never saved and never left on after
|
||||
// the editor closes.
|
||||
shared
|
||||
.monitor
|
||||
.store(encode(data.ui.monitor), Ordering::Release);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(40));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
let Ok(mut engine) = Engine::new(
|
||||
config.sample_rate,
|
||||
layout.main_input_channels.map_or(0, |v| v.get() as usize),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if engine.set_settings(self.params.settings()).is_err() {
|
||||
return false;
|
||||
}
|
||||
context.set_latency_samples(engine.latency_samples());
|
||||
self.shared.monitor.store(0, Ordering::Release);
|
||||
self.publish_interval = (config.sample_rate / 30.) as usize;
|
||||
self.engine = Some(engine);
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if let Some(e) = self.engine.as_mut() {
|
||||
e.reset();
|
||||
}
|
||||
self.publish_count = 0;
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
_: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(engine) = self.engine.as_mut() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let _ = engine.set_settings(self.params.settings());
|
||||
let monitor = if self.params.editor.is_open() {
|
||||
decode(self.shared.monitor.load(Ordering::Acquire))
|
||||
} else {
|
||||
Monitor::Quench
|
||||
};
|
||||
for mut sample in buffer.iter_samples() {
|
||||
let x = [
|
||||
*sample.get_mut(0).unwrap(),
|
||||
if engine.channels() == 2 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
];
|
||||
let out = engine.process_sample(x, monitor);
|
||||
for (c, value) in sample.into_iter().enumerate() {
|
||||
*value = out[c];
|
||||
}
|
||||
self.publish_count += 1;
|
||||
if self.publish_count >= self.publish_interval {
|
||||
let _ = self.shared.analyzer.force_push(engine.take_analyzer());
|
||||
self.publish_count = 0;
|
||||
}
|
||||
}
|
||||
// The oversampling filters must drain after input stops.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Quench {
|
||||
const CLAP_ID: &'static str = "io.narl.bass-foundry.quench";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Clips the top as hard as you like while the sub passes clean");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Distortion,
|
||||
ClapFeature::Limiter,
|
||||
ClapFeature::Mastering,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Quench);
|
||||
@@ -0,0 +1,108 @@
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use quench_core::{Settings, CEILING_MIN, DRIVE_MAX, HOLD_MAX, HOLD_MIN, SPLIT_MAX, SPLIT_MIN};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
|
||||
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
|
||||
}
|
||||
fn skewed(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
|
||||
FloatParam::new(
|
||||
name,
|
||||
v,
|
||||
FloatRange::Skewed {
|
||||
min,
|
||||
max,
|
||||
factor: FloatRange::skew_factor(-1.),
|
||||
},
|
||||
)
|
||||
.with_unit(unit)
|
||||
}
|
||||
/// Continuous controls, in the order of the gesture slots.
|
||||
pub const FLOATS: usize = 5;
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct QuenchParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[id = "drive"]
|
||||
pub drive: FloatParam,
|
||||
#[id = "ceiling"]
|
||||
pub ceiling: FloatParam,
|
||||
#[id = "split"]
|
||||
pub split: FloatParam,
|
||||
#[id = "hold"]
|
||||
pub hold: FloatParam,
|
||||
#[id = "soft"]
|
||||
pub soft: FloatParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for QuenchParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(quench_ui::WIDTH, quench_ui::HEIGHT),
|
||||
drive: linear("Drive", d.drive, 0., DRIVE_MAX, " dB"),
|
||||
ceiling: linear("Ceiling", d.ceiling, CEILING_MIN, 0., " dB"),
|
||||
split: skewed("Split", d.split, SPLIT_MIN, SPLIT_MAX, " Hz"),
|
||||
hold: skewed("Hold", d.hold, HOLD_MIN, HOLD_MAX, " ms"),
|
||||
soft: linear("Soft", d.soft, 0., 100., " %"),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl QuenchParams {
|
||||
pub fn settings(&self) -> Settings {
|
||||
Settings {
|
||||
drive: self.drive.value(),
|
||||
ceiling: self.ceiling.value(),
|
||||
split: self.split.value(),
|
||||
hold: self.hold.value(),
|
||||
soft: self.soft.value(),
|
||||
bypass: self.bypass.value(),
|
||||
}
|
||||
}
|
||||
fn floats(&self) -> [&FloatParam; FLOATS] {
|
||||
[
|
||||
&self.drive,
|
||||
&self.ceiling,
|
||||
&self.split,
|
||||
&self.hold,
|
||||
&self.soft,
|
||||
]
|
||||
}
|
||||
fn values(s: &Settings) -> [f32; FLOATS] {
|
||||
[s.drive, s.ceiling, s.split, s.hold, s.soft]
|
||||
}
|
||||
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
|
||||
/// while the pointer is down; switches are set in a single gesture.
|
||||
pub fn apply(
|
||||
&self,
|
||||
setter: &ParamSetter,
|
||||
old: &Settings,
|
||||
new: &Settings,
|
||||
dragging: bool,
|
||||
open: &mut [bool; FLOATS],
|
||||
) {
|
||||
let pairs = Self::values(old).into_iter().zip(Self::values(new));
|
||||
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
|
||||
if a != b {
|
||||
if !open[i] {
|
||||
open[i] = true;
|
||||
setter.begin_set_parameter(p);
|
||||
}
|
||||
setter.set_parameter(p, b);
|
||||
}
|
||||
if !dragging && open[i] {
|
||||
setter.end_set_parameter(p);
|
||||
open[i] = false;
|
||||
}
|
||||
}
|
||||
if new.bypass != old.bypass {
|
||||
setter.begin_set_parameter(&self.bypass);
|
||||
setter.set_parameter(&self.bypass, new.bypass);
|
||||
setter.end_set_parameter(&self.bypass);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user