// Command teleportfling is a standalone sender for the Teleport protocol. // // It captures a Wayland screen (PipeWire via xdg-desktop-portal) plus the // system's default audio output and streams them over TCP as the Teleport // protocol, announcing 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] [--quality 1..100] // [--fps N] [--source screen|pattern] [--audio] // [--stream-index N] [--duration SECONDS] // // --source pattern selects the M1 synthetic test pattern (colour bars with a // moving box) instead of real screen capture, which is useful for testing // without granting screen-share permission. package main import ( "errors" "flag" "image" "image/color" "io" "log" "os" "os/signal" "strconv" "sync/atomic" "syscall" "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 ) func main() { var ( name = flag.String("name", "", "announce name (default: hostname)") port = flag.Int("port", 9756, "TCP listening port") quality = flag.Int("quality", 80, "JPEG quality 1..100") fps = flag.Int("fps", 30, "video frames per second") source = flag.String("source", "screen", "capture source: screen or pattern") withAudio = flag.Bool("audio", true, "capture and stream system audio") streamIndex = flag.Int("stream-index", 0, "monitor index to capture (screen source)") 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) var ( totalFrames atomic.Int64 encoder = mustNewEncoder() start = time.Now() stop = make(chan struct{}) ) var ( cam capture.Capture loop frameSource ) switch *source { case "screen": cam, err = capture.OpenPipeWire(*streamIndex, *withAudio) if err != nil { log.Fatalf("capture: %v", err) } loop = captureLoop{cam} log.Printf("teleportfling: advertising on %d, capturing screen via PipeWire", p) case "pattern": loop = &patternLoop{ w: 1920, h: 1080, fps: *fps, } log.Printf("teleportfling: advertising on %d, streaming test pattern", p) default: log.Fatalf("teleportfling: unknown source %q (want screen or pattern)", *source) } // Interrupt / SIGTERM handling. sigc := make(chan os.Signal, 1) signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) // — Audio loop — audioDone := make(chan struct{}) go func() { defer close(audioDone) var src io.ReadCloser switch { case cam == nil: // Pattern source: synthesize silence to keep the audio pipeline alive. src = capture.NewSilenceSource() case cam.Audio() != nil: src = cam.Audio() default: log.Printf("teleportfling: system audio unavailable, streaming silence") src = capture.NewSilenceSource() } defer func() { _ = src.Close() }() audioLoop(sender, src, start, stop) }() // — Video loop — videoDone := make(chan struct{}) go func() { defer close(videoDone) videoLoop(sender, encoder, loop, *fps, *quality, start, stop, &totalFrames) }() // — 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(*duration, func() { close(ch) }) return ch } return nil }(): log.Printf("teleportfling: duration reached") } close(stop) <-audioDone <-videoDone <-statsDone announcer.Stop() sender.Close() encoder.Close() if cam != nil { if err := cam.Close(); err != nil { log.Printf("teleportfling: capture close: %v", err) } } log.Printf("teleportfling: stopped after %s", time.Since(start).Round(time.Millisecond)) } // audioLoop reads raw PCM from src and emits WAVE packets in audioChunk-sized // pieces. PCM is assumed interleaved signed-16-bit at sampleRate/speakers. // // start is the shared reference clock used by the video loop: audio and video // timestamps must share one time base, otherwise a constant skew between them // makes OBS buffer one stream to re-sync the other, adding latency. func audioLoop(sender *output.Sender, src io.Reader, start time.Time, stop <-chan struct{}) { 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(start)) packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n]) if perr != nil { log.Printf("teleportfling: wave: %v", perr) } else { sender.Send(packet) } } if err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { log.Printf("teleportfling: audio: %v", err) } select { case <-stop: return default: } } } } // videoLoop pulls frames from loop and sends them at fps, encoding each to // JPEG with the given quality. func videoLoop(sender *output.Sender, encoder *protocol.JPEGEncoder, loop frameSource, fps, quality int, start time.Time, stop <-chan struct{}, total *atomic.Int64) { frameInterval := time.Second / time.Duration(fps) next := start for { frame, err := loop.Next() if err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) { log.Printf("teleportfling: capture: %v", err) } select { case <-stop: return default: } continue } // Drop frames if we're running ahead of the target fps (e.g. a 60 Hz // monitor captured at 30 fps) to keep timestamps monotonic. now := time.Now() if now.Before(next) { continue } next = now.Add(frameInterval) ts := uint64(now.Sub(start)) buf, err := encodeFrame(encoder, frame, quality) if err != nil { log.Printf("teleportfling: 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("teleportfling: packet: %v", err) continue } sender.Send(packet) total.Add(1) } } // encodeFrame compresses a captured frame based on its concrete type. func encodeFrame(encoder *protocol.JPEGEncoder, frame *capture.VideoFrame, quality int) ([]byte, error) { if frame.Pix != nil { // BGRA from the PipeWire backend. return encoder.EncodeBGRA(frame.Pix, frame.Width, frame.Height, quality) } return nil, errors.New("capture: unsupported frame type") } // 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 fps 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 } // 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. 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 }