Skip to content

Commit 1cf5efd

Browse files
authored
Merge pull request #3 from spatialaudio/sfa_circular_harmonics
SFA circular harmonics
2 parents f4a0f31 + 5e496ee commit 1cf5efd

File tree

5 files changed

+347
-0
lines changed

5 files changed

+347
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Compute the plane wave decomposition for an incident broadband plane wave
3+
on an open circular array using a modal beamformer of finite order.
4+
"""
5+
6+
import numpy as np
7+
import matplotlib.pyplot as plt
8+
import micarray
9+
10+
N = 90 # order of modal beamformer/microphone array
11+
pw_angle = 1.23 * np.pi # incidence angle of plane wave
12+
pol_pwd = np.linspace(0, 2*np.pi, 91, endpoint=False) # angles for plane wave decomposition
13+
k = np.linspace(0.1, 20, 100) # wavenumber vector
14+
r = 1 # radius of array
15+
16+
# get uniform grid (microphone positions) of order N
17+
pol, weights = micarray.modal.angular.grid_equal_polar_angle(N)
18+
# get circular harmonics matrix for sensors
19+
Psi_p = micarray.modal.angular.cht_matrix(N, pol, weights)
20+
# get circular harmonics matrix for a source ensemble of azimuthal plane wave
21+
Psi_q = micarray.modal.angular.cht_matrix(N, pol_pwd)
22+
# get radial filters
23+
Bn = micarray.modal.radial.circular_pw(N, k, r, setup='open')
24+
Dn, _ = micarray.modal.radial.regularize(1/Bn, 100, 'softclip')
25+
D = micarray.modal.radial.circ_diagonal_mode_mat(Dn)
26+
27+
# compute microphone signals for an incident broad-band plane wave
28+
p = np.exp(1j * k[:, np.newaxis]*r * np.cos(pol - pw_angle))
29+
# compute plane wave decomposition
30+
A_pwd = np.matmul(np.matmul(np.conj(Psi_q.T), D), Psi_p)
31+
q_pwd = np.squeeze(np.matmul(A_pwd, np.expand_dims(p, 2)))
32+
q_pwd_t = np.fft.fftshift(np.fft.irfft(q_pwd, axis=0), axes=0)
33+
34+
# visualize plane wave decomposition (aka beampattern)
35+
plt.figure()
36+
plt.pcolormesh(k, pol_pwd/np.pi, micarray.util.db(q_pwd.T), vmin=-40)
37+
plt.colorbar()
38+
plt.xlabel(r'$kr$')
39+
plt.ylabel(r'$\phi / \pi$')
40+
plt.title('Plane wave docomposition by modal beamformer (frequency domain)')
41+
plt.savefig('modal_open_beamformer_pwd_fd.png')
42+
43+
plt.figure()
44+
plt.pcolormesh(range(2*len(k)-2), pol_pwd/np.pi, micarray.util.db(q_pwd_t.T), vmin=-40)
45+
plt.colorbar()
46+
plt.ylabel(r'$\phi / \pi$')
47+
plt.title('Plane wave docomposition by modal beamformer (time domain)')
48+
plt.savefig('modal_open_beamformer_pwd_td.png')
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
Compute the plane wave decomposition for an incident broadband plane wave
3+
on an rigid circular array using a modal beamformer of finite order.
4+
"""
5+
6+
import numpy as np
7+
import matplotlib.pyplot as plt
8+
import micarray
9+
10+
Nsf = 50 # order of the incident sound field
11+
N = 30 # order of modal beamformer/microphone array
12+
pw_angle = 1 * np.pi # incidence angle of plane wave
13+
pol_pwd = np.linspace(0, 2*np.pi, 180, endpoint=False) # angles for plane wave decomposition
14+
k = np.linspace(0.1, 20, 100) # wavenumber vector
15+
r = 1 # radius of array
16+
17+
# get uniform grid (microphone positions) of order N
18+
pol, weights = micarray.modal.angular.grid_equal_polar_angle(N)
19+
20+
# pressure on the surface of a rigid cylinder for an incident plane wave
21+
bn = micarray.modal.radial.circular_pw(Nsf, k, r, setup='rigid')
22+
D = micarray.modal.radial.circ_diagonal_mode_mat(bn)
23+
Psi_p = micarray.modal.angular.cht_matrix(Nsf, pol, weights)
24+
Psi_pw = micarray.modal.angular.cht_matrix(Nsf, pw_angle)
25+
p = np.matmul(np.matmul(np.conj(Psi_pw.T), D), Psi_p)
26+
p = np.squeeze(p)
27+
28+
# plane wave decomposition using modal beamforming
29+
Psi_p = micarray.modal.angular.cht_matrix(N, pol)
30+
Psi_q = micarray.modal.angular.cht_matrix(N, pol_pwd)
31+
Bn = micarray.modal.radial.circular_pw(N, k, r, setup='rigid')
32+
Dn, _ = micarray.modal.radial.regularize(1/Bn, 100, 'softclip')
33+
D = micarray.modal.radial.circ_diagonal_mode_mat(Dn)
34+
A_pwd = np.matmul(np.matmul(np.conj(Psi_q.T), D), Psi_p)
35+
q_pwd = np.squeeze(np.matmul(A_pwd, np.expand_dims(p, 2)))
36+
q_pwd_t = np.fft.fftshift(np.fft.irfft(q_pwd, axis=0), axes=0)
37+
38+
# visualize plane wave decomposition (aka beampattern)
39+
plt.figure()
40+
plt.pcolormesh(k, pol_pwd/np.pi, micarray.util.db(q_pwd.T), vmin=-40)
41+
plt.colorbar()
42+
plt.xlabel(r'$kr$')
43+
plt.ylabel(r'$\phi / \pi$')
44+
plt.title('Plane wave docomposition by modal beamformer (frequency domain)')
45+
plt.savefig('modal_open_beamformer_pwd_fd.png')
46+
47+
plt.figure()
48+
plt.pcolormesh(range(2*len(k)-2), pol_pwd/np.pi, micarray.util.db(q_pwd_t.T), vmin=-40)
49+
plt.colorbar()
50+
plt.ylabel(r'$\phi / \pi$')
51+
plt.title('Plane wave docomposition by modal beamformer (time domain)')
52+
plt.savefig('modal_open_beamformer_pwd_td.png')

