Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

208 lignes
4.9KB

  1. package dsp
  2. import (
  3. "math"
  4. "testing"
  5. )
  6. func TestCompositeClipperPeakLimit(t *testing.T) {
  7. // A signal at 2× ceiling must never exceed ceiling at output.
  8. c := NewCompositeClipper(CompositeClipperConfig{
  9. Ceiling: 1.0,
  10. Iterations: 3,
  11. SoftKnee: 0,
  12. SampleRate: 228000,
  13. })
  14. rate := 228000.0
  15. n := int(rate * 0.05) // 50ms
  16. maxOut := 0.0
  17. for i := 0; i < n; i++ {
  18. // 1kHz tone at amplitude 2.0 (200% modulation)
  19. in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate)
  20. out := c.Process(in)
  21. if math.Abs(out) > maxOut {
  22. maxOut = math.Abs(out)
  23. }
  24. }
  25. if maxOut > 1.001 {
  26. t.Errorf("peak %.6f exceeds ceiling 1.0", maxOut)
  27. }
  28. t.Logf("hard clip: peak=%.6f (ceiling=1.0)", maxOut)
  29. }
  30. func TestCompositeClipperSoftKneePeakLimit(t *testing.T) {
  31. c := NewCompositeClipper(CompositeClipperConfig{
  32. Ceiling: 1.0,
  33. Iterations: 3,
  34. SoftKnee: 0.2,
  35. SampleRate: 228000,
  36. })
  37. rate := 228000.0
  38. n := int(rate * 0.05)
  39. maxOut := 0.0
  40. for i := 0; i < n; i++ {
  41. in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate)
  42. out := c.Process(in)
  43. if math.Abs(out) > maxOut {
  44. maxOut = math.Abs(out)
  45. }
  46. }
  47. if maxOut > 1.001 {
  48. t.Errorf("soft clip peak %.6f exceeds ceiling 1.0", maxOut)
  49. }
  50. t.Logf("soft clip (knee=0.2): peak=%.6f", maxOut)
  51. }
  52. func TestCompositeClipperLookaheadPeakLimit(t *testing.T) {
  53. c := NewCompositeClipper(CompositeClipperConfig{
  54. Ceiling: 1.0,
  55. Iterations: 3,
  56. SoftKnee: 0.15,
  57. LookaheadMs: 1.0,
  58. SampleRate: 228000,
  59. })
  60. rate := 228000.0
  61. n := int(rate * 0.10) // 100ms to let look-ahead settle
  62. maxOut := 0.0
  63. for i := 0; i < n; i++ {
  64. in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate)
  65. out := c.Process(in)
  66. if math.Abs(out) > maxOut {
  67. maxOut = math.Abs(out)
  68. }
  69. }
  70. if maxOut > 1.001 {
  71. t.Errorf("lookahead peak %.6f exceeds ceiling 1.0", maxOut)
  72. }
  73. t.Logf("lookahead (1ms, soft knee=0.15, 3 iter): peak=%.6f", maxOut)
  74. }
  75. func TestCompositeClipperGuardBands(t *testing.T) {
  76. // Verify that after clipping, 19kHz and 57kHz energy is suppressed.
  77. // Feed a 1kHz sine at high level → clips → generates harmonics.
  78. // The clipper's notch filters should remove 19kHz and 57kHz.
  79. rate := 228000.0
  80. n := int(rate * 0.1) // 100ms
  81. for _, tc := range []struct {
  82. name string
  83. iterations int
  84. softKnee float64
  85. lookahead float64
  86. }{
  87. {"hard-1iter", 1, 0, 0},
  88. {"hard-3iter", 3, 0, 0},
  89. {"soft-3iter", 3, 0.15, 0},
  90. {"lookahead-3iter", 3, 0.15, 1.0},
  91. } {
  92. t.Run(tc.name, func(t *testing.T) {
  93. c := NewCompositeClipper(CompositeClipperConfig{
  94. Ceiling: 1.0,
  95. Iterations: tc.iterations,
  96. SoftKnee: tc.softKnee,
  97. LookaheadMs: tc.lookahead,
  98. SampleRate: rate,
  99. })
  100. out := make([]float64, n)
  101. for i := 0; i < n; i++ {
  102. in := 2.0 * math.Sin(2*math.Pi*1000*float64(i)/rate)
  103. out[i] = c.Process(in)
  104. }
  105. // Skip first 10ms for filter settling
  106. skip := int(rate * 0.01)
  107. analysis := out[skip:]
  108. e19 := GoertzelEnergy(analysis, rate, 19000)
  109. e57 := GoertzelEnergy(analysis, rate, 57000)
  110. e1k := GoertzelEnergy(analysis, rate, 1000)
  111. r19 := -100.0
  112. r57 := -100.0
  113. if e1k > 0 {
  114. if e19 > 0 {
  115. r19 = 10 * math.Log10(e19/e1k)
  116. }
  117. if e57 > 0 {
  118. r57 = 10 * math.Log10(e57/e1k)
  119. }
  120. }
  121. t.Logf("19kHz: %.1f dB below 1kHz, 57kHz: %.1f dB below 1kHz", -r19, -r57)
  122. // With 3 iterations, 19kHz should be suppressed by at least 40dB
  123. if tc.iterations >= 3 && r19 > -40 {
  124. t.Errorf("19kHz not sufficiently suppressed: %.1f dB (want < -40 dB)", r19)
  125. }
  126. })
  127. }
  128. }
  129. func TestSoftClipContinuity(t *testing.T) {
  130. // Verify C1 continuity at the knee boundary
  131. ceiling := 1.0
  132. knee := 0.2
  133. threshold := ceiling - knee // 0.8
  134. // Slope just below threshold (linear region)
  135. dx := 1e-8
  136. y0 := SoftClip(threshold-dx, ceiling, knee)
  137. y1 := SoftClip(threshold+dx, ceiling, knee)
  138. slope := (y1 - y0) / (2 * dx)
  139. if math.Abs(slope-1.0) > 0.01 {
  140. t.Errorf("slope at knee boundary = %.4f, want ~1.0", slope)
  141. }
  142. // Verify output never exceeds ceiling
  143. for x := 0.0; x <= 5.0; x += 0.001 {
  144. y := SoftClip(x, ceiling, knee)
  145. if y > ceiling+1e-9 {
  146. t.Fatalf("SoftClip(%.3f) = %.6f > ceiling %.6f", x, y, ceiling)
  147. }
  148. }
  149. // Verify asymptotic approach to ceiling
  150. y5 := SoftClip(5.0, ceiling, knee)
  151. if y5 < 0.99 {
  152. t.Errorf("SoftClip(5.0) = %.6f, expected near ceiling (%.6f)", y5, ceiling)
  153. }
  154. }
  155. func TestCompositeClipperReset(t *testing.T) {
  156. c := NewCompositeClipper(CompositeClipperConfig{
  157. Ceiling: 1.0,
  158. Iterations: 2,
  159. SoftKnee: 0.15,
  160. LookaheadMs: 1.0,
  161. SampleRate: 228000,
  162. })
  163. // Process some samples
  164. for i := 0; i < 1000; i++ {
  165. c.Process(0.5)
  166. }
  167. stats := c.Stats()
  168. if stats.Envelope == 0 {
  169. t.Error("envelope should be non-zero after processing")
  170. }
  171. c.Reset()
  172. stats = c.Stats()
  173. if stats.Envelope != 0 {
  174. t.Errorf("envelope should be 0 after reset, got %f", stats.Envelope)
  175. }
  176. if stats.LookaheadGain != 1.0 {
  177. t.Errorf("gain should be 1.0 after reset, got %f", stats.LookaheadGain)
  178. }
  179. }