| 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 | } |