Compare commits
5 Commits
20c5a17a61
...
4c5165b0bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5165b0bc | |||
| 9a54413bfa | |||
| f8f652f2c7 | |||
| a420d5dd39 | |||
| 8c550da92e |
+18
-7
@@ -74,11 +74,22 @@ if (Test-Path -LiteralPath '$clapSrc') { Copy-Item -LiteralPath '$clapSrc' -Dest
|
||||
Start-Process powershell -Verb RunAs -Wait -ArgumentList '-NoProfile','-EncodedCommand',$enc
|
||||
}
|
||||
|
||||
$check = Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name"
|
||||
if (Test-Path $check) {
|
||||
Write-Host "VST3 installed OK -> $check" -ForegroundColor Green
|
||||
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
throw "Install verification failed: $check not found"
|
||||
# Verify each bundle was actually refreshed. A plugin currently loaded in a DAW keeps its
|
||||
# binary locked, so the copy fails silently and leaves a STALE install — re-scanning then runs
|
||||
# old code. Compare install vs build timestamps to catch exactly that.
|
||||
function Test-Installed($label, $src, $dst) {
|
||||
if (-not (Test-Path $src)) { return } # nothing was built for this format
|
||||
if (-not (Test-Path $dst)) { throw "$label install verification failed: $dst not found" }
|
||||
$srcT = (Get-Item $src).LastWriteTime
|
||||
$dstT = (Get-Item $dst).LastWriteTime
|
||||
if ($dstT -lt $srcT) {
|
||||
Write-Warning "$label is STALE (installed $dstT < built $srcT). It's almost certainly loaded in your DAW (file locked). Close the plugin/DAW and re-run deploy."
|
||||
}
|
||||
else {
|
||||
Write-Host "$label installed OK -> $dst" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Test-Installed "VST3" (Join-Path $vst3Src "Contents\x86_64-win\$vst3Name") (Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name")
|
||||
Test-Installed "CLAP" $clapSrc (Join-Path $clapDst $clapName)
|
||||
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
|
||||
|
||||
@@ -152,6 +152,13 @@ impl Compressor {
|
||||
self.fixed_delay as u32
|
||||
}
|
||||
|
||||
/// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering.
|
||||
/// This is the smoothed detector output `yl`, so it tracks the visible needle, not the
|
||||
/// instantaneous static curve.
|
||||
pub fn gain_reduction_db(&self) -> f32 {
|
||||
self.yl
|
||||
}
|
||||
|
||||
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
|
||||
///
|
||||
/// `input` and `output` are short stack slices (one value per channel), so this
|
||||
|
||||
@@ -89,6 +89,12 @@ impl Limiter {
|
||||
self.fixed_delay as u32
|
||||
}
|
||||
|
||||
/// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a
|
||||
/// positive dB amount for the ceiling lamp / metering.
|
||||
pub fn gain_reduction_db(&self) -> f32 {
|
||||
-20.0 * self.gain.max(1e-9).log10()
|
||||
}
|
||||
|
||||
/// Limit one frame in place: `input[ch]` -> `output[ch]`.
|
||||
///
|
||||
/// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
//! DSP building blocks for Codename 206.
|
||||
//!
|
||||
//! Stage 2 introduces the full-band compressor (also the engine that will be reused
|
||||
//! per band and for the 'All' aggregate channel — see README.md). Later stages add the
|
||||
//! crossover filterbank, output limiter, and oversampler alongside it.
|
||||
//! The signal chain: a `crossover` filterbank splits into bands, each band (plus the summed
|
||||
//! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector)
|
||||
//! is the final stage. `biquad` is the shared filter primitive the crossover is built from.
|
||||
|
||||
pub mod biquad;
|
||||
pub mod compressor;
|
||||
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
//! 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<f64>,
|
||||
/// 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<Codename206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+78
-255
@@ -1,16 +1,20 @@
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{
|
||||
create_egui_editor,
|
||||
egui::{self, Vec2},
|
||||
resizable_window::ResizableWindow,
|
||||
widgets, EguiState,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod dsp;
|
||||
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||
mod editor;
|
||||
mod meters;
|
||||
mod params;
|
||||
|
||||
use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
|
||||
use dsp::crossover::Crossover;
|
||||
use dsp::limiter::Limiter;
|
||||
use meters::Meters;
|
||||
use params::{build_settings, Codename206Params};
|
||||
|
||||
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
|
||||
/// gain-gui example feel.)
|
||||
const METER_DECAY_MS: f64 = 150.0;
|
||||
|
||||
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
||||
const LOW: usize = 0;
|
||||
@@ -18,17 +22,6 @@ const MID: usize = 1;
|
||||
const HIGH: usize = 2;
|
||||
const ALL: usize = 3;
|
||||
|
||||
/// Level-detection mode for a compressor's detector.
|
||||
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||
enum DetectionMode {
|
||||
#[id = "peak"]
|
||||
#[name = "Peak"]
|
||||
Peak,
|
||||
#[id = "rms"]
|
||||
#[name = "RMS"]
|
||||
Rms,
|
||||
}
|
||||
|
||||
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
||||
///
|
||||
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
||||
@@ -42,58 +35,11 @@ struct Codename206 {
|
||||
comps: [Compressor; 4],
|
||||
/// Output brickwall limiter (final stage).
|
||||
limiter: Limiter,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
struct Codename206Params {
|
||||
#[persist = "editor-state"]
|
||||
editor_state: Arc<EguiState>,
|
||||
|
||||
/// Low/Mid crossover frequency.
|
||||
#[id = "xover_lo"]
|
||||
pub crossover_low_hz: FloatParam,
|
||||
/// Mid/High crossover frequency.
|
||||
#[id = "xover_hi"]
|
||||
pub crossover_high_hz: FloatParam,
|
||||
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
||||
#[id = "lookahead"]
|
||||
pub look_ahead_ms: FloatParam,
|
||||
|
||||
/// Output brickwall ceiling (the limiter never lets output exceed this).
|
||||
#[id = "ceiling"]
|
||||
pub output_ceiling_db: FloatParam,
|
||||
/// Output limiter release time.
|
||||
#[id = "lim_rel"]
|
||||
pub limiter_release_ms: FloatParam,
|
||||
|
||||
#[nested(id_prefix = "low", group = "Low")]
|
||||
pub low: CompressorParams,
|
||||
#[nested(id_prefix = "mid", group = "Mid")]
|
||||
pub mid: CompressorParams,
|
||||
#[nested(id_prefix = "high", group = "High")]
|
||||
pub high: CompressorParams,
|
||||
#[nested(id_prefix = "all", group = "All")]
|
||||
pub all: CompressorParams,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
struct CompressorParams {
|
||||
#[id = "detect"]
|
||||
pub detection: EnumParam<DetectionMode>,
|
||||
#[id = "thresh"]
|
||||
pub threshold_db: FloatParam,
|
||||
#[id = "ratio"]
|
||||
pub ratio: FloatParam,
|
||||
#[id = "knee"]
|
||||
pub knee_db: FloatParam,
|
||||
#[id = "attack"]
|
||||
pub attack_ms: FloatParam,
|
||||
#[id = "release"]
|
||||
pub release_ms: FloatParam,
|
||||
#[id = "makeup"]
|
||||
pub makeup_db: FloatParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
/// Lock-free meter state shared with the editor.
|
||||
meters: Arc<Meters>,
|
||||
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to
|
||||
/// the block length when applied once per block in `process`).
|
||||
meter_decay_weight: f32,
|
||||
}
|
||||
|
||||
impl Default for Codename206 {
|
||||
@@ -104,131 +50,12 @@ impl Default for Codename206 {
|
||||
crossover: Crossover::new(),
|
||||
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
||||
limiter: Limiter::new(),
|
||||
meters: Arc::new(Meters::default()),
|
||||
meter_decay_weight: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Codename206Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor_state: EguiState::from_size(760, 520),
|
||||
|
||||
crossover_low_hz: FloatParam::new(
|
||||
"Crossover Lo/Mid",
|
||||
200.0,
|
||||
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
crossover_high_hz: FloatParam::new(
|
||||
"Crossover Mid/Hi",
|
||||
2_500.0,
|
||||
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
look_ahead_ms: FloatParam::new(
|
||||
"Look-ahead",
|
||||
2.0,
|
||||
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
output_ceiling_db: FloatParam::new(
|
||||
"Ceiling",
|
||||
0.0,
|
||||
FloatRange::Linear { min: -24.0, max: 0.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
limiter_release_ms: FloatParam::new(
|
||||
"Limiter Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
low: CompressorParams::default(),
|
||||
mid: CompressorParams::default(),
|
||||
high: CompressorParams::default(),
|
||||
all: CompressorParams::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompressorParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
detection: EnumParam::new("Detection", DetectionMode::Peak),
|
||||
|
||||
threshold_db: FloatParam::new(
|
||||
"Threshold",
|
||||
-18.0,
|
||||
FloatRange::Linear { min: -60.0, max: 0.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
ratio: FloatParam::new(
|
||||
"Ratio",
|
||||
2.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
||||
.with_string_to_value(Arc::new(|s| {
|
||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||
})),
|
||||
|
||||
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 24.0 })
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
attack_ms: FloatParam::new(
|
||||
"Attack",
|
||||
10.0,
|
||||
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
release_ms: FloatParam::new(
|
||||
"Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -12.0, max: 24.0 })
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
bypass: BoolParam::new("Bypass", false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
||||
fn build_settings(p: &CompressorParams, lookahead: usize, sample_rate: f32) -> CompressorSettings {
|
||||
CompressorSettings {
|
||||
threshold_db: p.threshold_db.value(),
|
||||
ratio: p.ratio.value(),
|
||||
knee_db: p.knee_db.value(),
|
||||
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
||||
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: lookahead,
|
||||
use_rms: p.detection.value() == DetectionMode::Rms,
|
||||
bypass: p.bypass.value(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Codename206 {
|
||||
fn lookahead_samples(&self) -> usize {
|
||||
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||
@@ -269,68 +96,7 @@ impl Plugin for Codename206 {
|
||||
}
|
||||
|
||||
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let egui_state = self.params.editor_state.clone();
|
||||
create_egui_editor(
|
||||
self.params.editor_state.clone(),
|
||||
(),
|
||||
|_, _| {},
|
||||
move |egui_ctx, setter, _state| {
|
||||
// One column of controls for a single compressor channel.
|
||||
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
|
||||
ui.strong(title);
|
||||
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(Self::NAME);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
)
|
||||
editor::create(self.params.clone(), self.meters.clone())
|
||||
}
|
||||
|
||||
fn initialize(
|
||||
@@ -345,6 +111,10 @@ impl Plugin for Codename206 {
|
||||
.map(NonZeroU32::get)
|
||||
.unwrap_or(2) as usize;
|
||||
|
||||
// Per-block decay so the meters fall ~12 dB over METER_DECAY_MS of silence.
|
||||
self.meter_decay_weight =
|
||||
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
|
||||
|
||||
for comp in &mut self.comps {
|
||||
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||
}
|
||||
@@ -370,6 +140,8 @@ impl Plugin for Codename206 {
|
||||
comp.reset();
|
||||
}
|
||||
self.limiter.reset();
|
||||
// Transport restart / sample-rate change: drop stale meter values to silence.
|
||||
self.meters.clear();
|
||||
}
|
||||
|
||||
fn process(
|
||||
@@ -401,6 +173,15 @@ impl Plugin for Codename206 {
|
||||
let limiter_release =
|
||||
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
|
||||
|
||||
// Only do the (cheap) metering work when the editor is actually open.
|
||||
let metering = self.params.editor_state.is_open();
|
||||
let num_samples = buffer.samples();
|
||||
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
|
||||
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
|
||||
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input peak (for the scrolling plot)
|
||||
let mut gr = [0.0f32; meters::NUM_CHANNELS];
|
||||
let mut lim_gr = 0.0f32;
|
||||
|
||||
let mut in_frame = [0.0f32; 2];
|
||||
let mut band_in = [[0.0f32; 2]; 3];
|
||||
let mut band_out = [[0.0f32; 2]; 3];
|
||||
@@ -410,6 +191,7 @@ impl Plugin for Codename206 {
|
||||
|
||||
for mut frame in buffer.iter_samples() {
|
||||
let n = frame.len().min(2);
|
||||
let r = (n - 1).min(1); // right-channel index (== left when mono)
|
||||
for ch in 0..n {
|
||||
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||
}
|
||||
@@ -422,28 +204,69 @@ impl Plugin for Codename206 {
|
||||
band_in[HIGH][ch] = hi;
|
||||
}
|
||||
|
||||
// Compress each band (per-sample smoothed makeup), then sum.
|
||||
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
|
||||
summed[..n].fill(0.0);
|
||||
for b in 0..3 {
|
||||
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next());
|
||||
for ch in 0..n {
|
||||
band_in[b][ch] *= pre;
|
||||
}
|
||||
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
||||
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
||||
for ch in 0..n {
|
||||
summed[ch] += band_out[b][ch];
|
||||
}
|
||||
if metering {
|
||||
inp[b] = inp[b].max(band_in[b][0].abs().max(band_in[b][r].abs()));
|
||||
lvl_l[b] = lvl_l[b].max(band_out[b][0].abs());
|
||||
lvl_r[b] = lvl_r[b].max(band_out[b][r].abs());
|
||||
gr[b] = gr[b]
|
||||
.max(if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() });
|
||||
}
|
||||
}
|
||||
|
||||
// 'All' aggregate channel over the summed bands.
|
||||
// 'All' aggregate channel over the summed bands (driven before its compressor).
|
||||
let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next());
|
||||
for ch in 0..n {
|
||||
summed[ch] *= all_pre;
|
||||
}
|
||||
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||
|
||||
// Output brickwall limiter.
|
||||
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
|
||||
|
||||
if metering {
|
||||
inp[ALL] = inp[ALL].max(summed[0].abs().max(summed[r].abs()));
|
||||
lvl_l[ALL] = lvl_l[ALL].max(out_frame[0].abs());
|
||||
lvl_r[ALL] = lvl_r[ALL].max(out_frame[r].abs());
|
||||
gr[ALL] = gr[ALL]
|
||||
.max(if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() });
|
||||
lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
|
||||
}
|
||||
|
||||
for ch in 0..n {
|
||||
*frame.get_mut(ch).unwrap() = lim_frame[ch];
|
||||
}
|
||||
}
|
||||
|
||||
// Publish one decimated value per meter for this block. The decay weight is per-sample,
|
||||
// so raise it to the block length to keep the fall time constant independent of buffer size
|
||||
// (we apply it once per block, not once per sample).
|
||||
if metering {
|
||||
let w = self.meter_decay_weight.powi(num_samples as i32);
|
||||
for i in 0..meters::NUM_CHANNELS {
|
||||
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w);
|
||||
meters::decay_store(&self.meters.level_r[i], lvl_r[i], w);
|
||||
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
|
||||
// Raw block peaks for the scrolling plot (editor keeps its own history).
|
||||
meters::store_instant(&self.meters.plot_in[i], inp[i]);
|
||||
meters::store_instant(&self.meters.plot_out[i], lvl_l[i].max(lvl_r[i]));
|
||||
meters::store_instant(&self.meters.plot_gr[i], gr[i]);
|
||||
}
|
||||
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
|
||||
}
|
||||
|
||||
ProcessStatus::Normal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Lock-free meter state shared from the audio thread to the editor.
|
||||
//!
|
||||
//! `process()` is the single writer (one store per value per block — decimated, not per sample);
|
||||
//! the editor is the single reader (once per frame). All access is wait-free via atomics, so the
|
||||
//! realtime thread never blocks. Values are plain scalars (no streaming history yet) — enough for
|
||||
//! the per-channel level + gain-reduction bars and the ceiling lamp.
|
||||
|
||||
use nih_plug::prelude::AtomicF32;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
|
||||
pub const NUM_CHANNELS: usize = 4;
|
||||
|
||||
pub struct Meters {
|
||||
/// Left output level per channel as a **linear** peak. Peak-with-decay.
|
||||
pub level_l: [AtomicF32; NUM_CHANNELS],
|
||||
/// Right output level per channel (== left for mono signals). Stored separately so the planned
|
||||
/// `|L|GR|R|` layout is a pure editor change; the current bars render `max(L, R)`.
|
||||
pub level_r: [AtomicF32; NUM_CHANNELS],
|
||||
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
|
||||
/// stereo-linked, so the same gain applies to both channels.
|
||||
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
|
||||
/// Output limiter gain reduction in **dB (>= 0)** — drives the ceiling lamp.
|
||||
pub limiter_gr_db: AtomicF32,
|
||||
|
||||
// --- Scrolling plot feed: instantaneous block peaks, NOT decayed. The editor samples these
|
||||
// each frame into its own history ring. Per channel: input level (entering the compressor),
|
||||
// output level, and gain reduction.
|
||||
/// Mono input level per channel (linear peak, post pre-gain, pre-compressor).
|
||||
pub plot_in: [AtomicF32; NUM_CHANNELS],
|
||||
/// Mono output level per channel (linear peak, post-compressor).
|
||||
pub plot_out: [AtomicF32; NUM_CHANNELS],
|
||||
/// Gain reduction per channel in dB (>= 0).
|
||||
pub plot_gr: [AtomicF32; NUM_CHANNELS],
|
||||
}
|
||||
|
||||
impl Default for Meters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
level_r: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
limiter_gr_db: AtomicF32::new(0.0),
|
||||
plot_in: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
plot_out: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
plot_gr: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Meters {
|
||||
/// Zero every meter. Called from the plugin's `reset()` (transport restart / sample-rate
|
||||
/// change) so the display starts from silence rather than stale values. Real-time safe.
|
||||
pub fn clear(&self) {
|
||||
for i in 0..NUM_CHANNELS {
|
||||
self.level_l[i].store(0.0, Ordering::Relaxed);
|
||||
self.level_r[i].store(0.0, Ordering::Relaxed);
|
||||
self.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
|
||||
self.plot_in[i].store(0.0, Ordering::Relaxed);
|
||||
self.plot_out[i].store(0.0, Ordering::Relaxed);
|
||||
self.plot_gr[i].store(0.0, Ordering::Relaxed);
|
||||
}
|
||||
self.limiter_gr_db.store(0.0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an instantaneous value (no smoothing) — used for the scrolling-plot feed, which the
|
||||
/// editor smooths/decimates on its own.
|
||||
pub fn store_instant(meter: &AtomicF32, value: f32) {
|
||||
meter.store(value, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a
|
||||
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
|
||||
/// from flickering while staying responsive to transients.
|
||||
pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
|
||||
let current = meter.load(Ordering::Relaxed);
|
||||
let next = if block_value > current {
|
||||
block_value
|
||||
} else {
|
||||
current * decay_weight + block_value * (1.0 - decay_weight)
|
||||
};
|
||||
meter.store(next, Ordering::Relaxed);
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
//! Plugin parameters and their layout.
|
||||
//!
|
||||
//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All'
|
||||
//! aggregate channel). `build_settings` translates a channel's params into the per-block
|
||||
//! [`CompressorSettings`] the DSP consumes.
|
||||
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||
|
||||
/// Level-detection mode for a compressor's detector.
|
||||
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||
pub enum DetectionMode {
|
||||
#[id = "peak"]
|
||||
#[name = "Peak"]
|
||||
Peak,
|
||||
#[id = "rms"]
|
||||
#[name = "RMS"]
|
||||
Rms,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct Codename206Params {
|
||||
#[persist = "editor-state"]
|
||||
pub editor_state: Arc<EguiState>,
|
||||
|
||||
/// Low/Mid crossover frequency.
|
||||
#[id = "xover_lo"]
|
||||
pub crossover_low_hz: FloatParam,
|
||||
/// Mid/High crossover frequency.
|
||||
#[id = "xover_hi"]
|
||||
pub crossover_high_hz: FloatParam,
|
||||
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
||||
#[id = "lookahead"]
|
||||
pub look_ahead_ms: FloatParam,
|
||||
|
||||
/// Output brickwall ceiling (the limiter never lets output exceed this).
|
||||
#[id = "ceiling"]
|
||||
pub output_ceiling_db: FloatParam,
|
||||
/// Output limiter release time.
|
||||
#[id = "lim_rel"]
|
||||
pub limiter_release_ms: FloatParam,
|
||||
|
||||
#[nested(id_prefix = "low", group = "Low")]
|
||||
pub low: CompressorParams,
|
||||
#[nested(id_prefix = "mid", group = "Mid")]
|
||||
pub mid: CompressorParams,
|
||||
#[nested(id_prefix = "high", group = "High")]
|
||||
pub high: CompressorParams,
|
||||
#[nested(id_prefix = "all", group = "All")]
|
||||
pub all: CompressorParams,
|
||||
}
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct CompressorParams {
|
||||
/// Drive into the compressor: scales the signal **before** detection, so it both pushes the
|
||||
/// channel further into compression and feeds the downstream sum/limiter harder. Combined with
|
||||
/// makeup (post-comp), this gives full per-channel input/output gain-staging.
|
||||
#[id = "pregain"]
|
||||
pub pre_gain_db: FloatParam,
|
||||
#[id = "detect"]
|
||||
pub detection: EnumParam<DetectionMode>,
|
||||
#[id = "thresh"]
|
||||
pub threshold_db: FloatParam,
|
||||
#[id = "ratio"]
|
||||
pub ratio: FloatParam,
|
||||
#[id = "knee"]
|
||||
pub knee_db: FloatParam,
|
||||
#[id = "attack"]
|
||||
pub attack_ms: FloatParam,
|
||||
#[id = "release"]
|
||||
pub release_ms: FloatParam,
|
||||
#[id = "makeup"]
|
||||
pub makeup_db: FloatParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
|
||||
impl Default for Codename206Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor_state: EguiState::from_size(760, 520),
|
||||
|
||||
crossover_low_hz: FloatParam::new(
|
||||
"Crossover Lo/Mid",
|
||||
200.0,
|
||||
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
crossover_high_hz: FloatParam::new(
|
||||
"Crossover Mid/Hi",
|
||||
2_500.0,
|
||||
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||
|
||||
look_ahead_ms: FloatParam::new(
|
||||
"Look-ahead",
|
||||
2.0,
|
||||
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
output_ceiling_db: FloatParam::new(
|
||||
"Ceiling",
|
||||
0.0,
|
||||
FloatRange::Linear { min: -24.0, max: 0.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
limiter_release_ms: FloatParam::new(
|
||||
"Limiter Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
low: CompressorParams::default(),
|
||||
mid: CompressorParams::default(),
|
||||
high: CompressorParams::default(),
|
||||
all: CompressorParams::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompressorParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pre_gain_db: FloatParam::new(
|
||||
"Pre-gain",
|
||||
0.0,
|
||||
FloatRange::Linear { min: -24.0, max: 36.0 },
|
||||
)
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
detection: EnumParam::new("Detection", DetectionMode::Peak),
|
||||
|
||||
threshold_db: FloatParam::new(
|
||||
"Threshold",
|
||||
-18.0,
|
||||
FloatRange::Linear { min: -60.0, max: 0.0 },
|
||||
)
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
ratio: FloatParam::new(
|
||||
"Ratio",
|
||||
2.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
||||
)
|
||||
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
||||
.with_string_to_value(Arc::new(|s| {
|
||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||
})),
|
||||
|
||||
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 24.0 })
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
attack_ms: FloatParam::new(
|
||||
"Attack",
|
||||
10.0,
|
||||
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||
|
||||
release_ms: FloatParam::new(
|
||||
"Release",
|
||||
100.0,
|
||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||
)
|
||||
.with_unit(" ms")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -24.0, max: 24.0 })
|
||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
bypass: BoolParam::new("Bypass", false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
||||
pub fn build_settings(
|
||||
p: &CompressorParams,
|
||||
lookahead: usize,
|
||||
sample_rate: f32,
|
||||
) -> CompressorSettings {
|
||||
CompressorSettings {
|
||||
threshold_db: p.threshold_db.value(),
|
||||
ratio: p.ratio.value(),
|
||||
knee_db: p.knee_db.value(),
|
||||
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
||||
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: lookahead,
|
||||
use_rms: p.detection.value() == DetectionMode::Rms,
|
||||
bypass: p.bypass.value(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user