Skip to content

Commit 81e4318

Browse files
committed
Add PipeWire support
1 parent 860b580 commit 81e4318

File tree

4 files changed

+535
-0
lines changed

4 files changed

+535
-0
lines changed

input/all/all.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ package all
44
import (
55
_ "github.com/noriah/catnip/input/ffmpeg"
66
_ "github.com/noriah/catnip/input/parec"
7+
_ "github.com/noriah/catnip/input/pipewire"
78
)

input/pipewire/dump.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package pipewire
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"os/exec"
8+
9+
"github.com/pkg/errors"
10+
)
11+
12+
type pwObjects []pwObject
13+
14+
func pwDump(ctx context.Context) (pwObjects, error) {
15+
cmd := exec.CommandContext(ctx, "pw-dump")
16+
cmd.Stderr = os.Stderr
17+
18+
dumpOutput, err := cmd.Output()
19+
if err != nil {
20+
var execErr *exec.ExitError
21+
if errors.As(err, &execErr) {
22+
return nil, errors.Wrapf(err, "failed to run pw-dump: %s", execErr.Stderr)
23+
}
24+
return nil, errors.Wrap(err, "failed to run pw-dump")
25+
}
26+
27+
var dump pwObjects
28+
if err := json.Unmarshal(dumpOutput, &dump); err != nil {
29+
return nil, errors.Wrap(err, "failed to parse pw-dump output")
30+
}
31+
32+
return dump, nil
33+
}
34+
35+
// Filter filters for the devices that satisfies f.
36+
func (d pwObjects) Filter(fns ...func(pwObject) bool) pwObjects {
37+
filtered := make(pwObjects, 0, len(d))
38+
loop:
39+
for _, device := range d {
40+
for _, f := range fns {
41+
if !f(device) {
42+
continue loop
43+
}
44+
}
45+
filtered = append(filtered, device)
46+
}
47+
return filtered
48+
}
49+
50+
// Find returns the first object that satisfies f.
51+
func (d pwObjects) Find(f func(pwObject) bool) *pwObject {
52+
for i, device := range d {
53+
if f(device) {
54+
return &d[i]
55+
}
56+
}
57+
return nil
58+
}
59+
60+
// ResolvePorts returns all PipeWire port objects that belong to the given
61+
// object.
62+
func (d pwObjects) ResolvePorts(object *pwObject, dir pwPortDirection) pwObjects {
63+
return d.Filter(
64+
func(o pwObject) bool { return o.Type == pwInterfacePort },
65+
func(o pwObject) bool {
66+
return o.Info.Props.NodeID == object.ID && o.Info.Props.PortDirection == dir
67+
},
68+
)
69+
}
70+
71+
type pwObjectID int64
72+
73+
type pwObjectType string
74+
75+
const (
76+
pwInterfaceDevice pwObjectType = "PipeWire:Interface:Device"
77+
pwInterfaceNode pwObjectType = "PipeWire:Interface:Node"
78+
pwInterfacePort pwObjectType = "PipeWire:Interface:Port"
79+
pwInterfaceLink pwObjectType = "PipeWire:Interface:Link"
80+
)
81+
82+
type pwObject struct {
83+
ID pwObjectID `json:"id"`
84+
Type pwObjectType `json:"type"`
85+
Info struct {
86+
Props pwInfoProps `json:"props"`
87+
} `json:"info"`
88+
}
89+
90+
type pwInfoProps struct {
91+
pwDeviceProps
92+
pwNodeProps
93+
pwPortProps
94+
MediaClass string `json:"media.class"`
95+
96+
JSON json.RawMessage `json:"-"`
97+
}
98+
99+
func (p *pwInfoProps) UnmarshalJSON(data []byte) error {
100+
type Alias pwInfoProps
101+
if err := json.Unmarshal(data, (*Alias)(p)); err != nil {
102+
return err
103+
}
104+
p.JSON = append([]byte(nil), data...)
105+
return nil
106+
}
107+
108+
type pwDeviceProps struct {
109+
DeviceName string `json:"device.name"`
110+
}
111+
112+
// pwNodeProps is for Audio/Sink only.
113+
type pwNodeProps struct {
114+
NodeName string `json:"node.name"`
115+
NodeNick string `json:"node.nick"`
116+
NodeDescription string `json:"node.description"`
117+
}
118+
119+
// Constants for MediaClass.
120+
const (
121+
pwAudioDevice string = "Audio/Device"
122+
pwAudioSink string = "Audio/Sink"
123+
pwStreamOutputAudio string = "Stream/Output/Audio"
124+
)
125+
126+
type pwPortDirection string
127+
128+
const (
129+
pwPortIn = "in"
130+
pwPortOut = "out"
131+
)
132+
133+
type pwPortProps struct {
134+
PortID pwObjectID `json:"port.id"`
135+
PortName string `json:"port.name"`
136+
PortAlias string `json:"port.alias"`
137+
PortDirection pwPortDirection `json:"port.direction"`
138+
NodeID pwObjectID `json:"node.id"`
139+
ObjectPath string `json:"object.path"`
140+
}

