Commit 614b4e43f9f3
tarball12 files changed+2654-0
@@ -0,0 +1,23 @@
1
+[package]2
+name = "headroom-dsp"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license = "MPL-2.0"7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+11
+[dependencies]12
+# intentionally empty: must build clean on any host, most reusable crate in the workspace.13
+# think twice before adding a dependency here.14
+15
+[dev-dependencies]16
+criterion = { workspace = true }17
+18
+[features]19
+default = []20
+21
+[[bench]]22
+name = "layer_a"23
+harness = false@@ -0,0 +1,134 @@
1
+//! layer a (per-app level control) microbenchmarks vs the plan §4.7 budget.2
+3
+use criterion::{4
+ black_box, criterion_group, criterion_main, BatchSize, Criterion, Throughput,5
+};6
+use headroom_dsp::{7
+ Compressor, CompressorConfig, LevelEnvelopes, LevelEnvelopesConfig, Limiter, LimiterConfig,8
+};9
+10
+/// 1024-frame quantum at 48 kHz stereo: 2048 interleaved samples, 21.3 ms/block.11
+const FRAMES: usize = 1024;12
+const CHANNELS: usize = 2;13
+const SR: f32 = 48_000.0;14
+const BLOCK_DT_S: f32 = FRAMES as f32 / SR;15
+16
+/// noisy-but-bounded synthetic block; realistic value range so branch predictors / fpu17
+/// exercise the same paths as real audio.18
+fn make_block() -> Vec<f32> {19
+ let mut buf = Vec::with_capacity(FRAMES * CHANNELS);20
+ // two sine partials + tiny dc: peak not pegged to one sample, mean-square not trivially zero.21
+ let f1 = 220.0 / SR;22
+ let f2 = 1730.0 / SR;23
+ for n in 0..FRAMES {24
+ let t = n as f32;25
+ let s = 0.4 * (2.0 * std::f32::consts::PI * f1 * t).sin()26
+ + 0.18 * (2.0 * std::f32::consts::PI * f2 * t).sin()27
+ + 0.005;28
+ buf.push(s);29
+ buf.push(s * 0.92); // slight L/R difference30
+ }31
+ buf32
+}33
+34
+/// what the audio-thread layer a callback computes per block.35
+/// hand-rolled loop so the bench measures the candidate code, not iterator combinators.36
+#[inline]37
+fn analysis_scan(samples: &[f32]) -> (f32, f32) {38
+ let mut peak = 0.0_f32;39
+ let mut sumsq = 0.0_f32;40
+ for &s in samples {41
+ let a = s.abs();42
+ if a > peak {43
+ peak = a;44
+ }45
+ sumsq += s * s;46
+ }47
+ let mean_sq = sumsq / samples.len() as f32;48
+ (peak, mean_sq)49
+}50
+51
+fn bench_analysis_scan(c: &mut Criterion) {52
+ let block = make_block();53
+ let mut group = c.benchmark_group("layer_a_audio_thread");54
+ group.throughput(Throughput::Elements((FRAMES * CHANNELS) as u64));55
+ group.bench_function("analysis_scan_stereo_1024", |b| {56
+ b.iter(|| {57
+ let (p, m) = analysis_scan(black_box(&block));58
+ black_box((p, m));59
+ });60
+ });61
+ group.finish();62
+}63
+64
+fn bench_level_envelopes(c: &mut Criterion) {65
+ let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);66
+ let block = make_block();67
+ let (peak, mean_sq) = analysis_scan(&block);68
+69
+ let mut group = c.benchmark_group("layer_a_daemon_thread");70
+ group.bench_function("level_envelopes_process_block", |b| {71
+ b.iter(|| {72
+ let d = env.process_block(black_box(peak), black_box(mean_sq));73
+ black_box(d);74
+ });75
+ });76
+ group.finish();77
+}78
+79
+fn bench_filter_kernels(c: &mut Criterion) {80
+ // context only: layer a relative to the rt filter's existing per-frame cost.81
+ let mut comp = Compressor::new(CompressorConfig::default(), SR);82
+ let mut lim = Limiter::new(LimiterConfig::default(), SR);83
+ let mut silence_lim = Limiter::new(LimiterConfig::default(), SR);84
+ let mut block_lim = Limiter::new(LimiterConfig::default(), SR);85
+ let mut silence_block_lim = Limiter::new(LimiterConfig::default(), SR);86
+ let block = make_block();87
+ let mut silence = vec![0.0_f32; FRAMES * CHANNELS];88
+89
+ let mut group = c.benchmark_group("filter_reference_per_frame");90
+ group.throughput(Throughput::Elements(1));91
+ group.bench_function("compressor_process_frame", |b| {92
+ b.iter(|| {93
+ let (l, r) = comp.process_frame(black_box(0.3), black_box(-0.2));94
+ black_box((l, r));95
+ });96
+ });97
+ group.bench_function("limiter_process_frame", |b| {98
+ b.iter(|| {99
+ let (l, r) = lim.process_frame(black_box(0.3), black_box(-0.2));100
+ black_box((l, r));101
+ });102
+ });103
+ group.bench_function("limiter_process_frame_silence", |b| {104
+ b.iter(|| {105
+ let (l, r) = silence_lim.process_frame(black_box(0.0), black_box(0.0));106
+ black_box((l, r));107
+ });108
+ });109
+ group.bench_function("limiter_process_block_1024", |b| {110
+ b.iter_batched(111
+ || block.clone(),112
+ |mut buf| {113
+ block_lim.process_interleaved_stereo(black_box(&mut buf));114
+ black_box(buf);115
+ },116
+ BatchSize::SmallInput,117
+ );118
+ });119
+ group.bench_function("limiter_process_block_silence_1024", |b| {120
+ b.iter(|| {121
+ silence_block_lim.process_interleaved_stereo(black_box(&mut silence));122
+ black_box(&silence);123
+ });124
+ });125
+ group.finish();126
+}127
+128
+criterion_group!(129
+ benches,130
+ bench_analysis_scan,131
+ bench_level_envelopes,132
+ bench_filter_kernels133
+);134
+criterion_main!(benches);@@ -0,0 +1,183 @@
1
+//! anti-zipper smoother toward the control thread's agc target.2
+3
+use crate::util::{db_to_lin, time_to_alpha};4
+5
+#[derive(Debug, Clone, Copy, PartialEq)]6
+pub struct AgcGainConfig {7
+ /// smoother tau (ms). small enough to chase a 50 ms control tick without zippering,8
+ /// large enough not to itself act as a gain envelope.9
+ pub anti_zipper_ms: f32,10
+}11
+12
+impl Default for AgcGainConfig {13
+ fn default() -> Self {14
+ Self {15
+ anti_zipper_ms: 5.0,16
+ }17
+ }18
+}19
+20
+pub struct AgcGain {21
+ cfg: AgcGainConfig,22
+ sample_rate: f32,23
+ target_db: f32,24
+ current_db: f32,25
+ alpha: f32,26
+ enabled: bool,27
+}28
+29
+impl AgcGain {30
+ #[must_use]31
+ pub fn new(cfg: AgcGainConfig, sample_rate: f32) -> Self {32
+ Self {33
+ cfg,34
+ sample_rate,35
+ target_db: 0.0,36
+ current_db: 0.0,37
+ alpha: time_to_alpha(cfg.anti_zipper_ms, sample_rate),38
+ enabled: true,39
+ }40
+ }41
+42
+ pub fn set_target_db(&mut self, db: f32) {43
+ if db.is_finite() {44
+ self.target_db = db;45
+ }46
+ }47
+48
+ /// disabling pushes target to 0 dB so an active boost/cut unwinds at the smoother rate49
+ /// rather than snapping.50
+ pub fn set_enabled(&mut self, enabled: bool) {51
+ self.enabled = enabled;52
+ if !enabled {53
+ self.target_db = 0.0;54
+ }55
+ }56
+57
+ pub fn set_config(&mut self, cfg: AgcGainConfig) {58
+ self.cfg = cfg;59
+ self.alpha = time_to_alpha(cfg.anti_zipper_ms, self.sample_rate);60
+ }61
+62
+ #[must_use]63
+ pub fn config(&self) -> AgcGainConfig {64
+ self.cfg65
+ }66
+67
+ #[must_use]68
+ pub fn current_db(&self) -> f32 {69
+ self.current_db70
+ }71
+72
+ #[must_use]73
+ pub fn target_db(&self) -> f32 {74
+ self.target_db75
+ }76
+77
+ #[must_use]78
+ pub fn enabled(&self) -> bool {79
+ self.enabled80
+ }81
+82
+ pub fn process_frame(&mut self, l: f32, r: f32) -> (f32, f32) {83
+ self.current_db += self.alpha * (self.target_db - self.current_db);84
+ if l == 0.0 && r == 0.0 {85
+ return (0.0, 0.0);86
+ }87
+ let gain = db_to_lin(self.current_db);88
+ (l * gain, r * gain)89
+ }90
+}91
+92
+#[cfg(test)]93
+mod tests {94
+ use super::*;95
+ use crate::util::lin_to_db;96
+97
+ const SR: f32 = 48_000.0;98
+99
+ #[test]100
+ fn unity_at_zero_db() {101
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);102
+ for _ in 0..100 {103
+ let (l, r) = agc.process_frame(0.5, -0.3);104
+ assert!((l - 0.5).abs() < 1e-6);105
+ assert!((r - -0.3).abs() < 1e-6);106
+ }107
+ }108
+109
+ #[test]110
+ fn smooths_toward_target() {111
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);112
+ agc.set_target_db(6.0);113
+ // After ~5 ms (one anti-zipper tau), current_db should be in114
+ // the ~63% region.115
+ let samples = (0.005 * SR) as usize;116
+ for _ in 0..samples {117
+ let _ = agc.process_frame(0.0, 0.0);118
+ }119
+ let cur = agc.current_db();120
+ assert!(121
+ (cur - 6.0 * 0.63).abs() < 0.5,122
+ "expected ~3.8 dB after one tau, got {cur}"123
+ );124
+ // Settle.125
+ for _ in 0..(SR as usize) {126
+ let _ = agc.process_frame(0.0, 0.0);127
+ }128
+ assert!((agc.current_db() - 6.0).abs() < 0.01);129
+ }130
+131
+ #[test]132
+ fn applies_gain_to_signal() {133
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);134
+ agc.set_target_db(6.0);135
+ // Run long enough to settle.136
+ for _ in 0..(SR as usize) {137
+ let _ = agc.process_frame(0.0, 0.0);138
+ }139
+ let (l, r) = agc.process_frame(0.5, 0.5);140
+ // +6 dB = factor of ~2.0.141
+ assert!((l / 0.5 - 2.0).abs() < 0.05, "got {l}");142
+ assert!((r / 0.5 - 2.0).abs() < 0.05);143
+ }144
+145
+ #[test]146
+ fn disable_unwinds_back_to_unity() {147
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);148
+ agc.set_target_db(6.0);149
+ for _ in 0..(SR as usize) {150
+ let _ = agc.process_frame(0.0, 0.0);151
+ }152
+ assert!((agc.current_db() - 6.0).abs() < 0.01);153
+154
+ agc.set_enabled(false);155
+ for _ in 0..(SR as usize) {156
+ let _ = agc.process_frame(0.0, 0.0);157
+ }158
+ assert!(agc.current_db().abs() < 0.01, "got {}", agc.current_db());159
+ }160
+161
+ #[test]162
+ fn rejects_non_finite_target() {163
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);164
+ agc.set_target_db(3.0);165
+ agc.set_target_db(f32::NAN);166
+ assert!((agc.target_db() - 3.0).abs() < 1e-6);167
+ agc.set_target_db(f32::INFINITY);168
+ assert!((agc.target_db() - 3.0).abs() < 1e-6);169
+ }170
+171
+ #[test]172
+ fn lin_round_trip_check() {173
+ // Sanity: after settling, gain at target_db should produce174
+ // peak that matches lin_to_db.175
+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);176
+ agc.set_target_db(-6.0);177
+ for _ in 0..(SR as usize) {178
+ let _ = agc.process_frame(0.0, 0.0);179
+ }180
+ let (l, _) = agc.process_frame(1.0, 1.0);181
+ assert!((lin_to_db(l) - -6.0).abs() < 0.05);182
+ }183
+}@@ -0,0 +1,364 @@
1
+//! feed-forward log-domain compressor with soft-knee static curve.2
+3
+use crate::util::{db_to_lin, lin_to_db, time_to_alpha};4
+5
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]6
+pub enum Detector {7
+ /// max(|l|,|r|). slightly more percussive on transients.8
+ Peak,9
+ /// one-pole low-passed mean square. smoother on percussive material.10
+ Rms,11
+}12
+13
+#[derive(Debug, Clone, Copy, PartialEq)]14
+pub struct CompressorConfig {15
+ /// envelope state is NOT reset while disabled; with typical release constants any residual16
+ /// transient on re-enable is sub-audible.17
+ pub enabled: bool,18
+ pub threshold_db: f32,19
+ /// >= 1.020
+ pub ratio: f32,21
+ /// 0 = hard knee.22
+ pub knee_db: f32,23
+ pub attack_ms: f32,24
+ pub release_ms: f32,25
+ /// `None` = automatic mild boost.26
+ pub makeup_db: Option<f32>,27
+ pub detector: Detector,28
+ /// only used when detector == Rms.29
+ pub rms_window_ms: f32,30
+}31
+32
+impl Default for CompressorConfig {33
+ fn default() -> Self {34
+ Self {35
+ enabled: true,36
+ threshold_db: -24.0,37
+ ratio: 2.5,38
+ knee_db: 6.0,39
+ attack_ms: 10.0,40
+ release_ms: 100.0,41
+ makeup_db: None,42
+ detector: Detector::Peak,43
+ rms_window_ms: 5.0,44
+ }45
+ }46
+}47
+48
+impl CompressorConfig {49
+ #[must_use]50
+ pub fn sanitized(mut self) -> Self {51
+ if self.ratio < 1.0 {52
+ self.ratio = 1.0;53
+ }54
+ self.knee_db = self.knee_db.max(0.0);55
+ self.attack_ms = self.attack_ms.max(0.0);56
+ self.release_ms = self.release_ms.max(0.0);57
+ self.rms_window_ms = self.rms_window_ms.max(0.1);58
+ self59
+ }60
+}61
+62
+/// stereo-linked.63
+pub struct Compressor {64
+ cfg: CompressorConfig,65
+ sample_rate: f32,66
+ envelope_db: f32,67
+ attack_alpha: f32,68
+ release_alpha: f32,69
+ rms_state: f32,70
+ rms_alpha: f32,71
+ last_gr_db: f32,72
+}73
+74
+impl Compressor {75
+ #[must_use]76
+ pub fn new(cfg: CompressorConfig, sample_rate: f32) -> Self {77
+ let cfg = cfg.sanitized();78
+ Self {79
+ cfg,80
+ sample_rate,81
+ envelope_db: -200.0,82
+ attack_alpha: time_to_alpha(cfg.attack_ms, sample_rate),83
+ release_alpha: time_to_alpha(cfg.release_ms, sample_rate),84
+ rms_state: 0.0,85
+ rms_alpha: time_to_alpha(cfg.rms_window_ms, sample_rate),86
+ last_gr_db: 0.0,87
+ }88
+ }89
+90
+ #[must_use]91
+ pub fn config(&self) -> CompressorConfig {92
+ self.cfg93
+ }94
+95
+ /// negative when compressing.96
+ #[must_use]97
+ pub fn gain_reduction_db(&self) -> f32 {98
+ self.last_gr_db99
+ }100
+101
+ /// envelope kept across same-enabled tweaks (don't pop); reset on disabled → enabled so a102
+ /// stale envelope doesn't duck the first ~release_ms of audio on resume.103
+ pub fn set_config(&mut self, cfg: CompressorConfig) {104
+ let cfg = cfg.sanitized();105
+ let was_disabled = !self.cfg.enabled;106
+ self.cfg = cfg;107
+ if was_disabled && self.cfg.enabled {108
+ self.envelope_db = -200.0;109
+ self.rms_state = 0.0;110
+ self.last_gr_db = 0.0;111
+ }112
+ self.attack_alpha = time_to_alpha(cfg.attack_ms, self.sample_rate);113
+ self.release_alpha = time_to_alpha(cfg.release_ms, self.sample_rate);114
+ self.rms_alpha = time_to_alpha(cfg.rms_window_ms, self.sample_rate);115
+ }116
+117
+ pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {118
+ if !self.cfg.enabled {119
+ // report 0 so meters show "off", not the stale last value.120
+ self.last_gr_db = 0.0;121
+ return (left, right);122
+ }123
+ if left == 0.0 && right == 0.0 && self.envelope_db <= -199.0 && self.rms_state == 0.0 {124
+ self.envelope_db = -200.0;125
+ self.last_gr_db = 0.0;126
+ return (0.0, 0.0);127
+ }128
+ let det_lin = match self.cfg.detector {129
+ Detector::Peak => left.abs().max(right.abs()),130
+ Detector::Rms => {131
+ let sq = 0.5 * left.mul_add(left, right * right);132
+ self.rms_state += self.rms_alpha * (sq - self.rms_state);133
+ self.rms_state.max(0.0).sqrt()134
+ }135
+ };136
+ let det_db = lin_to_db(det_lin.max(1e-20));137
+138
+ if det_db > self.envelope_db {139
+ self.envelope_db += self.attack_alpha * (det_db - self.envelope_db);140
+ } else {141
+ self.envelope_db += self.release_alpha * (det_db - self.envelope_db);142
+ }143
+144
+ let gr_db = static_curve_gain_reduction(145
+ self.envelope_db,146
+ self.cfg.threshold_db,147
+ self.cfg.ratio,148
+ self.cfg.knee_db,149
+ );150
+ let makeup_db = self151
+ .cfg152
+ .makeup_db153
+ .unwrap_or_else(|| auto_makeup(self.cfg.threshold_db, self.cfg.ratio));154
+155
+ let lin_gain = db_to_lin(-gr_db + makeup_db);156
+ self.last_gr_db = -gr_db;157
+158
+ (left * lin_gain, right * lin_gain)159
+ }160
+161
+ pub fn reset(&mut self) {162
+ self.envelope_db = -200.0;163
+ self.rms_state = 0.0;164
+ self.last_gr_db = 0.0;165
+ }166
+}167
+168
+/// returns positive gain reduction (dB) to subtract from the input level.169
+fn static_curve_gain_reduction(170
+ input_db: f32,171
+ threshold_db: f32,172
+ ratio: f32,173
+ knee_db: f32,174
+) -> f32 {175
+ let over = input_db - threshold_db;176
+ if knee_db > 0.0 && over > -knee_db * 0.5 && over < knee_db * 0.5 {177
+ // quadratic soft knee178
+ let x = over + knee_db * 0.5;179
+ let factor = (x * x) / (2.0 * knee_db);180
+ factor * (1.0 - 1.0 / ratio)181
+ } else if over > 0.0 {182
+ over * (1.0 - 1.0 / ratio)183
+ } else {184
+ 0.0185
+ }186
+}187
+188
+/// half the static gr at 0 dBFS. conservative — the limiter is downstream, don't push it.189
+fn auto_makeup(threshold_db: f32, ratio: f32) -> f32 {190
+ let gr_at_zero = (-threshold_db).max(0.0) * (1.0 - 1.0 / ratio);191
+ gr_at_zero * 0.5192
+}193
+194
+#[cfg(test)]195
+mod tests {196
+ use super::*;197
+198
+ #[test]199
+ fn below_threshold_is_unity_minus_makeup() {200
+ let mut c = Compressor::new(CompressorConfig::default(), 48_000.0);201
+ // Drive a long, low signal and check we land at expected gain.202
+ let mut last = (0.0_f32, 0.0_f32);203
+ for _ in 0..10_000 {204
+ last = c.process_frame(0.01, 0.01);205
+ }206
+ // Below threshold: gain reduction is zero, only makeup applied.207
+ let makeup_db = auto_makeup(-24.0, 2.5);208
+ let expected = 0.01 * db_to_lin(makeup_db);209
+ assert!(210
+ (last.0 - expected).abs() < 1e-3,211
+ "got {} expected {}",212
+ last.0,213
+ expected214
+ );215
+ assert!(c.gain_reduction_db().abs() < 0.1);216
+ }217
+218
+ #[test]219
+ fn above_threshold_reduces_gain() {220
+ let cfg = CompressorConfig {221
+ threshold_db: -20.0,222
+ ratio: 4.0,223
+ knee_db: 0.0, // hard knee for clean math224
+ attack_ms: 0.1,225
+ release_ms: 0.1,226
+ makeup_db: Some(0.0), // no makeup so we test pure reduction227
+ ..CompressorConfig::default()228
+ };229
+ let mut c = Compressor::new(cfg, 48_000.0);230
+ // Drive ~-6 dBFS = 0.5 in linear.231
+ let target = 0.5_f32;232
+ let mut last_out = 0.0;233
+ for _ in 0..2_000 {234
+ let (l, _) = c.process_frame(target, target);235
+ last_out = l;236
+ }237
+ // Input is 14 dB above threshold. With ratio 4, GR = 14*(1-0.25) = 10.5 dB.238
+ // Expected output: -6 - 10.5 = -16.5 dB linear = 0.1496.239
+ let expected_db = -6.0 - 14.0 * (1.0 - 0.25);240
+ let expected_lin = db_to_lin(expected_db);241
+ let got_db = lin_to_db(last_out);242
+ assert!(243
+ (got_db - expected_db).abs() < 0.5,244
+ "got {got_db} expected {expected_db}"245
+ );246
+ assert!(c.gain_reduction_db() < -5.0, "gr was {}", c.gain_reduction_db());247
+ let _ = expected_lin;248
+ }249
+250
+ #[test]251
+ fn exact_zero_crossing_does_not_reset_active_envelope() {252
+ let cfg = CompressorConfig {253
+ threshold_db: -24.0,254
+ attack_ms: 0.1,255
+ release_ms: 200.0,256
+ makeup_db: Some(0.0),257
+ ..CompressorConfig::default()258
+ };259
+ let mut c = Compressor::new(cfg, 48_000.0);260
+ for _ in 0..1_000 {261
+ let _ = c.process_frame(1.0, 1.0);262
+ }263
+ assert!(c.gain_reduction_db() < -10.0);264
+265
+ let _ = c.process_frame(0.0, 0.0);266
+ assert!(267
+ c.gain_reduction_db() < -10.0,268
+ "a single exact-zero frame should release, not reset"269
+ );270
+ }271
+272
+ #[test]273
+ fn ratio_below_one_is_clamped() {274
+ let cfg = CompressorConfig {275
+ ratio: 0.5,276
+ ..CompressorConfig::default()277
+ }278
+ .sanitized();279
+ assert_eq!(cfg.ratio, 1.0);280
+ }281
+282
+ #[test]283
+ fn disabled_compressor_passes_signal_through_unchanged() {284
+ // Same hot input that would compress hard in the enabled285
+ // test above. With `enabled: false`, output equals input286
+ // exactly (no makeup gain, no reduction), and the reporter287
+ // shows zero GR — so the `transparent` and `bypass-all`288
+ // profiles actually do what their name claims.289
+ let cfg = CompressorConfig {290
+ enabled: false,291
+ threshold_db: -20.0,292
+ ratio: 4.0,293
+ makeup_db: Some(12.0),294
+ ..CompressorConfig::default()295
+ };296
+ let mut c = Compressor::new(cfg, 48_000.0);297
+ for _ in 0..1_000 {298
+ let (l, r) = c.process_frame(0.5, 0.5);299
+ assert_eq!(l, 0.5);300
+ assert_eq!(r, 0.5);301
+ }302
+ assert_eq!(c.gain_reduction_db(), 0.0);303
+ }304
+305
+ #[test]306
+ fn enable_transition_resets_stale_envelope() {307
+ // Run a loud signal through an enabled compressor to wind308
+ // the envelope up, then disable + re-enable via set_config.309
+ // The first sample after re-enable must NOT see the stale310
+ // envelope (which would otherwise duck the signal until311
+ // release_ms wound it down). Concretely: with a quiet input312
+ // after re-enable, the envelope should be at the floor, so313
+ // GR is zero — same as a freshly-constructed compressor.314
+ let loud_cfg = CompressorConfig {315
+ enabled: true,316
+ threshold_db: -20.0,317
+ ratio: 4.0,318
+ attack_ms: 0.1,319
+ release_ms: 1000.0, // slow release so stale state would otherwise stick320
+ knee_db: 0.0,321
+ makeup_db: Some(0.0),322
+ ..CompressorConfig::default()323
+ };324
+ let mut c = Compressor::new(loud_cfg, 48_000.0);325
+ // Drive hot signal to wind envelope up.326
+ for _ in 0..2_000 {327
+ c.process_frame(0.5, 0.5);328
+ }329
+ assert!(330
+ c.gain_reduction_db() < -5.0,331
+ "precondition: envelope should be wound up; gr={}",332
+ c.gain_reduction_db()333
+ );334
+335
+ // Disable, then re-enable — should reset.336
+ let mut disabled_cfg = loud_cfg;337
+ disabled_cfg.enabled = false;338
+ c.set_config(disabled_cfg);339
+ c.set_config(loud_cfg);340
+341
+ // Now drive a quiet signal. With reset envelope, GR should342
+ // ride near zero; without reset, the stale envelope would343
+ // bleed gain reduction out over ~release_ms.344
+ let (l, r) = c.process_frame(0.001, 0.001);345
+ assert!(346
+ c.gain_reduction_db().abs() < 0.01,347
+ "envelope didn't reset across enable transition; gr={}",348
+ c.gain_reduction_db()349
+ );350
+ // Output should be quiet (within makeup-applied scale).351
+ assert!(l.abs() < 0.01);352
+ assert!(r.abs() < 0.01);353
+ }354
+355
+ #[test]356
+ fn static_curve_at_threshold_with_soft_knee() {357
+ // At exactly threshold, soft knee contributes exactly half the358
+ // ratio's compression amount at the upper knee shoulder.359
+ let gr = static_curve_gain_reduction(-24.0, -24.0, 4.0, 6.0);360
+ // At over==0 inside the knee, x = knee/2, factor = knee/8.361
+ // GR = knee/8 * (1 - 1/4) = 6/8 * 0.75 = 0.5625362
+ assert!((gr - 0.5625).abs() < 1e-4, "gr={gr}");363
+ }364
+}@@ -0,0 +1,66 @@
1
+//! fixed-length fifo sample delay.2
+3
+pub struct DelayLine {4
+ buf: Vec<f32>,5
+ write_idx: usize,6
+}7
+8
+impl DelayLine {9
+ /// 0 clamps to 1 (one-sample identity minimum).10
+ #[must_use]11
+ pub fn new(samples: usize) -> Self {12
+ Self {13
+ buf: vec![0.0; samples.max(1)],14
+ write_idx: 0,15
+ }16
+ }17
+18
+ #[must_use]19
+ pub fn len(&self) -> usize {20
+ self.buf.len()21
+ }22
+23
+ #[must_use]24
+ pub fn is_empty(&self) -> bool {25
+ false26
+ }27
+28
+ /// write `x`, return the sample written `len` calls ago.29
+ pub fn push_pop(&mut self, x: f32) -> f32 {30
+ let out = self.buf[self.write_idx];31
+ self.buf[self.write_idx] = x;32
+ self.write_idx = (self.write_idx + 1) % self.buf.len();33
+ out34
+ }35
+36
+ pub fn reset(&mut self) {37
+ for v in &mut self.buf {38
+ *v = 0.0;39
+ }40
+ self.write_idx = 0;41
+ }42
+}43
+44
+#[cfg(test)]45
+mod tests {46
+ use super::*;47
+48
+ #[test]49
+ fn delays_exactly_n_samples() {50
+ let mut d = DelayLine::new(4);51
+ let expected = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0];52
+ let inputs = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];53
+ for (i, &x) in inputs.iter().enumerate() {54
+ let y = d.push_pop(x);55
+ assert!((y - expected[i]).abs() < 1e-9, "i={i} y={y}");56
+ }57
+ }58
+59
+ #[test]60
+ fn zero_length_clamps_to_one() {61
+ let mut d = DelayLine::new(0);62
+ assert_eq!(d.len(), 1);63
+ assert_eq!(d.push_pop(1.0), 0.0);64
+ assert_eq!(d.push_pop(2.0), 1.0);65
+ }66
+}@@ -0,0 +1,90 @@
1
+//! exponential attack/release envelope follower.2
+3
+use crate::util::time_to_alpha;4
+5
+pub struct AttackRelease {6
+ attack_alpha: f32,7
+ release_alpha: f32,8
+ state: f32,9
+}10
+11
+impl AttackRelease {12
+ #[must_use]13
+ pub fn new(attack_ms: f32, release_ms: f32, sample_rate: f32) -> Self {14
+ Self {15
+ attack_alpha: time_to_alpha(attack_ms, sample_rate),16
+ release_alpha: time_to_alpha(release_ms, sample_rate),17
+ state: 0.0,18
+ }19
+ }20
+21
+ pub fn set_times(&mut self, attack_ms: f32, release_ms: f32, sample_rate: f32) {22
+ self.attack_alpha = time_to_alpha(attack_ms, sample_rate);23
+ self.release_alpha = time_to_alpha(release_ms, sample_rate);24
+ }25
+26
+ /// attack on rising input, release on falling.27
+ pub fn process_peak(&mut self, target: f32) -> f32 {28
+ if target > self.state {29
+ self.state += self.attack_alpha * (target - self.state);30
+ } else {31
+ self.state += self.release_alpha * (target - self.state);32
+ }33
+ self.state34
+ }35
+36
+ /// inverse of process_peak: attack on falling gain, release on rising (recover to unity).37
+ pub fn process_gain(&mut self, target: f32) -> f32 {38
+ if target < self.state {39
+ self.state += self.attack_alpha * (target - self.state);40
+ } else {41
+ self.state += self.release_alpha * (target - self.state);42
+ }43
+ self.state44
+ }45
+46
+ #[must_use]47
+ pub fn state(&self) -> f32 {48
+ self.state49
+ }50
+51
+ pub fn reset(&mut self, value: f32) {52
+ self.state = value;53
+ }54
+}55
+56
+#[cfg(test)]57
+mod tests {58
+ use super::*;59
+60
+ #[test]61
+ fn peak_mode_attacks_fast_releases_slow() {62
+ let fs = 48_000.0;63
+ let mut env = AttackRelease::new(0.1, 100.0, fs);64
+ // Drive to 1.0 and let it settle.65
+ for _ in 0..100 {66
+ env.process_peak(1.0);67
+ }68
+ assert!(env.state() > 0.99);69
+ // Drop input to 0.0 and verify slow decay.70
+ env.process_peak(0.0);71
+ assert!(env.state() > 0.999);72
+ for _ in 0..10 {73
+ env.process_peak(0.0);74
+ }75
+ // Still well above zero on the release time scale.76
+ assert!(env.state() > 0.8);77
+ }78
+79
+ #[test]80
+ fn gain_mode_attacks_on_drop() {81
+ let fs = 48_000.0;82
+ let mut env = AttackRelease::new(0.1, 100.0, fs);83
+ env.reset(1.0);84
+ // Demand a gain drop. Should snap down quickly.85
+ for _ in 0..100 {86
+ env.process_gain(0.5);87
+ }88
+ assert!(env.state() < 0.51);89
+ }90
+}@@ -0,0 +1,365 @@
1
+//! block-rate two-tier (peak + rms) level detector for layer a (per-app level control).2
+//!3
+//! recovery is implicit: each envelope releases at its own constant, so neither path sticks4
+//! once input drops.5
+6
+use crate::util::{lin_to_db, time_to_alpha};7
+8
+#[derive(Debug, Clone, Copy, PartialEq)]9
+pub struct LevelEnvelopesConfig {10
+ /// peak envelope above this → cut. catches transient bursts.11
+ pub peak_threshold_db: f32,12
+ /// smoothed rms above this → cut. catches sustained loudness mismatch.13
+ pub rms_target_db: f32,14
+ /// cap on max(peak, rms) reduction.15
+ pub max_cut_db: f32,16
+ pub peak_attack_ms: f32,17
+ pub peak_release_ms: f32,18
+ /// one-pole tau on the mean-square input.19
+ pub rms_window_ms: f32,20
+}21
+22
+impl Default for LevelEnvelopesConfig {23
+ fn default() -> Self {24
+ Self {25
+ peak_threshold_db: -6.0,26
+ rms_target_db: -20.0,27
+ max_cut_db: 12.0,28
+ peak_attack_ms: 5.0,29
+ peak_release_ms: 500.0,30
+ rms_window_ms: 1500.0,31
+ }32
+ }33
+}34
+35
+impl LevelEnvelopesConfig {36
+ #[must_use]37
+ pub fn sanitized(mut self) -> Self {38
+ if self.peak_threshold_db > 0.0 {39
+ self.peak_threshold_db = 0.0;40
+ }41
+ if self.rms_target_db > 0.0 {42
+ self.rms_target_db = 0.0;43
+ }44
+ if !self.max_cut_db.is_finite() || self.max_cut_db < 0.0 {45
+ self.max_cut_db = 0.0;46
+ }47
+ for v in [48
+ &mut self.peak_attack_ms,49
+ &mut self.peak_release_ms,50
+ &mut self.rms_window_ms,51
+ ] {52
+ if !v.is_finite() || *v < 0.0 {53
+ *v = 0.0;54
+ }55
+ }56
+ self57
+ }58
+}59
+60
+pub struct LevelEnvelopes {61
+ cfg: LevelEnvelopesConfig,62
+ block_dt_s: f32,63
+ peak_attack_alpha: f32,64
+ peak_release_alpha: f32,65
+ rms_alpha: f32,66
+ /// starts at floor so the first push doesn't trip the threshold.67
+ peak_env_db: f32,68
+ rms_smoothed_mean_sq: f32,69
+}70
+71
+#[derive(Debug, Clone, Copy, PartialEq)]72
+pub struct LevelDecision {73
+ pub peak_reduction_db: f32,74
+ pub rms_reduction_db: f32,75
+ /// `min(max_cut, max(peak, rms))`, always >= 0. 0 = leave channelVolumes alone.76
+ pub total_reduction_db: f32,77
+}78
+79
+impl LevelEnvelopes {80
+ /// `block_dt_s` = samples_per_block / sample_rate; up to ~100 ms tracks fine for v0.81
+ #[must_use]82
+ pub fn new(cfg: LevelEnvelopesConfig, block_dt_s: f32) -> Self {83
+ let cfg = cfg.sanitized();84
+ let (peak_attack_alpha, peak_release_alpha, rms_alpha) = compute_alphas(&cfg, block_dt_s);85
+ Self {86
+ cfg,87
+ block_dt_s,88
+ peak_attack_alpha,89
+ peak_release_alpha,90
+ rms_alpha,91
+ peak_env_db: -200.0,92
+ rms_smoothed_mean_sq: 0.0,93
+ }94
+ }95
+96
+ #[must_use]97
+ pub fn config(&self) -> LevelEnvelopesConfig {98
+ self.cfg99
+ }100
+101
+ #[must_use]102
+ pub fn block_dt_s(&self) -> f32 {103
+ self.block_dt_s104
+ }105
+106
+ /// recomputes alphas; doesn't reset envelope state (live tweaks stay artefact-free).107
+ pub fn set_config(&mut self, cfg: LevelEnvelopesConfig) {108
+ let cfg = cfg.sanitized();109
+ let (a_a, a_r, a_rms) = compute_alphas(&cfg, self.block_dt_s);110
+ self.cfg = cfg;111
+ self.peak_attack_alpha = a_a;112
+ self.peak_release_alpha = a_r;113
+ self.rms_alpha = a_rms;114
+ }115
+116
+ /// re-derives alphas. call when the audio thread's quantum changes.117
+ pub fn set_block_dt(&mut self, dt_s: f32) {118
+ if dt_s <= 0.0 || !dt_s.is_finite() || (dt_s - self.block_dt_s).abs() < 1e-9 {119
+ return;120
+ }121
+ self.block_dt_s = dt_s;122
+ let (a_a, a_r, a_rms) = compute_alphas(&self.cfg, dt_s);123
+ self.peak_attack_alpha = a_a;124
+ self.peak_release_alpha = a_r;125
+ self.rms_alpha = a_rms;126
+ }127
+128
+ /// `peak_lin` = per-block max|x|, `mean_sq_lin` = per-block Σx²/N.129
+ pub fn process_block(&mut self, peak_lin: f32, mean_sq_lin: f32) -> LevelDecision {130
+ let peak_lin = peak_lin.max(0.0);131
+ let mean_sq_lin = mean_sq_lin.max(0.0);132
+133
+ let target_db = lin_to_db(peak_lin);134
+ if target_db > self.peak_env_db {135
+ self.peak_env_db += self.peak_attack_alpha * (target_db - self.peak_env_db);136
+ } else {137
+ self.peak_env_db += self.peak_release_alpha * (target_db - self.peak_env_db);138
+ }139
+140
+ // smooth in the linear-power domain (canonical R128/IEC rms detector), then to dB.141
+ self.rms_smoothed_mean_sq += self.rms_alpha * (mean_sq_lin - self.rms_smoothed_mean_sq);142
+ // 20*log10(sqrt(mean_sq)) = 10*log10(mean_sq)143
+ let rms_db = 10.0 * self.rms_smoothed_mean_sq.max(1e-30).log10();144
+145
+ let peak_reduction_db = (self.peak_env_db - self.cfg.peak_threshold_db).max(0.0);146
+ let rms_reduction_db = (rms_db - self.cfg.rms_target_db).max(0.0);147
+ let combined = peak_reduction_db.max(rms_reduction_db);148
+ let total_reduction_db = combined.min(self.cfg.max_cut_db);149
+150
+ LevelDecision {151
+ peak_reduction_db,152
+ rms_reduction_db,153
+ total_reduction_db,154
+ }155
+ }156
+157
+ /// reset envelope state; call when re-attaching after a deference period.158
+ pub fn reset(&mut self) {159
+ self.peak_env_db = -200.0;160
+ self.rms_smoothed_mean_sq = 0.0;161
+ }162
+}163
+164
+fn compute_alphas(cfg: &LevelEnvelopesConfig, block_dt_s: f32) -> (f32, f32, f32) {165
+ let block_dt_ms = block_dt_s * 1000.0;166
+ let block_rate = if block_dt_s > 0.0 { 1.0 / block_dt_s } else { 1.0 };167
+ // time_to_alpha against block rate, not sample rate: smoothers run at block boundaries,168
+ // one (peak, mean_sq) pair per block. time_to_alpha is rate-agnostic.169
+ let attack = time_to_alpha(cfg.peak_attack_ms, block_rate);170
+ let release = time_to_alpha(cfg.peak_release_ms, block_rate);171
+ let rms = time_to_alpha(cfg.rms_window_ms, block_rate);172
+ let _ = block_dt_ms; // currently informational173
+ (attack, release, rms)174
+}175
+176
+#[cfg(test)]177
+mod tests {178
+ use super::*;179
+ use crate::util::db_to_lin;180
+181
+ /// 1024-frame quantum at 48 kHz.182
+ const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;183
+184
+ fn run_steady(env: &mut LevelEnvelopes, peak_lin: f32, mean_sq_lin: f32, blocks: usize) -> LevelDecision {185
+ let mut last = env.process_block(peak_lin, mean_sq_lin);186
+ for _ in 1..blocks {187
+ last = env.process_block(peak_lin, mean_sq_lin);188
+ }189
+ last190
+ }191
+192
+ #[test]193
+ fn quiet_signal_produces_no_reduction() {194
+ let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);195
+ let quiet = db_to_lin(-30.0);196
+ let mean_sq = quiet * quiet;197
+ let dec = run_steady(&mut env, quiet, mean_sq, 200);198
+ assert_eq!(dec.peak_reduction_db, 0.0);199
+ assert_eq!(dec.rms_reduction_db, 0.0);200
+ assert_eq!(dec.total_reduction_db, 0.0);201
+ }202
+203
+ #[test]204
+ fn peak_above_threshold_requests_cut() {205
+ let cfg = LevelEnvelopesConfig {206
+ peak_threshold_db: -6.0,207
+ // Long RMS window so the slow path doesn't dominate.208
+ rms_target_db: 0.0,209
+ rms_window_ms: 5_000.0,210
+ ..Default::default()211
+ };212
+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);213
+ // 0 dBFS peak: 6 dB over threshold.214
+ let peak = db_to_lin(0.0);215
+ let mean_sq = (peak * peak) * 0.05; // low rms (intermittent peak)216
+ let dec = run_steady(&mut env, peak, mean_sq, 200);217
+ assert!(218
+ (dec.peak_reduction_db - 6.0).abs() < 0.5,219
+ "expected ~6 dB peak cut, got {}",220
+ dec.peak_reduction_db221
+ );222
+ assert_eq!(dec.rms_reduction_db, 0.0);223
+ assert!((dec.total_reduction_db - 6.0).abs() < 0.5);224
+ }225
+226
+ #[test]227
+ fn rms_above_target_requests_cut() {228
+ let cfg = LevelEnvelopesConfig {229
+ // Push peak threshold up so only RMS engages.230
+ peak_threshold_db: 0.0,231
+ rms_target_db: -20.0,232
+ rms_window_ms: 200.0, // shorter so test converges quickly233
+ ..Default::default()234
+ };235
+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);236
+ // Sustained -10 dBFS RMS: 10 dB above target.237
+ let rms_lin = db_to_lin(-10.0);238
+ let mean_sq = rms_lin * rms_lin;239
+ // Peak set just below threshold so peak detector stays asleep.240
+ let peak = db_to_lin(-1.0);241
+ let dec = run_steady(&mut env, peak, mean_sq, 200);242
+ assert_eq!(dec.peak_reduction_db, 0.0);243
+ assert!(244
+ (dec.rms_reduction_db - 10.0).abs() < 0.5,245
+ "expected ~10 dB RMS cut, got {}",246
+ dec.rms_reduction_db247
+ );248
+ }249
+250
+ #[test]251
+ fn combined_takes_max_of_peak_and_rms() {252
+ let cfg = LevelEnvelopesConfig {253
+ peak_threshold_db: -6.0,254
+ rms_target_db: -20.0,255
+ rms_window_ms: 200.0,256
+ max_cut_db: 100.0,257
+ ..Default::default()258
+ };259
+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);260
+ let peak = db_to_lin(0.0); // 6 dB over261
+ let rms_lin = db_to_lin(-10.0); // 10 dB over262
+ let mean_sq = rms_lin * rms_lin;263
+ let dec = run_steady(&mut env, peak, mean_sq, 200);264
+ assert!((dec.peak_reduction_db - 6.0).abs() < 0.5);265
+ assert!((dec.rms_reduction_db - 10.0).abs() < 0.5);266
+ assert!(267
+ (dec.total_reduction_db - 10.0).abs() < 0.5,268
+ "max(6, 10) ≈ 10, got {}",269
+ dec.total_reduction_db270
+ );271
+ }272
+273
+ #[test]274
+ fn total_reduction_is_clamped_to_max_cut_db() {275
+ let cfg = LevelEnvelopesConfig {276
+ peak_threshold_db: -30.0,277
+ rms_target_db: -30.0,278
+ rms_window_ms: 50.0,279
+ max_cut_db: 3.0, // tight cap280
+ ..Default::default()281
+ };282
+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);283
+ let peak = db_to_lin(0.0); // 30 dB over284
+ let rms_lin = db_to_lin(-5.0);285
+ let mean_sq = rms_lin * rms_lin;286
+ let dec = run_steady(&mut env, peak, mean_sq, 200);287
+ assert!(dec.peak_reduction_db > 20.0);288
+ assert!(289
+ (dec.total_reduction_db - 3.0).abs() < 1e-3,290
+ "total clamped to max_cut_db, got {}",291
+ dec.total_reduction_db292
+ );293
+ }294
+295
+ #[test]296
+ fn peak_envelope_releases_after_burst() {297
+ let cfg = LevelEnvelopesConfig {298
+ peak_threshold_db: -6.0,299
+ rms_target_db: 0.0,300
+ rms_window_ms: 5_000.0,301
+ peak_attack_ms: 5.0,302
+ peak_release_ms: 100.0,303
+ ..Default::default()304
+ };305
+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);306
+ // Burst.307
+ for _ in 0..20 {308
+ env.process_block(db_to_lin(0.0), 0.0);309
+ }310
+ let burst = env.process_block(db_to_lin(0.0), 0.0);311
+ assert!(burst.peak_reduction_db > 5.0);312
+313
+ // Silence.314
+ for _ in 0..200 {315
+ env.process_block(0.0, 0.0);316
+ }317
+ let quiet = env.process_block(0.0, 0.0);318
+ assert!(319
+ quiet.peak_reduction_db < 0.5,320
+ "expected ~0 after release, got {}",321
+ quiet.peak_reduction_db322
+ );323
+ }324
+325
+ #[test]326
+ fn set_config_updates_alphas_without_reset() {327
+ let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);328
+ for _ in 0..100 {329
+ env.process_block(db_to_lin(-3.0), 0.0);330
+ }331
+ let before = env.process_block(db_to_lin(-3.0), 0.0);332
+ // Tighter threshold; envelope state preserved across the swap.333
+ env.set_config(LevelEnvelopesConfig {334
+ peak_threshold_db: -12.0,335
+ ..LevelEnvelopesConfig::default()336
+ });337
+ let after = env.process_block(db_to_lin(-3.0), 0.0);338
+ assert!(339
+ after.peak_reduction_db > before.peak_reduction_db,340
+ "tighter threshold should request more cut"341
+ );342
+ }343
+344
+ #[test]345
+ fn set_block_dt_recomputes_alphas() {346
+ let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);347
+ let original_attack = env.peak_attack_alpha;348
+ // Double the block period — slower block rate → smaller alpha349
+ // for the same time constant.350
+ env.set_block_dt(BLOCK_DT_S * 2.0);351
+ assert!(env.peak_attack_alpha > original_attack);352
+ }353
+354
+ #[test]355
+ fn reset_returns_to_idle_state() {356
+ let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);357
+ for _ in 0..200 {358
+ env.process_block(db_to_lin(0.0), db_to_lin(-3.0));359
+ }360
+ env.reset();361
+ let dec = env.process_block(0.0, 0.0);362
+ assert_eq!(dec.peak_reduction_db, 0.0);363
+ assert_eq!(dec.rms_reduction_db, 0.0);364
+ }365
+}@@ -0,0 +1,23 @@
1
+//! dsp kernels. every `process_*` is allocation-free + bounded-time; `new` allocates (not rt-safe).2
+3
+#![forbid(unsafe_code)]4
+#![warn(missing_docs)]5
+6
+mod agc;7
+mod compressor;8
+mod delay;9
+mod envelope;10
+mod level_envelopes;11
+mod limiter;12
+mod oversample;13
+mod sliding_max;14
+pub mod util;15
+16
+pub use agc::{AgcGain, AgcGainConfig};17
+pub use compressor::{Compressor, CompressorConfig, Detector};18
+pub use delay::DelayLine;19
+pub use envelope::AttackRelease;20
+pub use level_envelopes::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig};21
+pub use limiter::{Limiter, LimiterConfig, SetConfigOutcome, SoftTierConfig};22
+pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};23
+pub use sliding_max::SlidingMaxBuffer;@@ -0,0 +1,1046 @@
1
+//! two-tier true-peak limiter: parallel soft (dynamic psr ceiling) + hard (absolute contract) tiers.2
+3
+use crate::delay::DelayLine;4
+use crate::envelope::AttackRelease;5
+use crate::oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};6
+use crate::sliding_max::SlidingMaxBuffer;7
+use crate::util::{db_to_lin, lin_to_db, time_to_alpha};8
+9
+/// soft tier targets a dynamic ceiling `program_loudness_lufs + max_psr_db`; bounds the10
+/// peak-to-loudness ratio, not a safety contract (that's the hard tier).11
+#[derive(Debug, Clone, Copy, PartialEq)]12
+pub struct SoftTierConfig {13
+ /// max peak-to-shortterm-loudness ratio (dB). effective ceiling = `program_lufs + max_psr_db`.14
+ pub max_psr_db: f32,15
+ /// fallback ceiling (dBTP) when no program loudness yet (startup, before first agc window).16
+ pub static_ceiling_dbtp: f32,17
+ pub attack_ms: f32,18
+ pub release_ms: f32,19
+}20
+21
+impl Default for SoftTierConfig {22
+ fn default() -> Self {23
+ Self {24
+ max_psr_db: 14.0,25
+ static_ceiling_dbtp: -6.0,26
+ attack_ms: 5.0,27
+ release_ms: 200.0,28
+ }29
+ }30
+}31
+32
+impl SoftTierConfig {33
+ #[must_use]34
+ pub fn sanitized(mut self) -> Self {35
+ if self.static_ceiling_dbtp > 0.0 {36
+ self.static_ceiling_dbtp = 0.0;37
+ }38
+ if !self.max_psr_db.is_finite() || self.max_psr_db < 0.0 {39
+ self.max_psr_db = 0.0;40
+ }41
+ if self.attack_ms < 0.0 || !self.attack_ms.is_finite() {42
+ self.attack_ms = 0.0;43
+ }44
+ if self.release_ms < 0.0 || !self.release_ms.is_finite() {45
+ self.release_ms = 0.0;46
+ }47
+ self48
+ }49
+}50
+51
+#[derive(Debug, Clone, Copy, PartialEq)]52
+pub struct LimiterConfig {53
+ /// hard-tier output ceiling (dBTP). must be `<= 0.0`.54
+ pub ceiling_dbtp: f32,55
+ /// lookahead (ms); sets delay-line length + peak-window size. shared by both tiers.56
+ pub lookahead_ms: f32,57
+ /// hard-tier exponential release toward unity (ms).58
+ pub release_ms: f32,59
+ /// hard-tier hold after a reduction before release begins (ms).60
+ pub hold_ms: f32,61
+ /// oversample factor. 1 disables isp detection; 4 is the bs.1770-4 reference.62
+ pub oversample: usize,63
+ /// fir taps for the oversampling filter (odd).64
+ pub fir_taps: usize,65
+ /// `None` disables the soft tier → pure brickwall.66
+ pub soft: Option<SoftTierConfig>,67
+}68
+69
+impl Default for LimiterConfig {70
+ fn default() -> Self {71
+ Self {72
+ ceiling_dbtp: -0.1,73
+ lookahead_ms: 2.0,74
+ release_ms: 80.0,75
+ hold_ms: 5.0,76
+ oversample: 4,77
+ fir_taps: 31,78
+ soft: Some(SoftTierConfig::default()),79
+ }80
+ }81
+}82
+83
+/// internal-rate cap (Hz). detector upsamples to `sample_rate × oversample`; above ~192 kHz84
+/// fir cost rises linearly with no isp-detection gain, so drop oversample on high base rates.85
+pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0;86
+87
+impl LimiterConfig {88
+ /// rate-agnostic. callers that know the sample rate should prefer [`Self::sanitize_for_rate`]89
+ /// so oversample scales down on high-rate inputs.90
+ #[must_use]91
+ pub fn sanitized(mut self) -> Self {92
+ if self.ceiling_dbtp > 0.0 {93
+ self.ceiling_dbtp = 0.0;94
+ }95
+ self.oversample = self.oversample.clamp(1, 8);96
+ if self.fir_taps < 5 {97
+ self.fir_taps = 5;98
+ }99
+ if self.fir_taps % 2 == 0 {100
+ self.fir_taps += 1;101
+ }102
+ if let Some(soft) = self.soft {103
+ self.soft = Some(soft.sanitized());104
+ }105
+ self106
+ }107
+108
+ /// caps oversample so post-upsample rate stays ≤ [`MAX_INTERNAL_RATE_HZ`]; always ≥ 1.109
+ /// at default 4×: 48 kHz → 4×, 96 kHz → 2×, 192 kHz → 1×.110
+ #[must_use]111
+ pub fn sanitize_for_rate(self, sample_rate: f32) -> Self {112
+ let mut s = self.sanitized();113
+ if sample_rate > 0.0 {114
+ let max_os =115
+ (MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize;116
+ if s.oversample > max_os {117
+ s.oversample = max_os;118
+ }119
+ }120
+ s121
+ }122
+123
+ /// brickwall only (no soft tier).124
+ #[must_use]125
+ pub fn brickwall_only() -> Self {126
+ Self {127
+ soft: None,128
+ ..Self::default()129
+ }130
+ }131
+}132
+133
+const MAX_OVERSAMPLE: usize = 8;134
+const SILENCE_EPS: f32 = 1.0e-20;135
+const OUTPUT_IDLE_EPS: f32 = 1.0e-12;136
+const GAIN_IDLE_EPS: f32 = 1.0e-4;137
+138
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]139
+pub enum SetConfigOutcome {140
+ /// applied in place.141
+ Applied,142
+ /// differs in `oversample`/`fir_taps`/`lookahead_ms` (in samples) → would realloc buffers.143
+ /// limiter unchanged; rebuild from `Limiter::new` on the control thread.144
+ StructuralChange,145
+}146
+147
+pub struct Limiter {148
+ cfg: LimiterConfig,149
+ /// captured at construction so try_set_config can recompute time coeffs without a repass.150
+ sample_rate: f32,151
+ ceiling_lin: f32,152
+ os: usize,153
+154
+ // Per-channel oversampler / downsampler / delay-line paths.155
+ up_l: PolyphaseUpsampler,156
+ up_r: PolyphaseUpsampler,157
+ down_l: PolyphaseDownsampler,158
+ down_r: PolyphaseDownsampler,159
+ delay_l: DelayLine,160
+ delay_r: DelayLine,161
+162
+ /// sliding-window peak (oversampled domain), shared across channels + tiers.163
+ peak_buf: SlidingMaxBuffer,164
+165
+ // ---- Hard tier state (instant attack + hold + release) ----166
+ hard_gain: f32,167
+ hold_remaining: u32,168
+ hold_samples_os: u32,169
+ hard_release_alpha: f32,170
+171
+ // ---- Soft tier state (smooth envelope) ----172
+ soft_envelope: Option<AttackRelease>,173
+ soft_max_psr_db: f32,174
+ soft_static_ceiling_lin: f32,175
+ program_loudness_lufs: Option<f32>,176
+ /// effective soft ceiling (linear), recomputed when program loudness changes.177
+ soft_ceiling_lin: f32,178
+179
+ // scratch buffers (sized for max oversample factor).180
+ up_buf_l: [f32; MAX_OVERSAMPLE],181
+ up_buf_r: [f32; MAX_OVERSAMPLE],182
+ gained_buf_l: [f32; MAX_OVERSAMPLE],183
+ gained_buf_r: [f32; MAX_OVERSAMPLE],184
+185
+ // telemetry (sampled per frame).186
+ last_peak_lin: f32,187
+ last_gr_db: f32,188
+ last_soft_gr_db: f32,189
+ last_hard_gr_db: f32,190
+ last_output_lin: f32,191
+ idle_drain_frames: usize,192
+ silent_drain_remaining: usize,193
+}194
+195
+impl Limiter {196
+ /// allocates fir coeffs, polyphase tables, delay buffers. not rt-safe.197
+ #[must_use]198
+ pub fn new(cfg: LimiterConfig, sample_rate: f32) -> Self {199
+ let cfg = cfg.sanitized();200
+ let os = cfg.oversample;201
+ let lowpass = if os > 1 {202
+ design_lowpass_blackman(cfg.fir_taps, 0.45 / os as f32)203
+ } else {204
+ vec![1.0]205
+ };206
+207
+ let os_rate = sample_rate * os as f32;208
+ let lookahead_samples_os = (cfg.lookahead_ms * 1e-3 * os_rate).round() as usize;209
+ let lookahead_samples_os = lookahead_samples_os.max(1);210
+ let lookahead_frames = lookahead_samples_os.div_ceil(os);211
+ let idle_drain_frames = lookahead_frames + cfg.fir_taps * 2 + 2;212
+ let hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;213
+ let hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);214
+ let ceiling_lin = db_to_lin(cfg.ceiling_dbtp);215
+216
+ let (soft_envelope, soft_max_psr_db, soft_static_ceiling_lin, soft_ceiling_lin) =217
+ if let Some(soft) = cfg.soft {218
+ let env = AttackRelease::new(soft.attack_ms, soft.release_ms, os_rate);219
+ let static_ceiling_lin = db_to_lin(soft.static_ceiling_dbtp);220
+ (221
+ Some(env),222
+ soft.max_psr_db,223
+ static_ceiling_lin,224
+ static_ceiling_lin,225
+ )226
+ } else {227
+ (None, 0.0, 1.0, 1.0)228
+ };229
+230
+ let mut me = Self {231
+ cfg,232
+ sample_rate,233
+ ceiling_lin,234
+ os,235
+ up_l: PolyphaseUpsampler::new(os, &lowpass),236
+ up_r: PolyphaseUpsampler::new(os, &lowpass),237
+ down_l: PolyphaseDownsampler::new(os, &lowpass),238
+ down_r: PolyphaseDownsampler::new(os, &lowpass),239
+ delay_l: DelayLine::new(lookahead_samples_os),240
+ delay_r: DelayLine::new(lookahead_samples_os),241
+ peak_buf: SlidingMaxBuffer::new(lookahead_samples_os),242
+ hard_gain: 1.0,243
+ hold_remaining: 0,244
+ hold_samples_os,245
+ hard_release_alpha,246
+ soft_envelope,247
+ soft_max_psr_db,248
+ soft_static_ceiling_lin,249
+ program_loudness_lufs: None,250
+ soft_ceiling_lin,251
+ up_buf_l: [0.0; MAX_OVERSAMPLE],252
+ up_buf_r: [0.0; MAX_OVERSAMPLE],253
+ gained_buf_l: [0.0; MAX_OVERSAMPLE],254
+ gained_buf_r: [0.0; MAX_OVERSAMPLE],255
+ last_peak_lin: 0.0,256
+ last_gr_db: 0.0,257
+ last_soft_gr_db: 0.0,258
+ last_hard_gr_db: 0.0,259
+ last_output_lin: 0.0,260
+ idle_drain_frames,261
+ silent_drain_remaining: 0,262
+ };263
+ // seed soft envelope to unity: no phantom gain reduction on the first frames.264
+ if let Some(env) = &mut me.soft_envelope {265
+ env.reset(1.0);266
+ }267
+ me268
+ }269
+270
+ #[must_use]271
+ pub fn config(&self) -> LimiterConfig {272
+ self.cfg273
+ }274
+275
+ #[must_use]276
+ pub fn ceiling_dbtp(&self) -> f32 {277
+ self.cfg.ceiling_dbtp278
+ }279
+280
+ /// applied total reduction `min(soft_gain, hard_gain)` (dB, negative when limiting).281
+ #[must_use]282
+ pub fn gain_reduction_db(&self) -> f32 {283
+ self.last_gr_db284
+ }285
+286
+ #[must_use]287
+ pub fn soft_gain_reduction_db(&self) -> f32 {288
+ self.last_soft_gr_db289
+ }290
+291
+ /// non-zero ⇒ soft tier didn't hold the ceiling and the brickwall engaged. routinely292
+ /// non-zero on benign material ⇒ soft under-configured (psr too high / attack too slow /293
+ /// lookahead too short).294
+ #[must_use]295
+ pub fn hard_gain_reduction_db(&self) -> f32 {296
+ self.last_hard_gr_db297
+ }298
+299
+ #[must_use]300
+ pub fn true_peak_dbtp(&self) -> f32 {301
+ lin_to_db(self.last_peak_lin.max(1e-20))302
+ }303
+304
+ /// `program_loudness_lufs + soft.max_psr_db` when both known, else `static_ceiling_dbtp`.305
+ /// `None` if soft tier disabled.306
+ #[must_use]307
+ pub fn effective_soft_ceiling_dbtp(&self) -> Option<f32> {308
+ self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))309
+ }310
+311
+ /// live-update non-structural params (ceiling, hard release/hold, soft toggle + scalars).312
+ /// allocation-free, rt-safe. structural changes return [`SetConfigOutcome::StructuralChange`]313
+ /// and leave the limiter unchanged — see that variant.314
+ pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome {315
+ let cfg = cfg.sanitized();316
+ let os_rate = self.sample_rate * cfg.oversample as f32;317
+ let new_lookahead_samples_os =318
+ ((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1);319
+ let cur_lookahead_samples_os = self.peak_buf.window();320
+ if cfg.oversample != self.os321
+ || cfg.fir_taps != self.cfg.fir_taps322
+ || new_lookahead_samples_os != cur_lookahead_samples_os323
+ {324
+ return SetConfigOutcome::StructuralChange;325
+ }326
+327
+ self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp);328
+ self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);329
+ self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;330
+331
+ match (cfg.soft, self.cfg.soft) {332
+ (Some(new_soft), Some(_old_soft)) => {333
+ self.soft_max_psr_db = new_soft.max_psr_db;334
+ self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);335
+ if let Some(env) = &mut self.soft_envelope {336
+ env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate);337
+ }338
+ }339
+ (Some(new_soft), None) => {340
+ // re-enable: seed envelope to unity, no phantom gain reduction.341
+ let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate);342
+ env.reset(1.0);343
+ self.soft_envelope = Some(env);344
+ self.soft_max_psr_db = new_soft.max_psr_db;345
+ self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);346
+ }347
+ (None, Some(_)) => {348
+ self.soft_envelope = None;349
+ self.soft_max_psr_db = 0.0;350
+ self.soft_static_ceiling_lin = 1.0;351
+ }352
+ (None, None) => {}353
+ }354
+355
+ self.cfg = cfg;356
+ self.recompute_soft_ceiling();357
+ SetConfigOutcome::Applied358
+ }359
+360
+ /// program loudness for the dynamic soft ceiling; called by the agc at tick rate.361
+ /// non-finite ignored.362
+ pub fn set_program_loudness_lufs(&mut self, lufs: f32) {363
+ if !lufs.is_finite() {364
+ return;365
+ }366
+ self.program_loudness_lufs = Some(lufs);367
+ self.recompute_soft_ceiling();368
+ }369
+370
+ /// forget program loudness; soft tier falls back to its static ceiling.371
+ pub fn clear_program_loudness(&mut self) {372
+ self.program_loudness_lufs = None;373
+ self.recompute_soft_ceiling();374
+ }375
+376
+ fn recompute_soft_ceiling(&mut self) {377
+ self.soft_ceiling_lin = match (self.cfg.soft, self.program_loudness_lufs) {378
+ (Some(_), Some(lufs)) => {379
+ let dynamic_dbtp = (lufs + self.soft_max_psr_db).min(0.0);380
+ db_to_lin(dynamic_dbtp)381
+ }382
+ (Some(_), None) => self.soft_static_ceiling_lin,383
+ (None, _) => 1.0,384
+ };385
+ }386
+387
+ /// allocation-free. output guaranteed within `±ceiling_dbtp` (the hard contract).388
+ pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {389
+ // sanitize NaN/Inf to zero; never let garbage into limiter state.390
+ let left = if left.is_finite() { left } else { 0.0 };391
+ let right = if right.is_finite() { right } else { 0.0 };392
+ let input_silent = left.abs() <= SILENCE_EPS && right.abs() <= SILENCE_EPS;393
+ if input_silent && self.signal_path_idle() {394
+ self.last_peak_lin = 0.0;395
+ self.last_gr_db = 0.0;396
+ self.last_soft_gr_db = 0.0;397
+ self.last_hard_gr_db = 0.0;398
+ self.last_output_lin = 0.0;399
+ return (0.0, 0.0);400
+ }401
+ if input_silent {402
+ self.silent_drain_remaining = self.silent_drain_remaining.saturating_sub(1);403
+ } else {404
+ self.silent_drain_remaining = self.idle_drain_frames;405
+ }406
+407
+ self.up_l.process(left, &mut self.up_buf_l[..self.os]);408
+ self.up_r.process(right, &mut self.up_buf_r[..self.os]);409
+410
+ let mut frame_peak = 0.0_f32;411
+ let mut min_soft_gain = 1.0_f32;412
+ let mut min_total_gain = 1.0_f32;413
+414
+ for k in 0..self.os {415
+ let s_l = self.up_buf_l[k];416
+ let s_r = self.up_buf_r[k];417
+418
+ let peak = s_l.abs().max(s_r.abs());419
+ frame_peak = frame_peak.max(peak);420
+421
+ let window_peak = self.peak_buf.push_and_max(peak);422
+423
+ // ---- Soft tier --------------------------------------424
+ let soft_gain = if let Some(env) = &mut self.soft_envelope {425
+ let target = if window_peak > self.soft_ceiling_lin && window_peak > 1e-20 {426
+ self.soft_ceiling_lin / window_peak427
+ } else {428
+ 1.0429
+ };430
+ env.process_gain(target)431
+ } else {432
+ 1.0433
+ };434
+ if soft_gain < min_soft_gain {435
+ min_soft_gain = soft_gain;436
+ }437
+438
+ // ---- Hard tier --------------------------------------439
+ // size hard gain against the peak *after* the soft tier acts, so it doesn't do440
+ // redundant work. predicted_post_soft = max(immediate: current soft gain applied;441
+ // asymptotic: soft converged to target) — the larger/more conservative.442
+ let predicted_post_soft = if self.soft_envelope.is_some() {443
+ let asymptotic = window_peak.min(self.soft_ceiling_lin);444
+ let immediate = window_peak * soft_gain;445
+ asymptotic.max(immediate)446
+ } else {447
+ window_peak448
+ };449
+ let hard_target =450
+ if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {451
+ self.ceiling_lin / predicted_post_soft452
+ } else {453
+ 1.0454
+ };455
+456
+ if hard_target < self.hard_gain {457
+ self.hard_gain = hard_target;458
+ self.hold_remaining = self.hold_samples_os;459
+ } else if self.hold_remaining > 0 {460
+ self.hold_remaining -= 1;461
+ } else {462
+ self.hard_gain += self.hard_release_alpha * (hard_target - self.hard_gain);463
+ if self.hard_gain > hard_target {464
+ self.hard_gain = hard_target;465
+ }466
+ }467
+468
+ // ---- Combine ----------------------------------------469
+ let total_gain = soft_gain.min(self.hard_gain);470
+ if total_gain < min_total_gain {471
+ min_total_gain = total_gain;472
+ }473
+474
+ let d_l = self.delay_l.push_pop(s_l);475
+ let d_r = self.delay_r.push_pop(s_r);476
+477
+ let mut out_l = d_l * total_gain;478
+ let mut out_r = d_r * total_gain;479
+480
+ // defense-in-depth #1: clip in the oversampled domain so overshoots can't enter481
+ // the downsampler.482
+ out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);483
+ out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);484
+485
+ self.gained_buf_l[k] = out_l;486
+ self.gained_buf_r[k] = out_r;487
+ }488
+489
+ let mut out_l = self.down_l.process(&self.gained_buf_l[..self.os]);490
+ let mut out_r = self.down_r.process(&self.gained_buf_r[..self.os]);491
+492
+ // defense-in-depth #2: clip post-downsample, guarding against fir ringing nudging493
+ // the output above the ceiling.494
+ out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);495
+ out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);496
+497
+ self.last_peak_lin = frame_peak;498
+ self.last_soft_gr_db = lin_to_db(min_soft_gain.max(1e-12));499
+ self.last_hard_gr_db = lin_to_db(self.hard_gain.max(1e-12));500
+ self.last_gr_db = lin_to_db(min_total_gain.max(1e-12));501
+ self.last_output_lin = out_l.abs().max(out_r.abs());502
+ if input_silent503
+ && self.silent_drain_remaining == 0504
+ && self.last_output_lin <= OUTPUT_IDLE_EPS505
+ && self.gain_state_idle()506
+ {507
+ self.reset_signal_path();508
+ }509
+510
+ (out_l, out_r)511
+ }512
+513
+ /// process an interleaved stereo buffer in place.514
+ pub fn process_interleaved_stereo(&mut self, buf: &mut [f32]) {515
+ debug_assert!(buf.len() % 2 == 0);516
+ for frame in buf.chunks_exact_mut(2) {517
+ let (l, r) = self.process_frame(frame[0], frame[1]);518
+ frame[0] = l;519
+ frame[1] = r;520
+ }521
+ }522
+523
+ /// reset all internal state; program loudness is also cleared.524
+ pub fn reset(&mut self) {525
+ self.reset_signal_path();526
+ self.program_loudness_lufs = None;527
+ self.recompute_soft_ceiling();528
+ }529
+530
+ fn reset_signal_path(&mut self) {531
+ self.up_l.reset();532
+ self.up_r.reset();533
+ self.down_l.reset();534
+ self.down_r.reset();535
+ self.delay_l.reset();536
+ self.delay_r.reset();537
+ self.peak_buf.reset();538
+ self.hard_gain = 1.0;539
+ self.hold_remaining = 0;540
+ if let Some(env) = &mut self.soft_envelope {541
+ env.reset(1.0);542
+ }543
+ self.last_peak_lin = 0.0;544
+ self.last_gr_db = 0.0;545
+ self.last_soft_gr_db = 0.0;546
+ self.last_hard_gr_db = 0.0;547
+ self.last_output_lin = 0.0;548
+ self.silent_drain_remaining = 0;549
+ }550
+551
+ fn signal_path_idle(&self) -> bool {552
+ self.silent_drain_remaining == 0553
+ && self.last_peak_lin <= OUTPUT_IDLE_EPS554
+ && self.last_output_lin <= OUTPUT_IDLE_EPS555
+ && self.gain_state_idle()556
+ }557
+558
+ fn gain_state_idle(&self) -> bool {559
+ self.hold_remaining == 0560
+ && (self.hard_gain - 1.0).abs() <= GAIN_IDLE_EPS561
+ && self562
+ .soft_envelope563
+ .as_ref()564
+ .is_none_or(|env| (env.state() - 1.0).abs() <= GAIN_IDLE_EPS)565
+ }566
+}567
+568
+#[cfg(test)]569
+mod tests {570
+ use super::*;571
+ use std::f32::consts::PI;572
+573
+ // ----------------------------------------------------------------574
+ // sanitize_for_rate: oversample factor scales down so the575
+ // internal (post-upsample) rate stays bounded.576
+ // ----------------------------------------------------------------577
+578
+ #[test]579
+ fn sanitize_for_rate_caps_oversample_at_internal_192k() {580
+ // Default config has oversample = 4.581
+ let default = LimiterConfig::default();582
+ assert_eq!(default.oversample, 4);583
+584
+ // At 48 kHz: 4× = 192 kHz, at the cap, untouched.585
+ assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4);586
+ // At 44.1 kHz: 4× = 176.4 kHz, under the cap.587
+ assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4);588
+ // At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz.589
+ assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2);590
+ // At 192 kHz: cap forces oversample = 1.591
+ assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1);592
+ // Pathological rate above the cap still leaves at least 1.593
+ assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1);594
+ }595
+596
+ #[test]597
+ fn sanitize_for_rate_preserves_user_lower_oversample() {598
+ // User who explicitly set oversample = 2 at 48 kHz should599
+ // keep it; the rate cap doesn't push the value *up*.600
+ let cfg = LimiterConfig {601
+ oversample: 2,602
+ ..LimiterConfig::default()603
+ };604
+ assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2);605
+ }606
+607
+ // ----------------------------------------------------------------608
+ // try_set_config: scalar updates apply in place, structural609
+ // changes are rejected.610
+ // ----------------------------------------------------------------611
+612
+ #[test]613
+ fn try_set_config_applies_scalar_changes() {614
+ let sr = 48_000.0;615
+ let mut l = Limiter::new(LimiterConfig::default(), sr);616
+ let cfg = LimiterConfig {617
+ ceiling_dbtp: -3.0,618
+ release_ms: 200.0,619
+ hold_ms: 10.0,620
+ ..LimiterConfig::default()621
+ };622
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);623
+ assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);624
+ let active = l.config();625
+ assert!((active.release_ms - 200.0).abs() < 1e-6);626
+ assert!((active.hold_ms - 10.0).abs() < 1e-6);627
+ }628
+629
+ #[test]630
+ fn try_set_config_can_toggle_soft_tier() {631
+ let sr = 48_000.0;632
+ let mut l = Limiter::new(LimiterConfig::default(), sr);633
+ // Start with soft on. Disable it.634
+ let mut cfg = LimiterConfig {635
+ soft: None,636
+ ..LimiterConfig::default()637
+ };638
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);639
+ assert!(l.config().soft.is_none());640
+ assert!(l.effective_soft_ceiling_dbtp().is_none());641
+642
+ // Re-enable with custom params.643
+ let new_soft = SoftTierConfig {644
+ max_psr_db: 10.0,645
+ static_ceiling_dbtp: -4.0,646
+ attack_ms: 8.0,647
+ release_ms: 300.0,648
+ };649
+ cfg.soft = Some(new_soft);650
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);651
+ let active_soft = l.config().soft.expect("soft re-enabled");652
+ assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6);653
+ assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6);654
+ }655
+656
+ #[test]657
+ fn try_set_config_rejects_oversample_change() {658
+ let sr = 48_000.0;659
+ let mut l = Limiter::new(LimiterConfig::default(), sr);660
+ let cfg = LimiterConfig {661
+ oversample: 8,662
+ ..LimiterConfig::default()663
+ };664
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);665
+ // Limiter unchanged.666
+ assert_eq!(l.config().oversample, LimiterConfig::default().oversample);667
+ }668
+669
+ #[test]670
+ fn try_set_config_rejects_lookahead_change() {671
+ let sr = 48_000.0;672
+ let mut l = Limiter::new(LimiterConfig::default(), sr);673
+ let cfg = LimiterConfig {674
+ // resizes delay + peak buffer675
+ lookahead_ms: 5.0,676
+ ..LimiterConfig::default()677
+ };678
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);679
+ }680
+681
+ #[test]682
+ fn try_set_config_rejects_fir_taps_change() {683
+ let sr = 48_000.0;684
+ let mut l = Limiter::new(LimiterConfig::default(), sr);685
+ let cfg = LimiterConfig {686
+ fir_taps: 63,687
+ ..LimiterConfig::default()688
+ };689
+ assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);690
+ }691
+692
+ fn run_sine(693
+ limiter: &mut Limiter,694
+ freq: f32,695
+ amp_db: f32,696
+ samples: usize,697
+ sr: f32,698
+ ) -> Vec<f32> {699
+ let amp = db_to_lin(amp_db);700
+ let mut out = Vec::with_capacity(samples * 2);701
+ for n in 0..samples {702
+ let t = n as f32 / sr;703
+ let s = amp * (2.0 * PI * freq * t).sin();704
+ let (l, r) = limiter.process_frame(s, s);705
+ out.push(l);706
+ out.push(r);707
+ }708
+ out709
+ }710
+711
+ // ----------------------------------------------------------------712
+ // Hard-tier contract: holds with or without the soft tier present.713
+ // ----------------------------------------------------------------714
+715
+ #[test]716
+ fn passes_signal_below_both_ceilings_unchanged() {717
+ let sr = 48_000.0;718
+ let mut l = Limiter::new(LimiterConfig::default(), sr);719
+ // -18 dBFS is below the default static soft ceiling of -6 dBTP720
+ // and the hard ceiling. Neither tier should engage.721
+ let out = run_sine(&mut l, 440.0, -18.0, 4_800, sr);722
+ let max_abs = out.iter().skip(1_000).fold(0.0_f32, |a, &b| a.max(b.abs()));723
+ let max_db = lin_to_db(max_abs);724
+ assert!(725
+ (max_db - (-18.0)).abs() < 0.5,726
+ "expected ~-18 dB, got {max_db}"727
+ );728
+ assert!(729
+ l.gain_reduction_db().abs() < 0.5,730
+ "expected ~0 GR, got {}",731
+ l.gain_reduction_db()732
+ );733
+ }734
+735
+ #[test]736
+ fn enforces_hard_ceiling_on_hot_signal_with_soft_tier() {737
+ let sr = 48_000.0;738
+ let mut l = Limiter::new(LimiterConfig::default(), sr);739
+ let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);740
+ let ceiling_lin = db_to_lin(-0.1);741
+ let max_abs = out742
+ .iter()743
+ .skip(2_000)744
+ .fold(0.0_f32, |a, &b| a.max(b.abs()));745
+ assert!(746
+ max_abs <= ceiling_lin + 1e-6,747
+ "above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"748
+ );749
+ }750
+751
+ #[test]752
+ fn enforces_hard_ceiling_on_intersample_peak_with_soft_tier() {753
+ let sr = 48_000.0;754
+ let mut l = Limiter::new(LimiterConfig::default(), sr);755
+ let ceiling_lin = db_to_lin(-0.1);756
+ let mut max_abs = 0.0_f32;757
+ let mut sign = 1.0_f32;758
+ let amp = 0.95_f32;759
+ for n in 0..9_600 {760
+ let s = sign * amp;761
+ sign = -sign;762
+ let (lo, ro) = l.process_frame(s, s);763
+ if n > 1_500 {764
+ max_abs = max_abs.max(lo.abs()).max(ro.abs());765
+ }766
+ }767
+ assert!(768
+ max_abs <= ceiling_lin + 1e-6,769
+ "ISP: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"770
+ );771
+ }772
+773
+ #[test]774
+ fn enforces_hard_ceiling_on_transient_impulse_with_soft_tier() {775
+ let sr = 48_000.0;776
+ let mut l = Limiter::new(LimiterConfig::default(), sr);777
+ let ceiling_lin = db_to_lin(-0.1);778
+ let mut max_abs = 0.0_f32;779
+ for n in 0..4_800_usize {780
+ let s = if n == 1_000 { 4.0 } else { 0.0 };781
+ let (lo, ro) = l.process_frame(s, s);782
+ max_abs = max_abs.max(lo.abs()).max(ro.abs());783
+ }784
+ assert!(785
+ max_abs <= ceiling_lin + 1e-6,786
+ "impulse: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"787
+ );788
+ }789
+790
+ #[test]791
+ fn brickwall_only_skips_soft_tier_entirely() {792
+ let sr = 48_000.0;793
+ let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);794
+ assert!(l.effective_soft_ceiling_dbtp().is_none());795
+ // Drive a hot signal; brickwall must still hold.796
+ let out = run_sine(&mut l, 440.0, 6.0, 4_800, sr);797
+ let ceiling_lin = db_to_lin(-0.1);798
+ let max_abs = out.iter().skip(800).fold(0.0_f32, |a, &b| a.max(b.abs()));799
+ assert!(max_abs <= ceiling_lin + 1e-6);800
+ // No soft gain reduction should ever have been recorded.801
+ assert!(l.soft_gain_reduction_db().abs() < 1e-6);802
+ }803
+804
+ // ----------------------------------------------------------------805
+ // Soft tier: static fallback ceiling806
+ // ----------------------------------------------------------------807
+808
+ #[test]809
+ fn soft_tier_static_ceiling_engages_before_hard() {810
+ let sr = 48_000.0;811
+ // Static soft ceiling at -6 dBTP, attack short enough to812
+ // settle inside the lookahead.813
+ let cfg = LimiterConfig {814
+ lookahead_ms: 5.0,815
+ soft: Some(SoftTierConfig {816
+ static_ceiling_dbtp: -6.0,817
+ attack_ms: 1.0,818
+ release_ms: 100.0,819
+ ..SoftTierConfig::default()820
+ }),821
+ ..LimiterConfig::default()822
+ };823
+ let mut l = Limiter::new(cfg, sr);824
+ // Drive a +6 dB sine — well above the soft ceiling.825
+ let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);826
+827
+ // Output should sit near the soft ceiling, well below hard.828
+ let soft_ceiling_lin = db_to_lin(-6.0);829
+ let max_abs = out830
+ .iter()831
+ .skip(2_000)832
+ .fold(0.0_f32, |a, &b| a.max(b.abs()));833
+ // Allow small overshoot during soft attack (gain hasn't fully834
+ // settled when the peak arrives), but it must be well under835
+ // the hard ceiling.836
+ assert!(837
+ max_abs <= soft_ceiling_lin * 1.1,838
+ "output above soft ceiling: max_abs={max_abs}, soft_lin={soft_ceiling_lin}"839
+ );840
+ // Soft tier should report meaningful GR; hard tier ideally841
+ // does very little once the soft tier has settled.842
+ assert!(843
+ l.soft_gain_reduction_db() < -3.0,844
+ "soft GR too small: {}",845
+ l.soft_gain_reduction_db()846
+ );847
+ }848
+849
+ // ----------------------------------------------------------------850
+ // Soft tier: dynamic ceiling from program loudness851
+ // ----------------------------------------------------------------852
+853
+ #[test]854
+ fn dynamic_ceiling_tracks_program_loudness() {855
+ let sr = 48_000.0;856
+ let mut l = Limiter::new(LimiterConfig::default(), sr);857
+ // Default max_psr_db = 14.858
+ l.set_program_loudness_lufs(-18.0);859
+ let dyn_ceiling = l.effective_soft_ceiling_dbtp().expect("soft tier active");860
+ assert!(861
+ (dyn_ceiling - (-4.0)).abs() < 1e-3,862
+ "expected -4 dBTP, got {dyn_ceiling}"863
+ );864
+865
+ // Move the program louder; ceiling rises (and clamps at 0).866
+ l.set_program_loudness_lufs(-2.0);867
+ let dyn_ceiling = l.effective_soft_ceiling_dbtp().unwrap();868
+ assert!(869
+ (-0.1..=0.0).contains(&dyn_ceiling),870
+ "expected clamp near 0 dBTP, got {dyn_ceiling}"871
+ );872
+873
+ // Clear it; falls back to static.874
+ l.clear_program_loudness();875
+ let fallback = l.effective_soft_ceiling_dbtp().unwrap();876
+ assert!(877
+ (fallback - (-6.0)).abs() < 1e-3,878
+ "expected static -6 dBTP, got {fallback}"879
+ );880
+ }881
+882
+ #[test]883
+ fn silence_fast_path_preserves_program_loudness() {884
+ let sr = 48_000.0;885
+ let mut l = Limiter::new(LimiterConfig::default(), sr);886
+ l.set_program_loudness_lufs(-18.0);887
+ let before = l.effective_soft_ceiling_dbtp().unwrap();888
+889
+ for _ in 0..10_000 {890
+ let (lo, ro) = l.process_frame(0.0, 0.0);891
+ assert_eq!(lo, 0.0);892
+ assert_eq!(ro, 0.0);893
+ }894
+895
+ assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), before);896
+ assert_eq!(l.gain_reduction_db(), 0.0);897
+ assert_eq!(l.true_peak_dbtp(), -200.0);898
+ }899
+900
+ #[test]901
+ fn silence_fast_path_does_not_truncate_lookahead_tail() {902
+ let sr = 48_000.0;903
+ let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);904
+ for _ in 0..16 {905
+ let _ = l.process_frame(0.5, 0.5);906
+ }907
+908
+ let mut delayed_peak = 0.0_f32;909
+ for n in 0..200 {910
+ let (lo, ro) = l.process_frame(0.0, 0.0);911
+ if n > 8 {912
+ delayed_peak = delayed_peak.max(lo.abs()).max(ro.abs());913
+ }914
+ }915
+916
+ assert!(917
+ delayed_peak > 1.0e-6,918
+ "lookahead tail was truncated before it could drain: delayed_peak={delayed_peak}"919
+ );920
+ }921
+922
+ #[test]923
+ fn dynamic_ceiling_bounds_psr_on_hot_transient() {924
+ let sr = 48_000.0;925
+ // Long lookahead and fast soft attack so the soft tier926
+ // demonstrably catches the transient before the hard tier927
+ // needs to.928
+ let cfg = LimiterConfig {929
+ lookahead_ms: 5.0,930
+ soft: Some(SoftTierConfig {931
+ max_psr_db: 14.0,932
+ static_ceiling_dbtp: -6.0,933
+ attack_ms: 1.0,934
+ release_ms: 100.0,935
+ }),936
+ ..LimiterConfig::default()937
+ };938
+ let mut l = Limiter::new(cfg, sr);939
+ l.set_program_loudness_lufs(-18.0);940
+ // Expected dynamic ceiling: -18 + 14 = -4 dBTP ≈ 0.631 lin.941
+ let dyn_ceil_lin = db_to_lin(-4.0);942
+943
+ // Slam a +6 dBFS impulse.944
+ let mut max_after = 0.0_f32;945
+ for n in 0..4_800_usize {946
+ let s = if n == 800 { db_to_lin(6.0) } else { 0.0 };947
+ let (lo, _) = l.process_frame(s, s);948
+ if n > 700 {949
+ max_after = max_after.max(lo.abs());950
+ }951
+ }952
+ // Output should be at or below the dynamic soft ceiling with953
+ // a small ringing margin. Critically, the hard tier should954
+ // *not* be the thing that catches it — its GR should be small.955
+ assert!(956
+ max_after <= dyn_ceil_lin * 1.15,957
+ "soft tier didn't bound the transient: max={max_after}, dyn_ceil={dyn_ceil_lin}"958
+ );959
+ // The hard tier may snap briefly at peak entry (soft envelope960
+ // hasn't ramped yet), then take its release time to recover.961
+ // We don't require zero hard engagement here — only that it962
+ // isn't doing the majority of the work.963
+ assert!(964
+ l.hard_gain_reduction_db().abs() < 4.0,965
+ "hard tier engaged unreasonably: {}",966
+ l.hard_gain_reduction_db()967
+ );968
+ }969
+970
+ // ----------------------------------------------------------------971
+ // Misc972
+ // ----------------------------------------------------------------973
+974
+ #[test]975
+ fn nan_inputs_do_not_propagate_with_soft_tier() {976
+ let sr = 48_000.0;977
+ let mut l = Limiter::new(LimiterConfig::default(), sr);978
+ for _ in 0..1_000 {979
+ let (lo, ro) = l.process_frame(f32::NAN, f32::INFINITY);980
+ assert!(lo.is_finite() && ro.is_finite());981
+ }982
+ }983
+984
+ #[test]985
+ fn ceiling_clamps_positive_config_to_zero() {986
+ let cfg = LimiterConfig {987
+ ceiling_dbtp: 3.0,988
+ ..LimiterConfig::default()989
+ }990
+ .sanitized();991
+ assert_eq!(cfg.ceiling_dbtp, 0.0);992
+ }993
+994
+ #[test]995
+ fn set_program_loudness_ignores_non_finite() {996
+ let sr = 48_000.0;997
+ let mut l = Limiter::new(LimiterConfig::default(), sr);998
+ // Establish a baseline.999
+ l.set_program_loudness_lufs(-20.0);1000
+ let baseline = l.effective_soft_ceiling_dbtp().unwrap();1001
+ // NaN / Inf should be ignored.1002
+ l.set_program_loudness_lufs(f32::NAN);1003
+ assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);1004
+ l.set_program_loudness_lufs(f32::INFINITY);1005
+ assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);1006
+ }1007
+1008
+ #[test]1009
+ fn soft_tier_reduces_perceived_peak_to_loudness_ratio() {1010
+ // The whole point of the soft tier: a transient on top of a1011
+ // quieter program should NOT come out near the hard ceiling.1012
+ let sr = 48_000.0;1013
+ let cfg = LimiterConfig {1014
+ lookahead_ms: 5.0,1015
+ soft: Some(SoftTierConfig {1016
+ max_psr_db: 12.0,1017
+ static_ceiling_dbtp: -8.0,1018
+ attack_ms: 1.0,1019
+ release_ms: 100.0,1020
+ }),1021
+ ..LimiterConfig::default()1022
+ };1023
+ let mut brickwall = Limiter::new(LimiterConfig::brickwall_only(), sr);1024
+ let mut two_tier = Limiter::new(cfg, sr);1025
+ two_tier.set_program_loudness_lufs(-20.0);1026
+1027
+ let mut bw_peak = 0.0_f32;1028
+ let mut tt_peak = 0.0_f32;1029
+ for n in 0..4_800_usize {1030
+ // Quiet program with a single big spike.1031
+ let s = if n == 1_200 { db_to_lin(3.0) } else { 0.01 };1032
+ let (lo_bw, _) = brickwall.process_frame(s, s);1033
+ let (lo_tt, _) = two_tier.process_frame(s, s);1034
+ if n > 1_000 {1035
+ bw_peak = bw_peak.max(lo_bw.abs());1036
+ tt_peak = tt_peak.max(lo_tt.abs());1037
+ }1038
+ }1039
+ // Brickwall lets the spike through near the hard ceiling.1040
+ // Two-tier holds it much lower.1041
+ assert!(1042
+ tt_peak < bw_peak * 0.6,1043
+ "soft tier did not meaningfully reduce peak: bw={bw_peak}, tt={tt_peak}"1044
+ );1045
+ }1046
+}@@ -0,0 +1,208 @@
1
+//! polyphase fir up/downsamplers for the limiter's oversampled isp detection (itu-r bs.1770-4).2
+3
+use std::f32::consts::PI;4
+5
+/// blackman-windowed sinc lowpass fir, normalized for unity dc gain.6
+/// odd `taps` → linear phase, group delay `(taps - 1) / 2`. `fc` normalized in `0.0..0.5`.7
+/// use `fc = 0.5 / M` as the prototype for M-times oversampling.8
+#[must_use]9
+pub fn design_lowpass_blackman(taps: usize, fc: f32) -> Vec<f32> {10
+ let taps = taps.max(1);11
+ let m = (taps as f32 - 1.0).max(1.0);12
+ let mut h = vec![0.0_f32; taps];13
+ let mut sum = 0.0_f32;14
+ for (n, h_n) in h.iter_mut().enumerate() {15
+ let x = n as f32 - m / 2.0;16
+ let sinc = if x.abs() < 1e-9 {17
+ 2.0 * fc18
+ } else {19
+ (2.0 * PI * fc * x).sin() / (PI * x)20
+ };21
+ let w = 0.42 - 0.5 * (2.0 * PI * n as f32 / m).cos() + 0.08 * (4.0 * PI * n as f32 / m).cos();22
+ *h_n = sinc * w;23
+ sum += *h_n;24
+ }25
+ if sum.abs() > 1e-12 {26
+ for v in &mut h {27
+ *v /= sum;28
+ }29
+ }30
+ h31
+}32
+33
+/// one input sample → `factor` output samples. coeffs pre-scaled by `factor` for unity dc gain.34
+pub struct PolyphaseUpsampler {35
+ factor: usize,36
+ taps_per_phase: usize,37
+ /// `phases[j * taps_per_phase + p] = h[p * factor + j] * factor`.38
+ phases: Vec<f32>,39
+ history: Vec<f32>,40
+ write_idx: usize,41
+}42
+43
+impl PolyphaseUpsampler {44
+ #[must_use]45
+ pub fn new(factor: usize, fir_taps: &[f32]) -> Self {46
+ let factor = factor.max(1);47
+ let taps_per_phase = fir_taps.len().div_ceil(factor);48
+ let mut phases = vec![0.0_f32; factor * taps_per_phase];49
+ for (n, &h) in fir_taps.iter().enumerate() {50
+ let j = n % factor;51
+ let p = n / factor;52
+ phases[j * taps_per_phase + p] = h * factor as f32;53
+ }54
+ Self {55
+ factor,56
+ taps_per_phase,57
+ phases,58
+ history: vec![0.0_f32; taps_per_phase.max(1)],59
+ write_idx: 0,60
+ }61
+ }62
+63
+ #[must_use]64
+ pub fn factor(&self) -> usize {65
+ self.factor66
+ }67
+68
+ /// emit `factor` samples into `out[..factor]`; `out.len() >= factor`.69
+ pub fn process(&mut self, x: f32, out: &mut [f32]) {70
+ debug_assert!(out.len() >= self.factor);71
+ let len = self.history.len();72
+ self.history[self.write_idx] = x;73
+ let just_written = self.write_idx;74
+ self.write_idx = (self.write_idx + 1) % len;75
+76
+ for (j, slot) in out.iter_mut().take(self.factor).enumerate() {77
+ let phase = &self.phases[j * self.taps_per_phase..(j + 1) * self.taps_per_phase];78
+ let mut acc = 0.0_f32;79
+ for (p, &h) in phase.iter().enumerate() {80
+ let idx = (just_written + len - p) % len;81
+ acc += h * self.history[idx];82
+ }83
+ *slot = acc;84
+ }85
+ }86
+87
+ pub fn reset(&mut self) {88
+ for v in &mut self.history {89
+ *v = 0.0;90
+ }91
+ self.write_idx = 0;92
+ }93
+}94
+95
+/// `factor` input samples → one output. same prototype lowpass as the upsampler.96
+/// no polyphase split — savings are modest at our tap count, clarity wins.97
+pub struct PolyphaseDownsampler {98
+ factor: usize,99
+ taps: Vec<f32>,100
+ history: Vec<f32>,101
+ write_idx: usize,102
+}103
+104
+impl PolyphaseDownsampler {105
+ #[must_use]106
+ pub fn new(factor: usize, fir_taps: &[f32]) -> Self {107
+ let factor = factor.max(1);108
+ Self {109
+ factor,110
+ taps: fir_taps.to_vec(),111
+ history: vec![0.0_f32; fir_taps.len().max(1)],112
+ write_idx: 0,113
+ }114
+ }115
+116
+ #[must_use]117
+ pub fn factor(&self) -> usize {118
+ self.factor119
+ }120
+121
+ /// push `factor` samples, return one filtered output.122
+ pub fn process(&mut self, ins: &[f32]) -> f32 {123
+ debug_assert_eq!(ins.len(), self.factor);124
+ let len = self.history.len();125
+ for &x in ins {126
+ self.history[self.write_idx] = x;127
+ self.write_idx = (self.write_idx + 1) % len;128
+ }129
+ let mut acc = 0.0_f32;130
+ for (k, &h) in self.taps.iter().enumerate() {131
+ let idx = (self.write_idx + len - 1 - k) % len;132
+ acc += h * self.history[idx];133
+ }134
+ acc135
+ }136
+137
+ pub fn reset(&mut self) {138
+ for v in &mut self.history {139
+ *v = 0.0;140
+ }141
+ self.write_idx = 0;142
+ }143
+}144
+145
+#[cfg(test)]146
+mod tests {147
+ use super::*;148
+149
+ fn fir(taps: usize, factor: usize) -> Vec<f32> {150
+ design_lowpass_blackman(taps, 0.45 / factor as f32)151
+ }152
+153
+ #[test]154
+ fn upsampler_dc_gain_preserved() {155
+ let h = fir(31, 4);156
+ let mut up = PolyphaseUpsampler::new(4, &h);157
+ // Drive DC and let the filter settle, then check unity gain.158
+ let mut buf = [0.0_f32; 8];159
+ let mut last_avg = 0.0;160
+ for _ in 0..200 {161
+ up.process(1.0, &mut buf);162
+ last_avg = buf[..4].iter().sum::<f32>() / 4.0;163
+ }164
+ assert!((last_avg - 1.0).abs() < 1e-3, "got {last_avg}");165
+ }166
+167
+ #[test]168
+ fn down_then_up_roundtrip_is_bounded() {169
+ // Stuff zero-padded input through up then down; output amplitude170
+ // should approximately equal input on smooth signals.171
+ let h = fir(31, 4);172
+ let mut up = PolyphaseUpsampler::new(4, &h);173
+ let mut down = PolyphaseDownsampler::new(4, &h);174
+ let mut max_err = 0.0_f32;175
+ let mut up_buf = [0.0_f32; 8];176
+ // Drive a slow sine well below Nyquist.177
+ for n in 0..2_000 {178
+ let t = n as f32 / 48_000.0;179
+ let x = (2.0 * std::f32::consts::PI * 1_000.0 * t).sin() * 0.5;180
+ up.process(x, &mut up_buf);181
+ let y = down.process(&up_buf[..4]);182
+ // After group-delay warm-up, the error should be small.183
+ if n > 80 {184
+ max_err = max_err.max((x - y).abs());185
+ }186
+ }187
+ // The filter is symmetric, so up/down with the same kernel188
+ // introduces ~6 dB attenuation by design (each pass contributes189
+ // half the gain). What we care about here is finite, bounded190
+ // output and no runaway.191
+ assert!(max_err < 1.0, "max_err {max_err}");192
+ }193
+194
+ #[test]195
+ fn upsampler_handles_impulse() {196
+ let h = fir(15, 4);197
+ let mut up = PolyphaseUpsampler::new(4, &h);198
+ let mut buf = [0.0_f32; 8];199
+ up.process(1.0, &mut buf);200
+ // Some non-zero output expected on first impulse already.201
+ assert!(buf[..4].iter().any(|&v| v.abs() > 1e-6));202
+ // Drive zeros; output decays to zero.203
+ for _ in 0..200 {204
+ up.process(0.0, &mut buf);205
+ }206
+ assert!(buf[..4].iter().all(|&v| v.abs() < 1e-6));207
+ }208
+}@@ -0,0 +1,98 @@
1
+//! amortized-O(1) sliding-window max via monotonic-decreasing deque; bounded capacity, no realloc.2
+3
+use std::collections::VecDeque;4
+5
+pub struct SlidingMaxBuffer {6
+ window: usize,7
+ counter: u64,8
+ /// `(index, value)`, monotonically decreasing in value from front.9
+ deque: VecDeque<(u64, f32)>,10
+}11
+12
+impl SlidingMaxBuffer {13
+ /// 0 clamps to 1.14
+ #[must_use]15
+ pub fn new(window: usize) -> Self {16
+ let window = window.max(1);17
+ Self {18
+ window,19
+ counter: 0,20
+ deque: VecDeque::with_capacity(window),21
+ }22
+ }23
+24
+ #[must_use]25
+ pub fn window(&self) -> usize {26
+ self.window27
+ }28
+29
+ /// max over the most recent `window` samples, inclusive of `value`.30
+ pub fn push_and_max(&mut self, value: f32) -> f32 {31
+ // drop entries aged out of the window.32
+ let cutoff = self.counter.saturating_sub(self.window as u64 - 1);33
+ while let Some(&(idx, _)) = self.deque.front() {34
+ if idx < cutoff {35
+ self.deque.pop_front();36
+ } else {37
+ break;38
+ }39
+ }40
+ // drop back entries <= value: they can never become the max.41
+ while let Some(&(_, v)) = self.deque.back() {42
+ if v <= value {43
+ self.deque.pop_back();44
+ } else {45
+ break;46
+ }47
+ }48
+ self.deque.push_back((self.counter, value));49
+ self.counter += 1;50
+ // deque is non-empty (we just pushed).51
+ self.deque.front().map_or(0.0, |&(_, v)| v)52
+ }53
+54
+ pub fn reset(&mut self) {55
+ self.counter = 0;56
+ self.deque.clear();57
+ }58
+}59
+60
+#[cfg(test)]61
+mod tests {62
+ use super::*;63
+64
+ #[test]65
+ fn tracks_window_max() {66
+ let mut s = SlidingMaxBuffer::new(3);67
+ assert_eq!(s.push_and_max(1.0), 1.0);68
+ assert_eq!(s.push_and_max(3.0), 3.0);69
+ assert_eq!(s.push_and_max(2.0), 3.0);70
+ assert_eq!(s.push_and_max(2.0), 3.0); // 3.0 aged out... actually still in (window=3, last 3 are [3,2,2])71
+ assert_eq!(s.push_and_max(0.5), 2.0); // window is now [2,2,0.5]72
+ assert_eq!(s.push_and_max(0.5), 2.0); // [2,0.5,0.5]73
+ assert_eq!(s.push_and_max(0.5), 0.5); // [0.5,0.5,0.5]74
+ }75
+76
+ #[test]77
+ fn monotonically_decreasing_input() {78
+ let mut s = SlidingMaxBuffer::new(4);79
+ for (i, &v) in [5.0_f32, 4.0, 3.0, 2.0, 1.0, 0.5].iter().enumerate() {80
+ let m = s.push_and_max(v);81
+ // After window is filled, max is the value `window-1` back.82
+ let expected = match i {83
+ 0..=3 => 5.0,84
+ 4 => 4.0,85
+ _ => 3.0,86
+ };87
+ assert_eq!(m, expected);88
+ }89
+ }90
+91
+ #[test]92
+ fn window_one_is_identity() {93
+ let mut s = SlidingMaxBuffer::new(1);94
+ for v in [1.0, 2.0, 0.5, 9.0_f32, -3.0] {95
+ assert_eq!(s.push_and_max(v), v);96
+ }97
+ }98
+}@@ -0,0 +1,54 @@
1
+//! db <-> linear conversions, time constants.2
+3
+/// lower bound to avoid `log10(0)`.4
+pub const PEAK_FLOOR: f32 = 1e-20;5
+6
+/// inputs at or below [`PEAK_FLOOR`] clamp to `-200 dB`.7
+#[must_use]8
+pub fn lin_to_db(x: f32) -> f32 {9
+ if x <= PEAK_FLOOR {10
+ -200.011
+ } else {12
+ 20.0 * x.log10()13
+ }14
+}15
+16
+#[must_use]17
+pub fn db_to_lin(db: f32) -> f32 {18
+ 10.0_f32.powf(db / 20.0)19
+}20
+21
+/// one-pole smoother coefficient for `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`.22
+/// alpha = `1 - exp(-1 / (tau * fs))`, tau = `time_ms / 1000`. time_ms <= 0 → 1.0 (instant).23
+#[must_use]24
+pub fn time_to_alpha(time_ms: f32, sample_rate: f32) -> f32 {25
+ if time_ms <= 0.0 || sample_rate <= 0.0 {26
+ 1.027
+ } else {28
+ let tau_samples = (time_ms * 1e-3) * sample_rate;29
+ 1.0 - (-1.0 / tau_samples).exp()30
+ }31
+}32
+33
+#[cfg(test)]34
+mod tests {35
+ use super::*;36
+37
+ #[test]38
+ fn db_round_trips() {39
+ for db in [-60.0, -20.0, -6.0, -0.1, 0.0, 3.0, 6.0_f32] {40
+ let lin = db_to_lin(db);41
+ let back = lin_to_db(lin);42
+ assert!((back - db).abs() < 1e-3, "db={db} back={back}");43
+ }44
+ }45
+46
+ #[test]47
+ fn time_to_alpha_endpoints() {48
+ assert_eq!(time_to_alpha(0.0, 48_000.0), 1.0);49
+ assert!(time_to_alpha(1000.0, 48_000.0) < 0.01);50
+ // Very fast attack: alpha approaches 1.51
+ let a_fast = time_to_alpha(0.01, 48_000.0);52
+ assert!(a_fast > 0.05, "fast alpha was {a_fast}");53
+ }54
+}