From 213069a11aae21dc79e2efe27c23100617846807 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 23:38:50 +0200 Subject: [PATCH] refactor: switch FM path to clip-filter-clip processing 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. --- docs/config.plutosdr.json | 7 +- internal/app/engine.go | 5 ++ internal/config/config.go | 16 ++-- internal/dsp/biquad.go | 71 ++++++++++----- internal/offline/generator.go | 136 ++++++++++++++++------------- internal/offline/generator_test.go | 34 +++++--- internal/rds/encoder.go | 12 +++ 7 files changed, 180 insertions(+), 101 deletions(-) diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 51c3b28..1f9cec8 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -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, diff --git a/internal/app/engine.go b/internal/app/engine.go index 0139a9b..3e84b34 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 584d189..2ea3c11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") } diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go index 827810d..39f6048 100644 --- a/internal/dsp/biquad.go +++ b/internal/dsp/biquad.go @@ -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 } diff --git a/internal/offline/generator.go b/internal/offline/generator.go index c4170f3..9dfcc72 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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 diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 24b6b8b..637ac29 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -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) } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index fd54455..fbe7b93 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -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