Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

164 řádky
4.7KB

  1. package offline
  2. import (
  3. "math"
  4. "testing"
  5. "time"
  6. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  7. "github.com/jan/fm-rds-tx/internal/dsp"
  8. "github.com/jan/fm-rds-tx/internal/license"
  9. "github.com/jan/fm-rds-tx/internal/watermark"
  10. )
  11. // TestWatermarkE2EFloat32 tests the FULL path including float32 storage
  12. // (as happens in IQSample.I) and FMUpsampler FM modulation + demodulation.
  13. func TestWatermarkE2EFloat32(t *testing.T) {
  14. const key = "test-key-e2e-f32"
  15. const duration = 45 * time.Second
  16. cfg := cfgpkg.Default()
  17. cfg.FM.CompositeRateHz = 228000
  18. cfg.FM.StereoEnabled = true
  19. cfg.FM.OutputDrive = 0.5
  20. cfg.FM.LimiterEnabled = true
  21. cfg.FM.LimiterCeiling = 1.0
  22. cfg.FM.FMModulationEnabled = false // split-rate mode
  23. cfg.Audio.ToneLeftHz = 1000
  24. cfg.Audio.ToneRightHz = 1600
  25. cfg.Audio.ToneAmplitude = 0.4
  26. cfg.Audio.Gain = 1.0
  27. cfg.FM.PreEmphasisTauUS = 50
  28. gen := NewGenerator(cfg)
  29. licState := license.NewState("")
  30. gen.SetLicense(licState, key)
  31. frame := gen.GenerateFrame(duration)
  32. nSamples := len(frame.Samples)
  33. compositeRate := frame.SampleRateHz
  34. t.Logf("Generated %d samples @ %.0f Hz", nSamples, compositeRate)
  35. // Test 1: float32 truncation
  36. t.Run("float32_storage", func(t *testing.T) {
  37. // Simulate what IQSample does: float32(composite)
  38. composite := make([]float64, nSamples)
  39. for i, s := range frame.Samples {
  40. composite[i] = float64(s.I) // s.I is float32
  41. }
  42. testDecode(t, composite, compositeRate, key)
  43. })
  44. // Test 2: FM modulate + demodulate
  45. t.Run("fm_mod_demod", func(t *testing.T) {
  46. maxDev := 75000.0
  47. // FM modulate (same as FMUpsampler phase accumulation)
  48. phases := make([]float64, nSamples)
  49. phaseInc := 2 * math.Pi * maxDev / compositeRate
  50. phase := 0.0
  51. for i, s := range frame.Samples {
  52. phase += float64(s.I) * phaseInc
  53. phases[i] = phase
  54. }
  55. // FM demodulate: instantaneous frequency = dphase/dt
  56. demod := make([]float64, nSamples)
  57. for i := 1; i < nSamples; i++ {
  58. dp := phases[i] - phases[i-1]
  59. demod[i] = dp / phaseInc // recover composite
  60. }
  61. demod[0] = demod[1]
  62. testDecode(t, demod, compositeRate, key)
  63. })
  64. // Test 3: FM mod + upsample 10× + downsample + demod (full SDR path)
  65. t.Run("fm_upsample_downsample", func(t *testing.T) {
  66. maxDev := 75000.0
  67. deviceRate := 2280000.0
  68. upsampler := dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev)
  69. upFrame := upsampler.Process(frame)
  70. t.Logf("Upsampled: %d IQ samples @ %.0f Hz", len(upFrame.Samples), upFrame.SampleRateHz)
  71. // FM demodulate the IQ output: phase = atan2(Q, I), freq = dphase/dt
  72. nUp := len(upFrame.Samples)
  73. demod := make([]float64, nUp)
  74. prevPhase := 0.0
  75. for i, s := range upFrame.Samples {
  76. p := math.Atan2(float64(s.Q), float64(s.I))
  77. dp := p - prevPhase
  78. // Unwrap
  79. for dp > math.Pi { dp -= 2 * math.Pi }
  80. for dp < -math.Pi { dp += 2 * math.Pi }
  81. demod[i] = dp * deviceRate / (2 * math.Pi * maxDev)
  82. prevPhase = p
  83. }
  84. // Downsample back to composite rate
  85. ratio := int(deviceRate / compositeRate)
  86. nDown := nUp / ratio
  87. downsampled := make([]float64, nDown)
  88. for i := 0; i < nDown; i++ {
  89. // Simple decimate (use average over ratio samples)
  90. sum := 0.0
  91. for j := 0; j < ratio; j++ {
  92. idx := i*ratio + j
  93. if idx < nUp { sum += demod[idx] }
  94. }
  95. downsampled[i] = sum / float64(ratio)
  96. }
  97. t.Logf("Downsampled: %d samples @ %.0f Hz", nDown, compositeRate)
  98. testDecode(t, downsampled, compositeRate, key)
  99. })
  100. }
  101. func testDecode(t *testing.T, composite []float64, rate float64, key string) {
  102. t.Helper()
  103. chipRate := float64(watermark.ChipRate)
  104. samplesPerBit := int(float64(watermark.PnChips) * rate / chipRate)
  105. nSamples := len(composite)
  106. // Phase search
  107. bestPhase := 0
  108. bestEnergy := 0.0
  109. step := max(1, samplesPerBit/200)
  110. for phase := 0; phase < samplesPerBit; phase += step {
  111. var energy float64
  112. for b := 0; b < min(100, nSamples/samplesPerBit); b++ {
  113. start := phase + b*samplesPerBit
  114. if start+samplesPerBit > nSamples { break }
  115. c := watermark.CorrelateAt(composite, start, rate)
  116. energy += c * c
  117. }
  118. if energy > bestEnergy {
  119. bestEnergy = energy; bestPhase = phase
  120. }
  121. }
  122. // Correlate
  123. nComplete := (nSamples - bestPhase) / samplesPerBit
  124. nFrames := nComplete / 128
  125. if nFrames < 1 { nFrames = 1 }
  126. corrs := make([]float64, 128)
  127. for i := 0; i < 128; i++ {
  128. for f := 0; f < nFrames; f++ {
  129. start := bestPhase + (f*128+i)*samplesPerBit
  130. if start+samplesPerBit > nSamples { break }
  131. corrs[i] += watermark.CorrelateAt(composite, start, rate)
  132. }
  133. }
  134. var nStrong, nDead int
  135. var sumAbs float64
  136. for _, c := range corrs {
  137. ac := math.Abs(c)
  138. sumAbs += ac
  139. if ac > 50 { nStrong++ }
  140. if ac < 5 { nDead++ }
  141. }
  142. t.Logf("phase=%d, frames=%d, avg|c|=%.1f, strong=%d, dead=%d",
  143. bestPhase, nFrames, sumAbs/128, nStrong, nDead)
  144. if nStrong < 100 {
  145. t.Errorf("Only %d/128 strong bits — watermark degraded", nStrong)
  146. }
  147. }