Stage 2: full-band compressor with constant-latency look-ahead
Implements the single full-band feed-forward compressor (the engine that will be reused per band + for the 'All' channel). Design follows Giannoulis et al. 2012: log-domain gain computer with a quadratic soft knee feeding a smooth decoupled peak detector for attack/release ballistics. Stereo-linked peak detection. Look-ahead uses a fixed audio delay with a constant reported latency (set once in initialize); the knob only moves the detector tap within that delay. This avoids renegotiating latency from process(), which crashed FL Studio when the look-ahead was adjusted during playback. - src/dsp/compressor.rs: Compressor + CompressorSettings, RT-safe (no alloc in process; buffers sized in prepare; envelope denormals flushed in-code) - src/lib.rs: nested CompressorParams (threshold/ratio/knee/attack/release/makeup/ bypass) + global look-ahead; egui ParamSlider grid; latency reported once - 5 unit tests (static curve, knee continuity, steady-state convergence, constant latency); Cargo.toml lib crate-type added so tests link - README: 'All' channel architecture already documented; look-ahead spec updated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+192
-33
@@ -2,31 +2,59 @@ use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Codename 206 — landmark build.
|
||||
mod dsp;
|
||||
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||
|
||||
/// Codename 206 — Stage 2: a single full-band compressor with look-ahead.
|
||||
///
|
||||
/// This is intentionally tiny: a single full-band gain parameter exposed through one
|
||||
/// egui slider. It is the foundation the multiband compressor/limiter (see README.md)
|
||||
/// will be built on top of. The signal path is currently just `sample *= gain`.
|
||||
/// The `CompressorParams` struct is `#[nested]` so the exact same controls + DSP can be
|
||||
/// reused for the three bands and the 'All' aggregate channel in later stages.
|
||||
struct Codename206 {
|
||||
params: Arc<Codename206Params>,
|
||||
sample_rate: f32,
|
||||
comp: Compressor,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
struct Codename206Params {
|
||||
/// The egui editor's window state (size). Persisted with the parameter state so a
|
||||
/// resized window is restored on reload.
|
||||
#[persist = "editor-state"]
|
||||
editor_state: Arc<EguiState>,
|
||||
|
||||
/// Full-band output gain. Stored as a linear gain multiplier but displayed in dB.
|
||||
#[id = "gain"]
|
||||
pub gain: FloatParam,
|
||||
/// Look-ahead time: how far ahead the detector reads so gain reduction can lead
|
||||
/// transients. The reported latency is constant (the max look-ahead) regardless of this
|
||||
/// value, so it is safe to adjust during playback.
|
||||
#[id = "lookahead"]
|
||||
pub look_ahead_ms: FloatParam,
|
||||
|
||||
/// The full-band compressor controls (reused per band + 'All' channel later).
|
||||
#[nested(group = "Compressor")]
|
||||
pub comp: CompressorParams,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
struct CompressorParams {
|
||||
#[id = "thresh"]
|
||||
pub threshold_db: FloatParam,
|
||||
#[id = "ratio"]
|
||||
pub ratio: FloatParam,
|
||||
#[id = "knee"]
|
||||
pub knee_db: FloatParam,
|
||||
#[id = "attack"]
|
||||
pub attack_ms: FloatParam,
|
||||
#[id = "release"]
|
||||
pub release_ms: FloatParam,
|
||||
#[id = "makeup"]
|
||||
pub makeup_db: FloatParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
|
||||
impl Default for Codename206 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(Codename206Params::default()),
|
||||
sample_rate: 48_000.0,
|
||||
comp: Compressor::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,28 +62,88 @@ impl Default for Codename206 {
|
||||
impl Default for Codename206Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor_state: EguiState::from_size(300, 140),
|
||||
editor_state: EguiState::from_size(360, 360),
|
||||
|
||||
// Stored as linear gain, displayed/edited in dB. Skewed so the slider feels
|
||||
// linear in decibels across the -30..+30 dB range.
|
||||
gain: FloatParam::new(
|
||||
"Gain",
|
||||
util::db_to_gain(0.0),
|
||||
FloatRange::Skewed {
|
||||
min: util::db_to_gain(-30.0),
|
||||
max: util::db_to_gain(30.0),
|
||||
factor: FloatRange::gain_skew_factor(-30.0, 30.0),
|
||||
},
|
||||
look_ahead_ms: FloatParam::new(
|
||||
"Look-ahead",
|
||||
2.0,
|
||||
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||
)
|
||||
// Linear-gain storage needs logarithmic smoothing to avoid zipper noise.
|
||||
.with_smoother(SmoothingStyle::Logarithmic(50.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_gain_to_db(2))
|
||||
.with_string_to_value(formatters::s2v_f32_gain_to_db()),
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
comp: CompressorParams::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompressorParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threshold_db: FloatParam::new(
|
||||
"Threshold",
|
||||
-18.0,
|
||||
FloatRange::Linear { min: -60.0, max: 0.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
ratio: FloatParam::new(
|
||||
"Ratio",
|
||||
2.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
||||
.with_string_to_value(Arc::new(|s| {
|
||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||
})),
|
||||
|
||||
knee_db: FloatParam::new(
|
||||
"Knee",
|
||||
6.0,
|
||||
FloatRange::Linear { min: 0.0, max: 24.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
attack_ms: FloatParam::new(
|
||||
"Attack",
|
||||
10.0,
|
||||
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
release_ms: FloatParam::new(
|
||||
"Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
makeup_db: FloatParam::new(
|
||||
"Makeup",
|
||||
0.0,
|
||||
FloatRange::Linear { min: -12.0, max: 24.0 },
|
||||
)
|
||||
// Applied per sample, so smooth it to avoid zipper noise.
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
bypass: BoolParam::new("Bypass", false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Codename206 {
|
||||
/// Look-ahead in samples for the current parameter value and sample rate.
|
||||
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";
|
||||
@@ -97,25 +185,96 @@ impl Plugin for Codename206 {
|
||||
|_, _| {},
|
||||
move |egui_ctx, setter, _state| {
|
||||
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
||||
ui.heading("Codename 206");
|
||||
ui.heading(Self::NAME);
|
||||
ui.separator();
|
||||
ui.label("Gain");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.gain, setter));
|
||||
egui::Grid::new("params").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Threshold");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.threshold_db, setter));
|
||||
ui.end_row();
|
||||
ui.label("Ratio");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.ratio, setter));
|
||||
ui.end_row();
|
||||
ui.label("Knee");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.knee_db, setter));
|
||||
ui.end_row();
|
||||
ui.label("Attack");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.attack_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Release");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.release_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Makeup");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.makeup_db, setter));
|
||||
ui.end_row();
|
||||
ui.label("Look-ahead");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||
ui.end_row();
|
||||
ui.label("Bypass");
|
||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.bypass, setter));
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
self.comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||
|
||||
// Latency is constant (the fixed audio delay) and reported exactly once, so changing
|
||||
// the look-ahead knob during playback never renegotiates latency with the host.
|
||||
context.set_latency_samples(self.comp.latency());
|
||||
true
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.comp.reset();
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_aux: &mut AuxiliaryBuffers,
|
||||
_context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
for channel_samples in buffer.iter_samples() {
|
||||
let gain = self.params.gain.smoothed.next();
|
||||
for sample in channel_samples {
|
||||
*sample *= gain;
|
||||
// Look-ahead is a detector-tap offset within a fixed delay; it never changes latency.
|
||||
let lookahead = self.lookahead_samples();
|
||||
|
||||
// Block-rate compressor settings (these change slowly; makeup is smoothed per sample).
|
||||
let c = &self.params.comp;
|
||||
let mut set = CompressorSettings {
|
||||
threshold_db: c.threshold_db.value(),
|
||||
ratio: c.ratio.value(),
|
||||
knee_db: c.knee_db.value(),
|
||||
attack_coef: Compressor::time_to_coef(c.attack_ms.value(), self.sample_rate),
|
||||
release_coef: Compressor::time_to_coef(c.release_ms.value(), self.sample_rate),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: lookahead,
|
||||
bypass: c.bypass.value(),
|
||||
};
|
||||
|
||||
let mut in_frame = [0.0f32; 2];
|
||||
let mut out_frame = [0.0f32; 2];
|
||||
for mut frame in buffer.iter_samples() {
|
||||
set.makeup_db = c.makeup_db.smoothed.next();
|
||||
|
||||
let n = frame.len().min(2);
|
||||
for ch in 0..n {
|
||||
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||
}
|
||||
self.comp.process(&in_frame[..n], &mut out_frame[..n], &set);
|
||||
for ch in 0..n {
|
||||
*frame.get_mut(ch).unwrap() = out_frame[ch];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +285,7 @@ impl Plugin for Codename206 {
|
||||
impl ClapPlugin for Codename206 {
|
||||
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Multiband compressor/limiter (landmark: full-band gain)");
|
||||
Some("Multiband compressor/limiter (stage 2: full-band compressor)");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
|
||||
Reference in New Issue
Block a user