Compare commits

...

4 Commits

Author SHA1 Message Date
Dmitry Sergeev d49f4ff15f run audio-only playback through audio slot 2026-08-28 22:16:57 +03:00
Dmitry Sergeev bcb0ae6a6d add SDL audio sink 2026-08-28 21:58:43 +03:00
Dmitry Sergeev d57e403c2e add validated audio interleaving 2026-08-28 21:53:27 +03:00
Dmitry Sergeev 1083f3b83c add local MXL audio adapter 2026-08-28 21:41:23 +03:00
8 changed files with 1224 additions and 21 deletions
+149 -19
View File
@@ -8,6 +8,7 @@ import (
"log"
mxladapter "mxl-player/internal/adapter/mxl"
"mxl-player/internal/imgui"
"mxl-player/internal/output"
"mxl-player/internal/playback"
"mxl-player/internal/renderer"
"mxl-player/internal/sdl"
@@ -149,6 +150,7 @@ func main() {
}
// path selection
useVideoSlot := args.AudioFlowId == ""
useAudioSlot := args.AudioFlowId != "" && args.VideoFlowId == ""
runtime.LockOSThread()
if err := sdl.Load(); err != nil {
@@ -303,25 +305,7 @@ func main() {
case args.VideoFlowId != "":
// VideoSlot owns opening and closing the video reader.
case args.AudioFlowId != "":
audioSrc, err = source.OpenAudio(args.Domain, args.AudioFlowId)
if err != nil {
log.Fatalf("audio source: %v", err)
}
aChans = audioSrc.Channels()
audioBatch = uint64(audioSrc.Rate().Num) / (100 * uint64(audioSrc.Rate().Den))
if audioBatch == 0 {
audioBatch = 1
}
audioStream = sdl.OpenAudioDeviceStream(sdlAudioDevice, sdl.AudioSpec{
Format: sdl.AudioF32,
Channels: int32(aChans),
Freq: int32(audioSrc.Rate().Num / audioSrc.Rate().Den),
})
if audioStream == 0 {
log.Fatalf("audio: %s", sdl.GetError())
}
sdl.ResumeAudioStreamDevice(audioStream)
fmt.Printf("audio: %dch %d/%d Hz\n", aChans, audioSrc.Rate().Num, audioSrc.Rate().Den)
// AudioSlot owns opening and closing the audio reader.
default:
// No configured feeds. Renderer and GUI use the placeholder.
}
@@ -390,6 +374,7 @@ func main() {
showStats bool = true
)
videoActive := args.VideoFlowId != ""
audioActive := useAudioSlot
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -437,6 +422,44 @@ func main() {
}
videoCommands := make(chan playback.FeedConfig, 1)
audioOutput := output.NewSDLAudioSink(sdlAudioDevice)
defer audioOutput.Close()
audioWorker, err := playback.NewAudioWorker(
mxladapter.AudioFactory{},
audioOutput,
retryPolicy,
mxladapter.ShouldRetry,
func(status playback.Status) {
statusStore.Observe(status)
if status.Err != nil {
log.Printf(
"audio: state=%v attempt=%d failed=%d: %v",
status.State,
status.Attempt,
status.FailedAttempts,
status.Err,
)
return
}
log.Printf(
"audio: state=%v attempt=%d failed=%d",
status.State,
status.Attempt,
status.FailedAttempts,
)
},
)
if err != nil {
panic(err)
}
audioSlot, err := playback.NewAudioSlot(audioWorker)
if err != nil {
panic(err)
}
audioCommands := make(chan playback.FeedConfig, 1)
reopen := func(params reconnectParams) error {
// Close current sources
if syncSrc != nil {
@@ -499,6 +522,17 @@ func main() {
default:
}
}
enqueueAudioConfig := func(config playback.FeedConfig) {
select {
case <-audioCommands:
default:
}
select {
case audioCommands <- config:
default:
}
}
doReconnect := func() {
if useVideoSlot {
@@ -516,6 +550,22 @@ func main() {
enqueueVideoConfig(config)
return
}
if useAudioSlot {
audioActive = audioStr != ""
config := playback.FeedConfig{}
if audioStr != "" {
config = playback.FeedConfig{
Domain: domainStr,
UUID: audioStr,
Active: true,
}
}
enqueueAudioConfig(config)
return
}
// legacy
select {
case <-control:
default:
@@ -542,6 +592,21 @@ func main() {
}
return
}
if useAudioSlot {
err := audioSlot.Run(
ctx,
playback.FeedConfig{
Domain: args.Domain,
UUID: args.AudioFlowId,
Active: args.AudioFlowId != "",
},
audioCommands,
)
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("audio slot: %v", err)
}
return
}
// Audio-only mode: independent loop.
if audioSrc != nil && syncSrc == nil && videoSrc == nil {
@@ -929,6 +994,71 @@ func main() {
cimgui.Text("Video actual: not started")
}
}
if useAudioSlot && audioActive {
if cimgui.Button("Stop audio") {
audioActive = false
enqueueAudioConfig(playback.FeedConfig{
Domain: domainStr,
UUID: audioStr,
Active: false,
})
}
}
if useAudioSlot && !audioActive && audioStr != "" {
if cimgui.Button("Resume audio") {
audioActive = true
enqueueAudioConfig(playback.FeedConfig{
Domain: domainStr,
UUID: audioStr,
Active: true,
})
}
}
if useAudioSlot && audioStr != "" {
if cimgui.Button("Remove audio") {
audioActive = false
audioStr = ""
enqueueAudioConfig(playback.FeedConfig{})
}
}
if useAudioSlot {
if audioActive {
cimgui.Text("Audio desired: active")
} else if audioStr != "" {
cimgui.Text("Audio desired: stopped")
} else {
cimgui.Text("Audio desired: not configured")
}
if status, ok := statusStore.Snapshot(playback.UnitAudio); ok {
cimgui.Text(fmt.Sprintf(
"Audio actual: %s",
status.State,
))
cimgui.Text(fmt.Sprintf(
"Attempt: %d, failed: %d",
status.Attempt,
status.FailedAttempts,
))
if status.RetryIn > 0 {
cimgui.Text(fmt.Sprintf(
"Retry in: %s",
status.RetryIn.Round(time.Millisecond),
))
}
if status.Err != nil {
cimgui.TextWrapped(status.Err.Error())
}
} else {
cimgui.Text("Audio actual: not started")
}
}
cimgui.End()
gui.EndFrame()
lastFrame = time.Now()
+2 -2
View File
@@ -14,7 +14,7 @@ Size=200,200
Collapsed=0
[Window][Connection]
Pos=74,351
Size=799,226
Pos=174,363
Size=986,237
Collapsed=0
+211
View File
@@ -0,0 +1,211 @@
package mxladapter
import (
"context"
"errors"
"fmt"
"math/bits"
"time"
"mxl-player/internal/playback"
"mxl-player/internal/source"
mxl "github.com/qvest-digital/go-mxl/mxl"
)
const (
DefaultAudioReadTimeout = 20 * time.Millisecond
DefaultAudioBatchDuration = 10 * time.Millisecond
DefaultAudioUnavailableAfter = 2 * time.Second
DefaultAudioTemporaryDelay = 10 * time.Millisecond
)
var ErrInvalidAudioBatch = errors.New("invalid audio batch configuration")
type AudioFactory struct {
ReadTimeout time.Duration
BatchDuration time.Duration
UnavailableAfter time.Duration
}
type localAudioSource interface {
ReadAudioOnceCtx(context.Context, uint64, time.Duration) (source.AudioFrame, error)
Rate() mxl.Rational
Channels() uint64
Close() error
}
type audioReader struct {
source localAudioSource
readTimeout time.Duration
batch uint64
channels uint64
rateNumerator int64
rateDenominator int64
unavailableAfter time.Duration
retryDelay time.Duration
now func() time.Time
wait temporaryWaitFunc
}
var _ playback.AudioReaderFactory = AudioFactory{}
var _ playback.AudioReader = (*audioReader)(nil)
func audioBatchSize(
rateNumerator int64,
rateDenominator int64,
duration time.Duration,
) (uint64, error) {
if rateNumerator <= 0 || rateDenominator <= 0 || duration <= 0 {
return 0, fmt.Errorf(
"%w: rate=%d/%d duration=%s",
ErrInvalidAudioBatch,
rateNumerator,
rateDenominator,
duration,
)
}
denominator := uint64(rateDenominator)
seconds := uint64(time.Second)
if denominator > ^uint64(0)/seconds {
return 0, fmt.Errorf("%w: denominator overflow", ErrInvalidAudioBatch)
}
denominator *= seconds
high, low := bits.Mul64(uint64(rateNumerator), uint64(duration))
if high >= denominator {
return 0, fmt.Errorf("%w: sample count overflow", ErrInvalidAudioBatch)
}
batch, _ := bits.Div64(high, low, denominator)
if batch == 0 {
batch = 1
}
return batch, nil
}
func (f AudioFactory) OpenAudio(
ctx context.Context,
config playback.FeedConfig,
) (playback.AudioReader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := config.Validate(); err != nil {
return nil, &source.SourceError{
Op: "validate audio feed",
Kind: source.ErrorKindInvalidConfig,
Err: err,
}
}
if !config.IsConfigured() {
return nil, &source.SourceError{
Op: "validate audio feed",
Kind: source.ErrorKindInvalidConfig,
Err: errors.New("audio feed is not configured"),
}
}
src, err := source.OpenAudio(config.Domain, config.UUID)
if err != nil {
return nil, fmt.Errorf("open local MXL audio: %w", err)
}
closeOnError := true
defer func() {
if closeOnError {
_ = src.Close()
}
}()
if err := ctx.Err(); err != nil {
return nil, err
}
readTimeout := f.ReadTimeout
if readTimeout <= 0 {
readTimeout = DefaultAudioReadTimeout
}
batchDuration := f.BatchDuration
if batchDuration <= 0 {
batchDuration = DefaultAudioBatchDuration
}
unavailableAfter := f.UnavailableAfter
if unavailableAfter <= 0 {
unavailableAfter = DefaultAudioUnavailableAfter
}
rate := src.Rate()
batch, err := audioBatchSize(rate.Num, rate.Den, batchDuration)
if err != nil {
return nil, &source.SourceError{
Op: "calculate audio batch",
Kind: source.ErrorKindInvalidConfig,
Err: err,
}
}
reader := &audioReader{
source: src,
readTimeout: readTimeout,
batch: batch,
channels: src.Channels(),
rateNumerator: rate.Num,
rateDenominator: rate.Den,
unavailableAfter: unavailableAfter,
retryDelay: DefaultAudioTemporaryDelay,
now: time.Now,
wait: waitForTemporaryRetry,
}
closeOnError = false
return reader, nil
}
func (r *audioReader) ReadAudio(ctx context.Context) (playback.AudioFrame, error) {
var unavailableSince time.Time
for {
frame, err := r.source.ReadAudioOnceCtx(ctx, r.batch, r.readTimeout)
if err == nil {
return playback.AudioFrame{
Index: frame.Index,
SampleCount: frame.SampleCount,
Channels: frame.Channels,
SampleRateNumerator: r.rateNumerator,
SampleRateDenominator: r.rateDenominator,
Samples: frame.Samples,
}, nil
}
if ctx.Err() != nil {
return playback.AudioFrame{}, ctx.Err()
}
if source.KindOf(err) != source.ErrorKindTemporary {
return playback.AudioFrame{}, err
}
now := r.now()
if unavailableSince.IsZero() {
unavailableSince = now
} else if now.Sub(unavailableSince) >= r.unavailableAfter {
return playback.AudioFrame{}, &source.SourceError{
Op: "read local MXL audio",
Kind: source.ErrorKindUnavailable,
Err: fmt.Errorf(
"no audio data for %s: %w",
r.unavailableAfter,
err,
),
}
}
if err := r.wait(ctx, r.retryDelay); err != nil {
if ctx.Err() != nil {
return playback.AudioFrame{}, ctx.Err()
}
return playback.AudioFrame{}, err
}
}
}
func (r *audioReader) Close() error {
return r.source.Close()
}
+264
View File
@@ -0,0 +1,264 @@
package mxladapter
import (
"context"
"errors"
"math"
"testing"
"time"
"mxl-player/internal/playback"
"mxl-player/internal/source"
mxl "github.com/qvest-digital/go-mxl/mxl"
)
type localAudioReadResult struct {
frame source.AudioFrame
err error
}
type fakeLocalAudioSource struct {
results []localAudioReadResult
calls int
batches []uint64
timeouts []time.Duration
closed bool
closeErr error
rate mxl.Rational
channels uint64
}
func (s *fakeLocalAudioSource) ReadAudioOnceCtx(
_ context.Context,
batch uint64,
timeout time.Duration,
) (source.AudioFrame, error) {
s.batches = append(s.batches, batch)
s.timeouts = append(s.timeouts, timeout)
if s.calls >= len(s.results) {
return source.AudioFrame{}, errors.New("unexpected local audio read")
}
result := s.results[s.calls]
s.calls++
return result.frame, result.err
}
func (s *fakeLocalAudioSource) Rate() mxl.Rational { return s.rate }
func (s *fakeLocalAudioSource) Channels() uint64 { return s.channels }
func (s *fakeLocalAudioSource) Close() error {
s.closed = true
return s.closeErr
}
func temporaryAudioError(cause error) error {
return &source.SourceError{
Op: "read audio",
Kind: source.ErrorKindTemporary,
Err: cause,
}
}
func TestAudioBatchSize(t *testing.T) {
tests := []struct {
name string
num int64
den int64
duration time.Duration
want uint64
wantErr bool
}{
{name: "ten milliseconds at 48kHz", num: 48000, den: 1, duration: 10 * time.Millisecond, want: 480},
{name: "fraction rounds down", num: 30000, den: 1001, duration: time.Second, want: 29},
{name: "minimum one sample", num: 1, den: 1, duration: time.Nanosecond, want: 1},
{name: "zero numerator", den: 1, duration: time.Second, wantErr: true},
{name: "zero denominator", num: 48000, duration: time.Second, wantErr: true},
{name: "zero duration", num: 48000, den: 1, wantErr: true},
{name: "result overflow", num: math.MaxInt64, den: 1, duration: time.Duration(math.MaxInt64), wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := audioBatchSize(tt.num, tt.den, tt.duration)
if tt.wantErr {
if !errors.Is(err, ErrInvalidAudioBatch) {
t.Fatalf("audioBatchSize() error = %v, want %v", err, ErrInvalidAudioBatch)
}
return
}
if err != nil {
t.Fatalf("audioBatchSize() error = %v", err)
}
if got != tt.want {
t.Fatalf("audioBatchSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestAudioFactoryOpenAudioCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
reader, err := (AudioFactory{}).OpenAudio(ctx, playback.FeedConfig{})
if reader != nil {
t.Fatal("OpenAudio() reader is not nil after cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("OpenAudio() error = %v, want context.Canceled", err)
}
}
func TestAudioFactoryOpenAudioRejectsInvalidConfig(t *testing.T) {
configs := []playback.FeedConfig{
{},
{Domain: "/audio", Active: true},
{UUID: "audio", Active: true},
}
for _, config := range configs {
reader, err := (AudioFactory{}).OpenAudio(context.Background(), config)
if reader != nil {
t.Fatalf("OpenAudio(%#v) reader is not nil", config)
}
if source.KindOf(err) != source.ErrorKindInvalidConfig {
t.Fatalf("OpenAudio(%#v) error kind = %v, want invalid config", config, source.KindOf(err))
}
if ShouldRetry(err) {
t.Fatalf("ShouldRetry(OpenAudio(%#v)) = true", config)
}
}
}
func TestAudioReaderTemporaryFailureThenFrameWithoutCopy(t *testing.T) {
samples := [][]byte{{1, 2, 3, 4}, {5, 6, 7, 8}}
want := source.AudioFrame{
Index: 42,
SampleCount: 1,
Channels: 2,
Samples: samples,
}
localSource := &fakeLocalAudioSource{
results: []localAudioReadResult{
{err: temporaryAudioError(errors.New("early"))},
{frame: want},
},
}
waits := 0
reader := &audioReader{
source: localSource,
readTimeout: 25 * time.Millisecond,
batch: 480,
channels: 2,
rateNumerator: 48000,
rateDenominator: 1,
unavailableAfter: 2 * time.Second,
retryDelay: 10 * time.Millisecond,
now: func() time.Time { return time.Unix(100, 0) },
wait: func(context.Context, time.Duration) error {
waits++
return nil
},
}
got, err := reader.ReadAudio(context.Background())
if err != nil {
t.Fatalf("ReadAudio() error = %v", err)
}
if localSource.calls != 2 || waits != 1 {
t.Fatalf("reads = %d, waits = %d; want 2, 1", localSource.calls, waits)
}
if got.Index != want.Index || got.SampleCount != want.SampleCount || got.Channels != want.Channels {
t.Fatalf("frame metadata = %#v, want %#v", got, want)
}
if got.SampleRateNumerator != 48000 || got.SampleRateDenominator != 1 {
t.Fatalf("sample rate = %d/%d, want 48000/1", got.SampleRateNumerator, got.SampleRateDenominator)
}
for channel := range samples {
if &got.Samples[channel][0] != &samples[channel][0] {
t.Fatalf("channel %d samples were copied", channel)
}
}
for _, batch := range localSource.batches {
if batch != 480 {
t.Fatalf("read batch = %d, want 480", batch)
}
}
}
func TestAudioReaderProlongedTemporaryFailureBecomesUnavailable(t *testing.T) {
lastCause := errors.New("last timeout")
localSource := &fakeLocalAudioSource{
results: []localAudioReadResult{
{err: temporaryAudioError(errors.New("first timeout"))},
{err: temporaryAudioError(errors.New("second timeout"))},
{err: temporaryAudioError(lastCause)},
},
}
times := []time.Time{time.Unix(100, 0), time.Unix(101, 0), time.Unix(102, 0)}
nowCall := 0
waits := 0
reader := &audioReader{
source: localSource,
readTimeout: 20 * time.Millisecond,
batch: 480,
unavailableAfter: 2 * time.Second,
retryDelay: 10 * time.Millisecond,
now: func() time.Time {
result := times[nowCall]
nowCall++
return result
},
wait: func(context.Context, time.Duration) error {
waits++
return nil
},
}
_, err := reader.ReadAudio(context.Background())
if source.KindOf(err) != source.ErrorKindUnavailable {
t.Fatalf("ReadAudio() error kind = %v, want unavailable", source.KindOf(err))
}
if !errors.Is(err, lastCause) {
t.Fatalf("ReadAudio() error = %v, want cause %v", err, lastCause)
}
if !ShouldRetry(err) {
t.Fatal("ShouldRetry(ReadAudio()) = false, want true")
}
if localSource.calls != 3 || waits != 2 {
t.Fatalf("reads = %d, waits = %d; want 3, 2", localSource.calls, waits)
}
}
func TestAudioReaderCancellationDuringTemporaryWait(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
localSource := &fakeLocalAudioSource{
results: []localAudioReadResult{{err: temporaryAudioError(errors.New("early"))}},
}
reader := &audioReader{
source: localSource,
batch: 480,
unavailableAfter: 2 * time.Second,
now: func() time.Time { return time.Unix(100, 0) },
wait: func(ctx context.Context, _ time.Duration) error {
cancel()
return ctx.Err()
},
}
_, err := reader.ReadAudio(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("ReadAudio() error = %v, want context.Canceled", err)
}
}
func TestAudioReaderCloseDelegates(t *testing.T) {
closeErr := errors.New("close failed")
source := &fakeLocalAudioSource{closeErr: closeErr}
reader := &audioReader{source: source}
err := reader.Close()
if !errors.Is(err, closeErr) || !source.closed {
t.Fatalf("Close() = %v, closed=%t; want %v, true", err, source.closed, closeErr)
}
}
+65
View File
@@ -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
}
+116
View File
@@ -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])
}
}
+204
View File
@@ -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
}
+213
View File
@@ -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)
}
}