// 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 }