package offline import ( "math" "os" "path/filepath" "strings" "testing" "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/license" ) func TestGenerateFrame(t *testing.T) { g := NewGenerator(cfgpkg.Default()) frame := g.GenerateFrame(50 * time.Millisecond) if frame == nil || len(frame.Samples) == 0 { t.Fatal("expected samples") } } func TestGenerateFrameFMIQ(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = true frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) 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: mag=%.4f", i, mag) } } } func TestGenerateFrameCompositeOnly(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = false frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) for i := 0; i < len(frame.Samples) && i < 100; i++ { if frame.Samples[i].Q != 0 { t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q) } } } func TestStereoDisabled(t *testing.T) { cfgS := cfgpkg.Default() cfgS.FM.FMModulationEnabled = false cfgS.FM.StereoEnabled = true cfgM := cfgS cfgM.FM.StereoEnabled = false sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond) mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond) var diffEnergy float64 for i := range sf.Samples { d := float64(sf.Samples[i].I - mf.Samples[i].I) diffEnergy += d * d } if diffEnergy == 0 { t.Fatal("expected difference") } } func TestWriteFile(t *testing.T) { cfg := cfgpkg.Default() out := filepath.Join(t.TempDir(), "test.iqf32") if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil { t.Fatal(err) } info, _ := os.Stat(out) if info.Size() == 0 { t.Fatal("empty file") } } func TestSummaryTones(t *testing.T) { cfg := cfgpkg.Default() cfg.Audio.InputPath = "" s := NewGenerator(cfg).Summary(10 * time.Millisecond) if !strings.Contains(s, "source=tones") { t.Fatalf("unexpected: %s", s) } } func TestSummaryToneFallback(t *testing.T) { cfg := cfgpkg.Default() cfg.Audio.InputPath = "missing.wav" s := NewGenerator(cfg).Summary(10 * time.Millisecond) if !strings.Contains(s, "source=tone-fallback") { t.Fatalf("unexpected: %s", s) } } func TestSummaryPreemph(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.PreEmphasisTauUS = 50 if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") { t.Fatal("missing preemph") } } func TestSummaryFMIQ(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = true if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") { t.Fatal("missing FM-IQ") } } func TestLimiterPreventsClipping(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.LimiterEnabled = true cfg.FM.LimiterCeiling = 1.0 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) // 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) } } } // --- Operator truth tests --- func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) { cfgOn := cfgpkg.Default() cfgOn.FM.FMModulationEnabled = false cfgOn.RDS.Enabled = true cfgOff := cfgOn cfgOff.RDS.Enabled = false fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond) fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond) var diff float64 for i := range fOn.Samples { d := float64(fOn.Samples[i].I - fOff.Samples[i].I) diff += d * d } if diff == 0 { t.Fatal("rds.enabled=false should produce different output") } } func TestFMModDisabledMeansComposite(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.FMModulationEnabled = false frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) for i := 0; i < 100; i++ { if frame.Samples[i].Q != 0 { t.Fatal("Q should be 0 when FM mod is off") } } } func TestLimiterEnabledChangesWaveform(t *testing.T) { base := cfgpkg.Default() base.FM.FMModulationEnabled = false base.Audio.ToneAmplitude = 0.95 base.Audio.Gain = 3.0 base.FM.OutputDrive = 2.5 base.FM.LimiterCeiling = 0.8 cfgOn := base cfgOn.FM.LimiterEnabled = true cfgOff := base cfgOff.FM.LimiterEnabled = false fOn := NewGenerator(cfgOn).GenerateFrame(50 * time.Millisecond) fOff := NewGenerator(cfgOff).GenerateFrame(50 * time.Millisecond) if len(fOn.Samples) != len(fOff.Samples) { t.Fatalf("sample length mismatch: %d vs %d", len(fOn.Samples), len(fOff.Samples)) } var diffEnergy float64 for i := range fOn.Samples { d := float64(fOn.Samples[i].I - fOff.Samples[i].I) diffEnergy += d * d } if diffEnergy == 0 { t.Fatal("expected limiterEnabled to change waveform") } } func TestSetLicenseDoesNotImplicitlyEnableWatermark(t *testing.T) { cfg := cfgpkg.Default() g := NewGenerator(cfg) g.SetLicense(license.NewState("")) g.init() if g.stftEmbedder != nil { t.Fatal("watermark should remain disabled unless explicitly configured") } } func TestConfigureWatermarkExplicitOptIn(t *testing.T) { cfg := cfgpkg.Default() cfg.FM.WatermarkEnabled = true g := NewGenerator(cfg) g.SetLicense(license.NewState("test-key")) g.ConfigureWatermark(true, "test-key") g.init() if g.stftEmbedder == nil { t.Fatal("expected watermark embedder after explicit opt-in") } }