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, Label: frame.Label, 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() }