Compare commits
15 Commits
04e7669e4b
...
5d1a9a3218
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d1a9a3218 | |||
| d4041af119 | |||
| bf062afd16 | |||
| 2fd320fe66 | |||
| 58aa4661bd | |||
| 0c4bacb798 | |||
| 743e1680c1 | |||
| 9df21828e9 | |||
| 1571e2700c | |||
| 461793b181 | |||
| 5284919d47 | |||
| f030bfa3b7 | |||
| b8801e793d | |||
| 1755fc1052 | |||
| e4e8da2568 |
@@ -1 +1,2 @@
|
|||||||
build
|
build
|
||||||
|
imgui.ini
|
||||||
|
|||||||
+266
-98
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
mxladapter "mxl-player/internal/adapter/mxl"
|
||||||
"mxl-player/internal/imgui"
|
"mxl-player/internal/imgui"
|
||||||
"mxl-player/internal/playback"
|
"mxl-player/internal/playback"
|
||||||
"mxl-player/internal/renderer"
|
"mxl-player/internal/renderer"
|
||||||
@@ -28,6 +29,17 @@ const (
|
|||||||
WIN_HEIGHT int32 = 720
|
WIN_HEIGHT int32 = 720
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
placeholderWidth uint32 = 1
|
||||||
|
placeholderHeight uint32 = 1
|
||||||
|
placeholderStride uint32 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
initialRetryDelay = 500 * time.Millisecond
|
||||||
|
maxRetryDelay = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type appArgs struct {
|
type appArgs struct {
|
||||||
ShowHelp bool
|
ShowHelp bool
|
||||||
Domain string
|
Domain string
|
||||||
@@ -47,7 +59,7 @@ type appArgs struct {
|
|||||||
|
|
||||||
func printCliHelp(fs *pflag.FlagSet) {
|
func printCliHelp(fs *pflag.FlagSet) {
|
||||||
fmt.Printf("%s %s\n", APP_NAME, APP_VER)
|
fmt.Printf("%s %s\n", APP_NAME, APP_VER)
|
||||||
fmt.Println("Usage: mxl-player -d <domain> (-v <uuid> | -a <uuid>) [-f] [-h]")
|
fmt.Println("Usage: mxl-player [-d <domain>] [-v <uuid>] [-a <uuid>] [options]")
|
||||||
fmt.Println(" [-g <gpu-id>] [-p <playback-id>] [--verbose]")
|
fmt.Println(" [-g <gpu-id>] [-p <playback-id>] [--verbose]")
|
||||||
fmt.Println(" or: mxl-player [--list-playback] [--list-gpu]")
|
fmt.Println(" or: mxl-player [--list-playback] [--list-gpu]")
|
||||||
fmt.Println(" or: mxl-player (and set everything in GUI)")
|
fmt.Println(" or: mxl-player (and set everything in GUI)")
|
||||||
@@ -56,13 +68,20 @@ func printCliHelp(fs *pflag.FlagSet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printUsage(w io.Writer) {
|
func printUsage(w io.Writer) {
|
||||||
fmt.Fprintln(w, "Usage: mxl-player -d <domain> (-v <uuid> | -a <uuid>) [-f] [-h]")
|
fmt.Fprintln(w, "Usage: mxl-player [-d <domain>] [-v <uuid>] [-a <uuid>] [options]")
|
||||||
fmt.Fprintln(w, "Try 'mxl-player -h' for more information.")
|
fmt.Fprintln(w, "Try 'mxl-player -h' for more information.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMXLargs(args appArgs) {
|
func checkMXLargs(args appArgs) {
|
||||||
|
if args.VideoFlowId == "" && args.AudioFlowId == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if args.Domain == "" {
|
if args.Domain == "" {
|
||||||
fmt.Fprintln(os.Stderr, "You should provide valid MXL domain and UUID of at least one flow")
|
fmt.Fprintln(
|
||||||
|
os.Stderr,
|
||||||
|
"You must provide a domain when a feed UUID is configured",
|
||||||
|
)
|
||||||
printUsage(os.Stderr)
|
printUsage(os.Stderr)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
@@ -74,11 +93,6 @@ func checkMXLargs(args appArgs) {
|
|||||||
printUsage(os.Stderr)
|
printUsage(os.Stderr)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
if args.VideoFlowId == "" && args.AudioFlowId == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "You must provide at least 1 MXL flow UUID")
|
|
||||||
printUsage(os.Stderr)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -88,11 +102,18 @@ func main() {
|
|||||||
// f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb
|
// f1 audio: 5fbec3b1-1b0f-417d-9059-8b94a47197eb
|
||||||
var args appArgs
|
var args appArgs
|
||||||
flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError)
|
flagSet := pflag.NewFlagSet(APP_NAME, pflag.ContinueOnError)
|
||||||
|
flagSet.SortFlags = false
|
||||||
flagSet.Usage = func() { printUsage(os.Stderr) }
|
flagSet.Usage = func() { printUsage(os.Stderr) }
|
||||||
flagSet.BoolVarP(&args.ShowHelp, "help", "h", false, "Show help message and exit")
|
flagSet.BoolVarP(&args.ShowHelp, "help", "h", false, "Show help message and exit")
|
||||||
flagSet.StringVarP(&args.Domain, "domain", "d", "", "MXL domain directory")
|
flagSet.StringVarP(&args.Domain, "domain", "d", "", "MXL domain directory")
|
||||||
flagSet.StringVarP(&args.VideoFlowId, "video", "v", "", "Video flow UUID")
|
flagSet.StringVarP(&args.VideoFlowId, "video", "v", "", "Video flow UUID")
|
||||||
flagSet.StringVarP(&args.AudioFlowId, "audio", "a", "", "Audio flow UUID")
|
flagSet.StringVarP(&args.AudioFlowId, "audio", "a", "", "Audio flow UUID")
|
||||||
|
flagSet.IntVar(
|
||||||
|
&args.MaxAttempts,
|
||||||
|
"max-attempts",
|
||||||
|
0,
|
||||||
|
"Maximum connection attempts per playback lifecycle; 0 retries indefinitely",
|
||||||
|
)
|
||||||
flagSet.BoolVarP(&args.IsFullscreen, "fullscreen", "f", false, "Run app in fullscreen mode")
|
flagSet.BoolVarP(&args.IsFullscreen, "fullscreen", "f", false, "Run app in fullscreen mode")
|
||||||
flagSet.Uint32VarP(&args.GpuId, "gpu-id", "g", 0, "GPU id [TODO]")
|
flagSet.Uint32VarP(&args.GpuId, "gpu-id", "g", 0, "GPU id [TODO]")
|
||||||
flagSet.Uint32VarP(&args.PlaybackId, "playback-id", "p", 0, "Playback audio device id")
|
flagSet.Uint32VarP(&args.PlaybackId, "playback-id", "p", 0, "Playback audio device id")
|
||||||
@@ -109,10 +130,25 @@ func main() {
|
|||||||
printCliHelp(flagSet)
|
printCliHelp(flagSet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if args.MaxAttempts < 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "--max-attempts cannot be negative")
|
||||||
|
printUsage(os.Stderr)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
retryPolicy := playback.RetryPolicy{
|
||||||
|
MaxAttempts: args.MaxAttempts,
|
||||||
|
InitialDelay: initialRetryDelay,
|
||||||
|
MaxDelay: maxRetryDelay,
|
||||||
|
}
|
||||||
|
if err := retryPolicy.Validate(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "invalid retry configuration:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
if !args.ListAudio && !args.ListGPU {
|
if !args.ListAudio && !args.ListGPU {
|
||||||
checkMXLargs(args)
|
checkMXLargs(args)
|
||||||
}
|
}
|
||||||
// end of cli args parse
|
// path selection
|
||||||
|
useVideoSlot := args.AudioFlowId == ""
|
||||||
|
|
||||||
runtime.LockOSThread()
|
runtime.LockOSThread()
|
||||||
if err := sdl.Load(); err != nil {
|
if err := sdl.Load(); err != nil {
|
||||||
@@ -265,13 +301,8 @@ func main() {
|
|||||||
fmt.Printf("sync: video %dx%d audio %dch batch=%d\n",
|
fmt.Printf("sync: video %dx%d audio %dch batch=%d\n",
|
||||||
syncSrc.Width(), syncSrc.Height(), aChans, audioBatch)
|
syncSrc.Width(), syncSrc.Height(), aChans, audioBatch)
|
||||||
case args.VideoFlowId != "":
|
case args.VideoFlowId != "":
|
||||||
videoSrc, err = source.Open(args.Domain, args.VideoFlowId)
|
// VideoSlot owns opening and closing the video reader.
|
||||||
if err != nil {
|
case args.AudioFlowId != "":
|
||||||
log.Fatalf("source: %v", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("video: %dx%d stride=%d\n", videoSrc.Width(), videoSrc.Height(), videoSrc.Stride())
|
|
||||||
|
|
||||||
default:
|
|
||||||
audioSrc, err = source.OpenAudio(args.Domain, args.AudioFlowId)
|
audioSrc, err = source.OpenAudio(args.Domain, args.AudioFlowId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("audio source: %v", err)
|
log.Fatalf("audio source: %v", err)
|
||||||
@@ -291,6 +322,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
sdl.ResumeAudioStreamDevice(audioStream)
|
sdl.ResumeAudioStreamDevice(audioStream)
|
||||||
fmt.Printf("audio: %dch %d/%d Hz\n", aChans, audioSrc.Rate().Num, audioSrc.Rate().Den)
|
fmt.Printf("audio: %dch %d/%d Hz\n", aChans, audioSrc.Rate().Num, audioSrc.Rate().Den)
|
||||||
|
default:
|
||||||
|
// No configured feeds. Renderer and GUI use the placeholder.
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -308,31 +341,30 @@ func main() {
|
|||||||
defer sdl.DestroyAudioStream(audioStream)
|
defer sdl.DestroyAudioStream(audioStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
var r *renderer.Renderer
|
// Create renderer
|
||||||
if args.VideoFlowId != "" {
|
r, err := renderer.New(renderer.Config{
|
||||||
var w, h, stride uint32
|
|
||||||
if syncSrc != nil {
|
|
||||||
w, h, stride = syncSrc.Width(), syncSrc.Height(), syncSrc.Stride()
|
|
||||||
} else {
|
|
||||||
w, h, stride = videoSrc.Width(), videoSrc.Height(), videoSrc.Stride()
|
|
||||||
}
|
|
||||||
r, err = renderer.New(renderer.Config{
|
|
||||||
PhysDevice: vkPhysDevice,
|
PhysDevice: vkPhysDevice,
|
||||||
Device: vkDevice,
|
Device: vkDevice,
|
||||||
Queue: vkQueue,
|
Queue: vkQueue,
|
||||||
Surface: vkSurf,
|
Surface: vkSurf,
|
||||||
Window: windowHandler,
|
Window: windowHandler,
|
||||||
GraphicsFamily: gfx,
|
GraphicsFamily: gfx,
|
||||||
VideoWidth: w,
|
VideoWidth: placeholderWidth,
|
||||||
VideoHeight: h,
|
VideoHeight: placeholderHeight,
|
||||||
VideoStride: stride,
|
VideoStride: placeholderStride,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer r.Destroy()
|
defer r.Destroy()
|
||||||
|
// Create GUI backend
|
||||||
guiBackend, err := imgui.NewVulkanBackend(
|
guiBackend, err := imgui.NewVulkanBackend(
|
||||||
vkPhysDevice, vkDevice, vkQueue, r.CmdPool(), r.RenderPass())
|
vkPhysDevice,
|
||||||
|
vkDevice,
|
||||||
|
vkQueue,
|
||||||
|
r.CmdPool(),
|
||||||
|
r.RenderPass(),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -340,8 +372,15 @@ func main() {
|
|||||||
r.ImGuiDraw = func(cmd vk.CommandBuffer) {
|
r.ImGuiDraw = func(cmd vk.CommandBuffer) {
|
||||||
guiBackend.RecordDraw(cmd, gui.LastDrawData())
|
guiBackend.RecordDraw(cmd, gui.LastDrawData())
|
||||||
}
|
}
|
||||||
defer vkDevice.WaitIdle()
|
if err := r.StageFrame(
|
||||||
|
[]byte{0, 0, 0, 255},
|
||||||
|
placeholderWidth,
|
||||||
|
placeholderHeight,
|
||||||
|
placeholderStride,
|
||||||
|
); err != nil {
|
||||||
|
panic(err)
|
||||||
}
|
}
|
||||||
|
defer vkDevice.WaitIdle()
|
||||||
|
|
||||||
// GUI state (accessible from doReconnect + goroutine)
|
// GUI state (accessible from doReconnect + goroutine)
|
||||||
var (
|
var (
|
||||||
@@ -350,6 +389,7 @@ func main() {
|
|||||||
audioStr string = args.AudioFlowId
|
audioStr string = args.AudioFlowId
|
||||||
showStats bool = true
|
showStats bool = true
|
||||||
)
|
)
|
||||||
|
videoActive := args.VideoFlowId != ""
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -358,9 +398,44 @@ func main() {
|
|||||||
video string
|
video string
|
||||||
audio string
|
audio string
|
||||||
}
|
}
|
||||||
// One control channel: grant (empty params) or reconnect (with params).
|
// Reconnect requests from GUI or automatic retry
|
||||||
control := make(chan reconnectParams, 1)
|
control := make(chan reconnectParams, 1)
|
||||||
videoBridge := playback.NewVideoBridge()
|
videoBridge := playback.NewVideoBridge()
|
||||||
|
statusStore := playback.NewStatusStore()
|
||||||
|
|
||||||
|
videoWorker, err := playback.NewVideoWorker(
|
||||||
|
mxladapter.VideoFactory{},
|
||||||
|
videoBridge,
|
||||||
|
retryPolicy,
|
||||||
|
mxladapter.ShouldRetry,
|
||||||
|
func(status playback.Status) {
|
||||||
|
statusStore.Observe(status)
|
||||||
|
if status.Err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"video: state=%v attempt=%d failed=%d: %v",
|
||||||
|
status.State,
|
||||||
|
status.Attempt,
|
||||||
|
status.FailedAttempts,
|
||||||
|
status.Err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf(
|
||||||
|
"video: state=%v attempt=%d failed=%d",
|
||||||
|
status.State,
|
||||||
|
status.Attempt,
|
||||||
|
status.FailedAttempts,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
videoSlot, err := playback.NewVideoSlot(videoWorker)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
videoCommands := make(chan playback.FeedConfig, 1)
|
||||||
|
|
||||||
reopen := func(params reconnectParams) error {
|
reopen := func(params reconnectParams) error {
|
||||||
// Close current sources
|
// Close current sources
|
||||||
@@ -377,18 +452,10 @@ func main() {
|
|||||||
audioSrc = nil
|
audioSrc = nil
|
||||||
}
|
}
|
||||||
// Try once. Return error if fails — caller loops back to select
|
// Try once. Return error if fails — caller loops back to select
|
||||||
// and can pick up new reconnect params or a new grant.
|
// and can pick up newer reconnect request.
|
||||||
if params.video != "" && params.audio != "" {
|
if params.video != "" && params.audio != "" {
|
||||||
s, e := source.OpenSameDomainSync(params.domain, params.video, params.audio)
|
s, e := source.OpenSameDomainSync(params.domain, params.video, params.audio)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
if r != nil {
|
|
||||||
newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height())
|
|
||||||
if newSize != r.FrameSize() {
|
|
||||||
if e = r.RecreateBuffers(newSize); e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncSrc = s
|
syncSrc = s
|
||||||
aChans = s.Channels()
|
aChans = s.Channels()
|
||||||
audioBatch = uint64(s.AudioRate().Num) / uint64(s.Rate().Num)
|
audioBatch = uint64(s.AudioRate().Num) / uint64(s.Rate().Num)
|
||||||
@@ -401,14 +468,6 @@ func main() {
|
|||||||
} else if params.video != "" {
|
} else if params.video != "" {
|
||||||
s, e := source.Open(params.domain, params.video)
|
s, e := source.Open(params.domain, params.video)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
if r != nil {
|
|
||||||
newSize := vk.DeviceSize(s.Stride()) * vk.DeviceSize(s.Height())
|
|
||||||
if newSize != r.FrameSize() {
|
|
||||||
if e = r.RecreateBuffers(newSize); e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
videoSrc = s
|
videoSrc = s
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -429,7 +488,34 @@ func main() {
|
|||||||
return fmt.Errorf("reopen: no flow specified")
|
return fmt.Errorf("reopen: no flow specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enqueueVideoConfig := func(config playback.FeedConfig) {
|
||||||
|
select {
|
||||||
|
case <-videoCommands:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case videoCommands <- config:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
doReconnect := func() {
|
doReconnect := func() {
|
||||||
|
if useVideoSlot {
|
||||||
|
videoActive = videoStr != ""
|
||||||
|
|
||||||
|
config := playback.FeedConfig{}
|
||||||
|
if videoStr != "" {
|
||||||
|
config = playback.FeedConfig{
|
||||||
|
Domain: domainStr,
|
||||||
|
UUID: videoStr,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueVideoConfig(config)
|
||||||
|
return
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-control:
|
case <-control:
|
||||||
default:
|
default:
|
||||||
@@ -437,8 +523,27 @@ func main() {
|
|||||||
control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}
|
control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playbackDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
// Audio-only mode: independent loop, no grant/staged handshake.
|
defer close(playbackDone)
|
||||||
|
|
||||||
|
if useVideoSlot {
|
||||||
|
err := videoSlot.Run(
|
||||||
|
ctx,
|
||||||
|
playback.FeedConfig{
|
||||||
|
Domain: args.Domain,
|
||||||
|
UUID: args.VideoFlowId,
|
||||||
|
Active: args.VideoFlowId != "",
|
||||||
|
},
|
||||||
|
videoCommands,
|
||||||
|
)
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Printf("video slot: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio-only mode: independent loop.
|
||||||
if audioSrc != nil && syncSrc == nil && videoSrc == nil {
|
if audioSrc != nil && syncSrc == nil && videoSrc == nil {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -503,31 +608,34 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video (with or without sync) mode: grant/staged handshake.
|
// Video bridge provides backpressure: only one borrowed frame is in flight.
|
||||||
for {
|
for {
|
||||||
params := <-control
|
|
||||||
if params.video != "" || params.audio != "" {
|
|
||||||
// Reconnect request from Connect button or auto-retry.
|
|
||||||
select {
|
select {
|
||||||
case <-control: // drain any pending grant
|
case <-ctx.Done():
|
||||||
default:
|
return
|
||||||
}
|
|
||||||
|
case params := <-control:
|
||||||
if rerr := reopen(params); rerr != nil {
|
if rerr := reopen(params); rerr != nil {
|
||||||
if errors.Is(rerr, context.Canceled) {
|
if errors.Is(rerr, context.Canceled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("source: reopen failed: %v, retrying", rerr)
|
log.Printf("source: reopen failed: %v, retrying", rerr)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case control <- params:
|
case control <- params:
|
||||||
default:
|
default:
|
||||||
|
// Preserve an already queued, potentially newer request.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
var videoFrame playback.VideoFrame
|
var videoFrame playback.VideoFrame
|
||||||
@@ -538,15 +646,16 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("source: %v", err)
|
log.Printf("source: %v", err)
|
||||||
// Drain any pending grant, then send reconnect.
|
// Request a reconnect after the read failure.
|
||||||
select {
|
params := reconnectParams{
|
||||||
case <-control:
|
domain: domainStr,
|
||||||
default:
|
video: videoStr,
|
||||||
|
audio: audioStr,
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}:
|
case control <- params:
|
||||||
case <-ctx.Done():
|
default:
|
||||||
return
|
// Preserve an already queued, potentially newer request.
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -570,14 +679,15 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("source: %v", err)
|
log.Printf("source: %v", err)
|
||||||
select {
|
params := reconnectParams{
|
||||||
case <-control:
|
domain: domainStr,
|
||||||
default:
|
video: videoStr,
|
||||||
|
audio: audioStr,
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case control <- reconnectParams{domain: domainStr, video: videoStr, audio: audioStr}:
|
case control <- params:
|
||||||
case <-ctx.Done():
|
default:
|
||||||
return
|
// Preserve an already queued, potentially newer request.
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -604,12 +714,16 @@ func main() {
|
|||||||
|
|
||||||
running := true
|
running := true
|
||||||
resized := false
|
resized := false
|
||||||
granted := false
|
|
||||||
fullscreen := args.IsFullscreen
|
fullscreen := args.IsFullscreen
|
||||||
if fullscreen {
|
if fullscreen {
|
||||||
sdl.SetWindowFullscreen(windowHandler, true)
|
sdl.SetWindowFullscreen(windowHandler, true)
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
|
displayedVideoWidth uint32 = placeholderWidth
|
||||||
|
displayedVideoHeight uint32 = placeholderHeight
|
||||||
|
displayedVideoStride uint32 = placeholderStride
|
||||||
|
hasDisplayedVideo bool = false
|
||||||
|
|
||||||
fps float64
|
fps float64
|
||||||
lastIndex uint64
|
lastIndex uint64
|
||||||
dropped uint64
|
dropped uint64
|
||||||
@@ -662,15 +776,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
resized = false
|
resized = false
|
||||||
}
|
}
|
||||||
if !granted {
|
|
||||||
select {
|
|
||||||
case control <- reconnectParams{}:
|
|
||||||
granted = true
|
|
||||||
case <-ctx.Done():
|
|
||||||
running = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var shownIndex uint64
|
var shownIndex uint64
|
||||||
hasFrame := false
|
hasFrame := false
|
||||||
|
|
||||||
@@ -697,15 +802,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shownIndex = pendingFrame.Frame.Index
|
shownIndex = pendingFrame.Frame.Index
|
||||||
granted = false
|
displayedVideoWidth = pendingFrame.Frame.Width
|
||||||
|
displayedVideoHeight = pendingFrame.Frame.Height
|
||||||
|
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) &&
|
||||||
!errors.Is(frameErr, context.Canceled) {
|
!errors.Is(frameErr, context.Canceled) {
|
||||||
panic(frameErr)
|
panic(frameErr)
|
||||||
} else {
|
|
||||||
// No frame arrived before the deadline.
|
|
||||||
granted = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stats
|
// stats
|
||||||
@@ -740,14 +845,12 @@ func main() {
|
|||||||
cimgui.Text(fmt.Sprintf("FPS: %.1f", fps))
|
cimgui.Text(fmt.Sprintf("FPS: %.1f", fps))
|
||||||
cimgui.Text(fmt.Sprintf("Dropped: %d", dropped))
|
cimgui.Text(fmt.Sprintf("Dropped: %d", dropped))
|
||||||
cimgui.Text(fmt.Sprintf("Index: %d", shownIndex))
|
cimgui.Text(fmt.Sprintf("Index: %d", shownIndex))
|
||||||
if videoSrc != nil {
|
if hasDisplayedVideo {
|
||||||
cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", videoSrc.Width(), videoSrc.Height(),
|
cimgui.Text(fmt.Sprintf(
|
||||||
float32(videoSrc.Rate().Num/videoSrc.Rate().Den)))
|
"Video: %dx%d",
|
||||||
}
|
displayedVideoWidth,
|
||||||
if syncSrc != nil {
|
displayedVideoHeight,
|
||||||
cimgui.Text(fmt.Sprintf("Video: %dx%d %.2fp", syncSrc.Width(), syncSrc.Height(),
|
))
|
||||||
float32(syncSrc.Rate().Num/syncSrc.Rate().Den)))
|
|
||||||
cimgui.Text(fmt.Sprintf("Audio: %dch %dkHz", syncSrc.Channels(), syncSrc.AudioRate().Num))
|
|
||||||
}
|
}
|
||||||
cimgui.Text("\nPress F1 to hide stats")
|
cimgui.Text("\nPress F1 to hide stats")
|
||||||
cimgui.Text("Q or Esc to quit")
|
cimgui.Text("Q or Esc to quit")
|
||||||
@@ -762,17 +865,79 @@ func main() {
|
|||||||
if cimgui.Button("Connect") {
|
if cimgui.Button("Connect") {
|
||||||
doReconnect()
|
doReconnect()
|
||||||
}
|
}
|
||||||
|
if useVideoSlot && videoActive {
|
||||||
|
cimgui.SameLine()
|
||||||
|
if cimgui.Button("Stop video") {
|
||||||
|
videoActive = false
|
||||||
|
enqueueVideoConfig(
|
||||||
|
playback.FeedConfig{
|
||||||
|
Domain: domainStr,
|
||||||
|
UUID: videoStr,
|
||||||
|
Active: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if useVideoSlot && !videoActive && videoStr != "" {
|
||||||
|
cimgui.SameLine()
|
||||||
|
if cimgui.Button("Resume video") {
|
||||||
|
videoActive = true
|
||||||
|
enqueueVideoConfig(playback.FeedConfig{
|
||||||
|
Domain: domainStr,
|
||||||
|
UUID: videoStr,
|
||||||
|
Active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if useVideoSlot && videoStr != "" {
|
||||||
|
if cimgui.Button("Remove video") {
|
||||||
|
videoActive = false
|
||||||
|
videoStr = ""
|
||||||
|
enqueueVideoConfig(playback.FeedConfig{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if useVideoSlot {
|
||||||
|
if videoActive {
|
||||||
|
cimgui.Text("Video desired: active")
|
||||||
|
} else if videoStr != "" {
|
||||||
|
cimgui.Text("Video desired: stopped")
|
||||||
|
} else {
|
||||||
|
cimgui.Text("Video desired: not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, ok := statusStore.Snapshot(playback.UnitVideo); ok {
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Video actual: %s",
|
||||||
|
status.State,
|
||||||
|
))
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Attempt: %d, failed: %d",
|
||||||
|
status.Attempt,
|
||||||
|
status.FailedAttempts,
|
||||||
|
))
|
||||||
|
|
||||||
|
if status.RetryIn > 0 {
|
||||||
|
cimgui.Text(fmt.Sprintf(
|
||||||
|
"Retry in: %s",
|
||||||
|
status.RetryIn.Round(time.Millisecond),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Err != nil {
|
||||||
|
cimgui.TextWrapped(status.Err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cimgui.Text("Video actual: not started")
|
||||||
|
}
|
||||||
|
}
|
||||||
cimgui.End()
|
cimgui.End()
|
||||||
gui.EndFrame()
|
gui.EndFrame()
|
||||||
lastFrame = time.Now()
|
lastFrame = time.Now()
|
||||||
// end of test widget
|
// end of test widget
|
||||||
var w, h, stride uint32
|
err := r.DrawFrame(
|
||||||
if syncSrc != nil {
|
displayedVideoWidth,
|
||||||
w, h, stride = syncSrc.Width(), syncSrc.Height(), syncSrc.Stride()
|
displayedVideoHeight,
|
||||||
} else if videoSrc != nil {
|
displayedVideoStride,
|
||||||
w, h, stride = videoSrc.Width(), videoSrc.Height(), videoSrc.Stride()
|
)
|
||||||
}
|
|
||||||
err := r.DrawFrame(w, h, stride)
|
|
||||||
if errors.Is(err, renderer.ErrOutOfDate) {
|
if errors.Is(err, renderer.ErrOutOfDate) {
|
||||||
if rerr := r.RecreateSwapchain(); rerr != nil {
|
if rerr := r.RecreateSwapchain(); rerr != nil {
|
||||||
if errors.Is(rerr, renderer.ErrMinimized) {
|
if errors.Is(rerr, renderer.ErrMinimized) {
|
||||||
@@ -791,4 +956,7 @@ func main() {
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
<-playbackDone
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Size=200,200
|
|||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Connection]
|
[Window][Connection]
|
||||||
Pos=1250,701
|
Pos=74,351
|
||||||
Size=523,153
|
Size=799,226
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AudioFrame contains deinterleaved F32 audio samples.
|
||||||
|
//
|
||||||
|
// Samples contains one byte slice per channel. Each channel contains
|
||||||
|
// SampleCount float32 samples.
|
||||||
|
//
|
||||||
|
// The sample payload may borrow source-owned memory. AudioSink must finish
|
||||||
|
// reading it before ConsumeAudio returns.
|
||||||
|
type AudioFrame struct {
|
||||||
|
Index uint64
|
||||||
|
SampleCount uint64
|
||||||
|
Channels uint64
|
||||||
|
|
||||||
|
SampleRateNumerator int64
|
||||||
|
SampleRateDenominator int64
|
||||||
|
|
||||||
|
Samples [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioReader reads batches from one audio feed.
|
||||||
|
//
|
||||||
|
// ReadAudio must not be called again until the previous frame has been
|
||||||
|
// consumed.
|
||||||
|
type AudioReader interface {
|
||||||
|
ReadAudio(context.Context) (AudioFrame, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioReaderFactory opens a reader for the configured audio feed.
|
||||||
|
type AudioReaderFactory interface {
|
||||||
|
OpenAudio(context.Context, FeedConfig) (AudioReader, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioSink synchronously consumes one borrowed audio batch.
|
||||||
|
//
|
||||||
|
// ConsumeAudio must not retain frame.Samples or their underlying byte slices.
|
||||||
|
type AudioSink interface {
|
||||||
|
ConsumeAudio(context.Context, AudioFrame) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type audioSinkError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *audioSinkError) Error() string {
|
||||||
|
return fmt.Sprintf("consume audio: %v", e.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *audioSinkError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAudioAttempt(
|
||||||
|
ctx context.Context,
|
||||||
|
factory AudioReaderFactory,
|
||||||
|
sink AudioSink,
|
||||||
|
config FeedConfig,
|
||||||
|
) (resultErr error) {
|
||||||
|
reader, err := factory.OpenAudio(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open audio: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if closeErr := reader.Close(); closeErr != nil {
|
||||||
|
closeErr = fmt.Errorf("close audio: %w", closeErr)
|
||||||
|
resultErr = errors.Join(resultErr, closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
frame, err := reader.ReadAudio(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read audio: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sink.ConsumeAudio(ctx, frame); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return &audioSinkError{err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAudioFactory struct {
|
||||||
|
reader AudioReader
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudioFactory) OpenAudio(
|
||||||
|
context.Context,
|
||||||
|
FeedConfig,
|
||||||
|
) (AudioReader, error) {
|
||||||
|
f.calls++
|
||||||
|
return f.reader, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudioReader struct {
|
||||||
|
frames []AudioFrame
|
||||||
|
readErr error
|
||||||
|
closeErr error
|
||||||
|
readCalls int
|
||||||
|
closed bool
|
||||||
|
read func(context.Context) (AudioFrame, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) {
|
||||||
|
r.readCalls++
|
||||||
|
if r.read != nil {
|
||||||
|
return r.read(ctx)
|
||||||
|
}
|
||||||
|
if len(r.frames) == 0 {
|
||||||
|
return AudioFrame{}, r.readErr
|
||||||
|
}
|
||||||
|
frame := r.frames[0]
|
||||||
|
r.frames = r.frames[1:]
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAudioReader) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return r.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudioSink struct {
|
||||||
|
frames []AudioFrame
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeAudioSink) ConsumeAudio(_ context.Context, frame AudioFrame) error {
|
||||||
|
s.frames = append(s.frames, frame)
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptOpenFailure(t *testing.T) {
|
||||||
|
openErr := errors.New("open failed")
|
||||||
|
factory := &fakeAudioFactory{err: openErr}
|
||||||
|
sink := &fakeAudioSink{}
|
||||||
|
|
||||||
|
err := runAudioAttempt(context.Background(), factory, sink, FeedConfig{})
|
||||||
|
|
||||||
|
if !errors.Is(err, openErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, openErr)
|
||||||
|
}
|
||||||
|
if factory.calls != 1 {
|
||||||
|
t.Errorf("factory calls = %d, want 1", factory.calls)
|
||||||
|
}
|
||||||
|
if len(sink.frames) != 0 {
|
||||||
|
t.Fatalf("consumed frame count = %d, want 0", len(sink.frames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptConsumesFrameWithoutCopyThenReturnsReadFailure(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
wantFrame := AudioFrame{
|
||||||
|
Index: 42,
|
||||||
|
SampleCount: 2,
|
||||||
|
Channels: 2,
|
||||||
|
SampleRateNumerator: 48000,
|
||||||
|
SampleRateDenominator: 1,
|
||||||
|
Samples: [][]byte{
|
||||||
|
{1, 2, 3, 4},
|
||||||
|
{5, 6, 7, 8},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
frames: []AudioFrame{wantFrame},
|
||||||
|
readErr: readErr,
|
||||||
|
}
|
||||||
|
sink := &fakeAudioSink{}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
sink,
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, readErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, readErr)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
if reader.readCalls != 2 {
|
||||||
|
t.Errorf("read calls = %d, want 2", reader.readCalls)
|
||||||
|
}
|
||||||
|
if len(sink.frames) != 1 {
|
||||||
|
t.Fatalf("consumed frame count = %d, want 1", len(sink.frames))
|
||||||
|
}
|
||||||
|
got := sink.frames[0]
|
||||||
|
if got.Index != wantFrame.Index ||
|
||||||
|
got.SampleCount != wantFrame.SampleCount ||
|
||||||
|
got.Channels != wantFrame.Channels ||
|
||||||
|
got.SampleRateNumerator != wantFrame.SampleRateNumerator ||
|
||||||
|
got.SampleRateDenominator != wantFrame.SampleRateDenominator {
|
||||||
|
t.Errorf("consumed frame metadata = %+v, want %+v", got, wantFrame)
|
||||||
|
}
|
||||||
|
for channel := range wantFrame.Samples {
|
||||||
|
if &got.Samples[channel][0] != &wantFrame.Samples[channel][0] {
|
||||||
|
t.Fatalf("channel %d samples were copied", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptSinkFailureStopsReadingAndCloses(t *testing.T) {
|
||||||
|
sinkErr := errors.New("audio output unavailable")
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
frames: []AudioFrame{
|
||||||
|
{Index: 1, Samples: [][]byte{{1}}},
|
||||||
|
{Index: 2, Samples: [][]byte{{2}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{err: sinkErr},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, sinkErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want %v", err, sinkErr)
|
||||||
|
}
|
||||||
|
var typedErr *audioSinkError
|
||||||
|
if !errors.As(err, &typedErr) {
|
||||||
|
t.Fatalf("runAudioAttempt() error type = %T, want *audioSinkError", err)
|
||||||
|
}
|
||||||
|
if reader.readCalls != 1 {
|
||||||
|
t.Errorf("read calls = %d, want 1", reader.readCalls)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptCanceledRead(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
read: func(ctx context.Context) (AudioFrame, error) {
|
||||||
|
cancel()
|
||||||
|
return AudioFrame{}, ctx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
ctx,
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("runAudioAttempt() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if !reader.closed {
|
||||||
|
t.Fatal("reader was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAudioAttemptJoinsReadAndCloseErrors(t *testing.T) {
|
||||||
|
readErr := errors.New("read failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
reader := &fakeAudioReader{readErr: readErr, closeErr: closeErr}
|
||||||
|
|
||||||
|
err := runAudioAttempt(
|
||||||
|
context.Background(),
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
FeedConfig{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, readErr) {
|
||||||
|
t.Errorf("runAudioAttempt() error does not contain read error: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, closeErr) {
|
||||||
|
t.Errorf("runAudioAttempt() error does not contain close error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAudioWorkerRequired = errors.New("audio worker is required")
|
||||||
|
|
||||||
|
type AudioSlot struct {
|
||||||
|
worker *AudioWorker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioSlot(worker *AudioWorker) (*AudioSlot, error) {
|
||||||
|
if worker == nil {
|
||||||
|
return nil, ErrAudioWorkerRequired
|
||||||
|
}
|
||||||
|
return &AudioSlot{worker: worker}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioSlot) Run(
|
||||||
|
ctx context.Context,
|
||||||
|
initial FeedConfig,
|
||||||
|
commands <-chan FeedConfig,
|
||||||
|
) error {
|
||||||
|
if err := initial.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate initial audio config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
workerCancel context.CancelFunc
|
||||||
|
workerDone chan error
|
||||||
|
)
|
||||||
|
|
||||||
|
start := func(config FeedConfig) {
|
||||||
|
workerCtx, cancel := context.WithCancel(ctx)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
workerCancel = cancel
|
||||||
|
workerDone = done
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- s.worker.Run(workerCtx, config)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := func() {
|
||||||
|
if workerCancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workerCancel()
|
||||||
|
<-workerDone
|
||||||
|
|
||||||
|
workerCancel = nil
|
||||||
|
workerDone = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial.Active {
|
||||||
|
start(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
stop()
|
||||||
|
return ctx.Err()
|
||||||
|
|
||||||
|
case config, ok := <-commands:
|
||||||
|
if !ok {
|
||||||
|
stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
// Ignore invalid commands without disturbing the current worker.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stop()
|
||||||
|
if config.Active {
|
||||||
|
start(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-workerDone:
|
||||||
|
// The worker stopped naturally or exhausted its retries.
|
||||||
|
workerCancel()
|
||||||
|
workerCancel = nil
|
||||||
|
workerDone = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type slotAudioFactory struct {
|
||||||
|
opened chan FeedConfig
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
active int
|
||||||
|
maxActive int
|
||||||
|
closeCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSlotAudioFactory() *slotAudioFactory {
|
||||||
|
return &slotAudioFactory{opened: make(chan FeedConfig, 8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *slotAudioFactory) OpenAudio(
|
||||||
|
_ context.Context,
|
||||||
|
config FeedConfig,
|
||||||
|
) (AudioReader, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.active++
|
||||||
|
if f.active > f.maxActive {
|
||||||
|
f.maxActive = f.active
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.opened <- config
|
||||||
|
return &slotAudioReader{factory: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *slotAudioFactory) counts() (active, maxActive, closeCount int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.active, f.maxActive, f.closeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type slotAudioReader struct {
|
||||||
|
factory *slotAudioFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *slotAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return AudioFrame{}, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *slotAudioReader) Close() error {
|
||||||
|
r.factory.mu.Lock()
|
||||||
|
defer r.factory.mu.Unlock()
|
||||||
|
r.factory.active--
|
||||||
|
r.factory.closeCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioSlotWorker(t *testing.T, factory AudioReaderFactory) *AudioWorker {
|
||||||
|
t.Helper()
|
||||||
|
return newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
factory,
|
||||||
|
&fakeAudioSink{},
|
||||||
|
1,
|
||||||
|
func(error) bool { return false },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveAudioSlotOpen(t *testing.T, opened <-chan FeedConfig) FeedConfig {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case config := <-opened:
|
||||||
|
return config
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("audio worker did not open")
|
||||||
|
return FeedConfig{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAudioSlotRequiresWorker(t *testing.T) {
|
||||||
|
slot, err := NewAudioSlot(nil)
|
||||||
|
if slot != nil {
|
||||||
|
t.Fatalf("NewAudioSlot(nil) slot = %#v, want nil", slot)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrAudioWorkerRequired) {
|
||||||
|
t.Fatalf("NewAudioSlot(nil) error = %v, want %v", err, ErrAudioWorkerRequired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioSlotStartsAndJoinsInitialWorker(t *testing.T) {
|
||||||
|
factory := newSlotAudioFactory()
|
||||||
|
slot, err := NewAudioSlot(newAudioSlotWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAudioSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
want := FeedConfig{Domain: "/audio", UUID: "first", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, want, make(chan FeedConfig)) }()
|
||||||
|
if got := receiveAudioSlotOpen(t, factory.opened); got != want {
|
||||||
|
t.Fatalf("opened config = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run() did not stop after cancellation")
|
||||||
|
}
|
||||||
|
active, maxActive, closeCount := factory.counts()
|
||||||
|
if active != 0 || maxActive != 1 || closeCount != 1 {
|
||||||
|
t.Fatalf("reader counts = %d, %d, %d; want 0, 1, 1", active, maxActive, closeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioSlotReplacesWithoutOverlapAndStops(t *testing.T) {
|
||||||
|
factory := newSlotAudioFactory()
|
||||||
|
slot, err := NewAudioSlot(newAudioSlotWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAudioSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
commands := make(chan FeedConfig)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
first := FeedConfig{Domain: "/audio", UUID: "first", Active: true}
|
||||||
|
second := FeedConfig{Domain: "/audio", UUID: "second", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, first, commands) }()
|
||||||
|
receiveAudioSlotOpen(t, factory.opened)
|
||||||
|
commands <- second
|
||||||
|
if got := receiveAudioSlotOpen(t, factory.opened); got != second {
|
||||||
|
t.Fatalf("replacement config = %#v, want %#v", got, second)
|
||||||
|
}
|
||||||
|
commands <- FeedConfig{Domain: "/audio", UUID: "second", Active: false}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
active, maxActive, closeCount := factory.counts()
|
||||||
|
if active == 0 && closeCount == 2 {
|
||||||
|
if maxActive != 1 {
|
||||||
|
t.Fatalf("maximum active readers = %d, want 1", maxActive)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("reader counts = %d, %d, %d; want 0, 1, 2", active, maxActive, closeCount)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(commands)
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run() did not stop after commands closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioSlotIgnoresInvalidCommand(t *testing.T) {
|
||||||
|
factory := newSlotAudioFactory()
|
||||||
|
slot, err := NewAudioSlot(newAudioSlotWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAudioSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
commands := make(chan FeedConfig)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- slot.Run(
|
||||||
|
ctx,
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "first", Active: true},
|
||||||
|
commands,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
receiveAudioSlotOpen(t, factory.opened)
|
||||||
|
commands <- FeedConfig{UUID: "invalid", Active: true}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case config := <-factory.opened:
|
||||||
|
t.Fatalf("invalid command opened config %#v", config)
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
active, _, closeCount := factory.counts()
|
||||||
|
if active != 1 || closeCount != 0 {
|
||||||
|
t.Fatalf("invalid command disturbed reader: active %d, closed %d", active, closeCount)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAudioFactoryRequired = errors.New("audio reader factory is required")
|
||||||
|
ErrAudioSinkRequired = errors.New("audio sink is required")
|
||||||
|
ErrAudioRetryDeciderRequired = errors.New("audio decider is required")
|
||||||
|
ErrAudioFeedInactive = errors.New("audio feed is not active")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioWorker struct {
|
||||||
|
factory AudioReaderFactory
|
||||||
|
sink AudioSink
|
||||||
|
retry RetryPolicy
|
||||||
|
shouldRetry retryDecider
|
||||||
|
observer StatusObserver
|
||||||
|
wait waitFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAudioWorker(
|
||||||
|
factory AudioReaderFactory,
|
||||||
|
sink AudioSink,
|
||||||
|
retry RetryPolicy,
|
||||||
|
shouldRetry func(error) bool,
|
||||||
|
observer StatusObserver,
|
||||||
|
) (*AudioWorker, error) {
|
||||||
|
if factory == nil {
|
||||||
|
return nil, ErrAudioFactoryRequired
|
||||||
|
}
|
||||||
|
if sink == nil {
|
||||||
|
return nil, ErrAudioSinkRequired
|
||||||
|
}
|
||||||
|
if shouldRetry == nil {
|
||||||
|
return nil, ErrAudioRetryDeciderRequired
|
||||||
|
}
|
||||||
|
if err := retry.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate audio retry policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AudioWorker{
|
||||||
|
factory: factory,
|
||||||
|
sink: sink,
|
||||||
|
retry: retry,
|
||||||
|
shouldRetry: shouldRetry,
|
||||||
|
observer: observer,
|
||||||
|
wait: waitForRetry,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stabilityAudioSink struct {
|
||||||
|
sink AudioSink
|
||||||
|
onStable func()
|
||||||
|
stable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stabilityAudioSink) ConsumeAudio(
|
||||||
|
ctx context.Context,
|
||||||
|
frame AudioFrame,
|
||||||
|
) error {
|
||||||
|
err := s.sink.ConsumeAudio(ctx, frame)
|
||||||
|
if err == nil && !s.stable {
|
||||||
|
s.stable = true
|
||||||
|
if s.onStable != nil {
|
||||||
|
s.onStable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *AudioWorker) emit(status Status) {
|
||||||
|
if w.observer != nil {
|
||||||
|
w.observer(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *AudioWorker) Run(
|
||||||
|
ctx context.Context,
|
||||||
|
config FeedConfig,
|
||||||
|
) error {
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate audio config: %w", err)
|
||||||
|
}
|
||||||
|
if !config.Active {
|
||||||
|
return ErrAudioFeedInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptNumber := 0
|
||||||
|
var latestRetry retryEvent
|
||||||
|
|
||||||
|
attempt := func(ctx context.Context) (bool, error) {
|
||||||
|
attemptNumber++
|
||||||
|
|
||||||
|
state := StateConnecting
|
||||||
|
if attemptNumber > 1 {
|
||||||
|
state = StateReconnecting
|
||||||
|
}
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: state,
|
||||||
|
Attempt: attemptNumber,
|
||||||
|
})
|
||||||
|
|
||||||
|
attemptSink := &stabilityAudioSink{
|
||||||
|
sink: w.sink,
|
||||||
|
onStable: func() {
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StatePlaying,
|
||||||
|
Attempt: attemptNumber,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runAudioAttempt(ctx, w.factory, attemptSink, config)
|
||||||
|
return attemptSink.stable, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decide := func(err error) bool {
|
||||||
|
var sinkErr *audioSinkError
|
||||||
|
if errors.As(err, &sinkErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return w.shouldRetry(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
observeRetry := func(event retryEvent) {
|
||||||
|
latestRetry = event
|
||||||
|
if !event.WillRetry {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StateReconnecting,
|
||||||
|
Attempt: attemptNumber + 1,
|
||||||
|
FailedAttempts: event.FailedAttempts,
|
||||||
|
RetryIn: event.RetryIn,
|
||||||
|
Err: event.Err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runWithRetry(
|
||||||
|
ctx,
|
||||||
|
w.retry,
|
||||||
|
attempt,
|
||||||
|
decide,
|
||||||
|
w.wait,
|
||||||
|
observeRetry,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StateStopping,
|
||||||
|
})
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StateIdle,
|
||||||
|
})
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StateFailed,
|
||||||
|
Attempt: attemptNumber,
|
||||||
|
FailedAttempts: latestRetry.FailedAttempts,
|
||||||
|
Err: err,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.emit(Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StateIdle,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type queuedAudioFactory struct {
|
||||||
|
readers []AudioReader
|
||||||
|
errs []error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *queuedAudioFactory) OpenAudio(
|
||||||
|
context.Context,
|
||||||
|
FeedConfig,
|
||||||
|
) (AudioReader, error) {
|
||||||
|
index := f.calls
|
||||||
|
f.calls++
|
||||||
|
if index < len(f.errs) && f.errs[index] != nil {
|
||||||
|
return nil, f.errs[index]
|
||||||
|
}
|
||||||
|
if index < len(f.readers) {
|
||||||
|
return f.readers[index], nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected audio open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioWorkerForTest(
|
||||||
|
t *testing.T,
|
||||||
|
factory AudioReaderFactory,
|
||||||
|
sink AudioSink,
|
||||||
|
maxAttempts int,
|
||||||
|
shouldRetry func(error) bool,
|
||||||
|
observer StatusObserver,
|
||||||
|
) *AudioWorker {
|
||||||
|
t.Helper()
|
||||||
|
worker, err := NewAudioWorker(
|
||||||
|
factory,
|
||||||
|
sink,
|
||||||
|
testRetryPolicy(maxAttempts),
|
||||||
|
shouldRetry,
|
||||||
|
observer,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAudioWorker() error = %v", err)
|
||||||
|
}
|
||||||
|
worker.wait = func(context.Context, time.Duration) error { return nil }
|
||||||
|
return worker
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAudioWorkerValidatesDependencies(t *testing.T) {
|
||||||
|
factory := &fakeAudioFactory{}
|
||||||
|
sink := &fakeAudioSink{}
|
||||||
|
retry := testRetryPolicy(3)
|
||||||
|
decider := func(error) bool { return true }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
factory AudioReaderFactory
|
||||||
|
sink AudioSink
|
||||||
|
retry RetryPolicy
|
||||||
|
decider func(error) bool
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "factory", sink: sink, retry: retry, decider: decider, wantErr: ErrAudioFactoryRequired},
|
||||||
|
{name: "sink", factory: factory, retry: retry, decider: decider, wantErr: ErrAudioSinkRequired},
|
||||||
|
{name: "decider", factory: factory, sink: sink, retry: retry, wantErr: ErrAudioRetryDeciderRequired},
|
||||||
|
{
|
||||||
|
name: "retry policy",
|
||||||
|
factory: factory,
|
||||||
|
sink: sink,
|
||||||
|
retry: RetryPolicy{},
|
||||||
|
decider: decider,
|
||||||
|
wantErr: ErrInvalidRetryDelay,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
worker, err := NewAudioWorker(
|
||||||
|
tt.factory,
|
||||||
|
tt.sink,
|
||||||
|
tt.retry,
|
||||||
|
tt.decider,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if worker != nil {
|
||||||
|
t.Fatalf("NewAudioWorker() worker = %#v, want nil", worker)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("NewAudioWorker() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioWorkerRejectsInactiveFeed(t *testing.T) {
|
||||||
|
worker := newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
&fakeAudioFactory{},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
1,
|
||||||
|
func(error) bool { return false },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := worker.Run(
|
||||||
|
context.Background(),
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: false},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrAudioFeedInactive) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, ErrAudioFeedInactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioWorkerPublishesPlayingThenFailed(t *testing.T) {
|
||||||
|
readErr := errors.New("audio disappeared")
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
frames: []AudioFrame{{Index: 1, Samples: [][]byte{{1, 2, 3, 4}}}},
|
||||||
|
readErr: readErr,
|
||||||
|
}
|
||||||
|
var statuses []Status
|
||||||
|
worker := newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
1,
|
||||||
|
func(error) bool { return false },
|
||||||
|
func(status Status) { statuses = append(statuses, status) },
|
||||||
|
)
|
||||||
|
|
||||||
|
err := worker.Run(
|
||||||
|
context.Background(),
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, readErr) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, readErr)
|
||||||
|
}
|
||||||
|
wantStates := []State{StateConnecting, StatePlaying, StateFailed}
|
||||||
|
if len(statuses) != len(wantStates) {
|
||||||
|
t.Fatalf("status count = %d, want %d: %#v", len(statuses), len(wantStates), statuses)
|
||||||
|
}
|
||||||
|
for i, want := range wantStates {
|
||||||
|
if statuses[i].Unit != UnitAudio || statuses[i].State != want {
|
||||||
|
t.Errorf("status[%d] = %#v, want audio/%v", i, statuses[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioWorkerRetriesUpToAttemptLimit(t *testing.T) {
|
||||||
|
openErr := errors.New("audio unavailable")
|
||||||
|
factory := &queuedAudioFactory{errs: []error{openErr, openErr, openErr}}
|
||||||
|
var statuses []Status
|
||||||
|
worker := newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
factory,
|
||||||
|
&fakeAudioSink{},
|
||||||
|
3,
|
||||||
|
func(error) bool { return true },
|
||||||
|
func(status Status) { statuses = append(statuses, status) },
|
||||||
|
)
|
||||||
|
|
||||||
|
err := worker.Run(
|
||||||
|
context.Background(),
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, openErr) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, openErr)
|
||||||
|
}
|
||||||
|
if factory.calls != 3 {
|
||||||
|
t.Fatalf("open calls = %d, want 3", factory.calls)
|
||||||
|
}
|
||||||
|
last := statuses[len(statuses)-1]
|
||||||
|
if last.State != StateFailed || last.Attempt != 3 || last.FailedAttempts != 3 {
|
||||||
|
t.Fatalf("last status = %#v, want failed attempt 3", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioWorkerDoesNotRetrySinkFailure(t *testing.T) {
|
||||||
|
sinkErr := errors.New("output failed")
|
||||||
|
factory := &fakeAudioFactory{
|
||||||
|
reader: &fakeAudioReader{frames: []AudioFrame{{Index: 1}}},
|
||||||
|
}
|
||||||
|
worker := newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
factory,
|
||||||
|
&fakeAudioSink{err: sinkErr},
|
||||||
|
3,
|
||||||
|
func(error) bool { return true },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := worker.Run(
|
||||||
|
context.Background(),
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, sinkErr) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, sinkErr)
|
||||||
|
}
|
||||||
|
if factory.calls != 1 {
|
||||||
|
t.Fatalf("open calls = %d, want 1", factory.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioWorkerCancellationPublishesStoppingAndIdle(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reader := &fakeAudioReader{
|
||||||
|
read: func(ctx context.Context) (AudioFrame, error) {
|
||||||
|
cancel()
|
||||||
|
<-ctx.Done()
|
||||||
|
return AudioFrame{}, ctx.Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var statuses []Status
|
||||||
|
worker := newAudioWorkerForTest(
|
||||||
|
t,
|
||||||
|
&fakeAudioFactory{reader: reader},
|
||||||
|
&fakeAudioSink{},
|
||||||
|
1,
|
||||||
|
func(error) bool { return true },
|
||||||
|
func(status Status) { statuses = append(statuses, status) },
|
||||||
|
)
|
||||||
|
|
||||||
|
err := worker.Run(
|
||||||
|
ctx,
|
||||||
|
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
||||||
|
)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
||||||
|
}
|
||||||
|
if len(statuses) < 3 {
|
||||||
|
t.Fatalf("statuses = %#v, want connecting, stopping, idle", statuses)
|
||||||
|
}
|
||||||
|
last := statuses[len(statuses)-2:]
|
||||||
|
if last[0].State != StateStopping || last[1].State != StateIdle {
|
||||||
|
t.Fatalf("final statuses = %#v, want stopping then idle", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package playback
|
package playback
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Unit uint8
|
type Unit uint8
|
||||||
|
|
||||||
@@ -31,3 +34,35 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatusObserver func(Status)
|
type StatusObserver func(Status)
|
||||||
|
|
||||||
|
func (u Unit) String() string {
|
||||||
|
switch u {
|
||||||
|
case UnitVideo:
|
||||||
|
return "video"
|
||||||
|
case UnitAudio:
|
||||||
|
return "audio"
|
||||||
|
case UnitSync:
|
||||||
|
return "sync"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Unit(%d)", uint8(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case StateIdle:
|
||||||
|
return "idle"
|
||||||
|
case StateConnecting:
|
||||||
|
return "connecting"
|
||||||
|
case StatePlaying:
|
||||||
|
return "playing"
|
||||||
|
case StateReconnecting:
|
||||||
|
return "reconnecting"
|
||||||
|
case StateFailed:
|
||||||
|
return "failed"
|
||||||
|
case StateStopping:
|
||||||
|
return "stopping"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("State(%d)", uint8(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,3 +38,42 @@ func TestStatusPreservesValues(t *testing.T) {
|
|||||||
t.Errorf("RetryIn = %s, want %s", status.RetryIn, time.Second)
|
t.Errorf("RetryIn = %s, want %s", status.RetryIn, time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnitString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
unit Unit
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{unit: UnitVideo, want: "video"},
|
||||||
|
{unit: UnitAudio, want: "audio"},
|
||||||
|
{unit: UnitSync, want: "sync"},
|
||||||
|
{unit: Unit(255), want: "Unit(255)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.unit.String(); got != tt.want {
|
||||||
|
t.Errorf("Unit(%d).String() = %q, want %q", tt.unit, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
state State
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{state: StateIdle, want: "idle"},
|
||||||
|
{state: StateConnecting, want: "connecting"},
|
||||||
|
{state: StatePlaying, want: "playing"},
|
||||||
|
{state: StateReconnecting, want: "reconnecting"},
|
||||||
|
{state: StateFailed, want: "failed"},
|
||||||
|
{state: StateStopping, want: "stopping"},
|
||||||
|
{state: State(255), want: "State(255)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.state.String(); got != tt.want {
|
||||||
|
t.Errorf("State(%d).String() = %q, want %q", tt.state, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type StatusStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
statuses map[Unit]Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatusStore() *StatusStore {
|
||||||
|
return &StatusStore{
|
||||||
|
statuses: make(map[Unit]Status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusStore) Observe(status Status) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.statuses[status.Unit] = status
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
status, ok := s.statuses[unit]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return status, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusStore) Clear(unit Unit) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.statuses, unit)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusStoreSnapshotUnknownUnit(t *testing.T) {
|
||||||
|
store := NewStatusStore()
|
||||||
|
|
||||||
|
status, ok := store.Snapshot(UnitVideo)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("Snapshot() = %#v, true; want false", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusStoreKeepsUnitsIndependent(t *testing.T) {
|
||||||
|
store := NewStatusStore()
|
||||||
|
wantVideo := Status{
|
||||||
|
Unit: UnitVideo,
|
||||||
|
State: StateReconnecting,
|
||||||
|
Attempt: 3,
|
||||||
|
FailedAttempts: 2,
|
||||||
|
Err: errors.New("video unavailable"),
|
||||||
|
}
|
||||||
|
wantAudio := Status{
|
||||||
|
Unit: UnitAudio,
|
||||||
|
State: StatePlaying,
|
||||||
|
Attempt: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Observe(wantVideo)
|
||||||
|
store.Observe(wantAudio)
|
||||||
|
|
||||||
|
if got, ok := store.Snapshot(UnitVideo); !ok || got != wantVideo {
|
||||||
|
t.Fatalf("video Snapshot() = %#v, %t; want %#v, true", got, ok, wantVideo)
|
||||||
|
}
|
||||||
|
if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio {
|
||||||
|
t.Fatalf("audio Snapshot() = %#v, %t; want %#v, true", got, ok, wantAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusStoreObserveReplacesLatestStatus(t *testing.T) {
|
||||||
|
store := NewStatusStore()
|
||||||
|
store.Observe(Status{Unit: UnitVideo, State: StateConnecting, Attempt: 1})
|
||||||
|
want := Status{Unit: UnitVideo, State: StatePlaying, Attempt: 2}
|
||||||
|
store.Observe(want)
|
||||||
|
|
||||||
|
got, ok := store.Snapshot(UnitVideo)
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Fatalf("Snapshot() = %#v, %t; want %#v, true", got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusStoreClearOnlySelectedUnit(t *testing.T) {
|
||||||
|
store := NewStatusStore()
|
||||||
|
wantAudio := Status{Unit: UnitAudio, State: StatePlaying}
|
||||||
|
store.Observe(Status{Unit: UnitVideo, State: StatePlaying})
|
||||||
|
store.Observe(wantAudio)
|
||||||
|
|
||||||
|
store.Clear(UnitVideo)
|
||||||
|
|
||||||
|
if status, ok := store.Snapshot(UnitVideo); ok {
|
||||||
|
t.Fatalf("video Snapshot() = %#v, true after Clear", status)
|
||||||
|
}
|
||||||
|
if got, ok := store.Snapshot(UnitAudio); !ok || got != wantAudio {
|
||||||
|
t.Fatalf("audio Snapshot() = %#v, %t; want %#v, true", got, ok, wantAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusStoreConcurrentAccess(t *testing.T) {
|
||||||
|
store := NewStatusStore()
|
||||||
|
const iterations = 1000
|
||||||
|
|
||||||
|
var writers sync.WaitGroup
|
||||||
|
for _, unit := range []Unit{UnitVideo, UnitAudio, UnitSync} {
|
||||||
|
unit := unit
|
||||||
|
writers.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer writers.Done()
|
||||||
|
for attempt := 1; attempt <= iterations; attempt++ {
|
||||||
|
store.Observe(Status{
|
||||||
|
Unit: unit,
|
||||||
|
State: StatePlaying,
|
||||||
|
Attempt: attempt,
|
||||||
|
})
|
||||||
|
store.Snapshot(unit)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
writers.Wait()
|
||||||
|
|
||||||
|
for _, unit := range []Unit{UnitVideo, UnitAudio, UnitSync} {
|
||||||
|
status, ok := store.Snapshot(unit)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Snapshot(%v) not found", unit)
|
||||||
|
}
|
||||||
|
if status.Attempt != iterations {
|
||||||
|
t.Fatalf(
|
||||||
|
"Snapshot(%v) attempt = %d, want %d",
|
||||||
|
unit,
|
||||||
|
status.Attempt,
|
||||||
|
iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrVideoWorkerRequired = errors.New("video worker is required")
|
||||||
|
|
||||||
|
type VideoSlot struct {
|
||||||
|
worker *VideoWorker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVideoSlot(worker *VideoWorker) (*VideoSlot, error) {
|
||||||
|
if worker == nil {
|
||||||
|
return nil, ErrVideoWorkerRequired
|
||||||
|
}
|
||||||
|
return &VideoSlot{worker: worker}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VideoSlot) Run(
|
||||||
|
ctx context.Context,
|
||||||
|
initial FeedConfig,
|
||||||
|
commands <-chan FeedConfig,
|
||||||
|
) error {
|
||||||
|
if err := initial.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate initial video config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
workerCancel context.CancelFunc
|
||||||
|
workerDone chan error
|
||||||
|
)
|
||||||
|
|
||||||
|
start := func(config FeedConfig) {
|
||||||
|
workerCtx, cancel := context.WithCancel(ctx)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
workerCancel = cancel
|
||||||
|
workerDone = done
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- s.worker.Run(workerCtx, config)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := func() {
|
||||||
|
if workerCancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workerCancel()
|
||||||
|
<-workerDone
|
||||||
|
|
||||||
|
workerCancel = nil
|
||||||
|
workerDone = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial.Active {
|
||||||
|
start(initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
stop()
|
||||||
|
return ctx.Err()
|
||||||
|
|
||||||
|
case config, ok := <-commands:
|
||||||
|
if !ok {
|
||||||
|
stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
// Ignore invalid commands without disturbing the current worker.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
if config.Active {
|
||||||
|
start(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-workerDone:
|
||||||
|
// The worker stopped naturally or exhausted its retries.
|
||||||
|
// Clear its lifecycle, but keep the slot alive for future commands.
|
||||||
|
workerCancel()
|
||||||
|
workerCancel = nil
|
||||||
|
workerDone = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type slotVideoFactory struct {
|
||||||
|
opened chan FeedConfig
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
active int
|
||||||
|
maxActive int
|
||||||
|
closeCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSlotVideoFactory() *slotVideoFactory {
|
||||||
|
return &slotVideoFactory{opened: make(chan FeedConfig, 8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *slotVideoFactory) OpenVideo(
|
||||||
|
_ context.Context,
|
||||||
|
config FeedConfig,
|
||||||
|
) (VideoReader, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.active++
|
||||||
|
if f.active > f.maxActive {
|
||||||
|
f.maxActive = f.active
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
f.opened <- config
|
||||||
|
return &slotVideoReader{factory: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *slotVideoFactory) counts() (active, maxActive, closeCount int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.active, f.maxActive, f.closeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type slotVideoReader struct {
|
||||||
|
factory *slotVideoFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *slotVideoReader) ReadVideo(ctx context.Context) (VideoFrame, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return VideoFrame{}, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *slotVideoReader) Close() error {
|
||||||
|
r.factory.mu.Lock()
|
||||||
|
defer r.factory.mu.Unlock()
|
||||||
|
r.factory.active--
|
||||||
|
r.factory.closeCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSlotTestWorker(t *testing.T, factory VideoReaderFactory) *VideoWorker {
|
||||||
|
t.Helper()
|
||||||
|
worker, err := NewVideoWorker(
|
||||||
|
factory,
|
||||||
|
&fakeVideoSink{},
|
||||||
|
testRetryPolicy(1),
|
||||||
|
func(error) bool { return false },
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVideoWorker() error = %v", err)
|
||||||
|
}
|
||||||
|
return worker
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveSlotOpen(t *testing.T, opened <-chan FeedConfig) FeedConfig {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case config := <-opened:
|
||||||
|
return config
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("video worker did not open")
|
||||||
|
return FeedConfig{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewVideoSlotRequiresWorker(t *testing.T) {
|
||||||
|
slot, err := NewVideoSlot(nil)
|
||||||
|
if slot != nil {
|
||||||
|
t.Fatalf("NewVideoSlot(nil) slot = %#v, want nil", slot)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrVideoWorkerRequired) {
|
||||||
|
t.Fatalf("NewVideoSlot(nil) error = %v, want %v", err, ErrVideoWorkerRequired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoSlotStartsInitialActiveConfig(t *testing.T) {
|
||||||
|
factory := newSlotVideoFactory()
|
||||||
|
slot, err := NewVideoSlot(newSlotTestWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVideoSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
want := FeedConfig{Domain: "/video", UUID: "first", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, want, make(chan FeedConfig)) }()
|
||||||
|
if got := receiveSlotOpen(t, factory.opened); got != want {
|
||||||
|
t.Fatalf("opened config = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run() did not stop after cancellation")
|
||||||
|
}
|
||||||
|
active, _, closeCount := factory.counts()
|
||||||
|
if active != 0 || closeCount != 1 {
|
||||||
|
t.Fatalf("reader counts = active %d, closed %d; want 0, 1", active, closeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoSlotReplacesWithoutOverlappingWorkers(t *testing.T) {
|
||||||
|
factory := newSlotVideoFactory()
|
||||||
|
slot, err := NewVideoSlot(newSlotTestWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVideoSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
commands := make(chan FeedConfig)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
first := FeedConfig{Domain: "/video", UUID: "first", Active: true}
|
||||||
|
second := FeedConfig{Domain: "/video", UUID: "second", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, first, commands) }()
|
||||||
|
receiveSlotOpen(t, factory.opened)
|
||||||
|
commands <- second
|
||||||
|
if got := receiveSlotOpen(t, factory.opened); got != second {
|
||||||
|
t.Fatalf("replacement config = %#v, want %#v", got, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run() did not stop")
|
||||||
|
}
|
||||||
|
active, maxActive, closeCount := factory.counts()
|
||||||
|
if active != 0 || maxActive != 1 || closeCount != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"reader counts = active %d, maximum %d, closed %d; want 0, 1, 2",
|
||||||
|
active, maxActive, closeCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoSlotIgnoresInvalidCommand(t *testing.T) {
|
||||||
|
factory := newSlotVideoFactory()
|
||||||
|
slot, err := NewVideoSlot(newSlotTestWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVideoSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
commands := make(chan FeedConfig)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
initial := FeedConfig{Domain: "/video", UUID: "first", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, initial, commands) }()
|
||||||
|
receiveSlotOpen(t, factory.opened)
|
||||||
|
commands <- FeedConfig{UUID: "invalid", Active: true}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case config := <-factory.opened:
|
||||||
|
t.Fatalf("invalid command opened config %#v", config)
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
active, _, closeCount := factory.counts()
|
||||||
|
if active != 1 || closeCount != 0 {
|
||||||
|
t.Fatalf("invalid command disturbed reader: active %d, closed %d", active, closeCount)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoSlotInactiveCommandStopsWithoutRestart(t *testing.T) {
|
||||||
|
factory := newSlotVideoFactory()
|
||||||
|
slot, err := NewVideoSlot(newSlotTestWorker(t, factory))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewVideoSlot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
commands := make(chan FeedConfig)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
initial := FeedConfig{Domain: "/video", UUID: "first", Active: true}
|
||||||
|
|
||||||
|
go func() { done <- slot.Run(ctx, initial, commands) }()
|
||||||
|
receiveSlotOpen(t, factory.opened)
|
||||||
|
commands <- FeedConfig{Domain: "/video", UUID: "first", Active: false}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
active, _, closeCount := factory.counts()
|
||||||
|
if active == 0 && closeCount == 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("inactive command did not stop reader")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case config := <-factory.opened:
|
||||||
|
t.Fatalf("inactive command restarted config %#v", config)
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
close(commands)
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Run() did not stop after commands closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-23
@@ -296,47 +296,98 @@ func OpenAudio(domain, flowID string) (*AudioSource, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioSource) NextAudio(ctx context.Context, batch uint64, timeout time.Duration) (AudioFrame, error) {
|
func (s *AudioSource) ReadAudioOnceCtx(
|
||||||
for {
|
ctx context.Context,
|
||||||
select {
|
batch uint64,
|
||||||
case <-ctx.Done():
|
timeout time.Duration,
|
||||||
return AudioFrame{}, ctx.Err()
|
) (AudioFrame, error) {
|
||||||
default:
|
if err := ctx.Err(); err != nil {
|
||||||
|
return AudioFrame{}, err
|
||||||
}
|
}
|
||||||
v, err := s.r.GetSamples(s.idx, int(batch), timeout)
|
|
||||||
|
value, err := s.r.GetSamples(s.idx, int(batch), timeout)
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return AudioFrame{}, ctxErr
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
samples := make([][]byte, s.chans)
|
samples := make([][]byte, s.chans)
|
||||||
for ch := uint64(0); ch < s.chans; ch++ {
|
for channel := uint64(0); channel < s.chans; channel++ {
|
||||||
f1, f2, _ := v.ChannelFragments(ch)
|
first, second, _ := value.ChannelFragments(channel)
|
||||||
if len(f2) > 0 {
|
if len(second) > 0 {
|
||||||
samples[ch] = append(f1, f2...)
|
samples[channel] = append(first, second...)
|
||||||
} else {
|
} else {
|
||||||
samples[ch] = f1
|
samples[channel] = first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f := AudioFrame{
|
|
||||||
|
frame := AudioFrame{
|
||||||
Index: s.idx,
|
Index: s.idx,
|
||||||
SampleCount: batch,
|
SampleCount: batch,
|
||||||
Channels: s.chans,
|
Channels: s.chans,
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
}
|
}
|
||||||
s.idx += batch
|
s.idx += batch
|
||||||
return f, nil
|
return frame, nil
|
||||||
case errors.Is(err, mxl.ErrOutOfRangeEarly):
|
case errors.Is(err, mxl.ErrOutOfRangeEarly):
|
||||||
select {
|
return AudioFrame{}, wrapError(
|
||||||
case <-time.After(10 * time.Millisecond):
|
"read audio",
|
||||||
case <-ctx.Done():
|
ErrorKindTemporary,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
case errors.Is(err, mxl.ErrOutOfRangeLate):
|
||||||
|
runtimeInfo, runtimeErr := s.r.Runtime()
|
||||||
|
if runtimeErr != nil {
|
||||||
|
return AudioFrame{}, wrapError(
|
||||||
|
"read audio runtime",
|
||||||
|
ErrorKindUnavailable,
|
||||||
|
runtimeErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
s.idx = runtimeInfo.HeadIndex
|
||||||
|
return AudioFrame{}, wrapError(
|
||||||
|
"read audio",
|
||||||
|
ErrorKindTemporary,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return AudioFrame{}, wrapError(
|
||||||
|
"read audio",
|
||||||
|
ErrorKindUnavailable,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AudioSource) NextAudio(
|
||||||
|
ctx context.Context,
|
||||||
|
batch uint64,
|
||||||
|
timeout time.Duration,
|
||||||
|
) (AudioFrame, error) {
|
||||||
|
for {
|
||||||
|
frame, err := s.ReadAudioOnceCtx(ctx, batch, timeout)
|
||||||
|
if err == nil {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
return AudioFrame{}, ctx.Err()
|
return AudioFrame{}, ctx.Err()
|
||||||
}
|
}
|
||||||
case errors.Is(err, mxl.ErrOutOfRangeLate):
|
if KindOf(err) != ErrorKindTemporary {
|
||||||
rt, rerr := s.r.Runtime()
|
return AudioFrame{}, err
|
||||||
if rerr != nil {
|
|
||||||
return AudioFrame{}, fmt.Errorf("Runtime: %w", rerr)
|
|
||||||
}
|
}
|
||||||
s.idx = rt.HeadIndex
|
|
||||||
|
timer := time.NewTimer(10 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
default:
|
default:
|
||||||
return AudioFrame{}, fmt.Errorf("GetSamples: %w", err)
|
}
|
||||||
|
}
|
||||||
|
return AudioFrame{}, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
That makes sense. The GUI is likely more responsive because frame staging is now serialized with rendering:
|
||||||
|
- The background goroutine no longer copies a large frame into Vulkan-mapped memory concurrently with GUI/render work.
|
||||||
|
- StageFrame waits for the GPU fence before writing, removing CPU/GPU memory contention and undefined synchronization.
|
||||||
|
- The bridge creates deterministic backpressure: the source cannot begin another frame until the current payload is staged.
|
||||||
|
- The main thread now controls the complete render sequence instead of coordinating loosely through two channels.
|
||||||
|
So we fixed both correctness and scheduling stability without adding another frame copy.
|
||||||
Reference in New Issue
Block a user