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
+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
}
}