2026-06-24 05:01:50 +09:00
|
|
|
//! 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
|
2026-06-24 14:53:00 +09:00
|
|
|
//! channel's existing history. It's fed by draining the audio thread's scope ring
|
|
|
|
|
//! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
|
|
|
|
|
//! rate rather than the editor frame rate. Buckets are folded into `PLOT_N` columns
|
|
|
|
|
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
|
2026-06-24 05:01:50 +09:00
|
|
|
|
|
|
|
|
use nih_plug::prelude::*;
|
2026-06-24 22:16:33 +09:00
|
|
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke};
|
2026-06-24 05:01:50 +09:00
|
|
|
|
|
|
|
|
use super::METER_FLOOR_DB;
|
2026-06-24 14:53:00 +09:00
|
|
|
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
2026-06-24 05:01:50 +09:00
|
|
|
|
|
|
|
|
/// 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],
|
2026-06-24 22:16:33 +09:00
|
|
|
/// Per-column flag: the output limiter hit the ceiling somewhere in this column.
|
|
|
|
|
hit: [bool; PLOT_N],
|
2026-06-24 05:01:50 +09:00
|
|
|
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],
|
2026-06-24 22:16:33 +09:00
|
|
|
hit: [false; PLOT_N],
|
2026-06-24 05:01:50 +09:00
|
|
|
write: 0,
|
|
|
|
|
len: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PlotHistory {
|
2026-06-24 22:16:33 +09:00
|
|
|
/// Append one column of (in_db, out_db, gr_db) per channel, plus the ceiling-hit flag.
|
|
|
|
|
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) {
|
2026-06-24 05:01:50 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-24 22:16:33 +09:00
|
|
|
self.hit[self.write] = hit;
|
2026-06-24 05:01:50 +09:00
|
|
|
self.write = (self.write + 1) % PLOT_N;
|
|
|
|
|
self.len = (self.len + 1).min(PLOT_N);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 14:53:00 +09:00
|
|
|
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
|
|
|
|
|
/// column being assembled from drained buckets.
|
2026-06-24 05:01:50 +09:00
|
|
|
pub(super) struct PlotState {
|
2026-06-25 15:24:08 +09:00
|
|
|
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
|
|
|
|
|
pub(super) selected: usize,
|
2026-06-24 05:01:50 +09:00
|
|
|
history: PlotHistory,
|
|
|
|
|
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
|
|
|
|
|
window_s: f64,
|
2026-06-24 14:53:00 +09:00
|
|
|
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
|
|
|
|
|
cursor: Option<u64>,
|
2026-06-24 05:01:50 +09:00
|
|
|
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
2026-06-24 14:53:00 +09:00
|
|
|
col_acc: [(f32, f32, f32); NUM_CHANNELS],
|
2026-06-24 22:16:33 +09:00
|
|
|
/// Ceiling-hit flag accumulated for the column currently being built.
|
|
|
|
|
col_hit: bool,
|
2026-06-24 14:53:00 +09:00
|
|
|
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
|
|
|
|
|
col_fill: f64,
|
2026-06-24 05:01:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for PlotState {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
selected: 0,
|
|
|
|
|
history: PlotHistory::default(),
|
|
|
|
|
window_s: 5.0,
|
2026-06-24 14:53:00 +09:00
|
|
|
cursor: None,
|
|
|
|
|
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
2026-06-24 22:16:33 +09:00
|
|
|
col_hit: false,
|
2026-06-24 14:53:00 +09:00
|
|
|
col_fill: 0.0,
|
2026-06-24 05:01:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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) {
|
2026-06-24 14:53:00 +09:00
|
|
|
// Buckets that make up one column at the current flow speed (may be fractional).
|
|
|
|
|
let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
|
|
|
|
|
|
|
|
|
|
// Drain every bucket produced since the last frame (audio-clocked), folding them into columns.
|
|
|
|
|
// A fresh cursor starts at "now" so we don't replay stale buckets.
|
|
|
|
|
{
|
|
|
|
|
let w0 = meters.scope.write_index();
|
|
|
|
|
let cursor = state.cursor.get_or_insert(w0);
|
|
|
|
|
let history = &mut state.history;
|
|
|
|
|
let col_acc = &mut state.col_acc;
|
2026-06-24 22:16:33 +09:00
|
|
|
let col_hit = &mut state.col_hit;
|
2026-06-24 14:53:00 +09:00
|
|
|
let col_fill = &mut state.col_fill;
|
2026-06-24 22:16:33 +09:00
|
|
|
meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| {
|
2026-06-24 14:53:00 +09:00
|
|
|
for ch in 0..NUM_CHANNELS {
|
|
|
|
|
col_acc[ch].0 = col_acc[ch].0.max(util::gain_to_db(in_lin[ch]));
|
|
|
|
|
col_acc[ch].1 = col_acc[ch].1.max(util::gain_to_db(out_lin[ch]));
|
|
|
|
|
col_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]);
|
|
|
|
|
}
|
2026-06-24 22:16:33 +09:00
|
|
|
*col_hit |= hit > 0.5;
|
2026-06-24 14:53:00 +09:00
|
|
|
*col_fill += 1.0;
|
|
|
|
|
while *col_fill >= buckets_per_col {
|
2026-06-24 22:16:33 +09:00
|
|
|
history.push(col_acc, *col_hit);
|
2026-06-24 14:53:00 +09:00
|
|
|
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
2026-06-24 22:16:33 +09:00
|
|
|
*col_hit = false;
|
2026-06-24 14:53:00 +09:00
|
|
|
*col_fill -= buckets_per_col;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-06-24 05:01:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-06-24 14:53:00 +09:00
|
|
|
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
2026-06-24 22:16:33 +09:00
|
|
|
state.col_hit = false;
|
2026-06-24 14:53:00 +09:00
|
|
|
state.col_fill = 0.0;
|
2026-06-24 05:01:50 +09:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-06-24 05:03:30 +09:00
|
|
|
let draw_series = |series: &[f32; PLOT_N], to_db: &dyn Fn(f32) -> f32, color: Color32, fill: bool| {
|
2026-06-24 05:01:50 +09:00
|
|
|
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]))));
|
|
|
|
|
}
|
2026-06-24 05:03:30 +09:00
|
|
|
if fill {
|
2026-06-24 14:53:00 +09:00
|
|
|
// Fill as a strip of per-segment convex quads down to the baseline. A single
|
|
|
|
|
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
|
|
|
|
|
// triangles), so build convex pieces — one box per time unit — instead.
|
2026-06-24 05:03:30 +09:00
|
|
|
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
|
2026-06-24 14:53:00 +09:00
|
|
|
for seg in pts.windows(2) {
|
|
|
|
|
let (a, b) = (seg[0], seg[1]);
|
|
|
|
|
p.add(egui::Shape::convex_polygon(
|
|
|
|
|
vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
|
|
|
|
|
fill_col,
|
|
|
|
|
Stroke::NONE,
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-06-24 05:03:30 +09:00
|
|
|
}
|
2026-06-24 05:01:50 +09:00
|
|
|
p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
|
|
|
|
|
};
|
2026-06-24 05:03:30 +09:00
|
|
|
draw_series(&state.history.in_db[c], &|db| db, COLOR_IN, true);
|
|
|
|
|
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
|
2026-06-24 05:01:50 +09:00
|
|
|
// GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline.
|
2026-06-24 05:03:30 +09:00
|
|
|
draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR, false);
|
2026-06-24 22:16:33 +09:00
|
|
|
|
|
|
|
|
// Ceiling-hit markers: a short red tick at the TOP for any column where the output limiter
|
|
|
|
|
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of
|
|
|
|
|
// hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise.
|
|
|
|
|
let dx = width / (PLOT_N - 1) as f32;
|
|
|
|
|
let marker = Color32::from_rgb(235, 45, 45);
|
|
|
|
|
for k in 0..len {
|
|
|
|
|
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
|
|
|
|
if state.history.hit[idx] {
|
|
|
|
|
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32;
|
|
|
|
|
let x = left + pos * width;
|
|
|
|
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + dx, top + 3.0)), CornerRadius::ZERO, marker);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-24 05:01:50 +09:00
|
|
|
}
|
|
|
|
|
}
|