diff --git a/internal/dsp/stereolimiter.go b/internal/dsp/stereolimiter.go index 08029ec..3df46a6 100644 --- a/internal/dsp/stereolimiter.go +++ b/internal/dsp/stereolimiter.go @@ -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 diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 4a0bb20..7b16f9a 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -217,7 +217,10 @@ func (g *Generator) init() { // The clips after it catch the peaks the limiter's attack time misses. // This is the "slow-to-fast progression" from broadcast processing: // slow limiter → fast clips. - g.limiter = dsp.NewStereoLimiter(ceiling, 5, 200, g.sampleRate) + // Burst-masking-optimized limiter (Bonello, JAES 2007): + // 2ms attack lets initial transient peaks clip for <5ms (burst-masked). + // 150ms release avoids audible pumping on sustained passages. + g.limiter = dsp.NewStereoLimiter(ceiling, 2, 150, g.sampleRate) // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics) g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate) g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)