feat: add PipeWire screen and system audio capture (M2)

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.
This commit is contained in:
2026-09-18 19:19:22 +01:00
parent b6e7485786
commit 0cb96b5792
7 changed files with 489 additions and 102 deletions
+41
View File
@@ -74,6 +74,47 @@ func (e *JPEGEncoder) Encode(img image.Image, quality int) ([]byte, error) {
}
}
// EncodeBGRA compresses a raw BGRA (blue, green, red, alpha) pixel buffer of
// the given dimensions. This is the fast path for the PipeWire screen-capture
// backend, which delivers frames in BGRA byte order. Subsampling defaults to
// 4:2:0 (a YCbCr JPEG), so the OBS receiver decodes it as I420 — the same
// layout obs-teleport produces for OBS-native YCbCr frames.
func (e *JPEGEncoder) EncodeBGRA(pix []byte, width, height, quality int) ([]byte, error) {
if quality < 1 {
quality = 1
}
if quality > 100 {
quality = 100
}
want := width * height * 4
if len(pix) < want {
return nil, errors.New("turbojpeg: BGRA buffer too small")
}
C.tj3Set(e.ctx, C.TJPARAM_QUALITY, C.int(quality))
C.tj3Set(e.ctx, C.TJPARAM_SUBSAMP, C.TJSAMP_420)
C.tj3Set(e.ctx, C.TJPARAM_COLORSPACE, C.TJCS_YCbCr)
size := C.tj3JPEGBufSize(C.int(width), C.int(height), C.TJSAMP_420)
buf := make([]byte, int(size))
srcPtr := unsafe.Pointer(&pix[0])
dstPtr := (*C.uchar)(&buf[0])
var pin runtime.Pinner
pin.Pin(srcPtr)
pin.Pin(dstPtr)
defer pin.Unpin()
jpegSize := size
rc := C.tj3Compress8(e.ctx, (*C.uchar)(srcPtr), C.int(width), 0, C.int(height), C.TJPF_BGRA, &dstPtr, &jpegSize)
if rc != 0 {
return nil, errors.New("turbojpeg BGRA compress failed")
}
return buf[:int(jpegSize)], nil
}
// encodeRGBA compresses a Go RGBA image (pixel layout [R,G,B,A] per 4 bytes).
// TJPF_RGBA tells turbojpeg the exact layout; colourspace is RGB.
func (e *JPEGEncoder) encodeRGBA(img *image.RGBA) ([]byte, error) {
+51
View File
@@ -120,3 +120,54 @@ func TestJPEGEncodeQualityClamp(t *testing.T) {
}
}
}
// TestJPEGEncodeBGRA checks the raw-BGRA fast path produces a decodable
// JPEG with correct SOI/EOI markers.
func TestJPEGEncodeBGRA(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
// 16x8 BGRA: top half red (R,G,B=255,0,0), bottom half blue (0,0,255).
w, h := 16, 8
pix := make([]byte, w*h*4)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
off := (y*w + x) * 4
if y < h/2 {
pix[off], pix[off+1], pix[off+2], pix[off+3] = 0, 0, 255, 255 // B,G,R,A = blue,red
} else {
pix[off], pix[off+1], pix[off+2], pix[off+3] = 255, 0, 0, 255 // B,G,R,A = red,blue
}
}
}
buf, err := enc.EncodeBGRA(pix, w, h, 85)
if err != nil {
t.Fatalf("EncodeBGRA: %v", err)
}
if len(buf) == 0 {
t.Fatal("empty JPEG output")
}
if !bytes.Equal(buf[:2], []byte{0xFF, 0xD8}) {
t.Errorf("bad JPEG SOI marker: %x", buf[:2])
}
if !bytes.Equal(buf[len(buf)-2:], []byte{0xFF, 0xD9}) {
t.Errorf("bad JPEG EOI marker")
}
}
// TestJPEGEncodeBGRAShort rejects truncated pixel buffers.
func TestJPEGEncodeBGRAShort(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
if _, err := enc.EncodeBGRA(make([]byte, 10), 16, 8, 80); err == nil {
t.Error("short BGRA buffer accepted")
}
}