refactor: extract streaming engine into internal/flinger
Move the capture/encode/send pipeline out of cmd/teleportfling into a GUI-free Engine so the CLI and the desktop app share one implementation: - flinger: Config/Engine/Status, audio+video+stats loops, pattern source - config: JSON persistence at ~/.config/teleportfling/config.json - cmd/teleportfling: thin CLI wrapper around the engine (same flags) - golangci: extend exclusions to flinger/config
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
// Package flinger implements the TeleportFling streaming engine: it captures
|
||||
// a screen (and optionally system audio), encodes video to JPEG, packetizes
|
||||
// both into the Teleport protocol, and broadcasts them to connected OBS
|
||||
// receivers.
|
||||
//
|
||||
// The engine is deliberately GUI-free so it can run headless (CLI/daemon) or
|
||||
// be driven by a desktop app. Callers create an Engine with a Config, call
|
||||
// Start, poll Status, and call Stop when done.
|
||||
package flinger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"teleportfling/internal/capture"
|
||||
"teleportfling/internal/discovery"
|
||||
"teleportfling/internal/output"
|
||||
"teleportfling/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
// sampleRate and speakers describe the captured/encoded audio stream.
|
||||
sampleRate = 48000
|
||||
speakers = 2
|
||||
// audioChunk sets how much audio we packetize per WAVE message (~10 ms).
|
||||
audioChunk = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// Config configures the streaming engine.
|
||||
type Config struct {
|
||||
// Name is the announce name advertised to receivers (empty → hostname).
|
||||
Name string
|
||||
// Port is the TCP listening port for receivers.
|
||||
Port int
|
||||
// Quality is the JPEG quality, 1–100.
|
||||
Quality int
|
||||
// FPS is the target video frame rate.
|
||||
FPS int
|
||||
// Source is "screen" (PipeWire) or "pattern" (synthetic test signal).
|
||||
Source string
|
||||
// Audio enables system audio capture and streaming.
|
||||
Audio bool
|
||||
// StreamIndex selects which monitor to capture (screen source only).
|
||||
StreamIndex int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the recommended defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: 9756,
|
||||
Quality: 80,
|
||||
FPS: 30,
|
||||
Source: "screen",
|
||||
Audio: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Status is a point-in-time snapshot of the running engine.
|
||||
type Status struct {
|
||||
Running bool
|
||||
Frames int64
|
||||
Conns int
|
||||
}
|
||||
|
||||
// Engine owns the capture, encode and send pipeline.
|
||||
type Engine struct {
|
||||
cfg Config
|
||||
|
||||
sender *output.Sender
|
||||
announcer *discovery.Announcer
|
||||
encoder *protocol.JPEGEncoder
|
||||
cam capture.Capture
|
||||
loop frameSource
|
||||
|
||||
start time.Time
|
||||
stop chan struct{}
|
||||
|
||||
frames atomic.Int64
|
||||
}
|
||||
|
||||
// New creates an engine from cfg. Capture is opened eagerly so that
|
||||
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
|
||||
func New(cfg Config) (*Engine, error) {
|
||||
e := &Engine{cfg: cfg}
|
||||
|
||||
sender := output.New()
|
||||
if _, err := sender.Listen(addr(cfg.Port)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enc, err := protocol.NewJPEGEncoder()
|
||||
if err != nil {
|
||||
sender.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.sender = sender
|
||||
e.encoder = enc
|
||||
|
||||
switch cfg.Source {
|
||||
case "screen":
|
||||
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio)
|
||||
if err != nil {
|
||||
e.encoder.Close()
|
||||
sender.Close()
|
||||
return nil, err
|
||||
}
|
||||
e.cam = cam
|
||||
e.loop = captureLoop{cam}
|
||||
case "pattern":
|
||||
e.loop = &patternLoop{w: 1920, h: 1080}
|
||||
default:
|
||||
e.encoder.Close()
|
||||
sender.Close()
|
||||
return nil, errors.New("unknown source " + cfg.Source)
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Start begins the audio, video and stats loops and starts announcing the
|
||||
// stream. It is idempotent.
|
||||
func (e *Engine) Start() {
|
||||
if e.stop != nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.start = time.Now()
|
||||
e.stop = make(chan struct{})
|
||||
|
||||
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
|
||||
|
||||
var src io.ReadCloser
|
||||
switch {
|
||||
case e.cam == nil:
|
||||
src = capture.NewSilenceSource()
|
||||
case e.cam.Audio() != nil:
|
||||
src = e.cam.Audio()
|
||||
default:
|
||||
log.Printf("flinger: system audio unavailable, streaming silence")
|
||||
src = capture.NewSilenceSource()
|
||||
}
|
||||
|
||||
go e.audioLoop(src)
|
||||
go e.videoLoop()
|
||||
go e.statsLoop()
|
||||
}
|
||||
|
||||
// Stop halts the loops, stops announcing and closes all resources. It is
|
||||
// idempotent. After Stop the engine must not be restarted.
|
||||
func (e *Engine) Stop() {
|
||||
if e.stop == nil {
|
||||
return
|
||||
}
|
||||
|
||||
close(e.stop)
|
||||
// Give the loops a moment to observe the stop signal.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if e.announcer != nil {
|
||||
e.announcer.Stop()
|
||||
}
|
||||
e.sender.Close()
|
||||
e.encoder.Close()
|
||||
if e.cam != nil {
|
||||
if err := e.cam.Close(); err != nil {
|
||||
log.Printf("flinger: capture close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the running engine.
|
||||
func (e *Engine) Status() Status {
|
||||
return Status{
|
||||
Running: e.stop != nil,
|
||||
Frames: e.frames.Load(),
|
||||
Conns: e.sender.NumConns(),
|
||||
}
|
||||
}
|
||||
|
||||
// audioLoop reads raw PCM and emits WAVE packets. start is the shared
|
||||
// reference clock used by the video loop so audio and video timestamps stay
|
||||
// aligned on the receiver.
|
||||
func (e *Engine) audioLoop(src io.ReadCloser) {
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
framesPerChunk := int(sampleRate) * int(audioChunk) / int(time.Second)
|
||||
chunkBytes := framesPerChunk * speakers * 2 // S16
|
||||
buf := make([]byte, chunkBytes)
|
||||
|
||||
for {
|
||||
n, err := io.ReadFull(src, buf)
|
||||
if n > 0 {
|
||||
frames := n / (speakers * 2)
|
||||
ts := uint64(time.Since(e.start))
|
||||
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
|
||||
if perr != nil {
|
||||
log.Printf("flinger: wave: %v", perr)
|
||||
} else {
|
||||
e.sender.Send(packet)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case <-e.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
||||
log.Printf("flinger: audio: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// videoLoop pulls frames and sends them at the configured fps.
|
||||
func (e *Engine) videoLoop() {
|
||||
frameInterval := time.Second / time.Duration(e.cfg.FPS)
|
||||
next := e.start
|
||||
|
||||
for {
|
||||
frame, err := e.loop.Next()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-e.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
|
||||
log.Printf("flinger: capture: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop frames when running ahead of the target fps to keep
|
||||
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
|
||||
now := time.Now()
|
||||
if now.Before(next) {
|
||||
continue
|
||||
}
|
||||
next = now.Add(frameInterval)
|
||||
|
||||
ts := uint64(now.Sub(e.start))
|
||||
buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, e.cfg.Quality)
|
||||
if err != nil {
|
||||
log.Printf("flinger: jpeg: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
packet, err := protocol.WritePacket(
|
||||
protocol.Header{Type: protocol.VideoType, Timestamp: ts, Size: int32(len(buf))},
|
||||
ptr(protocol.DefaultBT709Full()),
|
||||
nil,
|
||||
buf,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("flinger: packet: %v", err)
|
||||
continue
|
||||
}
|
||||
e.sender.Send(packet)
|
||||
e.frames.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// statsLoop logs a periodic summary.
|
||||
func (e *Engine) statsLoop() {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
st := e.Status()
|
||||
log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns)
|
||||
case <-e.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameSource abstracts the frame source: real capture or the test pattern.
|
||||
type frameSource interface {
|
||||
Next() (*capture.VideoFrame, error)
|
||||
}
|
||||
|
||||
// captureLoop wraps the PipeWire capture backend.
|
||||
type captureLoop struct {
|
||||
cam capture.Capture
|
||||
}
|
||||
|
||||
func (c captureLoop) Next() (*capture.VideoFrame, error) {
|
||||
return c.cam.Video().NextFrame()
|
||||
}
|
||||
|
||||
// patternLoop synthesizes the M1 test pattern (colour bars + moving box).
|
||||
type patternLoop struct {
|
||||
w, h int
|
||||
seq int64
|
||||
}
|
||||
|
||||
func (p *patternLoop) Next() (*capture.VideoFrame, error) {
|
||||
img := testPattern(p.w, p.h, int(p.seq))
|
||||
p.seq++
|
||||
return ycrcbToBGRA(img), nil
|
||||
}
|
||||
|
||||
// ycrcbToBGRA converts a YCbCr image to a BGRA VideoFrame so both sources
|
||||
// share the encode path (EncodeBGRA).
|
||||
func ycrcbToBGRA(img *image.YCbCr) *capture.VideoFrame {
|
||||
w, h := img.Rect.Dx(), img.Rect.Dy()
|
||||
frame := &capture.VideoFrame{
|
||||
Pix: make([]byte, w*h*4),
|
||||
Width: w,
|
||||
Height: h,
|
||||
Stride: w * 4,
|
||||
}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
yi := y*img.YStride + x
|
||||
ci := (y/2)*img.CStride + x/2
|
||||
r, g, b := color.YCbCrToRGB(img.Y[yi], img.Cb[ci], img.Cr[ci])
|
||||
off := (y*w + x) * 4
|
||||
frame.Pix[off], frame.Pix[off+1], frame.Pix[off+2], frame.Pix[off+3] = b, g, r, 255
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// ptr returns a pointer to v, for passing headers to WritePacket.
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// addr formats a port as a listen address.
|
||||
func addr(port int) string {
|
||||
return ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// testPattern renders a standard SMPTE colour bar with a moving white box at
|
||||
// the given frame index.
|
||||
func testPattern(w, h, frame int) *image.YCbCr {
|
||||
img := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
|
||||
|
||||
// 7 vertical colour bars (grey, yellow, cyan, green, magenta, red, blue).
|
||||
bars := []color.RGBA{
|
||||
{R: 191, G: 191, B: 191}, // 75% grey
|
||||
{R: 191, G: 191, B: 0}, // yellow
|
||||
{R: 0, G: 191, B: 191}, // cyan
|
||||
{R: 0, G: 191, B: 0}, // green
|
||||
{R: 191, G: 0, B: 191}, // magenta
|
||||
{R: 191, G: 0, B: 0}, // red
|
||||
{R: 0, G: 0, B: 191}, // blue
|
||||
}
|
||||
|
||||
const barCount = 7
|
||||
barW := w / barCount
|
||||
const boxSize = 80
|
||||
|
||||
// Moving white box sweeps left→right across the lower black block.
|
||||
boxMinX := (frame*(w+boxSize)/120)%(w+boxSize) - boxSize/2
|
||||
|
||||
buf := make([]color.RGBA, w*h)
|
||||
for by := 0; by < h; by++ {
|
||||
rowIsBars := by < h*2/3
|
||||
for bx := 0; bx < w; bx++ {
|
||||
var c color.RGBA
|
||||
switch {
|
||||
case rowIsBars:
|
||||
idx := bx / barW
|
||||
if idx >= barCount {
|
||||
idx = barCount - 1
|
||||
}
|
||||
c = bars[idx]
|
||||
case by%8 < 4 && bx > w/3 && bx < w*2/3:
|
||||
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
default:
|
||||
c = color.RGBA{}
|
||||
}
|
||||
if bx >= boxMinX && bx < boxMinX+boxSize && by >= h*2/3 {
|
||||
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
}
|
||||
buf[by*w+bx] = c
|
||||
}
|
||||
}
|
||||
|
||||
// Chroma planes: average each 2x2 RGB block, then convert to Cb/Cr.
|
||||
for by := 0; by < h; by += 2 {
|
||||
for bx := 0; bx < w; bx += 2 {
|
||||
var rSum, gSum, bSum uint32
|
||||
n := uint32(0)
|
||||
for dy := 0; dy < 2; dy++ {
|
||||
for dx := 0; dx < 2; dx++ {
|
||||
xx, yy := bx+dx, by+dy
|
||||
if xx >= w || yy >= h {
|
||||
continue
|
||||
}
|
||||
px := buf[yy*w+xx]
|
||||
rSum += uint32(px.R)
|
||||
gSum += uint32(px.G)
|
||||
bSum += uint32(px.B)
|
||||
n++
|
||||
}
|
||||
}
|
||||
_, cb, cr := color.RGBToYCbCr(uint8(rSum/n), uint8(gSum/n), uint8(bSum/n))
|
||||
img.Cb[(by/2)*img.CStride+bx/2] = cb
|
||||
img.Cr[(by/2)*img.CStride+bx/2] = cr
|
||||
}
|
||||
}
|
||||
|
||||
// Luma plane: Y = YCbCr luma of every pixel.
|
||||
for by := 0; by < h; by++ {
|
||||
for bx := 0; bx < w; bx++ {
|
||||
px := buf[by*w+bx]
|
||||
y, _, _ := color.RGBToYCbCr(px.R, px.G, px.B)
|
||||
img.Y[by*img.YStride+bx] = y
|
||||
}
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
Reference in New Issue
Block a user