mk-fg / python-pulse-control

Python high-level interface and ctypes-based bindings for PulseAudio (libpulse)
https://pypi.org/project/pulsectl/
MIT License
170 stars 36 forks source link

Example for updating volume values in sink object #41

Closed ulno closed 4 years ago

ulno commented 4 years ago

I am developing a small mqtt volume controller for various Linux based satellite speakers in my home. I wanted to use a simple polling mechanism checking with pulse.volume_get_all_chans(sink) for the volume and posting this then via mqtt to my network to show on a dashboard.

Unfortunately, pulse.volume_get_all_chans(sink) always returns the same value (also when I change the volumes with pavucontrol) - I seem to be missing some update calls. I do get events when registering a handler with event_callback_set, however the values in volume_get_all_chans still stay the same. Only when restarting my program, I get once an updated volume value.

Is this the expected behavior? How would I get up-to-date values (like when calling pamixer from the cli).

mk-fg commented 4 years ago

Hi,

Yeah, can totally see why it'd look like volume_get_all_chans would be fetching the value, as it's right next to volume_set functions, which actually go to libpulse and change stuff.

But it is defined simply as:

def volume_get_all_chans(self, obj):
  assert_pulse_object(obj)
  return obj.volume.value_flat

I.e. polling it would be about same as doing something like volume = get_volume(); print(volume); sleep(5); print(volume); ... - i.e. just reusing same stored value.

You'd need to do something like pulse.sink_input_info(...) to get current sink input parameters.

This might illustrate it better with pulsectl:

from pulsectl.lookup import pulse_obj_lookup
import os, sys, pulsectl, signal, functools as ft

signal.signal(signal.SIGINT, lambda sig,frm: sys.exit(0))

def callback(mpv, ev):
  if ev.index == mpv.index:
    raise pulsectl.PulseLoopStop

with pulsectl.Pulse() as pulse:
  mpv, = pulse_obj_lookup(pulse, 'media.role:music')

  pulse.event_mask_set(pulsectl.PulseEventMaskEnum.sink_input)
  pulse.event_callback_set(ft.partial(callback, mpv))
  while True:
    pulse.event_listen()
    print('----------')

    # Printing same value from same exact struct
    print('Initial value (always same):', pulse.volume_get_all_chans(mpv))
    print('Initial value (also same thing):', mpv.volume.value_flat)

    # Getting new SinkInputInfo object with updated value
    print('Actual value:', pulse.sink_input_info(mpv.index).volume.value_flat)

Last line actually gets new sink input info struct from libpulse with updated values, while first two prints just dump same value from same pre-fetched struct in a loop.

One quirk of this particular use-case, with polling value based on events, is that it's an eventloop (think async/await and asyncio), and this module is written to wrap underlying libpulse eventloop (glib) into blocking API, and can't easily switch back. Which is why there's raise pulsectl.PulseLoopStop in above example, as you can't easily run libpulse calls from within event handler itself with it. (you can cheat in various ways however, e.g. have with pulsectl.Pulse() as pulse_events, pulsectl.Pulse() as pulse: and run event_listen on one connection, while doing anything on the other one from within callbacks - object indexes are same/global)

Guess I'll at least add comment on pulse.volume_get_all_chans(...) to say that it's not actually querying anything, thanks for raising the issue.

ulno commented 4 years ago

Thanks for the solution - this is quite something to wrap your head around comparing it to a bash script using pamixer.

