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 {