// 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" "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, 1–100. 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 // StreamIndex selects which monitor to capture (screen source only). StreamIndex int // 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, 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 1–65535") } if c.Quality < 1 || c.Quality > 100 { return errors.New("quality must be 1–100") } if c.FPS < 1 || c.FPS > 240 { return errors.New("fps must be 1–240") } 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") } return nil } // Status is a point-in-time snapshot of the running engine. type Status struct { Running bool Frames int64 Dropped int64 Conns int // 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{} frames atomic.Int64 errMu sync.RWMutex lastErr error } // 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 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) 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 { return } e.start = time.Now() e.stop = make(chan struct{}) 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() } go e.audioLoop(src) go e.videoLoop() go e.statsLoop() } // Stop halts the loops, stops announcing and closes all resources. It is // idempotent. After Stop the engine must not be restarted. func (e *Engine) Stop() { if e.stop == nil { return } close(e.stop) // Give the loops a moment to observe the stop signal. time.Sleep(50 * time.Millisecond) if e.announcer != nil { e.announcer.Stop() } e.sender.Close() e.encoder.Close() if e.cam != nil { if err := e.cam.Close(); err != nil { log.Printf("flinger: capture close: %v", err) } } } // 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.stop != nil, Frames: e.frames.Load(), Dropped: e.sender.Dropped(), Conns: e.sender.NumConns(), Err: err, } } // 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 { n, err := io.ReadFull(src, buf) if n > 0 { 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) } } } } // 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 { 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) ts := uint64(now.Sub(e.start)) buf, err := e.encoder.EncodeBGRA(frame.Pix, frame.Width, frame.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() if st.Dropped > 0 { log.Printf("flinger: %d frames, %d dropped, %d conns", st.Frames, st.Dropped, st.Conns) } else { log.Printf("flinger: %d frames, %d conns", st.Frames, st.Conns) } case <-e.stop: return } } } // 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 }