570dd65fa8
5 ms was too short to be an RMS average at all. Squaring the detector signal
puts a component at twice the fundamental, and a one-pole that does not span a
cycle passes it straight through, so the detector ripples and the gain
intermodulates with the bass. Measured peak-to-peak detector ripple at 5 ms:
30 Hz 50 Hz 80 Hz 120 Hz 200 Hz 500 Hz 1 kHz
4.42 2.72 1.72 1.15 0.69 0.28 0.14 dB
That lands squarely on the low band, whose crossover reaches down to 30 Hz,
and attack is allowed to go to 0 ms so nothing downstream is guaranteed to
mask it. Above ~500 Hz the same window was so short that RMS mode barely
differed from peak, making the mode switch do less than it appeared to.
The fix is per-band rather than one longer constant, because the required
window is set by how low the band reaches and a multiband already knows that.
Cost is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so
each band gets only what it needs:
LOW 50 ms 30 Hz ripple 4.42 -> 0.46 dB; slow is wanted down here anyway
MID 20 ms body without dulling the band carrying most transients
HIGH 10 ms nothing up there ripples, so stay responsive
ALL 35 ms full-band, so it has to cover the low content too
RMS_WINDOW_MS moves from a compressor constant to a table in lib.rs indexed by
the band consts, and Compressor::prepare takes the window - the compressor has
no business knowing which band it is.
rms_window_suppresses_low_frequency_ripple pins the property: the LOW window
holds 30 Hz under 1 dB, a 5 ms window is far worse on the same signal (so the
bound cannot pass for the wrong reason), ripple falls monotonically with window
length, and the HIGH band's short window stays clean at 5 kHz.
This changes how the plugin sounds in RMS mode - needs an ear, not just tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
429 lines
18 KiB
Rust
429 lines
18 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
use nih_plug::prelude::*;
|
|
use std::sync::Arc;
|
|
|
|
mod dsp;
|
|
mod editor;
|
|
mod meters;
|
|
mod params;
|
|
|
|
use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
|
|
use dsp::crossover::Crossover;
|
|
use dsp::limiter::Limiter;
|
|
use meters::Meters;
|
|
use params::{build_settings, Codename206Params};
|
|
|
|
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
|
|
/// gain-gui example feel.)
|
|
const METER_DECAY_MS: f64 = 150.0;
|
|
|
|
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
|
const LOW: usize = 0;
|
|
const MID: usize = 1;
|
|
const HIGH: usize = 2;
|
|
const ALL: usize = 3;
|
|
|
|
/// RMS detector time constants, one per channel, indexed by the consts above.
|
|
///
|
|
/// Squaring the detector signal puts a component at twice the fundamental, so a one-pole that
|
|
/// does not span a cycle leaves the detector rippling and the gain intermodulating with the
|
|
/// bass. Measured peak-to-peak detector ripple at a 5 ms window was 4.4 dB at 30 Hz and 2.7 dB
|
|
/// at 50 Hz - squarely in the low band, whose crossover reaches down to 30 Hz. The cost of a
|
|
/// longer window is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so each
|
|
/// band gets only as much as its frequency range actually needs:
|
|
///
|
|
/// * LOW - 50 ms: 30 Hz ripple drops to 0.46 dB, and slow is the wanted behaviour down here.
|
|
/// * MID - 20 ms: enough for body without dulling the band that carries most transients.
|
|
/// * HIGH - 10 ms: nothing up here ripples, so keep it responsive.
|
|
/// * ALL - 35 ms: full-band, so it must cover the low content too.
|
|
const RMS_WINDOW_MS: [f32; 4] = [50.0, 20.0, 10.0, 35.0];
|
|
|
|
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
|
///
|
|
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
|
/// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
|
|
/// driven by the 'All' channel (the crossover sums flat).
|
|
struct Codename206 {
|
|
params: Arc<Codename206Params>,
|
|
sample_rate: f32,
|
|
crossover: Crossover,
|
|
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
|
|
comps: [Compressor; 4],
|
|
/// Output brickwall limiter (final stage).
|
|
limiter: Limiter,
|
|
/// Lock-free meter state shared with the editor.
|
|
meters: Arc<Meters>,
|
|
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to
|
|
/// the block length when applied once per block in `process`).
|
|
meter_decay_weight: f32,
|
|
|
|
/// Per-channel max accumulators for the plot bucket currently being built (in/out linear, GR
|
|
/// dB). Persist across blocks since a bucket spans many samples.
|
|
scope_in: [f32; 4],
|
|
scope_out: [f32; 4],
|
|
scope_gr: [f32; 4],
|
|
/// Max output-limiter gain reduction seen in the current bucket (for the ceiling-hit marker).
|
|
scope_hit: f32,
|
|
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
|
|
scope_samples: usize,
|
|
scope_bucket_len: usize,
|
|
}
|
|
|
|
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
|
|
const CEILING_HIT_GR_DB: f32 = 0.1;
|
|
|
|
impl Default for Codename206 {
|
|
fn default() -> Self {
|
|
Self {
|
|
params: Arc::new(Codename206Params::default()),
|
|
sample_rate: 48_000.0,
|
|
crossover: Crossover::new(),
|
|
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
|
limiter: Limiter::new(),
|
|
meters: Arc::new(Meters::default()),
|
|
meter_decay_weight: 1.0,
|
|
scope_in: [0.0; 4],
|
|
scope_out: [0.0; 4],
|
|
scope_gr: [0.0; 4],
|
|
scope_hit: 0.0,
|
|
scope_samples: 0,
|
|
scope_bucket_len: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Codename206 {
|
|
fn lookahead_samples(&self) -> usize {
|
|
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
|
}
|
|
}
|
|
|
|
impl Plugin for Codename206 {
|
|
const NAME: &'static str = "206 prototype";
|
|
const VENDOR: &'static str = "Novoyuuparosk";
|
|
const URL: &'static str = env!("CARGO_PKG_HOMEPAGE");
|
|
const EMAIL: &'static str = "mikkeli@novoyuuparosk.org";
|
|
|
|
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 MIDI_INPUT: MidiConfig = MidiConfig::None;
|
|
const MIDI_OUTPUT: MidiConfig = MidiConfig::None;
|
|
|
|
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
|
|
|
|
type SysExMessage = ();
|
|
type BackgroundTask = ();
|
|
|
|
fn params(&self) -> Arc<dyn Params> {
|
|
self.params.clone()
|
|
}
|
|
|
|
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
|
editor::create(self.params.clone(), self.meters.clone())
|
|
}
|
|
|
|
fn initialize(
|
|
&mut self,
|
|
audio_io_layout: &AudioIOLayout,
|
|
buffer_config: &BufferConfig,
|
|
context: &mut impl InitContext<Self>,
|
|
) -> bool {
|
|
self.sample_rate = buffer_config.sample_rate;
|
|
let channels = audio_io_layout
|
|
.main_output_channels
|
|
.map(NonZeroU32::get)
|
|
.unwrap_or(2) as usize;
|
|
|
|
// Per-block decay so the meters fall ~12 dB over METER_DECAY_MS of silence.
|
|
self.meter_decay_weight =
|
|
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
|
|
|
|
// Plot bucket length: emit a scope bucket every ~1/BUCKET_HZ seconds.
|
|
self.scope_bucket_len =
|
|
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
|
|
|
|
for (i, comp) in self.comps.iter_mut().enumerate() {
|
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS, RMS_WINDOW_MS[i]);
|
|
}
|
|
self.crossover.prepare(channels);
|
|
self.crossover.update(
|
|
self.sample_rate,
|
|
self.params.crossover_low_hz.value(),
|
|
self.params.crossover_high_hz.value(),
|
|
);
|
|
self.limiter.prepare(self.sample_rate, channels);
|
|
|
|
// Three series stages each with a fixed look-ahead delay: the bands, the 'All' channel,
|
|
// and the output limiter. Reported once as a constant; see the compressor look-ahead note.
|
|
let total_latency =
|
|
self.comps[LOW].latency() + self.comps[ALL].latency() + self.limiter.latency();
|
|
context.set_latency_samples(total_latency);
|
|
true
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.crossover.reset();
|
|
for comp in &mut self.comps {
|
|
comp.reset();
|
|
}
|
|
self.limiter.reset();
|
|
// Transport restart / sample-rate change: drop stale meter values to silence and discard
|
|
// the in-flight plot bucket.
|
|
self.meters.clear();
|
|
self.scope_in = [0.0; 4];
|
|
self.scope_out = [0.0; 4];
|
|
self.scope_gr = [0.0; 4];
|
|
self.scope_hit = 0.0;
|
|
self.scope_samples = 0;
|
|
}
|
|
|
|
fn process(
|
|
&mut self,
|
|
buffer: &mut Buffer,
|
|
_aux: &mut AuxiliaryBuffers,
|
|
context: &mut impl ProcessContext<Self>,
|
|
) -> ProcessStatus {
|
|
let lookahead = self.lookahead_samples();
|
|
|
|
// Crossover coefficients track the frequency params (recomputed per block — cheap).
|
|
self.crossover.update(
|
|
self.sample_rate,
|
|
self.params.crossover_low_hz.value(),
|
|
self.params.crossover_high_hz.value(),
|
|
);
|
|
|
|
// Block-rate settings for the three bands + the 'All' channel.
|
|
let band_params = [&self.params.low, &self.params.mid, &self.params.high];
|
|
let mut band_set = [
|
|
build_settings(&self.params.low, lookahead, self.sample_rate),
|
|
build_settings(&self.params.mid, lookahead, self.sample_rate),
|
|
build_settings(&self.params.high, lookahead, self.sample_rate),
|
|
];
|
|
let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate);
|
|
|
|
// Output limiter settings (block-rate).
|
|
let ceiling = util::db_to_gain(self.params.output_ceiling_db.value());
|
|
let limiter_release =
|
|
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
|
|
|
|
// Only do the (cheap) metering work when the editor is actually open.
|
|
let metering = self.params.editor_state.is_open();
|
|
// The host keeps calling process() with silence while stopped/paused (FL does), so the
|
|
// scope is gated on the transport actually playing — otherwise it would scroll silence.
|
|
let playing = context.transport().playing;
|
|
let num_samples = buffer.samples();
|
|
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
|
|
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
|
|
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input level (detector / gain-curve x)
|
|
let mut gr = [0.0f32; meters::NUM_CHANNELS];
|
|
let mut lim_gr = 0.0f32;
|
|
|
|
// Params only change on block boundaries here (we do not split blocks on events), so a
|
|
// smoother that is idle now stays idle for this whole block. When every smoother feeding
|
|
// a channel is idle - the overwhelming common case, i.e. nobody is touching a knob -
|
|
// its per-sample values are constants, so lift them out of the sample loop entirely.
|
|
// That removes 5 Smoother::next() calls and a db_to_gain per channel per sample.
|
|
let chan_static = |p: ¶ms::CompressorParams| {
|
|
!p.pre_gain_db.smoothed.is_smoothing()
|
|
&& !p.makeup_db.smoothed.is_smoothing()
|
|
&& !p.mix.smoothed.is_smoothing()
|
|
&& !p.low_slope.smoothed.is_smoothing()
|
|
&& !p.low_curve.smoothed.is_smoothing()
|
|
};
|
|
let band_static = [
|
|
chan_static(&self.params.low),
|
|
chan_static(&self.params.mid),
|
|
chan_static(&self.params.high),
|
|
];
|
|
let all_static = chan_static(&self.params.all);
|
|
let mut band_pre = [1.0f32; 3];
|
|
for b in 0..3 {
|
|
if band_static[b] {
|
|
band_pre[b] = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
|
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
|
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
|
|
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
|
|
}
|
|
}
|
|
let mut all_pre_static = 1.0f32;
|
|
if all_static {
|
|
all_pre_static = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
|
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
|
all_set.mix = self.params.all.mix.smoothed.next();
|
|
all_set.low_slope = self.params.all.low_slope.smoothed.next();
|
|
all_set.low_curve = self.params.all.low_curve.smoothed.next();
|
|
}
|
|
|
|
let mut in_frame = [0.0f32; 2];
|
|
let mut band_in = [[0.0f32; 2]; 3];
|
|
let mut band_out = [[0.0f32; 2]; 3];
|
|
let mut summed = [0.0f32; 2];
|
|
let mut out_frame = [0.0f32; 2];
|
|
let mut lim_frame = [0.0f32; 2];
|
|
|
|
for mut frame in buffer.iter_samples() {
|
|
let n = frame.len().min(2);
|
|
let r = (n - 1).min(1); // right-channel index (== left when mono)
|
|
for ch in 0..n {
|
|
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
|
}
|
|
|
|
// Split each channel into low/mid/high.
|
|
for ch in 0..n {
|
|
let [lo, mid, hi] = self.crossover.split(ch, in_frame[ch]);
|
|
band_in[LOW][ch] = lo;
|
|
band_in[MID][ch] = mid;
|
|
band_in[HIGH][ch] = hi;
|
|
}
|
|
|
|
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
|
|
summed[..n].fill(0.0);
|
|
for b in 0..3 {
|
|
// `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
|
|
// never reaches, in exchange for a cheaper exp() instead of a general powf().
|
|
let pre = if band_static[b] {
|
|
band_pre[b]
|
|
} else {
|
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
|
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
|
|
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
|
|
util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next())
|
|
};
|
|
for ch in 0..n {
|
|
band_in[b][ch] *= pre;
|
|
}
|
|
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
|
for ch in 0..n {
|
|
summed[ch] += band_out[b][ch];
|
|
}
|
|
if metering {
|
|
let in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
|
|
let out_l = band_out[b][0].abs();
|
|
let out_r = band_out[b][r].abs();
|
|
// Wet gain reduction (what the comp computes), independent of the mix.
|
|
let g = self.comps[b].gain_reduction_db();
|
|
inp[b] = inp[b].max(in_mono);
|
|
lvl_l[b] = lvl_l[b].max(out_l);
|
|
lvl_r[b] = lvl_r[b].max(out_r);
|
|
gr[b] = gr[b].max(g);
|
|
if playing {
|
|
self.scope_in[b] = self.scope_in[b].max(in_mono);
|
|
self.scope_out[b] = self.scope_out[b].max(out_l.max(out_r));
|
|
self.scope_gr[b] = self.scope_gr[b].max(g);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 'All' aggregate channel over the summed bands (driven before its compressor).
|
|
let all_pre = if all_static {
|
|
all_pre_static
|
|
} else {
|
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
|
all_set.mix = self.params.all.mix.smoothed.next();
|
|
all_set.low_slope = self.params.all.low_slope.smoothed.next();
|
|
all_set.low_curve = self.params.all.low_curve.smoothed.next();
|
|
util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next())
|
|
};
|
|
for ch in 0..n {
|
|
summed[ch] *= all_pre;
|
|
}
|
|
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
|
|
|
// Output brickwall limiter.
|
|
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
|
|
|
|
if metering {
|
|
let in_mono = summed[0].abs().max(summed[r].abs());
|
|
let out_l = out_frame[0].abs();
|
|
let out_r = out_frame[r].abs();
|
|
let g = self.comps[ALL].gain_reduction_db();
|
|
inp[ALL] = inp[ALL].max(in_mono);
|
|
lvl_l[ALL] = lvl_l[ALL].max(out_l);
|
|
lvl_r[ALL] = lvl_r[ALL].max(out_r);
|
|
gr[ALL] = gr[ALL].max(g);
|
|
lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
|
|
|
|
// Only advance the scope while the transport is playing, so it freezes (rather than
|
|
// scrolling silence) when the host is paused/stopped but still calling process().
|
|
if playing {
|
|
self.scope_in[ALL] = self.scope_in[ALL].max(in_mono);
|
|
self.scope_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r));
|
|
self.scope_gr[ALL] = self.scope_gr[ALL].max(g);
|
|
self.scope_hit = self.scope_hit.max(self.limiter.gain_reduction_db());
|
|
|
|
// Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ).
|
|
self.scope_samples += 1;
|
|
if self.scope_samples >= self.scope_bucket_len {
|
|
let hit = if self.scope_hit > CEILING_HIT_GR_DB { 1.0 } else { 0.0 };
|
|
self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr, hit);
|
|
self.scope_in = [0.0; meters::NUM_CHANNELS];
|
|
self.scope_out = [0.0; meters::NUM_CHANNELS];
|
|
self.scope_gr = [0.0; meters::NUM_CHANNELS];
|
|
self.scope_hit = 0.0;
|
|
self.scope_samples = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
for ch in 0..n {
|
|
*frame.get_mut(ch).unwrap() = lim_frame[ch];
|
|
}
|
|
}
|
|
|
|
// Publish one decimated value per meter for this block. The decay weight is per-sample,
|
|
// so raise it to the block length to keep the fall time constant independent of buffer size
|
|
// (we apply it once per block, not once per sample).
|
|
if metering {
|
|
let w = self.meter_decay_weight.powi(num_samples as i32);
|
|
for i in 0..meters::NUM_CHANNELS {
|
|
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w);
|
|
meters::decay_store(&self.meters.level_r[i], lvl_r[i], w);
|
|
meters::decay_store(&self.meters.input_level[i], inp[i], w);
|
|
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
|
|
}
|
|
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
|
|
}
|
|
|
|
ProcessStatus::Normal
|
|
}
|
|
}
|
|
|
|
impl ClapPlugin for Codename206 {
|
|
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
|
|
const CLAP_DESCRIPTION: Option<&'static str> =
|
|
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
|
|
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
|
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
|
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
|
ClapFeature::AudioEffect,
|
|
ClapFeature::Stereo,
|
|
ClapFeature::Mono,
|
|
ClapFeature::Compressor,
|
|
ClapFeature::Limiter,
|
|
];
|
|
}
|
|
|
|
impl Vst3Plugin for Codename206 {
|
|
const VST3_CLASS_ID: [u8; 16] = *b"Codename206Maxi!";
|
|
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
|
|
&[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics];
|
|
}
|
|
|
|
nih_export_clap!(Codename206);
|
|
nih_export_vst3!(Codename206);
|