stats window
This commit is contained in:
+114
-17
@@ -464,16 +464,18 @@ func main() {
|
|||||||
displayedVideoWidth uint32 = placeholderWidth
|
displayedVideoWidth uint32 = placeholderWidth
|
||||||
displayedVideoHeight uint32 = placeholderHeight
|
displayedVideoHeight uint32 = placeholderHeight
|
||||||
displayedVideoStride uint32 = placeholderStride
|
displayedVideoStride uint32 = placeholderStride
|
||||||
hasDisplayedVideo bool = false
|
|
||||||
|
|
||||||
fps float64
|
fps float64
|
||||||
lastIndex uint64
|
lastIndex uint64
|
||||||
dropped uint64
|
dropped uint64
|
||||||
frameCount uint64
|
droppedTotal uint64
|
||||||
lastReport time.Time
|
frameCount uint64
|
||||||
lastFrame time.Time
|
lastReport time.Time
|
||||||
|
lastFrame time.Time
|
||||||
|
renderLoopDT time.Duration
|
||||||
)
|
)
|
||||||
lastFrame = time.Now()
|
lastFrame = time.Now()
|
||||||
|
lastReport = lastFrame
|
||||||
|
|
||||||
// ImGui
|
// ImGui
|
||||||
var (
|
var (
|
||||||
@@ -555,7 +557,6 @@ func main() {
|
|||||||
displayedVideoWidth = pendingFrame.Frame.Width
|
displayedVideoWidth = pendingFrame.Frame.Width
|
||||||
displayedVideoHeight = pendingFrame.Frame.Height
|
displayedVideoHeight = pendingFrame.Frame.Height
|
||||||
displayedVideoStride = pendingFrame.Frame.Stride
|
displayedVideoStride = pendingFrame.Frame.Stride
|
||||||
hasDisplayedVideo = true
|
|
||||||
hasFrame = true
|
hasFrame = true
|
||||||
} else if frameErr != nil &&
|
} else if frameErr != nil &&
|
||||||
!errors.Is(frameErr, context.DeadlineExceeded) &&
|
!errors.Is(frameErr, context.DeadlineExceeded) &&
|
||||||
@@ -568,6 +569,7 @@ func main() {
|
|||||||
if lastIndex != 0 && shownIndex > lastIndex {
|
if lastIndex != 0 && shownIndex > lastIndex {
|
||||||
if g := shownIndex - lastIndex - 1; g > 0 {
|
if g := shownIndex - lastIndex - 1; g > 0 {
|
||||||
dropped += g
|
dropped += g
|
||||||
|
droppedTotal += g
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastIndex = shownIndex
|
lastIndex = shownIndex
|
||||||
@@ -585,25 +587,119 @@ func main() {
|
|||||||
// end of stats
|
// end of stats
|
||||||
if r != nil {
|
if r != nil {
|
||||||
gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height))
|
gui.BeginFrame(time.Since(lastFrame), int32(r.Extent().Width), int32(r.Extent().Height))
|
||||||
|
snapshot, hasSnapshot := player.Controller.Snapshot()
|
||||||
if showStats {
|
if showStats {
|
||||||
cimgui.SetNextWindowPos(cimgui.Vec2{X: 10, Y: 10})
|
cimgui.SetNextWindowPos(cimgui.Vec2{X: 10, Y: 10})
|
||||||
cimgui.SetNextWindowSize(cimgui.Vec2{X: 200, Y: 200})
|
cimgui.SetNextWindowSize(cimgui.Vec2{X: 460, Y: 510})
|
||||||
cimgui.BeginV("Stats", &showStats,
|
cimgui.BeginV("Stats", &showStats,
|
||||||
cimgui.WindowFlagsNoTitleBar|cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar)
|
cimgui.WindowFlagsNoTitleBar|cimgui.WindowFlagsNoResize|cimgui.WindowFlagsNoScrollbar)
|
||||||
cimgui.Text(fmt.Sprintf("FPS: %.1f", fps))
|
mediaStats := player.MediaStats.Snapshot()
|
||||||
cimgui.Text(fmt.Sprintf("Dropped: %d", dropped))
|
cimgui.SeparatorText("Video")
|
||||||
cimgui.Text(fmt.Sprintf("Index: %d", shownIndex))
|
if mediaStats.Video.Available {
|
||||||
if hasDisplayedVideo {
|
label := mediaStats.Video.Label
|
||||||
|
if label == "" {
|
||||||
|
label = "(no label)"
|
||||||
|
}
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("Label: %s", label))
|
||||||
|
if hasSnapshot && snapshot.Desired.Video.IsConfigured() {
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("Domain: %s", snapshot.Desired.Video.Domain))
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("UUID: %s", snapshot.Desired.Video.UUID))
|
||||||
|
}
|
||||||
cimgui.Text(fmt.Sprintf(
|
cimgui.Text(fmt.Sprintf(
|
||||||
"Video: %dx%d",
|
"Resolution: %dx%d (stride %d)",
|
||||||
displayedVideoWidth,
|
mediaStats.Video.Width,
|
||||||
displayedVideoHeight,
|
mediaStats.Video.Height,
|
||||||
|
mediaStats.Video.Stride,
|
||||||
))
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"FPS: flow %.3f | received %.1f | displayed %.1f",
|
||||||
|
mediaStats.Video.DeclaredFPS,
|
||||||
|
mediaStats.Video.ReceivedFPS,
|
||||||
|
fps,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Frame dt: source %.3f ms | render loop %.3f ms",
|
||||||
|
float64(mediaStats.Video.FrameDT.Microseconds())/1000,
|
||||||
|
float64(renderLoopDT.Microseconds())/1000,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Index: %d | payload: %d bytes",
|
||||||
|
mediaStats.Video.Index,
|
||||||
|
mediaStats.Video.PayloadSize,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Dropped: %d | invalid: %d",
|
||||||
|
droppedTotal,
|
||||||
|
mediaStats.Video.Invalid,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
cimgui.Text("No video frames received")
|
||||||
|
}
|
||||||
|
|
||||||
|
cimgui.SeparatorText("Audio")
|
||||||
|
if mediaStats.Audio.Available {
|
||||||
|
label := mediaStats.Audio.Label
|
||||||
|
if label == "" {
|
||||||
|
label = "(no label)"
|
||||||
|
}
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("Label: %s", label))
|
||||||
|
if hasSnapshot && snapshot.Desired.Audio.IsConfigured() {
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("Domain: %s", snapshot.Desired.Audio.Domain))
|
||||||
|
cimgui.TextWrapped(fmt.Sprintf("UUID: %s", snapshot.Desired.Audio.UUID))
|
||||||
|
}
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Format: %.3f kHz, %d channels",
|
||||||
|
mediaStats.Audio.SampleRateHz/1000,
|
||||||
|
mediaStats.Audio.Channels,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Batch: %d samples (%.3f ms)",
|
||||||
|
mediaStats.Audio.SampleCount,
|
||||||
|
float64(mediaStats.Audio.BatchDuration.Microseconds())/1000,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf("Index: %d", mediaStats.Audio.Index))
|
||||||
|
} else {
|
||||||
|
cimgui.Text("No audio batches received")
|
||||||
|
}
|
||||||
|
|
||||||
|
cimgui.SeparatorText("Runtime")
|
||||||
|
if hasSnapshot {
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Topology: %s | generation %d",
|
||||||
|
snapshot.Plan.Topology,
|
||||||
|
snapshot.Generation,
|
||||||
|
))
|
||||||
|
drawCompactStatus := func(label string, unit playback.Unit) {
|
||||||
|
status, ok := statusStore.Snapshot(unit)
|
||||||
|
if !ok {
|
||||||
|
cimgui.Text(fmt.Sprintf("%s: not started", label))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"%s: %s (attempt %d, failed %d)",
|
||||||
|
label,
|
||||||
|
status.State,
|
||||||
|
status.Attempt,
|
||||||
|
status.FailedAttempts,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
switch snapshot.Plan.Topology {
|
||||||
|
case playback.TopologySynchronized:
|
||||||
|
drawCompactStatus("Sync", playback.UnitSync)
|
||||||
|
case playback.TopologyIndependent:
|
||||||
|
if snapshot.Plan.Video.Active {
|
||||||
|
drawCompactStatus("Video", playback.UnitVideo)
|
||||||
|
}
|
||||||
|
if snapshot.Plan.Audio.Active {
|
||||||
|
drawCompactStatus("Audio", playback.UnitAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cimgui.Text("Playback controller is starting")
|
||||||
}
|
}
|
||||||
cimgui.End()
|
cimgui.End()
|
||||||
}
|
}
|
||||||
// settings & info window
|
// settings & info window
|
||||||
snapshot, hasSnapshot := player.Controller.Snapshot()
|
|
||||||
videoConfigured := videoStr != ""
|
videoConfigured := videoStr != ""
|
||||||
audioConfigured := audioStr != ""
|
audioConfigured := audioStr != ""
|
||||||
if hasSnapshot {
|
if hasSnapshot {
|
||||||
@@ -933,6 +1029,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
renderLoopDT = time.Since(frameStart)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type playerPlayback struct {
|
|||||||
Commands chan playback.SessionCommand
|
Commands chan playback.SessionCommand
|
||||||
Video *playback.VideoBridge
|
Video *playback.VideoBridge
|
||||||
Status *playback.StatusStore
|
Status *playback.StatusStore
|
||||||
|
MediaStats *playback.MediaStatsStore
|
||||||
Audio playerAudioSink
|
Audio playerAudioSink
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +30,10 @@ func newPlayerPlayback(
|
|||||||
) (*playerPlayback, error) {
|
) (*playerPlayback, error) {
|
||||||
videoBridge := playback.NewVideoBridge()
|
videoBridge := playback.NewVideoBridge()
|
||||||
statusStore := playback.NewStatusStore()
|
statusStore := playback.NewStatusStore()
|
||||||
|
mediaStats := playback.NewMediaStatsStore()
|
||||||
audioSink := output.NewSDLAudioSink(audioDevice)
|
audioSink := output.NewSDLAudioSink(audioDevice)
|
||||||
|
videoSink := playback.VideoStatsSink{Stats: mediaStats, Sink: videoBridge}
|
||||||
|
observedAudioSink := playback.AudioStatsSink{Stats: mediaStats, Sink: audioSink}
|
||||||
|
|
||||||
observe := func(status playback.Status) {
|
observe := func(status playback.Status) {
|
||||||
statusStore.Observe(status)
|
statusStore.Observe(status)
|
||||||
@@ -57,7 +61,7 @@ func newPlayerPlayback(
|
|||||||
|
|
||||||
videoWorker, err := playback.NewVideoWorker(
|
videoWorker, err := playback.NewVideoWorker(
|
||||||
mxladapter.VideoFactory{},
|
mxladapter.VideoFactory{},
|
||||||
videoBridge,
|
videoSink,
|
||||||
retry,
|
retry,
|
||||||
mxladapter.ShouldRetry,
|
mxladapter.ShouldRetry,
|
||||||
observe,
|
observe,
|
||||||
@@ -74,7 +78,7 @@ func newPlayerPlayback(
|
|||||||
|
|
||||||
audioWorker, err := playback.NewAudioWorker(
|
audioWorker, err := playback.NewAudioWorker(
|
||||||
mxladapter.AudioFactory{},
|
mxladapter.AudioFactory{},
|
||||||
audioSink,
|
observedAudioSink,
|
||||||
retry,
|
retry,
|
||||||
mxladapter.ShouldRetry,
|
mxladapter.ShouldRetry,
|
||||||
observe,
|
observe,
|
||||||
@@ -91,8 +95,8 @@ func newPlayerPlayback(
|
|||||||
|
|
||||||
syncWorker, err := playback.NewSyncWorker(
|
syncWorker, err := playback.NewSyncWorker(
|
||||||
mxladapter.SyncFactory{},
|
mxladapter.SyncFactory{},
|
||||||
videoBridge,
|
videoSink,
|
||||||
audioSink,
|
observedAudioSink,
|
||||||
retry,
|
retry,
|
||||||
mxladapter.ShouldRetry,
|
mxladapter.ShouldRetry,
|
||||||
observe,
|
observe,
|
||||||
@@ -125,6 +129,7 @@ func newPlayerPlayback(
|
|||||||
Commands: make(chan playback.SessionCommand, 32),
|
Commands: make(chan playback.SessionCommand, 32),
|
||||||
Video: videoBridge,
|
Video: videoBridge,
|
||||||
Status: statusStore,
|
Status: statusStore,
|
||||||
|
MediaStats: mediaStats,
|
||||||
Audio: audioSink,
|
Audio: audioSink,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Collapsed=0
|
|||||||
|
|
||||||
[Window][Stats]
|
[Window][Stats]
|
||||||
Pos=10,10
|
Pos=10,10
|
||||||
Size=200,200
|
Size=460,510
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Connection]
|
[Window][Connection]
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func (r *audioReader) ReadAudio(ctx context.Context) (playback.AudioFrame, error
|
|||||||
Index: frame.Index,
|
Index: frame.Index,
|
||||||
SampleCount: frame.SampleCount,
|
SampleCount: frame.SampleCount,
|
||||||
Channels: frame.Channels,
|
Channels: frame.Channels,
|
||||||
|
Label: frame.Label,
|
||||||
SampleRateNumerator: r.rateNumerator,
|
SampleRateNumerator: r.rateNumerator,
|
||||||
SampleRateDenominator: r.rateDenominator,
|
SampleRateDenominator: r.rateDenominator,
|
||||||
Samples: frame.Samples,
|
Samples: frame.Samples,
|
||||||
|
|||||||
@@ -133,18 +133,22 @@ func (r *syncReader) ReadSync(
|
|||||||
}
|
}
|
||||||
return playback.SyncFrame{
|
return playback.SyncFrame{
|
||||||
Video: playback.VideoFrame{
|
Video: playback.VideoFrame{
|
||||||
Index: video.Index,
|
Index: video.Index,
|
||||||
Width: video.Width,
|
Width: video.Width,
|
||||||
Height: video.Height,
|
Height: video.Height,
|
||||||
Stride: video.Stride,
|
Stride: video.Stride,
|
||||||
Size: video.Size,
|
Size: video.Size,
|
||||||
Invalid: video.Invalid,
|
Invalid: video.Invalid,
|
||||||
Payload: video.Payload,
|
Label: video.Label,
|
||||||
|
FrameRateNumerator: video.FrameRateNumerator,
|
||||||
|
FrameRateDenominator: video.FrameRateDenominator,
|
||||||
|
Payload: video.Payload,
|
||||||
},
|
},
|
||||||
Audio: playback.AudioFrame{
|
Audio: playback.AudioFrame{
|
||||||
Index: audio.Index,
|
Index: audio.Index,
|
||||||
SampleCount: audio.SampleCount,
|
SampleCount: audio.SampleCount,
|
||||||
Channels: audio.Channels,
|
Channels: audio.Channels,
|
||||||
|
Label: audio.Label,
|
||||||
SampleRateNumerator: r.rateNumerator,
|
SampleRateNumerator: r.rateNumerator,
|
||||||
SampleRateDenominator: r.rateDenominator,
|
SampleRateDenominator: r.rateDenominator,
|
||||||
Samples: audio.Samples,
|
Samples: audio.Samples,
|
||||||
|
|||||||
@@ -113,13 +113,16 @@ func (r *videoReader) ReadVideo(
|
|||||||
frame, err := r.source.ReadOnceCtx(ctx, r.readTimeout)
|
frame, err := r.source.ReadOnceCtx(ctx, r.readTimeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return playback.VideoFrame{
|
return playback.VideoFrame{
|
||||||
Index: frame.Index,
|
Index: frame.Index,
|
||||||
Width: frame.Width,
|
Width: frame.Width,
|
||||||
Height: frame.Height,
|
Height: frame.Height,
|
||||||
Stride: frame.Stride,
|
Stride: frame.Stride,
|
||||||
Size: frame.Size,
|
Size: frame.Size,
|
||||||
Invalid: frame.Invalid,
|
Invalid: frame.Invalid,
|
||||||
Payload: frame.Payload,
|
Label: frame.Label,
|
||||||
|
FrameRateNumerator: frame.FrameRateNumerator,
|
||||||
|
FrameRateDenominator: frame.FrameRateDenominator,
|
||||||
|
Payload: frame.Payload,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type AudioFrame struct {
|
|||||||
Index uint64
|
Index uint64
|
||||||
SampleCount uint64
|
SampleCount uint64
|
||||||
Channels uint64
|
Channels uint64
|
||||||
|
Label string
|
||||||
|
|
||||||
SampleRateNumerator int64
|
SampleRateNumerator int64
|
||||||
SampleRateDenominator int64
|
SampleRateDenominator int64
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VideoMediaStats struct {
|
||||||
|
Available bool
|
||||||
|
Label string
|
||||||
|
Index uint64
|
||||||
|
Width uint32
|
||||||
|
Height uint32
|
||||||
|
Stride uint32
|
||||||
|
PayloadSize uint32
|
||||||
|
DeclaredFPS float64
|
||||||
|
ReceivedFPS float64
|
||||||
|
FrameDT time.Duration
|
||||||
|
Invalid uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioMediaStats struct {
|
||||||
|
Available bool
|
||||||
|
Label string
|
||||||
|
Index uint64
|
||||||
|
SampleRateHz float64
|
||||||
|
Channels uint64
|
||||||
|
SampleCount uint64
|
||||||
|
BatchDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaStatsSnapshot struct {
|
||||||
|
Video VideoMediaStats
|
||||||
|
Audio AudioMediaStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaStatsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
snapshot MediaStatsSnapshot
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
lastVideoAt time.Time
|
||||||
|
videoWindowAt time.Time
|
||||||
|
videoWindowCount uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMediaStatsStore() *MediaStatsStore {
|
||||||
|
return &MediaStatsStore{now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStatsStore) ObserveVideo(frame VideoFrame) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
stats := &s.snapshot.Video
|
||||||
|
stats.Available = true
|
||||||
|
stats.Label = frame.Label
|
||||||
|
stats.Index = frame.Index
|
||||||
|
stats.Width = frame.Width
|
||||||
|
stats.Height = frame.Height
|
||||||
|
stats.Stride = frame.Stride
|
||||||
|
stats.PayloadSize = frame.Size
|
||||||
|
if frame.FrameRateDenominator > 0 {
|
||||||
|
stats.DeclaredFPS = float64(frame.FrameRateNumerator) /
|
||||||
|
float64(frame.FrameRateDenominator)
|
||||||
|
}
|
||||||
|
if !s.lastVideoAt.IsZero() {
|
||||||
|
stats.FrameDT = now.Sub(s.lastVideoAt)
|
||||||
|
}
|
||||||
|
s.lastVideoAt = now
|
||||||
|
if frame.Invalid {
|
||||||
|
stats.Invalid++
|
||||||
|
}
|
||||||
|
if s.videoWindowAt.IsZero() {
|
||||||
|
s.videoWindowAt = now
|
||||||
|
}
|
||||||
|
s.videoWindowCount++
|
||||||
|
if elapsed := now.Sub(s.videoWindowAt); elapsed >= time.Second {
|
||||||
|
stats.ReceivedFPS = float64(s.videoWindowCount) / elapsed.Seconds()
|
||||||
|
s.videoWindowAt = now
|
||||||
|
s.videoWindowCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStatsStore) ObserveAudio(frame AudioFrame) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
stats := &s.snapshot.Audio
|
||||||
|
stats.Available = true
|
||||||
|
stats.Label = frame.Label
|
||||||
|
stats.Index = frame.Index
|
||||||
|
stats.Channels = frame.Channels
|
||||||
|
stats.SampleCount = frame.SampleCount
|
||||||
|
if frame.SampleRateDenominator > 0 {
|
||||||
|
stats.SampleRateHz = float64(frame.SampleRateNumerator) /
|
||||||
|
float64(frame.SampleRateDenominator)
|
||||||
|
}
|
||||||
|
if stats.SampleRateHz > 0 {
|
||||||
|
stats.BatchDuration = time.Duration(
|
||||||
|
float64(time.Second) * float64(frame.SampleCount) / stats.SampleRateHz,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStatsStore) Snapshot() MediaStatsSnapshot {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoStatsSink struct {
|
||||||
|
Stats *MediaStatsStore
|
||||||
|
Sink VideoSink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s VideoStatsSink) ConsumeVideo(ctx context.Context, frame VideoFrame) error {
|
||||||
|
if s.Stats != nil {
|
||||||
|
s.Stats.ObserveVideo(frame)
|
||||||
|
}
|
||||||
|
return s.Sink.ConsumeVideo(ctx, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioStatsSink struct {
|
||||||
|
Stats *MediaStatsStore
|
||||||
|
Sink AudioSink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s AudioStatsSink) ConsumeAudio(ctx context.Context, frame AudioFrame) error {
|
||||||
|
if s.Stats != nil {
|
||||||
|
s.Stats.ObserveAudio(frame)
|
||||||
|
}
|
||||||
|
return s.Sink.ConsumeAudio(ctx, frame)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaStatsStoreObservesVideo(t *testing.T) {
|
||||||
|
store := NewMediaStatsStore()
|
||||||
|
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
frame := VideoFrame{
|
||||||
|
Index: 10,
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
Stride: 5120,
|
||||||
|
Size: 5_529_600,
|
||||||
|
Label: "Main video",
|
||||||
|
FrameRateNumerator: 30000,
|
||||||
|
FrameRateDenominator: 1001,
|
||||||
|
}
|
||||||
|
store.ObserveVideo(frame)
|
||||||
|
now = now.Add(40 * time.Millisecond)
|
||||||
|
frame.Index++
|
||||||
|
frame.Invalid = true
|
||||||
|
store.ObserveVideo(frame)
|
||||||
|
|
||||||
|
got := store.Snapshot().Video
|
||||||
|
if !got.Available || got.Label != "Main video" || got.Index != 11 {
|
||||||
|
t.Fatalf("video stats = %#v", got)
|
||||||
|
}
|
||||||
|
if got.Width != 1920 || got.Height != 1080 || got.Stride != 5120 {
|
||||||
|
t.Fatalf("video dimensions = %#v", got)
|
||||||
|
}
|
||||||
|
if got.DeclaredFPS < 29.96 || got.DeclaredFPS > 29.98 {
|
||||||
|
t.Fatalf("declared FPS = %v", got.DeclaredFPS)
|
||||||
|
}
|
||||||
|
if got.FrameDT != 40*time.Millisecond || got.Invalid != 1 {
|
||||||
|
t.Fatalf("video timing = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaStatsStoreCalculatesReceivedFPS(t *testing.T) {
|
||||||
|
store := NewMediaStatsStore()
|
||||||
|
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
store.ObserveVideo(VideoFrame{})
|
||||||
|
now = now.Add(500 * time.Millisecond)
|
||||||
|
store.ObserveVideo(VideoFrame{})
|
||||||
|
now = now.Add(500 * time.Millisecond)
|
||||||
|
store.ObserveVideo(VideoFrame{})
|
||||||
|
|
||||||
|
if got := store.Snapshot().Video.ReceivedFPS; got != 3 {
|
||||||
|
t.Fatalf("received FPS = %v, want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaStatsStoreObservesAudio(t *testing.T) {
|
||||||
|
store := NewMediaStatsStore()
|
||||||
|
store.ObserveAudio(AudioFrame{
|
||||||
|
Index: 100,
|
||||||
|
SampleCount: 480,
|
||||||
|
Channels: 2,
|
||||||
|
Label: "Programme audio",
|
||||||
|
SampleRateNumerator: 48_000,
|
||||||
|
SampleRateDenominator: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
got := store.Snapshot().Audio
|
||||||
|
if !got.Available || got.Label != "Programme audio" || got.Index != 100 {
|
||||||
|
t.Fatalf("audio stats = %#v", got)
|
||||||
|
}
|
||||||
|
if got.SampleRateHz != 48_000 || got.Channels != 2 || got.SampleCount != 480 {
|
||||||
|
t.Fatalf("audio format = %#v", got)
|
||||||
|
}
|
||||||
|
if got.BatchDuration != 10*time.Millisecond {
|
||||||
|
t.Fatalf("batch duration = %v, want 10ms", got.BatchDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingVideoStatsSink struct{ frame VideoFrame }
|
||||||
|
|
||||||
|
func (s *recordingVideoStatsSink) ConsumeVideo(_ context.Context, frame VideoFrame) error {
|
||||||
|
s.frame = frame
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingAudioStatsSink struct{ frame AudioFrame }
|
||||||
|
|
||||||
|
func (s *recordingAudioStatsSink) ConsumeAudio(_ context.Context, frame AudioFrame) error {
|
||||||
|
s.frame = frame
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsSinksObserveAndForwardFrames(t *testing.T) {
|
||||||
|
store := NewMediaStatsStore()
|
||||||
|
videoDownstream := &recordingVideoStatsSink{}
|
||||||
|
audioDownstream := &recordingAudioStatsSink{}
|
||||||
|
video := VideoFrame{Index: 7, Label: "video"}
|
||||||
|
audio := AudioFrame{Index: 8, Label: "audio"}
|
||||||
|
|
||||||
|
if err := (VideoStatsSink{Stats: store, Sink: videoDownstream}).ConsumeVideo(context.Background(), video); err != nil {
|
||||||
|
t.Fatalf("ConsumeVideo() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := (AudioStatsSink{Stats: store, Sink: audioDownstream}).ConsumeAudio(context.Background(), audio); err != nil {
|
||||||
|
t.Fatalf("ConsumeAudio() error = %v", err)
|
||||||
|
}
|
||||||
|
if videoDownstream.frame.Index != video.Index || audioDownstream.frame.Index != audio.Index {
|
||||||
|
t.Fatalf("forwarded frames = %#v, %#v", videoDownstream.frame, audioDownstream.frame)
|
||||||
|
}
|
||||||
|
snapshot := store.Snapshot()
|
||||||
|
if snapshot.Video.Label != "video" || snapshot.Audio.Label != "audio" {
|
||||||
|
t.Fatalf("stats snapshot = %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,16 @@ import "context"
|
|||||||
// reader is closed. Consumers must finish reading Payload before returning
|
// reader is closed. Consumers must finish reading Payload before returning
|
||||||
// control to the worker
|
// control to the worker
|
||||||
type VideoFrame struct {
|
type VideoFrame struct {
|
||||||
Index uint64
|
Index uint64
|
||||||
Width uint32
|
Width uint32
|
||||||
Height uint32
|
Height uint32
|
||||||
Stride uint32
|
Stride uint32
|
||||||
Size uint32
|
Size uint32
|
||||||
Invalid bool
|
Invalid bool
|
||||||
Payload []byte
|
Label string
|
||||||
|
FrameRateNumerator int64
|
||||||
|
FrameRateDenominator int64
|
||||||
|
Payload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// VideoReader reads frames from a video source.
|
// VideoReader reads frames from a video source.
|
||||||
|
|||||||
+68
-34
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type flowDef struct {
|
type flowDef struct {
|
||||||
|
Label string `json:"label"`
|
||||||
FrameWidth int `json:"frame_width"`
|
FrameWidth int `json:"frame_width"`
|
||||||
FrameHeight int `json:"frame_height"`
|
FrameHeight int `json:"frame_height"`
|
||||||
MediaType string `json:"media_type"`
|
MediaType string `json:"media_type"`
|
||||||
@@ -21,14 +22,31 @@ type flowDef struct {
|
|||||||
} `json:"grain_rate"`
|
} `json:"grain_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func flowLabel(inst *mxl.Instance, flowID string) string {
|
||||||
|
definition, err := inst.FlowDef(flowID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var metadata struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(definition), &metadata) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return metadata.Label
|
||||||
|
}
|
||||||
|
|
||||||
type Frame struct {
|
type Frame struct {
|
||||||
Index uint64
|
Index uint64
|
||||||
Width uint32
|
Width uint32
|
||||||
Height uint32
|
Height uint32
|
||||||
Stride uint32
|
Stride uint32
|
||||||
Size uint32
|
Size uint32
|
||||||
Invalid bool
|
Invalid bool
|
||||||
Payload []byte
|
Label string
|
||||||
|
FrameRateNumerator int64
|
||||||
|
FrameRateDenominator int64
|
||||||
|
Payload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type Source struct {
|
type Source struct {
|
||||||
@@ -40,6 +58,7 @@ type Source struct {
|
|||||||
stride uint32
|
stride uint32
|
||||||
width uint32
|
width uint32
|
||||||
height uint32
|
height uint32
|
||||||
|
label string
|
||||||
idx uint64
|
idx uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +128,7 @@ func Open(domain, flowID string) (*Source, error) {
|
|||||||
stride: info.Config.Discrete.SliceSizes[0],
|
stride: info.Config.Discrete.SliceSizes[0],
|
||||||
width: uint32(fd.FrameWidth),
|
width: uint32(fd.FrameWidth),
|
||||||
height: uint32(fd.FrameHeight),
|
height: uint32(fd.FrameHeight),
|
||||||
|
label: fd.Label,
|
||||||
idx: idx,
|
idx: idx,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -157,13 +177,16 @@ func (s *Source) ReadOnceCtx(
|
|||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
frame := Frame{
|
frame := Frame{
|
||||||
Index: g.Index,
|
Index: g.Index,
|
||||||
Width: s.width,
|
Width: s.width,
|
||||||
Height: s.height,
|
Height: s.height,
|
||||||
Stride: s.stride,
|
Stride: s.stride,
|
||||||
Size: g.GrainSize,
|
Size: g.GrainSize,
|
||||||
Invalid: g.Invalid(),
|
Invalid: g.Invalid(),
|
||||||
Payload: g.Payload,
|
Label: s.label,
|
||||||
|
FrameRateNumerator: s.rate.Num,
|
||||||
|
FrameRateDenominator: s.rate.Den,
|
||||||
|
Payload: g.Payload,
|
||||||
}
|
}
|
||||||
s.idx++
|
s.idx++
|
||||||
return frame, nil
|
return frame, nil
|
||||||
@@ -222,12 +245,14 @@ type AudioSource struct {
|
|||||||
rate mxl.Rational
|
rate mxl.Rational
|
||||||
chans uint64
|
chans uint64
|
||||||
idx uint64
|
idx uint64
|
||||||
|
label string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioFrame struct {
|
type AudioFrame struct {
|
||||||
Index uint64
|
Index uint64
|
||||||
SampleCount uint64
|
SampleCount uint64
|
||||||
Channels uint64
|
Channels uint64
|
||||||
|
Label string
|
||||||
Samples [][]byte // per-channel byte slices (F32, deinterleaved)
|
Samples [][]byte // per-channel byte slices (F32, deinterleaved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +318,7 @@ func OpenAudio(domain, flowID string) (*AudioSource, error) {
|
|||||||
rate: rate,
|
rate: rate,
|
||||||
chans: channels,
|
chans: channels,
|
||||||
idx: idx,
|
idx: idx,
|
||||||
|
label: flowLabel(inst, flowID),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +352,7 @@ func (s *AudioSource) ReadAudioOnceCtx(
|
|||||||
Index: s.idx,
|
Index: s.idx,
|
||||||
SampleCount: batch,
|
SampleCount: batch,
|
||||||
Channels: s.chans,
|
Channels: s.chans,
|
||||||
|
Label: s.label,
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
}
|
}
|
||||||
s.idx += batch
|
s.idx += batch
|
||||||
@@ -401,15 +428,16 @@ func (s *AudioSource) Rate() mxl.Rational { return s.rate }
|
|||||||
func (s *AudioSource) Channels() uint64 { return s.chans }
|
func (s *AudioSource) Channels() uint64 { return s.chans }
|
||||||
|
|
||||||
type SyncSource struct {
|
type SyncSource struct {
|
||||||
inst *mxl.Instance
|
inst *mxl.Instance
|
||||||
vr *mxl.Reader
|
vr *mxl.Reader
|
||||||
ar *mxl.Reader
|
ar *mxl.Reader
|
||||||
group *mxl.SyncGroup
|
group *mxl.SyncGroup
|
||||||
rate mxl.Rational // video rate
|
rate mxl.Rational // video rate
|
||||||
aRate mxl.Rational
|
aRate mxl.Rational
|
||||||
chans uint64
|
chans uint64
|
||||||
idx uint64
|
idx uint64
|
||||||
width, height, stride uint32
|
width, height, stride uint32
|
||||||
|
videoLabel, audioLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSameDomainSync opens a native MXL synchronization group.
|
// OpenSameDomainSync opens a native MXL synchronization group.
|
||||||
@@ -577,17 +605,19 @@ func OpenSameDomainSync(domain, videoFlow, audioFlow string) (*SyncSource, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &SyncSource{
|
return &SyncSource{
|
||||||
inst: inst,
|
inst: inst,
|
||||||
vr: vr,
|
vr: vr,
|
||||||
ar: ar,
|
ar: ar,
|
||||||
group: group,
|
group: group,
|
||||||
rate: vRate,
|
rate: vRate,
|
||||||
aRate: aRate,
|
aRate: aRate,
|
||||||
chans: channels,
|
chans: channels,
|
||||||
idx: idx,
|
idx: idx,
|
||||||
width: uint32(fd.FrameWidth),
|
width: uint32(fd.FrameWidth),
|
||||||
height: uint32(fd.FrameHeight),
|
height: uint32(fd.FrameHeight),
|
||||||
stride: vInfo.Config.Discrete.SliceSizes[0],
|
stride: vInfo.Config.Discrete.SliceSizes[0],
|
||||||
|
videoLabel: fd.Label,
|
||||||
|
audioLabel: flowLabel(inst, audioFlow),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +695,9 @@ func (s *SyncSource) NextSync(ctx context.Context, timeout time.Duration) (Frame
|
|||||||
Index: g.Index, Width: s.width, Height: s.height,
|
Index: g.Index, Width: s.width, Height: s.height,
|
||||||
Stride: s.stride, Size: g.GrainSize,
|
Stride: s.stride, Size: g.GrainSize,
|
||||||
Invalid: g.Invalid(), Payload: g.Payload,
|
Invalid: g.Invalid(), Payload: g.Payload,
|
||||||
|
Label: s.videoLabel,
|
||||||
|
FrameRateNumerator: s.rate.Num,
|
||||||
|
FrameRateDenominator: s.rate.Den,
|
||||||
}
|
}
|
||||||
s.idx++
|
s.idx++
|
||||||
|
|
||||||
@@ -680,6 +713,7 @@ func (s *SyncSource) NextSync(ctx context.Context, timeout time.Duration) (Frame
|
|||||||
aFrame := AudioFrame{
|
aFrame := AudioFrame{
|
||||||
Index: aIdx, SampleCount: audioBatch,
|
Index: aIdx, SampleCount: audioBatch,
|
||||||
Channels: s.chans, Samples: samples,
|
Channels: s.chans, Samples: samples,
|
||||||
|
Label: s.audioLabel,
|
||||||
}
|
}
|
||||||
return vFrame, aFrame, nil
|
return vFrame, aFrame, nil
|
||||||
case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly), errors.Is(err, mxl.ErrOutOfRangeLate):
|
case errors.Is(err, mxl.ErrTimeout), errors.Is(err, mxl.ErrOutOfRangeEarly), errors.Is(err, mxl.ErrOutOfRangeLate):
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"loop": true,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "timelapse",
|
||||||
|
"video": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ec"
|
||||||
|
},
|
||||||
|
"sync": true,
|
||||||
|
"duration": "10s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F1",
|
||||||
|
"video": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef"
|
||||||
|
},
|
||||||
|
"duration": "15s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Costa Rica",
|
||||||
|
"audio": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+14
-3
@@ -15,19 +15,30 @@
|
|||||||
"duration": "10s"
|
"duration": "10s"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "F1",
|
"name": "F1 Highlights",
|
||||||
"video": {
|
"video": {
|
||||||
"domain": "/dev/shm/mxl",
|
"domain": "/dev/shm/mxl",
|
||||||
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef"
|
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef"
|
||||||
},
|
},
|
||||||
"duration": "15s"
|
"audio": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197eb"
|
||||||
|
},
|
||||||
|
"sync": true,
|
||||||
|
"duration": "10s"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Costa Rica",
|
"name": "Costa Rica",
|
||||||
|
"video": {
|
||||||
|
"domain": "/dev/shm/mxl",
|
||||||
|
"uuid": "2618979d-76a5-45e0-83cb-0f192978d1cd"
|
||||||
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"domain": "/dev/shm/mxl",
|
"domain": "/dev/shm/mxl",
|
||||||
"uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b"
|
"uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b"
|
||||||
}
|
},
|
||||||
|
"sync": true,
|
||||||
|
"duration": "10s"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user