From a420d5dd39755e323d9873d0d6abda909a9d7443 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 21 Jun 2026 23:13:14 +0900 Subject: [PATCH] feat: per-channel meters, ceiling lamp, and pre-gain drive Stage 6 begins. Add lock-free Meters (atomics shared audio->GUI) with a peak-with-decay ballistic, published once per block and gated on the editor being open. Editor draws a per-channel level + gain-reduction meter panel and a ceiling lamp fed by the output limiter. Compressor/Limiter expose gain_reduction_db() for this. Also add a smoothed per-channel pre-gain applied before each compressor (and before the All compressor), driving the signal into compression and on into the limiter for a compressed semi-distortion. Pairs with makeup for full per-channel input/output gain-staging. Compressor DSP untouched; 16 tests pass. Co-Authored-By: Claude Opus 4.8 --- src/dsp/compressor.rs | 7 +++ src/dsp/limiter.rs | 6 +++ src/editor.rs | 112 ++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 67 +++++++++++++++++++++++-- src/meters.rs | 49 ++++++++++++++++++ src/params.rs | 14 ++++++ 6 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 src/meters.rs diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index 2565cbb..60310d8 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -152,6 +152,13 @@ impl Compressor { self.fixed_delay as u32 } + /// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering. + /// This is the smoothed detector output `yl`, so it tracks the visible needle, not the + /// instantaneous static curve. + pub fn gain_reduction_db(&self) -> f32 { + self.yl + } + /// Process one sample frame in place: `input[ch]` -> `output[ch]`. /// /// `input` and `output` are short stack slices (one value per channel), so this diff --git a/src/dsp/limiter.rs b/src/dsp/limiter.rs index b3a4088..f684e96 100644 --- a/src/dsp/limiter.rs +++ b/src/dsp/limiter.rs @@ -89,6 +89,12 @@ impl Limiter { self.fixed_delay as u32 } + /// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a + /// positive dB amount for the ceiling lamp / metering. + pub fn gain_reduction_db(&self) -> f32 { + -20.0 * self.gain.max(1e-9).log10() + } + /// Limit one frame in place: `input[ch]` -> `output[ch]`. /// /// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a diff --git a/src/editor.rs b/src/editor.rs index 37c1735..67a9ed2 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4,30 +4,49 @@ //! 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. +//! +//! First real visualiser: a row of per-channel meters (output level + gain reduction) plus a +//! ceiling lamp, fed by the lock-free [`Meters`] state the audio thread publishes each block. use nih_plug::prelude::*; use nih_plug_egui::{ create_egui_editor, - egui::{self, Vec2}, + egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Painter, Rect, Sense, Vec2}, resizable_window::ResizableWindow, widgets, }; +use std::sync::atomic::Ordering; use std::sync::Arc; +use crate::meters::{Meters, NUM_CHANNELS}; 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> { +/// Bottom of the level meter's dB scale (top is 0 dBFS). +const METER_FLOOR_DB: f32 = -60.0; +/// Full-scale of the gain-reduction meter (bar fills downward from the top). +const GR_FULL_DB: f32 = 24.0; +/// Limiter gain reduction (dB) at which the ceiling lamp is fully lit. +const LAMP_FULL_DB: f32 = 3.0; +/// Height of the meter panel. +const METER_PANEL_H: f32 = 130.0; + +/// Build the plugin editor over shared handles to the params and meter state. +pub(crate) fn create(params: Arc, meters: Arc) -> Option> { let egui_state = params.editor_state.clone(); create_egui_editor( params.editor_state.clone(), (), |_, _| {}, move |egui_ctx, setter, _state| { + // Keep frames coming so the meters animate while the editor is open. + egui_ctx.request_repaint(); + // One column of controls for a single compressor channel. let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| { ui.strong(title); + ui.label("Pre-gain"); + ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter)); ui.add(widgets::ParamSlider::for_param(&p.detection, setter)); ui.label("Threshold"); ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter)); @@ -51,6 +70,8 @@ pub(crate) fn create(params: Arc) -> Option> .show(egui_ctx, egui_state.as_ref(), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.heading(Codename206::NAME); + draw_meters(ui, &meters); + ui.separator(); // Global controls stacked vertically so they never overflow sideways. egui::Grid::new("globals").num_columns(2).show(ui, |ui| { ui.label("Xover Lo/Mid"); @@ -81,3 +102,88 @@ pub(crate) fn create(params: Arc) -> Option> }, ) } + +/// Draw the meter panel: one channel group per column (mono output level + gain reduction), +/// plus the ceiling lamp driven by the output limiter. +fn draw_meters(ui: &mut egui::Ui, meters: &Meters) { + let labels = ["LOW", "MID", "HIGH", "ALL"]; + let (rect, _) = + ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24)); + + let top = rect.top() + 10.0; + let bottom = rect.bottom() - 18.0; // leave a row for the labels + let cell_w = rect.width() / NUM_CHANNELS as f32; + let bar_w = (cell_w * 0.26).min(22.0); + let gap = (cell_w * 0.08).min(8.0); + + for i in 0..NUM_CHANNELS { + let cell_left = rect.left() + i as f32 * cell_w; + let group_w = bar_w * 2.0 + gap; + let bx = cell_left + (cell_w - group_w) * 0.5; + + // Level bar (mono = max of L/R), fills upward; colour warns as it nears 0 dBFS. + let level_lin = + meters.level_l[i].load(Ordering::Relaxed).max(meters.level_r[i].load(Ordering::Relaxed)); + let level_db = util::gain_to_db(level_lin); + let level_frac = ((level_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0); + v_bar(&p, bx, bar_w, top, bottom, level_frac, level_color(level_db), false); + + // Gain-reduction bar, fills downward from the top. + let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed); + let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0); + v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, Color32::from_rgb(240, 150, 60), true); + + p.text( + pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0), + Align2::CENTER_BOTTOM, + labels[i], + FontId::proportional(12.0), + Color32::from_gray(200), + ); + } + + // Ceiling lamp (top-right): dark when idle, bright red while the limiter is catching peaks. + let lit = (meters.limiter_gr_db.load(Ordering::Relaxed) / LAMP_FULL_DB).clamp(0.0, 1.0); + let lamp = Color32::from_rgb( + (40.0 + 215.0 * lit) as u8, + (12.0 + 28.0 * lit) as u8, + (12.0 + 28.0 * lit) as u8, + ); + let center = pos2(rect.right() - 14.0, rect.top() + 14.0); + p.circle_filled(center, 7.0, lamp); + p.text( + pos2(center.x - 14.0, center.y), + Align2::RIGHT_CENTER, + "CEILING", + FontId::proportional(11.0), + Color32::from_gray(180), + ); +} + +/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the +/// top (gain reduction) instead of upward from the bottom (level). +fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) { + let track = Color32::from_rgb(34, 34, 40); + p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track); + + let h = (bottom - top) * frac.clamp(0.0, 1.0); + let filled = if from_top { + Rect::from_min_max(pos2(x, top), pos2(x + w, top + h)) + } else { + Rect::from_min_max(pos2(x, bottom - h), pos2(x + w, bottom)) + }; + p.rect_filled(filled, CornerRadius::ZERO, fill); +} + +/// Level-bar colour: green below -6 dB, yellow approaching, red near 0 dBFS. +fn level_color(db: f32) -> Color32 { + if db >= -1.0 { + Color32::from_rgb(235, 70, 60) + } else if db >= -6.0 { + Color32::from_rgb(230, 200, 70) + } else { + Color32::from_rgb(90, 200, 110) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0153e0c..ebb9897 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,13 +3,19 @@ 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; @@ -29,6 +35,11 @@ struct Codename206 { 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, } impl Default for Codename206 { @@ -39,6 +50,8 @@ impl Default for Codename206 { 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, } } } @@ -83,7 +96,7 @@ impl Plugin for Codename206 { } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { - editor::create(self.params.clone()) + editor::create(self.params.clone(), self.meters.clone()) } fn initialize( @@ -98,6 +111,10 @@ impl Plugin for Codename206 { .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; + for comp in &mut self.comps { comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS); } @@ -154,6 +171,14 @@ impl Plugin for Codename206 { 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(); + 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]; @@ -163,6 +188,7 @@ impl Plugin for Codename206 { 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(); } @@ -175,28 +201,63 @@ impl Plugin for Codename206 { band_in[HIGH][ch] = hi; } - // Compress each band (per-sample smoothed makeup), then sum. + // 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(); 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 { + lvl_l[b] = lvl_l[b].max(band_out[b][0].abs()); + lvl_r[b] = lvl_r[b].max(band_out[b][r].abs()); + gr[b] = gr[b] + .max(if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() }); + } } - // 'All' aggregate channel over the summed bands. + // '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(); 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 { + lvl_l[ALL] = lvl_l[ALL].max(out_frame[0].abs()); + lvl_r[ALL] = lvl_r[ALL].max(out_frame[r].abs()); + gr[ALL] = gr[ALL] + .max(if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() }); + lim_gr = lim_gr.max(self.limiter.gain_reduction_db()); + } + 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 } } diff --git a/src/meters.rs b/src/meters.rs new file mode 100644 index 0000000..a8a78ca --- /dev/null +++ b/src/meters.rs @@ -0,0 +1,49 @@ +//! Lock-free meter state shared from the audio thread to the editor. +//! +//! `process()` is the single writer (one store per value per block — decimated, not per sample); +//! the editor is the single reader (once per frame). All access is wait-free via atomics, so the +//! realtime thread never blocks. Values are plain scalars (no streaming history yet) — enough for +//! the per-channel level + gain-reduction bars and the ceiling lamp. + +use nih_plug::prelude::AtomicF32; +use std::sync::atomic::Ordering; + +/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors. +pub const NUM_CHANNELS: usize = 4; + +pub struct Meters { + /// Left output level per channel as a **linear** peak. Peak-with-decay. + pub level_l: [AtomicF32; NUM_CHANNELS], + /// Right output level per channel (== left for mono signals). Stored separately so the planned + /// `|L|GR|R|` layout is a pure editor change; the current bars render `max(L, R)`. + pub level_r: [AtomicF32; NUM_CHANNELS], + /// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is + /// stereo-linked, so the same gain applies to both channels. + pub gain_reduction_db: [AtomicF32; NUM_CHANNELS], + /// Output limiter gain reduction in **dB (>= 0)** — drives the ceiling lamp. + pub limiter_gr_db: AtomicF32, +} + +impl Default for Meters { + fn default() -> Self { + Self { + level_l: std::array::from_fn(|_| AtomicF32::new(0.0)), + level_r: std::array::from_fn(|_| AtomicF32::new(0.0)), + gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)), + limiter_gr_db: AtomicF32::new(0.0), + } + } +} + +/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a +/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters +/// from flickering while staying responsive to transients. +pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) { + let current = meter.load(Ordering::Relaxed); + let next = if block_value > current { + block_value + } else { + current * decay_weight + block_value * (1.0 - decay_weight) + }; + meter.store(next, Ordering::Relaxed); +} diff --git a/src/params.rs b/src/params.rs index 9b8d881..8568d48 100644 --- a/src/params.rs +++ b/src/params.rs @@ -55,6 +55,11 @@ pub struct Codename206Params { #[derive(Params)] pub struct CompressorParams { + /// Drive into the compressor: scales the signal **before** detection, so it both pushes the + /// channel further into compression and feeds the downstream sum/limiter harder. Combined with + /// makeup (post-comp), this gives full per-channel input/output gain-staging. + #[id = "pregain"] + pub pre_gain_db: FloatParam, #[id = "detect"] pub detection: EnumParam, #[id = "thresh"] @@ -129,6 +134,15 @@ impl Default for Codename206Params { impl Default for CompressorParams { fn default() -> Self { Self { + pre_gain_db: FloatParam::new( + "Pre-gain", + 0.0, + FloatRange::Linear { min: -24.0, max: 36.0 }, + ) + .with_smoother(SmoothingStyle::Linear(20.0)) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + detection: EnumParam::new("Detection", DetectionMode::Peak), threshold_db: FloatParam::new(