|
|
|
@@ -6,20 +6,24 @@ import "math" |
|
|
|
// 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. |
|
|
|
// 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 with instant attack. |
|
|
|
// 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 { |
|
|
|
@@ -28,10 +32,19 @@ func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoL |
|
|
|
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), |
|
|
|
} |
|
|
|
} |
|
|
|
@@ -39,23 +52,24 @@ func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoL |
|
|
|
// 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. |
|
|
|
// 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)) |
|
|
|
|
|
|
|
// 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 |
|
|
|
// Attack: smooth ramp toward target (or instant if attackCoeff=1.0) |
|
|
|
l.gainReduction += l.attackCoeff * (targetReduction - l.gainReduction) |
|
|
|
} else { |
|
|
|
l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) // smooth |
|
|
|
// Release: smooth decay back toward zero reduction |
|
|
|
l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) |
|
|
|
} |
|
|
|
|
|
|
|
gain := 1.0 - l.gainReduction |
|
|
|
|