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