atagen

headroom

public
Star0
Watch0
da174e47c3dfe446a07b3dd77fdfcd414277a44e
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git
crates/headroom-dsp/src/agc.rsRust183 lines4.9 KB
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
}