diff --git a/internal/output/audio.go b/internal/output/audio.go new file mode 100644 index 0000000..5b09370 --- /dev/null +++ b/internal/output/audio.go @@ -0,0 +1,65 @@ +package output + +import ( + "errors" + + "mxl-player/internal/playback" +) + +var ( + ErrInvalidAudioFrame = errors.New("invalid audio frame") + ErrAudioPayloadTooSmall = errors.New("audio channel payload is too small") +) + +func InterleaveF32(frame playback.AudioFrame) ([]byte, error) { + if frame.Channels == 0 || + frame.SampleCount == 0 || + frame.SampleRateNumerator <= 0 || + frame.SampleRateDenominator <= 0 { + return nil, ErrInvalidAudioFrame + } + + if uint64(len(frame.Samples)) != frame.Channels { + return nil, ErrInvalidAudioFrame + } + + if frame.SampleCount > ^uint64(0)/4 { + return nil, ErrInvalidAudioFrame + } + bytesPerChannel := frame.SampleCount * 4 + + if frame.Channels > ^uint64(0)/bytesPerChannel { + return nil, ErrInvalidAudioFrame + } + totalBytes := frame.Channels * bytesPerChannel + + maxInt := uint64(^uint(0) >> 1) + if totalBytes > maxInt { + return nil, ErrInvalidAudioFrame + } + + for _, samples := range frame.Samples { + if bytesPerChannel > uint64(len(samples)) { + return nil, ErrAudioPayloadTooSmall + } + } + + result := make([]byte, int(totalBytes)) + + for sample := uint64(0); sample < frame.SampleCount; sample++ { + for channel := uint64(0); channel < frame.Channels; channel++ { + sourceOffset := sample * 4 + destinationOffset := (sample*frame.Channels + channel) * 4 + + sourceStart := int(sourceOffset) + destinationStart := int(destinationOffset) + + copy( + result[destinationStart:destinationStart+4], + frame.Samples[channel][sourceStart:sourceStart+4], + ) + } + } + + return result, nil +} diff --git a/internal/output/audio_test.go b/internal/output/audio_test.go new file mode 100644 index 0000000..651ee4f --- /dev/null +++ b/internal/output/audio_test.go @@ -0,0 +1,116 @@ +package output + +import ( + "bytes" + "errors" + "testing" + + "mxl-player/internal/playback" +) + +func validAudioFrame() playback.AudioFrame { + return playback.AudioFrame{ + Index: 42, + SampleCount: 2, + Channels: 2, + SampleRateNumerator: 48000, + SampleRateDenominator: 1, + Samples: [][]byte{ + {1, 2, 3, 4, 5, 6, 7, 8}, + {9, 10, 11, 12, 13, 14, 15, 16}, + }, + } +} + +func TestInterleaveF32OrdersSamplesByFrameThenChannel(t *testing.T) { + frame := validAudioFrame() + want := []byte{ + 1, 2, 3, 4, + 9, 10, 11, 12, + 5, 6, 7, 8, + 13, 14, 15, 16, + } + + got, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("InterleaveF32() = %v, want %v", got, want) + } +} + +func TestInterleaveF32IgnoresBytesAfterSampleCount(t *testing.T) { + frame := validAudioFrame() + frame.SampleCount = 1 + + got, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + want := []byte{1, 2, 3, 4, 9, 10, 11, 12} + if !bytes.Equal(got, want) { + t.Fatalf("InterleaveF32() = %v, want %v", got, want) + } +} + +func TestInterleaveF32RejectsInvalidMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*playback.AudioFrame) + }{ + {name: "zero samples", mutate: func(f *playback.AudioFrame) { f.SampleCount = 0 }}, + {name: "zero channels", mutate: func(f *playback.AudioFrame) { f.Channels = 0 }}, + {name: "zero rate numerator", mutate: func(f *playback.AudioFrame) { f.SampleRateNumerator = 0 }}, + {name: "negative rate numerator", mutate: func(f *playback.AudioFrame) { f.SampleRateNumerator = -1 }}, + {name: "zero rate denominator", mutate: func(f *playback.AudioFrame) { f.SampleRateDenominator = 0 }}, + {name: "wrong channel count", mutate: func(f *playback.AudioFrame) { f.Channels = 3 }}, + {name: "sample size overflow", mutate: func(f *playback.AudioFrame) { f.SampleCount = ^uint64(0) }}, + { + name: "allocation size overflow", + mutate: func(f *playback.AudioFrame) { + f.SampleCount = uint64(^uint(0)>>1) / 4 + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frame := validAudioFrame() + tt.mutate(&frame) + result, err := InterleaveF32(frame) + if result != nil { + t.Fatalf("InterleaveF32() result = %v, want nil", result) + } + if !errors.Is(err, ErrInvalidAudioFrame) { + t.Fatalf("InterleaveF32() error = %v, want %v", err, ErrInvalidAudioFrame) + } + }) + } +} + +func TestInterleaveF32RejectsShortChannel(t *testing.T) { + frame := validAudioFrame() + frame.Samples[1] = frame.Samples[1][:7] + + result, err := InterleaveF32(frame) + if result != nil { + t.Fatalf("InterleaveF32() result = %v, want nil", result) + } + if !errors.Is(err, ErrAudioPayloadTooSmall) { + t.Fatalf("InterleaveF32() error = %v, want %v", err, ErrAudioPayloadTooSmall) + } +} + +func TestInterleaveF32ReturnsIndependentOutput(t *testing.T) { + frame := validAudioFrame() + result, err := InterleaveF32(frame) + if err != nil { + t.Fatalf("InterleaveF32() error = %v", err) + } + + frame.Samples[0][0] = 99 + if result[0] != 1 { + t.Fatalf("output changed with borrowed input: first byte = %d, want 1", result[0]) + } +}