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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-24 05:02:39 +09:00
parent eb2499bad3
commit 0c2e1597a1
+52 -45
View File
@@ -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<f64>,
/// 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],
}
/// 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