nativeaudio

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

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 }