Files
TeleportFling/internal/capture/audio_test.go
T
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

65 lines
1.9 KiB
Go

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)
}
}
}