input/pipewire/link.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package pipewire
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/pkg/errors"
13+
)
14+
15+
func pwLink(outPortID, inPortID pwObjectID) error {
16+
cmd := exec.Command("pw-link", "-L", fmt.Sprint(outPortID), fmt.Sprint(inPortID))
17+
if err := cmd.Run(); err != nil {
18+
var exitErr *exec.ExitError
19+
if errors.As(err, &exitErr) && exitErr.Stderr != nil {
20+
return errors.Wrapf(err, "failed to run pw-link: %s", exitErr.Stderr)
21+
}
22+
return err
23+
}
24+
return nil
25+
}
26+
27+
type pwLinkObject struct {
28+
ID pwObjectID
29+
DeviceName string
30+
PortName string // usually like {input,output}_{FL,FR}
31+
}
32+
33+
func pwLinkObjectParse(line string) (pwLinkObject, error) {
34+
var obj pwLinkObject
35+
36+
idStr, portStr, ok := strings.Cut(line, " ")
37+
if !ok {
38+
return obj, fmt.Errorf("failed to parse pw-link object %q", line)
39+
}
40+
41+
id, err := strconv.Atoi(idStr)
42+
if err != nil {
43+
return obj, errors.Wrapf(err, "failed to parse pw-link object id %q", idStr)
44+
}
45+
46+
name, port, ok := strings.Cut(portStr, ":")
47+
if !ok {
48+
return obj, fmt.Errorf("failed to parse pw-link port string %q", portStr)
49+
}
50+
51+
obj = pwLinkObject{
52+
ID: pwObjectID(id),
53+
DeviceName: name,
54+
PortName: port,
55+
}
56+
57+
return obj, nil
58+
}
59+
60+
type pwLinkType string
61+
62+
const (
63+
pwLinkInputPorts pwLinkType = "i"
64+
pwLinkOutputPorts pwLinkType = "o"
65+
)
66+
67+
type pwLinkEvent interface {
68+
pwLinkEvent()
69+
}
70+
71+
type pwLinkAdd pwLinkObject
72+
type pwLinkRemove pwLinkObject
73+
74+
func (pwLinkAdd) pwLinkEvent() {}
75+
func (pwLinkRemove) pwLinkEvent() {}
76+
77+
func pwLinkMonitor(ctx context.Context, typ pwLinkType, ch chan<- pwLinkEvent) error {
78+
cmd := exec.CommandContext(ctx, "pw-link", "-mI"+string(typ))
79+
cmd.Stderr = os.Stderr
80+
81+
o, err := cmd.StdoutPipe()
82+
if err != nil {
83+
return errors.Wrap(err, "failed to get stdout pipe")
84+
}
85+
defer o.Close()
86+
87+
if err := cmd.Start(); err != nil {
88+
return errors.Wrap(err, "pw-link -m")
89+
}
90+
91+
scanner := bufio.NewScanner(o)
92+
for scanner.Scan() {
93+
line := scanner.Text()
94+
if line == "" {
95+
continue
96+
}
97+
98+
mark := line[0]
99+
100+
line = strings.TrimSpace(line[1:])
101+
102+
obj, err := pwLinkObjectParse(line)
103+
if err != nil {
104+
continue
105+
}
106+
107+
var ev pwLinkEvent
108+
switch mark {
109+
case '=':
110+
fallthrough
111+
case '+':
112+
ev = pwLinkAdd(obj)
113+
case '-':
114+
ev = pwLinkRemove(obj)
115+
default:
116+
continue
117+
}
118+
119+
select {
120+
case <-ctx.Done():
121+
return ctx.Err()
122+
case ch <- ev:
123+
}
124+
}
125+
126+
return errors.Wrap(cmd.Wait(), "pw-link exited")
127+
}

0 commit comments

Comments
 (0)