package dsp import ( "math" "testing" "github.com/jan/fm-rds-tx/internal/output" ) // makeCompositeFrame builds a CompositeFrame with composite values in I, Q=0. func makeCompositeFrame(values []float64, rate float64) *output.CompositeFrame { samples := make([]output.IQSample, len(values)) for i, v := range values { samples[i] = output.IQSample{I: float32(v), Q: 0} } return &output.CompositeFrame{ Samples: samples, SampleRateHz: rate, Sequence: 1, } } // iqMagnitude returns sqrt(I²+Q²) for an IQ sample. func iqMagnitude(s output.IQSample) float64 { return math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) } // iqPhase returns atan2(Q, I) for an IQ sample. func iqPhase(s output.IQSample) float64 { return math.Atan2(float64(s.Q), float64(s.I)) } // phaseDiff returns the shortest signed angular difference between two angles. func phaseDiff(a, b float64) float64 { d := b - a for d > math.Pi { d -= 2 * math.Pi } for d < -math.Pi { d += 2 * math.Pi } return d } // --- Test: zero composite produces no frequency offset --- func TestFMUpsampler_ZeroComposite(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) n := 11400 // 50ms at 228kHz vals := make([]float64, n) // All zeros — FM deviation = 0, carrier should be constant phase. frame := makeCompositeFrame(vals, 228000) out := u.Process(frame) if len(out.Samples) == 0 { t.Fatal("no output samples") } if out.SampleRateHz != 2280000 { t.Fatalf("expected rate 2280000, got %f", out.SampleRateHz) } // All output IQ should have magnitude ≈ 1.0 and constant phase (≈ 0) for i, s := range out.Samples { mag := iqMagnitude(s) if math.Abs(mag-1.0) > 0.001 { t.Fatalf("sample %d: magnitude %.6f, expected ~1.0", i, mag) } } // Phase should not advance (zero deviation) firstPhase := iqPhase(out.Samples[0]) for i := 1; i < len(out.Samples); i++ { p := iqPhase(out.Samples[i]) if math.Abs(phaseDiff(firstPhase, p)) > 0.01 { t.Fatalf("sample %d: phase drifted to %.4f (first was %.4f)", i, p, firstPhase) } } } // --- Test: DC composite produces constant frequency --- func TestFMUpsampler_DCComposite(t *testing.T) { srcRate := 228000.0 dstRate := 2280000.0 maxDev := 75000.0 u := NewFMUpsampler(srcRate, dstRate, maxDev) dc := 0.5 // half deviation = +37.5 kHz n := 11400 vals := make([]float64, n) for i := range vals { vals[i] = dc } frame := makeCompositeFrame(vals, srcRate) out := u.Process(frame) if len(out.Samples) < 100 { t.Fatalf("too few output samples: %d", len(out.Samples)) } // Expected frequency: dc * maxDev = 37500 Hz // Expected phase step per output sample: 2π * 37500 / 2280000 expectedStep := 2 * math.Pi * dc * maxDev / dstRate // Measure average phase step over a stable region (skip first 100) var totalDiff float64 count := 0 for i := 101; i < len(out.Samples) && i < 10000; i++ { d := phaseDiff(iqPhase(out.Samples[i-1]), iqPhase(out.Samples[i])) totalDiff += d count++ } avgStep := totalDiff / float64(count) if math.Abs(avgStep-expectedStep) > 0.001 { t.Fatalf("average phase step = %.6f, expected %.6f (error %.6f)", avgStep, expectedStep, avgStep-expectedStep) } } // --- Test: IQ magnitude is always ≈ 1.0 --- func TestFMUpsampler_UnitMagnitude(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) // Varying composite: a 1 kHz tone at full deviation n := 11400 vals := make([]float64, n) for i := range vals { vals[i] = math.Sin(2 * math.Pi * 1000 * float64(i) / 228000) } frame := makeCompositeFrame(vals, 228000) out := u.Process(frame) for i, s := range out.Samples { mag := iqMagnitude(s) if math.Abs(mag-1.0) > 0.002 { t.Fatalf("sample %d: magnitude %.6f, expected ~1.0", i, mag) } } } // --- Test: output sample count for exact integer ratio --- func TestFMUpsampler_OutputCountExactRatio(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 // 50ms vals := make([]float64, srcLen) frame := makeCompositeFrame(vals, 228000) // With exact 10:1 ratio, each chunk should produce exactly srcLen * 10 samples. // Small tolerance for boundary effects on first chunk. for chunk := 0; chunk < 10; chunk++ { out := u.Process(frame) expected := srcLen * 10 if out == nil || len(out.Samples) < expected-1 || len(out.Samples) > expected+1 { t.Fatalf("chunk %d: got %d output samples, expected ~%d", chunk, len(out.Samples), expected) } } } // --- Test: output sample count for non-integer ratio --- func TestFMUpsampler_OutputCountNonIntegerRatio(t *testing.T) { // PlutoSDR minimum: 228000 → 2084000, ratio ≈ 9.14 u := NewFMUpsampler(228000, 2084000, 75000) srcLen := 11400 vals := make([]float64, srcLen) frame := makeCompositeFrame(vals, 228000) var totalOut int for chunk := 0; chunk < 20; chunk++ { out := u.Process(frame) totalOut += len(out.Samples) } // Over 20 chunks, total output should be close to 20 * srcLen * ratio expectedTotal := int(20 * float64(srcLen) * 2084000 / 228000) drift := math.Abs(float64(totalOut-expectedTotal)) / float64(expectedTotal) if drift > 0.001 { t.Fatalf("total output %d, expected ~%d (drift %.4f%%)", totalOut, expectedTotal, drift*100) } } // --- CRITICAL TEST: phase continuity across chunk boundaries --- func TestFMUpsampler_PhaseContinuityAcrossChunks(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 dc := 0.3 // constant deviation vals := make([]float64, srcLen) for i := range vals { vals[i] = dc } frame := makeCompositeFrame(vals, 228000) var prevLastPhase float64 for chunk := 0; chunk < 20; chunk++ { out := u.Process(frame) n := len(out.Samples) if n == 0 { t.Fatalf("chunk %d: no output", chunk) } firstPhase := iqPhase(out.Samples[0]) // Check continuity from previous chunk if chunk > 0 { jump := math.Abs(phaseDiff(prevLastPhase, firstPhase)) // For DC composite at dstRate, the expected step between // consecutive output samples is 2π * dc * maxDev / dstRate ≈ 0.062 rad. // The boundary jump should be close to this, not larger. expectedStep := 2 * math.Pi * dc * 75000 / 2280000 if jump > expectedStep*3 { t.Fatalf("chunk %d: boundary phase jump %.6f rad (expected ~%.6f)", chunk, jump, expectedStep) } } // Also check that phase increments WITHIN the chunk are smooth maxJump := 0.0 for i := 1; i < n; i++ { d := math.Abs(phaseDiff(iqPhase(out.Samples[i-1]), iqPhase(out.Samples[i]))) if d > maxJump { maxJump = d } } expectedIntra := 2 * math.Pi * dc * 75000 / 2280000 if maxJump > expectedIntra*2 { t.Fatalf("chunk %d: intra-chunk max phase jump %.6f (expected ~%.6f)", chunk, maxJump, expectedIntra) } prevLastPhase = iqPhase(out.Samples[n-1]) } } // --- Test: non-integer ratio boundary continuity --- func TestFMUpsampler_BoundaryContinuityNonIntegerRatio(t *testing.T) { // 228000 → 2084000, ratio ≈ 9.14 — carry-over is non-zero every chunk u := NewFMUpsampler(228000, 2084000, 75000) srcLen := 11400 dc := 0.5 vals := make([]float64, srcLen) for i := range vals { vals[i] = dc } frame := makeCompositeFrame(vals, 228000) expectedStep := 2 * math.Pi * dc * 75000 / 2084000 var prevLastPhase float64 for chunk := 0; chunk < 50; chunk++ { out := u.Process(frame) n := len(out.Samples) if n == 0 { t.Fatalf("chunk %d: no output", chunk) } if chunk > 0 { jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) if jump > expectedStep*3 { t.Fatalf("chunk %d: boundary jump %.6f rad (expected ~%.6f)", chunk, jump, expectedStep) } } prevLastPhase = iqPhase(out.Samples[n-1]) } } // --- Test: phase wrapping doesn't introduce discontinuity --- func TestFMUpsampler_PhaseWrapping(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) // Full deviation for many chunks — phase will wrap many times srcLen := 11400 vals := make([]float64, srcLen) for i := range vals { vals[i] = 1.0 // full positive deviation } frame := makeCompositeFrame(vals, 228000) expectedStep := 2 * math.Pi * 1.0 * 75000 / 2280000 var prevLastPhase float64 for chunk := 0; chunk < 100; chunk++ { out := u.Process(frame) n := len(out.Samples) if chunk > 0 { jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) if jump > expectedStep*3 { t.Fatalf("chunk %d: post-wrap boundary jump %.6f (limit %.6f)", chunk, jump, expectedStep*3) } } // Spot-check magnitudes (should still be 1.0 after wrapping) for i := 0; i < n; i += n / 10 { mag := iqMagnitude(out.Samples[i]) if math.Abs(mag-1.0) > 0.002 { t.Fatalf("chunk %d sample %d: magnitude %.6f after wrapping", chunk, i, mag) } } prevLastPhase = iqPhase(out.Samples[n-1]) } } // --- Test: negative composite (phase wraps negative direction) --- func TestFMUpsampler_NegativeDeviation(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 vals := make([]float64, srcLen) for i := range vals { vals[i] = -0.8 } frame := makeCompositeFrame(vals, 228000) expectedStep := 2 * math.Pi * 0.8 * 75000 / 2280000 // magnitude var prevLastPhase float64 for chunk := 0; chunk < 100; chunk++ { out := u.Process(frame) n := len(out.Samples) if chunk > 0 { jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) if jump > expectedStep*3 { t.Fatalf("chunk %d: negative-dev boundary jump %.6f", chunk, jump) } } prevLastPhase = iqPhase(out.Samples[n-1]) } } // --- Test: equivalence with direct FMModulator (frequency comparison) --- func TestFMUpsampler_EquivalenceWithFMModulator(t *testing.T) { srcRate := 228000.0 maxDev := 75000.0 // Generate a 1kHz tone composite signal srcLen := 11400 composite := make([]float64, srcLen) for i := range composite { composite[i] = 0.6 * math.Sin(2*math.Pi*1000*float64(i)/srcRate) } // Path A: FMUpsampler at 1:1 ratio (no upsampling, just FM modulation) up := NewFMUpsampler(srcRate, srcRate, maxDev) frame := makeCompositeFrame(composite, srcRate) outA := up.Process(frame) // Path B: Direct FMModulator mod := NewFMModulator(srcRate) mod.MaxDeviation = maxDev outB := make([]output.IQSample, srcLen) for i, c := range composite { oi, oq := mod.Modulate(c) outB[i] = output.IQSample{I: float32(oi), Q: float32(oq)} } // The upsampler has a 1-sample offset due to the virtual prevPhase index. // Instead of comparing absolute phase, compare instantaneous frequency // (phase step per sample), which is invariant to time shifts. freqA := make([]float64, 0, len(outA.Samples)-1) for i := 1; i < len(outA.Samples); i++ { freqA = append(freqA, phaseDiff(iqPhase(outA.Samples[i-1]), iqPhase(outA.Samples[i]))) } freqB := make([]float64, 0, len(outB)-1) for i := 1; i < len(outB); i++ { freqB = append(freqB, phaseDiff(iqPhase(outB[i-1]), iqPhase(outB[i]))) } // Compare frequency profiles (skip edges, allow 1-sample alignment offset) minLen := len(freqA) if len(freqB) < minLen { minLen = len(freqB) } maxFreqDiff := 0.0 for i := 20; i < minLen-20; i++ { // Try both aligned and 1-sample-shifted d0 := math.Abs(freqA[i] - freqB[i]) d1 := math.Abs(freqA[i] - freqB[i-1]) d := math.Min(d0, d1) if d > maxFreqDiff { maxFreqDiff = d } } // Instantaneous frequency should match within 0.02 rad/sample if maxFreqDiff > 0.02 { t.Fatalf("max instantaneous frequency difference: %.6f rad/sample (too large)", maxFreqDiff) } // All magnitudes should be ~1.0 maxMagDiff := 0.0 for i := range outA.Samples { mag := iqMagnitude(outA.Samples[i]) d := math.Abs(mag - 1.0) if d > maxMagDiff { maxMagDiff = d } } if maxMagDiff > 0.01 { t.Fatalf("max magnitude deviation: %.6f", maxMagDiff) } t.Logf("equivalence: maxFreqDiff=%.6f rad/sample, maxMagDiff=%.6f", maxFreqDiff, maxMagDiff) } // --- Test: buffer reuse (no allocation after first call) --- func TestFMUpsampler_BufferReuse(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 vals := make([]float64, srcLen) frame := makeCompositeFrame(vals, 228000) // First call allocates out1 := u.Process(frame) ptr1 := &out1.Samples[0] // Second call should reuse the same buffer out2 := u.Process(frame) ptr2 := &out2.Samples[0] if ptr1 != ptr2 { t.Fatal("buffer was reallocated on second call — should reuse") } } // --- Test: Reset clears state properly --- func TestFMUpsampler_Reset(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) vals := make([]float64, 11400) for i := range vals { vals[i] = 0.5 } frame := makeCompositeFrame(vals, 228000) // Run a few chunks to accumulate state for i := 0; i < 5; i++ { u.Process(frame) } phase1, prev1, pos1 := u.Stats() if phase1 == 0 && prev1 == 0 && pos1 == 0 { t.Fatal("state should be non-zero after processing") } u.Reset() phase2, prev2, pos2 := u.Stats() if phase2 != 0 || prev2 != 0 || pos2 != 0 { t.Fatalf("state not zeroed after Reset: phase=%f prev=%f pos=%f", phase2, prev2, pos2) } } // --- Test: tiny chunks (edge case: 1 source sample) --- func TestFMUpsampler_TinyChunk(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) vals := []float64{0.5} frame := makeCompositeFrame(vals, 228000) for chunk := 0; chunk < 20; chunk++ { out := u.Process(frame) if len(out.Samples) == 0 { t.Fatalf("chunk %d: no output from single-sample input", chunk) } for i, s := range out.Samples { mag := iqMagnitude(s) if math.Abs(mag-1.0) > 0.01 { t.Fatalf("chunk %d sample %d: magnitude %.4f", chunk, i, mag) } } } } // --- Test: alternating sign composite (stress boundary interpolation) --- func TestFMUpsampler_AlternatingComposite(t *testing.T) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 vals := make([]float64, srcLen) for i := range vals { if i%2 == 0 { vals[i] = 0.8 } else { vals[i] = -0.8 } } frame := makeCompositeFrame(vals, 228000) var prevLastPhase float64 for chunk := 0; chunk < 20; chunk++ { out := u.Process(frame) n := len(out.Samples) if chunk > 0 { jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) // Alternating composite: the phase meanders. Boundary jump // should still be bounded by the maximum single-sample phase change. maxSingleStep := 2 * math.Pi * 0.8 * 75000 / 228000.0 // at source rate maxOutputJump := maxSingleStep * 0.1 // step = srcRate/dstRate if jump > maxOutputJump*3 { t.Fatalf("chunk %d: alternating boundary jump %.4f (limit %.4f)", chunk, jump, maxOutputJump*3) } } // Check all magnitudes for i, s := range out.Samples { mag := iqMagnitude(s) if math.Abs(mag-1.0) > 0.01 { t.Fatalf("chunk %d sample %d: magnitude %.4f", chunk, i, mag) } } prevLastPhase = iqPhase(out.Samples[n-1]) } } // --- Test: long-running stability (simulates 1 hour) --- func TestFMUpsampler_LongRun(t *testing.T) { if testing.Short() { t.Skip("skipping long-run test in short mode") } u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 vals := make([]float64, srcLen) for i := range vals { vals[i] = 0.3 * math.Sin(2*math.Pi*1000*float64(i)/228000) } frame := makeCompositeFrame(vals, 228000) // 20 chunks/sec × 3600 sec = 72000 chunks per hour. // We test 72000 chunks (simulating 1 hour). chunks := 72000 var prevLastPhase float64 for chunk := 0; chunk < chunks; chunk++ { out := u.Process(frame) n := len(out.Samples) if n == 0 { t.Fatalf("chunk %d: no output", chunk) } // Check boundary every 1000 chunks if chunk > 0 && chunk%1000 == 0 { jump := math.Abs(phaseDiff(prevLastPhase, iqPhase(out.Samples[0]))) if jump > 0.5 { t.Fatalf("chunk %d: boundary jump %.4f rad (phase drift?)", chunk, jump) } // Check magnitude of first sample mag := iqMagnitude(out.Samples[0]) if math.Abs(mag-1.0) > 0.01 { t.Fatalf("chunk %d: magnitude %.4f after long run", chunk, mag) } } prevLastPhase = iqPhase(out.Samples[n-1]) } // Check phase is still bounded phase, _, _ := u.Stats() if math.Abs(phase) > 2*math.Pi { t.Fatalf("phase unbounded after %d chunks: %.2f", chunks, phase) } } // --- Benchmark --- func BenchmarkFMUpsampler_Process(b *testing.B) { u := NewFMUpsampler(228000, 2280000, 75000) srcLen := 11400 vals := make([]float64, srcLen) for i := range vals { vals[i] = 0.5 * math.Sin(2*math.Pi*1000*float64(i)/228000) } frame := makeCompositeFrame(vals, 228000) // Warm up (trigger buffer allocation) u.Process(frame) b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { u.Process(frame) } }