├── .binder ├── apt.txt ├── runtime.txt ├── requirements.txt ├── postBuild └── README ├── examples ├── doc ├── contributing.rst ├── api.rst ├── version-history.rst ├── references.rst ├── examples │ ├── figures │ │ ├── cross.png │ │ ├── rect.png │ │ ├── tree.png │ │ └── circle.png │ ├── ipython_kernel_config.py │ ├── run_all.py │ ├── plot_particle_density.py │ ├── soundfigures.py │ ├── time_domain_nfchoa.py │ ├── time_domain.py │ ├── modal-room-acoustics.ipynb │ ├── mirror-image-source-model.ipynb │ ├── animations_pulsating_sphere.py │ ├── horizontal_plane_arrays.py │ ├── sound-field-synthesis.ipynb │ ├── wfs-referencing.ipynb │ └── animations-pulsating-sphere.ipynb ├── _static │ ├── thumbnails │ │ ├── pulsating_sphere.gif │ │ └── soundfigure_level.png │ └── css │ │ └── title.css ├── index.rst ├── README ├── _template │ └── layout.html ├── example-python-scripts.rst ├── examples.rst ├── math-definitions.rst ├── installation.rst ├── references.bib └── conf.py ├── data └── arrays │ ├── example_array_4LS_2D.csv │ ├── example_array_6LS_3D.txt │ ├── wfs_university_rostock_2015.csv │ └── wfs_university_rostock_2018.csv ├── .gitignore ├── .editorconfig ├── readthedocs.yml ├── sfs ├── plot3d.py ├── __init__.py ├── fd │ ├── __init__.py │ ├── nfchoa.py │ ├── sdm.py │ └── esa.py ├── td │ ├── __init__.py │ ├── source.py │ ├── wfs.py │ └── nfchoa.py ├── tapering.py └── util.py ├── README.rst ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── tests ├── test_array.py └── test_util.py ├── CONTRIBUTING.rst └── NEWS.rst /.binder/apt.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples: -------------------------------------------------------------------------------- 1 | doc/examples/ -------------------------------------------------------------------------------- /.binder/runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.11 2 | -------------------------------------------------------------------------------- /.binder/requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | . 3 | -------------------------------------------------------------------------------- /doc/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /.binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Add your commands here 6 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: sfs 5 | -------------------------------------------------------------------------------- /doc/version-history.rst: -------------------------------------------------------------------------------- 1 | .. default-role:: py:obj 2 | 3 | .. include:: ../NEWS.rst 4 | -------------------------------------------------------------------------------- /doc/references.rst: -------------------------------------------------------------------------------- 1 | References 2 | ========== 3 | 4 | .. bibliography:: 5 | :style: alpha 6 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /doc/examples/figures/cross.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/examples/figures/cross.png -------------------------------------------------------------------------------- /doc/examples/figures/rect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/examples/figures/rect.png -------------------------------------------------------------------------------- /doc/examples/figures/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/examples/figures/tree.png -------------------------------------------------------------------------------- /doc/examples/figures/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/examples/figures/circle.png -------------------------------------------------------------------------------- /doc/_static/thumbnails/pulsating_sphere.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/_static/thumbnails/pulsating_sphere.gif -------------------------------------------------------------------------------- /doc/_static/thumbnails/soundfigure_level.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfstoolbox/sfs-python/HEAD/doc/_static/thumbnails/soundfigure_level.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | .eggs/ 6 | sfs.egg-info/ 7 | /doc/sfs.*.rst 8 | /doc/examples/*.gif 9 | /doc/examples/*.png 10 | .ipynb_checkpoints/ 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/_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 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3" 7 | jobs: 8 | pre_create_environment: 9 | - asdf plugin add uv 10 | - asdf install uv latest 11 | - asdf global uv latest 12 | create_environment: 13 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 14 | install: 15 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen 16 | 17 | sphinx: 18 | configuration: doc/conf.py 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | .. nbgallery:: 14 | 15 | examples/sound-field-synthesis 16 | examples/wfs-referencing 17 | examples/modal-room-acoustics 18 | examples/mirror-image-source-model 19 | examples/animations-pulsating-sphere 20 | example-python-scripts 21 | -------------------------------------------------------------------------------- /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/_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/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 | -------------------------------------------------------------------------------- /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 and (optionally) Matplotlib 19 | * Install the ``sfs`` package for Python 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "sfs" 7 | license = "MIT" 8 | dynamic = ["version"] 9 | description = "Sound Field Synthesis Toolbox" 10 | readme = "README.rst" 11 | keywords = ["audio", "SFS", "WFS", "Ambisonics"] 12 | authors = [{ name = "SFS Toolbox Developers", email = "sfstoolbox@gmail.com" }] 13 | dependencies = ["numpy >= 2", "scipy >= 1.16"] 14 | requires-python = ">= 3.11" 15 | classifiers = [ 16 | "Operating System :: OS Independent", 17 | "Topic :: Scientific/Engineering", 18 | ] 19 | 20 | [project.urls] 21 | Documentation = "https://sfs-python.readthedocs.io/" 22 | Repository = "https://github.com/sfstoolbox/sfs-python" 23 | Issues = "https://github.com/sfstoolbox/sfs-python/issues" 24 | 25 | [dependency-groups] 26 | dev = [{ include-group = "test" }, { include-group = "doc" }] 27 | test = [ 28 | "pytest", 29 | ] 30 | doc = [ 31 | "sphinx>=8", 32 | "sphinx-rtd-theme", 33 | "nbsphinx", 34 | "ipykernel", 35 | "sphinxcontrib-bibtex>=2.1.4", 36 | "matplotlib>=3", 37 | ] 38 | 39 | [tool.setuptools.packages.find] 40 | include = ["sfs*"] 41 | 42 | [tool.setuptools.dynamic] 43 | version = { attr = "sfs.__version__" } 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish to PyPI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build distribution 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v5 9 | with: 10 | submodules: true 11 | - name: Set up Python 12 | uses: actions/setup-python@v6 13 | with: 14 | python-version: "3" 15 | - name: Install "build" 16 | run: | 17 | python -m pip install build 18 | - name: Build wheel and source tarball 19 | run: | 20 | python -m build 21 | - name: Store the distribution packages 22 | uses: actions/upload-artifact@v4 23 | with: 24 | name: dist 25 | path: dist 26 | publish: 27 | name: Upload release to PyPI 28 | if: startsWith(github.ref, 'refs/tags/') 29 | needs: 30 | - build 31 | runs-on: ubuntu-latest 32 | environment: 33 | name: pypi 34 | url: https://pypi.org/p/sfs 35 | permissions: 36 | id-token: write 37 | steps: 38 | - name: Get the artifacts 39 | uses: actions/download-artifact@v5 40 | with: 41 | name: dist 42 | path: dist 43 | - name: Publish package distributions to PyPI 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | with: 46 | print-hash: true 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | UV_PYTHON_DOWNLOADS: never 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macOS-latest, windows-latest] 15 | python-version: ['3.11', '3.12'] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v6 25 | - name: Prepare Ubuntu 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install --no-install-recommends -y pandoc ffmpeg 29 | if: matrix.os == 'ubuntu-latest' 30 | - name: Prepare OSX 31 | run: brew install pandoc ffmpeg 32 | if: matrix.os == 'macOS-latest' 33 | - name: prepare Windows 34 | run: choco install pandoc ffmpeg 35 | if: matrix.os == 'windows-latest' 36 | - name: Install dependencies 37 | run: | 38 | uv sync --locked 39 | - name: Test 40 | run: uv run --locked -m pytest 41 | - name: Test examples 42 | run: uv run --locked --with pillow --script doc/examples/run_all.py 43 | - name: Test documentation 44 | run: uv run --locked -m sphinx doc/ _build/ -b doctest -W 45 | -------------------------------------------------------------------------------- /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 = sfs.util.as_xyz_components([np.random.uniform(-3, 3, 40000), 18 | np.random.uniform(-3, 3, 40000), 19 | 0]) 20 | 21 | 22 | def plot_particle_displacement(title): 23 | # compute displacement 24 | X = grid + amplitude * sfs.fd.displacement(v, omega) 25 | # plot displacement 26 | plt.figure(figsize=(15, 15)) 27 | plt.cla() 28 | sfs.plot2d.particles(X, facecolor='black', s=3, trim=[-3, 3, -3, 3]) 29 | plt.axis('off') 30 | plt.title(title) 31 | plt.grid() 32 | plt.savefig(title + '.png') 33 | 34 | 35 | # point source 36 | v = sfs.fd.source.point_velocity(omega, xs, grid) 37 | amplitude = 1.5e6 38 | plot_particle_displacement('particle_displacement_point_source') 39 | 40 | # line source 41 | v = sfs.fd.source.line_velocity(omega, xs, grid) 42 | amplitude = 1.3e6 43 | plot_particle_displacement('particle_displacement_line_source') 44 | 45 | # plane wave 46 | v = sfs.fd.source.plane_velocity(omega, xs, npw, grid) 47 | amplitude = 1e5 48 | plot_particle_displacement('particle_displacement_plane_wave') 49 | -------------------------------------------------------------------------------- /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.3" 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 = 80 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 | uv sync 18 | 19 | .. _PyPI: https://pypi.org/project/sfs/ 20 | .. _Github: https://github.com/sfstoolbox/sfs-python/ 21 | 22 | 23 | Building the Documentation 24 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 25 | 26 | If you make changes to the documentation, you can re-create the HTML pages 27 | using Sphinx_. From the main ``sfs-python`` directory, run:: 28 | 29 | uv run sphinx-build doc _build 30 | 31 | The generated files will be available in the directory ``_build/``. 32 | 33 | .. _Sphinx: http://sphinx-doc.org/ 34 | 35 | 36 | Running the Tests 37 | ^^^^^^^^^^^^^^^^^ 38 | 39 | You'll need pytest_, which will be installed automatically. 40 | To execute the tests, simply run:: 41 | 42 | uv run pytest 43 | 44 | .. _pytest: https://pytest.org/ 45 | 46 | 47 | Editable Installation 48 | ^^^^^^^^^^^^^^^^^^^^^ 49 | 50 | If you want to work in a different directory on your own files, 51 | but using the latest development version (or a custom branch) of 52 | the ``sfs`` module, you can switch to a directory of your choice 53 | and enter this:: 54 | 55 | uv init --bare 56 | uv add --editable --no-workspace path/to/your/sfs/repo 57 | 58 | You can install further packages with ``uv add`` and then run 59 | whatever you need with ``uv run``. 60 | 61 | 62 | Creating a New Release 63 | ^^^^^^^^^^^^^^^^^^^^^^ 64 | 65 | These steps for creating a new release are proposed to be taken only after 66 | ensuring that CI (unit tests, sphinx for RTD_, build of wheel and source distribution) 67 | raised no errors. 68 | 69 | #. Bump version number in ``sfs/__init__.py`` 70 | #. Update ``NEWS.rst`` 71 | #. Commit those changes as "Release x.y.z" 72 | #. Create an (annotated) tag with ``git tag -a x.y.z`` 73 | #. Push the commit and the tag to Github 74 | #. The workflow ``.github/workflows/publish.yml`` will handle the build and the 75 | upload to PyPI 76 | #. `Add release notes`_ containing a link to PyPI and the bullet points 77 | from the updated ``NEWS.rst`` 78 | #. Select the new release as the default on RTD_ 79 | 80 | .. _add release notes: https://github.com/sfstoolbox/sfs-python/tags 81 | .. _RTD: https://readthedocs.org/projects/sfs-python/builds/ 82 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | Version History 2 | =============== 3 | 4 | 5 | Version 0.6.3 (2025-10-11): 6 | * Drop support for numpy 1.x 7 | * Register the colormaps ``cividis``, ``inferno``, ``magma``, ``plasma``, 8 | ``viridis``, ``RdBu``, ``coolwarm`` and their reversed versions as ``*_clip``, 9 | e.g. ``viridis_r_clip``, with a dedicated max/min value color clipping 10 | * Use ``viridis_clip`` colormap as default for `sfs.plot2d.level()` 11 | * Use ``coolwarm_clip`` colormap as default for `sfs.plot2d.amplitude()` 12 | * Level contour plot via `sfs.plot2d.level_contour()` 13 | * Add Jupyter notebook for 14 | `2.5D WFS referencing scheme `__ examples 15 | 16 | Version 0.6.2 (2021-06-05): 17 | * build doc fix, use sphinx4, mathjax2, html_css_files 18 | 19 | Version 0.6.1 (2021-06-05): 20 | * New default driving function for `sfs.td.wfs.point_25d()` for reference curve 21 | 22 | Version 0.6.0 (2020-12-01): 23 | * New function `sfs.fd.source.line_bandlimited()` computing the sound field of a spatially bandlimited line source 24 | * Drop support for Python 3.5 25 | 26 | Version 0.5.0 (2019-03-18): 27 | * Switching to separate `sfs.plot2d` and `sfs.plot3d` for plotting functions 28 | * Move `sfs.util.displacement()` to `sfs.fd.displacement()` 29 | * Switch to keyword only arguments 30 | * New default driving function for `sfs.fd.wfs.point_25d()` 31 | * New driving function syntax, e.g. `sfs.fd.wfs.point_25d()` 32 | * Example for the sound field of a pulsating sphere 33 | * Add time domain NFC-HOA driving functions `sfs.td.nfchoa` 34 | * `sfs.fd.synthesize()`, `sfs.td.synthesize()` for soundfield superposition 35 | * Change `sfs.mono` to `sfs.fd` and `sfs.time` to `sfs.td` 36 | * Move source selection helpers to `sfs.util` 37 | * Use `sfs.default` object instead of `sfs.defs` submodule 38 | * Drop support for legacy Python 2.7 39 | 40 | Version 0.4.0 (2018-03-14): 41 | * Driving functions in time domain for a plane wave, point source, and 42 | focused source 43 | * Image source model for a point source in a rectangular room 44 | * `sfs.util.DelayedSignal` class and `sfs.util.as_delayed_signal()` 45 | * Improvements to the documentation 46 | * Start using Jupyter notebooks for examples in documentation 47 | * Spherical Hankel function as `sfs.util.spherical_hn2()` 48 | * Use `scipy.special.spherical_jn`, `scipy.special.spherical_yn` instead of 49 | `scipy.special.sph_jnyn` 50 | * Generalization of the modal order argument in `sfs.mono.source.point_modal()` 51 | * Rename `sfs.util.normal_vector()` to `sfs.util.normalize_vector()` 52 | * Add parameter ``max_order`` to NFCHOA driving functions 53 | * Add ``beta`` parameter to Kaiser tapering window 54 | * Fix clipping problem of sound field plots with matplotlib 2.1 55 | * Fix elevation in `sfs.util.cart2sph()` 56 | * Fix `sfs.tapering.tukey()` for ``alpha=1`` 57 | 58 | Version 0.3.1 (2016-04-08): 59 | * Fixed metadata of release 60 | 61 | Version 0.3.0 (2016-04-08): 62 | * Dirichlet Green's function for the scattering of a line source at an edge 63 | * Driving functions for the synthesis of various virtual source types with 64 | edge-shaped arrays by the equivalent scattering appoach 65 | * Driving functions for the synthesis of focused sources by WFS 66 | 67 | Version 0.2.0 (2015-12-11): 68 | * Ability to calculate and plot particle velocity and displacement fields 69 | * Several function name and parameter name changes 70 | 71 | Version 0.1.1 (2015-10-08): 72 | * Fix missing `sfs.mono` subpackage in PyPI packages 73 | 74 | Version 0.1.0 (2015-09-22): 75 | Initial release. 76 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | Obviously, you'll need Python_. 8 | There are many ways to install Python, 9 | and you can use any way you like, 10 | however, we recommend using uv_ as shown in the steps below. 11 | 12 | You can install ``uv`` with your favorite package manager, 13 | or by one of the other methods described at 14 | https://docs.astral.sh/uv/getting-started/installation/. 15 | 16 | If you don't like ``uv``, no problem! 17 | You can also use Python's official packaging tool pip_ or any other third-party tool, 18 | as long as it can install `the SFS package`_. 19 | 20 | .. _Python: https://www.python.org/ 21 | .. _uv: https://docs.astral.sh/uv/ 22 | .. _pip: https://packaging.python.org/en/latest/tutorials/installing-packages/ 23 | .. _the SFS package: https://pypi.org/project/sfs/ 24 | .. _NumPy: http://www.numpy.org/ 25 | .. _SciPy: https://www.scipy.org/scipylib/ 26 | .. _Matplotlib: https://matplotlib.org/ 27 | 28 | Installation 29 | ------------ 30 | 31 | First, create a new directory wherever you want, change into it, then run:: 32 | 33 | uv init --bare 34 | 35 | This will create a file named ``pyproject.toml`` for you. 36 | Use the ``--help`` flag to see other options. 37 | 38 | The Sound Field Synthesis Toolbox can now be installed with:: 39 | 40 | uv add sfs 41 | 42 | This will automatically install the NumPy_ and SciPy_ libraries as well, 43 | which are needed by the SFS Toolbox. 44 | It will also create a file named ``uv.lock``, which tracks the exact versions 45 | of all installed packages. 46 | 47 | If you want to use the provided functions for plotting sound fields, you'll need 48 | Matplotlib_:: 49 | 50 | uv add matplotlib 51 | 52 | However, since all results are provided as plain NumPy_ arrays, you should also 53 | be able to use any other plotting library of your choice to visualize the sound 54 | fields. 55 | 56 | You might also want to install some other Python-related tools, 57 | e.g. JupyterLab_:: 58 | 59 | uv add jupyterlab 60 | 61 | .. _JupyterLab: https://jupyter.org/ 62 | 63 | You get the gist: whatever you need, just ``uv add ...`` it! 64 | 65 | Once everything is installed, you can start working with the tool of your choice 66 | by simply prefixing it with ``uv run``, for example:: 67 | 68 | uv run jupyter lab 69 | 70 | Similarly, you can launch any other tool, like a text editor, an IDE etc. 71 | 72 | You can also simply create a Python file, let's say ``my_script.py``: 73 | 74 | .. code:: python 75 | 76 | import matplotlib.pyplot as plt 77 | import numpy as np 78 | import sfs 79 | 80 | npw = sfs.util.direction_vector(np.radians(-45)) 81 | f = 300 # Hz 82 | omega = 2 * np.pi * f 83 | 84 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02) 85 | array = sfs.array.circular(N=32, R=1.5) 86 | 87 | d, selection, secondary_source = sfs.fd.wfs.plane_25d( 88 | omega, array.x, array.n, npw) 89 | 90 | p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) 91 | sfs.plot2d.amplitude(p, grid) 92 | sfs.plot2d.loudspeakers(array.x, array.n, selection * array.a, size=0.15) 93 | 94 | plt.show() 95 | 96 | You can then run this script (assuming you installed ``matplotlib`` before) with:: 97 | 98 | uv run my_script.py 99 | 100 | In a similar way, you can run the :doc:`example-python-scripts`. 101 | 102 | If you want to install the latest development version of the SFS Toolbox, have a 103 | look at :doc:`contributing`. 104 | -------------------------------------------------------------------------------- /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 | @phdthesis{Firtha2019, 100 | author = {Firtha, G.}, 101 | title = {{A Generalized Wave Field Synthesis Framework with Application 102 | for Moving Virtual Sources}}, 103 | school = {Budapest University of Technology and Economics}, 104 | year = {2019} 105 | } 106 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | import warnings 7 | warnings.simplefilter("ignore", np.exceptions.ComplexWarning) 8 | 9 | 10 | def particle_displacement(omega, center, radius, amplitude, grid, frames, 11 | figsize=(8, 8), interval=80, blit=True, **kwargs): 12 | """Generate sound particle animation.""" 13 | velocity = sfs.fd.source.pulsating_sphere_velocity( 14 | omega, center, radius, amplitude, grid) 15 | displacement = sfs.fd.displacement(velocity, omega) 16 | phasor = np.exp(1j * 2 * np.pi / frames) 17 | 18 | fig, ax = plt.subplots(figsize=figsize) 19 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 20 | scat = sfs.plot2d.particles(grid + displacement, **kwargs) 21 | 22 | def update_frame_displacement(i): 23 | position = np.real((grid + displacement * phasor**i)) 24 | position = np.column_stack([position[0].flatten(), 25 | position[1].flatten()]) 26 | scat.set_offsets(position) 27 | return [scat] 28 | 29 | return animation.FuncAnimation( 30 | fig, update_frame_displacement, frames, 31 | interval=interval, blit=blit) 32 | 33 | 34 | def particle_velocity(omega, center, radius, amplitude, grid, frames, 35 | figsize=(8, 8), interval=80, blit=True, **kwargs): 36 | """Generate particle velocity animation.""" 37 | velocity = sfs.fd.source.pulsating_sphere_velocity( 38 | omega, center, radius, amplitude, grid) 39 | phasor = np.exp(1j * 2 * np.pi / frames) 40 | 41 | fig, ax = plt.subplots(figsize=figsize) 42 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 43 | quiv = sfs.plot2d.vectors( 44 | velocity, grid, clim=[-omega * amplitude, omega * amplitude], 45 | **kwargs) 46 | 47 | def update_frame_velocity(i): 48 | np.real(quiv.set_UVC(*(velocity[:2] * phasor**i))) 49 | return [quiv] 50 | 51 | return animation.FuncAnimation( 52 | fig, update_frame_velocity, frames, interval=interval, blit=True) 53 | 54 | 55 | def sound_pressure(omega, center, radius, amplitude, grid, frames, 56 | pulsate=False, figsize=(8, 8), interval=80, blit=True, 57 | **kwargs): 58 | """Generate sound pressure animation.""" 59 | pressure = sfs.fd.source.pulsating_sphere( 60 | omega, center, radius, amplitude, grid, inside=pulsate) 61 | phasor = np.exp(1j * 2 * np.pi / frames) 62 | 63 | fig, ax = plt.subplots(figsize=figsize) 64 | im = sfs.plot2d.amplitude(np.real(pressure), grid, **kwargs) 65 | ax.axis([grid[0].min(), grid[0].max(), grid[1].min(), grid[1].max()]) 66 | 67 | def update_frame_pressure(i): 68 | distance = np.linalg.norm(grid) 69 | p = pressure * phasor**i 70 | if pulsate: 71 | p[distance <= radius + amplitude * np.real(phasor**i)] = np.nan 72 | im.set_array(np.real(p)) 73 | return [im] 74 | 75 | return animation.FuncAnimation( 76 | fig, update_frame_pressure, frames, interval=interval, blit=True) 77 | 78 | 79 | if __name__ == '__main__': 80 | 81 | # Pulsating sphere 82 | center = [0, 0, 0] 83 | radius = 0.25 84 | f = 750 # frequency 85 | omega = 2 * np.pi * f # angular frequency 86 | 87 | # Axis limits 88 | xmin, xmax = -1, 1 89 | ymin, ymax = -1, 1 90 | 91 | # Animations 92 | frames = 20 # frames per period 93 | 94 | # Particle displacement 95 | amplitude = 5e-2 # amplitude of the surface displacement 96 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.025) 97 | ani = particle_displacement( 98 | omega, center, radius, amplitude, grid, frames, c='Gray') 99 | ani.save('pulsating_sphere_displacement.gif', dpi=80, writer='imagemagick') 100 | 101 | # Particle velocity 102 | amplitude = 1e-3 # amplitude of the surface displacement 103 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04) 104 | ani = particle_velocity( 105 | omega, center, radius, amplitude, grid, frames) 106 | ani.save('pulsating_sphere_velocity.gif', dpi=80, writer='imagemagick') 107 | 108 | # Sound pressure 109 | amplitude = 1e-6 # amplitude of the surface displacement 110 | impedance_pw = sfs.default.rho0 * sfs.default.c 111 | max_pressure = omega * impedance_pw * amplitude 112 | grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005) 113 | ani = sound_pressure( 114 | omega, center, radius, amplitude, grid, frames, pulsate=True, 115 | colorbar=True, vmin=-max_pressure, vmax=max_pressure) 116 | ani.save('pulsating_sphere_pressure.gif', dpi=80, writer='imagemagick') 117 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/conf.py: -------------------------------------------------------------------------------- 1 | from subprocess import check_output 2 | 3 | import sphinx 4 | 5 | # -- General configuration ------------------------------------------------ 6 | 7 | extensions = [ 8 | 'sphinx.ext.autodoc', 9 | 'sphinx.ext.autosummary', 10 | 'sphinx.ext.viewcode', 11 | 'sphinx.ext.napoleon', # support for NumPy-style docstrings 12 | 'sphinx.ext.intersphinx', 13 | 'sphinx.ext.doctest', 14 | 'sphinxcontrib.bibtex', 15 | 'sphinx.ext.extlinks', 16 | 'matplotlib.sphinxext.plot_directive', 17 | 'nbsphinx', 18 | ] 19 | 20 | bibtex_bibfiles = ['references.bib'] 21 | 22 | nbsphinx_execute_arguments = [ 23 | "--InlineBackend.figure_formats={'svg', 'pdf'}", 24 | "--InlineBackend.rc={'figure.dpi': 96}", 25 | ] 26 | 27 | nbsphinx_thumbnails = { 28 | 'example-python-scripts': '_static/thumbnails/soundfigure_level.png', 29 | 'examples/animations-pulsating-sphere': '_static/thumbnails/pulsating_sphere.gif', 30 | } 31 | 32 | # Tell autodoc that the documentation is being generated 33 | sphinx.SFS_DOCS_ARE_BEING_BUILT = True 34 | 35 | autoclass_content = 'init' 36 | autodoc_member_order = 'bysource' 37 | autodoc_default_options = { 38 | 'members': True, 39 | 'undoc-members': True, 40 | } 41 | 42 | autosummary_generate = ['api'] 43 | 44 | napoleon_google_docstring = False 45 | napoleon_numpy_docstring = True 46 | napoleon_include_private_with_doc = False 47 | napoleon_include_special_with_doc = False 48 | napoleon_use_admonition_for_examples = False 49 | napoleon_use_admonition_for_notes = False 50 | napoleon_use_admonition_for_references = False 51 | napoleon_use_ivar = False 52 | napoleon_use_param = False 53 | napoleon_use_rtype = False 54 | 55 | intersphinx_mapping = { 56 | 'python': ('https://docs.python.org/3/', None), 57 | 'numpy': ('https://docs.scipy.org/doc/numpy/', None), 58 | 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None), 59 | 'matplotlib': ('https://matplotlib.org/', None), 60 | } 61 | 62 | extlinks = {'sfs': ('https://sfs.readthedocs.io/en/3.2/%s', 63 | 'https://sfs.rtfd.io/%s')} 64 | 65 | plot_include_source = True 66 | plot_html_show_source_link = False 67 | plot_html_show_formats = False 68 | plot_pre_code = '' 69 | plot_rcparams = { 70 | 'savefig.bbox': 'tight', 71 | } 72 | plot_formats = ['svg', 'pdf'] 73 | 74 | # use mathjax2 with 75 | # https://github.com/spatialaudio/nbsphinx/issues/572#issuecomment-853389268 76 | # and 'TeX' dictionary 77 | # in future we might switch to mathjax3 once the 78 | # 'begingroup' extension is available 79 | # http://docs.mathjax.org/en/latest/input/tex/extensions/begingroup.html#begingroup 80 | # https://mathjax.github.io/MathJax-demos-web/convert-configuration/convert-configuration.html 81 | mathjax_path = ('https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js' 82 | '?config=TeX-AMS-MML_HTMLorMML') 83 | mathjax2_config = { 84 | 'tex2jax': { 85 | 'inlineMath': [['$', '$'], ['\\(', '\\)']], 86 | 'processEscapes': True, 87 | 'ignoreClass': 'document', 88 | 'processClass': 'math|output_area', 89 | }, 90 | 'TeX': { 91 | 'extensions': ['newcommand.js', 'begingroup.js'], # Support for \gdef 92 | }, 93 | } 94 | 95 | templates_path = ['_template'] 96 | 97 | authors = 'SFS Toolbox Developers' 98 | project = 'SFS Toolbox' 99 | copyright = '2019, ' + authors 100 | 101 | try: 102 | release = check_output(['git', 'describe', '--tags', '--always']) 103 | release = release.decode().strip() 104 | except Exception: 105 | release = '' 106 | 107 | try: 108 | today = check_output(['git', 'show', '-s', '--format=%ad', '--date=short']) 109 | today = today.decode().strip() 110 | except Exception: 111 | today = '' 112 | 113 | exclude_patterns = ['_build', '**/.ipynb_checkpoints'] 114 | 115 | default_role = 'any' 116 | 117 | jinja_define = r""" 118 | {% set docname = 'doc/' + env.doc2path(env.docname, base=False)|string %} 119 | {% set latex_href = ''.join([ 120 | '\href{https://github.com/sfstoolbox/sfs-python/blob/', 121 | env.config.release, 122 | '/', 123 | docname | escape_latex, 124 | '}{\sphinxcode{\sphinxupquote{', 125 | docname | escape_latex, 126 | '}}}', 127 | ]) %} 128 | """ 129 | 130 | nbsphinx_prolog = jinja_define + r""" 131 | .. only:: html 132 | 133 | .. role:: raw-html(raw) 134 | :format: html 135 | 136 | .. nbinfo:: 137 | 138 | This page was generated from `{{ docname }}`__. 139 | Interactive online version: 140 | :raw-html:`Binder badge` 141 | 142 | __ https://github.com/sfstoolbox/sfs-python/blob/ 143 | {{ env.config.release }}/{{ docname }} 144 | 145 | .. raw:: latex 146 | 147 | \nbsphinxstartnotebook{\scriptsize\noindent\strut 148 | \textcolor{gray}{The following section was generated from {{ latex_href }} 149 | \dotfill}} 150 | """ 151 | 152 | nbsphinx_epilog = jinja_define + r""" 153 | .. raw:: latex 154 | 155 | \nbsphinxstopnotebook{\scriptsize\noindent\strut 156 | \textcolor{gray}{\dotfill\ {{ latex_href }} ends here.}} 157 | """ 158 | 159 | 160 | # -- Options for HTML output ---------------------------------------------- 161 | 162 | html_css_files = ['css/title.css'] 163 | 164 | html_theme = 'sphinx_rtd_theme' 165 | html_theme_options = { 166 | 'collapse_navigation': False, 167 | 'navigation_with_keys': True, 168 | } 169 | 170 | html_title = project + ", version " + release 171 | 172 | html_static_path = ['_static'] 173 | 174 | html_show_sourcelink = True 175 | html_sourcelink_suffix = '' 176 | 177 | htmlhelp_basename = 'SFS' 178 | 179 | html_scaled_image_link = False 180 | 181 | # -- Options for LaTeX output --------------------------------------------- 182 | 183 | latex_elements = { 184 | 'papersize': 'a4paper', 185 | 'printindex': '', 186 | 'sphinxsetup': r""" 187 | VerbatimColor={HTML}{F5F5F5}, 188 | VerbatimBorderColor={HTML}{E0E0E0}, 189 | noteBorderColor={HTML}{E0E0E0}, 190 | noteborder=1.5pt, 191 | warningBorderColor={HTML}{E0E0E0}, 192 | warningborder=1.5pt, 193 | warningBgColor={HTML}{FBFBFB}, 194 | """, 195 | 'preamble': r""" 196 | \usepackage[sc,osf]{mathpazo} 197 | \linespread{1.05} % see http://www.tug.dk/FontCatalogue/urwpalladio/ 198 | \renewcommand{\sfdefault}{pplj} % Palatino instead of sans serif 199 | \IfFileExists{zlmtt.sty}{ 200 | \usepackage[light,scaled=1.05]{zlmtt} % light typewriter font from lmodern 201 | }{ 202 | \renewcommand{\ttdefault}{lmtt} % typewriter font from lmodern 203 | } 204 | """, 205 | } 206 | 207 | latex_documents = [('index', 'SFS.tex', project, authors, 'howto')] 208 | 209 | latex_show_urls = 'footnote' 210 | 211 | latex_domain_indices = False 212 | 213 | 214 | # -- Options for epub output ---------------------------------------------- 215 | 216 | epub_author = authors 217 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /doc/examples/wfs-referencing.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# 2.5D WFS Referencing Schemes\n", 8 | "\n", 9 | "This notebook illustrates the usage of the SFS toolbox for the simulation of different 2.5D WFS referencing schemes.\n", 10 | "A dedicated referencing scheme allows correct amplitude alongside a reference contour within the listening area.\n", 11 | "For the theory please check\n", 12 | "Ch 3.1-3.3 in (Start1997),\n", 13 | "Ch. 4.1.3 in (Firtha2019) and\n", 14 | "(Firtha2017)." 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "import matplotlib.pyplot as plt\n", 24 | "import numpy as np\n", 25 | "import sfs" 26 | ] 27 | }, 28 | { 29 | "cell_type": "markdown", 30 | "metadata": {}, 31 | "source": [ 32 | "## Circular loudspeaker arrays" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "metadata": {}, 39 | "outputs": [], 40 | "source": [ 41 | "R = 1.5 # radius [m] of circular loudspeaker array\n", 42 | "N = 64 # loudspeakers\n", 43 | "array = sfs.array.circular(N=N, R=R)\n", 44 | "grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.02)\n", 45 | "\n", 46 | "xs = -4, 0, 0 # virtual point source on negative x-axis\n", 47 | "wavelength = 1 / 4 # m" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": null, 53 | "metadata": {}, 54 | "outputs": [], 55 | "source": [ 56 | "def sound_field(d, selection, array, secondary_source, grid, xref):\n", 57 | " p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid)\n", 58 | " fig, [ax_amp, ax_lvl] = plt.subplots(2, 1, sharex=True)\n", 59 | " fig.set_figheight(fig.get_figwidth() * 3/2)\n", 60 | " sfs.plot2d.amplitude(p, grid, vmax=2, vmin=-2, ax=ax_amp)\n", 61 | " sfs.plot2d.level(p, grid, vmax=12, vmin=-12, ax=ax_lvl)\n", 62 | " sfs.plot2d.level_contour(p, grid, levels=[0], colors='w', ax=ax_lvl)\n", 63 | " xref = np.broadcast_to(xref, array.x.shape)\n", 64 | " for ax in ax_amp, ax_lvl:\n", 65 | " sfs.plot2d.loudspeakers(array.x, array.n, selection, size=0.125, ax=ax)\n", 66 | " ax_lvl.scatter(*xref[selection, :2].T, marker='o', s=20, c='lightsalmon',\n", 67 | " zorder=3)\n", 68 | " plt.tight_layout()\n", 69 | " return p" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": null, 75 | "metadata": {}, 76 | "outputs": [], 77 | "source": [ 78 | "xs = sfs.util.asarray_of_rows(xs)\n", 79 | "frequency = sfs.default.c / wavelength # Hz\n", 80 | "omega = 2 * np.pi * frequency # rad/s\n", 81 | "normalize_gain = 4 * np.pi * np.linalg.norm(xs)" 82 | ] 83 | }, 84 | { 85 | "cell_type": "markdown", 86 | "metadata": {}, 87 | "source": [ 88 | "### Line as reference contour\n", 89 | "\n", 90 | "The reference contour is calculated according to eqs. (24), (31), (52) in (Firtha2017). \n", 91 | "The code assumes a virtual point source on x-axis.\n", 92 | "The reference contour is a straight line on y-axis." 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": null, 98 | "metadata": {}, 99 | "outputs": [], 100 | "source": [ 101 | "xref_line = 0\n", 102 | "cosbeta = (array.n @ [1, 0, 0]).reshape(-1, 1)\n", 103 | "xref = array.x + \\\n", 104 | " (xs - array.x) * (xref_line + R * cosbeta) / (xs[0, 0] + R * cosbeta)\n", 105 | "\n", 106 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(\n", 107 | " omega, array.x, array.n, xs, xref=xref)\n", 108 | "p_line = sound_field(\n", 109 | " d * normalize_gain, selection, array, secondary_source, grid, xref)" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "metadata": {}, 115 | "source": [ 116 | "The level plot includes a white 0 dB isobar curve.\n", 117 | "The orange-like dots represent the stationary phase points at which amplitude correct synthesis is to be expected.\n", 118 | "These dots shape the line reference contour.\n", 119 | "Note that the isobar curve is not perfectly aligned along line reference contour due to diffraction artifacts." 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": {}, 125 | "source": [ 126 | "### Circle as reference contour\n", 127 | "\n", 128 | "This reference contour is a circle with its origin at xs and a radius |xs|. This contour is obtained with more straightforward vector calculus than the previous example." 129 | ] 130 | }, 131 | { 132 | "cell_type": "code", 133 | "execution_count": null, 134 | "metadata": {}, 135 | "outputs": [], 136 | "source": [ 137 | "# reference contour is a circle with origin xs and radius |xs|\n", 138 | "xref_dist = np.linalg.norm(xs)\n", 139 | "# calc reference contour xref(x0), cf. [Firtha19, eq. (24), (31)]\n", 140 | "xref = xs + xref_dist * sfs.util.normalize_rows(array.x - xs)\n", 141 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(\n", 142 | " omega, array.x, array.n, xs, xref=xref)\n", 143 | "p_circ = sound_field(\n", 144 | " d * normalize_gain, selection, array, secondary_source, grid, xref)" 145 | ] 146 | }, 147 | { 148 | "cell_type": "markdown", 149 | "metadata": {}, 150 | "source": [ 151 | "### Reference point" 152 | ] 153 | }, 154 | { 155 | "cell_type": "markdown", 156 | "metadata": {}, 157 | "source": [ 158 | "The default handling in\n", 159 | "`point_25d(omega, x0, n0, xs, xref=[0, 0, 0], c=None, omalias=None)`\n", 160 | "uses just a reference point xref, and more specifically this default point is the origin of the coordinate system.\n", 161 | "This single point xref, the virtual source position xs and the loudspeaker array geometry together determine the reference contour without further user access to it.\n", 162 | "This handling is chosen due to convenience and practical relevance when working with circular loudspeaker arrays.\n", 163 | "\n", 164 | "The example below shows the resulting reference contour for the default case.\n", 165 | "In the example it looks similar to the line reference contour, but is in general not exactly the same.\n", 166 | "For example, please try a virtual point source that is far away from the array." 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": null, 172 | "metadata": {}, 173 | "outputs": [], 174 | "source": [ 175 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(\n", 176 | " omega, array.x, array.n, xs)\n", 177 | "p_point = sound_field(\n", 178 | " d * normalize_gain, selection, array, secondary_source,\n", 179 | " grid, [0, 0, 0])" 180 | ] 181 | }, 182 | { 183 | "cell_type": "markdown", 184 | "metadata": {}, 185 | "source": [ 186 | "Points with amplitude correct synthesis need to be stationary phase points, theoretically.\n", 187 | "Within the listening area, these points are found on rays that start at the virtual point source and intersect with active loudspeakers.\n", 188 | "The chosen points together shall shape a smooth contour, i.e. the reference contour.\n", 189 | "\n", 190 | "The example below shows a reference point xref that does not meet any ray (the gray lines in the level plot) alongside the stationary phase holds with its corresponding loudspeaker.\n", 191 | "\n", 192 | "The single point referencing scheme results in 0 dB isobar curve that closely passes the chosen xref point.\n", 193 | "In practice this typically works with sufficient precision once the position of xref is appropriately chosen (i.e. not too close, not too far, not to off-center from the active loudspeakers etc.)." 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": null, 199 | "metadata": {}, 200 | "outputs": [], 201 | "source": [ 202 | "xref = 0, 0.1175, 0 # intentionally no stationary phase point\n", 203 | "# we don't forget to normalize the point source's amplitude\n", 204 | "# to this new reference point:\n", 205 | "normalize_gain = 4 * np.pi * np.linalg.norm(xs - xref)\n", 206 | "d, selection, secondary_source = sfs.fd.wfs.point_25d(\n", 207 | " omega, array.x, array.n, xs, xref=xref)\n", 208 | "p_point = sound_field(\n", 209 | " d * normalize_gain, selection, array, secondary_source,\n", 210 | " grid, xref)\n", 211 | "\n", 212 | "# plot stationary phase rays\n", 213 | "# one ray connects the virtual source with one activate loudspeaker\n", 214 | "spa = array.x + 3*R * sfs.util.normalize_rows(array.x - xs)\n", 215 | "plt.plot(\n", 216 | " np.vstack((array.x[selection, 0], spa[selection, 0])),\n", 217 | " np.vstack((array.x[selection, 1], spa[selection, 1])),\n", 218 | " color='gray')\n", 219 | "plt.xlim(-2, 2)\n", 220 | "plt.ylim(-2, 2);" 221 | ] 222 | }, 223 | { 224 | "cell_type": "markdown", 225 | "metadata": {}, 226 | "source": [ 227 | "A plane wave like sound field, e.g. by setting `xs = -100, 0, 0`, for all above examples reveals some further interesting implications of the different referencing schemes." 228 | ] 229 | } 230 | ], 231 | "metadata": { 232 | "kernelspec": { 233 | "display_name": "sfs", 234 | "language": "python", 235 | "name": "python3" 236 | }, 237 | "language_info": { 238 | "codemirror_mode": { 239 | "name": "ipython", 240 | "version": 3 241 | }, 242 | "file_extension": ".py", 243 | "mimetype": "text/x-python", 244 | "name": "python", 245 | "nbconvert_exporter": "python", 246 | "pygments_lexer": "ipython3", 247 | "version": "3.13.7" 248 | } 249 | }, 250 | "nbformat": 4, 251 | "nbformat_minor": 2 252 | } 253 | -------------------------------------------------------------------------------- /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 = sfs.util.as_xyz_components(hex_grid([xmin, xmax],\n", 151 | " [ymin, ymax],\n", 152 | " 0.0125,\n", 153 | " 'vertical'))\n", 154 | "ani = animation.particle_displacement(\n", 155 | " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", 156 | "plt.close()\n", 157 | "HTML(ani.to_jshtml())" 158 | ] 159 | }, 160 | { 161 | "cell_type": "markdown", 162 | "metadata": {}, 163 | "source": [ 164 | "Another one using a random grid." 165 | ] 166 | }, 167 | { 168 | "cell_type": "code", 169 | "execution_count": null, 170 | "metadata": {}, 171 | "outputs": [], 172 | "source": [ 173 | "grid = sfs.util.as_xyz_components([np.random.uniform(xmin, xmax, 4000),\n", 174 | " np.random.uniform(ymin, ymax, 4000),\n", 175 | " 0])\n", 176 | "ani = animation.particle_displacement(\n", 177 | " omega, center, radius, amplitude, grid, frames, figsize, c='Gray')\n", 178 | "plt.close()\n", 179 | "HTML(ani.to_jshtml())" 180 | ] 181 | }, 182 | { 183 | "cell_type": "markdown", 184 | "metadata": {}, 185 | "source": [ 186 | "Each grid has its strengths and weaknesses. Please refer to the\n", 187 | "[on-line discussion](https://github.com/sfstoolbox/sfs-python/pull/69#issuecomment-468405536)." 188 | ] 189 | }, 190 | { 191 | "cell_type": "markdown", 192 | "metadata": {}, 193 | "source": [ 194 | "## Particle Velocity" 195 | ] 196 | }, 197 | { 198 | "cell_type": "code", 199 | "execution_count": null, 200 | "metadata": {}, 201 | "outputs": [], 202 | "source": [ 203 | "amplitude = 1e-3\n", 204 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.04)\n", 205 | "ani = animation.particle_velocity(\n", 206 | " omega, center, radius, amplitude, grid, frames, figsize)\n", 207 | "plt.close()\n", 208 | "HTML(ani.to_jshtml())" 209 | ] 210 | }, 211 | { 212 | "cell_type": "markdown", 213 | "metadata": {}, 214 | "source": [ 215 | "Please notice that the amplitude of the pulsating motion is adjusted\n", 216 | "so that the arrows are neither too short nor too long.\n", 217 | "This kind of compromise is inevitable since\n", 218 | "\n", 219 | "$$\n", 220 | "\\text{(particle velocity)} = \\text{i} \\omega \\times (\\text{amplitude}),\n", 221 | "$$\n", 222 | "\n", 223 | "thus the absolute value of particle velocity is usually\n", 224 | "much larger than that of amplitude.\n", 225 | "It should be also kept in mind that the hole in the middle\n", 226 | "does not visualizes the exact motion of the pulsating sphere.\n", 227 | "According to the above equation, the actual amplitude should be\n", 228 | "much smaller than the arrow lengths.\n", 229 | "The changing rate of its size is also two times higher than the original frequency." 230 | ] 231 | }, 232 | { 233 | "cell_type": "markdown", 234 | "metadata": {}, 235 | "source": [ 236 | "## Sound Pressure" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": null, 242 | "metadata": {}, 243 | "outputs": [], 244 | "source": [ 245 | "amplitude = 0.05\n", 246 | "impedance_pw = sfs.default.rho0 * sfs.default.c\n", 247 | "max_pressure = omega * impedance_pw * amplitude\n", 248 | "\n", 249 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", 250 | "ani = animation.sound_pressure(\n", 251 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 252 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 253 | "plt.close()\n", 254 | "HTML(ani.to_jshtml())" 255 | ] 256 | }, 257 | { 258 | "cell_type": "markdown", 259 | "metadata": {}, 260 | "source": [ 261 | "Notice that the sound pressure exceeds\n", 262 | "the atmospheric pressure ($\\approx 10^5$ Pa), which of course makes no sense.\n", 263 | "This is due to the large amplitude (50 mm) of the pulsating motion.\n", 264 | "It was chosen to better visualize the particle movements\n", 265 | "in the earlier animations.\n", 266 | "\n", 267 | "For 1 kHz, the amplitude corresponding to a moderate sound pressure,\n", 268 | "let say 1 Pa, is in the order of micrometer.\n", 269 | "As it is very small compared to the corresponding wavelength (0.343 m),\n", 270 | "the movement of the particles and the spatial structure of the sound field\n", 271 | "cannot be observed simultaneously.\n", 272 | "Furthermore, at high frequencies, the sound pressure\n", 273 | "for a given particle displacement scales with the frequency.\n", 274 | "The smaller wavelength (higher frequency) we choose,\n", 275 | "it is more likely to end up with a prohibitively high sound pressure.\n", 276 | "\n", 277 | "In the following examples, the amplitude is set to a realistic value 1 $\\mu$m.\n", 278 | "Notice that the pulsating motion of the sphere is no more visible." 279 | ] 280 | }, 281 | { 282 | "cell_type": "code", 283 | "execution_count": null, 284 | "metadata": {}, 285 | "outputs": [], 286 | "source": [ 287 | "amplitude = 1e-6\n", 288 | "impedance_pw = sfs.default.rho0 * sfs.default.c\n", 289 | "max_pressure = omega * impedance_pw * amplitude\n", 290 | "\n", 291 | "grid = sfs.util.xyz_grid([xmin, xmax], [ymin, ymax], 0, spacing=0.005)\n", 292 | "ani = animation.sound_pressure(\n", 293 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 294 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 295 | "plt.close()\n", 296 | "HTML(ani.to_jshtml())" 297 | ] 298 | }, 299 | { 300 | "cell_type": "markdown", 301 | "metadata": {}, 302 | "source": [ 303 | "Let's zoom in closer to the boundary of the sphere." 304 | ] 305 | }, 306 | { 307 | "cell_type": "code", 308 | "execution_count": null, 309 | "metadata": {}, 310 | "outputs": [], 311 | "source": [ 312 | "L = 10 * amplitude\n", 313 | "xmin_zoom, xmax_zoom = radius - L, radius + L\n", 314 | "ymin_zoom, ymax_zoom = -L, L" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": null, 320 | "metadata": {}, 321 | "outputs": [], 322 | "source": [ 323 | "grid = sfs.util.xyz_grid([xmin_zoom, xmax_zoom], [ymin_zoom, ymax_zoom], 0, spacing=L / 100)\n", 324 | "ani = animation.sound_pressure(\n", 325 | " omega, center, radius, amplitude, grid, frames, pulsate=True,\n", 326 | " figsize=figsize, vmin=-max_pressure, vmax=max_pressure)\n", 327 | "plt.close()\n", 328 | "HTML(ani.to_jshtml())" 329 | ] 330 | }, 331 | { 332 | "cell_type": "markdown", 333 | "metadata": {}, 334 | "source": [ 335 | "This shows how the vibrating motion of the sphere (left half)\n", 336 | "changes the sound pressure of the surrounding air (right half).\n", 337 | "Notice that the sound pressure increases/decreases (more red/blue)\n", 338 | "when the surface accelerates/decelerates." 339 | ] 340 | } 341 | ], 342 | "metadata": { 343 | "kernelspec": { 344 | "display_name": "Python [default]", 345 | "language": "python", 346 | "name": "python3" 347 | }, 348 | "language_info": { 349 | "codemirror_mode": { 350 | "name": "ipython", 351 | "version": 3 352 | }, 353 | "file_extension": ".py", 354 | "mimetype": "text/x-python", 355 | "name": "python", 356 | "nbconvert_exporter": "python", 357 | "pygments_lexer": "ipython3", 358 | "version": "3.5.6" 359 | } 360 | }, 361 | "nbformat": 4, 362 | "nbformat_minor": 2 363 | } 364 | -------------------------------------------------------------------------------- /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, firwin 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 | # either with: signal = unit_impulse(512), fs 30 | # or with: bandlimited Dirac, i.e. a linear-phase lowpass FIR 31 | Nlp = 2**10 + 1 32 | tlp = (Nlp/2) / fs 33 | signal = firwin(numtaps=Nlp, 34 | cutoff=16000, 35 | window=('kaiser', 4), 36 | pass_zero='lowpass', 37 | scale=True, fs=fs), fs 38 | 39 | # Circular loudspeaker array 40 | N = 32 # number of loudspeakers 41 | R = 1.5 # radius 42 | array = sfs.array.circular(N, R) 43 | 44 | grid = sfs.util.xyz_grid([-2, 2], [-2, 2], 0, spacing=0.01) 45 | 46 | def plot(d, selection, secondary_source, t=0): 47 | p = sfs.td.synthesize(d, selection, array, secondary_source, grid=grid, 48 | observation_time=t) 49 | sfs.plot2d.level(p, grid, cmap='Blues') 50 | sfs.plot2d.loudspeakers(array.x, array.n, 51 | selection * array.a, size=0.15) 52 | 53 | """ 54 | import numpy as _np 55 | 56 | from . import apply_delays as _apply_delays 57 | from . import secondary_source_point as _secondary_source_point 58 | from .. import default as _default 59 | from .. import util as _util 60 | 61 | 62 | def plane_25d(x0, n0, n=[0, 1, 0], xref=[0, 0, 0], c=None): 63 | r"""Plane wave model by 2.5-dimensional WFS. 64 | 65 | Parameters 66 | ---------- 67 | x0 : (N, 3) array_like 68 | Sequence of secondary source positions. 69 | n0 : (N, 3) array_like 70 | Sequence of secondary source orientations. 71 | n : (3,) array_like, optional 72 | Normal vector (propagation direction) of synthesized plane wave. 73 | xref : (3,) array_like, optional 74 | Reference position 75 | c : float, optional 76 | Speed of sound 77 | 78 | Returns 79 | ------- 80 | delays : (N,) numpy.ndarray 81 | Delays of secondary sources in seconds. 82 | weights : (N,) numpy.ndarray 83 | Weights of secondary sources. 84 | selection : (N,) numpy.ndarray 85 | Boolean array containing ``True`` or ``False`` depending on 86 | whether the corresponding secondary source is "active" or not. 87 | secondary_source_function : callable 88 | A function that can be used to create the sound field of a 89 | single secondary source. See `sfs.td.synthesize()`. 90 | 91 | Notes 92 | ----- 93 | 2.5D correction factor 94 | 95 | .. math:: 96 | 97 | g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} 98 | 99 | d using a plane wave as source model 100 | 101 | .. math:: 102 | 103 | d_{2.5D}(x_0,t) = 104 | 2 g_0 \scalarprod{n}{n_0} 105 | \dirac{t - \frac{1}{c} \scalarprod{n}{x_0}} \ast_t h(t) 106 | 107 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 108 | 109 | See :sfs:`d_wfs/#equation-td-wfs-plane-25d` 110 | 111 | Examples 112 | -------- 113 | .. plot:: 114 | :context: close-figs 115 | 116 | delays, weights, selection, secondary_source = \ 117 | sfs.td.wfs.plane_25d(array.x, array.n, npw) 118 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 119 | # note that WFS prefilter is not included 120 | plot(d, selection, secondary_source, t=tlp) 121 | 122 | """ 123 | if c is None: 124 | c = _default.c 125 | x0 = _util.asarray_of_rows(x0) 126 | n0 = _util.asarray_of_rows(n0) 127 | n = _util.normalize_vector(n) 128 | xref = _util.asarray_1d(xref) 129 | g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) 130 | delays = _util._inner1d(n, x0) / c 131 | weights = 2 * g0 * _util._inner1d(n, n0) 132 | selection = _util.source_selection_plane(n0, n) 133 | return delays, weights, selection, _secondary_source_point(c) 134 | 135 | 136 | def point_25d(x0, n0, xs, xref=[0, 0, 0], c=None): 137 | r"""Driving function for 2.5-dimensional WFS of a virtual point source. 138 | 139 | .. versionchanged:: 0.6.1 140 | see notes, old handling of `point_25d()` is now `point_25d_legacy()` 141 | 142 | Parameters 143 | ---------- 144 | x0 : (N, 3) array_like 145 | Sequence of secondary source positions. 146 | n0 : (N, 3) array_like 147 | Sequence of secondary source orientations. 148 | xs : (3,) array_like 149 | Virtual source position. 150 | xref : (N, 3) array_like or (3,) array_like 151 | Contour xref(x0) for amplitude correct synthesis, reference point xref. 152 | c : float, optional 153 | Speed of sound 154 | 155 | Returns 156 | ------- 157 | delays : (N,) numpy.ndarray 158 | Delays of secondary sources in seconds. 159 | weights: (N,) numpy.ndarray 160 | Weights of secondary sources. 161 | selection : (N,) numpy.ndarray 162 | Boolean array containing ``True`` or ``False`` depending on 163 | whether the corresponding secondary source is "active" or not. 164 | secondary_source_function : callable 165 | A function that can be used to create the sound field of a 166 | single secondary source. See `sfs.td.synthesize()`. 167 | 168 | Notes 169 | ----- 170 | Eq. (2.138) in :cite:`Schultz2016`: 171 | 172 | .. math:: 173 | 174 | d_{2.5D}(x_0, x_{ref}, t) = 175 | \sqrt{8\pi} 176 | \frac{\scalarprod{(x_0 - x_s)}{n_0}}{|x_0 - x_s|} 177 | \sqrt{\frac{|x_0 - x_s||x_0 - x_{ref}|}{|x_0 - x_s|+|x_0 - x_{ref}|}} 178 | \cdot 179 | \frac{\dirac{t - \frac{|x_0 - x_s|}{c}}}{4\pi |x_0 - x_s|} \ast_t h(t) 180 | 181 | .. math:: 182 | 183 | h(t) = F^{-1}(\sqrt{\frac{j \omega}{c}}) 184 | 185 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 186 | 187 | `point_25d()` derives WFS from 3D to 2.5D via the stationary phase 188 | approximation approach (i.e. the Delft approach). 189 | The theoretical link of `point_25d()` and `point_25d_legacy()` was 190 | introduced as *unified WFS framework* in :cite:`Firtha2017`. 191 | 192 | Examples 193 | -------- 194 | .. plot:: 195 | :context: close-figs 196 | 197 | delays, weights, selection, secondary_source = \ 198 | sfs.td.wfs.point_25d(array.x, array.n, xs) 199 | weights *= 4*np.pi*rs # normalize 200 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 201 | # note that WFS prefilter is not included 202 | plot(d, selection, secondary_source, t=ts+tlp) 203 | 204 | """ 205 | if c is None: 206 | c = _default.c 207 | x0 = _util.asarray_of_rows(x0) 208 | n0 = _util.asarray_of_rows(n0) 209 | xs = _util.asarray_1d(xs) 210 | xref = _util.asarray_of_rows(xref) 211 | 212 | x0xs = x0 - xs 213 | x0xref = x0 - xref 214 | x0xs_n = _np.linalg.norm(x0xs, axis=1) 215 | x0xref_n = _np.linalg.norm(x0xref, axis=1) 216 | 217 | g0 = 1/(_np.sqrt(2*_np.pi)*x0xs_n**2) 218 | g0 *= _np.sqrt((x0xs_n*x0xref_n)/(x0xs_n+x0xref_n)) 219 | 220 | delays = x0xs_n/c 221 | weights = g0 * _util._inner1d(x0xs, n0) 222 | selection = _util.source_selection_point(n0, x0, xs) 223 | return delays, weights, selection, _secondary_source_point(c) 224 | 225 | 226 | def point_25d_legacy(x0, n0, xs, xref=[0, 0, 0], c=None): 227 | r"""Driving function for 2.5-dimensional WFS of a virtual point source. 228 | 229 | .. versionadded:: 0.6.1 230 | `point_25d()` was renamed to `point_25d_legacy()` (and a new 231 | function with the name `point_25d()` was introduced). See notes below 232 | for further details. 233 | 234 | Parameters 235 | ---------- 236 | x0 : (N, 3) array_like 237 | Sequence of secondary source positions. 238 | n0 : (N, 3) array_like 239 | Sequence of secondary source orientations. 240 | xs : (3,) array_like 241 | Virtual source position. 242 | xref : (3,) array_like, optional 243 | Reference position 244 | c : float, optional 245 | Speed of sound 246 | 247 | Returns 248 | ------- 249 | delays : (N,) numpy.ndarray 250 | Delays of secondary sources in seconds. 251 | weights: (N,) numpy.ndarray 252 | Weights of secondary sources. 253 | selection : (N,) numpy.ndarray 254 | Boolean array containing ``True`` or ``False`` depending on 255 | whether the corresponding secondary source is "active" or not. 256 | secondary_source_function : callable 257 | A function that can be used to create the sound field of a 258 | single secondary source. See `sfs.td.synthesize()`. 259 | 260 | Notes 261 | ----- 262 | 2.5D correction factor 263 | 264 | .. math:: 265 | 266 | g_0 = \sqrt{2 \pi |x_\mathrm{ref} - x_0|} 267 | 268 | 269 | d using a point source as source model 270 | 271 | .. math:: 272 | 273 | d_{2.5D}(x_0,t) = 274 | \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} 275 | {2\pi |x_0 - x_s|^{3/2}} 276 | \dirac{t - \frac{|x_0 - x_s|}{c}} \ast_t h(t) 277 | 278 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 279 | 280 | See :sfs:`d_wfs/#equation-td-wfs-point-25d` 281 | 282 | `point_25d_legacy()` derives 2.5D WFS from the 2D 283 | Neumann-Rayleigh integral (i.e. the approach by Rabenstein & Spors), cf. 284 | :cite:`Spors2008`. 285 | The theoretical link of `point_25d()` and `point_25d_legacy()` was 286 | introduced as *unified WFS framework* in :cite:`Firtha2017`. 287 | 288 | Examples 289 | -------- 290 | .. plot:: 291 | :context: close-figs 292 | 293 | delays, weights, selection, secondary_source = \ 294 | sfs.td.wfs.point_25d(array.x, array.n, xs) 295 | weights *= 4*np.pi*rs # normalize 296 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 297 | # note that WFS prefilter is not included 298 | plot(d, selection, secondary_source, t=ts+tlp) 299 | 300 | """ 301 | if c is None: 302 | c = _default.c 303 | x0 = _util.asarray_of_rows(x0) 304 | n0 = _util.asarray_of_rows(n0) 305 | xs = _util.asarray_1d(xs) 306 | xref = _util.asarray_1d(xref) 307 | g0 = _np.sqrt(2 * _np.pi * _np.linalg.norm(xref - x0, axis=1)) 308 | ds = x0 - xs 309 | r = _np.linalg.norm(ds, axis=1) 310 | delays = r/c 311 | weights = g0 * _util._inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) 312 | selection = _util.source_selection_point(n0, x0, xs) 313 | return delays, weights, selection, _secondary_source_point(c) 314 | 315 | 316 | def focused_25d(x0, n0, xs, ns, xref=[0, 0, 0], c=None): 317 | r"""Point source by 2.5-dimensional WFS. 318 | 319 | Parameters 320 | ---------- 321 | x0 : (N, 3) array_like 322 | Sequence of secondary source positions. 323 | n0 : (N, 3) array_like 324 | Sequence of secondary source orientations. 325 | xs : (3,) array_like 326 | Virtual source position. 327 | ns : (3,) array_like 328 | Normal vector (propagation direction) of focused source. 329 | This is used for secondary source selection, 330 | see `sfs.util.source_selection_focused()`. 331 | xref : (3,) array_like, optional 332 | Reference position 333 | c : float, optional 334 | Speed of sound 335 | 336 | Returns 337 | ------- 338 | delays : (N,) numpy.ndarray 339 | Delays of secondary sources in seconds. 340 | weights: (N,) numpy.ndarray 341 | Weights of secondary sources. 342 | selection : (N,) numpy.ndarray 343 | Boolean array containing ``True`` or ``False`` depending on 344 | whether the corresponding secondary source is "active" or not. 345 | secondary_source_function : callable 346 | A function that can be used to create the sound field of a 347 | single secondary source. See `sfs.td.synthesize()`. 348 | 349 | Notes 350 | ----- 351 | 2.5D correction factor 352 | 353 | .. math:: 354 | 355 | g_0 = \sqrt{\frac{|x_\mathrm{ref} - x_0|} 356 | {|x_0-x_s| + |x_\mathrm{ref}-x_0|}} 357 | 358 | 359 | d using a point source as source model 360 | 361 | .. math:: 362 | 363 | d_{2.5D}(x_0,t) = 364 | \frac{g_0 \scalarprod{(x_0 - x_s)}{n_0}} 365 | {|x_0 - x_s|^{3/2}} 366 | \dirac{t + \frac{|x_0 - x_s|}{c}} \ast_t h(t) 367 | 368 | with wfs(2.5D) prefilter h(t), which is not implemented yet. 369 | 370 | See :sfs:`d_wfs/#equation-td-wfs-focused-25d` 371 | 372 | Examples 373 | -------- 374 | .. plot:: 375 | :context: close-figs 376 | 377 | delays, weights, selection, secondary_source = \ 378 | sfs.td.wfs.focused_25d(array.x, array.n, xf, nf) 379 | weights *= 4*np.pi*rs # normalize 380 | d = sfs.td.wfs.driving_signals(delays, weights, signal) 381 | # note that WFS prefilter is not included 382 | plot(d, selection, secondary_source, t=tf+tlp) 383 | 384 | """ 385 | if c is None: 386 | c = _default.c 387 | x0 = _util.asarray_of_rows(x0) 388 | n0 = _util.asarray_of_rows(n0) 389 | xs = _util.asarray_1d(xs) 390 | xref = _util.asarray_1d(xref) 391 | ds = x0 - xs 392 | r = _np.linalg.norm(ds, axis=1) 393 | g0 = _np.sqrt(_np.linalg.norm(xref - x0, axis=1) 394 | / (_np.linalg.norm(xref - x0, axis=1) + r)) 395 | delays = -r/c 396 | weights = g0 * _util._inner1d(ds, n0) / (2 * _np.pi * r**(3/2)) 397 | selection = _util.source_selection_focused(ns, x0, xs) 398 | return delays, weights, selection, _secondary_source_point(c) 399 | 400 | 401 | def driving_signals(delays, weights, signal): 402 | """Get driving signals per secondary source. 403 | 404 | Returned signals are the delayed and weighted mono input signal 405 | (with N samples) per channel (C). 406 | 407 | Parameters 408 | ---------- 409 | delays : (C,) array_like 410 | Delay in seconds for each channel, negative values allowed. 411 | weights : (C,) array_like 412 | Amplitude weighting factor for each channel. 413 | signal : (N,) array_like + float 414 | Excitation signal consisting of (mono) audio data and a sampling 415 | rate (in Hertz). A `DelayedSignal` object can also be used. 416 | 417 | Returns 418 | ------- 419 | `DelayedSignal` 420 | A tuple containing the driving signals (in a `numpy.ndarray` 421 | with shape ``(N, C)``), followed by the sampling rate (in Hertz) 422 | and a (possibly negative) time offset (in seconds). 423 | 424 | """ 425 | delays = _util.asarray_1d(delays) 426 | weights = _util.asarray_1d(weights) 427 | data, samplerate, signal_offset = _apply_delays(signal, delays) 428 | return _util.DelayedSignal(data * weights, samplerate, signal_offset) 429 | -------------------------------------------------------------------------------- /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 | .. plot:: 12 | :context: reset 13 | 14 | import matplotlib.pyplot as plt 15 | import numpy as np 16 | import sfs 17 | 18 | plt.rcParams['figure.figsize'] = 6, 6 19 | 20 | f = 343 # Hz 21 | omega = 2 * np.pi * f # rad / s 22 | k = omega / sfs.default.c # rad / m 23 | 24 | npw = sfs.util.direction_vector(np.radians(-45)) 25 | xs = np.array([-0.828427, 0.828427, 0]) 26 | 27 | grid = sfs.util.xyz_grid([-1, 5], [-5, 1], 0, spacing=0.02) 28 | dx, L = 0.05, 4 # m 29 | N = int(L / dx) 30 | array = sfs.array.edge(N, dx, center=[0, 0, 0], 31 | orientation=[0, -1, 0]) 32 | 33 | xref = np.array([2, -2, 0]) 34 | x_norm = np.linalg.norm(xs - xref) 35 | norm_ls = (np.sqrt(8 * np.pi * k * x_norm) * 36 | np.exp(+1j * np.pi / 4) * 37 | np.exp(-1j * k * x_norm)) 38 | norm_pw = np.exp(+1j * 4*np.pi*np.sqrt(2)) 39 | 40 | 41 | def plot(d, selection, secondary_source, norm_ref): 42 | # the series expansion is numerically tricky, hence 43 | d = np.nan_to_num(d) 44 | # especially handle the origin loudspeaker 45 | d[N] = 0 # as it tends to nan/inf 46 | p = sfs.fd.synthesize(d, selection, array, secondary_source, grid=grid) 47 | sfs.plot2d.amplitude(p * norm_ref, grid) 48 | sfs.plot2d.loudspeakers(array.x, array.n, 49 | selection * array.a, size=0.15) 50 | plt.xlim(-0.5, 4.5) 51 | plt.ylim(-4.5, 0.5) 52 | plt.grid(True) 53 | 54 | """ 55 | import numpy as _np 56 | from scipy.special import jn as _jn, hankel2 as _hankel2 57 | 58 | from . import secondary_source_line as _secondary_source_line 59 | from . import secondary_source_point as _secondary_source_point 60 | from .. import util as _util 61 | 62 | 63 | def plane_2d_edge(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, Nc=None, 64 | c=None): 65 | r"""Driving function for 2-dimensional plane wave with edge ESA. 66 | 67 | Driving function for a virtual plane wave using the 2-dimensional ESA 68 | for an edge-shaped secondary source distribution consisting of 69 | monopole line sources. 70 | 71 | Parameters 72 | ---------- 73 | omega : float 74 | Angular frequency. 75 | x0 : int(N, 3) array_like 76 | Sequence of secondary source positions. 77 | n : (3,) array_like, optional 78 | Normal vector of synthesized plane wave. 79 | alpha : float, optional 80 | Outer angle of edge. 81 | Nc : int, optional 82 | Number of elements for series expansion of driving function. Estimated 83 | if not given. 84 | c : float, optional 85 | Speed of sound 86 | 87 | Returns 88 | ------- 89 | d : (N,) numpy.ndarray 90 | Complex weights of secondary sources. 91 | selection : (N,) numpy.ndarray 92 | Boolean array containing ``True`` or ``False`` depending on 93 | whether the corresponding secondary source is "active" or not. 94 | secondary_source_function : callable 95 | A function that can be used to create the sound field of a 96 | single secondary source. See `sfs.fd.synthesize()`. 97 | 98 | Notes 99 | ----- 100 | One leg of the secondary sources has to be located on the x-axis (y0=0), 101 | the edge at the origin. 102 | 103 | Derived from :cite:`Spors2016` 104 | 105 | Examples 106 | -------- 107 | .. plot:: 108 | :context: close-figs 109 | 110 | d, selection, secondary_source = sfs.fd.esa.plane_2d_edge( 111 | omega, array.x, npw, alpha=np.pi*3/2) 112 | plot(d, selection, secondary_source, norm_pw) 113 | 114 | """ 115 | x0 = _np.asarray(x0) 116 | n = _util.normalize_vector(n) 117 | k = _util.wavenumber(omega, c) 118 | phi_s = _np.arctan2(n[1], n[0]) + _np.pi 119 | L = x0.shape[0] 120 | 121 | r = _np.linalg.norm(x0, axis=1) 122 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 123 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 124 | 125 | if Nc is None: 126 | Nc = int(_np.ceil(2 * k * _np.max(r) * alpha / _np.pi)) 127 | 128 | epsilon = _np.ones(Nc) # weights for series expansion 129 | epsilon[0] = 2 130 | 131 | d = _np.zeros(L, dtype=complex) 132 | for m in _np.arange(Nc): 133 | nu = m * _np.pi / alpha 134 | d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.sin(nu*phi_s) \ 135 | * _np.cos(nu*phi) * nu/r * _jn(nu, k*r) 136 | 137 | d[phi > 0] = -d[phi > 0] 138 | 139 | selection = _util.source_selection_all(len(x0)) 140 | return 4*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 141 | 142 | 143 | def plane_2d_edge_dipole_ssd(omega, x0, n=[0, 1, 0], *, alpha=_np.pi*3/2, 144 | Nc=None, c=None): 145 | r"""Driving function for 2-dimensional plane wave with edge dipole ESA. 146 | 147 | Driving function for a virtual plane wave using the 2-dimensional ESA 148 | for an edge-shaped secondary source distribution consisting of 149 | dipole line sources. 150 | 151 | Parameters 152 | ---------- 153 | omega : float 154 | Angular frequency. 155 | x0 : int(N, 3) array_like 156 | Sequence of secondary source positions. 157 | n : (3,) array_like, optional 158 | Normal vector of synthesized plane wave. 159 | alpha : float, optional 160 | Outer angle of edge. 161 | Nc : int, optional 162 | Number of elements for series expansion of driving function. Estimated 163 | if not given. 164 | c : float, optional 165 | Speed of sound 166 | 167 | Returns 168 | ------- 169 | d : (N,) numpy.ndarray 170 | Complex weights of secondary sources. 171 | selection : (N,) numpy.ndarray 172 | Boolean array containing ``True`` or ``False`` depending on 173 | whether the corresponding secondary source is "active" or not. 174 | secondary_source_function : callable 175 | A function that can be used to create the sound field of a 176 | single secondary source. See `sfs.fd.synthesize()`. 177 | 178 | Notes 179 | ----- 180 | One leg of the secondary sources has to be located on the x-axis (y0=0), 181 | the edge at the origin. 182 | 183 | Derived from :cite:`Spors2016` 184 | 185 | Examples 186 | -------- 187 | .. plot:: 188 | :context: close-figs 189 | 190 | d, selection, secondary_source = sfs.fd.esa.plane_2d_edge_dipole_ssd( 191 | omega, array.x, npw, alpha=np.pi*3/2) 192 | plot(d, selection, secondary_source, norm_ref=1) 193 | 194 | """ 195 | x0 = _np.asarray(x0) 196 | n = _util.normalize_vector(n) 197 | k = _util.wavenumber(omega, c) 198 | phi_s = _np.arctan2(n[1], n[0]) + _np.pi 199 | L = x0.shape[0] 200 | 201 | r = _np.linalg.norm(x0, axis=1) 202 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 203 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 204 | 205 | if Nc is None: 206 | Nc = int(_np.ceil(2 * k * _np.max(r) * alpha / _np.pi)) 207 | 208 | epsilon = _np.ones(Nc) # weights for series expansion 209 | epsilon[0] = 2 210 | 211 | d = _np.zeros(L, dtype=complex) 212 | for m in _np.arange(Nc): 213 | nu = m * _np.pi / alpha 214 | d = d + 1/epsilon[m] * _np.exp(1j*nu*_np.pi/2) * _np.cos(nu*phi_s) \ 215 | * _np.cos(nu*phi) * _jn(nu, k*r) 216 | 217 | selection = _util.source_selection_all(len(x0)) 218 | return 4*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 219 | 220 | 221 | def line_2d_edge(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, c=None): 222 | r"""Driving function for 2-dimensional line source with edge ESA. 223 | 224 | Driving function for a virtual line source using the 2-dimensional ESA 225 | for an edge-shaped secondary source distribution consisting of line 226 | sources. 227 | 228 | Parameters 229 | ---------- 230 | omega : float 231 | Angular frequency. 232 | x0 : int(N, 3) array_like 233 | Sequence of secondary source positions. 234 | xs : (3,) array_like 235 | Position of synthesized line source. 236 | alpha : float, optional 237 | Outer angle of edge. 238 | Nc : int, optional 239 | Number of elements for series expansion of driving function. Estimated 240 | if not given. 241 | c : float, optional 242 | Speed of sound 243 | 244 | Returns 245 | ------- 246 | d : (N,) numpy.ndarray 247 | Complex weights of secondary sources. 248 | selection : (N,) numpy.ndarray 249 | Boolean array containing ``True`` or ``False`` depending on 250 | whether the corresponding secondary source is "active" or not. 251 | secondary_source_function : callable 252 | A function that can be used to create the sound field of a 253 | single secondary source. See `sfs.fd.synthesize()`. 254 | 255 | Notes 256 | ----- 257 | One leg of the secondary sources has to be located on the x-axis (y0=0), 258 | the edge at the origin. 259 | 260 | Derived from :cite:`Spors2016` 261 | 262 | Examples 263 | -------- 264 | .. plot:: 265 | :context: close-figs 266 | 267 | d, selection, secondary_source = sfs.fd.esa.line_2d_edge( 268 | omega, array.x, xs, alpha=np.pi*3/2) 269 | plot(d, selection, secondary_source, norm_ls) 270 | 271 | """ 272 | x0 = _np.asarray(x0) 273 | k = _util.wavenumber(omega, c) 274 | phi_s = _np.arctan2(xs[1], xs[0]) 275 | if phi_s < 0: 276 | phi_s = phi_s + 2 * _np.pi 277 | r_s = _np.linalg.norm(xs) 278 | L = x0.shape[0] 279 | 280 | r = _np.linalg.norm(x0, axis=1) 281 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 282 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 283 | 284 | if Nc is None: 285 | Nc = int(_np.ceil(2 * k * _np.max(r) * alpha / _np.pi)) 286 | 287 | epsilon = _np.ones(Nc) # weights for series expansion 288 | epsilon[0] = 2 289 | 290 | d = _np.zeros(L, dtype=complex) 291 | idx = (r <= r_s) 292 | for m in _np.arange(Nc): 293 | nu = m * _np.pi / alpha 294 | f = 1/epsilon[m] * _np.sin(nu*phi_s) * _np.cos(nu*phi) * nu/r 295 | d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) 296 | d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) 297 | 298 | d[phi > 0] = -d[phi > 0] 299 | 300 | selection = _util.source_selection_all(len(x0)) 301 | return -1j*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 302 | 303 | 304 | def line_2d_edge_dipole_ssd(omega, x0, xs, *, alpha=_np.pi*3/2, Nc=None, 305 | c=None): 306 | r"""Driving function for 2-dimensional line source with edge dipole ESA. 307 | 308 | Driving function for a virtual line source using the 2-dimensional ESA 309 | for an edge-shaped secondary source distribution consisting of dipole line 310 | sources. 311 | 312 | Parameters 313 | ---------- 314 | omega : float 315 | Angular frequency. 316 | x0 : (N, 3) array_like 317 | Sequence of secondary source positions. 318 | xs : (3,) array_like 319 | Position of synthesized line source. 320 | alpha : float, optional 321 | Outer angle of edge. 322 | Nc : int, optional 323 | Number of elements for series expansion of driving function. Estimated 324 | if not given. 325 | c : float, optional 326 | Speed of sound 327 | 328 | Returns 329 | ------- 330 | d : (N,) numpy.ndarray 331 | Complex weights of secondary sources. 332 | selection : (N,) numpy.ndarray 333 | Boolean array containing ``True`` or ``False`` depending on 334 | whether the corresponding secondary source is "active" or not. 335 | secondary_source_function : callable 336 | A function that can be used to create the sound field of a 337 | single secondary source. See `sfs.fd.synthesize()`. 338 | 339 | Notes 340 | ----- 341 | One leg of the secondary sources has to be located on the x-axis (y0=0), 342 | the edge at the origin. 343 | 344 | Derived from :cite:`Spors2016` 345 | 346 | Examples 347 | -------- 348 | .. plot:: 349 | :context: close-figs 350 | 351 | d, selection, secondary_source = sfs.fd.esa.line_2d_edge_dipole_ssd( 352 | omega, array.x, xs, alpha=np.pi*3/2) 353 | plot(d, selection, secondary_source, norm_ref=1) 354 | 355 | """ 356 | x0 = _np.asarray(x0) 357 | k = _util.wavenumber(omega, c) 358 | phi_s = _np.arctan2(xs[1], xs[0]) 359 | if phi_s < 0: 360 | phi_s = phi_s + 2 * _np.pi 361 | r_s = _np.linalg.norm(xs) 362 | L = x0.shape[0] 363 | 364 | r = _np.linalg.norm(x0, axis=1) 365 | phi = _np.arctan2(x0[:, 1], x0[:, 0]) 366 | phi = _np.where(phi < 0, phi + 2 * _np.pi, phi) 367 | 368 | if Nc is None: 369 | Nc = int(_np.ceil(2 * k * _np.max(r) * alpha / _np.pi)) 370 | 371 | epsilon = _np.ones(Nc) # weights for series expansion 372 | epsilon[0] = 2 373 | 374 | d = _np.zeros(L, dtype=complex) 375 | idx = (r <= r_s) 376 | for m in _np.arange(Nc): 377 | nu = m * _np.pi / alpha 378 | f = 1/epsilon[m] * _np.cos(nu*phi_s) * _np.cos(nu*phi) 379 | d[idx] = d[idx] + f[idx] * _jn(nu, k*r[idx]) * _hankel2(nu, k*r_s) 380 | d[~idx] = d[~idx] + f[~idx] * _jn(nu, k*r_s) * _hankel2(nu, k*r[~idx]) 381 | 382 | selection = _util.source_selection_all(len(x0)) 383 | return -1j*_np.pi/alpha * d, selection, _secondary_source_line(omega, c) 384 | 385 | 386 | def point_25d_edge(omega, x0, xs, *, xref=[2, -2, 0], alpha=_np.pi*3/2, 387 | Nc=None, c=None): 388 | r"""Driving function for 2.5-dimensional point source with edge ESA. 389 | 390 | Driving function for a virtual point source using the 2.5-dimensional 391 | ESA for an edge-shaped secondary source distribution consisting of point 392 | sources. 393 | 394 | Parameters 395 | ---------- 396 | omega : float 397 | Angular frequency. 398 | x0 : int(N, 3) array_like 399 | Sequence of secondary source positions. 400 | xs : (3,) array_like 401 | Position of synthesized line source. 402 | xref: (3,) array_like or float 403 | Reference position or reference distance 404 | alpha : float, optional 405 | Outer angle of edge. 406 | Nc : int, optional 407 | Number of elements for series expansion of driving function. Estimated 408 | if not given. 409 | c : float, optional 410 | Speed of sound 411 | 412 | Returns 413 | ------- 414 | d : (N,) numpy.ndarray 415 | Complex weights of secondary sources. 416 | selection : (N,) numpy.ndarray 417 | Boolean array containing ``True`` or ``False`` depending on 418 | whether the corresponding secondary source is "active" or not. 419 | secondary_source_function : callable 420 | A function that can be used to create the sound field of a 421 | single secondary source. See `sfs.fd.synthesize()`. 422 | 423 | Notes 424 | ----- 425 | One leg of the secondary sources has to be located on the x-axis (y0=0), 426 | the edge at the origin. 427 | 428 | Derived from :cite:`Spors2016` 429 | 430 | Examples 431 | -------- 432 | .. plot:: 433 | :context: close-figs 434 | 435 | d, selection, secondary_source = sfs.fd.esa.point_25d_edge( 436 | omega, array.x, xs, xref=xref, alpha=np.pi*3/2) 437 | plot(d, selection, secondary_source, norm_ref=1) 438 | 439 | """ 440 | x0 = _np.asarray(x0) 441 | xs = _np.asarray(xs) 442 | xref = _np.asarray(xref) 443 | 444 | if _np.isscalar(xref): 445 | a = _np.linalg.norm(xref) / _np.linalg.norm(xref - xs) 446 | else: 447 | a = _np.linalg.norm(xref - x0, axis=1) / _np.linalg.norm(xref - xs) 448 | 449 | d, selection, _ = line_2d_edge(omega, x0, xs, alpha=alpha, Nc=Nc, c=c) 450 | return 1j*_np.sqrt(a) * d, selection, _secondary_source_point(omega, c) 451 | -------------------------------------------------------------------------------- /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/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 scipy.special import spherical_jn, spherical_yn 10 | from . import default 11 | 12 | 13 | def rotation_matrix(n1, n2): 14 | """Compute rotation matrix for rotation from *n1* to *n2*. 15 | 16 | Parameters 17 | ---------- 18 | n1, n2 : (3,) array_like 19 | Two vectors. They don't have to be normalized. 20 | 21 | Returns 22 | ------- 23 | (3, 3) `numpy.ndarray` 24 | Rotation matrix. 25 | 26 | """ 27 | n1 = normalize_vector(n1) 28 | n2 = normalize_vector(n2) 29 | I = np.identity(3) 30 | if np.all(n1 == n2): 31 | return I # no rotation 32 | elif np.all(n1 == -n2): 33 | return -I # flip 34 | # TODO: check for *very close to* parallel vectors 35 | 36 | # Algorithm from http://math.stackexchange.com/a/476311 37 | v = v0, v1, v2 = np.cross(n1, n2) 38 | s = np.linalg.norm(v) # sine 39 | c = np.inner(n1, n2) # cosine 40 | vx = [[0, -v2, v1], 41 | [v2, 0, -v0], 42 | [-v1, v0, 0]] # skew-symmetric cross-product matrix 43 | return I + vx + np.dot(vx, vx) * (1 - c) / s**2 44 | 45 | 46 | def wavenumber(omega, c=None): 47 | """Compute the wavenumber for a given radial frequency.""" 48 | if c is None: 49 | c = default.c 50 | return omega / c 51 | 52 | 53 | def direction_vector(alpha, beta=np.pi/2): 54 | """Compute normal vector from azimuth, colatitude.""" 55 | return sph2cart(alpha, beta, 1) 56 | 57 | 58 | def sph2cart(alpha, beta, r): 59 | r"""Spherical to cartesian coordinate transform. 60 | 61 | .. math:: 62 | 63 | x = r \cos \alpha \sin \beta \\ 64 | y = r \sin \alpha \sin \beta \\ 65 | z = r \cos \beta 66 | 67 | with :math:`\alpha \in [0, 2\pi), \beta \in [0, \pi], r \geq 0` 68 | 69 | Parameters 70 | ---------- 71 | alpha : float or array_like 72 | Azimuth angle in radiants 73 | beta : float or array_like 74 | Colatitude angle in radiants (with 0 denoting North pole) 75 | r : float or array_like 76 | Radius 77 | 78 | Returns 79 | ------- 80 | x : float or `numpy.ndarray` 81 | x-component of Cartesian coordinates 82 | y : float or `numpy.ndarray` 83 | y-component of Cartesian coordinates 84 | z : float or `numpy.ndarray` 85 | z-component of Cartesian coordinates 86 | 87 | """ 88 | x = r * np.cos(alpha) * np.sin(beta) 89 | y = r * np.sin(alpha) * np.sin(beta) 90 | z = r * np.cos(beta) 91 | return x, y, z 92 | 93 | 94 | def cart2sph(x, y, z): 95 | r"""Cartesian to spherical coordinate transform. 96 | 97 | .. math:: 98 | 99 | \alpha = \arctan \left( \frac{y}{x} \right) \\ 100 | \beta = \arccos \left( \frac{z}{r} \right) \\ 101 | r = \sqrt{x^2 + y^2 + z^2} 102 | 103 | with :math:`\alpha \in [-pi, pi], \beta \in [0, \pi], r \geq 0` 104 | 105 | Parameters 106 | ---------- 107 | x : float or array_like 108 | x-component of Cartesian coordinates 109 | y : float or array_like 110 | y-component of Cartesian coordinates 111 | z : float or array_like 112 | z-component of Cartesian coordinates 113 | 114 | Returns 115 | ------- 116 | alpha : float or `numpy.ndarray` 117 | Azimuth angle in radiants 118 | beta : float or `numpy.ndarray` 119 | Colatitude angle in radiants (with 0 denoting North pole) 120 | r : float or `numpy.ndarray` 121 | Radius 122 | 123 | """ 124 | r = np.sqrt(x**2 + y**2 + z**2) 125 | alpha = np.arctan2(y, x) 126 | beta = np.arccos(z / r) 127 | return alpha, beta, r 128 | 129 | 130 | def asarray_1d(a, **kwargs): 131 | """Squeeze the input and check if the result is one-dimensional. 132 | 133 | Returns *a* converted to a `numpy.ndarray` and stripped of 134 | all singleton dimensions. Scalars are "upgraded" to 1D arrays. 135 | The result must have exactly one dimension. 136 | If not, an error is raised. 137 | 138 | """ 139 | result = np.squeeze(np.asarray(a, **kwargs)) 140 | if result.ndim == 0: 141 | result = result.reshape((1,)) 142 | elif result.ndim > 1: 143 | raise ValueError("array must be one-dimensional") 144 | return result 145 | 146 | 147 | def asarray_of_rows(a, **kwargs): 148 | """Convert to 2D array, turn column vector into row vector. 149 | 150 | Returns *a* converted to a `numpy.ndarray` and stripped of 151 | all singleton dimensions. If the result has exactly one dimension, 152 | it is re-shaped into a 2D row vector. 153 | 154 | """ 155 | result = np.squeeze(np.asarray(a, **kwargs)) 156 | if result.ndim == 1: 157 | result = result.reshape(1, -1) 158 | return result 159 | 160 | 161 | def as_xyz_components(components, **kwargs): 162 | r"""Convert *components* to `XyzComponents` of `numpy.ndarray`\s. 163 | 164 | The *components* are first converted to NumPy arrays (using 165 | :func:`numpy.asarray`) which are then assembled into an 166 | `XyzComponents` object. 167 | 168 | Parameters 169 | ---------- 170 | components : triple or pair of array_like 171 | The values to be used as X, Y and Z arrays. Z is optional. 172 | **kwargs 173 | All further arguments are forwarded to :func:`numpy.asarray`, 174 | which is applied to the elements of *components*. 175 | 176 | """ 177 | return XyzComponents([np.asarray(c, **kwargs) for c in components]) 178 | 179 | 180 | def as_delayed_signal(arg, **kwargs): 181 | """Make sure that the given argument can be used as a signal. 182 | 183 | Parameters 184 | ---------- 185 | arg : sequence of 1 array_like followed by 1 or 2 scalars 186 | The first element is converted to a NumPy array, the second 187 | element is used as the sampling rate (in Hertz) and the optional 188 | third element is used as the starting time of the signal (in 189 | seconds). Default starting time is 0. 190 | **kwargs 191 | All keyword arguments are forwarded to :func:`numpy.asarray`. 192 | 193 | Returns 194 | ------- 195 | `DelayedSignal` 196 | A named tuple consisting of a `numpy.ndarray` containing the 197 | audio data, followed by the sampling rate (in Hertz) and the 198 | starting time (in seconds) of the signal. 199 | 200 | Examples 201 | -------- 202 | Typically, this is used together with tuple unpacking to assign the 203 | audio data, the sampling rate and the starting time to separate 204 | variables: 205 | 206 | >>> import sfs 207 | >>> sig = [1], 44100 208 | >>> data, fs, signal_offset = sfs.util.as_delayed_signal(sig) 209 | >>> data 210 | array([1]) 211 | >>> fs 212 | 44100 213 | >>> signal_offset 214 | 0 215 | 216 | """ 217 | try: 218 | data, samplerate, *time = arg 219 | time, = time or [0] 220 | except (IndexError, TypeError, ValueError): 221 | pass 222 | else: 223 | valid_arguments = (not np.isscalar(data) and 224 | np.isscalar(samplerate) and 225 | np.isscalar(time)) 226 | if valid_arguments: 227 | data = np.asarray(data, **kwargs) 228 | return DelayedSignal(data, samplerate, time) 229 | raise TypeError('expected audio data, samplerate, optional start time') 230 | 231 | 232 | def strict_arange(start, stop, step=1, *, endpoint=False, dtype=None, 233 | **kwargs): 234 | """Like :func:`numpy.arange`, but compensating numeric errors. 235 | 236 | Unlike :func:`numpy.arange`, but similar to :func:`numpy.linspace`, 237 | providing ``endpoint=True`` includes both endpoints. 238 | 239 | Parameters 240 | ---------- 241 | start, stop, step, dtype 242 | See :func:`numpy.arange`. 243 | endpoint 244 | See :func:`numpy.linspace`. 245 | 246 | .. note:: With ``endpoint=True``, the difference between *start* 247 | and *end* value must be an integer multiple of the 248 | corresponding *spacing* value! 249 | **kwargs 250 | All further arguments are forwarded to :func:`numpy.isclose`. 251 | 252 | Returns 253 | ------- 254 | `numpy.ndarray` 255 | Array of evenly spaced values. See :func:`numpy.arange`. 256 | 257 | """ 258 | remainder = (stop - start) % step 259 | if np.any(np.isclose(remainder, (0.0, step), **kwargs)): 260 | if endpoint: 261 | stop += step * 0.5 262 | else: 263 | stop -= step * 0.5 264 | elif endpoint: 265 | raise ValueError("Invalid stop value for endpoint=True") 266 | return np.arange(start, stop, step, dtype) 267 | 268 | 269 | def xyz_grid(x, y, z, *, spacing, endpoint=True, **kwargs): 270 | """Create a grid with given range and spacing. 271 | 272 | Parameters 273 | ---------- 274 | x, y, z : float or pair of float 275 | Inclusive range of the respective coordinate or a single value 276 | if only a slice along this dimension is needed. 277 | spacing : float or triple of float 278 | Grid spacing. If a single value is specified, it is used for 279 | all dimensions, if multiple values are given, one value is used 280 | per dimension. If a dimension (*x*, *y* or *z*) has only a 281 | single value, the corresponding spacing is ignored. 282 | endpoint : bool, optional 283 | If ``True`` (the default), the endpoint of each range is 284 | included in the grid. Use ``False`` to get a result similar to 285 | :func:`numpy.arange`. See `strict_arange()`. 286 | **kwargs 287 | All further arguments are forwarded to `strict_arange()`. 288 | 289 | Returns 290 | ------- 291 | `XyzComponents` 292 | A grid that can be used for sound field calculations. 293 | 294 | See Also 295 | -------- 296 | strict_arange, numpy.meshgrid 297 | 298 | """ 299 | if np.isscalar(spacing): 300 | spacing = [spacing] * 3 301 | ranges = [] 302 | scalars = [] 303 | for i, coord in enumerate([x, y, z]): 304 | if np.isscalar(coord): 305 | scalars.append((i, coord)) 306 | else: 307 | start, stop = coord 308 | ranges.append(strict_arange(start, stop, spacing[i], 309 | endpoint=endpoint, **kwargs)) 310 | grid = np.meshgrid(*ranges, sparse=True, copy=False) 311 | for i, s in scalars: 312 | grid.insert(i, s) 313 | return XyzComponents(grid) 314 | 315 | 316 | def normalize(p, grid, xnorm): 317 | """Normalize sound field wrt position *xnorm*.""" 318 | return p / np.abs(probe(p, grid, xnorm)) 319 | 320 | 321 | def probe(p, grid, x): 322 | """Determine the value at position *x* in the sound field *p*.""" 323 | grid = as_xyz_components(grid) 324 | x = asarray_1d(x) 325 | r = np.linalg.norm(grid - x) 326 | idx = np.unravel_index(r.argmin(), r.shape) 327 | return p[idx] 328 | 329 | 330 | def broadcast_zip(*args): 331 | """Broadcast arguments to the same shape and then use :func:`zip`.""" 332 | return zip(*np.broadcast_arrays(*args)) 333 | 334 | 335 | def normalize_vector(x): 336 | """Normalize a 1D vector.""" 337 | x = asarray_1d(x) 338 | return x / np.linalg.norm(x) 339 | 340 | 341 | def normalize_rows(x): 342 | """Normalize a list of vectors.""" 343 | x = asarray_of_rows(x) 344 | return x / np.linalg.norm(x, axis=1, keepdims=True) 345 | 346 | 347 | def db(x, *, power=False): 348 | """Convert *x* to decibel. 349 | 350 | Parameters 351 | ---------- 352 | x : array_like 353 | Input data. Values of 0 lead to negative infinity. 354 | power : bool, optional 355 | If ``power=False`` (the default), *x* is squared before 356 | conversion. 357 | 358 | """ 359 | with np.errstate(divide='ignore'): 360 | return (10 if power else 20) * np.log10(np.abs(x)) 361 | 362 | 363 | class XyzComponents(np.ndarray): 364 | """See __init__().""" 365 | 366 | def __init__(self, components): 367 | r"""Triple (or pair) of components: x, y, and optionally z. 368 | 369 | Instances of this class can be used to store coordinate grids 370 | (either regular grids like in `xyz_grid()` or arbitrary point 371 | clouds) or vector fields (e.g. particle velocity). 372 | 373 | This class is a subclass of `numpy.ndarray`. It is 374 | one-dimensional (like a plain `list`) and has a length of 3 (or 375 | 2, if no z-component is available). It uses ``dtype=object`` in 376 | order to be able to store other `numpy.ndarray`\s of arbitrary 377 | shapes but also scalars, if needed. Because it is a NumPy array 378 | subclass, it can be used in operations with scalars and "normal" 379 | NumPy arrays, as long as they have a compatible shape. Like any 380 | NumPy array, instances of this class are iterable and can be 381 | used, e.g., in for-loops and tuple unpacking. If slicing or 382 | broadcasting leads to an incompatible shape, a plain 383 | `numpy.ndarray` with ``dtype=object`` is returned. 384 | 385 | To make sure the *components* are NumPy arrays themselves, use 386 | `as_xyz_components()`. 387 | 388 | Parameters 389 | ---------- 390 | components : (3,) or (2,) array_like 391 | The values to be used as X, Y and Z data. Z is optional. 392 | 393 | """ 394 | # This method does nothing, it's only here for the documentation! 395 | 396 | def __new__(cls, components): 397 | # object arrays cannot be created and populated in a single step: 398 | obj = np.ndarray.__new__(cls, len(components), dtype=object) 399 | for i, component in enumerate(components): 400 | obj[i] = component 401 | return obj 402 | 403 | def __array_finalize__(self, obj): 404 | if self.ndim == 0: 405 | pass # this is allowed, e.g. for np.inner() 406 | elif self.ndim > 1 or len(self) not in (2, 3): 407 | raise ValueError("XyzComponents can only have 2 or 3 components") 408 | 409 | def __array_prepare__(self, obj, context=None): 410 | if obj.ndim == 1 and len(obj) in (2, 3): 411 | return obj.view(XyzComponents) 412 | return obj 413 | 414 | def __array_wrap__(self, obj, context=None): 415 | if obj.ndim != 1 or len(obj) not in (2, 3): 416 | return obj.view(np.ndarray) 417 | return obj 418 | 419 | def __getitem__(self, index): 420 | if isinstance(index, slice): 421 | start, stop, step = index.indices(len(self)) 422 | if start == 0 and stop in (2, 3) and step == 1: 423 | return np.ndarray.__getitem__(self, index) 424 | # Slices other than xy and xyz are "downgraded" to ndarray 425 | return np.ndarray.__getitem__(self.view(np.ndarray), index) 426 | 427 | def __repr__(self): 428 | return 'XyzComponents(\n' + ',\n'.join( 429 | ' {}={}'.format(name, repr(data).replace('\n', '\n ')) 430 | for name, data in zip('xyz', self)) + ')' 431 | 432 | def make_property(index, doc): 433 | 434 | def getter(self): 435 | return self[index] 436 | 437 | def setter(self, value): 438 | self[index] = value 439 | 440 | return property(getter, setter, doc=doc) 441 | 442 | x = make_property(0, doc='x-component.') 443 | y = make_property(1, doc='y-component.') 444 | z = make_property(2, doc='z-component (optional).') 445 | 446 | del make_property 447 | 448 | def apply(self, func, *args, **kwargs): 449 | """Apply a function to each component. 450 | 451 | The function *func* will be called once for each component, 452 | passing the current component as first argument. All further 453 | arguments are passed after that. 454 | The results are returned as a new `XyzComponents` object. 455 | 456 | """ 457 | return XyzComponents([func(i, *args, **kwargs) for i in self]) 458 | 459 | 460 | DelayedSignal = collections.namedtuple('DelayedSignal', 'data samplerate time') 461 | """A tuple of audio data, sampling rate and start time. 462 | 463 | This class (a `collections.namedtuple`) is not meant to be instantiated 464 | by users. 465 | 466 | To pass a signal to a function, just use a simple `tuple` or `list` 467 | containing the audio data and the sampling rate (in Hertz), with an 468 | optional starting time (in seconds) as a third item. 469 | If you want to ensure that a given variable contains a valid signal, use 470 | `sfs.util.as_delayed_signal()`. 471 | 472 | """ 473 | 474 | 475 | def image_sources_for_box(x, L, N, *, prune=True): 476 | """Image source method for a cuboid room. 477 | 478 | The classical method by Allen and Berkley :cite:`Allen1979`. 479 | 480 | Parameters 481 | ---------- 482 | x : (D,) array_like 483 | Original source location within box. 484 | Values between 0 and corresponding side length. 485 | L : (D,) array_like 486 | side lengths of room. 487 | N : int 488 | Maximum number of reflections per image source, see below. 489 | prune : bool, optional 490 | selection of image sources: 491 | 492 | - If True (default): 493 | Returns all images reflected up to N times. 494 | This is the usual interpretation of N as "maximum order". 495 | 496 | - If False: 497 | Returns reflected up to N times between individual wall pairs, 498 | a total number of :math:`M := (2N+1)^D`. 499 | This larger set is useful e.g. to select image sources based on 500 | distance to listener, as suggested by :cite:`Borish1984`. 501 | 502 | 503 | Returns 504 | ------- 505 | xs : (M, D) `numpy.ndarray` 506 | original & image source locations. 507 | wall_count : (M, 2D) `numpy.ndarray` 508 | number of reflections at individual walls for each source. 509 | 510 | """ 511 | def _images_1d_unit_box(x, N): 512 | result = np.arange(-N, N + 1, dtype=x.dtype) 513 | result[N % 2::2] += x 514 | result[1 - (N % 2)::2] += 1 - x 515 | return result 516 | 517 | def _count_walls_1d(a): 518 | b = np.floor(a/2) 519 | c = np.ceil((a-1)/2) 520 | return np.abs(np.stack([b, c], axis=1)).astype(int) 521 | 522 | L = asarray_1d(L) 523 | x = asarray_1d(x)/L 524 | D = len(x) 525 | xs = [_images_1d_unit_box(coord, N) for coord in x] 526 | xs = np.reshape(np.transpose(np.meshgrid(*xs, indexing='ij')), (-1, D)) 527 | 528 | wall_count = np.concatenate([_count_walls_1d(d) for d in xs.T], axis=1) 529 | xs *= L 530 | 531 | if prune is True: 532 | N_mask = np.sum(wall_count, axis=1) <= N 533 | xs = xs[N_mask, :] 534 | wall_count = wall_count[N_mask, :] 535 | 536 | return xs, wall_count 537 | 538 | 539 | def spherical_hn2(n, z): 540 | r"""Spherical Hankel function of 2nd kind. 541 | 542 | Defined as https://dlmf.nist.gov/10.47.E6, 543 | 544 | .. math:: 545 | 546 | \hankel{2}{n}{z} = \sqrt{\frac{\pi}{2z}} 547 | \Hankel{2}{n + \frac{1}{2}}{z}, 548 | 549 | where :math:`\Hankel{2}{n}{\cdot}` is the Hankel function of the 550 | second kind and n-th order, and :math:`z` its complex argument. 551 | 552 | Parameters 553 | ---------- 554 | n : array_like 555 | Order of the spherical Hankel function (n >= 0). 556 | z : array_like 557 | Argument of the spherical Hankel function. 558 | 559 | """ 560 | return spherical_jn(n, z) - 1j * spherical_yn(n, z) 561 | 562 | 563 | def source_selection_plane(n0, n): 564 | """Secondary source selection for a plane wave. 565 | 566 | Eq.(13) from :cite:`Spors2008` 567 | 568 | """ 569 | n0 = asarray_of_rows(n0) 570 | n = normalize_vector(n) 571 | return np.inner(n, n0) >= default.selection_tolerance 572 | 573 | 574 | def source_selection_point(n0, x0, xs): 575 | """Secondary source selection for a point source. 576 | 577 | Eq.(15) from :cite:`Spors2008` 578 | 579 | """ 580 | n0 = asarray_of_rows(n0) 581 | x0 = asarray_of_rows(x0) 582 | xs = asarray_1d(xs) 583 | ds = x0 - xs 584 | return _inner1d(ds, n0) >= default.selection_tolerance 585 | 586 | 587 | def source_selection_line(n0, x0, xs): 588 | """Secondary source selection for a line source. 589 | 590 | compare Eq.(15) from :cite:`Spors2008` 591 | 592 | """ 593 | return source_selection_point(n0, x0, xs) 594 | 595 | 596 | def source_selection_focused(ns, x0, xs): 597 | """Secondary source selection for a focused source. 598 | 599 | Eq.(2.78) from :cite:`Wierstorf2014` 600 | 601 | """ 602 | x0 = asarray_of_rows(x0) 603 | xs = asarray_1d(xs) 604 | ns = normalize_vector(ns) 605 | ds = xs - x0 606 | return _inner1d(ns, ds) >= default.selection_tolerance 607 | 608 | 609 | def source_selection_all(N): 610 | """Select all secondary sources.""" 611 | return np.ones(N, dtype=bool) 612 | 613 | 614 | def max_order_circular_harmonics(N): 615 | r"""Maximum order of 2D/2.5D HOA. 616 | 617 | It returns the maximum order for which no spatial aliasing appears. 618 | It is given on page 132 of :cite:`Ahrens2012` as 619 | 620 | .. math:: 621 | \mathtt{max\_order} = 622 | \begin{cases} 623 | N/2 - 1 & \text{even}\;N \\ 624 | (N-1)/2 & \text{odd}\;N, 625 | \end{cases} 626 | 627 | which is equivalent to 628 | 629 | .. math:: 630 | \mathtt{max\_order} = \big\lfloor \frac{N - 1}{2} \big\rfloor. 631 | 632 | Parameters 633 | ---------- 634 | N : int 635 | Number of secondary sources. 636 | 637 | """ 638 | return (N - 1) // 2 639 | 640 | 641 | def max_order_spherical_harmonics(N): 642 | r"""Maximum order of 3D HOA. 643 | 644 | .. math:: 645 | \mathtt{max\_order} = \lfloor \sqrt{N} \rfloor - 1. 646 | 647 | Parameters 648 | ---------- 649 | N : int 650 | Number of secondary sources. 651 | 652 | """ 653 | return int(np.sqrt(N) - 1) 654 | 655 | 656 | def _inner1d(arr1, arr2): 657 | # https://github.com/numpy/numpy/issues/10815#issuecomment-376847774 658 | return (arr1 * arr2).sum(axis=1) 659 | --------------------------------------------------------------------------------