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.tags/v0.9.0
| @@ -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 ✓ | | |||
| @@ -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, | |||
| @@ -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 { | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||