|
|
|
@@ -0,0 +1,311 @@ |
|
|
|
// Package dsp — CompositeClipper implements a broadcast-grade iterative |
|
|
|
// composite MPX clipper with optional soft-knee clipping and look-ahead |
|
|
|
// peak limiting. |
|
|
|
// |
|
|
|
// # Signal flow |
|
|
|
// |
|
|
|
// audioMPX (mono + stereo sub, no pilot/RDS) |
|
|
|
// → Look-ahead pre-limiter (optional, reduces peak:average before clipping) |
|
|
|
// → N iterations of: Clip → Notch19kHz → Notch57kHz |
|
|
|
// → Final hard-clip safety net |
|
|
|
// → output |
|
|
|
// |
|
|
|
// Each iteration reduces spectral splatter into the pilot (19 kHz) and RDS |
|
|
|
// (57 kHz) guard bands caused by the previous clip, while the subsequent |
|
|
|
// clip catches the filter overshoot. After 2-3 iterations the signal |
|
|
|
// converges: peaks stay below ceiling AND protected bands are clean. |
|
|
|
// |
|
|
|
// The look-ahead limiter uses a delay line so the envelope detector sees |
|
|
|
// peaks before they reach the clipper. This allows smooth gain reduction |
|
|
|
// instead of hard clipping, producing fewer harmonics per iteration. |
|
|
|
// |
|
|
|
// # Comparable to |
|
|
|
// |
|
|
|
// Omnia.11 composite processing, Orban Optimod 8700 composite clipper, |
|
|
|
// and similar ITU-R SM.1268-compliant broadcast processors. |
|
|
|
package dsp |
|
|
|
|
|
|
|
import "math" |
|
|
|
|
|
|
|
// CompositeClipperConfig holds the parameters for NewCompositeClipper. |
|
|
|
type CompositeClipperConfig struct { |
|
|
|
// Ceiling is the peak output level. Typically 1.0 (= 100% modulation |
|
|
|
// for the audio portion). Pilot and RDS are added externally after. |
|
|
|
Ceiling float64 |
|
|
|
|
|
|
|
// Iterations is the number of clip-filter-clip passes (1-5). |
|
|
|
// More iterations = cleaner guard bands, slightly more latency from |
|
|
|
// filter group delay. 3 is a good default; 1 is conservative, 5 is |
|
|
|
// aggressive (Omnia "brick wall" territory). |
|
|
|
Iterations int |
|
|
|
|
|
|
|
// SoftKnee sets the width of the soft-clip transition zone below |
|
|
|
// ceiling, in linear amplitude. 0 = hard clip, 0.15 = moderate, |
|
|
|
// 0.3 = gentle. The soft clipper uses tanh() compression above |
|
|
|
// (ceiling - softKnee), producing fewer harmonics per iteration |
|
|
|
// at the cost of slightly lower peak density. |
|
|
|
SoftKnee float64 |
|
|
|
|
|
|
|
// LookaheadMs sets the look-ahead delay in milliseconds. 0 = disabled. |
|
|
|
// Typical values: 0.5-2.0 ms. The limiter pre-reduces peaks before |
|
|
|
// they hit the clipper, so the clipper does less work and generates |
|
|
|
// fewer harmonics. Introduces LookaheadMs of audio latency. |
|
|
|
LookaheadMs float64 |
|
|
|
|
|
|
|
// SampleRate must match the composite DSP rate (typically 228000 Hz). |
|
|
|
SampleRate float64 |
|
|
|
} |
|
|
|
|
|
|
|
// CompositeClipper is a stateful per-sample processor. Not thread-safe — |
|
|
|
// call Process from the single DSP goroutine only. |
|
|
|
type CompositeClipper struct { |
|
|
|
ceiling float64 |
|
|
|
softKnee float64 |
|
|
|
iterations int |
|
|
|
|
|
|
|
// Per-iteration filter banks — each iteration needs independent state |
|
|
|
// because it processes a different signal (output of previous clip). |
|
|
|
notch19 []*FilterChain |
|
|
|
notch57 []*FilterChain |
|
|
|
|
|
|
|
// Look-ahead limiter state |
|
|
|
laEnabled bool |
|
|
|
laDelay []float64 // circular delay buffer |
|
|
|
laLen int // delay length in samples |
|
|
|
laWrite int // write position |
|
|
|
laEnv float64 // peak envelope (tracks input peaks) |
|
|
|
laGain float64 // current gain multiplier (applied to delayed output) |
|
|
|
laAttack float64 // envelope/gain attack coefficient |
|
|
|
laRelease float64 // envelope/gain release coefficient |
|
|
|
} |
|
|
|
|
|
|
|
// NewCompositeClipper creates a ready-to-use composite clipper. |
|
|
|
// Returns nil if cfg is invalid or sample rate is zero. |
|
|
|
func NewCompositeClipper(cfg CompositeClipperConfig) *CompositeClipper { |
|
|
|
if cfg.SampleRate <= 0 { |
|
|
|
return nil |
|
|
|
} |
|
|
|
if cfg.Ceiling <= 0 { |
|
|
|
cfg.Ceiling = 1.0 |
|
|
|
} |
|
|
|
if cfg.Iterations < 1 { |
|
|
|
cfg.Iterations = 1 |
|
|
|
} |
|
|
|
if cfg.Iterations > 5 { |
|
|
|
cfg.Iterations = 5 |
|
|
|
} |
|
|
|
if cfg.SoftKnee < 0 { |
|
|
|
cfg.SoftKnee = 0 |
|
|
|
} |
|
|
|
|
|
|
|
c := &CompositeClipper{ |
|
|
|
ceiling: cfg.Ceiling, |
|
|
|
softKnee: cfg.SoftKnee, |
|
|
|
iterations: cfg.Iterations, |
|
|
|
notch19: make([]*FilterChain, cfg.Iterations), |
|
|
|
notch57: make([]*FilterChain, cfg.Iterations), |
|
|
|
laGain: 1.0, |
|
|
|
} |
|
|
|
|
|
|
|
// Build per-iteration filter pairs. |
|
|
|
// Double-cascade notch at each frequency for deep rejection. |
|
|
|
// Q=15 at 19 kHz → narrow (~1.3 kHz), preserves stereo sub. |
|
|
|
// Q=10 at 57 kHz → slightly wider (~5.7 kHz), covers RDS bandwidth. |
|
|
|
for i := 0; i < cfg.Iterations; i++ { |
|
|
|
c.notch19[i] = &FilterChain{ |
|
|
|
Stages: []Biquad{ |
|
|
|
*NewNotch(19000, cfg.SampleRate, 15), |
|
|
|
*NewNotch(19000, cfg.SampleRate, 15), |
|
|
|
}, |
|
|
|
} |
|
|
|
c.notch57[i] = &FilterChain{ |
|
|
|
Stages: []Biquad{ |
|
|
|
*NewNotch(57000, cfg.SampleRate, 10), |
|
|
|
*NewNotch(57000, cfg.SampleRate, 10), |
|
|
|
}, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Look-ahead limiter |
|
|
|
if cfg.LookaheadMs > 0 { |
|
|
|
c.laEnabled = true |
|
|
|
c.laLen = int(math.Round(cfg.LookaheadMs * cfg.SampleRate / 1000)) |
|
|
|
if c.laLen < 1 { |
|
|
|
c.laLen = 1 |
|
|
|
} |
|
|
|
c.laDelay = make([]float64, c.laLen) |
|
|
|
|
|
|
|
// Attack: ramp down over half the look-ahead window so gain is |
|
|
|
// fully reduced by the time the peak exits the delay line. |
|
|
|
// Using half the window gives ~86% reduction at exit (2 time constants). |
|
|
|
attackSamples := float64(c.laLen) / 2 |
|
|
|
if attackSamples < 1 { |
|
|
|
attackSamples = 1 |
|
|
|
} |
|
|
|
c.laAttack = 1.0 - math.Exp(-1.0/attackSamples) |
|
|
|
|
|
|
|
// Release: 20ms — fast enough to recover between transients, |
|
|
|
// slow enough to avoid gain pumping on dense material. |
|
|
|
releaseSamples := 0.020 * cfg.SampleRate |
|
|
|
if releaseSamples < 1 { |
|
|
|
releaseSamples = 1 |
|
|
|
} |
|
|
|
c.laRelease = 1.0 - math.Exp(-1.0/releaseSamples) |
|
|
|
} |
|
|
|
|
|
|
|
return c |
|
|
|
} |
|
|
|
|
|
|
|
// Process runs one composite sample through the full chain. |
|
|
|
// |
|
|
|
// Input: audio-only MPX (mono + stereo sub, no pilot, no RDS). |
|
|
|
// Output: peak-limited, spectrally clean MPX. Never exceeds ceiling. |
|
|
|
// |
|
|
|
// The caller adds pilot and RDS to the output AFTER this call. |
|
|
|
func (c *CompositeClipper) Process(audioMPX float64) float64 { |
|
|
|
x := audioMPX |
|
|
|
|
|
|
|
// --- Stage 1: Look-ahead pre-limiting --- |
|
|
|
// Reduces peaks smoothly before the clipper sees them. |
|
|
|
// Fewer hard-clip events → fewer harmonics → cleaner output. |
|
|
|
if c.laEnabled { |
|
|
|
x = c.processLookahead(x) |
|
|
|
} |
|
|
|
|
|
|
|
// --- Stage 2: Iterative clip → notch → notch --- |
|
|
|
// Each pass: |
|
|
|
// - Clip catches peaks (from input or previous filter overshoot) |
|
|
|
// - Notch19 removes energy at pilot frequency |
|
|
|
// - Notch57 removes energy at RDS frequency |
|
|
|
// - Filter ringing creates small overshoots → next pass catches them |
|
|
|
// After N iterations the overshoot converges to near-zero. |
|
|
|
for i := 0; i < c.iterations; i++ { |
|
|
|
x = c.clip(x) |
|
|
|
x = c.notch19[i].Process(x) |
|
|
|
x = c.notch57[i].Process(x) |
|
|
|
} |
|
|
|
|
|
|
|
// --- Stage 3: Final safety hard-clip --- |
|
|
|
// Catches residual overshoot from the last iteration's notch filters. |
|
|
|
// Even with 3+ iterations there can be sub-0.1 dB overshoot from the |
|
|
|
// final notch; this guarantees the output never exceeds ceiling. |
|
|
|
x = HardClip(x, c.ceiling) |
|
|
|
|
|
|
|
return x |
|
|
|
} |
|
|
|
|
|
|
|
// processLookahead applies look-ahead peak limiting. |
|
|
|
// |
|
|
|
// The input enters a delay line. The envelope detector runs on the INPUT |
|
|
|
// (not the delayed output), so it sees peaks LookaheadMs before they exit |
|
|
|
// the delay. Gain is reduced smoothly over the look-ahead window, so when |
|
|
|
// the peak arrives at the output, the gain is already low enough. |
|
|
|
func (c *CompositeClipper) processLookahead(in float64) float64 { |
|
|
|
// Read delayed sample (from laLen samples ago) |
|
|
|
out := c.laDelay[c.laWrite] |
|
|
|
// Write new input into delay line |
|
|
|
c.laDelay[c.laWrite] = in |
|
|
|
c.laWrite++ |
|
|
|
if c.laWrite >= c.laLen { |
|
|
|
c.laWrite = 0 |
|
|
|
} |
|
|
|
|
|
|
|
// Envelope follower on the raw input — sees peaks before they exit |
|
|
|
absIn := math.Abs(in) |
|
|
|
if absIn > c.laEnv { |
|
|
|
// Attack: fast rise toward the peak |
|
|
|
c.laEnv += c.laAttack * (absIn - c.laEnv) |
|
|
|
} else { |
|
|
|
// Release: slow decay back down |
|
|
|
c.laEnv += c.laRelease * (absIn - c.laEnv) |
|
|
|
} |
|
|
|
|
|
|
|
// Target gain to keep output at ceiling |
|
|
|
targetGain := 1.0 |
|
|
|
if c.laEnv > c.ceiling { |
|
|
|
targetGain = c.ceiling / c.laEnv |
|
|
|
} |
|
|
|
|
|
|
|
// Smooth gain transitions (same attack/release as envelope) |
|
|
|
if targetGain < c.laGain { |
|
|
|
c.laGain += c.laAttack * (targetGain - c.laGain) |
|
|
|
} else { |
|
|
|
c.laGain += c.laRelease * (targetGain - c.laGain) |
|
|
|
} |
|
|
|
|
|
|
|
return out * c.laGain |
|
|
|
} |
|
|
|
|
|
|
|
// clip applies either soft-knee or hard clipping depending on config. |
|
|
|
func (c *CompositeClipper) clip(x float64) float64 { |
|
|
|
if c.softKnee <= 0 { |
|
|
|
return HardClip(x, c.ceiling) |
|
|
|
} |
|
|
|
return SoftClip(x, c.ceiling, c.softKnee) |
|
|
|
} |
|
|
|
|
|
|
|
// SoftClip applies tanh-based soft clipping with a configurable knee. |
|
|
|
// |
|
|
|
// Below (ceiling - knee): linear, no distortion. |
|
|
|
// Above (ceiling - knee): tanh compression, asymptotically approaching ceiling. |
|
|
|
// The transition is C1-continuous (slope = 1.0 at the knee boundary). |
|
|
|
// |
|
|
|
// This generates significantly fewer harmonics than hard clipping, which |
|
|
|
// means the notch filters have less work to do and produce less overshoot. |
|
|
|
func SoftClip(x, ceiling, knee float64) float64 { |
|
|
|
if knee <= 0 { |
|
|
|
return HardClip(x, ceiling) |
|
|
|
} |
|
|
|
|
|
|
|
threshold := ceiling - knee |
|
|
|
if threshold < 0 { |
|
|
|
threshold = 0 |
|
|
|
} |
|
|
|
|
|
|
|
ax := math.Abs(x) |
|
|
|
if ax <= threshold { |
|
|
|
return x // linear region — no distortion |
|
|
|
} |
|
|
|
|
|
|
|
s := 1.0 |
|
|
|
if x < 0 { |
|
|
|
s = -1.0 |
|
|
|
} |
|
|
|
|
|
|
|
// tanh compression: excess above threshold is compressed toward knee. |
|
|
|
// At excess=0: output = threshold, slope = 1.0 (C1 continuous). |
|
|
|
// At excess→∞: output → threshold + knee = ceiling. |
|
|
|
excess := ax - threshold |
|
|
|
compressed := threshold + knee*math.Tanh(excess/knee) |
|
|
|
return s * compressed |
|
|
|
} |
|
|
|
|
|
|
|
// Reset clears all filter and look-ahead state, as if freshly constructed. |
|
|
|
func (c *CompositeClipper) Reset() { |
|
|
|
for i := range c.notch19 { |
|
|
|
c.notch19[i].Reset() |
|
|
|
c.notch57[i].Reset() |
|
|
|
} |
|
|
|
if c.laEnabled { |
|
|
|
for i := range c.laDelay { |
|
|
|
c.laDelay[i] = 0 |
|
|
|
} |
|
|
|
c.laWrite = 0 |
|
|
|
c.laEnv = 0 |
|
|
|
c.laGain = 1.0 |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Stats returns diagnostic values for monitoring/logging. |
|
|
|
func (c *CompositeClipper) Stats() CompositeClipperStats { |
|
|
|
return CompositeClipperStats{ |
|
|
|
LookaheadGain: c.laGain, |
|
|
|
Envelope: c.laEnv, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// CompositeClipperStats exposes internal state for diagnostics. |
|
|
|
type CompositeClipperStats struct { |
|
|
|
LookaheadGain float64 `json:"lookaheadGain"` |
|
|
|
Envelope float64 `json:"envelope"` |
|
|
|
} |