From 1b9c188aae528df82995f7d5bfd89b36672a832a Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Fri, 1 May 2026 12:47:01 +1200 Subject: [PATCH 01/16] refactor: Modernise AGC state management and improve gain control stability - Replace coefficient-based `attack/release` with direct `Duration` types - Reduce `RMS_WINDOW_SIZE` from `8192` to `512` samples to lower latency - Switch RMS calculation from mean-based buffer (`CircularBuffer`) to sum-of-squares approach in `CircularBufferRMS` for accurate root-mean-square values - Introduce `SlowDownState` struct that manages timing and caching: counts samples in 2ms blocks, computes adaptive `slowdown_factor` using `compute_slowdown_factor` and caches the result for reuse - Implement `fast_exp` using Horner's method for efficient exponential approximation of release coefficients (third-order Taylor polynomial) - Add `NaN` handling in RMS calculation to prevent invalid values - Add rate limiting to gain changes: clamp gain change per sample based on dynamic attack/release duration to prevent overshooting - Add new `peak_tracking_window` setting to control peak level smoothing - Tune default timing parameters: 500ms attack, 0.5ms release, 10ms peak tracking window for balanced behaviour --- src/source/agc.rs | 462 ++++++++++++++++++++++++++++++---------------- src/source/mod.rs | 1 + 2 files changed, 301 insertions(+), 162 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 0d10a517b..6431a7516 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -3,20 +3,24 @@ // Designed by @UnknownSuperficialNight // // Features: -// • Adaptive peak detection -// • RMS-based level estimation -// • Asymmetric attack/release -// • RMS-based general adjustments with peak limiting +// • Adaptive peak detection with exponential smoothing (EMA) +// • O(1) RMS level estimation via circular buffer +// • Combined RMS and peak limiting with adaptive slowdown +// • Asymmetric attack/release with per-sample clamping +// • Configurable floor value for minimum gain threshold +// • Atomic operations support (experimental) +// • Fast release coefficient via 3rd‑order Taylor approximation (evaluated with Horner's method) +// • Power-of-two window sizing for efficiency // -// Optimized for smooth and responsive gain control +// Optimised for smooth and responsive gain control // // Crafted with love. Enjoy! :) // -use std::time::Duration; - use super::{SeekError, SpanTracker}; -use crate::{math::duration_to_coefficient, ChannelCount, Float, Sample, SampleRate, Source}; +use crate::math::{duration_to_coefficient, duration_to_float}; +use crate::{ChannelCount, Float, Sample, SampleRate, Source}; +use std::time::Duration; #[cfg(feature = "tracing")] use tracing; @@ -46,9 +50,20 @@ const fn power_of_two(n: usize) -> usize { n } +/// Divide `a` by `b` unless `b` is NaN, infinite, or <= 0, +/// in which case `fallback` is returned. +#[inline(always)] +fn div_or_fallback(a: Float, b: Float, fallback: Float) -> Float { + if b.is_finite() && b > 0.0 { + a / b + } else { + fallback + } +} + /// Size of the circular buffer used for RMS calculation. /// A larger size provides more stable RMS values but increases latency. -const RMS_WINDOW_SIZE: usize = power_of_two(8192); +const RMS_WINDOW_SIZE: usize = power_of_two(512); /// Settings for the Automatic Gain Control (AGC). /// @@ -68,15 +83,21 @@ pub struct AutomaticGainControlSettings { /// Maximum allowable gain multiplication to prevent excessive amplification. /// This acts as a safety limit to avoid distortion from over-amplification. pub absolute_max_gain: Float, + /// Duration of the peak tracking smoothing window. + /// Controls how much peak level measurements are smoothed before being used for gain calculation. + /// Larger values provide more stable peak detection but add latency to peak tracking. + /// Smaller values respond faster to sudden peaks but may allow more transient clipping. + pub peak_tracking_window: Duration, } impl Default for AutomaticGainControlSettings { fn default() -> Self { AutomaticGainControlSettings { - target_level: 1.0, // Default to original level - attack_time: Duration::from_secs(4), // Recommended attack time - release_time: Duration::from_secs(0), // Recommended release time - absolute_max_gain: 7.0, // Recommended max gain + target_level: 1.0, // Default to original level + attack_time: Duration::from_millis(500), // Recommended attack time + release_time: Duration::from_nanos(500000), // Recommended release time + absolute_max_gain: 7.0, // Recommended max gain + peak_tracking_window: Duration::from_millis(10), // Recommended peak tracking window for balanced stability and responsiveness } } } @@ -89,18 +110,29 @@ impl Default for AutomaticGainControlSettings { #[derive(Clone, Debug)] pub struct AutomaticGainControl { input: I, + + // Core gain values target_level: Arc, floor: Float, absolute_max_gain: Arc, - attack_time: Duration, - release_time: Duration, + peak_tracking_window: Duration, current_gain: Float, - attack_coeff: Arc, - release_coeff: Arc, + + // Timing parameters + attack_duration: Arc, + release_duration: Arc, + + // Signal analysis state peak_level: Float, - rms_window: CircularBuffer, + release_coefficient: Float, + rms_window: CircularBufferRMS, + + // Control flags is_enabled: Arc, span: SpanTracker, + + // Slowdown tracking + slow_down_state: SlowDownState, } #[cfg(not(feature = "experimental"))] @@ -111,74 +143,173 @@ pub struct AutomaticGainControl { #[derive(Clone, Debug)] pub struct AutomaticGainControl { input: I, + + // Core gain values target_level: Float, floor: Float, absolute_max_gain: Float, - attack_time: Duration, - release_time: Duration, + peak_tracking_window: Duration, current_gain: Float, - attack_coeff: Float, - release_coeff: Float, + + // Timing parameters + attack_duration: Float, + release_duration: Float, + + // Signal analysis state peak_level: Float, - rms_window: CircularBuffer, + release_coefficient: Float, + rms_window: CircularBufferRMS, + + // Control flags is_enabled: bool, span: SpanTracker, + + // Slowdown tracking + slow_down_state: SlowDownState, +} + +/// State for adaptive slowdown of gain changes. +/// +/// This struct holds the state for managing the slowdown of gain changes based on signal conditions. +/// The `slowdown_factor` determines how quickly or slowly the gain can change: +/// - When the signal is quiet and we're close to target, changes are allowed normally +/// - When the signal peaks significantly, changes are slowed down exponentially +/// - This prevents abrupt loudness jumps during automatic gain control adjustments. +#[derive(Clone, Debug)] +struct SlowDownState { + block_size: usize, + sample_counter: usize, + slowdown_factor: Float, +} + +impl SlowDownState { + #[inline] + fn new(sample_rate: SampleRate) -> Self { + // Calculate and cache block size based on sample rate + let block_size = (sample_rate.get() as usize / 1000) * 2; // 2ms blocks + + Self { + block_size, + sample_counter: 0, + slowdown_factor: 0.0, + } + } + + #[inline] + fn increment_sample_counter(&mut self) { + self.sample_counter = (self.sample_counter + 1) % self.block_size; + } + + /// Computes the slowdown factor for adaptive gain changes. + /// + /// The slowdown factor determines how quickly or slowly the gain can change based on the current signal conditions. + /// - When the desired gain is close to the current gain, the slowdown factor increases, preventing abrupt loudness jumps during automatic gain control adjustments. + /// - When the signal deviates significantly from the target, the slowdown factor remains high to maintain stability. + #[inline] + fn compute_slowdown_factor( + &mut self, + desired_gain: Float, + current_gain: Float, + rms: Float, + peak_level: Float, + ) { + // Calculate the absolute difference between the desired gain and the current gain + let distance_from_target = (desired_gain - current_gain).abs(); + + // Calculate the maximum distance as the sum of RMS and peak level + let max_distance = rms + peak_level; + + // Normalise distance clamped between [0,1] with a fallback of 1.0 + let normalise_distance = div_or_fallback(distance_from_target, max_distance, 1.0).min(1.0); + + // Compute the exponential slowdown factor based on the normalised distance + // The multiplier is scaled by the square root of the sum of peak level and RMS + let exp_multiplier = 10.0 * (peak_level + rms).sqrt(); + let exp_slowdown = fast_exp(1.0 + exp_multiplier * (1.0 - normalise_distance)); + + // Create a mask that is 1.0 if the distance is within the max_distance, otherwise 0.0 + // This mask is used to blend the exponential slowdown factor with a linear factor + let mask = ((max_distance - distance_from_target).max(0.0) / max_distance).min(1.0); + + // Blend the slowdown factor: when mask=1 use exp_slowdown, else 1.0 + // This ensures that the slowdown factor increases when the signal deviates from the target + self.slowdown_factor = 1.0 + mask * (exp_slowdown - 1.0); + } } -/// A circular buffer for efficient RMS calculation over a sliding window. +/// A circular buffer optimised for RMS calculation over a sliding window. /// -/// This structure allows for constant-time updates and mean calculations, -/// which is crucial for real-time audio processing. +/// Maintains a running sum of squares with O(1) updates and retrieval, +/// avoiding the need to scan stored samples for mean calculations. #[derive(Clone, Debug)] -struct CircularBuffer { +struct CircularBufferRMS { buffer: Box<[Float; RMS_WINDOW_SIZE]>, - sum: Float, + sum_of_squares: Float, index: usize, } -impl CircularBuffer { - /// Creates a new `CircularBuffer` with a fixed size determined at compile time. +impl CircularBufferRMS { + /// Creates a new `CircularBufferRMS` with a fixed size determined at compile time. + /// + /// The buffer size is `RMS_WINDOW_SIZE`, chosen as a power of two for + /// efficient modulo operations using bitwise arithmetic. #[inline] fn new() -> Self { - CircularBuffer { + CircularBufferRMS { buffer: Box::new([0.0; RMS_WINDOW_SIZE]), - sum: 0.0, + sum_of_squares: 0.0, index: 0, } } - /// Pushes a new value into the buffer and returns the old value. + /// Adds a sample to the buffer and updates the running sum of squares. /// - /// This method maintains a running sum for efficient mean calculation. + /// Maintains an incremental sum of squares for O(1) RMS computation + /// without recalculating from stored samples. #[inline] - fn push(&mut self, value: Float) -> Float { + fn push(&mut self, value: Float) { let old_value = self.buffer[self.index]; - // Update the sum by first subtracting the old value and then adding the new value; this is more accurate. - self.sum = self.sum - old_value + value; + // Update the sum of squares by subtracting the square of the old value and adding the square of the new value. + self.sum_of_squares = (self.sum_of_squares - (old_value * old_value)) + (value * value); self.buffer[self.index] = value; - // Use bitwise AND for efficient index wrapping since RMS_WINDOW_SIZE is a power of two. + // Use bitwise for efficient index wrapping since RMS_WINDOW_SIZE is a power of two. self.index = (self.index + 1) & (RMS_WINDOW_SIZE - 1); - old_value } - /// Calculates the mean of all values in the buffer. + /// Calculate the RMS (Root Mean Square) value of all values in the buffer. /// - /// This operation is `O(1)` due to the maintained running sum. + /// RMS provides a measure of the signal's effective or average magnitude. #[inline] - fn mean(&self) -> Float { - self.sum / RMS_WINDOW_SIZE as Float + fn rms(&self) -> Float { + (self.sum_of_squares / RMS_WINDOW_SIZE as Float).sqrt() } } +/// Fast approximation of `exp(x)` using Horner's Method for Polynomial Evaluation. +/// This function approximates the exponential function by evaluating the +/// third-order Taylor polynomial using Horner's scheme, which reduces the +/// number of multiplications and improves numerical stability. +/// +/// This approximation is valid for small values of `x` (near zero) and is +/// used in the AGC algorithm to efficiently compute the release coefficient. +/// It provides a good balance between speed and accuracy, resulting in +/// faster benchmark times compared to the standard `exp` function. +#[inline] +fn fast_exp(x: Float) -> Float { + // Horner's method: 1 + x*(1 + x*(0.5 + x/6)) + 1.0 + x * (1.0 + x * (0.5 + x / 6.0)) +} + /// Constructs an `AutomaticGainControl` object with specified parameters. /// /// # Arguments /// -/// * `input` - The input audio source -/// * `target_level` - The desired output level -/// * `attack_time` - Time constant for gain increase -/// * `release_time` - Time constant for gain decrease -/// * `absolute_max_gain` - Maximum allowable gain +/// `input` - The input audio source +/// `target_level` - The desired output level +/// `attack_time` - Time constant for gain increase +/// `release_time` - Time constant for gain decrease +/// `absolute_max_gain` - Maximum allowable gain +/// `peak_tracking_window` - Duration over which to track peak level #[inline] pub(crate) fn automatic_gain_control( input: I, @@ -186,13 +317,16 @@ pub(crate) fn automatic_gain_control( attack_time: Duration, release_time: Duration, absolute_max_gain: Float, + peak_tracking_window: Duration, ) -> AutomaticGainControl where I: Source, { let sample_rate = input.sample_rate(); - let attack_coeff = duration_to_coefficient(attack_time, sample_rate); - let release_coeff = duration_to_coefficient(release_time, sample_rate); + let attack_duration = duration_to_float(attack_time); + let release_duration = duration_to_float(release_time); + + let release_coefficient = duration_to_coefficient(peak_tracking_window, sample_rate); #[cfg(feature = "experimental")] { @@ -202,15 +336,16 @@ where target_level: Arc::new(AtomicFloat::new(target_level)), floor: 0.0, absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), - attack_time, - release_time, + peak_tracking_window, current_gain: 1.0, - attack_coeff: Arc::new(AtomicFloat::new(attack_coeff)), - release_coeff: Arc::new(AtomicFloat::new(release_coeff)), - peak_level: 0.0, - rms_window: CircularBuffer::new(), + attack_duration: Arc::new(AtomicFloat::new(attack_duration)), + release_duration: Arc::new(AtomicFloat::new(release_duration)), + peak_level: 0.7, + release_coefficient, + rms_window: CircularBufferRMS::new(), is_enabled: Arc::new(AtomicBool::new(true)), span: SpanTracker::new(sample_rate, channels), + slow_down_state: SlowDownState::new(sample_rate), } } @@ -222,15 +357,16 @@ where target_level, floor: 0.0, absolute_max_gain, - attack_time, - release_time, + peak_tracking_window, current_gain: 1.0, - attack_coeff, - release_coeff, - peak_level: 0.0, - rms_window: CircularBuffer::new(), + attack_duration, + release_duration, + peak_level: 0.7, + release_coefficient, + rms_window: CircularBufferRMS::new(), is_enabled: true, span: SpanTracker::new(sample_rate, channels), + slow_down_state: SlowDownState::new(sample_rate), } } } @@ -264,26 +400,26 @@ where } #[inline] - fn attack_coeff(&self) -> Float { + fn attack_duration(&self) -> Float { #[cfg(feature = "experimental")] { - self.attack_coeff.load(Ordering::Relaxed) + self.attack_duration.load(Ordering::Relaxed) } #[cfg(not(feature = "experimental"))] { - self.attack_coeff + self.attack_duration } } #[inline] - fn release_coeff(&self) -> Float { + fn release_duration(&self) -> Float { #[cfg(feature = "experimental")] { - self.release_coeff.load(Ordering::Relaxed) + self.release_duration.load(Ordering::Relaxed) } #[cfg(not(feature = "experimental"))] { - self.release_coeff + self.release_duration } } @@ -329,8 +465,8 @@ where /// Note: if the sample rate or channel count changes, any value set through this handle will /// be overwritten with the attack time that this AGC was constructed with. #[inline] - pub fn get_attack_coeff(&self) -> Arc { - Arc::clone(&self.attack_coeff) + pub fn get_attack_duration(&self) -> Arc { + Arc::clone(&self.attack_duration) } #[cfg(feature = "experimental")] @@ -343,8 +479,8 @@ where /// Note: if the sample rate or channel count changes, any value set through this handle will /// be overwritten with the release time that this AGC was constructed with. #[inline] - pub fn get_release_coeff(&self) -> Arc { - Arc::clone(&self.release_coeff) + pub fn get_release_duration(&self) -> Arc { + Arc::clone(&self.release_duration) } #[cfg(feature = "experimental")] @@ -388,22 +524,26 @@ where self.floor = floor.unwrap_or(0.0); } - /// Updates the peak level using instant attack and slow release behaviour + /// Updates the peak level using exponential smoothing (EMA) to blend the current + /// value toward the previous level using the release coefficient, then taking + /// the maximum of the current sample. /// - /// This method uses instant response (0.0 coefficient) when the signal is increasing - /// and the release coefficient when the signal is decreasing, providing - /// appropriate tracking behaviour for peak detection. + /// This provides a stable peak measurement that doesn't react to every sample, + /// preventing excessive gain adjustments when the signal is momentarily loud. + /// The peak serves as an absolute maximum safeguard to prevent output clipping + /// even when RMS-based gain calculations suggest aggressive amplification. #[inline] - fn update_peak_level(&mut self, sample_value: Sample, release_coeff: Float) { - let coeff = if sample_value > self.peak_level { - // Fast attack for rising peaks - 0.0 - } else { - // Slow release for falling peaks - release_coeff - }; - - self.peak_level = self.peak_level * coeff + sample_value * (1.0 - coeff); + fn update_peak_level(&mut self, sample_value: Float, release_coefficient: Float) { + // Compute the exponentially smoothed estimate of the previous peak level. + // The EMA smooths peak tracking over time, preventing sudden jumps when + // loud transients occur, which would otherwise cause extreme gain reductions. + let peak_release = + self.peak_level * release_coefficient + sample_value * (1.0 - release_coefficient); + + // Take maximum to ensure the peak is always an upper bound. + // This guarantees that peak_level never decreases below the current sample, + // preserving the safety mechanism against clipping. + self.peak_level = sample_value.max(peak_release); } /// Updates the RMS (Root Mean Square) level using a circular buffer approach. @@ -411,21 +551,14 @@ where /// providing a measure of the signal's average power over time. #[inline] fn update_rms(&mut self, sample_value: Sample) -> Float { - let squared_sample = sample_value * sample_value; - self.rms_window.push(squared_sample); - self.rms_window.mean().sqrt() - } + self.rms_window.push(sample_value); - /// Calculate gain adjustments based on peak levels - /// This method determines the appropriate gain level to apply to the audio - /// signal, considering the peak level. - /// The peak level helps prevent sudden spikes in the output signal. - #[inline] - fn calculate_peak_gain(&self, target_level: Float, absolute_max_gain: Float) -> Float { - if self.peak_level > 0.0 { - (target_level / self.peak_level).min(absolute_max_gain) + // Calculate RMS safely + let rms = self.rms_window.rms(); + if rms.is_nan() || rms <= 0.0 { + 0.0 // Default to 0 if RMS is invalid } else { - absolute_max_gain + rms } } @@ -434,72 +567,87 @@ where // Cache atomic loads at the start - avoids repeated atomic operations let target_level = self.target_level(); let absolute_max_gain = self.absolute_max_gain(); - let attack_coeff = self.attack_coeff(); - let release_coeff = self.release_coeff(); + let attack_time_in_seconds = self.attack_duration(); + let release_duration = self.release_duration(); + let sample_rate = self.sample_rate().get() as Float; // Sample rate in Hz // Convert the sample to its absolute float value for level calculations + // We use abs() to work with signal magnitude regardless of polarity + // This is crucial because RMS and peak detection care about energy, + // not whether the signal is positive or negative let sample_value = sample.abs(); + // Increment the sample counter + self.slow_down_state.increment_sample_counter(); + // Dynamically adjust peak level using cached release coefficient - self.update_peak_level(sample_value, release_coeff); + self.update_peak_level(sample_value, self.release_coefficient); // Calculate the current RMS (Root Mean Square) level using a sliding window approach let rms = self.update_rms(sample_value); - // Compute the gain adjustment required to reach the target level based on RMS - let rms_gain = if rms > 0.0 { - target_level / rms - } else { - absolute_max_gain // Default to max gain if RMS is zero - }; + // Compute the gain adjustment required to reach the adjusted target level + let rms_gain = div_or_fallback(target_level, rms, 1.0); + + // Calculate gain adjustments based on peak levels + // We divide target_level by peak_level to find the gain multiplier needed + // to scale the signal's peaks to match the target. If peak_level is high + // (loud signal), this gives us a gain < 1.0 (attenuation). If peak_level + // is low (quiet signal), this gives us a gain > 1.0 (amplification). + // The peak level acts as a safety mechanism to prevent output spikes + // that could exceed the target level. + let peak_gain = div_or_fallback(target_level, self.peak_level, 1.0).min(absolute_max_gain); + + // Combine RMS and peak gains by taking the minimum. We use min() because + // we need to choose a single gain value that respects both constraints. + // Think of it like this: RMS gain might suggest "amplify by 5x" based on + // average signal level, but peak gain might suggest "attenuate by 0.5x" + // to prevent output spikes. Since these goals conflict (amplify vs reduce), + // we pick the more conservative one: min() selects 0.5x (attenuation) over + // 5x (amplification). This ensures we don't blindly amplify and risk + // output spikes, even when the average signal seems quiet. + // Then we apply the floor to ensure we never drop below the minimum allowed gain. + let desired_gain = rms_gain.min(peak_gain).max(self.floor); - // Calculate the peak limiting gain - let peak_gain = self.calculate_peak_gain(target_level, absolute_max_gain); + if self.slow_down_state.sample_counter == 0 { + self.slow_down_state.compute_slowdown_factor( + desired_gain, + self.current_gain, + rms, + self.peak_level, + ); + } - // Use RMS for general adjustments, but limit by peak gain to prevent clipping and apply a minimum floor value - let desired_gain = rms_gain.min(peak_gain).max(self.floor); + let dynamic_attack_time = attack_time_in_seconds * self.slow_down_state.slowdown_factor; + + // Calculate max gain change per sample based on dynamic attack/release times + let max_attack_gain_change_per_sample = 1.0 / (dynamic_attack_time * sample_rate); + let max_release_gain_change_per_sample = 1.0 / (release_duration * sample_rate); + + // Determine gain difference + let gain_diff = desired_gain - self.current_gain; - // Adaptive attack/release speed for AGC (Automatic Gain Control) - // - // This mechanism implements an asymmetric approach to gain adjustment: - // 1. **Slow increase**: Prevents abrupt amplification of noise during quiet periods. - // 2. **Fast decrease**: Rapidly attenuates sudden loud signals to avoid distortion. - // - // The asymmetry is crucial because: - // - Gradual gain increases sound more natural and less noticeable to listeners. - // - Quick gain reductions are necessary to prevent clipping and maintain audio quality. - // - // This approach addresses several challenges associated with high attack times: - // 1. **Slow response**: With a high attack time, the AGC responds very slowly to changes in input level. - // This means it takes longer for the gain to adjust to new signal levels. - // 2. **Initial gain calculation**: When the audio starts or after a period of silence, the initial gain - // calculation might result in a very high gain value, especially if the input signal starts quietly. - // 3. **Overshooting**: As the gain slowly increases (due to the high attack time), it might overshoot - // the desired level, causing the signal to become too loud. - // 4. **Overcorrection**: The AGC then tries to correct this by reducing the gain, but due to the slow response, - // it might reduce the gain too much, causing the sound to drop to near-zero levels. - // 5. **Slow recovery**: Again, due to the high attack time, it takes a while for the gain to increase - // back to the appropriate level. - // - // By using a faster release time for decreasing gain, we can mitigate these issues and provide - // more responsive control over sudden level increases while maintaining smooth gain increases. - let attack_speed = if desired_gain > self.current_gain { - attack_coeff + // Clamp gain change based on attack or release phase + let gain_change = if gain_diff > 0.0 { + // Attack phase: Clamp the gain change to the maximum allowed per sample + gain_diff.clamp(0.0, max_attack_gain_change_per_sample) } else { - release_coeff + // Release phase: Clamp the gain change to the maximum allowed per sample + gain_diff.clamp(-max_release_gain_change_per_sample, 0.0) }; - // Gradually adjust the current gain towards the desired gain for smooth transitions - self.current_gain = self.current_gain * attack_speed + desired_gain * (1.0 - attack_speed); + // Update current gain + self.current_gain += gain_change; - // Ensure the calculated gain stays within the defined operational range - self.current_gain = self.current_gain.clamp(0.1, absolute_max_gain); - - // Output current gain value for developers to fine tune their inputs to automatic_gain_control #[cfg(feature = "tracing")] - tracing::debug!("AGC gain: {}", self.current_gain,); + if self.slow_down_state.sample_counter == 0 { + tracing::debug!( + "RMS: {:.4}, Peak: {:.4}, Desired Gain: {:.4}, Current Gain: {:.4}, Release Coefficient: {}, Attack Time: {:.4}", + rms, self.peak_level, desired_gain, self.current_gain, self.release_coefficient, dynamic_attack_time, + ); + } - // Apply the computed gain to the input sample and return the result + // Apply gain to sample and return sample * self.current_gain } @@ -526,24 +674,14 @@ where if detection.at_span_boundary && detection.parameters_changed { let current_sample_rate = self.input.sample_rate(); + // Recalculate coefficients for new sample rate - #[cfg(feature = "experimental")] - { - let attack_coeff = duration_to_coefficient(self.attack_time, current_sample_rate); - let release_coeff = duration_to_coefficient(self.release_time, current_sample_rate); - self.attack_coeff.store(attack_coeff, Ordering::Relaxed); - self.release_coeff.store(release_coeff, Ordering::Relaxed); - } - #[cfg(not(feature = "experimental"))] - { - self.attack_coeff = duration_to_coefficient(self.attack_time, current_sample_rate); - self.release_coeff = - duration_to_coefficient(self.release_time, current_sample_rate); - } + self.release_coefficient = + duration_to_coefficient(self.peak_tracking_window, current_sample_rate); // Reset RMS window to avoid mixing samples from different parameter sets - self.rms_window = CircularBuffer::new(); - self.peak_level = 0.0; + self.rms_window = CircularBufferRMS::new(); + self.peak_level = 0.7; self.current_gain = 1.0; } diff --git a/src/source/mod.rs b/src/source/mod.rs index 63d5233e9..8e4298673 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -438,6 +438,7 @@ pub trait Source: Iterator { attack_time_limited, release_time_limited, agc_settings.absolute_max_gain, + agc_settings.peak_tracking_window, ) } From 5e1a8696c309f88b001695e3defcf023e4fdf76d Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Fri, 1 May 2026 16:03:39 +1200 Subject: [PATCH 02/16] fix: Use `current_gain` as fallback during silence in AGC `rms_gain` calculation - Replace hardcoded `1.0` fallback with `self.current_gain` when `RMS` equals `0.0` - Add comment explaining this keeps gain stable or allows gradual decay instead of sudden drops --- src/source/agc.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 6431a7516..9d0af3b0a 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -587,7 +587,10 @@ where let rms = self.update_rms(sample_value); // Compute the gain adjustment required to reach the adjusted target level - let rms_gain = div_or_fallback(target_level, rms, 1.0); + // When rms is 0.0 (silence), we fall back to current_gain as the default + // This keeps the gain stable during silence without any hard reset + // The gain will only change gradually when peaks occur or signal returns + let rms_gain = div_or_fallback(target_level, rms, self.current_gain); // Calculate gain adjustments based on peak levels // We divide target_level by peak_level to find the gain multiplier needed From 233ba470806ef9db1b2a26f0e468d776f327fe6e Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Fri, 1 May 2026 20:00:15 +1200 Subject: [PATCH 03/16] fix: clamp peak_level to 1.0 to prevent decoder artifacts - Cap peak tracking at 1.0 to handle out-of-bounds decoder samples - Ensure samples from decoders that are not normalised like `libopus` do not track out-of-bounds values --- src/source/agc.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 9d0af3b0a..b5e54d35b 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -543,7 +543,8 @@ where // Take maximum to ensure the peak is always an upper bound. // This guarantees that peak_level never decreases below the current sample, // preserving the safety mechanism against clipping. - self.peak_level = sample_value.max(peak_release); + // Set an upper bound to prevent tracking out-of-bounds samples from decoders like libopus + self.peak_level = sample_value.max(peak_release).min(1.0); } /// Updates the RMS (Root Mean Square) level using a circular buffer approach. From c5020953c8e2efd777f66bf15ee2dd57226e7c2d Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sun, 3 May 2026 09:50:45 +1200 Subject: [PATCH 04/16] fix: clamp `rms` to `1.0` to prevent decoder artifacts - Cap rms tracking at 1.0 to handle out-of-bounds decoder samples - Ensure samples from decoders that are not normalised like `libopus` do not track out-of-bounds values --- src/source/agc.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index b5e54d35b..52a3537cb 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -281,7 +281,9 @@ impl CircularBufferRMS { /// RMS provides a measure of the signal's effective or average magnitude. #[inline] fn rms(&self) -> Float { - (self.sum_of_squares / RMS_WINDOW_SIZE as Float).sqrt() + (self.sum_of_squares / RMS_WINDOW_SIZE as Float) + .sqrt() + .min(1.0) } } From 5b256a1eb1c2ea6531d51161b342b62ca0edf452 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Wed, 6 May 2026 16:01:34 +1200 Subject: [PATCH 05/16] chore: Increase `RMS_WINDOW_SIZE` to `1024` samples - Change `RMS_WINDOW_SIZE` constant from `512` to `1024` - 1024 samples provides ~23ms window at 44.1kHz / ~21ms at 48kHz for stable RMS estimation --- src/source/agc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 52a3537cb..4ebd315a3 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -63,7 +63,7 @@ fn div_or_fallback(a: Float, b: Float, fallback: Float) -> Float { /// Size of the circular buffer used for RMS calculation. /// A larger size provides more stable RMS values but increases latency. -const RMS_WINDOW_SIZE: usize = power_of_two(512); +const RMS_WINDOW_SIZE: usize = power_of_two(1024); /// Settings for the Automatic Gain Control (AGC). /// From c98b7816e28fe32f742d838190811fd8d63f1ac0 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Fri, 15 May 2026 22:37:50 +1200 Subject: [PATCH 06/16] refactor: Move `fast_exp` from `AGC` to shared `math` module - Extract `fast_exp` function from `agc.rs` to `math.rs` - Export `fast_exp` as `pub(crate)` for reuse across the codebase - Update imports in `agc.rs` to use the shared `fast_exp` --- src/math.rs | 15 +++++++++++++++ src/source/agc.rs | 17 +---------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/math.rs b/src/math.rs index 5a1fa2794..a4804838f 100644 --- a/src/math.rs +++ b/src/math.rs @@ -154,6 +154,21 @@ pub(crate) fn duration_from_secs(secs: Float) -> Duration { } } +/// Fast approximation of `exp(x)` using Horner's Method for Polynomial Evaluation. +/// This function approximates the exponential function by evaluating the +/// third-order Taylor polynomial using Horner's scheme, which reduces the +/// number of multiplications and improves numerical stability. +/// +/// This approximation is valid for small values of `x` (near zero) and is +/// used in the AGC algorithm to efficiently compute the release coefficient. +/// It provides a good balance between speed and accuracy, resulting in +/// faster benchmark times compared to the standard `exp` function. +#[inline] +pub(crate) fn fast_exp(x: Float) -> Float { + // Horner's method: 1 + x*(1 + x*(0.5 + x/6)) + 1.0 + x * (1.0 + x * (0.5 + x / 6.0)) +} + /// Utility macro for getting a `NonZero` from a literal. Especially /// useful for passing in `ChannelCount` and `Samplerate`. /// Equivalent to: `const { core::num::NonZero::new($n).unwrap() }` diff --git a/src/source/agc.rs b/src/source/agc.rs index 4ebd315a3..d40813277 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -18,7 +18,7 @@ // use super::{SeekError, SpanTracker}; -use crate::math::{duration_to_coefficient, duration_to_float}; +use crate::math::{duration_to_coefficient, duration_to_float, fast_exp}; use crate::{ChannelCount, Float, Sample, SampleRate, Source}; use std::time::Duration; @@ -287,21 +287,6 @@ impl CircularBufferRMS { } } -/// Fast approximation of `exp(x)` using Horner's Method for Polynomial Evaluation. -/// This function approximates the exponential function by evaluating the -/// third-order Taylor polynomial using Horner's scheme, which reduces the -/// number of multiplications and improves numerical stability. -/// -/// This approximation is valid for small values of `x` (near zero) and is -/// used in the AGC algorithm to efficiently compute the release coefficient. -/// It provides a good balance between speed and accuracy, resulting in -/// faster benchmark times compared to the standard `exp` function. -#[inline] -fn fast_exp(x: Float) -> Float { - // Horner's method: 1 + x*(1 + x*(0.5 + x/6)) - 1.0 + x * (1.0 + x * (0.5 + x / 6.0)) -} - /// Constructs an `AutomaticGainControl` object with specified parameters. /// /// # Arguments From a54f9b6505f1b31d9de24ede456cb17977de012d Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Fri, 15 May 2026 23:57:09 +1200 Subject: [PATCH 07/16] =?UTF-8?q?chore:=20Use=20`from=5Fmicros`=20for=20`5?= =?UTF-8?q?00=C2=B5s`=20release=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/source/agc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index d40813277..40f385b7b 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -95,7 +95,7 @@ impl Default for AutomaticGainControlSettings { AutomaticGainControlSettings { target_level: 1.0, // Default to original level attack_time: Duration::from_millis(500), // Recommended attack time - release_time: Duration::from_nanos(500000), // Recommended release time + release_time: Duration::from_micros(500), // Recommended release time absolute_max_gain: 7.0, // Recommended max gain peak_tracking_window: Duration::from_millis(10), // Recommended peak tracking window for balanced stability and responsiveness } From 110030f5bee0f9c3603c12293a62ebe15e9bd2d4 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:07:24 +1200 Subject: [PATCH 08/16] Revert "fix: clamp `rms` to `1.0` to prevent decoder artifacts" This reverts commit c5020953c8e2efd777f66bf15ee2dd57226e7c2d. --- src/source/agc.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 40f385b7b..cb6ce00b6 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -281,9 +281,7 @@ impl CircularBufferRMS { /// RMS provides a measure of the signal's effective or average magnitude. #[inline] fn rms(&self) -> Float { - (self.sum_of_squares / RMS_WINDOW_SIZE as Float) - .sqrt() - .min(1.0) + (self.sum_of_squares / RMS_WINDOW_SIZE as Float).sqrt() } } From 18987608644932add64ff126eb59605db1e21a68 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:08:34 +1200 Subject: [PATCH 09/16] Revert "fix: clamp peak_level to 1.0 to prevent decoder artifacts" This reverts commit 233ba470806ef9db1b2a26f0e468d776f327fe6e. --- src/source/agc.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index cb6ce00b6..c6c4e84c8 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -528,8 +528,7 @@ where // Take maximum to ensure the peak is always an upper bound. // This guarantees that peak_level never decreases below the current sample, // preserving the safety mechanism against clipping. - // Set an upper bound to prevent tracking out-of-bounds samples from decoders like libopus - self.peak_level = sample_value.max(peak_release).min(1.0); + self.peak_level = sample_value.max(peak_release); } /// Updates the RMS (Root Mean Square) level using a circular buffer approach. From d3c5d1be090d3f804e77b5f2e629e2039e10bf59 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:18:29 +1200 Subject: [PATCH 10/16] refactor: Set AGC `Floor` To `1.0` apon initialisation - Change default AGC floor value from `0.0` to `1.0` in `AutomaticGainControl` constructors --- src/source/agc.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index c6c4e84c8..9e1471d01 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -319,7 +319,7 @@ where AutomaticGainControl { input, target_level: Arc::new(AtomicFloat::new(target_level)), - floor: 0.0, + floor: 1.0, absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), peak_tracking_window, current_gain: 1.0, @@ -340,7 +340,7 @@ where AutomaticGainControl { input, target_level, - floor: 0.0, + floor: 1.0, absolute_max_gain, peak_tracking_window, current_gain: 1.0, From 66a7652f05125bc42110bc8ca5e8b82f872cb008 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:34:31 +1200 Subject: [PATCH 11/16] feat: Make AGC floor configurable at initialization - Adds `floor` field to `AutomaticGainControlSettings` with default of 1.0 (Amplify Only mode) - Passes floor through `automatic_gain_control` function and AGC constructors --- src/source/agc.rs | 11 +++++++++-- src/source/mod.rs | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 9e1471d01..97a3d7196 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -88,6 +88,10 @@ pub struct AutomaticGainControlSettings { /// Larger values provide more stable peak detection but add latency to peak tracking. /// Smaller values respond faster to sudden peaks but may allow more transient clipping. pub peak_tracking_window: Duration, + /// The minimum output level (gain floor) that the AGC will not go below. + /// A value of 1.0 preserves loud passages at source level without additional amplification (amplification only). + /// A value of 0.0 allows unlimited amplification (pure AGC behaviour). + pub floor: Float, } impl Default for AutomaticGainControlSettings { @@ -98,6 +102,7 @@ impl Default for AutomaticGainControlSettings { release_time: Duration::from_micros(500), // Recommended release time absolute_max_gain: 7.0, // Recommended max gain peak_tracking_window: Duration::from_millis(10), // Recommended peak tracking window for balanced stability and responsiveness + floor: 1.0, // Amplify Only (preserve source level for loud passages by default) } } } @@ -295,6 +300,7 @@ impl CircularBufferRMS { /// `release_time` - Time constant for gain decrease /// `absolute_max_gain` - Maximum allowable gain /// `peak_tracking_window` - Duration over which to track peak level +/// `floor` - The minimum output level (gain floor) that the AGC will not go below #[inline] pub(crate) fn automatic_gain_control( input: I, @@ -303,6 +309,7 @@ pub(crate) fn automatic_gain_control( release_time: Duration, absolute_max_gain: Float, peak_tracking_window: Duration, + floor: Float, ) -> AutomaticGainControl where I: Source, @@ -319,7 +326,7 @@ where AutomaticGainControl { input, target_level: Arc::new(AtomicFloat::new(target_level)), - floor: 1.0, + floor, absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), peak_tracking_window, current_gain: 1.0, @@ -340,7 +347,7 @@ where AutomaticGainControl { input, target_level, - floor: 1.0, + floor, absolute_max_gain, peak_tracking_window, current_gain: 1.0, diff --git a/src/source/mod.rs b/src/source/mod.rs index 8e4298673..740e585e3 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -439,6 +439,7 @@ pub trait Source: Iterator { release_time_limited, agc_settings.absolute_max_gain, agc_settings.peak_tracking_window, + agc_settings.floor, ) } From 336351627785fd0f3b97dd70f41638268b01438d Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:40:06 +1200 Subject: [PATCH 12/16] refactor: Dynamically adjust RMS window size based on `sample_rate` and `duration` - Replace `RMS_WINDOW_SIZE` constant from fixed sample count with `RMS_WINDOW_MS`, now defined as a 20ms `Duration` - Replace fixed-size `CircularBufferRMS::buffer` with a boxed slice sized at construction - Accept variable `sample_rate` via `NonZeroU32` for initialization - Calculate buffer size at construction (via `calculate_rms_buffer_size`), rounding up to nearest power of two - Pass `sample_rate` and target duration to `CircularBufferRMS::new` and reset logic - Remove `power_of_two` helper; store computed buffer mask in `mask` field - Use calculated dynamic `mask` for index wrapping instead of fixed compile-time mask --- src/source/agc.rs | 59 +++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 97a3d7196..028893118 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -20,6 +20,7 @@ use super::{SeekError, SpanTracker}; use crate::math::{duration_to_coefficient, duration_to_float, fast_exp}; use crate::{ChannelCount, Float, Sample, SampleRate, Source}; +use std::num::NonZeroU32; use std::time::Duration; #[cfg(feature = "tracing")] @@ -41,15 +42,6 @@ type AtomicFloat = AtomicF32; #[cfg(all(feature = "experimental", feature = "64bit"))] type AtomicFloat = AtomicF64; -/// Ensures `RMS_WINDOW_SIZE` is a power of two -const fn power_of_two(n: usize) -> usize { - assert!( - n.is_power_of_two(), - "RMS_WINDOW_SIZE must be a power of two" - ); - n -} - /// Divide `a` by `b` unless `b` is NaN, infinite, or <= 0, /// in which case `fallback` is returned. #[inline(always)] @@ -61,9 +53,9 @@ fn div_or_fallback(a: Float, b: Float, fallback: Float) -> Float { } } -/// Size of the circular buffer used for RMS calculation. +/// Size of the target circular buffer used for RMS calculation, in milliseconds. /// A larger size provides more stable RMS values but increases latency. -const RMS_WINDOW_SIZE: usize = power_of_two(1024); +const RMS_WINDOW_MS: Duration = Duration::from_millis(20); /// Settings for the Automatic Gain Control (AGC). /// @@ -248,22 +240,43 @@ impl SlowDownState { /// avoiding the need to scan stored samples for mean calculations. #[derive(Clone, Debug)] struct CircularBufferRMS { - buffer: Box<[Float; RMS_WINDOW_SIZE]>, + buffer: Box<[Float]>, sum_of_squares: Float, index: usize, + mask: usize, } impl CircularBufferRMS { - /// Creates a new `CircularBufferRMS` with a fixed size determined at compile time. + /// Calculates the buffer size from the sample rate and target window length. + /// + /// The window is expressed in milliseconds, converted to samples, then rounded + /// up to the next power of two for efficient index wrapping using bitwise arithmetic. + #[inline] + fn calculate_rms_buffer_size(sample_rate: NonZeroU32, window_ms: Duration) -> usize { + // Convert the time window into the number of samples for this sample rate + // Example: 44,100 × 20 ms / 1,000 -> 882 samples + let samples = (sample_rate.get() as usize * window_ms.as_millis() as usize).div_ceil(1000); + + // Ensure minimum 1 sample, then round up to nearest power of two + // Result: 882 samples -> 1024-sample buffer + // Which is: 1024 samples at 44,100 Hz ≈ 23.2 ms + samples.max(1).next_power_of_two() + } + + /// Creates a new `CircularBufferRMS` from a sample rate and target window size in milliseconds. /// - /// The buffer size is `RMS_WINDOW_SIZE`, chosen as a power of two for - /// efficient modulo operations using bitwise arithmetic. + /// The buffer size is computed from the requested duration and rounded up to a power of two + /// so wrapping can use bitwise arithmetic instead of modulo. #[inline] - fn new() -> Self { + fn new(sample_rate: NonZeroU32, window_ms: Duration) -> Self { + // Calculate the buffer size from the sample_rate and target window + let size = Self::calculate_rms_buffer_size(sample_rate, window_ms); + CircularBufferRMS { - buffer: Box::new([0.0; RMS_WINDOW_SIZE]), + buffer: vec![0.0; size].into_boxed_slice(), // [T; N] requires const N; Vec allows runtime size sum_of_squares: 0.0, index: 0, + mask: size - 1, } } @@ -277,8 +290,8 @@ impl CircularBufferRMS { // Update the sum of squares by subtracting the square of the old value and adding the square of the new value. self.sum_of_squares = (self.sum_of_squares - (old_value * old_value)) + (value * value); self.buffer[self.index] = value; - // Use bitwise for efficient index wrapping since RMS_WINDOW_SIZE is a power of two. - self.index = (self.index + 1) & (RMS_WINDOW_SIZE - 1); + // Use bitwise for efficient index wrapping since the buffer size is a power of two. + self.index = (self.index + 1) & self.mask; } /// Calculate the RMS (Root Mean Square) value of all values in the buffer. @@ -286,7 +299,7 @@ impl CircularBufferRMS { /// RMS provides a measure of the signal's effective or average magnitude. #[inline] fn rms(&self) -> Float { - (self.sum_of_squares / RMS_WINDOW_SIZE as Float).sqrt() + (self.sum_of_squares / self.buffer.len() as Float).sqrt() } } @@ -334,7 +347,7 @@ where release_duration: Arc::new(AtomicFloat::new(release_duration)), peak_level: 0.7, release_coefficient, - rms_window: CircularBufferRMS::new(), + rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), is_enabled: Arc::new(AtomicBool::new(true)), span: SpanTracker::new(sample_rate, channels), slow_down_state: SlowDownState::new(sample_rate), @@ -355,7 +368,7 @@ where release_duration, peak_level: 0.7, release_coefficient, - rms_window: CircularBufferRMS::new(), + rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), is_enabled: true, span: SpanTracker::new(sample_rate, channels), slow_down_state: SlowDownState::new(sample_rate), @@ -675,7 +688,7 @@ where duration_to_coefficient(self.peak_tracking_window, current_sample_rate); // Reset RMS window to avoid mixing samples from different parameter sets - self.rms_window = CircularBufferRMS::new(); + self.rms_window = CircularBufferRMS::new(current_sample_rate, RMS_WINDOW_MS); self.peak_level = 0.7; self.current_gain = 1.0; } From 898abe8894faadf71a706043b5ad25d16063d385 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:35:00 +1200 Subject: [PATCH 13/16] perf: Optimize `CircularBufferRMS` RMS calculation to avoid division - Add `reciprocal_len` field to store precomputed `1 / size` - Initialize `reciprocal_len` in the constructor - Replace division with multiplication in `rms()` for performance - Precompute `1 / size` once, since division by `size` equals multiplication by its reciprocal --- src/source/agc.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index 028893118..ca15628ac 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -240,10 +240,11 @@ impl SlowDownState { /// avoiding the need to scan stored samples for mean calculations. #[derive(Clone, Debug)] struct CircularBufferRMS { - buffer: Box<[Float]>, - sum_of_squares: Float, - index: usize, - mask: usize, + buffer: Box<[Float]>, // Runtime-sized window so RMS spans the same time range at any sample rate + sum_of_squares: Float, // Keeps a running square-sum so RMS can be updated without re-scanning the entire buffer + index: usize, // Marks the current slot; each new sample overwrites the oldest one as we advance + mask: usize, // Lets the index wrap with `&` instead of `%`, which is faster because the size is a power of two + reciprocal_len: Float, // Stores `1 / len` so RMS normalizes with multiplication instead of division } impl CircularBufferRMS { @@ -277,6 +278,7 @@ impl CircularBufferRMS { sum_of_squares: 0.0, index: 0, mask: size - 1, + reciprocal_len: 1.0 / size as Float, } } @@ -299,7 +301,7 @@ impl CircularBufferRMS { /// RMS provides a measure of the signal's effective or average magnitude. #[inline] fn rms(&self) -> Float { - (self.sum_of_squares / self.buffer.len() as Float).sqrt() + (self.sum_of_squares * self.reciprocal_len).sqrt() } } From 181314a9ce785a2d6c04695b467e5d233cb4810d Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:31:48 +1200 Subject: [PATCH 14/16] chore: Remove irrelevant parameters for AGC --- src/source/mod.rs | 68 ----------------------------------------------- 1 file changed, 68 deletions(-) diff --git a/src/source/mod.rs b/src/source/mod.rs index 740e585e3..b6441cadc 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -352,74 +352,6 @@ pub trait Source: Iterator { /// /// Automatic Gain Control (AGC) adjusts the amplitude of the audio signal /// to maintain a consistent output level. - /// - /// # Parameters - /// - /// `target_level`: - /// **TL;DR**: Desired output level. 1.0 = original level, > 1.0 amplifies, < 1.0 reduces. - /// - /// The desired output level, where 1.0 represents the original sound level. - /// Values above 1.0 will amplify the sound, while values below 1.0 will lower it. - /// For example, a target_level of 1.4 means that at normal sound levels, the AGC - /// will aim to increase the gain by a factor of 1.4, resulting in a minimum 40% amplification. - /// A recommended level is `1.0`, which maintains the original sound level. - /// - /// `attack_time`: - /// **TL;DR**: Response time for volume increases. Shorter = faster but may cause abrupt changes. **Recommended: `4.0` seconds**. - /// - /// The time (in seconds) for the AGC to respond to input level increases. - /// Shorter times mean faster response but may cause abrupt changes. Longer times result - /// in smoother transitions but slower reactions to sudden volume changes. Too short can - /// lead to overreaction to peaks, causing unnecessary adjustments. Too long can make the - /// AGC miss important volume changes or react too slowly to sudden loud passages. Very - /// high values might result in excessively loud output or sluggish response, as the AGC's - /// adjustment speed is limited by the attack time. Balance is key for optimal performance. - /// A recommended attack_time of `4.0` seconds provides a sweet spot for most applications. - /// - /// `release_time`: - /// **TL;DR**: Response time for volume decreases. Shorter = faster gain reduction. **Recommended: `0.0` seconds**. - /// - /// The time (in seconds) for the AGC to respond to input level decreases. - /// This parameter controls how quickly the gain is reduced when the signal level drops. - /// Shorter release times result in faster gain reduction, which can be useful for quick - /// adaptation to quieter passages but may lead to pumping effects. Longer release times - /// provide smoother transitions but may be slower to respond to sudden decreases in volume. - /// However, if the release_time is too high, the AGC may not be able to lower the gain - /// quickly enough, potentially leading to clipping and distorted sound before it can adjust. - /// Finding the right balance is crucial for maintaining natural-sounding dynamics and - /// preventing distortion. A recommended release_time of `0.0` seconds works well for - /// general use, allowing the AGC to decrease the gain immediately with no delay, ensuring there is no clipping. - /// - /// `absolute_max_gain`: - /// **TL;DR**: Maximum allowed gain. Prevents over-amplification. **Recommended: `5.0`**. - /// - /// The maximum gain that can be applied to the signal. - /// This parameter acts as a safeguard against excessive amplification of quiet signals - /// or background noise. It establishes an upper boundary for the AGC's signal boost, - /// effectively preventing distortion or overamplification of low-level sounds. - /// This is crucial for maintaining audio quality and preventing unexpected volume spikes. - /// A recommended value for `absolute_max_gain` is `5`, which provides a good balance between - /// amplification capability and protection against distortion in most scenarios. - /// - /// `automatic_gain_control` example in this project shows a pattern you can use - /// to enable/disable the AGC filter dynamically. - /// - /// # Example (Quick start) - /// - /// ```rust - /// // Apply Automatic Gain Control to the source (AGC is on by default) - /// use rodio::source::{Source, SineWave, AutomaticGainControlSettings}; - /// use rodio::Player; - /// use std::time::Duration; - /// let source = SineWave::new(444.0); // An example. - /// let (player, output) = Player::new(); // An example. - /// - /// let agc_source = source.automatic_gain_control(AutomaticGainControlSettings::default()); - /// - /// // Add the AGC-controlled source to the sink - /// player.append(agc_source); - /// - /// ``` #[inline] fn automatic_gain_control( self, From 9c8c10ea72b8b4688728c4fcc5157f24d4851a8f Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:34:06 +1200 Subject: [PATCH 15/16] feat: Add `music` and `speech` presets for `AGC` - Add `music_preset()` for music-optimised gain control defaults - Add `speech_preset()` with faster attack/release for speech content - Update Default to use music preset as the baseline configuration --- src/source/agc.rs | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/source/agc.rs b/src/source/agc.rs index ca15628ac..f34e00688 100644 --- a/src/source/agc.rs +++ b/src/source/agc.rs @@ -11,6 +11,7 @@ // • Atomic operations support (experimental) // • Fast release coefficient via 3rd‑order Taylor approximation (evaluated with Horner's method) // • Power-of-two window sizing for efficiency +// • Presets for music and speech // // Optimised for smooth and responsive gain control // @@ -86,17 +87,41 @@ pub struct AutomaticGainControlSettings { pub floor: Float, } -impl Default for AutomaticGainControlSettings { - fn default() -> Self { +impl AutomaticGainControlSettings { + /// Returns a preset optimised for music content. + /// + /// Values tuned through empirical testing and are intended as good defaults for general music processing. + pub fn music_preset() -> Self { AutomaticGainControlSettings { - target_level: 1.0, // Default to original level - attack_time: Duration::from_millis(500), // Recommended attack time - release_time: Duration::from_micros(500), // Recommended release time - absolute_max_gain: 7.0, // Recommended max gain - peak_tracking_window: Duration::from_millis(10), // Recommended peak tracking window for balanced stability and responsiveness - floor: 1.0, // Amplify Only (preserve source level for loud passages by default) + target_level: 1.0, + attack_time: Duration::from_millis(500), + release_time: Duration::from_micros(500), + absolute_max_gain: 7.0, + peak_tracking_window: Duration::from_millis(10), + floor: 1.0, } } + + /// Returns a preset optimised for speech content. + /// + /// Values tuned through empirical testing and are intended as good defaults for general speech processing. + pub fn speech_preset() -> Self { + AutomaticGainControlSettings { + target_level: 1.0, + attack_time: Duration::from_millis(250), + release_time: Duration::from_micros(50), + absolute_max_gain: 7.0, + peak_tracking_window: Duration::from_millis(10), + floor: 0.0, + } + } +} + +impl Default for AutomaticGainControlSettings { + // Music preset is the default + fn default() -> Self { + Self::music_preset() + } } #[cfg(feature = "experimental")] From 6716bfc3ae311e4a3f44b380776f1aadb6b0eea0 Mon Sep 17 00:00:00 2001 From: UnknownSuperficialNight <88142731+UnknownSuperficialNight@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:46:54 +1200 Subject: [PATCH 16/16] refactor: restructure AGC with separate modules for settings, helpers, and calculations - Extract presets and config struct into `config.rs` - Split helper functions into new `helpers.rs` file - Move `RMS` calculation to `rms.rs` module - Move slowdown state to `slowdown.rs` module --- .../{ => automatic_gain_control}/agc.rs | 356 ++++-------------- src/source/automatic_gain_control/config.rs | 68 ++++ src/source/automatic_gain_control/helpers.rs | 12 + src/source/automatic_gain_control/mod.rs | 14 + src/source/automatic_gain_control/rms.rs | 74 ++++ src/source/automatic_gain_control/slowdown.rs | 72 ++++ src/source/mod.rs | 6 +- 7 files changed, 312 insertions(+), 290 deletions(-) rename src/source/{ => automatic_gain_control}/agc.rs (58%) create mode 100644 src/source/automatic_gain_control/config.rs create mode 100644 src/source/automatic_gain_control/helpers.rs create mode 100644 src/source/automatic_gain_control/mod.rs create mode 100644 src/source/automatic_gain_control/rms.rs create mode 100644 src/source/automatic_gain_control/slowdown.rs diff --git a/src/source/agc.rs b/src/source/automatic_gain_control/agc.rs similarity index 58% rename from src/source/agc.rs rename to src/source/automatic_gain_control/agc.rs index f34e00688..2afec0202 100644 --- a/src/source/agc.rs +++ b/src/source/automatic_gain_control/agc.rs @@ -18,10 +18,9 @@ // Crafted with love. Enjoy! :) // -use super::{SeekError, SpanTracker}; -use crate::math::{duration_to_coefficient, duration_to_float, fast_exp}; +use super::{div_or_fallback, CircularBufferRMS, SeekError, SlowDownState, SpanTracker}; +use crate::math::{duration_to_coefficient, duration_to_float}; use crate::{ChannelCount, Float, Sample, SampleRate, Source}; -use std::num::NonZeroU32; use std::time::Duration; #[cfg(feature = "tracing")] @@ -43,87 +42,10 @@ type AtomicFloat = AtomicF32; #[cfg(all(feature = "experimental", feature = "64bit"))] type AtomicFloat = AtomicF64; -/// Divide `a` by `b` unless `b` is NaN, infinite, or <= 0, -/// in which case `fallback` is returned. -#[inline(always)] -fn div_or_fallback(a: Float, b: Float, fallback: Float) -> Float { - if b.is_finite() && b > 0.0 { - a / b - } else { - fallback - } -} - /// Size of the target circular buffer used for RMS calculation, in milliseconds. /// A larger size provides more stable RMS values but increases latency. const RMS_WINDOW_MS: Duration = Duration::from_millis(20); -/// Settings for the Automatic Gain Control (AGC). -/// -/// This struct contains parameters that define how the AGC will function, -/// allowing users to customise its behaviour. -#[derive(Debug, Clone)] -pub struct AutomaticGainControlSettings { - /// The desired output level that the AGC tries to maintain. - /// A value of 1.0 means no change to the original level. - pub target_level: Float, - /// Time constant for gain increases (how quickly the AGC responds to level increases). - /// Longer durations result in slower, more gradual gain increases. - pub attack_time: Duration, - /// Time constant for gain decreases (how quickly the AGC responds to level decreases). - /// Shorter durations allow for faster response to sudden loud signals. - pub release_time: Duration, - /// Maximum allowable gain multiplication to prevent excessive amplification. - /// This acts as a safety limit to avoid distortion from over-amplification. - pub absolute_max_gain: Float, - /// Duration of the peak tracking smoothing window. - /// Controls how much peak level measurements are smoothed before being used for gain calculation. - /// Larger values provide more stable peak detection but add latency to peak tracking. - /// Smaller values respond faster to sudden peaks but may allow more transient clipping. - pub peak_tracking_window: Duration, - /// The minimum output level (gain floor) that the AGC will not go below. - /// A value of 1.0 preserves loud passages at source level without additional amplification (amplification only). - /// A value of 0.0 allows unlimited amplification (pure AGC behaviour). - pub floor: Float, -} - -impl AutomaticGainControlSettings { - /// Returns a preset optimised for music content. - /// - /// Values tuned through empirical testing and are intended as good defaults for general music processing. - pub fn music_preset() -> Self { - AutomaticGainControlSettings { - target_level: 1.0, - attack_time: Duration::from_millis(500), - release_time: Duration::from_micros(500), - absolute_max_gain: 7.0, - peak_tracking_window: Duration::from_millis(10), - floor: 1.0, - } - } - - /// Returns a preset optimised for speech content. - /// - /// Values tuned through empirical testing and are intended as good defaults for general speech processing. - pub fn speech_preset() -> Self { - AutomaticGainControlSettings { - target_level: 1.0, - attack_time: Duration::from_millis(250), - release_time: Duration::from_micros(50), - absolute_max_gain: 7.0, - peak_tracking_window: Duration::from_millis(10), - floor: 0.0, - } - } -} - -impl Default for AutomaticGainControlSettings { - // Music preset is the default - fn default() -> Self { - Self::music_preset() - } -} - #[cfg(feature = "experimental")] /// Automatic Gain Control filter for maintaining consistent output levels. /// @@ -190,223 +112,83 @@ pub struct AutomaticGainControl { slow_down_state: SlowDownState, } -/// State for adaptive slowdown of gain changes. -/// -/// This struct holds the state for managing the slowdown of gain changes based on signal conditions. -/// The `slowdown_factor` determines how quickly or slowly the gain can change: -/// - When the signal is quiet and we're close to target, changes are allowed normally -/// - When the signal peaks significantly, changes are slowed down exponentially -/// - This prevents abrupt loudness jumps during automatic gain control adjustments. -#[derive(Clone, Debug)] -struct SlowDownState { - block_size: usize, - sample_counter: usize, - slowdown_factor: Float, -} - -impl SlowDownState { - #[inline] - fn new(sample_rate: SampleRate) -> Self { - // Calculate and cache block size based on sample rate - let block_size = (sample_rate.get() as usize / 1000) * 2; // 2ms blocks - - Self { - block_size, - sample_counter: 0, - slowdown_factor: 0.0, - } - } - - #[inline] - fn increment_sample_counter(&mut self) { - self.sample_counter = (self.sample_counter + 1) % self.block_size; - } - - /// Computes the slowdown factor for adaptive gain changes. - /// - /// The slowdown factor determines how quickly or slowly the gain can change based on the current signal conditions. - /// - When the desired gain is close to the current gain, the slowdown factor increases, preventing abrupt loudness jumps during automatic gain control adjustments. - /// - When the signal deviates significantly from the target, the slowdown factor remains high to maintain stability. - #[inline] - fn compute_slowdown_factor( - &mut self, - desired_gain: Float, - current_gain: Float, - rms: Float, - peak_level: Float, - ) { - // Calculate the absolute difference between the desired gain and the current gain - let distance_from_target = (desired_gain - current_gain).abs(); - - // Calculate the maximum distance as the sum of RMS and peak level - let max_distance = rms + peak_level; - - // Normalise distance clamped between [0,1] with a fallback of 1.0 - let normalise_distance = div_or_fallback(distance_from_target, max_distance, 1.0).min(1.0); - - // Compute the exponential slowdown factor based on the normalised distance - // The multiplier is scaled by the square root of the sum of peak level and RMS - let exp_multiplier = 10.0 * (peak_level + rms).sqrt(); - let exp_slowdown = fast_exp(1.0 + exp_multiplier * (1.0 - normalise_distance)); - - // Create a mask that is 1.0 if the distance is within the max_distance, otherwise 0.0 - // This mask is used to blend the exponential slowdown factor with a linear factor - let mask = ((max_distance - distance_from_target).max(0.0) / max_distance).min(1.0); - - // Blend the slowdown factor: when mask=1 use exp_slowdown, else 1.0 - // This ensures that the slowdown factor increases when the signal deviates from the target - self.slowdown_factor = 1.0 + mask * (exp_slowdown - 1.0); - } -} - -/// A circular buffer optimised for RMS calculation over a sliding window. -/// -/// Maintains a running sum of squares with O(1) updates and retrieval, -/// avoiding the need to scan stored samples for mean calculations. -#[derive(Clone, Debug)] -struct CircularBufferRMS { - buffer: Box<[Float]>, // Runtime-sized window so RMS spans the same time range at any sample rate - sum_of_squares: Float, // Keeps a running square-sum so RMS can be updated without re-scanning the entire buffer - index: usize, // Marks the current slot; each new sample overwrites the oldest one as we advance - mask: usize, // Lets the index wrap with `&` instead of `%`, which is faster because the size is a power of two - reciprocal_len: Float, // Stores `1 / len` so RMS normalizes with multiplication instead of division -} - -impl CircularBufferRMS { - /// Calculates the buffer size from the sample rate and target window length. - /// - /// The window is expressed in milliseconds, converted to samples, then rounded - /// up to the next power of two for efficient index wrapping using bitwise arithmetic. - #[inline] - fn calculate_rms_buffer_size(sample_rate: NonZeroU32, window_ms: Duration) -> usize { - // Convert the time window into the number of samples for this sample rate - // Example: 44,100 × 20 ms / 1,000 -> 882 samples - let samples = (sample_rate.get() as usize * window_ms.as_millis() as usize).div_ceil(1000); - - // Ensure minimum 1 sample, then round up to nearest power of two - // Result: 882 samples -> 1024-sample buffer - // Which is: 1024 samples at 44,100 Hz ≈ 23.2 ms - samples.max(1).next_power_of_two() - } - - /// Creates a new `CircularBufferRMS` from a sample rate and target window size in milliseconds. - /// - /// The buffer size is computed from the requested duration and rounded up to a power of two - /// so wrapping can use bitwise arithmetic instead of modulo. - #[inline] - fn new(sample_rate: NonZeroU32, window_ms: Duration) -> Self { - // Calculate the buffer size from the sample_rate and target window - let size = Self::calculate_rms_buffer_size(sample_rate, window_ms); - - CircularBufferRMS { - buffer: vec![0.0; size].into_boxed_slice(), // [T; N] requires const N; Vec allows runtime size - sum_of_squares: 0.0, - index: 0, - mask: size - 1, - reciprocal_len: 1.0 / size as Float, - } - } - - /// Adds a sample to the buffer and updates the running sum of squares. - /// - /// Maintains an incremental sum of squares for O(1) RMS computation - /// without recalculating from stored samples. - #[inline] - fn push(&mut self, value: Float) { - let old_value = self.buffer[self.index]; - // Update the sum of squares by subtracting the square of the old value and adding the square of the new value. - self.sum_of_squares = (self.sum_of_squares - (old_value * old_value)) + (value * value); - self.buffer[self.index] = value; - // Use bitwise for efficient index wrapping since the buffer size is a power of two. - self.index = (self.index + 1) & self.mask; - } - - /// Calculate the RMS (Root Mean Square) value of all values in the buffer. - /// - /// RMS provides a measure of the signal's effective or average magnitude. - #[inline] - fn rms(&self) -> Float { - (self.sum_of_squares * self.reciprocal_len).sqrt() - } -} - -/// Constructs an `AutomaticGainControl` object with specified parameters. -/// -/// # Arguments -/// -/// `input` - The input audio source -/// `target_level` - The desired output level -/// `attack_time` - Time constant for gain increase -/// `release_time` - Time constant for gain decrease -/// `absolute_max_gain` - Maximum allowable gain -/// `peak_tracking_window` - Duration over which to track peak level -/// `floor` - The minimum output level (gain floor) that the AGC will not go below -#[inline] -pub(crate) fn automatic_gain_control( - input: I, - target_level: Float, - attack_time: Duration, - release_time: Duration, - absolute_max_gain: Float, - peak_tracking_window: Duration, - floor: Float, -) -> AutomaticGainControl +impl AutomaticGainControl where I: Source, { - let sample_rate = input.sample_rate(); - let attack_duration = duration_to_float(attack_time); - let release_duration = duration_to_float(release_time); + /// Constructs an `AutomaticGainControl` object with specified parameters. + /// + /// # Arguments + /// + /// `input` - The input audio source + /// `target_level` - The desired output level + /// `attack_time` - Time constant for gain increase + /// `release_time` - Time constant for gain decrease + /// `absolute_max_gain` - Maximum allowable gain + /// `peak_tracking_window` - Duration over which to track peak level + /// `floor` - The minimum output level (gain floor) that the AGC will not go below + #[inline] + pub(crate) fn new( + input: I, + target_level: Float, + attack_time: Duration, + release_time: Duration, + absolute_max_gain: Float, + peak_tracking_window: Duration, + floor: Float, + ) -> AutomaticGainControl + where + I: Source, + { + let sample_rate = input.sample_rate(); + let attack_duration = duration_to_float(attack_time); + let release_duration = duration_to_float(release_time); - let release_coefficient = duration_to_coefficient(peak_tracking_window, sample_rate); + let release_coefficient = duration_to_coefficient(peak_tracking_window, sample_rate); - #[cfg(feature = "experimental")] - { - let channels = input.channels(); - AutomaticGainControl { - input, - target_level: Arc::new(AtomicFloat::new(target_level)), - floor, - absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), - peak_tracking_window, - current_gain: 1.0, - attack_duration: Arc::new(AtomicFloat::new(attack_duration)), - release_duration: Arc::new(AtomicFloat::new(release_duration)), - peak_level: 0.7, - release_coefficient, - rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), - is_enabled: Arc::new(AtomicBool::new(true)), - span: SpanTracker::new(sample_rate, channels), - slow_down_state: SlowDownState::new(sample_rate), + #[cfg(feature = "experimental")] + { + let channels = input.channels(); + AutomaticGainControl { + input, + target_level: Arc::new(AtomicFloat::new(target_level)), + floor, + absolute_max_gain: Arc::new(AtomicFloat::new(absolute_max_gain)), + peak_tracking_window, + current_gain: 1.0, + attack_duration: Arc::new(AtomicFloat::new(attack_duration)), + release_duration: Arc::new(AtomicFloat::new(release_duration)), + peak_level: 0.7, + release_coefficient, + rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), + is_enabled: Arc::new(AtomicBool::new(true)), + span: SpanTracker::new(sample_rate, channels), + slow_down_state: SlowDownState::new(sample_rate), + } } - } - #[cfg(not(feature = "experimental"))] - { - let channels = input.channels(); - AutomaticGainControl { - input, - target_level, - floor, - absolute_max_gain, - peak_tracking_window, - current_gain: 1.0, - attack_duration, - release_duration, - peak_level: 0.7, - release_coefficient, - rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), - is_enabled: true, - span: SpanTracker::new(sample_rate, channels), - slow_down_state: SlowDownState::new(sample_rate), + #[cfg(not(feature = "experimental"))] + { + let channels = input.channels(); + AutomaticGainControl { + input, + target_level, + floor, + absolute_max_gain, + peak_tracking_window, + current_gain: 1.0, + attack_duration, + release_duration, + peak_level: 0.7, + release_coefficient, + rms_window: CircularBufferRMS::new(sample_rate, RMS_WINDOW_MS), + is_enabled: true, + span: SpanTracker::new(sample_rate, channels), + slow_down_state: SlowDownState::new(sample_rate), + } } } -} -impl AutomaticGainControl -where - I: Source, -{ #[inline] fn target_level(&self) -> Float { #[cfg(feature = "experimental")] diff --git a/src/source/automatic_gain_control/config.rs b/src/source/automatic_gain_control/config.rs new file mode 100644 index 000000000..69773e6f3 --- /dev/null +++ b/src/source/automatic_gain_control/config.rs @@ -0,0 +1,68 @@ +use crate::Float; +use std::time::Duration; + +/// Settings for the Automatic Gain Control (AGC). +/// +/// This struct contains parameters that define how the AGC will function, +/// allowing users to customise its behaviour. +#[derive(Debug, Clone)] +pub struct AutomaticGainControlSettings { + /// The desired output level that the AGC tries to maintain. + /// A value of 1.0 means no change to the original level. + pub target_level: Float, + /// Time constant for gain increases (how quickly the AGC responds to level increases). + /// Longer durations result in slower, more gradual gain increases. + pub attack_time: Duration, + /// Time constant for gain decreases (how quickly the AGC responds to level decreases). + /// Shorter durations allow for faster response to sudden loud signals. + pub release_time: Duration, + /// Maximum allowable gain multiplication to prevent excessive amplification. + /// This acts as a safety limit to avoid distortion from over-amplification. + pub absolute_max_gain: Float, + /// Duration of the peak tracking smoothing window. + /// Controls how much peak level measurements are smoothed before being used for gain calculation. + /// Larger values provide more stable peak detection but add latency to peak tracking. + /// Smaller values respond faster to sudden peaks but may allow more transient clipping. + pub peak_tracking_window: Duration, + /// The minimum output level (gain floor) that the AGC will not go below. + /// A value of 1.0 preserves loud passages at source level without additional amplification (amplification only). + /// A value of 0.0 allows unlimited amplification (pure AGC behaviour). + pub floor: Float, +} + +impl AutomaticGainControlSettings { + /// Returns a preset optimised for music content. + /// + /// Values tuned through empirical testing and are intended as good defaults for general music processing. + pub fn music_preset() -> Self { + AutomaticGainControlSettings { + target_level: 1.0, + attack_time: Duration::from_millis(500), + release_time: Duration::from_micros(500), + absolute_max_gain: 7.0, + peak_tracking_window: Duration::from_millis(10), + floor: 1.0, + } + } + + /// Returns a preset optimised for speech content. + /// + /// Values tuned through empirical testing and are intended as good defaults for general speech processing. + pub fn speech_preset() -> Self { + AutomaticGainControlSettings { + target_level: 1.0, + attack_time: Duration::from_millis(250), + release_time: Duration::from_micros(50), + absolute_max_gain: 7.0, + peak_tracking_window: Duration::from_millis(10), + floor: 0.0, + } + } +} + +impl Default for AutomaticGainControlSettings { + // Music preset is the default + fn default() -> Self { + Self::music_preset() + } +} diff --git a/src/source/automatic_gain_control/helpers.rs b/src/source/automatic_gain_control/helpers.rs new file mode 100644 index 000000000..df52a0a8c --- /dev/null +++ b/src/source/automatic_gain_control/helpers.rs @@ -0,0 +1,12 @@ +use crate::Float; + +/// Divide `a` by `b` unless `b` is NaN, infinite, or <= 0, +/// in which case `fallback` is returned. +#[inline(always)] +pub(super) fn div_or_fallback(a: Float, b: Float, fallback: Float) -> Float { + if b.is_finite() && b > 0.0 { + a / b + } else { + fallback + } +} diff --git a/src/source/automatic_gain_control/mod.rs b/src/source/automatic_gain_control/mod.rs new file mode 100644 index 000000000..6eb3dd7a6 --- /dev/null +++ b/src/source/automatic_gain_control/mod.rs @@ -0,0 +1,14 @@ +use super::{SeekError, SpanTracker}; + +mod agc; +mod config; +mod helpers; +mod rms; +mod slowdown; + +pub use agc::AutomaticGainControl; +pub use config::AutomaticGainControlSettings; + +use helpers::div_or_fallback; +use rms::CircularBufferRMS; +use slowdown::SlowDownState; diff --git a/src/source/automatic_gain_control/rms.rs b/src/source/automatic_gain_control/rms.rs new file mode 100644 index 000000000..af35a8618 --- /dev/null +++ b/src/source/automatic_gain_control/rms.rs @@ -0,0 +1,74 @@ +use std::{num::NonZeroU32, time::Duration}; + +use crate::Float; + +/// A circular buffer optimised for RMS calculation over a sliding window. +/// +/// Maintains a running sum of squares with O(1) updates and retrieval, +/// avoiding the need to scan stored samples for mean calculations. +#[derive(Clone, Debug)] +pub(super) struct CircularBufferRMS { + buffer: Box<[Float]>, // Runtime-sized window so RMS spans the same time range at any sample rate + sum_of_squares: Float, // Keeps a running square-sum so RMS can be updated without re-scanning the entire buffer + index: usize, // Marks the current slot; each new sample overwrites the oldest one as we advance + mask: usize, // Lets the index wrap with `&` instead of `%`, which is faster because the size is a power of two + reciprocal_len: Float, // Stores `1 / len` so RMS normalizes with multiplication instead of division +} + +impl CircularBufferRMS { + /// Calculates the buffer size from the sample rate and target window length. + /// + /// The window is expressed in milliseconds, converted to samples, then rounded + /// up to the next power of two for efficient index wrapping using bitwise arithmetic. + #[inline] + fn calculate_rms_buffer_size(sample_rate: NonZeroU32, window_ms: Duration) -> usize { + // Convert the time window into the number of samples for this sample rate + // Example: 44,100 × 20 ms / 1,000 -> 882 samples + let samples = (sample_rate.get() as usize * window_ms.as_millis() as usize).div_ceil(1000); + + // Ensure minimum 1 sample, then round up to nearest power of two + // Result: 882 samples -> 1024-sample buffer + // Which is: 1024 samples at 44,100 Hz ≈ 23.2 ms + samples.max(1).next_power_of_two() + } + + /// Creates a new `CircularBufferRMS` from a sample rate and target window size in milliseconds. + /// + /// The buffer size is computed from the requested duration and rounded up to a power of two + /// so wrapping can use bitwise arithmetic instead of modulo. + #[inline] + pub(super) fn new(sample_rate: NonZeroU32, window_ms: Duration) -> Self { + // Calculate the buffer size from the sample_rate and target window + let size = Self::calculate_rms_buffer_size(sample_rate, window_ms); + + CircularBufferRMS { + buffer: vec![0.0; size].into_boxed_slice(), // [T; N] requires const N; Vec allows runtime size + sum_of_squares: 0.0, + index: 0, + mask: size - 1, + reciprocal_len: 1.0 / size as Float, + } + } + + /// Adds a sample to the buffer and updates the running sum of squares. + /// + /// Maintains an incremental sum of squares for O(1) RMS computation + /// without recalculating from stored samples. + #[inline] + pub(super) fn push(&mut self, value: Float) { + let old_value = self.buffer[self.index]; + // Update the sum of squares by subtracting the square of the old value and adding the square of the new value. + self.sum_of_squares = (self.sum_of_squares - (old_value * old_value)) + (value * value); + self.buffer[self.index] = value; + // Use bitwise for efficient index wrapping since the buffer size is a power of two. + self.index = (self.index + 1) & self.mask; + } + + /// Calculate the RMS (Root Mean Square) value of all values in the buffer. + /// + /// RMS provides a measure of the signal's effective or average magnitude. + #[inline] + pub(super) fn rms(&self) -> Float { + (self.sum_of_squares * self.reciprocal_len).sqrt() + } +} diff --git a/src/source/automatic_gain_control/slowdown.rs b/src/source/automatic_gain_control/slowdown.rs new file mode 100644 index 000000000..dabecfaa9 --- /dev/null +++ b/src/source/automatic_gain_control/slowdown.rs @@ -0,0 +1,72 @@ +use super::div_or_fallback; +use crate::math::fast_exp; +use crate::{Float, SampleRate}; + +/// State for adaptive slowdown of gain changes. +/// +/// This struct holds the state for managing the slowdown of gain changes based on signal conditions. +/// The `slowdown_factor` determines how quickly or slowly the gain can change: +/// - When the signal is quiet and we're close to target, changes are allowed normally +/// - When the signal peaks significantly, changes are slowed down exponentially +/// - This prevents abrupt loudness jumps during automatic gain control adjustments. +#[derive(Clone, Debug)] +pub(super) struct SlowDownState { + block_size: usize, + pub(super) sample_counter: usize, + pub(super) slowdown_factor: Float, +} + +impl SlowDownState { + #[inline] + pub(super) fn new(sample_rate: SampleRate) -> Self { + // Calculate and cache block size based on sample rate + let block_size = (sample_rate.get() as usize / 1000) * 2; // 2ms blocks + + Self { + block_size, + sample_counter: 0, + slowdown_factor: 0.0, + } + } + + #[inline] + pub(super) fn increment_sample_counter(&mut self) { + self.sample_counter = (self.sample_counter + 1) % self.block_size; + } + + /// Computes the slowdown factor for adaptive gain changes. + /// + /// The slowdown factor determines how quickly or slowly the gain can change based on the current signal conditions. + /// - When the desired gain is close to the current gain, the slowdown factor increases, preventing abrupt loudness jumps during automatic gain control adjustments. + /// - When the signal deviates significantly from the target, the slowdown factor remains high to maintain stability. + #[inline] + pub(super) fn compute_slowdown_factor( + &mut self, + desired_gain: Float, + current_gain: Float, + rms: Float, + peak_level: Float, + ) { + // Calculate the absolute difference between the desired gain and the current gain + let distance_from_target = (desired_gain - current_gain).abs(); + + // Calculate the maximum distance as the sum of RMS and peak level + let max_distance = rms + peak_level; + + // Normalise distance clamped between [0,1] with a fallback of 1.0 + let normalise_distance = div_or_fallback(distance_from_target, max_distance, 1.0).min(1.0); + + // Compute the exponential slowdown factor based on the normalised distance + // The multiplier is scaled by the square root of the sum of peak level and RMS + let exp_multiplier = 10.0 * (peak_level + rms).sqrt(); + let exp_slowdown = fast_exp(1.0 + exp_multiplier * (1.0 - normalise_distance)); + + // Create a mask that is 1.0 if the distance is within the max_distance, otherwise 0.0 + // This mask is used to blend the exponential slowdown factor with a linear factor + let mask = ((max_distance - distance_from_target).max(0.0) / max_distance).min(1.0); + + // Blend the slowdown factor: when mask=1 use exp_slowdown, else 1.0 + // This ensures that the slowdown factor increases when the signal deviates from the target + self.slowdown_factor = 1.0 + mask * (exp_slowdown - 1.0); + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index b6441cadc..e6b9f604b 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -11,8 +11,8 @@ use crate::{ use dasp_sample::FromSample; -pub use self::agc::{AutomaticGainControl, AutomaticGainControlSettings}; pub use self::amplify::Amplify; +pub use self::automatic_gain_control::{AutomaticGainControl, AutomaticGainControlSettings}; pub use self::blt::BltFilter; pub use self::buffered::Buffered; pub use self::channel_volume::ChannelVolume; @@ -48,8 +48,8 @@ pub use self::triangle::TriangleWave; pub use self::uniform::UniformSourceIterator; pub use self::zero::{Zero, ZeroError}; -mod agc; mod amplify; +mod automatic_gain_control; mod blt; mod buffered; mod channel_volume; @@ -364,7 +364,7 @@ pub trait Source: Iterator { let attack_time_limited = agc_settings.attack_time.min(Duration::from_secs(10)); let release_time_limited = agc_settings.release_time.min(Duration::from_secs(10)); - agc::automatic_gain_control( + AutomaticGainControl::new( self, agc_settings.target_level, attack_time_limited,