|
|
@@ -0,0 +1,617 @@ |
|
|
|
|
|
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) |
|
|
|
|
|
} |
|
|
|
|
|
} |