// 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"` }