Stage 3: 3-band LR4 crossover + per-band compressors into the 'All' channel
Splits the input into low/mid/high with a Linkwitz-Riley 24 dB/oct crossover, compresses each band, sums them, then runs the sum through a fourth 'All' compressor. Bypassing the three bands collapses the plugin to a simple full-band comp driven by 'All' (the crossover sums flat in magnitude). - src/dsp/biquad.rs: generic RBJ biquad (Transposed Direct Form II), LP/HP/AP - src/dsp/crossover.rs: 3-band LR4 filterbank; lower band all-pass-compensated at the higher crossover so the bands sum to flat magnitude (an all-pass, not a bit-exact null — that only holds for linear-phase FIR). Mirrors nih-plug's crossover plugin design. - src/lib.rs: 4 Compressor instances (low/mid/high/all) + Crossover; params restructured to 4 nested CompressorParams (id_prefix low/mid/high/all) plus global crossover_low_hz/crossover_high_hz/look_ahead_ms; 4-column lo|mid|hi|all egui UI; latency = two series stages (bands + all), constant, reported once - 10 unit tests (adds biquad LP/AP magnitude, crossover flat-magnitude reconstruction, band-split sanity) - README: Stage 3 marked done; corrected the 'sum flat' expectation to flat magnitude (IIR LR sums to an all-pass, not a time-domain null) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ a first-class mode, not an afterthought.
|
|||||||
### Crossover Filterbank
|
### Crossover Filterbank
|
||||||
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
|
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
|
||||||
- LR4 = two cascaded biquads (Butterworth LP or HP)
|
- LR4 = two cascaded biquads (Butterworth LP or HP)
|
||||||
- Bands sum phase-coherently back to flat
|
- Bands sum phase-coherently to flat **magnitude** (the sum is an all-pass; lower bands get an all-pass at each later crossover to match phase — not a bit-exact time-domain null)
|
||||||
- Crossover frequencies are user-adjustable parameters
|
- Crossover frequencies are user-adjustable parameters
|
||||||
### Per-Band Compressor
|
### Per-Band Compressor
|
||||||
- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later)
|
- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later)
|
||||||
@@ -114,9 +114,9 @@ src/
|
|||||||
dsp/
|
dsp/
|
||||||
mod.rs # ✅ module declarations
|
mod.rs # ✅ module declarations
|
||||||
compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay
|
compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay
|
||||||
crossover.rs # (planned) LR4 filterbank (biquad chains)
|
crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation
|
||||||
|
biquad.rs # ✅ generic biquad (Transposed Direct Form II)
|
||||||
limiter.rs # (planned) output true-peak brickwall limiter
|
limiter.rs # (planned) output true-peak brickwall limiter
|
||||||
biquad.rs # (planned) generic biquad (Direct Form II transposed)
|
|
||||||
delay.rs # (planned) look-ahead delay (currently lives inside compressor.rs)
|
delay.rs # (planned) look-ahead delay (currently lives inside compressor.rs)
|
||||||
oversampler.rs # (planned) 4x oversampler for true-peak detection
|
oversampler.rs # (planned) 4x oversampler for true-peak detection
|
||||||
editor/
|
editor/
|
||||||
@@ -170,10 +170,12 @@ is essential — without it FL silently skips a plugin it has seen before.)
|
|||||||
|
|
||||||
Work through these stages in order — each stage produces a loadable, audible plugin.
|
Work through these stages in order — each stage produces a loadable, audible plugin.
|
||||||
|
|
||||||
**Status (2026-06-15):** Stages 1–2 are complete. Look-ahead + latency reporting (from Stage 4)
|
**Status (2026-06-17):** Stages 1–3 complete — full-band compressor, peak/RMS detection, and now
|
||||||
and a basic slider UI (from Stage 5) were pulled forward and already work. **Next: Stage 3 —
|
the 3-band LR4 crossover feeding per-band compressors summed into the 'All' channel (4 reusable
|
||||||
crossover filterbank.** DSP currently lives in `src/dsp/compressor.rs`; params and the egui
|
`Compressor` instances). Look-ahead + latency (Stage 4) and a basic 4-column UI (Stage 5) are in.
|
||||||
editor are still inline in `src/lib.rs` (not yet split into `params.rs` / `editor/`).
|
**Next: Stage 4 — output brickwall limiter + oversampler.** DSP is in `src/dsp/`
|
||||||
|
(`biquad.rs`, `crossover.rs`, `compressor.rs`); params and the egui editor are still inline in
|
||||||
|
`src/lib.rs` (not yet split into `params.rs` / `editor/`).
|
||||||
|
|
||||||
### Stage 1 — Skeleton plugin ✅
|
### Stage 1 — Skeleton plugin ✅
|
||||||
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
||||||
@@ -186,17 +188,17 @@ editor are still inline in `src/lib.rs` (not yet split into `params.rs` / `edito
|
|||||||
- [x] Implement gain computer (threshold, ratio, soft knee)
|
- [x] Implement gain computer (threshold, ratio, soft knee)
|
||||||
- [x] Implement attack/release envelope (smooth decoupled peak detector)
|
- [x] Implement attack/release envelope (smooth decoupled peak detector)
|
||||||
- [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency)
|
- [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency)
|
||||||
### Stage 3 — Crossover filterbank ⬅ next
|
### Stage 3 — Crossover filterbank ✅
|
||||||
- [ ] Implement LR4 LP and HP biquad chains in `crossover.rs`
|
- [x] Implement LR4 LP/HP biquad chains in `crossover.rs` (+ generic `biquad.rs`, Transposed Direct Form II)
|
||||||
- [ ] Verify bands sum flat (null test: sum vs dry should be silence)
|
- [x] Verify bands sum flat — for IIR LR4 the sum is an **all-pass** (flat *magnitude*, phase-shifted), not a bit-exact null; lower bands get an all-pass at each later crossover to phase-match. Tested via `bands_sum_to_flat_magnitude`
|
||||||
- [ ] Add per-band bypass; with all bands bypassed, output must null against dry (proves the "simple comp" mode path)
|
- [x] Per-band bypass — a bypassed band passes its delayed dry band; with all three bypassed the 'All' channel sees the flat-magnitude reconstruction = the simple-comp mode
|
||||||
- [ ] Apply per-band compressor to each band
|
- [x] Apply per-band compressor to each band
|
||||||
- [ ] Sum bands back together
|
- [x] Sum bands back together
|
||||||
- [ ] Run the summed signal through the 'All' channel comp/lim (reuse the per-band compressor) before output
|
- [x] Run the summed signal through the 'All' channel compressor before output
|
||||||
### Stage 4 — Look-ahead + brickwall limiter *(look-ahead + latency done early)*
|
### Stage 4 — Output brickwall limiter + oversampler ⬅ next *(look-ahead + latency already done)*
|
||||||
- [x] Look-ahead delay (circular buffer) — currently inside `compressor.rs`, no separate `delay.rs` yet
|
- [x] Look-ahead delay (circular buffer) — inside `compressor.rs`, no separate `delay.rs`
|
||||||
- [x] Wire look-ahead: detector reads N samples ahead of the VCA
|
- [x] Wire look-ahead: detector reads N samples ahead of the VCA
|
||||||
- [x] Report latency — via `context.set_latency_samples()`, reported once as a constant (see Latency note)
|
- [x] Report latency — `context.set_latency_samples()` once; now the constant two-stage total (bands + 'All')
|
||||||
- [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc)
|
- [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc)
|
||||||
- [ ] Implement brickwall output limiter with true-peak detection
|
- [ ] Implement brickwall output limiter with true-peak detection
|
||||||
### Stage 5 — Basic egui UI *(basic version done early)*
|
### Stage 5 — Basic egui UI *(basic version done early)*
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Generic second-order IIR biquad, Transposed Direct Form II.
|
||||||
|
//!
|
||||||
|
//! Coefficient formulas are the RBJ Audio EQ Cookbook
|
||||||
|
//! (<https://www.w3.org/TR/audio-eq-cookbook/>), prenormalised by `a0`. Scalar `f32`; we run
|
||||||
|
//! one filter per channel rather than SIMD to match the rest of the per-channel DSP.
|
||||||
|
|
||||||
|
use std::f32::consts;
|
||||||
|
|
||||||
|
/// Butterworth Q (= 1/√2). Two cascaded Butterworth sections make a 4th-order Linkwitz-Riley.
|
||||||
|
pub const NEUTRAL_Q: f32 = consts::FRAC_1_SQRT_2;
|
||||||
|
|
||||||
|
/// Prenormalised biquad coefficients `[b0, b1, b2, a1, a2]` (already divided by `a0`).
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct BiquadCoefficients {
|
||||||
|
b0: f32,
|
||||||
|
b1: f32,
|
||||||
|
b2: f32,
|
||||||
|
a1: f32,
|
||||||
|
a2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BiquadCoefficients {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::identity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BiquadCoefficients {
|
||||||
|
/// Passes the signal through unchanged.
|
||||||
|
pub fn identity() -> Self {
|
||||||
|
Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lowpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: ((1.0 - cos_w0) / 2.0) / a0,
|
||||||
|
b1: (1.0 - cos_w0) / a0,
|
||||||
|
b2: ((1.0 - cos_w0) / 2.0) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: ((1.0 + cos_w0) / 2.0) / a0,
|
||||||
|
b1: -(1.0 + cos_w0) / a0,
|
||||||
|
b2: ((1.0 + cos_w0) / 2.0) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: (1.0 - alpha) / a0,
|
||||||
|
b1: (-2.0 * cos_w0) / a0,
|
||||||
|
b2: (1.0 + alpha) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared intermediate terms: `(cos ω0, α)`.
|
||||||
|
fn omega(sample_rate: f32, frequency: f32, q: f32) -> (f32, f32) {
|
||||||
|
let w0 = consts::TAU * (frequency / sample_rate);
|
||||||
|
(w0.cos(), w0.sin() / (2.0 * q))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A biquad filter holding its two state variables.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct Biquad {
|
||||||
|
coefficients: BiquadCoefficients,
|
||||||
|
s1: f32,
|
||||||
|
s2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biquad {
|
||||||
|
/// Replace the coefficients (keeps the state — fine for smooth coefficient changes).
|
||||||
|
pub fn set_coefficients(&mut self, coefficients: BiquadCoefficients) {
|
||||||
|
self.coefficients = coefficients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one sample (Transposed Direct Form II).
|
||||||
|
#[inline]
|
||||||
|
pub fn process(&mut self, x: f32) -> f32 {
|
||||||
|
let c = &self.coefficients;
|
||||||
|
let y = c.b0 * x + self.s1;
|
||||||
|
self.s1 = c.b1 * x - c.a1 * y + self.s2;
|
||||||
|
self.s2 = c.b2 * x - c.a2 * y;
|
||||||
|
y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the filter state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.s1 = 0.0;
|
||||||
|
self.s2 = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
fn magnitude_at(mut coeffs_filter: Biquad, freq: f32) -> f32 {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
let n = 16_000usize;
|
||||||
|
let mut acc = 0.0f64;
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * freq * i as f32 / SR).sin();
|
||||||
|
let y = coeffs_filter.process(x);
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
acc += (y * y) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RMS of a unit sine is 1/√2; divide it out to get the magnitude response.
|
||||||
|
((acc / 8_000.0).sqrt() as f32) * std::f32::consts::SQRT_2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lowpass_passes_dc_blocks_highs() {
|
||||||
|
let lp = {
|
||||||
|
let mut b = Biquad::default();
|
||||||
|
b.set_coefficients(BiquadCoefficients::lowpass(SR, 1_000.0, NEUTRAL_Q));
|
||||||
|
b
|
||||||
|
};
|
||||||
|
assert!((magnitude_at(lp, 100.0) - 1.0).abs() < 0.05); // ~passband
|
||||||
|
assert!(magnitude_at(lp, 12_000.0) < 0.05); // ~stopband
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allpass_is_unity_magnitude() {
|
||||||
|
for &f in &[100.0, 1_000.0, 8_000.0] {
|
||||||
|
let mut b = Biquad::default();
|
||||||
|
b.set_coefficients(BiquadCoefficients::allpass(SR, 2_000.0, NEUTRAL_Q));
|
||||||
|
assert!((magnitude_at(b, f) - 1.0).abs() < 0.02, "allpass not flat at {f} Hz");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank.
|
||||||
|
//!
|
||||||
|
//! Each crossover splits into a low-passed band output and a high-passed remainder that feeds
|
||||||
|
//! the next crossover. Because higher bands pass through more filters, lower bands are phase-
|
||||||
|
//! compensated with an all-pass at every *later* crossover frequency so the three bands sum back
|
||||||
|
//! to flat **magnitude** (the sum is an all-pass of the input — phase-shifted, not bit-identical,
|
||||||
|
//! which is inherent to IIR Linkwitz-Riley). Approach mirrors NIH-plug's `crossover` plugin.
|
||||||
|
//!
|
||||||
|
//! For 3 bands there are two crossovers (low/mid at `f_lo`, mid/high at `f_hi`); only the low
|
||||||
|
//! band needs compensation (one all-pass at `f_hi`).
|
||||||
|
|
||||||
|
use super::biquad::{Biquad, BiquadCoefficients, NEUTRAL_Q};
|
||||||
|
|
||||||
|
/// Mono/stereo only, matching the plugin's audio layouts.
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// One channel's worth of filter state for the 3-band split.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct BandSplitter {
|
||||||
|
lp_lo: [Biquad; 2], // LR4 low-pass at f_lo (two cascaded Butterworth)
|
||||||
|
hp_lo: [Biquad; 2], // LR4 high-pass at f_lo
|
||||||
|
lp_hi: [Biquad; 2], // LR4 low-pass at f_hi
|
||||||
|
hp_hi: [Biquad; 2], // LR4 high-pass at f_hi
|
||||||
|
ap_low: Biquad, // all-pass at f_hi, phase-compensates the low band
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BandSplitter {
|
||||||
|
/// Split one sample into `[low, mid, high]`.
|
||||||
|
fn split(&mut self, x: f32) -> [f32; 3] {
|
||||||
|
// Crossover at f_lo: low-passed band + high-passed remainder.
|
||||||
|
let mut lp = x;
|
||||||
|
for f in &mut self.lp_lo {
|
||||||
|
lp = f.process(lp);
|
||||||
|
}
|
||||||
|
let mut hp = x;
|
||||||
|
for f in &mut self.hp_lo {
|
||||||
|
hp = f.process(hp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low band is phase-compensated for the f_hi crossover the upper bands pass through.
|
||||||
|
let low = self.ap_low.process(lp);
|
||||||
|
|
||||||
|
// Crossover at f_hi splits the remainder into mid + high.
|
||||||
|
let mut mid = hp;
|
||||||
|
for f in &mut self.lp_hi {
|
||||||
|
mid = f.process(mid);
|
||||||
|
}
|
||||||
|
let mut high = hp;
|
||||||
|
for f in &mut self.hp_hi {
|
||||||
|
high = f.process(high);
|
||||||
|
}
|
||||||
|
|
||||||
|
[low, mid, high]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_coefficients(
|
||||||
|
&mut self,
|
||||||
|
lp_lo: BiquadCoefficients,
|
||||||
|
hp_lo: BiquadCoefficients,
|
||||||
|
lp_hi: BiquadCoefficients,
|
||||||
|
hp_hi: BiquadCoefficients,
|
||||||
|
ap_low: BiquadCoefficients,
|
||||||
|
) {
|
||||||
|
for f in &mut self.lp_lo {
|
||||||
|
f.set_coefficients(lp_lo);
|
||||||
|
}
|
||||||
|
for f in &mut self.hp_lo {
|
||||||
|
f.set_coefficients(hp_lo);
|
||||||
|
}
|
||||||
|
for f in &mut self.lp_hi {
|
||||||
|
f.set_coefficients(lp_hi);
|
||||||
|
}
|
||||||
|
for f in &mut self.hp_hi {
|
||||||
|
f.set_coefficients(hp_hi);
|
||||||
|
}
|
||||||
|
self.ap_low.set_coefficients(ap_low);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
for f in self
|
||||||
|
.lp_lo
|
||||||
|
.iter_mut()
|
||||||
|
.chain(&mut self.hp_lo)
|
||||||
|
.chain(&mut self.lp_hi)
|
||||||
|
.chain(&mut self.hp_hi)
|
||||||
|
{
|
||||||
|
f.reset();
|
||||||
|
}
|
||||||
|
self.ap_low.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Crossover {
|
||||||
|
channels: usize,
|
||||||
|
splitters: [BandSplitter; MAX_CHANNELS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Crossover {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: 2,
|
||||||
|
splitters: [BandSplitter::default(); MAX_CHANNELS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Crossover {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the active channel count and clear state. Call from `initialize()`.
|
||||||
|
pub fn prepare(&mut self, channels: usize) {
|
||||||
|
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute and apply crossover coefficients. Cheap enough to call once per block.
|
||||||
|
/// Frequencies are clamped to a valid range and forced monotonic (`f_lo <= f_hi`).
|
||||||
|
pub fn update(&mut self, sample_rate: f32, low_hz: f32, high_hz: f32) {
|
||||||
|
let max_hz = sample_rate * 0.49;
|
||||||
|
let f_lo = low_hz.clamp(20.0, max_hz);
|
||||||
|
let f_hi = high_hz.clamp(f_lo, max_hz);
|
||||||
|
|
||||||
|
let lp_lo = BiquadCoefficients::lowpass(sample_rate, f_lo, NEUTRAL_Q);
|
||||||
|
let hp_lo = BiquadCoefficients::highpass(sample_rate, f_lo, NEUTRAL_Q);
|
||||||
|
let lp_hi = BiquadCoefficients::lowpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
let hp_hi = BiquadCoefficients::highpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
let ap_low = BiquadCoefficients::allpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
|
||||||
|
for s in &mut self.splitters {
|
||||||
|
s.set_coefficients(lp_lo, hp_lo, lp_hi, hp_hi, ap_low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for s in &mut self.splitters {
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split one sample of `channel` into `[low, mid, high]`.
|
||||||
|
#[inline]
|
||||||
|
pub fn split(&mut self, channel: usize, x: f32) -> [f32; 3] {
|
||||||
|
self.splitters[channel].split(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bands_sum_to_flat_magnitude() {
|
||||||
|
// LR4 bands sum to an all-pass: the magnitude is flat at every frequency (including the
|
||||||
|
// crossovers), even though the time-domain signal is phase-shifted (so it is NOT a
|
||||||
|
// bit-exact null — that only holds for linear-phase FIR crossovers).
|
||||||
|
let mut xo = Crossover::new();
|
||||||
|
xo.prepare(1);
|
||||||
|
xo.update(SR, 200.0, 2_500.0);
|
||||||
|
|
||||||
|
for &f in &[50.0, 200.0, 1_000.0, 2_500.0, 9_000.0] {
|
||||||
|
xo.reset();
|
||||||
|
let n = 24_000usize;
|
||||||
|
let (mut in_acc, mut out_acc) = (0.0f64, 0.0f64);
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * f * i as f32 / SR).sin();
|
||||||
|
let [lo, mid, hi] = xo.split(0, x);
|
||||||
|
let y = lo + mid + hi;
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
in_acc += (x * x) as f64;
|
||||||
|
out_acc += (y * y) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ratio = (out_acc / in_acc).sqrt() as f32;
|
||||||
|
assert!(
|
||||||
|
(ratio - 1.0).abs() < 0.06,
|
||||||
|
"reconstruction not flat at {f} Hz: {ratio}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bands_are_actually_split() {
|
||||||
|
// Sanity: the low band should keep lows and reject highs; the high band vice versa.
|
||||||
|
fn band_energy(band: usize, freq: f32) -> f64 {
|
||||||
|
let mut xo = Crossover::new();
|
||||||
|
xo.prepare(1);
|
||||||
|
xo.update(SR, 200.0, 2_500.0);
|
||||||
|
let n = 24_000usize;
|
||||||
|
let mut acc = 0.0f64;
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * freq * i as f32 / SR).sin();
|
||||||
|
let bands = xo.split(0, x);
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
acc += (bands[band] * bands[band]) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
assert!(band_energy(0, 50.0) > band_energy(0, 9_000.0) * 100.0); // low band: lows >> highs
|
||||||
|
assert!(band_energy(2, 9_000.0) > band_energy(2, 50.0) * 100.0); // high band: highs >> lows
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,6 @@
|
|||||||
//! per band and for the 'All' aggregate channel — see README.md). Later stages add the
|
//! per band and for the 'All' aggregate channel — see README.md). Later stages add the
|
||||||
//! crossover filterbank, output limiter, and oversampler alongside it.
|
//! crossover filterbank, output limiter, and oversampler alongside it.
|
||||||
|
|
||||||
|
pub mod biquad;
|
||||||
pub mod compressor;
|
pub mod compressor;
|
||||||
|
pub mod crossover;
|
||||||
|
|||||||
+163
-77
@@ -4,8 +4,15 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
mod dsp;
|
mod dsp;
|
||||||
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||||
|
use dsp::crossover::Crossover;
|
||||||
|
|
||||||
/// Level-detection mode for the compressor's detector.
|
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
||||||
|
const LOW: usize = 0;
|
||||||
|
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)]
|
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||||
enum DetectionMode {
|
enum DetectionMode {
|
||||||
#[id = "peak"]
|
#[id = "peak"]
|
||||||
@@ -16,14 +23,17 @@ enum DetectionMode {
|
|||||||
Rms,
|
Rms,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codename 206 — Stage 2: a single full-band compressor with look-ahead.
|
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
||||||
///
|
///
|
||||||
/// The `CompressorParams` struct is `#[nested]` so the exact same controls + DSP can be
|
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
||||||
/// reused for the three bands and the 'All' aggregate channel in later stages.
|
/// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
|
||||||
|
/// driven by the 'All' channel (the crossover sums flat).
|
||||||
struct Codename206 {
|
struct Codename206 {
|
||||||
params: Arc<Codename206Params>,
|
params: Arc<Codename206Params>,
|
||||||
sample_rate: f32,
|
sample_rate: f32,
|
||||||
comp: Compressor,
|
crossover: Crossover,
|
||||||
|
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
|
||||||
|
comps: [Compressor; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Params)]
|
#[derive(Params)]
|
||||||
@@ -31,15 +41,24 @@ struct Codename206Params {
|
|||||||
#[persist = "editor-state"]
|
#[persist = "editor-state"]
|
||||||
editor_state: Arc<EguiState>,
|
editor_state: Arc<EguiState>,
|
||||||
|
|
||||||
/// Look-ahead time: how far ahead the detector reads so gain reduction can lead
|
/// Low/Mid crossover frequency.
|
||||||
/// transients. The reported latency is constant (the max look-ahead) regardless of this
|
#[id = "xover_lo"]
|
||||||
/// value, so it is safe to adjust during playback.
|
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"]
|
#[id = "lookahead"]
|
||||||
pub look_ahead_ms: FloatParam,
|
pub look_ahead_ms: FloatParam,
|
||||||
|
|
||||||
/// The full-band compressor controls (reused per band + 'All' channel later).
|
#[nested(id_prefix = "low", group = "Low")]
|
||||||
#[nested(group = "Compressor")]
|
pub low: CompressorParams,
|
||||||
pub comp: 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)]
|
#[derive(Params)]
|
||||||
@@ -67,7 +86,8 @@ impl Default for Codename206 {
|
|||||||
Self {
|
Self {
|
||||||
params: Arc::new(Codename206Params::default()),
|
params: Arc::new(Codename206Params::default()),
|
||||||
sample_rate: 48_000.0,
|
sample_rate: 48_000.0,
|
||||||
comp: Compressor::new(),
|
crossover: Crossover::new(),
|
||||||
|
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +95,23 @@ 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(360, 360),
|
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_ms: FloatParam::new(
|
||||||
"Look-ahead",
|
"Look-ahead",
|
||||||
@@ -85,7 +121,10 @@ impl Default for Codename206Params {
|
|||||||
.with_unit(" ms")
|
.with_unit(" ms")
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
comp: CompressorParams::default(),
|
low: CompressorParams::default(),
|
||||||
|
mid: CompressorParams::default(),
|
||||||
|
high: CompressorParams::default(),
|
||||||
|
all: CompressorParams::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,11 +152,7 @@ impl Default for CompressorParams {
|
|||||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||||
})),
|
})),
|
||||||
|
|
||||||
knee_db: FloatParam::new(
|
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 24.0 })
|
||||||
"Knee",
|
|
||||||
6.0,
|
|
||||||
FloatRange::Linear { min: 0.0, max: 24.0 },
|
|
||||||
)
|
|
||||||
.with_unit(" dB")
|
.with_unit(" dB")
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
@@ -132,17 +167,12 @@ impl Default for CompressorParams {
|
|||||||
release_ms: FloatParam::new(
|
release_ms: FloatParam::new(
|
||||||
"Release",
|
"Release",
|
||||||
100.0,
|
100.0,
|
||||||
FloatRange::Skewed { min: 1.0, max: 1000.0, factor: FloatRange::skew_factor(-2.0) },
|
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
)
|
)
|
||||||
.with_unit(" ms")
|
.with_unit(" ms")
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
makeup_db: FloatParam::new(
|
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -12.0, max: 24.0 })
|
||||||
"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_smoother(SmoothingStyle::Linear(20.0))
|
||||||
.with_unit(" dB")
|
.with_unit(" dB")
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
@@ -152,8 +182,22 @@ impl Default for CompressorParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl Codename206 {
|
||||||
/// Look-ahead in samples for the current parameter value and sample rate.
|
|
||||||
fn lookahead_samples(&self) -> usize {
|
fn lookahead_samples(&self) -> usize {
|
||||||
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||||
}
|
}
|
||||||
@@ -199,37 +243,41 @@ impl Plugin for Codename206 {
|
|||||||
(),
|
(),
|
||||||
|_, _| {},
|
|_, _| {},
|
||||||
move |egui_ctx, setter, _state| {
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
egui::CentralPanel::default().show(egui_ctx, |ui| {
|
||||||
ui.heading(Self::NAME);
|
ui.heading(Self::NAME);
|
||||||
ui.separator();
|
ui.horizontal(|ui| {
|
||||||
egui::Grid::new("params").num_columns(2).show(ui, |ui| {
|
ui.label("Xover Lo/Mid");
|
||||||
ui.label("Detection");
|
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter));
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.detection, setter));
|
ui.label("Xover Mid/Hi");
|
||||||
ui.end_row();
|
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, 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.label("Look-ahead");
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||||
ui.end_row();
|
});
|
||||||
ui.label("Bypass");
|
ui.separator();
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.comp.bypass, setter));
|
ui.columns(4, |cols| {
|
||||||
ui.end_row();
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -247,16 +295,29 @@ impl Plugin for Codename206 {
|
|||||||
.main_output_channels
|
.main_output_channels
|
||||||
.map(NonZeroU32::get)
|
.map(NonZeroU32::get)
|
||||||
.unwrap_or(2) as usize;
|
.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
|
for comp in &mut self.comps {
|
||||||
// the look-ahead knob during playback never renegotiates latency with the host.
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||||
context.set_latency_samples(self.comp.latency());
|
}
|
||||||
|
self.crossover.prepare(channels);
|
||||||
|
self.crossover.update(
|
||||||
|
self.sample_rate,
|
||||||
|
self.params.crossover_low_hz.value(),
|
||||||
|
self.params.crossover_high_hz.value(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two compressor stages in series (bands → 'All'), each with the same fixed look-ahead
|
||||||
|
// delay. Reported once as a constant; see the look-ahead note in the compressor module.
|
||||||
|
let total_latency = self.comps[LOW].latency() + self.comps[ALL].latency();
|
||||||
|
context.set_latency_samples(total_latency);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.comp.reset();
|
self.crossover.reset();
|
||||||
|
for comp in &mut self.comps {
|
||||||
|
comp.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process(
|
fn process(
|
||||||
@@ -265,33 +326,58 @@ impl Plugin for Codename206 {
|
|||||||
_aux: &mut AuxiliaryBuffers,
|
_aux: &mut AuxiliaryBuffers,
|
||||||
_context: &mut impl ProcessContext<Self>,
|
_context: &mut impl ProcessContext<Self>,
|
||||||
) -> ProcessStatus {
|
) -> ProcessStatus {
|
||||||
// Look-ahead is a detector-tap offset within a fixed delay; it never changes latency.
|
|
||||||
let lookahead = self.lookahead_samples();
|
let lookahead = self.lookahead_samples();
|
||||||
|
|
||||||
// Block-rate compressor settings (these change slowly; makeup is smoothed per sample).
|
// Crossover coefficients track the frequency params (recomputed per block — cheap).
|
||||||
let c = &self.params.comp;
|
self.crossover.update(
|
||||||
let mut set = CompressorSettings {
|
self.sample_rate,
|
||||||
threshold_db: c.threshold_db.value(),
|
self.params.crossover_low_hz.value(),
|
||||||
ratio: c.ratio.value(),
|
self.params.crossover_high_hz.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),
|
// Block-rate settings for the three bands + the 'All' channel.
|
||||||
makeup_db: 0.0,
|
let band_params = [&self.params.low, &self.params.mid, &self.params.high];
|
||||||
lookahead_samples: lookahead,
|
let mut band_set = [
|
||||||
use_rms: c.detection.value() == DetectionMode::Rms,
|
build_settings(&self.params.low, lookahead, self.sample_rate),
|
||||||
bypass: c.bypass.value(),
|
build_settings(&self.params.mid, lookahead, self.sample_rate),
|
||||||
};
|
build_settings(&self.params.high, lookahead, self.sample_rate),
|
||||||
|
];
|
||||||
|
let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate);
|
||||||
|
|
||||||
let mut in_frame = [0.0f32; 2];
|
let mut in_frame = [0.0f32; 2];
|
||||||
|
let mut band_in = [[0.0f32; 2]; 3];
|
||||||
|
let mut band_out = [[0.0f32; 2]; 3];
|
||||||
|
let mut summed = [0.0f32; 2];
|
||||||
let mut out_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();
|
|
||||||
|
|
||||||
|
for mut frame in buffer.iter_samples() {
|
||||||
let n = frame.len().min(2);
|
let n = frame.len().min(2);
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||||
}
|
}
|
||||||
self.comp.process(&in_frame[..n], &mut out_frame[..n], &set);
|
|
||||||
|
// Split each channel into low/mid/high.
|
||||||
|
for ch in 0..n {
|
||||||
|
let [lo, mid, hi] = self.crossover.split(ch, in_frame[ch]);
|
||||||
|
band_in[LOW][ch] = lo;
|
||||||
|
band_in[MID][ch] = mid;
|
||||||
|
band_in[HIGH][ch] = hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress each band (per-sample smoothed makeup), then sum.
|
||||||
|
summed[..n].fill(0.0);
|
||||||
|
for b in 0..3 {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'All' aggregate channel over the summed bands.
|
||||||
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||||
|
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||||
|
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
*frame.get_mut(ch).unwrap() = out_frame[ch];
|
*frame.get_mut(ch).unwrap() = out_frame[ch];
|
||||||
}
|
}
|
||||||
@@ -304,7 +390,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 (stage 2: full-band compressor)");
|
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
|
||||||
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