a8be2179c3
Flag each plot bucket with whether the output limiter hit the ceiling (limiter GR > 0.1 dB), carried through ScopeRing as a per-bucket hit field and folded into history columns. The editor draws a short red tick at the top of any hit column (one column wide, so runs merge into segments and a lone hit is a dot); nothing otherwise. Global marker, shown on every channel tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
14 KiB
Rust
358 lines
14 KiB
Rust
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;
|
|
|
|
/// 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 comp in &mut self.comps {
|
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
|
}
|
|
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 gr = [0.0f32; meters::NUM_CHANNELS];
|
|
let mut lim_gr = 0.0f32;
|
|
|
|
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 {
|
|
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next());
|
|
for ch in 0..n {
|
|
band_in[b][ch] *= pre;
|
|
}
|
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
|
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();
|
|
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 = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next());
|
|
for ch in 0..n {
|
|
summed[ch] *= all_pre;
|
|
}
|
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
|
all_set.mix = self.params.all.mix.smoothed.next();
|
|
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();
|
|
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.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);
|