github.com/noriah/catnip@v1.8.5/input/pipewire/pipewire.go (about) 1 package pipewire 2 3 import ( 4 "context" 5 "encoding/base64" 6 "encoding/binary" 7 "encoding/json" 8 "fmt" 9 "log" 10 "os" 11 "strings" 12 "sync" 13 "sync/atomic" 14 "time" 15 16 "github.com/noriah/catnip/input" 17 "github.com/noriah/catnip/input/common/execread" 18 "github.com/pkg/errors" 19 ) 20 21 func init() { 22 input.RegisterBackend("pipewire", Backend{}) 23 } 24 25 type Backend struct{} 26 27 func (p Backend) Init() error { 28 return nil 29 } 30 31 func (p Backend) Close() error { 32 return nil 33 } 34 35 func (p Backend) Devices() ([]input.Device, error) { 36 pwObjs, err := pwDump(context.Background()) 37 if err != nil { 38 return nil, err 39 } 40 41 pwSinks := pwObjs.Filter(func(o pwObject) bool { 42 return o.Type == pwInterfaceNode && 43 o.Info.Props.MediaClass == pwAudioSink || 44 o.Info.Props.MediaClass == pwStreamOutputAudio 45 }) 46 47 devices := make([]input.Device, len(pwSinks)) 48 for i, device := range pwSinks { 49 devices[i] = AudioDevice{device.Info.Props.NodeName} 50 } 51 52 return devices, nil 53 } 54 55 func (p Backend) DefaultDevice() (input.Device, error) { 56 return AudioDevice{"auto"}, nil 57 } 58 59 func (p Backend) Start(cfg input.SessionConfig) (input.Session, error) { 60 return NewSession(cfg) 61 } 62 63 type AudioDevice struct { 64 name string 65 } 66 67 func (d AudioDevice) String() string { 68 return d.name 69 } 70 71 type catnipProps struct { 72 ApplicationName string `json:"application.name"` 73 CatnipID string `json:"catnip.id"` 74 } 75 76 // Session is a PipeWire session. 77 type Session struct { 78 session execread.Session 79 props catnipProps 80 targetName string 81 } 82 83 // NewSession creates a new PipeWire session. 84 func NewSession(cfg input.SessionConfig) (*Session, error) { 85 currentProps := catnipProps{ 86 ApplicationName: "catnip", 87 CatnipID: generateID(), 88 } 89 90 propsJSON, err := json.Marshal(currentProps) 91 if err != nil { 92 return nil, errors.Wrap(err, "failed to marshal props") 93 } 94 95 dv, ok := cfg.Device.(AudioDevice) 96 if !ok { 97 return nil, fmt.Errorf("invalid device type %T", cfg.Device) 98 } 99 100 target := "0" 101 if dv.name == "auto" { 102 target = dv.name 103 } 104 105 args := []string{ 106 "pw-cat", 107 "--record", 108 "--format", "f32", 109 "--rate", fmt.Sprint(cfg.SampleRate), 110 "--latency", fmt.Sprint(cfg.SampleSize), 111 "--channels", fmt.Sprint(cfg.FrameSize), 112 "--target", target, // see .relink comment below 113 "--quality", "0", 114 "--media-category", "Capture", 115 "--media-role", "DSP", 116 "--properties", string(propsJSON), 117 "-", 118 } 119 120 return &Session{ 121 session: *execread.NewSession(args, true, cfg), 122 props: currentProps, 123 targetName: dv.name, 124 }, nil 125 } 126 127 // Start starts the session. It implements input.Session. 128 func (s *Session) Start(ctx context.Context, dst [][]input.Sample, kickChan chan bool, mu *sync.Mutex) error { 129 ctx, cancel := context.WithCancel(ctx) 130 defer cancel() 131 132 errCh := make(chan error, 1) 133 setErr := func(err error) { 134 select { 135 case errCh <- err: 136 default: 137 } 138 } 139 140 var wg sync.WaitGroup 141 defer wg.Wait() 142 143 wg.Add(1) 144 go func() { 145 defer wg.Done() 146 setErr(s.session.Start(ctx, dst, kickChan, mu)) 147 }() 148 149 // No relinking needed if we're not connecting to a specific device. 150 if s.targetName != "auto" { 151 wg.Add(1) 152 go func() { 153 defer wg.Done() 154 setErr(s.startRelinker(ctx)) 155 }() 156 } 157 158 return <-errCh 159 } 160 161 // We do a bit of tomfoolery here. Wireplumber actually is pretty incompetent at 162 // handling target.device, so our --target flag is pretty much useless. We have 163 // to do the node links ourselves. 164 // 165 // Relevant issues: 166 // 167 // - https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/2731 168 // - https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues/358 169 // 170 func (s *Session) startRelinker(ctx context.Context) error { 171 var catnipPorts map[string]pwObjectID 172 var err error 173 // Employ this awful hack to get the needed port IDs for our session. We 174 // won't rely on the pwLinkMonitor below, since it may appear out of order. 175 for i := 0; i < 20; i++ { 176 catnipPorts, err = findCatnipPorts(ctx, s.props) 177 if err == nil { 178 break 179 } 180 time.Sleep(100 * time.Millisecond) 181 } 182 if err != nil { 183 return errors.Wrap(err, "failed to find catnip's input ports") 184 } 185 186 linkEvents := make(chan pwLinkEvent) 187 linkError := make(chan error, 1) 188 go func() { linkError <- pwLinkMonitor(ctx, pwLinkOutputPorts, linkEvents) }() 189 190 for { 191 select { 192 case <-ctx.Done(): 193 return ctx.Err() 194 case err := <-linkError: 195 return err 196 case event := <-linkEvents: 197 switch event := event.(type) { 198 case pwLinkAdd: 199 if event.DeviceName != s.targetName { 200 break 201 } 202 203 catnipPort, ok := matchPort(event, catnipPorts) 204 if !ok { 205 log.Printf( 206 "device port %s (%d) cannot be matched to catnip (ports: %v)", 207 event.PortName, event.PortID, catnipPorts) 208 break 209 } 210 211 if err := pwLink(event.PortID, catnipPort.PortID); err != nil { 212 log.Printf( 213 "failed to link catnip port %s (%d) to device port %s (%d): %v", 214 catnipPort.PortName, catnipPort.PortID, 215 event.PortName, event.PortID, err) 216 } 217 } 218 } 219 } 220 } 221 222 // matchPort tries to match the given link event to the catnip input ports. It 223 // returns the catnip port to link to, and whether the event was matched. 224 func matchPort(event pwLinkAdd, ourPorts map[string]pwObjectID) (pwLinkObject, bool) { 225 if len(ourPorts) == 1 { 226 // We only have one port, so we're probably in mono mode. 227 for name, id := range ourPorts { 228 return pwLinkObject{PortID: id, PortName: name}, true 229 } 230 } 231 232 // Try directly matching the port channel with the event's. This usually 233 // works if the number of ports is the same. 234 _, channel, ok := strings.Cut(event.PortName, "_") 235 if ok { 236 port := "input_" + channel 237 portID, ok := ourPorts[port] 238 if ok { 239 return pwLinkObject{PortID: portID, PortName: port}, true 240 } 241 } 242 243 // TODO: use more sophisticated matching here. 244 return pwLinkObject{}, false 245 } 246 247 func findCatnipPorts(ctx context.Context, ourProps catnipProps) (map[string]pwObjectID, error) { 248 objs, err := pwDump(ctx) 249 if err != nil { 250 return nil, errors.Wrap(err, "failed to get pw-dump") 251 } 252 253 // Find the catnip node. 254 nodeObj := objs.Find(func(obj pwObject) bool { 255 if obj.Type != pwInterfaceNode { 256 return false 257 } 258 var props catnipProps 259 err := json.Unmarshal(obj.Info.Props.JSON, &props) 260 return err == nil && props == ourProps 261 }) 262 if nodeObj == nil { 263 return nil, errors.New("failed to find catnip node in PipeWire") 264 } 265 266 // Find all of catnip's ports. We want catnip's input ports. 267 portObjs := objs.ResolvePorts(nodeObj, pwPortIn) 268 if len(portObjs) == 0 { 269 return nil, errors.New("failed to find any catnip port in PipeWire") 270 } 271 272 portMap := make(map[string]pwObjectID) 273 for _, obj := range portObjs { 274 portMap[obj.Info.Props.PortName] = obj.ID 275 } 276 277 return portMap, nil 278 } 279 280 var sessionCounter uint64 281 282 // generateID generates a unique ID for this session. 283 func generateID() string { 284 return fmt.Sprintf( 285 "%d@%s#%d", 286 os.Getpid(), 287 shortEpoch(), 288 atomic.AddUint64(&sessionCounter, 1), 289 ) 290 } 291 292 // shortEpoch generates a small string that is unique to the current epoch. 293 func shortEpoch() string { 294 now := time.Now().Unix() 295 var buf [8]byte 296 binary.LittleEndian.PutUint64(buf[:], uint64(now)) 297 return base64.RawURLEncoding.EncodeToString(buf[:]) 298 }