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": { | |||
| "frequencyMHz": 100.0, | |||
| "stereoEnabled": true, | |||
| "pilotLevel": 0.041, | |||
| "rdsInjection": 0.021, | |||
| "pilotLevel": 0.09, | |||
| "rdsInjection": 0.04, | |||
| "preEmphasisTauUS": 50, | |||
| "outputDrive": 2.2, | |||
| "outputDrive": 4.3, | |||
| "mpxGain": 1.0, | |||
| "compositeRateHz": 228000, | |||
| "maxDeviationHz": 75000, | |||
| "limiterEnabled": true, | |||
| @@ -115,6 +115,11 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | |||
| if maxDev <= 0 { | |||
| 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) | |||
| log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", | |||
| compositeRate, deviceRate, deviceRate/compositeRate) | |||
| @@ -35,8 +35,8 @@ type RDSConfig struct { | |||
| type FMConfig struct { | |||
| FrequencyMHz float64 `json:"frequencyMHz"` | |||
| 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 | |||
| OutputDrive float64 `json:"outputDrive"` | |||
| CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate | |||
| @@ -44,6 +44,7 @@ type FMConfig struct { | |||
| LimiterEnabled bool `json:"limiterEnabled"` | |||
| LimiterCeiling float64 `json:"limiterCeiling"` | |||
| FMModulationEnabled bool `json:"fmModulationEnabled"` | |||
| MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) | |||
| } | |||
| type BackendConfig struct { | |||
| @@ -64,8 +65,8 @@ func Default() Config { | |||
| FM: FMConfig{ | |||
| FrequencyMHz: 100.0, | |||
| StereoEnabled: true, | |||
| PilotLevel: 0.1, | |||
| RDSInjection: 0.05, | |||
| PilotLevel: 0.09, | |||
| RDSInjection: 0.04, | |||
| PreEmphasisTauUS: 50, | |||
| OutputDrive: 0.5, | |||
| CompositeRateHz: 228000, | |||
| @@ -73,6 +74,7 @@ func Default() Config { | |||
| LimiterEnabled: true, | |||
| LimiterCeiling: 1.0, | |||
| FMModulationEnabled: true, | |||
| MpxGain: 1.0, | |||
| }, | |||
| Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, | |||
| 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 { | |||
| 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)") | |||
| } | |||
| 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 { | |||
| 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 == "" { | |||
| 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). | |||
| // Provides -24dB/octave rolloff. At 228kHz with fc=15kHz: | |||
| // | |||
| // 15kHz: -6dB, 19kHz: -14dB, 38kHz: -36dB, 57kHz: -54dB | |||
| 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 | |||
| q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 | |||
| 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. | |||
| // Q controls width: higher Q = narrower notch. | |||
| // 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 --- | |||
| // 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 { | |||
| 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 | |||
| } | |||
| @@ -31,6 +31,7 @@ type LiveParams struct { | |||
| RDSEnabled bool | |||
| LimiterEnabled bool | |||
| LimiterCeiling float64 | |||
| MpxGain float64 // hardware calibration factor for composite output | |||
| } | |||
| // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | |||
| @@ -77,23 +78,28 @@ type Generator struct { | |||
| stereoEncoder stereo.StereoEncoder | |||
| rdsEnc *rds.Encoder | |||
| combiner mpx.DefaultCombiner | |||
| limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding | |||
| fmMod *dsp.FMModulator | |||
| sampleRate float64 | |||
| initialized bool | |||
| 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. | |||
| frameBuf *output.CompositeFrame | |||
| @@ -130,7 +136,7 @@ func (g *Generator) CurrentLiveParams() LiveParams { | |||
| if lp := g.liveParams.Load(); lp != nil { | |||
| 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. | |||
| @@ -163,27 +169,32 @@ func (g *Generator) init() { | |||
| } | |||
| ceiling := g.cfg.FM.LimiterCeiling | |||
| 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_R = dsp.NewAudioLPF(g.sampleRate) | |||
| // 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection | |||
| g.pilotNotchL = 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) | |||
| if g.cfg.FM.FMModulationEnabled { | |||
| 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 | |||
| @@ -195,6 +206,7 @@ func (g *Generator) init() { | |||
| RDSEnabled: g.cfg.RDS.Enabled, | |||
| LimiterEnabled: g.cfg.FM.LimiterEnabled, | |||
| LimiterCeiling: ceiling, | |||
| MpxGain: g.cfg.FM.MpxGain, | |||
| }) | |||
| g.initialized = true | |||
| @@ -237,72 +249,74 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| lp := g.liveParams.Load() | |||
| if lp == nil { | |||
| // 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 | |||
| // | |||
| // 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 | |||
| 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++ { | |||
| 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.pilotNotchL.Process(l) | |||
| r := g.audioLPF_R.Process(float64(in.R)) | |||
| r = g.pilotNotchR.Process(r) | |||
| // --- Stage 2: Drive + Limit --- | |||
| // --- Stage 2: Drive + Compress + Clip₁ --- | |||
| l *= lp.OutputDrive | |||
| r *= lp.OutputDrive | |||
| if lp.LimiterEnabled && g.limiter != nil { | |||
| if g.limiter != nil { | |||
| 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)) | |||
| 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) | |||
| if lp.StereoEnabled { | |||
| audioMPX += float64(comps.Stereo) | |||
| } | |||
| audioMPX = dsp.HardClip(audioMPX, audioCeiling) | |||
| audioMPX = dsp.HardClip(audioMPX, ceiling) | |||
| audioMPX = g.mpxNotch19.Process(audioMPX) | |||
| audioMPX = g.mpxNotch57.Process(audioMPX) | |||
| // --- Stage 5: Add protected components at fixed levels --- | |||
| // --- Stage 6: Add protected components --- | |||
| composite := audioMPX | |||
| if lp.StereoEnabled { | |||
| composite += pilotAmp * comps.Pilot | |||
| @@ -83,9 +83,11 @@ func TestLimiterPreventsClipping(t *testing.T) { | |||
| cfg.FM.FMModulationEnabled = false | |||
| cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | |||
| 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 { | |||
| 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.FM.FMModulationEnabled = false | |||
| 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] | |||
| } | |||
| } | |||
| // 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 | |||