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  }