From e5c576cbe2c5161082be33ff8834ac15f92bf0b4 Mon Sep 17 00:00:00 2001 From: Joshua Rich Date: Thu, 23 May 2024 10:58:37 +1000 Subject: [PATCH] feat(linux): :sparkles: migrate to different pulseaudio library - migrate to github.com/jfreymuth/pulse/proto for pulseaudio - fix mute switch not being associated with MQTT device --- go.mod | 8 -- go.sum | 18 --- internal/linux/media/volume.go | 70 +++++----- pkg/linux/pulseaudio/examples/main.go | 42 ++++++ pkg/linux/pulseaudio/pulseaudio.go | 183 ++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 65 deletions(-) create mode 100644 pkg/linux/pulseaudio/examples/main.go create mode 100644 pkg/linux/pulseaudio/pulseaudio.go diff --git a/go.mod b/go.mod index 6a3eb9486..fe878390c 100644 --- a/go.mod +++ b/go.mod @@ -27,18 +27,13 @@ require ( github.com/StackExchange/wmi v1.2.1 // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/jaypipes/pcidb v1.0.0 // indirect - github.com/josharian/native v1.1.0 // indirect github.com/mandykoh/go-parallel v0.1.0 // indirect - github.com/mdlayher/netlink v1.7.2 // indirect - github.com/mdlayher/socket v0.4.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect - golang.org/x/sync v0.7.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.0 // indirect ) @@ -49,7 +44,6 @@ require ( github.com/alecthomas/kong v0.9.0 github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fearful-symmetry/garlic v0.3.0 github.com/fredbi/uri v1.0.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect @@ -76,7 +70,6 @@ require ( github.com/mandykoh/prism v0.35.2 github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mdlayher/genetlink v1.3.2 github.com/miekg/dns v1.1.27 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -95,5 +88,4 @@ require ( golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect - mrogalski.eu/go/pulseaudio v0.0.0-20240327130323-384e01075e6e ) diff --git a/go.sum b/go.sum index df3893bd0..252cc9670 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,6 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fearful-symmetry/garlic v0.3.0 h1:ZAL/e5BhH6lKTt5FqwoxyFk0rB9E+Ld2omjQFnvfAyU= -github.com/fearful-symmetry/garlic v0.3.0/go.mod h1:+hcj5tRGZdwY6RKMopfdnlMAMepQz4Nbaw7A12MCNWk= github.com/fredbi/uri v1.0.0 h1:s4QwUAZ8fz+mbTsukND+4V5f+mJ/wjaTokwstGUAemg= github.com/fredbi/uri v1.0.0/go.mod h1:1xC40RnIOGCaQzswaOvrzvG/3M3F0hyDVb3aO/1iGy0= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -254,10 +252,6 @@ github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:Yiu github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= -github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= -github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= -github.com/joshuar/go-hass-anything/v9 v9.1.0 h1:dPNfCrMVu1k6+0vnNqm8ncAynAuckrhL6RHpaULgpiY= -github.com/joshuar/go-hass-anything/v9 v9.1.0/go.mod h1:kaNCsLaXjI+7yCnBwO2ZbUViQkoiriu52i8lnPx/NAQ= github.com/joshuar/go-hass-anything/v9 v9.2.0 h1:+VszoYfyo1Kmpqr3LRYd75Guqng0oboxjn/d1LZ6gR4= github.com/joshuar/go-hass-anything/v9 v9.2.0/go.mod h1:0UKaTpU52i/skJQ8Ci+szRT7ztIDGoP7agvstxIVX50= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -299,14 +293,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= -github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= -github.com/mdlayher/netlink v0.0.0-20180912140650-18e318c2e5d1 h1:pDJgDg70my00+GUR5Hu+tVnd939Z5eR/DGXNQqCCO74= -github.com/mdlayher/netlink v0.0.0-20180912140650-18e318c2e5d1/go.mod h1:a3TlQHkJH2m32RF224Z7LhD5N4mpyR8eUbCoYHywrwg= -github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= -github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= -github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -486,7 +472,6 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20170809000501-1c05540f6879/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -558,7 +543,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20170809190605-e42485b6e20a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -837,8 +821,6 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -mrogalski.eu/go/pulseaudio v0.0.0-20240327130323-384e01075e6e h1:SQSUCiMUx1MukBgCf+pYWTOCoV0Y2YbQ0y2vqBvCY50= -mrogalski.eu/go/pulseaudio v0.0.0-20240327130323-384e01075e6e/go.mod h1:C3V0v+gsiHHbMtFJvwozjNVdPJZw9oUxlRTVc619wSU= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/linux/media/volume.go b/internal/linux/media/volume.go index 0d4a88880..ad9d2da09 100644 --- a/internal/linux/media/volume.go +++ b/internal/linux/media/volume.go @@ -10,8 +10,6 @@ import ( "encoding/json" "strconv" - "mrogalski.eu/go/pulseaudio" - "github.com/eclipse/paho.golang/paho" mqtthass "github.com/joshuar/go-hass-anything/v9/pkg/hass" mqttapi "github.com/joshuar/go-hass-anything/v9/pkg/mqtt" @@ -20,10 +18,11 @@ import ( "github.com/joshuar/go-hass-agent/internal/linux" "github.com/joshuar/go-hass-agent/internal/preferences" + pulseaudiox "github.com/joshuar/go-hass-agent/pkg/linux/pulseaudio" ) type audioDevice struct { - pulseAudio *pulseaudio.Client + pulseAudio *pulseaudiox.PulseAudioClient msgCh chan *mqttapi.Msg muteEntity *mqtthass.SwitchEntity volEntity *mqtthass.NumberEntity[int] @@ -32,7 +31,7 @@ type audioDevice struct { func VolumeControl(ctx context.Context, msgCh chan *mqttapi.Msg) (*mqtthass.NumberEntity[int], *mqtthass.SwitchEntity) { device := linux.MQTTDevice() - client, err := pulseaudio.NewClient() + client, err := pulseaudiox.NewPulseClient(ctx) if err != nil { log.Warn().Err(err).Msg("Unable to connect to Pulseaudio. Volume control will be unavailable.") return nil, nil @@ -56,6 +55,8 @@ func VolumeControl(ctx context.Context, msgCh chan *mqttapi.Msg) (*mqtthass.Numb audioDev.muteEntity = mqtthass.AsSwitch( mqtthass.NewEntity(preferences.AppName, "Mute", device.Name+"_mute"). + WithOriginInfo(preferences.MQTTOrigin()). + WithDeviceInfo(device). WithIcon("mdi:volume-mute"). WithCommandCallback(audioDev.muteCommandCallback). WithStateCallback(audioDev.muteStateCallback). @@ -63,69 +64,60 @@ func VolumeControl(ctx context.Context, msgCh chan *mqttapi.Msg) (*mqtthass.Numb true) go func() { + log.Debug().Msg("Monitoring pulseaudio for events.") audioDev.publishVolume() audioDev.publishMute() - }() - - go func() { - events, err := client.Updates() - if err != nil { - log.Warn().Err(err).Msg("Cannot monitor Pulseaudio.") - return - } - log.Debug().Msg("Monitoring pulseaudio for events.") for { select { case <-ctx.Done(): log.Debug().Msg("Closing pulseaudio connection.") - client.Close() return - case <-events: - audioDev.publishVolume() - audioDev.publishMute() + case <-client.EventCh: + repl, err := client.GetState() + if err != nil { + log.Debug().Err(err).Msg("Failed to parse pulseaudio state.") + continue + } + volPct := pulseaudiox.ParseVolume(repl) + switch { + case repl.Mute != client.Mute: + audioDev.publishMute() + audioDev.pulseAudio.Mute = repl.Mute + case volPct != client.Vol: + audioDev.pulseAudio.Vol = volPct + audioDev.publishVolume() + } } } }() return audioDev.volEntity, audioDev.muteEntity } -func (d *audioDevice) getVolume() (int, error) { - v, err := d.pulseAudio.Volume() - if err != nil { - return 0, err - } - return int(v * 100), nil -} - -func (d *audioDevice) setVolume(v int) error { - newVol := float32(v) / 100 - return d.pulseAudio.SetVolume(newVol) -} - func (d *audioDevice) publishVolume() { msg, err := d.volEntity.MarshalState() if err != nil { - log.Warn().Err(err).Msg("Could not retrieve current volume.") + log.Debug().Err(err).Msg("Could not retrieve current volume.") return } d.msgCh <- msg } func (d *audioDevice) volStateCallback(_ ...any) (json.RawMessage, error) { - vol, err := d.getVolume() + vol, err := d.pulseAudio.GetVolume() + log.Trace().Int("volume", int(vol)).Msg("Publishing volume change.") if err != nil { return json.RawMessage(`{ "value": 0 }`), err } - return json.RawMessage(`{ "value": ` + strconv.Itoa(vol) + ` }`), nil + return json.RawMessage(`{ "value": ` + strconv.FormatFloat(vol, 'f', 0, 64) + ` }`), nil } func (d *audioDevice) volCommandCallback(p *paho.Publish) { if newValue, err := strconv.Atoi(string(p.Payload)); err != nil { - log.Warn().Err(err).Msg("Could not parse new volume level.") + log.Debug().Err(err).Msg("Could not parse new volume level.") } else { log.Trace().Int("volume", newValue).Msg("Received volume change from Home Assistant.") - if err := d.setVolume(newValue); err != nil { - log.Warn().Err(err).Msg("Could not set volume level.") + if err := d.pulseAudio.SetVolume(float64(newValue)); err != nil { + log.Debug().Err(err).Msg("Could not set volume level.") return } go func() { @@ -143,21 +135,21 @@ func (d *audioDevice) setMute(v bool) { err = d.pulseAudio.SetMute(false) } if err != nil { - log.Warn().Err(err).Msg("Could not set mute state.") + log.Debug().Err(err).Msg("Could not set mute state.") } } func (d *audioDevice) publishMute() { msg, err := d.muteEntity.MarshalState() if err != nil { - log.Warn().Msg("Could not retrieve mute state.") + log.Debug().Msg("Could not retrieve mute state.") } else { d.msgCh <- msg } } func (d *audioDevice) muteStateCallback(_ ...any) (json.RawMessage, error) { - muteState, err := d.pulseAudio.Mute() + muteState, err := d.pulseAudio.GetMute() if err != nil { return json.RawMessage(`OFF`), err } diff --git a/pkg/linux/pulseaudio/examples/main.go b/pkg/linux/pulseaudio/examples/main.go new file mode 100644 index 000000000..53785e058 --- /dev/null +++ b/pkg/linux/pulseaudio/examples/main.go @@ -0,0 +1,42 @@ +// Copyright (c) 2024 Joshua Rich +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package main + +import ( + "context" + "fmt" + "log/slog" + + pulseaudiox "github.com/joshuar/go-hass-agent/pkg/linux/pulseaudio" +) + +func main() { + client, err := pulseaudiox.NewPulseClient(context.Background()) + if err != nil { + panic(err) + } + + err = client.SetVolume(20) + if err != nil { + panic(err) + } + for { + <-client.EventCh + repl, err := client.GetState() + if err != nil { + slog.Error("failed to parse reply: %w", err) + } + volPct := pulseaudiox.ParseVolume(repl) + switch { + case repl.Mute != client.Mute: + fmt.Printf("mute changed to %v\n", repl.Mute) + client.Mute = repl.Mute + case volPct != client.Vol: + fmt.Printf("volume changed to %.0f%%\n", volPct) + client.Vol = volPct + } + } +} diff --git a/pkg/linux/pulseaudio/pulseaudio.go b/pkg/linux/pulseaudio/pulseaudio.go new file mode 100644 index 000000000..03bc95c07 --- /dev/null +++ b/pkg/linux/pulseaudio/pulseaudio.go @@ -0,0 +1,183 @@ +// Copyright (c) 2024 Joshua Rich +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package pulseaudiox + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/jfreymuth/pulse/proto" +) + +// PulseAudioClient represents a connection to PulseAudio. It will have an event +// channel which is triggered any time a change happens on the default output +// device. It also records the current state of the volume and mute status. +type PulseAudioClient struct { + client *proto.Client + conn net.Conn + EventCh chan struct{} + doneCh chan struct{} + // Mute (true: muted, false: unmuted). + Mute bool + // Vol as a percent (0 - 100%). + Vol float64 +} + +// Default input/output devices. +const ( + outputDevice = "@DEFAULT_SINK@" + inputDevice = "@DEFAULT_SOURCE@" +) + +// NewPulseClient creates a new connection to Pulseaudio. It also retrieves the +// current mute state and volume level of the default output device. It will +// also set up an event channel that can be used to listen to when a change is +// made to the output device (volume changed, mute status changed, etc.) If it +// cannot connect to Pulseaudio, a non-nil error will be returned with details +// on the issue. +func NewPulseClient(ctx context.Context) (*PulseAudioClient, error) { + // Connect to pulseaudio. + client, conn, err := proto.Connect("") + if err != nil { + return nil, fmt.Errorf("could not connect to pulseaudio: %w", err) + } + c := &PulseAudioClient{ + client: client, + conn: conn, + EventCh: make(chan struct{}, 1), + doneCh: make(chan struct{}, 1), + } + // Set client properties. + props := proto.PropList{} + err = c.client.Request(&proto.SetClientName{Props: props}, nil) + if err != nil { + return nil, fmt.Errorf("could not send client info: %w", err) + } + + // Get current mute state. + muteState, err := c.GetMute() + if err != nil { + return nil, fmt.Errorf("could not retrieve current mute status: %w", err) + } + c.Mute = muteState + + // Get current volume. + volPct, err := c.GetVolume() + if err != nil { + return nil, fmt.Errorf("could not retrieve current volume: %w", err) + } + c.Vol = volPct + + // Callback function to be used when a Pulseaudio event occurs. + client.Callback = func(val any) { + switch val := val.(type) { + case *proto.SubscribeEvent: + if val.Event.GetType() == proto.EventChange && val.Event.GetFacility() == proto.EventSink { + select { + case <-c.doneCh: + close(c.EventCh) + return + case c.EventCh <- struct{}{}: + default: + } + } + } + } + + // Request to subscribe to all events. + err = c.client.Request(&proto.Subscribe{Mask: proto.SubscriptionMaskAll}, nil) + if err != nil { + return nil, fmt.Errorf("could not subscribe to pulseaudio events: %w", err) + } + + // Shutdown gracefully when requested. + go func() { + defer c.conn.Close() + defer close(c.doneCh) + <-ctx.Done() + }() + + return c, nil +} + +// GetVolume will retrieve the current volume of the default output device, as a +// percentage. +func (c *PulseAudioClient) GetVolume() (float64, error) { + repl, err := c.GetState() + if err != nil { + return -1, fmt.Errorf("could not get current state: %w", err) + } + volPct := ParseVolume(repl) + return volPct, nil +} + +// SetVolume will set the volume of the default output device to the given +// percent amount. Values outside of 0 - 100 will be rejected. +func (c *PulseAudioClient) SetVolume(pct float64) error { + if pct < 0 || pct > 100 { + return errors.New("requested volume out of range") + } + repl, err := c.GetState() + if err != nil { + return fmt.Errorf("could not set volume: %w", err) + } + newVolume := pct / 100 * float64(proto.VolumeNorm) + volumes := repl.ChannelVolumes + for i := range volumes { + volumes[i] = uint32(newVolume) + } + err = c.client.Request(&proto.SetSinkVolume{SinkIndex: proto.Undefined, SinkName: outputDevice, ChannelVolumes: volumes}, nil) + if err != nil { + return fmt.Errorf("could not set volume: %w", err) + } + c.Vol = pct + return nil +} + +// GetMute retrieve the current mute state of the default output device as a +// bool (true: muted, false: unmuted). +func (c *PulseAudioClient) GetMute() (bool, error) { + repl, err := c.GetState() + if err != nil { + return false, fmt.Errorf("could not get current state: %w", err) + } + return repl.Mute, nil +} + +// SetMute will set the mute state of the default output device to the given +// state. +func (c *PulseAudioClient) SetMute(state bool) error { + err := c.client.Request(&proto.SetSinkMute{SinkIndex: proto.Undefined, SinkName: outputDevice, Mute: state}, nil) + if err != nil { + return fmt.Errorf("could not set mute state: %w", err) + } + c.Mute = state + return nil +} + +// GetState will return the low-level current state representation of the +// default output device. It can be used for more advanced parsing and retrieval +// about the output device. +func (c *PulseAudioClient) GetState() (*proto.GetSinkInfoReply, error) { + repl := &proto.GetSinkInfoReply{} + err := c.client.Request(&proto.GetSinkInfo{SinkIndex: proto.Undefined, SinkName: outputDevice}, repl) + if err != nil { + return nil, fmt.Errorf("could not parse reply: %w", err) + } + return repl, nil +} + +// ParseVolume will retrieve the volume as a percentage from a state message. +func ParseVolume(repl *proto.GetSinkInfoReply) float64 { + var acc int64 + for _, vol := range repl.ChannelVolumes { + acc += int64(vol) + } + acc /= int64(len(repl.ChannelVolumes)) + return float64(acc) / float64(proto.VolumeNorm) * 100.0 +}