From 0c2e1597a1cbacada7617167f11dbe429f34146b Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 24 Jun 2026 05:02:39 +0900 Subject: [PATCH] feat: per-channel ceiling lamps in the meter widget Replace the single global ceiling lamp with one lamp per channel cluster. Each latches when that channel's output reaches 0 dBFS (a hot/over warning, handy when pre-gain drives a band); the ALL channel additionally lights on a real output-limiter catch. Same latch/hold/click-to-clear behavior, now per channel. Co-Authored-By: Claude Opus 4.8 --- src/editor/meter.rs | 97 ++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 45 deletions(-) diff --git a/src/editor/meter.rs b/src/editor/meter.rs index 450ce12..53da399 100644 --- a/src/editor/meter.rs +++ b/src/editor/meter.rs @@ -1,7 +1,9 @@ -//! Per-channel level + gain-reduction meters and the latching ceiling lamp. +//! 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). The ceiling lamp latches on a limiter catch and holds, clearing after `LAMP_HOLD_S` +//! 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::*; @@ -15,22 +17,29 @@ 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; -/// Limiter gain reduction (dB) above which the ceiling lamp latches on. +/// 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 the ceiling lamp stays lit after the most recent catch (seconds). +/// 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 = 130.0; +const METER_PANEL_H: f32 = 140.0; -/// GUI-side state for the meter panel. -#[derive(Default)] +/// GUI-side state for the meter panel: one lamp latch per channel. pub(super) struct MeterState { - /// egui time (seconds) of the most recent ceiling catch, while the lamp is latched on. - /// `None` = lamp off (never caught, expired, or dismissed by a click). - ceiling_trigger: Option, + /// 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; NUM_CHANNELS], } -/// Draw the meter panel: a `|L | GR | R|` cluster per channel plus the latching ceiling lamp. +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); @@ -39,8 +48,8 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) { let p = ui.painter_at(rect); p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24)); - let top = rect.top() + 10.0; - let bottom = rect.bottom() - 18.0; // leave a row for the labels + 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); @@ -72,41 +81,39 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) { FontId::proportional(12.0), Color32::from_gray(200), ); - } - // Ceiling lamp (top-right): latches on when the limiter catches a peak, then holds. It clears - // after LAMP_HOLD_S or when clicked. Re-arms while limiting is ongoing. - if meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB { - state.ceiling_trigger = Some(now); - } - let center = pos2(rect.right() - 14.0, rect.top() + 14.0); - let lamp_rect = Rect::from_center_size(center, vec2(22.0, 22.0)); - let resp = ui - .interact(lamp_rect, ui.id().with("ceiling_lamp"), Sense::click()) - .on_hover_cursor(CursorIcon::PointingHand) - .on_hover_text("Ceiling reached — click to clear"); - if resp.clicked() { - state.ceiling_trigger = None; - } - // Expire the latch once the hold time has passed. - if let Some(t) = state.ceiling_trigger { - if now - t >= LAMP_HOLD_S { - state.ceiling_trigger = None; + // 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 = util::gain_to_db(meters.plot_out[i].load(Ordering::Relaxed)); + 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); } - let lamp = if state.ceiling_trigger.is_some() { - Color32::from_rgb(255, 40, 40) - } else { - Color32::from_rgb(40, 12, 12) - }; - p.circle_filled(center, 7.0, lamp); - p.text( - pos2(center.x - 14.0, center.y), - Align2::RIGHT_CENTER, - "CEILING", - FontId::proportional(11.0), - Color32::from_gray(180), - ); } /// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the