feat: decouple plot resolution from frame rate via a 200 Hz ring buffer

Feed the scrolling plot from a lock-free SPSC ScopeRing instead of sampling one
atomic per egui frame, so horizontal resolution is set by the audio-clocked
bucket rate (~200 Hz) rather than the ~60 fps repaint. process() accumulates a
bucket every sample_rate/BUCKET_HZ samples (peak-preserving, spanning blocks)
and pushes it; the editor drains all new buckets each frame and folds them into
PLOT_N columns. Fast transients between frames are no longer dropped, and the
plot is now audio-clocked (freezes on pause, falls to silence on stop/reset).

Drop the per-frame plot_* atomics (the per-channel lamp now reads the decayed
bar level). Also fix the area fill: render it as a strip of per-segment convex
quads instead of one concave polygon, which egui fan-filled from a corner and
left stray triangles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-24 14:53:00 +09:00
parent fe4033b772
commit 9a60b0ea72
4 changed files with 209 additions and 98 deletions
+52 -49
View File
@@ -1,16 +1,16 @@
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector.
//!
//! Histories for all four channels run continuously (cheap), so switching tabs shows that
//! channel's existing history. The scroll is time-based (a column every `window/PLOT_N` seconds)
//! so the window length — the flow speed — stays accurate regardless of frame rate, with
//! peak-preserving max accumulation between columns. Fed by the raw block-peak [`Meters`] feed.
//! channel's existing history. It's fed by draining the audio thread's scope ring
//! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
//! rate rather than the editor frame rate. Buckets are folded into `PLOT_N` columns
//! (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 std::sync::atomic::Ordering;
use super::METER_FLOOR_DB;
use crate::meters::{Meters, NUM_CHANNELS};
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
/// Height of the plot panel.
const PLOT_PANEL_H: f32 = 150.0;
@@ -55,17 +55,20 @@ impl PlotHistory {
}
}
/// GUI-side state for the plot: selected channel, history ring, and time-based scroll cadence.
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
/// column being assembled from drained buckets.
pub(super) struct PlotState {
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all).
selected: usize,
history: PlotHistory,
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
window_s: f64,
/// egui time of the last column pushed to the history ring (the cadence clock).
last_push: f64,
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
cursor: Option<u64>,
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
acc: [(f32, f32, f32); NUM_CHANNELS],
col_acc: [(f32, f32, f32); NUM_CHANNELS],
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
col_fill: f64,
}
impl Default for PlotState {
@@ -74,8 +77,9 @@ impl Default for PlotState {
selected: 0,
history: PlotHistory::default(),
window_s: 5.0,
last_push: 0.0,
acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
cursor: None,
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
col_fill: 0.0,
}
}
}
@@ -83,31 +87,30 @@ impl Default for PlotState {
/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs
/// and flow-speed selector. History for all channels advances every frame regardless of the tab.
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
let now = ui.ctx().input(|i| i.time);
// (Re)initialise the cadence clock on first use or after a long gap (e.g. tab hidden).
if state.last_push <= 0.0 || now - state.last_push > state.window_s {
state.last_push = now;
}
// Accumulate this frame's block peaks into the column currently being built.
for i in 0..NUM_CHANNELS {
let in_db = util::gain_to_db(meters.plot_in[i].load(Ordering::Relaxed));
let out_db = util::gain_to_db(meters.plot_out[i].load(Ordering::Relaxed));
let gr = meters.plot_gr[i].load(Ordering::Relaxed);
state.acc[i].0 = state.acc[i].0.max(in_db);
state.acc[i].1 = state.acc[i].1.max(out_db);
state.acc[i].2 = state.acc[i].2.max(gr);
}
// Emit columns on a fixed time grid so the window length stays accurate regardless of the
// frame rate. The while loop is bounded by PLOT_N thanks to the resync above.
let dt_col = state.window_s / PLOT_N as f64;
let mut pushed = false;
while now - state.last_push >= dt_col {
state.history.push(&state.acc);
state.last_push += dt_col;
pushed = true;
}
if pushed {
state.acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
// Buckets that make up one column at the current flow speed (may be fractional).
let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
// Drain every bucket produced since the last frame (audio-clocked), folding them into columns.
// A fresh cursor starts at "now" so we don't replay stale buckets.
{
let w0 = meters.scope.write_index();
let cursor = state.cursor.get_or_insert(w0);
let history = &mut state.history;
let col_acc = &mut state.col_acc;
let col_fill = &mut state.col_fill;
meters.scope.drain(cursor, |in_lin, out_lin, gr_db| {
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_fill += 1.0;
while *col_fill >= buckets_per_col {
history.push(col_acc);
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
*col_fill -= buckets_per_col;
}
});
}
// Channel tabs + flow-speed selector + legend.
@@ -129,8 +132,8 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
if speed_changed {
// Cadence changed: start the history fresh so the time axis is consistent.
state.history = PlotHistory::default();
state.acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
state.last_push = now;
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
state.col_fill = 0.0;
}
ui.separator();
ui.colored_label(COLOR_IN, "in");
@@ -175,18 +178,18 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx]))));
}
if fill {
// Translucent area from the line down to the bottom of the plot.
let mut area = Vec::with_capacity(pts.len() + 2);
area.push(pos2(pts[0].x, bottom));
area.extend_from_slice(&pts);
area.push(pos2(pts[pts.len() - 1].x, bottom));
// Fill as a strip of per-segment convex quads down to the baseline. A single
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
// triangles), so build convex pieces — one box per time unit — instead.
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
p.add(egui::Shape::Path(egui::epaint::PathShape {
points: area,
closed: true,
fill: fill_col,
stroke: egui::epaint::PathStroke::NONE,
}));
for seg in pts.windows(2) {
let (a, b) = (seg[0], seg[1]);
p.add(egui::Shape::convex_polygon(
vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
fill_col,
Stroke::NONE,
));
}
}
p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
};