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

Allow sink proplists to be edited #45

Closed josh-richardson closed 4 years ago

josh-richardson commented 4 years ago

At the moment, once you have a proplist for a certain device, you're unable to edit it. It would benefit my use-case to be able to rename a sink associated with a loaded module, so this PR adds the new required calls to enable that functionality.

mk-fg commented 4 years ago

My current idea is something like this:

diff --git a/pulsectl/_pulsectl.py b/pulsectl/_pulsectl.py
index 4422ddf..e5a4c1d 100644
--- a/pulsectl/_pulsectl.py
+++ b/pulsectl/_pulsectl.py
@@ -479,6 +479,8 @@ class LibPulse(object):
    func_defs = dict(
        pa_strerror=([c_int], c_str_p),
        pa_runtime_path=([c_str_p], (c_char_p, 'not_null')),
+       pa_operation_unref=[POINTER(PA_OPERATION)],
+
        pa_mainloop_new=(POINTER(PA_MAINLOOP)),
        pa_mainloop_get_api=([POINTER(PA_MAINLOOP)], POINTER(PA_MAINLOOP_API)),
        pa_mainloop_run=([POINTER(PA_MAINLOOP), POINTER(c_int)], c_int),
@@ -490,9 +492,11 @@ class LibPulse(object):
        pa_mainloop_set_poll_func=[POINTER(PA_MAINLOOP), PA_POLL_FUNC_T, c_void_p],
        pa_mainloop_quit=([POINTER(PA_MAINLOOP), c_int]),
        pa_mainloop_free=[POINTER(PA_MAINLOOP)],
+
        pa_signal_init=([POINTER(PA_MAINLOOP_API)], 'int_check_ge0'),
        pa_signal_new=([c_int, PA_SIGNAL_CB_T, POINTER(PA_SIGNAL_EVENT)]),
        pa_signal_done=None,
+
        pa_context_errno=([POINTER(PA_CONTEXT)], c_int),
        pa_context_new=([POINTER(PA_MAINLOOP_API), c_str_p], POINTER(PA_CONTEXT)),
        pa_context_set_state_callback=([POINTER(PA_CONTEXT), PA_STATE_CB_T, c_void_p]),
@@ -562,7 +566,6 @@ class LibPulse(object):
            [POINTER(PA_CONTEXT), c_uint32, PA_CLIENT_INFO_CB_T, c_void_p] ),
        pa_context_get_server_info=( 'pa_op',
            [POINTER(PA_CONTEXT), PA_SERVER_INFO_CB_T, c_void_p] ),
