├── playsound3 ├── py.typed ├── __init__.py ├── backends.py └── playsound3.py ├── tests ├── sounds │ ├── sample3s.flac │ ├── sample3s.mp3 │ └── звук 音 聲音.wav ├── test_special_characters.py ├── test_raising_errors.py ├── test_killing.py └── test_functionality.py ├── .github └── workflows │ ├── check-code-quality.yaml │ ├── check-with-pytest-macos.yaml │ ├── check-with-pytest-windows.yaml │ └── check-with-pytest-linux.yaml ├── .gitignore ├── LICENSE ├── pyproject.toml └── README.md /playsound3/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sounds/sample3s.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szmikler/playsound3/HEAD/tests/sounds/sample3s.flac -------------------------------------------------------------------------------- /tests/sounds/sample3s.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szmikler/playsound3/HEAD/tests/sounds/sample3s.mp3 -------------------------------------------------------------------------------- /tests/sounds/звук 音 聲音.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szmikler/playsound3/HEAD/tests/sounds/звук 音 聲音.wav -------------------------------------------------------------------------------- /playsound3/__init__.py: -------------------------------------------------------------------------------- 1 | __license__ = "MIT" 2 | __version__ = "3.3.0" 3 | __author__ = "Szymon Mikler" 4 | 5 | from playsound3.playsound3 import ( 6 | AVAILABLE_BACKENDS, 7 | DEFAULT_BACKEND, 8 | PlaysoundException, 9 | playsound, 10 | prefer_backends, 11 | ) 12 | 13 | __all__ = [ 14 | "AVAILABLE_BACKENDS", 15 | "DEFAULT_BACKEND", 16 | "playsound", 17 | "prefer_backends", 18 | "PlaysoundException", 19 | ] 20 | -------------------------------------------------------------------------------- /tests/test_special_characters.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from playsound3 import AVAILABLE_BACKENDS, playsound 4 | 5 | wav = "tests/sounds/звук 音 聲音.wav" 6 | 7 | 8 | def test_with_blocking(): 9 | for backend in AVAILABLE_BACKENDS: 10 | print(f"Testing backend: {backend}") 11 | 12 | sound = playsound(wav, block=True, backend=backend) 13 | assert not sound.is_alive() 14 | 15 | 16 | def test_non_blocking(): 17 | for backend in AVAILABLE_BACKENDS: 18 | print(f"Testing backend: {backend}") 19 | 20 | sound = playsound(wav, block=False, backend=backend) 21 | time.sleep(0.05) 22 | assert sound.is_alive() 23 | 24 | sound.wait() 25 | assert not sound.is_alive() 26 | -------------------------------------------------------------------------------- /tests/test_raising_errors.py: -------------------------------------------------------------------------------- 1 | import urllib.error 2 | 3 | import pytest 4 | 5 | from playsound3 import playsound 6 | from playsound3.playsound3 import PlaysoundException 7 | 8 | valid = "tests/sounds/sample3s.mp3" 9 | 10 | 11 | def test_invalid_sound_file(): 12 | with pytest.raises(PlaysoundException): 13 | playsound("invalid.mp3") 14 | 15 | 16 | def test_non_existent_file(): 17 | with pytest.raises(PlaysoundException): 18 | playsound("non_existent.mp3") 19 | 20 | 21 | def test_invalid_backend(): 22 | with pytest.raises(PlaysoundException): 23 | playsound(valid, backend="invalid_backend") 24 | 25 | 26 | def test_playsound_from_url(): 27 | url = "https://wrong-url.com/wrong-audio.mp3" 28 | with pytest.raises(urllib.error.URLError): 29 | playsound(url) 30 | -------------------------------------------------------------------------------- /.github/workflows/check-code-quality.yaml: -------------------------------------------------------------------------------- 1 | name: check code quality 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-22.04 15 | timeout-minutes: 5 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 3.10 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.10" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install ruff pyright certifi 27 | - name: Run ruff linting 28 | run: ruff check . 29 | - name: Run ruff formatting 30 | run: ruff format . --check 31 | - name: Run pyright 32 | run: pyright playsound3 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | # Custom 62 | .idea 63 | devel/ 64 | **/script* 65 | -------------------------------------------------------------------------------- /.github/workflows/check-with-pytest-macos.yaml: -------------------------------------------------------------------------------- 1 | name: install locally and run pytest on macOS 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-14 12 | timeout-minutes: 5 13 | 14 | strategy: 15 | matrix: 16 | python-version: [ 17 | "3.10", 18 | "3.11", 19 | "3.12", 20 | "3.13", 21 | "3.14", 22 | ] 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | pip install . 33 | pip install PyObjC 34 | pip install pytest 35 | 36 | - name: Check available backends 37 | run: | 38 | python -c "import playsound3; print(playsound3.AVAILABLE_BACKENDS)" 39 | 40 | - name: Test with pytest 41 | run: | 42 | pytest tests --log-cli-level=WARNING 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Szymon Mikler 4 | Copyright (c) 2021 Taylor Marks 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/check-with-pytest-windows.yaml: -------------------------------------------------------------------------------- 1 | name: install locally and run pytest on Windows 2 | 3 | # This test fails on Windows server without audio devices 4 | # So we don't run it automatically 5 | 6 | #on: 7 | # workflow_dispatch 8 | 9 | on: 10 | push: 11 | branches: ["main"] 12 | pull_request: 13 | branches: ["main"] 14 | 15 | jobs: 16 | build: 17 | runs-on: windows-2022 18 | timeout-minutes: 5 19 | 20 | strategy: 21 | matrix: 22 | python-version: [ 23 | "3.10", 24 | "3.11", 25 | "3.12", 26 | "3.13", 27 | "3.14", 28 | ] 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | # Add Windows Audio device 34 | - name: Print device 35 | run: Get-CimInstance Win32_SoundDevice | fl * 36 | - name: Install Scream 37 | shell: powershell 38 | run: | 39 | Start-Service audio* 40 | Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.6/Scream3.6.zip -OutFile C:\Scream3.6.zip 41 | Expand-Archive -Path 'C:\Scream3.6.zip' -DestinationPath 'C:\Scream' -Force 42 | $cert = (Get-AuthenticodeSignature C:\Scream\Install\driver\Scream.sys).SignerCertificate 43 | $store = [System.Security.Cryptography.X509Certificates.X509Store]::new("TrustedPublisher", "LocalMachine") 44 | $store.Open("ReadWrite") 45 | $store.Add($cert) 46 | $store.Close() 47 | cd C:\Scream\Install\driver 48 | C:\Scream\Install\helpers\devcon install Scream.inf *Scream 49 | - name: Print Audio Device 50 | run: Get-CimInstance Win32_SoundDevice | fl * 51 | 52 | - name: Set up Python ${{ matrix.python-version }} 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | - name: Install dependencies 57 | run: | 58 | pip install . 59 | pip install pytest 60 | 61 | - name: Check available backends 62 | run: | 63 | python -c "import playsound3; print(playsound3.AVAILABLE_BACKENDS)" 64 | 65 | - name: Test with pytest 66 | run: | 67 | pytest tests --log-cli-level=WARNING 68 | -------------------------------------------------------------------------------- /.github/workflows/check-with-pytest-linux.yaml: -------------------------------------------------------------------------------- 1 | name: install locally and run pytest on Linux 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | timeout-minutes: 10 13 | 14 | strategy: 15 | matrix: 16 | python-version: [ 17 | "3.10", 18 | "3.11", 19 | "3.12", 20 | "3.13", 21 | "3.14", 22 | ] 23 | 24 | steps: 25 | - name: Start PulseAudio server 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get -y install pulseaudio pulseaudio-utils 29 | pulseaudio --start 30 | pactl load-module module-null-sink sink_name=virtual-sink sink_properties=device.description="Virtual_Sink" 31 | pactl set-default-sink virtual-sink 32 | - name: Check PulseAudio status 33 | run: pulseaudio --check 34 | - name: List PulseAudio sinks 35 | run: pactl list short sinks 36 | - name: Setup GStreamer 37 | run: | 38 | sudo apt-get update 39 | sudo apt-get -y install \ 40 | libunwind-dev \ 41 | libgirepository1.0-dev \ 42 | gstreamer1.0 \ 43 | gstreamer1.0-pulseaudio \ 44 | gstreamer1.0-alsa \ 45 | libgstreamer1.0-dev \ 46 | libgstreamer-plugins-base1.0-dev \ 47 | libgstreamer-plugins-bad1.0-dev \ 48 | gstreamer1.0-plugins-base \ 49 | gstreamer1.0-plugins-good \ 50 | gstreamer1.0-plugins-bad \ 51 | gstreamer1.0-plugins-ugly \ 52 | gstreamer1.0-libav \ 53 | gstreamer1.0-tools \ 54 | gstreamer1.0-x \ 55 | gstreamer1.0-alsa \ 56 | gstreamer1.0-gl \ 57 | gstreamer1.0-gtk3 \ 58 | gstreamer1.0-qt5 \ 59 | gstreamer1.0-pulseaudio 60 | gst-play-1.0 --version 61 | 62 | - uses: actions/checkout@v4 63 | - name: Set up Python ${{ matrix.python-version }} 64 | uses: actions/setup-python@v5 65 | with: 66 | python-version: ${{ matrix.python-version }} 67 | - name: Install dependencies 68 | run: | 69 | pip install . 70 | pip install pytest 71 | 72 | - name: Check available backends 73 | run: | 74 | python -c "import playsound3; print(playsound3.AVAILABLE_BACKENDS)" 75 | 76 | - name: Test with pytest 77 | run: | 78 | timeout 120 pytest tests --log-cli-level=WARNING -vv 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "playsound3" 7 | dynamic = ["version"] 8 | requires-python = ">=3.10" 9 | authors = [ 10 | { name = "Szymon Mikler", email = "sjmikler@gmail.com" }, 11 | { name = "Taylor Marks", email = "taylor@marksfam.com" }, 12 | ] 13 | maintainers = [ 14 | { name = "Szymon Mikler", email = "sjmikler@gmail.com" }, 15 | ] 16 | description = "Cross-platform library to play audio files" 17 | readme = { file = "README.md", content-type = "text/markdown" } 18 | license = { text = "MIT License" } 19 | keywords = ["audio", "sound", "song", "play", "media", "playsound", "music", "wave", "wav", "mp3"] 20 | classifiers = [ 21 | "Development Status :: 4 - Beta", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Topic :: Multimedia :: Sound/Audio :: MIDI", 31 | "Topic :: Multimedia :: Sound/Audio :: Players", 32 | "Topic :: Multimedia :: Sound/Audio :: Players :: MP3", 33 | "Typing :: Typed", 34 | "Operating System :: OS Independent", 35 | ] 36 | dependencies = [ 37 | "pywin32; sys_platform == 'win32'", 38 | ] 39 | 40 | [project.urls] 41 | Home = "https://github.com/sjmikler/playsound3" 42 | Issues = "https://github.com/sjmikler/playsound3/issues" 43 | Documentation = "https://github.com/sjmikler/playsound3/blob/main/README.md#quick-start" 44 | 45 | [project.optional-dependencies] 46 | dev = [ 47 | "pyright", 48 | "pytest", 49 | "ruff", 50 | ] 51 | 52 | [tool.hatch.version] 53 | path = "playsound3/__init__.py" 54 | 55 | [tool.hatch.build.targets.sdist] 56 | include = ["playsound3", "README.md", "tests"] 57 | 58 | [tool.hatch.build.targets.wheel] 59 | packages = ["playsound3"] 60 | 61 | ################################## 62 | ## Formatting and testing tools ## 63 | ################################## 64 | 65 | [tool.pyright] 66 | typeCheckingMode = "standard" 67 | exclude = ["devel", "build", "dist"] 68 | pythonVersion = "3.10" 69 | 70 | [tool.ruff] 71 | line-length = 120 72 | target-version = "py310" 73 | 74 | [tool.ruff.format] 75 | quote-style = "double" 76 | indent-style = "space" 77 | 78 | [tool.ruff.lint] 79 | select = ["E", "F", "I", "B"] 80 | 81 | [tool.pytest.ini_options] 82 | pythonpath = ["."] 83 | 84 | # %% Old tools 85 | 86 | [tool.black] 87 | line_length = 120 88 | 89 | [tool.flake8] 90 | max-line-length = 120 91 | 92 | [tool.isort] 93 | profile = "black" 94 | line_length = 120 95 | -------------------------------------------------------------------------------- /tests/test_killing.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import subprocess 4 | import sys 5 | import time 6 | import typing as t 7 | 8 | import pytest 9 | 10 | from playsound3 import AVAILABLE_BACKENDS 11 | from playsound3.playsound3 import _prepare_path 12 | 13 | loc_mp3_3s = "tests/sounds/sample3s.mp3" 14 | loc_flc_3s = "tests/sounds/sample3s.flac" 15 | web_wav_3s = "https://samplelib.com/lib/preview/wav/sample-3s.wav" 16 | 17 | # Download web files to the local cache 18 | for url in [web_wav_3s]: 19 | _prepare_path(url) 20 | 21 | 22 | def get_supported_sounds(backend: str): 23 | not_supporting_flac = ["alsa", "winmm"] 24 | if backend in not_supporting_flac: 25 | return [loc_mp3_3s, web_wav_3s] 26 | else: 27 | return [loc_mp3_3s, loc_flc_3s, web_wav_3s] 28 | 29 | 30 | def _iter_pids() -> t.Iterable[int]: 31 | proc = pathlib.Path("/proc") 32 | for p in proc.iterdir(): 33 | if p.name.isdigit(): 34 | yield int(p.name) 35 | 36 | 37 | def _read_file(path: pathlib.Path) -> bytes: 38 | try: 39 | return path.read_bytes() 40 | except Exception: 41 | return b"" 42 | 43 | 44 | def list_tagged_player_pids(tag: str) -> t.List[int]: 45 | """Return PIDs whose environ contains TAG=""" 46 | pids = [] 47 | for pid in _iter_pids(): 48 | base = pathlib.Path(f"/proc/{pid}") 49 | env = _read_file(base / "environ") 50 | if not env: 51 | continue 52 | # /proc//environ is NUL-separated key=val entries 53 | if f"PLAYSOUND_TEST_TAG={tag}".encode() not in env.split(b"\x00"): 54 | continue 55 | cmdline = _read_file(base / "cmdline").replace(b"\x00", b" ").lower() 56 | if not cmdline: 57 | continue 58 | pids.append(pid) 59 | return pids 60 | 61 | 62 | HELPER_CODE = """ 63 | import os, sys, time 64 | from playsound3 import playsound 65 | 66 | sound = playsound({path!r}, block=False, backend={backend!r}) 67 | time.sleep(10) 68 | """ 69 | 70 | 71 | @pytest.mark.skipif(sys.platform != "linux", reason="Linux-only: relies on /proc and PDEATHSIG semantics") 72 | def test_killing_parent(): 73 | TAG = "__test_killing_tag" 74 | 75 | for backend in AVAILABLE_BACKENDS: 76 | for path in get_supported_sounds(backend): 77 | assert len(list_tagged_player_pids(TAG)) == 0 78 | code = HELPER_CODE.format(path=path, backend=backend) 79 | 80 | environ = os.environ.copy() 81 | environ["PLAYSOUND_TEST_TAG"] = TAG 82 | proc = subprocess.Popen(["python", "-c", code], env=environ) 83 | 84 | time.sleep(2.5) 85 | assert len(list_tagged_player_pids(TAG)) == 2 86 | 87 | proc.kill() 88 | assert len(list_tagged_player_pids(TAG)) == 0 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Version 3.0.0** 2 | > 3 | > New functionalities: 4 | > * stop sounds by calling `sound.stop()` 5 | > * check if sound is still playing with `sound.is_alive()` 6 | 7 | # playsound3 8 | 9 | [![PyPi version](https://img.shields.io/badge/dynamic/json?label=latest&query=info.version&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fplaysound3%2Fjson)](https://pypi.org/project/playsound3) 10 | [![PyPI license](https://img.shields.io/badge/dynamic/json?label=license&query=info.license&url=https%3A%2F%2Fpypi.org%2Fpypi%2Fplaysound3%2Fjson)](https://pypi.org/project/playsound3) 11 | 12 | Cross platform library to play sound files in Python. 13 | 14 | ## Installation 15 | 16 | Install via pip: 17 | 18 | ``` 19 | pip install playsound3 20 | ``` 21 | 22 | ## Quick Start 23 | 24 | After installation, playing sounds is simple: 25 | 26 | ```python 27 | from playsound3 import playsound 28 | 29 | # Play sounds from disk 30 | playsound("/path/to/sound/file.mp3") 31 | 32 | # or play sounds from the internet. 33 | playsound("http://url/to/sound/file.mp3") 34 | 35 | # You can play sounds in the background 36 | sound = playsound("/path/to/sound/file.mp3", block=False) 37 | 38 | # and check if they are still playing 39 | if sound.is_alive(): 40 | print("Sound is still playing!") 41 | 42 | # and stop them whenever you like. 43 | sound.stop() 44 | ``` 45 | 46 | ## Reference 47 | 48 | ### playsound 49 | 50 | ```python 51 | def playsound( 52 | sound: str | Path, 53 | block: bool = True, 54 | backend: str | None = None, 55 | ) -> Sound 56 | ``` 57 | 58 | `sound` (required) \ 59 | The audio file you want to play (local or URL). 60 | 61 | `block` (optional, default=`True`)\ 62 | Determines whether the sound plays synchronously (blocking) or asynchronously (background). 63 | 64 | `backend` (optional, default=`None`) \ 65 | Specify which audio backend to use. 66 | If `None`, the best backend is determined automatically. 67 | 68 | To see a list of backends supported by your system: 69 | 70 | ```python 71 | from playsound3 import AVAILABLE_BACKENDS, DEFAULT_BACKEND 72 | 73 | print(AVAILABLE_BACKENDS) # for example: ["gstreamer", "ffmpeg", ...] 74 | print(DEFAULT_BACKEND) # for example: "gstreamer" 75 | ``` 76 | 77 | ### Sound 78 | 79 | `playsound` returns a `Sound` object for playback control: 80 | 81 | | Method | Description | 82 | |---------------|-------------------------------------------| 83 | | `.is_alive()` | Checks if the sound is currently playing. | 84 | | `.wait()` | Blocks execution until playback finishes. | 85 | | `.stop()` | Immediately stops playback. | 86 | 87 | ## Supported systems 88 | 89 | * **Linux** 90 | * GStreamer 91 | * ALSA (aplay and mpg123) 92 | * **Windows** 93 | * WMPlayer 94 | * winmm.dll 95 | * **macOS** 96 | * AppKit 97 | * afplay 98 | * **Multiplatform** 99 | * FFmpeg 100 | 101 | ## Supported audio formats 102 | 103 | The bare minimum supported by every backend are `.mp3` and `.wav` files. 104 | Using them will keep your program compatible across different systems. 105 | To see an exhaustive list of extensions supported by a backend, refer to their respective documentation. 106 | 107 | ## Fork information 108 | 109 | This repository was originally forked from [playsound](https://github.com/TaylorSMarks/playsound) library created by Taylor Marks. 110 | The original library is not maintained anymore and doesn't accept pull requests. 111 | This library is a major rewrite of its original. 112 | 113 | Feel free to create an issue or contribute to `playsound3`! 114 | -------------------------------------------------------------------------------- /playsound3/backends.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | import uuid 5 | from threading import Thread 6 | from typing import Any 7 | 8 | WAIT_TIME: float = 0.02 9 | 10 | 11 | class PlaysoundException(Exception): 12 | pass 13 | 14 | 15 | class WmplayerPopen: 16 | """Popen-like object for Wmplayer backend.""" 17 | 18 | def __init__(self, sound: str): 19 | self._playing: bool = True 20 | self.thread: Thread = Thread(target=self._play, args=(sound,), daemon=True) 21 | self.thread.start() 22 | 23 | def _play(self, sound: str) -> None: 24 | try: 25 | import pythoncom # type: ignore 26 | import win32com.client # type: ignore 27 | except ImportError as e: 28 | raise PlaysoundException("Install 'pywin32' to use the 'wmplayer' backend.") from e 29 | 30 | # Create the Windows Media Player COM object 31 | wmp = win32com.client.Dispatch( 32 | "WMPlayer.OCX", 33 | pythoncom.CoInitialize(), 34 | ) 35 | wmp.settings.autoStart = True # Ensure playback starts automatically 36 | 37 | # Set the URL to your MP3 file 38 | wmp.URL = sound 39 | wmp.controls.play() # Start playback 40 | 41 | while wmp.playState != 1 and self._playing: # playState 1 indicates stopped 42 | pythoncom.PumpWaitingMessages() # Process COM events 43 | time.sleep(WAIT_TIME) 44 | 45 | wmp.controls.stop() 46 | self._playing = False 47 | 48 | def terminate(self) -> None: 49 | self._playing = False 50 | 51 | def poll(self) -> int | None: 52 | """None if sound is playing, integer if not.""" 53 | return None if self._playing else 0 54 | 55 | def wait(self) -> int: 56 | self.thread.join() 57 | return 0 58 | 59 | 60 | class WinmmPopen: 61 | """Popen-like object for Winmm backend.""" 62 | 63 | def __init__(self, sound: str): 64 | self._playing: bool = True 65 | self.alias: str | None = None 66 | self.thread: Thread = Thread(target=self._play, args=(sound,), daemon=True) 67 | self.thread.start() 68 | 69 | def _send_winmm_mci_command(self, command: str) -> str: 70 | try: 71 | import ctypes 72 | except ImportError as e: 73 | raise PlaysoundException("Install 'ctypes' to use the 'winmm' backend") from e 74 | 75 | winmm = ctypes.WinDLL("winmm.dll") # type: ignore 76 | buffer = ctypes.create_unicode_buffer(255) # Unicode buffer for wide characters 77 | error_code = winmm.mciSendStringW(ctypes.c_wchar_p(command), buffer, 254, 0) 78 | 79 | if error_code: 80 | self._playing = False 81 | raise RuntimeError(f"winmm was not able to play the file! MCI error code: {error_code}") 82 | return buffer.value 83 | 84 | def _play(self, sound: str) -> None: 85 | """Play a sound utilizing windll.winmm.""" 86 | # Select a unique alias for the sound 87 | self.alias = str(uuid.uuid4()) 88 | self._send_winmm_mci_command(f'open "{sound}" type mpegvideo alias {self.alias}') 89 | self._send_winmm_mci_command(f"play {self.alias}") 90 | 91 | while self._playing: 92 | time.sleep(WAIT_TIME) 93 | status = self._send_winmm_mci_command(f"status {self.alias} mode") 94 | if status != "playing": 95 | break 96 | 97 | self._send_winmm_mci_command(f"stop {self.alias}") 98 | self._send_winmm_mci_command(f"close {self.alias}") 99 | self._playing = False 100 | 101 | def terminate(self) -> None: 102 | self._playing = False 103 | 104 | def poll(self) -> int | None: 105 | """None if sound is playing, integer if not.""" 106 | return None if self._playing else 0 107 | 108 | def wait(self) -> int: 109 | self.thread.join() 110 | return 0 111 | 112 | 113 | class AppkitPopen: 114 | """Popen-like object for AppKit NSSound backend.""" 115 | 116 | def __init__(self, sound: str): 117 | try: 118 | from AppKit import NSSound # type: ignore 119 | from Foundation import NSURL # type: ignore 120 | except ImportError as e: 121 | raise PlaysoundException("Install 'PyObjC' to use 'appkit' backend.") from e 122 | 123 | nsurl: Any = NSURL.fileURLWithPath_(sound) 124 | self._nssound: Any = NSSound.alloc().initWithContentsOfURL_byReference_(nsurl, True) 125 | self._nssound.retain() 126 | self._start_time: float = time.time() 127 | 128 | self._nssound.play() 129 | self._duration: float = self._nssound.duration() 130 | 131 | def terminate(self) -> None: 132 | self._nssound.stop() 133 | self._duration = time.time() - self._start_time 134 | 135 | def poll(self) -> int | None: 136 | """None if sound is playing, integer if not.""" 137 | if time.time() - self._start_time >= self._duration: 138 | return 0 139 | return None 140 | 141 | def wait(self) -> int: 142 | while time.time() - self._start_time < self._duration: 143 | time.sleep(WAIT_TIME) 144 | return 0 145 | -------------------------------------------------------------------------------- /tests/test_functionality.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from playsound3 import AVAILABLE_BACKENDS, playsound 5 | from playsound3.playsound3 import _prepare_path 6 | 7 | loc_mp3_3s = "tests/sounds/sample3s.mp3" 8 | loc_flc_3s = "tests/sounds/sample3s.flac" 9 | web_wav_3s = "https://samplelib.com/lib/preview/wav/sample-3s.wav" 10 | 11 | # Download web files to the local cache 12 | for url in [web_wav_3s]: 13 | _prepare_path(url) 14 | 15 | 16 | def get_supported_sounds(backend): 17 | not_supporting_flac = ["alsa", "winmm"] 18 | 19 | if backend in not_supporting_flac: 20 | return [loc_mp3_3s, web_wav_3s] 21 | else: 22 | return [loc_mp3_3s, loc_flc_3s, web_wav_3s] 23 | 24 | 25 | CI = os.environ.get("CI", False) 26 | 27 | 28 | def test_blocking_1(): 29 | for backend in AVAILABLE_BACKENDS: 30 | for path in get_supported_sounds(backend): 31 | t0 = time.perf_counter() 32 | sound = playsound(path, block=True, backend=backend) 33 | 34 | td = time.perf_counter() - t0 35 | assert not sound.is_alive(), f"backend={backend}, path={path}" 36 | assert td >= 3.0, f"backend={backend}, path={path}" 37 | assert CI or td < 5.0, f"backend={backend}, path={path}" 38 | 39 | 40 | def test_waiting_1(): 41 | for backend in AVAILABLE_BACKENDS: 42 | for path in get_supported_sounds(backend): 43 | t0 = time.perf_counter() 44 | sound = playsound(path, block=False, backend=backend) 45 | assert sound.is_alive(), f"backend={backend}, path={path}" 46 | 47 | sound.wait() 48 | td = time.perf_counter() - t0 49 | assert not sound.is_alive(), f"backend={backend}, path={path}" 50 | assert td >= 3.0, f"backend={backend}, path={path}" 51 | assert CI or td < 5.0, f"backend={backend}, path={path}" 52 | 53 | 54 | def test_waiting_2(): 55 | for backend in AVAILABLE_BACKENDS: 56 | for path in get_supported_sounds(backend): 57 | sound = playsound(path, block=False, backend=backend) 58 | assert sound.is_alive(), f"backend={backend}, path={path}" 59 | 60 | time.sleep(5) 61 | assert not sound.is_alive(), f"backend={backend}, path={path}" 62 | 63 | 64 | def test_stopping_1(): 65 | for backend in AVAILABLE_BACKENDS: 66 | for path in get_supported_sounds(backend): 67 | t0 = time.perf_counter() 68 | sound = playsound(path, block=False, backend=backend) 69 | assert sound.is_alive(), f"backend={backend}, path={path}" 70 | 71 | time.sleep(1) 72 | sound.stop() 73 | td = time.perf_counter() - t0 74 | 75 | time.sleep(0.05) 76 | assert not sound.is_alive(), f"backend={backend}, path={path}" 77 | assert td >= 0.95 and td < 2.0, f"backend={backend}, path={path}" 78 | 79 | # Stopping again should be a no-op 80 | sound.stop() 81 | assert not sound.is_alive(), f"backend={backend}, path={path}" 82 | 83 | 84 | def test_parallel_1(): 85 | for backend in AVAILABLE_BACKENDS: 86 | for path in get_supported_sounds(backend): 87 | t0 = time.perf_counter() 88 | sounds = [playsound(path, block=False, backend=backend) for _ in range(3)] 89 | time.sleep(0.05) 90 | for sound in sounds: 91 | assert sound.is_alive(), f"backend={backend}" 92 | time.sleep(1) 93 | 94 | sounds[1].stop() 95 | time.sleep(0.05) 96 | assert sounds[0].is_alive(), f"backend={backend}" 97 | assert sounds[2].is_alive(), f"backend={backend}" 98 | assert not sounds[1].is_alive(), f"backend={backend}" 99 | time.sleep(1) 100 | 101 | assert sounds[0].is_alive(), f"backend={backend}" 102 | assert sounds[2].is_alive(), f"backend={backend}" 103 | sounds[0].stop() 104 | sounds[2].stop() 105 | td = time.perf_counter() - t0 106 | 107 | time.sleep(0.05) 108 | for sound in sounds: 109 | assert not sound.is_alive(), f"backend={backend}" 110 | assert td >= 2.0 and td < 3.0, f"backend={backend}" 111 | 112 | 113 | def test_parallel_2(): 114 | N_PARALLEL = 10 # Careful - this might be loud! 115 | 116 | for backend in AVAILABLE_BACKENDS: 117 | for path in get_supported_sounds(backend): 118 | sounds = [playsound(path, block=False, backend=backend) for _ in range(N_PARALLEL)] 119 | 120 | time.sleep(1) 121 | for sound in sounds: 122 | assert sound.is_alive(), f"backend={backend}, path={path}" 123 | for sound in sounds: 124 | sound.stop() 125 | 126 | time.sleep(0.05) 127 | for sound in sounds: 128 | assert not sound.is_alive(), f"backend={backend}, path={path}" 129 | 130 | 131 | def test_parallel_3(): 132 | for backend in AVAILABLE_BACKENDS: 133 | sounds = [playsound(path, block=False, backend=backend) for path in get_supported_sounds(backend)] 134 | 135 | time.sleep(1) 136 | for sound in sounds: 137 | assert sound.is_alive(), f"backend={backend}" 138 | for sound in sounds: 139 | sound.stop() 140 | 141 | time.sleep(0.05) 142 | for sound in sounds: 143 | assert not sound.is_alive(), f"backend={backend}" 144 | -------------------------------------------------------------------------------- /playsound3/playsound3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import atexit 4 | import logging 5 | import os 6 | import shutil 7 | import signal 8 | import subprocess 9 | import tempfile 10 | import urllib.request 11 | from abc import ABC, abstractmethod 12 | from importlib.util import find_spec 13 | from pathlib import Path 14 | from typing import Any, Protocol 15 | 16 | from playsound3 import backends 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class PlaysoundException(Exception): 22 | pass 23 | 24 | 25 | #################### 26 | ## DOWNLOAD TOOLS ## 27 | #################### 28 | 29 | _DOWNLOAD_CACHE: dict[str, str] = {} 30 | 31 | 32 | def _download_sound_from_web(link: str, destination: Path) -> None: 33 | # Identifies itself as a browser to avoid HTTP 403 errors 34 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64)"} 35 | request = urllib.request.Request(link, headers=headers) 36 | 37 | with urllib.request.urlopen(request) as response, destination.open("wb") as out_file: 38 | out_file.write(response.read()) 39 | 40 | 41 | def _prepare_path(sound: str | Path) -> str: 42 | if isinstance(sound, str) and sound.startswith(("http://", "https://")): 43 | # To play file from URL, we download the file first to a temporary location and cache it 44 | if sound not in _DOWNLOAD_CACHE: 45 | sound_suffix = Path(sound).suffix 46 | with tempfile.NamedTemporaryFile(delete=False, prefix="playsound3-", suffix=sound_suffix) as f: 47 | _download_sound_from_web(sound, Path(f.name)) 48 | _DOWNLOAD_CACHE[sound] = f.name 49 | sound = _DOWNLOAD_CACHE[sound] 50 | 51 | path = Path(sound) 52 | 53 | if not path.exists(): 54 | raise PlaysoundException(f"file not found: {sound}") 55 | return path.absolute().as_posix() 56 | 57 | 58 | ######################## 59 | ## BACKEND INTERFACES ## 60 | ######################## 61 | 62 | 63 | # Imitating subprocess.Popen 64 | class PopenLike(Protocol): 65 | def poll(self) -> int | None: ... 66 | 67 | def wait(self) -> int: ... 68 | 69 | def terminate(self) -> None: ... 70 | 71 | 72 | class SoundBackend(ABC): 73 | """Abstract class for sound backends.""" 74 | 75 | @abstractmethod 76 | def check(self) -> bool: 77 | raise NotImplementedError("check() must be implemented.") 78 | 79 | @abstractmethod 80 | def play(self, sound: str) -> PopenLike: 81 | raise NotImplementedError("play() must be implemented.") 82 | 83 | 84 | def _set_pdeathsig() -> None: 85 | """Set the signal delivered to this process if its parent dies.""" 86 | try: 87 | import ctypes 88 | 89 | libc = ctypes.CDLL("libc.so.6", use_errno=True) 90 | PR_SET_PDEATHSIG = 1 91 | if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) != 0: 92 | err = ctypes.get_errno() 93 | raise OSError(err, os.strerror(err)) 94 | except Exception: # if unavailable (non-Linux) or fails, do nothing 95 | pass 96 | 97 | 98 | def get_platform_specific_kwds() -> dict[str, Any]: 99 | """Get platform-specific keyword arguments for subprocess.Popen.""" 100 | if os.name == "nt": 101 | return {} 102 | else: 103 | # On Unix-like systems, we want to ensure that the child process is terminated if the parent process dies 104 | return {"preexec_fn": _set_pdeathsig} 105 | 106 | 107 | def run_as_subprocess(commands: list[str], **kwargs: Any) -> subprocess.Popen[str]: 108 | """A wrapper around subprocess.Popen to handle platform-specific keyword arguments. 109 | 110 | By default, stdout and stderr are suppressed (set to DEVNULL). 111 | Additional keyword arguments can be passed and will override defaults. 112 | """ 113 | popen_kwargs = { 114 | "stdout": subprocess.DEVNULL, 115 | "stderr": subprocess.DEVNULL, 116 | **get_platform_specific_kwds(), 117 | **kwargs, 118 | } 119 | 120 | return subprocess.Popen(commands, **popen_kwargs) 121 | 122 | 123 | class Gstreamer(SoundBackend): 124 | """Gstreamer backend for Linux.""" 125 | 126 | def check(self) -> bool: 127 | try: 128 | subprocess.run( 129 | ["gst-play-1.0", "--version"], 130 | stdout=subprocess.DEVNULL, 131 | stderr=subprocess.DEVNULL, 132 | check=True, 133 | ) 134 | return True 135 | except FileNotFoundError: 136 | return False 137 | 138 | def play(self, sound: str) -> subprocess.Popen[str]: 139 | return run_as_subprocess(["gst-play-1.0", "--no-interactive", "--quiet", sound]) 140 | 141 | 142 | class Alsa(SoundBackend): 143 | """ALSA backend for Linux.""" 144 | 145 | pty_master: int | None = None 146 | 147 | def check(self) -> bool: 148 | try: 149 | subprocess.run(["aplay", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 150 | subprocess.run(["mpg123", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 151 | return True 152 | except FileNotFoundError: 153 | return False 154 | 155 | def play(self, sound: str) -> subprocess.Popen[str]: 156 | suffix = Path(sound).suffix 157 | 158 | if self.pty_master is None: 159 | self.pty_master, _ = os.openpty() 160 | 161 | if suffix == ".wav": 162 | return run_as_subprocess(["aplay", "--quiet", sound]) 163 | elif suffix == ".mp3": 164 | return run_as_subprocess(["mpg123", "-q", sound], stdin=self.pty_master) 165 | else: 166 | raise PlaysoundException(f"ALSA does not support for {suffix} files.") 167 | 168 | 169 | class Ffplay(SoundBackend): 170 | """FFplay backend for systems with ffmpeg installed.""" 171 | 172 | def check(self) -> bool: 173 | try: 174 | subprocess.run(["ffplay", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 175 | return True 176 | except (FileNotFoundError, subprocess.CalledProcessError): 177 | return False 178 | 179 | def play(self, sound: str) -> subprocess.Popen[str]: 180 | return run_as_subprocess(["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", sound]) 181 | 182 | 183 | class Wmplayer(SoundBackend): 184 | """Windows Media Player backend for Windows.""" 185 | 186 | def check(self) -> bool: 187 | # The recommended way to check for missing library 188 | if find_spec("pythoncom") is None: 189 | return False 190 | 191 | try: 192 | import win32com.client # type: ignore 193 | 194 | _ = win32com.client.Dispatch("WMPlayer.OCX") 195 | return True 196 | except (ImportError, Exception): 197 | # pywintypes.com_error can be raised, which inherits directly from Exception 198 | return False 199 | 200 | def play(self, sound: str) -> backends.WmplayerPopen: 201 | return backends.WmplayerPopen(sound) 202 | 203 | 204 | class Winmm(SoundBackend): 205 | """WinMM backend for Windows.""" 206 | 207 | def check(self) -> bool: 208 | try: 209 | import ctypes 210 | 211 | _ = ctypes.WinDLL("winmm.dll") # type: ignore 212 | return True 213 | except (ImportError, FileNotFoundError, AttributeError): 214 | return False 215 | 216 | def play(self, sound: str) -> backends.WinmmPopen: 217 | return backends.WinmmPopen(sound) 218 | 219 | 220 | class Afplay(SoundBackend): 221 | """Afplay backend for macOS.""" 222 | 223 | def check(self) -> bool: 224 | # For some reason successful 'afplay -h' returns non-zero code 225 | # So we must use shutil to test if afplay exists 226 | return shutil.which("afplay") is not None 227 | 228 | def play(self, sound: str) -> subprocess.Popen[str]: 229 | return run_as_subprocess(["afplay", sound]) 230 | 231 | 232 | class Appkit(SoundBackend): 233 | """Appkit backend for macOS.""" 234 | 235 | def check(self) -> bool: 236 | try: 237 | from AppKit import NSSound # type: ignore # noqa: F401 238 | from Foundation import NSURL # type: ignore # noqa: F401 239 | 240 | return True 241 | except ImportError: 242 | return False 243 | 244 | def play(self, sound: str) -> backends.AppkitPopen: 245 | return backends.AppkitPopen(sound) 246 | 247 | 248 | ################ 249 | ## PLAYSOUND ## 250 | ################ 251 | 252 | _NO_BACKEND_MESSAGE = "No supported audio backends on this system!" 253 | 254 | 255 | def _auto_select_backend() -> str | None: 256 | if "PLAYSOUND3_BACKEND" in os.environ: 257 | # Allow users to override the automatic backend choice 258 | return os.environ["PLAYSOUND3_BACKEND"] 259 | 260 | for backend in _BACKEND_PREFERENCE: 261 | if backend in AVAILABLE_BACKENDS: 262 | return backend 263 | 264 | logger.warning(_NO_BACKEND_MESSAGE) 265 | return None 266 | 267 | 268 | class Sound: 269 | """Subprocess-based sound object. 270 | 271 | Attributes: 272 | backend: The name of the backend used to play the sound. 273 | subprocess: The subprocess object used to play the sound. 274 | """ 275 | 276 | def __init__( 277 | self, 278 | name: str, 279 | block: bool, 280 | backend: SoundBackend, 281 | ) -> None: 282 | """Initialize the player and begin playing.""" 283 | self.backend: str = str(type(backend)).lower() 284 | self.subprocess: PopenLike = backend.play(name) 285 | 286 | if block: 287 | self.wait() 288 | 289 | def is_alive(self) -> bool: 290 | """Check if the sound is still playing. 291 | 292 | Returns: 293 | True if the sound is still playing, else False. 294 | """ 295 | return self.subprocess.poll() is None 296 | 297 | def wait(self) -> None: 298 | """Block until the sound finishes playing. 299 | 300 | This only makes sense for non-blocking sounds. 301 | """ 302 | self.subprocess.wait() 303 | 304 | def stop(self) -> None: 305 | """Stop the sound.""" 306 | self.subprocess.terminate() 307 | 308 | 309 | def playsound( 310 | sound: str | Path, 311 | block: bool = True, 312 | backend: str | None = None, 313 | ) -> Sound: 314 | """Play a sound file using an available audio backend. 315 | 316 | Args: 317 | sound: Path or URL of the sound file (string or pathlib.Path). 318 | block: 319 | - `True` (default): Wait until sound finishes playing. 320 | - `False`: Play sound in the background. 321 | backend: Specific audio backend to use. Leave None for automatic selection. 322 | 323 | Returns: 324 | Sound object for controlling playback. 325 | """ 326 | path = _prepare_path(sound) 327 | backend = backend or DEFAULT_BACKEND 328 | if backend is None: 329 | raise PlaysoundException(_NO_BACKEND_MESSAGE) 330 | 331 | if isinstance(backend, str): 332 | if backend in _BACKEND_MAP: 333 | backend_obj = _BACKEND_MAP[backend] 334 | else: 335 | raise PlaysoundException(f"unknown backend '{backend}'") 336 | 337 | # Unofficially, you can pass a SoundBackend object 338 | elif isinstance(backend, SoundBackend): 339 | backend_obj = backend 340 | elif isinstance(backend, type) and issubclass(backend, SoundBackend): 341 | backend_obj = backend() 342 | else: 343 | raise PlaysoundException(f"invalid backend type '{type(backend)}'") 344 | return Sound(path, block, backend_obj) 345 | 346 | 347 | def _remove_cached_downloads(cache: dict[str, str]) -> None: 348 | """Remove all files saved in the cache when the program ends.""" 349 | for path in cache.values(): 350 | Path(path).unlink() 351 | 352 | 353 | #################### 354 | ## INITIALIZATION ## 355 | #################### 356 | 357 | atexit.register(_remove_cached_downloads, _DOWNLOAD_CACHE) 358 | 359 | _BACKEND_PREFERENCE = [ 360 | "gstreamer", # Linux; should be installed on every distro 361 | "wmplayer", # Windows; requires pywin32 -- should be working well on Windows 362 | "ffplay", # Multiplatform; requires ffmpeg 363 | "appkit", # macOS; requires PyObjC dependency 364 | "afplay", # macOS; should be installed on every macOS 365 | "winmm", # Windows; should be installed on every Windows, but is quirky with variable bitrate MP3s 366 | "alsa", # Linux; only supports .mp3 and .wav and might not be installed 367 | ] 368 | 369 | _BACKEND_MAP: dict[str, SoundBackend] = { 370 | name.lower(): obj() 371 | for name, obj in globals().items() 372 | if isinstance(obj, type) and issubclass(obj, SoundBackend) and obj is not SoundBackend 373 | } 374 | 375 | assert sorted(_BACKEND_PREFERENCE) == sorted(_BACKEND_MAP.keys()), "forgot to update _BACKEND_PREFERENCE?" 376 | AVAILABLE_BACKENDS: list[str] = [name for name in _BACKEND_PREFERENCE if _BACKEND_MAP[name].check()] 377 | DEFAULT_BACKEND: str | None = _auto_select_backend() 378 | 379 | 380 | # This function is defined here at the bottom because of: 381 | # SyntaxError: annotated name 'DEFAULT_BACKEND' can't be global 382 | def prefer_backends(*backends: str) -> str | None: 383 | """Add backends to the top of the preference list. 384 | 385 | This function overrides the default backend preference. 386 | Backend selected here will be used ONLY if available on the system. 387 | This means this function can be used to update the preference for a 388 | specific platform without breaking the cross-platform functionality. 389 | After updating the preferences, the new default backend is returned. 390 | 391 | Args: 392 | backends: Names of the backends to prefer. 393 | 394 | Returns: 395 | Name of the newly selected default backend. 396 | """ 397 | global DEFAULT_BACKEND, _BACKEND_PREFERENCE 398 | 399 | _BACKEND_PREFERENCE = list(backends) + _BACKEND_PREFERENCE 400 | DEFAULT_BACKEND = _auto_select_backend() 401 | return DEFAULT_BACKEND 402 | --------------------------------------------------------------------------------