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.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user