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
+1 -1
View File
@@ -84,7 +84,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the // Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
// output limiter is catching peaks (the true master-ceiling event). // output limiter is catching peaks (the true master-ceiling event).
let over_db = util::gain_to_db(meters.plot_out[i].load(Ordering::Relaxed)); let over_db = l_db.max(r_db);
let mut triggered = over_db >= OVER_DB; let mut triggered = over_db >= OVER_DB;
if i == NUM_CHANNELS - 1 { if i == NUM_CHANNELS - 1 {
triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB; triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB;
+50 -47
View File
@@ -1,16 +1,16 @@
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector. //! 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 //! 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) //! channel's existing history. It's fed by draining the audio thread's scope ring
//! so the window length — the flow speed — stays accurate regardless of frame rate, with //! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
//! peak-preserving max accumulation between columns. Fed by the raw block-peak [`Meters`] feed. //! 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::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, Sense, Stroke};
use std::sync::atomic::Ordering;
use super::METER_FLOOR_DB; use super::METER_FLOOR_DB;
use crate::meters::{Meters, NUM_CHANNELS}; use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
/// Height of the plot panel. /// Height of the plot panel.
const PLOT_PANEL_H: f32 = 150.0; 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 { pub(super) struct PlotState {
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). /// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all).
selected: usize, selected: usize,
history: PlotHistory, history: PlotHistory,
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster). /// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
window_s: f64, window_s: f64,
/// egui time of the last column pushed to the history ring (the cadence clock). /// Read position into the scope ring; `None` until the first frame (then starts at "now").
last_push: f64, 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.
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 { impl Default for PlotState {
@@ -74,8 +77,9 @@ impl Default for PlotState {
selected: 0, selected: 0,
history: PlotHistory::default(), history: PlotHistory::default(),
window_s: 5.0, window_s: 5.0,
last_push: 0.0, cursor: None,
acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS], 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 /// 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. /// 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) { pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
let now = ui.ctx().input(|i| i.time); // Buckets that make up one column at the current flow speed (may be fractional).
// (Re)initialise the cadence clock on first use or after a long gap (e.g. tab hidden). let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
if state.last_push <= 0.0 || now - state.last_push > state.window_s {
state.last_push = now; // 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]);
} }
// Accumulate this frame's block peaks into the column currently being built. *col_fill += 1.0;
for i in 0..NUM_CHANNELS { while *col_fill >= buckets_per_col {
let in_db = util::gain_to_db(meters.plot_in[i].load(Ordering::Relaxed)); history.push(col_acc);
let out_db = util::gain_to_db(meters.plot_out[i].load(Ordering::Relaxed)); *col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
let gr = meters.plot_gr[i].load(Ordering::Relaxed); *col_fill -= buckets_per_col;
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];
} }
// Channel tabs + flow-speed selector + legend. // 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 { if speed_changed {
// 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.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.last_push = now; state.col_fill = 0.0;
} }
ui.separator(); ui.separator();
ui.colored_label(COLOR_IN, "in"); 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])))); pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx]))));
} }
if fill { if fill {
// Translucent area from the line down to the bottom of the plot. // Fill as a strip of per-segment convex quads down to the baseline. A single
let mut area = Vec::with_capacity(pts.len() + 2); // concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
area.push(pos2(pts[0].x, bottom)); // triangles), so build convex pieces — one box per time unit — instead.
area.extend_from_slice(&pts);
area.push(pos2(pts[pts.len() - 1].x, bottom));
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40); let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
p.add(egui::Shape::Path(egui::epaint::PathShape { for seg in pts.windows(2) {
points: area, let (a, b) = (seg[0], seg[1]);
closed: true, p.add(egui::Shape::convex_polygon(
fill: fill_col, vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
stroke: egui::epaint::PathStroke::NONE, fill_col,
})); Stroke::NONE,
));
}
} }
p.add(egui::Shape::line(pts, Stroke::new(1.5, color))); p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
}; };
+54 -16
View File
@@ -40,6 +40,15 @@ struct Codename206 {
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to /// 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`). /// the block length when applied once per block in `process`).
meter_decay_weight: f32, meter_decay_weight: f32,
/// Per-channel max accumulators for the plot bucket currently being built (in/out linear, GR
/// dB). Persist across blocks since a bucket spans many samples.
scope_in: [f32; 4],
scope_out: [f32; 4],
scope_gr: [f32; 4],
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
scope_samples: usize,
scope_bucket_len: usize,
} }
impl Default for Codename206 { impl Default for Codename206 {
@@ -52,6 +61,11 @@ impl Default for Codename206 {
limiter: Limiter::new(), limiter: Limiter::new(),
meters: Arc::new(Meters::default()), meters: Arc::new(Meters::default()),
meter_decay_weight: 1.0, meter_decay_weight: 1.0,
scope_in: [0.0; 4],
scope_out: [0.0; 4],
scope_gr: [0.0; 4],
scope_samples: 0,
scope_bucket_len: 1,
} }
} }
} }
@@ -115,6 +129,10 @@ impl Plugin for Codename206 {
self.meter_decay_weight = self.meter_decay_weight =
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32; 0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
// Plot bucket length: emit a scope bucket every ~1/BUCKET_HZ seconds.
self.scope_bucket_len =
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
for comp in &mut self.comps { for comp in &mut self.comps {
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS); comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
} }
@@ -140,8 +158,13 @@ impl Plugin for Codename206 {
comp.reset(); comp.reset();
} }
self.limiter.reset(); self.limiter.reset();
// Transport restart / sample-rate change: drop stale meter values to silence. // Transport restart / sample-rate change: drop stale meter values to silence and discard
// the in-flight plot bucket.
self.meters.clear(); self.meters.clear();
self.scope_in = [0.0; 4];
self.scope_out = [0.0; 4];
self.scope_gr = [0.0; 4];
self.scope_samples = 0;
} }
fn process( fn process(
@@ -178,7 +201,6 @@ impl Plugin for Codename206 {
let num_samples = buffer.samples(); let num_samples = buffer.samples();
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS]; let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS]; let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input peak (for the scrolling plot)
let mut gr = [0.0f32; meters::NUM_CHANNELS]; let mut gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32; let mut lim_gr = 0.0f32;
@@ -217,11 +239,16 @@ impl Plugin for Codename206 {
summed[ch] += band_out[b][ch]; summed[ch] += band_out[b][ch];
} }
if metering { if metering {
inp[b] = inp[b].max(band_in[b][0].abs().max(band_in[b][r].abs())); let in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
lvl_l[b] = lvl_l[b].max(band_out[b][0].abs()); let out_l = band_out[b][0].abs();
lvl_r[b] = lvl_r[b].max(band_out[b][r].abs()); let out_r = band_out[b][r].abs();
gr[b] = gr[b] let g = if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() };
.max(if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() }); lvl_l[b] = lvl_l[b].max(out_l);
lvl_r[b] = lvl_r[b].max(out_r);
gr[b] = gr[b].max(g);
self.scope_in[b] = self.scope_in[b].max(in_mono);
self.scope_out[b] = self.scope_out[b].max(out_l.max(out_r));
self.scope_gr[b] = self.scope_gr[b].max(g);
} }
} }
@@ -237,12 +264,27 @@ impl Plugin for Codename206 {
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release); self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
if metering { if metering {
inp[ALL] = inp[ALL].max(summed[0].abs().max(summed[r].abs())); let in_mono = summed[0].abs().max(summed[r].abs());
lvl_l[ALL] = lvl_l[ALL].max(out_frame[0].abs()); let out_l = out_frame[0].abs();
lvl_r[ALL] = lvl_r[ALL].max(out_frame[r].abs()); let out_r = out_frame[r].abs();
gr[ALL] = gr[ALL] let g = if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() };
.max(if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() }); lvl_l[ALL] = lvl_l[ALL].max(out_l);
lvl_r[ALL] = lvl_r[ALL].max(out_r);
gr[ALL] = gr[ALL].max(g);
lim_gr = lim_gr.max(self.limiter.gain_reduction_db()); lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
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);
// 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);
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_samples = 0;
}
} }
for ch in 0..n { for ch in 0..n {
@@ -259,10 +301,6 @@ impl Plugin for Codename206 {
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w); 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.level_r[i], lvl_r[i], w);
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w); meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
// Raw block peaks for the scrolling plot (editor keeps its own history).
meters::store_instant(&self.meters.plot_in[i], inp[i]);
meters::store_instant(&self.meters.plot_out[i], lvl_l[i].max(lvl_r[i]));
meters::store_instant(&self.meters.plot_gr[i], gr[i]);
} }
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w); meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
} }
+102 -32
View File
@@ -1,37 +1,38 @@
//! Lock-free meter state shared from the audio thread to the editor. //! Lock-free meter state shared from the audio thread to the editor.
//! //!
//! `process()` is the single writer (one store per value per block — decimated, not per sample); //! Two feeds, both written by `process()` (single producer) and read by the editor (single
//! the editor is the single reader (once per frame). All access is wait-free via atomics, so the //! consumer), all wait-free:
//! realtime thread never blocks. Values are plain scalars (no streaming history yet) — enough for //!
//! the per-channel level + gain-reduction bars and the ceiling lamp. //! * **Bar meters** — decayed scalars per channel ([`Meters::level_l`] etc.), one store per block.
//! * **Scrolling plot** — a [`ScopeRing`] of raw buckets clocked at [`BUCKET_HZ`] (independent of
//! the GUI frame rate), so the plot's horizontal resolution isn't capped by the ~60 fps repaint.
use nih_plug::prelude::AtomicF32; use nih_plug::prelude::AtomicF32;
use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicU64, Ordering};
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors. /// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
pub const NUM_CHANNELS: usize = 4; pub const NUM_CHANNELS: usize = 4;
/// Rate the audio thread emits plot buckets at (Hz). Sets the plot's max horizontal resolution,
/// decoupled from the editor frame rate. ~5 ms per bucket.
pub const BUCKET_HZ: u32 = 200;
/// Buckets buffered between GUI drains. At [`BUCKET_HZ`] this is ~2.5 s of slack — far more than
/// the frame interval needs; if the GUI ever stalls longer, the oldest buckets are dropped.
const RING_N: usize = 512;
pub struct Meters { pub struct Meters {
/// Left output level per channel as a **linear** peak. Peak-with-decay. /// Left output level per channel as a **linear** peak. Peak-with-decay.
pub level_l: [AtomicF32; NUM_CHANNELS], pub level_l: [AtomicF32; NUM_CHANNELS],
/// Right output level per channel (== left for mono signals). Stored separately so the planned /// Right output level per channel (== left for mono signals).
/// `|L|GR|R|` layout is a pure editor change; the current bars render `max(L, R)`.
pub level_r: [AtomicF32; NUM_CHANNELS], pub level_r: [AtomicF32; NUM_CHANNELS],
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is /// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
/// stereo-linked, so the same gain applies to both channels. /// stereo-linked, so the same gain applies to both channels.
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS], pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
/// Output limiter gain reduction in **dB (>= 0)** — drives the ceiling lamp. /// Output limiter gain reduction in **dB (>= 0)** — feeds the ALL channel's ceiling lamp.
pub limiter_gr_db: AtomicF32, pub limiter_gr_db: AtomicF32,
/// Bucket stream feeding the scrolling in/out/GR plot.
// --- Scrolling plot feed: instantaneous block peaks, NOT decayed. The editor samples these pub scope: ScopeRing,
// each frame into its own history ring. Per channel: input level (entering the compressor),
// output level, and gain reduction.
/// Mono input level per channel (linear peak, post pre-gain, pre-compressor).
pub plot_in: [AtomicF32; NUM_CHANNELS],
/// Mono output level per channel (linear peak, post-compressor).
pub plot_out: [AtomicF32; NUM_CHANNELS],
/// Gain reduction per channel in dB (>= 0).
pub plot_gr: [AtomicF32; NUM_CHANNELS],
} }
impl Default for Meters { impl Default for Meters {
@@ -41,35 +42,25 @@ impl Default for Meters {
level_r: std::array::from_fn(|_| AtomicF32::new(0.0)), level_r: std::array::from_fn(|_| AtomicF32::new(0.0)),
gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)), gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
limiter_gr_db: AtomicF32::new(0.0), limiter_gr_db: AtomicF32::new(0.0),
plot_in: std::array::from_fn(|_| AtomicF32::new(0.0)), scope: ScopeRing::default(),
plot_out: std::array::from_fn(|_| AtomicF32::new(0.0)),
plot_gr: std::array::from_fn(|_| AtomicF32::new(0.0)),
} }
} }
} }
impl Meters { impl Meters {
/// Zero every meter. Called from the plugin's `reset()` (transport restart / sample-rate /// Zero the bar meters. Called from the plugin's `reset()` (transport restart / sample-rate
/// change) so the display starts from silence rather than stale values. Real-time safe. /// change) so the bars start from silence. The plot ring is left alone — it's continuous and
/// reflects the new (silent) buckets as they arrive. Real-time safe.
pub fn clear(&self) { pub fn clear(&self) {
for i in 0..NUM_CHANNELS { for i in 0..NUM_CHANNELS {
self.level_l[i].store(0.0, Ordering::Relaxed); self.level_l[i].store(0.0, Ordering::Relaxed);
self.level_r[i].store(0.0, Ordering::Relaxed); self.level_r[i].store(0.0, Ordering::Relaxed);
self.gain_reduction_db[i].store(0.0, Ordering::Relaxed); self.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
self.plot_in[i].store(0.0, Ordering::Relaxed);
self.plot_out[i].store(0.0, Ordering::Relaxed);
self.plot_gr[i].store(0.0, Ordering::Relaxed);
} }
self.limiter_gr_db.store(0.0, Ordering::Relaxed); self.limiter_gr_db.store(0.0, Ordering::Relaxed);
} }
} }
/// Store an instantaneous value (no smoothing) — used for the scrolling-plot feed, which the
/// editor smooths/decimates on its own.
pub fn store_instant(meter: &AtomicF32, value: f32) {
meter.store(value, Ordering::Relaxed);
}
/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a /// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters /// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
/// from flickering while staying responsive to transients. /// from flickering while staying responsive to transients.
@@ -82,3 +73,82 @@ pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
}; };
meter.store(next, Ordering::Relaxed); meter.store(next, Ordering::Relaxed);
} }
/// Lock-free single-producer/single-consumer ring of plot buckets. Each bucket holds a per-channel
/// (input level, output level, gain reduction) triple. The producer (audio thread) appends with
/// [`push`](ScopeRing::push); the consumer (GUI) reads new buckets with [`drain`](ScopeRing::drain),
/// tracking its own cursor. Per-field atomics avoid tearing; the consumer leaves one slot of margin
/// from the slot being written, so it never races the producer. If the consumer falls more than the
/// ring behind, the oldest buckets are silently dropped (a visual gap at worst).
pub struct ScopeRing {
/// `slot * NUM_CHANNELS + ch`, indexed by `bucket_index % RING_N`.
in_lin: Vec<AtomicF32>,
out_lin: Vec<AtomicF32>,
gr_db: Vec<AtomicF32>,
/// Monotonic count of buckets ever written.
write: AtomicU64,
}
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) }
}
}
impl ScopeRing {
/// Producer (audio thread): append one bucket of per-channel (in_lin, out_lin, gr_db).
pub fn push(
&self,
in_lin: &[f32; NUM_CHANNELS],
out_lin: &[f32; NUM_CHANNELS],
gr_db: &[f32; NUM_CHANNELS],
) {
let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write`
let base = (w as usize % RING_N) * 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);
}
// 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);
}
/// Consumer (GUI): call `on_bucket` for each bucket in `*cursor..write`, advancing `cursor`.
/// Skips ahead (dropping oldest) if the consumer fell more than the ring behind.
pub fn drain(
&self,
cursor: &mut u64,
mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS]),
) {
let w = self.write.load(Ordering::Acquire);
if *cursor > w {
*cursor = w; // counter went backwards (shouldn't happen) — resync
}
// Stay one slot clear of the slot currently being written.
let oldest = w.saturating_sub((RING_N - 1) as u64);
if *cursor < oldest {
*cursor = oldest;
}
let mut in_buf = [0.0f32; NUM_CHANNELS];
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;
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);
*cursor += 1;
}
}
/// Current write high-water mark (for a fresh consumer to start from "now").
pub fn write_index(&self) -> u64 {
self.write.load(Ordering::Acquire)
}
}