diff --git a/imgui.ini b/imgui.ini index a71e8e8..b71d071 100644 --- a/imgui.ini +++ b/imgui.ini @@ -14,7 +14,7 @@ Size=200,200 Collapsed=0 [Window][Connection] -Pos=911,549 +Pos=250,275 Size=640,352 Collapsed=0 diff --git a/internal/adapter/mxl/sync.go b/internal/adapter/mxl/sync.go new file mode 100644 index 0000000..d45dfc4 --- /dev/null +++ b/internal/adapter/mxl/sync.go @@ -0,0 +1,175 @@ +package mxladapter + +import ( + "context" + "errors" + "fmt" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +const ( + DefaultSyncReadTimeout = 200 * time.Millisecond + DefaultSyncBatchDuration = 10 * time.Millisecond +) + +var ErrNativeSyncDifferentDomains = errors.New( + "native MXL synchronization requires matching domains", +) + +type SyncFactory struct { + ReadTimeout time.Duration + BatchDuration time.Duration + open func(string, string, string) (localSyncSource, error) +} + +type localSyncSource interface { + NextSync( + context.Context, + uint64, + time.Duration, + ) (source.Frame, source.AudioFrame, error) + + AudioRate() mxl.Rational + Close() error +} + +type syncReader struct { + source localSyncSource + readTimeout time.Duration + audioBatch uint64 + rateNumerator int64 + rateDenominator int64 +} + +var _ playback.SyncReaderFactory = SyncFactory{} +var _ playback.SyncReader = (*syncReader)(nil) + +func (f SyncFactory) OpenSync( + ctx context.Context, + videoConfig playback.FeedConfig, + audioConfig playback.FeedConfig, +) (playback.SyncReader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := videoConfig.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate sync video feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if err := audioConfig.Validate(); err != nil { + return nil, &source.SourceError{ + Op: "validate sync audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + if !videoConfig.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate sync video feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("sync video feed is not configured"), + } + } + if !audioConfig.IsConfigured() { + return nil, &source.SourceError{ + Op: "validate sync audio feed", + Kind: source.ErrorKindInvalidConfig, + Err: errors.New("sync audio feed is not configured"), + } + } + + if videoConfig.Domain != audioConfig.Domain { + return nil, &source.SourceError{ + Op: "validate native MXL sync group", + Kind: source.ErrorKindInvalidConfig, + Err: ErrNativeSyncDifferentDomains, + } + } + + open := f.open + if open == nil { + open = func(domain, videoUUID, audioUUID string) (localSyncSource, error) { + return source.OpenSameDomainSync(domain, videoUUID, audioUUID) + } + } + src, err := open( + videoConfig.Domain, + videoConfig.UUID, + audioConfig.UUID, + ) + if err != nil { + return nil, fmt.Errorf("open native MXL sync group: %w", err) + } + + if err := ctx.Err(); err != nil { + _ = src.Close() + return nil, err + } + + readTimeout := f.ReadTimeout + if readTimeout <= 0 { + readTimeout = DefaultSyncReadTimeout + } + batchDuration := f.BatchDuration + if batchDuration <= 0 { + batchDuration = DefaultSyncBatchDuration + } + audioRate := src.AudioRate() + batch, err := audioBatchSize(audioRate.Num, audioRate.Den, batchDuration) + if err != nil { + _ = src.Close() + return nil, &source.SourceError{ + Op: "calculate sync audio batch", + Kind: source.ErrorKindInvalidConfig, + Err: err, + } + } + + return &syncReader{ + source: src, + readTimeout: readTimeout, + audioBatch: batch, + rateNumerator: audioRate.Num, + rateDenominator: audioRate.Den, + }, nil +} + +func (r *syncReader) ReadSync( + ctx context.Context, +) (playback.SyncFrame, error) { + video, audio, err := r.source.NextSync(ctx, r.audioBatch, r.readTimeout) + if err != nil { + return playback.SyncFrame{}, err + } + return playback.SyncFrame{ + Video: playback.VideoFrame{ + Index: video.Index, + Width: video.Width, + Height: video.Height, + Stride: video.Stride, + Size: video.Size, + Invalid: video.Invalid, + Payload: video.Payload, + }, + Audio: playback.AudioFrame{ + Index: audio.Index, + SampleCount: audio.SampleCount, + Channels: audio.Channels, + SampleRateNumerator: r.rateNumerator, + SampleRateDenominator: r.rateDenominator, + Samples: audio.Samples, + }, + }, nil +} + +func (r *syncReader) Close() error { + return r.source.Close() +} diff --git a/internal/adapter/mxl/sync_test.go b/internal/adapter/mxl/sync_test.go new file mode 100644 index 0000000..d91f341 --- /dev/null +++ b/internal/adapter/mxl/sync_test.go @@ -0,0 +1,143 @@ +package mxladapter + +import ( + "context" + "errors" + "testing" + "time" + + "mxl-player/internal/playback" + "mxl-player/internal/source" + + mxl "github.com/qvest-digital/go-mxl/mxl" +) + +type fakeLocalSyncSource struct { + video source.Frame + audio source.AudioFrame + readErr error + rate mxl.Rational + batch uint64 + timeout time.Duration + closed bool + closeError error +} + +func (s *fakeLocalSyncSource) NextSync( + _ context.Context, + batch uint64, + timeout time.Duration, +) (source.Frame, source.AudioFrame, error) { + s.batch = batch + s.timeout = timeout + return s.video, s.audio, s.readErr +} + +func (s *fakeLocalSyncSource) AudioRate() mxl.Rational { return s.rate } +func (s *fakeLocalSyncSource) Close() error { + s.closed = true + return s.closeError +} + +func syncFeedConfigs() (playback.FeedConfig, playback.FeedConfig) { + return playback.FeedConfig{Domain: "/mxl", UUID: "video", Active: true}, + playback.FeedConfig{Domain: "/mxl", UUID: "audio", Active: true} +} + +func TestSyncFactoryRejectsDifferentDomains(t *testing.T) { + video, audio := syncFeedConfigs() + audio.Domain = "/other" + reader, err := (SyncFactory{}).OpenSync(context.Background(), video, audio) + if reader != nil { + t.Fatal("OpenSync() reader is not nil") + } + if !errors.Is(err, ErrNativeSyncDifferentDomains) { + t.Fatalf("OpenSync() error = %v, want %v", err, ErrNativeSyncDifferentDomains) + } + if source.KindOf(err) != source.ErrorKindInvalidConfig { + t.Fatalf("error kind = %v, want invalid config", source.KindOf(err)) + } + if ShouldRetry(err) { + t.Fatal("ShouldRetry() = true for different domains") + } +} + +func TestSyncFactoryUsesDefaultsAndForwardsFeeds(t *testing.T) { + fake := &fakeLocalSyncSource{rate: mxl.Rational{Num: 48_000, Den: 1}} + var domain, videoUUID, audioUUID string + factory := SyncFactory{open: func(d, v, a string) (localSyncSource, error) { + domain, videoUUID, audioUUID = d, v, a + return fake, nil + }} + video, audio := syncFeedConfigs() + + reader, err := factory.OpenSync(context.Background(), video, audio) + if err != nil { + t.Fatalf("OpenSync() error = %v", err) + } + if domain != video.Domain || videoUUID != video.UUID || audioUUID != audio.UUID { + t.Fatalf("open args = %q %q %q", domain, videoUUID, audioUUID) + } + got := reader.(*syncReader) + if got.readTimeout != DefaultSyncReadTimeout || got.audioBatch != 480 { + t.Fatalf("reader timeout=%s batch=%d, want %s and 480", got.readTimeout, got.audioBatch, DefaultSyncReadTimeout) + } +} + +func TestSyncFactoryClosesSourceForInvalidAudioRate(t *testing.T) { + fake := &fakeLocalSyncSource{rate: mxl.Rational{}} + factory := SyncFactory{open: func(string, string, string) (localSyncSource, error) { + return fake, nil + }} + video, audio := syncFeedConfigs() + reader, err := factory.OpenSync(context.Background(), video, audio) + if reader != nil { + t.Fatal("OpenSync() reader is not nil") + } + if !errors.Is(err, ErrInvalidAudioBatch) || !fake.closed { + t.Fatalf("OpenSync() error=%v closed=%t", err, fake.closed) + } +} + +func TestSyncReaderConvertsPairWithoutCopying(t *testing.T) { + videoPayload := []byte{1, 2, 3} + audioSamples := [][]byte{{4, 5, 6, 7}} + fake := &fakeLocalSyncSource{ + video: source.Frame{Index: 10, Width: 20, Height: 30, Payload: videoPayload}, + audio: source.AudioFrame{Index: 40, SampleCount: 1, Channels: 1, Samples: audioSamples}, + rate: mxl.Rational{Num: 48_000, Den: 1}, + } + reader := &syncReader{ + source: fake, readTimeout: 7 * time.Millisecond, audioBatch: 12, + rateNumerator: 48_000, rateDenominator: 1, + } + + frame, err := reader.ReadSync(context.Background()) + if err != nil { + t.Fatal(err) + } + if fake.batch != 12 || fake.timeout != 7*time.Millisecond { + t.Fatalf("NextSync() batch=%d timeout=%s", fake.batch, fake.timeout) + } + if frame.Video.Index != 10 || frame.Audio.Index != 40 || frame.Audio.SampleRateNumerator != 48_000 { + t.Fatalf("frame = %+v", frame) + } + if &frame.Video.Payload[0] != &videoPayload[0] || &frame.Audio.Samples[0][0] != &audioSamples[0][0] { + t.Fatal("sync payload was copied") + } +} + +func TestSyncFactoryReturnsPreCanceledContextWithoutOpening(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + opened := false + factory := SyncFactory{open: func(string, string, string) (localSyncSource, error) { + opened = true + return nil, nil + }} + video, audio := syncFeedConfigs() + reader, err := factory.OpenSync(ctx, video, audio) + if reader != nil || !errors.Is(err, context.Canceled) || opened { + t.Fatalf("reader=%v error=%v opened=%t", reader, err, opened) + } +}