stage 6: per-app
This commit is contained in:
parent
9edd809416
commit
fcf421b94c
31 changed files with 6360 additions and 344 deletions
211
crates/headroom-dsp/src/agc.rs
Normal file
211
crates/headroom-dsp/src/agc.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Audio-thread piece of the slow AGC.
|
||||
//!
|
||||
//! Sits at the head of the DSP chain (before the compressor). Holds a
|
||||
//! fast anti-zipper smoother that interpolates the per-sample gain
|
||||
//! toward whatever target the control thread has most recently
|
||||
//! pushed. The slow musical smoothing of the target itself happens on
|
||||
//! the control side (`headroom-core::agc`), so this stage only has to
|
||||
//! suppress the step-change zippering at the boundary between control
|
||||
//! ticks.
|
||||
//!
|
||||
//! `process_frame` is allocation-free and bounded-time. `set_target_db`
|
||||
//! is also allocation-free and intended to be called from the
|
||||
//! audio thread once per audio command (drained at the top of every
|
||||
//! playback callback).
|
||||
|
||||
use crate::util::{db_to_lin, time_to_alpha};
|
||||
|
||||
/// Configuration for the audio-thread AGC gain stage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AgcGainConfig {
|
||||
/// Time constant (ms) for the per-sample smoother that interpolates
|
||||
/// `current_db` toward `target_db`. Small enough to chase a 50 ms
|
||||
/// control tick without zippering, large enough not to itself act
|
||||
/// as a gain-envelope. ~5 ms is a sensible default.
|
||||
pub anti_zipper_ms: f32,
|
||||
}
|
||||
|
||||
impl Default for AgcGainConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
anti_zipper_ms: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio-thread AGC gain stage. Two states: when `enabled` is false,
|
||||
/// the stage is a unity pass-through (still smoothed back to 0 dB).
|
||||
pub struct AgcGain {
|
||||
cfg: AgcGainConfig,
|
||||
sample_rate: f32,
|
||||
target_db: f32,
|
||||
current_db: f32,
|
||||
alpha: f32,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl AgcGain {
|
||||
/// Construct an AGC gain stage. `sample_rate` is the input rate
|
||||
/// (same as the rest of the DSP chain).
|
||||
#[must_use]
|
||||
pub fn new(cfg: AgcGainConfig, sample_rate: f32) -> Self {
|
||||
Self {
|
||||
cfg,
|
||||
sample_rate,
|
||||
target_db: 0.0,
|
||||
current_db: 0.0,
|
||||
alpha: time_to_alpha(cfg.anti_zipper_ms, sample_rate),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a fresh `target_db` from the control thread.
|
||||
pub fn set_target_db(&mut self, db: f32) {
|
||||
if db.is_finite() {
|
||||
self.target_db = db;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the stage. When disabled, the smoother
|
||||
/// pushes `target_db` to 0 dB so any active boost/cut unwinds at
|
||||
/// the anti-zipper rate rather than snapping.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
self.target_db = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Live-update non-structural parameters.
|
||||
pub fn set_config(&mut self, cfg: AgcGainConfig) {
|
||||
self.cfg = cfg;
|
||||
self.alpha = time_to_alpha(cfg.anti_zipper_ms, self.sample_rate);
|
||||
}
|
||||
|
||||
/// Active configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> AgcGainConfig {
|
||||
self.cfg
|
||||
}
|
||||
|
||||
/// Current smoother state, in dB. The actual gain applied to
|
||||
/// samples is `10^(current_db / 20)`.
|
||||
#[must_use]
|
||||
pub fn current_db(&self) -> f32 {
|
||||
self.current_db
|
||||
}
|
||||
|
||||
/// Active target_db (latest control-thread command).
|
||||
#[must_use]
|
||||
pub fn target_db(&self) -> f32 {
|
||||
self.target_db
|
||||
}
|
||||
|
||||
/// `true` if the stage is enabled (control commands may move the
|
||||
/// target away from 0 dB).
|
||||
#[must_use]
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Process one stereo frame: smooth the gain in dB, convert to
|
||||
/// linear, multiply both channels.
|
||||
pub fn process_frame(&mut self, l: f32, r: f32) -> (f32, f32) {
|
||||
self.current_db += self.alpha * (self.target_db - self.current_db);
|
||||
let gain = db_to_lin(self.current_db);
|
||||
(l * gain, r * gain)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::lin_to_db;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
|
||||
#[test]
|
||||
fn unity_at_zero_db() {
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
for _ in 0..100 {
|
||||
let (l, r) = agc.process_frame(0.5, -0.3);
|
||||
assert!((l - 0.5).abs() < 1e-6);
|
||||
assert!((r - -0.3).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooths_toward_target() {
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
agc.set_target_db(6.0);
|
||||
// After ~5 ms (one anti-zipper tau), current_db should be in
|
||||
// the ~63% region.
|
||||
let samples = (0.005 * SR) as usize;
|
||||
for _ in 0..samples {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
let cur = agc.current_db();
|
||||
assert!(
|
||||
(cur - 6.0 * 0.63).abs() < 0.5,
|
||||
"expected ~3.8 dB after one tau, got {cur}"
|
||||
);
|
||||
// Settle.
|
||||
for _ in 0..(SR as usize) {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
assert!((agc.current_db() - 6.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_gain_to_signal() {
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
agc.set_target_db(6.0);
|
||||
// Run long enough to settle.
|
||||
for _ in 0..(SR as usize) {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
let (l, r) = agc.process_frame(0.5, 0.5);
|
||||
// +6 dB = factor of ~2.0.
|
||||
assert!((l / 0.5 - 2.0).abs() < 0.05, "got {l}");
|
||||
assert!((r / 0.5 - 2.0).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_unwinds_back_to_unity() {
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
agc.set_target_db(6.0);
|
||||
for _ in 0..(SR as usize) {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
assert!((agc.current_db() - 6.0).abs() < 0.01);
|
||||
|
||||
agc.set_enabled(false);
|
||||
for _ in 0..(SR as usize) {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
assert!(agc.current_db().abs() < 0.01, "got {}", agc.current_db());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_finite_target() {
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
agc.set_target_db(3.0);
|
||||
agc.set_target_db(f32::NAN);
|
||||
assert!((agc.target_db() - 3.0).abs() < 1e-6);
|
||||
agc.set_target_db(f32::INFINITY);
|
||||
assert!((agc.target_db() - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lin_round_trip_check() {
|
||||
// Sanity: after settling, gain at target_db should produce
|
||||
// peak that matches lin_to_db.
|
||||
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
|
||||
agc.set_target_db(-6.0);
|
||||
for _ in 0..(SR as usize) {
|
||||
let _ = agc.process_frame(0.0, 0.0);
|
||||
}
|
||||
let (l, _) = agc.process_frame(1.0, 1.0);
|
||||
assert!((lin_to_db(l) - -6.0).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
427
crates/headroom-dsp/src/level_envelopes.rs
Normal file
427
crates/headroom-dsp/src/level_envelopes.rs
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
//! Block-rate level envelopes for Layer A (per-application level
|
||||
//! control).
|
||||
//!
|
||||
//! Implements the two-tier peak + RMS detector described in
|
||||
//! `PLAN.md` §4. Pure block-rate logic — no PipeWire, no allocation
|
||||
//! after construction. The audio thread computes `peak = max(|x|)`
|
||||
//! and `mean_sq = Σx²/N` per block and pushes them into a ring; the
|
||||
//! daemon thread feeds them to [`LevelEnvelopes::process_block`] and
|
||||
//! reads the recommended reduction back.
|
||||
//!
|
||||
//! Two parallel detectors:
|
||||
//!
|
||||
//! - **Peak envelope** — smoothed in dB with separate attack (fast,
|
||||
//! tens of ms) and release (slow, ~500 ms). Triggers a cut when the
|
||||
//! envelope crosses `peak_threshold_db`. Catches transient bursts.
|
||||
//! - **RMS envelope** — smoothed mean-square with a slow time
|
||||
//! constant (~1–2 s). Triggers a cut when the smoothed RMS in dB
|
||||
//! crosses `rms_target_db`. Catches sustained loudness mismatches.
|
||||
//!
|
||||
//! Output reduction is `max(peak_reduction, rms_reduction)`, clamped
|
||||
//! to `max_cut_db`. Recovery is implicit: each envelope releases at
|
||||
//! its own time constant, so neither path stays engaged once the
|
||||
//! input drops.
|
||||
|
||||
use crate::util::{lin_to_db, time_to_alpha};
|
||||
|
||||
/// Per-rule configuration. Mirrors `[per_app.rules]` in the profile
|
||||
/// schema (PLAN §6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LevelEnvelopesConfig {
|
||||
/// Peak envelope threshold (dBFS). Output rises above this →
|
||||
/// reduce gain. Default −6 dBFS.
|
||||
pub peak_threshold_db: f32,
|
||||
/// RMS envelope target (dBFS, equivalent). Smoothed RMS rising
|
||||
/// above this → reduce gain. Default ≈ −20 dBFS.
|
||||
pub rms_target_db: f32,
|
||||
/// Maximum cut the envelopes may request (dB). The signed cap on
|
||||
/// `max(peak_reduction, rms_reduction)`. Default 12 dB.
|
||||
pub max_cut_db: f32,
|
||||
/// Peak envelope attack time (ms). Time for the envelope to
|
||||
/// approach the input on a rising peak.
|
||||
pub peak_attack_ms: f32,
|
||||
/// Peak envelope release time (ms). Time for the envelope to
|
||||
/// decay back toward silence after the peak drops.
|
||||
pub peak_release_ms: f32,
|
||||
/// RMS smoothing window (ms). One-pole time constant on the
|
||||
/// mean-square input.
|
||||
pub rms_window_ms: f32,
|
||||
}
|
||||
|
||||
impl Default for LevelEnvelopesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: -20.0,
|
||||
max_cut_db: 12.0,
|
||||
peak_attack_ms: 5.0,
|
||||
peak_release_ms: 500.0,
|
||||
rms_window_ms: 1500.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LevelEnvelopesConfig {
|
||||
/// Sanitize: clamp non-finite values, ensure release > 0, threshold
|
||||
/// at or below 0 dB.
|
||||
#[must_use]
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
if self.peak_threshold_db > 0.0 {
|
||||
self.peak_threshold_db = 0.0;
|
||||
}
|
||||
if self.rms_target_db > 0.0 {
|
||||
self.rms_target_db = 0.0;
|
||||
}
|
||||
if !self.max_cut_db.is_finite() || self.max_cut_db < 0.0 {
|
||||
self.max_cut_db = 0.0;
|
||||
}
|
||||
for v in [
|
||||
&mut self.peak_attack_ms,
|
||||
&mut self.peak_release_ms,
|
||||
&mut self.rms_window_ms,
|
||||
] {
|
||||
if !v.is_finite() || *v < 0.0 {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-tier per-stream level detector.
|
||||
pub struct LevelEnvelopes {
|
||||
cfg: LevelEnvelopesConfig,
|
||||
/// Block period (s). Cached so we don't recompute alphas every
|
||||
/// call when the audio thread holds the quantum steady. Recomputed
|
||||
/// on `set_block_dt`.
|
||||
block_dt_s: f32,
|
||||
peak_attack_alpha: f32,
|
||||
peak_release_alpha: f32,
|
||||
rms_alpha: f32,
|
||||
/// Smoothed peak in dB. Starts at floor so first push doesn't
|
||||
/// trip the threshold artificially.
|
||||
peak_env_db: f32,
|
||||
/// Smoothed mean-square (linear). Starts at 0.
|
||||
rms_smoothed_mean_sq: f32,
|
||||
}
|
||||
|
||||
/// Result of processing one block.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LevelDecision {
|
||||
/// dB cut requested by the peak envelope (`0` when not engaged).
|
||||
pub peak_reduction_db: f32,
|
||||
/// dB cut requested by the RMS envelope (`0` when not engaged).
|
||||
pub rms_reduction_db: f32,
|
||||
/// Combined recommendation: `min(max_cut, max(peak, rms))`.
|
||||
/// Always `>= 0`; `0` means "no cut, leave channelVolumes alone."
|
||||
pub total_reduction_db: f32,
|
||||
}
|
||||
|
||||
impl LevelEnvelopes {
|
||||
/// Construct from a config and the audio thread's nominal block
|
||||
/// period. The block period (`samples_per_block / sample_rate`)
|
||||
/// must be small enough that the envelopes track properly; values
|
||||
/// up to ~100 ms work for v0.
|
||||
#[must_use]
|
||||
pub fn new(cfg: LevelEnvelopesConfig, block_dt_s: f32) -> Self {
|
||||
let cfg = cfg.sanitized();
|
||||
let (peak_attack_alpha, peak_release_alpha, rms_alpha) = compute_alphas(&cfg, block_dt_s);
|
||||
Self {
|
||||
cfg,
|
||||
block_dt_s,
|
||||
peak_attack_alpha,
|
||||
peak_release_alpha,
|
||||
rms_alpha,
|
||||
peak_env_db: -200.0,
|
||||
rms_smoothed_mean_sq: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current configuration.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> LevelEnvelopesConfig {
|
||||
self.cfg
|
||||
}
|
||||
|
||||
/// Block period the alphas were computed against.
|
||||
#[must_use]
|
||||
pub fn block_dt_s(&self) -> f32 {
|
||||
self.block_dt_s
|
||||
}
|
||||
|
||||
/// Update parameters in place. Recomputes alphas; resets neither
|
||||
/// envelope state (live tweaks don't cause artefacts).
|
||||
pub fn set_config(&mut self, cfg: LevelEnvelopesConfig) {
|
||||
let cfg = cfg.sanitized();
|
||||
let (a_a, a_r, a_rms) = compute_alphas(&cfg, self.block_dt_s);
|
||||
self.cfg = cfg;
|
||||
self.peak_attack_alpha = a_a;
|
||||
self.peak_release_alpha = a_r;
|
||||
self.rms_alpha = a_rms;
|
||||
}
|
||||
|
||||
/// Update the assumed block period (re-derives alphas). Call when
|
||||
/// the audio thread's quantum changes.
|
||||
pub fn set_block_dt(&mut self, dt_s: f32) {
|
||||
if dt_s <= 0.0 || !dt_s.is_finite() || (dt_s - self.block_dt_s).abs() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
self.block_dt_s = dt_s;
|
||||
let (a_a, a_r, a_rms) = compute_alphas(&self.cfg, dt_s);
|
||||
self.peak_attack_alpha = a_a;
|
||||
self.peak_release_alpha = a_r;
|
||||
self.rms_alpha = a_rms;
|
||||
}
|
||||
|
||||
/// Process one block. `peak_lin` is the per-block max of
|
||||
/// absolute samples (linear); `mean_sq_lin` is the per-block
|
||||
/// `Σx²/N`. Allocation-free.
|
||||
pub fn process_block(&mut self, peak_lin: f32, mean_sq_lin: f32) -> LevelDecision {
|
||||
let peak_lin = peak_lin.max(0.0);
|
||||
let mean_sq_lin = mean_sq_lin.max(0.0);
|
||||
|
||||
// Peak envelope in dB. Attack on rising edge, release on
|
||||
// falling. Use the actual block measurement as the target.
|
||||
let target_db = lin_to_db(peak_lin);
|
||||
if target_db > self.peak_env_db {
|
||||
self.peak_env_db += self.peak_attack_alpha * (target_db - self.peak_env_db);
|
||||
} else {
|
||||
self.peak_env_db += self.peak_release_alpha * (target_db - self.peak_env_db);
|
||||
}
|
||||
|
||||
// RMS envelope: smooth mean_sq directly (one alpha) then
|
||||
// convert to dB. Smoothing in the linear-power domain is the
|
||||
// canonical R128 / IEC-style RMS detector.
|
||||
self.rms_smoothed_mean_sq += self.rms_alpha * (mean_sq_lin - self.rms_smoothed_mean_sq);
|
||||
// 20*log10(sqrt(mean_sq)) = 10*log10(mean_sq).
|
||||
let rms_db = 10.0 * self.rms_smoothed_mean_sq.max(1e-30).log10();
|
||||
|
||||
let peak_reduction_db = (self.peak_env_db - self.cfg.peak_threshold_db).max(0.0);
|
||||
let rms_reduction_db = (rms_db - self.cfg.rms_target_db).max(0.0);
|
||||
let combined = peak_reduction_db.max(rms_reduction_db);
|
||||
let total_reduction_db = combined.min(self.cfg.max_cut_db);
|
||||
|
||||
LevelDecision {
|
||||
peak_reduction_db,
|
||||
rms_reduction_db,
|
||||
total_reduction_db,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset envelope state. Useful when re-attaching to a stream
|
||||
/// after a deference period.
|
||||
pub fn reset(&mut self) {
|
||||
self.peak_env_db = -200.0;
|
||||
self.rms_smoothed_mean_sq = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_alphas(cfg: &LevelEnvelopesConfig, block_dt_s: f32) -> (f32, f32, f32) {
|
||||
// The smoother is `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`, so a
|
||||
// larger alpha means a faster smoother. Cache the per-block alpha
|
||||
// derived from a continuous-time tau.
|
||||
let block_dt_ms = block_dt_s * 1000.0;
|
||||
let block_rate = if block_dt_s > 0.0 { 1.0 / block_dt_s } else { 1.0 };
|
||||
let attack = time_to_alpha(cfg.peak_attack_ms, block_rate);
|
||||
let release = time_to_alpha(cfg.peak_release_ms, block_rate);
|
||||
let rms = time_to_alpha(cfg.rms_window_ms, block_rate);
|
||||
// We use `time_to_alpha` against a *block rate* (Hz), not a
|
||||
// sample rate, because the smoothers operate at block boundaries
|
||||
// — the audio thread emits one (peak, mean_sq) pair per block.
|
||||
// `time_to_alpha` is sample-rate-agnostic: it converts time_ms
|
||||
// and a rate into alpha. Block rate is just "samples per second"
|
||||
// where each "sample" is a block.
|
||||
let _ = block_dt_ms; // currently informational
|
||||
(attack, release, rms)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::db_to_lin;
|
||||
|
||||
/// 1024-frame quantum at 48 kHz.
|
||||
const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;
|
||||
|
||||
fn run_steady(env: &mut LevelEnvelopes, peak_lin: f32, mean_sq_lin: f32, blocks: usize) -> LevelDecision {
|
||||
let mut last = env.process_block(peak_lin, mean_sq_lin);
|
||||
for _ in 1..blocks {
|
||||
last = env.process_block(peak_lin, mean_sq_lin);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_signal_produces_no_reduction() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
let quiet = db_to_lin(-30.0);
|
||||
let mean_sq = quiet * quiet;
|
||||
let dec = run_steady(&mut env, quiet, mean_sq, 200);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
assert_eq!(dec.total_reduction_db, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_above_threshold_requests_cut() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
// Long RMS window so the slow path doesn't dominate.
|
||||
rms_target_db: 0.0,
|
||||
rms_window_ms: 5_000.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// 0 dBFS peak: 6 dB over threshold.
|
||||
let peak = db_to_lin(0.0);
|
||||
let mean_sq = (peak * peak) * 0.05; // low rms (intermittent peak)
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!(
|
||||
(dec.peak_reduction_db - 6.0).abs() < 0.5,
|
||||
"expected ~6 dB peak cut, got {}",
|
||||
dec.peak_reduction_db
|
||||
);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
assert!((dec.total_reduction_db - 6.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_above_target_requests_cut() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
// Push peak threshold up so only RMS engages.
|
||||
peak_threshold_db: 0.0,
|
||||
rms_target_db: -20.0,
|
||||
rms_window_ms: 200.0, // shorter so test converges quickly
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// Sustained -10 dBFS RMS: 10 dB above target.
|
||||
let rms_lin = db_to_lin(-10.0);
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
// Peak set just below threshold so peak detector stays asleep.
|
||||
let peak = db_to_lin(-1.0);
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert!(
|
||||
(dec.rms_reduction_db - 10.0).abs() < 0.5,
|
||||
"expected ~10 dB RMS cut, got {}",
|
||||
dec.rms_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_takes_max_of_peak_and_rms() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: -20.0,
|
||||
rms_window_ms: 200.0,
|
||||
max_cut_db: 100.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
let peak = db_to_lin(0.0); // 6 dB over
|
||||
let rms_lin = db_to_lin(-10.0); // 10 dB over
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!((dec.peak_reduction_db - 6.0).abs() < 0.5);
|
||||
assert!((dec.rms_reduction_db - 10.0).abs() < 0.5);
|
||||
assert!(
|
||||
(dec.total_reduction_db - 10.0).abs() < 0.5,
|
||||
"max(6, 10) ≈ 10, got {}",
|
||||
dec.total_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_reduction_is_clamped_to_max_cut_db() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -30.0,
|
||||
rms_target_db: -30.0,
|
||||
rms_window_ms: 50.0,
|
||||
max_cut_db: 3.0, // tight cap
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
let peak = db_to_lin(0.0); // 30 dB over
|
||||
let rms_lin = db_to_lin(-5.0);
|
||||
let mean_sq = rms_lin * rms_lin;
|
||||
let dec = run_steady(&mut env, peak, mean_sq, 200);
|
||||
assert!(dec.peak_reduction_db > 20.0);
|
||||
assert!(
|
||||
(dec.total_reduction_db - 3.0).abs() < 1e-3,
|
||||
"total clamped to max_cut_db, got {}",
|
||||
dec.total_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_envelope_releases_after_burst() {
|
||||
let cfg = LevelEnvelopesConfig {
|
||||
peak_threshold_db: -6.0,
|
||||
rms_target_db: 0.0,
|
||||
rms_window_ms: 5_000.0,
|
||||
peak_attack_ms: 5.0,
|
||||
peak_release_ms: 100.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
|
||||
// Burst.
|
||||
for _ in 0..20 {
|
||||
env.process_block(db_to_lin(0.0), 0.0);
|
||||
}
|
||||
let burst = env.process_block(db_to_lin(0.0), 0.0);
|
||||
assert!(burst.peak_reduction_db > 5.0);
|
||||
|
||||
// Silence.
|
||||
for _ in 0..200 {
|
||||
env.process_block(0.0, 0.0);
|
||||
}
|
||||
let quiet = env.process_block(0.0, 0.0);
|
||||
assert!(
|
||||
quiet.peak_reduction_db < 0.5,
|
||||
"expected ~0 after release, got {}",
|
||||
quiet.peak_reduction_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_config_updates_alphas_without_reset() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
for _ in 0..100 {
|
||||
env.process_block(db_to_lin(-3.0), 0.0);
|
||||
}
|
||||
let before = env.process_block(db_to_lin(-3.0), 0.0);
|
||||
// Tighter threshold; envelope state preserved across the swap.
|
||||
env.set_config(LevelEnvelopesConfig {
|
||||
peak_threshold_db: -12.0,
|
||||
..LevelEnvelopesConfig::default()
|
||||
});
|
||||
let after = env.process_block(db_to_lin(-3.0), 0.0);
|
||||
assert!(
|
||||
after.peak_reduction_db > before.peak_reduction_db,
|
||||
"tighter threshold should request more cut"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_block_dt_recomputes_alphas() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
let original_attack = env.peak_attack_alpha;
|
||||
// Double the block period — slower block rate → smaller alpha
|
||||
// for the same time constant.
|
||||
env.set_block_dt(BLOCK_DT_S * 2.0);
|
||||
assert!(env.peak_attack_alpha > original_attack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_idle_state() {
|
||||
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
|
||||
for _ in 0..200 {
|
||||
env.process_block(db_to_lin(0.0), db_to_lin(-3.0));
|
||||
}
|
||||
env.reset();
|
||||
let dec = env.process_block(0.0, 0.0);
|
||||
assert_eq!(dec.peak_reduction_db, 0.0);
|
||||
assert_eq!(dec.rms_reduction_db, 0.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,17 +9,21 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod agc;
|
||||
mod compressor;
|
||||
mod delay;
|
||||
mod envelope;
|
||||
mod level_envelopes;
|
||||
mod limiter;
|
||||
mod oversample;
|
||||
mod sliding_max;
|
||||
pub mod util;
|
||||
|
||||
pub use agc::{AgcGain, AgcGainConfig};
|
||||
pub use compressor::{Compressor, CompressorConfig, Detector};
|
||||
pub use delay::DelayLine;
|
||||
pub use envelope::AttackRelease;
|
||||
pub use limiter::{Limiter, LimiterConfig, SoftTierConfig};
|
||||
pub use level_envelopes::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig};
|
||||
pub use limiter::{Limiter, LimiterConfig, SetConfigOutcome, SoftTierConfig};
|
||||
pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
|
||||
pub use sliding_max::SlidingMaxBuffer;
|
||||
|
|
|
|||
|
|
@ -174,9 +174,28 @@ impl LimiterConfig {
|
|||
|
||||
const MAX_OVERSAMPLE: usize = 8;
|
||||
|
||||
/// Result of attempting to live-apply a [`LimiterConfig`].
|
||||
///
|
||||
/// Returned by [`Limiter::try_set_config`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SetConfigOutcome {
|
||||
/// Config applied in place; the limiter is now running with the
|
||||
/// new parameters.
|
||||
Applied,
|
||||
/// The new config differs in `oversample`, `fir_taps`, or
|
||||
/// `lookahead_ms` (rounded to samples), and would require
|
||||
/// reallocating internal buffers. The limiter is unchanged;
|
||||
/// rebuild it from `Limiter::new` on the control thread.
|
||||
StructuralChange,
|
||||
}
|
||||
|
||||
/// Two-tier feed-forward true-peak limiter.
|
||||
pub struct Limiter {
|
||||
cfg: LimiterConfig,
|
||||
/// Input sample rate, captured at construction. Kept so live
|
||||
/// reconfiguration ([`Limiter::try_set_config`]) can recompute
|
||||
/// time-based coefficients without callers having to repass it.
|
||||
sample_rate: f32,
|
||||
ceiling_lin: f32,
|
||||
os: usize,
|
||||
|
||||
|
|
@ -259,6 +278,7 @@ impl Limiter {
|
|||
|
||||
let mut me = Self {
|
||||
cfg,
|
||||
sample_rate,
|
||||
ceiling_lin,
|
||||
os,
|
||||
up_l: PolyphaseUpsampler::new(os, &lowpass),
|
||||
|
|
@ -347,6 +367,68 @@ impl Limiter {
|
|||
self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
|
||||
}
|
||||
|
||||
/// Live-update non-structural parameters.
|
||||
///
|
||||
/// Applies changes that don't require reallocating internal
|
||||
/// buffers: ceiling, hard-tier release/hold, soft-tier toggle and
|
||||
/// scalars. Allocation-free; safe to call on the realtime audio
|
||||
/// thread.
|
||||
///
|
||||
/// Structural changes — `oversample`, `lookahead_ms` (when the
|
||||
/// rounded sample count differs from the current one), or
|
||||
/// `fir_taps` — cannot be applied in place because they would
|
||||
/// resize FIR coefficient tables, polyphase state, the delay line,
|
||||
/// or the sliding peak buffer. The method returns
|
||||
/// [`SetConfigOutcome::StructuralChange`] in that case and the
|
||||
/// limiter is left unchanged; the caller is expected to rebuild
|
||||
/// the [`Limiter`] from `Limiter::new` on the control thread.
|
||||
pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome {
|
||||
let cfg = cfg.sanitized();
|
||||
let os_rate = self.sample_rate * cfg.oversample as f32;
|
||||
let new_lookahead_samples_os =
|
||||
((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1);
|
||||
let cur_lookahead_samples_os = self.peak_buf.window();
|
||||
if cfg.oversample != self.os
|
||||
|| cfg.fir_taps != self.cfg.fir_taps
|
||||
|| new_lookahead_samples_os != cur_lookahead_samples_os
|
||||
{
|
||||
return SetConfigOutcome::StructuralChange;
|
||||
}
|
||||
|
||||
self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
|
||||
self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
|
||||
self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
|
||||
|
||||
match (cfg.soft, self.cfg.soft) {
|
||||
(Some(new_soft), Some(_old_soft)) => {
|
||||
self.soft_max_psr_db = new_soft.max_psr_db;
|
||||
self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
|
||||
if let Some(env) = &mut self.soft_envelope {
|
||||
env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate);
|
||||
}
|
||||
}
|
||||
(Some(new_soft), None) => {
|
||||
// Re-enable the soft tier. Seed the envelope to unity
|
||||
// so we don't start with phantom gain reduction.
|
||||
let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate);
|
||||
env.reset(1.0);
|
||||
self.soft_envelope = Some(env);
|
||||
self.soft_max_psr_db = new_soft.max_psr_db;
|
||||
self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
self.soft_envelope = None;
|
||||
self.soft_max_psr_db = 0.0;
|
||||
self.soft_static_ceiling_lin = 1.0;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
self.cfg = cfg;
|
||||
self.recompute_soft_ceiling();
|
||||
SetConfigOutcome::Applied
|
||||
}
|
||||
|
||||
/// Update the program loudness used to compute the dynamic soft
|
||||
/// ceiling. Typically called by the AGC at its tick rate with the
|
||||
/// short-term BS.1770 loudness; non-finite values are ignored.
|
||||
|
|
@ -533,6 +615,80 @@ mod tests {
|
|||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// try_set_config: scalar updates apply in place, structural
|
||||
// changes are rejected.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn try_set_config_applies_scalar_changes() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.ceiling_dbtp = -3.0;
|
||||
cfg.release_ms = 200.0;
|
||||
cfg.hold_ms = 10.0;
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
|
||||
assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
|
||||
let active = l.config();
|
||||
assert!((active.release_ms - 200.0).abs() < 1e-6);
|
||||
assert!((active.hold_ms - 10.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_set_config_can_toggle_soft_tier() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
// Start with soft on. Disable it.
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.soft = None;
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
|
||||
assert!(l.config().soft.is_none());
|
||||
assert!(l.effective_soft_ceiling_dbtp().is_none());
|
||||
|
||||
// Re-enable with custom params.
|
||||
let new_soft = SoftTierConfig {
|
||||
max_psr_db: 10.0,
|
||||
static_ceiling_dbtp: -4.0,
|
||||
attack_ms: 8.0,
|
||||
release_ms: 300.0,
|
||||
};
|
||||
cfg.soft = Some(new_soft);
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
|
||||
let active_soft = l.config().soft.expect("soft re-enabled");
|
||||
assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6);
|
||||
assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_set_config_rejects_oversample_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.oversample = 8;
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
// Limiter unchanged.
|
||||
assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_set_config_rejects_lookahead_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.lookahead_ms = 5.0; // resizes delay + peak buffer
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_set_config_rejects_fir_taps_change() {
|
||||
let sr = 48_000.0;
|
||||
let mut l = Limiter::new(LimiterConfig::default(), sr);
|
||||
let mut cfg = LimiterConfig::default();
|
||||
cfg.fir_taps = 63;
|
||||
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
|
||||
}
|
||||
|
||||
fn run_sine(
|
||||
limiter: &mut Limiter,
|
||||
freq: f32,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue