Compare commits
4 Commits
5d1a9a3218
...
d49f4ff15f
| Author | SHA1 | Date | |
|---|---|---|---|
| d49f4ff15f | |||
| bcb0ae6a6d | |||
| d57e403c2e | |||
| 1083f3b83c |
+149
-19
@@ -8,6 +8,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
mxladapter "mxl-player/internal/adapter/mxl"
|
mxladapter "mxl-player/internal/adapter/mxl"
|
||||||
"mxl-player/internal/imgui"
|
"mxl-player/internal/imgui"
|
||||||
|
"mxl-player/internal/output"
|
||||||
"mxl-player/internal/playback"
|
"mxl-player/internal/playback"
|
||||||
"mxl-player/internal/renderer"
|
"mxl-player/internal/renderer"
|
||||||
"mxl-player/internal/sdl"
|
"mxl-player/internal/sdl"
|
||||||
@@ -149,6 +150,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// path selection
|
// path selection
|
||||||
useVideoSlot := args.AudioFlowId == ""
|
useVideoSlot := args.AudioFlowId == ""
|
||||||
|
useAudioSlot := args.AudioFlowId != "" && args.VideoFlowId == ""
|
||||||
|
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
if err := sdl.Load(); err != nil {
|
if err := sdl.Load(); err != nil {
|
||||||
@@ -303,25 +305,7 @@ func main() {
|
|||||||
case args.VideoFlowId != "":
|
case args.VideoFlowId != "":
|
||||||
// VideoSlot owns opening and closing the video reader.
|
// VideoSlot owns opening and closing the video reader.
|
||||||
case args.AudioFlowId != "":
|
case args.AudioFlowId != "":
|
||||||
audioSrc, err = source.OpenAudio(args.Domain, args.AudioFlowId)
|
// AudioSlot owns opening and closing the audio reader.
|
||||||
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)
|
|
||||||
default:
|
default:
|
||||||
// No configured feeds. Renderer and GUI use the placeholder.
|
// No configured feeds. Renderer and GUI use the placeholder.
|
||||||
}
|
}
|
||||||
@@ -390,6 +374,7 @@ func main() {
|
|||||||
showStats bool = true
|
showStats bool = true
|
||||||
)
|
)
|
||||||
videoActive := args.VideoFlowId != ""
|
videoActive := args.VideoFlowId != ""
|
||||||
|
audioActive := useAudioSlot
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -437,6 +422,44 @@ func main() {
|
|||||||
}
|
}
|
||||||
videoCommands := make(chan playback.FeedConfig, 1)
|
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 {
|
reopen := func(params reconnectParams) error {
|
||||||
// Close current sources
|
// Close current sources
|
||||||
if syncSrc != nil {
|
if syncSrc != nil {
|
||||||
@@ -499,6 +522,17 @@ func main() {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enqueueAudioConfig := func(config playback.FeedConfig) {
|
||||||
|
select {
|
||||||
|
case <-audioCommands:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case audioCommands <- config:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
doReconnect := func() {
|
doReconnect := func() {
|
||||||
if useVideoSlot {
|
if useVideoSlot {
|
||||||
@@ -516,6 +550,22 @@ func main() {
|
|||||||
enqueueVideoConfig(config)
|
enqueueVideoConfig(config)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if useAudioSlot {
|
||||||
|
audioActive = audioStr != ""
|
||||||
|
|
||||||
|
config := playback.FeedConfig{}
|
||||||
|
if audioStr != "" {
|
||||||
|
config = playback.FeedConfig{
|
||||||
|
Domain: domainStr,
|
||||||
|
UUID: audioStr,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueAudioConfig(config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// legacy
|
||||||
select {
|
select {
|
||||||
case <-control:
|
case <-control:
|
||||||
default:
|
default:
|
||||||
@@ -542,6 +592,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
return
|
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.
|
// Audio-only mode: independent loop.
|
||||||
if audioSrc != nil && syncSrc == nil && videoSrc == nil {
|
if audioSrc != nil && syncSrc == nil && videoSrc == nil {
|
||||||
@@ -929,6 +994,71 @@ func main() {
|
|||||||
cimgui.Text("Video actual: not started")
|
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()
|
cimgui.End()
|
||||||
gui.EndFrame()
|
gui.EndFrame()
|
||||||
lastFrame = time.Now()
|
lastFrame = time.Now()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Size=200,200
|
|||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Connection]
|
[Window][Connection]
|
||||||
Pos=74,351
|
Pos=174,363
|
||||||
Size=799,226
|
Size=986,237
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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