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:
@@ -0,0 +1,97 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPWParseAudioNodes parses a representative pw-dump payload into the
|
||||
// node shape used by ListAudioSources.
|
||||
func TestPWParseAudioNodes(t *testing.T) {
|
||||
payload := `[
|
||||
{"id": 51, "info": {"props": {"media.class": "Audio/Sink", "node.name": "alsa_out_speaker", "node.description": "Speaker", "object.serial": 1179}}},
|
||||
{"id": 56, "info": {"props": {"media.class": "Audio/Source", "node.name": "alsa_in_mic", "node.description": "Stereo Mic", "object.serial": 1183}}},
|
||||
{"id": 64, "info": {"props": {"media.class": "Audio/Device", "node.description": "Not capturable", "object.serial": 1172}}}
|
||||
]`
|
||||
|
||||
var nodes []pwDumpNode
|
||||
if err := json.Unmarshal([]byte(payload), &nodes); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
var devs []AudioDevice
|
||||
for _, n := range nodes {
|
||||
props := n.Info.Props
|
||||
mc, _ := props["media.class"].(string)
|
||||
if !audioNodeClass(mc) {
|
||||
continue
|
||||
}
|
||||
devs = append(devs, AudioDevice{
|
||||
Serial: toUint64(props["object.serial"]),
|
||||
ID: n.ID,
|
||||
Name: props["node.name"].(string),
|
||||
Desc: props["node.description"].(string),
|
||||
IsOutput: mc == "Audio/Sink",
|
||||
})
|
||||
}
|
||||
|
||||
if len(devs) != 2 {
|
||||
t.Fatalf("got %d capturable devices, want 2", len(devs))
|
||||
}
|
||||
// Sink serial parsed and flagged as output.
|
||||
if devs[0].Serial != 1179 || !devs[0].IsOutput {
|
||||
t.Errorf("sink wrong: %+v", devs[0])
|
||||
}
|
||||
// Source serial parsed and flagged as input.
|
||||
if devs[1].Serial != 1183 || devs[1].IsOutput {
|
||||
t.Errorf("source wrong: %+v", devs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioNodeClass verifies which media classes are capturable.
|
||||
func TestAudioNodeClass(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"Audio/Sink": true,
|
||||
"Audio/Source": true,
|
||||
"Audio/Device": false,
|
||||
"Video/Source": false,
|
||||
}
|
||||
for cls, want := range cases {
|
||||
if got := audioNodeClass(cls); got != want {
|
||||
t.Errorf("audioNodeClass(%q) = %v, want %v", cls, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,14 @@ type PipeWire struct {
|
||||
}
|
||||
|
||||
// OpenPipeWire opens a PipeWire capture session. streamIndex selects which
|
||||
// monitor to capture when multiple are present. Triggering the portal
|
||||
// consent dialog is expected; the compositor decides whether to show it.
|
||||
func OpenPipeWire(streamIndex int, audio bool) (*PipeWire, error) {
|
||||
// monitor to capture when multiple are present; audioSourceSerial optionally
|
||||
// selects a specific PipeWire audio node (0 = system default). Triggering the
|
||||
// portal consent dialog is expected; the compositor decides whether to show it.
|
||||
func OpenPipeWire(streamIndex int, audio bool, audioSourceSerial uint64) (*PipeWire, error) {
|
||||
s, err := capture.Open(&capture.Options{
|
||||
StreamIndex: streamIndex,
|
||||
IncludeAudio: audio,
|
||||
StreamIndex: streamIndex,
|
||||
IncludeAudio: audio,
|
||||
AudioSourceSerial: audioSourceSerial,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user