🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

155
internal/pipewire/nodes.go Normal file
View File

@@ -0,0 +1,155 @@
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
}