Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

214 lines
5.7KB

  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. "github.com/jan/fm-rds-tx/internal/license"
  11. )
  12. func TestGenerateFrame(t *testing.T) {
  13. g := NewGenerator(cfgpkg.Default())
  14. frame := g.GenerateFrame(50 * time.Millisecond)
  15. if frame == nil || len(frame.Samples) == 0 {
  16. t.Fatal("expected samples")
  17. }
  18. }
  19. func TestGenerateFrameFMIQ(t *testing.T) {
  20. cfg := cfgpkg.Default()
  21. cfg.FM.FMModulationEnabled = true
  22. frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
  23. for i := 100; i < len(frame.Samples) && i < 200; i++ {
  24. s := frame.Samples[i]
  25. mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q))
  26. if math.Abs(mag-1.0) > 0.01 {
  27. t.Fatalf("sample %d: mag=%.4f", i, mag)
  28. }
  29. }
  30. }
  31. func TestGenerateFrameCompositeOnly(t *testing.T) {
  32. cfg := cfgpkg.Default()
  33. cfg.FM.FMModulationEnabled = false
  34. frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
  35. for i := 0; i < len(frame.Samples) && i < 100; i++ {
  36. if frame.Samples[i].Q != 0 {
  37. t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q)
  38. }
  39. }
  40. }
  41. func TestStereoDisabled(t *testing.T) {
  42. cfgS := cfgpkg.Default()
  43. cfgS.FM.FMModulationEnabled = false
  44. cfgS.FM.StereoEnabled = true
  45. cfgM := cfgS
  46. cfgM.FM.StereoEnabled = false
  47. sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond)
  48. mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond)
  49. var diffEnergy float64
  50. for i := range sf.Samples {
  51. d := float64(sf.Samples[i].I - mf.Samples[i].I)
  52. diffEnergy += d * d
  53. }
  54. if diffEnergy == 0 {
  55. t.Fatal("expected difference")
  56. }
  57. }
  58. func TestWriteFile(t *testing.T) {
  59. cfg := cfgpkg.Default()
  60. out := filepath.Join(t.TempDir(), "test.iqf32")
  61. if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil {
  62. t.Fatal(err)
  63. }
  64. info, _ := os.Stat(out)
  65. if info.Size() == 0 {
  66. t.Fatal("empty file")
  67. }
  68. }
  69. func TestSummaryTones(t *testing.T) {
  70. cfg := cfgpkg.Default()
  71. cfg.Audio.InputPath = ""
  72. s := NewGenerator(cfg).Summary(10 * time.Millisecond)
  73. if !strings.Contains(s, "source=tones") {
  74. t.Fatalf("unexpected: %s", s)
  75. }
  76. }
  77. func TestSummaryToneFallback(t *testing.T) {
  78. cfg := cfgpkg.Default()
  79. cfg.Audio.InputPath = "missing.wav"
  80. s := NewGenerator(cfg).Summary(10 * time.Millisecond)
  81. if !strings.Contains(s, "source=tone-fallback") {
  82. t.Fatalf("unexpected: %s", s)
  83. }
  84. }
  85. func TestSummaryPreemph(t *testing.T) {
  86. cfg := cfgpkg.Default()
  87. cfg.FM.PreEmphasisTauUS = 50
  88. if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") {
  89. t.Fatal("missing preemph")
  90. }
  91. }
  92. func TestSummaryFMIQ(t *testing.T) {
  93. cfg := cfgpkg.Default()
  94. cfg.FM.FMModulationEnabled = true
  95. if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") {
  96. t.Fatal("missing FM-IQ")
  97. }
  98. }
  99. func TestLimiterPreventsClipping(t *testing.T) {
  100. cfg := cfgpkg.Default()
  101. cfg.FM.LimiterEnabled = true
  102. cfg.FM.LimiterCeiling = 1.0
  103. cfg.FM.FMModulationEnabled = false
  104. cfg.Audio.ToneAmplitude = 0.9
  105. cfg.Audio.Gain = 2.0
  106. cfg.FM.OutputDrive = 1.0
  107. frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond)
  108. // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast).
  109. // Total = ceiling + pilotLevel*drive + rdsInjection*drive
  110. maxAllowed := cfg.FM.LimiterCeiling +
  111. cfg.FM.PilotLevel*cfg.FM.OutputDrive +
  112. cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02
  113. for i, s := range frame.Samples {
  114. if math.Abs(float64(s.I)) > maxAllowed {
  115. t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed)
  116. }
  117. }
  118. }
  119. // --- Operator truth tests ---
  120. func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) {
  121. cfgOn := cfgpkg.Default()
  122. cfgOn.FM.FMModulationEnabled = false
  123. cfgOn.RDS.Enabled = true
  124. cfgOff := cfgOn
  125. cfgOff.RDS.Enabled = false
  126. fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond)
  127. fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond)
  128. var diff float64
  129. for i := range fOn.Samples {
  130. d := float64(fOn.Samples[i].I - fOff.Samples[i].I)
  131. diff += d * d
  132. }
  133. if diff == 0 {
  134. t.Fatal("rds.enabled=false should produce different output")
  135. }
  136. }
  137. func TestFMModDisabledMeansComposite(t *testing.T) {
  138. cfg := cfgpkg.Default()
  139. cfg.FM.FMModulationEnabled = false
  140. frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
  141. for i := 0; i < 100; i++ {
  142. if frame.Samples[i].Q != 0 {
  143. t.Fatal("Q should be 0 when FM mod is off")
  144. }
  145. }
  146. }
  147. func TestLimiterEnabledChangesWaveform(t *testing.T) {
  148. base := cfgpkg.Default()
  149. base.FM.FMModulationEnabled = false
  150. base.Audio.ToneAmplitude = 0.95
  151. base.Audio.Gain = 3.0
  152. base.FM.OutputDrive = 2.5
  153. base.FM.LimiterCeiling = 0.8
  154. cfgOn := base
  155. cfgOn.FM.LimiterEnabled = true
  156. cfgOff := base
  157. cfgOff.FM.LimiterEnabled = false
  158. fOn := NewGenerator(cfgOn).GenerateFrame(50 * time.Millisecond)
  159. fOff := NewGenerator(cfgOff).GenerateFrame(50 * time.Millisecond)
  160. if len(fOn.Samples) != len(fOff.Samples) {
  161. t.Fatalf("sample length mismatch: %d vs %d", len(fOn.Samples), len(fOff.Samples))
  162. }
  163. var diffEnergy float64
  164. for i := range fOn.Samples {
  165. d := float64(fOn.Samples[i].I - fOff.Samples[i].I)
  166. diffEnergy += d * d
  167. }
  168. if diffEnergy == 0 {
  169. t.Fatal("expected limiterEnabled to change waveform")
  170. }
  171. }
  172. func TestSetLicenseDoesNotImplicitlyEnableWatermark(t *testing.T) {
  173. cfg := cfgpkg.Default()
  174. g := NewGenerator(cfg)
  175. g.SetLicense(license.NewState(""))
  176. g.init()
  177. if g.stftEmbedder != nil {
  178. t.Fatal("watermark should remain disabled unless explicitly configured")
  179. }
  180. }
  181. func TestConfigureWatermarkExplicitOptIn(t *testing.T) {
  182. cfg := cfgpkg.Default()
  183. cfg.FM.WatermarkEnabled = true
  184. g := NewGenerator(cfg)
  185. g.SetLicense(license.NewState("test-key"))
  186. g.ConfigureWatermark(true, "test-key")
  187. g.init()
  188. if g.stftEmbedder == nil {
  189. t.Fatal("expected watermark embedder after explicit opt-in")
  190. }
  191. }