Просмотр исходного кода

dsp: burst-masking-optimized limiter attack (Bonello, JAES 2007)

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.
main
Jan 1 месяц назад
Родитель
Сommit
775648363e
2 измененных файлов: 31 добавлений и 14 удалений
  1. +27
    -13
      internal/dsp/stereolimiter.go
  2. +4
    -1
      internal/offline/generator.go

+ 27
- 13
internal/dsp/stereolimiter.go Просмотреть файл

@@ -6,20 +6,24 @@ import "math"
// driven by the peak of max(|L|, |R|). This preserves the stereo image // driven by the peak of max(|L|, |R|). This preserves the stereo image
// while preventing either channel from exceeding the ceiling. // 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. // Release is smooth (exponential decay) to avoid audible pumping.
type StereoLimiter struct { type StereoLimiter struct {
ceiling float64 ceiling float64
attackCoeff float64
releaseCoeff float64 releaseCoeff float64
gainReduction 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). // releaseMs controls how quickly gain recovers after a peak (typ. 50-200ms).
func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoLimiter { func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoLimiter {
if ceiling <= 0 { if ceiling <= 0 {
@@ -28,10 +32,19 @@ func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoL
if releaseMs <= 0 { if releaseMs <= 0 {
releaseMs = 100 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 releaseSamples := releaseMs * sampleRate / 1000


return &StereoLimiter{ return &StereoLimiter{
ceiling: ceiling, ceiling: ceiling,
attackCoeff: attackCoeff,
releaseCoeff: 1.0 - math.Exp(-1.0/releaseSamples), 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 // Process applies stereo-linked limiting. Both channels receive the
// same gain factor, determined by the louder of the two. // 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) { func (l *StereoLimiter) Process(left, right float64) (float64, float64) {
peak := math.Max(math.Abs(left), math.Abs(right)) peak := math.Max(math.Abs(left), math.Abs(right))


// Target: how much gain reduction do we need right now?
targetReduction := 0.0 targetReduction := 0.0
if peak > l.ceiling { if peak > l.ceiling {
targetReduction = 1.0 - l.ceiling/peak 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 { 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 { } 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 gain := 1.0 - l.gainReduction


+ 4
- 1
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. // The clips after it catch the peaks the limiter's attack time misses.
// This is the "slow-to-fast progression" from broadcast processing: // This is the "slow-to-fast progression" from broadcast processing:
// slow limiter → fast clips. // 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) // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate) g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)


Загрузка…
Отмена
Сохранить