Files
codename-206/src/lib.rs
T
mikkeli 466ab478c9 chore: relicense to GPL-3.0-or-later and credit the ISC upstream
Commit 057d630 switched this repo from GPL-3.0 to Apache-2.0, which the
build contradicts: src/lib.rs calls nih_export_vst3! unconditionally and
nih_plug is taken with default features, so every build links vst3-sys —
GPLv3-or-later by its own authors' choice. deploy.ps1 then installs that
binary. The declared licence and the artifact's actual terms disagreed,
and the original GPL-3.0 was correct.

Given the intent to distribute non-profit and open-source, GPL costs
nothing here, so this restores it rather than dropping VST3 to keep a
permissive licence.

Also adds the ISC notice owed to Robbert van der Helm: the plugin
scaffolding derives from the NIH-plug cookiecutter template and example
plugins, whose one condition is that the copyright and permission notice
travel with all copies. The README credited the template under Reference
Material, which is courtesy, not compliance. ISC is GPL-compatible, so the
notice is retained in NOTICE rather than superseded.

Corrects the README's claim that Steinberg's SDK licence must be accepted
before distributing. No SDK source is vendored or redistributed, so there
is nothing to accept at build time. The binding constraint is vst3-sys's
GPLv3; Steinberg's trademark in "VST" is a separate regime that no source
licence resolves.

Copyright in the Irene <irene@novoyuuparosk.org> commits is held by the
repository owner (an agent identity), so no third-party agreement was
needed for this relicense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-08-31 22:32:05 +09:00

429 lines
18 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-or-later
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: &params::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);