From 1710c206cf82330815b6db82316eb422f8be5370 Mon Sep 17 00:00:00 2001 From: Watcom Hecht Date: Wed, 11 Mar 2015 13:29:32 -0300 Subject: [PATCH] PulseAudio support for volume input --- .travis.yml | 2 +- Makefile | 1 + README | 3 +- i3status.c | 21 ++-- include/i3status.h | 9 ++ man/i3status.man | 35 +++++-- src/print_volume.c | 29 +++++- src/pulse.c | 245 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 src/pulse.c diff --git a/.travis.yml b/.travis.yml index 9a81d97..0b18190 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ before_install: - sudo apt-get install -t utopic clang-format-3.5 - clang-format-3.5 --version install: - - sudo apt-get install libconfuse-dev libyajl-dev libasound2-dev libiw-dev asciidoc libcap2-bin + - sudo apt-get install libconfuse-dev libyajl-dev libasound2-dev libiw-dev asciidoc libcap2-bin libpulse-dev script: - make -j - clang-format-3.5 -i **/*.[ch] && git diff --exit-code || (echo 'Code was not formatted using clang-format!'; false) diff --git a/Makefile b/Makefile index 625b522..d357b41 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ CPPFLAGS+=-DVERSION=\"${GIT_VERSION}\" CFLAGS+=-Iinclude LIBS+=-lconfuse LIBS+=-lyajl +LIBS+=-lpulse VERSION:=$(shell git describe --tags --abbrev=0) GIT_VERSION:="$(shell git describe --tags --always) ($(shell git log --pretty=format:%cd --date=short -n1))" diff --git a/README b/README index ab0b7df..de61faf 100644 --- a/README +++ b/README @@ -20,9 +20,10 @@ i3status has the following dependencies: • libiw-dev • libcap2-bin (for getting network status without root permissions) • asciidoc (only for the documentation) + • libpulse-dev (for getting the current volume using PulseAudio) On debian-based systems, the following line will install all requirements: -apt-get install libconfuse-dev libyajl-dev libasound2-dev libiw-dev asciidoc libcap2-bin +apt-get install libconfuse-dev libyajl-dev libasound2-dev libiw-dev asciidoc libcap2-bin libpulse-dev ┌────────────────────────────┐ │ Upstream │ diff --git a/i3status.c b/i3status.c index bc8f35f..a0beb3e 100644 --- a/i3status.c +++ b/i3status.c @@ -62,6 +62,9 @@ cfg_t *cfg, *cfg_general, *cfg_section; void **cur_instance; +pthread_cond_t i3status_sleep_cond = PTHREAD_COND_INITIALIZER; +pthread_mutex_t i3status_sleep_mutex = PTHREAD_MUTEX_INITIALIZER; + /* * Set the exit_upon_signal flag, because one cannot do anything in a safe * manner in a signal handler (e.g. fprintf, which we really want to do for @@ -547,6 +550,7 @@ int main(int argc, char *argv[]) { char buffer[4096]; void **per_instance = calloc(cfg_size(cfg, "order"), sizeof(*per_instance)); + pthread_mutex_lock(&i3status_sleep_mutex); while (1) { if (exit_upon_signal) { @@ -682,13 +686,16 @@ int main(int argc, char *argv[]) { fflush(stdout); /* To provide updates on every full second (as good as possible) - * we don’t use sleep(interval) but we sleep until the next - * second (with microsecond precision) plus (interval-1) - * seconds. We also align to 60 seconds modulo interval such + * we don’t use sleep(interval) but we sleep until the next second. + * We also align to 60 seconds modulo interval such * that we start with :00 on every new minute. */ - struct timeval current_timeval; - gettimeofday(¤t_timeval, NULL); - struct timespec ts = {interval - 1 - (current_timeval.tv_sec % interval), (10e5 - current_timeval.tv_usec) * 1000}; - nanosleep(&ts, NULL); + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += interval - (ts.tv_sec % interval); + ts.tv_nsec = 0; + + /* Sleep to absolute time 'ts', unless the condition + * 'i3status_sleep_cond' is signaled from another thread */ + pthread_cond_timedwait(&i3status_sleep_cond, &i3status_sleep_mutex, &ts); } } diff --git a/include/i3status.h b/include/i3status.h index a61d966..f2a262c 100644 --- a/include/i3status.h +++ b/include/i3status.h @@ -14,10 +14,14 @@ enum { O_DZEN2, #include #include #include +#include +#include #define BEGINS_WITH(haystack, needle) (strncmp(haystack, needle, strlen(needle)) == 0) #define max(a, b) ((a) > (b) ? (a) : (b)) +#define DEFAULT_SINK_INDEX UINT32_MAX + #if defined(LINUX) #define THERMAL_ZONE "/sys/class/thermal/thermal_zone%d/temp" @@ -195,6 +199,8 @@ void print_eth_info(yajl_gen json_gen, char *buffer, const char *interface, cons void print_load(yajl_gen json_gen, char *buffer, const char *format, const float max_threshold); void print_volume(yajl_gen json_gen, char *buffer, const char *fmt, const char *fmt_muted, const char *device, const char *mixer, int mixer_idx); bool process_runs(const char *path); +int volume_pulseaudio(uint32_t sink_idx); +bool pulse_initialize(void); /* socket file descriptor for general purposes */ extern int general_socket; @@ -203,4 +209,7 @@ extern cfg_t *cfg, *cfg_general, *cfg_section; extern void **cur_instance; +extern pthread_cond_t i3status_sleep_cond; +extern pthread_mutex_t i3status_sleep_mutex; + #endif diff --git a/man/i3status.man b/man/i3status.man index 84f1d13..502f391 100644 --- a/man/i3status.man +++ b/man/i3status.man @@ -422,13 +422,26 @@ details on the format string. === Volume -Outputs the volume of the specified mixer on the specified device. Works only -on Linux because it uses ALSA. -A simplified configuration can be used on FreeBSD and OpenBSD due to -the lack of ALSA, the +device+ and +mixer+ options can be -ignored on these systems. On these systems the OSS API is used instead to -query +/dev/mixer+ directly if +mixer_dix+ is -1, otherwise -+/dev/mixer++mixer_idx+. +Outputs the volume of the specified mixer on the specified device. PulseAudio +and ALSA (Linux only) are supported. If PulseAudio is absent, a simplified +configuration can be used on FreeBSD and OpenBSD due to the lack of ALSA, the ++device+ and +mixer+ options can be ignored on these systems. On these systems +the OSS API is used instead to query +/dev/mixer+ directly if +mixer_idx+ is +-1, otherwise +/dev/mixer++mixer_idx+. + +To get PulseAudio volume information, one must use the following format in the +device line: + + device = "pulse" + +or + + device = "pulse:N" + +where N is the index of the PulseAudio sink. If no sink is specified the +default is used. If the device string is missing or is set to "default", +PulseAudio will be tried if detected and will fallback to ALSA (Linux) +or OSS (FreeBSD/OpenBSD). *Example order*: +volume master+ @@ -445,6 +458,14 @@ volume master { mixer_idx = 0 } ------------------------------------------------------------- +*Example configuration (PulseAudio)*: +------------------------------------------------------------- +volume master { + format = "♪: %volume" + format_muted = "♪: muted (%volume)" + device = "pulse:1" +} +------------------------------------------------------------- == Universal module options diff --git a/src/print_volume.c b/src/print_volume.c index d8766b7..4359ac1 100644 --- a/src/print_volume.c +++ b/src/print_volume.c @@ -51,13 +51,40 @@ void print_volume(yajl_gen json_gen, char *buffer, const char *fmt, const char * char *outwalk = buffer; int pbval = 1; - /* Printing volume only works with ALSA at the moment */ + /* Printing volume works with ALSA and PulseAudio at the moment */ if (output_format == O_I3BAR) { char *instance; asprintf(&instance, "%s.%s.%d", device, mixer, mixer_idx); INSTANCE(instance); free(instance); } + + /* Try PulseAudio first */ + + /* If the device name has the format "pulse[:N]" where N is the + * index of the PulseAudio sink then force PulseAudio, optionally + * overriding the default sink */ + if (!strncasecmp(device, "pulse", strlen("pulse"))) { + uint32_t sink_idx = device[5] == ':' ? (uint32_t)atoi(device + 6) + : DEFAULT_SINK_INDEX; + int ivolume = pulse_initialize() ? volume_pulseaudio(sink_idx) : 0; + /* negative result means error, stick to 0 */ + if (ivolume < 0) + ivolume = 0; + outwalk = apply_volume_format(fmt, outwalk, ivolume); + goto out; + } else if (!strcasecmp(device, "default") && pulse_initialize()) { + /* no device specified or "default" set */ + int ivolume = volume_pulseaudio(DEFAULT_SINK_INDEX); + if (ivolume >= 0) { + outwalk = apply_volume_format(fmt, outwalk, ivolume); + goto out; + } + /* negative result means error, fail PulseAudio attempt */ + } +/* If some other device was specified or PulseAudio is not detected, + * proceed to ALSA / OSS */ + #ifdef LINUX int err; snd_mixer_t *m; diff --git a/src/pulse.c b/src/pulse.c new file mode 100644 index 0000000..76e2495 --- /dev/null +++ b/src/pulse.c @@ -0,0 +1,245 @@ +// vim:ts=4:sw=4:expandtab +#include +#include +#include +#include "i3status.h" +#include "queue.h" + +#define APP_NAME "i3status" +#define APP_ID "org.i3wm" + +typedef struct indexed_volume_s { + uint32_t idx; + int volume; + TAILQ_ENTRY(indexed_volume_s) entries; +} indexed_volume_t; + +static pa_threaded_mainloop *main_loop = NULL; +static pa_context *context = NULL; +static pa_mainloop_api *api = NULL; +static bool context_ready = false; +static uint32_t default_sink_idx = DEFAULT_SINK_INDEX; +TAILQ_HEAD(tailhead, indexed_volume_s) cached_volume = + TAILQ_HEAD_INITIALIZER(cached_volume); +static pthread_mutex_t pulse_mutex = PTHREAD_MUTEX_INITIALIZER; + +static void pulseaudio_error_log(pa_context *c) { + fprintf(stderr, + "i3status: PulseAudio: %s\n", + pa_strerror(pa_context_errno(c))); +} + +static bool pulseaudio_free_operation(pa_context *c, pa_operation *o) { + if (o) + pa_operation_unref(o); + else + pulseaudio_error_log(c); + /* return false if the operation failed */ + return o; +} + +/* + * save the volume for the specified sink index + * returning true if the value was changed + */ +static bool save_volume(uint32_t sink_idx, int new_volume) { + pthread_mutex_lock(&pulse_mutex); + indexed_volume_t *entry; + TAILQ_FOREACH(entry, &cached_volume, entries) { + if (entry->idx == sink_idx) { + const bool changed = (new_volume != entry->volume); + entry->volume = new_volume; + pthread_mutex_unlock(&pulse_mutex); + return changed; + } + } + /* index not found, store it */ + entry = malloc(sizeof(*entry)); + TAILQ_INSERT_HEAD(&cached_volume, entry, entries); + entry->idx = sink_idx; + entry->volume = new_volume; + pthread_mutex_unlock(&pulse_mutex); + return true; +} + +static void store_volume_from_sink_cb(pa_context *c, + const pa_sink_info *info, + int eol, + void *userdata) { + if (eol < 0) { + if (pa_context_errno(c) == PA_ERR_NOENTITY) + return; + + pulseaudio_error_log(c); + return; + } + + if (eol > 0) + return; + + int avg_vol = pa_cvolume_avg(&info->volume); + int vol_perc = (int)((long long)avg_vol * 100 / PA_VOLUME_NORM); + + /* if this is the default sink we must try to save it twice: once with + * DEFAULT_SINK_INDEX as the index, and another with its proper value + * (using bitwise OR to avoid early-out logic) */ + if ((info->index == default_sink_idx && + save_volume(DEFAULT_SINK_INDEX, vol_perc)) | + save_volume(info->index, vol_perc)) { + /* if the volume changed, wake the main thread */ + pthread_mutex_lock(&i3status_sleep_mutex); + pthread_cond_broadcast(&i3status_sleep_cond); + pthread_mutex_unlock(&i3status_sleep_mutex); + } +} + +static void get_sink_info(pa_context *c, uint32_t idx) { + pa_operation *o = + idx == DEFAULT_SINK_INDEX ? pa_context_get_sink_info_by_name( + c, "@DEFAULT_SINK@", store_volume_from_sink_cb, NULL) + : pa_context_get_sink_info_by_index( + c, idx, store_volume_from_sink_cb, NULL); + pulseaudio_free_operation(c, o); +} + +static void store_default_sink_cb(pa_context *c, + const pa_sink_info *i, + int eol, + void *userdata) { + if (i) { + if (default_sink_idx != i->index) { + /* default sink changed? */ + default_sink_idx = i->index; + store_volume_from_sink_cb(c, i, eol, userdata); + } + } +} + +static void update_default_sink(pa_context *c) { + pa_operation *o = pa_context_get_sink_info_by_name( + c, + "@DEFAULT_SINK@", + store_default_sink_cb, + NULL); + pulseaudio_free_operation(c, o); +} + +static void subscribe_cb(pa_context *c, pa_subscription_event_type_t t, + uint32_t idx, void *userdata) { + if ((t & PA_SUBSCRIPTION_EVENT_TYPE_MASK) != PA_SUBSCRIPTION_EVENT_CHANGE) + return; + pa_subscription_event_type_t facility = + t & PA_SUBSCRIPTION_EVENT_FACILITY_MASK; + switch (facility) { + case PA_SUBSCRIPTION_EVENT_SERVER: + /* server change event, see if the default sink changed */ + update_default_sink(c); + break; + case PA_SUBSCRIPTION_EVENT_SINK: + get_sink_info(c, idx); + break; + default: + break; + } +} + +static void context_state_callback(pa_context *c, void *userdata) { + switch (pa_context_get_state(c)) { + case PA_CONTEXT_UNCONNECTED: + case PA_CONTEXT_CONNECTING: + case PA_CONTEXT_AUTHORIZING: + case PA_CONTEXT_SETTING_NAME: + case PA_CONTEXT_TERMINATED: + default: + break; + + case PA_CONTEXT_READY: { + pa_context_set_subscribe_callback(c, subscribe_cb, NULL); + update_default_sink(c); + + pa_operation *o = pa_context_subscribe( + c, + PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SERVER, + NULL, + NULL); + if (!pulseaudio_free_operation(c, o)) + break; + context_ready = true; + } break; + + case PA_CONTEXT_FAILED: + pulseaudio_error_log(c); + break; + } +} + +/* + * returns the current volume in percent, which, as per PulseAudio, + * may be > 100% + */ +int volume_pulseaudio(uint32_t sink_idx) { + if (!context_ready || default_sink_idx == DEFAULT_SINK_INDEX) + return -1; + + pthread_mutex_lock(&pulse_mutex); + const indexed_volume_t *entry; + TAILQ_FOREACH(entry, &cached_volume, entries) { + if (entry->idx == sink_idx) { + int vol = entry->volume; + pthread_mutex_unlock(&pulse_mutex); + return vol; + } + } + pthread_mutex_unlock(&pulse_mutex); + /* first time requires a prime callback call because we only get + * updates when the volume actually changes, but we need it to + * be correct even if it never changes */ + pa_threaded_mainloop_lock(main_loop); + get_sink_info(context, sink_idx); + pa_threaded_mainloop_unlock(main_loop); + /* show 0 while we don't have this information */ + return 0; +} + +/* + * detect and, if necessary, initialize the PulseAudio API + */ +bool pulse_initialize(void) { + if (!main_loop) { + main_loop = pa_threaded_mainloop_new(); + if (!main_loop) + return false; + } + if (!api) { + api = pa_threaded_mainloop_get_api(main_loop); + if (!api) + return false; + } + if (!context) { + pa_proplist *proplist = pa_proplist_new(); + pa_proplist_sets(proplist, PA_PROP_APPLICATION_NAME, APP_NAME); + pa_proplist_sets(proplist, PA_PROP_APPLICATION_ID, APP_ID); + pa_proplist_sets(proplist, PA_PROP_APPLICATION_VERSION, VERSION); + context = pa_context_new_with_proplist(api, APP_NAME, proplist); + pa_proplist_free(proplist); + if (!context) + return false; + pa_context_set_state_callback(context, + context_state_callback, + NULL); + if (pa_context_connect(context, + NULL, + PA_CONTEXT_NOFAIL | PA_CONTEXT_NOAUTOSPAWN, + NULL) < 0) { + pulseaudio_error_log(context); + return false; + } + if (pa_threaded_mainloop_start(main_loop) < 0) { + pulseaudio_error_log(context); + pa_threaded_mainloop_free(main_loop); + main_loop = NULL; + return false; + } + } + return true; +} -- 2.39.5