├── .github └── workflows │ ├── python-publish.yml │ └── pythonpackage.yml ├── .gitignore ├── .readthedocs.yml ├── CITATION.cff ├── LICENSE ├── README.md ├── data ├── Grids │ ├── fliegeMaierNodes_1_30.mat │ ├── lebedevQuadratures_3_131.mat │ ├── maxDetPoints_1_200.mat │ ├── n_designs_1_124.mat │ └── t_designs_1_21.mat ├── IR_Gewandhaus_SH1.wav ├── ls_layouts │ ├── Aalto_subset_C.json │ ├── Dome_29.json │ ├── Graz.json │ └── Partial_frontal.json └── piano_mono.flac ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── api.rst │ ├── conf.py │ ├── index.rst │ └── installation.rst ├── examples ├── Loudspeaker_decoder.py ├── SDM.py ├── SH-filters.ipynb ├── SH_tapering.ipynb ├── Sector_AnalysisSynthesis.ipynb ├── Sector_design.ipynb └── Signal_auralization.ipynb ├── setup.py ├── spaudiopy ├── __init__.py ├── decoder.py ├── grids.py ├── io.py ├── parsa.py ├── plot.py ├── process.py ├── sig.py ├── sph.py └── utils.py └── tests ├── reference.mat ├── test_algo.py ├── test_parallel.py └── test_vals.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Cross-Platform Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build_and_test: 14 | 15 | name: Test on ${{ matrix.os }} with Python ${{ matrix.python-version }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: [ubuntu-latest, macOS-latest, windows-latest] 21 | python-version: ["3.8", "3.9", "3.10", "3.11"] 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install --upgrade wheel 33 | python -m pip install flake8 pytest 34 | - name: Install system libraries 35 | if: ${{ contains( runner.os, 'Linux' ) }} 36 | run: | 37 | sudo apt-get install libsndfile1 38 | #sudo apt-get install libportaudio2 39 | - name: Install package 40 | run: python -m pip install -e . 41 | - name: Lint with flake8 42 | run: | 43 | # stop the build if there are Python syntax errors or undefined names 44 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 45 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 46 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 47 | - name: Set environment 48 | if: ${{ contains( runner.os, 'macOS' ) }} 49 | run: | 50 | mkdir -p ~/.matplotlib/ 51 | echo "backend : TkAgg" > ~/.matplotlib/matplotlibrc 52 | - name: Import package 53 | run: | 54 | python -c "import spaudiopy; print('spaudio v:', spaudiopy.__version__)" 55 | - name: Test with pytest 56 | run: | 57 | pytest -v --durations=0 --log-cli-level=INFO 58 | 59 | docs: 60 | 61 | name: Test building docs 62 | runs-on: ubuntu-latest 63 | 64 | 65 | steps: 66 | - uses: actions/checkout@v3 67 | - name: Set up Python 3.9 68 | uses: actions/setup-python@v4 69 | with: 70 | python-version: 3.9 71 | - name: Install dependencies 72 | run: | 73 | python -m pip install --upgrade pip 74 | #python -m pip install --upgrade wheel 75 | python -m pip install -r docs/requirements.txt 76 | - name: Install system libraries 77 | run: | 78 | sudo apt-get install libsndfile1 79 | #sudo apt-get install libportaudio2 80 | - name: Install package 81 | run: python -m pip install -e . 82 | - name: Build docs 83 | run: | 84 | python -m sphinx docs/source _build 85 | - uses: actions/upload-artifact@v4 86 | with: 87 | name: ci-docs 88 | path: _build/ 89 | 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # data folder 7 | data/ 8 | 9 | # joblib cache 10 | __cache_dir/ 11 | .spa_cache_dir/ 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | docs/source/spaudiopy.*.rst 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Environments 93 | .env 94 | .venv 95 | env/ 96 | venv/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | 114 | # editor configs 115 | .vscode/ 116 | *.pdf 117 | .DS_Store 118 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | 2 | # Required 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.9" 9 | 10 | # Optionally set the version of Python and requirements required to build your docs 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | - method: pip 15 | path: . 16 | 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/source/conf.py 21 | 22 | # Optionally build your docs in additional formats such as PDF and ePub 23 | formats: all 24 | 25 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Hold" 5 | given-names: "Christoph" 6 | orcid: "https://orcid.org/0000-0001-6579-265X" 7 | title: "spaudiopy" 8 | version: 0.2.0 9 | doi: 10.5281/zenodo.15384855 10 | date-released: 2025-05-11 11 | url: "https://github.com/chris-hld/spaudiopy" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Chris Hold 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spaudiopy 2 | [![Documentation Status](https://readthedocs.org/projects/spaudiopy/badge/?version=latest)](https://spaudiopy.readthedocs.io/en/latest/?badge=latest) 3 | [![PyPI version](https://badge.fury.io/py/spaudiopy.svg)](https://pypi.org/project/spaudiopy/) 4 | ![Cross-Platform Test](https://github.com/chris-hld/spaudiopy/workflows/Cross-Platform%20Test/badge.svg) 5 | 6 | Spatial Audio Python Package. 7 | 8 | The focus (so far) is on spatial audio encoders and decoders. 9 | The package includes e.g. spherical harmonics processing and (binaural renderings of) loudspeaker decoders, such as VBAP and AllRAD. 10 | 11 | ## Documentation 12 | 13 | You can find the latest package documentation here: 14 | https://spaudiopy.readthedocs.io/ 15 | 16 | Some details about the implementation can be found in my [thesis](https://doi.org/10.13140/RG.2.2.11905.20323), or just contact me. 17 | 18 | ## Quickstart 19 | 20 | It's easiest to start with something like [Anaconda](https://www.anaconda.com/distribution/) as a Python distribution. 21 | You'll need Python >= 3.6 . 22 | 23 | You can simply install the latest release with pip: 24 | `pip install spaudiopy` 25 | 26 | This also works for the latest [commit](https://github.com/chris-hld/spaudiopy/): 27 | `pip install git+https://github.com/chris-hld/spaudiopy.git@master` 28 | 29 | ### From Source 30 | 31 | Alternatively, if you want to go into detail and install the package from source: 32 | 33 | 1. Create a conda environment, called e.g. 'spaudio': 34 | `conda create --name spaudio python=3.8 anaconda portaudio` 35 | 2. Activate this new environment: 36 | `conda activate spaudio` 37 | 38 | Get the latest source code from GitHub: 39 | `git clone https://github.com/chris-hld/spaudiopy.git && cd spaudiopy` 40 | 41 | Install the package and remaining dependencies: 42 | `pip install -e . ` 43 | 44 | 45 | ## Contributions 46 | 47 | This is meant to be an open project and contributions or feature requests are always welcome! 48 | 49 | Some functions are also (heavily) inspired by other packages, e.g. https://github.com/polarch/Spherical-Harmonic-Transform, https://github.com/spatialaudio/sfa-numpy, https://github.com/AppliedAcousticsChalmers/sound_field_analysis-py . 50 | 51 | ## Licence 52 | 53 | MIT -- see the file LICENSE for details. 54 | -------------------------------------------------------------------------------- /data/Grids/fliegeMaierNodes_1_30.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/Grids/fliegeMaierNodes_1_30.mat -------------------------------------------------------------------------------- /data/Grids/lebedevQuadratures_3_131.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/Grids/lebedevQuadratures_3_131.mat -------------------------------------------------------------------------------- /data/Grids/maxDetPoints_1_200.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/Grids/maxDetPoints_1_200.mat -------------------------------------------------------------------------------- /data/Grids/n_designs_1_124.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/Grids/n_designs_1_124.mat -------------------------------------------------------------------------------- /data/Grids/t_designs_1_21.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/Grids/t_designs_1_21.mat -------------------------------------------------------------------------------- /data/IR_Gewandhaus_SH1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/IR_Gewandhaus_SH1.wav -------------------------------------------------------------------------------- /data/ls_layouts/Aalto_subset_C.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Aalto MCC Wilska subset C", 3 | "Description": "This configuration file was created with spaudiopy (v-0.1.2-dirty), 2020-06-26 15:01:59.051247", 4 | "LoudspeakerLayout": { 5 | "Name": "Aalto MCC Wilska subset C", 6 | "Description": "Symmetrical subset for Aalto MCC Wilska.", 7 | "Loudspeakers": [ 8 | { 9 | "Azimuth": 0.0, 10 | "Elevation": 90.0, 11 | "Radius": 1.0, 12 | "IsImaginary": false, 13 | "Channel": 1, 14 | "Gain": 1.0 15 | }, 16 | { 17 | "Azimuth": 0.0, 18 | "Elevation": 60.0, 19 | "Radius": 1.0, 20 | "IsImaginary": false, 21 | "Channel": 2, 22 | "Gain": 1.0 23 | }, 24 | { 25 | "Azimuth": 90.0, 26 | "Elevation": 60.0, 27 | "Radius": 1.0, 28 | "IsImaginary": false, 29 | "Channel": 3, 30 | "Gain": 1.0 31 | }, 32 | { 33 | "Azimuth": 180.0, 34 | "Elevation": 60.0, 35 | "Radius": 1.0, 36 | "IsImaginary": false, 37 | "Channel": 4, 38 | "Gain": 1.0 39 | }, 40 | { 41 | "Azimuth": 270.0, 42 | "Elevation": 60.0, 43 | "Radius": 1.0, 44 | "IsImaginary": false, 45 | "Channel": 5, 46 | "Gain": 1.0 47 | }, 48 | { 49 | "Azimuth": 0.0, 50 | "Elevation": 30.0, 51 | "Radius": 1.0, 52 | "IsImaginary": false, 53 | "Channel": 6, 54 | "Gain": 1.0 55 | }, 56 | { 57 | "Azimuth": 45.0, 58 | "Elevation": 30.0, 59 | "Radius": 1.0, 60 | "IsImaginary": false, 61 | "Channel": 7, 62 | "Gain": 1.0 63 | }, 64 | { 65 | "Azimuth": 90.0, 66 | "Elevation": 30.0, 67 | "Radius": 1.0, 68 | "IsImaginary": false, 69 | "Channel": 8, 70 | "Gain": 1.0 71 | }, 72 | { 73 | "Azimuth": 135.0, 74 | "Elevation": 30.0, 75 | "Radius": 1.0, 76 | "IsImaginary": false, 77 | "Channel": 9, 78 | "Gain": 1.0 79 | }, 80 | { 81 | "Azimuth": 180.0, 82 | "Elevation": 30.0, 83 | "Radius": 1.0, 84 | "IsImaginary": false, 85 | "Channel": 10, 86 | "Gain": 1.0 87 | }, 88 | { 89 | "Azimuth": 225.0, 90 | "Elevation": 30.0, 91 | "Radius": 1.0, 92 | "IsImaginary": false, 93 | "Channel": 11, 94 | "Gain": 1.0 95 | }, 96 | { 97 | "Azimuth": 270.0, 98 | "Elevation": 30.0, 99 | "Radius": 1.0, 100 | "IsImaginary": false, 101 | "Channel": 12, 102 | "Gain": 1.0 103 | }, 104 | { 105 | "Azimuth": 315.0, 106 | "Elevation": 30.0, 107 | "Radius": 1.0, 108 | "IsImaginary": false, 109 | "Channel": 13, 110 | "Gain": 1.0 111 | }, 112 | { 113 | "Azimuth": 0.0, 114 | "Elevation": 0.0, 115 | "Radius": 1.0, 116 | "IsImaginary": false, 117 | "Channel": 14, 118 | "Gain": 1.0 119 | }, 120 | { 121 | "Azimuth": 30.0, 122 | "Elevation": 0.0, 123 | "Radius": 1.0, 124 | "IsImaginary": false, 125 | "Channel": 15, 126 | "Gain": 1.0 127 | }, 128 | { 129 | "Azimuth": 60.0, 130 | "Elevation": 0.0, 131 | "Radius": 1.0, 132 | "IsImaginary": false, 133 | "Channel": 16, 134 | "Gain": 1.0 135 | }, 136 | { 137 | "Azimuth": 90.0, 138 | "Elevation": 0.0, 139 | "Radius": 1.0, 140 | "IsImaginary": false, 141 | "Channel": 17, 142 | "Gain": 1.0 143 | }, 144 | { 145 | "Azimuth": 120.0, 146 | "Elevation": 0.0, 147 | "Radius": 1.0, 148 | "IsImaginary": false, 149 | "Channel": 18, 150 | "Gain": 1.0 151 | }, 152 | { 153 | "Azimuth": 150.0, 154 | "Elevation": 0.0, 155 | "Radius": 1.0, 156 | "IsImaginary": false, 157 | "Channel": 19, 158 | "Gain": 1.0 159 | }, 160 | { 161 | "Azimuth": 180.0, 162 | "Elevation": 0.0, 163 | "Radius": 1.0, 164 | "IsImaginary": false, 165 | "Channel": 20, 166 | "Gain": 1.0 167 | }, 168 | { 169 | "Azimuth": 210.0, 170 | "Elevation": 0.0, 171 | "Radius": 1.0, 172 | "IsImaginary": false, 173 | "Channel": 21, 174 | "Gain": 1.0 175 | }, 176 | { 177 | "Azimuth": 240.0, 178 | "Elevation": 0.0, 179 | "Radius": 1.0, 180 | "IsImaginary": false, 181 | "Channel": 22, 182 | "Gain": 1.0 183 | }, 184 | { 185 | "Azimuth": 270.0, 186 | "Elevation": 0.0, 187 | "Radius": 1.0, 188 | "IsImaginary": false, 189 | "Channel": 23, 190 | "Gain": 1.0 191 | }, 192 | { 193 | "Azimuth": 300.0, 194 | "Elevation": 0.0, 195 | "Radius": 1.0, 196 | "IsImaginary": false, 197 | "Channel": 24, 198 | "Gain": 1.0 199 | }, 200 | { 201 | "Azimuth": 330.0, 202 | "Elevation": 0.0, 203 | "Radius": 1.0, 204 | "IsImaginary": false, 205 | "Channel": 25, 206 | "Gain": 1.0 207 | }, 208 | { 209 | "Azimuth": 0.0, 210 | "Elevation": -30.0, 211 | "Radius": 1.0, 212 | "IsImaginary": false, 213 | "Channel": 26, 214 | "Gain": 1.0 215 | }, 216 | { 217 | "Azimuth": 45.0, 218 | "Elevation": -30.0, 219 | "Radius": 1.0, 220 | "IsImaginary": false, 221 | "Channel": 27, 222 | "Gain": 1.0 223 | }, 224 | { 225 | "Azimuth": 90.0, 226 | "Elevation": -30.0, 227 | "Radius": 1.0, 228 | "IsImaginary": false, 229 | "Channel": 28, 230 | "Gain": 1.0 231 | }, 232 | { 233 | "Azimuth": 135.0, 234 | "Elevation": -30.0, 235 | "Radius": 1.0, 236 | "IsImaginary": false, 237 | "Channel": 29, 238 | "Gain": 1.0 239 | }, 240 | { 241 | "Azimuth": 180.0, 242 | "Elevation": -30.0, 243 | "Radius": 1.0, 244 | "IsImaginary": false, 245 | "Channel": 30, 246 | "Gain": 1.0 247 | }, 248 | { 249 | "Azimuth": 225.0, 250 | "Elevation": -30.0, 251 | "Radius": 1.0, 252 | "IsImaginary": false, 253 | "Channel": 31, 254 | "Gain": 1.0 255 | }, 256 | { 257 | "Azimuth": 270.0, 258 | "Elevation": -30.0, 259 | "Radius": 1.0, 260 | "IsImaginary": false, 261 | "Channel": 32, 262 | "Gain": 1.0 263 | }, 264 | { 265 | "Azimuth": 315.0, 266 | "Elevation": -30.0, 267 | "Radius": 1.0, 268 | "IsImaginary": false, 269 | "Channel": 33, 270 | "Gain": 1.0 271 | }, 272 | { 273 | "Azimuth": 0.0, 274 | "Elevation": -60.0, 275 | "Radius": 1.0, 276 | "IsImaginary": false, 277 | "Channel": 34, 278 | "Gain": 1.0 279 | }, 280 | { 281 | "Azimuth": 90.0, 282 | "Elevation": -60.0, 283 | "Radius": 1.0, 284 | "IsImaginary": false, 285 | "Channel": 35, 286 | "Gain": 1.0 287 | }, 288 | { 289 | "Azimuth": 180.0, 290 | "Elevation": -60.0, 291 | "Radius": 1.0, 292 | "IsImaginary": false, 293 | "Channel": 36, 294 | "Gain": 1.0 295 | }, 296 | { 297 | "Azimuth": 270.0, 298 | "Elevation": -60.0, 299 | "Radius": 1.0, 300 | "IsImaginary": false, 301 | "Channel": 37, 302 | "Gain": 1.0 303 | } 304 | ] 305 | } 306 | } -------------------------------------------------------------------------------- /data/ls_layouts/Dome_29.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Full sphere example of 20 loudspeakers.", 3 | "Description": "This configuration file was created with spaudiopy (v-0.1.2-dirty), 2020-06-26 15:01:58.621361", 4 | "LoudspeakerLayout": { 5 | "Name": "Full sphere example of 20 loudspeakers.", 6 | "Description": "Example loudspeaker layout used in research.spa.aalto.fi/projects/vbap-lib/vbap.html ", 7 | "Loudspeakers": [ 8 | { 9 | "Azimuth": 342.0, 10 | "Elevation": 0.0, 11 | "Radius": 1.0, 12 | "IsImaginary": false, 13 | "Channel": 1, 14 | "Gain": 1.0 15 | }, 16 | { 17 | "Azimuth": 306.0, 18 | "Elevation": 0.0, 19 | "Radius": 1.0, 20 | "IsImaginary": false, 21 | "Channel": 2, 22 | "Gain": 1.0 23 | }, 24 | { 25 | "Azimuth": 270.0, 26 | "Elevation": 0.0, 27 | "Radius": 1.0, 28 | "IsImaginary": false, 29 | "Channel": 3, 30 | "Gain": 1.0 31 | }, 32 | { 33 | "Azimuth": 234.0, 34 | "Elevation": 0.0, 35 | "Radius": 1.0, 36 | "IsImaginary": false, 37 | "Channel": 4, 38 | "Gain": 1.0 39 | }, 40 | { 41 | "Azimuth": 198.0, 42 | "Elevation": 0.0, 43 | "Radius": 1.0, 44 | "IsImaginary": false, 45 | "Channel": 5, 46 | "Gain": 1.0 47 | }, 48 | { 49 | "Azimuth": 162.0, 50 | "Elevation": 0.0, 51 | "Radius": 1.0, 52 | "IsImaginary": false, 53 | "Channel": 6, 54 | "Gain": 1.0 55 | }, 56 | { 57 | "Azimuth": 126.0, 58 | "Elevation": 0.0, 59 | "Radius": 1.0, 60 | "IsImaginary": false, 61 | "Channel": 7, 62 | "Gain": 1.0 63 | }, 64 | { 65 | "Azimuth": 90.0, 66 | "Elevation": 0.0, 67 | "Radius": 1.0, 68 | "IsImaginary": false, 69 | "Channel": 8, 70 | "Gain": 1.0 71 | }, 72 | { 73 | "Azimuth": 54.0, 74 | "Elevation": 0.0, 75 | "Radius": 1.0, 76 | "IsImaginary": false, 77 | "Channel": 9, 78 | "Gain": 1.0 79 | }, 80 | { 81 | "Azimuth": 18.0, 82 | "Elevation": 0.0, 83 | "Radius": 1.0, 84 | "IsImaginary": false, 85 | "Channel": 10, 86 | "Gain": 1.0 87 | }, 88 | { 89 | "Azimuth": 0.0, 90 | "Elevation": -10.0, 91 | "Radius": 1.0, 92 | "IsImaginary": false, 93 | "Channel": 11, 94 | "Gain": 1.0 95 | }, 96 | { 97 | "Azimuth": 288.0, 98 | "Elevation": -10.0, 99 | "Radius": 1.0, 100 | "IsImaginary": false, 101 | "Channel": 12, 102 | "Gain": 1.0 103 | }, 104 | { 105 | "Azimuth": 216.0, 106 | "Elevation": -10.0, 107 | "Radius": 1.0, 108 | "IsImaginary": false, 109 | "Channel": 13, 110 | "Gain": 1.0 111 | }, 112 | { 113 | "Azimuth": 144.0, 114 | "Elevation": -10.0, 115 | "Radius": 1.0, 116 | "IsImaginary": false, 117 | "Channel": 14, 118 | "Gain": 1.0 119 | }, 120 | { 121 | "Azimuth": 72.0, 122 | "Elevation": -10.0, 123 | "Radius": 1.0, 124 | "IsImaginary": false, 125 | "Channel": 15, 126 | "Gain": 1.0 127 | }, 128 | { 129 | "Azimuth": 315.0, 130 | "Elevation": 45.0, 131 | "Radius": 1.0, 132 | "IsImaginary": false, 133 | "Channel": 16, 134 | "Gain": 1.0 135 | }, 136 | { 137 | "Azimuth": 225.0, 138 | "Elevation": 45.0, 139 | "Radius": 1.0, 140 | "IsImaginary": false, 141 | "Channel": 17, 142 | "Gain": 1.0 143 | }, 144 | { 145 | "Azimuth": 135.0, 146 | "Elevation": 45.0, 147 | "Radius": 1.0, 148 | "IsImaginary": false, 149 | "Channel": 18, 150 | "Gain": 1.0 151 | }, 152 | { 153 | "Azimuth": 45.0, 154 | "Elevation": 45.0, 155 | "Radius": 1.0, 156 | "IsImaginary": false, 157 | "Channel": 19, 158 | "Gain": 1.0 159 | }, 160 | { 161 | "Azimuth": 0.0, 162 | "Elevation": 90.0, 163 | "Radius": 1.0, 164 | "IsImaginary": false, 165 | "Channel": 20, 166 | "Gain": 1.0 167 | } 168 | ] 169 | } 170 | } -------------------------------------------------------------------------------- /data/ls_layouts/Graz.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Graz", 3 | "Description": "This configuration file was created with spaudiopy (v-0.1.2-dirty), 2020-06-26 15:01:58.865282", 4 | "LoudspeakerLayout": { 5 | "Name": "Graz", 6 | "Description": "Layout from : Zotter, F., & Frank, M. (2012). All-Round Ambisonic Panning and Decoding. Journal of the Audio Engineering Society, 60.", 7 | "Loudspeakers": [ 8 | { 9 | "Azimuth": 0.0, 10 | "Elevation": 0.0, 11 | "Radius": 1.0, 12 | "IsImaginary": false, 13 | "Channel": 1, 14 | "Gain": 1.0 15 | }, 16 | { 17 | "Azimuth": 23.7, 18 | "Elevation": 0.4, 19 | "Radius": 1.0, 20 | "IsImaginary": false, 21 | "Channel": 2, 22 | "Gain": 1.0 23 | }, 24 | { 25 | "Azimuth": 48.2, 26 | "Elevation": 0.6, 27 | "Radius": 1.0, 28 | "IsImaginary": false, 29 | "Channel": 3, 30 | "Gain": 1.0 31 | }, 32 | { 33 | "Azimuth": 72.6, 34 | "Elevation": 0.7, 35 | "Radius": 1.0, 36 | "IsImaginary": false, 37 | "Channel": 4, 38 | "Gain": 1.0 39 | }, 40 | { 41 | "Azimuth": 103.1, 42 | "Elevation": 0.6, 43 | "Radius": 1.0, 44 | "IsImaginary": false, 45 | "Channel": 5, 46 | "Gain": 1.0 47 | }, 48 | { 49 | "Azimuth": 259.1, 50 | "Elevation": 0.6, 51 | "Radius": 1.0, 52 | "IsImaginary": false, 53 | "Channel": 6, 54 | "Gain": 1.0 55 | }, 56 | { 57 | "Azimuth": 290.2, 58 | "Elevation": 0.4, 59 | "Radius": 1.0, 60 | "IsImaginary": false, 61 | "Channel": 7, 62 | "Gain": 1.0 63 | }, 64 | { 65 | "Azimuth": 315.2, 66 | "Elevation": 0.5, 67 | "Radius": 1.0, 68 | "IsImaginary": false, 69 | "Channel": 8, 70 | "Gain": 1.0 71 | }, 72 | { 73 | "Azimuth": 338.6, 74 | "Elevation": 0.5, 75 | "Radius": 1.0, 76 | "IsImaginary": false, 77 | "Channel": 9, 78 | "Gain": 1.0 79 | }, 80 | { 81 | "Azimuth": 22.7, 82 | "Elevation": 28.5, 83 | "Radius": 1.0, 84 | "IsImaginary": false, 85 | "Channel": 10, 86 | "Gain": 1.0 87 | }, 88 | { 89 | "Azimuth": 67.9, 90 | "Elevation": 28.5, 91 | "Radius": 1.0, 92 | "IsImaginary": false, 93 | "Channel": 11, 94 | "Gain": 1.0 95 | }, 96 | { 97 | "Azimuth": 114.2, 98 | "Elevation": 27.9, 99 | "Radius": 1.0, 100 | "IsImaginary": false, 101 | "Channel": 12, 102 | "Gain": 1.0 103 | }, 104 | { 105 | "Azimuth": 246.7, 106 | "Elevation": 28.4, 107 | "Radius": 1.0, 108 | "IsImaginary": false, 109 | "Channel": 13, 110 | "Gain": 1.0 111 | }, 112 | { 113 | "Azimuth": 294.6, 114 | "Elevation": 28.5, 115 | "Radius": 1.0, 116 | "IsImaginary": false, 117 | "Channel": 14, 118 | "Gain": 1.0 119 | }, 120 | { 121 | "Azimuth": 337.3, 122 | "Elevation": 28.0, 123 | "Radius": 1.0, 124 | "IsImaginary": false, 125 | "Channel": 15, 126 | "Gain": 1.0 127 | }, 128 | { 129 | "Azimuth": 46.8, 130 | "Elevation": 57.0, 131 | "Radius": 1.0, 132 | "IsImaginary": false, 133 | "Channel": 16, 134 | "Gain": 1.0 135 | }, 136 | { 137 | "Azimuth": 133.4, 138 | "Elevation": 57.0, 139 | "Radius": 1.0, 140 | "IsImaginary": false, 141 | "Channel": 17, 142 | "Gain": 1.0 143 | }, 144 | { 145 | "Azimuth": 226.6, 146 | "Elevation": 56.6, 147 | "Radius": 1.0, 148 | "IsImaginary": false, 149 | "Channel": 18, 150 | "Gain": 1.0 151 | }, 152 | { 153 | "Azimuth": 316.6, 154 | "Elevation": 57.7, 155 | "Radius": 1.0, 156 | "IsImaginary": false, 157 | "Channel": 19, 158 | "Gain": 1.0 159 | } 160 | ] 161 | } 162 | } -------------------------------------------------------------------------------- /data/ls_layouts/Partial_frontal.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Partial frontal setup example of 9 loudspeakers.", 3 | "Description": "This configuration file was created with spaudiopy (v-0.1.2-dirty), 2020-06-26 15:01:58.746902", 4 | "LoudspeakerLayout": { 5 | "Name": "Partial frontal setup example of 9 loudspeakers.", 6 | "Description": "Example loudspeaker layout used in research.spa.aalto.fi/projects/vbap-lib/vbap.html ", 7 | "Loudspeakers": [ 8 | { 9 | "Azimuth": 280.0, 10 | "Elevation": 0.0, 11 | "Radius": 1.0, 12 | "IsImaginary": false, 13 | "Channel": 1, 14 | "Gain": 1.0 15 | }, 16 | { 17 | "Azimuth": 315.0, 18 | "Elevation": 0.0, 19 | "Radius": 1.0, 20 | "IsImaginary": false, 21 | "Channel": 2, 22 | "Gain": 1.0 23 | }, 24 | { 25 | "Azimuth": 0.0, 26 | "Elevation": 0.0, 27 | "Radius": 1.0, 28 | "IsImaginary": false, 29 | "Channel": 3, 30 | "Gain": 1.0 31 | }, 32 | { 33 | "Azimuth": 45.0, 34 | "Elevation": 0.0, 35 | "Radius": 1.0, 36 | "IsImaginary": false, 37 | "Channel": 4, 38 | "Gain": 1.0 39 | }, 40 | { 41 | "Azimuth": 80.0, 42 | "Elevation": 0.0, 43 | "Radius": 1.0, 44 | "IsImaginary": false, 45 | "Channel": 5, 46 | "Gain": 1.0 47 | }, 48 | { 49 | "Azimuth": 300.0, 50 | "Elevation": 60.0, 51 | "Radius": 1.0, 52 | "IsImaginary": false, 53 | "Channel": 6, 54 | "Gain": 1.0 55 | }, 56 | { 57 | "Azimuth": 330.0, 58 | "Elevation": 60.0, 59 | "Radius": 1.0, 60 | "IsImaginary": false, 61 | "Channel": 7, 62 | "Gain": 1.0 63 | }, 64 | { 65 | "Azimuth": 30.0, 66 | "Elevation": 60.0, 67 | "Radius": 1.0, 68 | "IsImaginary": false, 69 | "Channel": 8, 70 | "Gain": 1.0 71 | }, 72 | { 73 | "Azimuth": 60.0, 74 | "Elevation": 60.0, 75 | "Radius": 1.0, 76 | "IsImaginary": false, 77 | "Channel": 9, 78 | "Gain": 1.0 79 | } 80 | ] 81 | } 82 | } -------------------------------------------------------------------------------- /data/piano_mono.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/data/piano_mono.flac -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=4.0, <7 2 | Sphinx-RTD-Theme 3 | 4 | NumPy 5 | matplotlib>=3.3.0 6 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: spaudiopy 5 | 6 | 7 | 8 | Multiprocessing 9 | --------------- 10 | If the function has an argument called `jobs_count` the implementation allows launching multiple processing jobs. 11 | Keep in mind that there is always a certain computational overhead involved, and more jobs is not always faster. 12 | 13 | Especially on Windows, you then have to protect your `main()` function with:: 14 | 15 | if __name__ == '__main__': 16 | # Put your code here 17 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | from subprocess import check_output 18 | from datetime import date 19 | 20 | 21 | sys.path.insert(0, os.path.abspath('../../')) 22 | # print('\n'.join(sys.path)) 23 | 24 | # -- Project information ----------------------------------------------------- 25 | 26 | project = 'spaudiopy' 27 | copyright = str(date.today().year) + ', Chris Hold' 28 | author = 'Chris Hold' 29 | 30 | # The short X.Y version. 31 | # version = '0.0.0' 32 | # The full version, including alpha/beta/rc tags. 33 | try: 34 | release = check_output(['git', 'describe', '--tags', '--always']) 35 | release = release.decode().strip() 36 | except Exception: 37 | release = '' 38 | 39 | 40 | # -- General configuration --------------------------------------------------- 41 | 42 | # If your documentation needs a minimal Sphinx version, state it here. 43 | needs_sphinx = '1.3' # for sphinx.ext.napoleon 44 | 45 | # Add any Sphinx extension module names here, as strings. They can be 46 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 47 | # ones. 48 | extensions = [ 49 | 'sphinx.ext.autodoc', 50 | 'sphinx.ext.autosummary', 51 | 'sphinx.ext.mathjax', 52 | 'sphinx.ext.viewcode', 53 | 'sphinx.ext.napoleon', # support for NumPy-style docstrings 54 | 'matplotlib.sphinxext.plot_directive', 55 | ] 56 | 57 | # autodoc 58 | autodoc_default_options = { 59 | 'members': None, 60 | 'undoc-members': None, 61 | 'member-order': 'bysource', 62 | 'special-members': '__init__', 63 | 'exclude-members': '__weakref__', 64 | 'show-inheritance': 'True', 65 | 'inherited-members': 'True' 66 | } 67 | 68 | # Mock import soundfile and sounddevice, because they depend on external C lib 69 | autodoc_mock_imports = ['soundfile', 'sounddevice'] 70 | 71 | # autosummary 72 | autosummary_generate = ['api'] 73 | 74 | # napoleon 75 | napoleon_google_docstring = False 76 | napoleon_numpy_docstring = True 77 | napoleon_include_private_with_doc = False 78 | napoleon_include_special_with_doc = False 79 | napoleon_use_admonition_for_examples = False 80 | napoleon_use_admonition_for_notes = False 81 | napoleon_use_admonition_for_references = False 82 | napoleon_use_ivar = False 83 | napoleon_use_param = False 84 | napoleon_use_rtype = False 85 | 86 | # matplotlib inline plots 87 | plot_include_source = True 88 | plot_html_show_source_link = False 89 | # plot_html_show_formats = False 90 | # plot_pre_code = '' 91 | # plot_rcparams = {'figure.figsize': (8, 4.5), 92 | # } 93 | # plot_formats = ['svg', 'pdf', ('png', 96)] 94 | 95 | # Add any paths that contain templates here, relative to this directory. 96 | templates_path = ['_templates'] 97 | 98 | # The suffix(es) of source filenames. 99 | # You can specify multiple suffix as a list of string: 100 | # 101 | # source_suffix = ['.rst', '.md'] 102 | source_suffix = '.rst' 103 | 104 | # The master toctree document. 105 | master_doc = 'index' 106 | 107 | # The language for content autogenerated by Sphinx. Refer to documentation 108 | # for a list of supported languages. 109 | # 110 | # This is also used if you do content translation via gettext catalogs. 111 | # Usually you set "language" from the command line for these cases. 112 | # language = None 113 | 114 | # List of patterns, relative to source directory, that match files and 115 | # directories to ignore when looking for source files. 116 | # This pattern also affects html_static_path and html_extra_path. 117 | exclude_patterns = [] 118 | 119 | # The name of the Pygments (syntax highlighting) style to use. 120 | pygments_style = 'sphinx' 121 | 122 | 123 | # -- Options for HTML output ------------------------------------------------- 124 | 125 | # The theme to use for HTML and HTML Help pages. See the documentation for 126 | # a list of builtin themes. 127 | # 128 | html_theme = 'sphinx_rtd_theme' 129 | 130 | # Theme options are theme-specific and customize the look and feel of a theme 131 | # further. For a list of options available for each theme, see the 132 | # documentation. 133 | # 134 | # html_theme_options = {} 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | # html_static_path = ['_static'] 140 | 141 | # Custom sidebar templates, must be a dictionary that maps document names 142 | # to template names. 143 | # 144 | # The default sidebars (for documents that don't match any pattern) are 145 | # defined by theme itself. Builtin themes are using these templates by 146 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 147 | # 'searchbox.html']``. 148 | # 149 | # html_sidebars = {} 150 | 151 | 152 | # -- Options for HTMLHelp output --------------------------------------------- 153 | 154 | # Output file base name for HTML help builder. 155 | htmlhelp_basename = 'spaudiopydoc' 156 | 157 | 158 | # -- Options for LaTeX output ------------------------------------------------ 159 | 160 | latex_elements = { 161 | # The paper size ('letterpaper' or 'a4paper'). 162 | # 163 | # 'papersize': 'letterpaper', 164 | 165 | # The font size ('10pt', '11pt' or '12pt'). 166 | # 167 | # 'pointsize': '10pt', 168 | 169 | # Additional stuff for the LaTeX preamble. 170 | # 171 | # 'preamble': '', 172 | 173 | # Latex figure (float) alignment 174 | # 175 | # 'figure_align': 'htbp', 176 | } 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, 180 | # author, documentclass [howto, manual, or own class]). 181 | latex_documents = [ 182 | (master_doc, 'spaudiopy.tex', 'spaudiopy Documentation', 183 | 'Chris Hold', 'manual'), 184 | ] 185 | 186 | 187 | # -- Options for manual page output ------------------------------------------ 188 | 189 | # One entry per manual page. List of tuples 190 | # (source start file, name, description, authors, manual section). 191 | man_pages = [ 192 | (master_doc, 'spaudiopy', 'spaudiopy Documentation', 193 | [author], 1) 194 | ] 195 | 196 | 197 | # -- Options for Texinfo output ---------------------------------------------- 198 | 199 | # Grouping the document tree into Texinfo files. List of tuples 200 | # (source start file, target name, title, author, 201 | # dir menu entry, description, category) 202 | # texinfo_documents = [ 203 | # (master_doc, 'spaudiopy', 'spaudiopy Documentation', 204 | # author, 'spaudiopy', 'One line description of project.', 205 | # 'Miscellaneous'), 206 | # ] 207 | 208 | 209 | # -- Options for Epub output ------------------------------------------------- 210 | 211 | # Bibliographic Dublin Core info. 212 | epub_title = project 213 | 214 | # The unique identifier of the text. This can be a ISBN number 215 | # or the project homepage. 216 | # 217 | # epub_identifier = '' 218 | 219 | # A unique identification for the text. 220 | # 221 | # epub_uid = '' 222 | 223 | # A list of files that should not be packed into the epub file. 224 | epub_exclude_files = ['search.html'] 225 | 226 | 227 | # -- Extension configuration ------------------------------------------------- 228 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to spaudiopy's documentation! 3 | ===================================== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :caption: Contents: 8 | 9 | installation 10 | api 11 | 12 | 13 | Indices and tables 14 | ================== 15 | 16 | * :ref:`genindex` 17 | * :ref:`modindex` 18 | * :ref:`search` 19 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | For the unpatient, you can just install the pip version 5 | `pip install spaudiopy` 6 | 7 | 8 | Requirements 9 | ------------ 10 | It's easiest to start with something like `Anaconda `_ as a Python distribution. 11 | You'll need Python >= 3.6 . 12 | 13 | #. Create a conda environment: 14 | * `conda create --name spaudio python=3.8 anaconda portaudio` 15 | #. Activate this new environment: 16 | * `conda activate spaudio` 17 | 18 | 19 | Have a look at the `setup.py` file, all dependencies are listed there. 20 | When using `pip` to install this package as shown below, all remaining dependencies not available from conda will be downloaded and installed automatically. 21 | 22 | Installation 23 | ------------ 24 | Download this package from `GitHub `_ and navigate to there. Then simply run the line: :: 25 | 26 | pip install -e . 27 | 28 | This will check all dependencies and install this package as editable. 29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/Loudspeaker_decoder.py: -------------------------------------------------------------------------------- 1 | # --- 2 | # jupyter: 3 | # jupytext: 4 | # text_representation: 5 | # extension: .py 6 | # format_name: percent 7 | # format_version: '1.1' 8 | # jupytext_version: 0.8.4 9 | # kernelspec: 10 | # display_name: Python 3 11 | # language: python 12 | # name: python3 13 | # language_info: 14 | # codemirror_mode: 15 | # name: ipython 16 | # version: 3 17 | # file_extension: .py 18 | # mimetype: text/x-python 19 | # name: python 20 | # nbconvert_exporter: python 21 | # pygments_lexer: ipython3 22 | # version: 3.6.8 23 | # --- 24 | 25 | # %% 26 | import numpy as np 27 | 28 | import matplotlib.pyplot as plt 29 | 30 | from spaudiopy import io, plot, utils, sig, decoder, sph, grids 31 | 32 | 33 | # %% User setup 34 | setupname = "graz" 35 | LISTEN = True 36 | listener_position = [0, 0, 0] 37 | 38 | 39 | if setupname == "frontal_partial": 40 | ls_dirs = np.array([[-80, -45, 0, 45, 80, -60, -30, 30, 60], 41 | [0, 0, 0, 0, 0, 60, 60, 60, 60]]) 42 | ls_dirs[1, :] = 90 - ls_dirs[1, :] 43 | ls_x, ls_y, ls_z = utils.sph2cart(utils.deg2rad(ls_dirs[0, :]), 44 | utils.deg2rad(ls_dirs[1, :])) 45 | 46 | normal_limit = 85 47 | aperture_limit = 90 48 | opening_limit = 150 49 | blacklist = None 50 | 51 | ls_setup = decoder.LoudspeakerSetup(ls_x, ls_y, ls_z, listener_position) 52 | ls_setup.pop_triangles(normal_limit, aperture_limit, opening_limit, 53 | blacklist) 54 | 55 | elif setupname == "graz": 56 | normal_limit = 85 57 | aperture_limit = 90 58 | opening_limit = 135 59 | blacklist = None 60 | ls_setup = io.load_layout("../data/ls_layouts/Graz.json", 61 | listener_position=listener_position) 62 | ls_setup.pop_triangles(normal_limit, aperture_limit, opening_limit, 63 | blacklist) 64 | 65 | else: 66 | raise ValueError 67 | 68 | 69 | # %% Show setup 70 | ls_setup.show() 71 | plot.hull_normals(ls_setup) 72 | 73 | # Test source location 74 | src = np.array([1, 0.5, 2.5]) 75 | src_azi, src_zen, _ = utils.cart2sph(*src.tolist()) 76 | 77 | # %% VBAP 78 | gains_vbap = decoder.vbap(src, ls_setup, norm=1) # norm1 because binaural 79 | 80 | 81 | # %% Ambisonic decoding 82 | # Ambisonic setup 83 | N_e = ls_setup.get_characteristic_order() 84 | ls_setup.ambisonics_setup(update_hull=True, N_kernel=20) 85 | 86 | # Show ALLRAP hulls 87 | plot.hull(ls_setup.ambisonics_hull, title='Ambisonic hull') 88 | 89 | # ALLRAP 90 | gains_allrap = decoder.allrap(src, ls_setup, N_sph=N_e) 91 | # ALLRAP2 92 | gains_allrap2 = decoder.allrap2(src, ls_setup, N_sph=N_e) 93 | # ALLRAD 94 | input_F_nm = sph.sh_matrix(N_e, src_azi, src_zen, 'real').T # SH dirac 95 | out_allrad = decoder.allrad(input_F_nm, ls_setup, N_sph=N_e) 96 | out_allrad2 = decoder.allrad2(input_F_nm, ls_setup, N_sph=N_e) 97 | 98 | 99 | utils.test_diff(gains_allrap, out_allrad, msg="ALLRAD and ALLRAP:") 100 | utils.test_diff(gains_allrap2, out_allrad2, msg="ALLRAD2 and ALLRAP2:") 101 | 102 | # Nearest Loudspeaker 103 | gains_nls = decoder.nearest_loudspeaker(src, ls_setup) 104 | 105 | # %% test multiple sources 106 | _grid, _weights = grids.load_Fliege_Maier_nodes(10) 107 | G_vbap = decoder.vbap(_grid, ls_setup) 108 | G_allrap = decoder.allrap(_grid, ls_setup) 109 | G_allrap2 = decoder.allrap2(_grid, ls_setup) 110 | G_vbip = decoder.vbip(_grid, ls_setup) 111 | 112 | # %% Look at some performance measures 113 | plot.decoder_performance(ls_setup, 'NLS') 114 | plot.decoder_performance(ls_setup, 'VBAP') 115 | plot.decoder_performance(ls_setup, 'VBAP', norm=1, retain_outside=True) 116 | plt.suptitle('VBAP with imaginary loudspeaker and norm1') 117 | plot.decoder_performance(ls_setup, 'VBIP', retain_outside=True) 118 | plt.suptitle('VBIP with imaginary loudspeaker') 119 | plot.decoder_performance(ls_setup, 'EPAD') 120 | plot.decoder_performance(ls_setup, 'ALLRAP') 121 | plot.decoder_performance(ls_setup, 'ALLRAP2') 122 | 123 | 124 | # %% Binauralize 125 | fs = 44100 126 | hrirs = io.load_hrirs(fs, jobs_count=1) 127 | 128 | l_vbap_ir, r_vbap_ir = ls_setup.binauralize(ls_setup.loudspeaker_signals( 129 | gains_vbap), fs) 130 | 131 | l_allrap_ir, r_allrap_ir = ls_setup.binauralize(ls_setup.loudspeaker_signals( 132 | gains_allrap), fs) 133 | l_allrap2_ir, r_allrap2_ir = ls_setup.binauralize(ls_setup.loudspeaker_signals( 134 | gains_allrap2), fs) 135 | 136 | l_nls_ir, r_nls_ir = ls_setup.binauralize(ls_setup.loudspeaker_signals( 137 | gains_nls), fs) 138 | 139 | 140 | # %% 141 | fig, axs = plt.subplots(5, 1) 142 | axs[0].plot(hrirs.nearest_hrirs(src_azi, src_zen)[0]) 143 | axs[0].plot(hrirs.nearest_hrirs(src_azi, src_zen)[1]) 144 | axs[0].set_title("hrir") 145 | axs[1].plot(l_vbap_ir) 146 | axs[1].plot(r_vbap_ir) 147 | axs[1].set_title("binaural VBAP") 148 | axs[2].plot(l_allrap_ir) 149 | axs[2].plot(r_allrap_ir) 150 | axs[2].set_title("binaural ALLRAP") 151 | axs[3].plot(l_allrap2_ir) 152 | axs[3].plot(r_allrap2_ir) 153 | axs[3].set_title("binaural ALLRAP2") 154 | axs[4].plot(l_nls_ir) 155 | axs[4].plot(r_nls_ir) 156 | axs[4].set_title("binaural NLS") 157 | for ax in axs: 158 | ax.grid(True) 159 | plt.tight_layout() 160 | 161 | # Listen to some 162 | s_in = sig.MonoSignal.from_file('../data/piano_mono.flac', fs) 163 | s_in.trim(2.6, 6) 164 | 165 | s_out_vbap = sig.MultiSignal(2*[s_in.signal], fs=fs) 166 | s_out_vbap = s_out_vbap.conv([l_vbap_ir, r_vbap_ir]) 167 | 168 | s_out_allrap = sig.MultiSignal(2*[s_in.signal], fs=fs) 169 | s_out_allrap = s_out_allrap.conv([l_allrap_ir, r_allrap_ir]) 170 | 171 | s_out_allrap2 = sig.MultiSignal(2*[s_in.signal], fs=fs) 172 | s_out_allrap2 = s_out_allrap2.conv([l_allrap2_ir, r_allrap2_ir]) 173 | 174 | s_out_hrir = sig.MultiSignal(2*[s_in.signal], fs=fs) 175 | s_out_hrir = s_out_hrir.conv([hrirs.nearest_hrirs(src_azi, src_zen)[0], 176 | hrirs.nearest_hrirs(src_azi, src_zen)[1]]) 177 | 178 | 179 | if LISTEN: 180 | print("input") 181 | s_in.play() 182 | print("hrir") 183 | s_out_hrir.play() 184 | print("vbap") 185 | s_out_vbap.play() 186 | print("allrap") 187 | s_out_allrap.play() 188 | print("allrap2") 189 | s_out_allrap2.play() 190 | 191 | fig = plt.figure() 192 | fig.add_subplot(5, 1, 1) 193 | plt.plot(s_in.signal) 194 | plt.grid(True) 195 | plt.title("dry") 196 | fig.add_subplot(5, 1, 2) 197 | plt.plot(s_out_hrir.get_signals().T) 198 | plt.grid(True) 199 | plt.title("hrir") 200 | fig.add_subplot(5, 1, 3) 201 | plt.plot(s_out_vbap.get_signals().T) 202 | plt.grid(True) 203 | plt.title("binaural VBAP") 204 | fig.add_subplot(5, 1, 4) 205 | plt.plot(s_out_allrap.get_signals().T) 206 | plt.grid(True) 207 | plt.title("binaural ALLRAP") 208 | fig.add_subplot(5, 1, 5) 209 | plt.plot(s_out_allrap2.get_signals().T) 210 | plt.grid(True) 211 | plt.title("binaural ALLRAP2") 212 | plt.tight_layout() 213 | 214 | # Auralize with SSR-BRS renderer 215 | io.write_ssr_brirs_loudspeaker('allrap_brirs.wav', 216 | ls_setup.loudspeaker_signals(gains_allrap2), 217 | ls_setup, fs) 218 | 219 | plt.show() 220 | -------------------------------------------------------------------------------- /examples/SDM.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import numpy as np 5 | 6 | import spaudiopy as spa 7 | 8 | LISTEN = True 9 | 10 | # 5.0+4 Setup 11 | ls_dirs = np.array([[0, -30, 30, 110, -110, -30, 30, 110, -110], 12 | [0, 0, 0, 0, 0, 45, 45, 45, 45]]) 13 | ls_x, ls_y, ls_z = spa.utils.sph2cart(spa.utils.deg2rad(ls_dirs[0, :]), 14 | spa.utils.deg2rad(90 - ls_dirs[1, :])) 15 | 16 | ls_setup = spa.decoder.LoudspeakerSetup(ls_x, ls_y, ls_z) 17 | ls_setup.show() 18 | 19 | # Load SH impulse response 20 | ambi_ir = spa.sig.MultiSignal.from_file('../data/IR_Gewandhaus_SH1.wav') 21 | # convert to B-format 22 | ambi_ir = spa.sig.AmbiBSignal.sh_to_b(ambi_ir) 23 | 24 | fs = ambi_ir.fs 25 | 26 | # - SDM Encoding: 27 | sdm_p = ambi_ir.W 28 | sdm_azi, sdm_zen, _ = spa.parsa.pseudo_intensity(ambi_ir, f_bp=(100, 5000)) 29 | 30 | # Show first 10000 samples DOA 31 | spa.plot.doa(sdm_azi[:10000], sdm_zen[:10000], fs=fs, p=sdm_p[:10000]) 32 | 33 | 34 | # - SDM Decoding: 35 | # very quick stereo SDM decoding. This is only for testing! 36 | ir_st_l, ir_st_r = spa.parsa.render_stereo_sdm(sdm_p, sdm_azi, sdm_zen) 37 | 38 | # Loudspeaker decoding 39 | s_pos = np.array(spa.utils.sph2cart(sdm_azi, sdm_zen)).T 40 | ls_gains = spa.decoder.nearest_loudspeaker(s_pos, ls_setup) 41 | assert len(ls_gains) == len(sdm_p) 42 | ir_ls_l, ir_ls_r = spa.parsa.render_binaural_loudspeaker_sdm(sdm_p, ls_gains, 43 | ls_setup, fs) 44 | 45 | # Render some examples 46 | s_in = spa.sig.MonoSignal.from_file('../data/piano_mono.flac', fs) 47 | s_in.trim(2.6, 6) 48 | 49 | # Convolve with the omnidirectional IR 50 | s_out_p = s_in.copy() 51 | s_out_p.conv(sdm_p) 52 | 53 | # Convolve with the stereo SDM IR 54 | s_out_SDM_stereo = spa.sig.MultiSignal([s_in.signal, s_in.signal], fs=fs) 55 | s_out_SDM_stereo.conv([ir_st_l, ir_st_r]) 56 | 57 | # Convolve with the loudspeaker SDM IR 58 | s_out_SDM_ls = spa.sig.MultiSignal([s_in.signal, s_in.signal], fs=fs) 59 | s_out_SDM_ls.conv([ir_ls_l, ir_ls_r]) 60 | 61 | 62 | if LISTEN: 63 | print("input") 64 | s_in.play() 65 | print("output: Omni") 66 | s_out_p.play(gain=0.5/np.max(sdm_p)) 67 | print("output: Stereo SDM") 68 | s_out_SDM_stereo.play(gain=0.5/np.max(sdm_p)) 69 | print("output: Binaural Loudspeaker SDM") 70 | s_out_SDM_ls.play(gain=0.5/np.max(sdm_p)) 71 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """For pip install.""" 2 | 3 | import setuptools 4 | from os import path 5 | 6 | 7 | # read the contents of your README file 8 | this_directory = path.abspath(path.dirname(__file__)) 9 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | 12 | 13 | setuptools.setup(name='spaudiopy', 14 | version_config={ 15 | "template": "{tag}", 16 | "dev_template": "{tag}.post{ccount}+git.{sha}", 17 | "dirty_template": "{tag}.post{ccount}+git.{sha}.dirty", 18 | "starting_version": "0.0.0", 19 | }, 20 | setup_requires=['setuptools-git-versioning'], 21 | description='Spatial Audio Python Package', 22 | long_description=long_description, 23 | long_description_content_type='text/markdown', 24 | url='https://github.com/chris-hld/spaudiopy', 25 | author='Chris Hold', 26 | author_email='Christoph.Hold@alumni.aalto.fi', 27 | license='MIT', 28 | packages=setuptools.find_packages(), 29 | package_data={'spaudiopy': ['../data/Grids/*.mat', 30 | '../data/ls_layouts/*.json' 31 | ], 32 | }, 33 | install_requires=[ 34 | 'numpy', 35 | 'scipy', 36 | 'matplotlib !=3.1.*, !=3.2.*', # axis3d aspect broken 37 | 'soundfile', 38 | 'sounddevice', 39 | 'resampy', 40 | 'h5py' 41 | ], 42 | platforms='any', 43 | python_requires='>=3.6', 44 | classifiers=[ 45 | "Development Status :: 3 - Alpha", 46 | "License :: OSI Approved :: MIT License", 47 | "Operating System :: OS Independent", 48 | "Programming Language :: Python", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3 :: Only", 51 | "Topic :: Scientific/Engineering", 52 | ], 53 | zip_safe=True, 54 | ) 55 | -------------------------------------------------------------------------------- /spaudiopy/__init__.py: -------------------------------------------------------------------------------- 1 | """ spaudiopy 2 | 3 | .. rubric:: Submodules 4 | 5 | .. autosummary:: 6 | :toctree: 7 | 8 | io 9 | sig 10 | sph 11 | decoder 12 | process 13 | utils 14 | grids 15 | parsa 16 | plot 17 | 18 | """ 19 | from pathlib import Path 20 | from subprocess import run 21 | 22 | file_dir = Path(__file__).parent.absolute() 23 | 24 | 25 | try: 26 | r = run(['git', 'describe', '--tags', '--always', '--long', '--dirty'], 27 | check=True, capture_output=True, cwd=str(file_dir)) 28 | __version__ = r.stdout.decode().strip() 29 | 30 | except Exception: 31 | __version__ = "unknown (v0.2.0)" 32 | 33 | 34 | from . import decoder 35 | from . import grids 36 | from . import io 37 | from . import plot 38 | from . import process 39 | from . import parsa 40 | from . import sph 41 | from . import sig 42 | from . import utils 43 | -------------------------------------------------------------------------------- /spaudiopy/grids.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Sampling grids. 3 | 4 | .. plot:: 5 | :context: reset 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | plt.rcParams['axes.grid'] = True 10 | 11 | import spaudiopy as spa 12 | 13 | """ 14 | 15 | import os 16 | import numpy as np 17 | from warnings import warn 18 | from scipy.io import loadmat 19 | from . import sph 20 | 21 | 22 | def calculate_grid_weights(azi, zen, order=None): 23 | """Approximate quadrature weights by pseudo-inverse. 24 | 25 | Parameters 26 | ---------- 27 | azi : (Q,) array_like 28 | Azimuth. 29 | zen : (Q,) array_like 30 | Zenith / Colatitude. 31 | order : int, optional 32 | Supported order N, searched if not provided. 33 | 34 | Returns 35 | ------- 36 | weights : (Q,) array_like 37 | Grid / Quadrature weights. 38 | 39 | References 40 | ---------- 41 | Fornberg, B., & Martel, J. M. (2014). On spherical harmonics based 42 | numerical quadrature over the surface of a sphere. 43 | Advances in Computational Mathematics. 44 | 45 | """ 46 | if order is None: # search for max supported SHT order 47 | for itOrder in range(1, 100): 48 | cond = sph.check_cond_sht(itOrder, azi, zen, 'real', np.inf) 49 | if cond > 2*(itOrder+1): # experimental condition 50 | order = itOrder-1 51 | break 52 | assert (order > 0) 53 | Y = sph.sh_matrix(order, azi, zen, 'real') 54 | P_leftinv = np.linalg.pinv(Y) 55 | weights = np.sqrt(4*np.pi) * P_leftinv[0, :] 56 | if (np.abs(np.sum(weights) - 4*np.pi) > 0.01) or np.any(weights < 0): 57 | warn('Could not calculate weights') 58 | return weights 59 | 60 | 61 | def load_t_design(degree): 62 | """Return the unit coordinates of minimal T-designs. 63 | 64 | Parameters 65 | ---------- 66 | degree : int 67 | T-design degree between 1 and 21. 68 | 69 | Returns 70 | ------- 71 | vecs : (M, 3) numpy.ndarray 72 | Coordinates of points. 73 | 74 | Notes 75 | ----- 76 | Degree must be >= 2 * SH_order for spherical harmonic transform (SHT). 77 | 78 | References 79 | ---------- 80 | The designs have been copied from: 81 | http://neilsloane.com/sphdesigns/ 82 | and should be referenced as: 83 | 84 | "McLaren's Improved Snub Cube and Other New Spherical Designs in 85 | Three Dimensions", R. H. Hardin and N. J. A. Sloane, Discrete and 86 | Computational Geometry, 15 (1996), pp. 429-441. 87 | 88 | Examples 89 | -------- 90 | .. plot:: 91 | :context: close-figs 92 | 93 | vecs = spa.grids.load_t_design(degree=2*5) 94 | spa.plot.hull(spa.decoder.get_hull(*vecs.T)) 95 | 96 | """ 97 | if degree > 21: 98 | raise ValueError('Designs of order > 21 are not implemented.') 99 | elif degree < 1: 100 | raise ValueError('Order should be at least 1.') 101 | # extract 102 | current_file_dir = os.path.dirname(__file__) 103 | file_path = os.path.join(current_file_dir, 104 | '../data/Grids/t_designs_1_21.mat') 105 | mat = loadmat(file_path) 106 | t_designs_obj = mat['t_designs'] 107 | t_designs = t_designs_obj[0].tolist() 108 | # degree t>=2N should be used for SHT 109 | vecs = t_designs[degree - 1] 110 | return vecs 111 | 112 | 113 | def load_n_design(degree): 114 | """Return the unit coordinates of spherical N-design 115 | (Chebyshev-type quadrature rules). Seem to be equivalent but more 116 | modern t-designs. 117 | 118 | Parameters 119 | ---------- 120 | degree : int 121 | Degree of exactness N between 1 and 124. 122 | 123 | Returns 124 | ------- 125 | vecs : (M, 3) numpy.ndarray 126 | Coordinates of points. 127 | 128 | References 129 | ---------- 130 | The designs have been copied from: 131 | https://homepage.univie.ac.at/manuel.graef/quadrature.php 132 | 133 | Examples 134 | -------- 135 | .. plot:: 136 | :context: close-figs 137 | 138 | vecs = spa.grids.load_n_design(degree=2*5) 139 | spa.plot.hull(spa.decoder.get_hull(*vecs.T)) 140 | 141 | """ 142 | if degree > 124: 143 | raise ValueError('Designs of order > 124 are not implemented.') 144 | elif degree < 1: 145 | raise ValueError('Order should be at least 1.') 146 | # extract 147 | current_file_dir = os.path.dirname(__file__) 148 | file_path = os.path.join(current_file_dir, 149 | '../data/Grids/n_designs_1_124.mat') 150 | mat = loadmat(file_path) 151 | try: 152 | n_design = mat['N' + f'{degree:03}'] 153 | except KeyError: 154 | warn(f"Degree {degree} not defined, trying {degree+1} ...") 155 | n_design = load_n_design(degree + 1) 156 | return n_design 157 | 158 | 159 | def load_lebedev(degree): 160 | """Return the unit coordinates of Lebedev grid. 161 | 162 | Parameters 163 | ---------- 164 | degree : int 165 | Degree of precision p between 3 and 131. 166 | 167 | Returns 168 | ------- 169 | vecs : (M, 3) numpy.ndarray 170 | Coordinates of points. 171 | weights : array_like 172 | Quadrature weights. 173 | 174 | References 175 | ---------- 176 | The designs have been copied from: 177 | https://people.sc.fsu.edu/~jburkardt/datasets/sphere_lebedev_rule/sphere_lebedev_rule.html 178 | 179 | Examples 180 | -------- 181 | .. plot:: 182 | :context: close-figs 183 | 184 | vecs, weights = spa.grids.load_lebedev(degree=2*5) 185 | spa.plot.hull(spa.decoder.get_hull(*vecs.T)) 186 | 187 | """ 188 | if degree > 131: 189 | raise ValueError('Designs of order > 131 are not implemented.') 190 | elif degree < 3: 191 | raise ValueError('Order should be at least 3.') 192 | # extract 193 | current_file_dir = os.path.dirname(__file__) 194 | file_path = os.path.join(current_file_dir, 195 | '../data/Grids/lebedevQuadratures_3_131.mat') 196 | mat = loadmat(file_path) 197 | try: 198 | design = mat['lebedev_' + f'{degree:03}'] 199 | vecs = design[:, :3] 200 | weights = 4*np.pi * design[:, 3] 201 | if np.any(weights < 0): 202 | warn(f"Lebedev grid {degree} has negative weights.") 203 | except KeyError: 204 | warn(f"Degree {degree} not defined, trying {degree+1} ...") 205 | vecs, weights = load_lebedev(degree + 1) 206 | return vecs, weights 207 | 208 | 209 | def load_Fliege_Maier_nodes(grid_order): 210 | """Return Fliege-Maier grid nodes with associated weights. 211 | 212 | Parameters 213 | ---------- 214 | grid_order : int 215 | Grid order between 2 and 30 216 | 217 | Returns 218 | ------- 219 | vecs : (M, 3) numpy.ndarray 220 | Coordinates of points. 221 | weights : array_like 222 | Quadrature weights. 223 | 224 | References 225 | ---------- 226 | The designs have been copied from: 227 | http://www.personal.soton.ac.uk/jf1w07/nodes/nodes.html 228 | and should be referenced as: 229 | 230 | "A two-stage approach for computing cubature formulae for the sphere.", 231 | Jorg Fliege and Ulrike Maier, Mathematik 139T, Universitat Dortmund, 232 | Fachbereich Mathematik, Universitat Dortmund, 44221. 1996. 233 | 234 | Examples 235 | -------- 236 | .. plot:: 237 | :context: close-figs 238 | 239 | vecs, weights = spa.grids.load_Fliege_Maier_nodes(grid_order=5) 240 | spa.plot.hull(spa.decoder.get_hull(*vecs.T)) 241 | 242 | """ 243 | if grid_order > 30: 244 | raise ValueError('Designs of order > 30 are not implemented.') 245 | elif grid_order < 2: 246 | raise ValueError('Order should be at least 2.') 247 | # extract 248 | current_file_dir = os.path.dirname(__file__) 249 | file_path = os.path.join(current_file_dir, 250 | '../data/Grids/fliegeMaierNodes_1_30.mat') 251 | mat = loadmat(file_path) 252 | fliege_maier_nodes = np.squeeze(mat['fliegeNodes']) 253 | # grid_order >= N+1 should be used for SHT 254 | vecs = fliege_maier_nodes[grid_order - 1][:, :-1] 255 | weights = fliege_maier_nodes[grid_order - 1][:, -1] # sum(weights) == 4pi 256 | return vecs, weights 257 | 258 | 259 | def load_maxDet(degree): 260 | """Return Maximum Determinant (Fekete, Extremal) points on the sphere. 261 | 262 | Parameters 263 | ---------- 264 | degree : int 265 | Degree between 1 and 200. 266 | 267 | Returns 268 | ------- 269 | vecs : (M, 3) numpy.ndarray 270 | Coordinates of points. 271 | weights : array_like 272 | Quadrature weights. 273 | 274 | References 275 | ---------- 276 | The designs have been copied from: 277 | https://web.maths.unsw.edu.au/~rsw/Sphere/MaxDet/ 278 | 279 | Examples 280 | -------- 281 | .. plot:: 282 | :context: close-figs 283 | 284 | vecs, weights = spa.grids.load_maxDet(degree=5) 285 | spa.plot.hull(spa.decoder.get_hull(*vecs.T)) 286 | 287 | """ 288 | if degree > 200: 289 | raise ValueError('Designs of order > 200 are not implemented.') 290 | elif degree < 1: 291 | raise ValueError('Order should be at least 1.') 292 | # extract 293 | current_file_dir = os.path.dirname(__file__) 294 | file_path = os.path.join(current_file_dir, 295 | '../data/Grids/maxDetPoints_1_200.mat') 296 | mat = loadmat(file_path) 297 | try: 298 | design = mat['maxDet_' + f'{degree:03}'] 299 | vecs = design[:, :3] 300 | weights = design[:, 3] 301 | if np.any(weights < 0): 302 | warn(f"Grid {degree} has negative weights.") 303 | except KeyError: 304 | warn(f"Degree {degree} not defined, trying {degree+1} ...") 305 | vecs, weights = load_maxDet(degree + 1) 306 | return vecs, weights 307 | 308 | 309 | def equal_angle(n): 310 | """Equi-angular sampling points on a sphere. 311 | 312 | Parameters 313 | ---------- 314 | n : int 315 | Maximum order. 316 | 317 | Returns 318 | ------- 319 | azi : array_like 320 | Azimuth. 321 | zen : array_like 322 | Colatitude. 323 | weights : array_like 324 | Quadrature weights. 325 | 326 | References 327 | ---------- 328 | Rafaely, B. (2015). Fundamentals of Spherical Array Processing., sec.3.2 329 | 330 | Examples 331 | -------- 332 | .. plot:: 333 | :context: close-figs 334 | 335 | azi, zen, weights = spa.grids.equal_angle(n=5) 336 | spa.plot.hull(spa.decoder.get_hull(*spa.utils.sph2cart(azi, zen))) 337 | 338 | """ 339 | azi = np.linspace(0, 2*np.pi, 2*n+2, endpoint=False) 340 | zen, d_zen = np.linspace(0, np.pi, 2*n+2, endpoint=False, retstep=True) 341 | zen += d_zen/2 342 | 343 | weights = np.zeros_like(zen) 344 | p = np.arange(1, 2*n+2, 2) 345 | for i, theta in enumerate(zen): 346 | weights[i] = 2*np.pi/(n+1) * np.sin(theta) * np.sum(np.sin(p*theta)/p) 347 | 348 | azi = np.tile(azi, 2*n+2) 349 | zen = np.repeat(zen, 2*n+2) 350 | weights = np.repeat(weights, 2*n+2) 351 | weights /= n+1 # sum(weights) == 4pi 352 | return azi, zen, weights 353 | 354 | 355 | def gauss(n): 356 | """Gauss-Legendre sampling points on sphere. 357 | 358 | Parameters 359 | ---------- 360 | n : int 361 | Maximum order. 362 | 363 | Returns 364 | ------- 365 | azi : array_like 366 | Azimuth. 367 | zen : array_like 368 | Colatitude. 369 | weights : array_like 370 | Quadrature weights. 371 | 372 | References 373 | ---------- 374 | Rafaely, B. (2015). Fundamentals of Spherical Array Processing., sec.3.3 375 | 376 | Examples 377 | -------- 378 | .. plot:: 379 | :context: close-figs 380 | 381 | azi, zen, weights = spa.grids.gauss(n=5) 382 | spa.plot.hull(spa.decoder.get_hull(*spa.utils.sph2cart(azi, zen))) 383 | 384 | """ 385 | azi = np.linspace(0, 2*np.pi, 2*n+2, endpoint=False) 386 | x, weights = np.polynomial.legendre.leggauss(n+1) 387 | zen = np.arccos(x) 388 | azi = np.tile(azi, n+1) 389 | zen = np.repeat(zen, 2*n+2) 390 | weights = np.repeat(weights, 2*n+2) 391 | weights *= np.pi / (n+1) # sum(weights) == 4pi 392 | return azi, zen, weights 393 | 394 | 395 | def equal_polar_angle(n): 396 | """Equi-angular sampling points on a circle. 397 | 398 | Parameters 399 | ---------- 400 | n : int 401 | Maximum order 402 | 403 | Returns 404 | ------- 405 | pol : array_like 406 | Polar angle. 407 | weights : array_like 408 | Weights. 409 | """ 410 | num_mic = 2*n+1 411 | pol = np.linspace(0, 2*np.pi, num=num_mic, endpoint=False) 412 | weights = 1/num_mic * np.ones(num_mic) 413 | return pol, weights 414 | -------------------------------------------------------------------------------- /spaudiopy/io.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Input Output (IO) helpers. 3 | 4 | .. plot:: 5 | :context: reset 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | plt.rcParams['axes.grid'] = True 10 | 11 | import spaudiopy as spa 12 | 13 | """ 14 | 15 | import os 16 | from warnings import warn 17 | import multiprocessing 18 | import json 19 | from datetime import datetime 20 | 21 | import numpy as np 22 | from scipy.io import loadmat, savemat 23 | import h5py 24 | 25 | import soundfile as sf 26 | 27 | from . import utils, sig, decoder, grids, sph, process, parsa, __version__ 28 | 29 | 30 | def load_audio(filenames, fs=None): 31 | """Load mono and multichannel audio from files. 32 | 33 | Parameters 34 | ---------- 35 | filenames : string or list of strings 36 | Audio files. 37 | 38 | Returns 39 | ------- 40 | sig : sig.MonoSignal or sig.MultiSignal 41 | Audio signal. 42 | 43 | """ 44 | loaded_data = [] 45 | loaded_fs = [] 46 | # pack in list if only a single string 47 | if not isinstance(filenames, (list, tuple)): 48 | filenames = [filenames] 49 | for file in filenames: 50 | data, fs_file = sf.read(os.path.expanduser(file)) 51 | if data.ndim != 1: 52 | # detect and split interleaved wav 53 | for c in data.T: 54 | loaded_data.append(c) 55 | else: 56 | loaded_data.append(data) 57 | loaded_fs.append(fs_file) 58 | # Assert same sample rate for all channels 59 | assert all(x == loaded_fs[0] for x in loaded_fs) 60 | # Check against provided samplerate 61 | if fs is not None: 62 | if fs != loaded_fs[0]: 63 | raise ValueError("File: Found different fs:" + str(loaded_fs[0])) 64 | else: 65 | fs = loaded_fs[0] 66 | # MonoSignal or MultiSignal 67 | if len(loaded_data) == 1: 68 | return sig.MonoSignal(loaded_data, fs=fs) 69 | else: 70 | return sig.MultiSignal([*loaded_data], fs=fs) 71 | 72 | 73 | def save_audio(signal, filename, fs=None, subtype='FLOAT'): 74 | """Save signal to audio file. 75 | 76 | Parameters 77 | ---------- 78 | signal : sig. MonoSignal, sig.MultiSignal or np.ndarray 79 | Audio Signal, forwarded to sf.write(); (frames x channels). 80 | filename : string 81 | Audio file name. 82 | fs : int 83 | fs(t). 84 | subtype : optional 85 | 86 | """ 87 | if isinstance(sig, sig.MonoSignal): 88 | if fs is not None: 89 | assert (signal.fs == fs) 90 | 91 | if type(signal) == sig.MonoSignal: 92 | data = signal.signal 93 | data_fs = signal.fs 94 | elif type(signal) in (sig.MultiSignal, sig.AmbiBSignal): 95 | data = signal.get_signals().T 96 | data_fs = signal.fs 97 | elif isinstance(signal, (np.ndarray, np.generic)): 98 | if fs is None: 99 | raise ValueError("Needs fs for generic audio data!") 100 | data = signal 101 | data_fs = fs 102 | else: 103 | raise NotImplementedError('Data type not supported.') 104 | data = np.asarray(data) 105 | if (data.ndim > 1): 106 | assert (data.ndim == 2) 107 | if (data.shape[1] > data.shape[0]): 108 | warn(f"Writing file with {data.shape[1]} channels") 109 | if (np.max(np.abs(data)) > 1.0): 110 | warn(f"Audio clipped! ({np.max(np.abs(data)):.2f})") 111 | sf.write(os.path.expanduser(filename), data, data_fs, subtype=subtype) 112 | 113 | 114 | def load_hrirs(fs, filename=None, jobs_count=None): 115 | """Convenience function to load 'HRIRs.mat'. 116 | The file contains ['hrir_l', 'hrir_r', 'fs', 'azi', 'zen']. 117 | 118 | Parameters 119 | ---------- 120 | fs : int 121 | fs(t). 122 | filename : string, optional 123 | HRTF.mat file or default set, or 'dummy' for debugging. 124 | jobs_count : int or None, optional 125 | Number of parallel jobs for resample_hrirs() in get_default_hrirs(), 126 | 'None' employs 'cpu_count'. 127 | 128 | Returns 129 | ------- 130 | HRIRs : sig.HRIRs instance 131 | left : (g, h) numpy.ndarray 132 | h(t) for grid position g. 133 | right : (g, h) numpy.ndarray 134 | h(t) for grid position g. 135 | azi : (g,) array_like 136 | grid azimuth. 137 | zen : (g,) array_like 138 | grid zenith / colatitude. 139 | fs : int 140 | fs(t). 141 | 142 | """ 143 | if filename == 'dummy': 144 | azi, zen, _ = grids.gauss(15) 145 | # Create diracs as dummy 146 | hrir_l = np.zeros([len(azi), 256]) 147 | hrir_r = np.zeros_like(hrir_l) 148 | hrir_fs = fs 149 | # apply ILD / ITD 150 | a_l = 0.5*(1 + np.cos(azi - np.pi/2)) * np.sin(zen) 151 | a_r = 0.5*(1 + np.cos(azi + np.pi/2)) * np.sin(zen) 152 | hrir_l[np.arange(len(azi)), (a_r * 0.75e-3 * fs + 10).astype(int)] = 1. 153 | hrir_r[np.arange(len(azi)), (a_l * 0.75e-3 * fs + 10).astype(int)] = 1. 154 | hrir_l *= a_l[:, np.newaxis] + 0.1 155 | hrir_r *= a_r[:, np.newaxis] + 0.1 156 | hrir_l[np.arange(len(azi)), (a_l * 5).astype(int)] = hrir_l[:, 0] 157 | hrir_r[np.arange(len(azi)), (a_r * 5).astype(int)] = hrir_r[:, 0] 158 | 159 | elif filename is None: 160 | # default 161 | if fs not in [44100, 48000, 96000]: 162 | raise NotImplementedError('44100, 48000, 96000' 163 | ' default available.') 164 | default_file = '../data/HRIRs/' + 'HRIRs_default_' + str(fs) + '.mat' 165 | current_file_dir = os.path.dirname(__file__) 166 | filename = os.path.join(current_file_dir, default_file) 167 | 168 | try: 169 | mat = loadmat(filename) 170 | except FileNotFoundError: 171 | warn("No default hrirs. Generating them...") 172 | get_default_hrirs(jobs_count=jobs_count) 173 | mat = loadmat(filename) 174 | else: 175 | mat = loadmat(os.path.expanduser(filename)) 176 | 177 | if not filename == 'dummy': 178 | hrir_l = np.array(np.squeeze(mat['hrir_l']), dtype=float) 179 | hrir_r = np.array(np.squeeze(mat['hrir_r']), dtype=float) 180 | try: 181 | hrir_fs = int(mat['fs']) 182 | except KeyError: 183 | hrir_fs = int(mat['SamplingRate']) 184 | 185 | azi = np.array(np.squeeze(mat['azi']), dtype=float) 186 | zen = np.array(np.squeeze(mat['zen']), dtype=float) 187 | 188 | HRIRs = sig.HRIRs(hrir_l, hrir_r, azi, zen, hrir_fs) 189 | assert HRIRs.fs == fs 190 | return HRIRs 191 | 192 | 193 | def get_default_hrirs(grid_azi=None, grid_zen=None, jobs_count=None): 194 | """Creates the default HRIRs loaded by load_hrirs() by inverse SHT. 195 | By default it renders onto a gauss grid of order N=35, and additionally 196 | resamples fs to 48kHz. 197 | 198 | Parameters 199 | ---------- 200 | grid_azi : array_like, optional 201 | grid_zen : array_like, optional 202 | jobs_count : int or None, optional 203 | Number of parallel jobs for resample_hrirs(), 204 | 'None' employs 'cpu_count'. 205 | 206 | Notes 207 | ----- 208 | HRTFs in SH domain obtained from 209 | http://dx.doi.org/10.14279/depositonce-5718.5 210 | 211 | """ 212 | default_file = '../data/HRIRs/FABIAN/' \ 213 | 'SphericalHarmonics/FABIAN_DIR_measured_HATO_0.mat' 214 | current_file_dir = os.path.dirname(__file__) 215 | filename = os.path.join(current_file_dir, default_file) 216 | # Load HRTF 217 | try: 218 | file = loadmat(filename) 219 | 220 | except FileNotFoundError: 221 | import requests, zipfile, io 222 | print("Downloading from https://depositonce.tu-berlin.de/handle/" 223 | "11303/6153.5 ...") 224 | r = requests.get('https://depositonce.tu-berlin.de/bitstreams/' 225 | '91447f43-b5c3-446a-bb7e-0d9e53f95ff9/download') 226 | with zipfile.ZipFile(io.BytesIO(r.content)) as zip_ref: 227 | zip_ref.extractall(os.path.join(current_file_dir, 228 | '../data/HRIRs/FABIAN/')) 229 | file = loadmat(filename) 230 | # CTF already compensated in DIR 231 | # Extracting the data is a bit ugly here... 232 | SamplingRate = int(file['SamplingRate']) 233 | SH_l = file['SH'][0][0][0] 234 | SH_r = file['SH'][0][0][1] 235 | f = np.squeeze(file['SH'][0][0][5]) 236 | 237 | # default grid: 238 | if (grid_azi is None) and (grid_zen is None): 239 | grid_azi, grid_zen, _ = grids.gauss(35) # grid positions 240 | 241 | # Inverse SHT 242 | HRTF_l = sph.inverse_sht(SH_l, grid_azi, grid_zen, 'complex') 243 | HRTF_r = sph.inverse_sht(SH_r, grid_azi, grid_zen, 'complex') 244 | assert HRTF_l.shape == HRTF_r.shape 245 | # ifft 246 | hrir_l = np.fft.irfft(HRTF_l) # creates 256 samples(t) 247 | hrir_r = np.fft.irfft(HRTF_r) # creates 256 samples(t) 248 | assert hrir_l.shape == hrir_r.shape 249 | 250 | # Resample 251 | fs_target = 48000 252 | hrir_l_48k, hrir_r_48k, _ = process.resample_hrirs(hrir_l, hrir_r, 253 | SamplingRate, 254 | fs_target, 255 | jobs_count=jobs_count) 256 | fs_target = 96000 257 | hrir_l_96k, hrir_r_96k, _ = process.resample_hrirs(hrir_l, hrir_r, 258 | SamplingRate, 259 | fs_target, 260 | jobs_count=jobs_count) 261 | 262 | savemat(os.path.join(current_file_dir, '../data/HRIRs/' 263 | 'HRIRs_default_44100.mat'), 264 | {'hrir_l': hrir_l, 265 | 'hrir_r': hrir_r, 266 | 'azi': grid_azi, 'zen': grid_zen, 267 | 'fs': 44100}) 268 | savemat(os.path.join(current_file_dir, '../data/HRIRs/' 269 | 'HRIRs_default_48000.mat'), 270 | {'hrir_l': hrir_l_48k, 271 | 'hrir_r': hrir_r_48k, 272 | 'azi': grid_azi, 'zen': grid_zen, 273 | 'fs': 48000}) 274 | savemat(os.path.join(current_file_dir, '../data/HRIRs/' 275 | 'HRIRs_default_96000.mat'), 276 | {'hrir_l': hrir_l_96k, 277 | 'hrir_r': hrir_r_96k, 278 | 'azi': grid_azi, 'zen': grid_zen, 279 | 'fs': 96000}) 280 | print("Saved new default HRIRs.") 281 | 282 | 283 | def load_sofa_data(filename): 284 | """Load .sofa file into python dictionary that contains the data in 285 | numpy arrays.""" 286 | with h5py.File(os.path.expanduser(filename), 'r') as f: 287 | out_dict = {} 288 | for key, value in f.items(): 289 | out_dict[key] = np.squeeze(value) 290 | return out_dict 291 | 292 | 293 | def load_sofa_hrirs(filename): 294 | """ Load SOFA file containing HRIRs. 295 | 296 | Parameters 297 | ---------- 298 | filename : string 299 | SOFA filepath. 300 | 301 | Returns 302 | ------- 303 | HRIRs : sig.HRIRs instance 304 | left : (g, h) numpy.ndarray 305 | h(t) for grid position g. 306 | right : (g, h) numpy.ndarray 307 | h(t) for grid position g. 308 | azi : (g,) array_like 309 | grid azimuth. 310 | zen : (g,) array_like 311 | grid zenith / colatitude. 312 | fs : int 313 | fs(t). 314 | 315 | """ 316 | sdata = load_sofa_data(os.path.expanduser(filename)) 317 | fs = int(sdata['Data.SamplingRate']) 318 | irs = np.asarray(sdata['Data.IR']) 319 | grid = np.asarray(sdata['SourcePosition']) 320 | assert (abs((grid[:, 2]-grid[:, 2].mean())).mean() < 0.1) # Otherwise not r 321 | grid_azi, grid_zen = np.deg2rad(grid[:, 0]), np.pi/2 - np.deg2rad( 322 | grid[:, 1]) 323 | assert (all(grid_zen > -10e-6)) # Otherwise not zen 324 | irs_left = np.squeeze(irs[:, 0, :]) 325 | irs_right = np.squeeze(irs[:, 1, :]) 326 | HRIRs = sig.HRIRs(irs_left, irs_right, grid_azi, grid_zen, fs) 327 | return HRIRs 328 | 329 | 330 | def sofa_to_sh(filename, N_sph, sh_type='real'): 331 | """Load and transform SOFA IRs to the Spherical Harmonic Domain. 332 | 333 | Parameters 334 | ---------- 335 | filename : string 336 | SOFA file name. 337 | N_sph : int 338 | Spherical Harmonic Transform order. 339 | sh_type : 'real' (default) or 'complex' spherical harmonics. 340 | 341 | Returns 342 | ------- 343 | IRs_nm : (2, (N_sph+1)**2, S) numpy.ndarray 344 | Left and right (stacked) SH coefficients. 345 | fs : int 346 | 347 | """ 348 | hrirs = load_sofa_hrirs(filename) 349 | fs = hrirs.fs 350 | grid_azi, grid_zen = hrirs.azi, hrirs.zen 351 | # Pinv / lstsq since we can't be sure about the grid 352 | Y_pinv = np.linalg.pinv(sph.sh_matrix(N_sph, grid_azi, grid_zen, sh_type)) 353 | irs = np.stack((hrirs.left, hrirs.right), axis=0) 354 | IRs_nm = Y_pinv @ irs 355 | return IRs_nm, fs 356 | 357 | 358 | def load_sdm(filename, init_nan=True): 359 | """Convenience function to load 'SDM.mat'. 360 | The file contains 361 | ['h_ref' or 'p', 'sdm_azi' or 'sdm_phi', 'sdm_zen' or 'sdm_theta', 'fs']. 362 | 363 | Parameters 364 | ---------- 365 | filename : string 366 | SDM.mat file 367 | init_nan : bool, optional 368 | Initialize nan to [0, pi/2]. 369 | 370 | Returns 371 | ------- 372 | h : (n,) array_like 373 | p(t). 374 | sdm_azi : (n,) array_like 375 | Azimuth angle. 376 | sdm_zen : (n,) array_like 377 | Colatitude angle. 378 | fs : int 379 | fs(t). 380 | 381 | """ 382 | mat = loadmat(os.path.expanduser(filename)) 383 | try: 384 | h = np.array(np.squeeze(mat['h_ref']), dtype=float) 385 | except KeyError: 386 | h = np.array(np.squeeze(mat['p']), dtype=float) 387 | try: 388 | sdm_azi = np.array(np.squeeze(mat['sdm_azi']), dtype=float) 389 | except KeyError: 390 | sdm_azi = np.array(np.squeeze(mat['sdm_phi']), dtype=float) 391 | try: 392 | sdm_zen = np.array(np.squeeze(mat['sdm_zen']), dtype=float) 393 | except KeyError: 394 | sdm_zen = np.array(np.squeeze(mat['sdm_theta']), dtype=float) 395 | 396 | if init_nan: 397 | sdm_azi[np.isnan(sdm_azi)] = 0. 398 | sdm_zen[np.isnan(sdm_zen)] = np.pi / 2 399 | fs = int(mat['fs']) 400 | return h, sdm_azi, sdm_zen, fs 401 | 402 | 403 | def write_ssr_brirs_loudspeaker(filename, ls_irs, hull, fs, subtype='FLOAT', 404 | hrirs=None, jobs_count=1): 405 | """Write binaural room impulse responses (BRIRs) and save as wav file. 406 | 407 | The azimuth resolution is one degree. The channels are interleaved and 408 | directly compatible to the SoundScape Renderer (SSR) ssr-brs. 409 | 410 | Parameters 411 | ---------- 412 | filename : string 413 | ls_irs : (L, S) np.ndarray 414 | Impulse responses of L loudspeakers, 415 | e.g. by hull.loudspeaker_signals(). 416 | hull : decoder.LoudspeakerSetup 417 | fs : int 418 | subtype : forwarded to sf.write(), optional 419 | hrirs : sig.HRIRs, optional 420 | jobs_count : int, optional 421 | [CPU Cores], Number of Processes, switches implementation for n > 1. 422 | 423 | """ 424 | if hrirs is None: 425 | hrirs = load_hrirs(fs=fs) 426 | assert (hrirs.fs == fs) 427 | 428 | if jobs_count is None: 429 | jobs_count = multiprocessing.cpu_count() 430 | 431 | if not filename[-4:] == '.wav': 432 | filename = filename + '.wav' 433 | 434 | ssr_brirs = np.zeros((720, ls_irs.shape[1] + len(hrirs) - 1)) 435 | 436 | if jobs_count == 1: 437 | for angle in range(0, 360): 438 | ir_l, ir_r = hull.binauralize(ls_irs, fs, 439 | orientation=(np.deg2rad(angle), 0), 440 | hrirs=hrirs) 441 | # left 442 | ssr_brirs[2 * angle, :] = ir_l 443 | # right 444 | ssr_brirs[2 * angle + 1, :] = ir_r 445 | 446 | elif jobs_count > 1: 447 | with multiprocessing.Pool(processes=jobs_count) as pool: 448 | results = pool.starmap(hull.binauralize, 449 | map(lambda a: (ls_irs, fs, 450 | (np.deg2rad(a), 0), 451 | hrirs), 452 | range(0, 360))) 453 | # extract 454 | ir_l = [ir[0] for ir in results] 455 | ir_r = [ir[1] for ir in results] 456 | for angle in range(0, 360): 457 | # left 458 | ssr_brirs[2 * angle, :] = ir_l[angle] 459 | # right 460 | ssr_brirs[2 * angle + 1, :] = ir_r[angle] 461 | 462 | # normalize 463 | if np.max(np.abs(ssr_brirs)) > 1: 464 | warn('Normalizing BRIRs') 465 | ssr_brirs = ssr_brirs / np.max(np.abs(ssr_brirs)) 466 | 467 | # write to file 468 | save_audio(ssr_brirs.T, filename, fs, subtype=subtype) 469 | 470 | 471 | def write_ssr_brirs_sdm(filename, sdm_p, sdm_phi, sdm_theta, fs, 472 | subtype='FLOAT', hrirs=None): 473 | """Write binaural room impulse responses (BRIRs) and save as wav file. 474 | 475 | The azimuth resolution is one degree. The channels are interleaved and 476 | directly compatible to the SoundScape Renderer (SSR) ssr-brs. 477 | 478 | Parameters 479 | ---------- 480 | filename : string 481 | sdm_p : (n,) array_like 482 | Pressure p(t). 483 | sdm_phi : (n,) array_like 484 | Azimuth phi(t). 485 | sdm_theta : (n,) array_like 486 | Colatitude theta(t). 487 | fs : int 488 | subtype : forwarded to sf.write(), optional 489 | hrirs : sig.HRIRs, optional 490 | 491 | """ 492 | if hrirs is None: 493 | hrirs = load_hrirs(fs=fs) 494 | assert (hrirs.fs == fs) 495 | 496 | if not filename[-4:] == '.wav': 497 | filename = filename + '.wav' 498 | 499 | ssr_brirs = np.zeros((720, len(sdm_p) + len(hrirs) - 1)) 500 | for angle in range(0, 360): 501 | sdm_phi_rot = sdm_phi - np.deg2rad(angle) 502 | ir_l, ir_r = parsa.render_bsdm(sdm_p, sdm_phi_rot, sdm_theta, 503 | hrirs=hrirs) 504 | # left 505 | ssr_brirs[2 * angle, :] = ir_l 506 | # right 507 | ssr_brirs[2 * angle + 1, :] = ir_r 508 | 509 | # normalize 510 | if np.max(np.abs(ssr_brirs)) > 1: 511 | warn('Normalizing BRIRs') 512 | ssr_brirs = ssr_brirs / np.max(np.abs(ssr_brirs)) 513 | 514 | # write to file 515 | save_audio(ssr_brirs.T, filename, fs, subtype=subtype) 516 | 517 | 518 | def load_layout(filename, listener_position=None, N_kernel=50): 519 | """Load loudspeaker layout from json configuration file.""" 520 | with open(os.path.expanduser(filename), 'r') as f: 521 | in_data = json.load(f) 522 | 523 | layout = in_data['LoudspeakerLayout'] 524 | ls_data = layout['Loudspeakers'] 525 | 526 | azi = np.array([ls['Azimuth'] for ls in ls_data]) 527 | ele = np.array([ls['Elevation'] for ls in ls_data]) 528 | if np.any(ele < -90) or np.any(ele > +90): 529 | warn("Elevation out of bounds! (+-90)") 530 | r = np.array([ls['Radius'] for ls in ls_data]) 531 | try: 532 | # not actually used, yet 533 | ls_gains = np.array([ls['Gain'] for ls in ls_data]) 534 | except KeyError as e: 535 | warn('KeyError : {}, will return unit gain!'.format(e)) 536 | ls_gains = np.ones_like(azi) 537 | try: 538 | isImaginary = np.array([ls['IsImaginary'] for ls in ls_data]) 539 | except KeyError as e: 540 | warn('KeyError : {}, will return all False!'.format(e)) 541 | isImaginary = np.full_like(azi, False, dtype=bool) 542 | 543 | # first extract real loudspeakers 544 | ls_x, ls_y, ls_z = utils.sph2cart(utils.deg2rad(azi[~isImaginary]), 545 | utils.deg2rad(90-ele[~isImaginary]), 546 | r[~isImaginary]) 547 | 548 | ls_layout = decoder.LoudspeakerSetup(ls_x, ls_y, ls_z, 549 | listener_position=listener_position) 550 | ls_layout.ls_gains = ls_gains 551 | # then add imaginary loudspeakers to ambisonics setup 552 | imag_x, imag_y, imag_z = utils.sph2cart(utils.deg2rad(azi[isImaginary]), 553 | utils.deg2rad(90-ele[isImaginary]), 554 | r[isImaginary]) 555 | imag_pos = np.c_[imag_x, imag_y, imag_z] 556 | ls_layout.ambisonics_setup(N_kernel=N_kernel, update_hull=True, 557 | imaginary_ls=imag_pos) 558 | return ls_layout 559 | 560 | 561 | def save_layout(filename, ls_layout, name='unknown', description='unknown'): 562 | """Save loudspeaker layout to json configuration file.""" 563 | if not ls_layout.ambisonics_hull: 564 | raise ValueError("No ambisonics_hull.") 565 | out_data = {} 566 | out_data['Name'] = name 567 | out_data['Description'] = 'This configuration file was created with ' +\ 568 | 'spaudiopy (v-' + str(__version__) + '), ' + \ 569 | str(datetime.now()) 570 | 571 | out_data['LoudspeakerLayout'] = {} 572 | out_data['LoudspeakerLayout']['Name'] = name 573 | out_data['LoudspeakerLayout']['Description'] = description 574 | out_data['LoudspeakerLayout']['Loudspeakers'] = [] 575 | 576 | for ls_idx in range(ls_layout.ambisonics_hull.npoints): 577 | ls_dirs = utils.cart2sph(ls_layout.ambisonics_hull.x[ls_idx], 578 | ls_layout.ambisonics_hull.y[ls_idx], 579 | ls_layout.ambisonics_hull.z[ls_idx]) 580 | ls_dict = {} 581 | ls_dict['Azimuth'] = round(float(utils.rad2deg(ls_dirs[0])), 2) 582 | ls_dict['Elevation'] = round(float(90 - utils.rad2deg(ls_dirs[1])), 2) 583 | ls_dict['Radius'] = round(float(ls_dirs[2]), 2) 584 | ls_dict['IsImaginary'] = ls_idx in np.asarray( 585 | ls_layout.ambisonics_hull.imaginary_ls_idx) 586 | ls_dict['Channel'] = ls_idx + 1 587 | ls_dict['Gain'] = 0. if ls_idx in np.asarray( 588 | ls_layout.ambisonics_hull.imaginary_ls_idx) else \ 589 | ls_layout.ls_gains[ls_idx] 590 | out_data['LoudspeakerLayout']['Loudspeakers'].append(ls_dict) 591 | 592 | with open(os.path.expanduser(filename), 'w') as outfile: 593 | json.dump(out_data, outfile, indent=4) 594 | -------------------------------------------------------------------------------- /spaudiopy/parsa.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Parametric Spatial Audio (PARSA). 3 | 4 | .. plot:: 5 | :context: reset 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | plt.rcParams['axes.grid'] = True 10 | 11 | import spaudiopy as spa 12 | 13 | N_sph = 3 14 | # Three sources 15 | x_nm = spa.sph.src_to_sh(np.random.randn(3, 10000), 16 | [np.pi/2, -np.pi/4, np.pi/3], 17 | [np.pi/3, np.pi/2, 2/3 * np.pi], N_sph) 18 | # Diffuse noise 19 | x_nm += np.sqrt(16/(4*np.pi)) * np.random.randn(16, 10000) 20 | spa.plot.sh_rms_map(x_nm, title="Input SHD Signal") 21 | 22 | """ 23 | 24 | from itertools import repeat 25 | from warnings import warn 26 | import logging 27 | 28 | import numpy as np 29 | import multiprocessing 30 | 31 | from scipy import signal 32 | from . import utils, sph 33 | from . import process as pcs 34 | 35 | 36 | # Prepare 37 | shared_array = None 38 | lock = multiprocessing.RLock() 39 | 40 | 41 | def sh_beamformer_from_pattern(pattern, N_sph, azi_steer, zen_steer): 42 | """Get spherical harmonics domain (SHD) beamformer coefficients. 43 | 44 | Parameters 45 | ---------- 46 | pattern : string , or (N+1, ) array_like 47 | Pattern description, e.g. `'cardioid'` or modal weights. 48 | N_sph : int 49 | SH order. 50 | azi_steer : (J,) array_like 51 | Azimuth steering directions. 52 | zen_steer : (J,) array_like 53 | Zenith/colatitude steering directions. 54 | 55 | Returns 56 | ------- 57 | w_nm : (J, (N+1)**2) numpy.ndarray 58 | SHD Beamformer weights. 59 | 60 | Examples 61 | -------- 62 | See :py:func:`spaudiopy.parsa.sh_beamform`. 63 | 64 | """ 65 | if isinstance(pattern, str): 66 | if pattern.lower() in ['hypercardioid', 'max_di']: 67 | c_n = sph.hypercardioid_modal_weights(N_sph) 68 | elif pattern.lower() in ['cardioid', 'inphase']: 69 | c_n = sph.cardioid_modal_weights(N_sph) 70 | elif pattern.lower() in ['max_re', 'maxre']: 71 | c_n = sph.maxre_modal_weights(N_sph) 72 | else: 73 | raise ValueError("Pattern not available: " + pattern) 74 | else: 75 | c_n = utils.asarray_1d(pattern) 76 | assert len(c_n) == (N_sph+1), "Input not matching:" + str(c_n) 77 | 78 | w_nm = sph.repeat_per_order(c_n) 79 | Y_steer = sph.sh_matrix(N_sph, azi_steer, zen_steer, sh_type='real') 80 | return w_nm * Y_steer 81 | 82 | 83 | def sh_beamform(w_nm, sig_nm): 84 | """Apply spherical harmonics domain (SHD) beamformer. 85 | 86 | Parameters 87 | ---------- 88 | w_nm : ((N+1)**2,) array_like, or (J, (N+1)**2) np.ndarray 89 | SHD beamformer weights (for `J` beamformers) 90 | sig_nm : ((N+1)**2, l) np.ndarray 91 | SHD signal of length l. 92 | 93 | Returns 94 | ------- 95 | y : (J, l) np.ndarray 96 | Beamformer output signals. 97 | 98 | Examples 99 | -------- 100 | .. plot:: 101 | :context: close-figs 102 | 103 | vecs, _ = spa.grids.load_maxDet(50) 104 | dirs = spa.utils.vec2dir(vecs) 105 | w_nm = spa.parsa.sh_beamformer_from_pattern('cardioid', N_sph, 106 | dirs[:,0], dirs[:,1]) 107 | y = spa.parsa.sh_beamform(w_nm, x_nm) 108 | spa.plot.spherical_function_map(spa.utils.rms(y), dirs[:,0], dirs[:,1], 109 | TODB=True, title="Output RMS") 110 | 111 | """ 112 | W = np.atleast_2d(w_nm) 113 | sig_nm = np.asarray(sig_nm) 114 | if sig_nm.ndim == 1: 115 | sig_nm = sig_nm[:, np.newaxis] # upgrade to handle 1D arrays 116 | return W @ sig_nm 117 | 118 | 119 | def estimate_num_sources(cov_x, a=None, w=None): 120 | """Active source count estimate from signal covariance. 121 | 122 | Based on the relation of consecutive eigenvalues. 123 | 124 | Parameters 125 | ---------- 126 | cov_x : (L, L) numpy.2darray 127 | Signal covariance. 128 | a : float, optional 129 | Threshold condition (ratio), defaults to `1 + 2/len(cov_x)` 130 | w : (L,) array_like, optional 131 | Eigenvalues in ascending order, not using `cov_x` if available. 132 | 133 | Returns 134 | ------- 135 | num_src_est : int 136 | Number of active sources estimate. 137 | 138 | Examples 139 | -------- 140 | See :py:func:`spaudiopy.parsa.sh_music`. 141 | 142 | """ 143 | if w is None: 144 | w = np.linalg.eigvalsh(cov_x) 145 | else: 146 | w = utils.asarray_1d(w) 147 | if a is None: 148 | a = 1 + 2/len(w) 149 | if np.var(w) < a: 150 | num_src_est = 0 151 | else: 152 | c = w[1:] / (w[:-1] + 10e-8) 153 | cn = np.argmax(c > a) 154 | num_src_est = len(w)-1 - cn 155 | return num_src_est 156 | 157 | 158 | def separate_cov(cov_x, num_cut=None): 159 | """Separate Covariance matrix in signal and noise components. 160 | 161 | Parameters 162 | ---------- 163 | S_xx : (L, L) numpy.2darray 164 | Covariance. 165 | num_cut : int, optional 166 | Split point of Eigenvalues, default: `parsa.estimate_num_sources()`. 167 | 168 | Returns 169 | ------- 170 | S_pp : (L, L) numpy.2darray 171 | Signal covariance. 172 | S_nn : (L, L) numpy.2darray 173 | Noise (residual) covariance. 174 | 175 | Notes 176 | ----- 177 | Signal model is :math:`S_x = S_p + S_n` . 178 | 179 | Examples 180 | -------- 181 | .. plot:: 182 | :context: close-figs 183 | 184 | S_xx = x_nm @ x_nm.T 185 | S_pp, S_nn = spa.parsa.separate_cov(S_xx, num_cut=3) 186 | fig, axs = plt.subplots(1, 3, constrained_layout=True) 187 | axs[0].matshow(S_xx) 188 | axs[0].set_title("X") 189 | axs[1].matshow(S_pp) 190 | axs[1].set_title("S") 191 | axs[2].matshow(S_nn) 192 | axs[2].set_title("N") 193 | 194 | """ 195 | assert (cov_x.shape[0] == cov_x.shape[1]) 196 | w, v = np.linalg.eigh(cov_x) 197 | if num_cut is None: 198 | num_cut = estimate_num_sources([], w=w) 199 | 200 | w_nn = 1. * w 201 | w_r = w[-(num_cut+1)] 202 | w_nn[-num_cut:] = w_r 203 | S_nn = v @ np.diag(w_nn) @ v.T 204 | S_pp = v[:, -num_cut:] @ (np.diag(w[-num_cut:] - w_r)) @ v[:, -num_cut:].T 205 | return S_pp, S_nn 206 | 207 | 208 | def sh_music(cov_x, num_src, dirs_azi, dirs_zen): 209 | """SH domain / Eigenbeam Multiple Signal Classification (EB-MUSIC). 210 | 211 | Parameters 212 | ---------- 213 | cov_x : (L, L) numpy.2darray 214 | SH signal covariance. 215 | num_src : int 216 | Number of sources. 217 | dirs_azi : (g,) array_like 218 | dirs_zen : (g,) array_like 219 | 220 | Returns 221 | ------- 222 | P_music : (g,) array_like 223 | MUSIC (psuedo-) spectrum. 224 | 225 | Examples 226 | -------- 227 | .. plot:: 228 | :context: close-figs 229 | 230 | S_xx = x_nm @ x_nm.T 231 | num_src_est = spa.parsa.estimate_num_sources(S_xx) 232 | 233 | vecs, _ = spa.grids.load_maxDet(50) 234 | dirs = spa.utils.vec2dir(vecs) 235 | P_music = spa.parsa.sh_music(S_xx, num_src_est, dirs[:,0], dirs[:,1]) 236 | spa.plot.spherical_function_map(P_music, dirs[:,0], dirs[:,1], 237 | TODB=True, title="MUSIC spectrum") 238 | 239 | """ 240 | assert (cov_x.shape[0] == cov_x.shape[1]) 241 | N_sph = int(np.sqrt(cov_x.shape[0]) - 1) 242 | dirs_azi = utils.asarray_1d(dirs_azi) 243 | dirs_zen = utils.asarray_1d(dirs_zen) 244 | Y = sph.sh_matrix(N_sph, dirs_azi, dirs_zen, sh_type='real') 245 | _, v = np.linalg.eigh(cov_x) 246 | Qn = v[:, :-num_src] 247 | a = (Qn.T @ Y.T) 248 | P_music = 1 / (np.sum(a * a, 0) + 10e-12) 249 | return P_music 250 | 251 | 252 | def sh_mvdr(cov_x, dirs_azi, dirs_zen): 253 | """Spherical Harmonics domain MVDR beamformer. 254 | SH / Eigenbeam domain minimum variance distortionless response (EB-MVDR). 255 | Often employed on signal `cov_x = S_xx`, instead of noise `cov_x = S_nn`, 256 | then called minimum power distortionless response (MPDR) beamformer. 257 | 258 | Parameters 259 | ---------- 260 | cov_x : (L, L) numpy.2darray 261 | SH signal (noise) covariance. 262 | dirs_azi : (g,) array_like 263 | dirs_zen : (g,) array_like 264 | 265 | Returns 266 | ------- 267 | W_nm : (g, L) numpy.2darray 268 | MVDR beampattern weights. 269 | 270 | References 271 | ---------- 272 | Rafaely, B. (2015). Fundamentals of Spherical Array Processing. Springer. 273 | ch. 7.2. 274 | 275 | Examples 276 | -------- 277 | .. plot:: 278 | :context: close-figs 279 | 280 | S_xx = x_nm @ x_nm.T 281 | num_src_est = spa.parsa.estimate_num_sources(S_xx) 282 | _, S_nn = spa.parsa.separate_cov(S_xx, num_cut=num_src_est) 283 | 284 | vecs, _ = spa.grids.load_maxDet(50) 285 | dirs = spa.utils.vec2dir(vecs) 286 | W_nm = spa.parsa.sh_mvdr(S_nn, dirs[:,0], dirs[:,1]) 287 | y = spa.parsa.sh_beamform(W_nm, x_nm) 288 | spa.plot.spherical_function_map(spa.utils.rms(y), dirs[:,0], dirs[:,1], 289 | TODB=True, title="MVDR output RMS") 290 | 291 | """ 292 | assert (cov_x.shape[0] == cov_x.shape[1]) 293 | N_sph = int(np.sqrt(cov_x.shape[0]) - 1) 294 | dirs_azi = utils.asarray_1d(dirs_azi) 295 | dirs_zen = utils.asarray_1d(dirs_zen) 296 | Y_steer = sph.sh_matrix(N_sph, dirs_azi, dirs_zen, sh_type='real') 297 | S_inv = np.linalg.inv(cov_x) 298 | c = Y_steer @ S_inv 299 | a = Y_steer @ S_inv @ Y_steer.conj().T 300 | W_nm = c.T / np.diag(a) 301 | return W_nm.T 302 | 303 | 304 | def sh_lcmv(cov_x, dirs_azi_c, dirs_zen_c, c_gain): 305 | """Spherical Harmonics domain LCMV beamformer. 306 | SH / Eigenbeam domain Linearly Constrained Minimum Variance (LCMV) 307 | beamformer. 308 | Often employed on signal `cov_x = S_xx`, instead of noise `cov_x = S_nn`, 309 | then called linearly constrained minimum power (LCMP) beamformer. 310 | 311 | Parameters 312 | ---------- 313 | cov_x : (L, L) numpy.2darray 314 | SH signal (noise) covariance. 315 | dirs_azi : (g,) array_like 316 | dirs_zen : (g,) array_like 317 | c_gain : (g,) array_like 318 | Constraints (gain) on points `[dirs_azi, dirs_zen]`. 319 | 320 | Returns 321 | ------- 322 | w_nm : (L,) array_like 323 | LCMV beampattern weights. 324 | 325 | References 326 | ---------- 327 | Rafaely, B. (2015). Fundamentals of Spherical Array Processing. Springer. 328 | ch. 7.5. 329 | 330 | Examples 331 | -------- 332 | .. plot:: 333 | :context: close-figs 334 | 335 | S_xx = x_nm @ x_nm.T 336 | num_src_est = spa.parsa.estimate_num_sources(S_xx) 337 | _, S_nn = spa.parsa.separate_cov(S_xx, num_cut=num_src_est) 338 | 339 | dirs_azi_c = [np.pi/2, 0., np.pi] 340 | dirs_zen_c = [np.pi/2, np.pi/2, np.pi/4] 341 | c = [1, 0.5, 0] 342 | w_nm = spa.parsa.sh_lcmv(S_nn, dirs_azi_c, dirs_zen_c, c) 343 | spa.plot.sh_coeffs(w_nm) 344 | 345 | """ 346 | assert (cov_x.shape[0] == cov_x.shape[1]) 347 | dirs_azi_c = utils.asarray_1d(dirs_azi_c) 348 | dirs_zen_c = utils.asarray_1d(dirs_zen_c) 349 | c_gain = utils.asarray_1d(c_gain) 350 | 351 | assert (len(dirs_azi_c) == len(dirs_zen_c)) 352 | assert (len(dirs_azi_c) == len(c_gain)) 353 | N_sph = int(np.sqrt(cov_x.shape[0]) - 1) 354 | V = sph.sh_matrix(N_sph, dirs_azi_c, dirs_zen_c, sh_type='real').T 355 | S_inv = np.linalg.inv(cov_x) 356 | w_nm = c_gain.T @ np.linalg.inv(V.T @ S_inv @ V) @ V.T @ S_inv 357 | return w_nm 358 | 359 | 360 | def sh_sector_beamformer(A_nm): 361 | """ 362 | Get sector pressure and intensity beamformers. 363 | 364 | Parameters 365 | ---------- 366 | A_nm : (J, (N+1)**2), np.ndarray 367 | SH beamformer matrix, see spa.sph.design_sph_filterbank(). 368 | 369 | Returns 370 | ------- 371 | A_wxyz : ((4*J), (N+2)**2) 372 | SH sector pattern beamformers. 373 | 374 | """ 375 | num_sec = A_nm.shape[0] 376 | A_wxyz = np.zeros((4*num_sec, int(np.sqrt(A_nm.shape[1])+1)**2)) 377 | 378 | w_nm = np.sqrt(4*np.pi) * np.array([1, 0, 0, 0]) 379 | x_nm = np.sqrt(4/3*np.pi) * np.array([0, 0, 0, 1]) 380 | y_nm = np.sqrt(4/3*np.pi) * np.array([0, 1, 0, 0]) 381 | z_nm = np.sqrt(4/3*np.pi) * np.array([0, 0, 1, 0]) 382 | for idx_s in range(num_sec): 383 | A_wxyz[idx_s*4+0, :] = sph.sh_mult(w_nm, A_nm[idx_s, :], 'real') 384 | A_wxyz[idx_s*4+1, :] = sph.sh_mult(x_nm, A_nm[idx_s, :], 'real') 385 | A_wxyz[idx_s*4+2, :] = sph.sh_mult(y_nm, A_nm[idx_s, :], 'real') 386 | A_wxyz[idx_s*4+3, :] = sph.sh_mult(z_nm, A_nm[idx_s, :], 'real') 387 | return A_wxyz 388 | 389 | 390 | # part of parallel pseudo_intensity: 391 | def _intensity_sample(i, W, X, Y, Z, win): 392 | buf = len(win) 393 | # global shared_array 394 | shared_array[int(i + buf // 2), :] = np.asarray( 395 | [np.trapz(win * W[i:i + buf] * X[i:i + buf]), 396 | np.trapz(win * W[i:i + buf] * Y[i:i + buf]), 397 | np.trapz(win * W[i:i + buf] * Z[i:i + buf])]) 398 | 399 | 400 | def pseudo_intensity(ambi_b, win_len=33, f_bp=None, smoothing_order=5, 401 | jobs_count=1): 402 | """Direction of arrival (DOA) for each time sample from pseudo-intensity. 403 | 404 | Parameters 405 | ---------- 406 | ambi_b : sig.AmbiBSignal 407 | Input signal, B-format. 408 | win_len : int optional 409 | Sliding window length. 410 | f_bp : tuple(f_lo, f_hi), optional 411 | Cutoff frequencies for bandpass, 'None' to disable. 412 | smoothing_order : int, optional 413 | Apply hanning(smoothing_order) smoothing to output. 414 | jobs_count : int or None, optional 415 | Number of parallel jobs, 'None' employs 'cpu_count'. 416 | 417 | Returns 418 | ------- 419 | I_azi, I_zen, I_r : array_like 420 | Pseudo intensity vector for each time sample. 421 | 422 | """ 423 | # WIP 424 | if jobs_count is None: 425 | jobs_count = multiprocessing.cpu_count() 426 | 427 | assert (win_len % 2) 428 | win = np.hanning(win_len) 429 | fs = ambi_b.fs 430 | # Z_0 = 413.3 431 | # T_int = 1/fs * win_len 432 | # a = 1 / (np.sqrt(2) * T_int * Z_0) 433 | 434 | # get first order signals 435 | W = utils.asarray_1d(ambi_b.W) 436 | X = utils.asarray_1d(ambi_b.X) 437 | Y = utils.asarray_1d(ambi_b.Y) 438 | Z = utils.asarray_1d(ambi_b.Z) 439 | 440 | # Bandpass signals 441 | if f_bp is not None: 442 | f_lo = f_bp[0] 443 | f_hi = f_bp[1] 444 | b, a = signal.butter(N=2, Wn=(f_lo / (fs / 2), f_hi / (fs / 2)), 445 | btype='bandpass') 446 | W = signal.filtfilt(b, a, W) 447 | X = signal.filtfilt(b, a, X) 448 | Y = signal.filtfilt(b, a, Y) 449 | Z = signal.filtfilt(b, a, Z) 450 | 451 | # Initialize intensity vector 452 | I_vec = np.c_[np.zeros(len(ambi_b)), 453 | np.zeros(len(ambi_b)), np.zeros(len(ambi_b))] 454 | 455 | if jobs_count == 1: 456 | # I = p*v for each sample 457 | for i in range(len(ambi_b) - win_len): 458 | I_vec[int(i + win_len // 2), :] = np.asarray( 459 | [np.trapz(win * W[i:i + win_len] * X[i:i + win_len]), 460 | np.trapz(win * W[i:i + win_len] * Y[i:i + win_len]), 461 | np.trapz(win * W[i:i + win_len] * Z[i:i + win_len])]) 462 | else: 463 | logging.info("Using %i processes..." % jobs_count) 464 | # preparation 465 | shared_array_shape = np.shape(I_vec) 466 | _arr_base = _create_shared_array(shared_array_shape) 467 | _arg_itr = zip(range(len(ambi_b) - win_len), 468 | repeat(W), repeat(X), repeat(Y), repeat(Z), 469 | repeat(win)) 470 | # execute 471 | with multiprocessing.Pool(processes=jobs_count, 472 | initializer=_init_shared_array, 473 | initargs=(_arr_base, 474 | shared_array_shape,)) as pool: 475 | pool.starmap(_intensity_sample, _arg_itr) 476 | # reshape 477 | I_vec = np.frombuffer(_arr_base.get_obj()).reshape( 478 | shared_array_shape) 479 | 480 | if smoothing_order > 0: 481 | assert (smoothing_order % 2) 482 | I_vec = np.apply_along_axis(signal.convolve, 0, I_vec, 483 | np.hanning(smoothing_order), 'same') 484 | I_azi, I_zen, I_r = utils.cart2sph(I_vec[:, 0], I_vec[:, 1], 485 | I_vec[:, 2], steady_zen=True) 486 | return I_azi, I_zen, I_r 487 | 488 | 489 | def render_stereo_sdm(sdm_p, sdm_phi, sdm_theta): 490 | """Stereophonic SDM Render IR, with a cos(phi) pannign law. 491 | This is only meant for quick testing. 492 | 493 | Parameters 494 | ---------- 495 | sdm_p : (n,) array_like 496 | Pressure p(t). 497 | sdm_phi : (n,) array_like 498 | Azimuth phi(t). 499 | sdm_theta : (n,) array_like 500 | Colatitude theta(t). 501 | 502 | Returns 503 | ------- 504 | ir_l : array_like 505 | Left impulse response. 506 | ir_r : array_like 507 | Right impulse response. 508 | """ 509 | ir_l = np.zeros(len(sdm_p)) 510 | ir_r = np.zeros_like(ir_l) 511 | 512 | for i, (p, phi, theta) in enumerate(zip(sdm_p, sdm_phi, sdm_theta)): 513 | h_l = 0.5*(1 + np.cos(phi - np.pi/2)) 514 | h_r = 0.5*(1 + np.cos(phi + np.pi/2)) 515 | # convolve 516 | ir_l[i] += p * h_l 517 | ir_r[i] += p * h_r 518 | return ir_l, ir_r 519 | 520 | 521 | # part of parallel render_bsdm: 522 | def _render_bsdm_sample(i, p, phi, theta, hrirs): 523 | h_l, h_r = hrirs.nearest_hrirs(phi, theta) 524 | # global shared_array 525 | with lock: # synchronize access, operator += needs lock! 526 | shared_array[i:i + len(h_l), 0] += p * h_l 527 | shared_array[i:i + len(h_r), 1] += p * h_r 528 | 529 | 530 | def render_bsdm(sdm_p, sdm_phi, sdm_theta, hrirs, jobs_count=1): 531 | """Binaural SDM Render. 532 | Convolves each sample with corresponding hrir. No Post-EQ. 533 | 534 | Parameters 535 | ---------- 536 | sdm_p : (n,) array_like 537 | Pressure p(t). 538 | sdm_phi : (n,) array_like 539 | Azimuth phi(t). 540 | sdm_theta : (n,) array_like 541 | Colatitude theta(t). 542 | hrirs : sig.HRIRs 543 | jobs_count : int or None, optional 544 | Number of parallel jobs, 'None' employs 'cpu_count'. 545 | 546 | Returns 547 | ------- 548 | bsdm_l : array_like 549 | Left binaural impulse response. 550 | bsdm_r : array_like 551 | Right binaural impulse response. 552 | """ 553 | if jobs_count is None: 554 | jobs_count = multiprocessing.cpu_count() 555 | 556 | bsdm_l = np.zeros(len(sdm_p) + len(hrirs) - 1) 557 | bsdm_r = np.zeros_like(bsdm_l) 558 | 559 | if jobs_count == 1: 560 | for i, (p, phi, theta) in enumerate(zip(sdm_p, sdm_phi, sdm_theta)): 561 | h_l, h_r = hrirs.nearest_hrirs(phi, theta) 562 | # convolve 563 | bsdm_l[i:i + len(h_l)] += p * h_l 564 | bsdm_r[i:i + len(h_r)] += p * h_r 565 | 566 | else: 567 | logging.info("Using %i processes..." % jobs_count) 568 | _shared_array_shape = np.shape(np.c_[bsdm_l, bsdm_r]) 569 | _arr_base = _create_shared_array(_shared_array_shape) 570 | _arg_itr = zip(range(len(sdm_p)), sdm_p, sdm_phi, sdm_theta, 571 | repeat(hrirs)) 572 | # execute 573 | with multiprocessing.Pool(processes=jobs_count, 574 | initializer=_init_shared_array, 575 | initargs=(_arr_base, 576 | _shared_array_shape,)) as pool: 577 | pool.starmap(_render_bsdm_sample, _arg_itr) 578 | # reshape 579 | _result = np.frombuffer(_arr_base.get_obj()).reshape( 580 | _shared_array_shape) 581 | bsdm_l = _result[:, 0] 582 | bsdm_r = _result[:, 1] 583 | 584 | return bsdm_l, bsdm_r 585 | 586 | 587 | def render_binaural_loudspeaker_sdm(sdm_p, ls_gains, ls_setup, fs, 588 | post_eq_func='default', **kwargs): 589 | """Render sdm signal on loudspeaker setup as binaural synthesis. 590 | 591 | Parameters 592 | ---------- 593 | sdm_p : (n,) array_like 594 | Pressure p(t). 595 | ls_gains : (n, l) 596 | Loudspeaker (l) gains. 597 | ls_setup : decoder.LoudspeakerSetup 598 | fs : int 599 | post_eq_func : None, 'default' or function 600 | Post EQ applied to the loudspeaker signals. 'default' calls 601 | 'parsa.post_equalization', 'None' disables (not recommended). 602 | You can also provide your custom post-eq-function with the signature 603 | `post_eq_func(ls_sigs, sdm_p, fs, ls_setup, **kwargs)`. 604 | 605 | Returns 606 | ------- 607 | ir_l : array_like 608 | Left binaural impulse response. 609 | ir_r : array_like 610 | Right binaural impulse response. 611 | 612 | """ 613 | n = len(sdm_p) 614 | ls_gains = np.atleast_2d(ls_gains) 615 | assert (n == ls_gains.shape[0]) 616 | 617 | # render loudspeaker signals 618 | ls_sigs = ls_setup.loudspeaker_signals(ls_gains=ls_gains, sig_in=sdm_p) 619 | 620 | # post EQ 621 | if post_eq_func is not None: 622 | if post_eq_func == 'default': 623 | ls_sigs = sdm_post_equalization(ls_sigs, sdm_p, fs, ls_setup, 624 | **kwargs) 625 | else: # user defined function 626 | ls_sigs = post_eq_func(ls_sigs, sdm_p, fs, ls_setup, **kwargs) 627 | else: 628 | warn("No post EQ applied!") 629 | 630 | ir_l, ir_r = ls_setup.binauralize(ls_sigs, fs) 631 | return ir_l, ir_r 632 | 633 | 634 | def sdm_post_equalization(ls_sigs, sdm_p, fs, ls_setup, soft_clip=True): 635 | """Post equalization to compensate spectral whitening. 636 | 637 | Parameters 638 | ---------- 639 | ls_sigs : (L, S) np.ndarray 640 | Input loudspeaker signals. 641 | sdm_p : array_like 642 | Reference (sdm) pressure signal. 643 | fs : int 644 | ls_setup : decoder.LoudspeakerSetup 645 | soft_clip : bool, optional 646 | Limit the compensation boost to +6dB. 647 | 648 | Returns 649 | ------- 650 | ls_sigs_compensated : (L, S) np.ndarray 651 | Compensated loudspeaker signals. 652 | 653 | References 654 | ---------- 655 | Tervo, S., et. al. (2015). 656 | Spatial Analysis and Synthesis of Car Audio System and Car Cabin Acoustics 657 | with a Compact Microphone Array. Journal of the Audio Engineering Society. 658 | 659 | """ 660 | ls_distance = ls_setup.d # ls distance 661 | a = ls_setup.a # distance attenuation exponent 662 | 663 | CHECK_SANITY = False 664 | 665 | # prepare filterbank 666 | filter_gs, ff = pcs.frac_octave_filterbank(n=1, N_out=2**16, 667 | fs=fs, f_low=62.5, f_high=16000, 668 | mode='amplitude') 669 | 670 | # band dependent block size 671 | band_blocksizes = np.zeros(ff.shape[0]) 672 | # proposed by Tervo 673 | band_blocksizes[1:] = np.round(7 / ff[1:, 0] * fs) 674 | band_blocksizes[0] = np.round(7 / ff[0, 1] * fs) 675 | # make sure they are even 676 | band_blocksizes = (np.ceil(band_blocksizes / 2) * 2).astype(int) 677 | 678 | padsize = band_blocksizes.max() 679 | 680 | ntaps = padsize // 2 - 1 681 | assert (ntaps % 2), "N does not produce uneven number of filter taps." 682 | irs = np.zeros([filter_gs.shape[0], ntaps]) 683 | for ir_idx, g_b in enumerate(filter_gs): 684 | irs[ir_idx, :] = signal.firwin2(ntaps, np.linspace(0, 1, len(g_b)), 685 | g_b) 686 | 687 | # prepare Input 688 | pad = np.zeros([ls_sigs.shape[0], padsize]) 689 | x_padded = np.hstack([pad, ls_sigs, pad]) 690 | p_padded = np.hstack([np.zeros(padsize), sdm_p, np.zeros(padsize)]) 691 | ls_sigs_compensated = np.hstack([pad, np.zeros_like(x_padded), pad]) 692 | ls_sigs_band = np.zeros([ls_sigs_compensated.shape[0], 693 | ls_sigs_compensated.shape[1], 694 | irs.shape[0]]) 695 | assert (len(p_padded) == x_padded.shape[1]) 696 | 697 | for band_idx in range(irs.shape[0]): 698 | blocksize = band_blocksizes[band_idx] 699 | hopsize = blocksize // 2 700 | win = np.hanning(blocksize + 1)[0: -1] 701 | start_idx = 0 702 | while (start_idx + blocksize) <= x_padded.shape[1]: 703 | if CHECK_SANITY: 704 | dirac = np.zeros_like(irs) 705 | dirac[:, blocksize // 2] = np.sqrt(1/(irs.shape[0])) 706 | 707 | # blocks 708 | block_p = win * p_padded[start_idx: start_idx + blocksize] 709 | block_sdm = win[np.newaxis, :] * x_padded[:, start_idx: 710 | start_idx + blocksize] 711 | 712 | # block spectra 713 | nfft = blocksize + blocksize - 1 714 | 715 | H_p = np.fft.fft(block_p, nfft) 716 | H_sdm = np.fft.fft(block_sdm, nfft, axis=1) 717 | # distance 718 | spec_in_origin = np.diag(1 / ls_distance**a) @ H_sdm 719 | 720 | # magnitude difference by spectral division 721 | sdm_mag_incoherent = np.sqrt(np.sum(np.abs(spec_in_origin)**2, 722 | axis=0)) 723 | sdm_mag_coherent = np.sum(np.abs(spec_in_origin), axis=0) 724 | 725 | # Coherent addition in the lows 726 | if band_idx == 0: 727 | mag_diff = np.abs(H_p) / \ 728 | np.clip(sdm_mag_coherent, 10e-10, None) 729 | elif band_idx == 1: 730 | mag_diff = np.abs(H_p) / \ 731 | (0.5 * np.clip(sdm_mag_coherent, 10e-10, None) + 732 | 0.5 * np.clip(sdm_mag_incoherent, 10e-10, None)) 733 | elif band_idx == 2: 734 | mag_diff = np.abs(H_p) / \ 735 | (0.25 * np.clip(sdm_mag_coherent, 10e-10, None) + 736 | 0.75 * np.clip(sdm_mag_incoherent, 10e-10, None)) 737 | else: 738 | mag_diff = np.abs(H_p) / np.clip(sdm_mag_incoherent, 10e-10, 739 | None) 740 | # soft clip gain 741 | if soft_clip: 742 | mag_diff = pcs.gain_clipping(mag_diff, 1) 743 | 744 | # apply to ls input 745 | Y = H_sdm * mag_diff[np.newaxis, :] 746 | 747 | # inverse STFT 748 | X = np.real(np.fft.ifft(Y, axis=1)) 749 | # Zero Phase 750 | assert (np.mod(X.shape[1], 2)) 751 | # delay 752 | zp_delay = X.shape[1] // 2 753 | X = np.roll(X, zp_delay, axis=1) 754 | 755 | # overlap add 756 | ls_sigs_band[:, padsize + start_idx - zp_delay: 757 | padsize + start_idx - zp_delay + nfft, 758 | band_idx] += X 759 | 760 | # increase pointer 761 | start_idx += hopsize 762 | 763 | # apply filter 764 | for ls_idx in range(ls_sigs.shape[0]): 765 | ls_sigs_band[ls_idx, :, band_idx] = signal.convolve(ls_sigs_band[ 766 | ls_idx, :, 767 | band_idx], 768 | irs[band_idx], 769 | mode='same') 770 | 771 | # sum over bands 772 | ls_sigs_compensated = np.sum(ls_sigs_band, axis=2) 773 | 774 | # restore shape 775 | out_start_idx = int(2 * padsize) 776 | out_end_idx = int(-(2 * padsize)) 777 | if np.any(np.abs(ls_sigs_compensated[:, :out_start_idx]) > 10e-5) or \ 778 | np.any(np.abs(ls_sigs_compensated[:, -out_end_idx]) > 10e-5): 779 | warn('Truncated valid signal, consider more zero padding.') 780 | 781 | ls_sigs_compensated = ls_sigs_compensated[:, out_start_idx: out_end_idx] 782 | assert (ls_sigs_compensated.shape == ls_sigs.shape) 783 | return ls_sigs_compensated 784 | 785 | 786 | def sdm_post_equalization2(ls_sigs, sdm_p, fs, ls_setup, 787 | blocksize=4096, smoothing_order=5): 788 | """Post equalization to compensate spectral whitening. This alternative 789 | version works on fixed blocksizes with octave band gain smoothing. 790 | Sonically, this seems not the preferred version, but it can gain some 791 | insight through the band gains which are returned. 792 | 793 | Parameters 794 | ---------- 795 | ls_sigs : (L, S) np.ndarray 796 | Input loudspeaker signals. 797 | sdm_p : array_like 798 | Reference (sdm) pressure signal. 799 | fs : int 800 | ls_setup : decoder.LoudspeakerSetup 801 | blocksize : int 802 | smoothing_order : int 803 | Block smoothing, increasing Hanning window up to this order. 804 | 805 | Returns 806 | ------- 807 | ls_sigs_compensated : (L, S) np.ndarray 808 | Compensated loudspeaker signals. 809 | band_gains_list : list 810 | Each element contains the octave band gain applied as post eq. 811 | 812 | """ 813 | ls_distance = ls_setup.d # ls distance 814 | a = ls_setup.a # distance attenuation exponent 815 | 816 | CHECK_SANITY = False 817 | 818 | hopsize = blocksize // 2 819 | win = np.hanning(blocksize + 1)[0: -1] 820 | 821 | # prepare Input 822 | pad = np.zeros([ls_sigs.shape[0], blocksize]) 823 | x_padded = np.hstack([pad, ls_sigs, pad]) 824 | p_padded = np.hstack([np.zeros(blocksize), sdm_p, np.zeros(blocksize)]) 825 | ls_sigs_compensated = np.hstack([pad, np.zeros_like(x_padded), pad]) 826 | assert (len(p_padded) == x_padded.shape[1]) 827 | 828 | # prepare filterbank 829 | filter_gs, ff = pcs.frac_octave_filterbank(n=1, N_out=blocksize//2 + 1, 830 | fs=fs, f_low=62.5, f_high=16000) 831 | ntaps = blocksize+1 832 | assert (ntaps % 2), "N does not produce uneven number of filter taps." 833 | irs = np.zeros([filter_gs.shape[0], ntaps]) 834 | for ir_idx, g_b in enumerate(filter_gs): 835 | irs[ir_idx, :] = signal.firwin2(ntaps, np.linspace(0, 1, len(g_b)), 836 | g_b) 837 | 838 | band_gains_list = [] 839 | start_idx = 0 840 | while (start_idx + blocksize) <= x_padded.shape[1]: 841 | if CHECK_SANITY: 842 | dirac = np.zeros_like(irs) 843 | dirac[:, blocksize//2] = np.sqrt(1/(irs.shape[0])) 844 | 845 | # blocks 846 | block_p = win * p_padded[start_idx: start_idx + blocksize] 847 | block_sdm = win[np.newaxis, :] * x_padded[:, start_idx: 848 | start_idx + blocksize] 849 | 850 | # block mags 851 | p_mag = np.sqrt(np.abs(np.fft.rfft(block_p))**2) 852 | sdm_H = np.diag(1 / ls_distance**a) @ np.fft.rfft(block_sdm, axis=1) 853 | sdm_mag_incoherent = np.sqrt(np.sum(np.abs(sdm_H)**2, axis=0)) 854 | sdm_mag_coherent = np.sum(np.abs(sdm_H), axis=0) 855 | assert (len(p_mag) == len(sdm_mag_incoherent) == len(sdm_mag_coherent)) 856 | 857 | # get gains 858 | L_p = pcs.subband_levels(filter_gs * p_mag, ff[:, 2] - ff[:, 0], fs) 859 | L_sdm_incoherent = pcs.subband_levels(filter_gs * sdm_mag_incoherent, 860 | ff[:, 2] - ff[:, 0], fs) 861 | L_sdm_coherent = pcs.subband_levels(filter_gs * sdm_mag_coherent, 862 | ff[:, 2] - ff[:, 0], fs) 863 | with np.errstate(divide='ignore', invalid='ignore'): 864 | band_gains_incoherent = L_p / L_sdm_incoherent 865 | band_gains_coherent = L_p / L_sdm_coherent 866 | 867 | band_gains_incoherent[np.isnan(band_gains_incoherent)] = 1 868 | band_gains_coherent[np.isnan(band_gains_coherent)] = 1 869 | # clip gains 870 | gain_clip = 1 871 | band_gains_incoherent = np.clip(band_gains_incoherent, None, gain_clip) 872 | band_gains_coherent = np.clip(band_gains_coherent, None, gain_clip) 873 | 874 | # attenuate lows (coherent) 875 | band_gains = np.zeros_like(band_gains_coherent) 876 | band_gains[0] = band_gains_coherent[0] 877 | band_gains[1] = 0.5 * band_gains_coherent[1] + \ 878 | 0.5 * band_gains_incoherent[1] 879 | band_gains[2] = 0.25 * band_gains_coherent[2] + \ 880 | 0.75 * band_gains_incoherent[2] 881 | band_gains[3:] = band_gains_incoherent[3:] 882 | 883 | # gain smoothing over blocks 884 | if len(band_gains_list) > 0: 885 | # half-sided window, increasing in size 886 | current_order = min(smoothing_order, len(band_gains_list)) 887 | w = np.hanning(current_order * 2 + 1)[-(current_order + 1): -1] 888 | # normalize 889 | w = w / w.sum() 890 | band_gains_smoothed = w[0] * band_gains # current 891 | for order_idx in range(1, current_order): 892 | band_gains_smoothed += w[order_idx] * \ 893 | band_gains_list[-order_idx] 894 | else: 895 | band_gains_smoothed = band_gains 896 | 897 | band_gains_list.append(band_gains_smoothed) 898 | 899 | for ls_idx in range(ls_sigs.shape[0]): 900 | # prepare output 901 | X = np.zeros([irs.shape[0], blocksize + 2 * (irs.shape[1] - 1)]) 902 | # Transform 903 | for band_idx in range(irs.shape[0]): 904 | if not CHECK_SANITY: 905 | X[band_idx, :blocksize + irs.shape[1] - 1] = \ 906 | signal.convolve(block_sdm[ls_idx, :], irs[band_idx, :]) 907 | else: 908 | X[band_idx, :blocksize + irs.shape[1] - 1] = \ 909 | signal.convolve(block_sdm[ls_idx, :], 910 | dirac[band_idx, :]) 911 | # Apply gains 912 | if not CHECK_SANITY: 913 | X = band_gains[:, np.newaxis] * X 914 | else: 915 | X = X 916 | 917 | # Inverse, with zero phase 918 | for band_idx in range(irs.shape[0]): 919 | if not CHECK_SANITY: 920 | X[band_idx, :] = np.flip(signal.convolve( 921 | np.flip(X[band_idx, :blocksize + irs.shape[1] - 1]), 922 | irs[band_idx, :])) 923 | else: 924 | X[band_idx, :] = np.flip(signal.convolve( 925 | np.flip(X[band_idx, :blocksize + irs.shape[1] - 1]), 926 | dirac[band_idx, :])) 927 | 928 | # overlap add 929 | ls_sigs_compensated[ls_idx, 930 | start_idx + blocksize - (irs.shape[1] - 1): 931 | start_idx + 2 * blocksize + 932 | (irs.shape[1] - 1)] += np.sum(X, axis=0) 933 | 934 | # increase pointer 935 | start_idx += hopsize 936 | 937 | # restore shape 938 | out_start_idx = 2 * blocksize 939 | out_end_idx = -(2 * blocksize) 940 | if (np.sum(np.abs(ls_sigs_compensated[:, :out_start_idx])) + 941 | np.sum(np.abs(ls_sigs_compensated[:, -out_end_idx]))) > 10e-3: 942 | warn('Truncated valid signal, consider more zero padding.') 943 | 944 | ls_sigs_compensated = ls_sigs_compensated[:, out_start_idx: out_end_idx] 945 | assert (ls_sigs_compensated.shape == ls_sigs.shape) 946 | return ls_sigs_compensated, band_gains_list[2:-2] 947 | 948 | 949 | # Parallel worker stuff --> 950 | def _create_shared_array(shared_array_shape, d_type='d'): 951 | """Allocate ctypes array from shared memory with lock.""" 952 | shared_array_base = multiprocessing.Array(d_type, shared_array_shape[0] * 953 | shared_array_shape[1]) 954 | return shared_array_base 955 | 956 | 957 | def _init_shared_array(shared_array_base, shared_array_shape): 958 | """Make 'shared_array' available to child processes.""" 959 | global shared_array 960 | shared_array = np.frombuffer(shared_array_base.get_obj()) 961 | shared_array = shared_array.reshape(shared_array_shape) 962 | # < --Parallel worker stuff 963 | -------------------------------------------------------------------------------- /spaudiopy/process.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Collection of audio processing tools. 3 | 4 | .. plot:: 5 | :context: reset 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | plt.rcParams['axes.grid'] = True 10 | 11 | import spaudiopy as spa 12 | 13 | """ 14 | 15 | import numpy as np 16 | import resampy 17 | import pickle 18 | from scipy import signal 19 | import multiprocessing 20 | import logging 21 | 22 | from . import utils, sph, sig, grids 23 | 24 | 25 | def resample_hrirs(hrir_l, hrir_r, fs_hrir, fs_target, jobs_count=1): 26 | """ 27 | Resample HRIRs to new SamplingRate(t), using multiprocessing. 28 | 29 | Parameters 30 | ---------- 31 | hrir_l : (g, h) numpy.ndarray 32 | h(t) for grid position g. 33 | hrir_r : (g, h) numpy.ndarray 34 | h(t) for grid position g. 35 | fs_hrir : int 36 | Current fs(t) of hrirs. 37 | fs_target : int 38 | Target fs(t) of hrirs. 39 | jobs_count : int or None, optional 40 | Number of parallel jobs, 'None' employs 'cpu_count'. 41 | 42 | Returns 43 | ------- 44 | hrir_l_resampled : (g, h_n) numpy.ndarray 45 | h_n(t) resampled for grid position g. 46 | hrir_r_resampled : (g, h_n) numpy.ndarray 47 | h_n(t) resampled for grid position g. 48 | fs_hrir : int 49 | New fs(t) of hrirs. 50 | """ 51 | if jobs_count is None: 52 | jobs_count = multiprocessing.cpu_count() 53 | hrir_l_resampled = np.zeros([hrir_l.shape[0], 54 | int(hrir_l.shape[1] * fs_target / fs_hrir)]) 55 | hrir_r_resampled = np.zeros_like(hrir_l_resampled) 56 | 57 | if jobs_count == 1: 58 | hrir_l_resampled = resampy.resample(hrir_l, fs_hrir, fs_target, axis=1) 59 | hrir_r_resampled = resampy.resample(hrir_r, fs_hrir, fs_target, axis=1) 60 | elif jobs_count > 1: 61 | logging.info("Using %i processes..." % jobs_count) 62 | with multiprocessing.Pool(processes=jobs_count) as pool: 63 | results = pool.starmap(resampy.resample, 64 | map(lambda x: (x, fs_hrir, fs_target), 65 | hrir_l)) 66 | hrir_l_resampled = np.array(results) 67 | results = pool.starmap(resampy.resample, 68 | map(lambda x: (x, fs_hrir, fs_target), 69 | hrir_r)) 70 | hrir_r_resampled = np.array(results) 71 | 72 | fs_hrir = fs_target 73 | return hrir_l_resampled, hrir_r_resampled, fs_hrir 74 | 75 | 76 | def resample_signal(s_time, fs_current, fs_target, axis=-1): 77 | """ 78 | Resample time signal. 79 | 80 | Parameters 81 | ---------- 82 | s_time : numpy.ndarray 83 | Time signal, or signals stacked. 84 | fs_current : int 85 | fs_target : int 86 | axis : int, optional 87 | Axis along which to resample. The default is -1. 88 | 89 | Returns 90 | ------- 91 | single_spec_resamp : numpy.ndarray. 92 | 93 | """ 94 | s_time = np.atleast_2d(s_time) 95 | s_time_resamp = resampy.resample(s_time, fs_current, fs_target, axis=axis) 96 | return np.squeeze(s_time_resamp) 97 | 98 | 99 | def resample_spectrum(single_spec, fs_current, fs_target, axis=-1): 100 | """ 101 | Resample single sided spectrum, as e.g. from np.fft.rfft(). 102 | 103 | Parameters 104 | ---------- 105 | single_spec : numpy.ndarray 106 | Single sided spectrum, or spectra stacked. 107 | fs_current : int 108 | fs_target : int 109 | axis : int, optional 110 | Axis along which to resample. The default is -1. 111 | 112 | Returns 113 | ------- 114 | single_spec_resamp : numpy.ndarray. 115 | 116 | """ 117 | single_spec = np.atleast_2d(single_spec) 118 | s_time = np.fft.irfft(single_spec, axis=axis) 119 | s_time_resamp = resampy.resample(s_time, fs_current, fs_target, axis=axis) 120 | single_spec_resamp = np.fft.rfft(s_time_resamp, axis=axis) 121 | return np.squeeze(single_spec_resamp) 122 | 123 | 124 | def hrirs_ctf(hrirs, MIN_PHASE=True, freq_lims=(125, 10e3), grid_weights=None): 125 | """ 126 | Get common transfer function (CTF) EQ for HRIRs. 127 | 128 | Often used to equalize the direction independent coloration of a 129 | measurement. Can be used to replace headphone EQ. 130 | 131 | Parameters 132 | ---------- 133 | hrirs : sig.HRIRs 134 | MIN_PHASE : bool, optional 135 | Minimum phase EQ. The default is True. 136 | freq_lims : tuple, optional 137 | Frequency limits of inversion. The default is (125, 10e3). 138 | grid_weights : array_like, optional 139 | Grid weights of hrirs, `None` will calculate them. The default is None. 140 | 141 | Returns 142 | ------- 143 | eq_taps : np.ndarray 144 | EQ filter taps, same length as HRIRs. 145 | 146 | """ 147 | if grid_weights is None: 148 | grid_weights = grids.calculate_grid_weights(hrirs.azi, hrirs.zen, 5) 149 | 150 | num_taps = hrirs.num_samples 151 | num_grid_points = hrirs.num_grid_points 152 | 153 | assert len(grid_weights) == num_grid_points 154 | nfft = 16 * num_taps 155 | H_left = np.fft.rfft(hrirs.left, nfft, axis=-1) 156 | H_right = np.fft.rfft(hrirs.right, nfft, axis=-1) 157 | 158 | H_avg_left = 0.5*np.sqrt(np.sum(grid_weights[:, None] * 159 | np.abs(H_left)**2, axis=0)) / (4*np.pi) 160 | H_avg_right = 0.5*np.sqrt(np.sum(grid_weights[:, None] * 161 | np.abs(H_right)**2, axis=0)) / (4*np.pi) 162 | 163 | # Smoothing 164 | H_avg_smooth = 0.5*frac_octave_smoothing(np.squeeze(H_avg_left), 165 | 1, WEIGHTED=True) + \ 166 | 0.5*frac_octave_smoothing(np.squeeze(H_avg_right), 167 | 1, WEIGHTED=True) 168 | 169 | freq_vec = np.fft.rfftfreq(nfft, 1/hrirs.fs) 170 | 171 | freq_weights = np.ones(len(freq_vec)) 172 | idx_lo = np.argmin(abs(freq_vec - freq_lims[0])) 173 | idx_hi = np.argmin(abs(freq_vec - freq_lims[1])) 174 | w_lo = np.hanning(2*idx_lo + 1)[:idx_lo] 175 | w_hi = np.hanning(2*(len(freq_vec)-idx_hi)+1)[(len(freq_vec)-idx_hi)+1:] 176 | freq_weights[:idx_lo] = w_lo 177 | freq_weights[idx_hi:] = w_hi 178 | 179 | # norm filters 180 | idx_1k = np.argmin(abs(freq_vec - 1000)) 181 | H_target = H_avg_smooth / np.mean(np.abs(H_avg_smooth[idx_1k-5:idx_1k+5])) 182 | H_weighted_inv = freq_weights * (1 / H_target) + \ 183 | (1 - freq_weights) * np.ones(len(freq_weights)) 184 | 185 | # get EQ 186 | if MIN_PHASE: 187 | eq_taps = signal.minimum_phase( 188 | np.fft.fftshift(np.fft.irfft(H_weighted_inv**2)))[:num_taps] 189 | else: 190 | eq_taps = np.roll(np.fft.irfft(H_weighted_inv), 191 | num_taps//2+1)[:num_taps] 192 | eq_taps *= np.hanning(num_taps) 193 | 194 | return eq_taps 195 | 196 | 197 | def ilds_from_hrirs(hrirs, f_cut=(1e3, 20e3), TODB=True): 198 | """Calculate ILDs from HRIRs by high/band-passed broad-band RMS. 199 | 200 | Parameters 201 | ---------- 202 | hrirs : sig.HRIRs 203 | f_cut : float (2,), optional 204 | Band-pass cutoff frequencies. The default is (1000, 20000). 205 | TODB : bool, optional 206 | ILD in dB RMS ratio, otherwise as RMS difference. The default is TRUE. 207 | 208 | Returns 209 | ------- 210 | ild : array_like 211 | ILD per grid point, positive value indicates left ear louder. 212 | 213 | """ 214 | assert isinstance(hrirs, sig.HRIRs) 215 | fs = hrirs.fs 216 | sos = signal.butter(2, f_cut, 'bandpass', fs=fs, output='sos') 217 | 218 | hrirs_l_f = signal.sosfiltfilt(sos, hrirs.left, axis=-1) 219 | hrirs_r_f = signal.sosfiltfilt(sos, hrirs.right, axis=-1) 220 | 221 | if TODB: 222 | rms_diff = utils.db(utils.rms(hrirs_l_f, axis=-1) / 223 | utils.rms(hrirs_r_f, axis=-1)) 224 | else: 225 | rms_diff = utils.rms(hrirs_l_f, axis=-1) - \ 226 | utils.rms(hrirs_r_f, axis=-1) 227 | 228 | return rms_diff 229 | 230 | 231 | def itds_from_hrirs(hrirs, f_cut=(100, 1.5e3), upsample=4): 232 | """Calculate ITDs from HRIRs inter-aural cross-correlation (IACC). 233 | 234 | The method calculates IACC on energy of upsampled, filtered HRIRs. 235 | 236 | Parameters 237 | ---------- 238 | hrirs : sig.HRIRs 239 | f_cut : float (2,), optional 240 | Band-pass cutoff frequencies. The default is (100, 1500). 241 | upsample : int, optional 242 | Upsampling factor. The default is 4. 243 | 244 | Returns 245 | ------- 246 | itd : array_like 247 | ITD in seconds per grid point, positive value indicates left ear first. 248 | 249 | References 250 | ---------- 251 | Andreopoulou, A., & Katz, B. F. G. (2017). Identification of perceptually 252 | relevant methods of inter-aural time difference estimation. JASA. 253 | 254 | """ 255 | assert isinstance(hrirs, sig.HRIRs) 256 | fs_rs = upsample*hrirs.fs 257 | hrirs_l_us, hrirs_r_us, _ = resample_hrirs(hrirs.left, hrirs.right, 258 | hrirs.fs, fs_rs) 259 | sos = signal.butter(2, f_cut, 'bandpass', fs=fs_rs, output='sos') 260 | hrirs_l_us = signal.sosfiltfilt(sos, hrirs_l_us, axis=-1) 261 | hrirs_r_us = signal.sosfiltfilt(sos, hrirs_r_us, axis=-1) 262 | 263 | maxidx = np.zeros(hrirs.num_grid_points) 264 | for idx, hrirs_dir in enumerate(zip(hrirs_l_us, hrirs_r_us)): 265 | maxidx[idx] = np.argmax(np.correlate(hrirs_dir[0]**2, hrirs_dir[1]**2, 266 | mode='same')) 267 | maxidx -= hrirs_l_us.shape[1]//2 268 | # alternative 269 | # maxidx = np.argmax(hrirs_l_us, axis=-1) - np.argmax(hrirs_r_us, axis=-1) 270 | itd = -maxidx / fs_rs 271 | return itd 272 | 273 | 274 | def match_loudness(sig_in, sig_target): 275 | """ 276 | Match loundess of input to target, based on RMS and avoid clipping. 277 | 278 | Parameters 279 | ---------- 280 | sig_in : (n, c) array_like 281 | Input(t) samples n, channel c. 282 | sig_target : (n, c) array_like 283 | Target(t) samples n, channel c. 284 | 285 | Returns 286 | ------- 287 | sig_out : (n, c) array_like 288 | Output(t) samples n, channel c. 289 | """ 290 | L_in = np.max(np.sqrt(np.mean(np.square(sig_in), axis=0))) 291 | L_target = np.max(np.sqrt(np.mean(np.square(sig_target), axis=0))) 292 | sig_out = sig_in * L_target / L_in 293 | peak = np.max(np.abs(sig_out)) 294 | if peak > 1: 295 | sig_out = sig_out / peak 296 | print('Audio normalized') 297 | return sig_out 298 | 299 | 300 | def ambeo_a2b(Ambi_A, filter_coeffs=None): 301 | """Convert A 'MultiSignal' (type I: FLU, FRD, BLD, BRU) to B AmbiBSignal. 302 | 303 | Parameters 304 | ---------- 305 | Ambi_A : sig.MultiSignal 306 | Input signal. 307 | filter_coeffs : string 308 | Picklable file that contains b0_d, a0_d, b1_d, a1_d. 309 | 310 | Returns 311 | ------- 312 | Ambi_B : sig.AmbiBSignal 313 | B-format output signal. 314 | """ 315 | _B = sph.soundfield_to_b(Ambi_A.get_signals()) 316 | Ambi_B = sig.AmbiBSignal([_B[0, :], _B[1, :], _B[2, :], _B[3, :]], 317 | fs=Ambi_A.fs) 318 | if filter_coeffs is not None: 319 | b0_d, a0_d, b1_d, a1_d = pickle.load(open(filter_coeffs, "rb")) 320 | Ambi_B.W = signal.lfilter(b0_d, a0_d, Ambi_B.W) 321 | Ambi_B.X = signal.lfilter(b1_d, a1_d, Ambi_B.X) 322 | Ambi_B.Y = signal.lfilter(b1_d, a1_d, Ambi_B.Y) 323 | Ambi_B.Z = signal.lfilter(b1_d, a1_d, Ambi_B.Z) 324 | return Ambi_B 325 | 326 | 327 | def b_to_stereo(Ambi_B): 328 | """Downmix B format first order Ambisonics to Stereo. 329 | 330 | Parameters 331 | ---------- 332 | Ambi_B : sig.AmbiBSignal 333 | B-format output signal. 334 | 335 | Returns 336 | ------- 337 | L, R : array_like 338 | """ 339 | L = Ambi_B.W + (Ambi_B.X + Ambi_B.Y) / (np.sqrt(2)) 340 | R = Ambi_B.W + (Ambi_B.X - Ambi_B.Y) / (np.sqrt(2)) 341 | return L, R 342 | 343 | 344 | def lagrange_delay(N, delay): 345 | """Return fractional delay filter using lagrange interpolation. 346 | 347 | For best results, delay should be near N/2 +/- 1. 348 | 349 | Parameters 350 | ---------- 351 | N : int 352 | Filter order. 353 | delay : float 354 | Delay in samples. 355 | 356 | Returns 357 | ------- 358 | h : (N+1,) array_like 359 | FIR Filter. 360 | """ 361 | n = np.arange(N + 1) 362 | h = np.ones(N + 1) 363 | for k in range(N + 1): 364 | index = np.where(n != k) 365 | h[index] = h[index] * (delay - k) / (n[index] - k) 366 | return h 367 | 368 | 369 | def frac_octave_smoothing(a, smoothing_n, WEIGHTED=True): 370 | """Fractional octave (weighted) smoothing. 371 | 372 | Parameters 373 | ---------- 374 | a : (n,) array_like 375 | Input spectrum. 376 | smoothing_n : int 377 | 1 / smoothing_n octave band. 378 | WEIGHTED : bool, optional 379 | Use (hamming) weighting on mean around center. The default is True. 380 | 381 | Returns 382 | ------- 383 | smoothed_a : (n,) np.array 384 | 385 | """ 386 | a = utils.asarray_1d(a) 387 | smooth = np.zeros_like(a) 388 | num_smpls = len(a) 389 | for idx in range(num_smpls): 390 | k_lo = np.floor(idx / (2**(1/(2*smoothing_n)))) 391 | k_hi = np.clip(np.ceil(idx * (2**(1/(2*smoothing_n)))), 1, num_smpls) 392 | if WEIGHTED: 393 | win = np.hamming(k_hi-k_lo) # hamming is good because never 0 394 | else: 395 | win = np.ones(int(k_hi-k_lo)) 396 | smooth[idx] = 1/np.sum(win) * np.sum((win * a[k_lo.astype(int): 397 | k_hi.astype(int)])) 398 | return smooth 399 | 400 | 401 | def frac_octave_filterbank(n, N_out, fs, f_low, f_high=None, mode='energy', 402 | overlap=0.5, slope_l=3): 403 | r"""Fractional octave band filterbank. 404 | 405 | Design of digital fractional-octave-band filters with energy conservation 406 | and perfect reconstruction. 407 | 408 | Parameters 409 | ---------- 410 | n : int 411 | Octave fraction, e.g. n=3 third-octave bands. 412 | N_out : int 413 | Number of non-negative frequency bins [0, fs/2]. 414 | fs : int 415 | Sampling frequency in Hz. 416 | f_low : int 417 | Center frequency of first full band in Hz. 418 | f_high : int 419 | Cutoff frequency in Hz, above which no further bands are generated. 420 | mode : 'energy' or 'amplitude' 421 | 'energy' produces -3dB at crossover, 'amplitude' -6dB. 422 | overlap : float 423 | Band overlap, should be between [0, 0.5]. 424 | slope_l : int 425 | Band transition slope, implemented as recursion order `l`. 426 | 427 | Returns 428 | ------- 429 | g : (b, N) np.ndarray 430 | Band gains for non-negative frequency bins. 431 | ff : (b, 3) np.ndarray 432 | Filter frequencies as [f_lo, f_c, f_hi]. 433 | 434 | Notes 435 | ----- 436 | This filterbank is originally designed such that the sum of gains squared 437 | sums to unity. The alternative 'amplitude' mode ensures that the gains sum 438 | directly to unity. 439 | 440 | References 441 | ---------- 442 | Antoni, J. (2010). Orthogonal-like fractional-octave-band filters. 443 | The Journal of the Acoustical Society of America, 127(2), 884–895. 444 | 445 | Examples 446 | -------- 447 | .. plot:: 448 | :context: close-figs 449 | 450 | fs = 44100 451 | N = 2**16 452 | gs, ff = spa.process.frac_octave_filterbank(n=1, N_out=N, fs=fs, 453 | f_low=100, f_high=8000) 454 | f = np.linspace(0, fs//2, N) 455 | fig, ax = plt.subplots(2, 1, constrained_layout=True) 456 | ax[0].semilogx(f, gs.T) 457 | ax[0].set_title('Band gains') 458 | ax[1].semilogx(f, np.sum(np.abs(gs)**2, axis=0)) 459 | ax[1].set_title(r"$\sum |g| ^ 2$") 460 | for a_idx in ax: 461 | a_idx.grid(True) 462 | a_idx.set_xlim([20, fs//2]) 463 | a_idx.set_xlabel('f in Hz') 464 | a_idx.set_ylabel('Amplitude') 465 | 466 | """ 467 | # fft bins 468 | N = (N_out - 1) * 2 469 | # frequency axis 470 | freq = np.fft.rfftfreq(N, d=1. / fs) 471 | f_alias = fs // 2 472 | if f_high is None: 473 | f_high = f_alias 474 | else: 475 | f_high = np.min([f_high, f_alias]) 476 | assert (overlap <= 0.5) 477 | # center frequencies 478 | f_c = [] 479 | # first is f_low 480 | f_c.append(f_low) 481 | # check next cutoff frequency 482 | while (f_c[-1] * (2 ** (1 / (2 * n)))) < f_high: 483 | f_c.append(2 ** (1 / n) * f_c[-1]) 484 | f_c = np.array(f_c) 485 | 486 | # cut-off freqs 487 | f_lo = f_c / (2 ** (1 / (2 * n))) 488 | f_hi = f_c * (2 ** (1 / (2 * n))) 489 | 490 | # convert 491 | w_s = 2 * np.pi * fs 492 | # w_m 493 | w_c = 2 * np.pi * f_c 494 | # w_1 495 | w_lo = 2 * np.pi * f_lo 496 | # w_1+1 497 | w_hi = 2 * np.pi * f_hi 498 | 499 | # DFT line that corresponds to the lower bandedge frequency 500 | k_i = np.floor(N * w_lo / w_s).astype(int) 501 | # DFT bins in the frequency band 502 | N_i = np.diff(k_i) 503 | # band overlap (twice) 504 | P = np.round(overlap * (N * (w_c - w_lo) / w_s)).astype(int) 505 | 506 | g = np.ones([len(f_c) + 1, len(freq)]) 507 | for b_idx in range(len(f_c)): 508 | p = np.arange(-P[b_idx], P[b_idx] + 1) 509 | 510 | # phi within [-1, 1] 511 | phi = (p / P[b_idx]) 512 | phi[np.isnan(phi)] = 1. 513 | # recursion eq. 20 514 | for l_i in range(slope_l): 515 | phi = np.sin(np.pi / 2 * phi) 516 | 517 | # shift phi to [0, 1] 518 | phi = 0.5 * (phi + 1) 519 | a = np.sin(np.pi / 2 * phi) 520 | b = np.cos(np.pi / 2 * phi) 521 | 522 | # Hi 523 | g[b_idx, k_i[b_idx] - P[b_idx]: k_i[b_idx] + P[b_idx] + 1] = b 524 | g[b_idx, k_i[b_idx] + P[b_idx]:] = 0. 525 | # Lo 526 | g[b_idx + 1, k_i[b_idx] - P[b_idx]: k_i[b_idx] + P[b_idx] + 1] = a 527 | g[b_idx + 1, : k_i[b_idx] - P[b_idx]] = 0. 528 | 529 | if mode in ['energy']: 530 | g = g 531 | elif mode in ['amplitude', 'pressure']: 532 | # This is not part of Antony (2010), see 'notes' 533 | g = g**2 534 | else: 535 | raise ValueError("Mode not implemented: " + mode) 536 | 537 | # Corresponding frequency limits 538 | ff = np.c_[f_lo, f_c, f_hi] 539 | # last band 540 | ff[-1, -1] = fs / 2 541 | ff[-1, 1] = np.sqrt(ff[-1, 0] * ff[-1, -1]) 542 | # first band 543 | ff = np.vstack([np.array([0, np.sqrt(1 * ff[0, 0]), ff[0, 0]]), ff]) 544 | return g, ff 545 | 546 | 547 | def subband_levels(x, width, fs, power=False, axis=-1): 548 | """Compute the level/power in each subband of subband signals.""" 549 | N = x.shape[1] 550 | 551 | if power is False: 552 | # normalization wrt bandwidth/sampling interval 553 | L = np.sqrt(1 / width * fs / 2 * np.sum(np.abs(x) ** 2, axis=axis)) 554 | else: 555 | L = 1 / N * 1 / width * fs / 2 * np.sum(np.abs(x) ** 2, axis=axis) 556 | 557 | return L 558 | 559 | 560 | def energy_decay(p): 561 | """Energy decay curve (EDC) in dB by Schroeder backwards integration. 562 | 563 | Parameters 564 | ---------- 565 | p : array_like 566 | 567 | Returns 568 | ------- 569 | rd : array_like 570 | """ 571 | a = np.trapz(p**2) 572 | b = np.cumsum(p[::-1]**2)[::-1] 573 | return 10 * np.log10(b / a) 574 | 575 | 576 | def half_sided_Hann(N): 577 | """Design half-sided Hann tapering window of order N (>=3).""" 578 | assert (N >= 3) 579 | w_full = signal.hann(2 * ((N + 1) // 2) + 1) 580 | # get half sided window 581 | w_taper = np.ones(N + 1) 582 | w_taper[-((N - 1) // 2):] = w_full[-((N + 1) // 2):-1] 583 | return w_taper 584 | 585 | 586 | def gain_clipping(gain, threshold): 587 | """Limit gain factor by soft clipping function. Limits gain factor to +6dB 588 | beyond threshold point. (Pass values as factors/ratios, not dB!) 589 | 590 | Parameters 591 | ---------- 592 | gain : array_like 593 | threshold : float 594 | 595 | Returns 596 | ------- 597 | gain_clipped : array_like 598 | 599 | Examples 600 | -------- 601 | .. plot:: 602 | :context: close-figs 603 | 604 | x = np.linspace(-10, 10, 1000) 605 | lim_threshold = 2.5 606 | y = spa.process.gain_clipping(x, lim_threshold) 607 | plt.figure() 608 | plt.plot(x, x, '--', label='In') 609 | plt.plot(x, y, label='Out') 610 | plt.legend() 611 | plt.xlabel('In') 612 | plt.ylabel('Out') 613 | plt.grid(True) 614 | 615 | """ 616 | gain = gain / threshold # offset by threshold 617 | gain[gain > 1] = 1 + np.tanh(gain[gain > 1] - 1) # soft clipping to 2 618 | return gain * threshold 619 | 620 | 621 | def pulsed_noise(t_noise, t_pause, fs, reps=10, t_fade=0.02, pink_noise=True, 622 | normalize=True): 623 | """Pulsed noise train, pink or white. 624 | 625 | Parameters 626 | ---------- 627 | t_noise : float 628 | t in s for pulse. 629 | t_pause : float 630 | t in s between pulses. 631 | fs : int 632 | Sampling frequency. 633 | reps : int, optional 634 | Repetitions (independent). The default is 10. 635 | t_fade : float, optional 636 | t in s for fade in and out. The default is 0.02. 637 | pink_noise : bool, optional 638 | Use 'pink' (1/f) noise. The default is True 639 | normalize : bool, optional 640 | Normalize output. The default is True. 641 | 642 | Returns 643 | ------- 644 | s_out : array_like 645 | output signal. 646 | 647 | """ 648 | s_out = [] 649 | 650 | for _ in range(reps): 651 | s_noise = np.random.randn(int(fs*t_noise)) 652 | 653 | if pink_noise: 654 | X = np.fft.rfft(s_noise) 655 | nbins = len(X) 656 | # divide by sqrt(n), power spectrum 657 | X_pink = X / np.sqrt(np.arange(nbins)+1) 658 | s_noise = np.fft.irfft(X_pink) 659 | 660 | s_pause = np.zeros(int(fs*t_noise)) 661 | 662 | # fades 663 | mask_n = int(fs*t_fade) 664 | mask_in = np.sin(np.linspace(0, np.pi/2, mask_n))**2 665 | mask_out = np.cos(np.linspace(0, np.pi/2, mask_n))**2 666 | 667 | # apply 668 | s_noise[:mask_n] *= mask_in 669 | s_noise[-mask_n:] *= mask_out 670 | 671 | s_out = np.r_[s_out, s_noise, s_pause] 672 | 673 | if normalize: 674 | s_out /= np.max(abs(s_out)) 675 | 676 | return s_out 677 | -------------------------------------------------------------------------------- /spaudiopy/sig.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ Signal class. 3 | Avoid code duplications (and errors) by defining a few custom classes here. 4 | 5 | .. plot:: 6 | :context: reset 7 | 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | plt.rcParams['axes.grid'] = True 11 | 12 | import spaudiopy as spa 13 | 14 | """ 15 | 16 | import os 17 | import copy 18 | from warnings import warn 19 | 20 | import numpy as np 21 | from scipy import signal as scysig 22 | import soundfile as sf 23 | try: 24 | import sounddevice as sd 25 | except (ImportError, OSError) as e: 26 | print(str(e)) 27 | warn("Sounddevice not available.") 28 | 29 | from . import io, utils, sph 30 | from . import process as pcs 31 | 32 | 33 | # CLASSES 34 | class MonoSignal: 35 | """Signal class for a MONO channel audio signal.""" 36 | 37 | def __init__(self, signal, fs): 38 | """Constructor. 39 | 40 | Parameters 41 | ---------- 42 | signal : array_like 43 | fs : int 44 | 45 | """ 46 | self._signal = utils.asarray_1d(signal) 47 | self._fs = fs 48 | 49 | def __len__(self): 50 | """Override len().""" 51 | return len(self.signal) 52 | 53 | def __getitem__(self, key): 54 | """Enable [] operator, returns signal data.""" 55 | return self.signal[key] 56 | 57 | @property 58 | def signal(self): 59 | return self._signal 60 | 61 | @signal.setter 62 | def signal(self, value): 63 | self._signal = utils.asarray_1d(value) 64 | 65 | @property 66 | def fs(self): 67 | return self._fs 68 | 69 | @classmethod 70 | def from_file(cls, filename, fs=None): 71 | """Alternative constructor, load signal from filename.""" 72 | sig, fs_file = sf.read(os.path.expanduser(filename)) 73 | if fs is not None: 74 | if fs != fs_file: 75 | raise ValueError("File: Found different fs:" + str(fs_file)) 76 | else: 77 | fs = fs_file 78 | if sig.ndim != 1: 79 | raise ValueError("Signal must be mono. Try MultiSignal.") 80 | return cls(sig, fs) 81 | 82 | def copy(self): 83 | """Return an independent (deep) copy of the instance.""" 84 | return copy.deepcopy(self) 85 | 86 | def save(self, filename, subtype='FLOAT'): 87 | """Save to file.""" 88 | io.save_audio(self, os.path.expanduser(filename), subtype=subtype) 89 | 90 | def trim(self, start, stop, dur=None): 91 | """Trim audio to start and stop, or duration, in seconds.""" 92 | assert start < len(self) / self.fs, "Trim start exceeds signal." 93 | if dur is not None: 94 | stop = start + dur 95 | if stop > (len(self) / self.fs): 96 | warn("Stop exceeds signal.") 97 | self.signal = self.signal[int(start * self.fs): int(stop * self.fs)] 98 | 99 | def apply(self, func, *args, **kwargs): 100 | """Apply function 'func' to signal, arguments are forwarded.""" 101 | self.signal = func(*args, **kwargs) 102 | 103 | def conv(self, h, **kwargs): 104 | """Convolve signal, kwargs are forwarded to signal.convolve.""" 105 | h = utils.asarray_1d(h) 106 | self.signal = scysig.convolve(self.signal, h, **kwargs) 107 | return self 108 | 109 | def resample(self, fs_new): 110 | """Resample signal to new sampling rate fs_new.""" 111 | if fs_new == self.fs: 112 | warn("Same sampling rate requested, no resampling.") 113 | else: 114 | sig_resamp = pcs.resample_signal(self.signal, self.fs, fs_new) 115 | self.__init__(sig_resamp, fs_new) 116 | 117 | def play(self, gain=1, wait=True): 118 | """Play sound signal. Adjust gain and wait until finished.""" 119 | sd.play(gain * self.signal, int(self.fs)) 120 | if wait: 121 | sd.wait() 122 | 123 | 124 | class MultiSignal(MonoSignal): 125 | """Signal class for a MULTI channel audio signal.""" 126 | 127 | def __init__(self, signals, fs=None): 128 | """Constructor. 129 | 130 | Parameters 131 | ---------- 132 | signals : list of array_like 133 | fs : int 134 | 135 | """ 136 | assert isinstance(signals, (list, tuple)) 137 | self.channels = [] 138 | if fs is None: 139 | raise ValueError("Provide fs (as kwarg).") 140 | else: 141 | self._fs = fs 142 | for s in signals: 143 | self.channels.append(MonoSignal(s, fs)) 144 | 145 | self.channel_count = self.num_channels 146 | 147 | @property 148 | def num_channels(self): 149 | return len(self.channels) 150 | 151 | @property 152 | def signals(self): 153 | return self.get_signals() 154 | 155 | def __len__(self): 156 | """Override len().""" 157 | return len(self.channels[0]) 158 | 159 | def __getitem__(self, key): 160 | """Override [] operator, returns signal channel.""" 161 | return self.channels[key] 162 | 163 | @classmethod 164 | def from_file(cls, filename, fs=None): 165 | """Alternative constructor, load signal from filename.""" 166 | sig, fs_file = sf.read(os.path.expanduser(filename)) 167 | if fs is not None: 168 | if fs != fs_file: 169 | raise ValueError("File: Found different fs:" + str(fs_file)) 170 | else: 171 | fs = fs_file 172 | if np.ndim(sig) == 1: 173 | raise ValueError("Only one channel. Try MonoSignal.") 174 | return cls([*sig.T], fs=fs) 175 | 176 | def get_signals(self): 177 | """Return ndarray of signals, stacked along rows (nCH, nSmps).""" 178 | return np.asarray([x.signal for x in self.channels]) 179 | 180 | def trim(self, start, stop, dur=None): 181 | """Trim all channels to start and stop, or duration, in seconds.""" 182 | assert start < len(self) / self.fs, "Trim start exceeds signal." 183 | if dur is not None: 184 | stop = start + dur 185 | if stop > (len(self) / self.fs): 186 | warn("Stop exceeds signal.") 187 | for c in self.channels: 188 | c.signal = c.signal[int(start * c.fs): int(stop * c.fs)] 189 | 190 | def apply(self, func, *args, **kwargs): 191 | """Apply function 'func' to all signals, arguments are forwarded.""" 192 | for c in self.channels: 193 | c.signal = func(*args, **kwargs) 194 | 195 | def conv(self, irs, **kwargs): 196 | for c, h in zip(self.channels, irs): 197 | h = utils.asarray_1d(h) 198 | c.signal = scysig.convolve(c, h, **kwargs) 199 | return self 200 | 201 | def resample(self, fs_new): 202 | """Resample signal to new sampling rate fs_new.""" 203 | if fs_new == self.fs: 204 | warn("Same sampling rate requested, no resampling.") 205 | else: 206 | sig_resamp = pcs.resample_signal(self.get_signals(), 207 | self.fs, fs_new, axis=-1) 208 | self.__init__([*sig_resamp], fs_new) 209 | 210 | def play(self, gain=1, wait=True): 211 | """Play sound signal. Adjust gain and wait until finished.""" 212 | sd.play(gain * self.get_signals().T, int(self.fs)) 213 | if wait: 214 | sd.wait() 215 | 216 | 217 | class AmbiBSignal(MultiSignal): 218 | """Signal class for first order Ambisonics B-format signals.""" 219 | def __init__(self, signals, fs=None): 220 | MultiSignal.__init__(self, signals, fs=fs) 221 | assert self.channel_count == 4, "Provide four channels!" 222 | self.W = utils.asarray_1d(self.channels[0].signal) 223 | self.X = utils.asarray_1d(self.channels[1].signal) 224 | self.Y = utils.asarray_1d(self.channels[2].signal) 225 | self.Z = utils.asarray_1d(self.channels[3].signal) 226 | 227 | @classmethod 228 | def from_file(cls, filename, fs=None): 229 | """Alternative constructor, load signal from filename.""" 230 | return super().from_file(filename, fs=fs) 231 | 232 | @classmethod 233 | def sh_to_b(cls, multisig): 234 | """Alternative constructor, convert from sig.Multisignal. 235 | 236 | Assumes ACN channel order. 237 | """ 238 | assert isinstance(multisig, MultiSignal) 239 | _B = sph.sh_to_b(multisig.copy().get_signals()) 240 | return cls([*_B], fs=multisig.fs) 241 | 242 | 243 | class HRIRs: 244 | """Signal class for head-related impulse responses.""" 245 | 246 | def __init__(self, left, right, azi, zen, fs): 247 | """Constructor. 248 | 249 | Parameters 250 | ---------- 251 | left : (numDirs, numTaps) ndarray 252 | Left ear HRIRs. 253 | right : (numDirs, numTaps) ndarray 254 | Right ear HRIRs. 255 | azi : (numDirs,) array_like, in rad 256 | fs : int 257 | 258 | """ 259 | left = np.asarray(left) 260 | right = np.asarray(right) 261 | assert len(left) == len(right), "Signals must be of same length." 262 | assert left.ndim == 2 263 | assert right.ndim == 2 264 | azi = utils.asarray_1d(azi) 265 | zen = utils.asarray_1d(zen) 266 | assert len(azi) == len(zen) 267 | assert len(azi) == left.shape[0] 268 | 269 | self.left = left 270 | self.right = right 271 | self.azi = azi 272 | self.zen = zen 273 | self.fs = fs 274 | self.num_grid_points = len(azi) 275 | self.num_samples = left.shape[1] 276 | 277 | def __len__(self): 278 | """Override len() to count of samples per hrir.""" 279 | return self.left.shape[1] 280 | 281 | def __getitem__(self, key): 282 | """Enable [] operator, returns hrirs.""" 283 | return self.left[key, :], self.right[key, :] 284 | 285 | def copy(self): 286 | """Return an independent (deep) copy of the instance.""" 287 | return copy.deepcopy(self) 288 | 289 | def update_hrirs(self, left, right): 290 | """Update and replace HRIRs in place. 291 | 292 | Parameters 293 | ---------- 294 | left : (numDirs, numTaps) ndarray 295 | Left ear HRIRs. 296 | right : numDirs, numTaps ndarray 297 | Right ear HRIRs. 298 | 299 | Returns 300 | ------- 301 | None. 302 | 303 | """ 304 | # reinitialize 305 | self.__init__(left, right, self.azi, self.zen, self.fs) 306 | 307 | def nearest_hrirs(self, azi, zen): 308 | """For a point on the sphere, select closest HRIR defined on grid. 309 | 310 | Based on the haversine distance. 311 | 312 | Parameters 313 | ---------- 314 | azi : float 315 | Azimuth. 316 | zen : float 317 | Zenith / Colatitude. 318 | 319 | Returns 320 | ------- 321 | h_l : (n,) array_like 322 | h(t) closest to [phi, theta]. 323 | h_r : (n,) array_like 324 | h(t) closest to [phi, theta]. 325 | """ 326 | # search closest gridpoint 327 | d_idx = self.nearest_idx(azi, zen) 328 | # get hrirs to that angle 329 | return self[d_idx] 330 | 331 | def nearest_idx(self, azi, zen): 332 | """ 333 | Index of nearest HRIR grid point based on dot product. 334 | 335 | Parameters 336 | ---------- 337 | azi : float, array_like 338 | Azimuth. 339 | zen : float, array_like 340 | Zenith / Colatitude. 341 | 342 | Returns 343 | ------- 344 | idx : int, np.ndarray 345 | Index. 346 | """ 347 | azi = utils.asarray_1d(azi) 348 | zen = utils.asarray_1d(zen) 349 | vec = np.stack(utils.sph2cart(azi, zen), axis=1) 350 | vec_g = np.stack(utils.sph2cart(self.azi, self.zen), axis=1) 351 | return np.argmax(vec@vec_g.T, axis=1).squeeze() 352 | 353 | def apply_ctf_eq(self, eq_taps=None, mode='full'): 354 | """ 355 | Equalize common transfer function (CTF) of HRIRs. 356 | 357 | Parameters 358 | ---------- 359 | eq_taps : array_like, optional 360 | FIR filter, `None` will calculate. The default is None. 361 | mode : string, optional 362 | Forwarded to scipy.signal.convolve(). The default is 'full'. 363 | 364 | Returns 365 | ------- 366 | None. 367 | 368 | """ 369 | if eq_taps is None: 370 | eq_taps = pcs.hrirs_ctf(self) 371 | self.left = scysig.convolve(self.left, eq_taps[None, :], mode) 372 | self.right = scysig.convolve(self.right, eq_taps[None, :], mode) 373 | self.num_samples = self.left.shape[1] 374 | 375 | 376 | def trim_audio(A, start, stop): 377 | """Trim copy of MultiSignal audio to start and stop in seconds.""" 378 | B = copy.deepcopy(A) 379 | assert start < len(B) / B.fs, 'Trim start exceeds signal.' 380 | for c in range(B.channel_count): 381 | B.channels[c].signal = B.channels[c].signal[ 382 | int(start * B.fs): 383 | int(stop * B.fs)] 384 | return B 385 | -------------------------------------------------------------------------------- /spaudiopy/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A few helpers. 3 | 4 | .. plot:: 5 | :context: reset 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | plt.rcParams['axes.grid'] = True 10 | 11 | import spaudiopy as spa 12 | 13 | """ 14 | import numpy as np 15 | 16 | 17 | def asarray_1d(a, **kwargs): 18 | """Squeeze the input and check if the result is one-dimensional. 19 | 20 | Returns *a* converted to a `numpy.ndarray` and stripped of 21 | all singleton dimensions. Scalars are "upgraded" to 1D arrays. 22 | The result must have exactly one dimension. 23 | If not, an error is raised. 24 | """ 25 | result = np.squeeze(np.asarray(a, **kwargs)) 26 | if result.ndim == 0: 27 | result = result.reshape((1,)) 28 | elif result.ndim > 1: 29 | raise ValueError("array must be one-dimensional") 30 | return result 31 | 32 | 33 | def deg2rad(deg): 34 | """Convert from degree [0, 360) to radiant [0, 2*pi).""" 35 | return deg % 360 / 180 * np.pi 36 | 37 | 38 | def rad2deg(rad): 39 | """Convert from radiant [0, 2*pi) to degree [0, 360).""" 40 | return rad / np.pi * 180 % 360 41 | 42 | 43 | def cart2sph(x, y, z, positive_azi=False, steady_zen=False): 44 | """Conversion of cartesian to spherical coordinates.""" 45 | x = np.asarray(x) 46 | y = np.asarray(y) 47 | z = np.asarray(z) 48 | r = np.sqrt(x**2 + y**2 + z**2) 49 | azi = np.arctan2(y, x) 50 | if positive_azi: 51 | azi = azi % (2 * np.pi) # [-pi, pi] -> [0, 2pi) 52 | zen = np.arccos(z / r) if not steady_zen else \ 53 | np.arccos(z / np.clip(r, 10e-15, None)) 54 | return azi, zen, r 55 | 56 | 57 | def sph2cart(azi, zen, r=1): 58 | """Conversion of spherical to cartesian coordinates.""" 59 | azi = np.asarray(azi) 60 | zen = np.asarray(zen) 61 | r = np.asarray(r) 62 | x = r * np.cos(azi) * np.sin(zen) 63 | y = r * np.sin(azi) * np.sin(zen) 64 | z = r * np.cos(zen) 65 | return x, y, z 66 | 67 | 68 | def matlab_sph2cart(az, elev, r=1): 69 | """Matlab port with ELEVATION.""" 70 | z = r * np.sin(elev) 71 | rcoselev = r * np.cos(elev) 72 | x = rcoselev * np.cos(az) 73 | y = rcoselev * np.sin(az) 74 | return x, y, z 75 | 76 | 77 | def cart2dir(x, y, z): 78 | """Vectorized conversion of cartesian coordinates to (azi, zen).""" 79 | return np.arctan2(y, x), \ 80 | np.arccos(z/(np.sqrt(np.square(x) + np.square(y) + np.square(z)))) 81 | 82 | 83 | def dir2cart(azi, zen): 84 | """Vectorized conversion of direction to cartesian coordinates.""" 85 | return np.cos(azi) * np.sin(zen), np.sin(azi) * np.sin(zen), np.cos(zen) 86 | 87 | 88 | def vec2dir(vec): 89 | """Convert (along last axis) vec: [x, y, z] to dir: [azi, zen].""" 90 | azi, zen = cart2dir(vec[..., 0], vec[..., 1], vec[..., 2]) 91 | return np.stack((azi, zen), axis=-1) 92 | 93 | 94 | def angle_between(v1, v2, vi=None): 95 | """Angle between point v1 and v2(s) with initial point vi.""" 96 | v1 = asarray_1d(v1) 97 | v2 = np.asarray(v2) 98 | if vi is not None: 99 | v1 = v1 - vi 100 | v2 = v2 - vi 101 | 102 | a = np.dot(v1, v2.T) / (np.linalg.norm(v1.T, axis=0) * 103 | np.linalg.norm(v2.T, axis=0)) 104 | return np.arccos(np.clip(a, -1.0, 1.0)) 105 | 106 | 107 | def rotation_euler(yaw=0, pitch=0, roll=0): 108 | """Matrix rotating by Yaw (around z), pitch (around y), roll (around x). 109 | See https://mathworld.wolfram.com/RotationMatrix.html 110 | """ 111 | Rx = np.array([[1, 0, 0], [0, np.cos(roll), np.sin(roll)], 112 | [0, -np.sin(roll), np.cos(roll)]]) 113 | Ry = np.array([[np.cos(pitch), 0, -np.sin(pitch)], [0, 1, 0], 114 | [np.sin(pitch), 0, np.cos(pitch)]]) 115 | Rz = np.array([[np.cos(yaw), np.sin(yaw), 0], 116 | [-np.sin(yaw), np.cos(yaw), 0], [0, 0, 1]]) 117 | return Rz@Ry@Rx 118 | 119 | 120 | def rotation_rodrigues(k, theta): 121 | """Matrix rotating around axis defined by unit vector k, by angle theta. 122 | See https://mathworld.wolfram.com/RodriguesRotationFormula.html 123 | """ 124 | assert (len(k) == 3) 125 | if theta > 10e-10: 126 | k = k / np.linalg.norm(k) 127 | K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]]) 128 | R = np.eye(3) + np.sin(theta)*K + (1-np.cos(theta)) * K@K 129 | else: 130 | R = np.eye(3) 131 | return R 132 | 133 | 134 | def rotation_vecvec(f, t): 135 | """Matrix rotating from vector f to vector t, forces unit length.""" 136 | assert (len(f) == 3) 137 | assert (len(t) == 3) 138 | f = f / np.linalg.norm(f) 139 | t = t / np.linalg.norm(t) 140 | k = np.cross(f, t) 141 | if (np.linalg.norm(k) < 10e-15): 142 | raise ValueError("Can not find rotation axis (axis flip?).") 143 | R = rotation_rodrigues(k, np.arccos(np.dot(f, t))) 144 | return R 145 | 146 | 147 | def haversine(azi1, zen1, azi2, zen2, r=1): 148 | """Calculate the spherical distance between two points on the sphere. 149 | The spherical distance is central angle for r=1. 150 | 151 | Parameters 152 | ---------- 153 | azi1 : (n,) float, array_like 154 | zen1 : (n,) float, array_like 155 | azi2 : (n,) float, array_like 156 | zen2: (n,) float, array_like 157 | r : float, optional. 158 | 159 | Returns 160 | ------- 161 | c : (n,) array_like 162 | Haversine distance between pairs of points. 163 | 164 | References 165 | ---------- 166 | https://en.wikipedia.org/wiki/Haversine_formula 167 | 168 | """ 169 | azi1 = np.asarray(azi1) 170 | zen1 = np.asarray(zen1) 171 | azi2 = np.asarray(azi2) 172 | zen2 = np.asarray(zen2) 173 | 174 | lat1 = np.pi / 2 - zen1 175 | lat2 = np.pi / 2 - zen2 176 | 177 | dlon = azi2 - azi1 178 | dlat = lat2 - lat1 179 | 180 | haversin_A = np.sin(dlat / 2) ** 2 181 | haversin_B = np.sin(dlon / 2) ** 2 182 | 183 | haversin_alpha = haversin_A + np.cos(lat1) * np.cos(lat2) * haversin_B 184 | 185 | c = 2 * r * np.arcsin(np.sqrt(haversin_alpha)) 186 | return c 187 | 188 | 189 | def area_triangle(p1, p2, p3): 190 | """calculate area of any triangle given coordinates of its corners p.""" 191 | return 0.5 * np.linalg.norm(np.cross((p2 - p1), (p3 - p1))) 192 | 193 | 194 | def db(x, power=False): 195 | """Convert ratio *x* to decibel. 196 | 197 | Parameters 198 | ---------- 199 | x : array_like 200 | Input data. Values of 0 lead to negative infinity. 201 | power : bool, optional 202 | If ``power=False`` (the default), *x* is squared before 203 | conversion. 204 | """ 205 | with np.errstate(divide='ignore'): 206 | return (10 if power else 20) * np.log10(np.abs(x)) 207 | 208 | 209 | def from_db(db, power=False): 210 | """Convert decibel back to ratio. 211 | 212 | Parameters 213 | ---------- 214 | db : array_like 215 | Input data. 216 | power : bool, optional 217 | If ``power=False`` (the default), was used for conversion to dB. 218 | """ 219 | return 10 ** (db / (10 if power else 20)) 220 | 221 | 222 | def rms(x, axis=-1): 223 | """RMS (root-mean-squared) along given axis. 224 | 225 | Parameters 226 | ---------- 227 | x : array_like 228 | Input data. 229 | axis : int, optional 230 | Axis along which RMS is calculated 231 | """ 232 | return np.sqrt(np.mean(x * np.conj(x), axis=axis)) 233 | 234 | 235 | def stack(vector_1, vector_2): 236 | """Stack two 2D vectors along the same-sized or the smaller dimension.""" 237 | vector_1, vector_2 = np.atleast_2d(vector_1, vector_2) 238 | M1, N1 = vector_1.shape 239 | M2, N2 = vector_2.shape 240 | 241 | if (M1 == M2 and (M1 < N1 or M2 < N2)): 242 | out = np.vstack([vector_1, vector_2]) 243 | elif (N1 == N2 and (N1 < M1 or N2 < M2)): 244 | out = np.hstack([vector_1, vector_2]) 245 | else: 246 | raise ValueError('vector_1 and vector_2 dont have a common dimension.') 247 | return np.squeeze(out) 248 | 249 | 250 | def test_diff(v1, v2, msg=None, axis=None, test_lim=1e-6, VERBOSE=True): 251 | """Test if the cumulative element-wise difference between v1 and v2. 252 | Return difference and be verbose if is greater `test_lim`. 253 | """ 254 | v1 = np.asarray(v1) 255 | v2 = np.asarray(v2) 256 | d = np.sum(np.abs(v1.ravel() - v2.ravel()), axis=axis) # None is all 257 | if VERBOSE: 258 | if msg is not None: 259 | print(msg, '--', end=' ') 260 | if np.any(d > test_lim): 261 | print('Diff: ', d) 262 | else: 263 | print('Close enough.') 264 | return d 265 | 266 | 267 | def interleave_channels(left_channel, right_channel, style=None): 268 | """Interleave left and right channels (Nchannel x Nsamples). 269 | Style = 'SSR' checks if we total 360 channels. 270 | 271 | """ 272 | if not left_channel.shape == right_channel.shape: 273 | raise ValueError('left_channel and right_channel ' 274 | 'have to be of same dimensions!') 275 | 276 | if style == 'SSR': 277 | if not (left_channel.shape[0] == 360): 278 | raise ValueError('Provided arrays to have 360 channels ' 279 | '(Nchannel x Nsamples).') 280 | 281 | output_data = np.repeat(left_channel, 2, axis=0) 282 | output_data[1::2, :] = right_channel 283 | 284 | return output_data 285 | -------------------------------------------------------------------------------- /tests/reference.mat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-hld/spaudiopy/8995ca826d75baa4fe497b0b466f864f0ed72c08/tests/reference.mat -------------------------------------------------------------------------------- /tests/test_algo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pytest 4 | @author: chris 5 | 6 | Test algorithms. 7 | """ 8 | import pytest 9 | 10 | import numpy as np 11 | from numpy.testing import assert_allclose 12 | 13 | import spaudiopy as spa 14 | 15 | 16 | # SH Order 17 | N_SPHS = [1, 3, 5] # higher orders might need more tolerance 18 | 19 | 20 | @pytest.mark.parametrize('test_n_sph', N_SPHS) 21 | def test_sph_filter_bank(test_n_sph): 22 | N_sph = test_n_sph 23 | sec_dirs = spa.utils.cart2sph(*spa.grids.load_t_design(2*N_sph).T) 24 | c_n = spa.sph.maxre_modal_weights(N_sph) 25 | [A, B] = spa.sph.design_sph_filterbank(N_sph, sec_dirs[0], sec_dirs[1], 26 | c_n, mode='perfect') 27 | 28 | # diffuse SH signal 29 | in_nm = np.random.randn((N_sph+1)**2, 1000) 30 | # Sector signals (Analysis) 31 | s_sec = A @ in_nm 32 | # Reconstruction to SH domain 33 | out_nm = B @ s_sec 34 | 35 | # Perfect Reconstruction 36 | assert_allclose(in_nm, out_nm) 37 | 38 | 39 | @pytest.mark.parametrize('test_n_sph', N_SPHS) 40 | def test_calculate_grid_weights(test_n_sph): 41 | N_sph = test_n_sph 42 | vecs = spa.grids.load_t_design(degree=2*N_sph) 43 | azi, zen, _ = spa.utils.cart2sph(*vecs.T) 44 | 45 | q_weights_t = spa.grids.calculate_grid_weights(azi, zen) 46 | q_weights = 4*np.pi / len(q_weights_t) * np.ones_like(q_weights_t) 47 | 48 | # Perfect Reconstruction 49 | assert_allclose(q_weights_t, q_weights) 50 | 51 | 52 | @pytest.mark.parametrize('test_n_sph', N_SPHS) 53 | def test_rotation(test_n_sph): 54 | test_yaw, test_pitch, test_roll = (0, np.pi/2, 5), (0, 3, 5), (0, -3, 5) 55 | 56 | tgrid = spa.grids.load_t_design(degree=2*test_n_sph) 57 | tazi, tzen, _ = spa.utils.cart2sph(*tgrid.T) 58 | 59 | for yaw in test_yaw: 60 | for pitch in test_pitch: 61 | for roll in test_roll: 62 | print(yaw, pitch, roll) 63 | R = spa.utils.rotation_euler(yaw, pitch, roll) 64 | tgrid_rot = (R @ tgrid.T).T 65 | tazi_rot, tzen_rot, _ = spa.utils.cart2sph(*tgrid_rot.T) 66 | 67 | shmat = spa.sph.sh_matrix(test_n_sph, tazi, tzen, 'real') 68 | shmat_ref = spa.sph.sh_matrix(test_n_sph, tazi_rot, tzen_rot) 69 | 70 | R = spa.sph.sh_rotation_matrix(test_n_sph, yaw, pitch, roll, 71 | sh_type='real') 72 | 73 | shmat_rot = (R @ shmat.T).T 74 | assert_allclose(shmat_ref, shmat_rot, rtol=1e-3) 75 | 76 | shmat_rotate_sh = spa.sph.rotate_sh(shmat, yaw, pitch, roll) 77 | assert_allclose(shmat_ref, shmat_rotate_sh, rtol=1e-3) 78 | -------------------------------------------------------------------------------- /tests/test_parallel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pytest 4 | @author: chris 5 | 6 | Test parallel computing. 7 | """ 8 | import pytest 9 | 10 | import numpy as np 11 | from numpy.testing import assert_allclose 12 | 13 | import spaudiopy as spa 14 | 15 | 16 | JOB_COUNTS = [1, 2, None] 17 | 18 | 19 | @pytest.mark.parametrize('test_jobs', JOB_COUNTS) 20 | def test_pseudo_intensity(test_jobs): 21 | fs = 44100 22 | n_samples = 10000 23 | ambi_b = spa.sig.AmbiBSignal([np.random.randn(n_samples), 24 | np.random.randn(n_samples), 25 | np.random.randn(n_samples), 26 | np.random.randn(n_samples)], fs=fs) 27 | azi_r, zen_r, r_r = spa.parsa.pseudo_intensity(ambi_b, jobs_count=1) 28 | azi_t, zen_t, r_t = spa.parsa.pseudo_intensity(ambi_b, 29 | jobs_count=test_jobs) 30 | assert_allclose([azi_t, zen_t, r_t], [azi_r, zen_r, r_r]) 31 | 32 | 33 | @pytest.mark.parametrize('test_jobs', JOB_COUNTS) 34 | def test_vbap(test_jobs): 35 | vecs = spa.grids.load_t_design(degree=5) 36 | hull = spa.decoder.LoudspeakerSetup(*vecs.T) 37 | src = np.random.randn(1000, 3) 38 | gains_r = spa.decoder.vbap(src, hull, jobs_count=1) 39 | gains_t = spa.decoder.vbap(src, hull, jobs_count=test_jobs) 40 | assert_allclose(gains_t, gains_r) 41 | 42 | 43 | @pytest.mark.parametrize('test_jobs', JOB_COUNTS) 44 | def test_allrap(test_jobs): 45 | vecs = spa.grids.load_t_design(degree=5) 46 | hull = spa.decoder.LoudspeakerSetup(*vecs.T) 47 | hull.ambisonics_setup(update_hull=False) 48 | src = np.random.randn(1000, 3) 49 | gains_r = spa.decoder.allrap(src, hull, jobs_count=1) 50 | gains_t = spa.decoder.allrap(src, hull, jobs_count=test_jobs) 51 | assert_allclose(gains_t, gains_r) 52 | 53 | 54 | @pytest.mark.parametrize('test_jobs', JOB_COUNTS) 55 | def test_allrap2(test_jobs): 56 | vecs = spa.grids.load_t_design(degree=5) 57 | hull = spa.decoder.LoudspeakerSetup(*vecs.T) 58 | hull.ambisonics_setup(update_hull=False) 59 | src = np.random.randn(1000, 3) 60 | gains_r = spa.decoder.allrap2(src, hull, jobs_count=1) 61 | gains_t = spa.decoder.allrap2(src, hull, jobs_count=test_jobs) 62 | assert_allclose(gains_t, gains_r) 63 | 64 | 65 | #@pytest.mark.parametrize('test_jobs', JOB_COUNTS) 66 | #def test_render_bsdm(test_jobs): 67 | # sdm_p, sdm_azi, sdm_zen = [*np.random.randn(3, 1000)] 68 | # hrirs = spa.io.load_hrirs(fs=44100, filename='dummy') 69 | # bsdm_l_r, bsdm_r_r = spa.parsa.render_bsdm(sdm_p, sdm_azi, sdm_zen, hrirs, 70 | # jobs_count=1) 71 | # bsdm_l_t, bsdm_r_t = spa.parsa.render_bsdm(sdm_p, sdm_azi, sdm_zen, hrirs, 72 | # jobs_count=test_jobs) 73 | # assert_allclose([bsdm_l_t, bsdm_r_t], [bsdm_l_r, bsdm_r_r]) 74 | 75 | 76 | @pytest.mark.parametrize('test_jobs', JOB_COUNTS) 77 | def test_resample_hrirs(test_jobs): 78 | hrirs = spa.io.load_hrirs(fs=44100, filename='dummy') 79 | hrir_l_rsmp_r, hrir_r_rsmp_r, _ = spa.process.resample_hrirs(hrirs.left, 80 | hrirs.right, 81 | 44100, 48000, 82 | jobs_count=1) 83 | hrir_l_rsmp_t, hrir_r_rsmp_t, _ = spa.process.resample_hrirs(hrirs.left, 84 | hrirs.right, 85 | 44100, 48000, 86 | jobs_count= 87 | test_jobs) 88 | assert_allclose([hrir_l_rsmp_t, hrir_r_rsmp_t], 89 | [hrir_l_rsmp_r, hrir_r_rsmp_r]) 90 | -------------------------------------------------------------------------------- /tests/test_vals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pytest 4 | @author: chris 5 | 6 | Test values against references. 7 | """ 8 | import os 9 | import sys 10 | import pytest 11 | 12 | import numpy as np 13 | from numpy.testing import assert_allclose 14 | 15 | from scipy.io import loadmat 16 | 17 | import spaudiopy as spa 18 | 19 | current_file_dir = os.path.dirname(__file__) 20 | sys.path.insert(0, os.path.abspath(os.path.join( 21 | current_file_dir, '..'))) 22 | 23 | # SH Order 24 | N_sph = 8 25 | # dict with results from reference implementations 26 | ref_struct = loadmat(os.path.join(current_file_dir, 'reference.mat')) 27 | 28 | # More data to compare against 29 | cart_sph_data = [ 30 | ((1, 1, 1), (np.pi / 4, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 31 | ((-1, 1, 1), (3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 32 | ((1, -1, 1), (-np.pi / 4, np.arccos(1 / np.sqrt(3)), 33 | np.sqrt(3))), 34 | ((-1, -1, 1), (-3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), 35 | np.sqrt(3))), 36 | ((1, 1, -1), (np.pi / 4, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 37 | ((-1, 1, -1), (3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 38 | ((1, -1, -1), (-np.pi / 4, np.arccos(-1 / np.sqrt(3)), 39 | np.sqrt(3))), 40 | ((-1, -1, -1), (-3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), 41 | np.sqrt(3))), 42 | ] 43 | 44 | 45 | # Makes N globally accessible in test_ functions 46 | @pytest.fixture(autouse=True) 47 | def myglobal(request): 48 | request.function.__globals__['N'] = N_sph 49 | 50 | 51 | # SH Tests 52 | @pytest.mark.parametrize('expected_dirs', [ref_struct['dirs'], ]) 53 | def test_tDesign(expected_dirs): 54 | vecs = spa.grids.load_t_design(2*N_sph) 55 | dirs = spa.utils.vec2dir(vecs) 56 | dirs = dirs % (2 * np.pi) # [-pi, pi] -> [0, 2pi) 57 | assert (np.allclose(dirs, expected_dirs)) 58 | 59 | 60 | @pytest.mark.parametrize('expected_Ynm', [ref_struct['Y_N_r'], ]) 61 | def test_realSH(expected_Ynm): 62 | vecs = spa.grids.load_t_design(2*N_sph) 63 | dirs = spa.utils.vec2dir(vecs) 64 | Y_nm = spa.sph.sh_matrix(N_sph, dirs[:, 0], dirs[:, 1], sh_type='real') 65 | assert (np.allclose(Y_nm, expected_Ynm)) 66 | 67 | 68 | @pytest.mark.parametrize('expected_Ynm', [ref_struct['Y_N_c'], ]) 69 | def test_cpxSH(expected_Ynm): 70 | vecs = spa.grids.load_t_design(2*N_sph) 71 | dirs = spa.utils.vec2dir(vecs) 72 | Y_nm = spa.sph.sh_matrix(N_sph, dirs[:, 0], dirs[:, 1], sh_type='complex') 73 | assert (np.allclose(Y_nm, expected_Ynm)) 74 | 75 | 76 | # Coordinate system conversion 77 | @pytest.mark.parametrize('coord, polar', cart_sph_data) 78 | def test_cart2sph(coord, polar): 79 | x, y, z = coord 80 | a = spa.utils.cart2sph(x, y, z, positive_azi=False) 81 | a_steady = spa.utils.cart2sph(x, y, z, positive_azi=False, steady_zen=True) 82 | assert_allclose(a_steady, a) 83 | assert_allclose(np.squeeze(a), polar) 84 | 85 | 86 | @pytest.mark.parametrize('coord, polar', cart_sph_data) 87 | def test_sph2cart(coord, polar): 88 | alpha, beta, r = polar 89 | b = spa.utils.sph2cart(azi=alpha, zen=beta, r=r) 90 | assert_allclose(np.squeeze(b), coord) 91 | --------------------------------------------------------------------------------