|
- 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)
- }
- }
|