614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 1 | //! anti-zipper smoother toward the control thread's agc target. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 2 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 3 | use crate::util::{db_to_lin, time_to_alpha}; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 4 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 5 | #[derive(Debug, Clone, Copy, PartialEq)] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 6 | pub struct AgcGainConfig { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 7 | /// smoother tau (ms). small enough to chase a 50 ms control tick without zippering, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 8 | /// large enough not to itself act as a gain envelope. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 9 | pub anti_zipper_ms: f32, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 10 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 11 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 12 | impl Default for AgcGainConfig { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 13 | fn default() -> Self { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 14 | Self { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 15 | anti_zipper_ms: 5.0, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 16 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 17 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 18 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 19 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 20 | pub struct AgcGain { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 21 | cfg: AgcGainConfig, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 22 | sample_rate: f32, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 23 | target_db: f32, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 24 | current_db: f32, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 25 | alpha: f32, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 26 | enabled: bool, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 27 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 28 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 29 | impl AgcGain { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 30 | #[must_use] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 31 | pub fn new(cfg: AgcGainConfig, sample_rate: f32) -> Self { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 32 | Self { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 33 | cfg, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 34 | sample_rate, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 35 | target_db: 0.0, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 36 | current_db: 0.0, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 37 | alpha: time_to_alpha(cfg.anti_zipper_ms, sample_rate), |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 38 | enabled: true, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 39 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 40 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 41 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 42 | pub fn set_target_db(&mut self, db: f32) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 43 | if db.is_finite() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 44 | self.target_db = db; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 45 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 46 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 47 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 48 | /// disabling pushes target to 0 dB so an active boost/cut unwinds at the smoother rate |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 49 | /// rather than snapping. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 50 | pub fn set_enabled(&mut self, enabled: bool) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 51 | self.enabled = enabled; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 52 | if !enabled { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 53 | self.target_db = 0.0; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 54 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 55 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 56 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 57 | pub fn set_config(&mut self, cfg: AgcGainConfig) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 58 | self.cfg = cfg; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 59 | self.alpha = time_to_alpha(cfg.anti_zipper_ms, self.sample_rate); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 60 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 61 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 62 | #[must_use] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 63 | pub fn config(&self) -> AgcGainConfig { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 64 | self.cfg |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 65 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 66 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 67 | #[must_use] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 68 | pub fn current_db(&self) -> f32 { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 69 | self.current_db |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 70 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 71 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 72 | #[must_use] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 73 | pub fn target_db(&self) -> f32 { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 74 | self.target_db |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 75 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 76 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 77 | #[must_use] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 78 | pub fn enabled(&self) -> bool { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 79 | self.enabled |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 80 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 81 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 82 | pub fn process_frame(&mut self, l: f32, r: f32) -> (f32, f32) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 83 | self.current_db += self.alpha * (self.target_db - self.current_db); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 84 | if l == 0.0 && r == 0.0 { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 85 | return (0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 86 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 87 | let gain = db_to_lin(self.current_db); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 88 | (l * gain, r * gain) |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 89 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 90 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 91 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 92 | #[cfg(test)] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 93 | mod tests { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 94 | use super::*; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 95 | use crate::util::lin_to_db; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 96 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 97 | const SR: f32 = 48_000.0; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 98 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 99 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 100 | fn unity_at_zero_db() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 101 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 102 | for _ in 0..100 { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 103 | let (l, r) = agc.process_frame(0.5, -0.3); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 104 | assert!((l - 0.5).abs() < 1e-6); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 105 | assert!((r - -0.3).abs() < 1e-6); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 106 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 107 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 108 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 109 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 110 | fn smooths_toward_target() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 111 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 112 | agc.set_target_db(6.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 113 | // After ~5 ms (one anti-zipper tau), current_db should be in |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 114 | // the ~63% region. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 115 | let samples = (0.005 * SR) as usize; |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 116 | for _ in 0..samples { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 117 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 118 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 119 | let cur = agc.current_db(); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 120 | assert!( |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 121 | (cur - 6.0 * 0.63).abs() < 0.5, |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 122 | "expected ~3.8 dB after one tau, got {cur}" |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 123 | ); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 124 | // Settle. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 125 | for _ in 0..(SR as usize) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 126 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 127 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 128 | assert!((agc.current_db() - 6.0).abs() < 0.01); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 129 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 130 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 131 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 132 | fn applies_gain_to_signal() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 133 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 134 | agc.set_target_db(6.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 135 | // Run long enough to settle. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 136 | for _ in 0..(SR as usize) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 137 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 138 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 139 | let (l, r) = agc.process_frame(0.5, 0.5); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 140 | // +6 dB = factor of ~2.0. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 141 | assert!((l / 0.5 - 2.0).abs() < 0.05, "got {l}"); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 142 | assert!((r / 0.5 - 2.0).abs() < 0.05); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 143 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 144 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 145 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 146 | fn disable_unwinds_back_to_unity() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 147 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 148 | agc.set_target_db(6.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 149 | for _ in 0..(SR as usize) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 150 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 151 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 152 | assert!((agc.current_db() - 6.0).abs() < 0.01); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 153 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 154 | agc.set_enabled(false); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 155 | for _ in 0..(SR as usize) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 156 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 157 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 158 | assert!(agc.current_db().abs() < 0.01, "got {}", agc.current_db()); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 159 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 160 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 161 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 162 | fn rejects_non_finite_target() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 163 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 164 | agc.set_target_db(3.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 165 | agc.set_target_db(f32::NAN); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 166 | assert!((agc.target_db() - 3.0).abs() < 1e-6); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 167 | agc.set_target_db(f32::INFINITY); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 168 | assert!((agc.target_db() - 3.0).abs() < 1e-6); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 169 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 170 | |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 171 | #[test] |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 172 | fn lin_round_trip_check() { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 173 | // Sanity: after settling, gain at target_db should produce |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 174 | // peak that matches lin_to_db. |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 175 | let mut agc = AgcGain::new(AgcGainConfig::default(), SR); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 176 | agc.set_target_db(-6.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 177 | for _ in 0..(SR as usize) { |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 178 | let _ = agc.process_frame(0.0, 0.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 179 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 180 | let (l, _) = agc.process_frame(1.0, 1.0); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 181 | assert!((lin_to_db(l) - -6.0).abs() < 0.05); |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 182 | } |
614b4e43f9f3 | atagen | 2026-05-27 15:49 UTC | 183 | } |