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.

228 lines
6.1KB

  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 TestClipFilterClipAlwaysActive(t *testing.T) {
  148. // With clip-filter-clip architecture, peak control is always active
  149. // regardless of LimiterEnabled (legacy flag). Both configs should
  150. // produce the same peak level.
  151. base := cfgpkg.Default()
  152. base.FM.FMModulationEnabled = false
  153. base.Audio.ToneAmplitude = 0.9
  154. base.Audio.Gain = 2.0
  155. base.FM.OutputDrive = 1.0
  156. cfgA := base
  157. cfgA.FM.LimiterEnabled = true
  158. cfgA.FM.LimiterCeiling = 1.0
  159. cfgB := base
  160. cfgB.FM.LimiterEnabled = false
  161. cfgB.FM.LimiterCeiling = 1.0
  162. fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond)
  163. fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond)
  164. var maxA, maxB float64
  165. for _, s := range fA.Samples {
  166. if math.Abs(float64(s.I)) > maxA {
  167. maxA = math.Abs(float64(s.I))
  168. }
  169. }
  170. for _, s := range fB.Samples {
  171. if math.Abs(float64(s.I)) > maxB {
  172. maxB = math.Abs(float64(s.I))
  173. }
  174. }
  175. // Both should be within ceiling + pilot + RDS
  176. maxAllowed := cfgA.FM.LimiterCeiling +
  177. cfgA.FM.PilotLevel*cfgA.FM.OutputDrive +
  178. cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02
  179. if maxA > maxAllowed {
  180. t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed)
  181. }
  182. if maxB > maxAllowed {
  183. t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed)
  184. }
  185. }
  186. func TestSetLicenseDoesNotImplicitlyEnableWatermark(t *testing.T) {
  187. cfg := cfgpkg.Default()
  188. g := NewGenerator(cfg)
  189. g.SetLicense(license.NewState(""))
  190. g.init()
  191. if g.stftEmbedder != nil {
  192. t.Fatal("watermark should remain disabled unless explicitly configured")
  193. }
  194. }
  195. func TestConfigureWatermarkExplicitOptIn(t *testing.T) {
  196. cfg := cfgpkg.Default()
  197. cfg.FM.WatermarkEnabled = true
  198. g := NewGenerator(cfg)
  199. g.SetLicense(license.NewState("test-key"))
  200. g.ConfigureWatermark(true, "test-key")
  201. g.init()
  202. if g.stftEmbedder == nil {
  203. t.Fatal("expected watermark embedder after explicit opt-in")
  204. }
  205. }