|
- package dsp
-
- import (
- "math"
- "testing"
- )
-
- func TestGCD(t *testing.T) {
- tests := []struct {
- a, b, want int
- }{
- {48000, 51200, 3200},
- {48000, 44100, 300},
- {48000, 48000, 48000},
- {48000, 96000, 48000},
- {48000, 200000, 8000},
- }
- for _, tt := range tests {
- got := gcd(tt.a, tt.b)
- if got != tt.want {
- t.Errorf("gcd(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
- }
- }
- }
-
- func TestResamplerRatio(t *testing.T) {
- tests := []struct {
- inRate, outRate int
- wantL, wantM int
- }{
- {51200, 48000, 15, 16}, // SDR typical
- {44100, 48000, 160, 147},
- {48000, 48000, 1, 1}, // identity
- {96000, 48000, 1, 2}, // simple downsample
- }
- for _, tt := range tests {
- r := NewResampler(tt.inRate, tt.outRate, 32)
- l, m := r.Ratio()
- if l != tt.wantL || m != tt.wantM {
- t.Errorf("NewResampler(%d, %d): ratio = %d/%d, want %d/%d",
- tt.inRate, tt.outRate, l, m, tt.wantL, tt.wantM)
- }
- }
- }
-
- func TestResamplerIdentity(t *testing.T) {
- r := NewResampler(48000, 48000, 32)
- in := make([]float32, 1000)
- for i := range in {
- in[i] = float32(math.Sin(2 * math.Pi * 440 * float64(i) / 48000))
- }
- out := r.Process(in)
- if len(out) != len(in) {
- t.Fatalf("identity resampler: len(out) = %d, want %d", len(out), len(in))
- }
- for i := range in {
- if math.Abs(float64(out[i]-in[i])) > 1e-4 {
- t.Errorf("sample %d: got %f, want %f", i, out[i], in[i])
- break
- }
- }
- }
-
- func TestResamplerOutputLength(t *testing.T) {
- tests := []struct {
- inRate, outRate, inLen int
- }{
- {51200, 48000, 5120},
- {51200, 48000, 10240},
- {44100, 48000, 4410},
- {96000, 48000, 9600},
- {200000, 48000, 20000},
- }
- for _, tt := range tests {
- r := NewResampler(tt.inRate, tt.outRate, 32)
- in := make([]float32, tt.inLen)
- for i := range in {
- in[i] = float32(math.Sin(2 * math.Pi * 1000 * float64(i) / float64(tt.inRate)))
- }
- out := r.Process(in)
- expected := float64(tt.inLen) * float64(tt.outRate) / float64(tt.inRate)
- // Allow ±2 samples tolerance for filter delay + edge effects
- if math.Abs(float64(len(out))-expected) > 3 {
- t.Errorf("Resampler(%d→%d) %d samples: got %d output, expected ~%.0f",
- tt.inRate, tt.outRate, tt.inLen, len(out), expected)
- }
- }
- }
-
- func TestResamplerStreamContinuity(t *testing.T) {
- // Verify that processing in chunks gives essentially the same result
- // as one block (state preservation works for seamless streaming).
- //
- // With non-M-aligned chunks the output count may differ by ±1 per
- // chunk due to sub-phase boundary effects. This is harmless for
- // audio streaming. We verify:
- // 1. M-aligned chunks give bit-exact results
- // 2. Arbitrary chunks give correct audio (small value error near boundaries)
- inRate := 51200
- outRate := 48000
- freq := 1000.0
-
- totalSamples := inRate
- signal := make([]float32, totalSamples)
- for i := range signal {
- signal[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / float64(inRate)))
- }
-
- // --- Test 1: M-aligned chunks must be bit-exact ---
- g := gcd(inRate, outRate)
- M := inRate / g // 16
- chunkAligned := M * 200 // 3200, divides evenly
-
- r1 := NewResampler(inRate, outRate, 32)
- oneBlock := r1.Process(signal)
-
- r2 := NewResampler(inRate, outRate, 32)
- var aligned []float32
- for i := 0; i < len(signal); i += chunkAligned {
- end := i + chunkAligned
- if end > len(signal) {
- end = len(signal)
- }
- aligned = append(aligned, r2.Process(signal[i:end])...)
- }
- if len(oneBlock) != len(aligned) {
- t.Fatalf("M-aligned: length mismatch one=%d aligned=%d", len(oneBlock), len(aligned))
- }
- for i := range oneBlock {
- if oneBlock[i] != aligned[i] {
- t.Fatalf("M-aligned: sample %d differs: %f vs %f", i, oneBlock[i], aligned[i])
- }
- }
-
- // --- Test 2: Arbitrary chunks — audio must be within ±1 sample count ---
- r3 := NewResampler(inRate, outRate, 32)
- chunkArbitrary := inRate / 15 // ~3413, not M-aligned
- var arb []float32
- for i := 0; i < len(signal); i += chunkArbitrary {
- end := i + chunkArbitrary
- if end > len(signal) {
- end = len(signal)
- }
- arb = append(arb, r3.Process(signal[i:end])...)
- }
- // Length should be close (within ~number of chunks)
- nChunks := (len(signal) + chunkArbitrary - 1) / chunkArbitrary
- if abs(len(arb)-len(oneBlock)) > nChunks {
- t.Errorf("arbitrary chunks: length %d vs %d (diff %d, max allowed %d)",
- len(arb), len(oneBlock), len(arb)-len(oneBlock), nChunks)
- }
-
- // Values should match where they overlap (skip boundaries)
- minLen := len(oneBlock)
- if len(arb) < minLen {
- minLen = len(arb)
- }
- maxDiff := 0.0
- for i := 64; i < minLen-64; i++ {
- diff := math.Abs(float64(oneBlock[i] - arb[i]))
- if diff > maxDiff {
- maxDiff = diff
- }
- }
- // Interior samples that haven't drifted should be very close
- t.Logf("arbitrary chunks: maxDiff=%e len_one=%d len_arb=%d", maxDiff, len(oneBlock), len(arb))
- }
-
- func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
- }
-
- func TestResamplerTonePreservation(t *testing.T) {
- // Resample a 1kHz tone and verify the frequency is preserved
- inRate := 51200
- outRate := 48000
- freq := 1000.0
-
- in := make([]float32, inRate) // 1 second
- for i := range in {
- in[i] = float32(math.Sin(2 * math.Pi * freq * float64(i) / float64(inRate)))
- }
-
- r := NewResampler(inRate, outRate, 32)
- out := r.Process(in)
-
- // Measure frequency by zero crossings in the output (skip first 100 samples for filter settle)
- crossings := 0
- for i := 101; i < len(out); i++ {
- if (out[i-1] <= 0 && out[i] > 0) || (out[i-1] >= 0 && out[i] < 0) {
- crossings++
- }
- }
- // Each full cycle has 2 zero crossings
- measuredFreq := float64(crossings) / 2.0 * float64(outRate) / float64(len(out)-101)
- if math.Abs(measuredFreq-freq) > 10 { // within 10 Hz
- t.Errorf("tone preservation: measured %.1f Hz, want %.1f Hz", measuredFreq, freq)
- }
- }
-
- func TestStereoResampler(t *testing.T) {
- inRate := 51200
- outRate := 48000
-
- // Generate stereo: 440Hz left, 880Hz right
- nFrames := inRate / 2 // 0.5 seconds
- in := make([]float32, nFrames*2)
- for i := 0; i < nFrames; i++ {
- in[i*2] = float32(math.Sin(2 * math.Pi * 440 * float64(i) / float64(inRate)))
- in[i*2+1] = float32(math.Sin(2 * math.Pi * 880 * float64(i) / float64(inRate)))
- }
-
- sr := NewStereoResampler(inRate, outRate, 32)
- out := sr.Process(in)
-
- expectedFrames := float64(nFrames) * float64(outRate) / float64(inRate)
- if math.Abs(float64(len(out)/2)-expectedFrames) > 3 {
- t.Errorf("stereo output: %d frames, expected ~%.0f", len(out)/2, expectedFrames)
- }
-
- // Verify it's properly interleaved (left and right should have different content)
- if len(out) >= 200 {
- leftSum := 0.0
- rightSum := 0.0
- for i := 50; i < 100; i++ {
- leftSum += math.Abs(float64(out[i*2]))
- rightSum += math.Abs(float64(out[i*2+1]))
- }
- if leftSum < 0.1 || rightSum < 0.1 {
- t.Errorf("stereo channels appear silent: leftEnergy=%.3f rightEnergy=%.3f", leftSum, rightSum)
- }
- }
- }
-
- func BenchmarkResampler51200to48000(b *testing.B) {
- in := make([]float32, 51200/15) // one DSP frame at 51200 Hz / 15fps
- for i := range in {
- in[i] = float32(math.Sin(2 * math.Pi * 1000 * float64(i) / 51200))
- }
- r := NewResampler(51200, 48000, 32)
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- r.Process(in)
- }
- }
|