//! egui editor. //! //! 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. //! //! 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, CursorIcon, FontId, Painter, Rect, Sense, Stroke, Vec2}, resizable_window::ResizableWindow, widgets, }; use std::sync::atomic::Ordering; use std::sync::Arc; use crate::meters::{Meters, NUM_CHANNELS}; use crate::params::{Codename206Params, CompressorParams}; use crate::Codename206; /// Bottom of the level meter's dB scale (top is 0 dBFS). 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) 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 and the lamp can time out while open. egui_ctx.request_repaint(); // One column of controls for a single compressor channel. let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| { ui.strong(title); ui.label("Pre-gain"); ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter)); ui.add(widgets::ParamSlider::for_param(&p.detection, setter)); ui.label("Threshold"); ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter)); ui.label("Ratio"); ui.add(widgets::ParamSlider::for_param(&p.ratio, setter)); ui.label("Knee"); ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter)); ui.label("Attack"); ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter)); ui.label("Release"); ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter)); ui.label("Makeup"); ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter)); ui.add(widgets::ParamSlider::for_param(&p.bypass, setter)); }; // Resizable window; vertical scroll so every control stays reachable even when the // window is small. (Placeholder layout — Stage 6 will replace it.) ResizableWindow::new("editor") .min_size(Vec2::new(480.0, 320.0)) .show(egui_ctx, egui_state.as_ref(), |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.heading(Codename206::NAME); 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| { ui.label("Xover Lo/Mid"); ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter)); ui.end_row(); ui.label("Xover Mid/Hi"); ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter)); ui.end_row(); ui.label("Look-ahead"); ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter)); ui.end_row(); ui.label("Ceiling"); ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter)); ui.end_row(); ui.label("Lim Release"); ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter)); ui.end_row(); }); ui.separator(); ui.columns(4, |cols| { band_col(&mut cols[0], "LOW", ¶ms.low); band_col(&mut cols[1], "MID", ¶ms.mid); band_col(&mut cols[2], "HIGH", ¶ms.high); band_col(&mut cols[3], "ALL", ¶ms.all); }); }); }); }, ) } /// 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); 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 cell_w = rect.width() / NUM_CHANNELS as f32; // 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 * 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), ); } // 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), Align2::RIGHT_CENTER, "CEILING", FontId::proportional(11.0), Color32::from_gray(180), ); } /// 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) { 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) } }