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:
+30
-5
@@ -7,7 +7,7 @@
|
|||||||
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
|
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
|
||||||
|
|
||||||
use nih_plug::prelude::*;
|
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 super::METER_FLOOR_DB;
|
||||||
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
||||||
@@ -26,6 +26,8 @@ struct PlotHistory {
|
|||||||
in_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
in_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
out_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
out_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
gr_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,
|
write: usize,
|
||||||
len: usize,
|
len: usize,
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,7 @@ impl Default for PlotHistory {
|
|||||||
in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
||||||
out_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],
|
gr_db: [[0.0; PLOT_N]; NUM_CHANNELS],
|
||||||
|
hit: [false; PLOT_N],
|
||||||
write: 0,
|
write: 0,
|
||||||
len: 0,
|
len: 0,
|
||||||
}
|
}
|
||||||
@@ -43,13 +46,14 @@ impl Default for PlotHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlotHistory {
|
impl PlotHistory {
|
||||||
/// Append one column of (in_db, out_db, gr_db) per channel.
|
/// 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]) {
|
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) {
|
||||||
for i in 0..NUM_CHANNELS {
|
for i in 0..NUM_CHANNELS {
|
||||||
self.in_db[i][self.write] = samples[i].0;
|
self.in_db[i][self.write] = samples[i].0;
|
||||||
self.out_db[i][self.write] = samples[i].1;
|
self.out_db[i][self.write] = samples[i].1;
|
||||||
self.gr_db[i][self.write] = samples[i].2;
|
self.gr_db[i][self.write] = samples[i].2;
|
||||||
}
|
}
|
||||||
|
self.hit[self.write] = hit;
|
||||||
self.write = (self.write + 1) % PLOT_N;
|
self.write = (self.write + 1) % PLOT_N;
|
||||||
self.len = (self.len + 1).min(PLOT_N);
|
self.len = (self.len + 1).min(PLOT_N);
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,8 @@ pub(super) struct PlotState {
|
|||||||
cursor: Option<u64>,
|
cursor: Option<u64>,
|
||||||
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
||||||
col_acc: [(f32, f32, f32); NUM_CHANNELS],
|
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).
|
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
|
||||||
col_fill: f64,
|
col_fill: f64,
|
||||||
}
|
}
|
||||||
@@ -79,6 +85,7 @@ impl Default for PlotState {
|
|||||||
window_s: 5.0,
|
window_s: 5.0,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
||||||
|
col_hit: false,
|
||||||
col_fill: 0.0,
|
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 cursor = state.cursor.get_or_insert(w0);
|
||||||
let history = &mut state.history;
|
let history = &mut state.history;
|
||||||
let col_acc = &mut state.col_acc;
|
let col_acc = &mut state.col_acc;
|
||||||
|
let col_hit = &mut state.col_hit;
|
||||||
let col_fill = &mut state.col_fill;
|
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 {
|
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].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].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_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]);
|
||||||
}
|
}
|
||||||
|
*col_hit |= hit > 0.5;
|
||||||
*col_fill += 1.0;
|
*col_fill += 1.0;
|
||||||
while *col_fill >= buckets_per_col {
|
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_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
*col_hit = false;
|
||||||
*col_fill -= buckets_per_col;
|
*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.
|
// Cadence changed: start the history fresh so the time axis is consistent.
|
||||||
state.history = PlotHistory::default();
|
state.history = PlotHistory::default();
|
||||||
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
state.col_hit = false;
|
||||||
state.col_fill = 0.0;
|
state.col_fill = 0.0;
|
||||||
}
|
}
|
||||||
ui.separator();
|
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);
|
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.
|
// 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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -46,11 +46,16 @@ struct Codename206 {
|
|||||||
scope_in: [f32; 4],
|
scope_in: [f32; 4],
|
||||||
scope_out: [f32; 4],
|
scope_out: [f32; 4],
|
||||||
scope_gr: [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).
|
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
|
||||||
scope_samples: usize,
|
scope_samples: usize,
|
||||||
scope_bucket_len: 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 {
|
impl Default for Codename206 {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -64,6 +69,7 @@ impl Default for Codename206 {
|
|||||||
scope_in: [0.0; 4],
|
scope_in: [0.0; 4],
|
||||||
scope_out: [0.0; 4],
|
scope_out: [0.0; 4],
|
||||||
scope_gr: [0.0; 4],
|
scope_gr: [0.0; 4],
|
||||||
|
scope_hit: 0.0,
|
||||||
scope_samples: 0,
|
scope_samples: 0,
|
||||||
scope_bucket_len: 1,
|
scope_bucket_len: 1,
|
||||||
}
|
}
|
||||||
@@ -164,6 +170,7 @@ impl Plugin for Codename206 {
|
|||||||
self.scope_in = [0.0; 4];
|
self.scope_in = [0.0; 4];
|
||||||
self.scope_out = [0.0; 4];
|
self.scope_out = [0.0; 4];
|
||||||
self.scope_gr = [0.0; 4];
|
self.scope_gr = [0.0; 4];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
self.scope_samples = 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_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_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r));
|
||||||
self.scope_gr[ALL] = self.scope_gr[ALL].max(g);
|
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).
|
// Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ).
|
||||||
self.scope_samples += 1;
|
self.scope_samples += 1;
|
||||||
if self.scope_samples >= self.scope_bucket_len {
|
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_in = [0.0; meters::NUM_CHANNELS];
|
||||||
self.scope_out = [0.0; meters::NUM_CHANNELS];
|
self.scope_out = [0.0; meters::NUM_CHANNELS];
|
||||||
self.scope_gr = [0.0; meters::NUM_CHANNELS];
|
self.scope_gr = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
self.scope_samples = 0;
|
self.scope_samples = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -85,6 +85,9 @@ pub struct ScopeRing {
|
|||||||
in_lin: Vec<AtomicF32>,
|
in_lin: Vec<AtomicF32>,
|
||||||
out_lin: Vec<AtomicF32>,
|
out_lin: Vec<AtomicF32>,
|
||||||
gr_db: Vec<AtomicF32>,
|
gr_db: Vec<AtomicF32>,
|
||||||
|
/// 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<AtomicF32>,
|
||||||
/// Monotonic count of buckets ever written.
|
/// Monotonic count of buckets ever written.
|
||||||
write: AtomicU64,
|
write: AtomicU64,
|
||||||
}
|
}
|
||||||
@@ -92,7 +95,13 @@ pub struct ScopeRing {
|
|||||||
impl Default for ScopeRing {
|
impl Default for ScopeRing {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let make = || (0..RING_N * NUM_CHANNELS).map(|_| AtomicF32::new(0.0)).collect();
|
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],
|
in_lin: &[f32; NUM_CHANNELS],
|
||||||
out_lin: &[f32; NUM_CHANNELS],
|
out_lin: &[f32; NUM_CHANNELS],
|
||||||
gr_db: &[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 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 {
|
for ch in 0..NUM_CHANNELS {
|
||||||
self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed);
|
self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed);
|
||||||
self.out_lin[base + ch].store(out_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.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
|
// Publish the bucket: the Release pairs with the consumer's Acquire so the stores above are
|
||||||
// visible before the new count.
|
// visible before the new count.
|
||||||
self.write.store(w + 1, Ordering::Release);
|
self.write.store(w + 1, Ordering::Release);
|
||||||
@@ -121,7 +133,7 @@ impl ScopeRing {
|
|||||||
pub fn drain(
|
pub fn drain(
|
||||||
&self,
|
&self,
|
||||||
cursor: &mut u64,
|
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);
|
let w = self.write.load(Ordering::Acquire);
|
||||||
if *cursor > w {
|
if *cursor > w {
|
||||||
@@ -136,13 +148,15 @@ impl ScopeRing {
|
|||||||
let mut out_buf = [0.0f32; NUM_CHANNELS];
|
let mut out_buf = [0.0f32; NUM_CHANNELS];
|
||||||
let mut gr_buf = [0.0f32; NUM_CHANNELS];
|
let mut gr_buf = [0.0f32; NUM_CHANNELS];
|
||||||
while *cursor < w {
|
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 {
|
for ch in 0..NUM_CHANNELS {
|
||||||
in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed);
|
in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed);
|
||||||
out_buf[ch] = self.out_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);
|
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;
|
*cursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user