From f83d32320a16b49010f56bbeea9182b8f83b78b9 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 16:51:51 +0200 Subject: [PATCH] fix(engine): reset DSP state before restart Reset generator and upsampler state on engine start, add a full generator Reset path for stateful DSP/runtime components, and cover deterministic first-frame recovery after reset. --- internal/app/engine.go | 4 ++ internal/offline/generator.go | 99 +++++++++++++++++++++++++++--- internal/offline/generator_test.go | 23 +++++++ 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/internal/app/engine.go b/internal/app/engine.go index 73c8335..6d1cb02 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -440,6 +440,10 @@ func (e *Engine) Start(ctx context.Context) error { e.mu.Unlock() return fmt.Errorf("driver start: %w", err) } + e.generator.Reset() + if e.upsampler != nil { + e.upsampler.Reset() + } runCtx, cancel := context.WithCancel(ctx) e.cancel = cancel diff --git a/internal/offline/generator.go b/internal/offline/generator.go index cd4bb9e..02d5f81 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -205,6 +205,95 @@ func (g *Generator) RDSEncoder() *rds.Encoder { return g.rdsEnc } +func (g *Generator) resetSource() { + rawSource, _ := g.sourceFor(g.sampleRate) + g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) +} + +func (g *Generator) resetRDS2Encoder() { + if !g.cfg.RDS.Enabled || !g.cfg.RDS.RDS2Enabled { + g.rds2Enc = nil + return + } + g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate) + g.rds2Enc.Enable(true) + if g.cfg.RDS.StationLogoPath != "" { + if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil { + log.Printf("rds2: failed to load station logo: %v", err) + } + } +} + +// Reset clears stateful DSP/runtime state so the next run starts from a clean baseline +// without changing the current live parameters or feature enablement. +func (g *Generator) Reset() { + if !g.initialized { + return + } + + g.resetSource() + + mode := g.appliedStereoMode + if lp := g.liveParams.Load(); lp != nil { + mode = canonicalStereoMode(lp.StereoMode) + } + g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) + g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate) + g.appliedStereoMode = mode + + if g.rdsEnc != nil { + g.rdsEnc.Reset() + } + g.resetRDS2Encoder() + + if g.audioLPF_L != nil { + g.audioLPF_L.Reset() + } + if g.audioLPF_R != nil { + g.audioLPF_R.Reset() + } + if g.pilotNotchL != nil { + g.pilotNotchL.Reset() + } + if g.pilotNotchR != nil { + g.pilotNotchR.Reset() + } + if g.limiter != nil { + g.limiter.Reset() + } + if g.cleanupLPF_L != nil { + g.cleanupLPF_L.Reset() + } + if g.cleanupLPF_R != nil { + g.cleanupLPF_R.Reset() + } + if g.mpxNotch19 != nil { + g.mpxNotch19.Reset() + } + if g.mpxNotch57 != nil { + g.mpxNotch57.Reset() + } + if g.bs412 != nil { + g.bs412.Reset() + } + if g.compositeClip != nil { + g.compositeClip.Reset() + } + if g.fmMod != nil { + g.fmMod.Reset() + } + + if g.watermarkEnabled { + g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey) + g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate) + g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) + } else { + g.stftEmbedder = nil + g.wmDecimLPF = nil + g.wmInterpLPF = nil + } +} + func (g *Generator) init() { if g.initialized { return @@ -268,15 +357,7 @@ func (g *Generator) init() { }) // RDS2: additional subcarriers (66.5, 71.25, 76 kHz) - if g.cfg.RDS.RDS2Enabled { - g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate) - g.rds2Enc.Enable(true) - if g.cfg.RDS.StationLogoPath != "" { - if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil { - log.Printf("rds2: failed to load station logo: %v", err) - } - } - } + g.resetRDS2Encoder() } ceiling := g.cfg.FM.LimiterCeiling if ceiling <= 0 { diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index e8c9fb7..55d76e3 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -211,3 +211,26 @@ func TestConfigureWatermarkExplicitOptIn(t *testing.T) { t.Fatal("expected watermark embedder after explicit opt-in") } } + +func TestGeneratorResetRestoresDeterministicFirstFrame(t *testing.T) { + cfg := cfgpkg.Default() + cfg.RDS.Enabled = false + cfg.FM.FMModulationEnabled = true + g := NewGenerator(cfg) + + first := g.GenerateFrame(10 * time.Millisecond) + _ = g.GenerateFrame(10 * time.Millisecond) + g.Reset() + afterReset := g.GenerateFrame(10 * time.Millisecond) + + if len(first.Samples) != len(afterReset.Samples) { + t.Fatalf("sample length mismatch: %d vs %d", len(first.Samples), len(afterReset.Samples)) + } + for i := range first.Samples { + a := first.Samples[i] + b := afterReset.Samples[i] + if a != b { + t.Fatalf("sample %d differs after reset: first=(%v,%v) reset=(%v,%v)", i, a.I, a.Q, b.I, b.Q) + } + } +}