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 }