Stage 2: full-band compressor with constant-latency look-ahead
Implements the single full-band feed-forward compressor (the engine that will be reused per band + for the 'All' channel). Design follows Giannoulis et al. 2012: log-domain gain computer with a quadratic soft knee feeding a smooth decoupled peak detector for attack/release ballistics. Stereo-linked peak detection. Look-ahead uses a fixed audio delay with a constant reported latency (set once in initialize); the knob only moves the detector tap within that delay. This avoids renegotiating latency from process(), which crashed FL Studio when the look-ahead was adjusted during playback. - src/dsp/compressor.rs: Compressor + CompressorSettings, RT-safe (no alloc in process; buffers sized in prepare; envelope denormals flushed in-code) - src/lib.rs: nested CompressorParams (threshold/ratio/knee/attack/release/makeup/ bypass) + global look-ahead; egui ParamSlider grid; latency reported once - 5 unit tests (static curve, knee continuity, steady-state convergence, constant latency); Cargo.toml lib crate-type added so tests link - README: 'All' channel architecture already documented; look-ahead spec updated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -14,7 +14,8 @@ license = "GPL-3.0-or-later"
|
|||||||
members = ["xtask"]
|
members = ["xtask"]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
# `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests.
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Pinned to the exact commit of the local ./nih-plug reference clone so the build
|
# Pinned to the exact commit of the local ./nih-plug reference clone so the build
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ a first-class mode, not an afterthought.
|
|||||||
### Global
|
### Global
|
||||||
- `input_gain` — pre-gain before filterbank (dB)
|
- `input_gain` — pre-gain before filterbank (dB)
|
||||||
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
||||||
- `look_ahead_ms` — look-ahead time (0–10 ms)
|
- `look_ahead_ms` — look-ahead time (0–5 ms). Reported latency is **constant** (the max look-ahead); the knob only moves the detector tap within that fixed delay, so it is safe to adjust during playback (changing reported latency mid-stream crashes some hosts, FL included)
|
||||||
- `crossover_low_hz` — low/mid crossover frequency
|
- `crossover_low_hz` — low/mid crossover frequency
|
||||||
- `crossover_high_hz` — mid/high crossover frequency
|
- `crossover_high_hz` — mid/high crossover frequency
|
||||||
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
|
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! Single full-band feed-forward compressor with look-ahead.
|
||||||
|
//!
|
||||||
|
//! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor
|
||||||
|
//! Design — A Tutorial and Analysis" (JAES 2012):
|
||||||
|
//!
|
||||||
|
//! * a **log-domain gain computer** with a quadratic **soft knee**, and
|
||||||
|
//! * a **smooth, decoupled peak detector** for the attack/release ballistics
|
||||||
|
//! (their preferred topology — avoids the artefacts of naive branching smoothers).
|
||||||
|
//!
|
||||||
|
//! Detection is **stereo-linked** (the control signal is `max(|ch|)` across channels)
|
||||||
|
//! so a single gain is applied to every channel and the stereo image is preserved.
|
||||||
|
//!
|
||||||
|
//! Look-ahead is a per-channel delay line on the audio path: the output sample is the
|
||||||
|
//! input from `L` samples ago, while the gain is computed from the *current* input —
|
||||||
|
//! so the gain reduction leads the audio by `L` samples. `L` is the plugin's latency.
|
||||||
|
|
||||||
|
/// Maximum look-ahead. This is also the **fixed** latency the plugin reports: the audio is
|
||||||
|
/// always delayed by this much and the latency is reported once, so the look-ahead knob can be
|
||||||
|
/// adjusted during playback without ever renegotiating latency with the host (which crashes
|
||||||
|
/// some DAWs, FL included). The knob only moves where the detector taps within this window.
|
||||||
|
pub const MAX_LOOKAHEAD_MS: f32 = 5.0;
|
||||||
|
|
||||||
|
/// Most channels we ever process in one frame (our audio layouts are mono/stereo).
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
|
||||||
|
const LEVEL_EPS: f32 = 1e-12;
|
||||||
|
|
||||||
|
/// Flush a decaying envelope value to zero once it is far below audibility, so the
|
||||||
|
/// exponential tail can't drift into denormal range (which causes CPU spikes).
|
||||||
|
#[inline]
|
||||||
|
fn flush_denormal(x: f32) -> f32 {
|
||||||
|
if x.abs() < 1e-30 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-block compressor settings. Cheap to copy; rebuilt each process block from params.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CompressorSettings {
|
||||||
|
pub threshold_db: f32,
|
||||||
|
pub ratio: f32,
|
||||||
|
pub knee_db: f32,
|
||||||
|
/// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]).
|
||||||
|
pub attack_coef: f32,
|
||||||
|
/// One-pole coefficient for the release ramp.
|
||||||
|
pub release_coef: f32,
|
||||||
|
pub makeup_db: f32,
|
||||||
|
/// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`.
|
||||||
|
/// This does NOT change the reported latency — the audio delay is always `fixed_delay`.
|
||||||
|
pub lookahead_samples: usize,
|
||||||
|
pub bypass: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Compressor {
|
||||||
|
sample_rate: f32,
|
||||||
|
|
||||||
|
/// Per-channel circular delay line, each `capacity` samples long.
|
||||||
|
delay: Vec<Vec<f32>>,
|
||||||
|
capacity: usize,
|
||||||
|
write_pos: usize,
|
||||||
|
/// Constant audio delay applied to every sample == the reported plugin latency.
|
||||||
|
fixed_delay: usize,
|
||||||
|
|
||||||
|
/// Smooth decoupled peak-detector state, expressed as dB of **attenuation** (>= 0).
|
||||||
|
y1: f32, // release branch (peak-with-decay)
|
||||||
|
yl: f32, // attack-smoothed output
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Compressor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
delay: Vec::new(),
|
||||||
|
capacity: 0,
|
||||||
|
write_pos: 0,
|
||||||
|
fixed_delay: 0,
|
||||||
|
y1: 0.0,
|
||||||
|
yl: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compressor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`,
|
||||||
|
/// where allocation is allowed — never from `process()`.
|
||||||
|
pub fn prepare(&mut self, sample_rate: f32, num_channels: usize, max_lookahead_ms: f32) {
|
||||||
|
self.sample_rate = sample_rate;
|
||||||
|
self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize;
|
||||||
|
// +1 so the oldest (output) sample and the newest (write) sample never alias.
|
||||||
|
self.capacity = self.fixed_delay + 1;
|
||||||
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all state. Real-time safe (no allocation).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for ch in self.delay.iter_mut() {
|
||||||
|
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||||
|
}
|
||||||
|
self.write_pos = 0;
|
||||||
|
self.y1 = 0.0;
|
||||||
|
self.yl = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an attack/release time in milliseconds to a one-pole smoothing coefficient.
|
||||||
|
///
|
||||||
|
/// Convention: after `time_ms`, a step response reaches ~63% (1 − 1/e) of its target.
|
||||||
|
/// 0 ms (or less) yields coefficient 0 = instantaneous.
|
||||||
|
pub fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
|
||||||
|
if time_ms <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(-1.0 / (time_ms * 0.001 * sample_rate)).exp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
|
||||||
|
let over = level_db - threshold_db;
|
||||||
|
|
||||||
|
if knee_db > 0.0 && 2.0 * over.abs() <= knee_db {
|
||||||
|
// Inside the knee: a parabola joining the two regions with a continuous slope.
|
||||||
|
let x = over + knee_db * 0.5; // 0..knee
|
||||||
|
slope * x * x / (2.0 * knee_db)
|
||||||
|
} else if over > 0.0 {
|
||||||
|
// Above the knee (also covers the hard-knee case): linear region.
|
||||||
|
slope * over
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plugin's fixed reported latency in samples (the constant audio delay).
|
||||||
|
pub fn latency(&self) -> u32 {
|
||||||
|
self.fixed_delay as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
|
||||||
|
///
|
||||||
|
/// `input` and `output` are short stack slices (one value per channel), so this
|
||||||
|
/// performs no allocation. Detection is linked across the provided channels.
|
||||||
|
///
|
||||||
|
/// The audio is always delayed by `fixed_delay`; `set.lookahead_samples` (0..=fixed_delay)
|
||||||
|
/// only chooses how far *ahead* of the output the detector taps, so changing it never
|
||||||
|
/// alters latency.
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut [f32], set: &CompressorSettings) {
|
||||||
|
debug_assert_eq!(input.len(), output.len());
|
||||||
|
let n = input.len().min(self.delay.len());
|
||||||
|
let l = set.lookahead_samples.min(self.fixed_delay);
|
||||||
|
|
||||||
|
// 1) Write the current input into the delay lines.
|
||||||
|
for ch in 0..n {
|
||||||
|
self.delay[ch][self.write_pos] = input[ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads
|
||||||
|
// `l` samples newer than the output, i.e. `l` samples into the output's future.
|
||||||
|
let out_pos = (self.write_pos + self.capacity - self.fixed_delay) % self.capacity;
|
||||||
|
let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity;
|
||||||
|
|
||||||
|
// 3) Linked peak detector at the look-ahead tap.
|
||||||
|
let mut peak = 0.0f32;
|
||||||
|
for ch in 0..n {
|
||||||
|
peak = peak.max(self.delay[ch][det_pos].abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Gain computer + ballistics. On bypass we keep the delay aligned (so toggling
|
||||||
|
// bypass doesn't shift timing) but apply unity gain and no makeup.
|
||||||
|
let gain_lin = if set.bypass {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
let level_db = 20.0 * (peak + LEVEL_EPS).log10();
|
||||||
|
// Desired attenuation in dB, as a positive quantity.
|
||||||
|
let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db);
|
||||||
|
|
||||||
|
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
||||||
|
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
||||||
|
// yl = attack-smoothed y1
|
||||||
|
self.y1 = flush_denormal(
|
||||||
|
target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target),
|
||||||
|
);
|
||||||
|
self.yl = flush_denormal(set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1);
|
||||||
|
|
||||||
|
let total_db = set.makeup_db - self.yl;
|
||||||
|
10.0f32.powf(total_db / 20.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5) Output = delayed input (always `fixed_delay` old) * gain.
|
||||||
|
for ch in 0..n {
|
||||||
|
output[ch] = self.delay[ch][out_pos] * gain_lin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Advance the write head.
|
||||||
|
self.write_pos = (self.write_pos + 1) % self.capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
fn assert_close(a: f32, b: f32, tol: f32) {
|
||||||
|
assert!((a - b).abs() <= tol, "expected {a} ≈ {b} (tol {tol})");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(threshold_db: f32, ratio: f32, knee_db: f32) -> CompressorSettings {
|
||||||
|
CompressorSettings {
|
||||||
|
threshold_db,
|
||||||
|
ratio,
|
||||||
|
knee_db,
|
||||||
|
attack_coef: Compressor::time_to_coef(1.0, SR),
|
||||||
|
release_coef: Compressor::time_to_coef(1.0, SR),
|
||||||
|
makeup_db: 0.0,
|
||||||
|
lookahead_samples: 0,
|
||||||
|
bypass: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn below_threshold_is_untouched() {
|
||||||
|
// -30 dB input, -20 dB threshold -> no reduction.
|
||||||
|
assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn above_knee_follows_ratio() {
|
||||||
|
// 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction.
|
||||||
|
let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0);
|
||||||
|
assert_close(r, -7.5, 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn knee_is_continuous_with_linear_region() {
|
||||||
|
// At the upper knee edge the soft-knee and linear formulas must agree.
|
||||||
|
let (t, ratio, knee) = (0.0, 4.0, 6.0);
|
||||||
|
let edge = t + knee / 2.0;
|
||||||
|
let knee_val = Compressor::gain_computer(edge, t, ratio, knee);
|
||||||
|
let linear_val = (1.0 / ratio - 1.0) * (edge - t);
|
||||||
|
assert_close(knee_val, linear_val, 1e-4);
|
||||||
|
// At the lower edge there is still no reduction.
|
||||||
|
assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee), 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steady_state_matches_static_curve() {
|
||||||
|
// Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp:
|
||||||
|
// reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20).
|
||||||
|
let mut comp = Compressor::new();
|
||||||
|
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS);
|
||||||
|
let set = settings(-18.0, 2.0, 0.0);
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
for _ in 0..SR as usize {
|
||||||
|
comp.process(&[0.5], &mut out, &set);
|
||||||
|
}
|
||||||
|
let level_db = 20.0 * 0.5f32.log10();
|
||||||
|
let reduction = (1.0 / 2.0 - 1.0) * (level_db - (-18.0));
|
||||||
|
let expected = 0.5 * 10.0f32.powf(reduction / 20.0);
|
||||||
|
assert_close(out[0], expected, 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latency_is_constant_regardless_of_lookahead() {
|
||||||
|
// Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead
|
||||||
|
// knob must NOT change that (this is what keeps live adjustment from renegotiating
|
||||||
|
// latency and crashing the host).
|
||||||
|
let mut comp = Compressor::new();
|
||||||
|
comp.prepare(SR, 1, 0.5); // small max so the fixed delay is quick to test
|
||||||
|
let d = comp.latency() as usize;
|
||||||
|
assert!(d > 0);
|
||||||
|
|
||||||
|
for &l in &[0usize, d / 2, d] {
|
||||||
|
comp.reset();
|
||||||
|
let mut set = settings(0.0, 1.0, 0.0);
|
||||||
|
set.bypass = true; // unity gain -> isolate the delay behaviour
|
||||||
|
set.lookahead_samples = l;
|
||||||
|
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
for n in 0..(d + 5) {
|
||||||
|
let x = if n == 0 { 1.0 } else { 0.0 };
|
||||||
|
comp.process(&[x], &mut out, &set);
|
||||||
|
if n == d {
|
||||||
|
assert_close(out[0], 1.0, 1e-6); // impulse always emerges after `d`, any L
|
||||||
|
} else {
|
||||||
|
assert_close(out[0], 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
pub mod compressor;
|
||||||
+192
-33
@@ -2,31 +2,59 @@ use nih_plug::prelude::*;
|
|||||||
use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState};
|
use nih_plug_egui::{create_egui_editor, egui, widgets, EguiState};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Codename 206 — landmark build.
|
mod dsp;
|
||||||
|
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||||
|
|
||||||
|
/// Codename 206 — Stage 2: a single full-band compressor with look-ahead.
|
||||||
///
|
///
|
||||||
/// This is intentionally tiny: a single full-band gain parameter exposed through one
|
/// The `CompressorParams` struct is `#[nested]` so the exact same controls + DSP can be
|
||||||
/// egui slider. It is the foundation the multiband compressor/limiter (see README.md)
|
/// reused for the three bands and the 'All' aggregate channel in later stages.
|
||||||
/// will be built on top of. The signal path is currently just `sample *= gain`.
|
|
||||||
struct Codename206 {
|
struct Codename206 {
|
||||||
params: Arc<Codename206Params>,
|
params: Arc<Codename206Params>,
|
||||||
|
sample_rate: f32,
|
||||||
|
comp: Compressor,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Params)]
|
#[derive(Params)]
|
||||||
struct Codename206Params {
|
struct Codename206Params {
|
||||||
/// The egui editor's window state (size). Persisted with the parameter state so a
|
|
||||||
/// resized window is restored on reload.
|
|
||||||
#[persist = "editor-state"]
|
#[persist = "editor-state"]
|
||||||
editor_state: Arc<EguiState>,
|
editor_state: Arc<EguiState>,
|
||||||
|
|
||||||
/// Full-band output gain. Stored as a linear gain multiplier but displayed in dB.
|
/// Look-ahead time: how far ahead the detector reads so gain reduction can lead
|
||||||
#[id = "gain"]
|
/// transients. The reported latency is constant (the max look-ahead) regardless of this
|
||||||
pub gain: FloatParam,
|
/// value, so it is safe to adjust during playback.
|
||||||
|
#[id = "lookahead"]
|
||||||
|
pub look_ahead_ms: FloatParam,
|
||||||
|
|
||||||
|
/// The full-band compressor controls (reused per band + 'All' channel later).
|
||||||
|
#[nested(group = "Compressor")]
|
||||||
|
pub comp: CompressorParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Params)]
|
||||||
|
struct CompressorParams {
|
||||||
|
#[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 Codename206 {
|
impl Default for Codename206 {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
params: Arc::new(Codename206Params::default()),
|
params: Arc::new(Codename206Params::default()),
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
comp: Compressor::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,28 +62,88 @@ impl Default for Codename206 {
|
|||||||
impl Default for Codename206Params {
|
impl Default for Codename206Params {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
editor_state: EguiState::from_size(300, 140),
|
editor_state: EguiState::from_size(360, 360),
|
||||||
|
|
||||||
// Stored as linear gain, displayed/edited in dB. Skewed so the slider feels
|
look_ahead_ms: FloatParam::new(
|
||||||
// linear in decibels across the -30..+30 dB range.
|
"Look-ahead",
|
||||||
gain: FloatParam::new(
|
2.0,
|
||||||
"Gain",
|
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||||
util::db_to_gain(0.0),
|
|
||||||
FloatRange::Skewed {
|
|
||||||
min: util::db_to_gain(-30.0),
|
|
||||||
max: util::db_to_gain(30.0),
|
|
||||||
factor: FloatRange::gain_skew_factor(-30.0, 30.0),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
// Linear-gain storage needs logarithmic smoothing to avoid zipper noise.
|
.with_unit(" ms")
|
||||||
.with_smoother(SmoothingStyle::Logarithmic(50.0))
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
.with_unit(" dB")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_gain_to_db(2))
|
comp: CompressorParams::default(),
|
||||||
.with_string_to_value(formatters::s2v_f32_gain_to_db()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for CompressorParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
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: 1000.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 },
|
||||||
|
)
|
||||||
|
// Applied per sample, so smooth it to avoid zipper noise.
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
bypass: BoolParam::new("Bypass", false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Codename206 {
|
||||||
|
/// Look-ahead in samples for the current parameter value and sample rate.
|
||||||
|
fn lookahead_samples(&self) -> usize {
|
||||||
|
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Plugin for Codename206 {
|
impl Plugin for Codename206 {
|
||||||
const NAME: &'static str = "206 prototype";
|
const NAME: &'static str = "206 prototype";
|
||||||
const VENDOR: &'static str = "Novoyuuparosk";
|
const VENDOR: &'static str = "Novoyuuparosk";
|
||||||
@@ -97,25 +185,96 @@ impl Plugin for Codename206 {
|
|||||||
|_, _| {},
|
|_, _| {},
|
||||||
move |egui_ctx, setter, _state| {
|
move |egui_ctx, setter, _state| {
|
||||||
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
||||||
ui.heading("Codename 206");
|
ui.heading(Self::NAME);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label("Gain");
|
egui::Grid::new("params").num_columns(2).show(ui, |ui| {
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.gain, setter));
|
ui.label("Threshold");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.threshold_db, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Ratio");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.ratio, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Knee");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.knee_db, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Attack");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.attack_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Release");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.release_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Makeup");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.makeup_db, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Look-ahead");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Bypass");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.comp.bypass, setter));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initialize(
|
||||||
|
&mut self,
|
||||||
|
audio_io_layout: &AudioIOLayout,
|
||||||
|
buffer_config: &BufferConfig,
|
||||||
|
context: &mut impl InitContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
self.sample_rate = buffer_config.sample_rate;
|
||||||
|
let channels = audio_io_layout
|
||||||
|
.main_output_channels
|
||||||
|
.map(NonZeroU32::get)
|
||||||
|
.unwrap_or(2) as usize;
|
||||||
|
self.comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||||
|
|
||||||
|
// Latency is constant (the fixed audio delay) and reported exactly once, so changing
|
||||||
|
// the look-ahead knob during playback never renegotiates latency with the host.
|
||||||
|
context.set_latency_samples(self.comp.latency());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.comp.reset();
|
||||||
|
}
|
||||||
|
|
||||||
fn process(
|
fn process(
|
||||||
&mut self,
|
&mut self,
|
||||||
buffer: &mut Buffer,
|
buffer: &mut Buffer,
|
||||||
_aux: &mut AuxiliaryBuffers,
|
_aux: &mut AuxiliaryBuffers,
|
||||||
_context: &mut impl ProcessContext<Self>,
|
_context: &mut impl ProcessContext<Self>,
|
||||||
) -> ProcessStatus {
|
) -> ProcessStatus {
|
||||||
for channel_samples in buffer.iter_samples() {
|
// Look-ahead is a detector-tap offset within a fixed delay; it never changes latency.
|
||||||
let gain = self.params.gain.smoothed.next();
|
let lookahead = self.lookahead_samples();
|
||||||
for sample in channel_samples {
|
|
||||||
*sample *= gain;
|
// Block-rate compressor settings (these change slowly; makeup is smoothed per sample).
|
||||||
|
let c = &self.params.comp;
|
||||||
|
let mut set = CompressorSettings {
|
||||||
|
threshold_db: c.threshold_db.value(),
|
||||||
|
ratio: c.ratio.value(),
|
||||||
|
knee_db: c.knee_db.value(),
|
||||||
|
attack_coef: Compressor::time_to_coef(c.attack_ms.value(), self.sample_rate),
|
||||||
|
release_coef: Compressor::time_to_coef(c.release_ms.value(), self.sample_rate),
|
||||||
|
makeup_db: 0.0,
|
||||||
|
lookahead_samples: lookahead,
|
||||||
|
bypass: c.bypass.value(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut in_frame = [0.0f32; 2];
|
||||||
|
let mut out_frame = [0.0f32; 2];
|
||||||
|
for mut frame in buffer.iter_samples() {
|
||||||
|
set.makeup_db = c.makeup_db.smoothed.next();
|
||||||
|
|
||||||
|
let n = frame.len().min(2);
|
||||||
|
for ch in 0..n {
|
||||||
|
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||||
|
}
|
||||||
|
self.comp.process(&in_frame[..n], &mut out_frame[..n], &set);
|
||||||
|
for ch in 0..n {
|
||||||
|
*frame.get_mut(ch).unwrap() = out_frame[ch];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +285,7 @@ impl Plugin for Codename206 {
|
|||||||
impl ClapPlugin for Codename206 {
|
impl ClapPlugin for Codename206 {
|
||||||
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
|
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
|
||||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||||
Some("Multiband compressor/limiter (landmark: full-band gain)");
|
Some("Multiband compressor/limiter (stage 2: full-band compressor)");
|
||||||
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
||||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||||
|
|||||||
Reference in New Issue
Block a user