feat: static gain-curve display for the selected channel

Add editor/gain_curve.rs: a square panel beside the scrolling plot showing the
channel transfer (out vs in, -60..0 dB) for whichever channel the plot tab
selects. Plots the full wet path — out = (in + pre_gain) + gain_reduction + makeup
— using the shared Compressor::gain_computer (now pub) so it matches the DSP and
the GR meter. Unity-reference diagonal + threshold marker; mix not folded in.

Update README structure/status to reflect the editor/ widget module and the
completed Stage 6 visualisers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-25 15:24:08 +09:00
parent a8be2179c3
commit b1477f7ec6
5 changed files with 96 additions and 22 deletions
+3 -2
View File
@@ -132,8 +132,9 @@ impl Compressor {
}
/// Static compressor curve. Returns gain reduction in dB (<= 0) for an input `level_db`.
/// Quadratic soft knee of width `knee_db`, centred on `threshold_db`.
fn gain_computer(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
/// Quadratic soft knee of width `knee_db`, centred on `threshold_db`. Also used by the editor's
/// gain-curve display, so it stays the single source of truth for the transfer shape.
pub fn gain_computer(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
let over = level_db - threshold_db;
+65
View File
@@ -0,0 +1,65 @@
//! Static gain-curve display (visualisation only): output level vs input level for the channel
//! currently selected in the plot. Plots the full channel transfer for the wet path:
//! `out = (in + pre_gain) + gain_reduction(in + pre_gain) + makeup`. Mix is not folded in (the
//! curve shows the wet/100% path, matching the GR-meter convention); output is clamped at 0 dBFS.
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke};
use crate::dsp::compressor::Compressor;
use crate::params::Codename206Params;
/// Side length of the square plot.
const CURVE_SIZE: f32 = 150.0;
/// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS).
const FLOOR_DB: f32 = -60.0;
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usize) {
let labels = ["LOW", "MID", "HIGH", "ALL"];
let ch = selected.min(3);
let cp = match ch {
0 => &params.low,
1 => &params.mid,
2 => &params.high,
_ => &params.all,
};
let pre = cp.pre_gain_db.value();
let threshold = cp.threshold_db.value();
let ratio = cp.ratio.value();
let knee = cp.knee_db.value();
let makeup = cp.makeup_db.value();
ui.label(format!("Curve: {}", labels[ch]));
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
let inset = 2.0;
let (left, right, top, bottom) =
(rect.left() + inset, rect.right() - inset, rect.top() + inset, rect.bottom() - inset);
let w = right - left;
let h = bottom - top;
let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w;
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h;
// Unity reference (out = in), bottom-left to top-right.
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0, Color32::from_gray(45)));
// Threshold marker on the input axis — shifted left by pre-gain (the comp sees in + pre).
let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, Color32::from_rgb(80, 60, 45)));
// Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
let n = 96;
let mut pts = Vec::with_capacity(n + 1);
for i in 0..=n {
let in_db = FLOOR_DB + (i as f32 / n as f32) * -FLOOR_DB; // external input, -60..0
let driven = in_db + pre;
let gr = Compressor::gain_computer(driven, threshold, ratio, knee); // <= 0 dB
let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
pts.push(pos2(x_for(in_db), y_for(out_db)));
}
p.add(egui::Shape::line(pts, Stroke::new(1.6, Color32::from_rgb(120, 200, 160))));
// Corner dB ticks.
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), Color32::from_gray(90));
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), Color32::from_gray(90));
}
+7 -1
View File
@@ -20,6 +20,7 @@ use crate::params::{Codename206Params, CompressorParams};
use crate::Codename206;
mod crossover;
mod gain_curve;
mod meter;
mod plot;
@@ -75,7 +76,12 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
ui.heading(Codename206::NAME);
meter::draw(ui, &meters, &mut state.meter);
ui.separator();
plot::draw(ui, &meters, &mut state.plot);
// Gain curve (left, square) beside the scrolling plot (right, fills the rest).
let selected = state.plot.selected;
ui.horizontal_top(|ui| {
ui.vertical(|ui| gain_curve::draw(ui, &params, selected));
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
});
ui.separator();
crossover::draw(ui, &params, setter);
ui.separator();
+2 -2
View File
@@ -62,8 +62,8 @@ impl PlotHistory {
/// 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).
selected: usize,
/// 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,