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
+102 -8
View File
@@ -93,11 +93,17 @@ type App struct {
scaleSel *widget.Select
nameEnt *widget.Entry
audioChk *widget.Check
audioSel *widget.Select
audioDevs []capture.AudioDevice
announceChk *widget.Check
srcSel *widget.Select
monSel *widget.Select
monitors []capture.Monitor
statsDone chan struct{}
// lastStart records the capture-affecting config the current engine was
// created with, so Start can resume instead of reopening the portal.
lastStart config.Config
}
// Run starts the GUI and blocks until the app exits. configPath selects a
@@ -188,8 +194,9 @@ func (g *App) buildUI() {
g.presetSel.SetSelected("High")
// Audio.
g.audioChk = widget.NewCheck("Capture system audio", nil)
g.audioChk = widget.NewCheck("Capture system audio", func(bool) { g.applyLiveSettings("") })
g.audioChk.SetChecked(g.cfg.Audio)
g.setupAudioPicker()
// Announce over multicast.
g.announceChk = widget.NewCheck("Announce on LAN", nil)
@@ -217,7 +224,8 @@ func (g *App) buildUI() {
{Text: "Quality", Widget: g.qualitySel},
{Text: "Frame rate", Widget: g.fpsSel},
{Text: "Scale", Widget: g.scaleSel},
{Text: "", Widget: g.audioChk},
{Text: "Audio", Widget: g.audioChk},
{Text: "Audio source", Widget: g.audioSel},
{Text: "", Widget: g.announceChk},
},
}
@@ -283,6 +291,54 @@ func (g *App) selectedMonitorIndex() int {
return g.cfg.StreamIndex
}
// setupAudioPicker populates the audio source dropdown from PipeWire. The
// first option is "Default output"; the rest are the enumerated sinks and
// microphones. A no-op if enumeration is unavailable.
func (g *App) setupAudioPicker() {
g.audioDevs, _ = capture.ListAudioSources()
names := []string{"Default output"}
for _, d := range g.audioDevs {
label := d.Desc
if label == "" {
label = d.Name
}
if d.IsOutput {
label = "Output: " + label
} else {
label = "Input: " + label
}
names = append(names, label)
}
g.audioSel = widget.NewSelect(names, func(string) { g.applyLiveSettings("") })
// Preselect the configured serial if it matches an enumerated device.
if g.cfg.AudioSource > 0 {
for i, d := range g.audioDevs {
if d.Serial == g.cfg.AudioSource {
g.audioSel.SetSelectedIndex(i + 1)
break
}
}
} else {
g.audioSel.SetSelectedIndex(0)
}
}
// selectedAudioSerial returns the PipeWire serial chosen in the picker, or 0
// for the default output.
func (g *App) selectedAudioSerial() uint64 {
if g.audioSel == nil || g.audioSel.SelectedIndex() <= 0 {
return 0
}
idx := g.audioSel.SelectedIndex() - 1
if idx >= 0 && idx < len(g.audioDevs) {
return g.audioDevs[idx].Serial
}
return 0
}
// applyPreset applies a named quality/fps preset to the Quality and Frame
// rate selectors, then pushes it live if the engine is running.
func (g *App) applyPreset(string) {
@@ -320,6 +376,7 @@ func (g *App) applyLiveSettings(string) {
FPS: fps,
Source: g.cfg.Source,
Audio: g.audioChk.Checked,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.cfg.StreamIndex,
Scale: parseScale(g.scaleSel.Selected),
Announce: announce,
@@ -332,6 +389,7 @@ func (g *App) applyLiveSettings(string) {
g.cfg.Quality = quality
g.cfg.FPS = fps
g.cfg.Audio = cfg.Audio
g.cfg.AudioSource = cfg.AudioSource
g.cfg.Name = cfg.Name
g.cfg.Scale = cfg.Scale
ann := announce
@@ -343,31 +401,37 @@ func (g *App) toggleStream() {
if g.lock {
return
}
if g.eng != nil {
// Engine exists and is currently running → pause it.
if g.eng != nil && g.eng.Status().Running {
g.stop()
return
}
g.start()
}
// start reads the form into cfg, saves it, and boots the engine.
// start reads the form into cfg, saves it, and boots the engine. If an engine
// already exists and the capture-affecting settings are unchanged, it resumes
// the paused engine instead of reopening the portal session (which cannot be
// reliably re-created in-process).
func (g *App) start() {
port, _ := atoi(g.portEnt.Text)
quality, _ := atoi(g.qualitySel.Selected)
fps, _ := atoi(g.fpsSel.Selected)
announce := g.announceChk.Checked
g.cfg = config.Config{
newCfg := config.Config{
Name: g.nameEnt.Text,
Port: port,
Quality: quality,
FPS: fps,
Source: g.srcSel.Selected,
Audio: g.audioChk.Checked,
AudioSource: g.selectedAudioSerial(),
StreamIndex: g.selectedMonitorIndex(),
Scale: parseScale(g.scaleSel.Selected),
Announce: &announce,
}
g.cfg = newCfg
if g.configPath != "" {
if err := config.SaveTo(g.configPath, g.cfg); err != nil {
log.Printf("gui: config save: %v", err)
@@ -376,17 +440,47 @@ func (g *App) start() {
log.Printf("gui: config save: %v", err)
}
// Resume the existing engine if capture-affecting fields are unchanged.
if g.eng != nil && captureConfigEqual(g.lastStart, newCfg) {
// Push live-applicable changes, then resume.
g.applyLiveSettings("")
g.eng.Resume()
g.startedUI(newCfg)
return
}
// Otherwise close any existing engine and create a fresh one.
if g.eng != nil {
g.eng.Close()
g.eng = nil
}
eng, err := flinger.New(g.cfg.ToFlinger())
if err != nil {
dialog.ShowError(err, g.win)
return
}
g.eng = eng
g.lastStart = newCfg
g.eng.Start()
g.startedUI(newCfg)
}
// captureConfigEqual reports whether two configs agree on the fields that
// require reopening the capture session (source, port, monitor, audio
// source). Live-applicable fields (quality, fps, scale, name, announce,
// audio-on) are ignored.
func captureConfigEqual(a, b config.Config) bool {
return a.Source == b.Source && a.Port == b.Port &&
a.StreamIndex == b.StreamIndex && a.AudioSource == b.AudioSource
}
// startedUI updates the UI to the streaming state after a start/resume.
func (g *App) startedUI(cfg config.Config) {
g.startBtn.SetText("Stop")
g.startBtn.Importance = widget.DangerImportance
g.statusLab.SetText("Streaming (port " + itoa(g.cfg.Port) + ")")
g.statusLab.SetText("Streaming (port " + itoa(cfg.Port) + ")")
g.statusLab.Importance = widget.SuccessImportance
g.setTrayState(true, "TeleportFling · Streaming")
g.refresh()
@@ -460,7 +554,8 @@ func formatBitrate(bps int64) string {
}
}
// stop halts the engine and returns the UI to the stopped state.
// stop pauses the engine (keeping the portal session open) and returns the
// UI to the stopped state.
func (g *App) stop() {
if g.statsDone != nil {
close(g.statsDone)
@@ -468,7 +563,6 @@ func (g *App) stop() {
}
if g.eng != nil {
g.eng.Stop()
g.eng = nil
}
g.startBtn.SetText("Start")
g.startBtn.Importance = widget.HighImportance