Przeglądaj źródła

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.
tags/v0.9.0
Jan Svabenik 1 miesiąc temu
rodzic
commit
213069a11a
7 zmienionych plików z 180 dodań i 101 usunięć
  1. +4
    -3
      docs/config.plutosdr.json
  2. +5
    -0
      internal/app/engine.go
  3. +11
    -5
      internal/config/config.go
  4. +51
    -20
      internal/dsp/biquad.go
  5. +75
    -61
      internal/offline/generator.go
  6. +22
    -12
      internal/offline/generator_test.go
  7. +12
    -0
      internal/rds/encoder.go

+ 4
- 3
docs/config.plutosdr.json Wyświetl plik

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


+ 5
- 0
internal/app/engine.go Wyświetl plik

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


+ 11
- 5
internal/config/config.go Wyświetl plik

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


+ 51
- 20
internal/dsp/biquad.go Wyświetl plik

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

+ 75
- 61
internal/offline/generator.go Wyświetl plik

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


+ 22
- 12
internal/offline/generator_test.go Wyświetl plik

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

+ 12
- 0
internal/rds/encoder.go Wyświetl plik

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


Ładowanie…
Anuluj
Zapisz