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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-21 23:13:14 +09:00
parent 8c550da92e
commit a420d5dd39
6 changed files with 249 additions and 6 deletions
+7
View File
@@ -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
+6
View File
@@ -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
+109 -3
View File
@@ -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<Codename206Params>) -> Option<Box<dyn Editor>> {
/// 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<Codename206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
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<Codename206Params>) -> Option<Box<dyn Editor>>
.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<Codename206Params>) -> Option<Box<dyn Editor>>
},
)
}
/// 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)
}
}
+64 -3
View File
@@ -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<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,
}
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<Self>) -> Option<Box<dyn Editor>> {
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
}
}
+49
View File
@@ -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);
}
+14
View File
@@ -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<DetectionMode>,
#[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(