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:
Maksim Totmin
2026-08-19 12:03:54 +07:00
commit 4c34f0bedc
33 changed files with 2432 additions and 0 deletions
+86
View File
@@ -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
}
}
+143
View File
@@ -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)
}
}