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.

252 lines
6.8KB

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