Refactoring #3
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user