audio_test.go (8978B)
1 package test 2 3 import ( 4 "bytes" 5 _ "embed" 6 "encoding/binary" 7 "fmt" 8 "runtime" 9 "slices" 10 "strings" 11 "testing" 12 13 "git.sr.ht/~jackmordaunt/nativeaudio" 14 ) 15 16 var ( 17 //go:embed compressed.m4a 18 compressed []byte 19 //go:embed uncompressed.s16le.pcm 20 uncompressed []byte 21 //go:embed uncompressed.s16le.macos.pcm 22 uncompressed_macos []byte 23 //go:embed corrupt.m4a 24 corrupt []byte 25 ) 26 27 // getUncompressed returns the uncompressed result produced on the 28 // current platform. 29 func getUncompressed() []byte { 30 switch runtime.GOOS { 31 case "darwin": 32 return uncompressed_macos 33 default: 34 return uncompressed 35 } 36 } 37 38 // TestLoad ensures that output from the native decoders are close to 39 // the output of ffmpeg. 40 func TestLoad(t *testing.T) { 41 by, f, err := newDecoder(t).DecodeFile("compressed.m4a") 42 if err != nil { 43 t.Fatalf("unexpected error: %v", err) 44 } 45 t.Logf("format: %+v", f) 46 // Check for known meta data values (ffprobe -i compressed.m4a). 47 if f.BytesPerSample != 2 { 48 t.Fatalf("unexpected bit depth: want 2, got %d", f.BytesPerSample) 49 } 50 if f.SampleRate != 44100 { 51 t.Fatalf("unexpected sample rate: want 44100, got %d", f.SampleRate) 52 } 53 if f.Channels != 2 { 54 t.Fatalf("unexpected channel count: want 2, got %d", f.Channels) 55 } 56 // Test passes on exact match, otherwise do a tolerance test. 57 if bytes.Equal(by, getUncompressed()) { 58 return 59 } 60 if !equal(t, by, getUncompressed(), f.Channels*f.BytesPerSample) { 61 t.Fatalf("native output does not match ffmpeg output") 62 } 63 } 64 65 // TestDecode ensures that output from the native decoders are similar to 66 // the output of ffmpeg. 67 func TestDecode(t *testing.T) { 68 by, f, err := newDecoder(t).Decode(compressed) 69 if err != nil { 70 t.Fatalf("unexpected error: %v", err) 71 } 72 t.Logf("format: %+v", f) 73 // Check for known meta data values (ffprobe -i compressed.m4a). 74 if f.BytesPerSample != 2 { 75 t.Fatalf("unexpected bit depth: want 2, got %d", f.BytesPerSample) 76 } 77 if f.SampleRate != 44100 { 78 t.Fatalf("unexpected sample rate: want 44100, got %d", f.SampleRate) 79 } 80 if f.Channels != 2 { 81 t.Fatalf("unexpected channel count: want 2, got %d", f.Channels) 82 } 83 // Test passes on exact match, otherwise do a tolerance test. 84 if bytes.Equal(by, getUncompressed()) { 85 return 86 } 87 if !equal(t, by, getUncompressed(), f.Channels*f.BytesPerSample) { 88 t.Fatalf("native output does not match ffmpeg output") 89 } 90 } 91 92 // TestDecodeCorrupt ensures that we get an error value on invalid input and 93 // that we don't crash the process. 94 func TestDecodeCorrupt(t *testing.T) { 95 _, f, err := newDecoder(t).Decode(corrupt) 96 if err == nil { 97 t.Fatalf("expected error for corrupt audio data, got nil") 98 } 99 t.Logf("format: %+v", f) 100 } 101 102 // TestMemoryLeak runs the decode several times, forces a GC and verifies 103 // that no data is left over from this package. 104 func TestMemoryLeak(t *testing.T) { 105 runtime.MemProfileRate = 1 106 107 // Scoped so the Decoder itself is unreachable before the profile is 108 // taken. Holding it live would show up here as an allocation that 109 // was never freed, which is exactly what this test looks for. 110 func() { 111 d, err := nativeaudio.New() 112 if err != nil { 113 t.Fatalf("creating decoder: %v", err) 114 } 115 defer d.Close() 116 for ii := 0; ii < 10; ii++ { 117 by, f, err := d.DecodeFile("compressed.m4a") 118 if err != nil { 119 t.Fatalf("unexpected error: %v", err) 120 } 121 _ = by 122 _ = f 123 } 124 }() 125 126 runtime.GC() 127 runtime.GC() 128 129 var profiles []runtime.MemProfileRecord 130 131 for { 132 n, ok := runtime.MemProfile(profiles, false) 133 if ok { 134 profiles = profiles[:n] 135 break 136 } 137 profiles = slices.Grow(profiles, n)[:n] 138 } 139 140 for _, p := range profiles { 141 f := runtime.FuncForPC(p.Stack0[0]) 142 143 if !strings.Contains(f.Name(), "nativeaudio") { 144 continue 145 } 146 147 t.Errorf("un-freed data: %s -> %d\n", f.Name(), p.InUseBytes()) 148 } 149 } 150 151 // equal decodes the PCM samples and tests if they are "close enough" 152 // using a heuristic tolerance. 153 // 154 // Silence at either end is trimmed first. How much padding surrounds the 155 // audio is not a property of the audio: AAC carries encoder delay, and 156 // how many priming and trailing frames survive the round trip differs 157 // between decoder implementations and between ffmpeg releases. The 158 // reference here was generated by one ffmpeg; a later one decodes the 159 // same file to 767 fewer trailing frames of silence, and the audio 160 // between the silent ends is unchanged. Comparing raw lengths turns that 161 // into a failure and dates the fixture to whichever ffmpeg produced it. 162 // 163 // After trimming the two must agree on length exactly. Sample values are 164 // compared as signed integers by mean absolute difference, which must 165 // stay under one quantisation step (1 LSB). Different AAC decoders 166 // legitimately differ by rounding, so bit-exact output is not expected. 167 // 168 // Trimming cannot hide a bad decode. Only frames that are entirely zero 169 // are removed, so lost audio still reaches the comparison, and any 170 // misalignment introduced would put the mean far above 1 rather than 171 // passing quietly. 172 func equal(t *testing.T, left, right []byte, align int) bool { 173 if len(left) == 0 || len(right) == 0 { 174 return false 175 } 176 left, right = trimSilence(left, align), trimSilence(right, align) 177 if len(left) == 0 && len(right) == 0 { 178 // Both sides decoded to nothing but silence, which is agreement. 179 return true 180 } 181 if len(left) != len(right) { 182 t.Logf("length mismatch after trimming silence: native %d bytes, reference %d bytes", len(left), len(right)) 183 return false 184 } 185 var ( 186 lsamples = make([]int16, len(left)/2) 187 rsamples = make([]int16, len(right)/2) 188 ) 189 if err := binary.Read(bytes.NewReader(left), binary.LittleEndian, lsamples); err != nil { 190 panic(fmt.Errorf("left: binary read: %w", err)) 191 } 192 if err := binary.Read(bytes.NewReader(right), binary.LittleEndian, rsamples); err != nil { 193 panic(fmt.Errorf("right: binary read: %w", err)) 194 } 195 var ( 196 size int = min(len(lsamples), len(rsamples)) 197 sum int = 0 198 ) 199 for ii := 0; ii < size; ii++ { 200 var ( 201 lsample = int(lsamples[ii]) 202 rsample = int(rsamples[ii]) 203 ) 204 sum += abs(lsample - rsample) 205 } 206 mean := float64(sum) / float64(size) 207 t.Logf("mean: %f, sum: %d, size: %d\n", mean, sum, size) 208 return mean < 1.0 209 } 210 211 // TestEqual guards the comparison itself. Trimming silence is what lets 212 // the reference PCM outlive the ffmpeg that produced it, and the failure 213 // mode of getting it wrong is a comparison that passes for good, so the 214 // cases it must still reject are worth pinning down. 215 func TestEqual(t *testing.T) { 216 reference := getUncompressed() 217 const align = 4 // the fixture is 16-bit stereo. 218 219 reject := func(name string, mutate func([]byte) []byte) { 220 t.Run(name, func(t *testing.T) { 221 if equal(t, mutate(append([]byte(nil), reference...)), reference, align) { 222 t.Errorf("expected the comparison to reject %s", name) 223 } 224 }) 225 } 226 227 // Every sample louder by one step, which is the tolerance exactly. 228 reject("amplitude", func(b []byte) []byte { 229 for i := 0; i+1 < len(b); i += 2 { 230 v := int16(uint16(b[i]) | uint16(b[i+1])<<8) 231 if v < 32767 { 232 v++ 233 } 234 b[i], b[i+1] = byte(v), byte(uint16(v)>>8) 235 } 236 return b 237 }) 238 239 // Audio dropped from the middle, where no amount of trimming helps. 240 reject("truncated", func(b []byte) []byte { 241 mid := len(b) / 2 242 return append(b[:mid], b[mid+1000*align:]...) 243 }) 244 245 // Shifted by a frame, so the channels land on each other. 246 reject("shifted", func(b []byte) []byte { 247 lead := 0 248 for lead+align <= len(b) && isZero(b[lead:lead+align]) { 249 lead += align 250 } 251 return append(b[:lead:lead], b[lead+align:]...) 252 }) 253 254 t.Run("identity", func(t *testing.T) { 255 if !equal(t, append([]byte(nil), reference...), reference, align) { 256 t.Errorf("expected the comparison to accept identical input") 257 } 258 }) 259 } 260 261 // trimSilence removes whole silent frames from both ends of the PCM. 262 // 263 // A frame at a time, rather than a byte at a time: the low byte of a 264 // sample is zero whenever its value is a multiple of 256, so trimming 265 // individual zero bytes would leave the buffer straddling a frame 266 // boundary and compare the left channel against the right. 267 func trimSilence(b []byte, align int) []byte { 268 if align <= 0 { 269 return b 270 } 271 for len(b) >= align && isZero(b[:align]) { 272 b = b[align:] 273 } 274 for len(b) >= align && isZero(b[len(b)-align:]) { 275 b = b[:len(b)-align] 276 } 277 return b 278 } 279 280 func isZero(b []byte) bool { 281 for _, c := range b { 282 if c != 0 { 283 return false 284 } 285 } 286 return true 287 } 288 289 func abs(n int) int { 290 if n < 0 { 291 return -n 292 } 293 return n 294 } 295 296 func BenchmarkDecode(b *testing.B) { 297 b.Run("native-decode", func(b *testing.B) { 298 d := newDecoder(b) 299 b.ResetTimer() 300 for ii := 0; ii < b.N; ii++ { 301 by, f, err := d.Decode(compressed) 302 if err != nil { 303 b.Fatalf("unexpected error during decode: %v", err) 304 } 305 _ = by 306 _ = f 307 } 308 }) 309 b.Run("ffmpeg-decode", func(b *testing.B) { 310 for ii := 0; ii < b.N; ii++ { 311 by, f, err := nativeaudio.FFmpegDecode(compressed) 312 if err != nil { 313 b.Fatalf("unexpected error during decode: %v", err) 314 } 315 _ = by 316 _ = f 317 } 318 }) 319 }