././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.479202 pulsectl-23.5.2/0000755000175000017500000000000014432752527013315 5ustar00fraggodfraggod././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788518.0 pulsectl-23.5.2/CHANGES.rst0000644000175000017500000000420314432752446015116 0ustar00fraggodfraggod========= CHANGES ========= Only (unlikely) intentional breaking changes and new/added non-trivial functionality is listed here, no bugfixes or commit messages. Each entry is a package version which change first appears in, followed by description of the change itself. Last synced/updated: 23.5.2 --------------------------------------------------------------------------- - 21.10.4: Add channel_list_enum to compare channel_list values with something in a typo-free way, expose channel_list_raw with C enum values [#66]. - 21.5.0: Fix PA_VOLUME_MAX and PA_VOLUME_UI_MAX values, both were incorrect [#53]. - 21.3.4: Add timeout= option for connect() method [#48]. - 21.3.1: There is now https://pypi.org/project/pulsectl-asyncio/ module [#46]. It is maintained separately, and should provide similar bindings to use with async apps. - 20.2.4: Add pulse.get_card_by_name() wrapper [#38]. - 20.1.1: Add pulse.play_sample() - server-side stored sample playback [#36]. Loading is not implemented, would suggest something like libcanberra for that. - 19.9.1: Add pulse.get_peak_sample() func for getting volume peak within timespan [#33]. - 18.10.5: pulse.connect() can now be used to reconnect to same server. - 17.12.2: Use pa_card_profile_info2 / profiles2 introspection API [#19]. Only adds one "available" property to PulseCardProfileInfo. Requires pulseaudio/libpulse 5.0+. - 17.9.3: Add wrappers for Pulse.get_sink_by_name / Pulse.get_source_by_name [#17]. More efficient alternative for sink_input_list / source_output_list filtering. - 17.6.0: Add PulseCardInfo.port_list [#15]. These ports are different from sink/source ports in that they have proplist, card profiles and some other parameters associated with them, implemented as PulseCardPortInfo instances. - 17.1.3: Add wrappers for card profiles [#14]. More specifically - PulseCardProfileInfo objects and PulseCardInfo "profile_list" and "profile_active" attributes. ``pulse.card_profile_set(card, profile)`` can be used to set active profile (either by name or PulseCardProfileInfo object). - 16.11.0: This changelog file was started, thanks to the idea from #12. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1499941406.0 pulsectl-23.5.2/COPYING0000644000175000017500000000214313131645036014337 0ustar00fraggodfraggodMIT license from https://github.com/GeorgeFilipkin/pulsemixer/ Copyright (c) 2014 George Filipkin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1614250487.0 pulsectl-23.5.2/MANIFEST.in0000644000175000017500000000004714015700767015050 0ustar00fraggodfraggodinclude COPYING README.rst CHANGES.rst ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.479202 pulsectl-23.5.2/PKG-INFO0000644000175000017500000004201014432752527014407 0ustar00fraggodfraggodMetadata-Version: 2.1 Name: pulsectl Version: 23.5.2 Summary: Python high-level interface and ctypes-based bindings for PulseAudio (libpulse) Home-page: http://github.com/mk-fg/python-pulse-control Author: George Filipkin, Mike Kazantsev Author-email: mk.fraggod@gmail.com License: MIT Keywords: pulseaudio,libpulse,pulse,pa,bindings,sound,audio,ctypes,control,mixer,volume,mute,source,sink Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Multimedia Classifier: Topic :: Multimedia :: Sound/Audio License-File: COPYING python-pulse-control (pulsectl module) ====================================== Python (3.x and 2.x) blocking high-level interface and ctypes-based bindings for PulseAudio_ (libpulse), to use in a simple synchronous code. Wrappers are mostly for mixer-like controls and introspection-related operations, as opposed to e.g. submitting sound samples to play and player-like client. For async version to use with asyncio_, see `pulsectl-asyncio`_ project instead. Originally forked from pulsemixer_ project, which had this code bundled. .. _PulseAudio: https://wiki.freedesktop.org/www/Software/PulseAudio/ .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _pulsectl-asyncio: https://pypi.org/project/pulsectl-asyncio/ .. _pulsemixer: https://github.com/GeorgeFilipkin/pulsemixer/ | .. contents:: :backlinks: none Repository URLs: - https://github.com/mk-fg/python-pulse-control - https://codeberg.org/mk-fg/python-pulse-control - https://fraggod.net/code/git/python-pulse-control Usage ----- Simple example:: import pulsectl with pulsectl.Pulse('volume-increaser') as pulse: for sink in pulse.sink_list(): # Volume is usually in 0-1.0 range, with >1.0 being soft-boosted pulse.volume_change_all_chans(sink, 0.1) Listening for server state change events:: import pulsectl with pulsectl.Pulse('event-printer') as pulse: # print('Event types:', pulsectl.PulseEventTypeEnum) # print('Event facilities:', pulsectl.PulseEventFacilityEnum) # print('Event masks:', pulsectl.PulseEventMaskEnum) def print_events(ev): print('Pulse event:', ev) ### Raise PulseLoopStop for event_listen() to return before timeout (if any) # raise pulsectl.PulseLoopStop pulse.event_mask_set('all') pulse.event_callback_set(print_events) pulse.event_listen(timeout=10) Misc other tinkering:: >>> import pulsectl >>> pulse = pulsectl.Pulse('my-client-name') >>> pulse.sink_list() [] >>> pulse.sink_input_list() [] >>> pulse.sink_input_list()[0].proplist {'application.icon_name': 'mpv', 'application.language': 'C', 'application.name': 'mpv Media Player', ... 'native-protocol.version': '30', 'window.x11.display': ':1.0'} >>> pulse.source_list() [, ] >>> sink = pulse.sink_list()[0] >>> pulse.volume_change_all_chans(sink, -0.1) >>> pulse.volume_set_all_chans(sink, 0.5) >>> pulse.server_info().default_sink_name 'alsa_output.pci-0000_00_14.2.analog-stereo' >>> pulse.default_set(sink) >>> card = pulse.card_list()[0] >>> card.profile_list [, , ... ] >>> pulse.card_profile_set(card, 'output:hdmi-stereo') >>> help(pulse) ... >>> pulse.close() Current code logic is that all methods are invoked through the Pulse instance, and everything returned from these are "Pulse-Something-Info" objects - thin wrappers around C structs that describe the thing, without any methods attached. Aside from a few added convenience methods, most of them should have similar signature and do same thing as their C libpulse API counterparts, so see `pulseaudio doxygen documentation`_ for more information on them. Pulse client can be integrated into existing eventloop (e.g. asyncio, twisted, etc) using ``Pulse.set_poll_func()`` or ``Pulse.event_listen()`` in a separate thread. Somewhat extended usage example can be found in `pulseaudio-mixer-cli`_ project code, as well as tests here. .. _pulseaudio doxygen documentation: https://freedesktop.org/software/pulseaudio/doxygen/introspect_8h.html .. _pulseaudio-mixer-cli: https://github.com/mk-fg/pulseaudio-mixer-cli/blob/master/pa-mixer-mk3.py Notes ----- Some less obvious things are described in this section. Things not yet wrapped/exposed in python ```````````````````````````````````````` There are plenty of information, methods and other things in libpulse not yet wrapped/exposed by this module, as they weren't needed (yet) for author/devs use-case(s). Making them accessible from python code can be as simple as adding an attribute name to the "c_struct_fields" value in PulseSomethingInfo objects. See `github #3 `_ for a more concrete example of finding/adding such stuff. For info and commands that are not available through libpulse introspection API, it is possible to use ``pulsectl.connect_to_cli()`` fallback function, which will open unix socket to server's "module-cli" (signaling to load it, if necessary), which can be used in exactly same way as "pacmd" tool (not to be confused with "pactl", which uses native protocol instead of module-cli) or pulseaudio startup files (e.g. "default.pa"). Probably a bad idea to parse string output from commands there though, as these are not only subject to change, but can also vary depending on system locale. Volume `````` In PulseAudio, "volume" for anything is not a flat number, but essentially a list of numbers, one per channel (as in "left", "right", "front", "rear", etc), which should correspond to channel map of the object it relates/is-applied to. In this module, such lists are represented by PulseVolumeInfo objects. I.e. ``sink.volume`` is a PulseVolumeInfo instance, and all thin/simple wrappers that accept index of the object, expect such instance to be passed, e.g. ``pulse.sink_input_volume_set(sink.index, sink.volume)``. There are convenience ``volume_get_all_chans``, ``volume_set_all_chans`` and ``volume_change_all_chans`` methods to get/set/adjust volume as/by a single numeric value, which is also accessible on PulseVolumeInfo objects as a ``value_flat`` property. PulseVolumeInfo can be constructed from a numeric volume value plus number of channels, or a python list of per-channel numbers. All per-channel volume values in PulseVolumeInfo (and flat values in the wrapper funcs above), are float objects in 0-65536 range, with following meanings: * 0.0 volume is "no sound" (corresponds to PA_VOLUME_MUTED). * 1.0 value is "current sink volume level", 100% or PA_VOLUME_NORM. * >1.0 and up to 65536.0 (PA_VOLUME_MAX / PA_VOLUME_NORM) - software-boosted sound volume (higher values will negatively affect sound quality). Probably a good idea to set volume only in 0-1.0 range and boost volume in hardware without quality loss, e.g. by tweaking sink volume (which corresponds to ALSA/hardware volume), if that option is available. Note that ``flat-volumes=yes`` option ("yes" by default on some distros, "no" in e.g. Arch Linux) in pulseaudio daemon.conf already scales device-volume with the volume of the "loudest" application, so already does what's suggested above. Fractional volume values used in the module get translated (in a linear fashion) to/from pa_volume_t integers for libpulse. See ``src/pulse/volume.h`` in pulseaudio sources for all the gory details on the latter (e.g. how it relates to sound level in dB). Code example:: from pulsectl import Pulse, PulseVolumeInfo with Pulse('volume-example') as pulse: sink_input = pulse.sink_input_list()[0] # first random sink-input stream volume = sink_input.volume print(volume.values) # list of per-channel values (floats) print(volume.value_flat) # average level across channels (float) time.sleep(1) volume.value_flat = 0.3 # sets all volume.values to 0.3 pulse.volume_set(sink_input, volume) # applies the change time.sleep(1) n_channels = len(volume.values) new_volume = PulseVolumeInfo(0.5, n_channels) # 0.5 across all n_channels # new_volume = PulseVolumeInfo([0.15, 0.25]) # from a list of channel levels (stereo) pulse.volume_set(sink_input, new_volume) # pulse.sink_input_volume_set(sink_input.index, new_volume) # same as above In most common cases, doing something like ``pulse.volume_set_all_chans(sink_input, 0.2)`` should do the trick though - no need to bother with specific channels in PulseVolumeInfo there. String values ````````````` libpulse explicitly returns utf-8-encoded string values, which are always decoded to "abstract string" type in both python-2 (where it's called "unicode") and python-3 ("str"), for consistency. It might be wise to avoid mixing these with encoded strings ("bytes") in the code, especially in python-2, where "bytes" is often used as a default string type. Enumerated/named values (enums) ``````````````````````````````` In place of C integers that correspond to some enum or constant (e.g. -1 for PA_SINK_INVALID_STATE), module returns EnumValue objects, which are comparable to strings ("str" type in py2/py3). For example:: >>> pulsectl.PulseEventTypeEnum.change == 'change' True >>> pulsectl.PulseEventTypeEnum.change >>> pulsectl.PulseEventTypeEnum It might be preferrable to use enums instead of strings in the code so that interpreter can signal error on any typos or unknown values specified, as opposed to always silently failing checks with bogus strings. Event-handling code, threads ```````````````````````````` libpulse clients always work as an event loop, though this module kinda hides it, presenting a more old-style blocking interface. So what happens on any call (e.g. ``pulse.mute(...)``) is: * Make a call to libpulse, specifying callback for when operation will be completed. * Run libpulse event loop until that callback gets called. * Return result passed to that callback call, if any (for various "get" methods). ``event_callback_set()`` and ``event_listen()`` calls essentally do raw first and second step here. Which means that any pulse calls from callback function can't be used when ``event_listen()`` (or any other pulse call through this module, for that matter) waits for return value and runs libpulse loop already. One can raise PulseLoopStop exception there to make ``event_listen()`` return, run whatever pulse calls after that, then re-start the ``event_listen()`` thing. This will not miss any events, as all blocking calls do same thing as ``event_listen()`` does (second step above), and can cause callable passed to ``event_callback_set()`` to be called (when loop is running). Also, same instance of libpulse eventloop can't be run from different threads, naturally, so if threads are used, client can be initialized with ``threading_lock=True`` option (can also accept lock instance instead of True) to create a mutex around step-2 (run event loop) from the list above, so multiple threads won't do it at the same time. For proper python eventloop integration (think twisted or asyncio), use `pulsectl-asyncio`_ module instead. There are also some tricks mentioned in `github #11 `_ to shoehorn this module into async apps, but even with non-asyncio eventloop, starting from pulsectl-asyncio would probably be much easier. Tests ````` Test code is packaged/installed with the module and can be useful to run when changing module code, or to check if current python, module and pulseudio versions all work fine together. Commands to run tests from either checkout directory or installed module:: % python2 -m unittest discover % python3 -m unittest discover Note that if "pulsectl" module is available both in current directory (e.g. checkout dir) and user/system python module path, former should always take priority for commands above. Add e.g. ``-k test_stream_move`` for commands above to match and run specific test(s), and when isolating specific failure, it might also be useful to run with PA_DEBUG=1 env-var to get full verbose pulseaudio log, for example:: % PA_DEBUG=1 python -m unittest discover -k test_module_funcs Test suite runs ad-hoc isolated pulseaudio instance with null-sinks (not touching hardware), custom (non-default) startup script and environment, and interacts only with that instance, terminating it afterwards. Still uses system/user daemon.conf files though, so these can affect the tests. Any test failures can indicate incompatibilities, bugs in the module code, issues with pulseaudio (or its daemon.conf) and underlying dependencies. There are no "expected" test case failures. All tests can run for up to 10 seconds currently (v19.9.6), due to some involving playback (using paplay from /dev/urandom) being time-sensitive. Changelog and versioning scheme ``````````````````````````````` This package uses one-version-per-commit scheme (updated by pre-commit hook) and pretty much one release per git commit, unless more immediate follow-up commits are planned or too lazy to run ``py setup.py sdist bdist_wheel upload`` for some trivial README typo fix. | Version scheme: ``{year}.{month}.{git-commit-count-this-month}`` | I.e. "16.9.10" is "11th commit on Sep 2016". | There is a `CHANGES.rst `_ file with the list of any intentional breaking changes (should be exceptionally rare, if any) and new/added non-trivial functionality. | It can be a bit out of date though, as one has to remember to update it manually. | "Last synced/updated:" line there might give a hint as to by how much. Installation ------------ It's a regular package for Python (3.x or 2.x). `If a package is available for your distribution`_, using your package manager is the recommended way to install it. Otherwise, using pip_ is the best way:: % pip install pulsectl (add --user option to install into $HOME for current user only) Be sure to use python3/python2, pip3/pip2, easy_install-... commands based on which python version you want to install the module for, if you are still using python2 (and likely have python3 on the system as well). If you don't have "pip" command:: % python -m ensurepip % python -m pip install --upgrade pip % python -m pip install pulsectl (same suggestion wrt "install --user" as above) On a very old systems, one of these might work:: % curl https://bootstrap.pypa.io/get-pip.py | python % pip install pulsectl % easy_install pulsectl % git clone --depth=1 https://github.com/mk-fg/python-pulse-control % cd python-pulse-control % python setup.py install (all of install-commands here also have --user option) Current-git version can be installed like this:: % pip install 'git+https://github.com/mk-fg/python-pulse-control#egg=pulsectl' Note that to install stuff to system-wide PATH and site-packages (without --user), elevated privileges (i.e. root and su/sudo) are often required. Use "...install --user", `~/.pydistutils.cfg`_ or virtualenv_ to do unprivileged installs into custom paths. More info on python packaging can be found at `packaging.python.org`_. .. _If a package is available for your distribution: https://repology.org/project/python:pulsectl/versions .. _pip: http://pip-installer.org/ .. _~/.pydistutils.cfg: http://docs.python.org/install/index.html#distutils-configuration-files .. _virtualenv: http://pypi.python.org/pypi/virtualenv .. _packaging.python.org: https://packaging.python.org/installing/ Links ----- * pulsemixer_ - initial source for this project (embedded in the tool). * `pulsectl-asyncio`_ - similar libpulse wrapper to this one, but for async python code. * `libpulseaudio `_ - different libpulse bindings module, more low-level, auto-generated from pulseaudio header files. Branches there have bindings for different (newer) pulseaudio versions. * `pypulseaudio `_ - high-level bindings module, rather similar to this one. * `pulseaudio-mixer-cli`_ - alsamixer-like script built on top of this module. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1683327928.0 pulsectl-23.5.2/README.rst0000644000175000017500000004026114425305670015002 0ustar00fraggodfraggodpython-pulse-control (pulsectl module) ====================================== Python (3.x and 2.x) blocking high-level interface and ctypes-based bindings for PulseAudio_ (libpulse), to use in a simple synchronous code. Wrappers are mostly for mixer-like controls and introspection-related operations, as opposed to e.g. submitting sound samples to play and player-like client. For async version to use with asyncio_, see `pulsectl-asyncio`_ project instead. Originally forked from pulsemixer_ project, which had this code bundled. .. _PulseAudio: https://wiki.freedesktop.org/www/Software/PulseAudio/ .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _pulsectl-asyncio: https://pypi.org/project/pulsectl-asyncio/ .. _pulsemixer: https://github.com/GeorgeFilipkin/pulsemixer/ | .. contents:: :backlinks: none Repository URLs: - https://github.com/mk-fg/python-pulse-control - https://codeberg.org/mk-fg/python-pulse-control - https://fraggod.net/code/git/python-pulse-control Usage ----- Simple example:: import pulsectl with pulsectl.Pulse('volume-increaser') as pulse: for sink in pulse.sink_list(): # Volume is usually in 0-1.0 range, with >1.0 being soft-boosted pulse.volume_change_all_chans(sink, 0.1) Listening for server state change events:: import pulsectl with pulsectl.Pulse('event-printer') as pulse: # print('Event types:', pulsectl.PulseEventTypeEnum) # print('Event facilities:', pulsectl.PulseEventFacilityEnum) # print('Event masks:', pulsectl.PulseEventMaskEnum) def print_events(ev): print('Pulse event:', ev) ### Raise PulseLoopStop for event_listen() to return before timeout (if any) # raise pulsectl.PulseLoopStop pulse.event_mask_set('all') pulse.event_callback_set(print_events) pulse.event_listen(timeout=10) Misc other tinkering:: >>> import pulsectl >>> pulse = pulsectl.Pulse('my-client-name') >>> pulse.sink_list() [] >>> pulse.sink_input_list() [] >>> pulse.sink_input_list()[0].proplist {'application.icon_name': 'mpv', 'application.language': 'C', 'application.name': 'mpv Media Player', ... 'native-protocol.version': '30', 'window.x11.display': ':1.0'} >>> pulse.source_list() [, ] >>> sink = pulse.sink_list()[0] >>> pulse.volume_change_all_chans(sink, -0.1) >>> pulse.volume_set_all_chans(sink, 0.5) >>> pulse.server_info().default_sink_name 'alsa_output.pci-0000_00_14.2.analog-stereo' >>> pulse.default_set(sink) >>> card = pulse.card_list()[0] >>> card.profile_list [, , ... ] >>> pulse.card_profile_set(card, 'output:hdmi-stereo') >>> help(pulse) ... >>> pulse.close() Current code logic is that all methods are invoked through the Pulse instance, and everything returned from these are "Pulse-Something-Info" objects - thin wrappers around C structs that describe the thing, without any methods attached. Aside from a few added convenience methods, most of them should have similar signature and do same thing as their C libpulse API counterparts, so see `pulseaudio doxygen documentation`_ for more information on them. Pulse client can be integrated into existing eventloop (e.g. asyncio, twisted, etc) using ``Pulse.set_poll_func()`` or ``Pulse.event_listen()`` in a separate thread. Somewhat extended usage example can be found in `pulseaudio-mixer-cli`_ project code, as well as tests here. .. _pulseaudio doxygen documentation: https://freedesktop.org/software/pulseaudio/doxygen/introspect_8h.html .. _pulseaudio-mixer-cli: https://github.com/mk-fg/pulseaudio-mixer-cli/blob/master/pa-mixer-mk3.py Notes ----- Some less obvious things are described in this section. Things not yet wrapped/exposed in python ```````````````````````````````````````` There are plenty of information, methods and other things in libpulse not yet wrapped/exposed by this module, as they weren't needed (yet) for author/devs use-case(s). Making them accessible from python code can be as simple as adding an attribute name to the "c_struct_fields" value in PulseSomethingInfo objects. See `github #3 `_ for a more concrete example of finding/adding such stuff. For info and commands that are not available through libpulse introspection API, it is possible to use ``pulsectl.connect_to_cli()`` fallback function, which will open unix socket to server's "module-cli" (signaling to load it, if necessary), which can be used in exactly same way as "pacmd" tool (not to be confused with "pactl", which uses native protocol instead of module-cli) or pulseaudio startup files (e.g. "default.pa"). Probably a bad idea to parse string output from commands there though, as these are not only subject to change, but can also vary depending on system locale. Volume `````` In PulseAudio, "volume" for anything is not a flat number, but essentially a list of numbers, one per channel (as in "left", "right", "front", "rear", etc), which should correspond to channel map of the object it relates/is-applied to. In this module, such lists are represented by PulseVolumeInfo objects. I.e. ``sink.volume`` is a PulseVolumeInfo instance, and all thin/simple wrappers that accept index of the object, expect such instance to be passed, e.g. ``pulse.sink_input_volume_set(sink.index, sink.volume)``. There are convenience ``volume_get_all_chans``, ``volume_set_all_chans`` and ``volume_change_all_chans`` methods to get/set/adjust volume as/by a single numeric value, which is also accessible on PulseVolumeInfo objects as a ``value_flat`` property. PulseVolumeInfo can be constructed from a numeric volume value plus number of channels, or a python list of per-channel numbers. All per-channel volume values in PulseVolumeInfo (and flat values in the wrapper funcs above), are float objects in 0-65536 range, with following meanings: * 0.0 volume is "no sound" (corresponds to PA_VOLUME_MUTED). * 1.0 value is "current sink volume level", 100% or PA_VOLUME_NORM. * >1.0 and up to 65536.0 (PA_VOLUME_MAX / PA_VOLUME_NORM) - software-boosted sound volume (higher values will negatively affect sound quality). Probably a good idea to set volume only in 0-1.0 range and boost volume in hardware without quality loss, e.g. by tweaking sink volume (which corresponds to ALSA/hardware volume), if that option is available. Note that ``flat-volumes=yes`` option ("yes" by default on some distros, "no" in e.g. Arch Linux) in pulseaudio daemon.conf already scales device-volume with the volume of the "loudest" application, so already does what's suggested above. Fractional volume values used in the module get translated (in a linear fashion) to/from pa_volume_t integers for libpulse. See ``src/pulse/volume.h`` in pulseaudio sources for all the gory details on the latter (e.g. how it relates to sound level in dB). Code example:: from pulsectl import Pulse, PulseVolumeInfo with Pulse('volume-example') as pulse: sink_input = pulse.sink_input_list()[0] # first random sink-input stream volume = sink_input.volume print(volume.values) # list of per-channel values (floats) print(volume.value_flat) # average level across channels (float) time.sleep(1) volume.value_flat = 0.3 # sets all volume.values to 0.3 pulse.volume_set(sink_input, volume) # applies the change time.sleep(1) n_channels = len(volume.values) new_volume = PulseVolumeInfo(0.5, n_channels) # 0.5 across all n_channels # new_volume = PulseVolumeInfo([0.15, 0.25]) # from a list of channel levels (stereo) pulse.volume_set(sink_input, new_volume) # pulse.sink_input_volume_set(sink_input.index, new_volume) # same as above In most common cases, doing something like ``pulse.volume_set_all_chans(sink_input, 0.2)`` should do the trick though - no need to bother with specific channels in PulseVolumeInfo there. String values ````````````` libpulse explicitly returns utf-8-encoded string values, which are always decoded to "abstract string" type in both python-2 (where it's called "unicode") and python-3 ("str"), for consistency. It might be wise to avoid mixing these with encoded strings ("bytes") in the code, especially in python-2, where "bytes" is often used as a default string type. Enumerated/named values (enums) ``````````````````````````````` In place of C integers that correspond to some enum or constant (e.g. -1 for PA_SINK_INVALID_STATE), module returns EnumValue objects, which are comparable to strings ("str" type in py2/py3). For example:: >>> pulsectl.PulseEventTypeEnum.change == 'change' True >>> pulsectl.PulseEventTypeEnum.change >>> pulsectl.PulseEventTypeEnum It might be preferrable to use enums instead of strings in the code so that interpreter can signal error on any typos or unknown values specified, as opposed to always silently failing checks with bogus strings. Event-handling code, threads ```````````````````````````` libpulse clients always work as an event loop, though this module kinda hides it, presenting a more old-style blocking interface. So what happens on any call (e.g. ``pulse.mute(...)``) is: * Make a call to libpulse, specifying callback for when operation will be completed. * Run libpulse event loop until that callback gets called. * Return result passed to that callback call, if any (for various "get" methods). ``event_callback_set()`` and ``event_listen()`` calls essentally do raw first and second step here. Which means that any pulse calls from callback function can't be used when ``event_listen()`` (or any other pulse call through this module, for that matter) waits for return value and runs libpulse loop already. One can raise PulseLoopStop exception there to make ``event_listen()`` return, run whatever pulse calls after that, then re-start the ``event_listen()`` thing. This will not miss any events, as all blocking calls do same thing as ``event_listen()`` does (second step above), and can cause callable passed to ``event_callback_set()`` to be called (when loop is running). Also, same instance of libpulse eventloop can't be run from different threads, naturally, so if threads are used, client can be initialized with ``threading_lock=True`` option (can also accept lock instance instead of True) to create a mutex around step-2 (run event loop) from the list above, so multiple threads won't do it at the same time. For proper python eventloop integration (think twisted or asyncio), use `pulsectl-asyncio`_ module instead. There are also some tricks mentioned in `github #11 `_ to shoehorn this module into async apps, but even with non-asyncio eventloop, starting from pulsectl-asyncio would probably be much easier. Tests ````` Test code is packaged/installed with the module and can be useful to run when changing module code, or to check if current python, module and pulseudio versions all work fine together. Commands to run tests from either checkout directory or installed module:: % python2 -m unittest discover % python3 -m unittest discover Note that if "pulsectl" module is available both in current directory (e.g. checkout dir) and user/system python module path, former should always take priority for commands above. Add e.g. ``-k test_stream_move`` for commands above to match and run specific test(s), and when isolating specific failure, it might also be useful to run with PA_DEBUG=1 env-var to get full verbose pulseaudio log, for example:: % PA_DEBUG=1 python -m unittest discover -k test_module_funcs Test suite runs ad-hoc isolated pulseaudio instance with null-sinks (not touching hardware), custom (non-default) startup script and environment, and interacts only with that instance, terminating it afterwards. Still uses system/user daemon.conf files though, so these can affect the tests. Any test failures can indicate incompatibilities, bugs in the module code, issues with pulseaudio (or its daemon.conf) and underlying dependencies. There are no "expected" test case failures. All tests can run for up to 10 seconds currently (v19.9.6), due to some involving playback (using paplay from /dev/urandom) being time-sensitive. Changelog and versioning scheme ``````````````````````````````` This package uses one-version-per-commit scheme (updated by pre-commit hook) and pretty much one release per git commit, unless more immediate follow-up commits are planned or too lazy to run ``py setup.py sdist bdist_wheel upload`` for some trivial README typo fix. | Version scheme: ``{year}.{month}.{git-commit-count-this-month}`` | I.e. "16.9.10" is "11th commit on Sep 2016". | There is a `CHANGES.rst `_ file with the list of any intentional breaking changes (should be exceptionally rare, if any) and new/added non-trivial functionality. | It can be a bit out of date though, as one has to remember to update it manually. | "Last synced/updated:" line there might give a hint as to by how much. Installation ------------ It's a regular package for Python (3.x or 2.x). `If a package is available for your distribution`_, using your package manager is the recommended way to install it. Otherwise, using pip_ is the best way:: % pip install pulsectl (add --user option to install into $HOME for current user only) Be sure to use python3/python2, pip3/pip2, easy_install-... commands based on which python version you want to install the module for, if you are still using python2 (and likely have python3 on the system as well). If you don't have "pip" command:: % python -m ensurepip % python -m pip install --upgrade pip % python -m pip install pulsectl (same suggestion wrt "install --user" as above) On a very old systems, one of these might work:: % curl https://bootstrap.pypa.io/get-pip.py | python % pip install pulsectl % easy_install pulsectl % git clone --depth=1 https://github.com/mk-fg/python-pulse-control % cd python-pulse-control % python setup.py install (all of install-commands here also have --user option) Current-git version can be installed like this:: % pip install 'git+https://github.com/mk-fg/python-pulse-control#egg=pulsectl' Note that to install stuff to system-wide PATH and site-packages (without --user), elevated privileges (i.e. root and su/sudo) are often required. Use "...install --user", `~/.pydistutils.cfg`_ or virtualenv_ to do unprivileged installs into custom paths. More info on python packaging can be found at `packaging.python.org`_. .. _If a package is available for your distribution: https://repology.org/project/python:pulsectl/versions .. _pip: http://pip-installer.org/ .. _~/.pydistutils.cfg: http://docs.python.org/install/index.html#distutils-configuration-files .. _virtualenv: http://pypi.python.org/pypi/virtualenv .. _packaging.python.org: https://packaging.python.org/installing/ Links ----- * pulsemixer_ - initial source for this project (embedded in the tool). * `pulsectl-asyncio`_ - similar libpulse wrapper to this one, but for async python code. * `libpulseaudio `_ - different libpulse bindings module, more low-level, auto-generated from pulseaudio header files. Branches there have bindings for different (newer) pulseaudio versions. * `pypulseaudio `_ - high-level bindings module, rather similar to this one. * `pulseaudio-mixer-cli`_ - alsamixer-like script built on top of this module. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.478202 pulsectl-23.5.2/pulsectl/0000755000175000017500000000000014432752527015150 5ustar00fraggodfraggod././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1642881943.0 pulsectl-23.5.2/pulsectl/__init__.py0000644000175000017500000000123114173061627017252 0ustar00fraggodfraggod# -*- coding: utf-8 -*- from __future__ import print_function from . import _pulsectl from .pulsectl import ( PulsePortInfo, PulseClientInfo, PulseServerInfo, PulseModuleInfo, PulseSinkInfo, PulseSinkInputInfo, PulseSourceInfo, PulseSourceOutputInfo, PulseCardProfileInfo, PulseCardPortInfo, PulseCardInfo, PulseVolumeInfo, PulseExtStreamRestoreInfo, PulseEventInfo, PulseEventTypeEnum, PulseEventFacilityEnum, PulseEventMaskEnum, PulseStateEnum, PulseUpdateEnum, PulsePortAvailableEnum, PulseDirectionEnum, PulseError, PulseIndexError, PulseOperationFailed, PulseOperationInvalid, PulseLoopStop, PulseDisconnected, PulseObject, Pulse, connect_to_cli ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788339.0 pulsectl-23.5.2/pulsectl/_pulsectl.py0000644000175000017500000005405214432752163017516 0ustar00fraggodfraggod# -*- coding: utf-8 -*- from __future__ import print_function # C Bindings import os, sys, ctypes.util, functools as ft from ctypes import * force_str = lambda s, errors='strict': s.decode('utf-8', errors) if isinstance(s, bytes) else s force_bytes = lambda s, errors='strict': s.encode('utf-8', errors) if isinstance(s, unicode) else s if sys.version_info.major >= 3: class c_str_p_type(object): c_type = c_char_p def __call__(self, val): return force_str(val) def from_param(self, val): # int will be interpreted as pointer and segfault in py3 if isinstance(val, int): raise ArgumentError(type(val)) return force_bytes(val) unicode, c_str_p = str, c_str_p_type() import time mono_time = time.monotonic else: c_str_p = c_char_p def mono_time(): if not hasattr(mono_time, 'ts'): class timespec(Structure): _fields_ = [('tv_sec', c_long), ('tv_nsec', c_long)] librt = CDLL('librt.so.1', use_errno=True) mono_time.get = librt.clock_gettime mono_time.get.argtypes = [c_int, POINTER(timespec)] mono_time.ts = timespec ts = mono_time.ts() if mono_time.get(4, pointer(ts)) != 0: err = get_errno() raise OSError(err, os.strerror(err)) return ts.tv_sec + ts.tv_nsec * 1e-9 PA_INVALID = 2**32-1 PA_VOLUME_NORM = 0x10000 PA_VOLUME_MAX = (2**32-1) // 2 # was different before pulseaudio-1.0, see 179b291b there PA_VOLUME_INVALID = 2**32-1 pa_sw_volume_from_dB = lambda db:\ min(PA_VOLUME_MAX, int(round(((10.0 ** (db / 20.0)) ** (1/3)) * PA_VOLUME_NORM))) PA_VOLUME_UI_MAX = 99957 # pa_sw_volume_from_dB(+11.0) PA_CHANNELS_MAX = 32 PA_USEC_T = c_uint64 PA_CONTEXT_NOAUTOSPAWN = 0x0001 PA_CONTEXT_NOFAIL = 0x0002 PA_CONTEXT_UNCONNECTED = 0 PA_CONTEXT_CONNECTING = 1 PA_CONTEXT_AUTHORIZING = 2 PA_CONTEXT_SETTING_NAME = 3 PA_CONTEXT_READY = 4 PA_CONTEXT_FAILED = 5 PA_CONTEXT_TERMINATED = 6 PA_SUBSCRIPTION_MASK_NULL = 0x0000 PA_SUBSCRIPTION_MASK_SINK = 0x0001 PA_SUBSCRIPTION_MASK_SOURCE = 0x0002 PA_SUBSCRIPTION_MASK_SINK_INPUT = 0x0004 PA_SUBSCRIPTION_MASK_SOURCE_OUTPUT = 0x0008 PA_SUBSCRIPTION_MASK_MODULE = 0x0010 PA_SUBSCRIPTION_MASK_CLIENT = 0x0020 PA_SUBSCRIPTION_MASK_SAMPLE_CACHE = 0x0040 PA_SUBSCRIPTION_MASK_SERVER = 0x0080 PA_SUBSCRIPTION_MASK_AUTOLOAD = 0x0100 PA_SUBSCRIPTION_MASK_CARD = 0x0200 PA_SUBSCRIPTION_MASK_ALL = 0x02ff PA_SUBSCRIPTION_EVENT_SINK = 0x0000 PA_SUBSCRIPTION_EVENT_SOURCE = 0x0001 PA_SUBSCRIPTION_EVENT_SINK_INPUT = 0x0002 PA_SUBSCRIPTION_EVENT_SOURCE_OUTPUT = 0x0003 PA_SUBSCRIPTION_EVENT_MODULE = 0x0004 PA_SUBSCRIPTION_EVENT_CLIENT = 0x0005 PA_SUBSCRIPTION_EVENT_SAMPLE_CACHE = 0x0006 PA_SUBSCRIPTION_EVENT_SERVER = 0x0007 PA_SUBSCRIPTION_EVENT_AUTOLOAD = 0x0008 PA_SUBSCRIPTION_EVENT_CARD = 0x0009 PA_SUBSCRIPTION_EVENT_FACILITY_MASK = 0x000F PA_SUBSCRIPTION_EVENT_NEW = 0x0000 PA_SUBSCRIPTION_EVENT_CHANGE = 0x0010 PA_SUBSCRIPTION_EVENT_REMOVE = 0x0020 PA_SUBSCRIPTION_EVENT_TYPE_MASK = 0x0030 PA_SAMPLE_FLOAT32LE = 5 PA_SAMPLE_FLOAT32BE = 6 PA_SAMPLE_FLOAT32NE = dict( little=PA_SAMPLE_FLOAT32LE, big=PA_SAMPLE_FLOAT32BE )[sys.byteorder] PA_STREAM_DONT_MOVE = 0x0200 PA_STREAM_PEAK_DETECT = 0x0800 PA_STREAM_ADJUST_LATENCY = 0x2000 PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND = 0x8000 def c_enum_map(**values): return dict((v, force_str(k)) for k,v in values.items()) _globals = globals().copy() _pa_ev_type = dict( (force_str(k), _globals['PA_SUBSCRIPTION_EVENT_{}'.format(k.upper())]) for k in 'new change remove'.split() ) _pa_ev_fac, _pa_ev_mask = dict(), dict() for k, n in _globals.items(): if k.startswith('PA_SUBSCRIPTION_EVENT_'): if k.endswith('_MASK'): continue k = force_str(k[22:].lower()) if k in _pa_ev_type: continue assert n & PA_SUBSCRIPTION_EVENT_FACILITY_MASK == n, [k, n] _pa_ev_fac[k] = n elif k.startswith('PA_SUBSCRIPTION_MASK_'): _pa_ev_mask[force_str(k[21:].lower())] = n PA_EVENT_TYPE_MAP = c_enum_map(**_pa_ev_type) PA_EVENT_FACILITY_MAP = c_enum_map(**_pa_ev_fac) PA_EVENT_MASK_MAP = c_enum_map(**_pa_ev_mask) del _globals, _pa_ev_type, _pa_ev_fac, _pa_ev_mask PA_UPDATE_MAP = c_enum_map(set=0, merge=1, replace=2) PA_PORT_AVAILABLE_MAP = c_enum_map(unknown=0, no=1, yes=2) PA_DIRECTION_MAP = c_enum_map(unknown=0, output=1, input=2) # These are defined separately as # pa_sink_state / pa_source_state, but seem to match. PA_OBJ_STATE_MAP = c_enum_map(invalid=-1, running=0, idle=1, suspended=2) class PA_MAINLOOP(Structure): pass class PA_STREAM(Structure): pass class PA_MAINLOOP_API(Structure): pass class PA_CONTEXT(Structure): pass class PA_PROPLIST(Structure): pass class PA_OPERATION(Structure): pass class PA_SIGNAL_EVENT(Structure): pass class PA_IO_EVENT(Structure): pass class PA_SAMPLE_SPEC(Structure): _fields_ = [ ('format', c_int), ('rate', c_uint32), ('channels', c_uint8) ] class PA_CHANNEL_MAP(Structure): _fields_ = [ ('channels', c_uint8), ('map', c_int * PA_CHANNELS_MAX) ] class PA_CVOLUME(Structure): _fields_ = [ ('channels', c_uint8), ('values', c_uint32 * PA_CHANNELS_MAX) ] class PA_PORT_INFO(Structure): _fields_ = [ ('name', c_char_p), ('description', c_char_p), ('priority', c_uint32), ('available', c_int), ] class PA_SINK_INPUT_INFO(Structure): _fields_ = [ ('index', c_uint32), ('name', c_char_p), ('owner_module', c_uint32), ('client', c_uint32), ('sink', c_uint32), ('sample_spec', PA_SAMPLE_SPEC), ('channel_map', PA_CHANNEL_MAP), ('volume', PA_CVOLUME), ('buffer_usec', PA_USEC_T), ('sink_usec', PA_USEC_T), ('resample_method', c_char_p), ('driver', c_char_p), ('mute', c_int), ('proplist', POINTER(PA_PROPLIST)), ('corked', c_int), ('has_volume', c_int), ('volume_writable', c_int), ] class PA_SINK_INFO(Structure): _fields_ = [ ('name', c_char_p), ('index', c_uint32), ('description', c_char_p), ('sample_spec', PA_SAMPLE_SPEC), ('channel_map', PA_CHANNEL_MAP), ('owner_module', c_uint32), ('volume', PA_CVOLUME), ('mute', c_int), ('monitor_source', c_uint32), ('monitor_source_name', c_char_p), ('latency', PA_USEC_T), ('driver', c_char_p), ('flags', c_int), ('proplist', POINTER(PA_PROPLIST)), ('configured_latency', PA_USEC_T), ('base_volume', c_uint32), ('state', c_int), ('n_volume_steps', c_int), ('card', c_uint32), ('n_ports', c_uint32), ('ports', POINTER(POINTER(PA_PORT_INFO))), ('active_port', POINTER(PA_PORT_INFO)), ] class PA_SOURCE_OUTPUT_INFO(Structure): _fields_ = [ ('index', c_uint32), ('name', c_char_p), ('owner_module', c_uint32), ('client', c_uint32), ('source', c_uint32), ('sample_spec', PA_SAMPLE_SPEC), ('channel_map', PA_CHANNEL_MAP), ('buffer_usec', PA_USEC_T), ('source_usec', PA_USEC_T), ('resample_method', c_char_p), ('driver', c_char_p), ('proplist', POINTER(PA_PROPLIST)), ('corked', c_int), ('volume', PA_CVOLUME), ('mute', c_int), ('has_volume', c_int), ('volume_writable', c_int), ] class PA_SOURCE_INFO(Structure): _fields_ = [ ('name', c_char_p), ('index', c_uint32), ('description', c_char_p), ('sample_spec', PA_SAMPLE_SPEC), ('channel_map', PA_CHANNEL_MAP), ('owner_module', c_uint32), ('volume', PA_CVOLUME), ('mute', c_int), ('monitor_of_sink', c_uint32), ('monitor_of_sink_name', c_char_p), ('latency', PA_USEC_T), ('driver', c_char_p), ('flags', c_int), ('proplist', POINTER(PA_PROPLIST)), ('configured_latency', PA_USEC_T), ('base_volume', c_uint32), ('state', c_int), ('n_volume_steps', c_int), ('card', c_uint32), ('n_ports', c_uint32), ('ports', POINTER(POINTER(PA_PORT_INFO))), ('active_port', POINTER(PA_PORT_INFO)), ] class PA_CLIENT_INFO(Structure): _fields_ = [ ('index', c_uint32), ('name', c_char_p), ('owner_module', c_uint32), ('driver', c_char_p), ('proplist', POINTER(PA_PROPLIST)), ] class PA_SERVER_INFO(Structure): _fields_ = [ ('user_name', c_char_p), ('host_name', c_char_p), ('server_version', c_char_p), ('server_name', c_char_p), ('sample_spec', PA_SAMPLE_SPEC), ('default_sink_name', c_char_p), ('default_source_name', c_char_p), ('cookie', c_uint32), ('channel_map', PA_CHANNEL_MAP), ] class PA_CARD_PROFILE_INFO(Structure): _fields_ = [ ('name', c_char_p), ('description', c_char_p), ('n_sinks', c_uint32), ('n_sources', c_uint32), ('priority', c_uint32), ('available', c_int), ] # Extends PA_PORT_INFO with a few card-specific things class PA_CARD_PORT_INFO(Structure): _fields_ = [ ('name', c_char_p), ('description', c_char_p), ('priority', c_uint32), ('available', c_int), ('direction', c_int), ('n_profiles', c_uint32), ('profiles', c_void_p), # use profiles2 ('proplist', POINTER(PA_PROPLIST)), ('latency_offset', c_int64), ('profiles2', POINTER(POINTER(PA_CARD_PROFILE_INFO))), ] class PA_CARD_INFO(Structure): _fields_ = [ ('index', c_uint32), ('name', c_char_p), ('owner_module', c_uint32), ('driver', c_char_p), ('n_profiles', c_uint32), ('profiles', c_void_p), # use profiles2 / active_profile2 ('active_profile', c_void_p), ('proplist', POINTER(PA_PROPLIST)), ('n_ports', c_uint32), ('ports', POINTER(POINTER(PA_CARD_PORT_INFO))), ('profiles2', POINTER(POINTER(PA_CARD_PROFILE_INFO))), ('active_profile2', POINTER(PA_CARD_PROFILE_INFO)), ] class PA_MODULE_INFO(Structure): _fields_ = [ ('index', c_uint32), ('name', c_char_p), ('argument', c_char_p), ('n_used', c_uint32), ('auto_unload', c_int), ('proplist', POINTER(PA_PROPLIST)), ] class PA_EXT_STREAM_RESTORE_INFO(Structure): _fields_ = [ ('name', c_char_p), ('channel_map', PA_CHANNEL_MAP), ('volume', PA_CVOLUME), ('device', c_char_p), ('mute', c_int), ] class PA_BUFFER_ATTR(Structure): _fields_ = [ ('maxlength', c_uint32), ('tlength', c_uint32), ('prebuf', c_uint32), ('minreq', c_uint32), ('fragsize', c_uint32), ] class POLLFD(Structure): _fields_ = [ ('fd', c_int), ('events', c_short), ('revents', c_short), ] PA_POLL_FUNC_T = CFUNCTYPE(c_int, POINTER(POLLFD), c_ulong, c_int, c_void_p) PA_SIGNAL_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_MAINLOOP_API), POINTER(c_int), c_int, c_void_p) PA_STATE_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_void_p) PA_CLIENT_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_CLIENT_INFO), c_int, c_void_p) PA_SERVER_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_SERVER_INFO), c_void_p) PA_SINK_INPUT_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_SINK_INPUT_INFO), c_int, c_void_p) PA_SINK_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_SINK_INFO), c_int, c_void_p) PA_SOURCE_OUTPUT_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_SOURCE_OUTPUT_INFO), c_int, c_void_p) PA_SOURCE_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_SOURCE_INFO), c_int, c_void_p) PA_CONTEXT_DRAIN_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_void_p) PA_CONTEXT_INDEX_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_uint32, c_void_p) PA_CONTEXT_SUCCESS_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_int, c_void_p) PA_EXT_STREAM_RESTORE_TEST_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_uint32, c_void_p) PA_EXT_STREAM_RESTORE_READ_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_EXT_STREAM_RESTORE_INFO), c_int, c_void_p) PA_CARD_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_CARD_INFO), c_int, c_void_p) PA_MODULE_INFO_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), POINTER(PA_MODULE_INFO), c_int, c_void_p) PA_SUBSCRIBE_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_CONTEXT), c_int, c_int, c_void_p) PA_STREAM_REQUEST_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_STREAM), c_int, c_void_p) PA_STREAM_NOTIFY_CB_T = CFUNCTYPE(c_void_p, POINTER(PA_STREAM), c_void_p) class LibPulse(object): # func_def ::= arg_types_list | (arg_types_list, res_spec) | (res_spec, arg_types_list) # res_spec ::= ctypes_restype # | res_proc_func | (ctypes_restype, res_proc_func) # | res_spec_name_str | (ctypes_restype, res_spec_name_str) # res_spec_name_str ::= 'int_check_ge0' | 'pa_op' | ... 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), pa_mainloop_prepare=([POINTER(PA_MAINLOOP), c_int], 'int_check_ge0'), pa_mainloop_poll=([POINTER(PA_MAINLOOP)], 'int_check_ge0'), pa_mainloop_dispatch=([POINTER(PA_MAINLOOP)], 'int_check_ge0'), pa_mainloop_iterate=([POINTER(PA_MAINLOOP), c_int, POINTER(c_int)], 'int_check_ge0'), pa_mainloop_wakeup=[POINTER(PA_MAINLOOP)], 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]), pa_context_connect=([POINTER(PA_CONTEXT), c_str_p, c_int, POINTER(c_int)], 'int_check_ge0'), pa_context_get_state=([POINTER(PA_CONTEXT)], c_int), pa_context_disconnect=[POINTER(PA_CONTEXT)], pa_context_unref=[POINTER(PA_CONTEXT)], pa_context_drain=( 'pa_op', [POINTER(PA_CONTEXT), PA_CONTEXT_DRAIN_CB_T, c_void_p] ), pa_context_set_default_sink=( 'pa_op', [POINTER(PA_CONTEXT), c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_default_source=( 'pa_op', [POINTER(PA_CONTEXT), c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_get_sink_input_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_SINK_INPUT_INFO_CB_T, c_void_p] ), pa_context_get_sink_input_info=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_SINK_INPUT_INFO_CB_T, c_void_p] ), pa_context_get_sink_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_SINK_INFO_CB_T, c_void_p] ), pa_context_get_sink_info_by_name=( 'pa_op', [POINTER(PA_CONTEXT), c_str_p, PA_SINK_INFO_CB_T, c_void_p] ), pa_context_get_sink_info_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_SINK_INFO_CB_T, c_void_p] ), pa_context_set_sink_mute_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_suspend_sink_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_sink_port_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_sink_input_mute=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_sink_volume_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_sink_input_volume=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_move_sink_input_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_get_source_output_info=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_SOURCE_OUTPUT_INFO_CB_T, c_void_p] ), pa_context_get_source_output_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_SOURCE_OUTPUT_INFO_CB_T, c_void_p] ), pa_context_move_source_output_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_source_output_volume=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_source_output_mute=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_kill_source_output=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_get_source_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_SOURCE_INFO_CB_T, c_void_p] ), pa_context_get_source_info_by_name=( 'pa_op', [POINTER(PA_CONTEXT), c_str_p, PA_SOURCE_INFO_CB_T, c_void_p] ), pa_context_get_source_info_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_SOURCE_INFO_CB_T, c_void_p] ), pa_context_set_source_volume_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, POINTER(PA_CVOLUME), PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_source_mute_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_suspend_source_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_int, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_set_source_port_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_get_client_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_CLIENT_INFO_CB_T, c_void_p] ), pa_context_get_client_info=( 'pa_op', [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_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', [POINTER(PA_CONTEXT), c_str_p, PA_CARD_INFO_CB_T, c_void_p] ), pa_context_get_card_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_CARD_INFO_CB_T, c_void_p] ), pa_context_set_card_profile_by_index=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, c_str_p, PA_CONTEXT_SUCCESS_CB_T, c_void_p] ), pa_context_get_module_info=( 'pa_op', [POINTER(PA_CONTEXT), c_uint32, PA_MODULE_INFO_CB_T, c_void_p] ), pa_context_get_module_info_list=( 'pa_op', [POINTER(PA_CONTEXT), PA_MODULE_INFO_CB_T, c_void_p] ), pa_context_load_module=( 'pa_op', [POINTER(PA_CONTEXT), c_char_p, c_char_p, PA_CONTEXT_INDEX_CB_T, c_void_p] ), pa_context_unload_module=( 'pa_op', [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_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', [POINTER(PA_CONTEXT), PA_EXT_STREAM_RESTORE_READ_CB_T, c_void_p] ), pa_ext_stream_restore_write=( 'pa_op', [ POINTER(PA_CONTEXT), c_int, POINTER(PA_EXT_STREAM_RESTORE_INFO), 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_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_channel_map_init_mono=( [POINTER(PA_CHANNEL_MAP)], (POINTER(PA_CHANNEL_MAP), 'not_null') ), pa_channel_map_init_stereo=( [POINTER(PA_CHANNEL_MAP)], (POINTER(PA_CHANNEL_MAP), 'not_null') ), 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_channel_position_to_string=([c_int], c_str_p), pa_stream_new_with_proplist=( [ POINTER(PA_CONTEXT), c_str_p, POINTER(PA_SAMPLE_SPEC), POINTER(PA_CHANNEL_MAP), POINTER(PA_PROPLIST) ], POINTER(PA_STREAM) ), pa_stream_set_monitor_stream=([POINTER(PA_STREAM), c_uint32], 'int_check_ge0'), pa_stream_set_read_callback=[POINTER(PA_STREAM), PA_STREAM_REQUEST_CB_T, c_void_p], pa_stream_connect_record=( [POINTER(PA_STREAM), c_str_p, POINTER(PA_BUFFER_ATTR), c_int], 'int_check_ge0' ), pa_stream_unref=[POINTER(PA_STREAM)], 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') ) class CallError(Exception): pass def __init__(self): p = CDLL(ctypes.util.find_library('libpulse') or 'libpulse.so.0') self.funcs = dict() for k, spec in self.func_defs.items(): func, args, res_proc = getattr(p, k), None, None if spec: if not isinstance(spec, tuple): spec = (spec,) for v in spec: assert v, [k, spec, v] if isinstance(v, list): args = v else: res_proc = v func_k = k if not k.startswith('pa_') else k[3:] self.funcs[func_k] = self._func_wrapper(k, func, args, res_proc) def _func_wrapper(self, func_name, func, args=list(), res_proc=None): func.restype, func.argtypes = None, args if isinstance(res_proc, tuple): func.restype, res_proc = res_proc if isinstance(res_proc, str): if res_proc.startswith('int_check_'): func.restype = c_int elif res_proc == 'pa_op': func.restype = POINTER(PA_OPERATION) elif not func.restype and hasattr(res_proc, 'c_type'): func.restype = res_proc.c_type elif not func.restype: func.restype, res_proc = res_proc, None def _wrapper(*args): # print('libpulse call:', func_name, args, file=sys.stderr) # sys.stderr.flush() res = func(*args) if isinstance(res_proc, str): assert res_proc in ['int_check_ge0', 'pa_op', 'not_null'] if (res_proc == 'int_check_ge0' and res < 0)\ or (res_proc == 'pa_op' and not res)\ or (res_proc == 'not_null' and not res): err = [func_name, args, res] if args and isinstance(getattr(args[0], 'contents', None), PA_CONTEXT): errno_ = self.context_errno(args[0]) err.append('{} [pulse errno {}]'.format(self.strerror(errno_), errno_)) else: err.append('Return value check failed: {}'.format(res_proc)) raise self.CallError(*err) elif res_proc: res = res_proc(res) return res _wrapper.__name__ = 'libpulse.{}'.format(func_name) return _wrapper def __getattr__(self, k): return self.funcs[k] def return_value(self): return pointer(c_int()) pa = LibPulse() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1524502130.0 pulsectl-23.5.2/pulsectl/lookup.py0000644000175000017500000001044113267407162017030 0ustar00fraggodfraggod# -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals import itertools as it, operator as op, functools as ft import re lookup_types = { 'sink': 'sink_list', 'source': 'source_list', 'sink-input': 'sink_input_list', 'source-output': 'source_output_list' } lookup_types.update(it.chain.from_iterable( ((v, lookup_types[k]) for v in v) for k,v in { 'source': ['src'], 'sink-input': ['si', 'playback', 'play'], 'source-output': ['so', 'record', 'rec', 'mic'] }.items() )) lookup_key_defaults = dict( # No default keys for type = no implicit matches for that type sink_input_list=[ # match sink_input_list objects with these keys by default 'media.name', 'media.icon_name', 'media.role', 'application.name', 'application.process.binary', 'application.icon_name' ] ) def pulse_obj_lookup(pulse, obj_lookup, prop_default=None): '''Return set of pulse object(s) with proplist values matching lookup-string. Pattern syntax: [ { 'sink' | 'source' | 'sink-input' | 'source-output' } [ / ... ] ':' ] [ proplist-key-name (non-empty) [ / ... ] ':' ] [ ':' (for regexp match) ] [ proplist-key-value ] Examples: - sink:alsa.driver_name:snd_hda_intel Match sink(s) with alsa.driver_name=snd_hda_intel (exact match!). - sink/source:device.bus:pci Match all sinks and sources with device.bus=pci. - myprop:somevalue Match any object (of all 4 supported types) that has myprop=somevalue. - mpv Match any object with any of the "default lookup props" (!!!) being equal to "mpv". "default lookup props" are specified per-type in lookup_key_defaults above. For example, sink input will be looked-up by media.name, application.name, etc. - sink-input/source-output:mpv Same as above, but lookup streams only (not sinks/sources). Note that "sink-input/source-output" matches type spec, and parsed as such, not as key. - si/so:mpv Same as above - see aliases for types in lookup_types. - application.binary/application.icon:mpv Lookup by multiple keys with "any match" logic, same as with multiple object types. - key\/with\/slashes\:and\:colons:somevalue Lookup by key that has slashes and colons in it. "/" and ":" must only be escaped in the proplist key part, used as-is in values. Backslash itself can be escaped as well, i.e. as "\\". - module-stream-restore.id:sink-input-by-media-role:music Value has ":" in it, but there's no need to escape it in any way. - device.description::Analog Value lookup starting with : is interpreted as a regexp, i.e. any object with device.description *containing* "Analog" in this case. - si/so:application.name::^mpv\b Return all sink-inputs/source-outputs ("si/so") where "application.name" proplist value matches regexp "^mpv\b". - :^mpv\b Regexp lookup (stuff starting with "mpv" word) without type or key specification. For python2, lookup string should be unicode type. "prop_default" keyword arg can be used to specify default proplist value for when key is not found there.''' # \ue000-\uf8ff - private use area, never assigned to symbols obj_lookup = obj_lookup.replace('\\\\', '\ue000').replace('\\:', '\ue001') obj_types_re = '({0})(/({0}))*'.format('|'.join(lookup_types)) m = re.search( ( r'^((?P{}):)?'.format(obj_types_re) + r'((?P.+?):)?' r'(?P.*)$' ), obj_lookup, re.IGNORECASE ) if not m: raise ValueError(obj_lookup) lookup_type, lookup_keys, lookup_re = op.itemgetter('t', 'k', 'v')(m.groupdict()) if lookup_keys: lookup_keys = list( v.replace('\ue000', '\\\\').replace('\ue001', ':').replace('\ue002', '/') for v in lookup_keys.replace('\\/', '\ue002').split('/') ) lookup_re = lookup_re.replace('\ue000', '\\\\').replace('\ue001', '\\:') obj_list_res, lookup_re = list(), re.compile( lookup_re[1:] if lookup_re.startswith(':') else '^{}$'.format(re.escape(lookup_re)) ) for k in set( lookup_types[k] for k in (lookup_type.split('/') if lookup_type else lookup_types.keys()) ): if not lookup_keys: lookup_keys = lookup_key_defaults.get(k) if not lookup_keys: continue obj_list = getattr(pulse, k)() if not obj_list: continue for obj, k in it.product(obj_list, lookup_keys): v = obj.proplist.get(k, prop_default) if v is None: continue if lookup_re.search(v): obj_list_res.append(obj) return set(obj_list_res) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1683327623.0 pulsectl-23.5.2/pulsectl/pulsectl.py0000644000175000017500000012017514425305207017352 0ustar00fraggodfraggod# -*- coding: utf-8 -*- from __future__ import print_function import itertools as it, operator as op, functools as ft from collections import defaultdict from contextlib import contextmanager import os, sys, inspect, traceback from . import _pulsectl as c if sys.version_info.major >= 3: long, unicode = int, str print_err = ft.partial(print, file=sys.stderr, flush=True) def wrapper_with_sig_info(func, wrapper, index_arg=False): sig = inspect.signature(func or (lambda: None)) if index_arg: sig = sig.replace(parameters=[inspect.Parameter( 'index', inspect.Parameter.POSITIONAL_OR_KEYWORD )] + list(sig.parameters.values())) wrapper.__name__, wrapper.__signature__, wrapper.__doc__ = '', sig, func.__doc__ return wrapper else: range, map = xrange, it.imap def print_err(*args, **kws): kws.setdefault('file', sys.stderr) print(*args, **kws) kws['file'].flush() def wrapper_with_sig_info(func, wrapper, index_arg=False): func_args = list(inspect.getargspec(func or (lambda: None))) func_args[0] = list(func_args[0]) if index_arg: func_args[0] = ['index'] + func_args[0] wrapper.__name__ = '...' wrapper.__doc__ = 'Signature: func' + inspect.formatargspec(*func_args) if func.__doc__: wrapper.__doc__ += '\n\n' + func.__doc__ return wrapper is_str = lambda v,ext=None,native=False: ( isinstance(v, ( (unicode, bytes) if not native else (str,) ) + ((ext,) if ext else ())) ) is_str_native = ft.partial(is_str, native=True) is_num = lambda v: isinstance(v, (int, float, long)) is_list = lambda v: isinstance(v, (tuple, list)) is_dict = lambda v: isinstance(v, dict) def assert_pulse_object(obj): if not isinstance(obj, PulseObject): raise TypeError( 'PulseInfo' ' object is required instead of value: [{}] {}', type(obj), obj ) class FakeLock(): def __enter__(self): return self def __exit__(self, *err): pass @ft.total_ordering class EnumValue(object): 'String-based enum value, comparable to native strings.' __slots__ = '_t', '_value', '_c_val' def __init__(self, t, value, c_value=None): self._t, self._value, self._c_val = t, value, c_value def __repr__(self): return ''.format(self._t, self._value) def __eq__(self, val): if isinstance(val, EnumValue): val = val._value return self._value == val def __ne__(self, val): return not (self == val) def __lt__(self, val): if isinstance(val, EnumValue): val = val._value return self._value < val def __hash__(self): return hash(self._value) class Enum(object): def __init__(self, name, values_or_map): vals = values_or_map if is_str_native(vals): vals = vals.split() if is_list(vals): vals = zip(it.repeat(None), vals) if is_dict(vals): vals = vals.items() self._name, self._values, self._c_vals = name, dict(), dict() for c_val, k in vals: v = EnumValue(name, k, c_val) setattr(self, k.replace('-', '_'), v) self._c_vals[c_val] = self._values[k] = v def __getitem__(self, k, *default): if isinstance(k, EnumValue): t, k, v = k._t, k._value, k if t != self._name: raise KeyError(v) try: return getattr(self, k.replace('-', '_'), *default) except AttributeError: raise KeyError(k) def _get(self, k, default=None): return self.__getitem__(k, default) def __contains__(self, k): return self._get(k) is not None def _c_val(self, c_val, default=KeyError): v = self._c_vals.get(c_val) if v is not None: return v if default is not KeyError: return EnumValue(self._name, default, c_val) raise KeyError(c_val) def __repr__(self): return ''.format(self._name, ' '.join(sorted(self._values.keys()))) PulseEventTypeEnum = Enum('event-type', c.PA_EVENT_TYPE_MAP) PulseEventFacilityEnum = Enum('event-facility', c.PA_EVENT_FACILITY_MAP) PulseEventMaskEnum = Enum('event-mask', c.PA_EVENT_MASK_MAP) PulseStateEnum = Enum('sink/source-state', c.PA_OBJ_STATE_MAP) PulseUpdateEnum = Enum('update-type', c.PA_UPDATE_MAP) PulsePortAvailableEnum = Enum('available', c.PA_PORT_AVAILABLE_MAP) PulseDirectionEnum = Enum('direction', c.PA_DIRECTION_MAP) class PulseError(Exception): pass class PulseOperationFailed(PulseError): pass class PulseOperationInvalid(PulseOperationFailed): pass class PulseIndexError(PulseError): pass class PulseLoopStop(Exception): pass class PulseDisconnected(Exception): pass class PulseObject(object): c_struct_wrappers = dict() def __init__(self, struct=None, *field_data_list, **field_data_dict): field_data, fields = dict(), getattr(self, 'c_struct_fields', list()) if is_str_native(fields): fields = self.c_struct_fields = fields.split() if field_data_list: field_data.update(zip(fields, field_data_list)) if field_data_dict: field_data.update(field_data_dict) if struct is None: field_data, struct = dict(), field_data assert not set(field_data.keys()).difference(fields) if field_data: self._copy_struct_fields(field_data, fields=field_data.keys()) self._copy_struct_fields(struct, fields=set(fields).difference(field_data.keys())) if struct: if hasattr(struct, 'proplist'): self.proplist, state = dict(), c.c_void_p() while True: k = c.pa.proplist_iterate(struct.proplist, c.byref(state)) if not k: break self.proplist[c.force_str(k)] = c.force_str(c.pa.proplist_gets(struct.proplist, k)) if hasattr(struct, 'volume'): self.volume = self._get_wrapper(PulseVolumeInfo)(struct.volume) if hasattr(struct, 'base_volume'): self.base_volume = struct.base_volume / c.PA_VOLUME_NORM if hasattr(struct, 'n_ports'): cls_port = self._get_wrapper(PulsePortInfo) self.port_list = list( cls_port(struct.ports[n].contents) for n in range(struct.n_ports) ) if hasattr(struct, 'active_port'): cls_port = self._get_wrapper(PulsePortInfo) self.port_active = ( None if not struct.active_port else cls_port(struct.active_port.contents) ) if hasattr(struct, 'channel_map'): self.channel_count, self.channel_list = struct.channel_map.channels, list() self.channel_list_raw = struct.channel_map.map[:self.channel_count] if self.channel_count > 0: s = c.create_string_buffer(b'\0' * 512) c.pa.channel_map_snprint(s, len(s), struct.channel_map) self.channel_list.extend(map(c.force_str, s.value.strip().split(b','))) if hasattr(struct, 'state'): self.state = PulseStateEnum._c_val( struct.state, u'state.{}'.format(struct.state) ) self.state_values = sorted(PulseStateEnum._values.values()) if hasattr(struct, 'corked'): self.corked = bool(struct.corked) self._init_from_struct(struct) def _get_wrapper(self, cls_base): return self.c_struct_wrappers.get(cls_base, cls_base) def _copy_struct_fields(self, struct, fields=None, str_errors='strict'): if not fields: fields = self.c_struct_fields for k in fields: setattr(self, k, c.force_str( getattr(struct, k) if not is_dict(struct) else struct[k], str_errors )) def _init_from_struct(self, struct): pass # to parse fields in subclasses def _as_str(self, ext=None, fields=None, **kws): kws = list(it.starmap('{}={}'.format, kws.items())) if fields: if is_str_native(fields): fields = fields.split() kws.extend('{}={!r}'.format(k, getattr(self, k)) for k in fields) kws = sorted(kws) if ext: kws.append(str(ext)) return ', '.join(kws) def __str__(self): return self._as_str(fields=self.c_struct_fields) def __repr__(self): return '<{} at {:x} - {}>'.format(self.__class__.__name__, id(self), str(self)) class PulsePortInfo(PulseObject): c_struct_fields = 'name description available priority' def _init_from_struct(self, struct): self.available = PulsePortAvailableEnum._c_val(struct.available) self.available_state = self.available # for compatibility with <=17.6.0 def __eq__(self, o): if not isinstance(o, PulsePortInfo): raise TypeError(o) return self.name == o.name def __hash__(self): return hash(self.name) class PulseClientInfo(PulseObject): c_struct_fields = 'name index driver owner_module' class PulseServerInfo(PulseObject): c_struct_fields = ( 'user_name host_name' ' server_version server_name default_sink_name default_source_name cookie' ) class PulseModuleInfo(PulseObject): c_struct_fields = 'index name argument n_used auto_unload' class PulseSinkInfo(PulseObject): c_struct_fields = ( 'index name mute' ' description sample_spec owner_module latency driver' ' monitor_source monitor_source_name flags configured_latency card' ) def __str__(self): return self._as_str(self.volume, fields='index name description mute') class PulseSinkInputInfo(PulseObject): c_struct_fields = ( 'index name mute corked client' ' owner_module sink sample_spec' ' buffer_usec sink_usec resample_method driver' ) def __str__(self): return self._as_str(fields='index name mute') class PulseSourceInfo(PulseObject): c_struct_fields = ( 'index name mute' ' description sample_spec owner_module latency driver monitor_of_sink' ' monitor_of_sink_name flags configured_latency card' ) def __str__(self): return self._as_str(self.volume, fields='index name description mute') class PulseSourceOutputInfo(PulseObject): c_struct_fields = ( 'index name mute corked client' ' owner_module source sample_spec' ' buffer_usec source_usec resample_method driver' ) def __str__(self): return self._as_str(fields='index name mute') class PulseCardProfileInfo(PulseObject): c_struct_fields = 'name description n_sinks n_sources priority available' class PulseCardPortInfo(PulsePortInfo): c_struct_fields = 'name description available priority direction latency_offset' def _init_from_struct(self, struct): super(PulseCardPortInfo, self)._init_from_struct(struct) self.direction = PulseDirectionEnum._c_val(struct.direction) class PulseCardInfo(PulseObject): c_struct_fields = 'name index driver owner_module n_profiles' c_struct_wrappers = {PulsePortInfo: PulseCardPortInfo} def __init__(self, struct): super(PulseCardInfo, self).__init__(struct) self.profile_list = list( PulseCardProfileInfo(struct.profiles2[n][0]) for n in range(self.n_profiles) ) self.profile_active = PulseCardProfileInfo(struct.active_profile2.contents) def __str__(self): return self._as_str( fields='name index driver n_profiles', profile_active='[{}]'.format(self.profile_active.name) ) class PulseVolumeInfo(PulseObject): def __init__(self, struct_or_values=None, channels=None): if is_num(struct_or_values): assert channels is not None, 'Channel count specified if volume value is not a list.' self.values = [struct_or_values] * channels elif is_list(struct_or_values): self.values = struct_or_values else: self.values = list( (x / c.PA_VOLUME_NORM) for x in map(float, struct_or_values.values[:struct_or_values.channels]) ) @property def value_flat(self): return (sum(self.values) / float(len(self.values))) if self.values else 0 @value_flat.setter def value_flat(self, v): self.values = [v] * len(self.values) def to_struct(self): return c.PA_CVOLUME( len(self.values), tuple(min( c.PA_VOLUME_UI_MAX, int(round(v * c.PA_VOLUME_NORM)) ) for v in self.values) ) def __str__(self): return self._as_str( channels=len(self.values), volumes='[{}]'.format( ' '.join('{}%'.format(int(round(v*100))) for v in self.values) ) ) class PulseExtStreamRestoreInfo(PulseObject): c_struct_fields = 'name channel_map volume mute device' @classmethod def struct_from_value( cls, name, volume, channel_list=None, mute=False, device=None ): 'Same arguments as with class instance init.' chan_map = c.PA_CHANNEL_MAP() if not channel_list: c.pa.channel_map_init_mono(chan_map) else: if not is_str(channel_list): channel_list = b','.join(map(c.force_bytes, channel_list)) c.pa.channel_map_parse(chan_map, channel_list) if not isinstance(volume, PulseVolumeInfo): volume = PulseVolumeInfo(volume, chan_map.channels) struct = c.PA_EXT_STREAM_RESTORE_INFO( name=c.force_bytes(name), mute=int(bool(mute)), device=c.force_bytes(device), channel_map=chan_map, volume=volume.to_struct() ) return struct def __init__( self, struct_or_name=None, volume=None, channel_list=None, mute=False, device=None ): '''If string name is passed instead of C struct, will be initialized from args/kws. "volume" can be either a float number (same level for all channels) or list (value per channel). "channel_list" can be a pulse channel map string (comma-separated) or list of channel names. Defaults to stereo map, should probably match volume channels. "device" - name of sink/source or None (default).''' if is_str(struct_or_name): struct_or_name = self.struct_from_value( struct_or_name, volume, channel_list, mute, device ) super(PulseExtStreamRestoreInfo, self).__init__(struct_or_name) def to_struct(self): return self.struct_from_value(**dict( (k, getattr(self, k)) for k in 'name volume channel_list mute device'.split() )) def __str__(self): return self._as_str(self.volume, fields='name mute device') class PulseEventInfo(PulseObject): def __init__(self, ev_t, facility, index): self.t, self.facility, self.index = ev_t, facility, index def __str__(self): return self._as_str(fields='t facility index'.split()) class Pulse(object): _ctx = None def __init__(self, client_name=None, server=None, connect=True, threading_lock=False): '''Connects to specified pulse server by default. Specifying "connect=False" here prevents that, but be sure to call connect() later. "connect=False" can also be used here to have control over options passed to connect() method. "threading_lock" option (either bool or lock instance) can be used to wrap non-threadsafe eventloop polling (can only be done from one thread at a time) into a mutex lock, and should only be needed if same-instance methods will/should/might be called from different threads at the same time.''' self.name = client_name or 'pulsectl' self.server, self.connected = server, None self._ret = self._ctx = self._loop = self._api = None self._actions, self._action_ids = dict(),\ it.chain.from_iterable(map(range, it.repeat(2**30))) self.init() if threading_lock: if threading_lock is True: import threading threading_lock = threading.Lock() self._loop_lock = threading_lock if connect: try: self.connect(autospawn=True) except PulseError: self.close() raise def init(self): self._pa_state_cb = c.PA_STATE_CB_T(self._pulse_state_cb) self._pa_subscribe_cb = c.PA_SUBSCRIBE_CB_T(self._pulse_subscribe_cb) self._loop, self._loop_lock = c.pa.mainloop_new(), FakeLock() self._loop_running = self._loop_closed = False self._api = c.pa.mainloop_get_api(self._loop) self._ret = c.pa.return_value() self._ctx_init() self.event_types = sorted(PulseEventTypeEnum._values.values()) self.event_facilities = sorted(PulseEventFacilityEnum._values.values()) self.event_masks = sorted(PulseEventMaskEnum._values.values()) self.event_callback = None chan_names = dict() for n in range(256): name = c.pa.channel_position_to_string(n) if name is None: break chan_names[n] = name self.channel_list_enum = Enum('channel_pos', chan_names) def _ctx_init(self): if self._ctx: with self._loop_lock: self.disconnect() c.pa.context_unref(self._ctx) self._ctx = c.pa.context_new(self._api, self.name) c.pa.context_set_state_callback(self._ctx, self._pa_state_cb, None) c.pa.context_set_subscribe_callback(self._ctx, self._pa_subscribe_cb, None) def connect(self, autospawn=False, wait=False, timeout=None): '''Connect to pulseaudio server. "autospawn" option will start new pulse daemon, if necessary. Specifying "wait" option will make function block until pulseaudio server appears. "timeout" (in seconds) will raise PulseError if connection not established within it.''' if self._loop_closed: raise PulseError('Eventloop object was already' ' destroyed and cannot be reused from this instance.') if self.connected is not None: self._ctx_init() flags, self.connected = 0, None if not autospawn: flags |= c.PA_CONTEXT_NOAUTOSPAWN if wait: flags |= c.PA_CONTEXT_NOFAIL try: c.pa.context_connect(self._ctx, self.server, flags, None) except c.pa.CallError: self.connected = False if not timeout: # simplier process while self.connected is None: self._pulse_iterate() else: self._loop_stop, delta, ts_deadline = True, 1, c.mono_time() + timeout while self.connected is None: delta = ts_deadline - c.mono_time() self._pulse_poll(delta) if delta <= 0: break self._loop_stop = False if not self.connected: c.pa.context_disconnect(self._ctx) while self.connected is not False: self._pulse_iterate() raise PulseError('Timed-out connecting to pulseaudio server [{:,.1f}s]'.format(timeout)) if self.connected is False: raise PulseError('Failed to connect to pulseaudio server') def disconnect(self): if not self._ctx or not self.connected: return c.pa.context_disconnect(self._ctx) def close(self): if not self._loop: return if self._loop_running: # called from another thread self._loop_closed = True c.pa.mainloop_quit(self._loop, 0) return # presumably will be closed in a thread that's running it with self._loop_lock: try: self.disconnect() c.pa.context_unref(self._ctx) c.pa.mainloop_free(self._loop) finally: self._ctx = self._loop = None def __enter__(self): return self def __exit__(self, err_t, err, err_tb): self.close() def _pulse_state_cb(self, ctx, userdata): state = c.pa.context_get_state(ctx) if state >= c.PA_CONTEXT_READY: if state == c.PA_CONTEXT_READY: self.connected = True elif state in [c.PA_CONTEXT_FAILED, c.PA_CONTEXT_TERMINATED]: self.connected, self._loop_stop = False, True def _pulse_subscribe_cb(self, ctx, ev, idx, userdata): if not self.event_callback: return n = ev & c.PA_SUBSCRIPTION_EVENT_FACILITY_MASK ev_fac = PulseEventFacilityEnum._c_val(n, 'ev.facility.{}'.format(n)) n = ev & c.PA_SUBSCRIPTION_EVENT_TYPE_MASK ev_t = PulseEventTypeEnum._c_val(n, 'ev.type.{}'.format(n)) try: self.event_callback(PulseEventInfo(ev_t, ev_fac, idx)) except PulseLoopStop: self._loop_stop = True def _pulse_poll_cb(self, func, func_err, ufds, nfds, timeout, userdata): fd_list = list(ufds[n] for n in range(nfds)) try: nfds = func(fd_list, timeout / 1000.0) except Exception as err: func_err(*sys.exc_info()) return -1 return nfds @contextmanager def _pulse_loop(self): with self._loop_lock: if not self._loop: return if self._loop_running: raise PulseError( 'Running blocking pulse operations from pulse eventloop callbacks' ' or other threads while loop is running is not supported by this python module.' ' Supporting this would require threads or proper asyncio/twisted-like async code.' ' Workaround can be to stop the loop' ' (raise PulseLoopStop in callback or event_loop_stop() from another thread),' ' doing whatever pulse calls synchronously and then resuming event_listen() loop.' ) self._loop_running, self._loop_stop = True, False try: yield self._loop finally: self._loop_running = False if self._loop_closed: self.close() # to free() after stopping it def _pulse_run(self): with self._pulse_loop() as loop: c.pa.mainloop_run(loop, self._ret) def _pulse_iterate(self, block=True): with self._pulse_loop() as loop: c.pa.mainloop_iterate(loop, int(block), self._ret) @contextmanager def _pulse_op_cb(self, raw=False): act_id = next(self._action_ids) self._actions[act_id] = None try: cb = lambda s=True,k=act_id: self._actions.update({k: bool(s)}) if not raw: cb = c.PA_CONTEXT_SUCCESS_CB_T(lambda ctx,s,d,cb=cb: cb(s)) yield cb while self.connected and self._actions[act_id] is None: self._pulse_iterate() if not self._actions[act_id]: raise PulseOperationFailed(act_id) finally: self._actions.pop(act_id, None) def _pulse_poll(self, timeout=None): '''timeout should be in seconds (float), 0 for non-blocking poll and None (default) for no timeout.''' with self._pulse_loop() as loop: ts = c.mono_time() ts_deadline = timeout and (ts + timeout) while True: delay = max(0, int((ts_deadline - ts) * 1000000)) if ts_deadline else -1 c.pa.mainloop_prepare(loop, delay) # delay in us c.pa.mainloop_poll(loop) if self._loop_closed: break # interrupted by close() or such c.pa.mainloop_dispatch(loop) if self._loop_stop: break ts = c.mono_time() if ts_deadline and ts >= ts_deadline: break def _pulse_info_cb(self, info_cls, data_list, done_cb, ctx, info, eof, userdata): # No idea where callbacks with "userdata != NULL" come from, # but "info" pointer in them is always invalid, so they are discarded here. # Looks like some kind of mixup or corruption in libpulse memory? # See also: https://github.com/mk-fg/python-pulse-control/issues/35 if userdata is not None: return # Empty result list and conn issues are checked elsewhere. # Errors here are non-descriptive (errno), so should not be useful anyway. # if eof < 0: done_cb(s=False) if eof: done_cb() else: data_list.append(info_cls(info[0])) def _pulse_get_list(cb_t, pulse_func, info_cls, singleton=False, index_arg=True): def _wrapper_method(self, index=None): data = list() with self._pulse_op_cb(raw=True) as cb: cb = cb_t( ft.partial(self._pulse_info_cb, info_cls, data, cb) if not singleton else lambda ctx, info, userdata, cb=cb: data.append(info_cls(info[0])) or cb() ) pa_op = pulse_func( self._ctx, *([index, cb, None] if index is not None else [cb, None]) ) c.pa.operation_unref(pa_op) data = data or list() if index is not None or singleton: if not data: raise PulseIndexError(index) data, = data return data return wrapper_with_sig_info( None, _wrapper_method, not (pulse_func.__name__.endswith('_list') or singleton or not index_arg) ) get_sink_by_name = _pulse_get_list( c.PA_SINK_INFO_CB_T, c.pa.context_get_sink_info_by_name, PulseSinkInfo ) get_source_by_name = _pulse_get_list( c.PA_SOURCE_INFO_CB_T, c.pa.context_get_source_info_by_name, PulseSourceInfo ) get_card_by_name = _pulse_get_list( c.PA_CARD_INFO_CB_T, c.pa.context_get_card_info_by_name, PulseCardInfo ) sink_input_list = _pulse_get_list( c.PA_SINK_INPUT_INFO_CB_T, c.pa.context_get_sink_input_info_list, PulseSinkInputInfo ) sink_input_info = _pulse_get_list( c.PA_SINK_INPUT_INFO_CB_T, c.pa.context_get_sink_input_info, PulseSinkInputInfo ) source_output_list = _pulse_get_list( c.PA_SOURCE_OUTPUT_INFO_CB_T, c.pa.context_get_source_output_info_list, PulseSourceOutputInfo ) source_output_info = _pulse_get_list( c.PA_SOURCE_OUTPUT_INFO_CB_T, c.pa.context_get_source_output_info, PulseSourceOutputInfo ) sink_list = _pulse_get_list( c.PA_SINK_INFO_CB_T, c.pa.context_get_sink_info_list, PulseSinkInfo ) sink_info = _pulse_get_list( c.PA_SINK_INFO_CB_T, c.pa.context_get_sink_info_by_index, PulseSinkInfo ) source_list = _pulse_get_list( c.PA_SOURCE_INFO_CB_T, c.pa.context_get_source_info_list, PulseSourceInfo ) source_info = _pulse_get_list( c.PA_SOURCE_INFO_CB_T, c.pa.context_get_source_info_by_index, PulseSourceInfo ) card_list = _pulse_get_list( c.PA_CARD_INFO_CB_T, c.pa.context_get_card_info_list, PulseCardInfo ) card_info = _pulse_get_list( c.PA_CARD_INFO_CB_T, c.pa.context_get_card_info_by_index, PulseCardInfo ) client_list = _pulse_get_list( c.PA_CLIENT_INFO_CB_T, c.pa.context_get_client_info_list, PulseClientInfo ) client_info = _pulse_get_list( c.PA_CLIENT_INFO_CB_T, c.pa.context_get_client_info, PulseClientInfo ) server_info = _pulse_get_list( c.PA_SERVER_INFO_CB_T, c.pa.context_get_server_info, PulseServerInfo, singleton=True ) module_info = _pulse_get_list( c.PA_MODULE_INFO_CB_T, c.pa.context_get_module_info, PulseModuleInfo ) module_list = _pulse_get_list( 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): '''Creates following synchronous wrapper for async pa_operation callable: wrapper(index, ...) -> pulse_op(index, [*]args_func(...)) index_arg=False: wrapper(...) -> pulse_op([*]args_func(...))''' def _wrapper(self, *args, **kws): if index_arg: if 'index' in kws: index = kws.pop('index') else: index, args = args[0], args[1:] 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) with self._pulse_op_cb() as cb: try: pulse_op(self._ctx, *(list(pulse_args) + [cb, None])) except c.ArgumentError as err: raise TypeError(err.args) except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) return wrapper_with_sig_info(func, _wrapper, index_arg) card_profile_set_by_index = _pulse_method_call( c.pa.context_set_card_profile_by_index, lambda profile_name: profile_name ) sink_default_set = _pulse_method_call( c.pa.context_set_default_sink, index_arg=False, func=lambda sink: sink.name if isinstance(sink, PulseSinkInfo) else sink ) source_default_set = _pulse_method_call( c.pa.context_set_default_source, index_arg=False, func=lambda source: source.name if isinstance(source, PulseSourceInfo) else source ) sink_input_mute = _pulse_method_call( c.pa.context_set_sink_input_mute, lambda mute=True: mute ) sink_input_move = _pulse_method_call( c.pa.context_move_sink_input_by_index, lambda sink_index: sink_index ) sink_mute = _pulse_method_call( c.pa.context_set_sink_mute_by_index, lambda mute=True: mute ) sink_input_volume_set = _pulse_method_call( c.pa.context_set_sink_input_volume, lambda vol: vol.to_struct() ) sink_volume_set = _pulse_method_call( c.pa.context_set_sink_volume_by_index, lambda vol: vol.to_struct() ) sink_suspend = _pulse_method_call( c.pa.context_suspend_sink_by_index, lambda suspend=True: suspend ) sink_port_set = _pulse_method_call( c.pa.context_set_sink_port_by_index, lambda port: port.name if isinstance(port, PulsePortInfo) else port ) source_output_mute = _pulse_method_call( c.pa.context_set_source_output_mute, lambda mute=True: mute ) source_output_move = _pulse_method_call( c.pa.context_move_source_output_by_index, lambda sink_index: sink_index ) source_mute = _pulse_method_call( c.pa.context_set_source_mute_by_index, lambda mute=True: mute ) source_output_volume_set = _pulse_method_call( c.pa.context_set_source_output_volume, lambda vol: vol.to_struct() ) source_volume_set = _pulse_method_call( c.pa.context_set_source_volume_by_index, lambda vol: vol.to_struct() ) source_suspend = _pulse_method_call( c.pa.context_suspend_source_by_index, lambda suspend=True: suspend ) source_port_set = _pulse_method_call( c.pa.context_set_source_port_by_index, lambda port: port.name if isinstance(port, PulsePortInfo) else port ) def module_load(self, name, args=''): if is_list(args): args = ' '.join(args) name, args = map(c.force_bytes, [name, args]) data = list() with self._pulse_op_cb(raw=True) as cb: cb = c.PA_CONTEXT_INDEX_CB_T( lambda ctx, index, userdata, cb=cb: data.append(index) or cb() ) try: c.pa.context_load_module(self._ctx, name, args, cb, None) except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) index, = data if index == c.PA_INVALID: raise PulseError('Failed to load module: {} {}'.format(name, args)) return index module_unload = _pulse_method_call(c.pa.context_unload_module, None) def stream_restore_test(self): 'Returns module-stream-restore version int (e.g. 1) or None if it is unavailable.' data = list() with self._pulse_op_cb(raw=True) as cb: cb = c.PA_EXT_STREAM_RESTORE_TEST_CB_T( lambda ctx, version, userdata, cb=cb: data.append(version) or cb() ) try: c.pa.ext_stream_restore_test(self._ctx, cb, None) except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) version, = data return version if version != c.PA_INVALID else None stream_restore_read = _pulse_get_list( c.PA_EXT_STREAM_RESTORE_READ_CB_T, c.pa.ext_stream_restore_read, PulseExtStreamRestoreInfo, index_arg=False ) stream_restore_list = stream_restore_read # for consistency with other *_list methods @ft.partial(_pulse_method_call, c.pa.ext_stream_restore_write, index_arg=False) def stream_restore_write( obj_name_or_list, mode='merge', apply_immediately=False, **obj_kws ): '''Update module-stream-restore db entry for specified name. Can be passed PulseExtStreamRestoreInfo object or list of them as argument, or name string there and object init keywords (e.g. volume, mute, channel_list, etc). "mode" is PulseUpdateEnum value of 'merge' (default), 'replace' or 'set' (replaces ALL entries!!!).''' mode = PulseUpdateEnum[mode]._c_val if is_str(obj_name_or_list): obj_name_or_list = PulseExtStreamRestoreInfo(obj_name_or_list, **obj_kws) if isinstance(obj_name_or_list, PulseExtStreamRestoreInfo): obj_name_or_list = [obj_name_or_list] # obj_array is an array of structs, laid out contiguously in memory, not pointers obj_array = (c.PA_EXT_STREAM_RESTORE_INFO * len(obj_name_or_list))() for n, obj in enumerate(obj_name_or_list): obj_struct, dst_struct = obj.to_struct(), obj_array[n] for k,t in obj_struct._fields_: setattr(dst_struct, k, getattr(obj_struct, k)) return mode, obj_array, len(obj_array), int(bool(apply_immediately)) @ft.partial(_pulse_method_call, c.pa.ext_stream_restore_delete, index_arg=False) def stream_restore_delete(obj_name_or_list): '''Can be passed string name, PulseExtStreamRestoreInfo object or a list of any of these.''' if is_str(obj_name_or_list, PulseExtStreamRestoreInfo): obj_name_or_list = [obj_name_or_list] name_list = list((obj.name if isinstance( obj, PulseExtStreamRestoreInfo ) else obj) for obj in obj_name_or_list) name_struct = (c.c_char_p * len(name_list))() name_struct[:] = list(map(c.force_bytes, name_list)) return [name_struct] def default_set(self, obj): 'Set passed sink or source to be used as default one by pulseaudio server.' assert_pulse_object(obj) method = { PulseSinkInfo: self.sink_default_set, PulseSourceInfo: self.source_default_set }.get(type(obj)) if not method: raise NotImplementedError(type(obj)) method(obj) def mute(self, obj, mute=True): assert_pulse_object(obj) method = { PulseSinkInfo: self.sink_mute, PulseSinkInputInfo: self.sink_input_mute, PulseSourceInfo: self.source_mute, PulseSourceOutputInfo: self.source_output_mute }.get(type(obj)) if not method: raise NotImplementedError(type(obj)) method(obj.index, mute) obj.mute = mute def port_set(self, obj, port): assert_pulse_object(obj) method = { PulseSinkInfo: self.sink_port_set, PulseSourceInfo: self.source_port_set }.get(type(obj)) if not method: raise NotImplementedError(type(obj)) method(obj.index, port) obj.port_active = port def card_profile_set(self, card, profile): assert_pulse_object(card) if is_str(profile): profile_dict = dict((p.name, p) for p in card.profile_list) if profile not in profile_dict: raise PulseIndexError( 'Card does not have' ' profile with specified name: {!r}'.format(profile) ) profile = profile_dict[profile] self.card_profile_set_by_index(card.index, profile.name) card.profile_active = profile def volume_set(self, obj, vol): assert_pulse_object(obj) method = { PulseSinkInfo: self.sink_volume_set, PulseSinkInputInfo: self.sink_input_volume_set, PulseSourceInfo: self.source_volume_set, PulseSourceOutputInfo: self.source_output_volume_set }.get(type(obj)) if not method: raise NotImplementedError(type(obj)) method(obj.index, vol) obj.volume = vol def volume_set_all_chans(self, obj, vol): assert_pulse_object(obj) obj.volume.value_flat = vol self.volume_set(obj, obj.volume) def volume_change_all_chans(self, obj, inc): assert_pulse_object(obj) obj.volume.values = [max(0, v + inc) for v in obj.volume.values] self.volume_set(obj, obj.volume) def volume_get_all_chans(self, obj): # Purpose of this func can be a bit confusing, being here next to set/change ones '''Get "flat" volume float value for info-object as a mean of all channel values. Note that this DOES NOT query any kind of updated values from libpulse, and simply returns value(s) stored in passed object, i.e. same ones for same object.''' assert_pulse_object(obj) return obj.volume.value_flat def event_mask_set(self, *masks): mask = 0 for m in masks: mask |= PulseEventMaskEnum[m]._c_val with self._pulse_op_cb() as cb: c.pa.context_subscribe(self._ctx, mask, cb, None) def event_callback_set(self, func): '''Call event_listen() to start receiving these, and be sure to raise PulseLoopStop in a callback to stop the loop. Callback should accept single argument - PulseEventInfo instance. Passing None will disable the thing.''' self.event_callback = func def event_listen(self, timeout=None, raise_on_disconnect=True): '''Does not return until PulseLoopStop gets raised in event callback or timeout passes. timeout should be in seconds (float), 0 for non-blocking poll and None (default) for no timeout. raise_on_disconnect causes PulseDisconnected exceptions by default. Do not run any pulse operations from these callbacks.''' assert self.event_callback try: self._pulse_poll(timeout) except c.pa.CallError: pass # e.g. from mainloop_dispatch() on disconnect if raise_on_disconnect and not self.connected: raise PulseDisconnected() def event_listen_stop(self): '''Stop event_listen() loop from e.g. another thread. Does nothing if libpulse poll is not running yet, so might be racey with event_listen() - be sure to call it in a loop until event_listen returns or something.''' self._loop_stop = True c.pa.mainloop_wakeup(self._loop) def set_poll_func(self, func, func_err_handler=None): '''Can be used to integrate pulse client into existing eventloop. Function will be passed a list of pollfd structs and timeout value (seconds, float), which it is responsible to use and modify (set poll flags) accordingly, returning int value >= 0 with number of fds that had any new events within timeout. func_err_handler defaults to traceback.print_exception(), and will be called on any exceptions from callback (to e.g. log these), returning poll error code (-1) to libpulse after that.''' if not func_err_handler: func_err_handler = traceback.print_exception self._pa_poll_cb = c.PA_POLL_FUNC_T(ft.partial(self._pulse_poll_cb, func, func_err_handler)) c.pa.mainloop_set_poll_func(self._loop, self._pa_poll_cb, None) def get_peak_sample(self, source, timeout, stream_idx=None): '''Returns peak (max) value in 0-1.0 range for samples in source/stream within timespan. "source" can be either int index of pulseaudio source (i.e. source.index), its name (source.name), or None to use default source. Resulting value is what pulseaudio returns as PA_SAMPLE_FLOAT32NE float after "timeout" seconds. If specified source does not exist, 0 should be returned after timeout. This can be used to detect if there's any sound on the microphone or any sound played through a sink via its monitor_source index, or same for any specific stream connected to these (if "stream_idx" is passed). Sample stream masquerades as application.id=org.PulseAudio.pavucontrol to avoid being listed in various mixer apps. Example - get peak for specific sink input "si" for 0.8 seconds: pulse.get_peak_sample(pulse.sink_info(si.sink).monitor_source, 0.8, si.index)''' samples, proplist = [0], c.pa.proplist_from_string('application.id=org.PulseAudio.pavucontrol') ss = c.PA_SAMPLE_SPEC(format=c.PA_SAMPLE_FLOAT32NE, rate=25, channels=1) s = c.pa.stream_new_with_proplist(self._ctx, 'peak detect', c.byref(ss), None, proplist) c.pa.proplist_free(proplist) @c.PA_STREAM_REQUEST_CB_T def read_cb(s, bs, userdata): buff, bs = c.c_void_p(), c.c_int(bs) c.pa.stream_peek(s, buff, c.byref(bs)) try: if not buff or bs.value < 4: return # This assumes that native byte order for floats is BE, same as pavucontrol samples[0] = max(samples[0], c.cast(buff, c.POINTER(c.c_float))[0]) finally: # stream_drop() flushes buffered data (incl. buff=NULL "hole" data) # stream.h: "should not be called if the buffer is empty" if bs.value: c.pa.stream_drop(s) if stream_idx is not None: c.pa.stream_set_monitor_stream(s, stream_idx) c.pa.stream_set_read_callback(s, read_cb, None) if source is not None: source = unicode(source).encode('utf-8') try: c.pa.stream_connect_record( s, source, c.PA_BUFFER_ATTR(fragsize=4, maxlength=2**32-1), c.PA_STREAM_DONT_MOVE | c.PA_STREAM_PEAK_DETECT | c.PA_STREAM_ADJUST_LATENCY | c.PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND ) except c.pa.CallError: c.pa.stream_unref(s) raise try: self._pulse_poll(timeout) finally: try: c.pa.stream_disconnect(s) except c.pa.CallError: pass # stream was removed c.pa.stream_unref(s) return min(1.0, samples[0]) def play_sample(self, name, sink=None, volume=1.0, proplist_str=None): '''Play specified sound sample, with an optional sink object/name/index, volume and proplist string parameters. Sample must be stored on the server in advance, see e.g. "pacmd list-samples". See also libcanberra for an easy XDG theme sample loading, storage and playback API.''' if isinstance(sink, PulseSinkInfo): sink = sink.index sink = str(sink) if sink is not None else None proplist = c.pa.proplist_from_string(proplist_str) if proplist_str else None volume = int(round(volume*c.PA_VOLUME_NORM)) with self._pulse_op_cb() as cb: try: if not proplist: c.pa.context_play_sample(self._ctx, name, sink, volume, cb, None) else: c.pa.context_play_sample_with_proplist( self._ctx, name, sink, volume, proplist, cb, None ) except c.pa.CallError as err: raise PulseOperationInvalid(err.args[-1]) 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 or pulseaudio startup files (e.g. "default.pa"). "server" option can be specified to use non-standard unix socket path (when passed absolute path string) or remote tcp socket, when passed remote host address (to use default port) or (host, port) tuple. Be sure to adjust "socket_timeout" option for tcp sockets over laggy internet. Returned file object has line-buffered output, so there should be no need to use flush() after every command. Be sure to read from the socket line-by-line until "### EOF" or timeout for commands that have output (e.g. "dump\\n"). If default server socket is used (i.e. not specified), server pid will be signaled to load module-cli between connection attempts. Completely separate protocol from the regular API, as wrapped by libpulse. PulseError is raised on any failure.''' import socket, errno, signal, time s, n = None, attempts if attempts > 0 else None try: pid_path, sock_af, sock_t = None, socket.AF_UNIX, socket.SOCK_STREAM if not server: server, pid_path = map(c.pa.runtime_path, ['cli', 'pid']) else: if not is_list(server): server = c.force_str(server) if not server.startswith('/'): server = server, 4712 # default port if is_list(server): try: addrinfo = socket.getaddrinfo( server[0], server[1], 0, sock_t, socket.IPPROTO_TCP ) if not addrinfo: raise socket.gaierror('No addrinfo for socket: {}'.format(server)) except (socket.gaierror, socket.error) as err: raise PulseError( 'Failed to resolve socket parameters' ' (address, family) via getaddrinfo: {!r} - {} {}'.format(server, type(err), err) ) sock_af, sock_t, _, _, server = addrinfo[0] s = socket.socket(sock_af, sock_t) s.settimeout(socket_timeout) while True: ts = c.mono_time() try: s.connect(server) except socket.error as err: if err.errno not in [errno.ECONNREFUSED, errno.ENOENT, errno.ECONNABORTED]: raise else: break if n: n -= 1 if n <= 0: raise PulseError('Number of connection attempts ({}) exceeded'.format(attempts)) if pid_path: with open(pid_path) as src: os.kill(int(src.read().strip()), signal.SIGUSR2) time.sleep(max(0, retry_delay - (c.mono_time() - ts))) if as_file: res = s.makefile('rw', 1) else: res, s = s, None # to avoid closing this socket return res except Exception as err: # CallError, socket.error, IOError (pidfile), OSError (os.kill) raise PulseError( 'Failed to connect to pulse' ' cli socket {!r}: {} {}'.format(server, type(err), err) ) finally: if s: s.close() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.479202 pulsectl-23.5.2/pulsectl/tests/0000755000175000017500000000000014432752527016312 5ustar00fraggodfraggod././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1499941406.0 pulsectl-23.5.2/pulsectl/tests/__init__.py0000644000175000017500000000000013131645036020400 0ustar00fraggodfraggod././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1649515329.0 pulsectl-23.5.2/pulsectl/tests/test_with_dummy_instance.py0000644000175000017500000006205514224315501023767 0ustar00fraggodfraggod#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import unicode_literals, print_function import itertools as it, operator as op, functools as ft import unittest, contextlib, hashlib, atexit, signal, threading, select, errno import os, sys, io, time, subprocess, tempfile, shutil, socket if sys.version_info.major > 2: unicode = str try: import pulsectl except ImportError: sys.path.insert( 1, os.path.abspath(os.path.join(__file__, *['..']*3)) ) import pulsectl class adict(dict): def __init__(self, *args, **kws): super(adict, self).__init__(*args, **kws) self.__dict__ = self def start_sock_delay_thread(*args): # Simple py2/py3 hack to simulate slow network and test conn timeouts thread = threading.Thread(target=_sock_delay_thread, args=args) thread.daemon = True thread.start() return thread def hash_prng(seed, bs): n, hash_func = 0, hashlib.sha512 with io.BytesIO() as buff: while True: seed = hash_func(seed).digest() n += buff.write(seed) if n > bs: return buff.getvalue() def _sock_delay_thread( ev_ready, ev_done, ev_disco, bind, connect, delay, block=0.1 ): sl = s = c = None try: sl = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sl.bind(bind) sl.listen(1) ev_ready.set() sl.settimeout(block) while True: ev_disco.clear() while True: try: s, addr = sl.accept() except socket.timeout: pass else: break if ev_done.is_set(): return ts0 = time.time() c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect(connect) s.setblocking(False) c.setblocking(False) time.sleep(min(delay, max(0, delay - (time.time() - ts0)))) def _send_data(src, dst, bs=8*2**10): while True: try: buff = src.recv(bs) if not buff: break dst.sendall(buff) # just assuming it won't get full here except socket.error as err: if err.errno != errno.EAGAIN: return True break while True: r,w,x = select.select([s,c], [], [s,c], block) if x or ev_done.is_set(): return if ev_disco.is_set(): break if not (r or x): continue if c in r and _send_data(c, s): break if s in r and _send_data(s, c): break s, c = s.close(), c.close() finally: if c: c.close() if s: s.close() if sl: sl.close() def dummy_pulse_init(info=None): if not info: info = adict(proc=None, tmp_dir=None) try: _dummy_pulse_init(info) except Exception: dummy_pulse_cleanup(info) raise return info def _dummy_pulse_init(info): # These are to allow starting pulse with debug logging # or using pre-started (e.g. with gdb attached) instance. # Note: PA_REUSE=1234:1234:1235 are localhost tcp ports for tcp modules. # For example: # t1% env -i XDG_RUNTIME_DIR=/tmp/pulsectl-tests \ # gdb --args /usr/bin/pulseaudio --daemonize=no --fail \ # -nF /tmp/pulsectl-tests/conf.pa --exit-idle-time=-1 --log-level=debug # t2% PA_TMPDIR=/tmp/pulsectl-tests PA_REUSE=1234,1235 python -m unittest discover env_tmpdir, env_debug, env_reuse = map( os.environ.get, ['PA_TMPDIR', 'PA_DEBUG', 'PA_REUSE'] ) if not os.environ.get('PATH'): os.environ['PATH'] = '/usr/local/bin:/usr/bin:/bin' tmp_base = env_tmpdir or info.get('tmp_dir') if not tmp_base: tmp_base = info.tmp_dir = tempfile.mkdtemp(prefix='pulsectl-tests.') info.sock_unix = None tmp_base = os.path.realpath(tmp_base) tmp_path = ft.partial(os.path.join, tmp_base) # Pick some random available localhost ports if not info.get('sock_unix'): bind = ( ['127.0.0.1', 0, socket.AF_INET], ['::1', 0, socket.AF_INET6], ['127.0.0.1', 0, socket.AF_INET], ['127.0.0.1', 0, socket.AF_INET] ) for n, spec in enumerate(bind): if env_reuse: spec[1] = int(env_reuse.split(':')[n]) continue addr, p, af = spec with contextlib.closing(socket.socket(af, socket.SOCK_STREAM)) as s: try: s.bind((addr, p)) except socket.error: if af == socket.AF_INET: raise continue # leaves port=0 if IPv6 is disabled s.listen(1) spec[1] = s.getsockname()[1] info.update( sock_unix='unix:{}'.format(tmp_path('pulse', 'native')), sock_tcp4='tcp4:{}:{}'.format(bind[0][0], bind[0][1]), sock_tcp6='tcp6:[{}]:{}'.format(bind[1][0], bind[1][1]), sock_tcp_delay='tcp4:{}:{}'.format(bind[2][0], bind[2][1]), sock_tcp_delay_src=tuple(bind[2][:2]), sock_tcp_delay_dst=tuple(bind[0][:2]), sock_tcp_cli=tuple(bind[3][:2]) ) if not info.get('sock_delay_thread'): ev_ready, ev_exit, ev_disco = (threading.Event() for n in range(3)) delay = info.sock_delay = 0.5 info.sock_delay_thread_ready = ev_ready info.sock_delay_thread_disco = ev_disco info.sock_delay_thread_exit = ev_exit info.sock_delay_thread = start_sock_delay_thread( ev_ready, ev_exit, ev_disco, info.sock_tcp_delay_src, info.sock_tcp_delay_dst, delay ) if info.proc and info.proc.poll() is not None: info.proc = None if not env_reuse and not info.get('proc'): env = dict( PATH=os.environ['PATH'], HOME=os.environ['HOME'], XDG_RUNTIME_DIR=tmp_base, PULSE_STATE_PATH=tmp_base ) proc_stderr = sys.stderr if env_debug else open('/dev/null', 'wb') info.proc = subprocess.Popen( [ 'pulseaudio', '--daemonize=no', '--fail', '-nF', '/dev/stdin', '--exit-idle-time=-1', '--log-level=debug' ], env=env, stdin=subprocess.PIPE, stderr=proc_stderr ) if proc_stderr is not sys.stderr: proc_stderr.close() bind4, bind6 = info.sock_tcp4.split(':'), info.sock_tcp6.rsplit(':', 1) bind4, bind6 = (bind4[1], bind4[2]), (bind6[0].split(':', 1)[1].strip('[]'), bind6[1]) for line in [ 'module-augment-properties', 'module-default-device-restore', 'module-always-sink', 'module-intended-roles', 'module-suspend-on-idle', 'module-position-event-sounds', 'module-role-cork', 'module-filter-heuristics', 'module-filter-apply', 'module-switch-on-port-available', 'module-stream-restore', 'module-native-protocol-tcp auth-anonymous=true' ' listen={} port={}'.format(*bind4), 'module-native-protocol-tcp auth-anonymous=true' ' listen={} port={}'.format(*bind6) if int(bind6[1]) else '', 'module-native-protocol-unix', 'module-null-sink', 'module-null-sink' ]: if line.startswith('module-'): line = 'load-module {}'.format(line) info.proc.stdin.write('{}\n'.format(line).encode('utf-8')) info.proc.stdin.close() timeout, checks, p = 4, 10, info.sock_unix.split(':', 1)[-1] for n in range(checks): if not os.path.exists(p): time.sleep(float(timeout) / checks) continue break else: raise AssertionError( 'pulseaudio process' ' failed to start or create native socket at {}'.format(p) ) def dummy_pulse_cleanup(info=None, proc=None, tmp_dir=None): if not info: info = adict(proc=proc, tmp_dir=tmp_dir) if info.proc: try: info.proc.terminate() except OSError: pass timeout, checks = 4, 10 for n in range(checks): if info.proc.poll() is None: time.sleep(float(timeout) / checks) continue break else: try: info.proc.kill() except OSError: pass info.proc.wait() info.proc = None if info.get('sock_delay_thread'): info.sock_delay_thread_exit.set() info.sock_delay_thread = info.sock_delay_thread.join() if info.tmp_dir: shutil.rmtree(info.tmp_dir, ignore_errors=True) info.tmp_dir = None class DummyTests(unittest.TestCase): instance_info = proc = tmp_dir = None @classmethod def setUpClass(cls): assert not cls.proc and not cls.tmp_dir, [cls.proc, cls.tmp_dir] for sig in 'hup', 'term', 'int': signal.signal(getattr(signal, 'sig{}'.format(sig).upper()), lambda sig,frm: sys.exit()) atexit.register(cls.tearDownClass) cls.instance_info = dummy_pulse_init() for k, v in cls.instance_info.items(): setattr(cls, k, v) @classmethod def tearDownClass(cls): if cls.instance_info: dummy_pulse_cleanup(cls.instance_info) cls.instance_info = cls.proc = cls.tmp_dir = None # Fuzzy float comparison is necessary for volume, # as these loose precision when converted to/from pulse int values. _compare_floats_rounding = 3 def _compare_floats(self, a, b, msg=None): if round(a, self._compare_floats_rounding) != round(b, self._compare_floats_rounding): return self._baseAssertEqual(a, b, msg) def __init__(self, *args, **kws): super(DummyTests, self).__init__(*args, **kws) self.addTypeEqualityFunc(float, self._compare_floats) def test_enums(self): enum = pulsectl.PulseEventFacilityEnum ev_fac_map = dict(sink='sink', sink_input='stream') # hash should match strings self.assertTrue(ev_fac_map.get(enum.sink)) self.assertTrue(ev_fac_map.get(enum.sink_input)) self.assertEqual(enum.sink, 'sink') self.assertEqual(enum['sink'], 'sink') self.assertTrue('sink' in enum) def test_connect(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: si = pulse.server_info() with pulsectl.Pulse('t', server=self.sock_tcp4) as pulse: si4 = pulse.server_info() self.assertEqual(vars(si), vars(si4)) if int(self.sock_tcp6.rsplit(':', 1)[-1]): with pulsectl.Pulse('t', server=self.sock_tcp6) as pulse: si6 = pulse.server_info() self.assertEqual(vars(si), vars(si6)) def test_connect_timeout(self): self.sock_delay_thread_ready.wait(timeout=2) with pulsectl.Pulse('t', server=self.sock_unix) as pulse: si = pulse.server_info() with pulsectl.Pulse('t', server=self.sock_tcp_delay) as pulse: sid = pulse.server_info() self.assertEqual(vars(si), vars(sid)) self.sock_delay_thread_disco.set() with pulsectl.Pulse('t', server=self.sock_tcp_delay, connect=False) as pulse: pulse.connect() sid = pulse.server_info() self.assertEqual(vars(si), vars(sid)) self.sock_delay_thread_disco.set() with pulsectl.Pulse('t', server=self.sock_tcp_delay, connect=False) as pulse: pulse.connect(1.0) sid = pulse.server_info() self.assertEqual(vars(si), vars(sid)) self.sock_delay_thread_disco.set() with pulsectl.Pulse('t', server=self.sock_tcp_delay, connect=False) as pulse: with self.assertRaises(pulsectl.PulseError): pulse.connect(timeout=0.1) self.sock_delay_thread_disco.set() pulse.connect(timeout=1.0) sid = pulse.server_info() self.assertEqual(vars(si), vars(sid)) self.sock_delay_thread_disco.set() def test_server_info(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: si, srcs, sinks = pulse.server_info(), pulse.source_list(), pulse.sink_list() self.assertEqual(len(srcs), 2) self.assertEqual(len(sinks), 2) def test_default_set(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: (src1, src2), (sink1, sink2) = pulse.source_list()[:2], pulse.sink_list()[:2] self.assertNotEqual(sink1.name, sink2.name) self.assertNotEqual(src1.name, src2.name) pulse.default_set(sink1) pulse.default_set(sink1) pulse.default_set(src1) si = pulse.server_info() self.assertEqual(si.default_sink_name, sink1.name) self.assertEqual(si.default_source_name, src1.name) pulse.default_set(sink2) si = pulse.server_info() self.assertEqual(si.default_sink_name, sink2.name) self.assertEqual(si.default_source_name, src1.name) pulse.default_set(src2) pulse.default_set(src2) pulse.default_set(sink1) si = pulse.server_info() self.assertEqual(si.default_sink_name, sink1.name) self.assertEqual(si.default_source_name, src2.name) pulse.sink_default_set(sink2.name) pulse.source_default_set(src1.name) si = pulse.server_info() self.assertEqual(si.default_sink_name, sink2.name) self.assertEqual(si.default_source_name, src1.name) nx = 'xxx' self.assertNotIn(nx, [sink1.name, sink2.name]) self.assertNotIn(nx, [src1.name, src2.name]) with self.assertRaises(TypeError): pulse.sink_default_set(sink2.index) with self.assertRaises(pulsectl.PulseOperationFailed): pulse.sink_default_set(nx) with self.assertRaises(pulsectl.PulseOperationFailed): pulse.source_default_set(nx) si = pulse.server_info() self.assertEqual(si.default_sink_name, sink2.name) self.assertEqual(si.default_source_name, src1.name) def test_events(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: sink, cb_called = pulse.sink_list()[0], list() def ev_cb(ev): self.assertEqual(ev.facility, 'sink') self.assertEqual(ev.t, 'change') self.assertEqual(ev.index, sink.index) cb_called.append(True) raise pulsectl.PulseLoopStop pulse.event_mask_set('all') pulse.event_callback_set(ev_cb) pulse.volume_set_all_chans(sink, 0.6) if not cb_called: pulse.event_listen() self.assertTrue(bool(cb_called)) pulse.event_mask_set('null') pulse.event_callback_set(None) def test_cli(self): xdg_dir_prev = os.environ.get('XDG_RUNTIME_DIR') try: os.environ['XDG_RUNTIME_DIR'] = self.tmp_dir with contextlib.closing(pulsectl.connect_to_cli(as_file=False)) as s: s.send(b'dump\n') while True: try: buff = s.recv(2**20) except socket.error: buff = None if not buff: raise AssertionError if b'### EOF' in buff.splitlines(): break with contextlib.closing(pulsectl.connect_to_cli()) as s: s.write('dump\n') for line in s: if line == '### EOF\n': break else: raise AssertionError s.write( 'load-module module-cli-protocol-tcp' ' listen={} port={}\n'.format(*self.sock_tcp_cli) ) with contextlib.closing(pulsectl.connect_to_cli(self.sock_tcp_cli)) as s: s.write('dump\n') for line in s: if line == '### EOF\n': break else: raise AssertionError s.write('unload-module module-cli-protocol-tcp\n') finally: if xdg_dir_prev is not None: os.environ['XDG_RUNTIME_DIR'] = xdg_dir_prev def test_sink_src(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: src, sink = pulse.source_list()[0], pulse.sink_list()[0] self.assertTrue(src.proplist.get('device.class')) self.assertTrue(isinstance(src.proplist.get('device.class'), unicode)) self.assertTrue(isinstance(list(src.proplist.keys())[0], unicode)) self.assertTrue(sink.proplist.get('device.class')) self.assertTrue(isinstance(sink.proplist.get('device.class'), unicode)) self.assertTrue(isinstance(list(sink.proplist.keys())[0], unicode)) pulse.mute(src, False) self.assertFalse(src.mute) self.assertFalse(pulse.source_info(src.index).mute) pulse.mute(src, True) pulse.mute(src, True) self.assertTrue(src.mute) self.assertTrue(pulse.source_info(src.index).mute) pulse.mute(src, False) pulse.mute(sink, False) self.assertFalse(sink.mute) self.assertFalse(pulse.sink_info(sink.index).mute) pulse.mute(sink) self.assertTrue(sink.mute) self.assertTrue(pulse.sink_info(sink.index).mute) pulse.mute(sink, False) pulse.volume_set_all_chans(sink, 1.0) self.assertEqual(sink.volume.value_flat, 1.0) self.assertEqual(pulse.sink_info(sink.index).volume.values, sink.volume.values) pulse.volume_set_all_chans(sink, 0.5) self.assertEqual(sink.volume.value_flat, 0.5) self.assertEqual(pulse.sink_info(sink.index).volume.values, sink.volume.values) pulse.volume_change_all_chans(sink, -0.5) self.assertEqual(sink.volume.value_flat, 0.0) self.assertEqual(pulse.sink_info(sink.index).volume.values, sink.volume.values) pulse.volume_set_all_chans(sink, 1.0) def test_get_sink_src(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: src, sink = pulse.source_list(), pulse.sink_list() src_nx, sink_nx = max(s.index for s in src)+1, max(s.index for s in sink)+1 src, sink = src[0], sink[0] self.assertEqual(sink.index, pulse.get_sink_by_name(sink.name).index) self.assertEqual(src.index, pulse.get_source_by_name(src.name).index) with self.assertRaises(pulsectl.PulseIndexError): pulse.source_info(src_nx) with self.assertRaises(pulsectl.PulseIndexError): pulse.sink_info(sink_nx) # def test_get_card(self): no cards to test these calls with :( def test_module_funcs(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: self.assertEqual(len(pulse.sink_list()), 2) idx = pulse.module_load('module-null-sink') self.assertEqual(len(pulse.sink_list()), 3) pulse.module_unload(idx) self.assertEqual(len(pulse.sink_list()), 2) with self.assertRaises(pulsectl.PulseError): pulse.module_load('module-that-does-not-exist') self.assertEqual(len(pulse.sink_list()), 2) def test_stream(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: stream_started = list() def stream_ev_cb(ev): if ev.t != 'new': return stream_started.append(ev.index) raise pulsectl.PulseLoopStop pulse.event_mask_set('sink_input') pulse.event_callback_set(stream_ev_cb) paplay = subprocess.Popen( ['paplay', '--raw', '/dev/zero'], env=dict( PATH=os.environ['PATH'], XDG_RUNTIME_DIR=self.tmp_dir ) ) try: if not stream_started: pulse.event_listen() self.assertTrue(bool(stream_started)) stream_idx, = stream_started stream = pulse.sink_input_info(stream_idx) self.assertTrue(stream.proplist.get('application.name')) self.assertTrue(isinstance(stream.proplist.get('application.name'), unicode)) self.assertTrue(isinstance(list(stream.proplist.keys())[0], unicode)) pulse.mute(stream, False) self.assertFalse(stream.mute) self.assertFalse(pulse.sink_input_info(stream.index).mute) pulse.mute(stream) self.assertTrue(stream.mute) self.assertTrue(pulse.sink_input_info(stream.index).mute) pulse.mute(stream, False) pulse.volume_set_all_chans(stream, 1.0) self.assertEqual(stream.volume.value_flat, 1.0) self.assertEqual(pulse.sink_input_info(stream.index).volume.values, stream.volume.values) pulse.volume_set_all_chans(stream, 0.5) self.assertEqual(stream.volume.value_flat, 0.5) self.assertEqual(pulse.sink_input_info(stream.index).volume.values, stream.volume.values) pulse.volume_change_all_chans(stream, -0.5) self.assertEqual(stream.volume.value_flat, 0.0) self.assertEqual(pulse.sink_input_info(stream.index).volume.values, stream.volume.values) finally: if paplay.poll() is None: paplay.kill() paplay.wait() with self.assertRaises(pulsectl.PulseIndexError): pulse.sink_input_info(stream.index) def test_ext_stream_restore(self): sr_name1 = 'sink-input-by-application-name:pulsectl-test-1' sr_name2 = 'sink-input-by-application-name:pulsectl-test-2' with pulsectl.Pulse('t', server=self.sock_unix) as pulse: self.assertIsNotNone(pulse.stream_restore_test()) pulse.stream_restore_write(sr_name1, volume=0.5, mute=True) pulse.stream_restore_write( pulsectl.PulseExtStreamRestoreInfo(sr_name2, volume=0.3, channel_list='mono'), apply_immediately=True ) sr_list = pulse.stream_restore_list() self.assertIsInstance(sr_list, list) self.assertTrue(sr_list) sr_dict = dict((sr.name, sr) for sr in sr_list) self.assertEqual(sr_dict[sr_name1].volume.value_flat, 0.5) self.assertEqual(sr_dict[sr_name1].mute, 1) self.assertEqual(sr_dict[sr_name1].channel_list, [pulse.channel_list_enum.mono]) self.assertIn(sr_name2, sr_dict) self.assertEqual(sr_dict[sr_name1].channel_list, [pulse.channel_list_enum.mono]) self.assertEqual(sr_dict[sr_name1].channel_list_raw, [0]) pulse.stream_restore_delete(sr_name1) sr_dict = dict((sr.name, sr) for sr in pulse.stream_restore_list()) self.assertNotIn(sr_name1, sr_dict) self.assertIn(sr_name2, sr_dict) pulse.stream_restore_write( [ pulsectl.PulseExtStreamRestoreInfo( sr_name1, volume=0.7, channel_list=['front-left', 'front-right'] ), sr_dict[sr_name2] ], mode='merge' ) pulse.stream_restore_write(sr_name1, volume=0.3, channel_list='mono', mute=True ) sr_dict = dict((sr.name, sr) for sr in pulse.stream_restore_list()) self.assertEqual(sr_dict[sr_name1].volume.value_flat, 0.7) self.assertEqual(sr_dict[sr_name1].mute, 0) self.assertEqual( sr_dict[sr_name1].channel_list, [pulse.channel_list_enum.front_left, pulse.channel_list_enum.front_right] ) self.assertEqual(sr_dict[sr_name1].channel_list_raw, [1, 2]) pulse.stream_restore_write(sr_name1, volume=0.4, mode='replace') sr_dict = dict((sr.name, sr) for sr in pulse.stream_restore_list()) self.assertEqual(sr_dict[sr_name1].volume.value_flat, 0.4) pulse.stream_restore_write(sr_name2, volume=0.9, mode='set') sr_dict = dict((sr.name, sr) for sr in pulse.stream_restore_list()) self.assertEqual(sr_dict[sr_name2].volume.value_flat, 0.9) self.assertEqual(list(sr_dict.keys()), [sr_name2]) pulse.stream_restore_write([], mode='set') # i.e. remove all sr_dict = dict((sr.name, sr) for sr in pulse.stream_restore_list()) self.assertNotIn(sr_name1, sr_dict) self.assertNotIn(sr_name2, sr_dict) def test_stream_move(self): with pulsectl.Pulse('t', server=self.sock_unix) as pulse: stream_started = list() def stream_ev_cb(ev): if ev.t != 'new': return stream_started.append(ev.index) raise pulsectl.PulseLoopStop pulse.event_mask_set('sink_input') pulse.event_callback_set(stream_ev_cb) paplay = subprocess.Popen( ['paplay', '--raw', '/dev/zero'], env=dict( PATH=os.environ['PATH'], XDG_RUNTIME_DIR=self.tmp_dir ) ) try: if not stream_started: pulse.event_listen() stream_idx, = stream_started stream = pulse.sink_input_info(stream_idx) sink_indexes = set(s.index for s in pulse.sink_list()) sink1 = stream.sink sink2 = sink_indexes.difference([sink1]).pop() sink_nx = max(sink_indexes) + 1 pulse.sink_input_move(stream.index, sink2) stream_new = pulse.sink_input_info(stream.index) self.assertEqual(stream.sink, sink1) # old info doesn't get updated self.assertEqual(stream_new.sink, sink2) pulse.sink_input_move(stream.index, sink1) # move it back stream_new = pulse.sink_input_info(stream.index) self.assertEqual(stream_new.sink, sink1) with self.assertRaises(pulsectl.PulseOperationFailed): pulse.sink_input_move(stream.index, sink_nx) finally: if paplay.poll() is None: paplay.kill() paplay.wait() def test_get_peak_sample(self): if not os.environ.get('DEV_TESTS'): return # this test seem to be unreliable due to timings # Note: this test takes at least multiple seconds to run with pulsectl.Pulse('t', server=self.sock_unix) as pulse: source_any = max(s.index for s in pulse.source_list()) source_nx = source_any + 1 time.sleep(0.3) # make sure previous streams die peak = pulse.get_peak_sample(source_any, 0.3) self.assertEqual(peak, 0) stream_started = list() def stream_ev_cb(ev): if ev.t != 'new': return stream_started.append(ev.index) raise pulsectl.PulseLoopStop pulse.event_mask_set('sink_input') pulse.event_callback_set(stream_ev_cb) test_wav = os.path.join(self.tmp_dir, 'test.wav') with open(test_wav, 'wb') as dst: dst.write(hash_prng(b'consistent-prng-key-for-audible-noise', 5 * 2**20)) # 5M file paplay = subprocess.Popen( ['paplay', '--raw', test_wav], env=dict(PATH=os.environ['PATH'], XDG_RUNTIME_DIR=self.tmp_dir) ) try: if not stream_started: pulse.event_listen() stream_idx, = stream_started si = pulse.sink_input_info(stream_idx) sink = pulse.sink_info(si.sink) source = pulse.source_info(sink.monitor_source) # First poll can randomly fail if too short, probably due to latency or such peak = pulse.get_peak_sample(sink.monitor_source, 3) self.assertGreater(peak, 0) peak = pulse.get_peak_sample(source.index, 0.3, si.index) self.assertGreater(peak, 0) peak = pulse.get_peak_sample(source.name, 0.3, si.index) self.assertGreater(peak, 0) peak = pulse.get_peak_sample(source_nx, 0.3) self.assertEqual(peak, 0) paplay.terminate() paplay.wait() peak = pulse.get_peak_sample(source.index, 0.3, si.index) self.assertEqual(peak, 0) finally: if paplay.poll() is None: paplay.kill() paplay.wait() class PulseCrashTests(unittest.TestCase): @classmethod def setUpClass(cls): for sig in 'hup', 'term', 'int': signal.signal(getattr(signal, 'sig{}'.format(sig).upper()), lambda sig,frm: sys.exit()) def test_crash_after_connect(self): info = dummy_pulse_init() try: with pulsectl.Pulse('t', server=info.sock_unix) as pulse: for si in pulse.sink_list(): self.assertTrue(si) info.proc.terminate() info.proc.wait() with self.assertRaises(pulsectl.PulseOperationFailed): for si in pulse.sink_list(): raise AssertionError(si) self.assertFalse(pulse.connected) finally: dummy_pulse_cleanup(info) def test_reconnect(self): info = dummy_pulse_init() try: with pulsectl.Pulse('t', server=info.sock_unix, connect=False) as pulse: with self.assertRaises(Exception): for si in pulse.sink_list(): raise AssertionError(si) pulse.connect(autospawn=False) self.assertTrue(pulse.connected) for si in pulse.sink_list(): self.assertTrue(si) info.proc.terminate() info.proc.wait() with self.assertRaises(Exception): for si in pulse.sink_list(): raise AssertionError(si) self.assertFalse(pulse.connected) dummy_pulse_init(info) pulse.connect(autospawn=False, wait=True) self.assertTrue(pulse.connected) for si in pulse.sink_list(): self.assertTrue(si) pulse.disconnect() with self.assertRaises(Exception): for si in pulse.sink_list(): raise AssertionError(si) self.assertFalse(pulse.connected) pulse.connect(autospawn=False) self.assertTrue(pulse.connected) for si in pulse.sink_list(): self.assertTrue(si) finally: dummy_pulse_cleanup(info) if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.478202 pulsectl-23.5.2/pulsectl.egg-info/0000755000175000017500000000000014432752527016642 5ustar00fraggodfraggod././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788567.0 pulsectl-23.5.2/pulsectl.egg-info/PKG-INFO0000644000175000017500000004201014432752527017734 0ustar00fraggodfraggodMetadata-Version: 2.1 Name: pulsectl Version: 23.5.2 Summary: Python high-level interface and ctypes-based bindings for PulseAudio (libpulse) Home-page: http://github.com/mk-fg/python-pulse-control Author: George Filipkin, Mike Kazantsev Author-email: mk.fraggod@gmail.com License: MIT Keywords: pulseaudio,libpulse,pulse,pa,bindings,sound,audio,ctypes,control,mixer,volume,mute,source,sink Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Multimedia Classifier: Topic :: Multimedia :: Sound/Audio License-File: COPYING python-pulse-control (pulsectl module) ====================================== Python (3.x and 2.x) blocking high-level interface and ctypes-based bindings for PulseAudio_ (libpulse), to use in a simple synchronous code. Wrappers are mostly for mixer-like controls and introspection-related operations, as opposed to e.g. submitting sound samples to play and player-like client. For async version to use with asyncio_, see `pulsectl-asyncio`_ project instead. Originally forked from pulsemixer_ project, which had this code bundled. .. _PulseAudio: https://wiki.freedesktop.org/www/Software/PulseAudio/ .. _asyncio: https://docs.python.org/3/library/asyncio.html .. _pulsectl-asyncio: https://pypi.org/project/pulsectl-asyncio/ .. _pulsemixer: https://github.com/GeorgeFilipkin/pulsemixer/ | .. contents:: :backlinks: none Repository URLs: - https://github.com/mk-fg/python-pulse-control - https://codeberg.org/mk-fg/python-pulse-control - https://fraggod.net/code/git/python-pulse-control Usage ----- Simple example:: import pulsectl with pulsectl.Pulse('volume-increaser') as pulse: for sink in pulse.sink_list(): # Volume is usually in 0-1.0 range, with >1.0 being soft-boosted pulse.volume_change_all_chans(sink, 0.1) Listening for server state change events:: import pulsectl with pulsectl.Pulse('event-printer') as pulse: # print('Event types:', pulsectl.PulseEventTypeEnum) # print('Event facilities:', pulsectl.PulseEventFacilityEnum) # print('Event masks:', pulsectl.PulseEventMaskEnum) def print_events(ev): print('Pulse event:', ev) ### Raise PulseLoopStop for event_listen() to return before timeout (if any) # raise pulsectl.PulseLoopStop pulse.event_mask_set('all') pulse.event_callback_set(print_events) pulse.event_listen(timeout=10) Misc other tinkering:: >>> import pulsectl >>> pulse = pulsectl.Pulse('my-client-name') >>> pulse.sink_list() [] >>> pulse.sink_input_list() [] >>> pulse.sink_input_list()[0].proplist {'application.icon_name': 'mpv', 'application.language': 'C', 'application.name': 'mpv Media Player', ... 'native-protocol.version': '30', 'window.x11.display': ':1.0'} >>> pulse.source_list() [, ] >>> sink = pulse.sink_list()[0] >>> pulse.volume_change_all_chans(sink, -0.1) >>> pulse.volume_set_all_chans(sink, 0.5) >>> pulse.server_info().default_sink_name 'alsa_output.pci-0000_00_14.2.analog-stereo' >>> pulse.default_set(sink) >>> card = pulse.card_list()[0] >>> card.profile_list [, , ... ] >>> pulse.card_profile_set(card, 'output:hdmi-stereo') >>> help(pulse) ... >>> pulse.close() Current code logic is that all methods are invoked through the Pulse instance, and everything returned from these are "Pulse-Something-Info" objects - thin wrappers around C structs that describe the thing, without any methods attached. Aside from a few added convenience methods, most of them should have similar signature and do same thing as their C libpulse API counterparts, so see `pulseaudio doxygen documentation`_ for more information on them. Pulse client can be integrated into existing eventloop (e.g. asyncio, twisted, etc) using ``Pulse.set_poll_func()`` or ``Pulse.event_listen()`` in a separate thread. Somewhat extended usage example can be found in `pulseaudio-mixer-cli`_ project code, as well as tests here. .. _pulseaudio doxygen documentation: https://freedesktop.org/software/pulseaudio/doxygen/introspect_8h.html .. _pulseaudio-mixer-cli: https://github.com/mk-fg/pulseaudio-mixer-cli/blob/master/pa-mixer-mk3.py Notes ----- Some less obvious things are described in this section. Things not yet wrapped/exposed in python ```````````````````````````````````````` There are plenty of information, methods and other things in libpulse not yet wrapped/exposed by this module, as they weren't needed (yet) for author/devs use-case(s). Making them accessible from python code can be as simple as adding an attribute name to the "c_struct_fields" value in PulseSomethingInfo objects. See `github #3 `_ for a more concrete example of finding/adding such stuff. For info and commands that are not available through libpulse introspection API, it is possible to use ``pulsectl.connect_to_cli()`` fallback function, which will open unix socket to server's "module-cli" (signaling to load it, if necessary), which can be used in exactly same way as "pacmd" tool (not to be confused with "pactl", which uses native protocol instead of module-cli) or pulseaudio startup files (e.g. "default.pa"). Probably a bad idea to parse string output from commands there though, as these are not only subject to change, but can also vary depending on system locale. Volume `````` In PulseAudio, "volume" for anything is not a flat number, but essentially a list of numbers, one per channel (as in "left", "right", "front", "rear", etc), which should correspond to channel map of the object it relates/is-applied to. In this module, such lists are represented by PulseVolumeInfo objects. I.e. ``sink.volume`` is a PulseVolumeInfo instance, and all thin/simple wrappers that accept index of the object, expect such instance to be passed, e.g. ``pulse.sink_input_volume_set(sink.index, sink.volume)``. There are convenience ``volume_get_all_chans``, ``volume_set_all_chans`` and ``volume_change_all_chans`` methods to get/set/adjust volume as/by a single numeric value, which is also accessible on PulseVolumeInfo objects as a ``value_flat`` property. PulseVolumeInfo can be constructed from a numeric volume value plus number of channels, or a python list of per-channel numbers. All per-channel volume values in PulseVolumeInfo (and flat values in the wrapper funcs above), are float objects in 0-65536 range, with following meanings: * 0.0 volume is "no sound" (corresponds to PA_VOLUME_MUTED). * 1.0 value is "current sink volume level", 100% or PA_VOLUME_NORM. * >1.0 and up to 65536.0 (PA_VOLUME_MAX / PA_VOLUME_NORM) - software-boosted sound volume (higher values will negatively affect sound quality). Probably a good idea to set volume only in 0-1.0 range and boost volume in hardware without quality loss, e.g. by tweaking sink volume (which corresponds to ALSA/hardware volume), if that option is available. Note that ``flat-volumes=yes`` option ("yes" by default on some distros, "no" in e.g. Arch Linux) in pulseaudio daemon.conf already scales device-volume with the volume of the "loudest" application, so already does what's suggested above. Fractional volume values used in the module get translated (in a linear fashion) to/from pa_volume_t integers for libpulse. See ``src/pulse/volume.h`` in pulseaudio sources for all the gory details on the latter (e.g. how it relates to sound level in dB). Code example:: from pulsectl import Pulse, PulseVolumeInfo with Pulse('volume-example') as pulse: sink_input = pulse.sink_input_list()[0] # first random sink-input stream volume = sink_input.volume print(volume.values) # list of per-channel values (floats) print(volume.value_flat) # average level across channels (float) time.sleep(1) volume.value_flat = 0.3 # sets all volume.values to 0.3 pulse.volume_set(sink_input, volume) # applies the change time.sleep(1) n_channels = len(volume.values) new_volume = PulseVolumeInfo(0.5, n_channels) # 0.5 across all n_channels # new_volume = PulseVolumeInfo([0.15, 0.25]) # from a list of channel levels (stereo) pulse.volume_set(sink_input, new_volume) # pulse.sink_input_volume_set(sink_input.index, new_volume) # same as above In most common cases, doing something like ``pulse.volume_set_all_chans(sink_input, 0.2)`` should do the trick though - no need to bother with specific channels in PulseVolumeInfo there. String values ````````````` libpulse explicitly returns utf-8-encoded string values, which are always decoded to "abstract string" type in both python-2 (where it's called "unicode") and python-3 ("str"), for consistency. It might be wise to avoid mixing these with encoded strings ("bytes") in the code, especially in python-2, where "bytes" is often used as a default string type. Enumerated/named values (enums) ``````````````````````````````` In place of C integers that correspond to some enum or constant (e.g. -1 for PA_SINK_INVALID_STATE), module returns EnumValue objects, which are comparable to strings ("str" type in py2/py3). For example:: >>> pulsectl.PulseEventTypeEnum.change == 'change' True >>> pulsectl.PulseEventTypeEnum.change >>> pulsectl.PulseEventTypeEnum It might be preferrable to use enums instead of strings in the code so that interpreter can signal error on any typos or unknown values specified, as opposed to always silently failing checks with bogus strings. Event-handling code, threads ```````````````````````````` libpulse clients always work as an event loop, though this module kinda hides it, presenting a more old-style blocking interface. So what happens on any call (e.g. ``pulse.mute(...)``) is: * Make a call to libpulse, specifying callback for when operation will be completed. * Run libpulse event loop until that callback gets called. * Return result passed to that callback call, if any (for various "get" methods). ``event_callback_set()`` and ``event_listen()`` calls essentally do raw first and second step here. Which means that any pulse calls from callback function can't be used when ``event_listen()`` (or any other pulse call through this module, for that matter) waits for return value and runs libpulse loop already. One can raise PulseLoopStop exception there to make ``event_listen()`` return, run whatever pulse calls after that, then re-start the ``event_listen()`` thing. This will not miss any events, as all blocking calls do same thing as ``event_listen()`` does (second step above), and can cause callable passed to ``event_callback_set()`` to be called (when loop is running). Also, same instance of libpulse eventloop can't be run from different threads, naturally, so if threads are used, client can be initialized with ``threading_lock=True`` option (can also accept lock instance instead of True) to create a mutex around step-2 (run event loop) from the list above, so multiple threads won't do it at the same time. For proper python eventloop integration (think twisted or asyncio), use `pulsectl-asyncio`_ module instead. There are also some tricks mentioned in `github #11 `_ to shoehorn this module into async apps, but even with non-asyncio eventloop, starting from pulsectl-asyncio would probably be much easier. Tests ````` Test code is packaged/installed with the module and can be useful to run when changing module code, or to check if current python, module and pulseudio versions all work fine together. Commands to run tests from either checkout directory or installed module:: % python2 -m unittest discover % python3 -m unittest discover Note that if "pulsectl" module is available both in current directory (e.g. checkout dir) and user/system python module path, former should always take priority for commands above. Add e.g. ``-k test_stream_move`` for commands above to match and run specific test(s), and when isolating specific failure, it might also be useful to run with PA_DEBUG=1 env-var to get full verbose pulseaudio log, for example:: % PA_DEBUG=1 python -m unittest discover -k test_module_funcs Test suite runs ad-hoc isolated pulseaudio instance with null-sinks (not touching hardware), custom (non-default) startup script and environment, and interacts only with that instance, terminating it afterwards. Still uses system/user daemon.conf files though, so these can affect the tests. Any test failures can indicate incompatibilities, bugs in the module code, issues with pulseaudio (or its daemon.conf) and underlying dependencies. There are no "expected" test case failures. All tests can run for up to 10 seconds currently (v19.9.6), due to some involving playback (using paplay from /dev/urandom) being time-sensitive. Changelog and versioning scheme ``````````````````````````````` This package uses one-version-per-commit scheme (updated by pre-commit hook) and pretty much one release per git commit, unless more immediate follow-up commits are planned or too lazy to run ``py setup.py sdist bdist_wheel upload`` for some trivial README typo fix. | Version scheme: ``{year}.{month}.{git-commit-count-this-month}`` | I.e. "16.9.10" is "11th commit on Sep 2016". | There is a `CHANGES.rst `_ file with the list of any intentional breaking changes (should be exceptionally rare, if any) and new/added non-trivial functionality. | It can be a bit out of date though, as one has to remember to update it manually. | "Last synced/updated:" line there might give a hint as to by how much. Installation ------------ It's a regular package for Python (3.x or 2.x). `If a package is available for your distribution`_, using your package manager is the recommended way to install it. Otherwise, using pip_ is the best way:: % pip install pulsectl (add --user option to install into $HOME for current user only) Be sure to use python3/python2, pip3/pip2, easy_install-... commands based on which python version you want to install the module for, if you are still using python2 (and likely have python3 on the system as well). If you don't have "pip" command:: % python -m ensurepip % python -m pip install --upgrade pip % python -m pip install pulsectl (same suggestion wrt "install --user" as above) On a very old systems, one of these might work:: % curl https://bootstrap.pypa.io/get-pip.py | python % pip install pulsectl % easy_install pulsectl % git clone --depth=1 https://github.com/mk-fg/python-pulse-control % cd python-pulse-control % python setup.py install (all of install-commands here also have --user option) Current-git version can be installed like this:: % pip install 'git+https://github.com/mk-fg/python-pulse-control#egg=pulsectl' Note that to install stuff to system-wide PATH and site-packages (without --user), elevated privileges (i.e. root and su/sudo) are often required. Use "...install --user", `~/.pydistutils.cfg`_ or virtualenv_ to do unprivileged installs into custom paths. More info on python packaging can be found at `packaging.python.org`_. .. _If a package is available for your distribution: https://repology.org/project/python:pulsectl/versions .. _pip: http://pip-installer.org/ .. _~/.pydistutils.cfg: http://docs.python.org/install/index.html#distutils-configuration-files .. _virtualenv: http://pypi.python.org/pypi/virtualenv .. _packaging.python.org: https://packaging.python.org/installing/ Links ----- * pulsemixer_ - initial source for this project (embedded in the tool). * `pulsectl-asyncio`_ - similar libpulse wrapper to this one, but for async python code. * `libpulseaudio `_ - different libpulse bindings module, more low-level, auto-generated from pulseaudio header files. Branches there have bindings for different (newer) pulseaudio versions. * `pypulseaudio `_ - high-level bindings module, rather similar to this one. * `pulseaudio-mixer-cli`_ - alsamixer-like script built on top of this module. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788567.0 pulsectl-23.5.2/pulsectl.egg-info/SOURCES.txt0000644000175000017500000000052614432752527020531 0ustar00fraggodfraggodCHANGES.rst COPYING MANIFEST.in README.rst setup.cfg setup.py pulsectl/__init__.py pulsectl/_pulsectl.py pulsectl/lookup.py pulsectl/pulsectl.py pulsectl.egg-info/PKG-INFO pulsectl.egg-info/SOURCES.txt pulsectl.egg-info/dependency_links.txt pulsectl.egg-info/top_level.txt pulsectl/tests/__init__.py pulsectl/tests/test_with_dummy_instance.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788567.0 pulsectl-23.5.2/pulsectl.egg-info/dependency_links.txt0000644000175000017500000000000114432752527022710 0ustar00fraggodfraggod ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788567.0 pulsectl-23.5.2/pulsectl.egg-info/top_level.txt0000644000175000017500000000001114432752527021364 0ustar00fraggodfraggodpulsectl ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1684788567.479202 pulsectl-23.5.2/setup.cfg0000644000175000017500000000010314432752527015130 0ustar00fraggodfraggod[bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788494.0 pulsectl-23.5.2/setup.py0000600000175000017500000000233714432752416015021 0ustar00fraggodfraggod#!/usr/bin/env python2 #-*- coding: utf-8 -*- from setuptools import setup, find_packages import os, sys # Error-handling here is to allow package to be built w/o README included try: readme = open(os.path.join( os.path.dirname(__file__), 'README.rst' )).read() except IOError: readme = '' setup( name = 'pulsectl', version = '23.5.2', author = 'George Filipkin, Mike Kazantsev', author_email = 'mk.fraggod@gmail.com', license = 'MIT', keywords = [ 'pulseaudio', 'libpulse', 'pulse', 'pa', 'bindings', 'sound', 'audio', 'ctypes', 'control', 'mixer', 'volume', 'mute', 'source', 'sink' ], url = 'http://github.com/mk-fg/python-pulse-control', description = 'Python high-level interface' ' and ctypes-based bindings for PulseAudio (libpulse)', long_description = readme, classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Topic :: Multimedia', 'Topic :: Multimedia :: Sound/Audio' ], packages = find_packages(), include_package_data = True )