From 740e7f5f9825d0e0f2ca9258a10eb139fca4a6af Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 13:23:36 +0200 Subject: [PATCH] fix(stereo): apply mode changes at chunk boundaries Stop reconfiguring the stereo encoder from UpdateLive, stage canonical stereoMode values as desired state, and apply SetMode only from the DSP thread at the start of GenerateFrame. Add tests covering deferred mode application and mode-name canonicalization. --- internal/offline/generator.go | 42 +++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/offline/generator.go b/internal/offline/generator.go index d8fa6eb..cd4bb9e 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -85,15 +85,16 @@ type Generator struct { cfg cfgpkg.Config // Persistent DSP state across GenerateFrame calls - source *PreEmphasizedSource - stereoEncoder stereo.StereoEncoder - rdsEnc *rds.Encoder - rds2Enc *rds.RDS2Encoder - combiner mpx.DefaultCombiner - fmMod *dsp.FMModulator - sampleRate float64 - initialized bool - frameSeq uint64 + source *PreEmphasizedSource + stereoEncoder stereo.StereoEncoder + appliedStereoMode string // canonical mode currently applied to stereoEncoder; DSP goroutine only + rdsEnc *rds.Encoder + rds2Enc *rds.RDS2Encoder + combiner mpx.DefaultCombiner + fmMod *dsp.FMModulator + sampleRate float64 + initialized bool + frameSeq uint64 // Broadcast-standard clip-filter-clip chain (per channel L/R): // @@ -178,13 +179,10 @@ func (g *Generator) SetExternalSource(src frameSource) error { return nil } -// UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API, -// applied at the next chunk boundary by the DSP goroutine. +// UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API. +// The DSP goroutine applies mode changes at the next chunk boundary. func (g *Generator) UpdateLive(p LiveParams) { - // Detect stereo mode change: requires reconfiguring the encoder's Hilbert filter. - if old := g.liveParams.Load(); old != nil && old.StereoMode != p.StereoMode { - g.stereoEncoder.SetMode(stereo.ParseMode(p.StereoMode), g.sampleRate) - } + p.StereoMode = canonicalStereoMode(p.StereoMode) g.liveParams.Store(&p) } @@ -197,6 +195,10 @@ func (g *Generator) CurrentLiveParams() LiveParams { return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } +func canonicalStereoMode(mode string) string { + return stereo.ParseMode(mode).String() +} + // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled. // Used by the Engine to forward text updates. func (g *Generator) RDSEncoder() *rds.Encoder { @@ -223,7 +225,8 @@ func (g *Generator) init() { rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) - g.stereoEncoder.SetMode(stereo.ParseMode(g.cfg.FM.StereoMode), g.sampleRate) + g.appliedStereoMode = canonicalStereoMode(g.cfg.FM.StereoMode) + g.stereoEncoder.SetMode(stereo.ParseMode(g.appliedStereoMode), g.sampleRate) g.combiner = mpx.DefaultCombiner{ MonoGain: 1.0, StereoGain: 1.0, PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection, @@ -335,7 +338,7 @@ func (g *Generator) init() { g.liveParams.Store(&LiveParams{ OutputDrive: g.cfg.FM.OutputDrive, StereoEnabled: g.cfg.FM.StereoEnabled, - StereoMode: g.cfg.FM.StereoMode, + StereoMode: g.appliedStereoMode, PilotLevel: g.cfg.FM.PilotLevel, RDSInjection: g.cfg.FM.RDSInjection, RDSEnabled: g.cfg.RDS.Enabled, @@ -418,6 +421,11 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } + if mode := canonicalStereoMode(lp.StereoMode); mode != g.appliedStereoMode { + g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate) + g.appliedStereoMode = mode + } + // Apply live tone and gain updates each chunk. GenerateFrame runs on a // single goroutine so these field writes are safe without additional locking. if g.toneSource != nil {