Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

169 linhas
4.7KB

  1. package offline
  2. import (
  3. "math"
  4. "sort"
  5. "testing"
  6. "time"
  7. "github.com/jan/fm-rds-tx/internal/audio"
  8. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  9. "github.com/jan/fm-rds-tx/internal/dsp"
  10. "github.com/jan/fm-rds-tx/internal/license"
  11. "github.com/jan/fm-rds-tx/internal/output"
  12. "github.com/jan/fm-rds-tx/internal/watermark"
  13. )
  14. // noiseSource provides deterministic broadband stereo material so the
  15. // watermark has energy across many bins and the detector has something real to
  16. // correlate against.
  17. type noiseSource struct {
  18. stateL uint64
  19. stateR uint64
  20. amp float64
  21. }
  22. func newNoiseSource(seed uint64, amp float64) *noiseSource {
  23. return &noiseSource{stateL: seed, stateR: seed ^ 0x9e3779b97f4a7c15, amp: amp}
  24. }
  25. func (n *noiseSource) nextNorm(state *uint64) float64 {
  26. *state = *state*6364136223846793005 + 1442695040888963407
  27. return float64(int32(*state>>33)) / float64(1<<31)
  28. }
  29. func (n *noiseSource) NextFrame() audio.Frame {
  30. l := n.amp * n.nextNorm(&n.stateL)
  31. r := n.amp * n.nextNorm(&n.stateR)
  32. return audio.NewFrame(audio.Sample(l), audio.Sample(r))
  33. }
  34. func newWatermarkE2EGenerator(t *testing.T, key string) *Generator {
  35. t.Helper()
  36. cfg := cfgpkg.Default()
  37. cfg.RDS.Enabled = false
  38. cfg.FM.CompositeRateHz = 228000
  39. cfg.FM.StereoEnabled = true
  40. cfg.FM.OutputDrive = 0.5
  41. cfg.FM.LimiterEnabled = true
  42. cfg.FM.LimiterCeiling = 1.0
  43. cfg.FM.FMModulationEnabled = false // composite output for inspection
  44. cfg.Audio.ToneAmplitude = 0 // external source only
  45. cfg.Audio.Gain = 1.0
  46. cfg.FM.PreEmphasisTauUS = 50
  47. gen := NewGenerator(cfg)
  48. if err := gen.SetExternalSource(newNoiseSource(12345, 0.22)); err != nil {
  49. t.Fatalf("SetExternalSource: %v", err)
  50. }
  51. gen.SetLicense(license.NewState(""))
  52. gen.ConfigureWatermark(true, key)
  53. return gen
  54. }
  55. func extractCompositeFrame(frame *output.CompositeFrame) []float64 {
  56. out := make([]float64, len(frame.Samples))
  57. for i, s := range frame.Samples {
  58. out[i] = float64(s.I)
  59. }
  60. return out
  61. }
  62. func downsampleForWatermarkDetection(composite []float64, rate float64) ([]float64, float64) {
  63. decimFactor := int(rate / float64(watermark.WMRate))
  64. if decimFactor < 1 {
  65. decimFactor = 1
  66. }
  67. actualRate := rate / float64(decimFactor)
  68. lpf := dsp.NewLPF8(5500, rate)
  69. filtered := make([]float64, len(composite))
  70. for i, s := range composite {
  71. filtered[i] = lpf.Process(s)
  72. }
  73. out := make([]float64, len(filtered)/decimFactor)
  74. for i := range out {
  75. out[i] = filtered[i*decimFactor]
  76. }
  77. return out, actualRate
  78. }
  79. func decodeWatermarkFromComposite(t *testing.T, composite []float64, rate float64) ([watermark.RsDataBytes]byte, [watermark.PayloadBits]float64, bool) {
  80. t.Helper()
  81. var zero [watermark.RsDataBytes]byte
  82. down, actualRate := downsampleForWatermarkDetection(composite, rate)
  83. if math.Abs(actualRate-float64(watermark.WMRate)) > 1e-9 {
  84. t.Fatalf("unexpected detector rate %.3f Hz after decimation", actualRate)
  85. }
  86. if len(down) <= watermark.FFTSize*2 {
  87. return zero, [watermark.PayloadBits]float64{}, false
  88. }
  89. probe := down[watermark.FFTSize:]
  90. det := watermark.NewSTFTDetector()
  91. corrs, bestOffset := det.Detect(probe)
  92. var sumAbs float64
  93. var strong int
  94. for _, c := range corrs {
  95. ac := math.Abs(c)
  96. sumAbs += ac
  97. if ac > 1.0 {
  98. strong++
  99. }
  100. }
  101. t.Logf("watermark detect: bestOffset=%d avg|c|=%.2f strong=%d/%d", bestOffset, sumAbs/float64(watermark.PayloadBits), strong, watermark.PayloadBits)
  102. var recv [watermark.RsTotalBytes]byte
  103. byteConfs := make([]float64, watermark.RsTotalBytes)
  104. for i := 0; i < watermark.PayloadBits; i++ {
  105. if corrs[i] < 0 {
  106. recv[i/8] |= 1 << uint(7-(i%8))
  107. }
  108. byteConfs[i/8] += math.Abs(corrs[i])
  109. }
  110. type bc struct{ idx int; conf float64 }
  111. ranked := make([]bc, watermark.RsTotalBytes)
  112. for i := range ranked {
  113. ranked[i] = bc{i, byteConfs[i]}
  114. }
  115. sort.Slice(ranked, func(a, b int) bool { return ranked[a].conf < ranked[b].conf })
  116. for ne := 0; ne <= watermark.RsCheckBytes; ne++ {
  117. erasePos := make([]int, ne)
  118. for i := 0; i < ne; i++ {
  119. erasePos[i] = ranked[i].idx
  120. }
  121. sort.Ints(erasePos)
  122. payload, ok := watermark.RSDecode(recv, erasePos)
  123. if ok {
  124. return payload, corrs, true
  125. }
  126. }
  127. return zero, corrs, false
  128. }
  129. func TestWatermarkE2E(t *testing.T) {
  130. if testing.Short() {
  131. t.Skip("skipping long watermark E2E test in -short mode")
  132. }
  133. const key = "test-key-e2e"
  134. const duration = 45 * time.Second
  135. gen := newWatermarkE2EGenerator(t, key)
  136. frame := gen.GenerateFrame(duration)
  137. if frame == nil {
  138. t.Fatal("GenerateFrame returned nil")
  139. }
  140. composite := extractCompositeFrame(frame)
  141. payload, _, ok := decodeWatermarkFromComposite(t, composite, frame.SampleRateHz)
  142. if !ok {
  143. t.Fatal("RS decode failed — watermark not recoverable from generator composite")
  144. }
  145. if !watermark.KeyMatchesPayload(key, payload) {
  146. t.Fatalf("decoded payload mismatch: key=%q payload=%x", key, payload)
  147. }
  148. }