Files
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

98 lines
2.4 KiB
Go

// Audio source enumeration for the settings UI.
package capture
import (
"encoding/json"
"os/exec"
)
// AudioDevice describes a capturable PipeWire audio node.
type AudioDevice struct {
Serial uint64 // PipeWire object.serial, passed to the capture backend
ID uint32 // PipeWire node id (informational)
Name string // node name, e.g. "alsa_output...analog-stereo"
Desc string // human-readable description
IsOutput bool // true = a sink (system output); false = a source (mic)
}
// pwDumpNode is the subset of `pw-dump` output we parse.
type pwDumpNode struct {
ID uint32 `json:"id"`
Info struct {
Props map[string]any `json:"props"`
} `json:"info"`
}
// ListAudioSources enumerates PipeWire audio sinks and sources via `pw-dump`.
// To capture system audio we attach to a sink's monitor; to capture a
// microphone we attach to an Audio/Source node. This lists the capturable
// audio nodes so the GUI can present a picker instead of always using the
// system default output.
func ListAudioSources() ([]AudioDevice, error) {
out, err := exec.Command("pw-dump").Output()
if err != nil {
return nil, err
}
var nodes []pwDumpNode
if err := json.Unmarshal(out, &nodes); err != nil {
return nil, err
}
var sources []AudioDevice
for _, n := range nodes {
props := n.Info.Props
mediaClass, _ := props["media.class"].(string)
if !audioNodeClass(mediaClass) {
continue
}
desc, _ := props["node.description"].(string)
name, _ := props["node.name"].(string)
serial := toUint64(props["object.serial"])
if serial == 0 {
continue
}
sources = append(sources, AudioDevice{
Serial: serial,
ID: n.ID,
Name: name,
Desc: desc,
IsOutput: mediaClass == "Audio/Sink",
})
}
return sources, nil
}
// audioNodeClass reports whether a media.class is a capturable audio node.
func audioNodeClass(mediaClass string) bool {
switch mediaClass {
case "Audio/Sink", "Audio/Source":
return true
default:
return false
}
}
// toUint64 best-effort converts a pw-dump property value to uint64.
// PipeWire serials are small, so the conversions cannot overflow in practice.
//
//nolint:gosec // safe: JSON numbers from pw-dump are small node serials
func toUint64(v any) uint64 {
switch t := v.(type) {
case float64:
return uint64(t)
case int:
return uint64(t)
case int64:
return uint64(t)
case uint64:
return t
case json.Number:
if n, err := t.Int64(); err == nil {
return uint64(n)
}
}
return 0
}