Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

618 lines
16KB

  1. package dsp
  2. import (
  3. "math"
  4. "testing"
  5. "github.com/jan/fm-rds-tx/internal/output"
  6. )
  7. // makeCompositeFrame builds a CompositeFrame with composite values in I, Q=0.
  8. func makeCompositeFrame(values []float64, rate float64) *output.CompositeFrame {
  9. samples := make([]output.IQSample, len(values))
  10. for i, v := range values {
  11. samples[i] = output.IQSample{I: float32(v), Q: 0}
  12. }
  13. return &output.CompositeFrame{
  14. Samples: samples,
  15. SampleRateHz: rate,
  16. Sequence: 1,
  17. }
  18. }
  19. // iqMagnitude returns sqrt(I²+Q²) for an IQ sample.
  20. func iqMagnitude(s output.IQSample) float64 {
  21. return math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q))
  22. }
  23. // iqPhase returns atan2(Q, I) for an IQ sample.
  24. func iqPhase(s output.IQSample) float64 {
  25. return math.Atan2(float64(s.Q), float64(s.I))
  26. }
  27. // phaseDiff returns the shortest signed angular difference between two angles.
  28. func phaseDiff(a, b float64) float64 {
  29. d := b - a
  30. for d > math.Pi {
  31. d -= 2 * math.Pi
  32. }
  33. for d < -math.Pi {
  34. d += 2 * math.Pi
  35. }
  36. return d
  37. }
  38. // --- Test: zero composite produces no frequency offset ---
  39. func TestFMUpsampler_ZeroComposite(t *testing.T) {
  40. u := NewFMUpsampler(228000, 2280000, 75000)
  41. n := 11400 // 50ms at 228kHz
  42. vals := make([]float64, n)
  43. // All zeros — FM deviation = 0, carrier should be constant phase.
  44. frame := makeCompositeFrame(vals, 228000)
  45. out := u.Process(frame)
  46. if len(out.Samples) == 0 {
  47. t.Fatal("no output samples")
  48. }
  49. if out.SampleRateHz != 2280000 {
  50. t.Fatalf("expected rate 2280000, got %f", out.SampleRateHz)
  51. }
  52. // All output IQ should have magnitude ≈ 1.0 and constant phase (≈ 0)
  53. for i, s := range out.Samples {
  54. mag := iqMagnitude(s)
  55. if math.Abs(mag-1.0) > 0.001 {
  56. t.Fatalf("sample %d: magnitude %.6f, expected ~1.0", i, mag)
  57. }
  58. }
  59. // Phase should not advance (zero deviation)
  60. firstPhase := iqPhase(out.Samples[0])
  61. for i := 1; i < len(out.Samples); i++ {
  62. p := iqPhase(out.Samples[i])
  63. if math.Abs(phaseDiff(firstPhase, p)) > 0.01 {
  64. t.Fatalf("sample %d: phase drifted to %.4f (first was %.4f)", i, p, firstPhase)
  65. }
  66. }
  67. }
  68. // --- Test: DC composite produces constant frequency ---
  69. func TestFMUpsampler_DCComposite(t *testing.T) {
  70. srcRate := 228000.0
  71. dstRate := 2280000.0
  72. maxDev := 75000.0
  73. u := NewFMUpsampler(srcRate, dstRate, maxDev)
  74. dc := 0.5 // half deviation = +37.5 kHz
  75. n := 11400
  76. vals := make([]float64, n)
  77. for i := range vals {
  78. vals[i] = dc
  79. }
  80. frame := makeCompositeFrame(vals, srcRate)
  81. out := u.Process(frame)
  82. if len(out.Samples) < 100 {
  83. t.Fatalf("too few output samples: %d", len(out.Samples))
  84. }
  85. // Expected frequency: dc * maxDev = 37500 Hz
  86. // Expected phase step per output sample: 2π * 37500 / 2280000
  87. expectedStep := 2 * math.Pi * dc * maxDev / dstRate
  88. // Measure average phase step over a stable region (skip first 100)
  89. var totalDiff float64
  90. count := 0
  91. for i := 101; i < len(out.Samples) && i < 10000; i++ {
  92. d := phaseDiff(iqPhase(out.Samples[i-1]), iqPhase(out.Samples[i]))
  93. totalDiff += d
  94. count++
  95. }
  96. avgStep := totalDiff / float64(count)
  97. if math.Abs(avgStep-expectedStep) > 0.001 {
  98. t.Fatalf("average phase step = %.6f, expected %.6f (error %.6f)",
  99. avgStep, expectedStep, avgStep-expectedStep)
  100. }
  101. }
  102. // --- Test: IQ magnitude is always ≈ 1.0 ---
  103. func TestFMUpsampler_UnitMagnitude(t *testing.T) {
  104. u := NewFMUpsampler(228000, 2280000, 75000)
  105. // Varying composite: a 1 kHz tone at full deviation
  106. n := 11400
  107. vals := make([]float64, n)
  108. for i := range vals {
  109. vals[i] = math.Sin(2 * math.Pi * 1000 * float64(i) / 228000)
  110. }
  111. frame := makeCompositeFrame(vals, 228000)
  112. out := u.Process(frame)
  113. for i, s := range out.Samples {
  114. mag := iqMagnitude(s)
  115. if math.Abs(mag-1.0) > 0.002 {
  116. t.Fatalf("sample %d: magnitude %.6f, expected ~1.0", i, mag)
  117. }
  118. }
  119. }
  120. // --- Test: output sample count for exact integer ratio ---
  121. func TestFMUpsampler_OutputCountExactRatio(t *testing.T) {
  122. u := NewFMUpsampler(228000, 2280000, 75000)
  123. srcLen := 11400 // 50ms
  124. vals := make([]float64, srcLen)
  125. frame := makeCompositeFrame(vals, 228000)
  126. // With exact 10:1 ratio, each chunk should produce exactly srcLen * 10 samples.
  127. // Small tolerance for boundary effects on first chunk.
  128. for chunk := 0; chunk < 10; chunk++ {
  129. out := u.Process(frame)
  130. expected := srcLen * 10
  131. if out == nil || len(out.Samples) < expected-1 || len(out.Samples) > expected+1 {
  132. t.Fatalf("chunk %d: got %d output samples, expected ~%d",
  133. chunk, len(out.Samples), expected)
  134. }
  135. }
  136. }
  137. // --- Test: output sample count for non-integer ratio ---
  138. func TestFMUpsampler_OutputCountNonIntegerRatio(t *testing.T) {
  139. // PlutoSDR minimum: 228000 → 2084000, ratio ≈ 9.14
  140. u := NewFMUpsampler(228000, 2084000, 75000)
  141. srcLen := 11400
  142. vals := make([]float64, srcLen)
  143. frame := makeCompositeFrame(vals, 228000)
  144. var totalOut int
  145. for chunk := 0; chunk < 20; chunk++ {
  146. out := u.Process(frame)
  147. totalOut += len(out.Samples)
  148. }
  149. // Over 20 chunks, total output should be close to 20 * srcLen * ratio
  150. expectedTotal := int(20 * float64(srcLen) * 2084000 / 228000)
  151. drift := math.Abs(float64(totalOut-expectedTotal)) / float64(expectedTotal)
  152. if drift > 0.001 {
  153. t.Fatalf("total output %d, expected ~%d (drift %.4f%%)", totalOut, expectedTotal, drift*100)
  154. }
  155. }
  156. // --- CRITICAL TEST: phase continuity across chunk boundaries ---
  157. func TestFMUpsampler_PhaseContinuityAcrossChunks(t *testing.T) {
  158. u := NewFMUpsampler(228000, 2280000, 75000)
  159. srcLen := 11400
  160. dc := 0.3 // constant deviation
  161. vals := make([]float64, srcLen)
  162. for i := range vals {
  163. vals[i] = dc
  164. }
  165. frame := makeCompositeFrame(vals, 228000)
  166. var prevLastPhase float64
  167. for chunk := 0; chunk < 20; chunk++ {
  168. out := u.Process(frame)
  169. n := len(out.Samples)
  170. if n == 0 {
  171. t.Fatalf("chunk %d: no output", chunk)
  172. }
  173. firstPhase := iqPhase(out.Samples[0])
  174. // Check continuity from previous chunk
  175. if chunk > 0 {
  176. jump := math.Abs(phaseDiff(prevLastPhase, firstPhase))
  177. // For DC composite at dstRate, the expected step between
  178. // consecutive output samples is 2π * dc * maxDev / dstRate ≈ 0.062 rad.
  179. // The boundary jump should be close to this, not larger.
  180. expectedStep := 2 * math.Pi * dc * 75000 / 2280000
  181. if jump > expectedStep*3 {
  182. t.Fatalf("chunk %d: boundary phase jump %.6f rad (expected ~%.6f)",
  183. chunk, jump, expectedStep)
  184. }
  185. }
  186. // Also check that phase increments WITHIN the chunk are smooth
  187. maxJump := 0.0
  188. for i := 1; i < n; i++ {
  189. d := math.Abs(phaseDiff(iqPhase(out.Samples[i-1]), iqPhase(out.Samples[i])))
  190. if d > maxJump {
  191. maxJump = d
  192. }
  193. }
  194. expectedIntra := 2 * math.Pi * dc * 75000 / 2280000
  195. if maxJump > expectedIntra*2 {
  196. t.Fatalf("chunk %d: intra-chunk max phase jump %.6f (expected ~%.6f)",
  197. chunk, maxJump, expectedIntra)
  198. }
  199. prevLastPhase = iqPhase(out.Samples[n-1])
  200. }
  201. }
  202. // --- Test: non-integer ratio boundary continuity ---
  203. func TestFMUpsampler_BoundaryContinuityNonIntegerRatio(t *testing.T) {
  204. // 228000 → 2084000, ratio ≈ 9.14 — carry-over is non-zero every chunk
  205. u := NewFMUpsampler(228000, 2084000, 75000)
  206. srcLen := 11400
  207. dc := 0.5
  208. vals := make([]float64, srcLen)
  209. for i := range vals {
  210. vals[i] = dc
  211. }
  212. frame := makeCompositeFrame(vals, 228000)
  213. expectedStep := 2 * math.Pi * dc * 75000 / 2084000
  214. var prevLastPhase float64
  215. for chunk := 0; chunk < 50; chunk++ {
  216. out := u.Process(frame)
  217. n := len(out.Samples)
  218. if n == 0 {
  219. t.Fatalf("chunk %d: no output", chunk)
  220. }
  221. if chunk > 0 {
  222. jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0])))
  223. if jump > expectedStep*3 {
  224. t.Fatalf("chunk %d: boundary jump %.6f rad (expected ~%.6f)",
  225. chunk, jump, expectedStep)
  226. }
  227. }
  228. prevLastPhase = iqPhase(out.Samples[n-1])
  229. }
  230. }
  231. // --- Test: phase wrapping doesn't introduce discontinuity ---
  232. func TestFMUpsampler_PhaseWrapping(t *testing.T) {
  233. u := NewFMUpsampler(228000, 2280000, 75000)
  234. // Full deviation for many chunks — phase will wrap many times
  235. srcLen := 11400
  236. vals := make([]float64, srcLen)
  237. for i := range vals {
  238. vals[i] = 1.0 // full positive deviation
  239. }
  240. frame := makeCompositeFrame(vals, 228000)
  241. expectedStep := 2 * math.Pi * 1.0 * 75000 / 2280000
  242. var prevLastPhase float64
  243. for chunk := 0; chunk < 100; chunk++ {
  244. out := u.Process(frame)
  245. n := len(out.Samples)
  246. if chunk > 0 {
  247. jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0])))
  248. if jump > expectedStep*3 {
  249. t.Fatalf("chunk %d: post-wrap boundary jump %.6f (limit %.6f)",
  250. chunk, jump, expectedStep*3)
  251. }
  252. }
  253. // Spot-check magnitudes (should still be 1.0 after wrapping)
  254. for i := 0; i < n; i += n / 10 {
  255. mag := iqMagnitude(out.Samples[i])
  256. if math.Abs(mag-1.0) > 0.002 {
  257. t.Fatalf("chunk %d sample %d: magnitude %.6f after wrapping", chunk, i, mag)
  258. }
  259. }
  260. prevLastPhase = iqPhase(out.Samples[n-1])
  261. }
  262. }
  263. // --- Test: negative composite (phase wraps negative direction) ---
  264. func TestFMUpsampler_NegativeDeviation(t *testing.T) {
  265. u := NewFMUpsampler(228000, 2280000, 75000)
  266. srcLen := 11400
  267. vals := make([]float64, srcLen)
  268. for i := range vals {
  269. vals[i] = -0.8
  270. }
  271. frame := makeCompositeFrame(vals, 228000)
  272. expectedStep := 2 * math.Pi * 0.8 * 75000 / 2280000 // magnitude
  273. var prevLastPhase float64
  274. for chunk := 0; chunk < 100; chunk++ {
  275. out := u.Process(frame)
  276. n := len(out.Samples)
  277. if chunk > 0 {
  278. jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0])))
  279. if jump > expectedStep*3 {
  280. t.Fatalf("chunk %d: negative-dev boundary jump %.6f", chunk, jump)
  281. }
  282. }
  283. prevLastPhase = iqPhase(out.Samples[n-1])
  284. }
  285. }
  286. // --- Test: equivalence with direct FMModulator (frequency comparison) ---
  287. func TestFMUpsampler_EquivalenceWithFMModulator(t *testing.T) {
  288. srcRate := 228000.0
  289. maxDev := 75000.0
  290. // Generate a 1kHz tone composite signal
  291. srcLen := 11400
  292. composite := make([]float64, srcLen)
  293. for i := range composite {
  294. composite[i] = 0.6 * math.Sin(2*math.Pi*1000*float64(i)/srcRate)
  295. }
  296. // Path A: FMUpsampler at 1:1 ratio (no upsampling, just FM modulation)
  297. up := NewFMUpsampler(srcRate, srcRate, maxDev)
  298. frame := makeCompositeFrame(composite, srcRate)
  299. outA := up.Process(frame)
  300. // Path B: Direct FMModulator
  301. mod := NewFMModulator(srcRate)
  302. mod.MaxDeviation = maxDev
  303. outB := make([]output.IQSample, srcLen)
  304. for i, c := range composite {
  305. oi, oq := mod.Modulate(c)
  306. outB[i] = output.IQSample{I: float32(oi), Q: float32(oq)}
  307. }
  308. // The upsampler has a 1-sample offset due to the virtual prevPhase index.
  309. // Instead of comparing absolute phase, compare instantaneous frequency
  310. // (phase step per sample), which is invariant to time shifts.
  311. freqA := make([]float64, 0, len(outA.Samples)-1)
  312. for i := 1; i < len(outA.Samples); i++ {
  313. freqA = append(freqA, phaseDiff(iqPhase(outA.Samples[i-1]), iqPhase(outA.Samples[i])))
  314. }
  315. freqB := make([]float64, 0, len(outB)-1)
  316. for i := 1; i < len(outB); i++ {
  317. freqB = append(freqB, phaseDiff(iqPhase(outB[i-1]), iqPhase(outB[i])))
  318. }
  319. // Compare frequency profiles (skip edges, allow 1-sample alignment offset)
  320. minLen := len(freqA)
  321. if len(freqB) < minLen {
  322. minLen = len(freqB)
  323. }
  324. maxFreqDiff := 0.0
  325. for i := 20; i < minLen-20; i++ {
  326. // Try both aligned and 1-sample-shifted
  327. d0 := math.Abs(freqA[i] - freqB[i])
  328. d1 := math.Abs(freqA[i] - freqB[i-1])
  329. d := math.Min(d0, d1)
  330. if d > maxFreqDiff {
  331. maxFreqDiff = d
  332. }
  333. }
  334. // Instantaneous frequency should match within 0.02 rad/sample
  335. if maxFreqDiff > 0.02 {
  336. t.Fatalf("max instantaneous frequency difference: %.6f rad/sample (too large)", maxFreqDiff)
  337. }
  338. // All magnitudes should be ~1.0
  339. maxMagDiff := 0.0
  340. for i := range outA.Samples {
  341. mag := iqMagnitude(outA.Samples[i])
  342. d := math.Abs(mag - 1.0)
  343. if d > maxMagDiff {
  344. maxMagDiff = d
  345. }
  346. }
  347. if maxMagDiff > 0.01 {
  348. t.Fatalf("max magnitude deviation: %.6f", maxMagDiff)
  349. }
  350. t.Logf("equivalence: maxFreqDiff=%.6f rad/sample, maxMagDiff=%.6f", maxFreqDiff, maxMagDiff)
  351. }
  352. // --- Test: buffer reuse (no allocation after first call) ---
  353. func TestFMUpsampler_BufferReuse(t *testing.T) {
  354. u := NewFMUpsampler(228000, 2280000, 75000)
  355. srcLen := 11400
  356. vals := make([]float64, srcLen)
  357. frame := makeCompositeFrame(vals, 228000)
  358. // First call allocates
  359. out1 := u.Process(frame)
  360. ptr1 := &out1.Samples[0]
  361. // Second call should reuse the same buffer
  362. out2 := u.Process(frame)
  363. ptr2 := &out2.Samples[0]
  364. if ptr1 != ptr2 {
  365. t.Fatal("buffer was reallocated on second call — should reuse")
  366. }
  367. }
  368. // --- Test: Reset clears state properly ---
  369. func TestFMUpsampler_Reset(t *testing.T) {
  370. u := NewFMUpsampler(228000, 2280000, 75000)
  371. vals := make([]float64, 11400)
  372. for i := range vals {
  373. vals[i] = 0.5
  374. }
  375. frame := makeCompositeFrame(vals, 228000)
  376. // Run a few chunks to accumulate state
  377. for i := 0; i < 5; i++ {
  378. u.Process(frame)
  379. }
  380. phase1, prev1, pos1 := u.Stats()
  381. if phase1 == 0 && prev1 == 0 && pos1 == 0 {
  382. t.Fatal("state should be non-zero after processing")
  383. }
  384. u.Reset()
  385. phase2, prev2, pos2 := u.Stats()
  386. if phase2 != 0 || prev2 != 0 || pos2 != 0 {
  387. t.Fatalf("state not zeroed after Reset: phase=%f prev=%f pos=%f", phase2, prev2, pos2)
  388. }
  389. }
  390. // --- Test: tiny chunks (edge case: 1 source sample) ---
  391. func TestFMUpsampler_TinyChunk(t *testing.T) {
  392. u := NewFMUpsampler(228000, 2280000, 75000)
  393. vals := []float64{0.5}
  394. frame := makeCompositeFrame(vals, 228000)
  395. for chunk := 0; chunk < 20; chunk++ {
  396. out := u.Process(frame)
  397. if len(out.Samples) == 0 {
  398. t.Fatalf("chunk %d: no output from single-sample input", chunk)
  399. }
  400. for i, s := range out.Samples {
  401. mag := iqMagnitude(s)
  402. if math.Abs(mag-1.0) > 0.01 {
  403. t.Fatalf("chunk %d sample %d: magnitude %.4f", chunk, i, mag)
  404. }
  405. }
  406. }
  407. }
  408. // --- Test: alternating sign composite (stress boundary interpolation) ---
  409. func TestFMUpsampler_AlternatingComposite(t *testing.T) {
  410. u := NewFMUpsampler(228000, 2280000, 75000)
  411. srcLen := 11400
  412. vals := make([]float64, srcLen)
  413. for i := range vals {
  414. if i%2 == 0 {
  415. vals[i] = 0.8
  416. } else {
  417. vals[i] = -0.8
  418. }
  419. }
  420. frame := makeCompositeFrame(vals, 228000)
  421. var prevLastPhase float64
  422. for chunk := 0; chunk < 20; chunk++ {
  423. out := u.Process(frame)
  424. n := len(out.Samples)
  425. if chunk > 0 {
  426. jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0])))
  427. // Alternating composite: the phase meanders. Boundary jump
  428. // should still be bounded by the maximum single-sample phase change.
  429. maxSingleStep := 2 * math.Pi * 0.8 * 75000 / 228000 // at source rate
  430. maxOutputJump := maxSingleStep * (228000 / 2280000) // scaled to output
  431. if jump > maxOutputJump*3 {
  432. t.Fatalf("chunk %d: alternating boundary jump %.4f (limit %.4f)",
  433. chunk, jump, maxOutputJump*3)
  434. }
  435. }
  436. // Check all magnitudes
  437. for i, s := range out.Samples {
  438. mag := iqMagnitude(s)
  439. if math.Abs(mag-1.0) > 0.01 {
  440. t.Fatalf("chunk %d sample %d: magnitude %.4f", chunk, i, mag)
  441. }
  442. }
  443. prevLastPhase = iqPhase(out.Samples[n-1])
  444. }
  445. }
  446. // --- Test: long-running stability (simulates 1 hour) ---
  447. func TestFMUpsampler_LongRun(t *testing.T) {
  448. if testing.Short() {
  449. t.Skip("skipping long-run test in short mode")
  450. }
  451. u := NewFMUpsampler(228000, 2280000, 75000)
  452. srcLen := 11400
  453. vals := make([]float64, srcLen)
  454. for i := range vals {
  455. vals[i] = 0.3 * math.Sin(2*math.Pi*1000*float64(i)/228000)
  456. }
  457. frame := makeCompositeFrame(vals, 228000)
  458. // 20 chunks/sec × 3600 sec = 72000 chunks per hour.
  459. // We test 72000 chunks (simulating 1 hour).
  460. chunks := 72000
  461. var prevLastPhase float64
  462. for chunk := 0; chunk < chunks; chunk++ {
  463. out := u.Process(frame)
  464. n := len(out.Samples)
  465. if n == 0 {
  466. t.Fatalf("chunk %d: no output", chunk)
  467. }
  468. // Check boundary every 1000 chunks
  469. if chunk > 0 && chunk%1000 == 0 {
  470. jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0])))
  471. if jump > 0.5 {
  472. t.Fatalf("chunk %d: boundary jump %.4f rad (phase drift?)", chunk, jump)
  473. }
  474. // Check magnitude of first sample
  475. mag := iqMagnitude(out.Samples[0])
  476. if math.Abs(mag-1.0) > 0.01 {
  477. t.Fatalf("chunk %d: magnitude %.4f after long run", chunk, mag)
  478. }
  479. }
  480. prevLastPhase = iqPhase(out.Samples[n-1])
  481. }
  482. // Check phase is still bounded
  483. phase, _, _ := u.Stats()
  484. if math.Abs(phase) > 2*math.Pi {
  485. t.Fatalf("phase unbounded after %d chunks: %.2f", chunks, phase)
  486. }
  487. }
  488. // --- Benchmark ---
  489. func BenchmarkFMUpsampler_Process(b *testing.B) {
  490. u := NewFMUpsampler(228000, 2280000, 75000)
  491. srcLen := 11400
  492. vals := make([]float64, srcLen)
  493. for i := range vals {
  494. vals[i] = 0.5 * math.Sin(2*math.Pi*1000*float64(i)/228000)
  495. }
  496. frame := makeCompositeFrame(vals, 228000)
  497. // Warm up (trigger buffer allocation)
  498. u.Process(frame)
  499. b.ResetTimer()
  500. b.ReportAllocs()
  501. for i := 0; i < b.N; i++ {
  502. u.Process(frame)
  503. }
  504. }