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, 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, /// 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 { self.params.clone() } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { editor::create(self.params.clone(), self.meters.clone()) } fn initialize( &mut self, audio_io_layout: &AudioIOLayout, buffer_config: &BufferConfig, context: &mut impl InitContext, ) -> 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, ) -> 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);