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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-24 22:16:33 +09:00
parent 42c5f7dcd2
commit a8be2179c3
3 changed files with 60 additions and 11 deletions
+30 -5
View File
@@ -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<u64>,
/// 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);
}
}
}
}