Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

184 lines
4.9KB

  1. package offline
  2. import (
  3. "math"
  4. "os"
  5. "path/filepath"
  6. "strings"
  7. "testing"
  8. "time"
  9. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  10. )
  11. func TestGenerateFrame(t *testing.T) {
  12. g := NewGenerator(cfgpkg.Default())
  13. frame := g.GenerateFrame(50 * time.Millisecond)
  14. if frame == nil {
  15. t.Fatal("expected frame")
  16. }
  17. if len(frame.Samples) == 0 {
  18. t.Fatal("expected samples")
  19. }
  20. }
  21. func TestGenerateFrameFMIQ(t *testing.T) {
  22. cfg := cfgpkg.Default()
  23. cfg.FM.FMModulationEnabled = true
  24. g := NewGenerator(cfg)
  25. frame := g.GenerateFrame(10 * time.Millisecond)
  26. // With FM modulation, IQ samples should have magnitude ~1
  27. for i := 100; i < len(frame.Samples) && i < 200; i++ {
  28. s := frame.Samples[i]
  29. mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q))
  30. if math.Abs(mag-1.0) > 0.01 {
  31. t.Fatalf("sample %d: IQ magnitude=%.4f, expected ~1.0", i, mag)
  32. }
  33. }
  34. }
  35. func TestGenerateFrameCompositeOnly(t *testing.T) {
  36. cfg := cfgpkg.Default()
  37. cfg.FM.FMModulationEnabled = false
  38. g := NewGenerator(cfg)
  39. frame := g.GenerateFrame(10 * time.Millisecond)
  40. // Without FM modulation, Q should be 0
  41. for i := 0; i < len(frame.Samples) && i < 100; i++ {
  42. if frame.Samples[i].Q != 0 {
  43. t.Fatalf("sample %d: Q=%.6f, expected 0 in composite mode", i, frame.Samples[i].Q)
  44. }
  45. }
  46. }
  47. func TestStereoDisabledSuppressesPilotAndStereoDifference(t *testing.T) {
  48. cfgStereo := cfgpkg.Default()
  49. cfgStereo.FM.FMModulationEnabled = false
  50. cfgStereo.FM.StereoEnabled = true
  51. cfgStereo.Audio.ToneLeftHz = 1000
  52. cfgStereo.Audio.ToneRightHz = 1600
  53. cfgMono := cfgStereo
  54. cfgMono.FM.StereoEnabled = false
  55. stereoFrame := NewGenerator(cfgStereo).GenerateFrame(20 * time.Millisecond)
  56. monoFrame := NewGenerator(cfgMono).GenerateFrame(20 * time.Millisecond)
  57. if len(stereoFrame.Samples) != len(monoFrame.Samples) {
  58. t.Fatal("frame length mismatch")
  59. }
  60. var diffEnergy float64
  61. for i := range stereoFrame.Samples {
  62. d := float64(stereoFrame.Samples[i].I - monoFrame.Samples[i].I)
  63. diffEnergy += d * d
  64. }
  65. if diffEnergy == 0 {
  66. t.Fatal("expected stereo-enabled and stereo-disabled composite output to differ")
  67. }
  68. }
  69. func TestWriteFile(t *testing.T) {
  70. cfg := cfgpkg.Default()
  71. out := filepath.Join(t.TempDir(), "test.iqf32")
  72. cfg.Backend.OutputPath = out
  73. g := NewGenerator(cfg)
  74. if err := g.WriteFile(out, 20*time.Millisecond); err != nil {
  75. t.Fatalf("WriteFile failed: %v", err)
  76. }
  77. info, err := os.Stat(out)
  78. if err != nil {
  79. t.Fatalf("expected output file: %v", err)
  80. }
  81. if info.Size() == 0 {
  82. t.Fatal("expected non-empty file")
  83. }
  84. }
  85. func TestSummaryUsesToneFallback(t *testing.T) {
  86. cfg := cfgpkg.Default()
  87. cfg.Audio.InputPath = ""
  88. g := NewGenerator(cfg)
  89. summary := g.Summary(10 * time.Millisecond)
  90. if !strings.Contains(summary, "source=tones") {
  91. t.Fatalf("unexpected summary: %s", summary)
  92. }
  93. }
  94. func TestSummaryUsesFallbackLabelOnBadWAV(t *testing.T) {
  95. cfg := cfgpkg.Default()
  96. cfg.Audio.InputPath = "missing.wav"
  97. g := NewGenerator(cfg)
  98. summary := g.Summary(10 * time.Millisecond)
  99. if !strings.Contains(summary, "source=tone-fallback") {
  100. t.Fatalf("unexpected summary: %s", summary)
  101. }
  102. }
  103. func TestSummaryContainsPreemph(t *testing.T) {
  104. cfg := cfgpkg.Default()
  105. cfg.FM.PreEmphasisUS = 50
  106. g := NewGenerator(cfg)
  107. summary := g.Summary(10 * time.Millisecond)
  108. if !strings.Contains(summary, "preemph=50µs") {
  109. t.Fatalf("unexpected summary: %s", summary)
  110. }
  111. }
  112. func TestSummaryContainsFMIQ(t *testing.T) {
  113. cfg := cfgpkg.Default()
  114. cfg.FM.FMModulationEnabled = true
  115. g := NewGenerator(cfg)
  116. summary := g.Summary(10 * time.Millisecond)
  117. if !strings.Contains(summary, "FM-IQ") {
  118. t.Fatalf("unexpected summary: %s", summary)
  119. }
  120. }
  121. func TestLimiterPreventsClipping(t *testing.T) {
  122. cfg := cfgpkg.Default()
  123. cfg.FM.LimiterEnabled = true
  124. cfg.FM.LimiterCeiling = 1.0
  125. cfg.FM.FMModulationEnabled = false // raw composite to check levels
  126. cfg.Audio.ToneAmplitude = 0.9 // high amplitude to exercise limiter
  127. cfg.Audio.Gain = 2.0
  128. cfg.FM.OutputDrive = 1.0
  129. g := NewGenerator(cfg)
  130. frame := g.GenerateFrame(50 * time.Millisecond)
  131. for i, s := range frame.Samples {
  132. if math.Abs(float64(s.I)) > 1.01 {
  133. t.Fatalf("sample %d: composite=%.4f exceeds ceiling", i, s.I)
  134. }
  135. }
  136. }
  137. func TestParsePI(t *testing.T) {
  138. tests := []struct {
  139. name string
  140. in string
  141. want uint16
  142. }{
  143. {name: "plain hex", in: "1234", want: 0x1234},
  144. {name: "0x prefix", in: "0xBEEF", want: 0xBEEF},
  145. {name: "uppercase prefix", in: "0XCAFE", want: 0xCAFE},
  146. {name: "whitespace", in: " 0x2345 ", want: 0x2345},
  147. {name: "empty fallback", in: "", want: 0x1234},
  148. {name: "invalid fallback", in: "nope", want: 0x1234},
  149. }
  150. for _, tt := range tests {
  151. if got := parsePI(tt.in); got != tt.want {
  152. t.Fatalf("%s: got 0x%04X want 0x%04X", tt.name, got, tt.want)
  153. }
  154. }
  155. }
  156. func TestGeneratorUsesConfiguredPI(t *testing.T) {
  157. cfg := cfgpkg.Default()
  158. cfg.RDS.PI = "BEEF"
  159. if got := parsePI(cfg.RDS.PI); got != 0xBEEF {
  160. t.Fatalf("configured PI was not parsed as expected: got 0x%04X", got)
  161. }
  162. }