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
+85
View File
@@ -0,0 +1,85 @@
// Package capture abstracts the screen and audio sources that feed the
// Teleport sender. Implementations are platform-specific; the PipeWire
// backend (pipewire.go) handles Wayland via xdg-desktop-portal.
//
// The core only knows about two things: a stream of video frames and a
// stream of audio samples. Everything else (formats, portal negotiation)
// stays behind this interface so the sender can later run headless or be
// driven by a GUI without coupling.
package capture
import (
"io"
"time"
)
// VideoFrame is one captured screen frame. Pix holds BGRA (blue, green,
// red, alpha) bytes in row-major order; Stride is the byte offset between
// consecutive rows.
type VideoFrame struct {
Pix []byte
Width int
Height int
Stride int
}
// Capture is the combined screen + audio source. Close releases the
// underlying capture session (and portal resources).
type Capture interface {
// Video returns the frame source. Frames arrive at the compositor's
// refresh rate and are consumed one at a time via NextFrame.
Video() FrameSource
// Audio returns the audio source, or nil if audio capture is disabled
// or unavailable.
Audio() AudioSource
io.Closer
}
// FrameSource yields consecutive captured video frames.
type FrameSource interface {
// NextFrame blocks until the next frame is available and returns it.
NextFrame() (*VideoFrame, error)
}
// AudioSource yields raw interleaved PCM samples (signed 16-bit
// little-endian, 48 kHz, stereo) read from the system's default output.
type AudioSource interface {
io.ReadCloser
}
// ErrNoAudio is returned when the underlying backend cannot provide system
// audio capture (e.g. sandboxed Flatpak without a direct PipeWire link).
var ErrNoAudio = &AudioUnavailableError{}
// AudioUnavailableError signals that audio capture could not be started.
type AudioUnavailableError struct{}
func (e *AudioUnavailableError) Error() string {
return "capture: system audio unavailable"
}
// silenceStep is the pacing interval between silence chunk reads.
const silenceStep = 10 * time.Millisecond
// SilenceSource yields a continuous stream of digital silence, paced like a
// real audio capture. It keeps OBS's audio pipeline alive when no system
// audio is available or a synthetic source is in use.
type silenceSource struct{}
// NewSilenceSource creates a silence-generating audio source.
func NewSilenceSource() AudioSource {
return &silenceSource{}
}
func (s *silenceSource) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
// Emit a chunk of silence at a real-audio cadence. The reader is 1:1
// stereo S16, so 48000 * 10ms * 2ch * 2 bytes = 1920 bytes per read.
clear(p)
time.Sleep(silenceStep)
return len(p), nil
}
func (s *silenceSource) Close() error { return nil }
+84
View File
@@ -0,0 +1,84 @@
// Package capture provides the PipeWire backend for Wayland screen + audio
// capture.
//
// This uses go2tv.app/screencast (MIT) which implements the full
// xdg-desktop-portal ScreenCast negotiation and then receives frames over
// PipeWire. The portal session is responsible for the screen-share consent
// dialog presented by the compositor (Hyprland in our dev environment).
//
// Frames arrive as raw BGRA at the monitor's native resolution/refresh rate.
// Audio is signed 16-bit, 48 kHz, stereo interleaved PCM from the system's
// default output.
package capture
import (
"errors"
"io"
"go2tv.app/screencast/capture"
)
// PipeWire implements Capture on top of the screencast library.
type PipeWire struct {
stream *capture.Stream
}
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which
// monitor to capture when multiple are present. Triggering the portal
// consent dialog is expected; the compositor decides whether to show it.
func OpenPipeWire(streamIndex int, audio bool) (*PipeWire, error) {
s, err := capture.Open(&capture.Options{
StreamIndex: streamIndex,
IncludeAudio: audio,
})
if err != nil {
return nil, err
}
return &PipeWire{stream: s}, nil
}
// Video returns the BGRA frame source.
func (p *PipeWire) Video() FrameSource {
return &pipewireVideo{stream: p.stream}
}
// Audio returns the system audio source, or nil if unavailable.
func (p *PipeWire) Audio() AudioSource {
if p.stream.Audio == nil {
return nil
}
return p.stream.Audio
}
// Close releases the capture session and portal resources.
func (p *PipeWire) Close() error {
return p.stream.Close()
}
// pipewireVideo adapts the screencast io.ReadCloser into FrameSource.
type pipewireVideo struct {
stream *capture.Stream
frame *VideoFrame
}
// NextFrame blocks until the next full frame is delivered. The screencast
// library writes one complete BGRA frame per Read, so we assemble it with
// ReadFull and reuse the underlying buffer across calls.
func (v *pipewireVideo) NextFrame() (*VideoFrame, error) {
w, h := int(v.stream.Width), int(v.stream.Height)
if v.frame == nil {
v.frame = &VideoFrame{
Pix: make([]byte, w*h*4),
Width: w,
Height: h,
Stride: w * 4,
}
}
if _, err := io.ReadFull(v.stream, v.frame.Pix); err != nil {
if errors.Is(err, io.EOF) {
return nil, io.ErrClosedPipe
}
return nil, err
}
return v.frame, nil
}