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:
+88
-14
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user