9a60b0ea72
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>
144 lines
6.1 KiB
Rust
144 lines
6.1 KiB
Rust
//! Per-channel level + gain-reduction meters, each with its own latching ceiling/over lamp.
|
|
//!
|
|
//! Each channel is a `|L | GR | R|` cluster (output level left/right, mono gain reduction in the
|
|
//! middle) topped by a lamp. The lamp latches when the channel's output reaches 0 dBFS (a hot /
|
|
//! "over" warning — useful when pre-gain drives a band hard); for the ALL channel it also lights
|
|
//! when the output limiter is actually catching peaks. It holds, then clears after `LAMP_HOLD_S`
|
|
//! or on a click. Fed by the lock-free [`Meters`] state the audio thread publishes each block.
|
|
|
|
use nih_plug::prelude::*;
|
|
use nih_plug_egui::egui::{
|
|
self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Painter, Rect, Sense,
|
|
};
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use super::METER_FLOOR_DB;
|
|
use crate::meters::{Meters, NUM_CHANNELS};
|
|
|
|
/// Full-scale of the gain-reduction bar (fills downward from the top).
|
|
const GR_FULL_DB: f32 = 24.0;
|
|
/// Output level (dBFS) at/above which a channel's lamp latches on.
|
|
const OVER_DB: f32 = 0.0;
|
|
/// Limiter gain reduction (dB) above which the ALL channel's lamp also latches on.
|
|
const LAMP_TRIGGER_DB: f32 = 0.1;
|
|
/// How long a lamp stays lit after the most recent trigger (seconds).
|
|
const LAMP_HOLD_S: f64 = 3.0;
|
|
/// Height of the meter panel.
|
|
const METER_PANEL_H: f32 = 140.0;
|
|
|
|
/// GUI-side state for the meter panel: one lamp latch per channel.
|
|
pub(super) struct MeterState {
|
|
/// egui time (seconds) of each channel's most recent lamp trigger, while latched on.
|
|
/// `None` = lamp off (never triggered, expired, or dismissed by a click).
|
|
ceiling_trigger: [Option<f64>; NUM_CHANNELS],
|
|
}
|
|
|
|
impl Default for MeterState {
|
|
fn default() -> Self {
|
|
Self { ceiling_trigger: [None; NUM_CHANNELS] }
|
|
}
|
|
}
|
|
|
|
/// Draw the meter panel: a `|L | GR | R|` cluster + a latching over/ceiling lamp per channel.
|
|
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
|
let now = ui.ctx().input(|i| i.time);
|
|
let (rect, _) =
|
|
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
|
|
let p = ui.painter_at(rect);
|
|
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24));
|
|
|
|
let top = rect.top() + 22.0; // leave a row at the top for the lamps
|
|
let bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
|
|
let cell_w = rect.width() / NUM_CHANNELS as f32;
|
|
// Three bars per cluster, so they're narrower than a two-bar layout.
|
|
let bar_w = (cell_w * 0.17).min(14.0);
|
|
let gap = (cell_w * 0.05).min(5.0);
|
|
|
|
for i in 0..NUM_CHANNELS {
|
|
let cell_left = rect.left() + i as f32 * cell_w;
|
|
let group_w = bar_w * 3.0 + gap * 2.0;
|
|
let bx = cell_left + (cell_w - group_w) * 0.5;
|
|
|
|
// L / R output level (upward); colour warns as it nears 0 dBFS.
|
|
let l_db = util::gain_to_db(meters.level_l[i].load(Ordering::Relaxed));
|
|
let r_db = util::gain_to_db(meters.level_r[i].load(Ordering::Relaxed));
|
|
let l_frac = ((l_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
|
let r_frac = ((r_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
|
|
|
// Mono gain reduction (downward from the top).
|
|
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
|
|
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
|
|
|
|
v_bar(&p, bx, bar_w, top, bottom, l_frac, level_color(l_db), false);
|
|
v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, Color32::from_rgb(240, 150, 60), true);
|
|
v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, level_color(r_db), false);
|
|
|
|
p.text(
|
|
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
|
|
Align2::CENTER_BOTTOM,
|
|
labels[i],
|
|
FontId::proportional(12.0),
|
|
Color32::from_gray(200),
|
|
);
|
|
|
|
// 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).
|
|
let over_db = l_db.max(r_db);
|
|
let mut triggered = over_db >= OVER_DB;
|
|
if i == NUM_CHANNELS - 1 {
|
|
triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB;
|
|
}
|
|
if triggered {
|
|
state.ceiling_trigger[i] = Some(now);
|
|
}
|
|
|
|
let lamp_center = pos2(cell_left + cell_w * 0.5, rect.top() + 11.0);
|
|
let lamp_rect = Rect::from_center_size(lamp_center, vec2(18.0, 18.0));
|
|
let resp = ui
|
|
.interact(lamp_rect, ui.id().with(("ceiling_lamp", i)), Sense::click())
|
|
.on_hover_cursor(CursorIcon::PointingHand)
|
|
.on_hover_text("Output reached 0 dBFS — click to clear");
|
|
if resp.clicked() {
|
|
state.ceiling_trigger[i] = None;
|
|
}
|
|
if let Some(t) = state.ceiling_trigger[i] {
|
|
if now - t >= LAMP_HOLD_S {
|
|
state.ceiling_trigger[i] = None;
|
|
}
|
|
}
|
|
let lamp = if state.ceiling_trigger[i].is_some() {
|
|
Color32::from_rgb(255, 40, 40)
|
|
} else {
|
|
Color32::from_rgb(40, 12, 12)
|
|
};
|
|
p.circle_filled(lamp_center, 5.0, lamp);
|
|
}
|
|
}
|
|
|
|
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the
|
|
/// top (gain reduction) instead of upward from the bottom (level).
|
|
fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
|
|
let track = Color32::from_rgb(34, 34, 40);
|
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track);
|
|
|
|
let h = (bottom - top) * frac.clamp(0.0, 1.0);
|
|
let filled = if from_top {
|
|
Rect::from_min_max(pos2(x, top), pos2(x + w, top + h))
|
|
} else {
|
|
Rect::from_min_max(pos2(x, bottom - h), pos2(x + w, bottom))
|
|
};
|
|
p.rect_filled(filled, CornerRadius::ZERO, fill);
|
|
}
|
|
|
|
/// Level-bar colour: green below -6 dB, yellow approaching, red near 0 dBFS.
|
|
fn level_color(db: f32) -> Color32 {
|
|
if db >= -1.0 {
|
|
Color32::from_rgb(235, 70, 60)
|
|
} else if db >= -6.0 {
|
|
Color32::from_rgb(230, 200, 70)
|
|
} else {
|
|
Color32::from_rgb(90, 200, 110)
|
|
}
|
|
}
|