diff --git a/internal/output/sdl_audio.go b/internal/output/sdl_audio.go new file mode 100644 index 0000000..590725c --- /dev/null +++ b/internal/output/sdl_audio.go @@ -0,0 +1,204 @@ +package output + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/sdl" +) + +const ( + defaultMaxQueuedBatches = int32(20) + defaultAudioQueuePoll = 10 * time.Millisecond +) + +var ( + ErrInvalidAudioFormat = errors.New("invalid audio output format") + ErrOpenAudioStream = errors.New("open SDL audio stream") + ErrResumeAudioStream = errors.New("resume SDL audio stream") + ErrQueueAudioData = errors.New("queue SDL audio data") +) + +type audioBackend interface { + Open(uint32, sdl.AudioSpec) uintptr + Resume(uintptr) bool + Put(uintptr, []byte) bool + Queued(uintptr) int32 + Destroy(uintptr) + Error() string +} + +type systemAudioBackend struct{} + +func (systemAudioBackend) Open(device uint32, spec sdl.AudioSpec) uintptr { + return sdl.OpenAudioDeviceStream(device, spec) +} +func (systemAudioBackend) Resume(stream uintptr) bool { return sdl.ResumeAudioStreamDevice(stream) } +func (systemAudioBackend) Put(stream uintptr, data []byte) bool { + return sdl.PutAudioStreamData(stream, data) +} +func (systemAudioBackend) Queued(stream uintptr) int32 { return sdl.GetAudioStreamQueued(stream) } +func (systemAudioBackend) Destroy(stream uintptr) { sdl.DestroyAudioStream(stream) } +func (systemAudioBackend) Error() string { return sdl.GetError() } + +type audioFormat struct { + channels int32 + frequency int32 +} + +type audioQueueWaitFunc func(context.Context, time.Duration) error + +type SDLAudioSink struct { + deviceID uint32 + backend audioBackend + stream uintptr + format audioFormat + maxQueuedBatches int32 + queuePoll time.Duration + wait audioQueueWaitFunc +} + +var _ playback.AudioSink = (*SDLAudioSink)(nil) + +func NewSDLAudioSink(deviceID uint32) *SDLAudioSink { + return &SDLAudioSink{ + deviceID: deviceID, + backend: systemAudioBackend{}, + maxQueuedBatches: defaultMaxQueuedBatches, + queuePoll: defaultAudioQueuePoll, + wait: waitForAudioQueue, + } +} + +func waitForAudioQueue(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func audioOutputFormat(frame playback.AudioFrame) (audioFormat, error) { + if frame.Channels == 0 || frame.Channels > math.MaxInt32 || + frame.SampleRateNumerator <= 0 || frame.SampleRateDenominator <= 0 || + frame.SampleRateNumerator%frame.SampleRateDenominator != 0 { + return audioFormat{}, fmt.Errorf( + "%w: channels=%d rate=%d/%d", + ErrInvalidAudioFormat, + frame.Channels, + frame.SampleRateNumerator, + frame.SampleRateDenominator, + ) + } + + frequency := frame.SampleRateNumerator / frame.SampleRateDenominator + if frequency <= 0 || frequency > math.MaxInt32 { + return audioFormat{}, fmt.Errorf( + "%w: frequency=%d", + ErrInvalidAudioFormat, + frequency, + ) + } + + return audioFormat{ + channels: int32(frame.Channels), + frequency: int32(frequency), + }, nil +} + +func (s *SDLAudioSink) ConsumeAudio( + ctx context.Context, + frame playback.AudioFrame, +) error { + if err := ctx.Err(); err != nil { + return err + } + + format, err := audioOutputFormat(frame) + if err != nil { + return err + } + interleaved, err := InterleaveF32(frame) + if err != nil { + return err + } + + if s.stream == 0 || s.format != format { + if err := s.recreateStream(format); err != nil { + return err + } + } + + if s.maxQueuedBatches <= 0 || len(interleaved) > math.MaxInt32/int(s.maxQueuedBatches) { + return fmt.Errorf( + "%w: batch bytes=%d queue batches=%d", + ErrInvalidAudioFormat, + len(interleaved), + s.maxQueuedBatches, + ) + } + maxQueuedBytes := int32(len(interleaved)) * s.maxQueuedBatches + + for { + queued := s.backend.Queued(s.stream) + if queued < 0 { + return fmt.Errorf("%w: query queued bytes: %s", ErrQueueAudioData, s.backend.Error()) + } + if queued <= maxQueuedBytes { + break + } + if err := s.wait(ctx, s.queuePoll); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + } + + if !s.backend.Put(s.stream, interleaved) { + return fmt.Errorf("%w: %s", ErrQueueAudioData, s.backend.Error()) + } + return nil +} + +func (s *SDLAudioSink) recreateStream(format audioFormat) error { + if s.stream != 0 { + s.backend.Destroy(s.stream) + s.stream = 0 + s.format = audioFormat{} + } + + stream := s.backend.Open(s.deviceID, sdl.AudioSpec{ + Format: sdl.AudioF32, + Channels: format.channels, + Freq: format.frequency, + }) + if stream == 0 { + return fmt.Errorf("%w: %s", ErrOpenAudioStream, s.backend.Error()) + } + if !s.backend.Resume(stream) { + s.backend.Destroy(stream) + return fmt.Errorf("%w: %s", ErrResumeAudioStream, s.backend.Error()) + } + + s.stream = stream + s.format = format + return nil +} + +func (s *SDLAudioSink) Close() error { + if s.stream != 0 { + s.backend.Destroy(s.stream) + s.stream = 0 + } + s.format = audioFormat{} + return nil +} diff --git a/internal/output/sdl_audio_test.go b/internal/output/sdl_audio_test.go new file mode 100644 index 0000000..85e9109 --- /dev/null +++ b/internal/output/sdl_audio_test.go @@ -0,0 +1,213 @@ +package output + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/sdl" +) + +type fakeAudioBackend struct { + nextStream uintptr + openSpecs []sdl.AudioSpec + openDevice []uint32 + resumeOK bool + putOK bool + puts [][]byte + queued []int32 + queueCalls int + destroyed []uintptr + errText string +} + +func (b *fakeAudioBackend) Open(device uint32, spec sdl.AudioSpec) uintptr { + b.openDevice = append(b.openDevice, device) + b.openSpecs = append(b.openSpecs, spec) + return b.nextStream +} +func (b *fakeAudioBackend) Resume(uintptr) bool { return b.resumeOK } +func (b *fakeAudioBackend) Put(_ uintptr, data []byte) bool { + b.puts = append(b.puts, append([]byte(nil), data...)) + return b.putOK +} +func (b *fakeAudioBackend) Queued(uintptr) int32 { + if len(b.queued) == 0 { + return 0 + } + index := b.queueCalls + if index >= len(b.queued) { + index = len(b.queued) - 1 + } + b.queueCalls++ + return b.queued[index] +} +func (b *fakeAudioBackend) Destroy(stream uintptr) { + b.destroyed = append(b.destroyed, stream) +} +func (b *fakeAudioBackend) Error() string { return b.errText } + +func newTestSDLAudioSink(backend audioBackend) *SDLAudioSink { + return &SDLAudioSink{ + deviceID: 7, + backend: backend, + maxQueuedBatches: 2, + queuePoll: time.Millisecond, + wait: func(context.Context, time.Duration) error { return nil }, + } +} + +func TestAudioOutputFormat(t *testing.T) { + tests := []struct { + name string + frame playback.AudioFrame + want audioFormat + wantErr bool + }{ + { + name: "valid", + frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000, SampleRateDenominator: 1}, + want: audioFormat{channels: 2, frequency: 48000}, + }, + {name: "zero channels", frame: playback.AudioFrame{SampleRateNumerator: 48000, SampleRateDenominator: 1}, wantErr: true}, + {name: "zero numerator", frame: playback.AudioFrame{Channels: 2, SampleRateDenominator: 1}, wantErr: true}, + {name: "zero denominator", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 48000}, wantErr: true}, + {name: "fractional frequency", frame: playback.AudioFrame{Channels: 2, SampleRateNumerator: 30000, SampleRateDenominator: 1001}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := audioOutputFormat(tt.frame) + if tt.wantErr { + if !errors.Is(err, ErrInvalidAudioFormat) { + t.Fatalf("audioOutputFormat() error = %v, want %v", err, ErrInvalidAudioFormat) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("audioOutputFormat() = %#v, %v; want %#v, nil", got, err, tt.want) + } + }) + } +} + +func TestSDLAudioSinkOpensInterleavesAndReusesStream(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + frame := validAudioFrame() + + if err := sink.ConsumeAudio(context.Background(), frame); err != nil { + t.Fatalf("first ConsumeAudio() error = %v", err) + } + if err := sink.ConsumeAudio(context.Background(), frame); err != nil { + t.Fatalf("second ConsumeAudio() error = %v", err) + } + if len(backend.openSpecs) != 1 { + t.Fatalf("open calls = %d, want 1", len(backend.openSpecs)) + } + wantSpec := sdl.AudioSpec{Format: sdl.AudioF32, Channels: 2, Freq: 48000} + if backend.openDevice[0] != 7 || backend.openSpecs[0] != wantSpec { + t.Fatalf("open = device %d spec %#v, want 7 %#v", backend.openDevice[0], backend.openSpecs[0], wantSpec) + } + wantData := []byte{1, 2, 3, 4, 9, 10, 11, 12, 5, 6, 7, 8, 13, 14, 15, 16} + if len(backend.puts) != 2 || !bytes.Equal(backend.puts[0], wantData) { + t.Fatalf("queued data = %v, want %v twice", backend.puts, wantData) + } +} + +func TestSDLAudioSinkRecreatesStreamOnFormatChange(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + first := validAudioFrame() + if err := sink.ConsumeAudio(context.Background(), first); err != nil { + t.Fatalf("first ConsumeAudio() error = %v", err) + } + + backend.nextStream = 12 + second := first + second.SampleRateNumerator = 96000 + if err := sink.ConsumeAudio(context.Background(), second); err != nil { + t.Fatalf("second ConsumeAudio() error = %v", err) + } + if len(backend.openSpecs) != 2 || len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { + t.Fatalf("opens=%d destroyed=%v, want 2 and [11]", len(backend.openSpecs), backend.destroyed) + } +} + +func TestSDLAudioSinkReportsOpenResumeAndPutFailures(t *testing.T) { + frame := validAudioFrame() + + t.Run("open", func(t *testing.T) { + backend := &fakeAudioBackend{errText: "open failed"} + err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrOpenAudioStream) { + t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrOpenAudioStream) + } + }) + + t.Run("resume", func(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, errText: "resume failed"} + sink := newTestSDLAudioSink(backend) + err := sink.ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrResumeAudioStream) || sink.stream != 0 { + t.Fatalf("ConsumeAudio() error=%v stream=%d, want resume error and zero stream", err, sink.stream) + } + if len(backend.destroyed) != 1 || backend.destroyed[0] != 11 { + t.Fatalf("destroyed = %v, want [11]", backend.destroyed) + } + }) + + t.Run("put", func(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, errText: "put failed"} + err := newTestSDLAudioSink(backend).ConsumeAudio(context.Background(), frame) + if !errors.Is(err, ErrQueueAudioData) { + t.Fatalf("ConsumeAudio() error = %v, want %v", err, ErrQueueAudioData) + } + }) +} + +func TestSDLAudioSinkWaitsForQueueAndHonorsCancellation(t *testing.T) { + backend := &fakeAudioBackend{ + nextStream: 11, + resumeOK: true, + putOK: true, + queued: []int32{100, 100}, + } + sink := newTestSDLAudioSink(backend) + ctx, cancel := context.WithCancel(context.Background()) + waits := 0 + sink.wait = func(ctx context.Context, _ time.Duration) error { + waits++ + cancel() + return ctx.Err() + } + + err := sink.ConsumeAudio(ctx, validAudioFrame()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ConsumeAudio() error = %v, want context.Canceled", err) + } + if waits != 1 || len(backend.puts) != 0 { + t.Fatalf("waits=%d puts=%d, want 1 and 0", waits, len(backend.puts)) + } +} + +func TestSDLAudioSinkCloseIsIdempotent(t *testing.T) { + backend := &fakeAudioBackend{nextStream: 11, resumeOK: true, putOK: true} + sink := newTestSDLAudioSink(backend) + if err := sink.ConsumeAudio(context.Background(), validAudioFrame()); err != nil { + t.Fatalf("ConsumeAudio() error = %v", err) + } + + if err := sink.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := sink.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + if len(backend.destroyed) != 1 || sink.stream != 0 || sink.format != (audioFormat{}) { + t.Fatalf("destroyed=%v stream=%d format=%#v", backend.destroyed, sink.stream, sink.format) + } +}