Capture the Wayland desktop via xdg-desktop-portal + PipeWire using go2tv.app/screencast (MIT), and stream it to OBS: - internal/capture: Capture/FrameSource/AudioSource interfaces and the PipeWire backend (BGRA frames at monitor resolution, S16 48 kHz stereo system audio) - protocol: EncodeBGRA fast path producing 4:2:0 YCbCr JPEGs - cmd: --source screen|pattern, --audio, --stream-index flags; real capture feeds the existing sender - share one wall-clock reference between the audio and video loops so OBS receives aligned A/V timestamps (avoids multi-second latency) Verified end-to-end: real desktop at 30 fps renders in OBS with sub-second latency.
426 lines
11 KiB
Go
426 lines
11 KiB
Go
// 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
|
|
}
|