micarray/modal/angular.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,48 @@ def Legendre_matrix(N, ctheta):
9191
return Lmn
9292

9393

94+
def cht_matrix(N, pol, weights=None):
95+
r"""Matrix of circular harmonics up to order N for given angles.
96+
97+
Computes a matrix of circular harmonics up to order :math:`N`
98+
for the given angles/grid.
99+
100+
.. math::
101+
\Psi = \left[ \begin{array}{ccccccc}
102+
1 & e^{i\varphi[0]} & \cdots & e^{iN\varphi[0]} & e^{-iN\varphi[0]} & \cdots & e^{-i\varphi[0]} \\
103+
1 & e^{i\varphi[1]} & \cdots & e^{iN\varphi[1]} & e^{-iN\varphi[1]} & \cdots & e^{-i\varphi[1]} \\
104+
\vdots & \vdots & \vdots & \vdots & \vdots & \vdots & \vdots \\
105+
1 & e^{i\varphi[Q-1]} & \cdots & e^{iN\varphi[Q-1]} & e^{-iN\varphi[Q-1]} & \cdots & e^{-i\varphi[Q-1]}
106+
\end{array} \right]
107+
108+
Parameters
109+
----------
110+
N : int
111+
Maximum order.
112+
pol : (Q,) array_like
113+
Polar angle.
114+
weights : (Q,) array_like, optional
115+
Weights.
116+
117+
Returns
118+
-------
119+
Psi : (2N+1, Q) numpy.ndarray
120+
Matrix of circular harmonics.
121+
"""
122+
pol = util.asarray_1d(pol)
123+
if pol.ndim == 0:
124+
Q = 1
125+
else:
126+
Q = len(pol)
127+
if weights is None:
128+
weights = np.ones(Q)
129+
Psi = np.zeros([(2*N+1), Q], dtype=complex)
130+
order = np.roll(np.arange(-N, N+1), -N)
131+
for i, n in enumerate(order):
132+
Psi[i, :] = weights * np.exp(1j * n * pol)
133+
return Psi
134+
135+
94136
def grid_equal_angle(n):
95137
"""Equi-angular sampling points on a sphere.
96138
@@ -153,3 +195,24 @@ def grid_gauss(n):
153195
weights = np.repeat(weights, 2*n+2)
154196
weights *= np.pi / (n+1) # sum(weights) == 4pi
155197
return azi, elev, weights
198+
199+
200+
def grid_equal_polar_angle(n):
201+
"""Equi-angular sampling points on a circle.
202+
203+
Parameters
204+
----------
205+
n : int
206+
Maximum order
207+
208+
Returns
209+
-------
210+
pol : array_like
211+
Polar angle.
212+
weights : array_like
213+
Weights.
214+
"""
215+
num_mic = 2*n+1
216+
pol = np.linspace(0, 2*np.pi, num=num_mic, endpoint=False)
217+
weights = 1/num_mic * np.ones(num_mic)
218+
return pol, weights

micarray/modal/radial.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,171 @@ def repeat_n_m(v):
227227
"""
228228
krlist = [np.tile(v, (2*i+1, 1)).T for i, v in enumerate(v.T.tolist())]
229229
return np.squeeze(np.concatenate(krlist, axis=-1))
230+
231+
232+
def circular_pw(N, k, r, setup):
233+
r"""Radial coefficients for a plane wave.
234+
235+
Computes the radial component of the circular harmonics expansion of a
236+
plane wave impinging on a circular array.
237+
238+
.. math::
239+
240+
\mathring{P}_n(k) = i^{-n} b_n(kr)
241+
242+
Parameters
243+
----------
244+
N : int
245+
Maximum order.
246+
k : (M,) array_like
247+
Wavenumber.
248+
r : float
249+
Radius of microphone array.
250+
setup : {'open', 'card', 'rigid'}
251+
Array configuration (open, cardioids, rigid).
252+
253+
Returns
254+
-------
255+
bn : (M, N+1) numpy.ndarray
256+
Radial weights for all orders up to N and the given wavenumbers.
257+
"""
258+
kr = util.asarray_1d(k*r)
259+
n = np.arange(N+1)
260+
261+
bn = circ_radial_weights(N, kr, setup)
262+
for i, x in enumerate(kr):
263+
bn[i, :] = bn[i, :] * (1j)**(n)
264+
return bn
265+
266+
267+
def circular_ls(N, k, r, rs, setup):
268+
r"""Radial coefficients for a line source.
269+
270+
Computes the radial component of the circular harmonics expansion of a
271+
line source impinging on a circular array.
272+
273+
.. math::
274+
275+
\mathring{P}_n(k) = \frac{-i}{4} H_n^{(2)}(k r_s) b_n(kr)
276+
277+
Parameters
278+
----------
279+
N : int
280+
Maximum order.
281+
k : (M,) array_like
282+
Wavenumber.
283+
r : float
284+
Radius of microphone array.
285+
rs : float
286+
Distance of source.
287+
setup : {'open', 'card', 'rigid'}
288+
Array configuration (open, cardioids, rigid).
289+
290+
Returns
291+
-------
292+
bn : (M, N+1) numpy.ndarray
293+
Radial weights for all orders up to N and the given wavenumbers.
294+
"""
295+
k = util.asarray_1d(k)
296+
krs = k*rs
297+
n = np.arange(N+1)
298+
299+
bn = circ_radial_weights(N, k*r, setup)
300+
for i, x in enumerate(krs):
301+
Hn = special.hankel2(n, x)
302+
bn[i, :] = bn[i, :] * -1j/4 * Hn
303+
return bn
304+
305+
306+
def circ_radial_weights(N, kr, setup):
307+
r"""Radial weighing functions.
308+
309+
Computes the radial weighting functions for diferent array types
310+
311+
For instance for an rigid array
312+
313+
.. math::
314+
315+
b_n(kr) = J_n(kr) - \frac{J_n^\prime(kr)}{H_n^{(2)\prime}(kr)}H_n^{(2)}(kr)
316+
317+
Parameters
318+
----------
319+
N : int
320+
Maximum order.
321+
kr : (M,) array_like
322+
Wavenumber * radius.
323+
setup : {'open', 'card', 'rigid'}
324+
Array configuration (open, cardioids, rigid).
325+
326+
Returns
327+
-------
328+
bn : (M, N+1) numpy.ndarray
329+
Radial weights for all orders up to N and the given wavenumbers.
330+
331+
"""
332+
n = np.arange(N+1)
333+
Bns = np.zeros((len(kr), N+1), dtype=complex)
334+
for i, x in enumerate(kr):
335+
Jn = special.jv(n, x)
336+
if setup == 'open':
337+
bn = Jn
338+
elif setup == 'card':
339+
bn = Jn - 1j * special.jvp(n, x, n=1)
340+
elif setup == 'rigid':
341+
Jnd = special.jvp(n, x, n=1)
342+
Hn = special.hankel2(n, x)
343+
Hnd = special.h2vp(n, x)
344+
bn = Jn - Jnd/Hnd*Hn
345+
else:
346+
raise ValueError('setup must be either: open, card or rigid')
347+
Bns[i, :] = bn
348+
return np.squeeze(Bns)
349+
350+
351+
def circ_diagonal_mode_mat(bk):
352+
"""Diagonal matrix of radial coefficients for all modes/wavenumbers.
353+
354+
Parameters
355+
----------
356+
bk : (M, N+1) numpy.ndarray
357+
Vector containing values for all wavenumbers :math:`M` and modes up to
358+
order :math:`N`
359+
360+
Returns
361+
-------
362+
Bk : (M, 2*N+1, 2*N+1) numpy.ndarray
363+
Multidimensional array containing diagnonal matrices with input
364+
vector on main diagonal.
365+
"""
366+
bk = mirror_vec(bk)
367+
if len(bk.shape) == 1:
368+
bk = bk[np.newaxis, :]
369+
K, N = bk.shape
370+
Bk = np.zeros([K, N, N], dtype=complex)
371+
for k in range(K):
372+
Bk[k, :, :] = np.diag(bk[k, :])
373+
return np.squeeze(Bk)
374+
375+
376+
def mirror_vec(v):
377+
"""Mirror elements in a vector.
378+
379+
Returns a vector of length *2*len(v)-1* with symmetric elements.
380+
The first *len(v)* elements are the same as *v* and the last *len(v)-1*
381+
elements are *v[:0:-1]*. The function can be used to order the circular
382+
harmonic coefficients. If *v* is a matrix, it is treated as a stack of
383+
vectors residing in the last index and broadcast accordingly.
384+
385+
Parameters
386+
----------
387+
v : (, N+1) numpy.ndarray
388+
Input vector of stack of input vectors
389+
390+
Returns
391+
-------
392+
: (, 2*N+1) numpy.ndarray
393+
Vector of stack of vectors containing mirrored elements
394+
"""
395+
if len(v.shape) == 1:
396+
v = v[np.newaxis, :]
397+
return np.concatenate((v, v[:, :0:-1]), axis=1)

micarray/util.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,19 @@ def matdiagmul(A, b):
9191
for k in range(K):
9292
C[k, :, :] = A * b[k, :]
9393
return C
94+
95+
96+
def db(x, power=False):
97+
"""Convert *x* to decibel.
98+
99+
Parameters
100+
----------
101+
x : array_like
102+
Input data. Values of 0 lead to negative infinity.
103+
power : bool, optional
104+
If ``power=False`` (the default), *x* is squared before
105+
conversion.
106+
107+
"""
108+
with np.errstate(divide='ignore'):
109+
return 10 if power else 20 * np.log10(np.abs(x))

0 commit comments

Comments
 (0)