Skip to content

Commit d196133

Browse files
fakufakuebezzam
andauthored
Source/microphone directivity for ray tracing (#180)
* Adds some spherical distributions in new random submodule * Add files for common directivity patterns. * Adds rejection sampling code with example on the sphere with a cardioid shaped distribution * Packs Cardioid family sampler in single class * Re-format doa.grid and add a parameter to disable peak_fidining in GridSphere if necessary * Adds a spherical histogram class in directivities * Add default options to spher2cart for easy use in 2D. * Vectorize directivity response calculation and add one-shot function. * Update examples for directivities. * Switch to using Enum for common directivity patterns. * In CardioidFamilySampler, uses generic cardioid function * Adds interface to provide the rays directions to the simulator * Adds the necessary interface for getting directive response and ray sampling in the Directivity class. * Moves the CardioidSampler derived class to the directivities.py file to avoid circular dependency. * adds nanoflann kd-tree v1.4.2 * Adds directional histograms to the libroom microphone objects using kd-tree (via nanoflann) * Modifies ISM for general rooms to also record the source direction vector. * Adds source direction computatons to ISM, both shoebox and general * Adds libroom_src/ext/nanoflann to list of files to include in the pypi archive in MANIFEST.in * Moves spherical histogram into doa sub-package. Fixes test_rejection pre-test. * Adds the ray sampler for measured directivities. * Replaces cosine octave filter bank by that of Antoni 2009 (#384) * Adds implementation of Antoni's octave band filters. * Replaces the old cosine octave filter bank by the energy preserving perfect reconstruction filter bank from Antoni 2009. * Switches to using source directions computed as part of the image source model for source directivities. This enables the use of source directivities with non-shoebox rooms. * Adds distribution objects for measured and analytic directivities for sampling rays. Adds tests for these distributions as well as for the random sub-package. * Adds a method to broadcast the number of octave bands in the cpp engine. * Robustify the computation of spherical voronoi areas. * Adds ray energies to ray sampler to capture the energy distribution of sources. * Adds an argument to provide the energy of individual rays in the C++ ray tracing. * Fixes the DC shelf filter in the octave bands and the list of bandwidths. * Rewrites Sabine's formula in room.py to be the harmonic mean over walls and frequency bands. * Fixes the new ray tracing cpp function after merge. * Fixes real spherical harmonics directivity test. Modify random methods to use the package wide rng by default. * 1. Re-factor the directivities to accept cartesian coordinate inputs too for the response. 2. Fixes the scaling of the rays' energies in sample_rays. * Fixes rt60 measurement routine for when the RIR is very short. * Enable directional source and receivers in ray tracing. * Fixes support for 2d ray tracing, but only for omnidirectional sources and receivers. * Flip settings to use octave_bands_keep_dc=True * Improves API of the Directivity base class and the docstrings. Remove the unused parameter of get_response. * CHANGELOG and doc. --------- Co-authored-by: ebezzam <ebezzam@gmail.com>
1 parent 4cb001f commit d196133

50 files changed

Lines changed: 4309 additions & 1271 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "pyroomacoustics/libroom_src/ext/eigen"]
22
path = pyroomacoustics/libroom_src/ext/eigen
33
url = https://github.com/eigenteam/eigen-git-mirror.git
4+
[submodule "pyroomacoustics/libroom_src/ext/nanoflann"]
5+
path = pyroomacoustics/libroom_src/ext/nanoflann
6+
url = https://github.com/jlblancoc/nanoflann.git

CHANGELOG.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,46 @@ adheres to `Semantic Versioning <http://semver.org/spec/v2.0.0.html>`_.
1111
`Unreleased`_
1212
-------------
1313

14+
`Ray Tracing Directivity`
15+
~~~~~~~~~~~~~~~~~~~~~~~~~
16+
17+
This new release introduces source and receiver directivities for the ray
18+
tracing simulation engine.
19+
20+
- Support for source directivities in non-shoebox rooms using the images source
21+
model.
22+
23+
- New ``pyroomacoustics.random`` module that provides some primitives for
24+
sampling at random from arbitrary distributions on the sphere. This is used
25+
for source directivities in the ray tracing simulator.
26+
27+
- New octave filter bank with energy conservation and perfect reconstruction
28+
described in Antoni, "Orthogonal-like fractional-octave-band filters," 2009.
29+
The filter bank is implemented in
30+
``pyroomacoustics.acoustics.AntoniOctaveFilterBank``.
31+
32+
- A method ``sample_rays`` is added to the ``Directivity`` objects to provide a
33+
unified interface to sample rays of sources used for ray tracing.
34+
35+
- The class ``directivities.SphericalHistogram`` allows to collect and display
36+
histograms on the sphere which can be useful to visualize and test
37+
directivities.
38+
1439
Added
1540
~~~~~
1641

1742
- A new ``random`` sub-module that contains a Numpy random number generator to use
1843
package wide and some methods to set the seeds for this generator and that of
1944
the libroom module.
2045

46+
- New methods to sample from spherical distributions either analytically, or by
47+
rejection sampling are provided in ``random``.
48+
2149
Changed
2250
~~~~~~~
2351

52+
- Bumped the numpy requirement to v1.17.0 to use the ``numpy.random.Generator`` objects.
53+
2454
- Adds random "bending" of the rays to account for scattering in the ray tracing.
2555

2656
- Refactor the way the RIR is weighted with the histogram in simulation/rt.py.
@@ -30,6 +60,9 @@ Changed
3060

3161
- Add Directivity pattern for real spherical harmonics.
3262

63+
- Flip the settings to use ``octave_bands_keep_dc=True`` by default. There should be
64+
minimal change due to the introduction of the high-pass filter in `0.9.0`_.
65+
3366
Bugfix
3467
~~~~~~
3568

MANIFEST.in

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ include pyroomacoustics/libroom_src/*.h
77
include pyroomacoustics/libroom_src/*.hpp
88
include pyroomacoustics/libroom_src/*.cpp
99

10-
# The compiled extension code rely on Eigen, which is included
10+
# The compiled extension code rely on Eigen and nanoflann,
11+
# which are included
1112
include pyroomacoustics/libroom_src/ext/eigen/COPYING.*
1213
graft pyroomacoustics/libroom_src/ext/eigen/Eigen
14+
graft pyroomacoustics/libroom_src/ext/nanoflann/include
1315

1416
include pyproject.toml
1517
include requirements.txt

docs/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
MOCK_MODULES = [
2020
"numpy",
2121
"numpy.fft",
22+
"numpy.random",
2223
"scipy",
2324
"matplotlib",
2425
"matplotlib.pyplot",
@@ -122,6 +123,8 @@ def setup(app):
122123
# The master toctree document.
123124
master_doc = "index"
124125

126+
autodoc_imported_members = False
127+
125128
# General information about the project.
126129
project = "Pyroomacoustics"
127130
copyright = "2016, LCAV"

docs/pyroomacoustics.directivities.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ Directional Sources and Microphones
22
===================================
33

44
.. automodule:: pyroomacoustics.directivities
5-
:members:
65
:undoc-members:
76
:show-inheritance:
87

@@ -54,8 +53,9 @@ SOFA File Readers
5453
:members:
5554
:show-inheritance:
5655

57-
Direction of the Patterns
58-
-------------------------
56+
57+
Specifying Object Directions
58+
----------------------------
5959

6060
.. automodule:: pyroomacoustics.directivities.direction
6161
:members:

examples/directivities/source_and_microphone.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,15 @@
22
import numpy as np
33

44
import pyroomacoustics as pra
5-
from pyroomacoustics.directivities import (
6-
CardioidFamily,
7-
DirectionVector,
8-
DirectivityPattern,
9-
)
5+
from pyroomacoustics.directivities import DirectionVector, FigureEight, HyperCardioid
106

117
three_dim = True # 2D or 3D
128
shoebox = True # source directivity not supported for non-shoebox!
139
energy_absorption = 0.4
1410
source_pos = [2, 1.8]
1511
# source_dir = None # to disable
16-
source_dir = DirectivityPattern.FIGURE_EIGHT
1712
mic_pos = [3.5, 1.8]
1813
# mic_dir = None # to disable
19-
mic_dir = DirectivityPattern.HYPERCARDIOID
2014

2115

2216
# make 2-D room
@@ -42,19 +36,15 @@
4236
colatitude = None
4337

4438
# add source with directivity
45-
if source_dir is not None:
46-
source_dir = CardioidFamily(
47-
orientation=DirectionVector(azimuth=90, colatitude=colatitude, degrees=True),
48-
pattern_enum=source_dir,
49-
)
39+
source_dir = FigureEight(
40+
orientation=DirectionVector(azimuth=90, colatitude=colatitude, degrees=True),
41+
)
5042
room.add_source(position=source_pos, directivity=source_dir)
5143

5244
# add microphone with directivity
53-
if mic_dir is not None:
54-
mic_dir = CardioidFamily(
55-
orientation=DirectionVector(azimuth=0, colatitude=colatitude, degrees=True),
56-
pattern_enum=mic_dir,
57-
)
45+
mic_dir = HyperCardioid(
46+
orientation=DirectionVector(azimuth=0, colatitude=colatitude, degrees=True),
47+
)
5848
room.add_microphone(loc=mic_pos, directivity=mic_dir)
5949

6050
# plot room

examples/octave_band_filterbank.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""
2+
# Octave Band Filter Banks
3+
4+
Multi-frequency simulation relies on octave-band filterbanks
5+
to run the simulation in different perceptually relevant frequency bands
6+
before merging the results into a single room impulse response.
7+
8+
This scripts demonstrate some of the octave band filters available
9+
in pyroomacoustics.
10+
11+
Two ocatave band filter banks are implemented in pyroomacoustics.
12+
13+
## Cosine Filterbank
14+
15+
This filterbank uses a number of overlapping cosine filters to
16+
cover the octaves.
17+
It guarantees perfect reconstruction, but does not conserve the energy
18+
in the bands.
19+
20+
## Antoni's Orthogonal-like Fractional Octave Bands
21+
22+
This class implements a type of fractional octave filter bank with
23+
both perfect reconstruction and energy conservation.
24+
25+
J. Antoni, Orthogonal-like fractional-octave-band filters, J. Acoust. Soc.
26+
Am., 127, 2, February 2010
27+
"""
28+
29+
import matplotlib.pyplot as plt
30+
import numpy as np
31+
from scipy.signal import chirp
32+
33+
import pyroomacoustics as pra
34+
35+
if __name__ == "__main__":
36+
37+
# Test unit energy.
38+
fs = 16000
39+
n_fft = 2**10 # 1024
40+
base_freq = 125.0 # Hertz, default frequency in pyroomacoustics.
41+
42+
# The cosine filter bank
43+
octave_bands = pra.OctaveBandsFactory(
44+
fs=fs,
45+
n_fft=n_fft,
46+
keep_dc=True,
47+
base_frequency=base_freq,
48+
)
49+
50+
# The orthogonal-like filterbankd with perfect reconstruction and energy
51+
# conservation.
52+
# The `band_overlap_ratio` and `slope` parameters control the transition
53+
# between adjacent bands.
54+
antoni_octave_bands = pra.AntoniOctaveFilterBank(
55+
fs=fs,
56+
base_frequency=base_freq,
57+
band_overlap_ratio=0.5,
58+
slope=0,
59+
n_fft=n_fft,
60+
third=False,
61+
)
62+
63+
fig, axes = plt.subplots(2, 2, figsize=(10, 5))
64+
65+
filters_orth = np.zeros(n_fft)
66+
filters_orth[n_fft // 2] = 1.0
67+
filters_orth = antoni_octave_bands.analysis(filters_orth)
68+
69+
for i, (lbl, ob) in enumerate(
70+
{"Cosine": octave_bands.filters, "Antoni": filters_orth}.items()
71+
):
72+
filters_spectrum = np.fft.rfft(ob, axis=0)
73+
n_freq = filters_spectrum.shape[0]
74+
freq = np.arange(n_freq) / n_freq * (fs / 2)
75+
time = np.arange(ob.shape[0]) / fs
76+
77+
sum_reconstruction = np.sum(abs(filters_spectrum), axis=1)
78+
79+
for b in range(ob.shape[-1]):
80+
c = antoni_octave_bands.centers[b]
81+
line_label = (
82+
(f"{c if c < 1000 else c / 1000:.0f}{'k' if c >= 1000 else ''}Hz")
83+
if lbl == "Antoni"
84+
else None
85+
)
86+
axes[0, i].plot(freq, abs(filters_spectrum[:, b]) ** 2, label=line_label)
87+
axes[1, i].plot(time, ob[:, b])
88+
axes[0, i].plot(
89+
freq, sum_reconstruction, label="sum magnitude" if lbl == "Antoni" else None
90+
)
91+
axes[0, i].set_title(f"{lbl} - energy response")
92+
axes[1, i].set_title(f"{lbl} - impulse response")
93+
axes[1, i].set_xlim(0.025, 0.04)
94+
95+
fig.legend(loc="lower right")
96+
fig.tight_layout()
97+
98+
# Test octave bands interpolation
99+
coeffs = np.arange(antoni_octave_bands.n_bands)[::-1] + 1
100+
101+
mat_interp = {
102+
"Cosine": octave_bands.synthesis(coeffs, min_phase=True),
103+
"Cosine, min. phase": octave_bands.synthesis(coeffs, min_phase=False),
104+
"Antoni": antoni_octave_bands.synthesis(coeffs, min_phase=False),
105+
"Antoni, min. phase": antoni_octave_bands.synthesis(coeffs, min_phase=True),
106+
}
107+
108+
# Compare the energy of the original coefficients to those after filtering.
109+
energy = (octave_bands.get_bw() / octave_bands.fs * 2.0) * coeffs**2
110+
bar_labels = [
111+
f"{c if c < 1000 else c / 1000:.0f}{'k' if c >= 1000 else ''}"
112+
for c in antoni_octave_bands.centers
113+
] + ["Total"]
114+
bar_energy_original = energy.tolist() + [energy.sum()]
115+
116+
bar_width = 0.9 / (len(mat_interp) + 2)
117+
118+
def bar_x(idx):
119+
bar_space = 0.1 / (len(mat_interp) + 2)
120+
x = np.arange(len(bar_energy_original))
121+
return bar_width / 2.0 + idx * (bar_width + bar_space) + x
122+
123+
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
124+
axes[0].set_title("Impulse response")
125+
axes[1].set_title("Magnitude response")
126+
axes[2].set_title("Per-band energy")
127+
for idx, (lbl, values) in enumerate(mat_interp.items()):
128+
axes[0].plot(np.arange(values.shape[-1]) / fs, values, label=lbl)
129+
axes[0].set_xlabel("Time (s)")
130+
131+
H = abs(np.fft.rfft(values))
132+
axes[1].plot(np.arange(H.shape[-1]) * fs / values.shape[-1], H, label=lbl)
133+
axes[1].set_xlabel("Frequency (Hz)")
134+
135+
energy_bands = antoni_octave_bands.energy(values, oversampling=4).tolist()
136+
energy_bands += [sum(energy_bands)]
137+
axes[2].bar(bar_x(idx), energy_bands, label=lbl, width=bar_width)
138+
139+
axes[2].bar(
140+
bar_x(len(mat_interp)), bar_energy_original, label="True", width=bar_width
141+
)
142+
axes[2].set_xticks(np.arange(len(bar_energy_original)) + 0.5, bar_labels)
143+
axes[2].set_xlabel("Band centers (Hz)")
144+
axes[2].legend()
145+
fig.tight_layout()
146+
147+
# Test reconstruction
148+
time = np.arange(fs * 5) / fs
149+
f0 = 1.0
150+
x = np.zeros_like(time)
151+
x[fs : 3 * fs] = np.sin(2 * np.pi * f0 * time[fs : 3 * fs])
152+
x = chirp(time, 100, time[-1], 7500, method="linear")
153+
154+
x_bands = octave_bands.analysis(x)
155+
x_rec = x_bands.sum(axis=-1)
156+
157+
band_energy = antoni_octave_bands.energy(x, oversampling=4)
158+
print(
159+
f"Energy of input signal: {np.square(x).sum()}, "
160+
f"sum of band energies: {band_energy.sum()}"
161+
)
162+
print(f"Reconstruction error: {abs(x - x_rec).max()}")
163+
164+
low = None
165+
high = None
166+
167+
low = 0 if low is None else low
168+
high = x.shape[-1] if high is None else high
169+
170+
num_plots = x_bands.shape[-1] + 1
171+
freq = np.arange(x_bands.shape[-2] // 2 + 1) / x_bands.shape[-2] * fs
172+
time = np.arange(x.shape[-1]) / fs
173+
fig, axes = plt.subplots(num_plots, 2, figsize=(10, 6))
174+
axes[0, 0].plot(time[low:high], x_rec[low:high], label="reconstructed")
175+
axes[0, 0].plot(time[low:high], x[low:high], label="original")
176+
axes[0, 0].plot(time[low:high], x[low:high] - x_rec[low:high], label="error")
177+
L = axes[0, 1].magnitude_spectrum(x, label="reconstructed", Fs=fs)
178+
L = axes[0, 1].magnitude_spectrum(x_rec, label="reconstructed", Fs=fs)
179+
axes[0, 0].legend(fontsize="xx-small")
180+
181+
ylim_time = x.min(), x.max()
182+
ylim = -0.01 * max(L[0]), max(L[0])
183+
axes[0, 0].set_title("Filtered signal")
184+
axes[0, 1].set_title("Magnitude response")
185+
axes[0, 1].set_xlabel("")
186+
axes[0, 1].set_ylabel("")
187+
axes[0, 1].set_ylim(ylim)
188+
axes[0, 1].set_xticks([])
189+
axes[0, 0].set_ylim(ylim_time)
190+
axes[0, 0].set_xticks([])
191+
axes[0, 0].set_ylabel("Sum")
192+
for b in range(1, num_plots):
193+
axes[b, 0].plot(time[low:high], x_bands[low:high, b - 1])
194+
axes[b, 0].set_ylim(ylim_time)
195+
axes[b, 1].magnitude_spectrum(x_bands[low:high, b - 1], Fs=fs)
196+
axes[b, 1].set_ylim(ylim)
197+
axes[b, 1].set_xlabel("")
198+
axes[b, 1].set_ylabel("")
199+
if b < num_plots - 1:
200+
axes[b, 0].set_xticks([])
201+
axes[b, 1].set_xticks([])
202+
else:
203+
axes[b, 0].set_xlabel("Time (s)")
204+
ticks = np.arange(0, fs / 2 + 1, 1000)
205+
ticklabels = [
206+
f"{c if c < 1000 else c / 1000:.0f}{'k' if c > 1000 else ''} Hz"
207+
for c in ticks
208+
]
209+
axes[b, 1].set_xticks(ticks, ticklabels)
210+
if b > 0:
211+
c = antoni_octave_bands.centers[b - 1]
212+
axes[b, 0].set_ylabel(
213+
f"{c if c < 1000 else c / 1000:.0f}{'k' if c > 1000 else ''} Hz"
214+
)
215+
axes[b, 0].set_xlabel("Frequency (Hz)")
216+
fig.tight_layout()
217+
218+
plt.show()

0 commit comments

Comments
 (0)