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.
This commit is contained in:
2026-09-19 21:44:10 +01:00
parent f1f2bfe1a3
commit 3b3355e002
9 changed files with 366 additions and 29 deletions
+88 -14
View File
@@ -48,6 +48,9 @@ type Config struct {
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
@@ -122,8 +125,18 @@ type Engine struct {
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
@@ -162,6 +175,7 @@ func (e *Engine) SetConfig(cfg Config) error {
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
@@ -199,7 +213,7 @@ func New(cfg Config) (*Engine, error) {
switch cfg.Source {
case "screen":
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio)
cam, err := capture.OpenPipeWire(cfg.StreamIndex, cfg.Audio, cfg.AudioSource)
if err != nil {
e.encoder.Close()
sender.Close()
@@ -222,11 +236,13 @@ func New(cfg Config) (*Engine, error) {
// 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())
@@ -243,32 +259,68 @@ func (e *Engine) Start() {
src = capture.NewSilenceSource()
}
go e.audioLoop(src)
go e.videoLoop()
go e.statsLoop()
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 halts the loops, stops announcing and closes all resources. It is
// idempotent. After Stop the engine must not be restarted.
// 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)
// Give the loops a moment to observe the stop signal.
time.Sleep(50 * time.Millisecond)
// 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()
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.
@@ -278,7 +330,7 @@ func (e *Engine) Status() Status {
e.errMu.RUnlock()
return Status{
Running: e.stop != nil,
Running: e.running.Load() && e.stop != nil,
Frames: e.frames.Load(),
Dropped: e.sender.Dropped(),
Conns: e.sender.NumConns(),
@@ -333,8 +385,17 @@ func (e *Engine) audioLoop(src io.ReadCloser) {
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])
@@ -433,6 +494,12 @@ func (e *Engine) videoLoop() {
next := e.start
for {
select {
case <-e.stop:
return
default:
}
frame, err := e.loop.Next()
if err != nil {
select {
@@ -464,6 +531,13 @@ func (e *Engine) videoLoop() {
}
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)
+2 -2
View File
@@ -22,7 +22,7 @@ func TestEnginePatternStartStop(t *testing.T) {
}
eng.Start()
defer eng.Stop()
defer eng.Close()
// Connect a raw receiver and read until the engine reports frames sent.
conn, err := net.Dial("tcp", "127.0.0.1:"+itoa(eng.sender.Port()))
@@ -143,7 +143,7 @@ func TestBitrateMeasurement(t *testing.T) {
t.Fatalf("New: %v", err)
}
eng.Start()
defer eng.Stop()
defer eng.Close()
// Connect a receiver so packets actually flow, and wait for the bitrate
// window to produce a measurement.