package pipewire import ( "encoding/json" "fmt" "os/exec" "strings" ) type Node struct { ID int Name string Description string MediaClass string } func (n Node) String() string { if n.Description != "" && n.Description != n.Name { return fmt.Sprintf("%s [%s]", n.Description, n.Name) } return n.Name } type pwObject struct { ID int `json:"id"` Type string `json:"type"` Info struct { Props struct { MediaClass string `json:"media.class"` NodeName string `json:"node.name"` NodeDesc string `json:"node.description"` } `json:"props"` } `json:"info"` } func listAllNodes() ([]Node, error) { out, err := exec.Command("pw-dump").Output() if err != nil { return nil, fmt.Errorf("pw-dump: %w", err) } var objects []pwObject if err := json.Unmarshal(out, &objects); err != nil { return nil, fmt.Errorf("parse pw-dump: %w", err) } var nodes []Node for _, o := range objects { if o.Type != "PipeWire:Interface:Node" { continue } if !strings.HasPrefix(o.Info.Props.MediaClass, "Audio/") || o.Info.Props.NodeName == "" { continue } nodes = append(nodes, Node{ ID: o.ID, Name: o.Info.Props.NodeName, Description: o.Info.Props.NodeDesc, MediaClass: o.Info.Props.MediaClass, }) } return nodes, nil } func ListSinks() ([]Node, error) { all, err := listAllNodes() if err != nil { return nil, err } var out []Node for _, n := range all { if n.MediaClass == "Audio/Sink" { out = append(out, n) } } return out, nil } func ListSources() ([]Node, error) { all, err := listAllNodes() if err != nil { return nil, err } var out []Node for _, n := range all { switch n.MediaClass { case "Audio/Source", "Audio/Source.Virtual": out = append(out, n) } } return out, nil } // CaptureTarget is a node that can be fed to `pw-cat --capture --target`. // Real microphones / line-ins / virtual sources have IsMonitor=false. // Audio sinks are surfaced with IsMonitor=true: pw-cat reads the monitor // port when given a sink as --target, so the live server can stream // whatever audio the host is currently playing. type CaptureTarget struct { Name string Description string IsMonitor bool } // ListCaptureTargets returns every PipeWire node usable as audio input // for the live server. Sinks are surfaced as monitor capture targets // (pw-cat captures the monitor port when given a sink as --target); // `.monitor` pseudo-sources exposed by the PulseAudio compatibility // layer are also included since `pw-cat --target .monitor` is // the canonical way to capture playback on many setups. // // Order: real microphones / line-ins first, then monitors (the things // most users actually want for "stream what's playing"). func ListCaptureTargets() ([]CaptureTarget, error) { all, err := listAllNodes() if err != nil { return nil, err } var realSources, monitorSources, sinkMonitors []CaptureTarget monitorNames := map[string]bool{} for _, n := range all { switch n.MediaClass { case "Audio/Source", "Audio/Source.Virtual": if strings.HasSuffix(n.Name, ".monitor") { monitorSources = append(monitorSources, CaptureTarget{ Name: n.Name, Description: n.Description, IsMonitor: true, }) monitorNames[n.Name] = true } else { realSources = append(realSources, CaptureTarget{ Name: n.Name, Description: n.Description, }) } case "Audio/Sink": // Skip the sink itself when its corresponding ".monitor" // source already covers the same capture path — pick one // canonical name (the source) to avoid two indistinguishable // dropdown entries. if monitorNames[n.Name+".monitor"] { continue } sinkMonitors = append(sinkMonitors, CaptureTarget{ Name: n.Name, Description: n.Description, IsMonitor: true, }) } } out := make([]CaptureTarget, 0, len(realSources)+len(monitorSources)+len(sinkMonitors)) out = append(out, realSources...) out = append(out, monitorSources...) out = append(out, sinkMonitors...) return out, nil }