From a8be2179c354083058f17dbc2d65758839de74a6 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 24 Jun 2026 22:16:33 +0900 Subject: [PATCH] feat: ceiling-hit markers as top-edge ticks on the rolling plot Flag each plot bucket with whether the output limiter hit the ceiling (limiter GR > 0.1 dB), carried through ScopeRing as a per-bucket hit field and folded into history columns. The editor draws a short red tick at the top of any hit column (one column wide, so runs merge into segments and a lone hit is a dot); nothing otherwise. Global marker, shown on every channel tab. Co-Authored-By: Claude Opus 4.8 --- src/editor/plot.rs | 35 ++++++++++++++++++++++++++++++----- src/lib.rs | 12 +++++++++++- src/meters.rs | 24 +++++++++++++++++++----- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/editor/plot.rs b/src/editor/plot.rs index 21eabb5..ba896c5 100644 --- a/src/editor/plot.rs +++ b/src/editor/plot.rs @@ -7,7 +7,7 @@ //! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column. use nih_plug::prelude::*; -use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke}; +use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke}; use super::METER_FLOOR_DB; use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS}; @@ -26,6 +26,8 @@ struct PlotHistory { in_db: [[f32; PLOT_N]; NUM_CHANNELS], out_db: [[f32; PLOT_N]; NUM_CHANNELS], gr_db: [[f32; PLOT_N]; NUM_CHANNELS], + /// Per-column flag: the output limiter hit the ceiling somewhere in this column. + hit: [bool; PLOT_N], write: usize, len: usize, } @@ -36,6 +38,7 @@ impl Default for PlotHistory { in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS], out_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS], gr_db: [[0.0; PLOT_N]; NUM_CHANNELS], + hit: [false; PLOT_N], write: 0, len: 0, } @@ -43,13 +46,14 @@ impl Default for PlotHistory { } impl PlotHistory { - /// Append one column of (in_db, out_db, gr_db) per channel. - fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS]) { + /// Append one column of (in_db, out_db, gr_db) per channel, plus the ceiling-hit flag. + fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) { for i in 0..NUM_CHANNELS { self.in_db[i][self.write] = samples[i].0; self.out_db[i][self.write] = samples[i].1; self.gr_db[i][self.write] = samples[i].2; } + self.hit[self.write] = hit; self.write = (self.write + 1) % PLOT_N; self.len = (self.len + 1).min(PLOT_N); } @@ -67,6 +71,8 @@ pub(super) struct PlotState { cursor: Option, /// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built. col_acc: [(f32, f32, f32); NUM_CHANNELS], + /// Ceiling-hit flag accumulated for the column currently being built. + col_hit: bool, /// Buckets folded into the current column so far (fractional — a column may span <1 bucket). col_fill: f64, } @@ -79,6 +85,7 @@ impl Default for PlotState { window_s: 5.0, cursor: None, col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS], + col_hit: false, col_fill: 0.0, } } @@ -97,17 +104,20 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) { let cursor = state.cursor.get_or_insert(w0); let history = &mut state.history; let col_acc = &mut state.col_acc; + let col_hit = &mut state.col_hit; let col_fill = &mut state.col_fill; - meters.scope.drain(cursor, |in_lin, out_lin, gr_db| { + meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| { for ch in 0..NUM_CHANNELS { col_acc[ch].0 = col_acc[ch].0.max(util::gain_to_db(in_lin[ch])); col_acc[ch].1 = col_acc[ch].1.max(util::gain_to_db(out_lin[ch])); col_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]); } + *col_hit |= hit > 0.5; *col_fill += 1.0; while *col_fill >= buckets_per_col { - history.push(col_acc); + history.push(col_acc, *col_hit); *col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS]; + *col_hit = false; *col_fill -= buckets_per_col; } }); @@ -133,6 +143,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) { // Cadence changed: start the history fresh so the time axis is consistent. state.history = PlotHistory::default(); state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS]; + state.col_hit = false; state.col_fill = 0.0; } ui.separator(); @@ -197,5 +208,19 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) { draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true); // GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline. draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR, false); + + // Ceiling-hit markers: a short red tick at the TOP for any column where the output limiter + // hit the ceiling (global — shown on every channel's view). One column wide, so runs of + // hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise. + let dx = width / (PLOT_N - 1) as f32; + let marker = Color32::from_rgb(235, 45, 45); + for k in 0..len { + let idx = (write + PLOT_N - len + k) % PLOT_N; + if state.history.hit[idx] { + let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32; + let x = left + pos * width; + p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + dx, top + 3.0)), CornerRadius::ZERO, marker); + } + } } } diff --git a/src/lib.rs b/src/lib.rs index 7da4e97..2d66c7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,11 +46,16 @@ struct Codename206 { scope_in: [f32; 4], scope_out: [f32; 4], scope_gr: [f32; 4], + /// Max output-limiter gain reduction seen in the current bucket (for the ceiling-hit marker). + scope_hit: f32, /// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ). scope_samples: usize, scope_bucket_len: usize, } +/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling. +const CEILING_HIT_GR_DB: f32 = 0.1; + impl Default for Codename206 { fn default() -> Self { Self { @@ -64,6 +69,7 @@ impl Default for Codename206 { scope_in: [0.0; 4], scope_out: [0.0; 4], scope_gr: [0.0; 4], + scope_hit: 0.0, scope_samples: 0, scope_bucket_len: 1, } @@ -164,6 +170,7 @@ impl Plugin for Codename206 { self.scope_in = [0.0; 4]; self.scope_out = [0.0; 4]; self.scope_gr = [0.0; 4]; + self.scope_hit = 0.0; self.scope_samples = 0; } @@ -287,14 +294,17 @@ impl Plugin for Codename206 { self.scope_in[ALL] = self.scope_in[ALL].max(in_mono); self.scope_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r)); self.scope_gr[ALL] = self.scope_gr[ALL].max(g); + self.scope_hit = self.scope_hit.max(self.limiter.gain_reduction_db()); // Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ). self.scope_samples += 1; if self.scope_samples >= self.scope_bucket_len { - self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr); + let hit = if self.scope_hit > CEILING_HIT_GR_DB { 1.0 } else { 0.0 }; + self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr, hit); self.scope_in = [0.0; meters::NUM_CHANNELS]; self.scope_out = [0.0; meters::NUM_CHANNELS]; self.scope_gr = [0.0; meters::NUM_CHANNELS]; + self.scope_hit = 0.0; self.scope_samples = 0; } } diff --git a/src/meters.rs b/src/meters.rs index 4bbffc2..ddf6737 100644 --- a/src/meters.rs +++ b/src/meters.rs @@ -85,6 +85,9 @@ pub struct ScopeRing { in_lin: Vec, out_lin: Vec, gr_db: Vec, + /// Per-bucket (not per-channel) flag: `1.0` if the output limiter hit the ceiling in this + /// bucket, else `0.0`. Indexed by `bucket_index % RING_N`. + hit: Vec, /// Monotonic count of buckets ever written. write: AtomicU64, } @@ -92,7 +95,13 @@ pub struct ScopeRing { impl Default for ScopeRing { fn default() -> Self { let make = || (0..RING_N * NUM_CHANNELS).map(|_| AtomicF32::new(0.0)).collect(); - Self { in_lin: make(), out_lin: make(), gr_db: make(), write: AtomicU64::new(0) } + Self { + in_lin: make(), + out_lin: make(), + gr_db: make(), + hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(), + write: AtomicU64::new(0), + } } } @@ -103,14 +112,17 @@ impl ScopeRing { in_lin: &[f32; NUM_CHANNELS], out_lin: &[f32; NUM_CHANNELS], gr_db: &[f32; NUM_CHANNELS], + hit: f32, ) { let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write` - let base = (w as usize % RING_N) * NUM_CHANNELS; + let slot = w as usize % RING_N; + let base = slot * NUM_CHANNELS; for ch in 0..NUM_CHANNELS { self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed); self.out_lin[base + ch].store(out_lin[ch], Ordering::Relaxed); self.gr_db[base + ch].store(gr_db[ch], Ordering::Relaxed); } + self.hit[slot].store(hit, Ordering::Relaxed); // Publish the bucket: the Release pairs with the consumer's Acquire so the stores above are // visible before the new count. self.write.store(w + 1, Ordering::Release); @@ -121,7 +133,7 @@ impl ScopeRing { pub fn drain( &self, cursor: &mut u64, - mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS]), + mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], f32), ) { let w = self.write.load(Ordering::Acquire); if *cursor > w { @@ -136,13 +148,15 @@ impl ScopeRing { let mut out_buf = [0.0f32; NUM_CHANNELS]; let mut gr_buf = [0.0f32; NUM_CHANNELS]; while *cursor < w { - let base = (*cursor as usize % RING_N) * NUM_CHANNELS; + let slot = *cursor as usize % RING_N; + let base = slot * NUM_CHANNELS; for ch in 0..NUM_CHANNELS { in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed); out_buf[ch] = self.out_lin[base + ch].load(Ordering::Relaxed); gr_buf[ch] = self.gr_db[base + ch].load(Ordering::Relaxed); } - on_bucket(&in_buf, &out_buf, &gr_buf); + let hit = self.hit[slot].load(Ordering::Relaxed); + on_bucket(&in_buf, &out_buf, &gr_buf, hit); *cursor += 1; } }