From 775648363edeb351abe32a25473e2a9b24346c45 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 11 Apr 2026 11:21:02 +0200 Subject: [PATCH] dsp: burst-masking-optimized limiter attack (Bonello, JAES 2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StereoLimiter previously used instantaneous attack — gain was reduced in the same sample that exceeded the ceiling. While this guarantees zero overshoot, it suppresses transient peaks that the human auditory system cannot resolve anyway, reducing perceived loudness and causing audible gain pumping on percussive material. Changed to a 2ms exponential attack based on psychoacoustic burst masking research (O. Bonello, "Multiband audio processing and its influence on the coverage area of FM stereo transmission", JAES 2007): - The ear-brain system needs ~50ms to resolve distortion in a signal. For bursts shorter than 5ms, masking thresholds increase by up to 36 dB compared to steady-state (burst masking). - With 2ms attack, initial transient peaks pass through the limiter unattenuated and are caught by the downstream HardClip. The clip artifacts last <5ms (63% reduction in 2ms, 95% in 6ms), falling within the burst masking window. - The limiter no longer reacts to micro-transients that were already inaudible, raising average modulation level without increasing perceived distortion. Signal chain interaction: Audio → Drive → StereoLimiter (2ms attack, 150ms release) → HardClip (safety net, catches the <5ms transient peaks) → Cleanup LPF → HardClip → Stereo Encode → Composite Clipper The HardClip after the limiter remains as the compliance safety net. Peak modulation is guaranteed by the clip, not by the limiter. The limiter's job is average level management; the clipper handles peaks. Release time reduced from 200ms to 150ms for slightly faster recovery on sustained passages without audible pumping. --- internal/dsp/stereolimiter.go | 40 +++++++++++++++++++++++------------ internal/offline/generator.go | 5 ++++- 2 files changed, 31 insertions(+), 14 deletions(-) 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)