soumya92/barista

pipewire audio volume incompatible

shibumi opened this issue · 6 comments

The volume module seems to be incompatible with pipewire. With the pulseaudio.DefaultSink() I get just an error, although pipewire-pulse is installed.

And with the alsa.DefaultSink() I get completely wrong values. Right now the Volume is 1%, but according to pamixer --get-volume it's set to 23%....

Any idea how to fix this? Is this a change in pipewire's pulse implementation?

WGH- commented

I believe the problem is that PulseAudio volume support here is implemented via dbus interface, which is not supported on PipeWire. In fact, I think it's not even enabled by default on PulseAudio itself, at least I had to run pactl load-module module-dbus-protocol.

The fix would be to use native PulseAudio interface, which would require either implementing (a subset of) it in pure Go, or linking to libpulse and using cgo.

Or waiting until PipeWire decides to implement it :)

WGH- commented

Well, there's also a native PipeWire protocol, but I doubt it's worth it to implement it right now (it might not be stable, etc.).

@WGH- is enabling dbus a possible workaround for this?

WGH- commented

is enabling dbus a possible workaround for this?

@shibumi, sorry for not being clear enough. PipeWire doesn't currently support the PulseAudio dbus interface at all.

The fix would be to use native PulseAudio interface, which would require either implementing (a subset of) it in pure Go, or linking to libpulse and using cgo.

I think this is my preferred approach. dbus might add overhead that we can avoid this way, plus it works with pipewire. https://github.com/jfreymuth/pulse is probably the way to go (hah!) here, the proto package should have everything we need.

If it helps, this is the script I had hacked together using pactl:

main.go
forceLeftToRight := "\u202d"
volumeModuleRateLimiter := rate.NewLimiter(rate.Every(20*time.Millisecond), 1)
volumeModule := funcs.Every(time.Second, func(sink bar.Sink) {
	isMuted, err := pactl.IsMuted()
	if sink.Error(err) {
		return
	}

	var volume int
	if !isMuted {
		volume, err = pactl.GetVolume()
		if sink.Error(err) {
			return
		}
	}

	var icon string
	switch {
	case isMuted:
		icon = "\ufc5d"
	case volume == 0:
		icon = "\ufa80"
	case volume <= 33:
		icon = "\ufa7e"
	case volume <= 66:
		icon = "\ufa7f"
	default:
		icon = "\ufa7d"
	}

	var volumeString string
	if isMuted {
		volumeString = "--"
	} else {
		volumeString = fmt.Sprintf("%d%%", volume)
	}

	sink.Output(outputs.
		Textf("%s%s%s", forceLeftToRight, icon, volumeString).
		OnClick(func(event bar.Event) {
			if !volumeModuleRateLimiter.Allow() {
				return
			}
			switch event.Button {
			case bar.ButtonLeft, bar.ScrollDown:
				sink.Error(pactl.ChangeVolume(-2))
			case bar.ButtonRight, bar.ScrollUp:
				sink.Error(pactl.ChangeVolume(+2))
			case bar.ButtonMiddle:
				sink.Error(pactl.ToggleMute())
			case bar.ButtonBack, bar.ScrollLeft:
				sink.Error(pactl.NextSink())
			case bar.ButtonForward, bar.ScrollRight:
				sink.Error(pactl.PrevSink())
			}
		}))
})
pactl/pactl.go
package pactl

import (
	"bytes"
	"fmt"
	"os/exec"
	"regexp"
	"strconv"

	"golang.org/x/exp/slices"
)

func IsMuted() (bool, error) {
	output, err := exec.Command("pactl", "get-sink-mute", "@DEFAULT_SINK@").Output()
	if err != nil {
		return false, err
	}

	switch {
	case bytes.Contains(output, []byte("Mute: yes")):
		return true, nil
	case bytes.Contains(output, []byte("Mute: no")):
		return false, nil
	default:
		return false, fmt.Errorf("could not parse mute state")
	}
}

func ToggleMute() error {
	_, err := exec.Command("pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle").Output()
	return err
}

func GetVolume() (int, error) {
	output, err := exec.Command("pactl", "get-sink-volume", "@DEFAULT_SINK@").Output()
	if err != nil {
		return 0, err
	}
	pattern, err := regexp.Compile(`(?P<volume>[1-9]?[0-9]|100)%`)
	if err != nil {
		return 0, err
	}

	match := pattern.FindSubmatch(output)
	if len(match) > 0 {
		return strconv.Atoi(string(match[1]))
	}
	return 0, fmt.Errorf("could not parse volume")
}

func ChangeVolume(byPercent int) error {
	byPercentString := fmt.Sprintf("%+d%%", byPercent)
	_, err := exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", byPercentString).Output()
	return err
}

func NextSink() error {
	return nextSink(false)
}

func PrevSink() error {
	return nextSink(true)
}

func nextSink(reverse bool) error {
	sinks, err := getSinks()
	if err != nil {
		return err
	}

	defaultSink, err := getDefaultSink()
	if err != nil {
		return err
	}

	defaultSinkIdx := slices.Index(sinks, defaultSink)
	if defaultSinkIdx == -1 {
		return fmt.Errorf("could not find default sink in list of sinks")
	}

	var nextSinkIdx int
	if reverse {
		nextSinkIdx = (defaultSinkIdx + len(sinks) - 1) % len(sinks)
	} else {
		nextSinkIdx = (defaultSinkIdx + 1) % len(sinks)
	}
	nextSink := sinks[nextSinkIdx]

	return setSink(nextSink)
}

func getDefaultSink() (string, error) {
	output, err := exec.Command("pactl", "get-default-sink").Output()
	if err != nil {
		return "", err
	}
	sink := string(bytes.TrimSpace(output))
	return sink, nil
}

func getSinks() ([]string, error) {
	output, err := exec.Command("pactl", "list", "short", "sinks").Output()
	if err != nil {
		return nil, err
	}

	var sinks []string
	rows := bytes.Split(output, []byte("\n"))
	for _, row := range rows {
		if len(row) == 0 {
			continue
		}
		cols := bytes.Fields(row)
		if len(cols) > 1 {
			sinks = append(sinks, string(cols[1]))
		} else {
			return nil, fmt.Errorf("could not parse list of sinks")
		}
	}

	return sinks, nil
}

func setSink(sink string) error {
	_, err := exec.Command("pactl", "set-default-sink", sink).Output()
	return err
}

I haven't figured out how to update it on click though (#311), any inputs would be great.