//go:build linux package player import ( "fmt" "os" "os/exec" "strconv" "strings" ) // ListAudioDevices returns available output sinks via pactl. // Works on PulseAudio and PipeWire (via pipewire-pulse). func ListAudioDevices() ([]AudioDevice, error) { defaultSink := "" if out, err := exec.Command("pactl", "get-default-sink").Output(); err == nil { defaultSink = strings.TrimSpace(string(out)) } out, err := exec.Command("pactl", "list", "sinks").Output() if err != nil { return nil, fmt.Errorf("pactl: %w (is PulseAudio/PipeWire running?)", err) } var devices []AudioDevice var cur *AudioDevice for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Sink #") { idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink #")) devices = append(devices, AudioDevice{Index: idx}) cur = &devices[len(devices)-1] } else if cur != nil { if key, val, ok := strings.Cut(line, ": "); ok { switch key { case "Name": cur.Name = val cur.Active = val == defaultSink case "Description": cur.Description = val } } } } return devices, nil } // PrepareAudioDevice sets PIPEWIRE_NODE so the PipeWire ALSA plugin // routes this process's audio to the named device. // Must be called before player.New(). Returns a cleanup that restores the env. func PrepareAudioDevice(device string) func() { prev, hadPrev := os.LookupEnv("PIPEWIRE_NODE") os.Setenv("PIPEWIRE_NODE", device) return func() { if hadPrev { os.Setenv("PIPEWIRE_NODE", prev) } else { os.Unsetenv("PIPEWIRE_NODE") } } } // SwitchAudioDevice moves cliamp's audio stream to a different sink. // Falls back to changing the system default if the stream can't be found. func SwitchAudioDevice(deviceName string) error { out, err := exec.Command("pactl", "list", "sink-inputs").Output() if err != nil { return fmt.Errorf("pactl: %w", err) } pidStr := strconv.Itoa(os.Getpid()) sinkInputIdx := -1 currentIdx := 0 props := map[string]string{} // Parse all sink-inputs, collecting properties per entry. lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Sink Input #") { // Check previous entry before moving on. if sinkInputIdx < 0 { sinkInputIdx = matchCliamp(props, pidStr, currentIdx) } if sinkInputIdx >= 0 { break } currentIdx, _ = strconv.Atoi(strings.TrimPrefix(line, "Sink Input #")) props = map[string]string{} continue } if key, val, ok := strings.Cut(line, "="); ok { props[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(val), `"`) } } // Check the last entry. if sinkInputIdx < 0 { sinkInputIdx = matchCliamp(props, pidStr, currentIdx) } if sinkInputIdx >= 0 { cmd := exec.Command("pactl", "move-sink-input", strconv.Itoa(sinkInputIdx), deviceName) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("move-sink-input: %s (%w)", strings.TrimSpace(string(out)), err) } return nil } // Fallback: change the system default sink. if out, err := exec.Command("pactl", "set-default-sink", deviceName).CombinedOutput(); err != nil { return fmt.Errorf("set-default-sink: %s (%w)", strings.TrimSpace(string(out)), err) } return nil } // matchCliamp checks if a sink-input's properties belong to cliamp. func matchCliamp(props map[string]string, pidStr string, idx int) int { if props["application.process.id"] == pidStr { return idx } if strings.EqualFold(props["application.process.binary"], "cliamp") { return idx } if strings.Contains(strings.ToLower(props["application.name"]), "cliamp") { return idx } return -1 }