package dsp import "math" // StereoLimiter applies identical gain reduction to L and R channels, // driven by the peak of max(|L|, |R|). This preserves the stereo image // while preventing either channel from exceeding the ceiling. // // Attack time is tuned for psychoacoustic burst masking (Bonello, JAES 2007): // A ~2ms attack lets initial transient peaks pass through to the hard // clipper, where they're clipped for <5ms. The human auditory system // cannot resolve distortion in bursts shorter than ~5ms (burst masking // provides up to 36 dB of additional masking). This gives higher average // loudness without audible distortion, compared to instant attack which // suppresses transients that were already inaudible. // // Release is smooth (exponential decay) to avoid audible pumping. type StereoLimiter struct { ceiling float64 attackCoeff float64 releaseCoeff float64 gainReduction float64 } // NewStereoLimiter creates a stereo-linked limiter. // attackMs ~2ms for burst-masking benefit (Bonello). 0 = instant (legacy). // releaseMs controls how quickly gain recovers after a peak (typ. 50-200ms). func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoLimiter { if ceiling <= 0 { ceiling = 1.0 } if releaseMs <= 0 { releaseMs = 100 } var attackCoeff float64 if attackMs > 0 { attackSamples := attackMs * sampleRate / 1000 attackCoeff = 1.0 - math.Exp(-1.0/attackSamples) } else { attackCoeff = 1.0 // instant: full step in one sample } releaseSamples := releaseMs * sampleRate / 1000 return &StereoLimiter{ ceiling: ceiling, attackCoeff: attackCoeff, releaseCoeff: 1.0 - math.Exp(-1.0/releaseSamples), } } // Process applies stereo-linked limiting. Both channels receive the // same gain factor, determined by the louder of the two. // // With attackMs > 0: transient peaks that exceed the ceiling are NOT // instantly suppressed. They pass through to the downstream hard clipper, // which clips them for a few ms until the limiter catches up. These // sub-5ms clip artifacts are inaudible due to psychoacoustic burst masking. func (l *StereoLimiter) Process(left, right float64) (float64, float64) { peak := math.Max(math.Abs(left), math.Abs(right)) targetReduction := 0.0 if peak > l.ceiling { targetReduction = 1.0 - l.ceiling/peak } if targetReduction > l.gainReduction { // Attack: smooth ramp toward target (or instant if attackCoeff=1.0) l.gainReduction += l.attackCoeff * (targetReduction - l.gainReduction) } else { // Release: smooth decay back toward zero reduction l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) } gain := 1.0 - l.gainReduction return left * gain, right * gain } // Reset clears the limiter state. func (l *StereoLimiter) Reset() { l.gainReduction = 0 }