I was taking a look at pyaudio and wow, I hadn't thought that audio in Python is in such a bad state - guess I have mainly used audio so far in pygame. I was hoping to use something that abstracts a little bit of the system-grit away, so I should probably blame general audio handling complexity for this, but still wondering if it wouldn't be possible to write some kind of (even if it's threaded) sound abstraction layer that allowed direct interaction with mixers and sound cards.

However, this should probably be discussed somewhere else.

ulno commented 4 years ago

The example just hangs in event_listen() even if volume gets changed.

If I give event_listen a timeout, the example fails with an PulseIndexError exception.

Is there any disadvantage in just calling sink_info each time before I want to have a current volume value? That seems to work.

mk-fg commented 4 years ago

That example also looks up mpv, = pulse_obj_lookup(pulse, 'media.role:music') and handles events only from that, you probably don't have it running on your system.

ulno commented 4 years ago

I changed that line to a local device:

from pulsectl.lookup import pulse_obj_lookup
import os, sys, pulsectl, signal, functools as ft

signal.signal(signal.SIGINT, lambda sig,frm: sys.exit(0))

def callback(mpv, ev):
  if ev.index == mpv.index:
    raise pulsectl.PulseLoopStop

with pulsectl.Pulse() as pulse:
  mpv, = pulse_obj_lookup(pulse, 'sink:device.description::Altec')
  print("mpv:", mpv)

  pulse.event_mask_set(pulsectl.PulseEventMaskEnum.sink_input)
  pulse.event_callback_set(ft.partial(callback, mpv))
  while True:
    pulse.event_listen(1)
    print('----------')

    # Printing same value from same exact struct
    print('Initial value (always same):', pulse.volume_get_all_chans(mpv))
    print('Initial value (also same thing):', mpv.volume.value_flat)

    # Getting new SinkInputInfo object with updated value
    print('Actual value:', pulse.sink_input_info(mpv.index).volume.value_flat)

crashes now after 1s with:

pv: description='Altec Lansing XT2 - USB Audio Analog Stereo', index=70, mute=0, name='alsa_output.usb-Altec_Lansing_Technologies__Inc._Altec_Lansing_XT2_-_USB_Audio-00.analog-stereo', channels=2, volumes=[78% 78%]
----------
Initial value (always same): 0.780914306640625
Initial value (also same thing): 0.780914306640625
Traceback (most recent call last):
  File "pulsectl_test.py", line 25, in <module>
    print('Actual value:', pulse.sink_input_info(mpv.index).volume.value_flat)
  File "/home/ulno/sync/work/devel/iotempower/.local/vp/lib/python3.8/site-packages/pulsectl/pulsectl.py", line 536, in _decorator_or_method
    return _wrapper(func_or_self, index)
  File "/home/ulno/sync/work/devel/iotempower/.local/vp/lib/python3.8/site-packages/pulsectl/pulsectl.py", line 526, in _wrapper
    if not data: raise PulseIndexError(index)
pulsectl.pulsectl.PulseIndexError: 70
mk-fg commented 4 years ago

I was hoping to use something that abstracts a little bit of the system-grit away, so I should probably blame general audio handling complexity for this

Maybe it's just overcomplicated example that I've used above?

I mean, if you want something simple like "get volume of some random sink", something like first example from the README should work: with pulsectl.Pulse() as pulse: pulse.volume_get_all_chans(0) Seem to be simple enough.

mk-fg commented 4 years ago

Wrt this line too: mpv, = pulse_obj_lookup(pulse, 'sink:device.description::Altec') Probably shouldn't have used it in example above, as it's kinda-private API which I've added in response to some Issue here (which you can probably lookup), mostly to demonstrate how complicated "way to get sink by string" might be :) It's not really intended to be used by anyone.

ulno commented 4 years ago

Yep, that was why I asked above if just calling sink_info before evaluating volume would have any busy-waiting side effects? So, I might not need the example if this way of polling would be ok. It seems to take here less than 1% of cpu load.

mk-fg commented 4 years ago

Actual problem with code you pasted above is here:

  mpv, = pulse_obj_lookup(pulse, 'sink:device.description::Altec')
  ...
  pulse.event_mask_set(pulsectl.PulseEventMaskEnum.sink_input)
  ...
  print('Actual value:', pulse.sink_input_info(mpv.index).volume.value_flat)

You're getting a SINK object, and then using its index to lookup SINK INPUTs (well, lookup or filter events by, with latter probably being cause for "hang" - nothing matches). In my original example "mpv" (popular media player) is not a sink, but a stream from a background music player.

ulno commented 4 years ago

Ah, that mpv (I was wondering about the acronym) <- I actually do try to control the volume of machines where it plays here ;-)

Thanks for the help, I think I have a solution, I will link my code example here when done (need to fix the mqtt loop still).

mk-fg commented 4 years ago

Yep, that was why I asked above if just calling sink_info before evaluating volume would have any busy-waiting side effects?

Don't think I understand the question.

If you want to do something like while True: print(pulse.sink_info(...).volume); sleep(0.1) - can just run and check how it'd impact CPU. Ideally it'd just be libpulse making a command, sending it over unix socket, pulse server handling it and then parsing the response in client - not too heavy. And there's no real "busy-wait" wrt socket layer - module/libpulse will run this exchange in a glib eventloop, though guess you mean something else there.

mk-fg commented 4 years ago

Thought to also comment on this thing I said above:

mostly to demonstrate how complicated "way to get sink by string" might be

You can totally get sinks/sources by name without going to that crazy lookup api, kinda like one you've used above, e.g. have "alsa_output.pci-0000_00_14.2.analog-stereo" for built-in Intel HDA here.

These names should be unique and probably good enough for "something to put into config file to ID sound sink" purposes.

ulno commented 4 years ago

Works now, thanks again.

That's what I wanted to accomplish: https://github.com/iotempire/iotempower/blob/master/examples/integriot/volume/pulse_mqtt_volume.py

Hope it's not a too embarrassing use of your library.

mk-fg commented 4 years ago

Eh, if it works, it works :) I think using events in one of ways suggested above (e.g. like in example) might be just as much code as comparing values though.

mk-fg commented 4 years ago

Oh, also, I think you have some dead code there, and some typos like "integriot" (maybe a fancy new word though) :)

mk-fg commented 4 years ago

some typos like "integriot" (maybe a fancy new word though)

Nah, it's a project name, but "nad" after it definitely a typo.

ulno commented 4 years ago

fixed typo - integriot is a merge between integrate and iot, I wanted something simpler than paho for my students to build small integrations of mqtt components, but this is slowly going off topic, guess I have cluttered this issue now enough.

The volume control over mqtt from a node-red network now works perfectly both for setting and reporting the volume thanks to pulsectl.