466ab478c9
Commit 057d630 switched this repo from GPL-3.0 to Apache-2.0, which the
build contradicts: src/lib.rs calls nih_export_vst3! unconditionally and
nih_plug is taken with default features, so every build links vst3-sys —
GPLv3-or-later by its own authors' choice. deploy.ps1 then installs that
binary. The declared licence and the artifact's actual terms disagreed,
and the original GPL-3.0 was correct.
Given the intent to distribute non-profit and open-source, GPL costs
nothing here, so this restores it rather than dropping VST3 to keep a
permissive licence.
Also adds the ISC notice owed to Robbert van der Helm: the plugin
scaffolding derives from the NIH-plug cookiecutter template and example
plugins, whose one condition is that the copyright and permission notice
travel with all copies. The README credited the template under Reference
Material, which is courtesy, not compliance. ISC is GPL-compatible, so the
notice is retained in NOTICE rather than superseded.
Corrects the README's claim that Steinberg's SDK licence must be accepted
before distributing. No SDK source is vendored or redistributed, so there
is nothing to accept at build time. The binding constraint is vst3-sys's
GPLv3; Steinberg's trademark in "VST" is a separate regime that no source
licence resolves.
Copyright in the Irene <irene@novoyuuparosk.org> commits is held by the
repository owner (an agent identity), so no third-party agreement was
needed for this relicense.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
228 lines
9.6 KiB
Rust
228 lines
9.6 KiB
Rust
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//! 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. 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.
|
|
|
|
use nih_plug::prelude::*;
|
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke};
|
|
|
|
use super::METER_FLOOR_DB;
|
|
use crate::meters::{Meters, BUCKET_HZ, 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],
|
|
/// Per-column flag: the output limiter hit the ceiling somewhere in this column.
|
|
hit: [bool; PLOT_N],
|
|
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],
|
|
hit: [false; PLOT_N],
|
|
write: 0,
|
|
len: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PlotHistory {
|
|
/// 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) {
|
|
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.hit[self.write] = hit;
|
|
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, ring-drain cursor, and the
|
|
/// column being assembled from drained buckets.
|
|
pub(super) struct PlotState {
|
|
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
|
|
pub(super) selected: usize,
|
|
history: PlotHistory,
|
|
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
|
|
window_s: f64,
|
|
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
|
|
cursor: Option<u64>,
|
|
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
|
col_acc: [(f32, f32, f32); NUM_CHANNELS],
|
|
/// Ceiling-hit flag accumulated for the column currently being built.
|
|
col_hit: bool,
|
|
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
|
|
col_fill: f64,
|
|
}
|
|
|
|
impl Default for PlotState {
|
|
fn default() -> Self {
|
|
Self {
|
|
selected: 0,
|
|
history: PlotHistory::default(),
|
|
window_s: 5.0,
|
|
cursor: None,
|
|
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
|
col_hit: false,
|
|
col_fill: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
// 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;
|
|
let col_hit = &mut state.col_hit;
|
|
let col_fill = &mut state.col_fill;
|
|
meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| {
|
|
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]);
|
|
}
|
|
*col_hit |= hit > 0.5;
|
|
*col_fill += 1.0;
|
|
while *col_fill >= buckets_per_col {
|
|
history.push(col_acc, *col_hit);
|
|
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
|
*col_hit = false;
|
|
*col_fill -= buckets_per_col;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
|
state.col_hit = false;
|
|
state.col_fill = 0.0;
|
|
}
|
|
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, fill: bool| {
|
|
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]))));
|
|
}
|
|
if fill {
|
|
// 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.
|
|
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
|
|
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,
|
|
));
|
|
}
|
|
}
|
|
p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
|
|
};
|
|
draw_series(&state.history.in_db[c], &|db| db, COLOR_IN, true);
|
|
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
|
|
// 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, false);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|