├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── NEWS.rst ├── README.rst ├── binder ├── README ├── apt.txt ├── postBuild └── requirements.txt ├── data └── arrays │ ├── example_array_4LS_2D.csv │ ├── example_array_6LS_3D.txt │ ├── wfs_university_rostock_2015.csv │ └── wfs_university_rostock_2018.csv ├── doc ├── README ├── _static │ └── css │ │ └── title.css ├── _template │ └── layout.html ├── api.rst ├── conf.py ├── contributing.rst ├── example-python-scripts.rst ├── examples.rst ├── examples │ ├── animations-pulsating-sphere.ipynb │ ├── animations_pulsating_sphere.py │ ├── figures │ │ ├── circle.png │ │ ├── cross.png │ │ ├── rect.png │ │ └── tree.png │ ├── horizontal_plane_arrays.py │ ├── ipython_kernel_config.py │ ├── mirror-image-source-model.ipynb │ ├── modal-room-acoustics.ipynb │ ├── plot_particle_density.py │ ├── run_all.py │ ├── sound-field-synthesis.ipynb │ ├── soundfigures.py │ ├── time_domain.py │ └── time_domain_nfchoa.py ├── index.rst ├── installation.rst ├── math-definitions.rst ├── readthedocs-environment.yml ├── references.bib ├── references.rst ├── requirements.txt └── version-history.rst ├── examples ├── readthedocs.yml ├── setup.cfg ├── setup.py ├── sfs ├── __init__.py ├── array.py ├── fd │ ├── __init__.py │ ├── esa.py │ ├── nfchoa.py │ ├── sdm.py │ ├── source.py │ └── wfs.py ├── plot2d.py ├── plot3d.py ├── tapering.py ├── td │ ├── __init__.py │ ├── nfchoa.py │ ├── source.py │ └── wfs.py └── util.py └── tests ├── requirements.txt ├── test_array.py └── test_util.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | max_line_length = 80 9 | indent_style = space 10 | indent_size = 4 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.py] 15 | max_line_length = 79 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macOS-latest, windows-latest] 12 | python-version: [3.6, 3.7, 3.8] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Prepare Ubuntu 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install --no-install-recommends -y pandoc ffmpeg 24 | if: matrix.os == 'ubuntu-latest' 25 | - name: Prepare OSX 26 | run: brew install pandoc ffmpeg 27 | if: matrix.os == 'macOS-latest' 28 | - name: prepare Windows 29 | run: choco install pandoc ffmpeg 30 | if: matrix.os == 'windows-latest' 31 | - name: Install dependencies 32 | run: | 33 | python -V 34 | python -m pip install --upgrade pip 35 | python -m pip install . 36 | python -m pip install -r tests/requirements.txt 37 | python -m pip install -r doc/requirements.txt 38 | # This is needed in example scripts: 39 | python -m pip install pillow 40 | - name: Test 41 | run: python -m pytest 42 | - name: Test examples 43 | run: python doc/examples/run_all.py 44 | - name: Test documentation 45 | run: python -m sphinx doc/ _build/ -b doctest 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | .eggs/ 6 | sfs.egg-info/ 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ------------ 3 | 4 | If you find errors, omissions, inconsistencies or other things that need 5 | improvement, please create an issue or a pull request at 6 | https://github.com/sfstoolbox/sfs-python/. 7 | Contributions are always welcome! 8 | 9 | Development Installation 10 | ^^^^^^^^^^^^^^^^^^^^^^^^ 11 | 12 | Instead of pip-installing the latest release from PyPI_, you should get the 13 | newest development version from Github_:: 14 | 15 | git clone https://github.com/sfstoolbox/sfs-python.git 16 | cd sfs-python 17 | python3 -m pip install --user -e . 18 | 19 | ... where ``-e`` stands for ``--editable``. 20 | 21 | This way, your installation always stays up-to-date, even if you pull new 22 | changes from the Github repository. 23 | 24 | .. _PyPI: https://pypi.org/project/sfs/ 25 | .. _Github: https://github.com/sfstoolbox/sfs-python/ 26 | 27 | 28 | Building the Documentation 29 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 30 | 31 | If you make changes to the documentation, you can re-create the HTML pages 32 | using Sphinx_. 33 | You can install it and a few other necessary packages with:: 34 | 35 | python3 -m pip install -r doc/requirements.txt --user 36 | 37 | To create the HTML pages, use:: 38 | 39 | python3 setup.py build_sphinx 40 | 41 | The generated files will be available in the directory ``build/sphinx/html/``. 42 | 43 | To create the EPUB file, use:: 44 | 45 | python3 setup.py build_sphinx -b epub 46 | 47 | The generated EPUB file will be available in the directory 48 | ``build/sphinx/epub/``. 49 | 50 | To create the PDF file, use:: 51 | 52 | python3 setup.py build_sphinx -b latex 53 | 54 | Afterwards go to the folder ``build/sphinx/latex/`` and run LaTeX to create the 55 | PDF file. If you don’t know how to create a PDF file from the LaTeX output, you 56 | should have a look at Latexmk_ (see also this `Latexmk tutorial`_). 57 | 58 | It is also possible to automatically check if all links are still valid:: 59 | 60 | python3 setup.py build_sphinx -b linkcheck 61 | 62 | .. _Sphinx: http://sphinx-doc.org/ 63 | .. _Latexmk: http://personal.psu.edu/jcc8/software/latexmk-jcc/ 64 | .. _Latexmk tutorial: https://mg.readthedocs.io/latexmk.html 65 | 66 | Running the Tests 67 | ^^^^^^^^^^^^^^^^^ 68 | 69 | You'll need pytest_ for that. 70 | It can be installed with:: 71 | 72 | python3 -m pip install -r tests/requirements.txt --user 73 | 74 | To execute the tests, simply run:: 75 | 76 | python3 -m pytest 77 | 78 | .. _pytest: https://pytest.org/ 79 | 80 | Creating a New Release 81 | ^^^^^^^^^^^^^^^^^^^^^^ 82 | 83 | New releases are made using the following steps: 84 | 85 | #. Bump version number in ``sfs/__init__.py`` 86 | #. Update ``NEWS.rst`` 87 | #. Commit those changes as "Release x.y.z" 88 | #. Create an (annotated) tag with ``git tag -a x.y.z`` 89 | #. Clear the ``dist/`` directory 90 | #. Create a source distribution with ``python3 setup.py sdist`` 91 | #. Create a wheel distribution with ``python3 setup.py bdist_wheel`` 92 | #. Check that both files have the correct content 93 | #. Upload them to PyPI_ with twine_: ``python3 -m twine upload dist/*`` 94 | #. Push the commit and the tag to Github and `add release notes`_ containing a 95 | link to PyPI and the bullet points from ``NEWS.rst`` 96 | #. Check that the new release was built correctly on RTD_ 97 | and select the new release as default version 98 | 99 | .. _twine: https://twine.readthedocs.io/ 100 | .. _add release notes: https://github.com/sfstoolbox/sfs-python/tags 101 | .. _RTD: https://readthedocs.org/projects/sfs-python/builds/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2019 SFS Toolbox Developers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.rst 3 | include doc/requirements.txt 4 | recursive-include doc *.rst *.py 5 | recursive-include tests *.py 6 | include doc/examples/*.ipynb 7 | include doc/examples/figures/*.png 8 | recursive-include data * 9 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | Version History 2 | =============== 3 | 4 | 5 | Version 0.6.2 (2021-06-05): 6 | * build doc fix, use sphinx4, mathjax2, html_css_files 7 | 8 | Version 0.6.1 (2021-06-05): 9 | * New default driving function for `sfs.td.wfs.point_25d()` for reference curve 10 | 11 | Version 0.6.0 (2020-12-01): 12 | * New function `sfs.fd.source.line_bandlimited()` computing the sound field of a spatially bandlimited line source 13 | * Drop support for Python 3.5 14 | 15 | Version 0.5.0 (2019-03-18): 16 | * Switching to separate `sfs.plot2d` and `sfs.plot3d` for plotting functions 17 | * Move `sfs.util.displacement()` to `sfs.fd.displacement()` 18 | * Switch to keyword only arguments 19 | * New default driving function for `sfs.fd.wfs.point_25d()` 20 | * New driving function syntax, e.g. `sfs.fd.wfs.point_25d()` 21 | * Example for the sound field of a pulsating sphere 22 | * Add time domain NFC-HOA driving functions `sfs.td.nfchoa` 23 | * `sfs.fd.synthesize()`, `sfs.td.synthesize()` for soundfield superposition 24 | * Change `sfs.mono` to `sfs.fd` and `sfs.time` to `sfs.td` 25 | * Move source selection helpers to `sfs.util` 26 | * Use `sfs.default` object instead of `sfs.defs` submodule 27 | * Drop support for legacy Python 2.7 28 | 29 | Version 0.4.0 (2018-03-14): 30 | * Driving functions in time domain for a plane wave, point source, and 31 | focused source 32 | * Image source model for a point source in a rectangular room 33 | * `sfs.util.DelayedSignal` class and `sfs.util.as_delayed_signal()` 34 | * Improvements to the documentation 35 | * Start using Jupyter notebooks for examples in documentation 36 | * Spherical Hankel function as `sfs.util.spherical_hn2()` 37 | * Use `scipy.special.spherical_jn`, `scipy.special.spherical_yn` instead of 38 | `scipy.special.sph_jnyn` 39 | * Generalization of the modal order argument in `sfs.mono.source.point_modal()` 40 | * Rename `sfs.util.normal_vector()` to `sfs.util.normalize_vector()` 41 | * Add parameter ``max_order`` to NFCHOA driving functions 42 | * Add ``beta`` parameter to Kaiser tapering window 43 | * Fix clipping problem of sound field plots with matplotlib 2.1 44 | * Fix elevation in `sfs.util.cart2sph()` 45 | * Fix `sfs.tapering.tukey()` for ``alpha=1`` 46 | 47 | Version 0.3.1 (2016-04-08): 48 | * Fixed metadata of release 49 | 50 | Version 0.3.0 (2016-04-08): 51 | * Dirichlet Green's function for the scattering of a line source at an edge 52 | * Driving functions for the synthesis of various virtual source types with 53 | edge-shaped arrays by the equivalent scattering appoach 54 | * Driving functions for the synthesis of focused sources by WFS 55 | 56 | Version 0.2.0 (2015-12-11): 57 | * Ability to calculate and plot particle velocity and displacement fields 58 | * Several function name and parameter name changes 59 | 60 | Version 0.1.1 (2015-10-08): 61 | * Fix missing `sfs.mono` subpackage in PyPI packages 62 | 63 | Version 0.1.0 (2015-09-22): 64 | Initial release. 65 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Sound Field Synthesis (SFS) Toolbox for Python 2 | ============================================== 3 | 4 | A Python library for creating numercial simulations of sound field synthesis 5 | methods like Wave Field Synthesis (WFS) or Near-Field Compensated Higher Order 6 | Ambisonics (NFC-HOA). 7 | 8 | Documentation: 9 | https://sfs-python.readthedocs.io/ 10 | 11 | Source code and issue tracker: 12 | https://github.com/sfstoolbox/sfs-python/ 13 | 14 | License: 15 | MIT -- see the file ``LICENSE`` for details. 16 | 17 | Quick start: 18 | * Install Python 3, NumPy, SciPy and Matplotlib 19 | * ``python3 -m pip install sfs --user`` 20 | * Check out the examples in the documentation 21 | 22 | More information about the underlying theory can be found at 23 | https://sfs.readthedocs.io/. 24 | There is also a Sound Field Synthesis Toolbox for Octave/Matlab, see 25 | https://sfs-matlab.readthedocs.io/. 26 | -------------------------------------------------------------------------------- /binder/README: -------------------------------------------------------------------------------- 1 | This directory holds configuration files for https://mybinder.org/. 2 | 3 | The SFS Toolbox examples can be accessed with this link: 4 | https://mybinder.org/v2/gh/sfstoolbox/sfs-python/master?filepath=doc/examples 5 | 6 | To check out a different version, just replace "master" with the desired 7 | branch/tag name or commit hash. 8 | -------------------------------------------------------------------------------- /binder/apt.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/043e9dd0f6aae5eeebbed8357968a2669d29fc40/binder/apt.txt -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | python setup.py develop 6 | -------------------------------------------------------------------------------- /binder/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | scipy 3 | matplotlib>=1.5 4 | -------------------------------------------------------------------------------- /data/arrays/example_array_4LS_2D.csv: -------------------------------------------------------------------------------- 1 | 1,0,0,-1,0,0,1 2 | 0,1,0,0,-1,0,1 3 | -1,0,0,1,0,0,1 4 | 0,-1,0,0,1,0,1 -------------------------------------------------------------------------------- /data/arrays/example_array_6LS_3D.txt: -------------------------------------------------------------------------------- 1 | 1 0 0 1 2 | -1 0 0 1 3 | 0 1 0 1 4 | 0 -1 0 1 5 | 0 0 1 1 6 | 0 0 -1 1 -------------------------------------------------------------------------------- /data/arrays/wfs_university_rostock_2015.csv: -------------------------------------------------------------------------------- 1 | 1.88,0.1275,0,-1,0,0,1 2 | 1.88,0.31,0,-1,0,0,1 3 | 1.88,0.54,0,-1,0,0,1 4 | 1.88,0.7725,0,-1,0,0,1 5 | 1.88,1.02,0,-1,0,0,1 6 | 1.88,1.27,0,-1,0,0,1 7 | 1.88,1.4975,0,-1,0,0,1 8 | 1.88,1.6775,0,-1,0,0,1 9 | 1.685,1.88,0,0,-1,0,1 10 | 1.495,1.88,0,0,-1,0,1 11 | 1.2525,1.88,0,0,-1,0,1 12 | 1.02,1.88,0,0,-1,0,1 13 | 0.7725,1.88,0,0,-1,0,1 14 | 0.5475,1.88,0,0,-1,0,1 15 | 0.3025,1.88,0,0,-1,0,1 16 | 0.0575,1.88,0,0,-1,0,1 17 | -0.13,1.88,0,0,-1,0,1 18 | -0.315,1.88,0,0,-1,0,1 19 | -0.5375,1.88,0,0,-1,0,1 20 | -0.7725,1.88,0,0,-1,0,1 21 | -1.0175,1.88,0,0,-1,0,1 22 | -1.27,1.88,0,0,-1,0,1 23 | -1.4975,1.88,0,0,-1,0,1 24 | -1.69,1.88,0,0,-1,0,1 25 | -1.88,1.6875,0,1,0,0,1 26 | -1.88,1.5,0,1,0,0,1 27 | -1.88,1.2525,0,1,0,0,1 28 | -1.88,1.02,0,1,0,0,1 29 | -1.88,0.775,0,1,0,0,1 30 | -1.88,0.55,0,1,0,0,1 31 | -1.88,0.305,0,1,0,0,1 32 | -1.88,0.0625,0,1,0,0,1 33 | -1.88,-0.13,0,1,0,0,1 34 | -1.88,-0.3125,0,1,0,0,1 35 | -1.88,-0.5325,0,1,0,0,1 36 | -1.88,-0.7625,0,1,0,0,1 37 | -1.88,-1.0125,0,1,0,0,1 38 | -1.88,-1.2625,0,1,0,0,1 39 | -1.88,-1.5,0,1,0,0,1 40 | -1.88,-1.69,0,1,0,0,1 41 | -1.6925,-1.88,0,0,1,0,1 42 | -1.505,-1.88,0,0,1,0,1 43 | -1.2625,-1.88,0,0,1,0,1 44 | -1.025,-1.88,0,0,1,0,1 45 | -0.785,-1.88,0,0,1,0,1 46 | -0.555,-1.88,0,0,1,0,1 47 | -0.3125,-1.88,0,0,1,0,1 48 | -0.0675,-1.88,0,0,1,0,1 49 | 0.125,-1.88,0,0,1,0,1 50 | 0.305,-1.88,0,0,1,0,1 51 | 0.525,-1.88,0,0,1,0,1 52 | 0.7625,-1.88,0,0,1,0,1 53 | 1.0075,-1.88,0,0,1,0,1 54 | 1.2775,-1.88,0,0,1,0,1 55 | 1.4925,-1.88,0,0,1,0,1 56 | 1.6825,-1.88,0,0,1,0,1 57 | 1.88,-1.69,0,-1,0,0,1 58 | 1.88,-1.4975,0,-1,0,0,1 59 | 1.88,-1.25,0,-1,0,0,1 60 | 1.88,-1.02,0,-1,0,0,1 61 | 1.88,-0.7725,0,-1,0,0,1 62 | 1.88,-0.5475,0,-1,0,0,1 63 | 1.88,-0.3025,0,-1,0,0,1 64 | 1.88,-0.0625,0,-1,0,0,1 65 | -------------------------------------------------------------------------------- /data/arrays/wfs_university_rostock_2018.csv: -------------------------------------------------------------------------------- 1 | 1.8555,0.12942,1.6137,-1,0,0,0.1877 2 | 1.8604,0.31567,1.6137,-1,0,0,0.2045 3 | 1.8638,0.53832,1.6133,-1,0,0,0.22837 4 | 1.8665,0.77237,1.6118,-1,0,0,0.24117 5 | 1.8673,1.0206,1.6157,-1,0,0,0.24838 6 | 1.8688,1.2691,1.6154,-1,0,0,0.23781 7 | 1.8702,1.4962,1.6167,-1,0,0,0.20929 8 | 1.8755,1.6876,1.6163,-1,0,0,0.22679 9 | 1.6875,1.8702,1.6203,0,-1,0,0.22545 10 | 1.4993,1.8843,1.6154,0,-1,0,0.21679 11 | 1.2547,1.8749,1.6174,0,-1,0,0.23875 12 | 1.022,1.8768,1.6184,0,-1,0,0.23992 13 | 0.77488,1.8763,1.6175,0,-1,0,0.2349 14 | 0.55221,1.8775,1.6177,0,-1,0,0.2327 15 | 0.3095,1.8797,1.6157,0,-1,0,0.24573 16 | 0.060789,1.882,1.6134,0,-1,0,0.21554 17 | -0.12151,1.8841,1.6101,0,-1,0,0.18685 18 | -0.31278,1.8791,1.613,0,-1,0,0.20506 19 | -0.53142,1.8855,1.6099,0,-1,0,0.22562 20 | -0.76382,1.8905,1.6061,0,-1,0,0.23945 21 | -1.0102,1.8888,1.6101,0,-1,0,0.25042 22 | -1.2646,1.8911,1.6086,0,-1,0,0.23947 23 | -1.4891,1.8936,1.607,0,-1,0,0.20807 24 | -1.6807,1.8964,1.6062,0,-1,0,0.22572 25 | -1.8625,1.7108,1.6075,1,0,0,0.22016 26 | -1.863,1.5303,1.6066,1,0,0,0.21877 27 | -1.8611,1.2733,1.6107,1,0,0,0.2448 28 | -1.8653,1.0408,1.6075,1,0,0,0.23885 29 | -1.8729,0.79578,1.6054,1,0,0,0.23437 30 | -1.8704,0.5722,1.6071,1,0,0,0.23219 31 | -1.881,0.33166,1.6053,1,0,0,0.24605 32 | -1.8783,0.080365,1.6075,1,0,0,0.21801 33 | -1.8781,-0.10434,1.6061,1,0,0,0.1852 34 | -1.8798,-0.28999,1.609,1,0,0,0.20278 35 | -1.8842,-0.50982,1.6095,1,0,0,0.22814 36 | -1.8911,-0.74608,1.6054,1,0,0,0.23945 37 | -1.8901,-0.98854,1.6102,1,0,0,0.24439 38 | -1.8928,-1.2348,1.6095,1,0,0,0.24209 39 | -1.8925,-1.4727,1.6117,1,0,0,0.21306 40 | -1.8939,-1.6609,1.6115,1,0,0,0.22209 41 | -1.7127,-1.8417,1.611,0,1,0,0.21959 42 | -1.5295,-1.8417,1.6129,0,1,0,0.21598 43 | -1.2809,-1.8485,1.6079,0,1,0,0.24212 44 | -1.0454,-1.8478,1.6094,0,1,0,0.2401 45 | -0.80072,-1.8512,1.609,0,1,0,0.23619 46 | -0.57305,-1.8524,1.6082,0,1,0,0.23437 47 | -0.33198,-1.8525,1.6074,0,1,0,0.24395 48 | -0.085164,-1.854,1.6085,0,1,0,0.21792 49 | 0.10383,-1.8571,1.6082,0,1,0,0.18649 50 | 0.28774,-1.8609,1.6061,0,1,0,0.20288 51 | 0.50951,-1.8574,1.6049,0,1,0,0.22772 52 | 0.74305,-1.8643,1.6034,0,1,0,0.23983 53 | 0.989,-1.8695,1.6036,0,1,0,0.24802 54 | 1.239,-1.8649,1.6041,0,1,0,0.24388 55 | 1.4767,-1.8678,1.6054,0,1,0,0.20977 56 | 1.6585,-1.8653,1.6059,0,1,0,0.22148 57 | 1.8436,-1.6811,1.6054,-1,0,0,0.22264 58 | 1.8563,-1.4974,1.6033,-1,0,0,0.21688 59 | 1.8468,-1.248,1.6072,-1,0,0,0.24047 60 | 1.85,-1.0167,1.6076,-1,0,0,0.23909 61 | 1.8513,-0.76986,1.6101,-1,0,0,0.23739 62 | 1.8585,-0.54207,1.6076,-1,0,0,0.23585 63 | 1.8562,-0.29831,1.6107,-1,0,0,0.24122 64 | 1.857,-0.059658,1.6121,-1,0,0,0.21387 65 | -------------------------------------------------------------------------------- /doc/README: -------------------------------------------------------------------------------- 1 | This directory holds the documentation in reStructuredText/Sphinx format. 2 | It also contains some examples (Jupyter notebooks and Python scripts). 3 | Have a look at the online documentation for the auto-generated HTML version: 4 | 5 | https://sfs-python.readthedocs.io/ 6 | 7 | If you want to generate the HTML (or LaTeX/PDF) files on your computer, have a 8 | look at https://sfs-python.readthedocs.io/en/latest/contributing.html. 9 | -------------------------------------------------------------------------------- /doc/_static/css/title.css: -------------------------------------------------------------------------------- 1 | .wy-side-nav-search>a, .wy-side-nav-search .wy-dropdown>a { 2 | font-family: "Roboto Slab","ff-tisa-web-pro","Georgia",Arial,sans-serif; 3 | font-size: 200%; 4 | margin-top: .222em; 5 | margin-bottom: .202em; 6 | } 7 | .wy-side-nav-search { 8 | padding: 0; 9 | } 10 | form#rtd-search-form { 11 | margin-left: .809em; 12 | margin-right: .809em; 13 | } 14 | .rtd-nav a { 15 | float: left; 16 | display: block; 17 | width: 33.3%; 18 | height: 100%; 19 | padding-top: 7px; 20 | color: white; 21 | } 22 | .rtd-nav { 23 | overflow: hidden; 24 | width: 100%; 25 | height: 35px; 26 | margin-top: 15px; 27 | } 28 | .rtd-nav a:hover { 29 | background-color: #388bbd; 30 | } 31 | .rtd-nav a.active { 32 | background-color: #388bbd; 33 | } 34 | -------------------------------------------------------------------------------- /doc/_template/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block sidebartitle %} 3 | 4 | {{ project }} 5 | 6 | {% include "searchbox.html" %} 7 | 8 |
9 | Theory 10 | Matlab 11 | Python 12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: sfs 5 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # SFS documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Nov 4 14:01:37 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | from subprocess import check_output 19 | 20 | import sphinx 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | #sys.path.insert(0, os.path.abspath('..')) 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | needs_sphinx = '1.3' # for sphinx.ext.napoleon 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.autosummary', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx.ext.napoleon', # support for NumPy-style docstrings 40 | 'sphinx.ext.intersphinx', 41 | 'sphinx.ext.doctest', 42 | 'sphinxcontrib.bibtex', 43 | 'sphinx.ext.extlinks', 44 | 'matplotlib.sphinxext.plot_directive', 45 | 'nbsphinx', 46 | ] 47 | 48 | bibtex_bibfiles = ['references.bib'] 49 | 50 | nbsphinx_execute_arguments = [ 51 | "--InlineBackend.figure_formats={'svg', 'pdf'}", 52 | "--InlineBackend.rc={'figure.dpi': 96}", 53 | ] 54 | 55 | # Tell autodoc that the documentation is being generated 56 | sphinx.SFS_DOCS_ARE_BEING_BUILT = True 57 | 58 | autoclass_content = 'init' 59 | autodoc_member_order = 'bysource' 60 | autodoc_default_options = { 61 | 'members': True, 62 | 'undoc-members': True, 63 | } 64 | 65 | autosummary_generate = ['api'] 66 | 67 | napoleon_google_docstring = False 68 | napoleon_numpy_docstring = True 69 | napoleon_include_private_with_doc = False 70 | napoleon_include_special_with_doc = False 71 | napoleon_use_admonition_for_examples = False 72 | napoleon_use_admonition_for_notes = False 73 | napoleon_use_admonition_for_references = False 74 | napoleon_use_ivar = False 75 | napoleon_use_param = False 76 | napoleon_use_rtype = False 77 | 78 | intersphinx_mapping = { 79 | 'python': ('https://docs.python.org/3/', None), 80 | 'numpy': ('https://docs.scipy.org/doc/numpy/', None), 81 | 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), 82 | 'matplotlib': ('https://matplotlib.org/', None), 83 | } 84 | 85 | extlinks = {'sfs': ('https://sfs.readthedocs.io/en/3.2/%s', 86 | 'https://sfs.rtfd.io/')} 87 | 88 | plot_include_source = True 89 | plot_html_show_source_link = False 90 | plot_html_show_formats = False 91 | plot_pre_code = '' 92 | plot_rcparams = { 93 | 'savefig.bbox': 'tight', 94 | } 95 | plot_formats = ['svg', 'pdf'] 96 | 97 | # use mathjax2 with 98 | # https://github.com/spatialaudio/nbsphinx/issues/572#issuecomment-853389268 99 | # and 'TeX' dictionary 100 | # in future we might switch to mathjax3 once the 101 | # 'begingroup' extension is available 102 | # http://docs.mathjax.org/en/latest/input/tex/extensions/begingroup.html#begingroup 103 | # https://mathjax.github.io/MathJax-demos-web/convert-configuration/convert-configuration.html 104 | mathjax_path = ('https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js' 105 | '?config=TeX-AMS-MML_HTMLorMML') 106 | mathjax2_config = { 107 | 'tex2jax': { 108 | 'inlineMath': [['$', '$'], ['\\(', '\\)']], 109 | 'processEscapes': True, 110 | 'ignoreClass': 'document', 111 | 'processClass': 'math|output_area', 112 | }, 113 | 'TeX': { 114 | 'extensions': ['newcommand.js', 'begingroup.js'], # Support for \gdef 115 | }, 116 | } 117 | 118 | # Add any paths that contain templates here, relative to this directory. 119 | templates_path = ['_template'] 120 | 121 | # The suffix of source filenames. 122 | source_suffix = '.rst' 123 | 124 | # The encoding of source files. 125 | #source_encoding = 'utf-8-sig' 126 | 127 | # The master toctree document. 128 | master_doc = 'index' 129 | 130 | # General information about the project. 131 | authors = 'SFS Toolbox Developers' 132 | project = 'SFS Toolbox' 133 | copyright = '2019, ' + authors 134 | 135 | # The version info for the project you're documenting, acts as replacement for 136 | # |version| and |release|, also used in various other places throughout the 137 | # built documents. 138 | # 139 | # The short X.Y version. 140 | #version = '0.0.0' 141 | # The full version, including alpha/beta/rc tags. 142 | try: 143 | release = check_output(['git', 'describe', '--tags', '--always']) 144 | release = release.decode().strip() 145 | except Exception: 146 | release = '' 147 | 148 | # The language for content autogenerated by Sphinx. Refer to documentation 149 | # for a list of supported languages. 150 | #language = None 151 | 152 | # There are two options for replacing |today|: either, you set today to some 153 | # non-false value, then it is used: 154 | #today = '' 155 | # Else, today_fmt is used as the format for a strftime call. 156 | #today_fmt = '%B %d, %Y' 157 | try: 158 | today = check_output(['git', 'show', '-s', '--format=%ad', '--date=short']) 159 | today = today.decode().strip() 160 | except Exception: 161 | today = '' 162 | 163 | # List of patterns, relative to source directory, that match files and 164 | # directories to ignore when looking for source files. 165 | exclude_patterns = ['_build', '**/.ipynb_checkpoints'] 166 | 167 | # The reST default role (used for this markup: `text`) to use for all 168 | # documents. 169 | default_role = 'any' 170 | 171 | # If true, '()' will be appended to :func: etc. cross-reference text. 172 | #add_function_parentheses = True 173 | 174 | # If true, the current module name will be prepended to all description 175 | # unit titles (such as .. function::). 176 | #add_module_names = True 177 | 178 | # If true, sectionauthor and moduleauthor directives will be shown in the 179 | # output. They are ignored by default. 180 | #show_authors = False 181 | 182 | # The name of the Pygments (syntax highlighting) style to use. 183 | pygments_style = 'sphinx' 184 | 185 | # A list of ignored prefixes for module index sorting. 186 | #modindex_common_prefix = [] 187 | 188 | # If true, keep warnings as "system message" paragraphs in the built documents. 189 | #keep_warnings = False 190 | 191 | jinja_define = """ 192 | {% set docname = env.doc2path(env.docname, base='doc') %} 193 | {% set latex_href = ''.join([ 194 | '\href{https://github.com/sfstoolbox/sfs-python/blob/', 195 | env.config.release, 196 | '/', 197 | docname | escape_latex, 198 | '}{\sphinxcode{\sphinxupquote{', 199 | docname | escape_latex, 200 | '}}}', 201 | ]) %} 202 | """ 203 | 204 | nbsphinx_prolog = jinja_define + r""" 205 | .. only:: html 206 | 207 | .. role:: raw-html(raw) 208 | :format: html 209 | 210 | .. nbinfo:: 211 | 212 | This page was generated from `{{ docname }}`__. 213 | Interactive online version: 214 | :raw-html:`Binder badge` 215 | 216 | __ https://github.com/sfstoolbox/sfs-python/blob/ 217 | {{ env.config.release }}/{{ docname }} 218 | 219 | .. raw:: latex 220 | 221 | \nbsphinxstartnotebook{\scriptsize\noindent\strut 222 | \textcolor{gray}{The following section was generated from {{ latex_href }} 223 | \dotfill}} 224 | """ 225 | 226 | nbsphinx_epilog = jinja_define + r""" 227 | .. raw:: latex 228 | 229 | \nbsphinxstopnotebook{\scriptsize\noindent\strut 230 | \textcolor{gray}{\dotfill\ {{ latex_href }} ends here.}} 231 | """ 232 | 233 | 234 | # -- Options for HTML output ---------------------------------------------- 235 | 236 | html_css_files = ['css/title.css'] 237 | 238 | # The theme to use for HTML and HTML Help pages. See the documentation for 239 | # a list of builtin themes. 240 | html_theme = 'sphinx_rtd_theme' 241 | 242 | # Theme options are theme-specific and customize the look and feel of a theme 243 | # further. For a list of options available for each theme, see the 244 | # documentation. 245 | html_theme_options = { 246 | 'collapse_navigation': False, 247 | } 248 | 249 | # Add any paths that contain custom themes here, relative to this directory. 250 | #html_theme_path = [] 251 | 252 | # The name for this set of Sphinx documents. If None, it defaults to 253 | # " v documentation". 254 | html_title = project + ", version " + release 255 | 256 | # A shorter title for the navigation bar. Default is the same as html_title. 257 | #html_short_title = None 258 | 259 | # The name of an image file (relative to this directory) to place at the top 260 | # of the sidebar. 261 | #html_logo = None 262 | 263 | # The name of an image file (within the static path) to use as favicon of the 264 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 265 | # pixels large. 266 | #html_favicon = None 267 | 268 | # Add any paths that contain custom static files (such as style sheets) here, 269 | # relative to this directory. They are copied after the builtin static files, 270 | # so a file named "default.css" will overwrite the builtin "default.css". 271 | html_static_path = ['_static'] 272 | 273 | # Add any extra paths that contain custom files (such as robots.txt or 274 | # .htaccess) here, relative to this directory. These files are copied 275 | # directly to the root of the documentation. 276 | #html_extra_path = [] 277 | 278 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 279 | # using the given strftime format. 280 | #html_last_updated_fmt = '%b %d, %Y' 281 | 282 | # If true, SmartyPants will be used to convert quotes and dashes to 283 | # typographically correct entities. 284 | #html_use_smartypants = True 285 | 286 | # Custom sidebar templates, maps document names to template names. 287 | #html_sidebars = {} 288 | 289 | # Additional templates that should be rendered to pages, maps page names to 290 | # template names. 291 | #html_additional_pages = {} 292 | 293 | # If false, no module index is generated. 294 | #html_domain_indices = True 295 | 296 | # If false, no index is generated. 297 | #html_use_index = True 298 | 299 | # If true, the index is split into individual pages for each letter. 300 | #html_split_index = False 301 | 302 | # If true, links to the reST sources are added to the pages. 303 | html_show_sourcelink = True 304 | html_sourcelink_suffix = '' 305 | 306 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 307 | #html_show_sphinx = True 308 | 309 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 310 | #html_show_copyright = True 311 | 312 | # If true, an OpenSearch description file will be output, and all pages will 313 | # contain a tag referring to it. The value of this option must be the 314 | # base URL from which the finished HTML is served. 315 | #html_use_opensearch = '' 316 | 317 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 318 | #html_file_suffix = None 319 | 320 | # Output file base name for HTML help builder. 321 | htmlhelp_basename = 'SFS' 322 | 323 | html_scaled_image_link = False 324 | 325 | # -- Options for LaTeX output --------------------------------------------- 326 | 327 | latex_elements = { 328 | 'papersize': 'a4paper', 329 | 'printindex': '', 330 | 'sphinxsetup': r""" 331 | VerbatimColor={HTML}{F5F5F5}, 332 | VerbatimBorderColor={HTML}{E0E0E0}, 333 | noteBorderColor={HTML}{E0E0E0}, 334 | noteborder=1.5pt, 335 | warningBorderColor={HTML}{E0E0E0}, 336 | warningborder=1.5pt, 337 | warningBgColor={HTML}{FBFBFB}, 338 | """, 339 | 'preamble': r""" 340 | \usepackage[sc,osf]{mathpazo} 341 | \linespread{1.05} % see http://www.tug.dk/FontCatalogue/urwpalladio/ 342 | \renewcommand{\sfdefault}{pplj} % Palatino instead of sans serif 343 | \IfFileExists{zlmtt.sty}{ 344 | \usepackage[light,scaled=1.05]{zlmtt} % light typewriter font from lmodern 345 | }{ 346 | \renewcommand{\ttdefault}{lmtt} % typewriter font from lmodern 347 | } 348 | """, 349 | } 350 | 351 | # Grouping the document tree into LaTeX files. List of tuples 352 | # (source start file, target name, title, 353 | # author, documentclass [howto, manual, or own class]). 354 | latex_documents = [('index', 'SFS.tex', project, authors, 'howto')] 355 | 356 | # The name of an image file (relative to this directory) to place at the top of 357 | # the title page. 358 | #latex_logo = None 359 | 360 | # For "manual" documents, if this is true, then toplevel headings are parts, 361 | # not chapters. 362 | #latex_use_parts = False 363 | 364 | # If true, show page references after internal links. 365 | #latex_show_pagerefs = False 366 | 367 | # If true, show URL addresses after external links. 368 | latex_show_urls = 'footnote' 369 | 370 | # Documents to append as an appendix to all manuals. 371 | #latex_appendices = [] 372 | 373 | # If false, no module index is generated. 374 | latex_domain_indices = False 375 | 376 | 377 | # -- Options for manual page output --------------------------------------- 378 | 379 | # One entry per manual page. List of tuples 380 | # (source start file, name, description, authors, manual section). 381 | #man_pages = [('index', 'sfs', project, [authors], 1)] 382 | 383 | # If true, show URL addresses after external links. 384 | #man_show_urls = False 385 | 386 | 387 | # -- Options for Texinfo output ------------------------------------------- 388 | 389 | # Grouping the document tree into Texinfo files. List of tuples 390 | # (source start file, target name, title, author, 391 | # dir menu entry, description, category) 392 | #texinfo_documents = [ 393 | # ('index', 'SFS', project, project, 'SFS', 'Sound Field Synthesis Toolbox.', 394 | # 'Miscellaneous'), 395 | #] 396 | 397 | # Documents to append as an appendix to all manuals. 398 | #texinfo_appendices = [] 399 | 400 | # If false, no module index is generated. 401 | #texinfo_domain_indices = True 402 | 403 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 404 | #texinfo_show_urls = 'footnote' 405 | 406 | # If true, do not generate a @detailmenu in the "Top" node's menu. 407 | #texinfo_no_detailmenu = False 408 | 409 | 410 | # -- Options for epub output ---------------------------------------------- 411 | 412 | epub_author = authors 413 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /doc/example-python-scripts.rst: -------------------------------------------------------------------------------- 1 | Example Python Scripts 2 | ====================== 3 | 4 | Various example scripts are located in the directory ``doc/examples/``, e.g. 5 | 6 | * :download:`examples/horizontal_plane_arrays.py`: Computes the sound fields 7 | for various techniques, virtual sources and loudspeaker array configurations 8 | * :download:`examples/animations_pulsating_sphere.py`: Creates animations of a 9 | pulsating sphere, see also `the corresponding Jupyter notebook 10 | `__ 11 | * :download:`examples/soundfigures.py`: Illustrates the synthesis of sound 12 | figures with Wave Field Synthesis 13 | -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | .. only:: html 5 | 6 | You can play with the Jupyter notebooks (without having to install anything) 7 | by clicking |binder logo| on the respective example page. 8 | 9 | .. |binder logo| image:: https://mybinder.org/badge_logo.svg 10 | :target: https://mybinder.org/v2/gh/sfstoolbox/sfs-python/master? 11 | filepath=doc/examples 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | 16 | examples/sound-field-synthesis 17 | examples/modal-room-acoustics 18 | examples/mirror-image-source-model 19 | examples/animations-pulsating-sphere 20 | example-python-scripts 21 | -------------------------------------------------------------------------------- /doc/examples/animations-pulsating-sphere.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Animations of a Pulsating Sphere" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import sfs\n", 17 | "import numpy as np\n", 18 | "import matplotlib.pyplot as plt\n", 19 | "from IPython.display import HTML" 20 | ] 21 | }, 22 | { 23 | "cell_type": "markdown", 24 | "metadata": {}, 25 | "source": [ 26 | "In this example, the sound field of a pulsating sphere is visualized.\n", 27 | "Different acoustic variables, such as sound pressure,\n", 28 | "particle velocity, and particle displacement, are simulated.\n", 29 | "The first two quantities are computed with\n", 30 | "\n", 31 | "- [sfs.fd.source.pulsating_sphere()](../sfs.fd.source.rst#sfs.fd.source.pulsating_sphere) and \n", 32 | "- [sfs.fd.source.pulsating_sphere_velocity()](../sfs.fd.source.rst#sfs.fd.source.pulsating_sphere_velocity)\n", 33 | "\n", 34 | "while the last one can be obtained by using\n", 35 | "\n", 36 | "- [sfs.fd.displacement()](../sfs.fd.rst#sfs.fd.displacement)\n", 37 | "\n", 38 | "which converts the particle velocity into displacement.\n", 39 | "\n", 40 | "A couple of additional functions are implemented in\n", 41 | "\n", 42 | "- [animations_pulsating_sphere.py](animations_pulsating_sphere.py)\n", 43 | "\n", 44 | "in order to help creating animating pictures, which is fun!" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "import animations_pulsating_sphere as animation" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": null, 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "# Pulsating sphere\n", 63 | "center = [0, 0, 0]\n", 64 | "radius = 0.25\n", 65 | "amplitude = 0.05\n", 66 | "f = 1000 # frequency\n", 67 | "omega = 2 * np.pi * f # angular frequency\n", 68 | "\n", 69 | "# Axis limits\n", 70 | "figsize = (6, 6)\n", 71 | "xmin, xmax = -1, 1\n", 72 | "ymin, ymax = -1, 1\n", 73 | "\n", 74 | "# Animations\n", 75 | "frames = 20 # frames per period" 76 | ] 77 | }, 78 | { 79 | "cell_type": "markdown", 80 | "metadata": {}, 81 | "source": [ 82 | "## Particle Displacement" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.025)\n", 92 | "ani = animation.particle_displacement(\n", 93 | " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", 94 | "plt.close()\n", 95 | "HTML(ani.to_jshtml())" 96 | ] 97 | }, 98 | { 99 | "cell_type": "markdown", 100 | "metadata": {}, 101 | "source": [ 102 | "Click the arrow button to start the animation.\n", 103 | "`to_jshtml()` allows you to play with the animation,\n", 104 | "e.g. speed up/down the animation (+/- button).\n", 105 | "Try to reverse the playback by clicking the left arrow.\n", 106 | "You'll see a sound _sink_.\n", 107 | "\n", 108 | "You can also show the animation by using `to_html5_video()`.\n", 109 | "See the [documentation](https://matplotlib.org/api/_as_gen/matplotlib.animation.ArtistAnimation.html#matplotlib.animation.ArtistAnimation.to_html5_video) for more detail.\n", 110 | "\n", 111 | "Of course, different types of grid can be chosen.\n", 112 | "Below is the particle animation using the same parameters\n", 113 | "but with a [hexagonal grid](https://www.redblobgames.com/grids/hexagons/)." 114 | ] 115 | }, 116 | { 117 | "cell_type": "code", 118 | "execution_count": null, 119 | "metadata": {}, 120 | "outputs": [], 121 | "source": [ 122 | "def hex_grid(xlim, ylim, hex_edge, align='horizontal'):\n", 123 | " if align is 'vertical':\n", 124 | " umin, umax = ylim\n", 125 | " vmin, vmax = xlim\n", 126 | " else:\n", 127 | " umin, umax = xlim\n", 128 | " vmin, vmax = ylim\n", 129 | " du = np.sqrt(3) * hex_edge\n", 130 | " dv = 1.5 * hex_edge\n", 131 | " num_u = int((umax - umin) / du)\n", 132 | " num_v = int((vmax - vmin) / dv)\n", 133 | " u, v = np.meshgrid(np.linspace(umin, umax, num_u),\n", 134 | " np.linspace(vmin, vmax, num_v))\n", 135 | " u[::2] += 0.5 * du\n", 136 | "\n", 137 | " if align is 'vertical':\n", 138 | " grid = v, u, 0\n", 139 | " elif align is 'horizontal':\n", 140 | " grid = u, v, 0\n", 141 | " return grid" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": null, 147 | "metadata": {}, 148 | "outputs": [], 149 | "source": [ 150 | "grid = hex_grid([xmin, xmax], [ymin, ymax], 0.0125, 'vertical')\n", 151 | "ani = animation.particle_displacement(\n", 152 | " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", 153 | "plt.close()\n", 154 | "HTML(ani.to_jshtml())" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "Another one using a random grid." 162 | ] 163 | }, 164 | { 165 | "cell_type": "code", 166 | "execution_count": null, 167 | "metadata": {}, 168 | "outputs": [], 169 | "source": [ 170 | "grid = [np.random.uniform(xmin, xmax, 4000),\n", 171 | " np.random.uniform(ymin, ymax, 4000), 0]\n", 172 | "ani = animation.particle_displacement(\n", 173 | " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", 174 | "plt.close()\n", 175 | "HTML(ani.to_jshtml())" 176 | ] 177 | }, 178 | { 179 | "cell_type": "markdown", 180 | "metadata": {}, 181 | "source": [ 182 | "Each grid has its strengths and weaknesses. Please refer to the\n", 183 | "[on-line discussion](https://github.com/sfstoolbox/sfs-python/pull/69#issuecomment-468405536)." 184 | ] 185 | }, 186 | { 187 | "cell_type": "markdown", 188 | "metadata": {}, 189 | "source": [ 190 | "## Particle Velocity" 191 | ] 192 | }, 193 | { 194 | "cell_type": "code", 195 | "execution_count": null, 196 | "metadata": {}, 197 | "outputs": [], 198 | "source": [ 199 | "amplitude = 1e-3\n", 200 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04)\n", 201 | "ani = animation.particle_velocity(\n", 202 | " omega, center, radius, amplitude, grid, frames, figsize)\n", 203 | "plt.close()\n", 204 | "HTML(ani.to_jshtml())" 205 | ] 206 | }, 207 | { 208 | "cell_type": "markdown", 209 | "metadata": {}, 210 | "source": [ 211 | "Please notice that the amplitude of the pulsating motion is adjusted\n", 212 | "so that the arrows are neither too short nor too long.\n", 213 | "This kind of compromise is inevitable since\n", 214 | "\n", 215 | "$$\n", 216 | "\\text{(particle velocity)} = \\text{i} \\omega \\times (\\text{amplitude}),\n", 217 | "$$\n", 218 | "\n", 219 | "thus the absolute value of particle velocity is usually\n", 220 | "much larger than that of amplitude.\n", 221 | "It should be also kept in mind that the hole in the middle\n", 222 | "does not visualizes the exact motion of the pulsating sphere.\n", 223 | "According to the above equation, the actual amplitude should be\n", 224 | "much smaller than the arrow lengths.\n", 225 | "The changing rate of its size is also two times higher than the original frequency." 226 | ] 227 | }, 228 | { 229 | "cell_type": "markdown", 230 | "metadata": {}, 231 | "source": [ 232 | "## Sound Pressure" 233 | ] 234 | }, 235 | { 236 | "cell_type": "code", 237 | "execution_count": null, 238 | "metadata": {}, 239 | "outputs": [], 240 | "source": [ 241 | "amplitude = 0.05\n", 242 | "impedance_pw = sfs.default.rho0 * sfs.default.c\n", 243 | "max_pressure = omega * impedance_pw * amplitude\n", 244 | "\n", 245 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", 246 | "ani = animation.sound_pressure(\n", 247 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 248 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 249 | "plt.close()\n", 250 | "HTML(ani.to_jshtml())" 251 | ] 252 | }, 253 | { 254 | "cell_type": "markdown", 255 | "metadata": {}, 256 | "source": [ 257 | "Notice that the sound pressure exceeds\n", 258 | "the atmospheric pressure ($\\approx 10^5$ Pa), which of course makes no sense.\n", 259 | "This is due to the large amplitude (50 mm) of the pulsating motion.\n", 260 | "It was chosen to better visualize the particle movements\n", 261 | "in the earlier animations.\n", 262 | "\n", 263 | "For 1 kHz, the amplitude corresponding to a moderate sound pressure,\n", 264 | "let say 1 Pa, is in the order of micrometer.\n", 265 | "As it is very small compared to the corresponding wavelength (0.343 m),\n", 266 | "the movement of the particles and the spatial structure of the sound field\n", 267 | "cannot be observed simultaneously.\n", 268 | "Furthermore, at high frequencies, the sound pressure\n", 269 | "for a given particle displacement scales with the frequency.\n", 270 | "The smaller wavelength (higher frequency) we choose,\n", 271 | "it is more likely to end up with a prohibitively high sound pressure.\n", 272 | "\n", 273 | "In the following examples, the amplitude is set to a realistic value 1 $\\mu$m.\n", 274 | "Notice that the pulsating motion of the sphere is no more visible." 275 | ] 276 | }, 277 | { 278 | "cell_type": "code", 279 | "execution_count": null, 280 | "metadata": {}, 281 | "outputs": [], 282 | "source": [ 283 | "amplitude = 1e-6\n", 284 | "impedance_pw = sfs.default.rho0 * sfs.default.c\n", 285 | "max_pressure = omega * impedance_pw * amplitude\n", 286 | "\n", 287 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", 288 | "ani = animation.sound_pressure(\n", 289 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 290 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 291 | "plt.close()\n", 292 | "HTML(ani.to_jshtml())" 293 | ] 294 | }, 295 | { 296 | "cell_type": "markdown", 297 | "metadata": {}, 298 | "source": [ 299 | "Let's zoom in closer to the boundary of the sphere." 300 | ] 301 | }, 302 | { 303 | "cell_type": "code", 304 | "execution_count": null, 305 | "metadata": {}, 306 | "outputs": [], 307 | "source": [ 308 | "L = 10 * amplitude\n", 309 | "xmin_zoom, xmax_zoom = radius - L, radius + L\n", 310 | "ymin_zoom, ymax_zoom = -L, L" 311 | ] 312 | }, 313 | { 314 | "cell_type": "code", 315 | "execution_count": null, 316 | "metadata": {}, 317 | "outputs": [], 318 | "source": [ 319 | "grid = sfs.util.xyz_grid([xmin_zoom, xmax_zoom], [ymin_zoom, ymax_zoom], 0, spacing=L / 100)\n", 320 | "ani = animation.sound_pressure(\n", 321 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 322 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 323 | "plt.close()\n", 324 | "HTML(ani.to_jshtml())" 325 | ] 326 | }, 327 | { 328 | "cell_type": "markdown", 329 | "metadata": {}, 330 | "source": [ 331 | "This shows how the vibrating motion of the sphere (left half)\n", 332 | "changes the sound pressure of the surrounding air (right half).\n", 333 | "Notice that the sound pressure increases/decreases (more red/blue)\n", 334 | "when the surface accelerates/decelerates." 335 | ] 336 | } 337 | ], 338 | "metadata": { 339 | "kernelspec": { 340 | "display_name": "Python [default]", 341 | "language": "python", 342 | "name": "python3" 343 | }, 344 | "language_info": { 345 | "codemirror_mode": { 346 | "name": "ipython", 347 | "version": 3 348 | }, 349 | "file_extension": ".py", 350 | "mimetype": "text/x-python", 351 | "name": "python", 352 | "nbconvert_exporter": "python", 353 | "pygments_lexer": "ipython3", 354 | "version": "3.5.6" 355 | } 356 | }, 357 | "nbformat": 4, 358 | "nbformat_minor": 2 359 | } 360 | -------------------------------------------------------------------------------- /doc/examples/animations_pulsating_sphere.py: -------------------------------------------------------------------------------- 1 | """Animations of pulsating sphere.""" 2 | import sfs 3 | import numpy as np 4 | from matplotlib import pyplot as plt 5 | from matplotlib import animation 6 | 7 | 8 | def particle_displacement(omega, center, radius, amplitude, grid, frames, 9 | figsize=(8, 8), interval=80, blit=True, **kwargs): 10 | """Generate sound particle animation.""" 11 | velocity = sfs.fd.source.pulsating_sphere_velocity( 12 | omega, center, radius, amplitude, grid) 13 | displacement = sfs.fd.displacement(velocity, omega) 14 | phasor = np.exp(1j * 2 * np.pi / frames) 15 | 16 | fig, ax = plt.subplots(figsize=figsize) 17 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 18 | scat = sfs.plot2d.particles(grid + displacement, **kwargs) 19 | 20 | def update_frame_displacement(i): 21 | position = (grid + displacement * phasor**i).apply(np.real) 22 | position = np.column_stack([position[0].flatten(), 23 | position[1].flatten()]) 24 | scat.set_offsets(position) 25 | return [scat] 26 | 27 | return animation.FuncAnimation( 28 | fig, update_frame_displacement, frames, 29 | interval=interval, blit=blit) 30 | 31 | 32 | def particle_velocity(omega, center, radius, amplitude, grid, frames, 33 | figsize=(8, 8), interval=80, blit=True, **kwargs): 34 | """Generate particle velocity animation.""" 35 | velocity = sfs.fd.source.pulsating_sphere_velocity( 36 | omega, center, radius, amplitude, grid) 37 | phasor = np.exp(1j * 2 * np.pi / frames) 38 | 39 | fig, ax = plt.subplots(figsize=figsize) 40 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 41 | quiv = sfs.plot2d.vectors( 42 | velocity, grid, clim=[-omega * amplitude, omega * amplitude], 43 | **kwargs) 44 | 45 | def update_frame_velocity(i): 46 | quiv.set_UVC(*(velocity[:2] * phasor**i).apply(np.real)) 47 | return [quiv] 48 | 49 | return animation.FuncAnimation( 50 | fig, update_frame_velocity, frames, interval=interval, blit=True) 51 | 52 | 53 | def sound_pressure(omega, center, radius, amplitude, grid, frames, 54 | pulsate=False, figsize=(8, 8), interval=80, blit=True, 55 | **kwargs): 56 | """Generate sound pressure animation.""" 57 | pressure = sfs.fd.source.pulsating_sphere( 58 | omega, center, radius, amplitude, grid, inside=pulsate) 59 | phasor = np.exp(1j * 2 * np.pi / frames) 60 | 61 | fig, ax = plt.subplots(figsize=figsize) 62 | im = sfs.plot2d.amplitude(np.real(pressure), grid, **kwargs) 63 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 64 | 65 | def update_frame_pressure(i): 66 | distance = np.linalg.norm(grid) 67 | p = pressure * phasor**i 68 | if pulsate: 69 | p[distance <= radius + amplitude * np.real(phasor**i)] = np.nan 70 | im.set_array(np.real(p)) 71 | return [im] 72 | 73 | return animation.FuncAnimation( 74 | fig, update_frame_pressure, frames, interval=interval, blit=True) 75 | 76 | 77 | if __name__ == '__main__': 78 | 79 | # Pulsating sphere 80 | center = [0, 0, 0] 81 | radius = 0.25 82 | f = 750 # frequency 83 | omega = 2 * np.pi * f # angular frequency 84 | 85 | # Axis limits 86 | xmin, xmax = -1, 1 87 | ymin, ymax = -1, 1 88 | 89 | # Animations 90 | frames = 20 # frames per period 91 | 92 | # Particle displacement 93 | amplitude = 5e-2 # amplitude of the surface displacement 94 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.025) 95 | ani = particle_displacement( 96 | omega, center, radius, amplitude, grid, frames, c='Gray') 97 | ani.save('pulsating_sphere_displacement.gif', dpi=80, writer='imagemagick') 98 | 99 | # Particle velocity 100 | amplitude = 1e-3 # amplitude of the surface displacement 101 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04) 102 | ani = particle_velocity( 103 | omega, center, radius, amplitude, grid, frames) 104 | ani.save('pulsating_sphere_velocity.gif', dpi=80, writer='imagemagick') 105 | 106 | # Sound pressure 107 | amplitude = 1e-6 # amplitude of the surface displacement 108 | impedance_pw = sfs.default.rho0 * sfs.default.c 109 | max_pressure = omega * impedance_pw * amplitude 110 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005) 111 | ani = sound_pressure( 112 | omega, center, radius, amplitude, grid, frames, pulsate=True, 113 | colorbar=True, vmin=-max_pressure, vmax=max_pressure) 114 | ani.save('pulsating_sphere_pressure.gif', dpi=80, writer='imagemagick') 115 | -------------------------------------------------------------------------------- /doc/examples/figures/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/043e9dd0f6aae5eeebbed8357968a2669d29fc40/doc/examples/figures/circle.png -------------------------------------------------------------------------------- /doc/examples/figures/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/043e9dd0f6aae5eeebbed8357968a2669d29fc40/doc/examples/figures/cross.png -------------------------------------------------------------------------------- /doc/examples/figures/rect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/043e9dd0f6aae5eeebbed8357968a2669d29fc40/doc/examples/figures/rect.png -------------------------------------------------------------------------------- /doc/examples/figures/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/043e9dd0f6aae5eeebbed8357968a2669d29fc40/doc/examples/figures/tree.png -------------------------------------------------------------------------------- /doc/examples/horizontal_plane_arrays.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generates sound fields for various arrays and virtual source types. 3 | """ 4 | 5 | import numpy as np 6 | import matplotlib.pyplot as plt 7 | import sfs 8 | 9 | 10 | dx = 0.1 # secondary source distance 11 | N = 30 # number of secondary sources 12 | f = 1000 # frequency 13 | pw_angle = 20 # traveling direction of plane wave 14 | xs = [-1.5, 0.2, 0] # position of virtual monopole 15 | tapering = sfs.tapering.tukey # tapering window 16 | talpha = 0.3 # parameter for tapering window 17 | xnorm = [1, 1, 0] # normalization point for plots 18 | grid = sfs.util.xyz_grid([-2.5, 2.5], [-1.5, 2.5], 0, spacing=0.02) 19 | acenter = [0.3, 0.7, 0] # center and normal vector of array 20 | anormal = sfs.util.direction_vector(np.radians(35), np.radians(90)) 21 | 22 | # angular frequency 23 | omega = 2 * np.pi * f 24 | # normal vector of plane wave 25 | npw = sfs.util.direction_vector(np.radians(pw_angle), np.radians(90)) 26 | 27 | 28 | def compute_and_plot_soundfield(title): 29 | """Compute and plot synthesized sound field.""" 30 | print('Computing', title) 31 | 32 | twin = tapering(selection, alpha=talpha) 33 | p = sfs.fd.synthesize(d, twin, array, secondary_source, grid=grid) 34 | 35 | plt.figure(figsize=(15, 15)) 36 | plt.cla() 37 | sfs.plot2d.amplitude(p, grid, xnorm=xnorm) 38 | sfs.plot2d.loudspeakers(array.x, array.n, twin) 39 | sfs.plot2d.virtualsource(xs) 40 | sfs.plot2d.virtualsource([0, 0], npw, type='plane') 41 | plt.title(title) 42 | plt.grid() 43 | plt.savefig(title + '.png') 44 | 45 | 46 | # linear array, secondary point sources, virtual monopole 47 | array = sfs.array.linear(N, dx, center=acenter, orientation=anormal) 48 | 49 | d, selection, secondary_source = sfs.fd.wfs.point_3d( 50 | omega, array.x, array.n, xs) 51 | compute_and_plot_soundfield('linear_ps_wfs_3d_point') 52 | 53 | d, selection, secondary_source = sfs.fd.wfs.point_25d( 54 | omega, array.x, array.n, xs, xref=xnorm) 55 | compute_and_plot_soundfield('linear_ps_wfs_25d_point') 56 | 57 | d, selection, secondary_source = sfs.fd.wfs.point_2d( 58 | omega, array.x, array.n, xs) 59 | compute_and_plot_soundfield('linear_ps_wfs_2d_point') 60 | 61 | # linear array, secondary line sources, virtual line source 62 | d, selection, secondary_source = sfs.fd.wfs.line_2d( 63 | omega, array.x, array.n, xs) 64 | compute_and_plot_soundfield('linear_ls_wfs_2d_line') 65 | 66 | 67 | # linear array, secondary point sources, virtual plane wave 68 | d, selection, secondary_source = sfs.fd.wfs.plane_3d( 69 | omega, array.x, array.n, npw) 70 | compute_and_plot_soundfield('linear_ps_wfs_3d_plane') 71 | 72 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 73 | omega, array.x, array.n, npw, xref=xnorm) 74 | compute_and_plot_soundfield('linear_ps_wfs_25d_plane') 75 | 76 | d, selection, secondary_source = sfs.fd.wfs.plane_2d( 77 | omega, array.x, array.n, npw) 78 | compute_and_plot_soundfield('linear_ps_wfs_2d_plane') 79 | 80 | 81 | # non-uniform linear array, secondary point sources 82 | array = sfs.array.linear_diff(N//3 * [dx] + N//3 * [dx/2] + N//3 * [dx], 83 | center=acenter, orientation=anormal) 84 | 85 | d, selection, secondary_source = sfs.fd.wfs.point_25d( 86 | omega, array.x, array.n, xs, xref=xnorm) 87 | compute_and_plot_soundfield('linear_nested_ps_wfs_25d_point') 88 | 89 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 90 | omega, array.x, array.n, npw, xref=xnorm) 91 | compute_and_plot_soundfield('linear_nested_ps_wfs_25d_plane') 92 | 93 | 94 | # random sampled linear array, secondary point sources 95 | array = sfs.array.linear_random(N, dx/2, 1.5*dx, center=acenter, 96 | orientation=anormal) 97 | 98 | d, selection, secondary_source = sfs.fd.wfs.point_25d( 99 | omega, array.x, array.n, xs, xref=xnorm) 100 | compute_and_plot_soundfield('linear_random_ps_wfs_25d_point') 101 | 102 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 103 | omega, array.x, array.n, npw, xref=xnorm) 104 | compute_and_plot_soundfield('linear_random_ps_wfs_25d_plane') 105 | 106 | 107 | # rectangular array, secondary point sources 108 | array = sfs.array.rectangular((N, N//2), dx, center=acenter, orientation=anormal) 109 | d, selection, secondary_source = sfs.fd.wfs.point_25d( 110 | omega, array.x, array.n, xs, xref=xnorm) 111 | compute_and_plot_soundfield('rectangular_ps_wfs_25d_point') 112 | 113 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 114 | omega, array.x, array.n, npw, xref=xnorm) 115 | compute_and_plot_soundfield('rectangular_ps_wfs_25d_plane') 116 | 117 | 118 | # circular array, secondary point sources 119 | N = 60 120 | array = sfs.array.circular(N, 1, center=acenter) 121 | d, selection, secondary_source = sfs.fd.wfs.point_25d( 122 | omega, array.x, array.n, xs, xref=xnorm) 123 | compute_and_plot_soundfield('circular_ps_wfs_25d_point') 124 | 125 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 126 | omega, array.x, array.n, npw, xref=xnorm) 127 | compute_and_plot_soundfield('circular_ps_wfs_25d_plane') 128 | 129 | 130 | # circular array, secondary line sources, NFC-HOA 131 | array = sfs.array.circular(N, 1) 132 | xnorm = [0, 0, 0] 133 | talpha = 0 # switches off tapering 134 | 135 | d, selection, secondary_source = sfs.fd.nfchoa.plane_2d( 136 | omega, array.x, 1, npw) 137 | compute_and_plot_soundfield('circular_ls_nfchoa_2d_plane') 138 | 139 | 140 | # circular array, secondary point sources, NFC-HOA 141 | array = sfs.array.circular(N, 1) 142 | xnorm = [0, 0, 0] 143 | talpha = 0 # switches off tapering 144 | 145 | d, selection, secondary_source = sfs.fd.nfchoa.point_25d( 146 | omega, array.x, 1, xs) 147 | compute_and_plot_soundfield('circular_ps_nfchoa_25d_point') 148 | 149 | d, selection, secondary_source = sfs.fd.nfchoa.plane_25d( 150 | omega, array.x, 1, npw) 151 | compute_and_plot_soundfield('circular_ps_nfchoa_25d_plane') 152 | -------------------------------------------------------------------------------- /doc/examples/ipython_kernel_config.py: -------------------------------------------------------------------------------- 1 | # This is a configuration file that's used when opening the Jupyter notebooks 2 | # in this directory. 3 | # See https://nbviewer.jupyter.org/github/mgeier/python-audio/blob/master/plotting/matplotlib-inline-defaults.ipynb 4 | 5 | c.InlineBackend.figure_formats = {'svg'} 6 | c.InlineBackend.rc = {'figure.dpi': 96} 7 | -------------------------------------------------------------------------------- /doc/examples/mirror-image-source-model.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Mirror Image Sources and the Sound Field in a Rectangular Room" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import matplotlib.pyplot as plt\n", 17 | "import numpy as np\n", 18 | "import sfs" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "L = 2, 2.7, 3 # room dimensions\n", 28 | "x0 = 1.2, 1.7, 1.5 # source position\n", 29 | "max_order = 2 # maximum order of image sources\n", 30 | "coeffs = .8, .8, .6, .6, .7, .7 # wall reflection coefficients" 31 | ] 32 | }, 33 | { 34 | "cell_type": "markdown", 35 | "metadata": {}, 36 | "source": [ 37 | "## 2D Mirror Image Sources" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": {}, 44 | "outputs": [], 45 | "source": [ 46 | "xs, wall_count = sfs.util.image_sources_for_box(x0[0:2], L[0:2], max_order)\n", 47 | "source_strength = np.prod(coeffs[0:4]**wall_count, axis=1)" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": null, 53 | "metadata": {}, 54 | "outputs": [], 55 | "source": [ 56 | "from matplotlib.patches import Rectangle" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": null, 62 | "metadata": {}, 63 | "outputs": [], 64 | "source": [ 65 | "fig, ax = plt.subplots()\n", 66 | "ax.scatter(*xs.T, source_strength * 20)\n", 67 | "ax.add_patch(Rectangle((0, 0), L[0], L[1], fill=False))\n", 68 | "ax.set_xlabel('x / m')\n", 69 | "ax.set_ylabel('y / m')\n", 70 | "ax.axis('equal');" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | "## Monochromatic Sound Field" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": null, 83 | "metadata": {}, 84 | "outputs": [], 85 | "source": [ 86 | "omega = 2 * np.pi * 1000 # angular frequency" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": null, 92 | "metadata": {}, 93 | "outputs": [], 94 | "source": [ 95 | "grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.02)\n", 96 | "P = sfs.fd.source.point_image_sources(omega, x0, grid, L,\n", 97 | " max_order=max_order, coeffs=coeffs)" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": null, 103 | "metadata": {}, 104 | "outputs": [], 105 | "source": [ 106 | "sfs.plot2d.amplitude(P, grid, xnorm=[L[0]/2, L[1]/2, L[2]/2]);" 107 | ] 108 | }, 109 | { 110 | "cell_type": "markdown", 111 | "metadata": {}, 112 | "source": [ 113 | "## Spatio-temporal Impulse Response" 114 | ] 115 | }, 116 | { 117 | "cell_type": "code", 118 | "execution_count": null, 119 | "metadata": {}, 120 | "outputs": [], 121 | "source": [ 122 | "fs = 44100 # sample rate\n", 123 | "signal = [1, 0, 0], fs" 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": null, 129 | "metadata": {}, 130 | "outputs": [], 131 | "source": [ 132 | "grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], 1.5, spacing=0.005)\n", 133 | "p = sfs.td.source.point_image_sources(x0, signal, 0.004, grid, L, max_order,\n", 134 | " coeffs=coeffs)" 135 | ] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "execution_count": null, 140 | "metadata": {}, 141 | "outputs": [], 142 | "source": [ 143 | "sfs.plot2d.level(p, grid)\n", 144 | "sfs.plot2d.virtualsource(x0)" 145 | ] 146 | } 147 | ], 148 | "metadata": { 149 | "kernelspec": { 150 | "display_name": "Python 3", 151 | "language": "python", 152 | "name": "python3" 153 | }, 154 | "language_info": { 155 | "codemirror_mode": { 156 | "name": "ipython", 157 | "version": 3 158 | }, 159 | "file_extension": ".py", 160 | "mimetype": "text/x-python", 161 | "name": "python", 162 | "nbconvert_exporter": "python", 163 | "pygments_lexer": "ipython3", 164 | "version": "3.7.2+" 165 | } 166 | }, 167 | "nbformat": 4, 168 | "nbformat_minor": 2 169 | } 170 | -------------------------------------------------------------------------------- /doc/examples/modal-room-acoustics.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Modal Room Acoustics" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import numpy as np\n", 17 | "import matplotlib.pyplot as plt\n", 18 | "import sfs" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "%matplotlib inline" 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": null, 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "x0 = 1, 3, 1.80 # source position\n", 37 | "L = 6, 6, 3 # dimensions of room\n", 38 | "deltan = 0.01 # absorption factor of walls\n", 39 | "N = 20 # maximum order of modes" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "metadata": {}, 45 | "source": [ 46 | "You can experiment with different combinations of modes:" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": null, 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "#N = [[1], 0, 0]" 56 | ] 57 | }, 58 | { 59 | "cell_type": "markdown", 60 | "metadata": {}, 61 | "source": [ 62 | "## Sound Field for One Frequency" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "metadata": {}, 69 | "outputs": [], 70 | "source": [ 71 | "f = 500 # frequency\n", 72 | "omega = 2 * np.pi * f # angular frequency" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "grid = sfs.util.xyz_grid([0, L[0]], [0, L[1]], L[2] / 2, spacing=.1)" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "p = sfs.fd.source.point_modal(omega, x0, grid, L, N=N, deltan=deltan)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "metadata": {}, 96 | "source": [ 97 | "For now, we apply an arbitrary scaling factor to make the plot look good\n", 98 | "\n", 99 | "TODO: proper normalization" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "p *= 0.05" 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": null, 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "sfs.plot2d.amplitude(p, grid);" 118 | ] 119 | }, 120 | { 121 | "cell_type": "markdown", 122 | "metadata": {}, 123 | "source": [ 124 | "## Frequency Response at One Point" 125 | ] 126 | }, 127 | { 128 | "cell_type": "code", 129 | "execution_count": null, 130 | "metadata": {}, 131 | "outputs": [], 132 | "source": [ 133 | "f = np.linspace(20, 200, 180) # frequency\n", 134 | "omega = 2 * np.pi * f # angular frequency\n", 135 | "\n", 136 | "receiver = 1, 1, 1.8\n", 137 | "\n", 138 | "p = [sfs.fd.source.point_modal(om, x0, receiver, L, N=N, deltan=deltan)\n", 139 | " for om in omega]\n", 140 | " \n", 141 | "plt.plot(f, sfs.util.db(p))\n", 142 | "plt.xlabel('frequency / Hz')\n", 143 | "plt.ylabel('level / dB')\n", 144 | "plt.grid()" 145 | ] 146 | } 147 | ], 148 | "metadata": { 149 | "kernelspec": { 150 | "display_name": "Python 3", 151 | "language": "python", 152 | "name": "python3" 153 | }, 154 | "language_info": { 155 | "codemirror_mode": { 156 | "name": "ipython", 157 | "version": 3 158 | }, 159 | "file_extension": ".py", 160 | "mimetype": "text/x-python", 161 | "name": "python", 162 | "nbconvert_exporter": "python", 163 | "pygments_lexer": "ipython3", 164 | "version": "3.6.3" 165 | } 166 | }, 167 | "nbformat": 4, 168 | "nbformat_minor": 2 169 | } 170 | -------------------------------------------------------------------------------- /doc/examples/plot_particle_density.py: -------------------------------------------------------------------------------- 1 | """ Example for particle density visualization of sound sources """ 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import sfs 6 | 7 | # simulation parameters 8 | pw_angle = 45 # traveling direction of plane wave 9 | xs = [0, 0, 0] # source position 10 | f = 300 # frequency 11 | 12 | # angular frequency 13 | omega = 2 * np.pi * f 14 | # normal vector of plane wave 15 | npw = sfs.util.direction_vector(np.radians(pw_angle)) 16 | # random grid for velocity 17 | grid = [np.random.uniform(-3, 3, 40000), np.random.uniform(-3, 3, 40000), 0] 18 | 19 | 20 | def plot_particle_displacement(title): 21 | # compute displacement 22 | X = grid + amplitude * sfs.fd.displacement(v, omega) 23 | # plot displacement 24 | plt.figure(figsize=(15, 15)) 25 | plt.cla() 26 | sfs.plot2d.particles(X, facecolor='black', s=3, trim=[-3, 3, -3, 3]) 27 | plt.axis('off') 28 | plt.title(title) 29 | plt.grid() 30 | plt.savefig(title + '.png') 31 | 32 | 33 | # point source 34 | v = sfs.fd.source.point_velocity(omega, xs, grid) 35 | amplitude = 1.5e6 36 | plot_particle_displacement('particle_displacement_point_source') 37 | 38 | # line source 39 | v = sfs.fd.source.line_velocity(omega, xs, grid) 40 | amplitude = 1.3e6 41 | plot_particle_displacement('particle_displacement_line_source') 42 | 43 | # plane wave 44 | v = sfs.fd.source.plane_velocity(omega, xs, npw, grid) 45 | amplitude = 1e5 46 | plot_particle_displacement('particle_displacement_plane_wave') 47 | -------------------------------------------------------------------------------- /doc/examples/run_all.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pathlib import Path 3 | import subprocess 4 | import sys 5 | 6 | if __name__ != '__main__': 7 | raise ImportError(__name__ + ' is not meant be imported') 8 | 9 | self = Path(__file__) 10 | cwd = self.parent 11 | 12 | for script in cwd.glob('*.py'): 13 | if self == script: 14 | # Don't call yourself! 15 | continue 16 | if script.name == 'ipython_kernel_config.py': 17 | # This is a configuration file, not an example script 18 | continue 19 | print('Running', script, '...') 20 | args = [sys.executable, str(script.relative_to(cwd))] + sys.argv[1:] 21 | result = subprocess.run(args, cwd=str(cwd)) 22 | if result.returncode: 23 | print('Error running', script, file=sys.stderr) 24 | sys.exit(result.returncode) 25 | -------------------------------------------------------------------------------- /doc/examples/sound-field-synthesis.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Sound Field Synthesis\n", 8 | "\n", 9 | "Illustrates the usage of the SFS toolbox for the simulation of different sound field synthesis methods." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import numpy as np\n", 19 | "import matplotlib.pyplot as plt \n", 20 | "import sfs" 21 | ] 22 | }, 23 | { 24 | "cell_type": "code", 25 | "execution_count": null, 26 | "metadata": {}, 27 | "outputs": [], 28 | "source": [ 29 | "# Simulation parameters\n", 30 | "number_of_secondary_sources = 56\n", 31 | "frequency = 680 # in Hz\n", 32 | "pw_angle = 30 # traveling direction of plane wave in degree\n", 33 | "xs = [-2, -1, 0] # position of virtual point source in m\n", 34 | "\n", 35 | "grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02)\n", 36 | "omega = 2 * np.pi * frequency # angular frequency\n", 37 | "npw = sfs.util.direction_vector(np.radians(pw_angle)) # normal vector of plane wave" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "metadata": {}, 43 | "source": [ 44 | "Define a helper function for synthesize and plot the sound field from the given driving signals." 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": null, 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "def sound_field(d, selection, secondary_source, array, grid, tapering=True):\n", 54 | " if tapering:\n", 55 | " tapering_window = sfs.tapering.tukey(selection, alpha=0.3)\n", 56 | " else:\n", 57 | " tapering_window = sfs.tapering.none(selection)\n", 58 | " p = sfs.fd.synthesize(d, tapering_window, array, secondary_source, grid=grid)\n", 59 | " sfs.plot2d.amplitude(p, grid, xnorm=[0, 0, 0])\n", 60 | " sfs.plot2d.loudspeakers(array.x, array.n, tapering_window)" 61 | ] 62 | }, 63 | { 64 | "cell_type": "markdown", 65 | "metadata": {}, 66 | "source": [ 67 | "## Circular loudspeaker arrays\n", 68 | "\n", 69 | "In the following we show different sound field synthesis methods applied to a circular loudspeaker array." 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": null, 75 | "metadata": {}, 76 | "outputs": [], 77 | "source": [ 78 | "radius = 1.5 # in m\n", 79 | "array = sfs.array.circular(number_of_secondary_sources, radius)" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "### Wave Field Synthesis (WFS)\n", 87 | "\n", 88 | "#### Plane wave" 89 | ] 90 | }, 91 | { 92 | "cell_type": "code", 93 | "execution_count": null, 94 | "metadata": {}, 95 | "outputs": [], 96 | "source": [ 97 | "d, selection, secondary_source = sfs.fd.wfs.plane_25d(omega, array.x, array.n, n=npw)\n", 98 | "sound_field(d, selection, secondary_source, array, grid)" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "#### Point source" 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": null, 111 | "metadata": {}, 112 | "outputs": [], 113 | "source": [ 114 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(omega, array.x, array.n, xs)\n", 115 | "sound_field(d, selection, secondary_source, array, grid)" 116 | ] 117 | }, 118 | { 119 | "cell_type": "markdown", 120 | "metadata": {}, 121 | "source": [ 122 | "### Near-Field Compensated Higher Order Ambisonics (NFC-HOA)\n", 123 | "\n", 124 | "#### Plane wave" 125 | ] 126 | }, 127 | { 128 | "cell_type": "code", 129 | "execution_count": null, 130 | "metadata": {}, 131 | "outputs": [], 132 | "source": [ 133 | "d, selection, secondary_source = sfs.fd.nfchoa.plane_25d(omega, array.x, radius, n=npw)\n", 134 | "sound_field(d, selection, secondary_source, array, grid, tapering=False)" 135 | ] 136 | }, 137 | { 138 | "cell_type": "markdown", 139 | "metadata": {}, 140 | "source": [ 141 | "#### Point source" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": null, 147 | "metadata": {}, 148 | "outputs": [], 149 | "source": [ 150 | "d, selection, secondary_source = sfs.fd.nfchoa.point_25d(omega, array.x, radius, xs)\n", 151 | "sound_field(d, selection, secondary_source, array, grid, tapering=False)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "markdown", 156 | "metadata": {}, 157 | "source": [ 158 | "## Linear loudspeaker array\n", 159 | "\n", 160 | "In the following we show different sound field synthesis methods applied to a linear loudspeaker array." 161 | ] 162 | }, 163 | { 164 | "cell_type": "code", 165 | "execution_count": null, 166 | "metadata": {}, 167 | "outputs": [], 168 | "source": [ 169 | "spacing = 0.07 # in m\n", 170 | "array = sfs.array.linear(number_of_secondary_sources, spacing,\n", 171 | " center=[0, -0.5, 0], orientation=[0, 1, 0])" 172 | ] 173 | }, 174 | { 175 | "cell_type": "markdown", 176 | "metadata": {}, 177 | "source": [ 178 | "### Wave Field Synthesis (WFS)\n", 179 | "\n", 180 | "#### Plane wave" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "metadata": {}, 187 | "outputs": [], 188 | "source": [ 189 | "d, selection, secondary_source = sfs.fd.wfs.plane_25d(omega, array.x, array.n, npw)\n", 190 | "sound_field(d, selection, secondary_source, array, grid)" 191 | ] 192 | }, 193 | { 194 | "cell_type": "markdown", 195 | "metadata": {}, 196 | "source": [ 197 | "#### Point source" 198 | ] 199 | }, 200 | { 201 | "cell_type": "code", 202 | "execution_count": null, 203 | "metadata": {}, 204 | "outputs": [], 205 | "source": [ 206 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(omega, array.x, array.n, xs)\n", 207 | "sound_field(d, selection, secondary_source, array, grid)" 208 | ] 209 | } 210 | ], 211 | "metadata": { 212 | "kernelspec": { 213 | "display_name": "Python 3", 214 | "language": "python", 215 | "name": "python3" 216 | }, 217 | "language_info": { 218 | "codemirror_mode": { 219 | "name": "ipython", 220 | "version": 3 221 | }, 222 | "file_extension": ".py", 223 | "mimetype": "text/x-python", 224 | "name": "python", 225 | "nbconvert_exporter": "python", 226 | "pygments_lexer": "ipython3", 227 | "version": "3.5.2" 228 | } 229 | }, 230 | "nbformat": 4, 231 | "nbformat_minor": 2 232 | } 233 | -------------------------------------------------------------------------------- /doc/examples/soundfigures.py: -------------------------------------------------------------------------------- 1 | """This example illustrates the synthesis of a sound figure. 2 | 3 | The sound figure is defined by a grayscale PNG image. Various example 4 | images are located in the "figures/" directory. 5 | 6 | """ 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | from PIL import Image 10 | import sfs 11 | 12 | dx = 0.10 # secondary source distance 13 | N = 60 # number of secondary sources 14 | pw_angle = [90, 45] # traveling direction of plane wave 15 | f = 2000 # frequency 16 | 17 | # angular frequency 18 | omega = 2 * np.pi * f 19 | 20 | # normal vector of plane wave 21 | npw = sfs.util.direction_vector(*np.radians(pw_angle)) 22 | 23 | # spatial grid 24 | grid = sfs.util.xyz_grid([-3, 3], [-3, 3], 0, spacing=0.02) 25 | 26 | # get secondary source positions 27 | array = sfs.array.cube(N, dx) 28 | 29 | # driving function for sound figure 30 | figure = np.array(Image.open('figures/tree.png')) # read image from file 31 | figure = np.rot90(figure) # turn 0deg to the top 32 | d, selection, secondary_source = sfs.fd.wfs.soundfigure_3d( 33 | omega, array.x, array.n, figure, npw=npw) 34 | 35 | # compute synthesized sound field 36 | p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) 37 | 38 | # plot and save synthesized sound field 39 | plt.figure(figsize=(10, 10)) 40 | sfs.plot2d.amplitude(p, grid, xnorm=[0, -2.2, 0], cmap='BrBG', colorbar=False, 41 | vmin=-1, vmax=1) 42 | plt.title('Synthesized Sound Field') 43 | plt.savefig('soundfigure.png') 44 | 45 | # plot and save level of synthesized sound field 46 | plt.figure(figsize=(12.5, 12.5)) 47 | im = sfs.plot2d.level(p, grid, xnorm=[0, -2.2, 0], vmin=-50, vmax=0, 48 | colorbar_kwargs=dict(label='dB')) 49 | plt.title('Level of Synthesized Sound Field') 50 | plt.savefig('soundfigure_level.png') 51 | -------------------------------------------------------------------------------- /doc/examples/time_domain.py: -------------------------------------------------------------------------------- 1 | """ 2 | Create some examples in the time domain. 3 | 4 | Simulate and plot impulse behavior for Wave Field Synthesis. 5 | 6 | """ 7 | 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | import sfs 11 | 12 | # simulation parameters 13 | grid = sfs.util.xyz_grid([-3, 3], [-3, 3], 0, spacing=0.01) 14 | my_cmap = 'YlOrRd' 15 | N = 56 # number of secondary sources 16 | R = 1.5 # radius of spherical/circular array 17 | array = sfs.array.circular(N, R) # get secondary source positions 18 | fs = 44100 # sampling rate 19 | 20 | # unit impulse 21 | signal = [1], fs 22 | 23 | # POINT SOURCE 24 | xs = 2, 2, 0 # position of virtual source 25 | t = 0.008 26 | # compute driving signals 27 | d_delay, d_weight, selection, secondary_source = \ 28 | sfs.td.wfs.point_25d(array.x, array.n, xs) 29 | d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) 30 | 31 | # test soundfield 32 | twin = sfs.tapering.tukey(selection, alpha=0.3) 33 | 34 | p = sfs.td.synthesize(d, twin, array, 35 | secondary_source, observation_time=t, grid=grid) 36 | p = p * 100 # scale absolute amplitude 37 | 38 | plt.figure(figsize=(10, 10)) 39 | sfs.plot2d.level(p, grid, cmap=my_cmap) 40 | sfs.plot2d.loudspeakers(array.x, array.n, twin) 41 | plt.grid() 42 | sfs.plot2d.virtualsource(xs) 43 | plt.title('impulse_ps_wfs_25d') 44 | plt.savefig('impulse_ps_wfs_25d.png') 45 | 46 | # PLANE WAVE 47 | pw_angle = 30 # traveling direction of plane wave 48 | npw = sfs.util.direction_vector(np.radians(pw_angle)) 49 | t = -0.001 50 | 51 | # compute driving signals 52 | d_delay, d_weight, selection, secondary_source = \ 53 | sfs.td.wfs.plane_25d(array.x, array.n, npw) 54 | d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) 55 | 56 | # test soundfield 57 | twin = sfs.tapering.tukey(selection, alpha=0.3) 58 | p = sfs.td.synthesize(d, twin, array, 59 | secondary_source, observation_time=t, grid=grid) 60 | 61 | plt.figure(figsize=(10, 10)) 62 | sfs.plot2d.level(p, grid, cmap=my_cmap) 63 | sfs.plot2d.loudspeakers(array.x, array.n, twin) 64 | plt.grid() 65 | sfs.plot2d.virtualsource([0, 0], npw, type='plane') 66 | plt.title('impulse_pw_wfs_25d') 67 | plt.savefig('impulse_pw_wfs_25d.png') 68 | 69 | # FOCUSED SOURCE 70 | xs = np.r_[0.5, 0.5, 0] # position of virtual source 71 | xref = np.r_[0, 0, 0] 72 | nfs = sfs.util.normalize_vector(xref - xs) # main n of fsource 73 | t = 0.003 # compute driving signals 74 | d_delay, d_weight, selection, secondary_source = \ 75 | sfs.td.wfs.focused_25d(array.x, array.n, xs, nfs) 76 | d = sfs.td.wfs.driving_signals(d_delay, d_weight, signal) 77 | 78 | # test soundfield 79 | twin = sfs.tapering.tukey(selection, alpha=0.3) 80 | p = sfs.td.synthesize(d, twin, array, 81 | secondary_source, observation_time=t, grid=grid) 82 | p = p * 100 # scale absolute amplitude 83 | 84 | plt.figure(figsize=(10, 10)) 85 | sfs.plot2d.level(p, grid, cmap=my_cmap) 86 | sfs.plot2d.loudspeakers(array.x, array.n, twin) 87 | plt.grid() 88 | sfs.plot2d.virtualsource(xs) 89 | plt.title('impulse_fs_wfs_25d') 90 | plt.savefig('impulse_fs_wfs_25d.png') 91 | -------------------------------------------------------------------------------- /doc/examples/time_domain_nfchoa.py: -------------------------------------------------------------------------------- 1 | """Create some examples of time-domain NFC-HOA.""" 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import sfs 6 | from scipy.signal import unit_impulse 7 | 8 | # Parameters 9 | fs = 44100 # sampling frequency 10 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.005) 11 | N = 60 # number of secondary sources 12 | R = 1.5 # radius of circular array 13 | array = sfs.array.circular(N, R) 14 | 15 | # Excitation signal 16 | signal = unit_impulse(512), fs, 0 17 | 18 | # Plane wave 19 | max_order = None 20 | npw = [0, -1, 0] # propagating direction 21 | t = 0 # observation time 22 | delay, weight, sos, phaseshift, selection, secondary_source = \ 23 | sfs.td.nfchoa.plane_25d(array.x, R, npw, fs, max_order) 24 | d = sfs.td.nfchoa.driving_signals_25d( 25 | delay, weight, sos, phaseshift, signal) 26 | p = sfs.td.synthesize(d, selection, array, secondary_source, 27 | observation_time=t, grid=grid) 28 | 29 | plt.figure() 30 | sfs.plot2d.level(p, grid) 31 | sfs.plot2d.loudspeakers(array.x, array.n) 32 | sfs.plot2d.virtualsource([0, 0], ns=npw, type='plane') 33 | plt.savefig('impulse_pw_nfchoa_25d.png') 34 | 35 | # Point source 36 | max_order = 100 37 | xs = [1.5, 1.5, 0] # position 38 | t = np.linalg.norm(xs) / sfs.default.c # observation time 39 | delay, weight, sos, phaseshift, selection, secondary_source = \ 40 | sfs.td.nfchoa.point_25d(array.x, R, xs, fs, max_order) 41 | d = sfs.td.nfchoa.driving_signals_25d( 42 | delay, weight, sos, phaseshift, signal) 43 | p = sfs.td.synthesize(d, selection, array, secondary_source, 44 | observation_time=t, grid=grid) 45 | 46 | plt.figure() 47 | sfs.plot2d.level(p, grid) 48 | sfs.plot2d.loudspeakers(array.x, array.n) 49 | sfs.plot2d.virtualsource(xs, type='point') 50 | plt.savefig('impulse_ps_nfchoa_25d.png') 51 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | ---- 4 | 5 | .. toctree:: 6 | 7 | installation 8 | examples 9 | api 10 | references 11 | contributing 12 | version-history 13 | 14 | .. only:: html 15 | 16 | * :ref:`genindex` 17 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | Obviously, you'll need Python_. 8 | More specifically, you'll need Python 3. 9 | NumPy_ and SciPy_ are needed for the calculations. 10 | If you want to use the provided functions for plotting sound fields, you'll need 11 | Matplotlib_. 12 | However, since all results are provided as plain NumPy_ arrays, you should also 13 | be able to use any plotting library of your choice to visualize the sound 14 | fields. 15 | 16 | Instead of installing all of the requirements separately, you should probably 17 | get a Python distribution that already includes everything, e.g. Anaconda_. 18 | 19 | .. _Python: https://www.python.org/ 20 | .. _NumPy: http://www.numpy.org/ 21 | .. _SciPy: https://www.scipy.org/scipylib/ 22 | .. _Matplotlib: https://matplotlib.org/ 23 | .. _Anaconda: https://docs.anaconda.com/anaconda/ 24 | 25 | Installation 26 | ------------ 27 | 28 | Once you have installed the above-mentioned dependencies, you can use pip_ 29 | to download and install the latest release of the Sound Field Synthesis Toolbox 30 | with a single command:: 31 | 32 | python3 -m pip install sfs --user 33 | 34 | If you want to install it system-wide for all users (assuming you have the 35 | necessary rights), you can just drop the ``--user`` option. 36 | 37 | To un-install, use:: 38 | 39 | python3 -m pip uninstall sfs 40 | 41 | If you want to install the latest development version of the SFS Toolbox, have a 42 | look at :doc:`contributing`. 43 | 44 | .. _pip: https://pip.pypa.io/en/latest/installing/ 45 | -------------------------------------------------------------------------------- /doc/math-definitions.rst: -------------------------------------------------------------------------------- 1 | .. raw:: latex 2 | 3 | \marginpar{% Avoid creating empty vertical space for the math definitions 4 | 5 | .. rst-class:: hidden 6 | .. math:: 7 | 8 | \gdef\dirac#1{\mathop{{}\delta}\left(#1\right)} 9 | \gdef\e#1{\operatorname{e}^{#1}} 10 | \gdef\Hankel#1#2#3{\mathop{{}H_{#2}^{(#1)}}\!\left(#3\right)} 11 | \gdef\hankel#1#2#3{\mathop{{}h_{#2}^{(#1)}}\!\left(#3\right)} 12 | \gdef\i{\mathrm{i}} 13 | \gdef\scalarprod#1#2{\left\langle#1,#2\right\rangle} 14 | \gdef\vec#1{\mathbf{#1}} 15 | \gdef\wc{\frac{\omega}{c}} 16 | \gdef\w{\omega} 17 | \gdef\x{\vec{x}} 18 | \gdef\n{\vec{n}} 19 | 20 | .. raw:: latex 21 | 22 | } 23 | -------------------------------------------------------------------------------- /doc/readthedocs-environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | - python>=3 5 | - sphinx>=1.3.6 6 | - sphinx_rtd_theme 7 | - sphinxcontrib-bibtex 8 | - numpy 9 | - scipy 10 | - matplotlib>=1.5 11 | - ipykernel 12 | - pandoc 13 | - pip: 14 | - nbsphinx 15 | -------------------------------------------------------------------------------- /doc/references.bib: -------------------------------------------------------------------------------- 1 | @book{Ahrens2012, 2 | author = {Ahrens, J.}, 3 | title = {{Analytic Methods of Sound Field Synthesis}}, 4 | publisher = {Springer}, 5 | address = {Berlin Heidelberg}, 6 | year = {2012}, 7 | doi = {10.1007/978-3-642-25743-8} 8 | } 9 | @book{Moser2012, 10 | author = {Möser, M.}, 11 | title = {{Technische Akustik}}, 12 | publisher = {Springer}, 13 | address = {Berlin Heidelberg}, 14 | year = {2012}, 15 | doi = {10.1007/978-3-642-30933-5} 16 | } 17 | @inproceedings{Spors2010, 18 | author = {Spors, S. and Ahrens, J.}, 19 | title = {{Analysis and Improvement of Pre-equalization in 2.5-dimensional 20 | Wave Field Synthesis}}, 21 | booktitle = {128th Convention of the Audio Engineering Society}, 22 | year = {2010}, 23 | url = {http://bit.ly/2Ad6RRR} 24 | } 25 | @inproceedings{Spors2009, 26 | author = {Spors, S. and Ahrens, J.}, 27 | title = {{Spatial Sampling Artifacts of Wave Field Synthesis for the 28 | Reproduction of Virtual Point Sources}}, 29 | booktitle = {126th Convention of the Audio Engineering Society}, 30 | year = {2009}, 31 | url = {http://bit.ly/2jkfboi} 32 | } 33 | @inproceedings{Spors2016, 34 | author = {Spors, S. and Schultz, F. and Rettberg, T.}, 35 | title = {{Improved Driving Functions for Rectangular Loudspeaker Arrays 36 | Driven by Sound Field Synthesis}}, 37 | booktitle = {42nd German Annual Conference on Acoustics (DAGA)}, 38 | year = {2016}, 39 | url = {http://bit.ly/2AWRo7G} 40 | } 41 | @inproceedings{Spors2008, 42 | author = {Spors, S. and Rabenstein, R. and Ahrens, J.}, 43 | title = {{The Theory of Wave Field Synthesis Revisited}}, 44 | booktitle = {124th Convention of the Audio Engineering Society}, 45 | year = {2008}, 46 | url = {http://bit.ly/2ByRjnB} 47 | } 48 | @phdthesis{Wierstorf2014, 49 | author = {Wierstorf, H.}, 50 | title = {{Perceptual Assessment of Sound Field Synthesis}}, 51 | school = {Technische Universität Berlin}, 52 | year = {2014}, 53 | doi = {10.14279/depositonce-4310} 54 | } 55 | @article{Allen1979, 56 | author = {Allen, J. B. and Berkley, D. A.}, 57 | title = {{Image method for efficiently simulating small-room acoustics}}, 58 | journal = {Journal of the Acoustical Society of America}, 59 | volume = {65}, 60 | pages = {943--950}, 61 | year = {1979}, 62 | doi = {10.1121/1.382599} 63 | } 64 | @article{Borish1984, 65 | author = {Borish, J.}, 66 | title = {{Extension of the image model to arbitrary polyhedra}}, 67 | journal = {Journal of the Acoustical Society of America}, 68 | volume = {75}, 69 | pages = {1827--1836}, 70 | year = {1984}, 71 | doi = {10.1121/1.390983} 72 | } 73 | @article{Firtha2017, 74 | author = {Gergely Firtha AND P{\'e}ter Fiala AND Frank Schultz AND 75 | Sascha Spors}, 76 | title = {{Improved Referencing Schemes for 2.5D Wave Field Synthesis 77 | Driving Functions}}, 78 | journal = {IEEE/ACM Trans. Audio Speech Language Process.}, 79 | volume = {25}, 80 | number = {5}, 81 | pages = {1117-1127}, 82 | year = {2017}, 83 | doi = {10.1109/TASLP.2017.2689245} 84 | } 85 | @phdthesis{Start1997, 86 | author = {Evert W. Start}, 87 | title = {{Direct Sound Enhancement by Wave Field Synthesis}}, 88 | school = {Delft University of Technology}, 89 | year = {1997} 90 | } 91 | @phdthesis{Schultz2016, 92 | author = {Frank Schultz}, 93 | title = {{Sound Field Synthesis for Line Source Array Applications in 94 | Large-Scale Sound Reinforcement}}, 95 | school = {University of Rostock}, 96 | year = {2016}, 97 | doi = {10.18453/rosdok_id00001765} 98 | } 99 | -------------------------------------------------------------------------------- /doc/references.rst: -------------------------------------------------------------------------------- 1 | References 2 | ========== 3 | 4 | .. bibliography:: 5 | :style: alpha 6 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.3.6 2 | Sphinx-RTD-Theme 3 | nbsphinx 4 | ipykernel 5 | sphinxcontrib-bibtex>=2.1.4 6 | 7 | NumPy 8 | SciPy 9 | matplotlib>=1.5 10 | -------------------------------------------------------------------------------- /doc/version-history.rst: -------------------------------------------------------------------------------- 1 | .. default-role:: py:obj 2 | 3 | .. include:: ../NEWS.rst 4 | -------------------------------------------------------------------------------- /examples: -------------------------------------------------------------------------------- 1 | doc/examples/ -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | conda: 2 | file: doc/readthedocs-environment.yml 3 | python: 4 | pip_install: true 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | __version__ = "unknown" 4 | 5 | # "import" __version__ 6 | for line in open("sfs/__init__.py"): 7 | if line.startswith("__version__"): 8 | exec(line) 9 | break 10 | 11 | setup( 12 | name="sfs", 13 | version=__version__, 14 | packages=find_packages(), 15 | install_requires=[ 16 | 'numpy!=1.11.0', # https://github.com/sfstoolbox/sfs-python/issues/11 17 | 'scipy', 18 | ], 19 | author="SFS Toolbox Developers", 20 | author_email="sfstoolbox@gmail.com", 21 | description="Sound Field Synthesis Toolbox", 22 | long_description=open('README.rst').read(), 23 | license="MIT", 24 | keywords="audio SFS WFS Ambisonics".split(), 25 | url="http://github.com/sfstoolbox/", 26 | platforms='any', 27 | python_requires='>=3.6', 28 | classifiers=[ 29 | "Development Status :: 3 - Alpha", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3 :: Only", 37 | "Topic :: Scientific/Engineering", 38 | ], 39 | zip_safe=True, 40 | ) 41 | -------------------------------------------------------------------------------- /sfs/__init__.py: -------------------------------------------------------------------------------- 1 | """Sound Field Synthesis Toolbox. 2 | 3 | https://sfs-python.readthedocs.io/ 4 | 5 | .. rubric:: Submodules 6 | 7 | .. autosummary:: 8 | :toctree: 9 | 10 | fd 11 | td 12 | array 13 | tapering 14 | plot2d 15 | plot3d 16 | util 17 | 18 | """ 19 | __version__ = "0.6.2" 20 | 21 | 22 | class default: 23 | """Get/set defaults for the *sfs* module. 24 | 25 | For example, when you want to change the default speed of sound:: 26 | 27 | import sfs 28 | sfs.default.c = 330 29 | 30 | """ 31 | 32 | c = 343 33 | """Speed of sound.""" 34 | 35 | rho0 = 1.2250 36 | """Static density of air.""" 37 | 38 | selection_tolerance = 1e-6 39 | """Tolerance used for secondary source selection.""" 40 | 41 | def __setattr__(self, name, value): 42 | """Only allow setting existing attributes.""" 43 | if name in dir(self) and name != 'reset': 44 | super().__setattr__(name, value) 45 | else: 46 | raise AttributeError( 47 | '"default" object has no attribute ' + repr(name)) 48 | 49 | def reset(self): 50 | """Reset all attributes to their "factory default".""" 51 | vars(self).clear() 52 | 53 | 54 | import sys as _sys 55 | if not getattr(_sys.modules.get('sphinx'), 'SFS_DOCS_ARE_BEING_BUILT', False): 56 | # This object shadows the 'default' class, except when the docs are built: 57 | default = default() 58 | 59 | from . import tapering 60 | from . import array 61 | from . import util 62 | try: 63 | from . import plot2d 64 | except ImportError: 65 | pass 66 | try: 67 | from . import plot3d 68 | except ImportError: 69 | pass 70 | 71 | from . import fd 72 | from . import td 73 | -------------------------------------------------------------------------------- /sfs/fd/__init__.py: -------------------------------------------------------------------------------- 1 | """Submodules for monochromatic sound fields. 2 | 3 | .. autosummary:: 4 | :toctree: 5 | 6 | source 7 | 8 | wfs 9 | nfchoa 10 | sdm 11 | esa 12 | 13 | """ 14 | import numpy as _np 15 | 16 | from . import source 17 | from .. import array as _array 18 | from .. import util as _util 19 | 20 | 21 | def shiftphase(p, phase): 22 | """Shift phase of a sound field.""" 23 | p = _np.asarray(p) 24 | return p * _np.exp(1j * phase) 25 | 26 | 27 | def displacement(v, omega): 28 | r"""Particle displacement. 29 | 30 | .. math:: 31 | 32 | d(x, t) = \int_{-\infty}^t v(x, \tau) d\tau 33 | 34 | """ 35 | return _util.as_xyz_components(v) / (1j * omega) 36 | 37 | 38 | def synthesize(d, weights, ssd, secondary_source_function, **kwargs): 39 | """Compute sound field for a generic driving function. 40 | 41 | Parameters 42 | ---------- 43 | d : array_like 44 | Driving function. 45 | weights : array_like 46 | Additional weights applied during integration, e.g. source 47 | selection and tapering. 48 | ssd : sequence of between 1 and 3 array_like objects 49 | Positions, normal vectors and weights of secondary sources. 50 | A `SecondarySourceDistribution` can also be used. 51 | secondary_source_function : callable 52 | A function that generates the sound field of a secondary source. 53 | This signature is expected:: 54 | 55 | secondary_source_function( 56 | position, normal_vector, **kwargs) -> numpy.ndarray 57 | 58 | **kwargs 59 | All keyword arguments are forwarded to *secondary_source_function*. 60 | This is typically used to pass the *grid* argument. 61 | 62 | """ 63 | ssd = _array.as_secondary_source_distribution(ssd) 64 | if not (len(ssd.x) == len(ssd.n) == len(ssd.a) == len(d) == 65 | len(weights)): 66 | raise ValueError("length mismatch") 67 | p = 0 68 | for x, n, a, d, weight in zip(ssd.x, ssd.n, ssd.a, d, weights): 69 | if weight != 0: 70 | p += a * weight * d * secondary_source_function(x, n, **kwargs) 71 | return p 72 | 73 | 74 | def secondary_source_point(omega, c): 75 | """Create a point source for use in `sfs.fd.synthesize()`.""" 76 | 77 | def secondary_source(position, _, grid): 78 | return source.point(omega, position, grid, c=c) 79 | 80 | return secondary_source 81 | 82 | 83 | def secondary_source_line(omega, c): 84 | """Create a line source for use in `sfs.fd.synthesize()`.""" 85 | 86 | def secondary_source(position, _, grid): 87 | return source.line(omega, position, grid, c=c) 88 | 89 | return secondary_source 90 | 91 | 92 | from . import esa 93 | from . import nfchoa 94 | from . import sdm 95 | from . import wfs 96 | -------------------------------------------------------------------------------- /sfs/fd/esa.py: -------------------------------------------------------------------------------- 1 | """Compute ESA driving functions for various systems. 2 | 3 | ESA is abbreviation for equivalent scattering approach. 4 | 5 | ESA driving functions for an edge-shaped SSD are provided below. 6 | Further ESA for different geometries might be added here. 7 | 8 | Note that mode-matching (such as NFC-HOA, SDM) are equivalent 9 | to ESA in their specific geometries (spherical/circular, planar/linear). 10 | 11 | """ 12 | import numpy as _np 13 | from scipy.special import jn as _jn, hankel2 as _hankel2 14 | 15 | from . import secondary_source_line as _secondary_source_line 16 | from . import secondary_source_point as _secondary_source_point 17 | from .. import util as _util 18 | 19 | 20 | def plane_2d_edge(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, Nc=None, 21 | c=None): 22 | r"""Driving function for 2-dimensional plane wave with edge ESA. 23 | 24 | Driving function for a virtual plane wave using the 2-dimensional ESA 25 | for an edge-shaped secondary source distribution consisting of 26 | monopole line sources. 27 | 28 | Parameters 29 | ---------- 30 | omega : float 31 | Angular frequency. 32 | x0 : int(N, 3) array_like 33 | Sequence of secondary source positions. 34 | n : (3,) array_like, optional 35 | Normal vector of synthesized plane wave. 36 | alpha : float, optional 37 | Outer angle of edge. 38 | Nc : int, optional 39 | Number of elements for series expansion of driving function. Estimated 40 | if not given. 41 | c : float, optional 42 | Speed of sound 43 | 44 | Returns 45 | ------- 46 | d : (N,) numpy.ndarray 47 | Complex weights of secondary sources. 48 | selection : (N,) numpy.ndarray 49 | Boolean array containing ``True`` or ``False`` depending on 50 | whether the corresponding secondary source is "active" or not. 51 | secondary_source_function : callable 52 | A function that can be used to create the sound field of a 53 | single secondary source. See `sfs.fd.synthesize()`. 54 | 55 | Notes 56 | ----- 57 | One leg of the secondary sources has to be located on the x-axis (y0=0), 58 | the edge at the origin. 59 | 60 | Derived from :cite:`Spors2016` 61 | 62 | """ 63 | x0 = _np.asarray(x0) 64 | n = _util.normalize_vector(n) 65 | k = _util.wavenumber(omega, c) 66 | phi_s = _np.arctan2(n[1], n[0]) + _np.pi 67 | L = x0.shape[0] 68 | 69 | r = _np.linalg.norm(x0, axis=1) 70 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 71 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 72 | 73 | if Nc is None: 74 | Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) 75 | 76 | epsilon = _np.ones(Nc) # weights for series expansion 77 | epsilon[0] = 2 78 | 79 | d = _np.zeros(L, dtype=complex) 80 | for m in _np.arange(Nc): 81 | nu = m * _np.pi / alpha 82 | d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.sin(nu*phi_s) \ 83 | * _np.cos(nu*phi) * nu/r * _jn(nu, k*r) 84 | 85 | d[phi > 0] = -d[phi > 0] 86 | 87 | selection = _util.source_selection_all(len(x0)) 88 | return 4*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 89 | 90 | 91 | def plane_2d_edge_dipole_ssd(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, 92 | Nc=None, c=None): 93 | r"""Driving function for 2-dimensional plane wave with edge dipole ESA. 94 | 95 | Driving function for a virtual plane wave using the 2-dimensional ESA 96 | for an edge-shaped secondary source distribution consisting of 97 | dipole line sources. 98 | 99 | Parameters 100 | ---------- 101 | omega : float 102 | Angular frequency. 103 | x0 : int(N, 3) array_like 104 | Sequence of secondary source positions. 105 | n : (3,) array_like, optional 106 | Normal vector of synthesized plane wave. 107 | alpha : float, optional 108 | Outer angle of edge. 109 | Nc : int, optional 110 | Number of elements for series expansion of driving function. Estimated 111 | if not given. 112 | c : float, optional 113 | Speed of sound 114 | 115 | Returns 116 | ------- 117 | d : (N,) numpy.ndarray 118 | Complex weights of secondary sources. 119 | selection : (N,) numpy.ndarray 120 | Boolean array containing ``True`` or ``False`` depending on 121 | whether the corresponding secondary source is "active" or not. 122 | secondary_source_function : callable 123 | A function that can be used to create the sound field of a 124 | single secondary source. See `sfs.fd.synthesize()`. 125 | 126 | Notes 127 | ----- 128 | One leg of the secondary sources has to be located on the x-axis (y0=0), 129 | the edge at the origin. 130 | 131 | Derived from :cite:`Spors2016` 132 | 133 | """ 134 | x0 = _np.asarray(x0) 135 | n = _util.normalize_vector(n) 136 | k = _util.wavenumber(omega, c) 137 | phi_s = _np.arctan2(n[1], n[0]) + _np.pi 138 | L = x0.shape[0] 139 | 140 | r = _np.linalg.norm(x0, axis=1) 141 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 142 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 143 | 144 | if Nc is None: 145 | Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) 146 | 147 | epsilon = _np.ones(Nc) # weights for series expansion 148 | epsilon[0] = 2 149 | 150 | d = _np.zeros(L, dtype=complex) 151 | for m in _np.arange(Nc): 152 | nu = m * _np.pi / alpha 153 | d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.cos(nu*phi_s) \ 154 | * _np.cos(nu*phi) * _jn(nu, k*r) 155 | 156 | return 4*_np.pi/alpha * d 157 | 158 | 159 | def line_2d_edge(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, c=None): 160 | r"""Driving function for 2-dimensional line source with edge ESA. 161 | 162 | Driving function for a virtual line source using the 2-dimensional ESA 163 | for an edge-shaped secondary source distribution consisting of line 164 | sources. 165 | 166 | Parameters 167 | ---------- 168 | omega : float 169 | Angular frequency. 170 | x0 : int(N, 3) array_like 171 | Sequence of secondary source positions. 172 | xs : (3,) array_like 173 | Position of synthesized line source. 174 | alpha : float, optional 175 | Outer angle of edge. 176 | Nc : int, optional 177 | Number of elements for series expansion of driving function. Estimated 178 | if not given. 179 | c : float, optional 180 | Speed of sound 181 | 182 | Returns 183 | ------- 184 | d : (N,) numpy.ndarray 185 | Complex weights of secondary sources. 186 | selection : (N,) numpy.ndarray 187 | Boolean array containing ``True`` or ``False`` depending on 188 | whether the corresponding secondary source is "active" or not. 189 | secondary_source_function : callable 190 | A function that can be used to create the sound field of a 191 | single secondary source. See `sfs.fd.synthesize()`. 192 | 193 | Notes 194 | ----- 195 | One leg of the secondary sources has to be located on the x-axis (y0=0), 196 | the edge at the origin. 197 | 198 | Derived from :cite:`Spors2016` 199 | 200 | """ 201 | x0 = _np.asarray(x0) 202 | k = _util.wavenumber(omega, c) 203 | phi_s = _np.arctan2(xs[1], xs[0]) 204 | if phi_s < 0: 205 | phi_s = phi_s + 2 * _np.pi 206 | r_s = _np.linalg.norm(xs) 207 | L = x0.shape[0] 208 | 209 | r = _np.linalg.norm(x0, axis=1) 210 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 211 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 212 | 213 | if Nc is None: 214 | Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) 215 | 216 | epsilon = _np.ones(Nc) # weights for series expansion 217 | epsilon[0] = 2 218 | 219 | d = _np.zeros(L, dtype=complex) 220 | idx = (r <= r_s) 221 | for m in _np.arange(Nc): 222 | nu = m * _np.pi / alpha 223 | f = 1/epsilon[m] * _np.sin(nu*phi_s) * _np.cos(nu*phi) * nu/r 224 | d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) 225 | d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) 226 | 227 | d[phi > 0] = -d[phi > 0] 228 | 229 | selection = _util.source_selection_all(len(x0)) 230 | return -1j*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 231 | 232 | 233 | def line_2d_edge_dipole_ssd(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, 234 | c=None): 235 | r"""Driving function for 2-dimensional line source with edge dipole ESA. 236 | 237 | Driving function for a virtual line source using the 2-dimensional ESA 238 | for an edge-shaped secondary source distribution consisting of dipole line 239 | sources. 240 | 241 | Parameters 242 | ---------- 243 | omega : float 244 | Angular frequency. 245 | x0 : (N, 3) array_like 246 | Sequence of secondary source positions. 247 | xs : (3,) array_like 248 | Position of synthesized line source. 249 | alpha : float, optional 250 | Outer angle of edge. 251 | Nc : int, optional 252 | Number of elements for series expansion of driving function. Estimated 253 | if not given. 254 | c : float, optional 255 | Speed of sound 256 | 257 | Returns 258 | ------- 259 | d : (N,) numpy.ndarray 260 | Complex weights of secondary sources. 261 | selection : (N,) numpy.ndarray 262 | Boolean array containing ``True`` or ``False`` depending on 263 | whether the corresponding secondary source is "active" or not. 264 | secondary_source_function : callable 265 | A function that can be used to create the sound field of a 266 | single secondary source. See `sfs.fd.synthesize()`. 267 | 268 | Notes 269 | ----- 270 | One leg of the secondary sources has to be located on the x-axis (y0=0), 271 | the edge at the origin. 272 | 273 | Derived from :cite:`Spors2016` 274 | 275 | """ 276 | x0 = _np.asarray(x0) 277 | k = _util.wavenumber(omega, c) 278 | phi_s = _np.arctan2(xs[1], xs[0]) 279 | if phi_s < 0: 280 | phi_s = phi_s + 2 * _np.pi 281 | r_s = _np.linalg.norm(xs) 282 | L = x0.shape[0] 283 | 284 | r = _np.linalg.norm(x0, axis=1) 285 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 286 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 287 | 288 | if Nc is None: 289 | Nc = _np.ceil(2 * k * _np.max(r) * alpha / _np.pi) 290 | 291 | epsilon = _np.ones(Nc) # weights for series expansion 292 | epsilon[0] = 2 293 | 294 | d = _np.zeros(L, dtype=complex) 295 | idx = (r <= r_s) 296 | for m in _np.arange(Nc): 297 | nu = m * _np.pi / alpha 298 | f = 1/epsilon[m] * _np.cos(nu*phi_s) * _np.cos(nu*phi) 299 | d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) 300 | d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) 301 | 302 | return -1j*_np.pi/alpha * d 303 | 304 | 305 | def point_25d_edge(omega, x0, xs, *, xref=[2, -2, 0], alpha=_np.pi*3/2, 306 | Nc=None, c=None): 307 | r"""Driving function for 2.5-dimensional point source with edge ESA. 308 | 309 | Driving function for a virtual point source using the 2.5-dimensional 310 | ESA for an edge-shaped secondary source distribution consisting of point 311 | sources. 312 | 313 | Parameters 314 | ---------- 315 | omega : float 316 | Angular frequency. 317 | x0 : int(N, 3) array_like 318 | Sequence of secondary source positions. 319 | xs : (3,) array_like 320 | Position of synthesized line source. 321 | xref: (3,) array_like or float 322 | Reference position or reference distance 323 | alpha : float, optional 324 | Outer angle of edge. 325 | Nc : int, optional 326 | Number of elements for series expansion of driving function. Estimated 327 | if not given. 328 | c : float, optional 329 | Speed of sound 330 | 331 | Returns 332 | ------- 333 | d : (N,) numpy.ndarray 334 | Complex weights of secondary sources. 335 | selection : (N,) numpy.ndarray 336 | Boolean array containing ``True`` or ``False`` depending on 337 | whether the corresponding secondary source is "active" or not. 338 | secondary_source_function : callable 339 | A function that can be used to create the sound field of a 340 | single secondary source. See `sfs.fd.synthesize()`. 341 | 342 | Notes 343 | ----- 344 | One leg of the secondary sources has to be located on the x-axis (y0=0), 345 | the edge at the origin. 346 | 347 | Derived from :cite:`Spors2016` 348 | 349 | """ 350 | x0 = _np.asarray(x0) 351 | xs = _np.asarray(xs) 352 | xref = _np.asarray(xref) 353 | 354 | if _np.isscalar(xref): 355 | a = _np.linalg.norm(xref) / _np.linalg.norm(xref - xs) 356 | else: 357 | a = _np.linalg.norm(xref - x0, axis=1) / _np.linalg.norm(xref - xs) 358 | 359 | d, selection, _ = line_2d_edge(omega, x0, xs, alpha=alpha, Nc=Nc, c=c) 360 | return 1j*_np.sqrt(a) * d, selection, _secondary_source_point(omega, c) 361 | -------------------------------------------------------------------------------- /sfs/fd/nfchoa.py: -------------------------------------------------------------------------------- 1 | """Compute NFC-HOA driving functions. 2 | 3 | .. include:: math-definitions.rst 4 | 5 | .. plot:: 6 | :context: reset 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import sfs 11 | 12 | plt.rcParams['figure.figsize'] = 6, 6 13 | 14 | xs = -1.5, 1.5, 0 15 | # normal vector for plane wave: 16 | npw = sfs.util.direction_vector(np.radians(-45)) 17 | f = 300 # Hz 18 | omega = 2 * np.pi * f 19 | R = 1.5 # Radius of circular loudspeaker array 20 | 21 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) 22 | 23 | array = sfs.array.circular(N=32, R=R) 24 | 25 | def plot(d, selection, secondary_source): 26 | p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) 27 | sfs.plot2d.amplitude(p, grid) 28 | sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) 29 | 30 | """ 31 | import numpy as _np 32 | from scipy.special import hankel2 as _hankel2 33 | 34 | from . import secondary_source_point as _secondary_source_point 35 | from . import secondary_source_line as _secondary_source_line 36 | from .. import util as _util 37 | 38 | 39 | def plane_2d(omega, x0, r0, n=[0, 1, 0], *, max_order=None, c=None): 40 | r"""Driving function for 2-dimensional NFC-HOA for a virtual plane wave. 41 | 42 | Parameters 43 | ---------- 44 | omega : float 45 | Angular frequency of plane wave. 46 | x0 : (N, 3) array_like 47 | Sequence of secondary source positions. 48 | r0 : float 49 | Radius of circular secondary source distribution. 50 | n : (3,) array_like, optional 51 | Normal vector (traveling direction) of plane wave. 52 | max_order : float, optional 53 | Maximum order of circular harmonics used for the calculation. 54 | c : float, optional 55 | Speed of sound. 56 | 57 | Returns 58 | ------- 59 | d : (N,) numpy.ndarray 60 | Complex weights of secondary sources. 61 | selection : (N,) numpy.ndarray 62 | Boolean array containing only ``True`` indicating that 63 | all secondary source are "active" for NFC-HOA. 64 | secondary_source_function : callable 65 | A function that can be used to create the sound field of a 66 | single secondary source. See `sfs.fd.synthesize()`. 67 | 68 | Notes 69 | ----- 70 | .. math:: 71 | 72 | D(\phi_0, \omega) = 73 | -\frac{2\i}{\pi r_0} 74 | \sum_{m=-M}^M 75 | \frac{\i^{-m}}{\Hankel{2}{m}{\wc r_0}} 76 | \e{\i m (\phi_0 - \phi_\text{pw})} 77 | 78 | See :sfs:`d_nfchoa/#equation-fd-nfchoa-plane-2d` 79 | 80 | Examples 81 | -------- 82 | .. plot:: 83 | :context: close-figs 84 | 85 | d, selection, secondary_source = sfs.fd.nfchoa.plane_2d( 86 | omega, array.x, R, npw) 87 | plot(d, selection, secondary_source) 88 | 89 | """ 90 | if max_order is None: 91 | max_order = _util.max_order_circular_harmonics(len(x0)) 92 | 93 | x0 = _util.asarray_of_rows(x0) 94 | k = _util.wavenumber(omega, c) 95 | n = _util.normalize_vector(n) 96 | phi, _, r = _util.cart2sph(*n) 97 | phi0 = _util.cart2sph(*x0.T)[0] 98 | d = 0 99 | for m in range(-max_order, max_order + 1): 100 | d += 1j**-m / _hankel2(m, k * r0) * _np.exp(1j * m * (phi0 - phi)) 101 | selection = _util.source_selection_all(len(x0)) 102 | return -2j / (_np.pi*r0) * d, selection, _secondary_source_line(omega, c) 103 | 104 | 105 | def point_25d(omega, x0, r0, xs, *, max_order=None, c=None): 106 | r"""Driving function for 2.5-dimensional NFC-HOA for a virtual point source. 107 | 108 | Parameters 109 | ---------- 110 | omega : float 111 | Angular frequency of point source. 112 | x0 : (N, 3) array_like 113 | Sequence of secondary source positions. 114 | r0 : float 115 | Radius of circular secondary source distribution. 116 | xs : (3,) array_like 117 | Position of point source. 118 | max_order : float, optional 119 | Maximum order of circular harmonics used for the calculation. 120 | c : float, optional 121 | Speed of sound. 122 | 123 | Returns 124 | ------- 125 | d : (N,) numpy.ndarray 126 | Complex weights of secondary sources. 127 | selection : (N,) numpy.ndarray 128 | Boolean array containing only ``True`` indicating that 129 | all secondary source are "active" for NFC-HOA. 130 | secondary_source_function : callable 131 | A function that can be used to create the sound field of a 132 | single secondary source. See `sfs.fd.synthesize()`. 133 | 134 | Notes 135 | ----- 136 | .. math:: 137 | 138 | D(\phi_0, \omega) = 139 | \frac{1}{2 \pi r_0} 140 | \sum_{m=-M}^M 141 | \frac{\hankel{2}{|m|}{\wc r}}{\hankel{2}{|m|}{\wc r_0}} 142 | \e{\i m (\phi_0 - \phi)} 143 | 144 | See :sfs:`d_nfchoa/#equation-fd-nfchoa-point-25d` 145 | 146 | Examples 147 | -------- 148 | .. plot:: 149 | :context: close-figs 150 | 151 | d, selection, secondary_source = sfs.fd.nfchoa.point_25d( 152 | omega, array.x, R, xs) 153 | plot(d, selection, secondary_source) 154 | 155 | """ 156 | if max_order is None: 157 | max_order = _util.max_order_circular_harmonics(len(x0)) 158 | 159 | x0 = _util.asarray_of_rows(x0) 160 | k = _util.wavenumber(omega, c) 161 | xs = _util.asarray_1d(xs) 162 | phi, _, r = _util.cart2sph(*xs) 163 | phi0 = _util.cart2sph(*x0.T)[0] 164 | hr = _util.spherical_hn2(range(0, max_order + 1), k * r) 165 | hr0 = _util.spherical_hn2(range(0, max_order + 1), k * r0) 166 | d = 0 167 | for m in range(-max_order, max_order + 1): 168 | d += hr[abs(m)] / hr0[abs(m)] * _np.exp(1j * m * (phi0 - phi)) 169 | selection = _util.source_selection_all(len(x0)) 170 | return d / (2 * _np.pi * r0), selection, _secondary_source_point(omega, c) 171 | 172 | 173 | def plane_25d(omega, x0, r0, n=[0, 1, 0], *, max_order=None, c=None): 174 | r"""Driving function for 2.5-dimensional NFC-HOA for a virtual plane wave. 175 | 176 | Parameters 177 | ---------- 178 | omega : float 179 | Angular frequency of point source. 180 | x0 : (N, 3) array_like 181 | Sequence of secondary source positions. 182 | r0 : float 183 | Radius of circular secondary source distribution. 184 | n : (3,) array_like, optional 185 | Normal vector (traveling direction) of plane wave. 186 | max_order : float, optional 187 | Maximum order of circular harmonics used for the calculation. 188 | c : float, optional 189 | Speed of sound. 190 | 191 | Returns 192 | ------- 193 | d : (N,) numpy.ndarray 194 | Complex weights of secondary sources. 195 | selection : (N,) numpy.ndarray 196 | Boolean array containing only ``True`` indicating that 197 | all secondary source are "active" for NFC-HOA. 198 | secondary_source_function : callable 199 | A function that can be used to create the sound field of a 200 | single secondary source. See `sfs.fd.synthesize()`. 201 | 202 | Notes 203 | ----- 204 | .. math:: 205 | 206 | D(\phi_0, \omega) = 207 | \frac{2\i}{r_0} 208 | \sum_{m=-M}^M 209 | \frac{\i^{-|m|}}{\wc \hankel{2}{|m|}{\wc r_0}} 210 | \e{\i m (\phi_0 - \phi_\text{pw})} 211 | 212 | See :sfs:`d_nfchoa/#equation-fd-nfchoa-plane-25d` 213 | 214 | Examples 215 | -------- 216 | .. plot:: 217 | :context: close-figs 218 | 219 | d, selection, secondary_source = sfs.fd.nfchoa.plane_25d( 220 | omega, array.x, R, npw) 221 | plot(d, selection, secondary_source) 222 | 223 | """ 224 | if max_order is None: 225 | max_order = _util.max_order_circular_harmonics(len(x0)) 226 | 227 | x0 = _util.asarray_of_rows(x0) 228 | k = _util.wavenumber(omega, c) 229 | n = _util.normalize_vector(n) 230 | phi, _, r = _util.cart2sph(*n) 231 | phi0 = _util.cart2sph(*x0.T)[0] 232 | d = 0 233 | hn2 = _util.spherical_hn2(range(0, max_order + 1), k * r0) 234 | for m in range(-max_order, max_order + 1): 235 | d += (-1j)**abs(m) / (k * hn2[abs(m)]) * _np.exp(1j * m * (phi0 - phi)) 236 | selection = _util.source_selection_all(len(x0)) 237 | return 2*1j / r0 * d, selection, _secondary_source_point(omega, c) 238 | -------------------------------------------------------------------------------- /sfs/fd/sdm.py: -------------------------------------------------------------------------------- 1 | """Compute SDM driving functions. 2 | 3 | .. include:: math-definitions.rst 4 | 5 | .. plot:: 6 | :context: reset 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import sfs 11 | 12 | plt.rcParams['figure.figsize'] = 6, 6 13 | 14 | xs = -1.5, 1.5, 0 15 | # normal vector for plane wave: 16 | npw = sfs.util.direction_vector(np.radians(-45)) 17 | f = 300 # Hz 18 | omega = 2 * np.pi * f 19 | 20 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) 21 | 22 | array = sfs.array.linear(32, 0.2, orientation=[0, -1, 0]) 23 | 24 | def plot(d, selection, secondary_source): 25 | p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) 26 | sfs.plot2d.amplitude(p, grid) 27 | sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) 28 | 29 | """ 30 | import numpy as _np 31 | from scipy.special import hankel2 as _hankel2 32 | 33 | from . import secondary_source_line as _secondary_source_line 34 | from . import secondary_source_point as _secondary_source_point 35 | from .. import util as _util 36 | 37 | 38 | def line_2d(omega, x0, n0, xs, *, c=None): 39 | r"""Driving function for 2-dimensional SDM for a virtual line source. 40 | 41 | Parameters 42 | ---------- 43 | omega : float 44 | Angular frequency of line source. 45 | x0 : (N, 3) array_like 46 | Sequence of secondary source positions. 47 | n0 : (N, 3) array_like 48 | Sequence of normal vectors of secondary sources. 49 | xs : (3,) array_like 50 | Position of line source. 51 | c : float, optional 52 | Speed of sound. 53 | 54 | Returns 55 | ------- 56 | d : (N,) numpy.ndarray 57 | Complex weights of secondary sources. 58 | selection : (N,) numpy.ndarray 59 | Boolean array containing ``True`` or ``False`` depending on 60 | whether the corresponding secondary source is "active" or not. 61 | secondary_source_function : callable 62 | A function that can be used to create the sound field of a 63 | single secondary source. See `sfs.fd.synthesize()`. 64 | 65 | Notes 66 | ----- 67 | The secondary sources have to be located on the x-axis (y0=0). 68 | Derived from :cite:`Spors2009`, Eq.(9), Eq.(4). 69 | 70 | Examples 71 | -------- 72 | .. plot:: 73 | :context: close-figs 74 | 75 | d, selection, secondary_source = sfs.fd.sdm.line_2d( 76 | omega, array.x, array.n, xs) 77 | plot(d, selection, secondary_source) 78 | 79 | """ 80 | x0 = _util.asarray_of_rows(x0) 81 | n0 = _util.asarray_of_rows(n0) 82 | xs = _util.asarray_1d(xs) 83 | k = _util.wavenumber(omega, c) 84 | ds = x0 - xs 85 | r = _np.linalg.norm(ds, axis=1) 86 | d = - 1j/2 * k * xs[1] / r * _hankel2(1, k * r) 87 | selection = _util.source_selection_all(len(x0)) 88 | return d, selection, _secondary_source_line(omega, c) 89 | 90 | 91 | def plane_2d(omega, x0, n0, n=[0, 1, 0], *, c=None): 92 | r"""Driving function for 2-dimensional SDM for a virtual plane wave. 93 | 94 | Parameters 95 | ---------- 96 | omega : float 97 | Angular frequency of plane wave. 98 | x0 : (N, 3) array_like 99 | Sequence of secondary source positions. 100 | n0 : (N, 3) array_like 101 | Sequence of normal vectors of secondary sources. 102 | n: (3,) array_like, optional 103 | Normal vector (traveling direction) of plane wave. 104 | c : float, optional 105 | Speed of sound. 106 | 107 | Returns 108 | ------- 109 | d : (N,) numpy.ndarray 110 | Complex weights of secondary sources. 111 | selection : (N,) numpy.ndarray 112 | Boolean array containing ``True`` or ``False`` depending on 113 | whether the corresponding secondary source is "active" or not. 114 | secondary_source_function : callable 115 | A function that can be used to create the sound field of a 116 | single secondary source. See `sfs.fd.synthesize()`. 117 | 118 | Notes 119 | ----- 120 | The secondary sources have to be located on the x-axis (y0=0). 121 | Derived from :cite:`Ahrens2012`, Eq.(3.73), Eq.(C.5), Eq.(C.11): 122 | 123 | .. math:: 124 | 125 | D(\x_0,k) = k_\text{pw,y} \e{-\i k_\text{pw,x} x} 126 | 127 | Examples 128 | -------- 129 | .. plot:: 130 | :context: close-figs 131 | 132 | d, selection, secondary_source = sfs.fd.sdm.plane_2d( 133 | omega, array.x, array.n, npw) 134 | plot(d, selection, secondary_source) 135 | 136 | """ 137 | x0 = _util.asarray_of_rows(x0) 138 | n0 = _util.asarray_of_rows(n0) 139 | n = _util.normalize_vector(n) 140 | k = _util.wavenumber(omega, c) 141 | d = k * n[1] * _np.exp(-1j * k * n[0] * x0[:, 0]) 142 | selection = _util.source_selection_all(len(x0)) 143 | return d, selection, _secondary_source_line(omega, c) 144 | 145 | 146 | def plane_25d(omega, x0, n0, n=[0, 1, 0], *, xref=[0, 0, 0], c=None): 147 | r"""Driving function for 2.5-dimensional SDM for a virtual plane wave. 148 | 149 | Parameters 150 | ---------- 151 | omega : float 152 | Angular frequency of plane wave. 153 | x0 : (N, 3) array_like 154 | Sequence of secondary source positions. 155 | n0 : (N, 3) array_like 156 | Sequence of normal vectors of secondary sources. 157 | n: (3,) array_like, optional 158 | Normal vector (traveling direction) of plane wave. 159 | xref : (3,) array_like, optional 160 | Reference point for synthesized sound field. 161 | c : float, optional 162 | Speed of sound. 163 | 164 | Returns 165 | ------- 166 | d : (N,) numpy.ndarray 167 | Complex weights of secondary sources. 168 | selection : (N,) numpy.ndarray 169 | Boolean array containing ``True`` or ``False`` depending on 170 | whether the corresponding secondary source is "active" or not. 171 | secondary_source_function : callable 172 | A function that can be used to create the sound field of a 173 | single secondary source. See `sfs.fd.synthesize()`. 174 | 175 | Notes 176 | ----- 177 | The secondary sources have to be located on the x-axis (y0=0). 178 | Eq.(3.79) from :cite:`Ahrens2012`. 179 | 180 | Examples 181 | -------- 182 | .. plot:: 183 | :context: close-figs 184 | 185 | d, selection, secondary_source = sfs.fd.sdm.plane_25d( 186 | omega, array.x, array.n, npw, xref=[0, -1, 0]) 187 | plot(d, selection, secondary_source) 188 | 189 | """ 190 | x0 = _util.asarray_of_rows(x0) 191 | n0 = _util.asarray_of_rows(n0) 192 | n = _util.normalize_vector(n) 193 | xref = _util.asarray_1d(xref) 194 | k = _util.wavenumber(omega, c) 195 | d = 4j * _np.exp(-1j*k*n[1]*xref[1]) / _hankel2(0, k*n[1]*xref[1]) * \ 196 | _np.exp(-1j*k*n[0]*x0[:, 0]) 197 | selection = _util.source_selection_all(len(x0)) 198 | return d, selection, _secondary_source_point(omega, c) 199 | 200 | 201 | def point_25d(omega, x0, n0, xs, *, xref=[0, 0, 0], c=None): 202 | r"""Driving function for 2.5-dimensional SDM for a virtual point source. 203 | 204 | Parameters 205 | ---------- 206 | omega : float 207 | Angular frequency of point source. 208 | x0 : (N, 3) array_like 209 | Sequence of secondary source positions. 210 | n0 : (N, 3) array_like 211 | Sequence of normal vectors of secondary sources. 212 | xs: (3,) array_like 213 | Position of virtual point source. 214 | xref : (3,) array_like, optional 215 | Reference point for synthesized sound field. 216 | c : float, optional 217 | Speed of sound. 218 | 219 | Returns 220 | ------- 221 | d : (N,) numpy.ndarray 222 | Complex weights of secondary sources. 223 | selection : (N,) numpy.ndarray 224 | Boolean array containing ``True`` or ``False`` depending on 225 | whether the corresponding secondary source is "active" or not. 226 | secondary_source_function : callable 227 | A function that can be used to create the sound field of a 228 | single secondary source. See `sfs.fd.synthesize()`. 229 | 230 | Notes 231 | ----- 232 | The secondary sources have to be located on the x-axis (y0=0). 233 | Driving function from :cite:`Spors2010`, Eq.(24). 234 | 235 | Examples 236 | -------- 237 | .. plot:: 238 | :context: close-figs 239 | 240 | d, selection, secondary_source = sfs.fd.sdm.point_25d( 241 | omega, array.x, array.n, xs, xref=[0, -1, 0]) 242 | plot(d, selection, secondary_source) 243 | 244 | """ 245 | x0 = _util.asarray_of_rows(x0) 246 | n0 = _util.asarray_of_rows(n0) 247 | xs = _util.asarray_1d(xs) 248 | xref = _util.asarray_1d(xref) 249 | k = _util.wavenumber(omega, c) 250 | ds = x0 - xs 251 | r = _np.linalg.norm(ds, axis=1) 252 | d = 1/2 * 1j * k * _np.sqrt(xref[1] / (xref[1] - xs[1])) * \ 253 | xs[1] / r * _hankel2(1, k * r) 254 | selection = _util.source_selection_all(len(x0)) 255 | return d, selection, _secondary_source_point(omega, c) 256 | -------------------------------------------------------------------------------- /sfs/plot2d.py: -------------------------------------------------------------------------------- 1 | """2D plots of sound fields etc.""" 2 | import matplotlib as _mpl 3 | import matplotlib.pyplot as _plt 4 | from mpl_toolkits import axes_grid1 as _axes_grid1 5 | import numpy as _np 6 | 7 | from . import default as _default 8 | from . import util as _util 9 | 10 | 11 | def _register_cmap_clip(name, original_cmap, alpha): 12 | """Create a color map with "over" and "under" values.""" 13 | from matplotlib.colors import LinearSegmentedColormap 14 | cdata = _plt.cm.datad[original_cmap] 15 | if isinstance(cdata, dict): 16 | cmap = LinearSegmentedColormap(name, cdata) 17 | else: 18 | cmap = LinearSegmentedColormap.from_list(name, cdata) 19 | cmap.set_over([alpha * c + 1 - alpha for c in cmap(1.0)[:3]]) 20 | cmap.set_under([alpha * c + 1 - alpha for c in cmap(0.0)[:3]]) 21 | _plt.cm.register_cmap(cmap=cmap) 22 | 23 | 24 | # The 'coolwarm' colormap is based on the paper 25 | # "Diverging Color Maps for Scientific Visualization" by Kenneth Moreland 26 | # http://www.sandia.gov/~kmorel/documents/ColorMaps/ 27 | _register_cmap_clip('coolwarm_clip', 'coolwarm', 0.7) 28 | 29 | 30 | def _register_cmap_transparent(name, color): 31 | """Create a color map from a given color to transparent.""" 32 | from matplotlib.colors import colorConverter, LinearSegmentedColormap 33 | red, green, blue = colorConverter.to_rgb(color) 34 | cdict = {'red': ((0, red, red), (1, red, red)), 35 | 'green': ((0, green, green), (1, green, green)), 36 | 'blue': ((0, blue, blue), (1, blue, blue)), 37 | 'alpha': ((0, 0, 0), (1, 1, 1))} 38 | cmap = LinearSegmentedColormap(name, cdict) 39 | _plt.cm.register_cmap(cmap=cmap) 40 | 41 | 42 | _register_cmap_transparent('blacktransparent', 'black') 43 | 44 | 45 | def virtualsource(xs, ns=None, type='point', *, ax=None): 46 | """Draw position/orientation of virtual source.""" 47 | xs = _np.asarray(xs) 48 | ns = _np.asarray(ns) 49 | if ax is None: 50 | ax = _plt.gca() 51 | 52 | if type == 'point': 53 | vps = _plt.Circle(xs, .05, edgecolor='k', facecolor='k') 54 | ax.add_artist(vps) 55 | for n in range(1, 3): 56 | vps = _plt.Circle(xs, .05+n*0.05, edgecolor='k', fill=False) 57 | ax.add_artist(vps) 58 | elif type == 'plane': 59 | ns = 0.2 * ns 60 | 61 | ax.arrow(xs[0], xs[1], ns[0], ns[1], head_width=0.05, 62 | head_length=0.1, fc='k', ec='k') 63 | 64 | 65 | def reference(xref, *, size=0.1, ax=None): 66 | """Draw reference/normalization point.""" 67 | xref = _np.asarray(xref) 68 | if ax is None: 69 | ax = _plt.gca() 70 | 71 | ax.plot((xref[0]-size, xref[0]+size), (xref[1]-size, xref[1]+size), 'k-') 72 | ax.plot((xref[0]-size, xref[0]+size), (xref[1]+size, xref[1]-size), 'k-') 73 | 74 | 75 | def secondary_sources(x0, n0, *, size=0.05, grid=None): 76 | """Simple visualization of secondary source locations. 77 | 78 | Parameters 79 | ---------- 80 | x0 : (N, 3) array_like 81 | Loudspeaker positions. 82 | n0 : (N, 3) or (3,) array_like 83 | Normal vector(s) of loudspeakers. 84 | size : float, optional 85 | Size of loudspeakers in metres. 86 | grid : triple of array_like, optional 87 | If specified, only loudspeakers within the *grid* are shown. 88 | """ 89 | x0 = _np.asarray(x0) 90 | n0 = _np.asarray(n0) 91 | ax = _plt.gca() 92 | 93 | # plot only secondary sources inside simulated area 94 | if grid is not None: 95 | x0, n0 = _visible_secondarysources(x0, n0, grid) 96 | 97 | # plot symbols 98 | for x00 in x0: 99 | ss = _plt.Circle(x00[0:2], size, edgecolor='k', facecolor='k') 100 | ax.add_artist(ss) 101 | 102 | 103 | def loudspeakers(x0, n0, a0=0.5, *, size=0.08, show_numbers=False, grid=None, 104 | ax=None): 105 | """Draw loudspeaker symbols at given locations and angles. 106 | 107 | Parameters 108 | ---------- 109 | x0 : (N, 3) array_like 110 | Loudspeaker positions. 111 | n0 : (N, 3) or (3,) array_like 112 | Normal vector(s) of loudspeakers. 113 | a0 : float or (N,) array_like, optional 114 | Weighting factor(s) of loudspeakers. 115 | size : float, optional 116 | Size of loudspeakers in metres. 117 | show_numbers : bool, optional 118 | If ``True``, loudspeaker numbers are shown. 119 | grid : triple of array_like, optional 120 | If specified, only loudspeakers within the *grid* are shown. 121 | ax : Axes object, optional 122 | The loudspeakers are plotted into this `matplotlib.axes.Axes` 123 | object or -- if not specified -- into the current axes. 124 | 125 | """ 126 | x0 = _util.asarray_of_rows(x0) 127 | n0 = _util.asarray_of_rows(n0) 128 | a0 = _util.asarray_1d(a0).reshape(-1, 1) 129 | 130 | # plot only secondary sources inside simulated area 131 | if grid is not None: 132 | x0, n0 = _visible_secondarysources(x0, n0, grid) 133 | 134 | # normalized coordinates of loudspeaker symbol (see IEC 60617-9) 135 | codes, coordinates = zip(*( 136 | (_mpl.path.Path.MOVETO, [-0.62, 0.21]), 137 | (_mpl.path.Path.LINETO, [-0.31, 0.21]), 138 | (_mpl.path.Path.LINETO, [0, 0.5]), 139 | (_mpl.path.Path.LINETO, [0, -0.5]), 140 | (_mpl.path.Path.LINETO, [-0.31, -0.21]), 141 | (_mpl.path.Path.LINETO, [-0.62, -0.21]), 142 | (_mpl.path.Path.CLOSEPOLY, [0, 0]), 143 | (_mpl.path.Path.MOVETO, [-0.31, 0.21]), 144 | (_mpl.path.Path.LINETO, [-0.31, -0.21]), 145 | )) 146 | coordinates = _np.column_stack([coordinates, _np.zeros(len(coordinates))]) 147 | coordinates *= size 148 | 149 | patches = [] 150 | for x00, n00 in _util.broadcast_zip(x0, n0): 151 | # rotate and translate coordinates 152 | R = _util.rotation_matrix([1, 0, 0], n00) 153 | transformed_coordinates = _np.inner(coordinates, R) + x00 154 | 155 | patches.append(_mpl.patches.PathPatch(_mpl.path.Path( 156 | transformed_coordinates[:, :2], codes))) 157 | 158 | # add collection of patches to current axis 159 | p = _mpl.collections.PatchCollection( 160 | patches, edgecolor='0', facecolor=_np.tile(1 - a0, 3)) 161 | if ax is None: 162 | ax = _plt.gca() 163 | ax.add_collection(p) 164 | 165 | if show_numbers: 166 | for idx, (x00, n00) in enumerate(_util.broadcast_zip(x0, n0)): 167 | x, y = x00[:2] - 1.2 * size * n00[:2] 168 | ax.text(x, y, idx + 1, horizontalalignment='center', 169 | verticalalignment='center', clip_on=True) 170 | 171 | 172 | def _visible_secondarysources(x0, n0, grid): 173 | """Determine secondary sources which lie within *grid*.""" 174 | x, y = _util.as_xyz_components(grid[:2]) 175 | idx = _np.where((x0[:, 0] > x.min()) & (x0[:, 0] < x.max()) & 176 | (x0[:, 1] > y.min()) & (x0[:, 1] < x.max())) 177 | idx = _np.squeeze(idx) 178 | 179 | return x0[idx, :], n0[idx, :] 180 | 181 | 182 | def amplitude(p, grid, *, xnorm=None, cmap='coolwarm_clip', 183 | vmin=-2.0, vmax=2.0, xlabel=None, ylabel=None, 184 | colorbar=True, colorbar_kwargs={}, ax=None, **kwargs): 185 | """Two-dimensional plot of sound field (real part). 186 | 187 | Parameters 188 | ---------- 189 | p : array_like 190 | Sound pressure values (or any other scalar quantity if you 191 | like). If the values are complex, the imaginary part is 192 | ignored. 193 | Typically, *p* is two-dimensional with a shape of *(Ny, Nx)*, 194 | *(Nz, Nx)* or *(Nz, Ny)*. This is the case if 195 | `sfs.util.xyz_grid()` was used with a single number for *z*, 196 | *y* or *x*, respectively. 197 | However, *p* can also be three-dimensional with a shape of *(Ny, 198 | Nx, 1)*, *(1, Nx, Nz)* or *(Ny, 1, Nz)*. This is the case if 199 | :func:`numpy.meshgrid` was used with a scalar for *z*, *y* or 200 | *x*, respectively (and of course with the default 201 | ``indexing='xy'``). 202 | 203 | .. note:: If you want to plot a single slice of a pre-computed 204 | "full" 3D sound field, make sure that the slice still 205 | has three dimensions (including one singleton 206 | dimension). This way, you can use the original *grid* 207 | of the full volume without changes. 208 | This works because the grid component corresponding to 209 | the singleton dimension is simply ignored. 210 | 211 | grid : triple or pair of numpy.ndarray 212 | The grid that was used to calculate *p*, see 213 | `sfs.util.xyz_grid()`. If *p* is two-dimensional, but 214 | *grid* has 3 components, one of them must be scalar. 215 | xnorm : array_like, optional 216 | Coordinates of a point to which the sound field should be 217 | normalized before plotting. If not specified, no normalization 218 | is used. See `sfs.util.normalize()`. 219 | 220 | Returns 221 | ------- 222 | AxesImage 223 | See :func:`matplotlib.pyplot.imshow`. 224 | 225 | Other Parameters 226 | ---------------- 227 | xlabel, ylabel : str 228 | Overwrite default x/y labels. Use ``xlabel=''`` and 229 | ``ylabel=''`` to remove x/y labels. The labels can be changed 230 | afterwards with :func:`matplotlib.pyplot.xlabel` and 231 | :func:`matplotlib.pyplot.ylabel`. 232 | colorbar : bool, optional 233 | If ``False``, no colorbar is created. 234 | colorbar_kwargs : dict, optional 235 | Further colorbar arguments, see `add_colorbar()`. 236 | ax : Axes, optional 237 | If given, the plot is created on *ax* instead of the current 238 | axis (see :func:`matplotlib.pyplot.gca`). 239 | cmap, vmin, vmax, **kwargs 240 | All further parameters are forwarded to 241 | :func:`matplotlib.pyplot.imshow`. 242 | 243 | See Also 244 | -------- 245 | sfs.plot2d.level 246 | 247 | """ 248 | p = _np.asarray(p) 249 | grid = _util.as_xyz_components(grid) 250 | 251 | # normalize sound field wrt xnorm 252 | if xnorm is not None: 253 | p = _util.normalize(p, grid, xnorm) 254 | 255 | if p.ndim == 3: 256 | if p.shape[2] == 1: 257 | p = p[:, :, 0] # first axis: y; second axis: x 258 | plotting_plane = 'xy' 259 | elif p.shape[1] == 1: 260 | p = p[:, 0, :].T # first axis: z; second axis: y 261 | plotting_plane = 'yz' 262 | elif p.shape[0] == 1: 263 | p = p[0, :, :].T # first axis: z; second axis: x 264 | plotting_plane = 'xz' 265 | else: 266 | raise ValueError("If p is 3D, one dimension must have length 1") 267 | elif len(grid) == 3: 268 | if grid[2].ndim == 0: 269 | plotting_plane = 'xy' 270 | elif grid[1].ndim == 0: 271 | plotting_plane = 'xz' 272 | elif grid[0].ndim == 0: 273 | plotting_plane = 'yz' 274 | else: 275 | raise ValueError( 276 | "If p is 2D and grid is 3D, one grid component must be scalar") 277 | else: 278 | # 2-dimensional case 279 | plotting_plane = 'xy' 280 | 281 | if plotting_plane == 'xy': 282 | x, y = grid[[0, 1]] 283 | elif plotting_plane == 'xz': 284 | x, y = grid[[0, 2]] 285 | elif plotting_plane == 'yz': 286 | x, y = grid[[1, 2]] 287 | 288 | dx = 0.5 * x.ptp() / p.shape[0] 289 | dy = 0.5 * y.ptp() / p.shape[1] 290 | 291 | if ax is None: 292 | ax = _plt.gca() 293 | 294 | # see https://github.com/matplotlib/matplotlib/issues/10567 295 | if _mpl.__version__.startswith('2.1.'): 296 | p = _np.clip(p, -1e15, 1e15) # clip to float64 range 297 | 298 | im = ax.imshow(_np.real(p), cmap=cmap, origin='lower', 299 | extent=[x.min()-dx, x.max()+dx, y.min()-dy, y.max()+dy], 300 | vmax=vmax, vmin=vmin, **kwargs) 301 | if xlabel is None: 302 | xlabel = plotting_plane[0] + ' / m' 303 | if ylabel is None: 304 | ylabel = plotting_plane[1] + ' / m' 305 | ax.set_xlabel(xlabel) 306 | ax.set_ylabel(ylabel) 307 | if colorbar: 308 | add_colorbar(im, **colorbar_kwargs) 309 | return im 310 | 311 | 312 | def level(p, grid, *, xnorm=None, power=False, cmap=None, vmax=3, vmin=-50, 313 | **kwargs): 314 | """Two-dimensional plot of level (dB) of sound field. 315 | 316 | Takes the same parameters as `sfs.plot2d.amplitude()`. 317 | 318 | Other Parameters 319 | ---------------- 320 | power : bool, optional 321 | See `sfs.util.db()`. 322 | 323 | """ 324 | # normalize before converting to dB! 325 | if xnorm is not None: 326 | p = _util.normalize(p, grid, xnorm) 327 | L = _util.db(p, power=power) 328 | return amplitude(L, grid=grid, xnorm=None, cmap=cmap, 329 | vmax=vmax, vmin=vmin, **kwargs) 330 | 331 | 332 | def particles(x, *, trim=None, ax=None, xlabel='x (m)', ylabel='y (m)', 333 | edgecolors=None, marker='.', s=15, **kwargs): 334 | """Plot particle positions as scatter plot. 335 | 336 | Parameters 337 | ---------- 338 | x : triple or pair of array_like 339 | x, y and optionally z components of particle positions. The z 340 | components are ignored. 341 | If the values are complex, the imaginary parts are ignored. 342 | 343 | Returns 344 | ------- 345 | Scatter 346 | See :func:`matplotlib.pyplot.scatter`. 347 | 348 | Other Parameters 349 | ---------------- 350 | trim : array of float, optional 351 | xmin, xmax, ymin, ymax limits for which the particles are plotted. 352 | ax : Axes, optional 353 | If given, the plot is created on *ax* instead of the current 354 | axis (see :func:`matplotlib.pyplot.gca`). 355 | xlabel, ylabel : str 356 | Overwrite default x/y labels. Use ``xlabel=''`` and 357 | ``ylabel=''`` to remove x/y labels. The labels can be changed 358 | afterwards with :func:`matplotlib.pyplot.xlabel` and 359 | :func:`matplotlib.pyplot.ylabel`. 360 | edgecolors, markr, s, **kwargs 361 | All further parameters are forwarded to 362 | :func:`matplotlib.pyplot.scatter`. 363 | 364 | """ 365 | XX, YY = [_np.real(c) for c in x[:2]] 366 | 367 | if trim is not None: 368 | xmin, xmax, ymin, ymax = trim 369 | 370 | idx = _np.where((XX > xmin) & (XX < xmax) & (YY > ymin) & (YY < ymax)) 371 | XX = XX[idx] 372 | YY = YY[idx] 373 | 374 | if ax is None: 375 | ax = _plt.gca() 376 | 377 | if xlabel: 378 | ax.set_xlabel(xlabel) 379 | if ylabel: 380 | ax.set_ylabel(ylabel) 381 | return ax.scatter(XX, YY, edgecolors=edgecolors, marker=marker, s=s, 382 | **kwargs) 383 | 384 | 385 | def vectors(v, grid, *, cmap='blacktransparent', headlength=3, 386 | headaxislength=2.5, ax=None, clim=None, **kwargs): 387 | """Plot a vector field in the xy plane. 388 | 389 | Parameters 390 | ---------- 391 | v : triple or pair of array_like 392 | x, y and optionally z components of vector field. The z 393 | components are ignored. 394 | If the values are complex, the imaginary parts are ignored. 395 | grid : triple or pair of array_like 396 | The grid that was used to calculate *v*, see 397 | `sfs.util.xyz_grid()`. Any z components are ignored. 398 | 399 | Returns 400 | ------- 401 | Quiver 402 | See :func:`matplotlib.pyplot.quiver`. 403 | 404 | Other Parameters 405 | ---------------- 406 | ax : Axes, optional 407 | If given, the plot is created on *ax* instead of the current 408 | axis (see :func:`matplotlib.pyplot.gca`). 409 | clim : pair of float, optional 410 | Limits for the scaling of arrow colors. 411 | See :func:`matplotlib.pyplot.quiver`. 412 | cmap, headlength, headaxislength, **kwargs 413 | All further parameters are forwarded to 414 | :func:`matplotlib.pyplot.quiver`. 415 | 416 | """ 417 | v = _util.as_xyz_components(v[:2]).apply(_np.real) 418 | X, Y = _util.as_xyz_components(grid[:2]) 419 | speed = _np.linalg.norm(v) 420 | with _np.errstate(invalid='ignore'): 421 | U, V = v.apply(_np.true_divide, speed) 422 | if ax is None: 423 | ax = _plt.gca() 424 | if clim is None: 425 | v_ref = 1 / (_default.rho0 * _default.c) # reference particle velocity 426 | clim = 0, 2 * v_ref 427 | return ax.quiver(X, Y, U, V, speed, cmap=cmap, pivot='mid', units='xy', 428 | angles='xy', headlength=headlength, 429 | headaxislength=headaxislength, clim=clim, **kwargs) 430 | 431 | 432 | def add_colorbar(im, *, aspect=20, pad=0.5, **kwargs): 433 | r"""Add a vertical color bar to a plot. 434 | 435 | Parameters 436 | ---------- 437 | im : ScalarMappable 438 | The output of `sfs.plot2d.amplitude()`, `sfs.plot2d.level()` or any 439 | other `matplotlib.cm.ScalarMappable`. 440 | aspect : float, optional 441 | Aspect ratio of the colorbar. Strictly speaking, since the 442 | colorbar is vertical, it's actually the inverse of the aspect 443 | ratio. 444 | pad : float, optional 445 | Space between image plot and colorbar, as a fraction of the 446 | width of the colorbar. 447 | 448 | .. note:: The *pad* argument of 449 | :meth:`matplotlib.figure.Figure.colorbar` has a 450 | slightly different meaning ("fraction of original 451 | axes")! 452 | \**kwargs 453 | All further arguments are forwarded to 454 | :meth:`matplotlib.figure.Figure.colorbar`. 455 | 456 | See Also 457 | -------- 458 | matplotlib.pyplot.colorbar 459 | 460 | """ 461 | ax = im.axes 462 | divider = _axes_grid1.make_axes_locatable(ax) 463 | width = _axes_grid1.axes_size.AxesY(ax, aspect=1/aspect) 464 | pad = _axes_grid1.axes_size.Fraction(pad, width) 465 | current_ax = _plt.gca() 466 | cax = divider.append_axes("right", size=width, pad=pad) 467 | _plt.sca(current_ax) 468 | return ax.figure.colorbar(im, cax=cax, orientation='vertical', **kwargs) 469 | -------------------------------------------------------------------------------- /sfs/plot3d.py: -------------------------------------------------------------------------------- 1 | """3D plots of sound fields etc.""" 2 | import matplotlib.pyplot as _plt 3 | 4 | 5 | def secondary_sources(x0, n0, a0=None, *, w=0.08, h=0.08): 6 | """Plot positions and normals of a 3D secondary source distribution.""" 7 | fig = _plt.figure(figsize=(15, 15)) 8 | ax = fig.add_subplot(111, projection='3d') 9 | q = ax.quiver(x0[:, 0], x0[:, 1], x0[:, 2], n0[:, 0], 10 | n0[:, 1], n0[:, 2], length=0.1) 11 | _plt.xlabel('x (m)') 12 | _plt.ylabel('y (m)') 13 | _plt.title('Secondary Sources') 14 | return q 15 | -------------------------------------------------------------------------------- /sfs/tapering.py: -------------------------------------------------------------------------------- 1 | """Weights (tapering) for the driving function. 2 | 3 | .. plot:: 4 | :context: reset 5 | 6 | import sfs 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | plt.rcParams['figure.figsize'] = 8, 3 # inch 10 | plt.rcParams['axes.grid'] = True 11 | 12 | active1 = np.zeros(101, dtype=bool) 13 | active1[5:-5] = True 14 | 15 | # The active part can wrap around from the end to the beginning: 16 | active2 = np.ones(101, dtype=bool) 17 | active2[30:-10] = False 18 | 19 | """ 20 | import numpy as _np 21 | 22 | 23 | def none(active): 24 | """No tapering window. 25 | 26 | Parameters 27 | ---------- 28 | active : array_like, dtype=bool 29 | A boolean array containing ``True`` for active loudspeakers. 30 | 31 | Returns 32 | ------- 33 | type(active) 34 | The input, unchanged. 35 | 36 | Examples 37 | -------- 38 | .. plot:: 39 | :context: close-figs 40 | 41 | plt.plot(sfs.tapering.none(active1)) 42 | plt.axis([-3, 103, -0.1, 1.1]) 43 | 44 | .. plot:: 45 | :context: close-figs 46 | 47 | plt.plot(sfs.tapering.none(active2)) 48 | plt.axis([-3, 103, -0.1, 1.1]) 49 | 50 | """ 51 | return active 52 | 53 | 54 | def tukey(active, *, alpha): 55 | """Tukey tapering window. 56 | 57 | This uses a function similar to :func:`scipy.signal.tukey`, except 58 | that the first and last value are not zero. 59 | 60 | Parameters 61 | ---------- 62 | active : array_like, dtype=bool 63 | A boolean array containing ``True`` for active loudspeakers. 64 | alpha : float 65 | Shape parameter of the Tukey window, see 66 | :func:`scipy.signal.tukey`. 67 | 68 | Returns 69 | ------- 70 | (len(active),) `numpy.ndarray` 71 | Tapering weights. 72 | 73 | Examples 74 | -------- 75 | .. plot:: 76 | :context: close-figs 77 | 78 | plt.plot(sfs.tapering.tukey(active1, alpha=0), label='alpha = 0') 79 | plt.plot(sfs.tapering.tukey(active1, alpha=0.25), label='alpha = 0.25') 80 | plt.plot(sfs.tapering.tukey(active1, alpha=0.5), label='alpha = 0.5') 81 | plt.plot(sfs.tapering.tukey(active1, alpha=0.75), label='alpha = 0.75') 82 | plt.plot(sfs.tapering.tukey(active1, alpha=1), label='alpha = 1') 83 | plt.axis([-3, 103, -0.1, 1.1]) 84 | plt.legend(loc='lower center') 85 | 86 | .. plot:: 87 | :context: close-figs 88 | 89 | plt.plot(sfs.tapering.tukey(active2, alpha=0.3)) 90 | plt.axis([-3, 103, -0.1, 1.1]) 91 | 92 | """ 93 | idx = _windowidx(active) 94 | alpha = _np.clip(alpha, 0, 1) 95 | if alpha == 0: 96 | return none(active) 97 | # design Tukey window 98 | x = _np.linspace(0, 1, len(idx) + 2) 99 | tukey = _np.ones_like(x) 100 | first_part = x < alpha / 2 101 | tukey[first_part] = 0.5 * ( 102 | 1 + _np.cos(2 * _np.pi / alpha * (x[first_part] - alpha / 2))) 103 | third_part = x >= (1 - alpha / 2) 104 | tukey[third_part] = 0.5 * ( 105 | 1 + _np.cos(2 * _np.pi / alpha * (x[third_part] - 1 + alpha / 2))) 106 | # fit window into tapering function 107 | result = _np.zeros(len(active)) 108 | result[idx] = tukey[1:-1] 109 | return result 110 | 111 | 112 | def kaiser(active, *, beta): 113 | """Kaiser tapering window. 114 | 115 | This uses :func:`numpy.kaiser`. 116 | 117 | Parameters 118 | ---------- 119 | active : array_like, dtype=bool 120 | A boolean array containing ``True`` for active loudspeakers. 121 | alpha : float 122 | Shape parameter of the Kaiser window, see :func:`numpy.kaiser`. 123 | 124 | Returns 125 | ------- 126 | (len(active),) `numpy.ndarray` 127 | Tapering weights. 128 | 129 | Examples 130 | -------- 131 | .. plot:: 132 | :context: close-figs 133 | 134 | plt.plot(sfs.tapering.kaiser(active1, beta=0), label='beta = 0') 135 | plt.plot(sfs.tapering.kaiser(active1, beta=2), label='beta = 2') 136 | plt.plot(sfs.tapering.kaiser(active1, beta=6), label='beta = 6') 137 | plt.plot(sfs.tapering.kaiser(active1, beta=8.6), label='beta = 8.6') 138 | plt.plot(sfs.tapering.kaiser(active1, beta=14), label='beta = 14') 139 | plt.axis([-3, 103, -0.1, 1.1]) 140 | plt.legend(loc='lower center') 141 | 142 | .. plot:: 143 | :context: close-figs 144 | 145 | plt.plot(sfs.tapering.kaiser(active2, beta=7)) 146 | plt.axis([-3, 103, -0.1, 1.1]) 147 | 148 | """ 149 | idx = _windowidx(active) 150 | window = _np.zeros(len(active)) 151 | window[idx] = _np.kaiser(len(idx), beta) 152 | return window 153 | 154 | 155 | def _windowidx(active): 156 | """Return list of connected indices for window function. 157 | 158 | Note: Gaps within the active part are not allowed. 159 | 160 | """ 161 | # find index where active loudspeakers begin (works for connected contours) 162 | if (active[0] and not active[-1]) or _np.all(active): 163 | first_idx = 0 164 | else: 165 | first_idx = _np.argmax(_np.diff(active.astype(int))) + 1 166 | # shift generic index vector to get a connected list of indices 167 | idx = _np.roll(_np.arange(len(active)), -first_idx) 168 | # remove indices of inactive secondary sources 169 | return idx[:_np.count_nonzero(active)] 170 | -------------------------------------------------------------------------------- /sfs/td/__init__.py: -------------------------------------------------------------------------------- 1 | """Submodules for broadband sound fields. 2 | 3 | .. autosummary:: 4 | :toctree: 5 | 6 | source 7 | 8 | wfs 9 | nfchoa 10 | 11 | """ 12 | import numpy as _np 13 | 14 | from . import source 15 | from .. import array as _array 16 | from .. import util as _util 17 | 18 | 19 | def synthesize(signals, weights, ssd, secondary_source_function, **kwargs): 20 | """Compute sound field for an array of secondary sources. 21 | 22 | Parameters 23 | ---------- 24 | signals : (N, C) array_like + float 25 | Driving signals consisting of audio data (C channels) and a 26 | sampling rate (in Hertz). 27 | A `DelayedSignal` object can also be used. 28 | weights : (C,) array_like 29 | Additional weights applied during integration, e.g. source 30 | selection and tapering. 31 | ssd : sequence of between 1 and 3 array_like objects 32 | Positions (shape ``(C, 3)``), normal vectors (shape ``(C, 3)``) 33 | and weights (shape ``(C,)``) of secondary sources. 34 | A `SecondarySourceDistribution` can also be used. 35 | secondary_source_function : callable 36 | A function that generates the sound field of a secondary source. 37 | This signature is expected:: 38 | 39 | secondary_source_function( 40 | position, normal_vector, **kwargs) -> numpy.ndarray 41 | 42 | **kwargs 43 | All keyword arguments are forwarded to *secondary_source_function*. 44 | This is typically used to pass the *observation_time* and *grid* 45 | arguments. 46 | 47 | Returns 48 | ------- 49 | numpy.ndarray 50 | Sound pressure at grid positions. 51 | 52 | """ 53 | ssd = _array.as_secondary_source_distribution(ssd) 54 | data, samplerate, signal_offset = _util.as_delayed_signal(signals) 55 | weights = _util.asarray_1d(weights) 56 | channels = data.T 57 | if not (len(ssd.x) == len(ssd.n) == len(ssd.a) == len(channels) == 58 | len(weights)): 59 | raise ValueError("Length mismatch") 60 | p = 0 61 | for x, n, a, channel, weight in zip(ssd.x, ssd.n, ssd.a, 62 | channels, weights): 63 | if weight != 0: 64 | signal = channel, samplerate, signal_offset 65 | p += a * weight * secondary_source_function(x, n, signal, **kwargs) 66 | return p 67 | 68 | 69 | def apply_delays(signal, delays): 70 | """Apply delays for every channel. 71 | 72 | Parameters 73 | ---------- 74 | signal : (N,) array_like + float 75 | Excitation signal consisting of (mono) audio data and a sampling 76 | rate (in Hertz). A `DelayedSignal` object can also be used. 77 | delays : (C,) array_like 78 | Delay in seconds for each channel (C), negative values allowed. 79 | 80 | Returns 81 | ------- 82 | `DelayedSignal` 83 | A tuple containing the delayed signals (in a `numpy.ndarray` 84 | with shape ``(N, C)``), followed by the sampling rate (in Hertz) 85 | and a (possibly negative) time offset (in seconds). 86 | 87 | """ 88 | data, samplerate, initial_offset = _util.as_delayed_signal(signal) 89 | data = _util.asarray_1d(data) 90 | delays = _util.asarray_1d(delays) 91 | delays += initial_offset 92 | 93 | delays_samples = _np.rint(samplerate * delays).astype(int) 94 | offset_samples = delays_samples.min() 95 | delays_samples -= offset_samples 96 | out = _np.zeros((delays_samples.max() + len(data), len(delays_samples))) 97 | for column, row in enumerate(delays_samples): 98 | out[row:row + len(data), column] = data 99 | return _util.DelayedSignal(out, samplerate, offset_samples / samplerate) 100 | 101 | 102 | def secondary_source_point(c): 103 | """Create a point source for use in `sfs.td.synthesize()`.""" 104 | 105 | def secondary_source(position, _, signal, observation_time, grid): 106 | return source.point(position, signal, observation_time, grid, c=c) 107 | 108 | return secondary_source 109 | 110 | 111 | from . import nfchoa 112 | from . import wfs 113 | -------------------------------------------------------------------------------- /sfs/td/nfchoa.py: -------------------------------------------------------------------------------- 1 | """Compute NFC-HOA driving functions. 2 | 3 | .. include:: math-definitions.rst 4 | 5 | .. plot:: 6 | :context: reset 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import sfs 11 | from scipy.signal import unit_impulse 12 | 13 | # Plane wave 14 | npw = sfs.util.direction_vector(np.radians(-45)) 15 | 16 | # Point source 17 | xs = -1.5, 1.5, 0 18 | rs = np.linalg.norm(xs) # distance from origin 19 | ts = rs / sfs.default.c # time-of-arrival at origin 20 | 21 | # Impulsive excitation 22 | fs = 44100 23 | signal = unit_impulse(512), fs 24 | 25 | # Circular loudspeaker array 26 | N = 32 # number of loudspeakers 27 | R = 1.5 # radius 28 | array = sfs.array.circular(N, R) 29 | 30 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) 31 | 32 | def plot(d, selection, secondary_source, t=0): 33 | p = sfs.td.synthesize(d, selection, array, secondary_source, grid=grid, 34 | observation_time=t) 35 | sfs.plot2d.level(p, grid) 36 | sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) 37 | 38 | """ 39 | import numpy as _np 40 | import scipy.signal as _sig 41 | from scipy.special import eval_legendre as _legendre 42 | 43 | from . import secondary_source_point as _secondary_source_point 44 | from .. import default as _default 45 | from .. import util as _util 46 | 47 | 48 | def matchedz_zpk(s_zeros, s_poles, s_gain, fs): 49 | """Matched-z transform of poles and zeros. 50 | 51 | Parameters 52 | ---------- 53 | s_zeros : array_like 54 | Zeros in the Laplace domain. 55 | s_poles : array_like 56 | Poles in the Laplace domain. 57 | s_gain : float 58 | System gain in the Laplace domain. 59 | fs : int 60 | Sampling frequency in Hertz. 61 | 62 | Returns 63 | ------- 64 | z_zeros : numpy.ndarray 65 | Zeros in the z-domain. 66 | z_poles : numpy.ndarray 67 | Poles in the z-domain. 68 | z_gain : float 69 | System gain in the z-domain. 70 | 71 | See Also 72 | -------- 73 | :func:`scipy.signal.bilinear_zpk` 74 | 75 | """ 76 | z_zeros = _np.exp(s_zeros / fs) 77 | z_poles = _np.exp(s_poles / fs) 78 | omega = 1j * _np.pi * fs 79 | s_gain *= _np.prod((omega - s_zeros) / (omega - s_poles) 80 | * (-1 - z_poles) / (-1 - z_zeros)) 81 | return z_zeros, z_poles, _np.real(s_gain) 82 | 83 | 84 | def plane_25d(x0, r0, npw, fs, max_order=None, c=None, s2z=matchedz_zpk): 85 | r"""Virtual plane wave by 2.5-dimensional NFC-HOA. 86 | 87 | .. math:: 88 | 89 | D(\phi_0, s) = 90 | 2\e{\frac{s}{c}r_0} 91 | \sum_{m=-M}^{M} 92 | (-1)^m 93 | \Big(\frac{s}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu 94 | \prod_{l=1}^{\nu} 95 | \frac{s^2}{(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} 96 | \e{\i m(\phi_0 - \phi_\text{pw})} 97 | 98 | The driving function is represented in the Laplace domain, 99 | from which the recursive filters are designed. 100 | :math:`\sigma_l + \i\omega_l` denotes the complex roots of 101 | the reverse Bessel polynomial. 102 | The number of second-order sections is 103 | :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, 104 | whereas the number of first-order section :math:`\mu` is either 0 or 1 105 | for even and odd :math:`|m|`, respectively. 106 | 107 | Parameters 108 | ---------- 109 | x0 : (N, 3) array_like 110 | Sequence of secondary source positions. 111 | r0 : float 112 | Radius of the circular secondary source distribution. 113 | npw : (3,) array_like 114 | Unit vector (propagation direction) of plane wave. 115 | fs : int 116 | Sampling frequency in Hertz. 117 | max_order : int, optional 118 | Ambisonics order. 119 | c : float, optional 120 | Speed of sound in m/s. 121 | s2z : callable, optional 122 | Function transforming s-domain poles and zeros into z-domain, 123 | e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. 124 | 125 | Returns 126 | ------- 127 | delay : float 128 | Overall delay in seconds. 129 | weight : float 130 | Overall weight. 131 | sos : list of numpy.ndarray 132 | Second-order section filters :func:`scipy.signal.sosfilt`. 133 | phaseshift : (N,) numpy.ndarray 134 | Phase shift in radians. 135 | selection : (N,) numpy.ndarray 136 | Boolean array containing only ``True`` indicating that 137 | all secondary source are "active" for NFC-HOA. 138 | secondary_source_function : callable 139 | A function that can be used to create the sound field of a 140 | single secondary source. See `sfs.td.synthesize()`. 141 | 142 | Examples 143 | -------- 144 | .. plot:: 145 | :context: close-figs 146 | 147 | delay, weight, sos, phaseshift, selection, secondary_source = \ 148 | sfs.td.nfchoa.plane_25d(array.x, R, npw, fs) 149 | d = sfs.td.nfchoa.driving_signals_25d( 150 | delay, weight, sos, phaseshift, signal) 151 | plot(d, selection, secondary_source) 152 | 153 | """ 154 | if max_order is None: 155 | max_order = _util.max_order_circular_harmonics(len(x0)) 156 | if c is None: 157 | c = _default.c 158 | 159 | x0 = _util.asarray_of_rows(x0) 160 | npw = _util.asarray_1d(npw) 161 | phi0, _, _ = _util.cart2sph(*x0.T) 162 | phipw, _, _ = _util.cart2sph(*npw) 163 | phaseshift = phi0 - phipw + _np.pi 164 | 165 | delay = -r0 / c 166 | weight = 2 167 | sos = [] 168 | for m in range(max_order + 1): 169 | _, p, _ = _sig.besselap(m, norm='delay') 170 | s_zeros = _np.zeros(m) 171 | s_poles = c / r0 * p 172 | s_gain = 1 173 | z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) 174 | sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) 175 | selection = _util.source_selection_all(len(x0)) 176 | return (delay, weight, sos, phaseshift, selection, 177 | _secondary_source_point(c)) 178 | 179 | 180 | def point_25d(x0, r0, xs, fs, max_order=None, c=None, s2z=matchedz_zpk): 181 | r"""Virtual Point source by 2.5-dimensional NFC-HOA. 182 | 183 | .. math:: 184 | 185 | D(\phi_0, s) = 186 | \frac{1}{2\pi r_\text{s}} 187 | \e{\frac{s}{c}(r_0-r_\text{s})} 188 | \sum_{m=-M}^{M} 189 | \Big(\frac{s-\frac{c}{r_\text{s}}\sigma_0}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu 190 | \prod_{l=1}^{\nu} 191 | \frac{(s-\frac{c}{r_\text{s}}\sigma_l)^2-(\frac{c}{r_\text{s}}\omega_l)^2} 192 | {(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} 193 | \e{\i m(\phi_0 - \phi_\text{s})} 194 | 195 | The driving function is represented in the Laplace domain, 196 | from which the recursive filters are designed. 197 | :math:`\sigma_l + \i\omega_l` denotes the complex roots of 198 | the reverse Bessel polynomial. 199 | The number of second-order sections is 200 | :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, 201 | whereas the number of first-order section :math:`\mu` is either 0 or 1 202 | for even and odd :math:`|m|`, respectively. 203 | 204 | Parameters 205 | ---------- 206 | x0 : (N, 3) array_like 207 | Sequence of secondary source positions. 208 | r0 : float 209 | Radius of the circular secondary source distribution. 210 | xs : (3,) array_like 211 | Virtual source position. 212 | fs : int 213 | Sampling frequency in Hertz. 214 | max_order : int, optional 215 | Ambisonics order. 216 | c : float, optional 217 | Speed of sound in m/s. 218 | s2z : callable, optional 219 | Function transforming s-domain poles and zeros into z-domain, 220 | e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. 221 | 222 | Returns 223 | ------- 224 | delay : float 225 | Overall delay in seconds. 226 | weight : float 227 | Overall weight. 228 | sos : list of numpy.ndarray 229 | Second-order section filters :func:`scipy.signal.sosfilt`. 230 | phaseshift : (N,) numpy.ndarray 231 | Phase shift in radians. 232 | selection : (N,) numpy.ndarray 233 | Boolean array containing only ``True`` indicating that 234 | all secondary source are "active" for NFC-HOA. 235 | secondary_source_function : callable 236 | A function that can be used to create the sound field of a 237 | single secondary source. See `sfs.td.synthesize()`. 238 | 239 | Examples 240 | -------- 241 | .. plot:: 242 | :context: close-figs 243 | 244 | delay, weight, sos, phaseshift, selection, secondary_source = \ 245 | sfs.td.nfchoa.point_25d(array.x, R, xs, fs) 246 | d = sfs.td.nfchoa.driving_signals_25d( 247 | delay, weight, sos, phaseshift, signal) 248 | plot(d, selection, secondary_source, t=ts) 249 | 250 | """ 251 | if max_order is None: 252 | max_order = _util.max_order_circular_harmonics(len(x0)) 253 | if c is None: 254 | c = _default.c 255 | 256 | x0 = _util.asarray_of_rows(x0) 257 | xs = _util.asarray_1d(xs) 258 | phi0, _, _ = _util.cart2sph(*x0.T) 259 | phis, _, rs = _util.cart2sph(*xs) 260 | phaseshift = phi0 - phis 261 | 262 | delay = (rs - r0) / c 263 | weight = 1 / 2 / _np.pi / rs 264 | sos = [] 265 | for m in range(max_order + 1): 266 | _, p, _ = _sig.besselap(m, norm='delay') 267 | s_zeros = c / rs * p 268 | s_poles = c / r0 * p 269 | s_gain = 1 270 | z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) 271 | sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) 272 | selection = _util.source_selection_all(len(x0)) 273 | return (delay, weight, sos, phaseshift, selection, 274 | _secondary_source_point(c)) 275 | 276 | 277 | def plane_3d(x0, r0, npw, fs, max_order=None, c=None, s2z=matchedz_zpk): 278 | r"""Virtual plane wave by 3-dimensional NFC-HOA. 279 | 280 | .. math:: 281 | 282 | D(\phi_0, s) = 283 | \frac{\e{\frac{s}{c}r_0}}{r_0} 284 | \sum_{n=0}^{N} 285 | (-1)^n (2n+1) P_{n}(\cos\Theta) 286 | \Big(\frac{s}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu 287 | \prod_{l=1}^{\nu} 288 | \frac{s^2}{(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} 289 | 290 | The driving function is represented in the Laplace domain, 291 | from which the recursive filters are designed. 292 | :math:`\sigma_l + \i\omega_l` denotes the complex roots of 293 | the reverse Bessel polynomial. 294 | The number of second-order sections is 295 | :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, 296 | whereas the number of first-order section :math:`\mu` is either 0 or 1 297 | for even and odd :math:`|m|`, respectively. 298 | :math:`P_{n}(\cdot)` denotes the Legendre polynomial of degree :math:`n`, 299 | and :math:`\Theta` the angle between :math:`(\theta, \phi)` 300 | and :math:`(\theta_\text{pw}, \phi_\text{pw})`. 301 | 302 | Parameters 303 | ---------- 304 | x0 : (N, 3) array_like 305 | Sequence of secondary source positions. 306 | r0 : float 307 | Radius of the spherical secondary source distribution. 308 | npw : (3,) array_like 309 | Unit vector (propagation direction) of plane wave. 310 | fs : int 311 | Sampling frequency in Hertz. 312 | max_order : int, optional 313 | Ambisonics order. 314 | c : float, optional 315 | Speed of sound in m/s. 316 | s2z : callable, optional 317 | Function transforming s-domain poles and zeros into z-domain, 318 | e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. 319 | 320 | Returns 321 | ------- 322 | delay : float 323 | Overall delay in seconds. 324 | weight : float 325 | Overall weight. 326 | sos : list of numpy.ndarray 327 | Second-order section filters :func:`scipy.signal.sosfilt`. 328 | phaseshift : (N,) numpy.ndarray 329 | Phase shift in radians. 330 | selection : (N,) numpy.ndarray 331 | Boolean array containing only ``True`` indicating that 332 | all secondary source are "active" for NFC-HOA. 333 | secondary_source_function : callable 334 | A function that can be used to create the sound field of a 335 | single secondary source. See `sfs.td.synthesize()`. 336 | 337 | """ 338 | if max_order is None: 339 | max_order = _util.max_order_spherical_harmonics(len(x0)) 340 | if c is None: 341 | c = _default.c 342 | 343 | x0 = _util.asarray_of_rows(x0) 344 | npw = _util.asarray_1d(npw) 345 | phi0, theta0, _ = _util.cart2sph(*x0.T) 346 | phipw, thetapw, _ = _util.cart2sph(*npw) 347 | phaseshift = _np.arccos(_np.dot(x0 / r0, -npw)) 348 | 349 | delay = -r0 / c 350 | weight = 4 * _np.pi / r0 351 | sos = [] 352 | for m in range(max_order + 1): 353 | _, p, _ = _sig.besselap(m, norm='delay') 354 | s_zeros = _np.zeros(m) 355 | s_poles = c / r0 * p 356 | s_gain = 1 357 | z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) 358 | sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) 359 | selection = _util.source_selection_all(len(x0)) 360 | return (delay, weight, sos, phaseshift, selection, 361 | _secondary_source_point(c)) 362 | 363 | 364 | def point_3d(x0, r0, xs, fs, max_order=None, c=None, s2z=matchedz_zpk): 365 | r"""Virtual point source by 3-dimensional NFC-HOA. 366 | 367 | .. math:: 368 | 369 | D(\phi_0, s) = 370 | \frac{\e{\frac{s}{c}(r_0-r_\text{s})}}{4 \pi r_0 r_\text{s}} 371 | \sum_{n=0}^{N} 372 | (2n+1) P_{n}(\cos\Theta) 373 | \Big(\frac{s-\frac{c}{r_\text{s}}\sigma_0}{s-\frac{c}{r_0}\sigma_0}\Big)^\mu 374 | \prod_{l=1}^{\nu} 375 | \frac{(s-\frac{c}{r_\text{s}}\sigma_l)^2-(\frac{c}{r_\text{s}}\omega_l)^2} 376 | {(s-\frac{c}{r_0}\sigma_l)^2+(\frac{c}{r_0}\omega_l)^2} 377 | 378 | The driving function is represented in the Laplace domain, 379 | from which the recursive filters are designed. 380 | :math:`\sigma_l + \i\omega_l` denotes the complex roots of 381 | the reverse Bessel polynomial. 382 | The number of second-order sections is 383 | :math:`\nu = \big\lfloor\tfrac{|m|}{2}\big\rfloor`, 384 | whereas the number of first-order section :math:`\mu` is either 0 or 1 385 | for even and odd :math:`|m|`, respectively. 386 | :math:`P_{n}(\cdot)` denotes the Legendre polynomial of degree :math:`n`, 387 | and :math:`\Theta` the angle between :math:`(\theta, \phi)` 388 | and :math:`(\theta_\text{s}, \phi_\text{s})`. 389 | 390 | Parameters 391 | ---------- 392 | x0 : (N, 3) array_like 393 | Sequence of secondary source positions. 394 | r0 : float 395 | Radius of the spherial secondary source distribution. 396 | xs : (3,) array_like 397 | Virtual source position. 398 | fs : int 399 | Sampling frequency in Hertz. 400 | max_order : int, optional 401 | Ambisonics order. 402 | c : float, optional 403 | Speed of sound in m/s. 404 | s2z : callable, optional 405 | Function transforming s-domain poles and zeros into z-domain, 406 | e.g. :func:`matchedz_zpk`, :func:`scipy.signal.bilinear_zpk`. 407 | 408 | Returns 409 | ------- 410 | delay : float 411 | Overall delay in seconds. 412 | weight : float 413 | Overall weight. 414 | sos : list of numpy.ndarray 415 | Second-order section filters :func:`scipy.signal.sosfilt`. 416 | phaseshift : (N,) numpy.ndarray 417 | Phase shift in radians. 418 | selection : (N,) numpy.ndarray 419 | Boolean array containing only ``True`` indicating that 420 | all secondary source are "active" for NFC-HOA. 421 | secondary_source_function : callable 422 | A function that can be used to create the sound field of a 423 | single secondary source. See `sfs.td.synthesize()`. 424 | 425 | """ 426 | if max_order is None: 427 | max_order = _util.max_order_spherical_harmonics(len(x0)) 428 | if c is None: 429 | c = _default.c 430 | 431 | x0 = _util.asarray_of_rows(x0) 432 | xs = _util.asarray_1d(xs) 433 | phi0, theta0, _ = _util.cart2sph(*x0.T) 434 | phis, thetas, rs = _util.cart2sph(*xs) 435 | phaseshift = _np.arccos(_np.dot(x0 / r0, xs / rs)) 436 | 437 | delay = (rs - r0) / c 438 | weight = 1 / r0 / rs 439 | sos = [] 440 | for m in range(max_order + 1): 441 | _, p, _ = _sig.besselap(m, norm='delay') 442 | s_zeros = c / rs * p 443 | s_poles = c / r0 * p 444 | s_gain = 1 445 | z_zeros, z_poles, z_gain = s2z(s_zeros, s_poles, s_gain, fs) 446 | sos.append(_sig.zpk2sos(z_zeros, z_poles, z_gain, pairing='nearest')) 447 | selection = _util.source_selection_all(len(x0)) 448 | return (delay, weight, sos, phaseshift, selection, 449 | _secondary_source_point(c)) 450 | 451 | 452 | def driving_signals_25d(delay, weight, sos, phaseshift, signal): 453 | """Get 2.5-dimensional NFC-HOA driving signals. 454 | 455 | Parameters 456 | ---------- 457 | delay : float 458 | Overall delay in seconds. 459 | weight : float 460 | Overall weight. 461 | sos : list of array_like 462 | Second-order section filters :func:`scipy.signal.sosfilt`. 463 | phaseshift : (N,) array_like 464 | Phase shift in radians. 465 | signal : (L,) array_like + float 466 | Excitation signal consisting of (mono) audio data and a sampling 467 | rate (in Hertz). A `DelayedSignal` object can also be used. 468 | 469 | Returns 470 | ------- 471 | `DelayedSignal` 472 | A tuple containing the delayed signals (in a `numpy.ndarray` 473 | with shape ``(L, N)``), followed by the sampling rate (in Hertz) 474 | and a (possibly negative) time offset (in seconds). 475 | 476 | """ 477 | data, fs, t_offset = _util.as_delayed_signal(signal) 478 | N = len(phaseshift) 479 | out = _np.tile(_np.expand_dims(_sig.sosfilt(sos[0], data), 1), (1, N)) 480 | for m in range(1, len(sos)): 481 | modal_response = _sig.sosfilt(sos[m], data)[:, _np.newaxis] 482 | out += modal_response * _np.cos(m * phaseshift) 483 | return _util.DelayedSignal(2 * weight * out, fs, t_offset + delay) 484 | 485 | 486 | def driving_signals_3d(delay, weight, sos, phaseshift, signal): 487 | """Get 3-dimensional NFC-HOA driving signals. 488 | 489 | Parameters 490 | ---------- 491 | delay : float 492 | Overall delay in seconds. 493 | weight : float 494 | Overall weight. 495 | sos : list of array_like 496 | Second-order section filters :func:`scipy.signal.sosfilt`. 497 | phaseshift : (N,) array_like 498 | Phase shift in radians. 499 | signal : (L,) array_like + float 500 | Excitation signal consisting of (mono) audio data and a sampling 501 | rate (in Hertz). A `DelayedSignal` object can also be used. 502 | 503 | Returns 504 | ------- 505 | `DelayedSignal` 506 | A tuple containing the delayed signals (in a `numpy.ndarray` 507 | with shape ``(L, N)``), followed by the sampling rate (in Hertz) 508 | and a (possibly negative) time offset (in seconds). 509 | 510 | """ 511 | data, fs, t_offset = _util.as_delayed_signal(signal) 512 | N = len(phaseshift) 513 | out = _np.tile(_np.expand_dims(_sig.sosfilt(sos[0], data), 1), (1, N)) 514 | for m in range(1, len(sos)): 515 | modal_response = _sig.sosfilt(sos[m], data)[:, _np.newaxis] 516 | out += (2 * m + 1) * modal_response * _legendre(m, _np.cos(phaseshift)) 517 | return _util.DelayedSignal(weight / 4 / _np.pi * out, fs, t_offset + delay) 518 | -------------------------------------------------------------------------------- /sfs/td/source.py: -------------------------------------------------------------------------------- 1 | """Compute the sound field generated by a sound source. 2 | 3 | The Green's function describes the spatial sound propagation over time. 4 | 5 | .. include:: math-definitions.rst 6 | 7 | .. plot:: 8 | :context: reset 9 | 10 | import matplotlib.pyplot as plt 11 | import numpy as np 12 | from scipy.signal import unit_impulse 13 | import sfs 14 | 15 | xs = 1.5, 1, 0 # source position 16 | rs = np.linalg.norm(xs) # distance from origin 17 | ts = rs / sfs.default.c # time-of-arrival at origin 18 | 19 | # Impulsive excitation 20 | fs = 44100 21 | signal = unit_impulse(512), fs 22 | 23 | grid = sfs.util.xyz_grid([-2, 3], [-1, 2], 0, spacing=0.02) 24 | 25 | """ 26 | import numpy as _np 27 | 28 | from .. import default as _default 29 | from .. import util as _util 30 | 31 | 32 | def point(xs, signal, observation_time, grid, c=None): 33 | r"""Source model for a point source: 3D Green's function. 34 | 35 | Calculates the scalar sound pressure field for a given point in 36 | time, evoked by source excitation signal. 37 | 38 | Parameters 39 | ---------- 40 | xs : (3,) array_like 41 | Position of source in cartesian coordinates. 42 | signal : (N,) array_like + float 43 | Excitation signal consisting of (mono) audio data and a sampling 44 | rate (in Hertz). A `DelayedSignal` object can also be used. 45 | observation_time : float 46 | Observed point in time. 47 | grid : triple of array_like 48 | The grid that is used for the sound field calculations. 49 | See `sfs.util.xyz_grid()`. 50 | c : float, optional 51 | Speed of sound. 52 | 53 | Returns 54 | ------- 55 | numpy.ndarray 56 | Scalar sound pressure field, evaluated at positions given by 57 | *grid*. 58 | 59 | Notes 60 | ----- 61 | .. math:: 62 | 63 | g(x-x_s,t) = \frac{1}{4 \pi |x - x_s|} \dirac{t - \frac{|x - 64 | x_s|}{c}} 65 | 66 | Examples 67 | -------- 68 | .. plot:: 69 | :context: close-figs 70 | 71 | p = sfs.td.source.point(xs, signal, ts, grid) 72 | sfs.plot2d.level(p, grid) 73 | 74 | """ 75 | xs = _util.asarray_1d(xs) 76 | data, samplerate, signal_offset = _util.as_delayed_signal(signal) 77 | data = _util.asarray_1d(data) 78 | grid = _util.as_xyz_components(grid) 79 | if c is None: 80 | c = _default.c 81 | r = _np.linalg.norm(grid - xs) 82 | # If r is +-0, the sound pressure is +-infinity 83 | with _np.errstate(divide='ignore'): 84 | weights = 1 / (4 * _np.pi * r) 85 | delays = r / c 86 | base_time = observation_time - signal_offset 87 | points_at_time = _np.interp(base_time - delays, 88 | _np.arange(len(data)) / samplerate, 89 | data, left=0, right=0) 90 | # weights can be +-infinity 91 | with _np.errstate(invalid='ignore'): 92 | return weights * points_at_time 93 | 94 | 95 | def point_image_sources(x0, signal, observation_time, grid, L, max_order, 96 | coeffs=None, c=None): 97 | """Point source in a rectangular room using the mirror image source model. 98 | 99 | Parameters 100 | ---------- 101 | x0 : (3,) array_like 102 | Position of source in cartesian coordinates. 103 | signal : (N,) array_like + float 104 | Excitation signal consisting of (mono) audio data and a sampling 105 | rate (in Hertz). A `DelayedSignal` object can also be used. 106 | observation_time : float 107 | Observed point in time. 108 | grid : triple of array_like 109 | The grid that is used for the sound field calculations. 110 | See `sfs.util.xyz_grid()`. 111 | L : (3,) array_like 112 | Dimensions of the rectangular room. 113 | max_order : int 114 | Maximum number of reflections for each image source. 115 | coeffs : (6,) array_like, optional 116 | Reflection coeffecients of the walls. 117 | If not given, the reflection coefficients are set to one. 118 | c : float, optional 119 | Speed of sound. 120 | 121 | Returns 122 | ------- 123 | numpy.ndarray 124 | Scalar sound pressure field, evaluated at positions given by 125 | *grid*. 126 | 127 | Examples 128 | -------- 129 | .. plot:: 130 | :context: close-figs 131 | 132 | room = 5, 3, 1.5 # room dimensions 133 | order = 2 # image source order 134 | coeffs = .8, .8, .6, .6, .7, .7 # wall reflection coefficients 135 | grid = sfs.util.xyz_grid([0, room[0]], [0, room[1]], 0, spacing=0.01) 136 | p = sfs.td.source.point_image_sources( 137 | xs, signal, 1.5 * ts, grid, room, order, coeffs) 138 | sfs.plot2d.level(p, grid) 139 | 140 | """ 141 | if coeffs is None: 142 | coeffs = _np.ones(6) 143 | 144 | positions, order = _util.image_sources_for_box(x0, L, max_order) 145 | source_strengths = _np.prod(coeffs**order, axis=1) 146 | 147 | p = 0 148 | for position, strength in zip(positions, source_strengths): 149 | if strength != 0: 150 | p += strength * point(position, signal, observation_time, grid, c) 151 | 152 | return p 153 | -------------------------------------------------------------------------------- /sfs/td/wfs.py: -------------------------------------------------------------------------------- 1 | """Compute WFS driving functions. 2 | 3 | .. include:: math-definitions.rst 4 | 5 | .. plot:: 6 | :context: reset 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import sfs 11 | from scipy.signal import unit_impulse 12 | 13 | # Plane wave 14 | npw = sfs.util.direction_vector(np.radians(-45)) 15 | 16 | # Point source 17 | xs = -1.5, 1.5, 0 18 | rs = np.linalg.norm(xs) # distance from origin 19 | ts = rs / sfs.default.c # time-of-arrival at origin 20 | 21 | # Focused source 22 | xf = -0.5, 0.5, 0 23 | nf = sfs.util.direction_vector(np.radians(-45)) # normal vector 24 | rf = np.linalg.norm(xf) # distance from origin 25 | tf = rf / sfs.default.c # time-of-arrival at origin 26 | 27 | # Impulsive excitation 28 | fs = 44100 29 | signal = unit_impulse(512), fs 30 | 31 | # Circular loudspeaker array 32 | N = 32 # number of loudspeakers 33 | R = 1.5 # radius 34 | array = sfs.array.circular(N, R) 35 | 36 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) 37 | 38 | def plot(d, selection, secondary_source, t=0): 39 | p = sfs.td.synthesize(d, selection, array, secondary_source, grid=grid, 40 | observation_time=t) 41 | sfs.plot2d.level(p, grid) 42 | sfs.plot2d.loudspeakers(array.x, array.n, 43 | selection * array.a, size=0.15) 44 | 45 | """ 46 | import numpy as _np 47 | from numpy.core.umath_tests import inner1d as _inner1d 48 | 49 | from . import apply_delays as _apply_delays 50 | from . import secondary_source_point as _secondary_source_point 51 | from .. import default as _default 52 | from .. import util as _util 53 | 54 | 55 | def plane_25d(x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None): 56 | r"""Plane wave model by 2.5-dimensional WFS. 57 | 58 | Parameters 59 | ---------- 60 | x0 : (N, 3) array_like 61 | Sequence of secondary source positions. 62 | n0 : (N, 3) array_like 63 | Sequence of secondary source orientations. 64 | n : (3,) array_like, optional 65 | Normal vector (propagation direction) of synthesized plane wave. 66 | xref : (3,) array_like, optional 67 | Reference position 68 | c : float, optional 69 | Speed of sound 70 | 71 | Returns 72 | ------- 73 | delays : (N,) numpy.ndarray 74 | Delays of secondary sources in seconds. 75 | weights : (N,) numpy.ndarray 76 | Weights of secondary sources. 77 | selection : (N,) numpy.ndarray 78 | Boolean array containing ``True`` or ``False`` depending on 79 | whether the corresponding secondary source is "active" or not. 80 | secondary_source_function : callable 81 | A function that can be used to create the sound field of a 82 | single secondary source. See `sfs.td.synthesize()`. 83 | 84 | Notes 85 | ----- 86 | 2.5D correction factor 87 | 88 | .. math:: 89 | 90 | g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} 91 | 92 | d using a plane wave as source model 93 | 94 | .. math:: 95 | 96 | d_{2.5D}(x_0,t) = 97 | 2 g_0 \scalarprod{n}{n_0} 98 | \dirac{t - \frac{1}{c} \scalarprod{n}{x_0}} \ast_t h(t) 99 | 100 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 101 | 102 | See :sfs:`d_wfs/#equation-td-wfs-plane-25d` 103 | 104 | Examples 105 | -------- 106 | .. plot:: 107 | :context: close-figs 108 | 109 | delays, weights, selection, secondary_source = \ 110 | sfs.td.wfs.plane_25d(array.x, array.n, npw) 111 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 112 | plot(d, selection, secondary_source) 113 | 114 | """ 115 | if c is None: 116 | c = _default.c 117 | x0 = _util.asarray_of_rows(x0) 118 | n0 = _util.asarray_of_rows(n0) 119 | n = _util.normalize_vector(n) 120 | xref = _util.asarray_1d(xref) 121 | g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) 122 | delays = _inner1d(n, x0) / c 123 | weights = 2 * g0 * _inner1d(n, n0) 124 | selection = _util.source_selection_plane(n0, n) 125 | return delays, weights, selection, _secondary_source_point(c) 126 | 127 | 128 | def point_25d(x0, n0, xs, xref=[0, 0, 0], c=None): 129 | r"""Driving function for 2.5-dimensional WFS of a virtual point source. 130 | 131 | .. versionchanged:: 0.6.1 132 | see notes, old handling of `point_25d()` is now `point_25d_legacy()` 133 | 134 | Parameters 135 | ---------- 136 | x0 : (N, 3) array_like 137 | Sequence of secondary source positions. 138 | n0 : (N, 3) array_like 139 | Sequence of secondary source orientations. 140 | xs : (3,) array_like 141 | Virtual source position. 142 | xref : (N, 3) array_like or (3,) array_like 143 | Contour xref(x0) for amplitude correct synthesis, reference point xref. 144 | c : float, optional 145 | Speed of sound 146 | 147 | Returns 148 | ------- 149 | delays : (N,) numpy.ndarray 150 | Delays of secondary sources in seconds. 151 | weights: (N,) numpy.ndarray 152 | Weights of secondary sources. 153 | selection : (N,) numpy.ndarray 154 | Boolean array containing ``True`` or ``False`` depending on 155 | whether the corresponding secondary source is "active" or not. 156 | secondary_source_function : callable 157 | A function that can be used to create the sound field of a 158 | single secondary source. See `sfs.td.synthesize()`. 159 | 160 | Notes 161 | ----- 162 | Eq. (2.138) in :cite:`Schultz2016`: 163 | 164 | .. math:: 165 | 166 | d_{2.5D}(x_0, x_{ref}, t) = 167 | \sqrt{8\pi} 168 | \frac{\scalarprod{(x_0 - x_s)}{n_0}}{|x_0 - x_s|} 169 | \sqrt{\frac{|x_0 - x_s||x_0 - x_{ref}|}{|x_0 - x_s|+|x_0 - x_{ref}|}} 170 | \cdot 171 | \frac{\dirac{t - \frac{|x_0 - x_s|}{c}}}{4\pi |x_0 - x_s|} \ast_t h(t) 172 | 173 | .. math:: 174 | 175 | h(t) = F^{-1}(\sqrt{\frac{j \omega}{c}}) 176 | 177 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 178 | 179 | `point_25d()` derives WFS from 3D to 2.5D via the stationary phase 180 | approximation approach (i.e. the Delft approach). 181 | The theoretical link of `point_25d()` and `point_25d_legacy()` was 182 | introduced as *unified WFS framework* in :cite:`Firtha2017`. 183 | 184 | Examples 185 | -------- 186 | .. plot:: 187 | :context: close-figs 188 | 189 | delays, weights, selection, secondary_source = \ 190 | sfs.td.wfs.point_25d(array.x, array.n, xs) 191 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 192 | plot(d, selection, secondary_source, t=ts) 193 | 194 | """ 195 | if c is None: 196 | c = _default.c 197 | x0 = _util.asarray_of_rows(x0) 198 | n0 = _util.asarray_of_rows(n0) 199 | xs = _util.asarray_1d(xs) 200 | xref = _util.asarray_of_rows(xref) 201 | 202 | x0xs = x0 - xs 203 | x0xref = x0 - xref 204 | x0xs_n = _np.linalg.norm(x0xs, axis=1) 205 | x0xref_n = _np.linalg.norm(x0xref, axis=1) 206 | 207 | g0 = 1/(_np.sqrt(2*_np.pi)*x0xs_n**2) 208 | g0 *= _np.sqrt((x0xs_n*x0xref_n)/(x0xs_n+x0xref_n)) 209 | 210 | delays = x0xs_n/c 211 | weights = g0*_inner1d(x0xs, n0) 212 | selection = _util.source_selection_point(n0, x0, xs) 213 | return delays, weights, selection, _secondary_source_point(c) 214 | 215 | 216 | def point_25d_legacy(x0, n0, xs, xref=[0, 0, 0], c=None): 217 | r"""Driving function for 2.5-dimensional WFS of a virtual point source. 218 | 219 | .. versionadded:: 0.6.1 220 | `point_25d()` was renamed to `point_25d_legacy()` (and a new 221 | function with the name `point_25d()` was introduced). See notes below 222 | for further details. 223 | 224 | Parameters 225 | ---------- 226 | x0 : (N, 3) array_like 227 | Sequence of secondary source positions. 228 | n0 : (N, 3) array_like 229 | Sequence of secondary source orientations. 230 | xs : (3,) array_like 231 | Virtual source position. 232 | xref : (3,) array_like, optional 233 | Reference position 234 | c : float, optional 235 | Speed of sound 236 | 237 | Returns 238 | ------- 239 | delays : (N,) numpy.ndarray 240 | Delays of secondary sources in seconds. 241 | weights: (N,) numpy.ndarray 242 | Weights of secondary sources. 243 | selection : (N,) numpy.ndarray 244 | Boolean array containing ``True`` or ``False`` depending on 245 | whether the corresponding secondary source is "active" or not. 246 | secondary_source_function : callable 247 | A function that can be used to create the sound field of a 248 | single secondary source. See `sfs.td.synthesize()`. 249 | 250 | Notes 251 | ----- 252 | 2.5D correction factor 253 | 254 | .. math:: 255 | 256 | g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} 257 | 258 | 259 | d using a point source as source model 260 | 261 | .. math:: 262 | 263 | d_{2.5D}(x_0,t) = 264 | \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} 265 | {2\pi |x_0 - x_s|^{3/2}} 266 | \dirac{t - \frac{|x_0 - x_s|}{c}} \ast_t h(t) 267 | 268 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 269 | 270 | See :sfs:`d_wfs/#equation-td-wfs-point-25d` 271 | 272 | `point_25d_legacy()` derives 2.5D WFS from the 2D 273 | Neumann-Rayleigh integral (i.e. the approach by Rabenstein & Spors), cf. 274 | :cite:`Spors2008`. 275 | The theoretical link of `point_25d()` and `point_25d_legacy()` was 276 | introduced as *unified WFS framework* in :cite:`Firtha2017`. 277 | 278 | Examples 279 | -------- 280 | .. plot:: 281 | :context: close-figs 282 | 283 | delays, weights, selection, secondary_source = \ 284 | sfs.td.wfs.point_25d(array.x, array.n, xs) 285 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 286 | plot(d, selection, secondary_source, t=ts) 287 | 288 | """ 289 | if c is None: 290 | c = _default.c 291 | x0 = _util.asarray_of_rows(x0) 292 | n0 = _util.asarray_of_rows(n0) 293 | xs = _util.asarray_1d(xs) 294 | xref = _util.asarray_1d(xref) 295 | g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) 296 | ds = x0 - xs 297 | r = _np.linalg.norm(ds, axis=1) 298 | delays = r/c 299 | weights = g0 * _inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) 300 | selection = _util.source_selection_point(n0, x0, xs) 301 | return delays, weights, selection, _secondary_source_point(c) 302 | 303 | 304 | def focused_25d(x0, n0, xs, ns, xref=[0, 0, 0], c=None): 305 | r"""Point source by 2.5-dimensional WFS. 306 | 307 | Parameters 308 | ---------- 309 | x0 : (N, 3) array_like 310 | Sequence of secondary source positions. 311 | n0 : (N, 3) array_like 312 | Sequence of secondary source orientations. 313 | xs : (3,) array_like 314 | Virtual source position. 315 | ns : (3,) array_like 316 | Normal vector (propagation direction) of focused source. 317 | This is used for secondary source selection, 318 | see `sfs.util.source_selection_focused()`. 319 | xref : (3,) array_like, optional 320 | Reference position 321 | c : float, optional 322 | Speed of sound 323 | 324 | Returns 325 | ------- 326 | delays : (N,) numpy.ndarray 327 | Delays of secondary sources in seconds. 328 | weights: (N,) numpy.ndarray 329 | Weights of secondary sources. 330 | selection : (N,) numpy.ndarray 331 | Boolean array containing ``True`` or ``False`` depending on 332 | whether the corresponding secondary source is "active" or not. 333 | secondary_source_function : callable 334 | A function that can be used to create the sound field of a 335 | single secondary source. See `sfs.td.synthesize()`. 336 | 337 | Notes 338 | ----- 339 | 2.5D correction factor 340 | 341 | .. math:: 342 | 343 | g_0 = \sqrt{\frac{|x_\mathrm{ref} - x_0|} 344 | {|x_0-x_s| + |x_\mathrm{ref}-x_0|}} 345 | 346 | 347 | d using a point source as source model 348 | 349 | .. math:: 350 | 351 | d_{2.5D}(x_0,t) = 352 | \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} 353 | {|x_0 - x_s|^{3/2}} 354 | \dirac{t + \frac{|x_0 - x_s|}{c}} \ast_t h(t) 355 | 356 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 357 | 358 | See :sfs:`d_wfs/#equation-td-wfs-focused-25d` 359 | 360 | Examples 361 | -------- 362 | .. plot:: 363 | :context: close-figs 364 | 365 | delays, weights, selection, secondary_source = \ 366 | sfs.td.wfs.focused_25d(array.x, array.n, xf, nf) 367 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 368 | plot(d, selection, secondary_source, t=tf) 369 | 370 | """ 371 | if c is None: 372 | c = _default.c 373 | x0 = _util.asarray_of_rows(x0) 374 | n0 = _util.asarray_of_rows(n0) 375 | xs = _util.asarray_1d(xs) 376 | xref = _util.asarray_1d(xref) 377 | ds = x0 - xs 378 | r = _np.linalg.norm(ds, axis=1) 379 | g0 = _np.sqrt(_np.linalg.norm(xref - x0, axis=1) 380 | / (_np.linalg.norm(xref - x0, axis=1) + r)) 381 | delays = -r/c 382 | weights = g0 * _inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) 383 | selection = _util.source_selection_focused(ns, x0, xs) 384 | return delays, weights, selection, _secondary_source_point(c) 385 | 386 | 387 | def driving_signals(delays, weights, signal): 388 | """Get driving signals per secondary source. 389 | 390 | Returned signals are the delayed and weighted mono input signal 391 | (with N samples) per channel (C). 392 | 393 | Parameters 394 | ---------- 395 | delays : (C,) array_like 396 | Delay in seconds for each channel, negative values allowed. 397 | weights : (C,) array_like 398 | Amplitude weighting factor for each channel. 399 | signal : (N,) array_like + float 400 | Excitation signal consisting of (mono) audio data and a sampling 401 | rate (in Hertz). A `DelayedSignal` object can also be used. 402 | 403 | Returns 404 | ------- 405 | `DelayedSignal` 406 | A tuple containing the driving signals (in a `numpy.ndarray` 407 | with shape ``(N, C)``), followed by the sampling rate (in Hertz) 408 | and a (possibly negative) time offset (in seconds). 409 | 410 | """ 411 | delays = _util.asarray_1d(delays) 412 | weights = _util.asarray_1d(weights) 413 | data, samplerate, signal_offset = _apply_delays(signal, delays) 414 | return _util.DelayedSignal(data * weights, samplerate, signal_offset) 415 | -------------------------------------------------------------------------------- /sfs/util.py: -------------------------------------------------------------------------------- 1 | """Various utility functions. 2 | 3 | .. include:: math-definitions.rst 4 | 5 | """ 6 | 7 | import collections 8 | import numpy as np 9 | from numpy.core.umath_tests import inner1d 10 | from scipy.special import spherical_jn, spherical_yn 11 | from . import default 12 | 13 | 14 | def rotation_matrix(n1, n2): 15 | """Compute rotation matrix for rotation from *n1* to *n2*. 16 | 17 | Parameters 18 | ---------- 19 | n1, n2 : (3,) array_like 20 | Two vectors. They don't have to be normalized. 21 | 22 | Returns 23 | ------- 24 | (3, 3) `numpy.ndarray` 25 | Rotation matrix. 26 | 27 | """ 28 | n1 = normalize_vector(n1) 29 | n2 = normalize_vector(n2) 30 | I = np.identity(3) 31 | if np.all(n1 == n2): 32 | return I # no rotation 33 | elif np.all(n1 == -n2): 34 | return -I # flip 35 | # TODO: check for *very close to* parallel vectors 36 | 37 | # Algorithm from http://math.stackexchange.com/a/476311 38 | v = v0, v1, v2 = np.cross(n1, n2) 39 | s = np.linalg.norm(v) # sine 40 | c = np.inner(n1, n2) # cosine 41 | vx = [[0, -v2, v1], 42 | [v2, 0, -v0], 43 | [-v1, v0, 0]] # skew-symmetric cross-product matrix 44 | return I + vx + np.dot(vx, vx) * (1 - c) / s**2 45 | 46 | 47 | def wavenumber(omega, c=None): 48 | """Compute the wavenumber for a given radial frequency.""" 49 | if c is None: 50 | c = default.c 51 | return omega / c 52 | 53 | 54 | def direction_vector(alpha, beta=np.pi/2): 55 | """Compute normal vector from azimuth, colatitude.""" 56 | return sph2cart(alpha, beta, 1) 57 | 58 | 59 | def sph2cart(alpha, beta, r): 60 | r"""Spherical to cartesian coordinate transform. 61 | 62 | .. math:: 63 | 64 | x = r \cos \alpha \sin \beta \\ 65 | y = r \sin \alpha \sin \beta \\ 66 | z = r \cos \beta 67 | 68 | with :math:`\alpha \in [0, 2\pi), \beta \in [0, \pi], r \geq 0` 69 | 70 | Parameters 71 | ---------- 72 | alpha : float or array_like 73 | Azimuth angle in radiants 74 | beta : float or array_like 75 | Colatitude angle in radiants (with 0 denoting North pole) 76 | r : float or array_like 77 | Radius 78 | 79 | Returns 80 | ------- 81 | x : float or `numpy.ndarray` 82 | x-component of Cartesian coordinates 83 | y : float or `numpy.ndarray` 84 | y-component of Cartesian coordinates 85 | z : float or `numpy.ndarray` 86 | z-component of Cartesian coordinates 87 | 88 | """ 89 | x = r * np.cos(alpha) * np.sin(beta) 90 | y = r * np.sin(alpha) * np.sin(beta) 91 | z = r * np.cos(beta) 92 | return x, y, z 93 | 94 | 95 | def cart2sph(x, y, z): 96 | r"""Cartesian to spherical coordinate transform. 97 | 98 | .. math:: 99 | 100 | \alpha = \arctan \left( \frac{y}{x} \right) \\ 101 | \beta = \arccos \left( \frac{z}{r} \right) \\ 102 | r = \sqrt{x^2 + y^2 + z^2} 103 | 104 | with :math:`\alpha \in [-pi, pi], \beta \in [0, \pi], r \geq 0` 105 | 106 | Parameters 107 | ---------- 108 | x : float or array_like 109 | x-component of Cartesian coordinates 110 | y : float or array_like 111 | y-component of Cartesian coordinates 112 | z : float or array_like 113 | z-component of Cartesian coordinates 114 | 115 | Returns 116 | ------- 117 | alpha : float or `numpy.ndarray` 118 | Azimuth angle in radiants 119 | beta : float or `numpy.ndarray` 120 | Colatitude angle in radiants (with 0 denoting North pole) 121 | r : float or `numpy.ndarray` 122 | Radius 123 | 124 | """ 125 | r = np.sqrt(x**2 + y**2 + z**2) 126 | alpha = np.arctan2(y, x) 127 | beta = np.arccos(z / r) 128 | return alpha, beta, r 129 | 130 | 131 | def asarray_1d(a, **kwargs): 132 | """Squeeze the input and check if the result is one-dimensional. 133 | 134 | Returns *a* converted to a `numpy.ndarray` and stripped of 135 | all singleton dimensions. Scalars are "upgraded" to 1D arrays. 136 | The result must have exactly one dimension. 137 | If not, an error is raised. 138 | 139 | """ 140 | result = np.squeeze(np.asarray(a, **kwargs)) 141 | if result.ndim == 0: 142 | result = result.reshape((1,)) 143 | elif result.ndim > 1: 144 | raise ValueError("array must be one-dimensional") 145 | return result 146 | 147 | 148 | def asarray_of_rows(a, **kwargs): 149 | """Convert to 2D array, turn column vector into row vector. 150 | 151 | Returns *a* converted to a `numpy.ndarray` and stripped of 152 | all singleton dimensions. If the result has exactly one dimension, 153 | it is re-shaped into a 2D row vector. 154 | 155 | """ 156 | result = np.squeeze(np.asarray(a, **kwargs)) 157 | if result.ndim == 1: 158 | result = result.reshape(1, -1) 159 | return result 160 | 161 | 162 | def as_xyz_components(components, **kwargs): 163 | r"""Convert *components* to `XyzComponents` of `numpy.ndarray`\s. 164 | 165 | The *components* are first converted to NumPy arrays (using 166 | :func:`numpy.asarray`) which are then assembled into an 167 | `XyzComponents` object. 168 | 169 | Parameters 170 | ---------- 171 | components : triple or pair of array_like 172 | The values to be used as X, Y and Z arrays. Z is optional. 173 | **kwargs 174 | All further arguments are forwarded to :func:`numpy.asarray`, 175 | which is applied to the elements of *components*. 176 | 177 | """ 178 | return XyzComponents([np.asarray(c, **kwargs) for c in components]) 179 | 180 | 181 | def as_delayed_signal(arg, **kwargs): 182 | """Make sure that the given argument can be used as a signal. 183 | 184 | Parameters 185 | ---------- 186 | arg : sequence of 1 array_like followed by 1 or 2 scalars 187 | The first element is converted to a NumPy array, the second 188 | element is used as the sampling rate (in Hertz) and the optional 189 | third element is used as the starting time of the signal (in 190 | seconds). Default starting time is 0. 191 | **kwargs 192 | All keyword arguments are forwarded to :func:`numpy.asarray`. 193 | 194 | Returns 195 | ------- 196 | `DelayedSignal` 197 | A named tuple consisting of a `numpy.ndarray` containing the 198 | audio data, followed by the sampling rate (in Hertz) and the 199 | starting time (in seconds) of the signal. 200 | 201 | Examples 202 | -------- 203 | Typically, this is used together with tuple unpacking to assign the 204 | audio data, the sampling rate and the starting time to separate 205 | variables: 206 | 207 | >>> import sfs 208 | >>> sig = [1], 44100 209 | >>> data, fs, signal_offset = sfs.util.as_delayed_signal(sig) 210 | >>> data 211 | array([1]) 212 | >>> fs 213 | 44100 214 | >>> signal_offset 215 | 0 216 | 217 | """ 218 | try: 219 | data, samplerate, *time = arg 220 | time, = time or [0] 221 | except (IndexError, TypeError, ValueError): 222 | pass 223 | else: 224 | valid_arguments = (not np.isscalar(data) and 225 | np.isscalar(samplerate) and 226 | np.isscalar(time)) 227 | if valid_arguments: 228 | data = np.asarray(data, **kwargs) 229 | return DelayedSignal(data, samplerate, time) 230 | raise TypeError('expected audio data, samplerate, optional start time') 231 | 232 | 233 | def strict_arange(start, stop, step=1, *, endpoint=False, dtype=None, 234 | **kwargs): 235 | """Like :func:`numpy.arange`, but compensating numeric errors. 236 | 237 | Unlike :func:`numpy.arange`, but similar to :func:`numpy.linspace`, 238 | providing ``endpoint=True`` includes both endpoints. 239 | 240 | Parameters 241 | ---------- 242 | start, stop, step, dtype 243 | See :func:`numpy.arange`. 244 | endpoint 245 | See :func:`numpy.linspace`. 246 | 247 | .. note:: With ``endpoint=True``, the difference between *start* 248 | and *end* value must be an integer multiple of the 249 | corresponding *spacing* value! 250 | **kwargs 251 | All further arguments are forwarded to :func:`numpy.isclose`. 252 | 253 | Returns 254 | ------- 255 | `numpy.ndarray` 256 | Array of evenly spaced values. See :func:`numpy.arange`. 257 | 258 | """ 259 | remainder = (stop - start) % step 260 | if np.any(np.isclose(remainder, (0.0, step), **kwargs)): 261 | if endpoint: 262 | stop += step * 0.5 263 | else: 264 | stop -= step * 0.5 265 | elif endpoint: 266 | raise ValueError("Invalid stop value for endpoint=True") 267 | return np.arange(start, stop, step, dtype) 268 | 269 | 270 | def xyz_grid(x, y, z, *, spacing, endpoint=True, **kwargs): 271 | """Create a grid with given range and spacing. 272 | 273 | Parameters 274 | ---------- 275 | x, y, z : float or pair of float 276 | Inclusive range of the respective coordinate or a single value 277 | if only a slice along this dimension is needed. 278 | spacing : float or triple of float 279 | Grid spacing. If a single value is specified, it is used for 280 | all dimensions, if multiple values are given, one value is used 281 | per dimension. If a dimension (*x*, *y* or *z*) has only a 282 | single value, the corresponding spacing is ignored. 283 | endpoint : bool, optional 284 | If ``True`` (the default), the endpoint of each range is 285 | included in the grid. Use ``False`` to get a result similar to 286 | :func:`numpy.arange`. See `strict_arange()`. 287 | **kwargs 288 | All further arguments are forwarded to `strict_arange()`. 289 | 290 | Returns 291 | ------- 292 | `XyzComponents` 293 | A grid that can be used for sound field calculations. 294 | 295 | See Also 296 | -------- 297 | strict_arange, numpy.meshgrid 298 | 299 | """ 300 | if np.isscalar(spacing): 301 | spacing = [spacing] * 3 302 | ranges = [] 303 | scalars = [] 304 | for i, coord in enumerate([x, y, z]): 305 | if np.isscalar(coord): 306 | scalars.append((i, coord)) 307 | else: 308 | start, stop = coord 309 | ranges.append(strict_arange(start, stop, spacing[i], 310 | endpoint=endpoint, **kwargs)) 311 | grid = np.meshgrid(*ranges, sparse=True, copy=False) 312 | for i, s in scalars: 313 | grid.insert(i, s) 314 | return XyzComponents(grid) 315 | 316 | 317 | def normalize(p, grid, xnorm): 318 | """Normalize sound field wrt position *xnorm*.""" 319 | return p / np.abs(probe(p, grid, xnorm)) 320 | 321 | 322 | def probe(p, grid, x): 323 | """Determine the value at position *x* in the sound field *p*.""" 324 | grid = as_xyz_components(grid) 325 | x = asarray_1d(x) 326 | r = np.linalg.norm(grid - x) 327 | idx = np.unravel_index(r.argmin(), r.shape) 328 | return p[idx] 329 | 330 | 331 | def broadcast_zip(*args): 332 | """Broadcast arguments to the same shape and then use :func:`zip`.""" 333 | return zip(*np.broadcast_arrays(*args)) 334 | 335 | 336 | def normalize_vector(x): 337 | """Normalize a 1D vector.""" 338 | x = asarray_1d(x) 339 | return x / np.linalg.norm(x) 340 | 341 | 342 | def db(x, *, power=False): 343 | """Convert *x* to decibel. 344 | 345 | Parameters 346 | ---------- 347 | x : array_like 348 | Input data. Values of 0 lead to negative infinity. 349 | power : bool, optional 350 | If ``power=False`` (the default), *x* is squared before 351 | conversion. 352 | 353 | """ 354 | with np.errstate(divide='ignore'): 355 | return (10 if power else 20) * np.log10(np.abs(x)) 356 | 357 | 358 | class XyzComponents(np.ndarray): 359 | """See __init__().""" 360 | 361 | def __init__(self, components): 362 | r"""Triple (or pair) of components: x, y, and optionally z. 363 | 364 | Instances of this class can be used to store coordinate grids 365 | (either regular grids like in `xyz_grid()` or arbitrary point 366 | clouds) or vector fields (e.g. particle velocity). 367 | 368 | This class is a subclass of `numpy.ndarray`. It is 369 | one-dimensional (like a plain `list`) and has a length of 3 (or 370 | 2, if no z-component is available). It uses ``dtype=object`` in 371 | order to be able to store other `numpy.ndarray`\s of arbitrary 372 | shapes but also scalars, if needed. Because it is a NumPy array 373 | subclass, it can be used in operations with scalars and "normal" 374 | NumPy arrays, as long as they have a compatible shape. Like any 375 | NumPy array, instances of this class are iterable and can be 376 | used, e.g., in for-loops and tuple unpacking. If slicing or 377 | broadcasting leads to an incompatible shape, a plain 378 | `numpy.ndarray` with ``dtype=object`` is returned. 379 | 380 | To make sure the *components* are NumPy arrays themselves, use 381 | `as_xyz_components()`. 382 | 383 | Parameters 384 | ---------- 385 | components : (3,) or (2,) array_like 386 | The values to be used as X, Y and Z data. Z is optional. 387 | 388 | """ 389 | # This method does nothing, it's only here for the documentation! 390 | 391 | def __new__(cls, components): 392 | # object arrays cannot be created and populated in a single step: 393 | obj = np.ndarray.__new__(cls, len(components), dtype=object) 394 | for i, component in enumerate(components): 395 | obj[i] = component 396 | return obj 397 | 398 | def __array_finalize__(self, obj): 399 | if self.ndim == 0: 400 | pass # this is allowed, e.g. for np.inner() 401 | elif self.ndim > 1 or len(self) not in (2, 3): 402 | raise ValueError("XyzComponents can only have 2 or 3 components") 403 | 404 | def __array_prepare__(self, obj, context=None): 405 | if obj.ndim == 1 and len(obj) in (2, 3): 406 | return obj.view(XyzComponents) 407 | return obj 408 | 409 | def __array_wrap__(self, obj, context=None): 410 | if obj.ndim != 1 or len(obj) not in (2, 3): 411 | return obj.view(np.ndarray) 412 | return obj 413 | 414 | def __getitem__(self, index): 415 | if isinstance(index, slice): 416 | start, stop, step = index.indices(len(self)) 417 | if start == 0 and stop in (2, 3) and step == 1: 418 | return np.ndarray.__getitem__(self, index) 419 | # Slices other than xy and xyz are "downgraded" to ndarray 420 | return np.ndarray.__getitem__(self.view(np.ndarray), index) 421 | 422 | def __repr__(self): 423 | return 'XyzComponents(\n' + ',\n'.join( 424 | ' {}={}'.format(name, repr(data).replace('\n', '\n ')) 425 | for name, data in zip('xyz', self)) + ')' 426 | 427 | def make_property(index, doc): 428 | 429 | def getter(self): 430 | return self[index] 431 | 432 | def setter(self, value): 433 | self[index] = value 434 | 435 | return property(getter, setter, doc=doc) 436 | 437 | x = make_property(0, doc='x-component.') 438 | y = make_property(1, doc='y-component.') 439 | z = make_property(2, doc='z-component (optional).') 440 | 441 | del make_property 442 | 443 | def apply(self, func, *args, **kwargs): 444 | """Apply a function to each component. 445 | 446 | The function *func* will be called once for each component, 447 | passing the current component as first argument. All further 448 | arguments are passed after that. 449 | The results are returned as a new `XyzComponents` object. 450 | 451 | """ 452 | return XyzComponents([func(i, *args, **kwargs) for i in self]) 453 | 454 | 455 | DelayedSignal = collections.namedtuple('DelayedSignal', 'data samplerate time') 456 | """A tuple of audio data, sampling rate and start time. 457 | 458 | This class (a `collections.namedtuple`) is not meant to be instantiated 459 | by users. 460 | 461 | To pass a signal to a function, just use a simple `tuple` or `list` 462 | containing the audio data and the sampling rate (in Hertz), with an 463 | optional starting time (in seconds) as a third item. 464 | If you want to ensure that a given variable contains a valid signal, use 465 | `sfs.util.as_delayed_signal()`. 466 | 467 | """ 468 | 469 | 470 | def image_sources_for_box(x, L, N, *, prune=True): 471 | """Image source method for a cuboid room. 472 | 473 | The classical method by Allen and Berkley :cite:`Allen1979`. 474 | 475 | Parameters 476 | ---------- 477 | x : (D,) array_like 478 | Original source location within box. 479 | Values between 0 and corresponding side length. 480 | L : (D,) array_like 481 | side lengths of room. 482 | N : int 483 | Maximum number of reflections per image source, see below. 484 | prune : bool, optional 485 | selection of image sources: 486 | 487 | - If True (default): 488 | Returns all images reflected up to N times. 489 | This is the usual interpretation of N as "maximum order". 490 | 491 | - If False: 492 | Returns reflected up to N times between individual wall pairs, 493 | a total number of :math:`M := (2N+1)^D`. 494 | This larger set is useful e.g. to select image sources based on 495 | distance to listener, as suggested by :cite:`Borish1984`. 496 | 497 | 498 | Returns 499 | ------- 500 | xs : (M, D) `numpy.ndarray` 501 | original & image source locations. 502 | wall_count : (M, 2D) `numpy.ndarray` 503 | number of reflections at individual walls for each source. 504 | 505 | """ 506 | def _images_1d_unit_box(x, N): 507 | result = np.arange(-N, N + 1, dtype=x.dtype) 508 | result[N % 2::2] += x 509 | result[1 - (N % 2)::2] += 1 - x 510 | return result 511 | 512 | def _count_walls_1d(a): 513 | b = np.floor(a/2) 514 | c = np.ceil((a-1)/2) 515 | return np.abs(np.stack([b, c], axis=1)).astype(int) 516 | 517 | L = asarray_1d(L) 518 | x = asarray_1d(x)/L 519 | D = len(x) 520 | xs = [_images_1d_unit_box(coord, N) for coord in x] 521 | xs = np.reshape(np.transpose(np.meshgrid(*xs, indexing='ij')), (-1, D)) 522 | 523 | wall_count = np.concatenate([_count_walls_1d(d) for d in xs.T], axis=1) 524 | xs *= L 525 | 526 | if prune is True: 527 | N_mask = np.sum(wall_count, axis=1) <= N 528 | xs = xs[N_mask, :] 529 | wall_count = wall_count[N_mask, :] 530 | 531 | return xs, wall_count 532 | 533 | 534 | def spherical_hn2(n, z): 535 | r"""Spherical Hankel function of 2nd kind. 536 | 537 | Defined as https://dlmf.nist.gov/10.47.E6, 538 | 539 | .. math:: 540 | 541 | \hankel{2}{n}{z} = \sqrt{\frac{\pi}{2z}} 542 | \Hankel{2}{n + \frac{1}{2}}{z}, 543 | 544 | where :math:`\Hankel{2}{n}{\cdot}` is the Hankel function of the 545 | second kind and n-th order, and :math:`z` its complex argument. 546 | 547 | Parameters 548 | ---------- 549 | n : array_like 550 | Order of the spherical Hankel function (n >= 0). 551 | z : array_like 552 | Argument of the spherical Hankel function. 553 | 554 | """ 555 | return spherical_jn(n, z) - 1j * spherical_yn(n, z) 556 | 557 | 558 | def source_selection_plane(n0, n): 559 | """Secondary source selection for a plane wave. 560 | 561 | Eq.(13) from :cite:`Spors2008` 562 | 563 | """ 564 | n0 = asarray_of_rows(n0) 565 | n = normalize_vector(n) 566 | return np.inner(n, n0) >= default.selection_tolerance 567 | 568 | 569 | def source_selection_point(n0, x0, xs): 570 | """Secondary source selection for a point source. 571 | 572 | Eq.(15) from :cite:`Spors2008` 573 | 574 | """ 575 | n0 = asarray_of_rows(n0) 576 | x0 = asarray_of_rows(x0) 577 | xs = asarray_1d(xs) 578 | ds = x0 - xs 579 | return inner1d(ds, n0) >= default.selection_tolerance 580 | 581 | 582 | def source_selection_line(n0, x0, xs): 583 | """Secondary source selection for a line source. 584 | 585 | compare Eq.(15) from :cite:`Spors2008` 586 | 587 | """ 588 | return source_selection_point(n0, x0, xs) 589 | 590 | 591 | def source_selection_focused(ns, x0, xs): 592 | """Secondary source selection for a focused source. 593 | 594 | Eq.(2.78) from :cite:`Wierstorf2014` 595 | 596 | """ 597 | x0 = asarray_of_rows(x0) 598 | xs = asarray_1d(xs) 599 | ns = normalize_vector(ns) 600 | ds = xs - x0 601 | return inner1d(ns, ds) >= default.selection_tolerance 602 | 603 | 604 | def source_selection_all(N): 605 | """Select all secondary sources.""" 606 | return np.ones(N, dtype=bool) 607 | 608 | 609 | def max_order_circular_harmonics(N): 610 | r"""Maximum order of 2D/2.5D HOA. 611 | 612 | It returns the maximum order for which no spatial aliasing appears. 613 | It is given on page 132 of :cite:`Ahrens2012` as 614 | 615 | .. math:: 616 | \mathtt{max\_order} = 617 | \begin{cases} 618 | N/2 - 1 & \text{even}\;N \\ 619 | (N-1)/2 & \text{odd}\;N, 620 | \end{cases} 621 | 622 | which is equivalent to 623 | 624 | .. math:: 625 | \mathtt{max\_order} = \big\lfloor \frac{N - 1}{2} \big\rfloor. 626 | 627 | Parameters 628 | ---------- 629 | N : int 630 | Number of secondary sources. 631 | 632 | """ 633 | return (N - 1) // 2 634 | 635 | 636 | def max_order_spherical_harmonics(N): 637 | r"""Maximum order of 3D HOA. 638 | 639 | .. math:: 640 | \mathtt{max\_order} = \lfloor \sqrt{N} \rfloor - 1. 641 | 642 | Parameters 643 | ---------- 644 | N : int 645 | Number of secondary sources. 646 | 647 | """ 648 | return int(np.sqrt(N) - 1) 649 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /tests/test_array.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy.testing import assert_array_equal 3 | import pytest 4 | import sfs 5 | 6 | 7 | def vectortypes(*coeffs): 8 | return [ 9 | list(coeffs), 10 | tuple(coeffs), 11 | np.array(coeffs), 12 | np.array(coeffs).reshape(1, -1), 13 | np.array(coeffs).reshape(-1, 1), 14 | ] 15 | 16 | 17 | def vector_id(vector): 18 | if isinstance(vector, np.ndarray): 19 | return 'array, shape=' + repr(vector.shape) 20 | return type(vector).__name__ 21 | 22 | 23 | @pytest.mark.parametrize('N, spacing, result', [ 24 | (2, 1, sfs.array.SecondarySourceDistribution( 25 | x=[[0, -0.5, 0], [0, 0.5, 0]], 26 | n=[[1, 0, 0], [1, 0, 0]], 27 | a=[1, 1], 28 | )), 29 | (3, 1, sfs.array.SecondarySourceDistribution( 30 | x=[[0, -1, 0], [0, 0, 0], [0, 1, 0]], 31 | n=[[1, 0, 0], [1, 0, 0], [1, 0, 0]], 32 | a=[1, 1, 1], 33 | )), 34 | (3, 0.5, sfs.array.SecondarySourceDistribution( 35 | x=[[0, -0.5, 0], [0, 0, 0], [0, 0.5, 0]], 36 | n=[[1, 0, 0], [1, 0, 0], [1, 0, 0]], 37 | a=[0.5, 0.5, 0.5], 38 | )), 39 | ]) 40 | def test_linear_with_defaults(N, spacing, result): 41 | a = sfs.array.linear(N, spacing) 42 | assert a.x.dtype == np.float64 43 | assert a.n.dtype == np.float64 44 | assert a.a.dtype == np.float64 45 | assert_array_equal(a.x, result.x) 46 | assert_array_equal(a.n, result.n) 47 | assert_array_equal(a.a, result.a) 48 | 49 | 50 | def test_linear_with_named_arguments(): 51 | a = sfs.array.linear(N=2, spacing=0.5) 52 | assert_array_equal(a.x, [[0, -0.25, 0], [0, 0.25, 0]]) 53 | assert_array_equal(a.n, [[1, 0, 0], [1, 0, 0]]) 54 | assert_array_equal(a.a, [0.5, 0.5]) 55 | 56 | 57 | @pytest.mark.parametrize('center', vectortypes(-1, 0.5, 2), ids=vector_id) 58 | def test_linear_with_center(center): 59 | a = sfs.array.linear(2, 1, center=center) 60 | assert_array_equal(a.x, [[-1, 0, 2], [-1, 1, 2]]) 61 | assert_array_equal(a.n, [[1, 0, 0], [1, 0, 0]]) 62 | assert_array_equal(a.a, [1, 1]) 63 | 64 | 65 | @pytest.mark.parametrize('orientation', vectortypes(0, -1, 0), ids=vector_id) 66 | def test_linear_with_center_and_orientation(orientation): 67 | a = sfs.array.linear(2, 1, center=[0, 1, 2], orientation=orientation) 68 | assert_array_equal(a.x, [[-0.5, 1, 2], [0.5, 1, 2]]) 69 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy.testing import assert_allclose 3 | import pytest 4 | import sfs 5 | 6 | 7 | cart_sph_data = [ 8 | ((1, 1, 1), (np.pi / 4, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 9 | ((-1, 1, 1), (3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 10 | ((1, -1, 1), (-np.pi / 4, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 11 | ((-1, -1, 1), (-3 / 4 * np.pi, np.arccos(1 / np.sqrt(3)), np.sqrt(3))), 12 | ((1, 1, -1), (np.pi / 4, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 13 | ((-1, 1, -1), (3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 14 | ((1, -1, -1), (-np.pi / 4, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 15 | ((-1, -1, -1), (-3 / 4 * np.pi, np.arccos(-1 / np.sqrt(3)), np.sqrt(3))), 16 | ] 17 | 18 | 19 | @pytest.mark.parametrize('coord, polar', cart_sph_data) 20 | def test_cart2sph(coord, polar): 21 | x, y, z = coord 22 | a = sfs.util.cart2sph(x, y, z) 23 | assert_allclose(a, polar) 24 | 25 | 26 | @pytest.mark.parametrize('coord, polar', cart_sph_data) 27 | def test_sph2cart(coord, polar): 28 | alpha, beta, r = polar 29 | b = sfs.util.sph2cart(alpha, beta, r) 30 | assert_allclose(b, coord) 31 | 32 | 33 | direction_vector_data = [ 34 | ((np.pi / 4, np.pi / 4), (0.5, 0.5, np.sqrt(2) / 2)), 35 | ((3 * np.pi / 4, 3 * np.pi / 4), (-1 / 2, 1 / 2, -np.sqrt(2) / 2)), 36 | ((3 * np.pi / 4, -3 * np.pi / 4), (1 / 2, -1 / 2, -np.sqrt(2) / 2)), 37 | ((np.pi / 4, -np.pi / 4), (-1 / 2, -1 / 2, np.sqrt(2) / 2)), 38 | ((-np.pi / 4, np.pi / 4), (1 / 2, -1 / 2, np.sqrt(2) / 2)), 39 | ((-3 * np.pi / 4, 3 * np.pi / 4), (-1 / 2, -1 / 2, -np.sqrt(2) / 2)), 40 | ((-3 * np.pi / 4, -3 * np.pi / 4), (1 / 2, 1 / 2, -np.sqrt(2) / 2)), 41 | ((-np.pi / 4, -np.pi / 4), (-1 / 2, 1 / 2, np.sqrt(2) / 2)), 42 | ] 43 | 44 | 45 | @pytest.mark.parametrize('input, vector', direction_vector_data) 46 | def test_direction_vector(input, vector): 47 | alpha, beta = input 48 | c = sfs.util.direction_vector(alpha, beta) 49 | assert_allclose(c, vector) 50 | 51 | 52 | db_data = [ 53 | (0, -np.inf), 54 | (0.5, -3.01029995663981), 55 | (1, 0), 56 | (2, 3.01029995663981), 57 | (10, 10), 58 | ] 59 | 60 | 61 | @pytest.mark.parametrize('linear, power_db', db_data) 62 | def test_db_amplitude(linear, power_db): 63 | d = sfs.util.db(linear) 64 | assert_allclose(d, power_db * 2) 65 | 66 | 67 | @pytest.mark.parametrize('linear, power_db', db_data) 68 | def test_db_power(linear, power_db): 69 | d = sfs.util.db(linear, power=True) 70 | assert_allclose(d, power_db) 71 | --------------------------------------------------------------------------------