Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitry Sergeev 9bc08109fc generation-aware playback statuses 2026-09-01 18:03:18 +03:00
Dmitry Sergeev 7a19fe0dad redesign settings 2026-09-01 17:56:13 +03:00
16 changed files with 433 additions and 185 deletions
+130 -84
View File
@@ -99,6 +99,8 @@ func main() {
// timelapse audio: 5fbec3b1-1b0f-417d-9059-8b94a47197ec // timelapse audio: 5fbec3b1-1b0f-417d-9059-8b94a47197ec
// f1 video: 5fbec3b1-1b0f-417d-9059-8b94a47197ef // f1 video: 5fbec3b1-1b0f-417d-9059-8b94a47197ef
// f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb // f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb
// sync video: 2618979d-76a5-45e0-83cb-0f192978d1cd
// sync audio: 9d2a041b-01cf-4ee4-bffa-188fe093c99b
var args appArgs var args appArgs
flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError) flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError)
flagSet.SortFlags = false flagSet.SortFlags = false
@@ -200,6 +202,19 @@ func main() {
// ImGui init // ImGui init
gui := imgui.New() gui := imgui.New()
defer gui.Destroy() defer gui.Destroy()
fontConfig := cimgui.NewFontConfig()
font := gui.IO().Fonts().AddFontFromFileTTFV(
"/home/itten/Downloads/JetBrainsMono/JetBrainsMonoNLNerdFontMono-Regular.ttf",
18,
fontConfig,
nil,
)
fontConfig.Destroy()
if font == nil || font.CData == nil {
log.Fatal("failed to load ImGui font")
}
gui.IO().SetFontDefault(font)
// sdl keys handler
sdl.StartTextInput(windowHandler) sdl.StartTextInput(windowHandler)
defer sdl.StopTextInput(windowHandler) defer sdl.StopTextInput(windowHandler)
// fin on ImGui init // fin on ImGui init
@@ -421,6 +436,12 @@ func main() {
) )
lastFrame = time.Now() lastFrame = time.Now()
// ImGui
var (
settingWindowWidth float32 = 700
settingsWindowState bool = true
)
for running { for running {
frameStart := time.Now() frameStart := time.Now()
var event [128]byte var event [128]byte
@@ -447,6 +468,8 @@ func main() {
resized = true resized = true
case sdl.KeyF1: case sdl.KeyF1:
showStats = !showStats showStats = !showStats
case sdl.KeyF2:
settingsWindowState = !settingsWindowState
} }
} }
gui.ProcessEvent(&event) gui.ProcessEvent(&event)
@@ -523,8 +546,6 @@ 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))
// test widget
// cimgui.Begin("Test")
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: 200, Y: 200})
@@ -540,13 +561,9 @@ func main() {
displayedVideoHeight, displayedVideoHeight,
)) ))
} }
cimgui.Text("\nPress F1 to hide stats")
cimgui.Text("Q or Esc to quit")
cimgui.Text("F for fullscreen")
cimgui.End() cimgui.End()
} }
cimgui.Begin("Connection") // settings & info window
snapshot, hasSnapshot := player.Controller.Snapshot() snapshot, hasSnapshot := player.Controller.Snapshot()
videoConfigured := videoStr != "" videoConfigured := videoStr != ""
audioConfigured := audioStr != "" audioConfigured := audioStr != ""
@@ -556,20 +573,63 @@ func main() {
videoConfigured = snapshot.Desired.Video.IsConfigured() videoConfigured = snapshot.Desired.Video.IsConfigured()
audioConfigured = snapshot.Desired.Audio.IsConfigured() audioConfigured = snapshot.Desired.Audio.IsConfigured()
syncRequested = snapshot.Desired.SyncRequested syncRequested = snapshot.Desired.SyncRequested
cimgui.Text(fmt.Sprintf(
"Topology: %s (generation %d)",
snapshot.Plan.Topology,
snapshot.Generation,
))
} else {
cimgui.Text("Topology: starting")
} }
cimgui.Separator() drawSettingsContents := func() {
var collapsingHeaderFlags cimgui.TreeNodeFlags = cimgui.TreeNodeFlagsDefaultOpen
drawFeedsSections := func() {
cimgui.SeparatorText("Video")
cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil) cimgui.InputTextWithHint("Video domain", "/dev/shm/mxl", &videoDomainStr, 0, nil)
cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil) cimgui.InputTextWithHint("Video UUID", "", &videoStr, 0, nil)
if videoActive {
cimgui.SameLine()
if cimgui.Button("Stop##video") {
videoActive = false
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopVideo})
}
}
if !videoActive && videoConfigured {
cimgui.SameLine()
if cimgui.Button("Resume##video") {
videoActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeVideo})
}
}
if videoConfigured {
cimgui.SameLine()
if cimgui.Button("Remove##video") {
videoActive = false
videoStr = ""
enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveVideo})
}
}
cimgui.SeparatorText("Audio")
cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil) cimgui.InputTextWithHint("Audio domain", "/dev/shm/mxl", &audioDomainStr, 0, nil)
cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil) cimgui.InputTextWithHint("Audio UUID", "", &audioStr, 0, nil)
if audioActive {
cimgui.SameLine()
if cimgui.Button("Stop##audio") {
audioActive = false
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopAudio})
}
}
if !audioActive && audioConfigured {
cimgui.SameLine()
if cimgui.Button("Resume##audio") {
audioActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAudio})
}
}
if audioConfigured {
cimgui.SameLine()
if cimgui.Button("Remove##audio") {
audioActive = false
audioStr = ""
enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveAudio})
}
}
cimgui.SeparatorText("Controls")
if cimgui.Button("Apply feeds") { if cimgui.Button("Apply feeds") {
doReconnect() doReconnect()
} }
@@ -587,7 +647,7 @@ func main() {
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAll}) enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAll})
} }
} }
cimgui.SameLine()
if cimgui.Checkbox("Synchronize", &syncRequested) { if cimgui.Checkbox("Synchronize", &syncRequested) {
kind := playback.CommandDisableSync kind := playback.CommandDisableSync
if syncRequested { if syncRequested {
@@ -595,6 +655,33 @@ func main() {
} }
enqueueCommand(playback.SessionCommand{Kind: kind}) enqueueCommand(playback.SessionCommand{Kind: kind})
} }
cimgui.SeparatorText("Feeds stats")
cimgui.Checkbox("Show stats", &showStats)
}
if cimgui.CollapsingHeaderTreeNodeFlagsV("Feeds", collapsingHeaderFlags) {
drawFeedsSections()
}
drawHotkeysSection := func() {
cimgui.Text("F1 - show/hide stats")
cimgui.Text("F2 - show/hide settings")
cimgui.Text("F - toggle fullscreen")
cimgui.Text("Q or Esc - quit")
}
if cimgui.CollapsingHeaderTreeNodeFlagsV("Hotkeys", collapsingHeaderFlags) {
drawHotkeysSection()
}
drawDebugSection := func() {
if hasSnapshot {
cimgui.Text(fmt.Sprintf(
"Topology: %s (generation %d)",
snapshot.Plan.Topology,
snapshot.Generation,
))
} else {
cimgui.Text("Topology: starting")
}
if hasSnapshot && syncRequested && snapshot.Plan.Topology != playback.TopologySynchronized { if hasSnapshot && syncRequested && snapshot.Plan.Topology != playback.TopologySynchronized {
switch { switch {
case !videoConfigured || !audioConfigured: case !videoConfigured || !audioConfigured:
@@ -607,72 +694,6 @@ func main() {
cimgui.TextWrapped("Sync requested but currently unavailable. Playing independently.") cimgui.TextWrapped("Sync requested but currently unavailable. Playing independently.")
} }
} }
cimgui.Separator()
cimgui.Text("Video")
if videoActive {
if cimgui.Button("Stop video") {
videoActive = false
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopVideo})
}
}
if !videoActive && videoConfigured {
cimgui.SameLine()
if cimgui.Button("Resume video") {
videoActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeVideo})
}
}
if videoConfigured {
cimgui.SameLine()
if cimgui.Button("Remove video") {
videoActive = false
videoStr = ""
enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveVideo})
}
}
if videoActive {
cimgui.Text("Video desired: active")
} else if videoConfigured {
cimgui.Text("Video desired: stopped")
} else {
cimgui.Text("Video desired: not configured")
}
cimgui.Separator()
cimgui.Text("Audio")
if audioActive {
if cimgui.Button("Stop audio") {
audioActive = false
enqueueCommand(playback.SessionCommand{Kind: playback.CommandStopAudio})
}
}
if !audioActive && audioConfigured {
cimgui.SameLine()
if cimgui.Button("Resume audio") {
audioActive = true
enqueueCommand(playback.SessionCommand{Kind: playback.CommandResumeAudio})
}
}
if audioConfigured {
cimgui.SameLine()
if cimgui.Button("Remove audio") {
audioActive = false
audioStr = ""
enqueueCommand(playback.SessionCommand{Kind: playback.CommandRemoveAudio})
}
}
if audioActive {
cimgui.Text("Audio desired: active")
} else if audioConfigured {
cimgui.Text("Audio desired: stopped")
} else {
cimgui.Text("Audio desired: not configured")
}
cimgui.Separator() cimgui.Separator()
cimgui.Text("Current playback") cimgui.Text("Current playback")
if hasSnapshot { if hasSnapshot {
@@ -692,11 +713,36 @@ func main() {
} else { } else {
cimgui.Text("Playback controller is starting") cimgui.Text("Playback controller is starting")
} }
cimgui.Separator() cimgui.Separator()
cimgui.Checkbox("Show stats", &showStats) if videoActive {
cimgui.Text("Video desired: active")
} else if videoConfigured {
cimgui.Text("Video desired: stopped")
} else {
cimgui.Text("Video desired: not configured")
}
if audioActive {
cimgui.Text("Audio desired: active")
} else if audioConfigured {
cimgui.Text("Audio desired: stopped")
} else {
cimgui.Text("Audio desired: not configured")
}
}
if cimgui.CollapsingHeaderTreeNodeFlagsV("Debug Info", collapsingHeaderFlags) {
drawDebugSection()
}
}
if settingsWindowState {
cimgui.SetNextWindowPos(cimgui.Vec2{X: float32(r.Extent().Width) - settingWindowWidth, Y: 0})
cimgui.SetNextWindowSize(cimgui.Vec2{X: settingWindowWidth, Y: float32(r.Extent().Height)})
if cimgui.BeginV("Settings & Info", &settingsWindowState, cimgui.WindowFlagsNone) {
drawSettingsContents()
}
cimgui.End() cimgui.End()
}
gui.EndFrame() gui.EndFrame()
lastFrame = time.Now() lastFrame = time.Now()
// end of test widget // end of test widget
+24 -4
View File
@@ -1,6 +1,6 @@
[Window][Debug##Default] [Window][Debug##Default]
Pos=60,60 Pos=519,181
Size=400,400 Size=400,398
Collapsed=0 Collapsed=0
[Window][Test] [Window][Test]
@@ -14,7 +14,27 @@ Size=200,200
Collapsed=0 Collapsed=0
[Window][Connection] [Window][Connection]
Pos=322,130 Pos=42,264
Size=661,444 Size=605,416
Collapsed=0
[Window][Test slider]
Pos=1370,0
Size=550,1080
Collapsed=0
[Window][Settings]
Pos=730,0
Size=550,720
Collapsed=0
[Window][Settings & Info]
Pos=1220,0
Size=700,1080
Collapsed=0
[Window][Seetings & Info]
Pos=1220,0
Size=700,1080
Collapsed=0 Collapsed=0
+9 -8
View File
@@ -72,7 +72,8 @@ func (s *stabilityAudioSink) ConsumeAudio(
return err return err
} }
func (w *AudioWorker) emit(status Status) { func (w *AudioWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -99,7 +100,7 @@ func (w *AudioWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -108,7 +109,7 @@ func (w *AudioWorker) Run(
attemptSink := &stabilityAudioSink{ attemptSink := &stabilityAudioSink{
sink: w.sink, sink: w.sink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -134,7 +135,7 @@ func (w *AudioWorker) Run(
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -154,11 +155,11 @@ func (w *AudioWorker) Run(
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateIdle, State: StateIdle,
}) })
@@ -166,7 +167,7 @@ func (w *AudioWorker) Run(
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -176,7 +177,7 @@ func (w *AudioWorker) Run(
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitAudio, Unit: UnitAudio,
State: StateIdle, State: StateIdle,
}) })
+25
View File
@@ -116,6 +116,31 @@ func TestAudioWorkerRejectsInactiveFeed(t *testing.T) {
} }
} }
func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newAudioWorkerForTest(
t,
&queuedAudioFactory{errs: []error{openErr}},
&fakeAudioSink{},
1,
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(
withGeneration(context.Background(), 8),
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 8 {
t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status)
}
}
}
func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) { func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) {
readErr := errors.New("audio disappeared") readErr := errors.New("audio disappeared")
reader := &fakeAudioReader{ reader := &fakeAudioReader{
+11 -7
View File
@@ -109,9 +109,8 @@ func (c *SessionController) Run(
} }
desired := initial desired := initial
runtime := c.startSessionRuntime(ctx, plan)
generation := uint64(1) generation := uint64(1)
runtime := c.startSessionRuntime(ctx, plan, generation)
c.publish(SessionSnapshot{ c.publish(SessionSnapshot{
Desired: initial, Desired: initial,
Plan: plan, Plan: plan,
@@ -148,11 +147,16 @@ func (c *SessionController) Run(
continue continue
} }
nextGeneration := generation
if plan.Topology != nextPlan.Topology {
nextGeneration++
}
nextRuntime, err := c.reconcileSessionRuntime( nextRuntime, err := c.reconcileSessionRuntime(
ctx, ctx,
runtime, runtime,
plan, plan,
nextPlan, nextPlan,
nextGeneration,
) )
if err != nil { if err != nil {
stopSessionRuntime(runtime) stopSessionRuntime(runtime)
@@ -162,9 +166,7 @@ func (c *SessionController) Run(
return err return err
} }
if plan.Topology != nextPlan.Topology { generation = nextGeneration
generation++
}
desired = nextDesired desired = nextDesired
plan = nextPlan plan = nextPlan
runtime = nextRuntime runtime = nextRuntime
@@ -180,12 +182,13 @@ func (c *SessionController) Run(
func (c *SessionController) startSessionRuntime( func (c *SessionController) startSessionRuntime(
ctx context.Context, ctx context.Context,
plan SessionPlan, plan SessionPlan,
generation uint64,
) *sessionRuntime { ) *sessionRuntime {
if plan.Topology == TopologyIdle { if plan.Topology == TopologyIdle {
return &sessionRuntime{topology: TopologyIdle} return &sessionRuntime{topology: TopologyIdle}
} }
runtimeCtx, cancel := context.WithCancel(ctx) runtimeCtx, cancel := context.WithCancel(withGeneration(ctx, generation))
runtime := &sessionRuntime{ runtime := &sessionRuntime{
topology: plan.Topology, topology: plan.Topology,
cancel: cancel, cancel: cancel,
@@ -255,13 +258,14 @@ func (c *SessionController) reconcileSessionRuntime(
runtime *sessionRuntime, runtime *sessionRuntime,
current SessionPlan, current SessionPlan,
next SessionPlan, next SessionPlan,
nextGeneration uint64,
) (*sessionRuntime, error) { ) (*sessionRuntime, error) {
if current.Topology != next.Topology { if current.Topology != next.Topology {
stopSessionRuntime(runtime) stopSessionRuntime(runtime)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return runtime, err return runtime, err
} }
return c.startSessionRuntime(ctx, next), nil return c.startSessionRuntime(ctx, next, nextGeneration), nil
} }
switch next.Topology { switch next.Topology {
+16 -3
View File
@@ -90,6 +90,7 @@ func TestNewSessionControllerStoresSyncPredicate(t *testing.T) {
type controllerEvent struct { type controllerEvent struct {
unit Unit unit Unit
action string action string
generation uint64
feed FeedConfig feed FeedConfig
pair SyncPairConfig pair SyncPairConfig
} }
@@ -101,7 +102,10 @@ func (s recordingVideoSlot) Run(
initial FeedConfig, initial FeedConfig,
commands <-chan FeedConfig, commands <-chan FeedConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitVideo, action: "start", feed: initial} s.events <- controllerEvent{
unit: UnitVideo, action: "start",
generation: generationFromContext(ctx), feed: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -120,7 +124,10 @@ func (s recordingAudioSlot) Run(
initial FeedConfig, initial FeedConfig,
commands <-chan FeedConfig, commands <-chan FeedConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitAudio, action: "start", feed: initial} s.events <- controllerEvent{
unit: UnitAudio, action: "start",
generation: generationFromContext(ctx), feed: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -139,7 +146,10 @@ func (s recordingSyncSlot) Run(
initial SyncPairConfig, initial SyncPairConfig,
commands <-chan SyncPairConfig, commands <-chan SyncPairConfig,
) error { ) error {
s.events <- controllerEvent{unit: UnitSync, action: "start", pair: initial} s.events <- controllerEvent{
unit: UnitSync, action: "start",
generation: generationFromContext(ctx), pair: initial,
}
for { for {
select { select {
case config := <-commands: case config := <-commands:
@@ -291,6 +301,9 @@ func TestSessionControllerStopsIndependentSlotsBeforeStartingSync(t *testing.T)
if !stopped[UnitVideo] || !stopped[UnitAudio] { if !stopped[UnitVideo] || !stopped[UnitAudio] {
t.Fatalf("sync started before both independent slots stopped: %v", stopped) t.Fatalf("sync started before both independent slots stopped: %v", stopped)
} }
if event.generation != 2 {
t.Fatalf("sync runtime generation = %d, want 2", event.generation)
}
break break
} }
if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) { if event.action != "stop" || (event.unit != UnitVideo && event.unit != UnitAudio) {
+13
View File
@@ -1,6 +1,7 @@
package playback package playback
import ( import (
"context"
"fmt" "fmt"
"time" "time"
) )
@@ -27,6 +28,7 @@ const (
type Status struct { type Status struct {
Unit Unit Unit Unit
State State State State
Generation uint64
Attempt int Attempt int
FailedAttempts int FailedAttempts int
RetryIn time.Duration RetryIn time.Duration
@@ -35,6 +37,17 @@ type Status struct {
type StatusObserver func(Status) type StatusObserver func(Status)
type generationContextKey struct{}
func withGeneration(ctx context.Context, generation uint64) context.Context {
return context.WithValue(ctx, generationContextKey{}, generation)
}
func generationFromContext(ctx context.Context) uint64 {
generation, _ := ctx.Value(generationContextKey{}).(uint64)
return generation
}
func (u Unit) String() string { func (u Unit) String() string {
switch u { switch u {
case UnitVideo: case UnitVideo:
+11
View File
@@ -1,11 +1,22 @@
package playback package playback
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"time" "time"
) )
func TestGenerationContext(t *testing.T) {
if got := generationFromContext(context.Background()); got != 0 {
t.Fatalf("background generation = %d, want 0", got)
}
ctx := withGeneration(context.Background(), 42)
if got := generationFromContext(ctx); got != 42 {
t.Fatalf("generation = %d, want 42", got)
}
}
func TestStatusPreservesValues(t *testing.T) { func TestStatusPreservesValues(t *testing.T) {
wantErr := errors.New("producer missing") wantErr := errors.New("producer missing")
status := Status{ status := Status{
+9 -1
View File
@@ -4,6 +4,7 @@ import "sync"
type StatusStore struct { type StatusStore struct {
mu sync.RWMutex mu sync.RWMutex
generation uint64
statuses map[Unit]Status statuses map[Unit]Status
} }
@@ -15,8 +16,15 @@ func NewStatusStore() *StatusStore {
func (s *StatusStore) Observe(status Status) { func (s *StatusStore) Observe(status Status) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock()
if status.Generation < s.generation {
return
}
if status.Generation > s.generation {
clear(s.statuses)
s.generation = status.Generation
}
s.statuses[status.Unit] = status s.statuses[status.Unit] = status
s.mu.Unlock()
} }
func (s *StatusStore) Snapshot(unit Unit) (Status, bool) { func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
+47
View File
@@ -106,3 +106,50 @@ func TestStatusStoreConcurrentAccess(t *testing.T) {
} }
} }
} }
func TestStatusStoreNewGenerationClearsPreviousUnits(t *testing.T) {
store := NewStatusStore()
store.Observe(Status{Unit: UnitVideo, State: StatePlaying, Generation: 1})
store.Observe(Status{Unit: UnitAudio, State: StatePlaying, Generation: 1})
want := Status{Unit: UnitSync, State: StateConnecting, Generation: 2}
store.Observe(want)
if _, ok := store.Snapshot(UnitVideo); ok {
t.Fatal("video status survived generation change")
}
if _, ok := store.Snapshot(UnitAudio); ok {
t.Fatal("audio status survived generation change")
}
if got, ok := store.Snapshot(UnitSync); !ok || got != want {
t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want)
}
}
func TestStatusStoreIgnoresOlderGeneration(t *testing.T) {
store := NewStatusStore()
want := Status{Unit: UnitSync, State: StatePlaying, Generation: 3}
store.Observe(want)
store.Observe(Status{Unit: UnitVideo, State: StateIdle, Generation: 2})
if _, ok := store.Snapshot(UnitVideo); ok {
t.Fatal("older video status was stored")
}
if got, ok := store.Snapshot(UnitSync); !ok || got != want {
t.Fatalf("sync Snapshot() = %#v, %t; want %#v, true", got, ok, want)
}
}
func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) {
store := NewStatusStore()
wantVideo := Status{Unit: UnitVideo, State: StatePlaying, Generation: 4}
wantAudio := Status{Unit: UnitAudio, State: StateReconnecting, Generation: 4}
store.Observe(wantVideo)
store.Observe(wantAudio)
if got, ok := store.Snapshot(UnitVideo); !ok || got != wantVideo {
t.Fatalf("video Snapshot() = %#v, %t", got, ok)
}
if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio {
t.Fatalf("audio Snapshot() = %#v, %t", got, ok)
}
}
+9 -8
View File
@@ -59,7 +59,8 @@ func NewSyncWorker(
}, nil }, nil
} }
func (w *SyncWorker) emit(status Status) { func (w *SyncWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -90,7 +91,7 @@ func (w *SyncWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -99,7 +100,7 @@ func (w *SyncWorker) Run(
attemptAudioSink := &stabilityAudioSink{ attemptAudioSink := &stabilityAudioSink{
sink: w.audioSink, sink: w.audioSink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -133,7 +134,7 @@ func (w *SyncWorker) Run(
if !event.WillRetry { if !event.WillRetry {
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -151,18 +152,18 @@ func (w *SyncWorker) Run(
observeRetry, observeRetry,
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateIdle, State: StateIdle,
}) })
return ctx.Err() return ctx.Err()
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -171,7 +172,7 @@ func (w *SyncWorker) Run(
}) })
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitSync, Unit: UnitSync,
State: StateIdle, State: StateIdle,
}) })
+23
View File
@@ -126,6 +126,29 @@ func TestSyncWorkerRejectsInvalidOrInactiveFeeds(t *testing.T) {
} }
} }
func TestSyncWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newTestSyncWorker(
t,
&scriptedSyncFactory{results: []syncOpenResult{{err: openErr}}},
&fakeVideoSink{},
&fakeAudioSink{},
1,
func(status Status) { statuses = append(statuses, status) },
)
video, audio := activeSyncConfigs()
_ = worker.Run(withGeneration(context.Background(), 9), video, audio)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 9 {
t.Fatalf("status generation = %d, want 9: %+v", status.Generation, status)
}
}
}
func TestSyncWorkerExhaustsOpenRetries(t *testing.T) { func TestSyncWorkerExhaustsOpenRetries(t *testing.T) {
openErr := errors.New("sync producer unavailable") openErr := errors.New("sync producer unavailable")
factory := &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}, {err: openErr}}} factory := &scriptedSyncFactory{results: []syncOpenResult{{err: openErr}, {err: openErr}}}
+9 -8
View File
@@ -72,7 +72,8 @@ func (s *stabilityVideoSink) ConsumeVideo(
return err return err
} }
func (w *VideoWorker) emit(status Status) { func (w *VideoWorker) emit(ctx context.Context, status Status) {
status.Generation = generationFromContext(ctx)
if w.observer != nil { if w.observer != nil {
w.observer(status) w.observer(status)
} }
@@ -99,7 +100,7 @@ func (w *VideoWorker) Run(
if attemptNumber > 1 { if attemptNumber > 1 {
state = StateReconnecting state = StateReconnecting
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: state, State: state,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -108,7 +109,7 @@ func (w *VideoWorker) Run(
attemptSink := &stabilityVideoSink{ attemptSink := &stabilityVideoSink{
sink: w.sink, sink: w.sink,
onStable: func() { onStable: func() {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StatePlaying, State: StatePlaying,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -134,7 +135,7 @@ func (w *VideoWorker) Run(
return return
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateReconnecting, State: StateReconnecting,
Attempt: attemptNumber + 1, Attempt: attemptNumber + 1,
@@ -154,11 +155,11 @@ func (w *VideoWorker) Run(
) )
if ctx.Err() != nil { if ctx.Err() != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateStopping, State: StateStopping,
}) })
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateIdle, State: StateIdle,
}) })
@@ -166,7 +167,7 @@ func (w *VideoWorker) Run(
} }
if err != nil { if err != nil {
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateFailed, State: StateFailed,
Attempt: attemptNumber, Attempt: attemptNumber,
@@ -176,7 +177,7 @@ func (w *VideoWorker) Run(
return err return err
} }
w.emit(Status{ w.emit(ctx, Status{
Unit: UnitVideo, Unit: UnitVideo,
State: StateIdle, State: StateIdle,
}) })
+22
View File
@@ -151,6 +151,28 @@ func TestVideoWorkerRejectsInactiveFeed(t *testing.T) {
} }
} }
func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
openErr := errors.New("unavailable")
var statuses []Status
worker := newTestVideoWorker(
t,
&scriptedVideoFactory{results: []videoOpenResult{{err: openErr}}},
&fakeVideoSink{},
1,
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(withGeneration(context.Background(), 7), activeVideoConfig())
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
for _, status := range statuses {
if status.Generation != 7 {
t.Fatalf("status generation = %d, want 7: %+v", status.Generation, status)
}
}
}
func TestVideoWorkerExhaustsOpenRetries(t *testing.T) { func TestVideoWorkerExhaustsOpenRetries(t *testing.T) {
openErr := errors.New("producer unavailable") openErr := errors.New("producer unavailable")
factory := &scriptedVideoFactory{ factory := &scriptedVideoFactory{
+1
View File
@@ -32,6 +32,7 @@ const (
KeyF uint32 = 0x66 KeyF uint32 = 0x66
KeyQ uint32 = 0x71 KeyQ uint32 = 0x71
KeyF1 uint32 = 0x4000003A KeyF1 uint32 = 0x4000003A
KeyF2 uint32 = 0x4000003B
InitAudio uint32 = 0x00000010 InitAudio uint32 = 0x00000010
AudioDeviceDefaultPlayback uint32 = 0xFFFFFFFF AudioDeviceDefaultPlayback uint32 = 0xFFFFFFFF
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
VIDEO_ID="2618979d-76a5-45e0-83cb-0f192978d1cd"
AUDIO_ID="9d2a041b-01cf-4ee4-bffa-188fe093c99b"
VIDEO_URI=$1
if [[ -z "${VIDEO_URI}" ]] then
VIDEO_URI="${HOME}/Videos/test-vid/motogp.ts"
fi
export GST_PLUGIN_PATH="${HOME}/.gst-plugin:${GST_PLUGIN_PATH}"
mxl-gst-looping-filesrc -d /dev/shm/mxl -i "${VIDEO_URI}" --video-id "${VIDEO_ID}" --audio-id "${AUDIO_ID}" 2>/dev/null
# sleep 5
# kill -9 $(pidof "mxl-gst-looping-filesrc")
# echo -e "\ngst-looping-filesrc killed"