|
- package dsp
-
- import "math"
-
- // BS412Limiter implements ITU-R BS.412 MPX power limiting.
- // Measures the rolling 60-second average power of the composite signal
- // and reduces audio gain when the power exceeds the threshold.
- //
- // The threshold is specified in dBr where 0 dBr is the reference power
- // of a fully modulated mono signal (composite peak = 1.0, power = 0.5).
- //
- // Pilot and RDS power are accounted for: the audio power budget is
- // reduced by their constant contribution so the total stays within limits.
- type BS412Limiter struct {
- enabled bool
- thresholdPow float64 // linear power threshold for total MPX
- audioBudget float64 // = thresholdPow - pilotPow - rdsPow
-
- // Rolling 60-second power integrator
- powerBuf []float64 // per-chunk average power values
- bufIdx int
- bufFull bool // true once the buffer has wrapped at least once
- powerSum float64
-
- // Slow gain controller
- gain float64 // current output gain (0..1)
- attackCoeff float64 // gain reduction speed
- releaseCoeff float64 // gain recovery speed
- }
-
- // NewBS412Limiter creates a BS.412 MPX power limiter.
- //
- // Parameters:
- // - thresholdDBr: power limit in dBr (0 = standard, +3 = relaxed)
- // - pilotLevel: pilot amplitude in composite (e.g. 0.09)
- // - rdsInjection: RDS amplitude in composite (e.g. 0.04)
- // - chunkDurationSec: duration of each processing chunk (e.g. 0.05 for 50ms)
- func NewBS412Limiter(thresholdDBr, pilotLevel, rdsInjection, chunkDurationSec float64) *BS412Limiter {
- // Reference power: 0 dBr = power of mono sine at peak=1.0 = 0.5
- refPower := 0.5
- thresholdPow := refPower * math.Pow(10, thresholdDBr/10)
-
- // Constant power contributions from pilot and RDS
- pilotPow := pilotLevel * pilotLevel / 2 // sine wave RMS²
- rdsPow := rdsInjection * rdsInjection / 4 // BPSK has ~half the power of a sine
-
- audioBudget := thresholdPow - pilotPow - rdsPow
- if audioBudget < 0.01 {
- audioBudget = 0.01
- }
-
- // 60-second window in chunks
- windowSec := 60.0
- bufLen := int(math.Ceil(windowSec / chunkDurationSec))
- if bufLen < 10 {
- bufLen = 10
- }
-
- // Attack: ~2 seconds (slow, avoids pumping)
- // Release: ~5 seconds (very slow, smooth recovery)
- attackTC := 2.0 / chunkDurationSec // time constant in chunks
- releaseTC := 5.0 / chunkDurationSec
-
- return &BS412Limiter{
- enabled: true,
- thresholdPow: thresholdPow,
- audioBudget: audioBudget,
- powerBuf: make([]float64, bufLen),
- gain: 1.0,
- attackCoeff: 1.0 - math.Exp(-1.0/attackTC),
- releaseCoeff: 1.0 - math.Exp(-1.0/releaseTC),
- }
- }
-
- // UpdateChunkDuration reconfigures the limiter for a new chunk size.
- // Call this from GenerateFrame when the actual chunk duration is known
- // (computed as samples/sampleRate) to avoid calibration errors if the
- // engine's chunk duration differs from the value passed to NewBS412Limiter.
- // Safe to call on every chunk; no-ops when duration has not changed.
- func (l *BS412Limiter) UpdateChunkDuration(chunkSec float64) {
- if chunkSec <= 0 {
- return
- }
- windowSec := 60.0
- newBufLen := int(math.Ceil(windowSec / chunkSec))
- if newBufLen < 10 {
- newBufLen = 10
- }
- if newBufLen == len(l.powerBuf) {
- return // no change
- }
- // Resize buffer — drop history to avoid stale power readings from the
- // old window size distorting the rolling average.
- l.powerBuf = make([]float64, newBufLen)
- l.bufIdx = 0
- l.bufFull = false
- l.powerSum = 0
- attackTC := 2.0 / chunkSec
- releaseTC := 5.0 / chunkSec
- l.attackCoeff = 1.0 - math.Exp(-1.0/attackTC)
- l.releaseCoeff = 1.0 - math.Exp(-1.0/releaseTC)
- }
-
- // ProcessChunk measures the audio power of a chunk and returns the
- // gain factor to apply to the audio composite for BS.412 compliance.
- // Call once per chunk with the average audio power of that chunk.
- //
- // audioPower = (1/N) × Σ sample² over the chunk's audio composite samples.
- func (l *BS412Limiter) ProcessChunk(audioPower float64) float64 {
- if !l.enabled {
- return 1.0
- }
-
- // Update rolling 60-second power average
- old := l.powerBuf[l.bufIdx]
- l.powerBuf[l.bufIdx] = audioPower
- l.powerSum += audioPower - old
- l.bufIdx++
- if l.bufIdx >= len(l.powerBuf) {
- l.bufIdx = 0
- l.bufFull = true
- }
-
- // Calculate average power over the window
- var count int
- if l.bufFull {
- count = len(l.powerBuf)
- } else {
- count = l.bufIdx
- }
- if count < 1 {
- return 1.0
- }
- avgPower := l.powerSum / float64(count)
-
- // Target gain: bring average audio power to budget
- targetGain := 1.0
- if avgPower > l.audioBudget && avgPower > 0 {
- targetGain = math.Sqrt(l.audioBudget / avgPower)
- }
-
- // Smooth gain changes (slow attack, slower release)
- if targetGain < l.gain {
- l.gain += l.attackCoeff * (targetGain - l.gain)
- } else {
- l.gain += l.releaseCoeff * (targetGain - l.gain)
- }
-
- // Clamp
- if l.gain < 0.01 {
- l.gain = 0.01
- }
- if l.gain > 1.0 {
- l.gain = 1.0
- }
-
- return l.gain
- }
-
- // CurrentGain returns the current gain factor (0..1).
- // Called at the start of each chunk to get the gain to apply.
- func (l *BS412Limiter) CurrentGain() float64 {
- return l.gain
- }
-
- // CurrentGainDB returns the current gain reduction in dB (negative = reducing).
- func (l *BS412Limiter) CurrentGainDB() float64 {
- if l.gain <= 0 {
- return -100
- }
- return 20 * math.Log10(l.gain)
- }
-
- // Reset clears the power history and restores unity gain.
- func (l *BS412Limiter) Reset() {
- for i := range l.powerBuf {
- l.powerBuf[i] = 0
- }
- l.bufIdx = 0
- l.bufFull = false
- l.powerSum = 0
- l.gain = 1.0
- }
|