From 6bb289ebc94640b9f90d4b4ef465268bf2a1e936 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 4 Apr 2026 00:13:27 +0200 Subject: [PATCH] feat: add BS.412 limiter and document the clip-filter-clip chain Introduce an optional ITU-R BS.412 MPX power limiter, tighten the low-pass/notch filter design around the protected composite path, and document the full DSP architecture and recommended Pluto configuration in detail. --- docs/DSP-CHAIN.md | 287 ++++++++++++++++++++++++++++++++++ docs/config.plutosdr.json | 6 +- internal/config/config.go | 2 + internal/dsp/biquad.go | 97 ++++++++++-- internal/dsp/bs412.go | 154 ++++++++++++++++++ internal/offline/generator.go | 31 ++++ 6 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 docs/DSP-CHAIN.md create mode 100644 internal/dsp/bs412.go diff --git a/docs/DSP-CHAIN.md b/docs/DSP-CHAIN.md new file mode 100644 index 0000000..3a287f6 --- /dev/null +++ b/docs/DSP-CHAIN.md @@ -0,0 +1,287 @@ +# fm-rds-tx — DSP Signal Chain & Konfiguration + +## Übersicht + +fm-rds-tx ist ein broadcast-konformer FM-Stereo-MPX-Encoder mit RDS für PlutoSDR/SoapySDR. +Die DSP-Kette folgt dem Industriestandard (Omnia, Orban, Stereo Tool) mit Clip-Filter-Clip- +Architektur und ITU-R BS.412 MPX Power Limiting. + +--- + +## Signalkette + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AUDIO INPUT │ +│ S16LE Stereo PCM via stdin (ffmpeg) oder interner Tongenerator │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 1: Pre-Emphasis + Band-Limiting (pro Kanal L/R) │ +│ │ +│ Audio × gain ──→ Pre-Emphasis (50µs EU / 75µs US) │ +│ ──→ 15kHz LPF (8th-order Chebyshev Type I, 0.5dB Ripple) │ +│ ──→ 19kHz Notch (Q=15, double-cascade) │ +│ │ +│ Frequenzantwort (verifiziert): │ +│ 10kHz: +0.1dB (flat) 15kHz: -0.2dB │ +│ 17kHz: -21dB 18.5kHz: -40dB │ +│ 19kHz: -155dB (tot) 22kHz: -51dB │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 2: Drive + Kompression + Clip₁ │ +│ │ +│ × OutputDrive │ +│ ──→ StereoLimiter (5ms Attack / 200ms Release, ceiling) │ +│ ──→ HardClip₁ (ceiling) │ +│ │ +│ Der Limiter komprimiert die Dynamik (bringt Average hoch). │ +│ Der Clip fängt Peaks die der Limiter's Attack verpasst. │ +│ "Slow-to-fast Progression" — Broadcast-Standard. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 3: Cleanup LPF + Clip₂ (Overshoot-Kompensator) │ +│ │ +│ ──→ 15kHz LPF (8th-order Chebyshev, identisch zu Stage 1) │ +│ ──→ HardClip₂ (ceiling) │ +│ │ +│ Der zweite LPF-Pass entfernt Clip₁-Harmonische. │ +│ Clip₂ fängt die LPF-Overshoots (IIR-Filter erzeugen diese). │ +│ Doppelter LPF-Pass verdoppelt die Guard-Band-Dämpfung. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 4: Stereo-Encode │ +│ │ +│ L/R ──→ Mono: (L+R)/2 (0–15kHz Baseband) │ +│ ──→ Stereo: (L-R)/2 × sin(38kHz) (23–53kHz DSB-SC) │ +│ ──→ Pilot: sin(19kHz) (phase-locked, kohärent) │ +│ ──→ RDS Carrier: sin(57kHz) (3× Pilot-Phase, kohärent) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 5: Composite Clip + Schutzfilter │ +│ │ +│ Audio-MPX (Mono + Stereo-Sub) │ +│ ──→ HardClip₃ (ceiling) — finale Deviations-Kontrolle │ +│ ──→ 19kHz Notch (Q=10, double) — Clip-Harmonische bei Pilot │ +│ ──→ 57kHz Notch (Q=10, double) — Clip-Harmonische bei RDS │ +│ │ +│ Guard Bands (total, 2× LPF + Notches): │ +│ 19kHz: >-80dB broadband, >-90dB exakt │ +│ 57kHz: >-100dB │ +│ (Omnia 11 Spezifikation: >80dB — wir sind on par) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 6: BS.412 MPX Power Limiter (optional) │ +│ │ +│ ──→ × BS412 Gain │ +│ │ +│ Rolling 60-Sekunden RMS-Messung auf dem Audio-Composite. │ +│ Langsamer Gain-Regler (2s Attack / 5s Release). │ +│ Zieht Pilot+RDS-Power automatisch vom Budget ab. │ +│ Pflicht in CH, DE, NL, FR für lizenzierte FM-Sender. │ +│ ~5dB Lautheitsverlust bei 0 dBr Threshold. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 7: Pilot + RDS Injection (fixe Amplitude) │ +│ │ +│ composite = audioMPX │ +│ + pilotLevel × sin(19kHz) — IMMER 9% │ +│ + rdsInjection × rdsWaveform — IMMER 4% │ +│ │ +│ Pilot und RDS werden NIE geclippt, NIE gefiltert, NIE vom │ +│ BS.412-Limiter berührt. Konstante Amplitude, immer. │ +│ │ +│ Peak Composite = ceiling + pilotLevel + rdsInjection ≈ 113% │ +│ (Standard-Broadcast-Praxis — Pilot/RDS werden von den meisten │ +│ Regulierungsbehörden aus dem Modulationslimit ausgenommen) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 8: FM-Modulation │ +│ │ +│ Split-Rate: Composite @ 228kHz ──→ FMUpsampler ──→ IQ @ 2.28MHz│ +│ maxDeviation × mpxGain = effektive Deviation │ +│ composite=1.0 → ±75kHz Deviation (bei mpxGain=1.0) │ +│ │ +│ Ausgabe: IQ-Samples (float32) an PlutoSDR via libiio │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Konfiguration + +### Audio + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `audio.gain` | float | 1.0 | Eingangsverstärkung vor Pre-Emphasis. 1.0 = unity. | +| `audio.inputPath` | string | "" | WAV-Datei als Quelle (leer = stdin oder Tongenerator) | + +**Empfehlung:** `gain: 1.0`. Pegel-Kontrolle über `outputDrive`. + +### FM — Audio-Processing + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `outputDrive` | float | 0.5 | 0–10 | Eingangsverstärkung vor Limiter/Clip. Bestimmt wie aggressiv die Kompression arbeitet. | +| `limiterEnabled` | bool | true | — | Aktiviert den StereoLimiter (5ms/200ms). | +| `limiterCeiling` | float | 1.0 | 0–2 | Maximum-Amplitude für Audio L/R und Composite. 1.0 = ±75kHz. | +| `preEmphasisTauUS` | float | 50 | 0/50/75 | Pre-Emphasis Zeitkonstante. 50µs = Europa/CH, 75µs = USA, 0 = aus. | + +**outputDrive im Detail:** + +Der Drive bestimmt den *Klangcharakter*, nicht die Lautstärke (wenn BS.412 aktiv ist): + +| Drive | Effekt | Einsatz | +|---|---|---| +| 1–2 | Wenig Kompression, dynamisch, sauber | Klassik, Jazz, Wortbeiträge | +| 3–4 | Moderate Kompression, ausgewogen | **Empfohlen für die meisten Formate** | +| 5–7 | Aggressive Kompression, dichter Sound | Pop/Rock-Formatradio | +| 8–10 | Maximale Dichte, hörbare Clip-Artefakte | Nicht empfohlen | + +**Empfehlung:** `outputDrive: 3.0` für sauberen, broadcast-fähigen Sound. + +### FM — Pilot & RDS + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `pilotLevel` | float | 0.09 | 0–0.2 | 19kHz Pilot-Amplitude. **Direkte Prozentangabe von ±75kHz.** 0.09 = 9% = ITU-Standard. | +| `rdsInjection` | float | 0.04 | 0–0.15 | RDS-Amplitude. **Direkte Prozentangabe.** 0.04 = 4%. Waveform ist unity-normalisiert. | +| `stereoEnabled` | bool | true | — | Stereo-Encode an/aus. Aus = nur Mono (L+R)/2, kein Pilot. | + +**Empfehlung:** `pilotLevel: 0.09`, `rdsInjection: 0.04`. Nicht ändern ausser es gibt einen guten Grund. + +Pilot und RDS sind **unabhängig vom OutputDrive** — sie bleiben immer bei der konfigurierten Amplitude, egal wie hart das Audio komprimiert wird. + +### FM — BS.412 (ITU-R MPX Power Limiter) + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `bs412Enabled` | bool | false | Aktiviert den BS.412 MPX Power Limiter. **Pflicht in CH, DE, NL, FR.** | +| `bs412ThresholdDBr` | float | 0 | Power-Limit in dBr. 0 = Standard. +3 = relaxiert. | + +**Was BS.412 macht:** +Begrenzt die durchschnittliche MPX-Leistung über ein rollendes 60-Sekunden-Fenster. +Reduziert die Audio-Amplitude langsam wenn die Power den Threshold überschreitet. +Pilot und RDS werden automatisch vom Power-Budget abgezogen. + +**Effekt auf den Sound:** +- ~5dB Lautheitsverlust bei 0 dBr mit aggressiver Kompression +- Weniger Verlust bei dynamischerem Material +- OutputDrive beeinflusst bei aktivem BS.412 nur den Klangcharakter, nicht die Lautheit + +**Empfehlung:** `bs412Enabled: true`, `bs412ThresholdDBr: 0` für BAKOM-Compliance. + +### FM — Hardware-Kalibrierung + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `mpxGain` | float | 1.0 | 0.1–5 | Skaliert die FM-Deviation (nicht den Composite!). Kompensiert DAC/SDR-Hardware-Faktoren. | +| `maxDeviationHz` | float | 75000 | 0–150000 | Maximale FM-Deviation in Hz. 75000 = Standard. | +| `compositeRateHz` | int | 228000 | — | Interne DSP-Sample-Rate. 228000 = 12×19kHz (optimal für Pilot-Kohärenz). | + +**MpxTool-Kalibrierung:** +1. `mpxGain: 1.0` setzen (keine Skalierung) +2. MpxTool Ref Level so einstellen dass **Pilot Level = 9.0%** anzeigt +3. Für PlutoSDR typisch: Ref Level ca. **-7.5 dBFS** +4. Einmal kalibrieren, nie wieder anfassen + +**Empfehlung:** `mpxGain: 1.0`, `maxDeviationHz: 75000`. Kalibrierung über MpxTool Ref Level. + +### RDS + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `rds.enabled` | bool | true | RDS an/aus | +| `rds.pi` | string | "1234" | Programme Identification (4-stellig hex). Muss mit BAKOM-Zuteilung übereinstimmen. | +| `rds.ps` | string | "FMRTX" | Programme Service Name (max 8 Zeichen). Stationsname auf dem Display. | +| `rds.radioText` | string | "" | Radio Text (max 64 Zeichen). Scrolltext auf dem Display. | +| `rds.pty` | int | 0 | Programme Type. 0=undefined, 1=News, 3=Info, 10=Pop, 15=Other Music, etc. | + +### Backend + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `backend.kind` | string | "file" | `"pluto"` für PlutoSDR, `"soapy"` für SoapySDR, `"file"` für Dateiausgabe | +| `backend.device` | string | "" | Device-String. PlutoSDR: `"usb:"` oder `"ip:192.168.2.1"` | +| `backend.deviceSampleRateHz` | float | 0 | SDR-Device-Rate. 2280000 = 10× compositeRate (optimal). | + +--- + +## Referenz-Konfiguration (BAKOM-konform, PlutoSDR) + +```json +{ + "audio": { + "gain": 1.0 + }, + "rds": { + "enabled": true, + "pi": "BEEF", + "ps": "RADIO-ZH", + "radioText": "Ihr Zürcher Kurzradio", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.09, + "rdsInjection": 0.04, + "preEmphasisTauUS": 50, + "outputDrive": 3.0, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "bs412Enabled": true, + "bs412ThresholdDBr": 0, + "mpxGain": 1.0, + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "fmModulationEnabled": true + }, + "backend": { + "kind": "pluto", + "device": "usb:", + "deviceSampleRateHz": 2280000 + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} +``` + +--- + +## Audio-Streaming (Produktionsbetrieb) + +```bash +ffmpeg -i http://stream-url/stream -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 44100 --config config.json +``` + +**Hinweis:** Unter Windows `cmd.exe` verwenden, nicht PowerShell (korrumpiert die Binary-Pipe). + +--- + +## Verifizierte Messwerte (MpxTool, PlutoSDR @ 100MHz) + +| Parameter | Messung | Soll | +|---|---|---| +| Pilot Level | 9.0% | 9% ✓ | +| RDS Injection | 3.4% | 4% (≈, BPSK-Mittelung) | +| MPX Peak | 105–110% | 100–113% ✓ | +| Guard Band 19kHz | >-80dB | >-80dB (Omnia 11: >80dB) ✓ | +| Audio Bandwidth | flat bis 15kHz | 15kHz ✓ | diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 1f9cec8..1676050 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -10,16 +10,18 @@ "enabled": true, "pi": "BEEF", "ps": "PLUTO-TX", - "radioText": "Hello from PlutoSDR", + "radioText": "TESTATSSENDUNG 1mW", "pty": 0 }, "fm": { + "bs412Enabled": true, + "bs412ThresholdDBr": 0, "frequencyMHz": 100.0, "stereoEnabled": true, "pilotLevel": 0.09, "rdsInjection": 0.04, "preEmphasisTauUS": 50, - "outputDrive": 4.3, + "outputDrive": 1.0, "mpxGain": 1.0, "compositeRateHz": 228000, "maxDeviationHz": 75000, diff --git a/internal/config/config.go b/internal/config/config.go index 2ea3c11..2f48f6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,8 @@ type FMConfig struct { LimiterCeiling float64 `json:"limiterCeiling"` FMModulationEnabled bool `json:"fmModulationEnabled"` MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) + BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) + BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) } type BackendConfig struct { diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go index 39f6048..11d7732 100644 --- a/internal/dsp/biquad.go +++ b/internal/dsp/biquad.go @@ -119,42 +119,109 @@ func NewNotch(centerHz, sampleRate, q float64) *Biquad { } } +// NewChebyshevI creates an Nth-order Chebyshev Type I lowpass filter. +// Passband ripple in dB (typ. 0.5), then steep rolloff into stopband. +// Much steeper transition band than Butterworth at the same order. +// At 228kHz, 8th-order, 0.5dB ripple, fc=15kHz: -40dB@19kHz (vs -17dB Butterworth). +func NewChebyshevI(order int, rippleDB, cutoffHz, sampleRate float64) *FilterChain { + if order < 2 || order%2 != 0 { + return &FilterChain{Stages: []Biquad{{b0: 1}}} + } + if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { + return &FilterChain{Stages: []Biquad{{b0: 1}}} + } + + N := order + nSections := N / 2 + + // Chebyshev parameters + epsilon := math.Sqrt(math.Pow(10, rippleDB/10) - 1) + v := math.Asinh(1/epsilon) / float64(N) + + // Bilinear transform constant and frequency pre-warp + c := 2.0 * sampleRate + warp := c * math.Tan(math.Pi*cutoffHz/sampleRate) + + stages := make([]Biquad, nSections) + + for i := 0; i < nSections; i++ { + // Analog prototype pole (normalized Ωc=1) + angle := float64(2*i+1) * math.Pi / float64(2*N) + sigmaN := -math.Sinh(v) * math.Sin(angle) + omegaN := math.Cosh(v) * math.Cos(angle) + + // Scale to actual cutoff frequency + sigma := sigmaN * warp + omega := omegaN * warp + + // Analog section: H(s) = A / (s² + Bs + A) + A := sigma*sigma + omega*omega + B := -2 * sigma // positive (sigma is negative) + + // Bilinear transform to digital biquad + c2 := c * c + a0 := c2 + B*c + A + + stages[i] = Biquad{ + b0: A / a0, + b1: 2 * A / a0, + b2: A / a0, + a1: (-2*c2 + 2*A) / a0, + a2: (c2 - B*c + A) / a0, + } + } + + // Normalize DC gain to unity (Chebyshev even-order has -ripple at DC) + dcGain := 1.0 + for _, s := range stages { + dcGain *= (s.b0 + s.b1 + s.b2) / (1 + s.a1 + s.a2) + } + if dcGain > 0 { + corr := 1.0 / dcGain + stages[0].b0 *= corr + stages[0].b1 *= corr + stages[0].b2 *= corr + } + + return &FilterChain{Stages: stages} +} + // --- Broadcast-specific filter factories --- -// NewAudioLPF creates the broadcast-standard audio lowpass at 14kHz. -// 8th-order Butterworth: -21dB@19kHz per pass. Two passes through the -// clip-filter-clip loop give -42dB broadband floor at 19kHz. +// NewAudioLPF creates the broadcast-standard audio lowpass at 15kHz. +// 8th-order Chebyshev Type I with 0.5dB passband ripple. +// Flat to 15kHz, then steep wall: -40dB@19kHz (vs -17dB Butterworth). +// Two passes through clip-filter-clip: -80dB broadband at 19kHz. func NewAudioLPF(sampleRate float64) *FilterChain { - return NewLPF8(14000, sampleRate) + return NewChebyshevI(8, 0.5, 15000, sampleRate) } // NewPilotNotch creates a double-cascade 19kHz notch for maximum -// rejection at the pilot frequency. Two stages give >60dB rejection. -// Applied BEFORE stereo encoding to kill audio energy at 19kHz. +// rejection at the pilot frequency. Q=15: only 1.3kHz wide (18.4–19.6kHz). +// The 8th-order LPF handles broadband; this kills the exact 19kHz peak. func NewPilotNotch(sampleRate float64) *FilterChain { return &FilterChain{ Stages: []Biquad{ - *NewNotch(19000, sampleRate, 5), - *NewNotch(19000, sampleRate, 5), + *NewNotch(19000, sampleRate, 15), + *NewNotch(19000, sampleRate, 15), }, } } // NewCompositeProtection creates double-cascade notch filters for the -// composite clipper. Each band gets two notch stages for >60dB rejection. -// Applied to clipped audio composite to remove clip harmonics from the -// pilot (19kHz) and RDS (57kHz) bands. +// composite clipper. Q=10: ~1.9kHz wide at 19kHz, ~5.7kHz wide at 57kHz. +// Narrow enough to preserve audio/stereo, deep enough to protect pilot/RDS. func NewCompositeProtection(sampleRate float64) (notch19, notch57 *FilterChain) { notch19 = &FilterChain{ Stages: []Biquad{ - *NewNotch(19000, sampleRate, 3), - *NewNotch(19000, sampleRate, 3), + *NewNotch(19000, sampleRate, 10), + *NewNotch(19000, sampleRate, 10), }, } notch57 = &FilterChain{ Stages: []Biquad{ - *NewNotch(57000, sampleRate, 3), - *NewNotch(57000, sampleRate, 3), + *NewNotch(57000, sampleRate, 10), + *NewNotch(57000, sampleRate, 10), }, } return diff --git a/internal/dsp/bs412.go b/internal/dsp/bs412.go new file mode 100644 index 0000000..527c709 --- /dev/null +++ b/internal/dsp/bs412.go @@ -0,0 +1,154 @@ +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), + } +} + +// 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 +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 9dfcc72..dd6afde 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -100,6 +100,7 @@ type Generator struct { cleanupLPF_R *dsp.FilterChain mpxNotch19 *dsp.FilterChain // composite clipper protection mpxNotch57 *dsp.FilterChain + bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional) // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame @@ -186,6 +187,16 @@ func (g *Generator) init() { g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) // Composite clipper protection: double-notch at 19kHz + 57kHz g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) + // BS.412 MPX power limiter (EU/CH requirement for licensed FM) + if g.cfg.FM.BS412Enabled { + chunkSec := 0.05 // 50ms chunks (matches engine default) + g.bs412 = dsp.NewBS412Limiter( + g.cfg.FM.BS412ThresholdDBr, + g.cfg.FM.PilotLevel, + g.cfg.FM.RDSInjection, + chunkSec, + ) + } if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) maxDev := g.cfg.FM.MaxDeviationHz @@ -279,6 +290,14 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame pilotAmp := lp.PilotLevel rdsAmp := lp.RDSInjection + // BS.412 MPX power limiter: uses previous chunk's measurement to set gain. + // Power is measured during this chunk and fed back at the end. + bs412Gain := 1.0 + var bs412PowerAccum float64 + if g.bs412 != nil { + bs412Gain = g.bs412.CurrentGain() + } + for i := 0; i < samples; i++ { in := g.source.NextFrame() @@ -316,6 +335,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame audioMPX = g.mpxNotch19.Process(audioMPX) audioMPX = g.mpxNotch57.Process(audioMPX) + // BS.412: apply gain and measure power + if bs412Gain < 1.0 { + audioMPX *= bs412Gain + } + bs412PowerAccum += audioMPX * audioMPX + // --- Stage 6: Add protected components --- composite := audioMPX if lp.StereoEnabled { @@ -334,6 +359,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} } } + + // BS.412: feed this chunk's average audio power for next chunk's gain calculation + if g.bs412 != nil && samples > 0 { + g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) + } + return frame }