From 3678b43427341317005cbc2ab1af337bc0f8b2b9 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 09:13:26 +0200 Subject: [PATCH] feat: wire full DSP chain into offline generator with FM IQ output mode --- internal/offline/generator.go | 296 ++++++++++++++++++----------- internal/offline/generator_test.go | 153 +++++++++++---- 2 files changed, 298 insertions(+), 151 deletions(-) diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 79d0734..4af764b 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -1,137 +1,215 @@ package offline import ( - "context" - "encoding/binary" - "fmt" - "math" - "path/filepath" - "time" - - "github.com/jan/fm-rds-tx/internal/audio" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" - "github.com/jan/fm-rds-tx/internal/mpx" - "github.com/jan/fm-rds-tx/internal/output" - "github.com/jan/fm-rds-tx/internal/rds" - "github.com/jan/fm-rds-tx/internal/stereo" + "context" + "encoding/binary" + "fmt" + "path/filepath" + "time" + + "github.com/jan/fm-rds-tx/internal/audio" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/dsp" + "github.com/jan/fm-rds-tx/internal/mpx" + "github.com/jan/fm-rds-tx/internal/output" + "github.com/jan/fm-rds-tx/internal/rds" + "github.com/jan/fm-rds-tx/internal/stereo" ) type frameSource interface { - NextFrame() audio.Frame + NextFrame() audio.Frame } type SourceInfo struct { - Kind string - SampleRate float64 - Detail string + Kind string + SampleRate float64 + Detail string } type Generator struct { - cfg cfgpkg.Config + cfg cfgpkg.Config } func NewGenerator(cfg cfgpkg.Config) *Generator { - return &Generator{cfg: cfg} + return &Generator{cfg: cfg} } func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { - if g.cfg.Audio.InputPath != "" { - if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { - return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} - } - return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} - } - return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} + if g.cfg.Audio.InputPath != "" { + if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { + return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} + } + return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} + } + return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} } func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { - sampleRate := float64(g.cfg.FM.CompositeRateHz) - if sampleRate <= 0 { - sampleRate = 228000 - } - samples := int(duration.Seconds() * sampleRate) - if samples <= 0 { - samples = int(sampleRate / 10) - } - - frame := &output.CompositeFrame{ - Samples: make([]output.IQSample, samples), - SampleRateHz: sampleRate, - Timestamp: time.Now().UTC(), - Sequence: 1, - } - - stereoEncoder := stereo.NewStereoEncoder(sampleRate) - combiner := mpx.NewDefaultCombiner() - combiner.PilotGain = g.cfg.FM.PilotLevel - combiner.RDSGain = g.cfg.FM.RDSInjection - - rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{ - PI: 0x1234, - PS: g.cfg.RDS.PS, - RT: g.cfg.RDS.RadioText, - PTY: uint8(g.cfg.RDS.PTY), - SampleRate: sampleRate, - }) - rdsSamples := rdsEnc.Generate(samples) - - source, _ := g.sourceFor(sampleRate) - - for i := 0; i < samples; i++ { - t := float64(i) / sampleRate - in := source.NextFrame() - comps := stereoEncoder.Encode(in) - stereoDSB := comps.Stereo * math.Sin(2*math.Pi*38000.0*t) - rdsValue := 0.0 - if g.cfg.RDS.Enabled && i < len(rdsSamples) { - rdsValue = rdsSamples[i] - } - composite := combiner.Combine(comps.Mono, stereoDSB, comps.Pilot, rdsValue) * g.cfg.FM.OutputDrive - frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} - } - - return frame + sampleRate := float64(g.cfg.FM.CompositeRateHz) + if sampleRate <= 0 { + sampleRate = 228000 + } + samples := int(duration.Seconds() * sampleRate) + if samples <= 0 { + samples = int(sampleRate / 10) + } + + frame := &output.CompositeFrame{ + Samples: make([]output.IQSample, samples), + SampleRateHz: sampleRate, + Timestamp: time.Now().UTC(), + Sequence: 1, + } + + // --- DSP chain --- + + // Pre-emphasis filters for L and R channels + var preL, preR *dsp.PreEmphasis + if g.cfg.FM.PreEmphasisUS > 0 { + preL = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate) + preR = dsp.NewPreEmphasis(g.cfg.FM.PreEmphasisUS, sampleRate) + } + + // Stereo encoder (includes stateful 19kHz pilot and 38kHz subcarrier) + stereoEncoder := stereo.NewStereoEncoder(sampleRate) + + // MPX combiner + combiner := mpx.NewDefaultCombiner() + combiner.PilotGain = g.cfg.FM.PilotLevel / 0.1 // normalize: pilot generator has 0.1 level built-in + combiner.RDSGain = g.cfg.FM.RDSInjection / 0.05 // normalize: RDS encoder has 0.05 amplitude built-in + + // RDS encoder (standards-grade group framing + CRC + diff encoding) + rdsEnc, _ := rds.NewEncoder(rds.RDSConfig{ + PI: 0x1234, + PS: g.cfg.RDS.PS, + RT: g.cfg.RDS.RadioText, + PTY: uint8(g.cfg.RDS.PTY), + SampleRate: sampleRate, + }) + + // MPX limiter + var limiter *dsp.MPXLimiter + ceiling := g.cfg.FM.LimiterCeiling + if ceiling <= 0 { + ceiling = 1.0 + } + if g.cfg.FM.LimiterEnabled { + limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, sampleRate) + } + + // FM modulator for IQ output + var fmMod *dsp.FMModulator + if g.cfg.FM.FMModulationEnabled { + fmMod = dsp.NewFMModulator(sampleRate) + if g.cfg.FM.MaxDeviationHz > 0 { + fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz + } + } + + // Audio source + source, _ := g.sourceFor(sampleRate) + + // --- Sample loop --- + for i := 0; i < samples; i++ { + in := source.NextFrame() + + // Apply gain + inL := float64(in.L) * g.cfg.Audio.Gain + inR := float64(in.R) * g.cfg.Audio.Gain + + // Pre-emphasis + if preL != nil { + inL = preL.Process(inL) + inR = preR.Process(inR) + } + + // Stereo encode (produces mono, DSB-SC stereo, pilot) + preFrame := audio.NewFrame(audio.Sample(inL), audio.Sample(inR)) + comps := stereoEncoder.Encode(preFrame) + + // RDS + rdsValue := 0.0 + if g.cfg.RDS.Enabled { + rdsBuf := rdsEnc.Generate(1) + rdsValue = rdsBuf[0] + } + + // Combine MPX + composite := combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) + + // Apply output drive + composite *= g.cfg.FM.OutputDrive + + // Limiter + if limiter != nil { + composite = limiter.Process(composite) + } + + // Hard clip safety net + composite = dsp.HardClip(composite, ceiling) + + // Output: FM modulated IQ or raw composite + if fmMod != nil { + iq_i, iq_q := fmMod.Modulate(composite) + frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)} + } else { + frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} + } + } + + return frame } func (g *Generator) WriteFile(path string, duration time.Duration) error { - if path == "" { - path = g.cfg.Backend.OutputPath - } - if path == "" { - path = filepath.Join("build", "offline", "composite.iqf32") - } - backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{ - Name: "offline-file", - Description: "offline composite file backend", - }) - if err != nil { - return err - } - defer backend.Close(context.Background()) - - if err := backend.Configure(context.Background(), output.BackendConfig{ - SampleRateHz: float64(g.cfg.FM.CompositeRateHz), - Channels: 2, - IQLevel: float32(g.cfg.FM.OutputDrive), - }); err != nil { - return err - } - - frame := g.GenerateFrame(duration) - if _, err := backend.Write(context.Background(), frame); err != nil { - return err - } - if err := backend.Flush(context.Background()); err != nil { - return err - } - return nil + if path == "" { + path = g.cfg.Backend.OutputPath + } + if path == "" { + path = filepath.Join("build", "offline", "composite.iqf32") + } + backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{ + Name: "offline-file", + Description: "offline composite file backend", + }) + if err != nil { + return err + } + defer backend.Close(context.Background()) + + if err := backend.Configure(context.Background(), output.BackendConfig{ + SampleRateHz: float64(g.cfg.FM.CompositeRateHz), + Channels: 2, + IQLevel: float32(g.cfg.FM.OutputDrive), + }); err != nil { + return err + } + + frame := g.GenerateFrame(duration) + if _, err := backend.Write(context.Background(), frame); err != nil { + return err + } + if err := backend.Flush(context.Background()); err != nil { + return err + } + return nil } func (g *Generator) Summary(duration time.Duration) string { - sampleRate := float64(g.cfg.FM.CompositeRateHz) - if sampleRate <= 0 { - sampleRate = 228000 - } - _, info := g.sourceFor(sampleRate) - return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t source=%s detail=%s", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, info.Kind, info.Detail) + sampleRate := float64(g.cfg.FM.CompositeRateHz) + if sampleRate <= 0 { + sampleRate = 228000 + } + _, info := g.sourceFor(sampleRate) + preemph := "off" + if g.cfg.FM.PreEmphasisUS > 0 { + preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisUS) + } + modMode := "composite" + if g.cfg.FM.FMModulationEnabled { + modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz) + } + return fmt.Sprintf("offline frame: freq=%.1fMHz rate=%d duration=%s drive=%.2f stereo=%t rds=%t preemph=%s limiter=%t output=%s source=%s detail=%s", + g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), + g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, + preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail) } diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 5309cae..46bbcc3 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -1,59 +1,128 @@ package offline import ( - "os" - "path/filepath" - "strings" - "testing" - "time" + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" ) func TestGenerateFrame(t *testing.T) { - g := NewGenerator(cfgpkg.Default()) - frame := g.GenerateFrame(50 * time.Millisecond) - if frame == nil { - t.Fatal("expected frame") - } - if len(frame.Samples) == 0 { - t.Fatal("expected samples") - } + g := NewGenerator(cfgpkg.Default()) + frame := g.GenerateFrame(50 * time.Millisecond) + if frame == nil { + t.Fatal("expected frame") + } + if len(frame.Samples) == 0 { + t.Fatal("expected samples") + } +} + +func TestGenerateFrameFMIQ(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = true + g := NewGenerator(cfg) + frame := g.GenerateFrame(10 * time.Millisecond) + + // With FM modulation, IQ samples should have magnitude ~1 + for i := 100; i < len(frame.Samples) && i < 200; i++ { + s := frame.Samples[i] + mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) + if math.Abs(mag-1.0) > 0.01 { + t.Fatalf("sample %d: IQ magnitude=%.4f, expected ~1.0", i, mag) + } + } +} + +func TestGenerateFrameCompositeOnly(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = false + g := NewGenerator(cfg) + frame := g.GenerateFrame(10 * time.Millisecond) + + // Without FM modulation, Q should be 0 + for i := 0; i < len(frame.Samples) && i < 100; i++ { + if frame.Samples[i].Q != 0 { + t.Fatalf("sample %d: Q=%.6f, expected 0 in composite mode", i, frame.Samples[i].Q) + } + } } func TestWriteFile(t *testing.T) { - cfg := cfgpkg.Default() - out := filepath.Join(t.TempDir(), "test.iqf32") - cfg.Backend.OutputPath = out - g := NewGenerator(cfg) - if err := g.WriteFile(out, 20*time.Millisecond); err != nil { - t.Fatalf("WriteFile failed: %v", err) - } - info, err := os.Stat(out) - if err != nil { - t.Fatalf("expected output file: %v", err) - } - if info.Size() == 0 { - t.Fatal("expected non-empty file") - } + cfg := cfgpkg.Default() + out := filepath.Join(t.TempDir(), "test.iqf32") + cfg.Backend.OutputPath = out + g := NewGenerator(cfg) + if err := g.WriteFile(out, 20*time.Millisecond); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + info, err := os.Stat(out) + if err != nil { + t.Fatalf("expected output file: %v", err) + } + if info.Size() == 0 { + t.Fatal("expected non-empty file") + } } func TestSummaryUsesToneFallback(t *testing.T) { - cfg := cfgpkg.Default() - cfg.Audio.InputPath = "" - g := NewGenerator(cfg) - summary := g.Summary(10 * time.Millisecond) - if !strings.Contains(summary, "source=tones") { - t.Fatalf("unexpected summary: %s", summary) - } + cfg := cfgpkg.Default() + cfg.Audio.InputPath = "" + g := NewGenerator(cfg) + summary := g.Summary(10 * time.Millisecond) + if !strings.Contains(summary, "source=tones") { + t.Fatalf("unexpected summary: %s", summary) + } } func TestSummaryUsesFallbackLabelOnBadWAV(t *testing.T) { - cfg := cfgpkg.Default() - cfg.Audio.InputPath = "missing.wav" - g := NewGenerator(cfg) - summary := g.Summary(10 * time.Millisecond) - if !strings.Contains(summary, "source=tone-fallback") { - t.Fatalf("unexpected summary: %s", summary) - } + cfg := cfgpkg.Default() + cfg.Audio.InputPath = "missing.wav" + g := NewGenerator(cfg) + summary := g.Summary(10 * time.Millisecond) + if !strings.Contains(summary, "source=tone-fallback") { + t.Fatalf("unexpected summary: %s", summary) + } +} + +func TestSummaryContainsPreemph(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.PreEmphasisUS = 50 + g := NewGenerator(cfg) + summary := g.Summary(10 * time.Millisecond) + if !strings.Contains(summary, "preemph=50µs") { + t.Fatalf("unexpected summary: %s", summary) + } +} + +func TestSummaryContainsFMIQ(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = true + g := NewGenerator(cfg) + summary := g.Summary(10 * time.Millisecond) + if !strings.Contains(summary, "FM-IQ") { + t.Fatalf("unexpected summary: %s", summary) + } +} + +func TestLimiterPreventsClipping(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.LimiterEnabled = true + cfg.FM.LimiterCeiling = 1.0 + cfg.FM.FMModulationEnabled = false // raw composite to check levels + cfg.Audio.ToneAmplitude = 0.9 // high amplitude to exercise limiter + cfg.Audio.Gain = 2.0 + cfg.FM.OutputDrive = 1.0 + g := NewGenerator(cfg) + frame := g.GenerateFrame(50 * time.Millisecond) + + for i, s := range frame.Samples { + if math.Abs(float64(s.I)) > 1.01 { + t.Fatalf("sample %d: composite=%.4f exceeds ceiling", i, s.I) + } + } }