156 lines
4.0 KiB
Go
156 lines
4.0 KiB
Go
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 <name>.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
|
|
}
|