Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

178 satır
5.4KB

  1. package offline
  2. import (
  3. "math"
  4. "sort"
  5. "testing"
  6. "time"
  7. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  8. "github.com/jan/fm-rds-tx/internal/license"
  9. "github.com/jan/fm-rds-tx/internal/watermark"
  10. )
  11. // TestWatermarkE2E runs a FULL generator pipeline (audio source → pre-emphasis →
  12. // LPF → drive → clip → cleanup → watermark injection → stereo encode → composite
  13. // clip → notch → pilot → RDS → FM mod) and then tries to decode the watermark
  14. // from the composite output. This tests the real code path, not just the embedder.
  15. func TestWatermarkE2E(t *testing.T) {
  16. const key = "test-key-e2e"
  17. const duration = 45 * time.Second
  18. cfg := cfgpkg.Default()
  19. cfg.FM.CompositeRateHz = 228000
  20. cfg.FM.StereoEnabled = true
  21. cfg.FM.OutputDrive = 0.5
  22. cfg.FM.LimiterEnabled = true
  23. cfg.FM.LimiterCeiling = 1.0
  24. cfg.FM.FMModulationEnabled = false // split-rate: composite output, no IQ
  25. cfg.Audio.ToneLeftHz = 1000
  26. cfg.Audio.ToneRightHz = 1600
  27. cfg.Audio.ToneAmplitude = 0.4
  28. cfg.Audio.Gain = 1.0
  29. cfg.FM.PreEmphasisTauUS = 50
  30. gen := NewGenerator(cfg)
  31. licState := license.NewState("")
  32. gen.SetLicense(licState, key)
  33. // Generate composite
  34. frame := gen.GenerateFrame(duration)
  35. if frame == nil {
  36. t.Fatal("GenerateFrame returned nil")
  37. }
  38. t.Logf("Generated %d composite samples @ %.0f Hz (%.2fs)",
  39. len(frame.Samples), frame.SampleRateHz, float64(len(frame.Samples))/frame.SampleRateHz)
  40. // Extract mono composite (I channel = composite baseband in non-FM mode)
  41. compositeRate := frame.SampleRateHz
  42. nSamples := len(frame.Samples)
  43. composite := make([]float64, nSamples)
  44. for i, s := range frame.Samples {
  45. composite[i] = float64(s.I)
  46. }
  47. // RMS
  48. var rmsAcc float64
  49. for _, s := range composite {
  50. rmsAcc += s * s
  51. }
  52. rms := math.Sqrt(rmsAcc / float64(nSamples))
  53. t.Logf("Composite RMS: %.1f dBFS", 20*math.Log10(rms+1e-12))
  54. // Now decode the watermark from the composite
  55. chipRate := float64(watermark.ChipRate)
  56. samplesPerBit := int(float64(watermark.PnChips) * compositeRate / chipRate)
  57. frameLen := samplesPerBit * watermark.PayloadBits
  58. nFrames := nSamples / frameLen
  59. t.Logf("Decode: samplesPerBit=%d, frameLen=%d, nFrames=%d", samplesPerBit, frameLen, nFrames)
  60. if nFrames < 1 {
  61. t.Fatalf("Need at least 1 frame (%d samples), have %d", frameLen, nSamples)
  62. }
  63. // Phase search (should be 0 since we start from sample 0)
  64. bestPhase := 0
  65. bestEnergy := 0.0
  66. step := max(1, samplesPerBit/500)
  67. for phase := 0; phase < samplesPerBit; phase += step {
  68. var energy float64
  69. nBits := min(200, (nSamples-phase)/samplesPerBit)
  70. for b := 0; b < nBits; b++ {
  71. start := phase + b*samplesPerBit
  72. if start+samplesPerBit > nSamples { break }
  73. c := watermark.CorrelateAt(composite, start, compositeRate)
  74. energy += c * c
  75. }
  76. if energy > bestEnergy {
  77. bestEnergy = energy
  78. bestPhase = phase
  79. }
  80. }
  81. t.Logf("Phase: %d (energy=%.1f)", bestPhase, bestEnergy)
  82. // Correlate all 128 bits with frame averaging
  83. nCompleteBits := (nSamples - bestPhase) / samplesPerBit
  84. nAvgFrames := nCompleteBits / watermark.PayloadBits
  85. if nAvgFrames < 1 { nAvgFrames = 1 }
  86. corrs := make([]float64, watermark.PayloadBits)
  87. for i := 0; i < watermark.PayloadBits; i++ {
  88. for f := 0; f < nAvgFrames; f++ {
  89. bitGlobal := f*watermark.PayloadBits + i
  90. start := bestPhase + bitGlobal*samplesPerBit
  91. if start+samplesPerBit > nSamples { break }
  92. corrs[i] += watermark.CorrelateAt(composite, start, compositeRate)
  93. }
  94. }
  95. var minC, maxC, sumC float64
  96. var nStrong, nDead int
  97. for i, c := range corrs {
  98. ac := math.Abs(c)
  99. sumC += ac
  100. if i == 0 || ac < minC { minC = ac }
  101. if ac > maxC { maxC = ac }
  102. if ac > 50 { nStrong++ }
  103. if ac < 5 { nDead++ }
  104. }
  105. t.Logf("Correlations: min=%.1f max=%.1f avg=%.1f strong=%d dead=%d",
  106. minC, maxC, sumC/128, nStrong, nDead)
  107. if nStrong < 64 {
  108. t.Errorf("Too few strong bits: %d/128 (expected >64)", nStrong)
  109. }
  110. // Frame sync: try all 128 rotations with byte-level erasure
  111. type decResult struct {
  112. rot int
  113. payload [watermark.RsDataBytes]byte
  114. ok bool
  115. }
  116. var bestDec *decResult
  117. for rot := 0; rot < watermark.PayloadBits; rot++ {
  118. var recv [watermark.RsTotalBytes]byte
  119. byteConfs := make([]float64, watermark.RsTotalBytes)
  120. for i := 0; i < watermark.PayloadBits; i++ {
  121. srcBit := (i + rot) % watermark.PayloadBits
  122. if corrs[srcBit] < 0 {
  123. recv[i/8] |= 1 << uint(7-(i%8))
  124. }
  125. byteConfs[i/8] += math.Abs(corrs[srcBit])
  126. }
  127. // Try with 0..8 byte erasures
  128. type bc struct{ idx int; conf float64 }
  129. ranked := make([]bc, watermark.RsTotalBytes)
  130. for i := range ranked { ranked[i] = bc{i, byteConfs[i]} }
  131. sort.Slice(ranked, func(a, b int) bool { return ranked[a].conf < ranked[b].conf })
  132. for ne := 0; ne <= watermark.RsCheckBytes; ne++ {
  133. erasePos := make([]int, ne)
  134. for i := 0; i < ne; i++ { erasePos[i] = ranked[i].idx }
  135. sort.Ints(erasePos)
  136. payload, ok := watermark.RSDecode(recv, erasePos)
  137. if ok {
  138. bestDec = &decResult{rot, payload, true}
  139. break
  140. }
  141. }
  142. if bestDec != nil { break }
  143. }
  144. if bestDec == nil {
  145. t.Fatal("RS decode FAILED — watermark not recoverable from generator output")
  146. }
  147. t.Logf("Decoded: rotation=%d, payload=%x", bestDec.rot, bestDec.payload)
  148. if !watermark.KeyMatchesPayload(key, bestDec.payload) {
  149. t.Errorf("Key mismatch: %q does not match payload %x", key, bestDec.payload)
  150. } else {
  151. t.Logf("Key %q MATCHES ✓", key)
  152. }
  153. }
  154. func min(a, b int) int { if a < b { return a }; return b }
  155. func max(a, b int) int { if a > b { return a }; return b }