add local MXL audio adapter

This commit is contained in:
Dmitry Sergeev
2026-08-28 21:41:23 +03:00
parent 5d1a9a3218
commit 1083f3b83c
3 changed files with 477 additions and 2 deletions
+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()
}