Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow a way to specify the portaudio location #130

Open
peircej opened this issue Apr 4, 2018 · 14 comments
Open

Allow a way to specify the portaudio location #130

peircej opened this issue Apr 4, 2018 · 14 comments

Comments

@peircej
Copy link

peircej commented Apr 4, 2018

It would be nice to be able to specify the location of the portaudio lib for people that want to use a specific location and/or version. At present the code just uses whatever ctypes find_library discovers or reverts to the provided _sounddevice_data folder.

Downsides:

  1. it's often unnecessary to provide portaudio (at least on *nix systems)
  2. this isn't the location that the rest of the OS expects things. That causes problems for packaging the lib into an app. For example to wrap sounddevice up into an application bundle on Mac (e.g. py2app) the dylib files really should go into the Contents/Frameworks folder. If the app is going to be code-signed to verify it was from a trusted developer then the dylibs MUST go into the frameworks folder

I wonder if we could have something like a check for an environment variable first, like os.environ['SD_PORTAUDIO'] ?

@mgeier
Copy link
Member

mgeier commented Apr 5, 2018

Thanks for bringing this up. I was actually wondering that this didn't come up earlier.

Note that since Python 3.6 the environment variable LD_LIBRARY_PATH is checked (see https://docs.python.org/3/library/ctypes.html#finding-shared-libraries), but that's only a solution for Linux.

it's often unnecessary to provide portaudio (at least on *nix systems)

I don't understand this "downside". Do you mean if PortAudio is already installed with the package manager? Doesn't find_library() work in this case?

this isn't the location that the rest of the OS expects things

Well the problem is that some OSs don't have standard places or standard mechanisms for that.

I've already tried to support a special case in #122, I can try to add others.

I wonder if we could have something like a check for an environment variable first, like os.environ['SD_PORTAUDIO'] ?

Sure, I'm open to a PR.
But wouldn't it be better to try to handle all this at "setup" time (i.e. when creating the wheel, app bundle, ...) instead of when importing the module?
We could probably try to inject the path (or a list of potential paths) into the _sounddevice module?

BTW, did you try if other packaging tools work better, like e.g. PyInstaller?
Did you try py2app/PyInstaller when libportaudio.dylib is installed system-wide (not using the one from _sounddevice_data?

Would API mode (see #91) help?
It would be nice to be able to choose between API and ABI mode at "setup" time, but I don't know if that's possible.

How are "traditional" compiled Python extension modules handled by py2app?

@peircej
Copy link
Author

peircej commented Apr 6, 2018

did you try if other packaging tools work better, like e.g. PyInstaller?
Did you try py2app/PyInstaller when libportaudio.dylib is installed system-wide (not using the one from _sounddevice_data?

The problem isn't the packaging tool itself (that part I can mostly work around). The issue is Apple's rules. To code-sign an app you must put all compiled libs into the Frameworks folder.

How are "traditional" compiled Python extension modules handled by py2app?

They are put into the frameworks folder.

I don't understand this "downside". Do you mean if PortAudio is already installed with the package manager? Doesn't find_library() work in this case?

On Mac I've found find_library() to be pretty dumb in its search BUT I've just realised that it will find the necessary lib if it's given the complete name (in this case I just needed to point to the correct full name of the version I was packaging and then it did find the lib in that Frameworks location).

print(util.find_library('portaudio'))
None
print(util.find_library('libportaudio'))
None
print(util.find_library('libportaudio.2.dylib'))
/Applications/PsychoPy2.app/Contents/Resources/../Frameworks/libportaudio.2.dylib

So I think I can work with this; I'll just add a symlink during packaging
Frameworks/libportaudio.dylib -> Frameworks/libportaudio.2.dylib
and then I think your code will work fine.

I'll also need to ban _sounddevice_data from being packaged but that's OK

@j9ac9k
Copy link

j9ac9k commented Oct 24, 2019

I just created a PR for PyInstaller to bundle libportaudio.dylib as a binary and place it in the correct location within a .app bundle. You can follow along w/ the PR here:

pyinstaller/pyinstaller#4498

@mgeier
Copy link
Member

mgeier commented Oct 25, 2019

Thanks @j9ac9k!

@j9ac9k
Copy link

j9ac9k commented Oct 25, 2019

Thanks for making this library, you helped me regain my sanity with trying to replicate the sounddevice.OutputStream functionality with the Qt framework using QAudioOutput (after weeks of trying I gave up, and stumbled across sounddevice).

Also in case you're curious, with this PR, libportaudio.dylib is placed in a part of the .app bundle where the bundle can be codesigned and notarized.

@papr
Copy link

papr commented Dec 30, 2022

Hi!

I am using PyInstaller to bundle a sounddevice application on macOS, Windows, and Ubuntu. It works great on Windows and macOS but fails on Linux because ctypes.util.find_library is not able to find the library at runtime.

Sanity Checks

I have made sure that

  1. libportaudio2 is installed at packaging time
  2. libportaudio.so.2 is packaged correctly in the application
  3. LD_LIBRARY_PATH includes the path to the libportaudio.so.2 directory at runtime

Experiment

I have tested the following within the frozen application:

Search term ctypes.util.find_library ctypes.CDLL _sounddevice.ffi
portaudio
libportaudio
libportaudio.so
libportaudio.so.2 ✔️ ✔️

This means, loading sounddevice fails due to two reasons:

  1. libportaudio.so.2 is not part of the hardcoded list of search terms
  2. Even if it was listed, ctypes.util.find_library fails to find it

That means that even @peircej's sym-link workaround would not work here.

Solutions

To solve this issue long-term, it would be great if one could manually specify the library name that is passed to _sounddevice.ffi and skip ctypes.util.find_library all together.

My temporary workaround is to define a runtime hook that modifies ctypes.util.find_library. But I would prefer to do without it in the future.

import ctypes.util
import functools


print("Attempting to import sounddevice using patched `ctypes.util.find_library`...")
_find_library_original = ctypes.util.find_library


@functools.wraps(_find_library_original)
def _find_library_patched(name):
    if name == "portaudio":
        return "libportaudio.so.2"
    else:
        return _find_library_original(name)


ctypes.util.find_library = _find_library_patched

import sounddevice

print("sounddevice import successful!")
print("Restoring original `ctypes.util.find_library`...")
ctypes.util.find_library = _find_library_original
del _find_library_patched
print("Original `ctypes.util.find_library` restored.")

@mgeier
Copy link
Member

mgeier commented Jan 7, 2023

I don't fully understand this ... maybe because I have never used PyInstaller ...

When you talk about finding libportaudio.so.2, do you mean the library installed on the end user's system?
Or are you talking about a somehow bundled library file?

Can you please describe the layout of the files when using PyInstaller?
Maybe you can provide an example package that shows the problem?

I guess we could try to bundle a Linux binary, just like we do with macOS and Windows already, but I don't really know how to create such a library that is compatibly with most Linux systems.

Another alternative would be to switch to API mode (see #91), and provide a Linux wheel. I don't know how that would interact with PyInstaller, though.

To solve this issue long-term, it would be great if one could manually specify the library name that is passed to _sounddevice.ffi and skip ctypes.util.find_library all together.

Yeah, that's what this issue is about, but so far I haven't found a "good" way to achieve this.

libportaudio.so.2 is not part of the hardcoded list of search terms

We could add it there, but if I understand you correctly, this wouldn't help anyway.

@papr
Copy link

papr commented Jan 9, 2023

@mgeier Hi, thank you for taking the time to respond and look into this issue!

This is a shortened version of the PyInstaller Linux bundle file structure that I created:

opt
└── pupil_player
    ├── ...
    ├── base_library.zip
    ├── ...
    ├── libportaudio.so.2
    ├── ...
    ├── libpython3.11.so.1.0
    ├── numpy
    │   ├── ...
    ├── pupil_player (the main executable)
    ├── ...

PyInstaller repackages the files and ensures that the dynamic linking works as expected. Even if the wheel were included in the wheel, the corresponding loading code would need to be adjusted to not rely on ctypes.util.find_library.

We could add it there, but if I understand you correctly, this wouldn't help anyway.

Correct, this is my understanding, too. No need to add it.

I haven't found a "good" way to achieve this.

Other libraries, e.g. pyGLFW, look for the existence of a special environment variable.

API mode is not an option as long as it does not support Windows and macOS, too. :-/


I don't really know how to create such a library that is compatibly with most Linux systems.

I can highly recommend https://cibuildwheel.readthedocs.io/en/stable/ It takes care of running the wheel packaging process on a corresponding manylinux image. Depending on the age of the selected manylinux image, you might need to use yum to install libportaudio, or build it from scratch. But as long as you do it on the manylinux image, the result will be compatible with many distributions.

cibuildwheel also calls auditwheel which ensures that the wheel contains all needed c libraries. The corresponding macOS and Windows equivalents are https://github.com/matthew-brett/delocate and https://github.com/adang1345/delvewheel

@mgeier
Copy link
Member

mgeier commented Jan 17, 2023

There is already a hook for PyInstaller https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/src/_pyinstaller_hooks_contrib/hooks/stdhooks/hook-sounddevice.py which mentions Linux (and which may be the reason why libportaudio.so.2 is copied in the first place), and there is even a test for sounddevice: https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/src/_pyinstaller_hooks_contrib/tests/test_libraries.py

However, I don't know if those tests have actually be run on Linux ... probably not?

I'm not sure what the search paths for find_library() are, but I guess opt/pupil_player in your example is not in it.

The Python docs (https://docs.python.org/3.8/library/ctypes.html#finding-shared-libraries) mention LD_LIBRARY_PATH ... did you try that?

I'm not sure if that feasible though, because how would you make sure your users have the environment variable set correctly?

The Python docs link also says this:

"If wrapping a shared library with ctypes, it may be better to determine the shared library name at development time, and hardcode that into the wrapper module instead of using find_library() to locate the library at runtime."

I think it would be great to solve this a install time and not rely on an environment variable at runtime.

Other libraries, e.g. pyGLFW, look for the existence of a special environment variable.

Yeah, that's a possibility, but I'm not sure how easy that is for users.
Would you be able to make sure that the environment variable is automatically set?

API mode is not an option as long as it does not support Windows and macOS, too. :-/

Sure, I would only do that if it supports Windows and macOS, and ideally also Raspberry Pi.

Thanks for mentioning cibuildwheel. I've heard about it but I've not actually tried it.
I think this would be a good option if we use API mode, which would generate a "normal" Python extension, ideally containing PortAudio as a static library (but with auditwheel it might also work with a dynamic PortAudio).

However, what I meant was a way to create the libportaudio.so file on its own. Would that also be possible with cibuildwheel?

@papr
Copy link

papr commented Jan 24, 2023

Hi @mgeier
Apologies for the delayed response, I missed the email notification. Thank you for continuing to look into this!

  • pyinstaller hook - yes, this hook ensures that the library is packaged within the bundle in the first place. It does not affect the library loading during runtime, though.

  • find_library / LD_LIBRARY_PATH - As mentioned in my initial post, this envvar set correctly. My understanding is that PyInstaller overwrites the find_library/ctypes.CDLL behavior to load bundled libraries only. The issue seems to be, that PyInstaller's find_library is explicitly ignoring LD_LIBRARY_PATH to avoid loading system libs by accident.

  • hardcoding the library path -

    I think it would be great to solve this a install time and not rely on an environment variable at runtime.

    Unfortunately, it is not that simple. One cannot rely on absolute paths because the install location might differ for each user, especially on macOS. Also, the wrapper module, in this case, would be python-sounddevice and I doubt that you would like to start hardcoding paths from third-party applications like mine into your module.

    Environment variables are the perfect solution for this, and not just a hacky workaround. The user is not required to set the variable manually. Instead, the applications can determine their absolute install location at runtime and set the corresponding envvar before importing sounddevice (or any other ctypes wrapper in that matter). Since envvars only apply to the process (and their children) that created them, the modification of the environment is an isolated action, without the possibility to conflict with other processes.

    Another popular ctypes library that uses envvars as an opt-in mechanism to override the default search is PyOpenGL.

  • Building libportaudio as part of the wheel process - Yes, especially in combination with scikit-build this is a feasible option! I maintain multiple libraries that use this approach. The one that comes closest to sounddevice's current setup is https://github.com/pupil-labs/apriltags

  • Building libportaudio as part of a C extension - This is also very easily possible with scikit-build. There is a custom CMAKE command that allows building a C extension and linking C libraries to it.. For example, this is a library of mine that follows this approach: https://github.com/pupil-labs/pyuvc

@mgeier
Copy link
Member

mgeier commented Jan 31, 2023

Thanks for the detailed information!

Environment variables are the perfect solution for this, and not just a hacky workaround.

I think I'm starting to accept that, thanks for providing the real-world examples.

I originally had the feeling that this might add some overhead to all the cases where the feature is not needed, but I guess the environment variables are loaded by the Python interpreter anyway, so the cost would just be a dictionary lookup, right?

Would you like to make a PR for this?

@papr
Copy link

papr commented Feb 7, 2023

Hey, yes, I have a few other things to do first, but I will try to find the time to contribute the implementation!

@mgeier
Copy link
Member

mgeier commented Jan 21, 2024

For the record, I have documented how to use a custom PortAudio: #518

@rmccampbell
Copy link
Contributor

Regarding the system paths discussion in #496, I don't think it really matters whether it's a standard or custom location; either way it's probably not a good idea to require adding it to the default library search path, regardless of OS. That means that all apps/libraries which are searching for the same library will find the overridden one, which may or may not be what the user wants. I think to allow custom library dependencies it should be either in a sounddevice-specific directory (e.g. %AppData%\sounddevice\, ~/.sounddevice/, etc) or specified by environment variable as suggested, and either way should bypass the find_library, which should only be for actually system installed libraries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants