|
- 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")
- }
- }
-
- 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)
- }
- }
- }
|