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:
+102
-32
@@ -1,37 +1,38 @@
|
||||
//! 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);
|
||||
//! the editor is the single reader (once per frame). All access is wait-free via atomics, so the
|
||||
//! 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.
|
||||
//! Two feeds, both written by `process()` (single producer) and read by the editor (single
|
||||
//! consumer), all wait-free:
|
||||
//!
|
||||
//! * **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 std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
|
||||
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 {
|
||||
/// Left output level per channel as a **linear** peak. Peak-with-decay.
|
||||
pub level_l: [AtomicF32; NUM_CHANNELS],
|
||||
/// Right output level per channel (== left for mono signals). Stored separately so the planned
|
||||
/// `|L|GR|R|` layout is a pure editor change; the current bars render `max(L, R)`.
|
||||
/// Right output level per channel (== left for mono signals).
|
||||
pub level_r: [AtomicF32; NUM_CHANNELS],
|
||||
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
|
||||
/// stereo-linked, so the same gain applies to both 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,
|
||||
|
||||
// --- Scrolling plot feed: instantaneous block peaks, NOT decayed. The editor samples these
|
||||
// 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],
|
||||
/// Bucket stream feeding the scrolling in/out/GR plot.
|
||||
pub scope: ScopeRing,
|
||||
}
|
||||
|
||||
impl Default for Meters {
|
||||
@@ -41,35 +42,25 @@ impl Default for Meters {
|
||||
level_r: 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),
|
||||
plot_in: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
plot_out: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
plot_gr: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
scope: ScopeRing::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Meters {
|
||||
/// Zero every meter. Called from the plugin's `reset()` (transport restart / sample-rate
|
||||
/// change) so the display starts from silence rather than stale values. Real-time safe.
|
||||
/// Zero the bar meters. Called from the plugin's `reset()` (transport restart / sample-rate
|
||||
/// 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) {
|
||||
for i in 0..NUM_CHANNELS {
|
||||
self.level_l[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.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user