From 8c550da92ee5f9329ca823a306ab68e298e22762 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 21 Jun 2026 14:57:18 +0900 Subject: [PATCH] refactor: split lib.rs into params and editor modules Move parameter structs, defaults, and build_settings into src/params.rs; move the egui editor into src/editor.rs. lib.rs now holds only the plugin shell, DSP wiring, and process(). No behavior change (16/16 tests pass). Co-Authored-By: Claude Opus 4.8 --- src/dsp/mod.rs | 6 +- src/editor.rs | 83 ++++++++++++++++ src/lib.rs | 259 ++----------------------------------------------- src/params.rs | 199 +++++++++++++++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 256 deletions(-) create mode 100644 src/editor.rs create mode 100644 src/params.rs diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index 22e1806..fb03e53 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -1,8 +1,8 @@ //! DSP building blocks for Codename 206. //! -//! Stage 2 introduces the full-band compressor (also the engine that will be reused -//! per band and for the 'All' aggregate channel — see README.md). Later stages add the -//! crossover filterbank, output limiter, and oversampler alongside it. +//! The signal chain: a `crossover` filterbank splits into bands, each band (plus the summed +//! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector) +//! is the final stage. `biquad` is the shared filter primitive the crossover is built from. pub mod biquad; pub mod compressor; diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..37c1735 --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,83 @@ +//! egui editor. +//! +//! Placeholder layout for now — Stage 6 replaces it with meters, gain-reduction displays, a +//! gain-curve view and draggable crossover handles. Built to stay usable meanwhile: a resizable +//! window with a vertical scroll area so every control is reachable at any window size, global +//! controls in a label|slider grid, and the four channels (low/mid/high/all) side by side. + +use nih_plug::prelude::*; +use nih_plug_egui::{ + create_egui_editor, + egui::{self, Vec2}, + resizable_window::ResizableWindow, + widgets, +}; +use std::sync::Arc; + +use crate::params::{Codename206Params, CompressorParams}; +use crate::Codename206; + +/// Build the plugin editor over a shared handle to the params. +pub(crate) fn create(params: Arc) -> Option> { + let egui_state = params.editor_state.clone(); + create_egui_editor( + params.editor_state.clone(), + (), + |_, _| {}, + move |egui_ctx, setter, _state| { + // One column of controls for a single compressor channel. + let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| { + ui.strong(title); + ui.add(widgets::ParamSlider::for_param(&p.detection, setter)); + ui.label("Threshold"); + ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter)); + ui.label("Ratio"); + ui.add(widgets::ParamSlider::for_param(&p.ratio, setter)); + ui.label("Knee"); + ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter)); + ui.label("Attack"); + ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter)); + ui.label("Release"); + ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter)); + ui.label("Makeup"); + ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter)); + ui.add(widgets::ParamSlider::for_param(&p.bypass, setter)); + }; + + // Resizable window; vertical scroll so every control stays reachable even when the + // window is small. (Placeholder layout — Stage 6 will replace it.) + ResizableWindow::new("editor") + .min_size(Vec2::new(480.0, 320.0)) + .show(egui_ctx, egui_state.as_ref(), |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.heading(Codename206::NAME); + // Global controls stacked vertically so they never overflow sideways. + egui::Grid::new("globals").num_columns(2).show(ui, |ui| { + ui.label("Xover Lo/Mid"); + ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter)); + ui.end_row(); + ui.label("Xover Mid/Hi"); + ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter)); + ui.end_row(); + ui.label("Look-ahead"); + ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter)); + ui.end_row(); + ui.label("Ceiling"); + ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter)); + ui.end_row(); + ui.label("Lim Release"); + ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter)); + ui.end_row(); + }); + ui.separator(); + ui.columns(4, |cols| { + band_col(&mut cols[0], "LOW", ¶ms.low); + band_col(&mut cols[1], "MID", ¶ms.mid); + band_col(&mut cols[2], "HIGH", ¶ms.high); + band_col(&mut cols[3], "ALL", ¶ms.all); + }); + }); + }); + }, + ) +} diff --git a/src/lib.rs b/src/lib.rs index 35d3540..0153e0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,14 @@ use nih_plug::prelude::*; -use nih_plug_egui::{ - create_egui_editor, - egui::{self, Vec2}, - resizable_window::ResizableWindow, - widgets, EguiState, -}; use std::sync::Arc; mod dsp; -use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS}; +mod editor; +mod params; + +use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS}; use dsp::crossover::Crossover; use dsp::limiter::Limiter; +use params::{build_settings, Codename206Params}; /// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel. const LOW: usize = 0; @@ -18,17 +16,6 @@ const MID: usize = 1; const HIGH: usize = 2; const ALL: usize = 3; -/// Level-detection mode for a compressor's detector. -#[derive(Enum, PartialEq, Clone, Copy)] -enum DetectionMode { - #[id = "peak"] - #[name = "Peak"] - Peak, - #[id = "rms"] - #[name = "RMS"] - Rms, -} - /// 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 → @@ -44,58 +31,6 @@ struct Codename206 { limiter: Limiter, } -#[derive(Params)] -struct Codename206Params { - #[persist = "editor-state"] - editor_state: Arc, - - /// Low/Mid crossover frequency. - #[id = "xover_lo"] - pub crossover_low_hz: FloatParam, - /// Mid/High crossover frequency. - #[id = "xover_hi"] - pub crossover_high_hz: FloatParam, - /// Global look-ahead time (constant reported latency — safe to adjust during playback). - #[id = "lookahead"] - pub look_ahead_ms: FloatParam, - - /// Output brickwall ceiling (the limiter never lets output exceed this). - #[id = "ceiling"] - pub output_ceiling_db: FloatParam, - /// Output limiter release time. - #[id = "lim_rel"] - pub limiter_release_ms: FloatParam, - - #[nested(id_prefix = "low", group = "Low")] - pub low: CompressorParams, - #[nested(id_prefix = "mid", group = "Mid")] - pub mid: CompressorParams, - #[nested(id_prefix = "high", group = "High")] - pub high: CompressorParams, - #[nested(id_prefix = "all", group = "All")] - pub all: CompressorParams, -} - -#[derive(Params)] -struct CompressorParams { - #[id = "detect"] - pub detection: EnumParam, - #[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 { @@ -108,127 +43,6 @@ impl Default for Codename206 { } } -impl Default for Codename206Params { - fn default() -> Self { - Self { - editor_state: EguiState::from_size(760, 520), - - crossover_low_hz: FloatParam::new( - "Crossover Lo/Mid", - 200.0, - FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) }, - ) - .with_value_to_string(formatters::v2s_f32_hz_then_khz(0)) - .with_string_to_value(formatters::s2v_f32_hz_then_khz()), - - crossover_high_hz: FloatParam::new( - "Crossover Mid/Hi", - 2_500.0, - FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) }, - ) - .with_value_to_string(formatters::v2s_f32_hz_then_khz(0)) - .with_string_to_value(formatters::s2v_f32_hz_then_khz()), - - look_ahead_ms: FloatParam::new( - "Look-ahead", - 2.0, - FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS }, - ) - .with_unit(" ms") - .with_value_to_string(formatters::v2s_f32_rounded(2)), - - output_ceiling_db: FloatParam::new( - "Ceiling", - 0.0, - FloatRange::Linear { min: -24.0, max: 0.0 }, - ) - .with_unit(" dB") - .with_value_to_string(formatters::v2s_f32_rounded(1)), - - limiter_release_ms: FloatParam::new( - "Limiter Release", - 100.0, - FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) }, - ) - .with_unit(" ms") - .with_value_to_string(formatters::v2s_f32_rounded(1)), - - low: CompressorParams::default(), - mid: CompressorParams::default(), - high: CompressorParams::default(), - all: CompressorParams::default(), - } - } -} - -impl Default for CompressorParams { - fn default() -> Self { - Self { - detection: EnumParam::new("Detection", DetectionMode::Peak), - - 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::().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: 1_000.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 }) - .with_smoother(SmoothingStyle::Linear(20.0)) - .with_unit(" dB") - .with_value_to_string(formatters::v2s_f32_rounded(1)), - - bypass: BoolParam::new("Bypass", false), - } - } -} - -/// Build the per-block compressor settings for one channel's params (makeup filled per sample). -fn build_settings(p: &CompressorParams, lookahead: usize, sample_rate: f32) -> CompressorSettings { - CompressorSettings { - threshold_db: p.threshold_db.value(), - ratio: p.ratio.value(), - knee_db: p.knee_db.value(), - attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate), - release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate), - makeup_db: 0.0, - lookahead_samples: lookahead, - use_rms: p.detection.value() == DetectionMode::Rms, - bypass: p.bypass.value(), - } -} - impl Codename206 { fn lookahead_samples(&self) -> usize { (self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize @@ -269,68 +83,7 @@ impl Plugin for Codename206 { } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { - let params = self.params.clone(); - let egui_state = self.params.editor_state.clone(); - create_egui_editor( - self.params.editor_state.clone(), - (), - |_, _| {}, - move |egui_ctx, setter, _state| { - // One column of controls for a single compressor channel. - let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| { - ui.strong(title); - ui.add(widgets::ParamSlider::for_param(&p.detection, setter)); - ui.label("Threshold"); - ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter)); - ui.label("Ratio"); - ui.add(widgets::ParamSlider::for_param(&p.ratio, setter)); - ui.label("Knee"); - ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter)); - ui.label("Attack"); - ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter)); - ui.label("Release"); - ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter)); - ui.label("Makeup"); - ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter)); - ui.add(widgets::ParamSlider::for_param(&p.bypass, setter)); - }; - - // Resizable window; vertical scroll so every control stays reachable even when the - // window is small. (Placeholder layout — Stage 6 will replace it.) - ResizableWindow::new("editor") - .min_size(Vec2::new(480.0, 320.0)) - .show(egui_ctx, egui_state.as_ref(), |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - ui.heading(Self::NAME); - // Global controls stacked vertically so they never overflow sideways. - egui::Grid::new("globals").num_columns(2).show(ui, |ui| { - ui.label("Xover Lo/Mid"); - ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter)); - ui.end_row(); - ui.label("Xover Mid/Hi"); - ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter)); - ui.end_row(); - ui.label("Look-ahead"); - ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter)); - ui.end_row(); - ui.label("Ceiling"); - ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter)); - ui.end_row(); - ui.label("Lim Release"); - ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter)); - ui.end_row(); - }); - ui.separator(); - ui.columns(4, |cols| { - band_col(&mut cols[0], "LOW", ¶ms.low); - band_col(&mut cols[1], "MID", ¶ms.mid); - band_col(&mut cols[2], "HIGH", ¶ms.high); - band_col(&mut cols[3], "ALL", ¶ms.all); - }); - }); - }); - }, - ) + editor::create(self.params.clone()) } fn initialize( diff --git a/src/params.rs b/src/params.rs new file mode 100644 index 0000000..9b8d881 --- /dev/null +++ b/src/params.rs @@ -0,0 +1,199 @@ +//! Plugin parameters and their layout. +//! +//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All' +//! aggregate channel). `build_settings` translates a channel's params into the per-block +//! [`CompressorSettings`] the DSP consumes. + +use nih_plug::prelude::*; +use nih_plug_egui::EguiState; +use std::sync::Arc; + +use crate::dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS}; + +/// Level-detection mode for a compressor's detector. +#[derive(Enum, PartialEq, Clone, Copy)] +pub enum DetectionMode { + #[id = "peak"] + #[name = "Peak"] + Peak, + #[id = "rms"] + #[name = "RMS"] + Rms, +} + +#[derive(Params)] +pub struct Codename206Params { + #[persist = "editor-state"] + pub editor_state: Arc, + + /// Low/Mid crossover frequency. + #[id = "xover_lo"] + pub crossover_low_hz: FloatParam, + /// Mid/High crossover frequency. + #[id = "xover_hi"] + pub crossover_high_hz: FloatParam, + /// Global look-ahead time (constant reported latency — safe to adjust during playback). + #[id = "lookahead"] + pub look_ahead_ms: FloatParam, + + /// Output brickwall ceiling (the limiter never lets output exceed this). + #[id = "ceiling"] + pub output_ceiling_db: FloatParam, + /// Output limiter release time. + #[id = "lim_rel"] + pub limiter_release_ms: FloatParam, + + #[nested(id_prefix = "low", group = "Low")] + pub low: CompressorParams, + #[nested(id_prefix = "mid", group = "Mid")] + pub mid: CompressorParams, + #[nested(id_prefix = "high", group = "High")] + pub high: CompressorParams, + #[nested(id_prefix = "all", group = "All")] + pub all: CompressorParams, +} + +#[derive(Params)] +pub struct CompressorParams { + #[id = "detect"] + pub detection: EnumParam, + #[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 Codename206Params { + fn default() -> Self { + Self { + editor_state: EguiState::from_size(760, 520), + + crossover_low_hz: FloatParam::new( + "Crossover Lo/Mid", + 200.0, + FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) }, + ) + .with_value_to_string(formatters::v2s_f32_hz_then_khz(0)) + .with_string_to_value(formatters::s2v_f32_hz_then_khz()), + + crossover_high_hz: FloatParam::new( + "Crossover Mid/Hi", + 2_500.0, + FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) }, + ) + .with_value_to_string(formatters::v2s_f32_hz_then_khz(0)) + .with_string_to_value(formatters::s2v_f32_hz_then_khz()), + + look_ahead_ms: FloatParam::new( + "Look-ahead", + 2.0, + FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS }, + ) + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(2)), + + output_ceiling_db: FloatParam::new( + "Ceiling", + 0.0, + FloatRange::Linear { min: -24.0, max: 0.0 }, + ) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + + limiter_release_ms: FloatParam::new( + "Limiter Release", + 100.0, + FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) }, + ) + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + + low: CompressorParams::default(), + mid: CompressorParams::default(), + high: CompressorParams::default(), + all: CompressorParams::default(), + } + } +} + +impl Default for CompressorParams { + fn default() -> Self { + Self { + detection: EnumParam::new("Detection", DetectionMode::Peak), + + 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::().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: 1_000.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 }) + .with_smoother(SmoothingStyle::Linear(20.0)) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + + bypass: BoolParam::new("Bypass", false), + } + } +} + +/// Build the per-block compressor settings for one channel's params (makeup filled per sample). +pub fn build_settings( + p: &CompressorParams, + lookahead: usize, + sample_rate: f32, +) -> CompressorSettings { + CompressorSettings { + threshold_db: p.threshold_db.value(), + ratio: p.ratio.value(), + knee_db: p.knee_db.value(), + attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate), + release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate), + makeup_db: 0.0, + lookahead_samples: lookahead, + use_rms: p.detection.value() == DetectionMode::Rms, + bypass: p.bypass.value(), + } +}