//go:build linux package audio // devices_linux.go — audio endpoints on Linux, through PulseAudio. // // PulseAudio and not ALSA, for two reasons that both matter here. ALSA's C // library needs cgo, and OpsLog is a pure-Go build; and PulseAudio is the API // that is actually present on a ham's desktop — PipeWire, which most current // distributions ship, answers the PulseAudio protocol through pipewire-pulse, // so one client speaks to both. github.com/jfreymuth/pulse implements that // protocol in Go over the server's Unix socket, so nothing is linked in. // // The endpoint id we persist is the sink/source NAME // ("alsa_input.usb-Icom_Inc._IC-7610-00.analog-stereo"), never the numeric // index: the index is assigned at boot in device-arrival order and moves the // moment a rig is plugged in before a headset. import ( "fmt" "strings" "github.com/jfreymuth/pulse" ) // pulseClient opens a short-lived connection to the local sound server. Each // call gets its own: the connection is a Unix socket to a server that may be // restarted underneath us (a PipeWire update, a user logging the session out // and in), and holding one open for the lifetime of the app means every later // call fails until OpsLog itself restarts. func pulseClient() (*pulse.Client, error) { c, err := pulse.NewClient(pulse.ClientApplicationName("OpsLog")) if err != nil { return nil, fmt.Errorf("cannot reach the sound server (is PulseAudio or PipeWire running?): %w", err) } return c, nil } // ListInputDevices returns the capture sources. // // Monitor sources (".monitor", what a given output is playing) are kept rather // than filtered out. They look like clutter until you meet the operator whose // rig audio reaches OpsLog through a virtual cable — on Linux that is a // null-sink and its monitor, and hiding it would hide the only device that // works for them. func ListInputDevices() ([]Device, error) { c, err := pulseClient() if err != nil { return nil, err } defer c.Close() srcs, err := c.ListSources() if err != nil { return nil, err } defID := "" if d, err := c.DefaultSource(); err == nil && d != nil { defID = d.ID() } out := make([]Device, 0, len(srcs)) for _, s := range srcs { out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID}) } return out, nil } // ListOutputDevices returns the render sinks. func ListOutputDevices() ([]Device, error) { c, err := pulseClient() if err != nil { return nil, err } defer c.Close() sinks, err := c.ListSinks() if err != nil { return nil, err } defID := "" if d, err := c.DefaultSink(); err == nil && d != nil { defID = d.ID() } out := make([]Device, 0, len(sinks)) for _, s := range sinks { out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID}) } return out, nil } // endpointLabel prefers the server's human description ("USB Audio CODEC // Analog Stereo") and falls back to the raw name, which is ugly but still // identifies the device — an empty entry in the dropdown identifies nothing. func endpointLabel(desc, id string) string { if d := strings.TrimSpace(desc); d != "" { return d } return id }