diff --git a/src/editor.rs b/src/editor.rs index 67a9ed2..90c435e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,17 +1,19 @@ //! egui editor. //! -//! Placeholder layout for now — Stage 6 replaces it with meters, gain-reduction displays, a -//! gain-curve view and draggable crossover handles. Built to stay usable meanwhile: a resizable -//! window with a vertical scroll area so every control is reachable at any window size, global -//! controls in a label|slider grid, and the four channels (low/mid/high/all) side by side. +//! Placeholder control layout for now — Stage 6 will keep adding visualisers (a rolling +//! reduction/in/out plot next, then a gain-curve view and draggable crossover handles) and +//! eventually replace the slider columns. Built to stay usable meanwhile: a resizable window with +//! a vertical scroll area so every control is reachable at any size, global controls in a +//! label|slider grid, and the four channels (low/mid/high/all) side by side. //! -//! First real visualiser: a row of per-channel meters (output level + gain reduction) plus a -//! ceiling lamp, fed by the lock-free [`Meters`] state the audio thread publishes each block. +//! Meters: a per-channel `|L | GR | R|` cluster (output level left/right + mono gain reduction in +//! the middle) plus a latching ceiling lamp, fed by the lock-free [`Meters`] state the audio +//! thread publishes each block. use nih_plug::prelude::*; use nih_plug_egui::{ create_egui_editor, - egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Painter, Rect, Sense, Vec2}, + egui::{self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Painter, Rect, Sense, Stroke, Vec2}, resizable_window::ResizableWindow, widgets, }; @@ -26,20 +28,96 @@ use crate::Codename206; const METER_FLOOR_DB: f32 = -60.0; /// Full-scale of the gain-reduction meter (bar fills downward from the top). const GR_FULL_DB: f32 = 24.0; -/// Limiter gain reduction (dB) at which the ceiling lamp is fully lit. -const LAMP_FULL_DB: f32 = 3.0; +/// Limiter gain reduction (dB) above which the ceiling lamp latches on. +const LAMP_TRIGGER_DB: f32 = 0.1; +/// How long the ceiling lamp stays lit after the most recent catch (seconds). +const LAMP_HOLD_S: f64 = 3.0; /// Height of the meter panel. const METER_PANEL_H: f32 = 130.0; +/// Height of the scrolling-plot panel. +const PLOT_PANEL_H: f32 = 150.0; +/// Number of frames held in the scrolling-plot history (~a few seconds at ~60 fps). +const PLOT_N: usize = 256; + +const COLOR_IN: Color32 = Color32::from_rgb(90, 170, 235); +const COLOR_OUT: Color32 = Color32::from_rgb(90, 200, 110); +const COLOR_GR: Color32 = Color32::from_rgb(240, 150, 60); + +/// Rolling history for the in/out/GR plot — kept for ALL channels at once (cheap: ~12 KB), so +/// switching the selected tab shows that channel's existing history rather than restarting blank. +/// It's a per-channel ring sampled once per GUI frame (wall-clock, not sample-accurate). +struct PlotHistory { + in_db: [[f32; PLOT_N]; NUM_CHANNELS], + out_db: [[f32; PLOT_N]; NUM_CHANNELS], + gr_db: [[f32; PLOT_N]; NUM_CHANNELS], + write: usize, + len: usize, +} + +impl Default for PlotHistory { + fn default() -> Self { + Self { + in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS], + out_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS], + gr_db: [[0.0; PLOT_N]; NUM_CHANNELS], + write: 0, + len: 0, + } + } +} + +impl PlotHistory { + /// Append one frame of (in_db, out_db, gr_db) per channel. + fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS]) { + for i in 0..NUM_CHANNELS { + self.in_db[i][self.write] = samples[i].0; + self.out_db[i][self.write] = samples[i].1; + self.gr_db[i][self.write] = samples[i].2; + } + self.write = (self.write + 1) % PLOT_N; + self.len = (self.len + 1).min(PLOT_N); + } +} + +/// GUI-side editor state (not persisted): ceiling-lamp latch, selected plot channel, plot history, +/// and the time-based scroll cadence (flow speed = how many seconds span the plot width). +struct EditorState { + /// 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, + /// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). + selected: usize, + history: PlotHistory, + /// Seconds of history shown across the full plot width — the flow speed (smaller = faster). + window_s: f64, + /// egui time of the last column pushed to the history ring (the cadence clock). + last_push: f64, + /// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built. + acc: [(f32, f32, f32); NUM_CHANNELS], +} + +impl Default for EditorState { + fn default() -> Self { + Self { + ceiling_trigger: None, + selected: 0, + history: PlotHistory::default(), + window_s: 5.0, + last_push: 0.0, + acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS], + } + } +} /// Build the plugin editor over shared handles to the params and meter state. pub(crate) fn create(params: Arc, meters: Arc) -> Option> { let egui_state = params.editor_state.clone(); create_egui_editor( params.editor_state.clone(), - (), + EditorState::default(), |_, _| {}, - move |egui_ctx, setter, _state| { - // Keep frames coming so the meters animate while the editor is open. + move |egui_ctx, setter, state| { + // Keep frames coming so the meters animate and the lamp can time out while open. egui_ctx.request_repaint(); // One column of controls for a single compressor channel. @@ -70,7 +148,9 @@ pub(crate) fn create(params: Arc, meters: Arc) -> Opt .show(egui_ctx, egui_state.as_ref(), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.heading(Codename206::NAME); - draw_meters(ui, &meters); + draw_meters(ui, &meters, state); + ui.separator(); + draw_plot(ui, &meters, state); ui.separator(); // Global controls stacked vertically so they never overflow sideways. egui::Grid::new("globals").num_columns(2).show(ui, |ui| { @@ -103,10 +183,11 @@ pub(crate) fn create(params: Arc, meters: Arc) -> Opt ) } -/// Draw the meter panel: one channel group per column (mono output level + gain reduction), -/// plus the ceiling lamp driven by the output limiter. -fn draw_meters(ui: &mut egui::Ui, meters: &Meters) { +/// Draw the meter panel: one channel group per column as `|L | GR | R|` (output level left/right, +/// mono gain reduction in the middle), plus the latching ceiling lamp driven by the limiter. +fn draw_meters(ui: &mut egui::Ui, meters: &Meters, state: &mut EditorState) { 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); @@ -115,25 +196,28 @@ fn draw_meters(ui: &mut egui::Ui, meters: &Meters) { let top = rect.top() + 10.0; let bottom = rect.bottom() - 18.0; // leave a row for the labels let cell_w = rect.width() / NUM_CHANNELS as f32; - let bar_w = (cell_w * 0.26).min(22.0); - let gap = (cell_w * 0.08).min(8.0); + // Three bars per cluster now, so they're narrower than the old 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 * 2.0 + gap; + let group_w = bar_w * 3.0 + gap * 2.0; let bx = cell_left + (cell_w - group_w) * 0.5; - // Level bar (mono = max of L/R), fills upward; colour warns as it nears 0 dBFS. - let level_lin = - meters.level_l[i].load(Ordering::Relaxed).max(meters.level_r[i].load(Ordering::Relaxed)); - let level_db = util::gain_to_db(level_lin); - let level_frac = ((level_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0); - v_bar(&p, bx, bar_w, top, bottom, level_frac, level_color(level_db), false); + // 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); - // Gain-reduction bar, fills downward from the top. + // 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), @@ -144,14 +228,31 @@ fn draw_meters(ui: &mut egui::Ui, meters: &Meters) { ); } - // Ceiling lamp (top-right): dark when idle, bright red while the limiter is catching peaks. - let lit = (meters.limiter_gr_db.load(Ordering::Relaxed) / LAMP_FULL_DB).clamp(0.0, 1.0); - let lamp = Color32::from_rgb( - (40.0 + 215.0 * lit) as u8, - (12.0 + 28.0 * lit) as u8, - (12.0 + 28.0 * lit) as u8, - ); + // 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; + } + } + 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), @@ -162,6 +263,110 @@ fn draw_meters(ui: &mut egui::Ui, meters: &Meters) { ); } +/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs. +/// History for all channels is sampled every frame (wall-clock), so it scrolls continuously while +/// the editor is open regardless of which tab is shown. +fn draw_plot(ui: &mut egui::Ui, meters: &Meters, state: &mut EditorState) { + let now = ui.ctx().input(|i| i.time); + // (Re)initialise the cadence clock on first use or after a long gap (e.g. tab hidden). + if state.last_push <= 0.0 || now - state.last_push > state.window_s { + state.last_push = now; + } + // Accumulate this frame's block peaks into the column currently being built. + for i in 0..NUM_CHANNELS { + let in_db = util::gain_to_db(meters.plot_in[i].load(Ordering::Relaxed)); + let out_db = util::gain_to_db(meters.plot_out[i].load(Ordering::Relaxed)); + let gr = meters.plot_gr[i].load(Ordering::Relaxed); + state.acc[i].0 = state.acc[i].0.max(in_db); + state.acc[i].1 = state.acc[i].1.max(out_db); + state.acc[i].2 = state.acc[i].2.max(gr); + } + // Emit columns on a fixed time grid so the window length stays accurate regardless of the + // frame rate. The while loop is bounded by PLOT_N thanks to the resync above. + let dt_col = state.window_s / PLOT_N as f64; + let mut pushed = false; + while now - state.last_push >= dt_col { + state.history.push(&state.acc); + state.last_push += dt_col; + pushed = true; + } + if pushed { + state.acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS]; + } + + // Channel tabs + flow-speed selector + legend. + let labels = ["LOW", "MID", "HIGH", "ALL"]; + let speeds = [2.0f64, 5.0, 15.0, 45.0]; + ui.horizontal(|ui| { + ui.label("Plot:"); + for (i, l) in labels.iter().enumerate() { + ui.selectable_value(&mut state.selected, i, *l); + } + ui.separator(); + ui.label("Speed:"); + let mut speed_changed = false; + for &w in &speeds { + if ui.selectable_value(&mut state.window_s, w, format!("{w:.0}s")).changed() { + speed_changed = true; + } + } + if speed_changed { + // Cadence changed: start the history fresh so the time axis is consistent. + state.history = PlotHistory::default(); + state.acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS]; + state.last_push = now; + } + ui.separator(); + ui.colored_label(COLOR_IN, "in"); + ui.colored_label(COLOR_OUT, "out"); + ui.colored_label(COLOR_GR, "GR"); + }); + + let (rect, _) = + ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20)); + + let (top, bottom, left, right) = + (rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0); + let width = right - left; + let y_for_db = |db: f32| -> f32 { + let frac = ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0); + bottom - frac * (bottom - top) + }; + + // Gridlines (dB). + for &g in &[0.0f32, -12.0, -24.0, -48.0] { + let y = y_for_db(g); + p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, Color32::from_gray(40))); + p.text( + pos2(left + 2.0, y), + Align2::LEFT_BOTTOM, + format!("{g:.0}"), + FontId::proportional(9.0), + Color32::from_gray(90), + ); + } + + let c = state.selected.min(NUM_CHANNELS - 1); + let (write, len) = (state.history.write, state.history.len); + if len >= 2 { + let draw_series = |series: &[f32; PLOT_N], to_db: &dyn Fn(f32) -> f32, color: Color32| { + let mut pts = Vec::with_capacity(len); + for k in 0..len { + let idx = (write + PLOT_N - len + k) % PLOT_N; + let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32; // newest hugs the right + pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx])))); + } + p.add(egui::Shape::line(pts, Stroke::new(1.5, color))); + }; + draw_series(&state.history.in_db[c], &|db| db, COLOR_IN); + draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT); + // GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline. + draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR); + } +} + /// 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) { diff --git a/src/lib.rs b/src/lib.rs index ebb9897..e698e59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,6 +140,8 @@ impl Plugin for Codename206 { comp.reset(); } self.limiter.reset(); + // Transport restart / sample-rate change: drop stale meter values to silence. + self.meters.clear(); } fn process( @@ -176,6 +178,7 @@ impl Plugin for Codename206 { let num_samples = buffer.samples(); let mut lvl_l = [0.0f32; meters::NUM_CHANNELS]; let mut lvl_r = [0.0f32; meters::NUM_CHANNELS]; + let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input peak (for the scrolling plot) let mut gr = [0.0f32; meters::NUM_CHANNELS]; let mut lim_gr = 0.0f32; @@ -214,6 +217,7 @@ impl Plugin for Codename206 { summed[ch] += band_out[b][ch]; } if metering { + inp[b] = inp[b].max(band_in[b][0].abs().max(band_in[b][r].abs())); lvl_l[b] = lvl_l[b].max(band_out[b][0].abs()); lvl_r[b] = lvl_r[b].max(band_out[b][r].abs()); gr[b] = gr[b] @@ -233,6 +237,7 @@ impl Plugin for Codename206 { self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release); if metering { + inp[ALL] = inp[ALL].max(summed[0].abs().max(summed[r].abs())); lvl_l[ALL] = lvl_l[ALL].max(out_frame[0].abs()); lvl_r[ALL] = lvl_r[ALL].max(out_frame[r].abs()); gr[ALL] = gr[ALL] @@ -254,6 +259,10 @@ impl Plugin for Codename206 { meters::decay_store(&self.meters.level_l[i], lvl_l[i], w); meters::decay_store(&self.meters.level_r[i], lvl_r[i], w); meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w); + // Raw block peaks for the scrolling plot (editor keeps its own history). + meters::store_instant(&self.meters.plot_in[i], inp[i]); + meters::store_instant(&self.meters.plot_out[i], lvl_l[i].max(lvl_r[i])); + meters::store_instant(&self.meters.plot_gr[i], gr[i]); } meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w); } diff --git a/src/meters.rs b/src/meters.rs index a8a78ca..4b173db 100644 --- a/src/meters.rs +++ b/src/meters.rs @@ -22,6 +22,16 @@ pub struct Meters { pub gain_reduction_db: [AtomicF32; NUM_CHANNELS], /// Output limiter gain reduction in **dB (>= 0)** — drives the 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], } impl Default for Meters { @@ -31,10 +41,35 @@ 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)), } } } +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. + 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.