Files
TeleportFling/internal/flinger/flinger.go
T
petere 3b3355e002 feat: selectable audio source
Add an audio source picker to the GUI so users can capture a specific
PipeWire device (sink monitor or microphone) instead of only the system
default output.

- vendor go2tv.app/screencast and patch the audio stream to accept a
  target PipeWire node serial (PW_KEY_TARGET_OBJECT); the upstream lib
  only ever auto-connected to the default
- capture: ListAudioSources enumerates PipeWire sinks/sources via pw-dump;
  OpenPipeWire takes the selected node serial
- flinger/config/gui: AudioSource config field, persisted and exposed as
  an Audio source dropdown (default output + enumerated devices)

Also fixes two pre-existing bugs surfaced by stop/start testing:
- engine Stop now waits for the video/audio/stats goroutines before
  destroying the encoder (was a use-after-free SIGSEGV)
- Start/Stop now pause/resume the engine instead of tearing down and
  re-opening the portal session, which the portal cannot reliably do
  in-process (2nd CreateSession returned Ended/cancelled). Capture
  session stays open across stop/start.
2026-09-19 21:44:10 +01:00

736 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package flinger implements the TeleportFling streaming engine: it captures
// a screen (and optionally system audio), encodes video to JPEG, packetizes
// both into the Teleport protocol, and broadcasts them to connected OBS
// receivers.
//
// The engine is deliberately GUI-free so it can run headless (CLI/daemon) or
// be driven by a desktop app. Callers create an Engine with a Config, call
// Start, poll Status, and call Stop when done.
package flinger
import (
"errors"
"fmt"
"image"
"image/color"
"io"
"log"
"strconv"
"sync"
"sync/atomic"
"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
)
// Config configures the streaming engine.
type Config struct {
// Name is the announce name advertised to receivers (empty → hostname).
Name string
// Port is the TCP listening port for receivers.
Port int
// Quality is the JPEG quality, 1100.
Quality int
// FPS is the target video frame rate.
FPS int
// Source is "screen" (PipeWire) or "pattern" (synthetic test signal).
Source string
// Audio enables system audio capture and streaming.
Audio bool
// AudioSource is the PipeWire node serial to capture audio from (0 = system
// default output). Only applies when Audio is true and Source is "screen".
AudioSource uint64
// StreamIndex selects which monitor to capture (screen source only).
StreamIndex int
// Scale downscales the captured frame before encoding. 1.0 = native
// resolution, 0.5 = half width/height, etc. Must be in (0, 1].
Scale float64
// Announce controls whether the stream is advertised via UDP multicast.
// When disabled, receivers must connect by IP manually.
Announce bool
}
// DefaultConfig returns the recommended defaults.
func DefaultConfig() Config {
return Config{
Port: 9756,
Quality: 80,
FPS: 30,
Source: "screen",
Audio: true,
Scale: 1.0,
Announce: true,
}
}
// Validate checks the config for out-of-range or unsupported values.
func (c Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return errors.New("port must be 165535")
}
if c.Quality < 1 || c.Quality > 100 {
return errors.New("quality must be 1100")
}
if c.FPS < 1 || c.FPS > 240 {
return errors.New("fps must be 1240")
}
if c.Source != "screen" && c.Source != "pattern" {
return errors.New("source must be screen or pattern")
}
if c.StreamIndex < 0 {
return errors.New("stream index must be >= 0")
}
if c.Scale <= 0 || c.Scale > 1 {
return errors.New("scale must be in (0, 1]")
}
return nil
}
// Status is a point-in-time snapshot of the running engine.
type Status struct {
Running bool
Frames int64
Dropped int64
Conns int
// Bitrate is the measured stream bandwidth in bits per second, averaged
// over the previous measurement window.
Bitrate int64
// Err is the most recent runtime error encountered (capture, encode,
// packet or audio), or nil if the stream is healthy.
Err error
}
// Engine owns the capture, encode and send pipeline.
type Engine struct {
cfgMu sync.RWMutex
cfg Config
sender *output.Sender
announcer *discovery.Announcer
encoder *protocol.JPEGEncoder
cam capture.Capture
loop frameSource
start time.Time
stop chan struct{}
// running gates packet sending: when false the loops consume capture
// frames but do not transmit. This lets the GUI "stop" without tearing
// down the portal/PipeWire session (which the portal cannot reliably
// recreate in-process), so a subsequent Start just flips running on.
running atomic.Bool
frames atomic.Int64
// wg tracks the audio/video/stats goroutines so Stop can wait for them to
// finish before destroying shared resources (encoder, sender, capture).
wg sync.WaitGroup
// scaleBuf is the cached downscaled frame, reallocated only when the
// configured scale or source dimensions change.
scaleBuf *capture.VideoFrame
errMu sync.RWMutex
lastErr error
// bitrate tracking
bitMu sync.Mutex
bitLast time.Time
bitBytes int64
bitrate int64
}
// setErr records the most recent runtime error. Pass nil to clear it.
func (e *Engine) setErr(err error) {
e.errMu.Lock()
e.lastErr = err
e.errMu.Unlock()
}
// SetConfig updates engine settings live (FPS, quality, name, etc.) without
// restarting the stream. It validates the new config first; on error the
// engine keeps its current settings.
//
// Not every field is live-applicable mid-stream: source, port and
// stream-index still require a restart (they are ignored if changed).
func (e *Engine) SetConfig(cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
e.cfgMu.Lock()
defer e.cfgMu.Unlock()
// Fields that cannot change live keep their current values.
cfg.Source = e.cfg.Source
cfg.Port = e.cfg.Port
cfg.StreamIndex = e.cfg.StreamIndex
cfg.AudioSource = e.cfg.AudioSource
e.cfg = cfg
return nil
}
// getCfg returns a snapshot of the current config.
func (e *Engine) getCfg() Config {
e.cfgMu.RLock()
defer e.cfgMu.RUnlock()
return e.cfg
}
// New creates an engine from cfg. Capture is opened eagerly so that
// misconfiguration (e.g. no screen-share permission) surfaces before Start.
func New(cfg Config) (*Engine, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
e := &Engine{cfg: cfg}
sender := output.New()
if _, err := sender.Listen(addr(cfg.Port)); err != nil {
return nil, err
}
enc, err := protocol.NewJPEGEncoder()
if err != nil {
sender.Close()
return nil, err
}
e.sender = sender
e.encoder = enc
switch cfg.Source {
case "screen":
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio, cfg.AudioSource)
if err != nil {
e.encoder.Close()
sender.Close()
return nil, err
}
e.cam = cam
e.loop = captureLoop{cam}
case "pattern":
e.loop = &patternLoop{w: 1920, h: 1080}
default:
e.encoder.Close()
sender.Close()
return nil, errors.New("unknown source " + cfg.Source)
}
return e, nil
}
// Start begins the audio, video and stats loops and (unless disabled in the
// config) starts announcing the stream. It is idempotent.
func (e *Engine) Start() {
if e.stop != nil {
e.Resume()
return
}
e.start = time.Now()
e.stop = make(chan struct{})
e.running.Store(true)
if e.cfg.Announce {
e.announcer = discovery.Start(e.cfg.Name, e.sender.Port())
}
var src io.ReadCloser
switch {
case e.cam == nil:
src = capture.NewSilenceSource()
case e.cam.Audio() != nil:
src = e.cam.Audio()
default:
log.Printf("flinger: system audio unavailable, streaming silence")
src = capture.NewSilenceSource()
}
e.wg.Add(3)
go func() {
defer e.wg.Done()
e.audioLoop(src)
}()
go func() {
defer e.wg.Done()
e.videoLoop()
}()
go func() {
defer e.wg.Done()
e.statsLoop()
}()
}
// Stop pauses the stream: loops keep consuming capture frames but stop
// transmitting, and the portal/PipeWire session stays open so the engine can
// be resumed with Start without re-opening the portal (which the portal
// cannot reliably do in-process). Idempotent.
func (e *Engine) Stop() {
e.running.Store(false)
}
// Pause is an alias for Stop; kept for clarity at call sites.
func (e *Engine) Pause() { e.Stop() }
// Resume restarts transmission on a paused engine. It is a no-op if the
// engine was never started.
func (e *Engine) Resume() {
if e.stop == nil {
return
}
e.running.Store(true)
}
// Close fully tears down the engine: stops the loops, closes the portal
// session and frees the encoder/sender. After Close the engine must not be
// reused.
func (e *Engine) Close() {
if e.stop == nil {
return
}
close(e.stop)
// Closing the capture source unblocks the audio and video loops that may
// be stuck in a read. Do this before waiting so they can observe stop.
if e.cam != nil {
if err := e.cam.Close(); err != nil {
log.Printf("flinger: capture close: %v", err)
}
}
// Wait for the audio/video/stats goroutines to finish so they no longer
// touch the encoder or sender before we destroy them.
e.wg.Wait()
if e.announcer != nil {
e.announcer.Stop()
}
e.sender.Close()
e.encoder.Close()
}
// Status returns a snapshot of the running engine.
func (e *Engine) Status() Status {
e.errMu.RLock()
err := e.lastErr
e.errMu.RUnlock()
return Status{
Running: e.running.Load() && e.stop != nil,
Frames: e.frames.Load(),
Dropped: e.sender.Dropped(),
Conns: e.sender.NumConns(),
Bitrate: e.measureBitrate(),
Err: err,
}
}
// bitrateWindow is the sliding window over which bitrate is averaged.
const bitrateWindow = 2 * time.Second
// measureBitrate computes the current stream bitrate (bits/sec) over a
// sliding window. It is called from Status.
func (e *Engine) measureBitrate() int64 {
e.bitMu.Lock()
defer e.bitMu.Unlock()
now := time.Now()
bytes := e.sender.BytesSent()
if e.bitLast.IsZero() {
e.bitLast = now
e.bitBytes = bytes
return 0
}
elapsed := now.Sub(e.bitLast)
if elapsed < bitrateWindow {
return e.bitrate
}
// Bytes accumulated since the previous sample.
delta := bytes - e.bitBytes
e.bitrate = int64(float64(delta*8) / elapsed.Seconds())
e.bitLast = now
e.bitBytes = bytes
return e.bitrate
}
// audioLoop reads raw PCM and emits WAVE packets. start is the shared
// reference clock used by the video loop so audio and video timestamps stay
// aligned on the receiver.
//
// The PipeWire capture negotiates interleaved signed 16-bit stereo at 48 kHz
// (the teleportfling stream's negotiated Format is S16LE), which is exactly
// what the WAVE packets carry.
func (e *Engine) audioLoop(src io.ReadCloser) {
defer func() { _ = src.Close() }()
framesPerChunk := int(sampleRate) * int(audioChunk) / int(time.Second)
chunkBytes := framesPerChunk * speakers * 2 // S16
buf := make([]byte, chunkBytes)
for {
select {
case <-e.stop:
return
default:
}
n, err := io.ReadFull(src, buf)
if n > 0 {
if !e.running.Load() {
continue
}
frames := n / (speakers * 2)
ts := uint64(time.Since(e.start))
packet, perr := protocol.BuildWavePacket(ts, protocol.AudioFormatS16, sampleRate, speakers, int32(frames), buf[:n])
if perr != nil {
e.setErr(perr)
log.Printf("flinger: wave: %v", perr)
} else {
e.sender.Send(packet)
}
}
if err != nil {
select {
case <-e.stop:
return
default:
}
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
e.setErr(err)
log.Printf("flinger: audio: %v", err)
}
}
}
}
// scaleFrame downscales frame by the configured factor, returning the original
// when scale is 1.0 (native). The result is a cached buffer reused across
// frames, reallocated only when the dimensions or scale change.
func (e *Engine) scaleFrame(frame *capture.VideoFrame, scale float64) *capture.VideoFrame {
if scale >= 1.0 || frame == nil {
return frame
}
sw := int(float64(frame.Width) * scale)
sh := int(float64(frame.Height) * scale)
if sw < 1 {
sw = 1
}
if sh < 1 {
sh = 1
}
// Reuse the cached buffer if it matches the target size.
if e.scaleBuf == nil || e.scaleBuf.Width != sw || e.scaleBuf.Height != sh {
e.scaleBuf = &capture.VideoFrame{
Pix: make([]byte, sw*sh*4),
Width: sw,
Height: sh,
Stride: sw * 4,
}
}
scaleBGRA(frame, e.scaleBuf)
return e.scaleBuf
}
// scaleBGRA bilinearly downsamples an interleaved BGRA frame into dst.
func scaleBGRA(src, dst *capture.VideoFrame) {
sw, sh := float64(src.Width), float64(src.Height)
for y := 0; y < dst.Height; y++ {
srcY := (float64(y) + 0.5) * sh / float64(dst.Height)
y0 := int(srcY)
if y0 >= src.Height-1 {
y0 = src.Height - 2
}
yFrac := srcY - float64(y0)
row0 := y0 * src.Stride
row1 := (y0 + 1) * src.Stride
di := y * dst.Stride
for x := 0; x < dst.Width; x++ {
srcX := (float64(x) + 0.5) * sw / float64(dst.Width)
x0 := int(srcX)
if x0 >= src.Width-1 {
x0 = src.Width - 2
}
xFrac := srcX - float64(x0)
p00 := row0 + x0*4
p01 := row0 + (x0+1)*4
p10 := row1 + x0*4
p11 := row1 + (x0+1)*4
for c := 0; c < 4; c++ {
top := float64(src.Pix[p00+c])*(1-xFrac) + float64(src.Pix[p01+c])*xFrac
bot := float64(src.Pix[p10+c])*(1-xFrac) + float64(src.Pix[p11+c])*xFrac
dst.Pix[di+x*4+c] = uint8(top*(1-yFrac) + bot*yFrac)
}
}
}
}
// videoLoop pulls frames and sends them at the configured fps. The frame rate
// and JPEG quality are read from the live config so SetConfig takes effect
// without restarting.
func (e *Engine) videoLoop() {
frameInterval := time.Second / time.Duration(e.getCfg().FPS)
next := e.start
for {
select {
case <-e.stop:
return
default:
}
frame, err := e.loop.Next()
if err != nil {
select {
case <-e.stop:
return
default:
}
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
e.setErr(err)
log.Printf("flinger: capture: %v", err)
}
continue
}
// Re-read the live config each frame so FPS/quality changes apply
// immediately. When the interval changes, resync `next` to now.
cfg := e.getCfg()
interval := time.Second / time.Duration(cfg.FPS)
if interval != frameInterval {
frameInterval = interval
next = time.Now()
}
// Drop frames when running ahead of the target fps to keep
// timestamps monotonic (e.g. a 60 Hz monitor captured at 30 fps).
now := time.Now()
if now.Before(next) {
continue
}
next = now.Add(frameInterval)
// When paused (Stop), keep consuming frames to stay alive but do not
// encode or transmit. The portal session is left open so a subsequent
// Start can resume without re-opening the portal.
if !e.running.Load() {
continue
}
ts := uint64(now.Sub(e.start))
enc := e.scaleFrame(frame, cfg.Scale)
buf, err := e.encoder.EncodeBGRA(enc.Pix, enc.Width, enc.Height, cfg.Quality)
if err != nil {
e.setErr(err)
log.Printf("flinger: 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 {
e.setErr(err)
log.Printf("flinger: packet: %v", err)
continue
}
e.sender.Send(packet)
e.frames.Add(1)
}
}
// statsLoop logs a periodic summary.
func (e *Engine) statsLoop() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-tick.C:
st := e.Status()
rate := formatBitrate(st.Bitrate)
if st.Dropped > 0 {
log.Printf("flinger: %d frames, %d dropped, %d conns, %s", st.Frames, st.Dropped, st.Conns, rate)
} else {
log.Printf("flinger: %d frames, %d conns, %s", st.Frames, st.Conns, rate)
}
case <-e.stop:
return
}
}
}
// formatBitrate renders a bits/sec value in a human-readable form.
func formatBitrate(bps int64) string {
switch {
case bps >= 1_000_000:
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
case bps >= 1_000:
return fmt.Sprintf("%.0f kbps", float64(bps)/1_000)
default:
return fmt.Sprintf("%d bps", bps)
}
}
// 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
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
}
// 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.
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:
c = color.RGBA{R: 255, G: 255, B: 255, A: 255}
default:
c = color.RGBA{}
}
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
}