atagen

headroom

public
Star0
Watch0

Commit 614b4e43f9f3

tarball

atagen <boss@atagen.co>2026-06-02 05:09 UTC

12 files changed+2654-0
dsp: agc, compressor, true-peak limiter, bs.1770 metering
Added file: crates/headroom-dsp/Cargo.toml+23 additions
@@ -0,0 +1,23 @@
1+[package]
2+name = "headroom-dsp"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license = "MPL-2.0"
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
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
Added file: crates/headroom-dsp/benches/layer_a.rs+134 additions
@@ -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 / fpu
17+/// 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 difference
30+ }
31+ buf
32+}
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_kernels
133+);
134+criterion_main!(benches);
Added file: crates/headroom-dsp/src/agc.rs+183 additions
@@ -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 rate
49+ /// 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.cfg
65+ }
66+
67+ #[must_use]
68+ pub fn current_db(&self) -> f32 {
69+ self.current_db
70+ }
71+
72+ #[must_use]
73+ pub fn target_db(&self) -> f32 {
74+ self.target_db
75+ }
76+
77+ #[must_use]
78+ pub fn enabled(&self) -> bool {
79+ self.enabled
80+ }
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 in
114+ // 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 produce
174+ // 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+}
Added file: crates/headroom-dsp/src/compressor.rs+364 additions
@@ -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 residual
16+ /// transient on re-enable is sub-audible.
17+ pub enabled: bool,
18+ pub threshold_db: f32,
19+ /// >= 1.0
20+ 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+ self
59+ }
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.cfg
93+ }
94+
95+ /// negative when compressing.
96+ #[must_use]
97+ pub fn gain_reduction_db(&self) -> f32 {
98+ self.last_gr_db
99+ }
100+
101+ /// envelope kept across same-enabled tweaks (don't pop); reset on disabled → enabled so a
102+ /// 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 = self
151+ .cfg
152+ .makeup_db
153+ .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 knee
178+ 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.0
185+ }
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.5
192+}
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+ expected
214+ );
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 math
224+ attack_ms: 0.1,
225+ release_ms: 0.1,
226+ makeup_db: Some(0.0), // no makeup so we test pure reduction
227+ ..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 enabled
285+ // test above. With `enabled: false`, output equals input
286+ // exactly (no makeup gain, no reduction), and the reporter
287+ // 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 wind
308+ // the envelope up, then disable + re-enable via set_config.
309+ // The first sample after re-enable must NOT see the stale
310+ // envelope (which would otherwise duck the signal until
311+ // release_ms wound it down). Concretely: with a quiet input
312+ // after re-enable, the envelope should be at the floor, so
313+ // 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 stick
320+ 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 should
342+ // ride near zero; without reset, the stale envelope would
343+ // 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 the
358+ // 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.5625
362+ assert!((gr - 0.5625).abs() < 1e-4, "gr={gr}");
363+ }
364+}
Added file: crates/headroom-dsp/src/delay.rs+66 additions
@@ -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+ false
26+ }
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+ out
34+ }
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+}
Added file: crates/headroom-dsp/src/envelope.rs+90 additions
@@ -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.state
34+ }
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.state
44+ }
45+
46+ #[must_use]
47+ pub fn state(&self) -> f32 {
48+ self.state
49+ }
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+}
Added file: crates/headroom-dsp/src/level_envelopes.rs+365 additions
@@ -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 sticks
4+//! 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+ self
57+ }
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.cfg
99+ }
100+
101+ #[must_use]
102+ pub fn block_dt_s(&self) -> f32 {
103+ self.block_dt_s
104+ }
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 informational
173+ (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+ last
190+ }
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_db
221+ );
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 quickly
233+ ..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_db
247+ );
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 over
261+ let rms_lin = db_to_lin(-10.0); // 10 dB over
262+ 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_db
270+ );
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 cap
280+ ..Default::default()
281+ };
282+ let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
283+ let peak = db_to_lin(0.0); // 30 dB over
284+ 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_db
292+ );
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_db
322+ );
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 alpha
349+ // 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+}
Added file: crates/headroom-dsp/src/lib.rs+23 additions
@@ -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;
Added file: crates/headroom-dsp/src/limiter.rs+1046 additions
@@ -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 the
10+/// 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+ self
48+ }
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 kHz
84+/// 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+ self
106+ }
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+ s
121+ }
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+ me
268+ }
269+
270+ #[must_use]
271+ pub fn config(&self) -> LimiterConfig {
272+ self.cfg
273+ }
274+
275+ #[must_use]
276+ pub fn ceiling_dbtp(&self) -> f32 {
277+ self.cfg.ceiling_dbtp
278+ }
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_db
284+ }
285+
286+ #[must_use]
287+ pub fn soft_gain_reduction_db(&self) -> f32 {
288+ self.last_soft_gr_db
289+ }
290+
291+ /// non-zero ⇒ soft tier didn't hold the ceiling and the brickwall engaged. routinely
292+ /// 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_db
297+ }
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.os
321+ || cfg.fir_taps != self.cfg.fir_taps
322+ || new_lookahead_samples_os != cur_lookahead_samples_os
323+ {
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::Applied
358+ }
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_peak
427+ } else {
428+ 1.0
429+ };
430+ env.process_gain(target)
431+ } else {
432+ 1.0
433+ };
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 do
440+ // 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_peak
448+ };
449+ let hard_target =
450+ if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {
451+ self.ceiling_lin / predicted_post_soft
452+ } else {
453+ 1.0
454+ };
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 enter
481+ // 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 nudging
493+ // 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_silent
503+ && self.silent_drain_remaining == 0
504+ && self.last_output_lin <= OUTPUT_IDLE_EPS
505+ && 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 == 0
553+ && self.last_peak_lin <= OUTPUT_IDLE_EPS
554+ && self.last_output_lin <= OUTPUT_IDLE_EPS
555+ && self.gain_state_idle()
556+ }
557+
558+ fn gain_state_idle(&self) -> bool {
559+ self.hold_remaining == 0
560+ && (self.hard_gain - 1.0).abs() <= GAIN_IDLE_EPS
561+ && self
562+ .soft_envelope
563+ .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 the
575+ // 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 should
599+ // 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, structural
609+ // 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 buffer
675+ 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+ out
709+ }
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 dBTP
720+ // 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 = out
742+ .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 ceiling
806+ // ----------------------------------------------------------------
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 to
812+ // 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 = out
830+ .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 fully
834+ // settled when the peak arrives), but it must be well under
835+ // 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 ideally
841+ // 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 loudness
851+ // ----------------------------------------------------------------
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 tier
926+ // demonstrably catches the transient before the hard tier
927+ // 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 with
953+ // a small ringing margin. Critically, the hard tier should
954+ // *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 envelope
960+ // hasn't ramped yet), then take its release time to recover.
961+ // We don't require zero hard engagement here — only that it
962+ // 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+ // Misc
972+ // ----------------------------------------------------------------
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 a
1011+ // 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+}
Added file: crates/headroom-dsp/src/oversample.rs+208 additions
@@ -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 * fc
18+ } 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+ h
31+}
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.factor
66+ }
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.factor
119+ }
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+ acc
135+ }
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 amplitude
170+ // 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 kernel
188+ // introduces ~6 dB attenuation by design (each pass contributes
189+ // half the gain). What we care about here is finite, bounded
190+ // 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+}
Added file: crates/headroom-dsp/src/sliding_max.rs+98 additions
@@ -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.window
27+ }
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+}
Added file: crates/headroom-dsp/src/util.rs+54 additions
@@ -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.0
11+ } 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.0
27+ } 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+}