diff --git a/src/editor.rs b/src/editor.rs deleted file mode 100644 index 90c435e..0000000 --- a/src/editor.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! 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) - } -} diff --git a/src/editor/meter.rs b/src/editor/meter.rs new file mode 100644 index 0000000..450ce12 --- /dev/null +++ b/src/editor/meter.rs @@ -0,0 +1,136 @@ +//! Per-channel level + gain-reduction meters and the latching ceiling 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` +//! 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; +/// 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; + +/// GUI-side state for the meter panel. +#[derive(Default)] +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, +} + +/// Draw the meter panel: a `|L | GR | R|` cluster per channel plus the latching ceiling lamp. +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() + 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, 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), + ); + } + + // 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 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) + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..30f51b5 --- /dev/null +++ b/src/editor/mod.rs @@ -0,0 +1,107 @@ +//! egui editor: assembly + control layout. +//! +//! The aggregator. Builds the editor window and lays out the heading, the meter panel +//! ([`meter`]), the rolling plot ([`plot`]), and the (placeholder) per-channel slider columns. +//! Each visualiser owns its GUI state and drawing in its submodule; this module wires them +//! together and holds the shared [`EditorState`]. When the UI is redesigned the slider columns +//! get replaced and the visualisers stay as self-contained widgets. + +use nih_plug::prelude::*; +use nih_plug_egui::{ + create_egui_editor, + egui::{self, Vec2}, + resizable_window::ResizableWindow, + widgets, +}; +use std::sync::Arc; + +use crate::meters::Meters; +use crate::params::{Codename206Params, CompressorParams}; +use crate::Codename206; + +mod meter; +mod plot; + +/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS). +const METER_FLOOR_DB: f32 = -60.0; + +/// GUI-side editor state (not persisted): the per-widget state for the meter panel and the plot. +#[derive(Default)] +struct EditorState { + meter: meter::MeterState, + plot: plot::PlotState, +} + +/// 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 (placeholder layout). + 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 — the redesign will replace the slider columns.) + 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); + meter::draw(ui, &meters, &mut state.meter); + ui.separator(); + plot::draw(ui, &meters, &mut state.plot); + 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); + }); + }); + }); + }, + ) +} diff --git a/src/editor/plot.rs b/src/editor/plot.rs new file mode 100644 index 0000000..de0924d --- /dev/null +++ b/src/editor/plot.rs @@ -0,0 +1,184 @@ +//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector. +//! +//! Histories for all four channels run continuously (cheap), so switching tabs shows that +//! channel's existing history. The scroll is time-based (a column every `window/PLOT_N` seconds) +//! so the window length — the flow speed — stays accurate regardless of frame rate, with +//! peak-preserving max accumulation between columns. Fed by the raw block-peak [`Meters`] feed. + +use nih_plug::prelude::*; +use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke}; +use std::sync::atomic::Ordering; + +use super::METER_FLOOR_DB; +use crate::meters::{Meters, NUM_CHANNELS}; + +/// Height of the plot panel. +const PLOT_PANEL_H: f32 = 150.0; +/// Number of columns held in the history ring. +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 all channels: a per-channel ring of (in_db, out_db, gr_db) columns. +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 column 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 state for the plot: selected channel, history ring, and time-based scroll cadence. +pub(super) struct PlotState { + /// 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 PlotState { + fn default() -> Self { + Self { + selected: 0, + history: PlotHistory::default(), + window_s: 5.0, + last_push: 0.0, + acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS], + } + } +} + +/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs +/// and flow-speed selector. History for all channels advances every frame regardless of the tab. +pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) { + 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); + } +}