From 9fa9acec843a7b02fa0c6552de1a2d2eede6341c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Kintzi?= Date: Sat, 11 Nov 2023 19:30:33 +0100 Subject: [PATCH] Add Volume widget --- cmds/pavolmon/README.md | 12 ++ cmds/pavolmon/volmon.cpp | 255 +++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + widgets/volume.go | 188 +++++++++++++++++++++++++++++ 5 files changed, 458 insertions(+) create mode 100644 cmds/pavolmon/README.md create mode 100644 cmds/pavolmon/volmon.cpp create mode 100644 widgets/volume.go diff --git a/cmds/pavolmon/README.md b/cmds/pavolmon/README.md new file mode 100644 index 0000000..78d2148 --- /dev/null +++ b/cmds/pavolmon/README.md @@ -0,0 +1,12 @@ +This simple program connects to the PulseAudio server and constantly monitors +the volume level of a default output device. Whenever it changes it prints +on the standard output a single line containg the level. + +Installation: + +``` +g++ -o pavolmon $(pkg-config -libs libpulse) -lm ./cmds/pavolmon/volmon.cpp +sudo cp pavolmon /usr/local/bin +``` + +This program is needed by the Volume widget. diff --git a/cmds/pavolmon/volmon.cpp b/cmds/pavolmon/volmon.cpp new file mode 100644 index 0000000..ed68882 --- /dev/null +++ b/cmds/pavolmon/volmon.cpp @@ -0,0 +1,255 @@ +/** + * Author: Jason White + * License: Public Domain + * + * Description: + * This is a simple test program to hook into PulseAudio volume change + * notifications. It was created for the possibility of having an automatically + * updating volume widget in a tiling window manager status bar. + * + * Compiling: + * + * g++ $(pkg-config libpulse --cflags --libs) pulsetest.c -o pulsetest + * + * Modified by Radosław Kintzi to print voulme of a default sink + */ +#include +#include +#include +#include +#include +#include + +class PulseAudio +{ +private: + pa_mainloop* _mainloop; + pa_mainloop_api* _mainloop_api; + pa_context* _context; + pa_signal_event* _signal; + const char *_default_sink_name; +public: + PulseAudio() + : _mainloop(NULL), _mainloop_api(NULL), _context(NULL), _signal(NULL) + { + } + + /** + * Initializes state and connects to the PulseAudio server. + */ + bool initialize() + { + _mainloop = pa_mainloop_new(); + if (!_mainloop) + { + std::cerr<< "pa_mainloop_new() failed.\n"; + return false; + } + + _mainloop_api = pa_mainloop_get_api(_mainloop); + + if (pa_signal_init(_mainloop_api) != 0) + { + std::cerr << "pa_signal_init() failed\n"; + return false; + } + + _signal = pa_signal_new(SIGINT, exit_signal_callback, this); + if (!_signal) + { + std::cerr << "pa_signal_new() failed\n"; + return false; + } + signal(SIGPIPE, SIG_IGN); + + _context = pa_context_new(_mainloop_api, "PulseAudio Test"); + if (!_context) + { + std::cerr << "pa_context_new() failed\n"; + return false; + } + + if (pa_context_connect(_context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL) < 0) + { + std::cerr << "pa_context_connect() failed: " << pa_strerror(pa_context_errno(_context)) << "\n"; + return false; + } + + pa_context_set_state_callback(_context, context_state_callback, this); + + return true; + } + + /** + * Runs the main PulseAudio event loop. Calling quit will cause the event + * loop to exit. + */ + int run() + { + int ret = 1; + if (pa_mainloop_run(_mainloop, &ret) < 0) + { + std::cerr << "pa_mainloop_run() failed.\n"; + return ret; + } + + return ret; + } + + /** + * Exits the main loop with the specified return code. + */ + void quit(int ret = 0) + { + _mainloop_api->quit(_mainloop_api, ret); + } + + /** + * Called when the PulseAudio system is to be destroyed. + */ + void destroy() + { + if (_context) + { + pa_context_unref(_context); + _context = NULL; + } + + if (_signal) + { + pa_signal_free(_signal); + pa_signal_done(); + _signal = NULL; + } + + if (_mainloop) + { + pa_mainloop_free(_mainloop); + _mainloop = NULL; + _mainloop_api = NULL; + } + } + + ~PulseAudio() + { + destroy(); + } + +private: + + /* + * Called on SIGINT. + */ + static void exit_signal_callback(pa_mainloop_api *m, pa_signal_event *e, int sig, void *userdata) + { + PulseAudio* pa = (PulseAudio*)userdata; + if (pa) pa->quit(); + } + + /* + * Called whenever the context status changes. + */ + static void context_state_callback(pa_context *c, void *userdata) + { + assert(c && userdata); + + PulseAudio* pa = (PulseAudio*)userdata; + + switch (pa_context_get_state(c)) + { + case PA_CONTEXT_CONNECTING: + case PA_CONTEXT_AUTHORIZING: + case PA_CONTEXT_SETTING_NAME: + break; + + case PA_CONTEXT_READY: + std::cerr <<"PulseAudio connection established.\n"; + pa_context_get_server_info(c, server_info_callback, userdata); + + // Subscribe to sink events from the server. This is how we get + // volume change notifications from the server. + pa_context_set_subscribe_callback(c, subscribe_callback, userdata); + pa_context_subscribe(c, (pa_subscription_mask_t)(PA_SUBSCRIPTION_MASK_SINK|PA_SUBSCRIPTION_MASK_SERVER), NULL, NULL); + break; + + case PA_CONTEXT_TERMINATED: + pa->quit(0); + std::cerr << "PulseAudio connection terminated.\n"; + break; + + case PA_CONTEXT_FAILED: + default: + std::cerr<< "Connection failure: " << pa_strerror(pa_context_errno(c)) << "\n"; + pa->quit(1); + break; + } + } + + /* + * Called when an event we subscribed to occurs. + */ + static void subscribe_callback(pa_context *c, + pa_subscription_event_type_t type, uint32_t idx, void *userdata) + { + unsigned facility = type & PA_SUBSCRIPTION_EVENT_FACILITY_MASK; + //type &= PA_SUBSCRIPTION_EVENT_TYPE_MASK; + + pa_operation *op = NULL; + + switch (facility) + { + case PA_SUBSCRIPTION_EVENT_SINK: + op = pa_context_get_sink_info_by_index(c, idx, sink_info_callback, userdata); + break; + case PA_SUBSCRIPTION_EVENT_SERVER: + op= pa_context_get_server_info(c, server_info_callback, userdata); + break; + + default: + assert(0); // Got event we aren't expecting. + break; + } + + if (op) + pa_operation_unref(op); + } + + /* + * Called when the requested sink information is ready. + */ + static void sink_info_callback(pa_context *c, const pa_sink_info *i, + int eol, void *userdata) + { + PulseAudio* pa = (PulseAudio*)userdata; + if (i && strcmp(pa->_default_sink_name, i->name)==0) + { + float volume = (float)pa_cvolume_avg(&(i->volume)) / (float)PA_VOLUME_NORM; + std::cout << std::round(volume * 100.0f) << (i->mute ? "m" :"") << "\n"; + std::cout.flush(); + } + } + + /* + * Called when the requested information on the server is ready. This is + * used to find the default PulseAudio sink. + */ + static void server_info_callback(pa_context *c, const pa_server_info *i, + void *userdata) + { + PulseAudio* pa = (PulseAudio*)userdata; + pa->_default_sink_name = i->default_sink_name; + pa_context_get_sink_info_by_name(c, i->default_sink_name, sink_info_callback, userdata); + } +}; + + +int main(int argc, char *argv[]) +{ + PulseAudio pa = PulseAudio(); + if (!pa.initialize()) + return 0; + + int ret = pa.run(); + + return ret; +} diff --git a/go.mod b/go.mod index 117bef8..a85f6a6 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/adrg/xdg v0.4.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/go-cmp v0.6.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/shirou/gopsutil/v3 v3.23.10 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 5bd586d..c8a126b 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/widgets/volume.go b/widgets/volume.go new file mode 100644 index 0000000..6866808 --- /dev/null +++ b/widgets/volume.go @@ -0,0 +1,188 @@ +package widgets + +import ( + "bufio" + "context" + "io" + "os/exec" + "strconv" + "strings" + "sync" + + "github.com/google/shlex" + "gobytes.dev/statusbar" + "gobytes.dev/statusbar/internal/registry" +) + +type Volume struct { + Format string `yaml:"format"` + Icon string `yaml:"icon"` + MuteIcon string `yaml:"muteIcon"` + VolMonCmd string `yaml:"volMonCommand"` + VolUpCmd string `yaml:"volUpCommand"` + VolDownCmd string `yaml:"volDownCommand"` + MuteToggleCmd string `yaml:"muteToggleCommand"` + OnClickCmd string `yaml:"onClickCommand"` + + volMonCmd *exec.Cmd + volUpCmd []string + volDownCmd []string + muteToggleCmd []string + onClickCmd []string + + fmt Format + rc io.ReadCloser + procErr error + + baseWidget +} + +func (w *Volume) WidgetName() string { return "Volume" } +func (w *Volume) New() statusbar.Widget { return new(Volume) } + +func (w *Volume) Run(ctx context.Context, update statusbar.UpdateFunc, done func()) { + defer done() + w.init() + if w.volMonCmd == nil { + w.errorf("No volMonCommand provided") + return + } + rc, err := w.volMonCmd.StdoutPipe() + if err != nil { + w.errorf(err.Error()) + return + } + w.rc = rc + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(ctx) + end := func() { + cancel() + wg.Done() + } + wg.Add(2) + go w.readVol(ctx, end, update) + go w.run(ctx, end, w.volMonCmd) + wg.Wait() + w.errorf(w.procErr.Error()) +} + +func (w *Volume) Event(ev statusbar.Event) { + var cmd []string + switch ev.Button { + case 4: + cmd = w.volUpCmd + case 5: + cmd = w.volDownCmd + case 1: + cmd = w.onClickCmd + case 3: + cmd = w.muteToggleCmd + } + if len(cmd) == 0 { + return + } + go func() { + c := exec.Command(cmd[0], cmd[1:]...) + err := c.Run() + if err != nil { + w.errorf(err.Error()) + } + }() +} + +func (w *Volume) run(ctx context.Context, done func(), cmd *exec.Cmd) { + defer done() + w.procErr = cmd.Run() +} + +func (w *Volume) readVol(ctx context.Context, done func(), update statusbar.UpdateFunc) { + defer done() + buf := bufio.NewReader(w.rc) + line, err := buf.ReadString('\n') + for ; err == nil; line, err = buf.ReadString('\n') { + var mute bool + line = strings.TrimSpace(line) + mute = strings.HasSuffix(line, "m") + line = strings.TrimSuffix(line, "m") + vol, err := strconv.Atoi(line) + if err != nil { + w.errorf(err.Error()) + continue + } + w.emitVol(vol, mute, update) + } + if err != nil { + w.errorf(err.Error()) + } +} + +func (w *Volume) emitVol(vol int, mute bool, update statusbar.UpdateFunc) { + icon := w.Icon + if mute { + icon = w.MuteIcon + } + status := w.fmt.Format(icon, vol) + update([]statusbar.Block{ + { + FullText: status, + Align: "left", + Name: "volmon", + Instance: "0", + Markup: "pango", + }, + }) +} + +func (w *Volume) init() { + w.prefix = "Volume" + if w.Format == "" { + w.Format = "{icon} {level}" + } + if w.Icon == "" { + w.Icon = "\ue892" + } + if w.MuteIcon == "" { + w.MuteIcon = "\ue890" + } + w.fmt = ParseFormat(w.Format, "icon:%s", "level:%d") + if w.VolMonCmd == "" { + w.VolMonCmd = "pavolmon" + } + if cmd, err := shlex.Split(w.VolMonCmd); err != nil { + w.errorf(err.Error()) + } else { + w.volMonCmd = exec.Command(cmd[0], cmd[1:]...) + } + if w.VolUpCmd != "" { + if cmd, err := shlex.Split(w.VolUpCmd); err != nil { + w.errorf(err.Error()) + } else { + w.volUpCmd = cmd + } + } + if w.VolDownCmd != "" { + if cmd, err := shlex.Split(w.VolDownCmd); err != nil { + w.errorf(err.Error()) + } else { + w.volDownCmd = cmd + } + } + if w.MuteToggleCmd != "" { + if cmd, err := shlex.Split(w.MuteToggleCmd); err != nil { + w.errorf(err.Error()) + } else { + w.muteToggleCmd = cmd + } + } + if w.OnClickCmd != "" { + if cmd, err := shlex.Split(w.OnClickCmd); err != nil { + w.errorf(err.Error()) + } else { + w.onClickCmd = cmd + } + } +} + +func init() { + registry.RegisterWidget(new(Volume)) +} -- 2.45.2