Rework the DSP chain to a clip-filter-clip architecture with cascaded 14 kHz low-pass stages, double 19/57 kHz protection notches, fixed pilot/RDS injection semantics, and explicit MPX gain calibration support. Update config defaults and tests to match the new broadcast-style modulation budgeting and protected composite path.tags/v0.9.0
| @@ -16,10 +16,11 @@ | |||||
| "fm": { | "fm": { | ||||
| "frequencyMHz": 100.0, | "frequencyMHz": 100.0, | ||||
| "stereoEnabled": true, | "stereoEnabled": true, | ||||
| "pilotLevel": 0.041, | |||||
| "rdsInjection": 0.021, | |||||
| "pilotLevel": 0.09, | |||||
| "rdsInjection": 0.04, | |||||
| "preEmphasisTauUS": 50, | "preEmphasisTauUS": 50, | ||||
| "outputDrive": 2.2, | |||||
| "outputDrive": 4.3, | |||||
| "mpxGain": 1.0, | |||||
| "compositeRateHz": 228000, | "compositeRateHz": 228000, | ||||
| "maxDeviationHz": 75000, | "maxDeviationHz": 75000, | ||||
| "limiterEnabled": true, | "limiterEnabled": true, | ||||
| @@ -115,6 +115,11 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | |||||
| if maxDev <= 0 { | if maxDev <= 0 { | ||||
| maxDev = 75000 | maxDev = 75000 | ||||
| } | } | ||||
| // mpxGain scales the FM deviation to compensate for hardware | |||||
| // DAC/SDR scaling factors. DSP chain stays at logical 0-1.0 levels. | |||||
| if cfg.FM.MpxGain > 0 && cfg.FM.MpxGain != 1.0 { | |||||
| maxDev *= cfg.FM.MpxGain | |||||
| } | |||||
| upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) | upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) | ||||
| log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", | log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", | ||||
| compositeRate, deviceRate, deviceRate/compositeRate) | compositeRate, deviceRate, deviceRate/compositeRate) | ||||
| @@ -35,8 +35,8 @@ type RDSConfig struct { | |||||
| type FMConfig struct { | type FMConfig struct { | ||||
| FrequencyMHz float64 `json:"frequencyMHz"` | FrequencyMHz float64 `json:"frequencyMHz"` | ||||
| StereoEnabled bool `json:"stereoEnabled"` | StereoEnabled bool `json:"stereoEnabled"` | ||||
| PilotLevel float64 `json:"pilotLevel"` // linear injection level in composite (e.g. 0.1 = 10%) | |||||
| RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%) | |||||
| PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) | |||||
| RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) | |||||
| PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off | PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off | ||||
| OutputDrive float64 `json:"outputDrive"` | OutputDrive float64 `json:"outputDrive"` | ||||
| CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate | CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate | ||||
| @@ -44,6 +44,7 @@ type FMConfig struct { | |||||
| LimiterEnabled bool `json:"limiterEnabled"` | LimiterEnabled bool `json:"limiterEnabled"` | ||||
| LimiterCeiling float64 `json:"limiterCeiling"` | LimiterCeiling float64 `json:"limiterCeiling"` | ||||
| FMModulationEnabled bool `json:"fmModulationEnabled"` | FMModulationEnabled bool `json:"fmModulationEnabled"` | ||||
| MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) | |||||
| } | } | ||||
| type BackendConfig struct { | type BackendConfig struct { | ||||
| @@ -64,8 +65,8 @@ func Default() Config { | |||||
| FM: FMConfig{ | FM: FMConfig{ | ||||
| FrequencyMHz: 100.0, | FrequencyMHz: 100.0, | ||||
| StereoEnabled: true, | StereoEnabled: true, | ||||
| PilotLevel: 0.1, | |||||
| RDSInjection: 0.05, | |||||
| PilotLevel: 0.09, | |||||
| RDSInjection: 0.04, | |||||
| PreEmphasisTauUS: 50, | PreEmphasisTauUS: 50, | ||||
| OutputDrive: 0.5, | OutputDrive: 0.5, | ||||
| CompositeRateHz: 228000, | CompositeRateHz: 228000, | ||||
| @@ -73,6 +74,7 @@ func Default() Config { | |||||
| LimiterEnabled: true, | LimiterEnabled: true, | ||||
| LimiterCeiling: 1.0, | LimiterCeiling: 1.0, | ||||
| FMModulationEnabled: true, | FMModulationEnabled: true, | ||||
| MpxGain: 1.0, | |||||
| }, | }, | ||||
| Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, | Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, | ||||
| Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, | Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, | ||||
| @@ -128,7 +130,7 @@ func (c Config) Validate() error { | |||||
| if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 { | if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 { | ||||
| return fmt.Errorf("fm.rdsInjection out of range") | return fmt.Errorf("fm.rdsInjection out of range") | ||||
| } | } | ||||
| if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 3 { | |||||
| if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 { | |||||
| return fmt.Errorf("fm.outputDrive out of range (0..3)") | return fmt.Errorf("fm.outputDrive out of range (0..3)") | ||||
| } | } | ||||
| if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { | if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { | ||||
| @@ -143,6 +145,10 @@ func (c Config) Validate() error { | |||||
| if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { | if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { | ||||
| return fmt.Errorf("fm.limiterCeiling out of range") | return fmt.Errorf("fm.limiterCeiling out of range") | ||||
| } | } | ||||
| if c.FM.MpxGain == 0 { c.FM.MpxGain = 1.0 } // default if omitted from JSON | |||||
| if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { | |||||
| return fmt.Errorf("fm.mpxGain out of range (0.1..5)") | |||||
| } | |||||
| if c.Backend.Kind == "" { | if c.Backend.Kind == "" { | ||||
| return fmt.Errorf("backend.kind is required") | return fmt.Errorf("backend.kind is required") | ||||
| } | } | ||||
| @@ -68,12 +68,7 @@ func newBiquadLPFWithQ(cutoffHz, sampleRate, q float64) *Biquad { | |||||
| } | } | ||||
| // NewLPF4 creates a 4th-order Butterworth lowpass (two cascaded biquads). | // NewLPF4 creates a 4th-order Butterworth lowpass (two cascaded biquads). | ||||
| // Provides -24dB/octave rolloff. At 228kHz with fc=15kHz: | |||||
| // | |||||
| // 15kHz: -6dB, 19kHz: -14dB, 38kHz: -36dB, 57kHz: -54dB | |||||
| func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { | func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { | ||||
| // 4th-order Butterworth: cascade two 2nd-order sections with Q values | |||||
| // derived from the pole angles: π/8 and 3π/8 | |||||
| q1 := 1.0 / (2 * math.Cos(math.Pi/8)) // ≈ 0.5412 | q1 := 1.0 / (2 * math.Cos(math.Pi/8)) // ≈ 0.5412 | ||||
| q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 | q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 | ||||
| return &FilterChain{ | return &FilterChain{ | ||||
| @@ -84,6 +79,26 @@ func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { | |||||
| } | } | ||||
| } | } | ||||
| // NewLPF8 creates an 8th-order Butterworth lowpass (four cascaded biquads). | |||||
| // Provides -48dB/octave rolloff. At 228kHz with fc=15kHz: | |||||
| // | |||||
| // 15kHz: -6dB, 19kHz: -28dB, 38kHz: -72dB, 57kHz: -108dB | |||||
| func NewLPF8(cutoffHz, sampleRate float64) *FilterChain { | |||||
| // 8th-order Butterworth pole angles: π/16, 3π/16, 5π/16, 7π/16 | |||||
| q1 := 1.0 / (2 * math.Cos(math.Pi/16)) // ≈ 0.5098 | |||||
| q2 := 1.0 / (2 * math.Cos(3*math.Pi/16)) // ≈ 0.6013 | |||||
| q3 := 1.0 / (2 * math.Cos(5*math.Pi/16)) // ≈ 0.8999 | |||||
| q4 := 1.0 / (2 * math.Cos(7*math.Pi/16)) // ≈ 2.5629 | |||||
| return &FilterChain{ | |||||
| Stages: []Biquad{ | |||||
| *newBiquadLPFWithQ(cutoffHz, sampleRate, q1), | |||||
| *newBiquadLPFWithQ(cutoffHz, sampleRate, q2), | |||||
| *newBiquadLPFWithQ(cutoffHz, sampleRate, q3), | |||||
| *newBiquadLPFWithQ(cutoffHz, sampleRate, q4), | |||||
| }, | |||||
| } | |||||
| } | |||||
| // NewNotch creates a 2nd-order IIR notch (bandstop) filter. | // NewNotch creates a 2nd-order IIR notch (bandstop) filter. | ||||
| // Q controls width: higher Q = narrower notch. | // Q controls width: higher Q = narrower notch. | ||||
| // Typical: Q=5 → ~4kHz wide at -3dB, Q=10 → ~2kHz wide. | // Typical: Q=5 → ~4kHz wide at -3dB, Q=10 → ~2kHz wide. | ||||
| @@ -106,25 +121,41 @@ func NewNotch(centerHz, sampleRate, q float64) *Biquad { | |||||
| // --- Broadcast-specific filter factories --- | // --- Broadcast-specific filter factories --- | ||||
| // NewAudioLPF creates the broadcast-standard 15kHz audio lowpass. | |||||
| // 4th-order Butterworth ensures the guard band (15–23kHz) is clean. | |||||
| // 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. | |||||
| func NewAudioLPF(sampleRate float64) *FilterChain { | func NewAudioLPF(sampleRate float64) *FilterChain { | ||||
| return NewLPF4(15000, sampleRate) | |||||
| return NewLPF8(14000, sampleRate) | |||||
| } | } | ||||
| // NewPilotNotch creates a narrow notch at 19kHz to kill residual audio | |||||
| // energy at the pilot frequency. Applied BEFORE stereo encoding. | |||||
| // Q=5: -3dB width ~4kHz, >40dB rejection at 19kHz, <0.5dB loss at 15kHz. | |||||
| func NewPilotNotch(sampleRate float64) *Biquad { | |||||
| return NewNotch(19000, sampleRate, 5) | |||||
| // 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. | |||||
| func NewPilotNotch(sampleRate float64) *FilterChain { | |||||
| return &FilterChain{ | |||||
| Stages: []Biquad{ | |||||
| *NewNotch(19000, sampleRate, 5), | |||||
| *NewNotch(19000, sampleRate, 5), | |||||
| }, | |||||
| } | |||||
| } | } | ||||
| // NewCompositeProtection creates notch filters for the composite clipper. | |||||
| // Applied to clipped audio composite (mono+stereo_sub) to remove clip | |||||
| // harmonics from the pilot (19kHz) and RDS (57kHz) bands before adding | |||||
| // the actual pilot and RDS signals at clean, fixed levels. | |||||
| func NewCompositeProtection(sampleRate float64) (notch19, notch57 *Biquad) { | |||||
| notch19 = NewNotch(19000, sampleRate, 3) // wider Q for broadband clip artifacts | |||||
| notch57 = NewNotch(57000, sampleRate, 3) | |||||
| // 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. | |||||
| func NewCompositeProtection(sampleRate float64) (notch19, notch57 *FilterChain) { | |||||
| notch19 = &FilterChain{ | |||||
| Stages: []Biquad{ | |||||
| *NewNotch(19000, sampleRate, 3), | |||||
| *NewNotch(19000, sampleRate, 3), | |||||
| }, | |||||
| } | |||||
| notch57 = &FilterChain{ | |||||
| Stages: []Biquad{ | |||||
| *NewNotch(57000, sampleRate, 3), | |||||
| *NewNotch(57000, sampleRate, 3), | |||||
| }, | |||||
| } | |||||
| return | return | ||||
| } | } | ||||
| @@ -31,6 +31,7 @@ type LiveParams struct { | |||||
| RDSEnabled bool | RDSEnabled bool | ||||
| LimiterEnabled bool | LimiterEnabled bool | ||||
| LimiterCeiling float64 | LimiterCeiling float64 | ||||
| MpxGain float64 // hardware calibration factor for composite output | |||||
| } | } | ||||
| // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | ||||
| @@ -77,23 +78,28 @@ type Generator struct { | |||||
| stereoEncoder stereo.StereoEncoder | stereoEncoder stereo.StereoEncoder | ||||
| rdsEnc *rds.Encoder | rdsEnc *rds.Encoder | ||||
| combiner mpx.DefaultCombiner | combiner mpx.DefaultCombiner | ||||
| limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding | |||||
| fmMod *dsp.FMModulator | fmMod *dsp.FMModulator | ||||
| sampleRate float64 | sampleRate float64 | ||||
| initialized bool | initialized bool | ||||
| frameSeq uint64 | frameSeq uint64 | ||||
| // Broadcast-standard audio filter chain (per channel, L and R): | |||||
| // Pre-emphasis → 15kHz LPF (4th-order) → 19kHz Notch → Drive → Limiter | |||||
| audioLPF_L *dsp.FilterChain // 4th-order Butterworth 15kHz | |||||
| audioLPF_R *dsp.FilterChain | |||||
| pilotNotchL *dsp.Biquad // 19kHz notch (guard band protection) | |||||
| pilotNotchR *dsp.Biquad | |||||
| // Composite clipper protection (post-clip notch filters): | |||||
| // Audio composite → clip → notch 19kHz → notch 57kHz → + pilot → + RDS | |||||
| mpxNotch19 *dsp.Biquad // removes clip harmonics at pilot freq | |||||
| mpxNotch57 *dsp.Biquad // removes clip harmonics at RDS freq | |||||
| // Broadcast-standard clip-filter-clip chain (per channel L/R): | |||||
| // | |||||
| // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive | |||||
| // → StereoLimiter (slow AGC: raises average level) | |||||
| // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots] | |||||
| // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ | |||||
| // → + Pilot → + RDS → FM | |||||
| // | |||||
| audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip) | |||||
| audioLPF_R *dsp.FilterChain | |||||
| pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band) | |||||
| pilotNotchR *dsp.FilterChain | |||||
| limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks) | |||||
| cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup) | |||||
| cleanupLPF_R *dsp.FilterChain | |||||
| mpxNotch19 *dsp.FilterChain // composite clipper protection | |||||
| mpxNotch57 *dsp.FilterChain | |||||
| // Pre-allocated frame buffer — reused every GenerateFrame call. | // Pre-allocated frame buffer — reused every GenerateFrame call. | ||||
| frameBuf *output.CompositeFrame | frameBuf *output.CompositeFrame | ||||
| @@ -130,7 +136,7 @@ func (g *Generator) CurrentLiveParams() LiveParams { | |||||
| if lp := g.liveParams.Load(); lp != nil { | if lp := g.liveParams.Load(); lp != nil { | ||||
| return *lp | return *lp | ||||
| } | } | ||||
| return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} | |||||
| return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} | |||||
| } | } | ||||
| // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled. | // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled. | ||||
| @@ -163,27 +169,32 @@ func (g *Generator) init() { | |||||
| } | } | ||||
| ceiling := g.cfg.FM.LimiterCeiling | ceiling := g.cfg.FM.LimiterCeiling | ||||
| if ceiling <= 0 { ceiling = 1.0 } | if ceiling <= 0 { ceiling = 1.0 } | ||||
| // Audio ceiling leaves headroom for pilot + RDS so total ≤ ceiling | |||||
| pilotAmp := g.cfg.FM.PilotLevel * g.cfg.FM.OutputDrive | |||||
| rdsAmp := g.cfg.FM.RDSInjection * g.cfg.FM.OutputDrive | |||||
| audioCeiling := ceiling - pilotAmp - rdsAmp | |||||
| if audioCeiling < 0.3 { audioCeiling = 0.3 } | |||||
| if g.cfg.FM.LimiterEnabled { | |||||
| g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate) | |||||
| } | |||||
| // Broadcast-standard filter chain: | |||||
| // 1) 15kHz 4th-order Butterworth LPF — steep guard band, -14dB@19kHz, -54dB@57kHz | |||||
| // Broadcast clip-filter-clip chain: | |||||
| // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel) | |||||
| g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) | g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) | ||||
| g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) | g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) | ||||
| // 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection | |||||
| g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) | g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) | ||||
| g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) | g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) | ||||
| // 3) Composite clipper protection notches at 19kHz + 57kHz | |||||
| // Slow compressor: 5ms attack / 200ms release. Brings average level UP. | |||||
| // The clips after it catch the peaks the limiter's attack time misses. | |||||
| // This is the "slow-to-fast progression" from broadcast processing: | |||||
| // slow limiter → fast clips. | |||||
| g.limiter = dsp.NewStereoLimiter(ceiling, 5, 200, g.sampleRate) | |||||
| // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics) | |||||
| g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate) | |||||
| g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) | |||||
| // Composite clipper protection: double-notch at 19kHz + 57kHz | |||||
| g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) | g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) | ||||
| if g.cfg.FM.FMModulationEnabled { | if g.cfg.FM.FMModulationEnabled { | ||||
| g.fmMod = dsp.NewFMModulator(g.sampleRate) | g.fmMod = dsp.NewFMModulator(g.sampleRate) | ||||
| if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } | |||||
| maxDev := g.cfg.FM.MaxDeviationHz | |||||
| if maxDev > 0 { | |||||
| if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 { | |||||
| maxDev *= g.cfg.FM.MpxGain | |||||
| } | |||||
| g.fmMod.MaxDeviation = maxDev | |||||
| } | |||||
| } | } | ||||
| // Seed initial live params from config | // Seed initial live params from config | ||||
| @@ -195,6 +206,7 @@ func (g *Generator) init() { | |||||
| RDSEnabled: g.cfg.RDS.Enabled, | RDSEnabled: g.cfg.RDS.Enabled, | ||||
| LimiterEnabled: g.cfg.FM.LimiterEnabled, | LimiterEnabled: g.cfg.FM.LimiterEnabled, | ||||
| LimiterCeiling: ceiling, | LimiterCeiling: ceiling, | ||||
| MpxGain: g.cfg.FM.MpxGain, | |||||
| }) | }) | ||||
| g.initialized = true | g.initialized = true | ||||
| @@ -237,72 +249,74 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| lp := g.liveParams.Load() | lp := g.liveParams.Load() | ||||
| if lp == nil { | if lp == nil { | ||||
| // Fallback: should never happen after init(), but be safe | // Fallback: should never happen after init(), but be safe | ||||
| lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} | |||||
| lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} | |||||
| } | } | ||||
| // Broadcast-standard FM MPX signal chain: | |||||
| // Broadcast clip-filter-clip FM MPX signal chain: | |||||
| // | // | ||||
| // Audio L/R | |||||
| // → PreEmphasis (50µs EU / 75µs US) | |||||
| // → 15kHz LPF (4th-order Butterworth, -14dB@19kHz, -54dB@57kHz) | |||||
| // → 19kHz Notch (>40dB rejection, guard band protection) | |||||
| // → × OutputDrive | |||||
| // → StereoLimiter (instant attack, smooth release) | |||||
| // → Stereo Encode → Mono (L+R)/2 + Stereo Sub (L-R)/2 × 38kHz | |||||
| // Audio MPX composite | |||||
| // → HardClip at audioCeiling (catches limiter overshoots) | |||||
| // → 19kHz Notch (removes clip harmonics at pilot freq) | |||||
| // → 57kHz Notch (removes clip harmonics at RDS freq) | |||||
| // + Pilot 19kHz (fixed amplitude, post-clip) | |||||
| // + RDS 57kHz (fixed amplitude, post-clip) | |||||
| // Audio L/R → PreEmphasis | |||||
| // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double) | |||||
| // → × OutputDrive → HardClip₁ (ceiling) | |||||
| // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics] | |||||
| // → HardClip₂ (ceiling) [catches LPF₂ overshoots] | |||||
| // → Stereo Encode | |||||
| // Audio MPX (mono + stereo sub) | |||||
| // → HardClip₃ (ceiling) [composite deviation control] | |||||
| // → 19kHz Notch (double) [protect pilot band] | |||||
| // → 57kHz Notch (double) [protect RDS band] | |||||
| // + Pilot 19kHz (fixed, NEVER clipped) | |||||
| // + RDS 57kHz (fixed, NEVER clipped) | |||||
| // → FM Modulator | // → FM Modulator | ||||
| // | // | ||||
| // Key: Pilot and RDS are NEVER clipped or filtered. They're added | |||||
| // after all audio processing at constant amplitude. | |||||
| // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB) | |||||
| // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB | |||||
| ceiling := lp.LimiterCeiling | ceiling := lp.LimiterCeiling | ||||
| if ceiling <= 0 { ceiling = 1.0 } | if ceiling <= 0 { ceiling = 1.0 } | ||||
| pilotAmp := lp.PilotLevel * lp.OutputDrive | |||||
| rdsAmp := lp.RDSInjection * lp.OutputDrive | |||||
| audioCeiling := ceiling - pilotAmp - rdsAmp | |||||
| if audioCeiling < 0.3 { audioCeiling = 0.3 } | |||||
| // Pilot and RDS are FIXED injection levels, independent of OutputDrive. | |||||
| // Config values directly represent percentage of ±75kHz deviation: | |||||
| // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) | |||||
| // rdsInjection: 0.04 = 4% = ±3.0kHz (typical) | |||||
| pilotAmp := lp.PilotLevel | |||||
| rdsAmp := lp.RDSInjection | |||||
| for i := 0; i < samples; i++ { | for i := 0; i < samples; i++ { | ||||
| in := g.source.NextFrame() | in := g.source.NextFrame() | ||||
| // --- Stage 1: Audio filtering (per-channel) --- | |||||
| // 15kHz LPF removes out-of-band pre-emphasis energy. | |||||
| // 19kHz notch kills residual energy at pilot frequency. | |||||
| // Both run BEFORE drive+limiter so the limiter sees the | |||||
| // actual audio bandwidth, not wasted HF energy. | |||||
| // --- Stage 1: Band-limit pre-emphasized audio --- | |||||
| l := g.audioLPF_L.Process(float64(in.L)) | l := g.audioLPF_L.Process(float64(in.L)) | ||||
| l = g.pilotNotchL.Process(l) | l = g.pilotNotchL.Process(l) | ||||
| r := g.audioLPF_R.Process(float64(in.R)) | r := g.audioLPF_R.Process(float64(in.R)) | ||||
| r = g.pilotNotchR.Process(r) | r = g.pilotNotchR.Process(r) | ||||
| // --- Stage 2: Drive + Limit --- | |||||
| // --- Stage 2: Drive + Compress + Clip₁ --- | |||||
| l *= lp.OutputDrive | l *= lp.OutputDrive | ||||
| r *= lp.OutputDrive | r *= lp.OutputDrive | ||||
| if lp.LimiterEnabled && g.limiter != nil { | |||||
| if g.limiter != nil { | |||||
| l, r = g.limiter.Process(l, r) | l, r = g.limiter.Process(l, r) | ||||
| } | } | ||||
| l = dsp.HardClip(l, ceiling) | |||||
| r = dsp.HardClip(r, ceiling) | |||||
| // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) --- | |||||
| l = g.cleanupLPF_L.Process(l) | |||||
| r = g.cleanupLPF_R.Process(r) | |||||
| l = dsp.HardClip(l, ceiling) | |||||
| r = dsp.HardClip(r, ceiling) | |||||
| // --- Stage 3: Stereo encode --- | |||||
| // --- Stage 4: Stereo encode --- | |||||
| limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) | limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) | ||||
| comps := g.stereoEncoder.Encode(limited) | comps := g.stereoEncoder.Encode(limited) | ||||
| // --- Stage 4: Audio composite clip + protection --- | |||||
| // Clip the audio-only composite (mono + stereo sub) to budget. | |||||
| // Then notch-filter the clip harmonics out of the pilot (19kHz) | |||||
| // and RDS (57kHz) bands before adding the real pilot and RDS. | |||||
| // --- Stage 5: Composite clip + protection --- | |||||
| audioMPX := float64(comps.Mono) | audioMPX := float64(comps.Mono) | ||||
| if lp.StereoEnabled { | if lp.StereoEnabled { | ||||
| audioMPX += float64(comps.Stereo) | audioMPX += float64(comps.Stereo) | ||||
| } | } | ||||
| audioMPX = dsp.HardClip(audioMPX, audioCeiling) | |||||
| audioMPX = dsp.HardClip(audioMPX, ceiling) | |||||
| audioMPX = g.mpxNotch19.Process(audioMPX) | audioMPX = g.mpxNotch19.Process(audioMPX) | ||||
| audioMPX = g.mpxNotch57.Process(audioMPX) | audioMPX = g.mpxNotch57.Process(audioMPX) | ||||
| // --- Stage 5: Add protected components at fixed levels --- | |||||
| // --- Stage 6: Add protected components --- | |||||
| composite := audioMPX | composite := audioMPX | ||||
| if lp.StereoEnabled { | if lp.StereoEnabled { | ||||
| composite += pilotAmp * comps.Pilot | composite += pilotAmp * comps.Pilot | ||||
| @@ -83,9 +83,11 @@ func TestLimiterPreventsClipping(t *testing.T) { | |||||
| cfg.FM.FMModulationEnabled = false | cfg.FM.FMModulationEnabled = false | ||||
| cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | ||||
| frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) | frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) | ||||
| // Total composite (audio + pilot + RDS) should stay within ceiling. | |||||
| // Audio ceiling is auto-reduced to leave headroom for pilot + RDS. | |||||
| maxAllowed := cfg.FM.LimiterCeiling + 0.02 // small tolerance for limiter settling | |||||
| // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast). | |||||
| // Total = ceiling + pilotLevel*drive + rdsInjection*drive | |||||
| maxAllowed := cfg.FM.LimiterCeiling + | |||||
| cfg.FM.PilotLevel*cfg.FM.OutputDrive + | |||||
| cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02 | |||||
| for i, s := range frame.Samples { | for i, s := range frame.Samples { | ||||
| if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) } | if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) } | ||||
| } | } | ||||
| @@ -113,20 +115,28 @@ func TestFMModDisabledMeansComposite(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestLimiterDisabledAllowsHigherPeaks(t *testing.T) { | |||||
| func TestClipFilterClipAlwaysActive(t *testing.T) { | |||||
| // With clip-filter-clip architecture, peak control is always active | |||||
| // regardless of LimiterEnabled (legacy flag). Both configs should | |||||
| // produce the same peak level. | |||||
| base := cfgpkg.Default() | base := cfgpkg.Default() | ||||
| base.FM.FMModulationEnabled = false | base.FM.FMModulationEnabled = false | ||||
| base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0 | base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0 | ||||
| cfgLim := base; cfgLim.FM.LimiterEnabled = true; cfgLim.FM.LimiterCeiling = 1.0 | |||||
| cfgNoLim := base; cfgNoLim.FM.LimiterEnabled = false | |||||
| cfgA := base; cfgA.FM.LimiterEnabled = true; cfgA.FM.LimiterCeiling = 1.0 | |||||
| cfgB := base; cfgB.FM.LimiterEnabled = false; cfgB.FM.LimiterCeiling = 1.0 | |||||
| fLim := NewGenerator(cfgLim).GenerateFrame(50 * time.Millisecond) | |||||
| fNoLim := NewGenerator(cfgNoLim).GenerateFrame(50 * time.Millisecond) | |||||
| fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond) | |||||
| fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond) | |||||
| var maxLim, maxNoLim float64 | |||||
| for _, s := range fLim.Samples { if math.Abs(float64(s.I)) > maxLim { maxLim = math.Abs(float64(s.I)) } } | |||||
| for _, s := range fNoLim.Samples { if math.Abs(float64(s.I)) > maxNoLim { maxNoLim = math.Abs(float64(s.I)) } } | |||||
| var maxA, maxB float64 | |||||
| for _, s := range fA.Samples { if math.Abs(float64(s.I)) > maxA { maxA = math.Abs(float64(s.I)) } } | |||||
| for _, s := range fB.Samples { if math.Abs(float64(s.I)) > maxB { maxB = math.Abs(float64(s.I)) } } | |||||
| if maxNoLim <= maxLim { t.Fatalf("limiter disabled should allow higher peaks: lim=%.4f nolim=%.4f", maxLim, maxNoLim) } | |||||
| // Both should be within ceiling + pilot + RDS | |||||
| maxAllowed := cfgA.FM.LimiterCeiling + | |||||
| cfgA.FM.PilotLevel*cfgA.FM.OutputDrive + | |||||
| cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02 | |||||
| if maxA > maxAllowed { t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed) } | |||||
| if maxB > maxAllowed { t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed) } | |||||
| } | } | ||||
| @@ -119,6 +119,18 @@ func NewEncoder(cfg RDSConfig) (*Encoder, error) { | |||||
| waveform[i] = refWaveform[idx] | waveform[i] = refWaveform[idx] | ||||
| } | } | ||||
| } | } | ||||
| // Normalize to peak=1.0 so rdsInjection directly maps to injection %. | |||||
| // The raw PiFmRds waveform peaks at ~0.543, which would make config | |||||
| // values misleading (0.05 would give 2.7% instead of 5%). | |||||
| var peak float64 | |||||
| for _, v := range waveform { | |||||
| if a := math.Abs(v); a > peak { peak = a } | |||||
| } | |||||
| if peak > 0 { | |||||
| for i := range waveform { | |||||
| waveform[i] /= peak | |||||
| } | |||||
| } | |||||
| ringSize := spb + wfLen | ringSize := spb + wfLen | ||||