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 is INSTANTANEOUS — gain is reduced in the same sample that // exceeds the ceiling. This avoids overshoot entirely, which is critical // because overshoot causes composite clipping that destroys pilot/RDS. // Unlike hard clipping, gain scaling preserves the waveform shape and // does not create harmonics. // // Release is smooth (exponential decay) to avoid audible pumping. type StereoLimiter struct { ceiling float64 releaseCoeff float64 gainReduction float64 } // NewStereoLimiter creates a stereo-linked limiter with instant attack. // 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 } releaseSamples := releaseMs * sampleRate / 1000 return &StereoLimiter{ ceiling: ceiling, 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. // // If the peak exceeds ceiling, gain is INSTANTLY reduced (zero overshoot). // When the signal drops below ceiling, gain recovers smoothly via release. func (l *StereoLimiter) Process(left, right float64) (float64, float64) { peak := math.Max(math.Abs(left), math.Abs(right)) // Target: how much gain reduction do we need right now? targetReduction := 0.0 if peak > l.ceiling { targetReduction = 1.0 - l.ceiling/peak } // Instant attack: if we need MORE reduction, apply it NOW. // Smooth release: if we need LESS reduction, decay slowly. if targetReduction > l.gainReduction { l.gainReduction = targetReduction // instant } else { l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) // smooth } gain := 1.0 - l.gainReduction return left * gain, right * gain } // Reset clears the limiter state. func (l *StereoLimiter) Reset() { l.gainReduction = 0 }