├── .deepsource.toml ├── .github └── workflows │ ├── build.yml │ ├── codecov.yml │ ├── publish_docs.yml │ └── publish_pypi.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── CICD.md ├── INSTALL_PIPEWIRE.md ├── KNOWN_ISSUES.md ├── NEW_RELEASE.md ├── ROADMAP.md ├── UNINSTALL_PIPEWIRE.md ├── beers.wav ├── html │ ├── index.html │ ├── pipewire_python.html │ ├── pipewire_python │ │ ├── _constants.html │ │ ├── _utils.html │ │ └── controller.html │ └── search.json └── index.html ├── pipewire_python ├── __init__.py ├── _constants.py ├── _utils.py ├── controller.py └── link.py ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt ├── tests ├── __init__.py ├── test_interfaces.py ├── test_links.py ├── test_playback.py ├── test_record.py └── test_targets.py ├── tox.ini └── tutorial.py /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "python" 5 | 6 | [analyzers.meta] 7 | runtime_version = "3.x.x" 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu 12 | strategy: 13 | matrix: 14 | python-version: 15 | - 3.13 16 | - 3.12 17 | - 3.11 18 | - 3.10.9 19 | - 3.9 20 | - 3.8 21 | - 3.7 22 | # env: 23 | # PYTHON_FOR_COVERAGE: "3.9" 24 | steps: 25 | - name: Checkout sources 26 | uses: actions/checkout@v4 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: "${{ matrix.python-version }}" 31 | - name: Install dependencies and run tox (flake8 and more) 32 | run: | 33 | make deps 34 | make tox 35 | 36 | # - name: Upload coverage to Codecov 37 | # uses: codecov/codecov-action@v1 38 | # if: contains(env.PYTHON_FOR_COVERAGE, matrix.python-version) # ONLY ON LATEST 39 | # with: 40 | # token: "${{ secrets.CODECOV_TOKEN }}" 41 | # fail_ci_if_error: true # optional (default = false) 42 | # verbose: true # optional (default = false) 43 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | 3 | on: 4 | push: 5 | branches: [main,dev] 6 | pull_request: 7 | branches: [main,dev] 8 | 9 | jobs: 10 | codecov-job: 11 | runs-on: ubuntu-latest 12 | # strategy: 13 | # matrix: 14 | # python-version: 15 | # - 3.9 16 | # env: 17 | # PYTHON_FOR_COVERAGE: "3.9" 18 | steps: 19 | - name: Checkout sources 20 | uses: actions/checkout@v2 21 | 22 | - name: setup python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: "3.9" 26 | 27 | - name: Install apt-dependencies 28 | run: | 29 | make apt-packages-ubuntu 30 | 31 | - name: Install python-dependencies 32 | run: | 33 | make deps 34 | 35 | - name: Run coverage 36 | run: | 37 | coverage erase 38 | coverage run --include=pipewire_python/* -m pytest -v -ra 39 | coverage report -m 40 | coverage xml 41 | 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v1 44 | # if: "contains(env.USING_COVERAGE, matrix.python-version)" 45 | with: 46 | files: ./coverage.xml 47 | token: "${{ secrets.CODECOV_TOKEN }}" 48 | fail_ci_if_error: true 49 | verbose: true 50 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yml: -------------------------------------------------------------------------------- 1 | name: publish_docs 2 | 3 | on: 4 | # ON RELEASE, the commit is not handled by Github Actions 5 | # release: 6 | # types: [created] 7 | push: 8 | branches: [main] 9 | jobs: 10 | publish-docs: 11 | runs-on: ubuntu 12 | steps: 13 | - name: checkout repo content 14 | uses: actions/checkout@v3 15 | 16 | - name: setup python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.9" 20 | 21 | - name: install deps and run automatic documentation 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install pdoc 25 | 26 | - name: run automatic documentation 27 | run: | 28 | python -m pdoc pipewire_python/ --docformat google --output-directory docs/html/ --edit-url pipewire_python=https://github.com/pablodz/pipewire_python/tree/main/pipewire_python/ 29 | 30 | - uses: stefanzweifel/git-auto-commit-action@v5 31 | with: 32 | # Optional, but recommended 33 | # Defaults to "Apply automatic changes" 34 | commit_message: PDOC-Automatic documentation 35 | 36 | # Optional. Used by `git-commit`. 37 | # See https://git-scm.com/docs/git-commit#_options 38 | commit_options: "--no-verify --signoff --allow-empty" 39 | 40 | # Optional glob pattern of files which should be added to the commit 41 | # Defaults to all (.) 42 | # See the `pathspec`-documentation for git 43 | # - https://git-scm.com/docs/git-add#Documentation/git-add.txt-ltpathspecgt82308203 44 | # - https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspecapathspec 45 | file_pattern: docs/* 46 | 47 | # Optional local file path to the repository 48 | # Defaults to the root of the repository 49 | # repository: . 50 | 51 | # Optional commit user and author settings 52 | # commit_user_name: My GitHub Actions Bot # defaults to "GitHub Actions" 53 | # commit_user_email: my-github-actions-bot@example.org # defaults to "actions@github.com" 54 | # commit_author: Author # defaults to author of the commit that triggered the run 55 | 56 | # Optional tag message 57 | # Action will create and push a new tag to the remote repository and the defined branch 58 | # tagging_message: 'v1.0.0' 59 | 60 | # Optional. Used by `git-status` 61 | # See https://git-scm.com/docs/git-status#_options 62 | # status_options: '--untracked-files=no' 63 | 64 | # Optional. Used by `git-add` 65 | # See https://git-scm.com/docs/git-add#_options 66 | add_options: "-u" 67 | 68 | # Optional. Used by `git-push` 69 | # See https://git-scm.com/docs/git-push#_options 70 | # push_options: '--force' 71 | 72 | # Optional. Disable dirty check and always try to create a commit and push 73 | skip_dirty_check: true 74 | 75 | # Optional. Skip internal call to `git fetch` 76 | skip_fetch: true 77 | 78 | # Optional. Prevents the shell from expanding filenames. 79 | # Details: https://www.gnu.org/software/bash/manual/html_node/Filename-Expansion.html 80 | disable_globbing: true 81 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | name: publish_pypi 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-pypi: 9 | runs-on: ubuntu 10 | steps: 11 | - name: Checkout sources 12 | uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.9" 17 | - name: Install dependencies 18 | run: | 19 | make deps 20 | - name: Publish to PyPi 21 | env: 22 | FLIT_USERNAME: "${{ secrets.PYPI_USERNAME }}" 23 | FLIT_PASSWORD: "${{ secrets.PYPI_PASSWORD }}" 24 | run: | 25 | make publish 26 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | .vscode/ 141 | 142 | # Audio formats 143 | *.wav 144 | *.ogg 145 | *.mp3 146 | *.3gp 147 | *.aa 148 | *.aac 149 | *.aax 150 | *.act 151 | *.aiff 152 | *.alac 153 | *.amr 154 | *.ape 155 | *.au 156 | *.awb 157 | *.dss 158 | *.dvf 159 | *.flac 160 | *.gsm 161 | *.iklax 162 | *.ivs 163 | *.m4a 164 | *.m4b 165 | *.m4p 166 | *.nmf 167 | *.mpc 168 | *.msv 169 | *.oga 170 | *.mogg 171 | *.opus 172 | *.org 173 | *.ra 174 | *.rm 175 | *.raw 176 | *.rf64 177 | *.sln 178 | *.tta 179 | *.voc 180 | *.vox 181 | *.wma 182 | *.wv 183 | *.webm 184 | *.8svx 185 | *.cda 186 | 187 | !docs/beers.wav -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Repository Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | apt-packages-ubuntu: ## Install packages needed 4 | sudo add-apt-repository ppa:pipewire-debian/pipewire-upstream -y 5 | sudo apt update 6 | sudo apt-get install pipewire -y 7 | 8 | deps: ## Install dependencies 9 | python -m pip install --upgrade pip 10 | python -m pip install black coverage flake8 flit mccabe mypy pylint pytest pytest-cov tox tox-gh-actions 11 | 12 | publish: ## Publish to PyPi 13 | python -m flit publish 14 | 15 | push: ## Push code with tags 16 | git push && git push --tags 17 | 18 | test: ## Run tests [LOCALHOST] 19 | python -m pytest -ra 20 | 21 | tox: ## Run tox 22 | python3 -m tox 23 | ls -la 24 | 25 | help: 26 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PIPEWIRE's Python controller (wrapper) 2 | 3 | [![PyPI Version][pypi-image]][pypi-url] 4 | [![Build Status][build-image]][build-url] 5 | [![publish_docs](https://github.com/pablodz/pipewire_python/actions/workflows/publish_docs.yml/badge.svg)](https://github.com/pablodz/pipewire_python/actions/workflows/publish_docs.yml) 6 | [![publish_pypi](https://github.com/pablodz/pipewire_python/actions/workflows/publish_pypi.yml/badge.svg)](https://github.com/pablodz/pipewire_python/actions/workflows/publish_pypi.yml) 7 | [![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/pipewire_python.svg)][pypiversions-url] 8 | [![codecov](https://codecov.io/gh/pablodz/pipewire_python/branch/main/graph/badge.svg?token=VN6O9QK3ZH)](https://codecov.io/gh/pablodz/pipewire_python) 9 | [![Maintainability](https://api.codeclimate.com/v1/badges/fe82f8353628a4214abd/maintainability)](https://codeclimate.com/github/pablodz/pipewire_python/maintainability) 10 | [![Test Coverage](https://api.codeclimate.com/v1/badges/fe82f8353628a4214abd/test_coverage)](https://codeclimate.com/github/pablodz/pipewire_python/test_coverage) 11 | [![Downloads](https://pepy.tech/badge/pipewire-python)](https://pepy.tech/project/pipewire-python) 12 | 13 | - **ONLY AUDIO BY NOW [PR & FR WELCOME]** 14 | - **STREAMING NOT SUPPORTED BY NOW** 15 | 16 |
17 | 18 | Python controller, player and recorder via pipewire's commands. 19 | 20 | - [Pipewire](https://gitlab.freedesktop.org/pipewire/pipewire) is a project that aims to greatly improve handling of audio and video under Linux. (Better than pulseaudio or jack) 21 | 22 | ## Requirements 23 | 24 | 1. A Pipewire version installed (clean or via Pulseaudio) is needed, to check if you have pipewire installed and running, run this command, if the output is different, you'll need to [install pipewire](./docs/INSTALL_PIPEWIRE.md): 25 | 26 | 1. Pipewire versions supported: 0.3.30, 0.3.32+ 27 | 28 | ```bash 29 | pw-cli info 0 30 | ``` 31 | 32 | ```bash 33 | # Example output 34 | id: 0 35 | permissions: rwxm 36 | type: PipeWire:Interface:Core/3 37 | cookie: 134115873 38 | user-name: "user" 39 | host-name: "user" 40 | version: "0.3.30" # Possibly more actual than this version 41 | name: "pipewire-0" 42 | ... 43 | ``` 44 | 45 | > To uninstall pipewire [click here](./docs/UNINSTALL_PIPEWIRE.md). 46 | 47 | 2. Python 3.7+ 48 | 3. Ubuntu 20.04+ 49 | 50 | ## Install & Tutorial 51 | 52 | ### Install 53 | 54 | ```bash 55 | pip3 install pipewire_python # or pip 56 | ``` 57 | 58 | ### Tutorial 59 | 60 | #### PLAY AND RECORD 61 | 62 | ```python 63 | from pipewire_python.controller import Controller 64 | 65 | # [PLAYBACK]: normal way 66 | audio_controller = Controller() 67 | audio_controller.set_config(rate=384000, 68 | channels=2, 69 | _format='f64', 70 | volume=0.98, 71 | quality=4) 72 | audio_controller.playback(audio_filename='docs/beers.wav') 73 | 74 | # [RECORD]: normal way 75 | audio_controller = Controller() 76 | audio_controller.record(audio_filename='docs/5sec_record.wav', 77 | timeout_seconds=5) 78 | ``` 79 | #### GET INTERFACES 80 | 81 | ```python 82 | from pipewire_python.controller import Controller 83 | 84 | audio_controller = Controller() 85 | # Return all Client Interfaces on Pipewire 86 | audio_controller.get_list_interfaces( 87 | type_interfaces="Client", 88 | filtered_by_type=True, 89 | ) 90 | # Return all interfaces 91 | audio_controller.get_list_interfaces( 92 | filtered_by_type=False, 93 | ) 94 | ``` 95 | 96 | #### LINK PORTS 97 | ### Linking Ports 98 | 99 | ```python 100 | from pipewire_python import link 101 | 102 | inputs = link.list_inputs() 103 | outputs = link.list_outputs() 104 | 105 | # Connect the last output to the last input -- during testing it was found that 106 | # Midi channel is normally listed first, so this avoids that. 107 | source = outputs[-1] 108 | sink = inputs[-1] 109 | source.connect(sink) 110 | 111 | # Fun Fact! You can connect/disconnect in either order! 112 | sink.disconnect(source) # Tada! 113 | 114 | # Default Input/Output links will be made with left-left and right-right 115 | # connections; in other words, a straight stereo connection. 116 | # It's possible to manually cross the lines, however! 117 | source.right.connect(sink.left) 118 | source.left.connect(sink.right) 119 | ``` 120 | 121 | 122 | ## Documentation 123 | 124 | You can check the automatic build documentation [HERE](https://pablodz.github.io/pipewire_python/html/) 125 | 126 | ## Roadmap 127 | 128 | Future implementations, next steps, API implementation and Control over Pipewire directly from python in the [ROADMAP](docs/ROADMAP.md). 129 | 130 | ## Contributions 131 | 132 | PR, FR, and issues are welcome. Changes with PR in `dev` branch please due documentation runs after each commit in `main` branch. Check more [here](docs/NEW_RELEASE.md) 133 | 134 | ## License 135 | 136 | [LICENSE](./LICENSE) 137 | 138 | 139 | 140 | [pypi-image]: https://img.shields.io/pypi/v/pipewire_python 141 | [pypi-url]: https://pypi.org/project/pipewire_python/ 142 | [build-image]: https://github.com/pablodz/pipewire_python/actions/workflows/build.yml/badge.svg 143 | [build-url]: https://github.com/pablodz/pipewire_python/actions/workflows/build.yml 144 | [coverage-image]: https://codecov.io/gh/pablodz/pipewire_python/branch/main/graph/badge.svg 145 | [coverage-url]: https://codecov.io/gh/pablodz/pipewire_python 146 | [quality-image]: https://api.codeclimate.com/v1/badges/3130fa0ba3b7993fbf0a/maintainability 147 | [quality-url]: https://codeclimate.com/github/pablodz/pipewire_python 148 | [pypiversions-url]: https://pypi.python.org/pypi/pipewire_python/ 149 | -------------------------------------------------------------------------------- /docs/CICD.md: -------------------------------------------------------------------------------- 1 | # Continous Integration / Continous Deployment 2 | 3 | Steps to setup: 4 | 5 | 1. SET ENV VARIABLES ON GIT HOST (GITHUB,GITLAB) 6 | 2. SETUP COVERAGE AND MAINTAIBILITY 7 | 3. CHECK WORFLOWS: 8 | >env: 9 | > USING_COVERAGE: "3.10" 10 | 11 | 12 | ## Worflow locally 13 | 14 | https://github.com/nektos/act 15 | 16 | ```bash 17 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 18 | ``` 19 | 20 | ```bash 21 | brew install act 22 | ``` 23 | 24 | ```bash 25 | act push -v -j codecov-job 26 | ``` -------------------------------------------------------------------------------- /docs/INSTALL_PIPEWIRE.md: -------------------------------------------------------------------------------- 1 | # PIPEWIRE INSTALLATION 2 | 3 | ## Ubuntu +20.04 4 | 5 | 6 | 1. Install pipewire via PPA:
`sudo add-apt-repository ppa:pipewire-debian/pipewire-upstream`
`sudo apt install pipewire` 7 | 8 | https://github.com/pipewire-debian/pipewire-debian 9 | 10 | 11 | 2. Reboot your computer or laptop 12 | 3. [OPTIONAL] Install `pavucontrol` without pulseaudio:
`sudo apt-get install --no-install-recommends pavucontrol` 13 | 4. Reboot and change configs in `pavucontrol` to `Pro Audio` 14 | 15 | ![](https://imgur.com/514XIgR.png) 16 | -------------------------------------------------------------------------------- /docs/KNOWN_ISSUES.md: -------------------------------------------------------------------------------- 1 | # KNOW ISSUES (Ubuntu 20.04+) 2 | 3 | - Multiple "pw-cat" appears on my volume control with `pavucontrol`: 4 | 5 | 1. Kill all tasks with pw-cat:
6 | ```bash 7 | kill $(pidof pw-play) 8 | kill $(pidof pw-cat) 9 | kill $(pidof pw-record) 10 | ``` 11 | 2. All it's ok. 12 | 13 | - Nothing appears on `pavucontrol (without pulseaudio)`: 14 | 15 | 1. Close `pavucontrol` 16 | 17 | 2. Restart pipewire via `systemctl`:
18 | ```bash 19 | #!/bin/bash 20 | systemctl --user restart pipewire.service 21 | ``` 22 | 23 | 3. Open `pavucontrol` -------------------------------------------------------------------------------- /docs/NEW_RELEASE.md: -------------------------------------------------------------------------------- 1 | # NEW RELEASES 2 | 3 | There are steps to consider when releasing a new version of this software: 4 | 5 | 1. All executes on main branch 6 | 2. Documentation builds on `main` branch. 7 | 3. Publish to Pypi happens when a new release is `created` 8 | -------------------------------------------------------------------------------- /docs/ROADMAP.md: -------------------------------------------------------------------------------- 1 | # ROADMAP 2 | 3 | Things expected to implement: 4 | 5 | Controller: 6 | - [ ] Async and multiprocess support. 7 | - [ ] Chunked data processing. 8 | - [ ] Real time processing. 9 | - [ ] Real time convert from wav to other encodings formats. 10 | - [ ] Multiple players independently 11 | 12 | 13 | Pipewire's API implementation: 14 | 15 | - [x] Play `pw-play` 16 | - [x] Record `pw-record` 17 | - [ ] Cat `pw-cat` 18 | - [ ] JACK-servers `pw-jack` 19 | - [ ] pipewire command line. 20 | - [ ] control over multiple pipewire cat running. 21 | - [ ] `pw-mon` dumps and monitors the state of the PipeWire daemon 22 | - [ ] `pw-dot` can dump a graph of the pipeline, check out the help for how to do this. 23 | - [ ] `pw-top` monitors the real-time status of the graph. This is handy to find out what clients are running and how much DSP resources they use. 24 | - [ ] `pw-dump` dumps the state of the PipeWire daemon in JSON format. This can be used to find out the properties and parameters of the objects in the PipeWire daemon. 25 | - [x] `pw-cli ls Device` to list devices 26 | - [ ] `pw-metadata` get metadata 27 | - [ ] `pw-cli cn adapter factory.name=audiotestsrc media.class="Audio/Source" object.linger=1 node.name=my-null-source node.description="My null source"` Null sources can be created with the native API 28 | - [ ] `pw-cli cn adapter factory.name=audiotestsrc media.class="Stream/Output/Audio" object.linger=1 node.name=my-sine-stream node.description="My sine stream" node.autoconnect=1` You can create a sine stream 29 | - [ ] `pw-cli cn adapter factory.name=audiotestsrc media.class="Audio/Source" object.linger=1 node.name=my-sine-source node.description="My sine source"` Or a sine source 30 | - [ ] `pw-cli s Profile '{ index: , save: true }'` # set new default 31 | - [ ] `pw-cli e Profile` You can query the current profile with 32 | - [x] `pw-cli ls Node` 33 | - [ ] `pw-metadata 0 default.configured.audio.sink '{ "name": "" }'` You can change the configured defaults with 34 | 35 | External aps 36 | 37 | - [x] Easyeffects 38 | 39 | ## Availability 40 | 41 | - [X] PYPI package releases 42 | - [X] CI/CD implementation 43 | 44 | 45 | Graphic User Intefrace: 46 | - [ ] GUI similar to `pavucontrol`, maybe `pwcontrol`. 47 | 48 | 49 | > All APIS [here](https://docs.pipewire.org/page_api.html) 50 | 51 | > More info [here](https://gitlab.freedesktop.org/pipewire/pipewire/-/tree/master) 52 | -------------------------------------------------------------------------------- /docs/UNINSTALL_PIPEWIRE.md: -------------------------------------------------------------------------------- 1 | # UNINSTALL PIPEWIRE 2 | 3 | https://github.com/pipewire-debian/pipewire-debian 4 | 5 | ## Ubuntu 20.04+ 6 | 7 | ```bash 8 | sudo apt install ppa-purge && sudo ppa-purge ppa:pipewire-debian/pipewire-upstream 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/beers.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablodz/pipewire_python/6941921042e54dc540766cd1edfe96baf539d0ef/docs/beers.wav -------------------------------------------------------------------------------- /docs/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/html/pipewire_python.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pipewire_python API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 51 |
52 |
53 | Edit on GitHub 54 |

55 | pipewire_python

56 | 57 |

Description

58 | 59 |

PIPEWIRE provides a low-latency, graph based processing engine 60 | on top of audio and video devices that can be used to 61 | support the use cases currently handled by both pulseaudio 62 | and JACK. PipeWire was designed with a powerful security model 63 | that makes interacting with audio and video devices from 64 | containerized applications easy, with supporting Flatpak 65 | applications being the primary goal. Alongside Wayland and 66 | Flatpak we expect PipeWire to provide a core building block 67 | for the future of Linux application development.

68 | 69 |

pipewire_python 70 | controlls pipewire via terminal, creating shell commands 71 | and executing them as required.

72 | 73 |

🎹 There are two ways to manage the python package:

74 | 75 |
    76 |
  1. NO_ASYNC: this way works as expected with delay time between 77 | pipewire_python and the rest of your code.

  2. 78 |
  3. ASYNC: [⚠️Not yet implemented] this way works delegating 79 | the task to record or to play 80 | a song file in background. Works with threads.

  4. 81 |
  5. MULTIPROCESS: [⚠️Not yet implemented] Works with processes.

  6. 82 |
83 | 84 |

📄 More information about pipewire and it's API's:

85 | 86 | 91 | 92 |

Developed with ❤️ by Pablo Diaz

93 | 94 |

Install via

95 | 96 |
97 |
pip3 install pipewire_python # or pip
 98 | 
99 |
100 | 101 |

Tutorial

102 | 103 |
104 |
from pipewire_python.controller import Controller
105 | 
106 | # PLAYBACK: normal way
107 | audio_controller = Controller()
108 | audio_controller.set_config(rate=384000,
109 |                             channels=2,
110 |                             _format='f64',
111 |                             volume=0.98,
112 |                             quality=4,
113 |                             # Debug
114 |                             verbose=True)
115 | audio_controller.playback(audio_filename='docs/beers.wav')
116 | 
117 | # RECORD: normal way
118 | audio_controller = Controller(verbose=True)
119 | audio_controller.record(audio_filename='docs/5sec_record.wav',
120 |                         timeout_seconds=5,
121 |                         # Debug
122 |                         verbose=True)
123 | 
124 |
125 | 126 |

Linking Ports

127 | 128 |
129 |
from pipewire_python import link
130 | 
131 | inputs = link.list_inputs()
132 | outputs = link.list_outputs()
133 | 
134 | # Connect the last output to the last input -- during testing it was found that
135 | # Midi channel is normally listed first, so this avoids that.
136 | source = outputs[-1]
137 | sink = inputs[-1]
138 | source.connect(sink)
139 | 
140 | # Fun Fact! You can connect/disconnect in either order!
141 | sink.disconnect(source) # Tada!
142 | 
143 | # Default Input/Output links will be made with left-left and right-right
144 | # connections; in other words, a straight stereo connection.
145 | # It's possible to manually cross the lines, however!
146 | source.right.connect(sink.left)
147 | source.left.connect(sink.right)
148 | 
149 |
150 |
151 | 152 | 153 | 154 | 155 | 156 |
  1"""
157 |   2## Description
158 |   3
159 |   4[PIPEWIRE](https://pipewire.org/) provides a low-latency, graph based processing engine
160 |   5on top of audio and video devices that can be used to
161 |   6support the use cases currently handled by both pulseaudio
162 |   7and JACK. PipeWire was designed with a powerful security model
163 |   8that makes interacting with audio and video devices from 
164 |   9containerized applications easy, with supporting Flatpak
165 |  10applications being the primary goal. Alongside Wayland and
166 |  11Flatpak we expect PipeWire to provide a core building block 
167 |  12for the future of Linux application development.
168 |  13
169 |  14[pipewire_python](https://pypi.org/project/pipewire_python/) 
170 |  15controlls `pipewire` via terminal, creating shell commands 
171 |  16and executing them as required.
172 |  17
173 |  18🎹 There are two ways to manage the python package:
174 |  19
175 |  201. NO_ASYNC: this way works as expected with delay time between 
176 |  21`pipewire_python` and the rest of your code.
177 |  22
178 |  232. ASYNC: [⚠️Not yet implemented] this way works delegating
179 |  24the task to record or to play
180 |  25a song file in background. Works with threads.
181 |  26
182 |  273. MULTIPROCESS: [⚠️Not yet implemented] Works with processes.
183 |  28
184 |  29
185 |  30📄 More information about `pipewire` and it's API's:
186 |  31
187 |  32- 🎵 Asyncio https://docs.python.org/3/library/asyncio-subprocess.html
188 |  33- 🎵 Pipewire APIs https://www.linuxfromscratch.org/blfs/view/cvs/multimedia/pipewire.html
189 |  34- 🎵 APIs example https://fedoraproject.org/wiki/QA:Testcase_PipeWire_PipeWire_CLI
190 |  35
191 |  36Developed with ❤️ by Pablo Diaz
192 |  37
193 |  38
194 |  39##  Install via
195 |  40```bash
196 |  41
197 |  42pip3 install pipewire_python # or pip
198 |  43```
199 |  44
200 |  45## Tutorial
201 |  46
202 |  47
203 |  48```python
204 |  49from pipewire_python.controller import Controller
205 |  50
206 |  51# PLAYBACK: normal way
207 |  52audio_controller = Controller()
208 |  53audio_controller.set_config(rate=384000,
209 |  54                            channels=2,
210 |  55                            _format='f64',
211 |  56                            volume=0.98,
212 |  57                            quality=4,
213 |  58                            # Debug
214 |  59                            verbose=True)
215 |  60audio_controller.playback(audio_filename='docs/beers.wav')
216 |  61
217 |  62# RECORD: normal way
218 |  63audio_controller = Controller(verbose=True)
219 |  64audio_controller.record(audio_filename='docs/5sec_record.wav',
220 |  65                        timeout_seconds=5,
221 |  66                        # Debug
222 |  67                        verbose=True)
223 |  68
224 |  69```
225 |  70
226 |  71### Linking Ports
227 |  72
228 |  73```python
229 |  74from pipewire_python import link
230 |  75
231 |  76inputs = link.list_inputs()
232 |  77outputs = link.list_outputs()
233 |  78
234 |  79# Connect the last output to the last input -- during testing it was found that
235 |  80# Midi channel is normally listed first, so this avoids that.
236 |  81source = outputs[-1]
237 |  82sink = inputs[-1]
238 |  83source.connect(sink)
239 |  84
240 |  85# Fun Fact! You can connect/disconnect in either order!
241 |  86sink.disconnect(source) # Tada!
242 |  87
243 |  88# Default Input/Output links will be made with left-left and right-right
244 |  89# connections; in other words, a straight stereo connection.
245 |  90# It's possible to manually cross the lines, however!
246 |  91source.right.connect(sink.left)
247 |  92source.left.connect(sink.right)
248 |  93```
249 |  94"""
250 |  95
251 |  96__version__ = "0.2.3"
252 |  97
253 |  98import sys
254 |  99
255 | 100if sys.platform == "linux":
256 | 101    # from pipewire_python.controller import *
257 | 102    pass
258 | 103else:
259 | 104    raise NotImplementedError("By now, Pipewire only runs on linux.")
260 | 
261 | 262 | 263 |
264 |
265 | 447 | -------------------------------------------------------------------------------- /docs/html/pipewire_python/_constants.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pipewire_python._constants API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 53 |
54 |
55 | Edit on GitHub 56 |

57 | pipewire_python._constants

58 | 59 |

Here we store constant values, don't expect 60 | to see something here in documentation html version.

61 |
62 | 63 | 64 | 65 | 66 | 67 |
 1"""
 68 |  2Here we store constant values, don't expect
 69 |  3to see something here in documentation html version.
 70 |  4"""
 71 |  5
 72 |  6MESSAGES_ERROR = {
 73 |  7    "NotImplementedError": "This function is not yet implemented",
 74 |  8    "ValueError": "The value entered is wrong",
 75 |  9}
 76 | 10
 77 | 11RECOMMENDED_RATES = [
 78 | 12    8000,
 79 | 13    11025,
 80 | 14    16000,
 81 | 15    22050,
 82 | 16    44100,
 83 | 17    48000,
 84 | 18    88200,
 85 | 19    96000,
 86 | 20    176400,
 87 | 21    192000,
 88 | 22    352800,
 89 | 23    384000,
 90 | 24]
 91 | 25
 92 | 26RECOMMENDED_FORMATS = ["u8", "s8", "s16", "s32", "f32", "f64"]
 93 | 
94 | 95 | 96 |
97 |
98 |
99 | MESSAGES_ERROR = 100 | 101 | {'NotImplementedError': 'This function is not yet implemented', 'ValueError': 'The value entered is wrong'} 102 | 103 | 104 |
105 | 106 | 107 | 108 | 109 |
110 | 122 | 134 |
135 | 317 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redirecting 4 | 5 | -------------------------------------------------------------------------------- /pipewire_python/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ## Description 3 | 4 | [PIPEWIRE](https://pipewire.org/) provides a low-latency, graph based processing engine 5 | on top of audio and video devices that can be used to 6 | support the use cases currently handled by both pulseaudio 7 | and JACK. PipeWire was designed with a powerful security model 8 | that makes interacting with audio and video devices from 9 | containerized applications easy, with supporting Flatpak 10 | applications being the primary goal. Alongside Wayland and 11 | Flatpak we expect PipeWire to provide a core building block 12 | for the future of Linux application development. 13 | 14 | [pipewire_python](https://pypi.org/project/pipewire_python/) 15 | controlls `pipewire` via terminal, creating shell commands 16 | and executing them as required. 17 | 18 | 🎹 There are two ways to manage the python package: 19 | 20 | 1. NO_ASYNC: this way works as expected with delay time between 21 | `pipewire_python` and the rest of your code. 22 | 23 | 2. ASYNC: [⚠️Not yet implemented] this way works delegating 24 | the task to record or to play 25 | a song file in background. Works with threads. 26 | 27 | 3. MULTIPROCESS: [⚠️Not yet implemented] Works with processes. 28 | 29 | 30 | 📄 More information about `pipewire` and it's API's: 31 | 32 | - 🎵 Asyncio https://docs.python.org/3/library/asyncio-subprocess.html 33 | - 🎵 Pipewire APIs https://www.linuxfromscratch.org/blfs/view/cvs/multimedia/pipewire.html 34 | - 🎵 APIs example https://fedoraproject.org/wiki/QA:Testcase_PipeWire_PipeWire_CLI 35 | 36 | Developed with ❤️ by Pablo Diaz 37 | 38 | 39 | ## Install via 40 | ```bash 41 | 42 | pip3 install pipewire_python # or pip 43 | ``` 44 | 45 | ## Tutorial 46 | 47 | 48 | ```python 49 | from pipewire_python.controller import Controller 50 | 51 | # PLAYBACK: normal way 52 | audio_controller = Controller() 53 | audio_controller.set_config(rate=384000, 54 | channels=2, 55 | _format='f64', 56 | volume=0.98, 57 | quality=4, 58 | # Debug 59 | verbose=True) 60 | audio_controller.playback(audio_filename='docs/beers.wav') 61 | 62 | # RECORD: normal way 63 | audio_controller = Controller(verbose=True) 64 | audio_controller.record(audio_filename='docs/5sec_record.wav', 65 | timeout_seconds=5, 66 | # Debug 67 | verbose=True) 68 | 69 | ``` 70 | 71 | ### Linking Ports 72 | 73 | ```python 74 | from pipewire_python import link 75 | 76 | inputs = link.list_inputs() 77 | outputs = link.list_outputs() 78 | 79 | # Connect the last output to the last input -- during testing it was found that 80 | # Midi channel is normally listed first, so this avoids that. 81 | source = outputs[-1] 82 | sink = inputs[-1] 83 | source.connect(sink) 84 | 85 | # Fun Fact! You can connect/disconnect in either order! 86 | sink.disconnect(source) # Tada! 87 | 88 | # Default Input/Output links will be made with left-left and right-right 89 | # connections; in other words, a straight stereo connection. 90 | # It's possible to manually cross the lines, however! 91 | source.right.connect(sink.left) 92 | source.left.connect(sink.right) 93 | ``` 94 | """ 95 | 96 | __version__ = "0.2.3" 97 | 98 | import sys 99 | 100 | if sys.platform == "linux": 101 | # from pipewire_python.controller import * 102 | pass 103 | else: 104 | raise NotImplementedError("By now, Pipewire only runs on linux.") 105 | -------------------------------------------------------------------------------- /pipewire_python/_constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Here we store constant values, don't expect 3 | to see something here in documentation html version. 4 | """ 5 | 6 | MESSAGES_ERROR = { 7 | "NotImplementedError": "This function is not yet implemented", 8 | "ValueError": "The value entered is wrong", 9 | } 10 | 11 | RECOMMENDED_RATES = [ 12 | 8000, 13 | 11025, 14 | 16000, 15 | 22050, 16 | 44100, 17 | 48000, 18 | 88200, 19 | 96000, 20 | 176400, 21 | 192000, 22 | 352800, 23 | 384000, 24 | ] 25 | 26 | RECOMMENDED_FORMATS = ["u8", "s8", "s16", "s32", "f32", "f64"] 27 | -------------------------------------------------------------------------------- /pipewire_python/_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Here we store internal functions, don't expect 3 | to see something here in documentation html version. 4 | """ 5 | import asyncio 6 | import re 7 | import subprocess 8 | from typing import Dict, List 9 | 10 | # Loading constants Constants.py 11 | from pipewire_python._constants import MESSAGES_ERROR 12 | 13 | 14 | def _print_std( 15 | stdout: bytes, 16 | stderr: bytes, 17 | # Debug 18 | verbose: bool = False, 19 | ): 20 | """ 21 | Print terminal output if are different to None and verbose activated 22 | """ 23 | 24 | if stdout is not None and verbose: 25 | print(f"[_print_std][stdout][type={type(stdout)}]\n{stdout.decode()}") 26 | if stderr is not None and verbose: 27 | print(f"[_print_std][stderr][type={type(stderr)}]\n{stderr.decode()}") 28 | 29 | 30 | def _get_dict_from_stdout( 31 | stdout: str, 32 | # Debug 33 | verbose: bool = False, 34 | ): 35 | """ 36 | Converts shell output (str) to dictionary looking for 37 | "default" and "--" values 38 | """ 39 | 40 | rows = stdout.split("\n") 41 | config_dict = {} 42 | for row in rows: 43 | if "default" in row: 44 | key = "--" + row.split("--")[1].split(" ")[0] 45 | value = row.split("default ")[1].replace(")", "") 46 | config_dict[key] = value 47 | if verbose: 48 | print(config_dict) 49 | return config_dict 50 | 51 | 52 | def _update_dict_by_dict( 53 | main_dict: Dict, 54 | secondary_dict: Dict, 55 | ): 56 | """ 57 | Update values of one dictionary with values of another dictionary 58 | based on keys 59 | """ 60 | return main_dict.update( 61 | ([(key, secondary_dict[key]) for key in secondary_dict.keys()]) 62 | ) 63 | 64 | 65 | def _drop_keys_with_none_values(main_dict: dict): 66 | """ 67 | Drop keys with None values to parse safe dictionary config 68 | """ 69 | return {k: v for k, v in main_dict.items() if v is not None} 70 | 71 | 72 | def _generate_command_by_dict( 73 | mydict: Dict, 74 | # Debug 75 | verbose: bool = False, 76 | ): 77 | """ 78 | Generate an array based on dictionary with keys and values 79 | """ 80 | array_command = [] 81 | # append to a list 82 | for key, value in mydict.items(): 83 | array_command.extend([key, value]) 84 | if verbose: 85 | print(array_command) 86 | # return values 87 | return array_command 88 | 89 | 90 | def _execute_shell_command( 91 | command: List[str], 92 | timeout: int = -1, # *default= no limit 93 | # Debug 94 | verbose: bool = False, 95 | ): 96 | """ 97 | Execute command on terminal via subprocess 98 | 99 | Args: 100 | - command (str): command line to execute. Example: 'ls -l' 101 | - timeout (int): (seconds) time to end the terminal process 102 | - verbose (bool): print variables for debug purposes 103 | Return: 104 | - stdout (str): terminal response to the command 105 | - stderr (str): terminal response to the command 106 | """ 107 | # Create subprocess 108 | # NO-RESOURCE-ALLOCATING 109 | # terminal_subprocess = subprocess.Popen( 110 | # command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT # Example ['ls ','l'] 111 | # ) 112 | 113 | with subprocess.Popen( 114 | command, 115 | stdout=subprocess.PIPE, 116 | stderr=subprocess.STDOUT, # Example ['ls ','l'] 117 | ) as terminal_subprocess: 118 | # Execute command depending or not in timeout 119 | try: 120 | if timeout == -1: 121 | stdout, stderr = terminal_subprocess.communicate() 122 | else: 123 | stdout, stderr = terminal_subprocess.communicate(timeout=timeout) 124 | except subprocess.TimeoutExpired: # When script finish in time 125 | terminal_subprocess.kill() 126 | stdout, stderr = terminal_subprocess.communicate() 127 | 128 | # Print terminal output 129 | _print_std(stdout, stderr, verbose=verbose) 130 | 131 | # Return terminal output 132 | return stdout, stderr 133 | 134 | 135 | async def _execute_shell_command_async( 136 | command, 137 | timeout: int = -1, 138 | # Debug 139 | verbose: bool = False, 140 | ): 141 | """[ASYNC] Function that execute terminal commands in asyncio way 142 | 143 | Args: 144 | - command (str): command line to execute. Example: 'ls -l' 145 | Return: 146 | - stdout (str): terminal response to the command. 147 | - stderr (str): terminal response to the command. 148 | """ 149 | if timeout == -1: 150 | # No timeout 151 | terminal_process_async = await asyncio.create_subprocess_shell( 152 | command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE 153 | ) 154 | stdout, stderr = await terminal_process_async.communicate() 155 | print( 156 | f"[_execute_shell_command_async]\ 157 | [{command!r} exited with\ 158 | {terminal_process_async.returncode}]" 159 | ) 160 | _print_std(stdout, stderr, verbose=verbose) 161 | 162 | else: 163 | raise NotImplementedError(MESSAGES_ERROR["NotImplementedError"]) 164 | 165 | return stdout, stderr 166 | 167 | 168 | def _generate_dict_list_targets( 169 | longstring: str, # string output of shell 170 | # Debug 171 | verbose: bool = False, 172 | ): 173 | """ 174 | Function that transform long string of list targets 175 | to a `dict` 176 | """ 177 | 178 | regex_id = r"(\d.*):" 179 | regex_desc = r'description="([^"]*)"' 180 | regex_prio = r"prio=(-?\d.*)" 181 | regex_default_node = r"[*]\t(\d\d)" 182 | regex_alsa_node = r"(alsa_[a-zA-Z].*)" 183 | 184 | results_regex_id = re.findall(regex_id, longstring) 185 | results_regex_desc = re.findall(regex_desc, longstring) 186 | results_regex_prio = re.findall(regex_prio, longstring) 187 | results_regex_default_node = re.findall(regex_default_node, longstring) 188 | results_regex_alsa_mode = re.findall(regex_alsa_node, longstring) 189 | 190 | mydict = {} 191 | for idx, _ in enumerate(results_regex_id): 192 | mydict[results_regex_id[idx]] = { 193 | "description": results_regex_desc[idx], 194 | "prior": results_regex_prio[idx], 195 | } 196 | mydict["_list_nodes"] = results_regex_id 197 | mydict["_node_default"] = results_regex_default_node 198 | mydict["_alsa_node"] = results_regex_alsa_mode 199 | 200 | if verbose: 201 | print(mydict) 202 | 203 | return mydict 204 | 205 | 206 | def _generate_dict_interfaces( 207 | longstring: str, # string output of shell 208 | # Debug 209 | verbose: bool = False, 210 | ): 211 | """ 212 | Function that transform long string of list interfaces 213 | to a `dict` 214 | """ 215 | 216 | mydict = {} 217 | text_in_lines = longstring.split("\n") 218 | first_level = "X" 219 | 220 | for line in text_in_lines: 221 | try: 222 | is_interface = True 223 | if "id: " in line: 224 | # when interface starts 225 | regex_id = r"\tid: ([0-9]*)" 226 | results_regex_id = re.findall(regex_id, line) 227 | is_interface = False 228 | 229 | if is_interface: 230 | if "*" in line[:1]: 231 | # delete * on each line at the beginning 232 | line = line[1:] 233 | if "\t\t" in line: 234 | # third level data 235 | data = line.replace("\t\t", "") 236 | data = data.split(" = ") 237 | third_level = str(data[0]) 238 | data_to_place = " ".join(data[1:]).replace('"', "") 239 | 240 | if "properties" not in mydict[first_level]: 241 | mydict[first_level]["properties"] = {} 242 | if third_level not in mydict[first_level]["properties"]: 243 | mydict[first_level]["properties"][third_level] = {} 244 | mydict[first_level]["properties"][third_level] = data_to_place 245 | 246 | elif "\t " in line: 247 | # second level data: params 248 | 249 | data = line.replace("\t ", "").split(" ") 250 | third_level = str(data[0]) 251 | if not isinstance(mydict[first_level]["params"], dict): 252 | mydict[first_level]["params"] = {} 253 | mydict[first_level]["params"][third_level] = { 254 | "spa": data[1], 255 | "permissions": data[2], 256 | } 257 | 258 | elif "\t" in line: 259 | # first level data 260 | data = line.replace("\t", "") 261 | data = data.split(": ") 262 | first_level = str(results_regex_id[0]) 263 | second_level = str(data[0]) 264 | data_to_place = " ".join(data[1:]).replace('"', "") 265 | 266 | # to_dict 267 | if first_level not in mydict: 268 | mydict[str(first_level)] = {} 269 | 270 | mydict[first_level][second_level] = data_to_place 271 | except Exception as e: 272 | print(e) 273 | 274 | if verbose: 275 | print(mydict) 276 | 277 | return mydict 278 | 279 | 280 | def _filter_by_type( 281 | dict_interfaces: dict, # interfaecs dict 282 | type_interfaces: str, # string with type 283 | # Debug 284 | verbose: bool = False, 285 | ): 286 | """ 287 | Function that filters a `dict` by type of interface 288 | """ 289 | 290 | dict_filtered = {} 291 | for key in dict_interfaces: 292 | # Filter 293 | if type_interfaces in dict_interfaces[key]["type"]: 294 | dict_filtered[key] = dict_interfaces[key] 295 | 296 | if verbose: 297 | print(dict_filtered) 298 | 299 | return dict_filtered 300 | -------------------------------------------------------------------------------- /pipewire_python/controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | PIPEWIRE's Python controller (wrapper) 3 | 4 | In the next pages you'll see documentation of each Python component 5 | `controller.py`. 6 | """ 7 | 8 | # import warnings 9 | 10 | # Loading constants Constants.py 11 | from pipewire_python._constants import ( 12 | MESSAGES_ERROR, 13 | RECOMMENDED_FORMATS, 14 | RECOMMENDED_RATES, 15 | ) 16 | 17 | # Loading internal functions 18 | from pipewire_python._utils import ( 19 | _drop_keys_with_none_values, 20 | _execute_shell_command, 21 | _filter_by_type, 22 | _generate_command_by_dict, 23 | _generate_dict_interfaces, 24 | _generate_dict_list_targets, 25 | _get_dict_from_stdout, 26 | ) 27 | 28 | # [DEPRECATED] [FLAKE8] TO_AVOID_F401 PEP8 29 | # [DEPRECATED] https://stackoverflow.com/a/31079085/10491422 30 | # NOW USED IN DOCUMENTATION 31 | # __all__ = [ 32 | # # Classes and fucntions to doc 33 | # 'Controller', 34 | # # [DEPRECATED] Unused files pylint 35 | # # "_print_std", 36 | # # "_get_dict_from_stdout", 37 | # # "_update_dict_by_dict", 38 | # # "_drop_keys_with_none_values", 39 | # # "_generate_command_by_dict", 40 | # # "_execute_shell_command", 41 | # ] 42 | 43 | 44 | class Controller: 45 | """ 46 | Class that controls pipewire command line interface 47 | with shell commands, handling outputs, loading default 48 | configs and more. 49 | """ 50 | 51 | _pipewire_cli = { # Help 52 | "--help": "--help", # -h 53 | "--version": "--version", 54 | "--remote": None, # -r 55 | } 56 | 57 | _pipewire_modes = { # Modes 58 | "--playback": None, # -p 59 | "--record": None, # -r 60 | "--midi": None, # -m 61 | } 62 | 63 | _pipewire_list_targets = { # "--list-targets": None, 64 | "list_playback": None, 65 | "list_record": None, 66 | } 67 | 68 | _pipewire_configs = { # Configs 69 | "--media-type": None, # *default=Audio 70 | "--media-category": None, # *default=Playback 71 | "--media-role": None, # *default=Music 72 | "--target": None, # *default=auto 73 | "--latency": None, # *default=100ms (SOURCE FILE if not specified) 74 | "--rate": None, # *default=48000 75 | "--channels": None, # [1,2] *default=2 76 | "--channel-map": None, # ["stereo", "surround-51", "FL,FR"...] *default="FL,FR" 77 | "--format": None, # [u8|s8|s16|s32|f32|f64] *default=s16 78 | "--volume": None, # [0.0,1.0] *default=1.000 79 | "--quality": None, # -q # [0,15] *default=4 80 | "--verbose": None, # -v 81 | } 82 | 83 | _kill_pipewire = { 84 | "all": ["kill", "$(pidof pw-cat)"], 85 | "playback": ["kill", "$(pidof pw-play)"], 86 | "record": ["kill", "$(pidof pw-record)"], 87 | } 88 | 89 | def __init__( 90 | self, 91 | # Debug 92 | verbose: bool = False, 93 | ): 94 | """This constructor load default configs from OS executing 95 | the following pipewire command 96 | 97 | ```bash 98 | #!/bin/bash 99 | # Get defaults from output of: 100 | pw-cat -h 101 | ``` 102 | """ 103 | # LOAD ALL DEFAULT PARAMETERS 104 | 105 | mycommand = ["pw-cat", "-h"] 106 | 107 | # get default parameters with help 108 | stdout, _ = _execute_shell_command(command=mycommand, verbose=verbose) # stderr 109 | # convert stdout to dictionary 110 | dict_default_values = _get_dict_from_stdout( 111 | stdout=str(stdout.decode()), verbose=verbose 112 | ) 113 | 114 | if verbose: 115 | print(self._pipewire_configs) 116 | 117 | # Save default system configs to our json 118 | self._pipewire_configs.update( 119 | ([(key, dict_default_values[key]) for key in dict_default_values]) 120 | ) 121 | 122 | if verbose: 123 | print(self._pipewire_configs) 124 | 125 | # Delete keys with None values 126 | self._pipewire_configs = _drop_keys_with_none_values(self._pipewire_configs) 127 | 128 | if verbose: 129 | print(self._pipewire_configs) 130 | 131 | # Load values of list targets 132 | self.load_list_targets(mode="playback", verbose=verbose) 133 | self.load_list_targets(mode="record", verbose=verbose) 134 | 135 | def _help_cli( 136 | self, 137 | # Debug 138 | verbose: bool = True, 139 | ): 140 | """Get pipewire command line help""" 141 | 142 | mycommand = ["pipewire", self._pipewire_cli["--help"]] 143 | 144 | stdout, _ = _execute_shell_command(command=mycommand, verbose=verbose) # stderr 145 | 146 | return stdout 147 | 148 | def get_version( 149 | self, 150 | # Debug 151 | verbose: bool = False, 152 | ): 153 | """Get version of pipewire installed on OS by executing the following 154 | code: 155 | 156 | ```bash 157 | #!/bin/bash 158 | pw-cli --version 159 | ``` 160 | 161 | Args: 162 | verbose (bool) : True enable debug logs. *default=False 163 | 164 | Returns: 165 | - versions (list) : Versions of pipewire compiled 166 | """ 167 | 168 | mycommand = ["pw-cli", "--version"] 169 | 170 | if verbose: 171 | print(f"[mycommand]{mycommand}") 172 | 173 | stdout, _ = _execute_shell_command( 174 | command=mycommand, timeout=-1, verbose=verbose 175 | ) 176 | versions = stdout.decode().split("\n")[1:] 177 | 178 | self._pipewire_cli["--version"] = versions 179 | 180 | return versions 181 | 182 | def verbose( 183 | self, 184 | status: bool = True, 185 | ): 186 | """Get full log of pipewire stream status with the command `pw-cat` 187 | 188 | An example of pw-cli usage is the code below: 189 | 190 | ```bash 191 | #!/bin/bash 192 | # For example 193 | pw-cat --playback beers.wav --verbose 194 | ``` 195 | 196 | that will generate an output like this: 197 | 198 | ```bash 199 | opened file "beers.wav" format 00010002 channels:2 rate:44100 200 | using default channel map: FL,FR 201 | rate=44100 channels=2 fmt=s16 samplesize=2 stride=4 latency=4410 (0.100s) 202 | connecting playback stream; target_id=4294967295 203 | stream state changed unconnected -> connecting 204 | stream param change: id=2 205 | stream properties: 206 | media.type = "Audio" 207 | ... 208 | now=0 rate=0/0 ticks=0 delay=0 queued=0 209 | remote 0 is named "pipewire-0" 210 | core done 211 | stream state changed connecting -> paused 212 | stream param change: id=2 213 | ... 214 | stream param change: id=15 215 | stream param change: id=15 216 | now=13465394419270 rate=1/48000 ticks=35840 delay=512 queued=0 217 | now=13466525228363 rate=1/48000 ticks=90112 delay=512 queued=0 218 | ... 219 | stream drained 220 | stream state changed streaming -> paused 221 | stream param change: id=4 222 | stream state changed paused -> unconnected 223 | stream param change: id=4 224 | ``` 225 | """ 226 | 227 | if status: 228 | self._pipewire_configs["--verbose"] = " " 229 | else: 230 | pass 231 | 232 | def get_config(self): 233 | """Return config dictionary with default or setup variables, remember that 234 | this object changes only on python-side. Is not updated on real time, 235 | For real-time, please create and destroy the class. 236 | 237 | Args: 238 | Nothing 239 | 240 | Returns: 241 | - _pipewire_configs (`dict`) : dictionary with config values 242 | 243 | """ 244 | 245 | return self._pipewire_configs 246 | 247 | def set_config( 248 | self, 249 | # configs 250 | media_type=None, 251 | media_category=None, 252 | media_role=None, 253 | target=None, 254 | latency=None, 255 | rate=None, 256 | channels=None, 257 | channels_map=None, 258 | _format=None, 259 | volume=None, 260 | quality=None, 261 | # Debug 262 | verbose=False, 263 | ): 264 | """Method that get args as variables and set them 265 | to the `json` parameter of the class `_pipewire_configs`, 266 | then you can use in other method, such as `playback(...)` or 267 | `record(...)`. This method verifies values to avoid wrong 268 | settings. 269 | 270 | Args: 271 | media_type : Set media type 272 | media_category : Set media category 273 | media_role : Set media role 274 | target : Set node target 275 | latency : Set node latency *example=100ms 276 | rate : Set sample rate [8000,11025,16000,22050,44100,48000,88200,96000,176400,192000,352800,384000] 277 | channels : Numbers of channels [1,2] 278 | channels_map : ["stereo", "surround-51", "FL,FR", ...] 279 | _format : ["u8", "s8", "s16", "s32", "f32", "f64"] 280 | volume : Stream volume [0.000, 1.000] 281 | quality : Resampler quality [0, 15] 282 | verbose (`bool`): True enable debug logs. *default=False 283 | 284 | Returns: 285 | - Nothing 286 | 287 | More: 288 | Check all links listed at the beginning of this page 289 | """ # 1 - media_type 290 | if media_type: 291 | self._pipewire_configs["--media-type"] = str(media_type) 292 | elif media_type is None: 293 | pass 294 | else: 295 | raise ValueError( 296 | f"{MESSAGES_ERROR['ValueError']}[media_type='{media_type}'] EMPTY VALUE" 297 | ) 298 | # 2 - media_category 299 | if media_category: 300 | self._pipewire_configs["--media-category"] = str(media_category) 301 | elif media_category is None: 302 | pass 303 | else: 304 | raise ValueError( 305 | f"{MESSAGES_ERROR['ValueError']}[media_category='{media_category}'] EMPTY VALUE" 306 | ) 307 | # 3 - media_role 308 | if media_role: 309 | self._pipewire_configs["--media-role"] = str(media_role) 310 | elif media_role is None: 311 | pass 312 | else: 313 | raise ValueError( 314 | f"{MESSAGES_ERROR['ValueError']}[media_role='{media_role}'] EMPTY VALUE" 315 | ) 316 | # 4 - target 317 | if target: 318 | self._pipewire_configs["--target"] = str(target) 319 | elif target is None: 320 | pass 321 | else: 322 | raise ValueError( 323 | f"{MESSAGES_ERROR['ValueError']}[target='{target}'] EMPTY VALUE" 324 | ) 325 | # 5 - latency 326 | if latency: 327 | if any(chr.isdigit() for chr in latency): # Contain numbers 328 | self._pipewire_configs["--latency"] = str(latency) 329 | else: 330 | raise ValueError( 331 | f"{MESSAGES_ERROR['ValueError']}[latency='{latency}'] NO NUMBER IN VARIABLE" 332 | ) 333 | elif latency is None: 334 | pass 335 | else: 336 | raise ValueError( 337 | f"{MESSAGES_ERROR['ValueError']}[latency='{latency}'] EMPTY VALUE" 338 | ) 339 | # 6 - rate 340 | if rate: 341 | if rate in RECOMMENDED_RATES: 342 | self._pipewire_configs["--rate"] = str(rate) 343 | else: 344 | raise ValueError( 345 | f"{MESSAGES_ERROR['ValueError']}[rate='{rate}']\ 346 | VALUE NOT IN RECOMMENDED LIST \n{RECOMMENDED_RATES}" 347 | ) 348 | elif rate is None: 349 | pass 350 | else: 351 | raise ValueError( 352 | f"{MESSAGES_ERROR['ValueError']}[rate='{rate}'] EMPTY VALUE" 353 | ) 354 | # 7 - channels 355 | if channels: 356 | if channels in [1, 2]: # values 357 | self._pipewire_configs["--channels"] = str(channels) 358 | else: 359 | raise ValueError( 360 | f"{MESSAGES_ERROR['ValueError']}[channels='{channels}']\ 361 | WRONG VALUE\n ONLY 1 or 2." 362 | ) 363 | elif channels is None: 364 | pass 365 | else: 366 | raise ValueError( 367 | f"{MESSAGES_ERROR['ValueError']}[channels='{channels}'] EMPTY VALUE" 368 | ) 369 | # 8 - channels-map 370 | if channels_map: 371 | self._pipewire_configs["--channels-map"] = str(channels_map) 372 | elif channels_map is None: 373 | pass 374 | else: 375 | raise ValueError( 376 | f"{MESSAGES_ERROR['ValueError']}[channels_map='{channels_map}'] EMPTY VALUE" 377 | ) 378 | # 9 - format 379 | if _format: 380 | if _format in RECOMMENDED_FORMATS: 381 | self._pipewire_configs["--format"] = str(_format) 382 | else: 383 | raise ValueError( 384 | f"{MESSAGES_ERROR['ValueError']}[_format='{_format}']\ 385 | VALUE NOT IN RECOMMENDED LIST \n{RECOMMENDED_FORMATS}" 386 | ) 387 | elif _format is None: 388 | pass 389 | else: 390 | raise ValueError( 391 | f"{MESSAGES_ERROR['ValueError']}[_format='{_format}'] EMPTY VALUE" 392 | ) 393 | # 10 - volume 394 | if volume: 395 | if 0.0 <= volume <= 1.0: 396 | self._pipewire_configs["--volume"] = str(volume) 397 | else: 398 | raise ValueError( 399 | f"{MESSAGES_ERROR['ValueError']}[volume='{volume}']\ 400 | OUT OF RANGE \n [0.000, 1.000]" 401 | ) 402 | elif volume is None: 403 | pass 404 | else: 405 | raise ValueError( 406 | f"{MESSAGES_ERROR['ValueError']}[volume='{volume}'] EMPTY VALUE" 407 | ) 408 | # 11 - quality 409 | if quality: 410 | if 0 <= quality <= 15: 411 | self._pipewire_configs["--quality"] = str(quality) 412 | else: 413 | raise ValueError( 414 | f"{MESSAGES_ERROR['ValueError']}[quality='{quality}'] OUT OF RANGE \n [0, 15]" 415 | ) 416 | elif quality is None: 417 | pass 418 | else: 419 | raise ValueError( 420 | f"{MESSAGES_ERROR['ValueError']}[volume='{volume}'] EMPTY VALUE" 421 | ) 422 | 423 | # 12 - verbose cli 424 | if verbose: # True 425 | self._pipewire_configs["--verbose"] = " " 426 | else: 427 | pass 428 | 429 | if verbose: 430 | print(self._pipewire_configs) 431 | 432 | def load_list_targets( 433 | self, 434 | mode, # playback or record 435 | # Debug, 436 | verbose: bool = False, 437 | ): 438 | """Returns a list of targets to playback or record. Then you can use 439 | the output to select a device to playback or record. 440 | """ 441 | 442 | if mode == "playback": 443 | mycommand = ["pw-cat", "--playback", "--list-targets"] 444 | stdout, _ = _execute_shell_command( 445 | command=mycommand, timeout=-1, verbose=verbose 446 | ) 447 | self._pipewire_list_targets["list_playback"] = _generate_dict_list_targets( 448 | longstring=stdout.decode(), verbose=verbose 449 | ) 450 | elif mode == "record": 451 | mycommand = ["pw-cat", "--record", "--list-targets"] 452 | stdout, _ = _execute_shell_command( 453 | command=mycommand, timeout=-1, verbose=verbose 454 | ) 455 | self._pipewire_list_targets["list_record"] = _generate_dict_list_targets( 456 | longstring=stdout.decode(), verbose=verbose 457 | ) 458 | else: 459 | raise AttributeError(MESSAGES_ERROR["ValueError"]) 460 | 461 | if verbose: 462 | print(f"[mycommand]{mycommand}") 463 | 464 | def get_list_targets( 465 | self, 466 | # Debug, 467 | verbose: bool = False, 468 | ): 469 | """Returns a list of targets to playback or record. Then you can use 470 | the output to select a device to playback or record. 471 | 472 | Returns: 473 | - `_pipewire_list_targets` 474 | 475 | Examples: 476 | ```python 477 | >>> Controller().get_list_targets() 478 | { 479 | "list_playback": { 480 | "86": { 481 | "description": "Starship/Matisse HD Audio Controller Pro", 482 | "prior": "936" 483 | }, 484 | "_list_nodes": [ 485 | "86" 486 | ], 487 | "_node_default": [ 488 | "86" 489 | ], 490 | "_alsa_node": [ 491 | "alsa_output.pci-0000_0a_00.4.pro-output-0" 492 | ] 493 | }, 494 | "list_record": { 495 | "86": { 496 | "description": "Starship/Matisse HD Audio Controller Pro", 497 | "prior": "936" 498 | }, 499 | "_list_nodes": [ 500 | "86" 501 | ], 502 | "_node_default": [ 503 | "86" 504 | ], 505 | "_alsa_node": [ 506 | "alsa_output.pci-0000_0a_00.4.pro-output-0" 507 | ] 508 | } 509 | } 510 | ``` 511 | """ 512 | if verbose: 513 | print(self._pipewire_list_targets) 514 | return self._pipewire_list_targets 515 | 516 | def get_list_interfaces( 517 | self, 518 | filtered_by_type: str = True, 519 | type_interfaces: str = "Client", 520 | # Debug 521 | verbose: bool = False, 522 | ): 523 | """Returns a list of applications currently using pipewire on Client. 524 | An example of pw-cli usage is the code below: 525 | 526 | ```bash 527 | #!/bin/bash 528 | pw-cli ls Client 529 | ``` 530 | Args: 531 | filtered_by_type : If False, returns all. If not, returns a fitered dict 532 | type_interfaces : Set type of Interface 533 | ["Client","Link","Node","Factory","Module","Metadata","Endpoint", 534 | "Session","Endpoint Stream","EndpointLink","Port"] 535 | 536 | Returns: 537 | - dict_interfaces_filtered: dictionary 538 | with list of interfaces matching conditions 539 | 540 | Examples: 541 | ```python 542 | >>> Controller().get_list_interfaces() 543 | 544 | ``` 545 | """ 546 | mycommand = ["pw-cli", "info", "all"] 547 | 548 | # if verbose: 549 | # print(f"[mycommand]{mycommand}") 550 | 551 | stdout, _ = _execute_shell_command( 552 | command=mycommand, timeout=-1, verbose=verbose 553 | ) 554 | dict_interfaces = _generate_dict_interfaces( 555 | longstring=stdout.decode(), verbose=verbose 556 | ) 557 | 558 | if filtered_by_type: 559 | dict_interfaces_filtered = _filter_by_type( 560 | dict_interfaces=dict_interfaces, type_interfaces=type_interfaces 561 | ) 562 | else: 563 | dict_interfaces_filtered = dict_interfaces 564 | 565 | return dict_interfaces_filtered 566 | 567 | def playback( 568 | self, 569 | audio_filename: str = "myplayback.wav", 570 | # Debug 571 | verbose: bool = False, 572 | ): 573 | """Execute pipewire command to play an audio file with the following 574 | command: 575 | 576 | ```bash 577 | #!/bin/bash 578 | pw-cat --playback {audio_filename} + {configs} 579 | # configs are a concatenated params 580 | ``` 581 | 582 | Args: 583 | audio_filename (`str`): Path of the file to be played. *default='myplayback.wav' 584 | verbose (`bool`): True enable debug logs. *default=False 585 | 586 | Returns: 587 | - stdout (`str`): Shell response to the command in stdout format 588 | - stderr (`str`): Shell response response to the command in stderr format 589 | """ 590 | # warnings.warn("The name of the function may change on future releases", DeprecationWarning) 591 | 592 | mycommand = [ 593 | "pw-cat", 594 | "--playback", 595 | audio_filename, 596 | ] + _generate_command_by_dict(mydict=self._pipewire_configs, verbose=verbose) 597 | 598 | if verbose: 599 | print(f"[mycommand]{mycommand}") 600 | 601 | stdout, stderr = _execute_shell_command( 602 | command=mycommand, timeout=-1, verbose=verbose 603 | ) 604 | return stdout, stderr 605 | 606 | def record( 607 | self, 608 | audio_filename: str = "myplayback.wav", 609 | timeout_seconds=5, 610 | # Debug 611 | verbose: bool = False, 612 | ): 613 | """Execute pipewire command to record an audio file, with a timeout of 5 614 | seconds with the following code and exiting the shell when tiomeout is over. 615 | 616 | ```bash 617 | #!/bin/bash 618 | pw-cat --record {audio_filename} 619 | # timeout is managed by python3 (when signal CTRL+C is sended) 620 | ``` 621 | 622 | Args: 623 | audio_filename (`str`): Path of the file to be played. *default='myplayback.wav' 624 | verbose (`bool`): True enable debug logs. *default=False 625 | 626 | Returns: 627 | - stdout (`str`): Shell response to the command in stdout format 628 | - stderr (`str`): Shell response response to the command in stderr format 629 | """ 630 | # warnings.warn("The name of the function may change on future releases", DeprecationWarning) 631 | 632 | mycommand = ["pw-cat", "--record", audio_filename] + _generate_command_by_dict( 633 | mydict=self._pipewire_configs, verbose=verbose 634 | ) 635 | 636 | if verbose: 637 | print(f"[mycommand]{mycommand}") 638 | 639 | stdout, stderr = _execute_shell_command( 640 | command=mycommand, timeout=timeout_seconds, verbose=verbose 641 | ) 642 | return stdout, stderr 643 | 644 | def clear_devices( 645 | self, 646 | mode: str = "all", # ['all','playback','record'] 647 | # Debug 648 | verbose: bool = False, 649 | ): 650 | """Function to stop process running under pipewire executed by 651 | python controller and with default process name of `pw-cat`, `pw-play` or `pw-record`. 652 | 653 | Args: 654 | mode (`str`) : string to kill process under `pw-cat`, `pw-play` or `pw-record`. 655 | 656 | Returns: 657 | - stdoutdict (`dict`) : a dictionary with keys of `mode`. 658 | 659 | Example with pipewire: 660 | pw-cat process 661 | """ 662 | 663 | mycommand = self._kill_pipewire[mode] 664 | 665 | if verbose: 666 | print(f"[mycommands]{mycommand}") 667 | 668 | stdout, _ = _execute_shell_command(command=mycommand, verbose=verbose) 669 | 670 | return {mode: stdout} 671 | -------------------------------------------------------------------------------- /pipewire_python/link.py: -------------------------------------------------------------------------------- 1 | """ 2 | PIPEWIRE's Python controller (wrapper) 3 | 4 | Pipewire exposes a command-line interface known as `pw-link` to support linking 5 | between outputs (sources) and inputs (sinks). 6 | 7 | ```bash 8 | $> pw-link --help 9 | pw-link : PipeWire port and link manager. 10 | Generic: pw-link [options] 11 | -h, --help Show this help 12 | --version Show version 13 | -r, --remote=NAME Remote daemon name 14 | List: pw-link [options] [out-pattern] [in-pattern] 15 | -o, --output List output ports 16 | -i, --input List input ports 17 | -l, --links List links 18 | -m, --monitor Monitor links and ports 19 | -I, --id List IDs 20 | -v, --verbose Verbose port properties 21 | Connect: pw-link [options] output input 22 | -L, --linger Linger (default, unless -m is used) 23 | -P, --passive Passive link 24 | -p, --props=PROPS Properties as JSON object 25 | Disconnect: pw-link -d [options] output input 26 | pw-link -d [options] link-id 27 | -d, --disconnect Disconnect ports 28 | ``` 29 | 30 | 31 | Examples 32 | -------- 33 | >>> from pipewire_python import link 34 | >>> inputs = link.list_inputs() 35 | >>> outputs = link.list_outputs() 36 | >>> # Connect the last output to the last input -- during testing it was found 37 | >>> # that Midi channel is normally listed first, so this avoids that. 38 | >>> source = outputs[-1] 39 | >>> sink = inputs[-1] 40 | >>> source.connect(sink) 41 | >>> # Fun Fact! You can connect/disconnect in either order! 42 | >>> sink.disconnect(source) # Tada! 43 | >>> # Default Input/Output links will be made with left-left and right-right 44 | >>> # connections; in other words, a straight stereo connection. 45 | >>> # It's possible to manually cross the lines, however! 46 | >>> source.right.connect(sink.left) 47 | >>> source.left.connect(sink.right) 48 | """ 49 | 50 | from dataclasses import dataclass 51 | from enum import Enum 52 | from typing import List, Union 53 | 54 | from pipewire_python._utils import ( 55 | _execute_shell_command, 56 | ) 57 | 58 | __all__ = [ 59 | "PortType", 60 | "Port", 61 | "Input", 62 | "Output", 63 | "StereoInput", 64 | "StereoOutput", 65 | "Link", 66 | "StereoLink", 67 | "list_inputs", 68 | "list_outputs", 69 | "list_links", 70 | ] 71 | 72 | 73 | PW_LINK_COMMAND = "pw-link" 74 | 75 | 76 | class InvalidLink(ValueError): 77 | """Invalid link configuration.""" 78 | 79 | 80 | class FailedToLinkPorts(ValueError): 81 | """Failed to Link the Specified Ports.""" 82 | 83 | 84 | class PortType(Enum): 85 | """Pipewire Channel Type - Input or Output.""" 86 | 87 | INPUT = 1 88 | OUTPUT = 2 89 | 90 | 91 | @dataclass 92 | class Port: 93 | """ 94 | Pipewire Link Port Object. 95 | 96 | Port for an input or output in Pipewire link. This is the basic, structural 97 | component for the Python wrapper of Pipewire-link. Ports may be connected by 98 | links, and Inputs/Outputs consist of one or more of these Port objects 99 | corresponding to left/right channels. 100 | 101 | Attributes 102 | ---------- 103 | id: int 104 | Pipewire connector identifier. 105 | device: str 106 | Pipewire device name. 107 | name: str 108 | Pipewire device connector name, (typically uses FL or FR). 109 | port_type: PortType 110 | Designation of connector as input or output. 111 | is_midi: bool 112 | Indicator to mark that the port is a Midi connection. 113 | """ 114 | 115 | device: str 116 | name: str 117 | id: int 118 | port_type: PortType 119 | is_midi: bool = False 120 | 121 | def _join_arguments(self, other: "Port", message: str) -> List[str]: 122 | """ 123 | Generate a list of arguments to appropriately set output, then input 124 | for the connection/disconnection command. 125 | """ 126 | args = [PW_LINK_COMMAND] 127 | if self.port_type == PortType.INPUT: 128 | if other.port_type == PortType.INPUT: 129 | raise InvalidLink(message.format("input")) 130 | # Valid -- Append the Output (other) First 131 | args.append(":".join((other.device, other.name))) 132 | args.append(":".join((self.device, self.name))) 133 | else: 134 | if other.port_type == PortType.OUTPUT: 135 | raise InvalidLink(message.format("output")) 136 | # Valid -- Append the Output (self) First 137 | args.append(":".join((self.device, self.name))) 138 | args.append(":".join((other.device, other.name))) 139 | return args 140 | 141 | def connect(self, other: "Port") -> None: 142 | """Connect this channel to another channel.""" 143 | args = self._join_arguments( 144 | other=other, message="Cannot connect an {} to another {}." 145 | ) 146 | stdout, _ = _execute_shell_command(args) 147 | if b"failed to link ports" in stdout: 148 | raise FailedToLinkPorts(stdout) 149 | 150 | def disconnect(self, other: "Port") -> None: 151 | """Disconnect this channel from another.""" 152 | args = self._join_arguments( 153 | other=other, message="Cannot disconnect an {} from another {}." 154 | ) 155 | args.append("--disconnect") 156 | _ = _execute_shell_command(args) 157 | 158 | 159 | class Input(Port): 160 | """ 161 | Pipewire Link Input Port Object. 162 | 163 | Input in Pipewire link. Inputs may be composed into left/right channels with 164 | StereoInput objects. 165 | 166 | Attributes 167 | ---------- 168 | id: int 169 | Pipewire connector identifier. 170 | device: str 171 | Pipewire device name. 172 | name: str 173 | Pipewire device connector name, (typically uses FL or FR). 174 | port_type: PortType 175 | Designation of connector as an input. Set to PortType.INPUT 176 | is_midi: bool 177 | Indicator to mark that the port is a Midi connection. 178 | """ 179 | 180 | 181 | class Output(Port): 182 | """ 183 | Pipewire Link Output Port Object. 184 | 185 | Output in Pipewire link. Outputs may be composed into left/right channels 186 | with StereoOutput objects. 187 | 188 | Attributes 189 | ---------- 190 | id: int 191 | Pipewire connector identifier. 192 | device: str 193 | Pipewire device name. 194 | name: str 195 | Pipewire device connector name, (typically uses FL or FR). 196 | port_type: PortType 197 | Designation of connector as output. Set to PortType.OUTPUT 198 | is_midi: bool 199 | Indicator to mark that the port is a Midi connection. 200 | """ 201 | 202 | 203 | @dataclass 204 | class StereoInput: 205 | """ 206 | Stereo (paired) Pipewire Input Object. 207 | 208 | Grouping of left and right channel ports for a Pipewire link input. 209 | 210 | Examples 211 | -------- 212 | >>> from pipewire_python import link 213 | >>> inputs = link.list_inputs() # List the inputs on the system. 214 | >>> # Inputs can also manually built 215 | >>> my_input = link.Input( 216 | ... left = link.Port( 217 | ... id=123, 218 | ... device="alsa.my.device", 219 | ... name="FL", 220 | ... port_type=link.PortType.INPUT 221 | ... ), 222 | ... right = link.Port( 223 | ... id=321, 224 | ... device="alsa.my.device", 225 | ... name="FR", 226 | ... port_type=link.PortType.INPUT 227 | ... ) 228 | ... ) 229 | 230 | Attributes 231 | ---------- 232 | left: Input 233 | Left (or mono) channel port. 234 | right: Input 235 | Right channel port. 236 | """ 237 | 238 | left: Input 239 | right: Input 240 | 241 | @property 242 | def device(self) -> Union[str, None]: 243 | """Determine the Device Associated with this Stereo Input.""" 244 | if self.left.device == self.right.device: 245 | return self.right.device 246 | 247 | def connect(self, other: "StereoOutput") -> Union["StereoLink", "Link", None]: 248 | """Connect this input to an output.""" 249 | connections = [] 250 | if self.left and other.left: 251 | self.left.connect(other.left) 252 | connections.append(Link(input=self.left, output=other.left, id=None)) 253 | if self.right and other.right: 254 | self.right.connect(other.right) 255 | connections.append(Link(input=self.right, output=other.right, id=None)) 256 | if connections: 257 | if len(connections) > 1: 258 | return StereoLink(left=connections[0], right=connections[1]) 259 | return connections 260 | return None 261 | 262 | def disconnect(self, other: Union["StereoOutput", "StereoLink", "Link"]) -> None: 263 | """Disconnect this input from an output.""" 264 | if self.left and other.left: 265 | self.left.disconnect(other.left) 266 | if self.right and other.right: 267 | self.right.disconnect(other.right) 268 | 269 | 270 | @dataclass 271 | class StereoOutput: 272 | """ 273 | Stereo (paired) Pipewire Output Object. 274 | 275 | Grouping of left and right channel ports for a Pipewire link output. 276 | 277 | Examples 278 | -------- 279 | >>> from pipewire_python import link 280 | >>> outputs = link.list_outputs() # List the outputs on the system. 281 | >>> # Outputs can also manually built 282 | >>> my_output = link.Output( 283 | ... left = link.Port( 284 | ... id=123, 285 | ... device="alsa.my.device", 286 | ... name="FL", 287 | ... port_type=link.PortType.OUTPUT 288 | ... ), 289 | ... right = link.Port( 290 | ... id=321, 291 | ... device="alsa.my.device", 292 | ... name="FR", 293 | ... port_type=link.PortType.OUTPUT 294 | ... )from 295 | ... ) 296 | 297 | Attributes 298 | ---------- 299 | left: Output 300 | Left (or mono) channel port. 301 | right: Output 302 | Right channel port. 303 | """ 304 | 305 | left: Output 306 | right: Output 307 | 308 | @property 309 | def device(self) -> Union[str, None]: 310 | """Determine the Device Associated with this Stereo Output.""" 311 | if self.left.device == self.right.device: 312 | return self.right.device 313 | 314 | def connect(self, other: "StereoInput") -> Union["StereoLink", "Link", None]: 315 | """Connect this input to an output.""" 316 | connections = [] 317 | if self.left and other.left: 318 | self.left.connect(other.left) 319 | connections.append(Link(input=other.left, output=self.left, id=None)) 320 | if self.right and other.right: 321 | self.right.connect(other.right) 322 | connections.append(Link(input=other.right, output=self.right, id=None)) 323 | if connections: 324 | if len(connections) > 1: 325 | return StereoLink(left=connections[0], right=connections[1]) 326 | return connections 327 | return None 328 | 329 | def disconnect(self, other: Union["StereoInput", "StereoLink", "Link"]) -> None: 330 | """Disconnect this input from an output.""" 331 | if self.left and other.left: 332 | self.left.disconnect(other.left) 333 | if self.right and other.right: 334 | self.right.disconnect(other.right) 335 | 336 | 337 | @dataclass 338 | class Link: 339 | """ 340 | Pipewire Link Object. 341 | 342 | Configured Pipewire link between an input and output device. 343 | 344 | Attributes 345 | ---------- 346 | id: int 347 | Identifier for Pipewire link. 348 | input: Input 349 | Pipewire port object acting as input connected with link. 350 | output: Output 351 | Pipewire port object acting as output and connected with link. 352 | """ 353 | 354 | id: Union[int, None] 355 | input: Input 356 | output: Output 357 | 358 | def disconnect(self): 359 | """Disconnect the Link.""" 360 | self.input.disconnect(self.output) 361 | 362 | def reconnect(self): 363 | """Reconnect the Link if Previously Disconnected.""" 364 | self.input.connect(self.output) 365 | 366 | 367 | @dataclass 368 | class StereoLink: 369 | """ 370 | Stereo (paired) Pipewire Linked Object. 371 | 372 | Configured Pipewire link between a pair of input and output devices acting 373 | as a stereo pair. 374 | 375 | Attributes 376 | ---------- 377 | left: Link 378 | Pipewire link between output and input for left channel. 379 | right: Link 380 | Pipewire link between output and input for right channel. 381 | """ 382 | 383 | left: Link 384 | right: Link 385 | 386 | @property 387 | def inputs(self) -> StereoInput: 388 | """Provide a StereoInput Object Representing the L/R Input Pair.""" 389 | return StereoInput(left=self.left, right=self.right) 390 | 391 | @property 392 | def outputs(self) -> StereoOutput: 393 | """Provide a StereoInput Object Representing the L/R Output Pair.""" 394 | return StereoOutput(left=self.left, right=self.right) 395 | 396 | def disconnect(self): 397 | """Disconnect the stereo pair of links.""" 398 | self.left.disconnect() 399 | self.right.disconnect() 400 | 401 | def reconnect(self): 402 | """Reconnect the Link Pair if Previously Disconnected.""" 403 | self.left.reconnect() 404 | self.right.reconnect() 405 | 406 | 407 | @dataclass 408 | class LinkGroup: 409 | """ 410 | Grouped Pipewire Link Objects. 411 | 412 | Configured Pipewire link between one or more inputs and one or more outputs, 413 | all associated with the same "channel." 414 | 415 | Attributes 416 | ---------- 417 | common_device: str 418 | Device common to all ports in link group. 419 | common_name: str 420 | Name of the common device. 421 | links: List[Link] 422 | Pipewire link between output and input for associated 423 | channels. 424 | """ 425 | 426 | common_device: str 427 | common_name: str 428 | links: List[Link] 429 | 430 | @property 431 | def inputs(self) -> List[Input]: 432 | """Provide a List of Input Objects Represented in the LinkGroup.""" 433 | return [link.input for link in self.links] 434 | 435 | @property 436 | def outputs(self) -> Output: 437 | """Provide a List of Output Objects Represented in the LinkGroup.""" 438 | return [link.output for link in self.links] 439 | 440 | def disconnect(self): 441 | """Disconnect the stereo pair of links.""" 442 | for link in self.links: 443 | link.disconnect() 444 | 445 | def reconnect(self): 446 | """Reconnect the Link Pair if Previously Disconnected.""" 447 | for link in self.links: 448 | link.disconnect() 449 | 450 | 451 | def _split_id_from_data(command) -> List[List[str]]: 452 | """Helper function to generate a list of channels""" 453 | stdout, _ = _execute_shell_command([PW_LINK_COMMAND, command, "--id"]) 454 | data_sets = [] 455 | for data_response in stdout.decode("utf-8").split("\n"): 456 | ports = data_response.lstrip().split(" ", maxsplit=1) 457 | if len(ports) == 2: 458 | data_sets.append([port.strip(" ") for port in ports]) 459 | return data_sets 460 | 461 | 462 | def list_inputs(pair_stereo: bool = True) -> List[Union[StereoInput, Input]]: 463 | """ 464 | List the Inputs Available on System. 465 | 466 | This will identify the available inputs on the system, and proceed with a 467 | best-effort Input port grouping between left-and-right ports. 468 | 469 | ```bash 470 | #!/bin/bash 471 | # Get inputs from output of: 472 | pw-link --input --id 473 | ``` 474 | 475 | Parameters 476 | ---------- 477 | pair_stereo: bool, optional 478 | Control to opt for pairing output ports into their 479 | corresponding stereo pairs (left/right). 480 | 481 | Returns 482 | ------- 483 | list[StereoInput | Input]: List of the identified inputs or stereo input 484 | pairs. 485 | """ 486 | ports = [] 487 | 488 | inputs = _split_id_from_data("--input") 489 | if len(inputs) == 0: 490 | return ports 491 | 492 | for channel_id, channel_data in _split_id_from_data("--input"): 493 | device, name = channel_data.split(":", maxsplit=1) 494 | ports.append( 495 | Input( 496 | id=int(channel_id), 497 | device=device, 498 | name=name, 499 | port_type=PortType.INPUT, 500 | ) 501 | ) 502 | if not pair_stereo: 503 | return ports 504 | i = 0 505 | num_ports = len(ports) 506 | inputs = [] 507 | # Review the list of ports to Pair Appropriate ports into an Input 508 | while i < num_ports: 509 | i += 1 510 | if i + 1 <= num_ports: 511 | # If this channel device is the same as the next channel's device 512 | if ports[i].device == ports[i - 1].device: 513 | # Identify Left and Right ports 514 | if "FL" in ports[i].name.upper(): 515 | inputs.append(StereoInput(left=ports[i], right=ports[i - 1])) 516 | i += 1 517 | continue 518 | if "FR" in ports[i].name.upper(): 519 | inputs.append(StereoInput(right=ports[i], left=ports[i - 1])) 520 | i += 1 521 | continue 522 | # Use Left-Channel Only if there's no left/right 523 | inputs.append(ports[i - 1]) 524 | return inputs 525 | 526 | 527 | def list_outputs(pair_stereo: bool = True) -> List[Union[StereoOutput, Output]]: 528 | """ 529 | List the Outputs Available on System. 530 | 531 | This will identify the available outputs on the system, and proceed with a 532 | best-effort Output port grouping between left-and-right ports. 533 | 534 | ```bash 535 | #!/bin/bash 536 | # Get outputs from output of: 537 | pw-link --output --id 538 | ``` 539 | 540 | Parameters 541 | ---------- 542 | pair_stereo: bool, optional 543 | Control to opt for pairing output ports into their 544 | corresponding stereo pairs (left/right). 545 | 546 | Returns 547 | ------- 548 | list[StereoOutput | Output]: List of the identified outputs or stereo 549 | output pairs. 550 | """ 551 | ports = [] 552 | for channel_id, channel_data in _split_id_from_data("--output"): 553 | device, name = channel_data.split(":", maxsplit=1) 554 | ports.append( 555 | Output( 556 | id=int(channel_id), 557 | device=device, 558 | name=name, 559 | port_type=PortType.OUTPUT, 560 | ) 561 | ) 562 | if not pair_stereo: 563 | return ports 564 | i = 0 565 | num_ports = len(ports) 566 | outputs = [] 567 | # Review the list of ports to Pair Appropriate ports into an Output 568 | while i < num_ports: 569 | i += 1 570 | if i + 1 <= num_ports: 571 | # If this channel device is the same as the next channel's device 572 | if ports[i].device == ports[i - 1].device: 573 | # Identify Left and Right ports 574 | if "FL" in ports[i].name.upper(): 575 | outputs.append(StereoOutput(left=ports[i], right=ports[i - 1])) 576 | i += 1 577 | continue 578 | if "FR" in ports[i].name.upper(): 579 | outputs.append(StereoOutput(right=ports[i], left=ports[i - 1])) 580 | i += 1 581 | continue 582 | # Use Left-Channel Only if there's no left/right 583 | outputs.append(ports[i - 1]) 584 | return outputs 585 | 586 | 587 | def list_links() -> List[Link]: 588 | """ 589 | List the Links Available on System. 590 | 591 | This will identify the present Pipewire links on the system. 592 | 593 | ```bash 594 | #!/bin/bash 595 | # Get links from output of: 596 | pw-link --links --id 597 | ``` 598 | 599 | Returns 600 | ------- 601 | list[Link]: List of the identified links. 602 | """ 603 | # Parse STDOUT Data for Port Information 604 | link_data_lines = _split_id_from_data("--links") 605 | num_link_lines = len(link_data_lines) 606 | i = 0 607 | links = [] 608 | while i < (num_link_lines - 1): 609 | # Split Side "A" (first) Port Data 610 | side_a_device, side_a_name = link_data_lines[i][1].split(":") 611 | # Determine Direction of Port Link 612 | direction = link_data_lines[i + 1][1].split(" ", maxsplit=1)[0] 613 | side_a_port = Port( 614 | device=side_a_device, 615 | name=side_a_name, 616 | id=int(link_data_lines[i][0]), 617 | port_type=PortType.INPUT if direction == "|<-" else PortType.OUTPUT, 618 | ) 619 | i += 1 620 | while i < num_link_lines: 621 | # Split Side "B" (second) Port Data 622 | side_b_data = link_data_lines[i][1].split(" ", maxsplit=1)[1].strip() 623 | side_b_id, side_b_data = side_b_data.split(" ", maxsplit=1) 624 | side_b_device, side_b_name = side_b_data.split(":") 625 | side_b_port = Port( 626 | device=side_b_device, 627 | name=side_b_name, 628 | id=int(side_b_id), 629 | port_type=PortType.OUTPUT if direction == "|<-" else PortType.INPUT, 630 | ) 631 | if side_a_port.port_type == PortType.INPUT: 632 | links.append( 633 | Link( 634 | input=side_a_port, 635 | output=side_b_port, 636 | id=int(link_data_lines[i][0]), 637 | ) 638 | ) 639 | else: 640 | links.append( 641 | Link( 642 | input=side_b_port, 643 | output=side_a_port, 644 | id=int(link_data_lines[i][0]), 645 | ) 646 | ) 647 | i += 1 648 | if i == num_link_lines: 649 | break 650 | # Determine if Next Line is Associated with this Link 651 | if ( 652 | "|->" not in link_data_lines[i][1] 653 | and "|<-" not in link_data_lines[i][1] 654 | ): 655 | break # Continue to Next Link Group 656 | return links 657 | 658 | 659 | def list_link_groups() -> List[LinkGroup]: 660 | """ 661 | List the Groped Links Available on System. 662 | 663 | This will identify the present Pipewire links on the system, and provide 664 | them as a set of groups keyed by each device name. 665 | 666 | ```bash 667 | #!/bin/bash 668 | # Get links from output of: 669 | pw-link --links --id 670 | ``` 671 | 672 | Returns 673 | ------- 674 | dict[str, Link]: Dictionary of the identified links, keyed by their names. 675 | """ 676 | # Parse STDOUT Data for Port Information 677 | link_data_lines = _split_id_from_data("--links") 678 | num_link_lines = len(link_data_lines) 679 | i = 0 680 | link_groups = [] 681 | while i < (num_link_lines - 1): 682 | # Split Side "A" (first) Port Data 683 | side_a_device, side_a_name = link_data_lines[i][1].split(":") 684 | # Determine Direction of Port Link 685 | direction = link_data_lines[i + 1][1].split(" ", maxsplit=1)[0] 686 | side_a_port = Port( 687 | device=side_a_device, 688 | name=side_a_name, 689 | id=int(link_data_lines[i][0]), 690 | port_type=PortType.INPUT if direction == "|<-" else PortType.OUTPUT, 691 | ) 692 | i += 1 693 | links = [] 694 | while i < num_link_lines: 695 | # Split Side "B" (second) Port Data 696 | side_b_data = link_data_lines[i][1].split(" ", maxsplit=1)[1].strip() 697 | side_b_id, side_b_data = side_b_data.split(" ", maxsplit=1) 698 | side_b_device, side_b_name = side_b_data.split(":") 699 | side_b_port = Port( 700 | device=side_b_device, 701 | name=side_b_name, 702 | id=int(side_b_id), 703 | port_type=PortType.OUTPUT if direction == "|<-" else PortType.INPUT, 704 | ) 705 | if side_a_port.port_type == PortType.INPUT: 706 | links.append( 707 | Link( 708 | input=side_a_port, 709 | output=side_b_port, 710 | id=int(link_data_lines[i][0]), 711 | ) 712 | ) 713 | else: 714 | links.append( 715 | Link( 716 | input=side_b_port, 717 | output=side_a_port, 718 | id=int(link_data_lines[i][0]), 719 | ) 720 | ) 721 | i += 1 722 | if i == num_link_lines: 723 | break 724 | # Determine if Next Line is Associated with this Link 725 | if ( 726 | "|->" not in link_data_lines[i][1] 727 | and "|<-" not in link_data_lines[i][1] 728 | ): 729 | break # Continue to Next Link Group 730 | link_groups.append( 731 | LinkGroup(common_device=side_a_device, common_name=side_a_name, links=links) 732 | ) 733 | return link_groups 734 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pipewire_python" 7 | authors = [ 8 | {name = "Pablo Diaz"}, 9 | {name = "Anna Absi", email = "anna.absi@gmail.com"}, 10 | {name = "mrteathyme"}, 11 | {name = "Joe Stanley", email = "engineerjoe440@yahoo.com"} 12 | ] 13 | maintainers = [ 14 | {name = "Pablo Diaz"} 15 | ] 16 | license = {file = "LICENSE"} 17 | home-page = "https://github.com/pablodz/pipewire_python" 18 | requires-python = ">=3.7" 19 | description = "Python controller, player and recorder via pipewire's commands" 20 | readme = "README.md" 21 | classifiers = [ 22 | "License :: OSI Approved :: MIT License", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: Science/Research", 25 | "Natural Language :: English", 26 | "Topic :: Multimedia :: Sound/Audio :: Capture/Recording", 27 | "Topic :: Multimedia :: Sound/Audio :: Players", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | ] 36 | dynamic = ["version"] 37 | 38 | [project.urls] 39 | Home = "https://github.com/pablodz/pipewire_python" 40 | Documentation = "https://pablodz.github.io/pipewire_python/html/pipewire_python.html" 41 | 42 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # Test 2 | flit 3 | pytest 4 | tox 5 | # Documentation 6 | pdoc -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pablodz/pipewire_python/6941921042e54dc540766cd1edfe96baf539d0ef/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_interfaces.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.controller import Controller 2 | 3 | 4 | def test_interfaces(): 5 | # Client 6 | audio_controller = Controller() 7 | list_interfaces_client = audio_controller.get_list_interfaces( 8 | type_interfaces="Client", filtered_by_type=True, verbose=True 9 | ) 10 | 11 | print(list_interfaces_client) 12 | # check if dict 13 | assert isinstance( 14 | list_interfaces_client, dict 15 | ), "list_interfaces_client should be of type dict" 16 | # not empty dict 17 | # empty on CI/CD 18 | assert len(list_interfaces_client) >= 0 19 | 20 | # All 21 | audio_controller = Controller() 22 | list_interfaces_client = audio_controller.get_list_interfaces( 23 | filtered_by_type=False, verbose=True 24 | ) 25 | print(list_interfaces_client) 26 | # check if dict 27 | assert isinstance( 28 | list_interfaces_client, dict 29 | ), "list_interfaces_client should be of type dict" 30 | # not empty dict 31 | # empty on CI/CD 32 | assert len(list_interfaces_client) >= 0 33 | -------------------------------------------------------------------------------- /tests/test_links.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.link import ( 2 | list_inputs, 3 | list_outputs, 4 | list_links, 5 | list_link_groups, 6 | StereoInput, 7 | StereoOutput, 8 | ) 9 | 10 | 11 | def test_list(): 12 | """Test that the lists provide some devices and that disconnecting clears them.""" 13 | inputs = list_inputs() 14 | if len(inputs) == 0: 15 | return # No inputs, no point in testing 16 | 17 | assert list_inputs() 18 | assert list_outputs() 19 | assert list_links() 20 | assert list_link_groups() 21 | 22 | # Disconnect everything 23 | for in_dev in list_inputs(): 24 | for out_dev in list_outputs(): 25 | if isinstance(in_dev, StereoInput) and isinstance(out_dev, StereoOutput): 26 | in_dev.disconnect(out_dev) 27 | 28 | assert len(list_links()) == 0 29 | 30 | 31 | def test_connect_disconnect(): 32 | """Test that all points quickly connect then disconnect.""" 33 | links = [] 34 | 35 | # Connect everything 36 | for in_dev in list_inputs(): 37 | for out_dev in list_outputs(): 38 | if isinstance(in_dev, StereoInput) and isinstance(out_dev, StereoOutput): 39 | links.append(in_dev.connect(out_dev)) 40 | 41 | # Disconnect Afterwards 42 | for link in links: 43 | link.disconnect() 44 | -------------------------------------------------------------------------------- /tests/test_playback.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.controller import Controller 2 | 3 | # import requests 4 | 5 | 6 | # response=requests.get('https://github.com/pablodz/pipewire_python/blob/main/docs/beers.wav?raw=true') 7 | 8 | # with open("beers.wav", 'w') as file: 9 | # file.write(response.text) 10 | 11 | 12 | ######################### 13 | # PLAYBACK # 14 | ######################### 15 | # normal way 16 | def test_playback(): 17 | audio_controller = Controller(verbose=True) 18 | audio_controller.set_config( 19 | rate=384000, 20 | channels=2, 21 | _format="f64", 22 | volume=0.98, 23 | quality=4, 24 | # Debug 25 | verbose=True, 26 | ) 27 | audio_controller.playback( 28 | audio_filename="docs/beers.wav", 29 | # Debug 30 | verbose=True, 31 | ) 32 | 33 | assert type(audio_controller.get_config()) 34 | -------------------------------------------------------------------------------- /tests/test_record.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.controller import Controller 2 | 3 | ######################### 4 | # PLAYBACK # 5 | ######################### 6 | # normal way 7 | 8 | 9 | def test_record(): 10 | audio_controller = Controller(verbose=True) 11 | audio_controller.record( 12 | audio_filename="1sec_record.wav", 13 | timeout_seconds=1, 14 | # Debug 15 | verbose=True, 16 | ) 17 | assert type(audio_controller.get_config()) 18 | -------------------------------------------------------------------------------- /tests/test_targets.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.controller import Controller 2 | 3 | 4 | def test_interfaces(): 5 | # Client 6 | audio_controller = Controller() 7 | list_targets_client = audio_controller.get_list_targets() 8 | 9 | print(list_targets_client) 10 | # check if dict 11 | assert isinstance( 12 | list_targets_client, dict 13 | ), "list_targets_client should be of type dict" 14 | # not empty dict 15 | # empty on CI/CD 16 | assert len(list_targets_client) >= 0 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Same dir as setup.py 2 | # DOC https://github.com/tox-dev/tox/blob/master/tox.ini 3 | # CONFIGS https://medium.com/analytics-vidhya/essential-developer-tools-for-improving-your-python-code-71616254134b 4 | [tox] 5 | envlist = py37, py38, py39, py310, py311, py312, py313 6 | isolated_build = true 7 | 8 | [gh-actions] 9 | python = 10 | 3.7: py37 11 | 3.8: py38 12 | 3.9: py39 13 | 3.10: py310 14 | 3.11: py311 15 | 3.12: py312 16 | 3.13: py313 17 | 18 | ; [testenv] 19 | ; description= run the tests with pytest under {basepython} 20 | ; deps = pytest, safety 21 | 22 | ; setenv = 23 | ; PIP_DISABLE_PIP_VERSION_CHECK = 1 24 | ; COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} 25 | 26 | ; commands = 27 | ; python -V 28 | ; # flake8 ./pipewire_python/ 29 | ; # pylint ./pipewire_python/ # broken 2021-09-07 30 | ; # mypy ./pipewire_python/ # broken 2021-06-20 31 | ; # black ./pipewire_python/ 32 | ; safety check -------------------------------------------------------------------------------- /tutorial.py: -------------------------------------------------------------------------------- 1 | from pipewire_python.controller import Controller 2 | 3 | # import asyncio 4 | 5 | ######################### 6 | # PLAYBACK # 7 | ######################### 8 | # normal way 9 | audio_controller = Controller(verbose=True) 10 | audio_controller.set_config( 11 | rate=384000, 12 | channels=2, 13 | _format="f64", 14 | volume=0.98, 15 | quality=4, 16 | # Debug 17 | verbose=True, 18 | ) 19 | audio_controller.playback( 20 | audio_filename="docs/beers.wav", 21 | # Debug 22 | verbose=True, 23 | ) 24 | 25 | # async way 26 | # player = Player() 27 | # asyncio.run(player.play_wav_file_async('docs/beers.wav', 28 | # verbose=True)) 29 | 30 | ######################### 31 | # RECORD # 32 | ######################### 33 | 34 | # normal way 35 | audio_controller = Controller(verbose=True) 36 | audio_controller.record( 37 | audio_filename="docs/5sec_record.wav", 38 | timeout_seconds=5, 39 | # Debug 40 | verbose=True, 41 | ) 42 | 43 | # async way 44 | # player = Player() 45 | # asyncio.run(player.record_wav_file_async('docs/5sec_record.wav', 46 | # verbose=True)) 47 | --------------------------------------------------------------------------------