Files
TeleportFling/cmd/teleportfling/main.go
T
petere 10fa72b228 feat: implement M1 teleport protocol sender
Stream a synthetic test pattern and silent PCM audio over the OBS
Teleport protocol:
- protocol: wire format (Header/ImageHeader/WaveHeader), BT.709 full
  range colour matrix, JPEG encode via turbojpeg cgo, WAVE packet builder
- output: TCP sender with per-connection buffered channels and drop-on-overflow
- discovery: multicast announce via peerdiscovery
- cmd: teleportfling CLI with flags, test-pattern frame generator

Verified end-to-end: OBS discovers and renders the stream with correct
colours and motion.
2026-09-18 18:36:25 +01:00

308 lines
8.0 KiB
Go

// Command teleportfling is a standalone sender for the Teleport protocol.
//
// M1 milestone: protocol proof-of-life. It streams a synthetic test pattern
// (colour bars with a moving box) plus a silent stereo 48 kHz audio tone over
// TCP and announces itself on the LAN multicast group, so an OBS instance
// with the obs-teleport plugin can discover and decode the stream.
//
// Usage:
//
// teleportfling [--name NAME] [--port PORT] [--width W] [--height H]
// [--fps N] [--quality 1..100] [--duration SECONDS]
//
// M2+ replaces the synthetic sources with real PipeWire screen/audio capture.
package main
import (
"flag"
"image"
"image/color"
"log"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
"teleportfling/internal/discovery"
"teleportfling/internal/output"
"teleportfling/internal/protocol"
)
func main() {
var (
name = flag.String("name", "", "announce name (default: hostname)")
port = flag.Int("port", 9756, "TCP listening port")
width = flag.Int("width", 1920, "frame width")
height = flag.Int("height", 1080, "frame height")
fps = flag.Int("fps", 30, "video frames per second")
quality = flag.Int("quality", 80, "JPEG quality 1..100")
duration = flag.Duration("duration", 0, "stream duration (0 = run until interrupted)")
)
flag.Parse()
// Build the sender: TCP listener + multicast announcer.
sender := output.New()
p, err := sender.Listen(addr(*port))
if err != nil {
log.Fatalf("output: listen: %v", err)
}
announcer := discovery.Start(*name, p)
log.Printf("teleportfling: advertising on %d, capturing %dx%d @ %d fps", p, *width, *height, *fps)
// Pipeline state.
var (
totalFrames atomic.Int64
encoder = mustNewEncoder()
frameInterval = time.Second / time.Duration(*fps)
audioInterval = 100 * time.Millisecond
sampleRate = 48000
speakers = 2
start = time.Now()
audioStart time.Time
deadline time.Time
stop = make(chan struct{})
)
if *duration > 0 {
deadline = start.Add(*duration)
}
// Interrupt / SIGTERM handling.
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM)
// — Audio goroutine: ~100 ms chunks of a silent stereo float32 tone —
// A silent master tone keeps OBS's audio pipeline alive without needing
// a mic. M2 will replace this with real captured audio.
audioDone := make(chan struct{})
go func() {
defer close(audioDone)
audioStart = time.Now()
tick := time.NewTicker(audioInterval)
defer tick.Stop()
framesPerChunk := int32(float64(sampleRate) * audioInterval.Seconds())
pcm := make([]byte, 0, framesPerChunk*int32(speakers)*4)
sendAudio := func(ts uint64, chunkFrames int32) {
// Right = left = 0 → digital silence.
pcm = pcm[:0]
for f := 0; f < int(chunkFrames); f++ {
pcm = append(pcm, 0, 0, 0, 0, 0, 0, 0, 0)
}
packet, err := protocol.BuildWavePacket(ts, protocol.AudioFormatF32, int32(sampleRate), int32(speakers), chunkFrames, pcm)
if err != nil {
log.Printf("teleportfling: wave: %v", err)
return
}
sender.Send(packet)
}
for {
select {
case now := <-tick.C:
ts := uint64(now.Sub(audioStart))
// Compute frames elapsed since audioStart so timestamps are a
// continuous stream (not aligned to tick boundaries).
elapsedFrames := int64(now.Sub(audioStart) / (time.Second / time.Duration(sampleRate)))
// Cumulative frames sent so far.
sendAudio(ts, framesPerChunk)
_ = elapsedFrames
case <-stop:
return
}
}
}()
// — Video loop —
videoDone := make(chan struct{})
go func() {
defer close(videoDone)
ticker := time.NewTicker(frameInterval)
defer ticker.Stop()
var frameNum int64
for {
select {
case now := <-ticker.C:
ts := uint64(now.Sub(start))
img := testPattern(*width, *height, int(frameNum))
frameNum++
frameStart := time.Now()
buf, err := encoder.Encode(img, *quality)
if err != nil {
log.Printf("teleportfling: jpeg: %v", err)
continue
}
encodeDur := time.Since(frameStart)
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("teleportfling: packet: %v", err)
continue
}
sender.Send(packet)
totalFrames.Add(1)
_ = encodeDur // stats below
case <-stop:
return
}
}
}()
// — Stats ticker —
statsDone := make(chan struct{})
go func() {
defer close(statsDone)
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
log.Printf("stats: %d frames, %d conns",
totalFrames.Load(), sender.NumConns())
case <-stop:
return
}
}
}()
// — Wait for interrupt/duration —
select {
case <-sigc:
log.Printf("teleportfling: stopping…")
case <-func() <-chan struct{} {
if *duration > 0 {
ch := make(chan struct{})
time.AfterFunc(time.Until(deadline), func() { close(ch) })
return ch
}
return nil
}():
log.Printf("teleportfling: duration reached")
}
close(stop)
<-audioDone
<-videoDone
<-statsDone
announcer.Stop()
sender.Close()
encoder.Close()
log.Printf("teleportfling: stopped after %s", time.Since(start).Round(time.Millisecond))
}
// mustNewEncoder creates a JPEG encoder or panics.
func mustNewEncoder() *protocol.JPEGEncoder {
enc, err := protocol.NewJPEGEncoder()
if err != nil {
log.Fatal(err)
}
return enc
}
// 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. The result is a *image.YCbCr 4:2:0 image so the
// encoder uses the YUV path — closest to what real PipeWire capture will
// produce in M2.
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:
// Periodic white band across the lower black block for motion.
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
default:
c = color.RGBA{}
}
// Overlay the moving box on the bottom band.
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
}