├── .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 |
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:`
`
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 |
--------------------------------------------------------------------------------