Files
codename-206/src/meters.rs
T
Mikkeli Matlock a8be2179c3 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>
2026-06-24 22:16:33 +09:00

169 lines
7.2 KiB
Rust

//! Lock-free meter state shared from the audio thread to the editor.
//!
//! 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::{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).
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)** — feeds the ALL channel's ceiling lamp.
pub limiter_gr_db: AtomicF32,
/// Bucket stream feeding the scrolling in/out/GR plot.
pub scope: ScopeRing,
}
impl Default for Meters {
fn default() -> Self {
Self {
level_l: 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)),
limiter_gr_db: AtomicF32::new(0.0),
scope: ScopeRing::default(),
}
}
}
impl Meters {
/// 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.limiter_gr_db.store(0.0, 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.
pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
let current = meter.load(Ordering::Relaxed);
let next = if block_value > current {
block_value
} else {
current * decay_weight + block_value * (1.0 - decay_weight)
};
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>,
/// 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.
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(),
hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(),
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],
hit: f32,
) {
let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write`
let slot = w as usize % RING_N;
let base = slot * 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);
}
self.hit[slot].store(hit, 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], f32),
) {
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 slot = *cursor as usize % RING_N;
let base = slot * 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);
}
let hit = self.hit[slot].load(Ordering::Relaxed);
on_bucket(&in_buf, &out_buf, &gr_buf, hit);
*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)
}
}