Sfoglia il codice sorgente

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.
tags/v0.9.0
Jan Svabenik 1 mese fa
parent
commit
6bb289ebc9
6 ha cambiato i file con 560 aggiunte e 17 eliminazioni
  1. +287
    -0
      docs/DSP-CHAIN.md
  2. +4
    -2
      docs/config.plutosdr.json
  3. +2
    -0
      internal/config/config.go
  4. +82
    -15
      internal/dsp/biquad.go
  5. +154
    -0
      internal/dsp/bs412.go
  6. +31
    -0
      internal/offline/generator.go

+ 287
- 0
docs/DSP-CHAIN.md Vedi File

@@ -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 ✓ |

+ 4
- 2
docs/config.plutosdr.json Vedi File

@@ -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,


+ 2
- 0
internal/config/config.go Vedi File

@@ -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 {


+ 82
- 15
internal/dsp/biquad.go Vedi File

@@ -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


+ 154
- 0
internal/dsp/bs412.go Vedi File

@@ -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
}

+ 31
- 0
internal/offline/generator.go Vedi File

@@ -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
}



Loading…
Annulla
Salva