-       pa_operation_unref=[POINTER(PA_OPERATION)],
        pa_context_get_card_info_by_index=( 'pa_op',
            [POINTER(PA_CONTEXT), c_uint32, PA_CARD_INFO_CB_T, c_void_p] ),
        pa_context_get_card_info_by_name=( 'pa_op',
@@ -581,6 +584,17 @@ class LibPulse(object):
            [POINTER(PA_CONTEXT), c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
        pa_context_subscribe=( 'pa_op',
            [POINTER(PA_CONTEXT), c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
+       pa_context_set_subscribe_callback=[POINTER(PA_CONTEXT), PA_SUBSCRIBE_CB_T, c_void_p],
+       pa_context_play_sample=( 'pa_op',
+           [POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
+       pa_context_play_sample_with_proplist=( 'pa_op',
+           [ POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32,
+               POINTER(PA_PROPLIST), PA_CONTEXT_SUCCESS_CB_T, c_void_p ] ),
+       pa_context_proplist_update=( 'pa_op',
+           [POINTER(PA_CONTEXT), c_int, POINTER(PA_PROPLIST), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
+       pa_context_proplist_remove=( 'pa_op',
+           [POINTER(PA_CONTEXT), POINTER(POINTER(c_char_p)), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
+
        pa_ext_stream_restore_test=( 'pa_op',
            [POINTER(PA_CONTEXT), PA_EXT_STREAM_RESTORE_TEST_CB_T, c_void_p] ),
        pa_ext_stream_restore_read=( 'pa_op',
@@ -590,9 +604,7 @@ class LibPulse(object):
            c_uint, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p ] ),
        pa_ext_stream_restore_delete=( 'pa_op',
            [POINTER(PA_CONTEXT), POINTER(c_char_p), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
-       pa_context_set_subscribe_callback=[POINTER(PA_CONTEXT), PA_SUBSCRIBE_CB_T, c_void_p],
-       pa_proplist_iterate=([POINTER(PA_PROPLIST), POINTER(c_void_p)], c_str_p),
-       pa_proplist_gets=([POINTER(PA_PROPLIST), c_str_p], c_str_p),
+
        pa_channel_map_init_mono=(
            [POINTER(PA_CHANNEL_MAP)], (POINTER(PA_CHANNEL_MAP), 'not_null') ),
        pa_channel_map_init_stereo=(
@@ -600,8 +612,12 @@ class LibPulse(object):
        pa_channel_map_snprint=([c_str_p, c_int, POINTER(PA_CHANNEL_MAP)], c_str_p),
        pa_channel_map_parse=(
            [POINTER(PA_CHANNEL_MAP), c_str_p], (POINTER(PA_CHANNEL_MAP), 'not_null') ),
+
        pa_proplist_from_string=([c_str_p], POINTER(PA_PROPLIST)),
+       pa_proplist_iterate=([POINTER(PA_PROPLIST), POINTER(c_void_p)], c_str_p),
+       pa_proplist_gets=([POINTER(PA_PROPLIST), c_str_p], c_str_p),
        pa_proplist_free=[POINTER(PA_PROPLIST)],
+
        pa_stream_new_with_proplist=(
            [ POINTER(PA_CONTEXT), c_str_p,
                POINTER(PA_SAMPLE_SPEC), POINTER(PA_CHANNEL_MAP), POINTER(PA_PROPLIST) ],
@@ -614,12 +630,7 @@ class LibPulse(object):
        pa_stream_peek=(
            [POINTER(PA_STREAM), POINTER(c_void_p), POINTER(c_int)], 'int_check_ge0' ),
        pa_stream_drop=([POINTER(PA_STREAM)], 'int_check_ge0'),
-       pa_stream_disconnect=([POINTER(PA_STREAM)], 'int_check_ge0'),
-       pa_context_play_sample=( 'pa_op',
-           [POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ),
-       pa_context_play_sample_with_proplist=( 'pa_op',
-           [ POINTER(PA_CONTEXT), c_str_p, c_str_p, c_uint32,
-               POINTER(PA_PROPLIST), PA_CONTEXT_SUCCESS_CB_T, c_void_p ] ) )
+       pa_stream_disconnect=([POINTER(PA_STREAM)], 'int_check_ge0') )

    class CallError(Exception): pass

diff --git a/pulsectl/pulsectl.py b/pulsectl/pulsectl.py
index 4c79168..d2f8bb4 100644
--- a/pulsectl/pulsectl.py
+++ b/pulsectl/pulsectl.py
@@ -578,14 +578,16 @@ class Pulse(object):
        c.PA_MODULE_INFO_CB_T, c.pa.context_get_module_info_list, PulseModuleInfo )

-   def _pulse_method_call(pulse_op, func=None, index_arg=True):
+   def _pulse_method_call(pulse_op, func=None, index_arg=True, self_arg=False):
        '''Creates following synchronous wrapper for async pa_operation callable:
            wrapper(index, ...) -> pulse_op(index, [*]args_func(...))
-           index_arg=False: wrapper(...) -> pulse_op([*]args_func(...))'''
+           index_arg=False: wrapper(...) -> pulse_op([*]args_func(...))
+           self_arg=True: args_func(self, ...)'''
        def _wrapper(self, *args, **kws):
            if index_arg:
                if 'index' in kws: index = kws.pop('index')
                else: index, args = args[0], args[1:]
+           if self_arg: args = [self] + list(args)
            pulse_args = func(*args, **kws) if func else list()
            if not is_list(pulse_args): pulse_args = [pulse_args]
            if index_arg: pulse_args = [index] + list(pulse_args)
@@ -893,6 +895,23 @@ class Pulse(object):
            except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1])

+   @ft.partial(_pulse_method_call, c.pa.context_proplist_update, index_arg=False, self_arg=True)
+   def client_proplist_update(self, props_dict, mode='merge'):
+       '''Set/update/replace properties of this pulseaudio client.
+           Property keys/values will be coerced to strings. None value = remove property.
+           See/use PulseUpdateEnum for "mode" options (set, merge, replace).'''
+       proplist, keys_del, mode = list(), list(), PulseUpdateEnum[mode]._c_val
+       for k, v in props_dict.items():
+           if v is None: keys_del.append(k)
+           else: proplist.append('{}="{}"'.format(k, str(v).replace('"', r'\"')))
+       if keys_del:
+           # XXX: convert keys_del to array of pointers here
+           with self._pulse_op_cb() as cb:
+               try: c.pa.context_proplist_remove(self._ctx, keys_del, cb, None)
+               except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1])
+       return mode, c.pa.proplist_from_string('\n'.join(proplist))
+
+
 def connect_to_cli(server=None, as_file=True, socket_timeout=1.0, attempts=5, retry_delay=0.3):
    '''Returns connected CLI interface socket (as file object, unless as_file=False),
            where one can send same commands (as lines) as to "pacmd" tool

But need to remember how to make array of pointers in ctypes...

mk-fg commented 4 years ago

Ended up with something like this:

def client_proplist_update(self, props_dict, mode='merge'):
  '''Set/update/replace properties of this pulseaudio client.
    Property keys/values will be coerced to strings. None value = remove property.
    See/use PulseUpdateEnum for "mode" options (set, merge, replace).'''
  proplist, keys_del, mode = list(), list(), PulseUpdateEnum[mode]._c_val
  for k, v in props_dict.items():
    if v is None: keys_del.append(k)
    else: proplist.append('{}="{}"'.format(k, str(v).replace('"', r'\"')))
  proplist = c.pa.proplist_from_string('\n'.join(proplist))
  if proplist:
    with self._pulse_op_cb() as cb:
      try: c.pa.context_proplist_update(self._ctx, mode, proplist, cb, None)
      except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1])
  if keys_del:
    keys_del_arr = (c.POINTER(c.c_char)*(len(keys_del)+1))()
    for n, k in enumerate(keys_del):
      keys_del_arr[n] = c.cast(bytes(k, 'utf-8'), c.POINTER(c.c_char))
    keys_del_arr[n+1] = None
    keys_del_arr = c.cast(keys_del_arr, c.POINTER(c.c_char_p))
    with self._pulse_op_cb() as cb:
      try: c.pa.context_proplist_remove(self._ctx, keys_del_arr, cb, None)
      except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1])

But it doesn't seem to delete keys, probably because I've messed-up something in ctypes-wrapping magic for that "NULL-terminated array of char_p" argument.

Anyhow, looks like this isn't needed after all and what you want is more like pacmd update-source-proplist mic_denoised_out.monitor device.description="MySink", which libpulse "native" protocol can't do, likely for various security reasons (doesn't allow full access to other objects, load .so module files, control server, etc).

pulsectl.connect_to_cli can access that privileged pacmd api, and probably good enough too for the job in this case.