nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

stream_test.go (5870B)


      1 package test
      2 
      3 import (
      4 	"bytes"
      5 	"io"
      6 	"testing"
      7 	"time"
      8 
      9 	"git.sr.ht/~jackmordaunt/nativeaudio"
     10 )
     11 
     12 // TestStreamMatchesDecode ensures the incremental path produces exactly
     13 // the same PCM as the buffered one. Any divergence here means the two
     14 // code paths have drifted apart.
     15 func TestStreamMatchesDecode(t *testing.T) {
     16 	d := newDecoder(t)
     17 
     18 	want, wantFormat, err := d.Decode(compressed)
     19 	if err != nil {
     20 		t.Fatalf("Decode: %v", err)
     21 	}
     22 
     23 	s, err := d.Stream(compressed)
     24 	if err != nil {
     25 		t.Fatalf("Stream: %v", err)
     26 	}
     27 	defer s.Close()
     28 
     29 	// The format must be known before a single byte is read.
     30 	if s.Format() != wantFormat {
     31 		t.Errorf("stream format: want %+v, got %+v", wantFormat, s.Format())
     32 	}
     33 
     34 	got, err := io.ReadAll(s)
     35 	if err != nil {
     36 		t.Fatalf("reading stream: %v", err)
     37 	}
     38 	if !bytes.Equal(got, want) {
     39 		t.Fatalf("stream output differs from buffered decode: %d vs %d bytes", len(got), len(want))
     40 	}
     41 }
     42 
     43 // TestStreamFileMatchesDecodeFile covers the file entry point, which on
     44 // some backends pipes the decode rather than buffering it.
     45 func TestStreamFileMatchesDecodeFile(t *testing.T) {
     46 	d := newDecoder(t)
     47 
     48 	want, wantFormat, err := d.DecodeFile("compressed.m4a")
     49 	if err != nil {
     50 		t.Fatalf("DecodeFile: %v", err)
     51 	}
     52 
     53 	s, err := d.StreamFile("compressed.m4a")
     54 	if err != nil {
     55 		t.Fatalf("StreamFile: %v", err)
     56 	}
     57 	defer s.Close()
     58 
     59 	if s.Format() != wantFormat {
     60 		t.Errorf("stream format: want %+v, got %+v", wantFormat, s.Format())
     61 	}
     62 
     63 	got, err := io.ReadAll(s)
     64 	if err != nil {
     65 		t.Fatalf("reading stream: %v", err)
     66 	}
     67 	if !bytes.Equal(got, want) {
     68 		t.Fatalf("stream output differs from buffered decode: %d vs %d bytes", len(got), len(want))
     69 	}
     70 }
     71 
     72 // TestStreamSmallReads ensures the stream honours whatever buffer size
     73 // the caller offers, rather than assuming it is handed a large one.
     74 func TestStreamSmallReads(t *testing.T) {
     75 	d := newDecoder(t)
     76 
     77 	want, _, err := d.Decode(compressed)
     78 	if err != nil {
     79 		t.Fatalf("Decode: %v", err)
     80 	}
     81 
     82 	s, err := d.Stream(compressed)
     83 	if err != nil {
     84 		t.Fatalf("Stream: %v", err)
     85 	}
     86 	defer s.Close()
     87 
     88 	var (
     89 		got [][]byte
     90 		buf = make([]byte, 7) // deliberately small and not sample-aligned
     91 	)
     92 	for {
     93 		n, err := s.Read(buf)
     94 		if n > 0 {
     95 			got = append(got, append([]byte(nil), buf[:n]...))
     96 		}
     97 		if err == io.EOF {
     98 			break
     99 		}
    100 		if err != nil {
    101 			t.Fatalf("reading stream: %v", err)
    102 		}
    103 	}
    104 	if joined := bytes.Join(got, nil); !bytes.Equal(joined, want) {
    105 		t.Fatalf("small reads produced %d bytes, want %d", len(joined), len(want))
    106 	}
    107 }
    108 
    109 // TestStreamAbandonedEarly closes a stream without draining it. Backends
    110 // hold decoder state and, where ffmpeg is used, a live subprocess, so
    111 // this must not leak either.
    112 func TestStreamAbandonedEarly(t *testing.T) {
    113 	d := newDecoder(t)
    114 	for i := 0; i < 5; i++ {
    115 		s, err := d.Stream(compressed)
    116 		if err != nil {
    117 			t.Fatalf("Stream: %v", err)
    118 		}
    119 		if _, err := io.CopyN(io.Discard, s, 1024); err != nil {
    120 			t.Fatalf("partial read: %v", err)
    121 		}
    122 		if err := s.Close(); err != nil {
    123 			t.Fatalf("closing abandoned stream: %v", err)
    124 		}
    125 		if err := s.Close(); err != nil {
    126 			t.Fatalf("second close should be a no-op: %v", err)
    127 		}
    128 	}
    129 }
    130 
    131 // TestCloseWaitsForStreams ensures the decoder does not tear down
    132 // platform state while a stream is still using it.
    133 func TestCloseWaitsForStreams(t *testing.T) {
    134 	d, err := nativeaudio.New()
    135 	if err != nil {
    136 		t.Fatalf("New: %v", err)
    137 	}
    138 	s, err := d.Stream(compressed)
    139 	if err != nil {
    140 		t.Fatalf("Stream: %v", err)
    141 	}
    142 
    143 	closed := make(chan error, 1)
    144 	go func() { closed <- d.Close() }()
    145 
    146 	select {
    147 	case err := <-closed:
    148 		t.Fatalf("Close returned while a stream was open: %v", err)
    149 	case <-time.After(100 * time.Millisecond):
    150 	}
    151 
    152 	if err := s.Close(); err != nil {
    153 		t.Fatalf("closing stream: %v", err)
    154 	}
    155 
    156 	select {
    157 	case err := <-closed:
    158 		if err != nil {
    159 			t.Fatalf("Close: %v", err)
    160 		}
    161 	case <-time.After(10 * time.Second):
    162 		t.Fatal("Close did not return after the stream was closed")
    163 	}
    164 
    165 	if _, err := d.Stream(compressed); err != nativeaudio.ErrClosed {
    166 		t.Fatalf("stream after close: want ErrClosed, got %v", err)
    167 	}
    168 }
    169 
    170 // TestFFmpegStream covers the piped ffmpeg decode directly, on every
    171 // platform that has ffmpeg rather than only where it is the backend.
    172 func TestFFmpegStream(t *testing.T) {
    173 	requireTools(t, "ffmpeg", "ffprobe")
    174 
    175 	want, wantFormat, err := nativeaudio.FFmpegLoad("compressed.m4a")
    176 	if err != nil {
    177 		t.Fatalf("FFmpegLoad: %v", err)
    178 	}
    179 
    180 	s, err := nativeaudio.FFmpegStream("compressed.m4a")
    181 	if err != nil {
    182 		t.Fatalf("FFmpegStream: %v", err)
    183 	}
    184 	defer s.Close()
    185 
    186 	if s.Format() != wantFormat {
    187 		t.Errorf("stream format: want %+v, got %+v", wantFormat, s.Format())
    188 	}
    189 
    190 	got, err := io.ReadAll(s)
    191 	if err != nil {
    192 		t.Fatalf("reading stream: %v", err)
    193 	}
    194 	if !bytes.Equal(got, want) {
    195 		t.Fatalf("piped ffmpeg output differs from buffered: %d vs %d bytes", len(got), len(want))
    196 	}
    197 }
    198 
    199 // TestFFmpegStreamAbandoned kills the ffmpeg process early. There is no
    200 // portable way to inspect the process table, so the timeout is what
    201 // catches a stream that fails to reap its child.
    202 func TestFFmpegStreamAbandoned(t *testing.T) {
    203 	requireTools(t, "ffmpeg", "ffprobe")
    204 	for i := 0; i < 3; i++ {
    205 		s, err := nativeaudio.FFmpegStream("compressed.m4a")
    206 		if err != nil {
    207 			t.Fatalf("FFmpegStream: %v", err)
    208 		}
    209 		if _, err := io.CopyN(io.Discard, s, 512); err != nil {
    210 			t.Fatalf("partial read: %v", err)
    211 		}
    212 		done := make(chan error, 1)
    213 		go func() { done <- s.Close() }()
    214 		select {
    215 		case err := <-done:
    216 			if err != nil {
    217 				t.Fatalf("closing abandoned ffmpeg stream: %v", err)
    218 			}
    219 		case <-time.After(30 * time.Second):
    220 			// Generous on purpose. This guards against a stream that never
    221 			// reaps its child at all, not against a slow one: a loaded CI
    222 			// runner took ten seconds just to tear the process down.
    223 			t.Fatal("closing an abandoned ffmpeg stream hung")
    224 		}
    225 	}
    226 }