Initial commit: Go driver for the Lian Li Galahad II LCD
Streams H.264 to the Galahad II LCD via USB (gousb) with: - ffmpeg CLI transcoding (no fragile FFmpeg C-ABI bindings) - pure-Go Annex-B access-unit splitting - framerate pacing with graceful shutdown and USB reconnect - TOML config, cobra CLI, structured slog logging, unit tests
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// Package stream plays back pre-encoded H.264 access units to a Device at a
|
||||
// constant frame rate. Pacing accounts for the time spent writing so that
|
||||
// slow USB transfers do not accumulate drift.
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"galahad2lcd/internal/device"
|
||||
"galahad2lcd/internal/h264"
|
||||
)
|
||||
|
||||
const defaultFPS = 30.0
|
||||
|
||||
// LoadPackets reads an Annex-B H.264 file from disk and splits it into
|
||||
// access units (one per video frame).
|
||||
func LoadPackets(path string) ([][]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read cache %q: %w", path, err)
|
||||
}
|
||||
return h264.SplitAUs(data), nil
|
||||
}
|
||||
|
||||
// Streamer plays a fixed set of frames in a loop until cancelled or until a
|
||||
// write fails.
|
||||
type Streamer struct {
|
||||
device device.Device
|
||||
packets [][]byte
|
||||
frameTime time.Duration
|
||||
}
|
||||
|
||||
// New builds a Streamer. speed > 1 slows playback, speed < 1 speeds it up.
|
||||
func New(dev device.Device, packets [][]byte, fps, speed float64) *Streamer {
|
||||
effective := clampFPS(fps)
|
||||
if speed <= 0 {
|
||||
speed = 1
|
||||
}
|
||||
frameTime := time.Duration(float64(time.Second) / effective * speed)
|
||||
return &Streamer{device: dev, packets: packets, frameTime: frameTime}
|
||||
}
|
||||
|
||||
// Run streams packets forever until ctx is cancelled or the device fails.
|
||||
// A nil error means ctx was cancelled and shutdown is clean.
|
||||
func (s *Streamer) Run(ctx context.Context) error {
|
||||
if len(s.packets) == 0 {
|
||||
return errors.New("no frames to stream")
|
||||
}
|
||||
|
||||
for {
|
||||
for _, pkt := range s.packets {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil // clean shutdown
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := s.device.WriteFrame(ctx, pkt); err != nil {
|
||||
return fmt.Errorf("write frame: %w", err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
if wait := s.frameTime - elapsed; wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clampFPS(fps float64) float64 {
|
||||
switch {
|
||||
case fps <= 0:
|
||||
return defaultFPS
|
||||
case fps > 120:
|
||||
return 120
|
||||
default:
|
||||
return fps
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeDevice struct {
|
||||
mu sync.Mutex
|
||||
wrote [][]byte
|
||||
failAt int // fail after this many writes; <0 = never fail
|
||||
failErr error
|
||||
// onWrite is invoked after each successful write with the running count.
|
||||
onWrite func(n int)
|
||||
}
|
||||
|
||||
func (f *fakeDevice) WriteFrame(_ context.Context, frame []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failAt >= 0 && f.failAt == len(f.wrote) {
|
||||
if f.failErr == nil {
|
||||
f.failErr = errors.New("boom")
|
||||
}
|
||||
return f.failErr
|
||||
}
|
||||
cp := make([]byte, len(frame))
|
||||
copy(cp, frame)
|
||||
f.wrote = append(f.wrote, cp)
|
||||
if f.onWrite != nil {
|
||||
f.onWrite(len(f.wrote))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDevice) Close() error { return nil }
|
||||
|
||||
func (f *fakeDevice) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.wrote)
|
||||
}
|
||||
|
||||
func TestStreamerPreservesOrder(t *testing.T) {
|
||||
frames := [][]byte{{1, 2, 3}, {4, 5}, {6}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := &fakeDevice{
|
||||
failAt: -1,
|
||||
onWrite: func(n int) {
|
||||
if n == len(frames) {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
s := New(f, frames, 1000, 1)
|
||||
|
||||
if err := s.Run(ctx); err != nil {
|
||||
t.Fatalf("Run error = %v", err)
|
||||
}
|
||||
|
||||
if f.count() != 3 {
|
||||
t.Fatalf("wrote %d frames, want 3", f.count())
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, want := range frames {
|
||||
if len(f.wrote[i]) != len(want) {
|
||||
t.Errorf("frame %d length = %d, want %d", i, len(f.wrote[i]), len(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamerStopsOnCancel(t *testing.T) {
|
||||
f := &fakeDevice{failAt: -1}
|
||||
s := New(f, [][]byte{{1}}, 100, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := s.Run(ctx); err != nil {
|
||||
t.Fatalf("Run error = %v, want nil on clean shutdown", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamerPropagatesDeviceError(t *testing.T) {
|
||||
f := &fakeDevice{failAt: 1}
|
||||
s := New(f, [][]byte{{1}, {2}}, 1000, 1)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.Run(ctx); err == nil {
|
||||
t.Fatal("Run returned nil, want device error")
|
||||
}
|
||||
if f.count() != 1 {
|
||||
t.Errorf("wrote %d frames before error, want 1", f.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamerNoFrames(t *testing.T) {
|
||||
f := &fakeDevice{failAt: -1}
|
||||
s := New(f, nil, 30, 1)
|
||||
if err := s.Run(context.Background()); err == nil {
|
||||
t.Error("Run returned nil, want error for empty packet list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamerPacing(t *testing.T) {
|
||||
// 10 fps => 100ms per frame. Two frames should take ~200ms.
|
||||
f := &fakeDevice{failAt: -1}
|
||||
s := New(f, [][]byte{{1}, {2}}, 10, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
start := time.Now()
|
||||
go func() {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_ = s.Run(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// At 10fps each frame takes 100ms; after 400ms we expect ~4 frames.
|
||||
if f.count() < 2 {
|
||||
t.Errorf("too few frames streamed: %d", f.count())
|
||||
}
|
||||
if elapsed < 150*time.Millisecond {
|
||||
t.Errorf("elapsed = %v, pacing too fast", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPacketsMissingFile(t *testing.T) {
|
||||
if _, err := LoadPackets("/nonexistent/cache.h264"); err == nil {
|
||||
t.Error("LoadPackets returned nil error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampFPS(t *testing.T) {
|
||||
if got := clampFPS(0); got != defaultFPS {
|
||||
t.Errorf("clampFPS(0) = %v, want %v", got, defaultFPS)
|
||||
}
|
||||
if got := clampFPS(300); got != 120 {
|
||||
t.Errorf("clampFPS(300) = %v, want 120", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user