package audio import ( "bytes" "encoding/binary" "io" "math" "sync" "sync/atomic" "testing" ) func TestStreamSource_WriteRead(t *testing.T) { s := NewStreamSource(1024, 44100) if s.size != 1024 { t.Fatalf("expected size 1024, got %d", s.size) } // Write and read a frame f := NewFrame(0.5, -0.3) if !s.WriteFrame(f) { t.Fatal("write failed") } if s.Available() != 1 { t.Fatalf("expected 1 available, got %d", s.Available()) } out := s.ReadFrame() if out.L != 0.5 || out.R != -0.3 { t.Fatalf("read mismatch: got L=%.2f R=%.2f", out.L, out.R) } if s.Available() != 0 { t.Fatalf("expected 0 available, got %d", s.Available()) } } func TestStreamSource_Underrun(t *testing.T) { s := NewStreamSource(16, 44100) // Read from empty buffer — should return silence f := s.ReadFrame() if f.L != 0 || f.R != 0 { t.Fatal("expected silence on underrun") } if s.Underruns.Load() != 1 { t.Fatalf("expected 1 underrun, got %d", s.Underruns.Load()) } } func TestStreamSource_Overflow(t *testing.T) { s := NewStreamSource(4, 44100) // size rounds up to 4 // Fill completely for i := 0; i < 4; i++ { if !s.WriteFrame(NewFrame(Sample(float64(i)/10), 0)) { t.Fatalf("write %d failed", i) } } // Next write should overflow if s.WriteFrame(NewFrame(1, 1)) { t.Fatal("expected overflow") } if s.Overflows.Load() != 1 { t.Fatalf("expected 1 overflow, got %d", s.Overflows.Load()) } } func TestStreamSource_PowerOf2Rounding(t *testing.T) { tests := []struct{ in, expect int }{ {1, 1}, {2, 2}, {3, 4}, {5, 8}, {100, 128}, {1024, 1024}, {1025, 2048}, } for _, tt := range tests { s := NewStreamSource(tt.in, 44100) if s.size != tt.expect { t.Fatalf("NewStreamSource(%d): size=%d, expected %d", tt.in, s.size, tt.expect) } } } func TestStreamSource_FIFO(t *testing.T) { s := NewStreamSource(64, 44100) n := 50 for i := 0; i < n; i++ { s.WriteFrame(NewFrame(Sample(float64(i)), 0)) } for i := 0; i < n; i++ { f := s.ReadFrame() if int(f.L) != i { t.Fatalf("FIFO order broken at %d: got %d", i, int(f.L)) } } } func TestStreamSource_Wraparound(t *testing.T) { s := NewStreamSource(8, 44100) // size = 8 // Write and read more than buffer size to test wraparound for round := 0; round < 10; round++ { for i := 0; i < 8; i++ { val := float64(round*8 + i) if !s.WriteFrame(NewFrame(Sample(val), 0)) { t.Fatalf("write failed round=%d i=%d", round, i) } } for i := 0; i < 8; i++ { expected := float64(round*8 + i) f := s.ReadFrame() if float64(f.L) != expected { t.Fatalf("round=%d i=%d: got %f expected %f", round, i, float64(f.L), expected) } } } stats := s.Stats() if stats.Underruns != 0 || stats.Overflows != 0 { t.Fatalf("unexpected errors: underruns=%d overflows=%d", stats.Underruns, stats.Overflows) } } func TestStreamSource_WritePCM(t *testing.T) { s := NewStreamSource(256, 44100) // Create 10 stereo frames of S16LE PCM var buf bytes.Buffer for i := 0; i < 10; i++ { l := int16(i * 1000) r := int16(-i * 1000) binary.Write(&buf, binary.LittleEndian, l) binary.Write(&buf, binary.LittleEndian, r) } written := s.WritePCM(buf.Bytes()) if written != 10 { t.Fatalf("expected 10 frames, wrote %d", written) } // Verify first frame f := s.ReadFrame() if f.L != 0 || f.R != 0 { t.Fatalf("frame 0: L=%.4f R=%.4f, expected 0", f.L, f.R) } // Verify frame 5 for i := 1; i < 5; i++ { s.ReadFrame() } f = s.ReadFrame() expectedL := 5000.0 / 32768.0 if math.Abs(float64(f.L)-expectedL) > 0.001 { t.Fatalf("frame 5 L=%.4f, expected %.4f", f.L, expectedL) } } func TestStreamSource_ConcurrentSPSC(t *testing.T) { s := NewStreamSource(4096, 44100) frames := 50000 var producerDone atomic.Bool var wg sync.WaitGroup wg.Add(2) // Producer go func() { defer wg.Done() for i := 0; i < frames; i++ { for !s.WriteFrame(NewFrame(Sample(float64(i+1)), 0)) { // Buffer full — yield } } producerDone.Store(true) }() // Consumer var lastVal float64 var orderOK = true var readCount int go func() { defer wg.Done() for { if s.Available() == 0 { if producerDone.Load() { break } continue } f := s.ReadFrame() readCount++ v := float64(f.L) if v > 0 && v < lastVal { orderOK = false } if v > 0 { lastVal = v } } }() wg.Wait() if !orderOK { t.Fatal("FIFO order broken in concurrent SPSC") } if readCount < frames/2 { t.Fatalf("read too few frames: %d (expected ~%d)", readCount, frames) } } // --- StreamResampler tests --- func TestStreamResampler_1to1(t *testing.T) { s := NewStreamSource(256, 44100) r := NewStreamResampler(s, 44100) // 1:1 for i := 0; i < 100; i++ { s.WriteFrame(NewFrame(Sample(float64(i)/100), 0)) } // At 1:1 ratio, output should track input with a small startup delay. // Skip first few samples (resampler priming), then verify monotonic increase. prev := -1.0 for i := 0; i < 90; i++ { f := r.NextFrame() v := float64(f.L) if i > 5 && v < prev-0.001 { t.Fatalf("sample %d: non-monotonic %.4f < %.4f", i, v, prev) } if v > 0 { prev = v } } // Final value should be close to 0.9 (we wrote 0..0.99) if prev < 0.5 { t.Fatalf("final value %.4f too low (expected > 0.5)", prev) } } func TestStreamResampler_Upsample(t *testing.T) { // 44100 → 228000 (ratio ≈ 0.1934, ~5.17× upsampling) s := NewStreamSource(4096, 44100) r := NewStreamResampler(s, 228000) // Write 1000 frames of a 1kHz sine at 44100 Hz for i := 0; i < 1000; i++ { v := math.Sin(2 * math.Pi * 1000 * float64(i) / 44100) s.WriteFrame(NewFrame(Sample(v), Sample(v))) } // Read upsampled output — should be ~5170 samples for 1000 input // (minus a few for resampler priming) out := make([]float64, 0, 5200) for i := 0; i < 5000; i++ { f := r.NextFrame() out = append(out, float64(f.L)) } // Verify the output is a smooth sine, not clicks or zeros // Check that max amplitude is close to 1.0 maxAmp := 0.0 for _, v := range out[100:] { // skip initial ramp if math.Abs(v) > maxAmp { maxAmp = math.Abs(v) } } if maxAmp < 0.8 { t.Fatalf("max amplitude %.4f too low (expected ~1.0)", maxAmp) } // Check smoothness: no sudden jumps > 0.1 between adjacent samples maxJump := 0.0 for i := 101; i < len(out); i++ { d := math.Abs(out[i] - out[i-1]) if d > maxJump { maxJump = d } } // At 228kHz with 1kHz tone: max step ≈ sin(2π*1000/228000) ≈ 0.0276 if maxJump > 0.05 { t.Fatalf("max inter-sample jump %.4f (expected < 0.05 for smooth sine)", maxJump) } } func TestStreamResampler_Downsample(t *testing.T) { // 96000 → 44100 (ratio ≈ 2.177, downsampling) s := NewStreamSource(8192, 96000) r := NewStreamResampler(s, 44100) // Write 4000 frames at 96kHz for i := 0; i < 4000; i++ { v := math.Sin(2 * math.Pi * 440 * float64(i) / 96000) s.WriteFrame(NewFrame(Sample(v), 0)) } // Should get ~1837 output frames (4000 × 44100/96000) count := 0 for i := 0; i < 1800; i++ { f := r.NextFrame() _ = f count++ } if count != 1800 { t.Fatalf("expected 1800 reads, got %d", count) } } func TestStreamResampler_NilSource(t *testing.T) { r := NewStreamResampler(nil, 228000) f := r.NextFrame() if f.L != 0 || f.R != 0 { t.Fatal("expected silence from nil source") } } // --- IngestReader test --- func TestIngestReader(t *testing.T) { s := NewStreamSource(4096, 44100) // Create PCM data: 100 stereo frames var buf bytes.Buffer for i := 0; i < 100; i++ { l := int16(i * 100) r := int16(-i * 100) binary.Write(&buf, binary.LittleEndian, l) binary.Write(&buf, binary.LittleEndian, r) } // IngestReader should read all data and return nil (EOF) err := IngestReader(bytes.NewReader(buf.Bytes()), s) if err != nil { t.Fatalf("IngestReader: %v", err) } if s.Available() != 100 { t.Fatalf("expected 100 frames, got %d", s.Available()) } // Verify first and last f := s.ReadFrame() if f.L != 0 { t.Fatalf("frame 0 L=%.4f, expected 0", f.L) } for i := 1; i < 99; i++ { s.ReadFrame() } f = s.ReadFrame() expectedL := 9900.0 / 32768.0 if math.Abs(float64(f.L)-expectedL) > 0.01 { t.Fatalf("frame 99 L=%.4f, expected ~%.4f", f.L, expectedL) } } func TestIngestReader_Error(t *testing.T) { s := NewStreamSource(256, 44100) errReader := &errAfterN{n: 10} err := IngestReader(errReader, s) if err == nil { t.Fatal("expected error") } } type errAfterN struct { n, count int } func (r *errAfterN) Read(p []byte) (int, error) { if r.count >= r.n { return 0, io.ErrUnexpectedEOF } r.count++ // Return 4 bytes (one stereo frame) if len(p) >= 4 { p[0], p[1], p[2], p[3] = 0, 0, 0, 0 return 4, nil } return 0, nil }