-
Notifications
You must be signed in to change notification settings - Fork 1
/
pybeam.py
515 lines (453 loc) · 18.9 KB
/
pybeam.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
Collection of functions for loudspeaker beamforming.
"""
import numpy as np
import scipy.signal
import scipy.io.wavfile
import os
import sys
import shutil
import pickle as pkl
import wave
import pyaudio as pa
import matplotlib.pyplot as plt
from typing import Tuple
# speed of sound constant
__c = 343
# l2-norm operation
__l2 = lambda arr: np.sqrt(np.sum(arr**2))
# linear transfer function
__Zf = lambda x_m, y_l, omega: np.e**(-1j * omega / __c * __l2(x_m - y_l)) \
/ (4 * np.pi * __l2(x_m - y_l))
def get_source_matrix(dim: tuple=(2, 1), delta: tuple=(0.5, 0),
center: tuple=(0,0,0)) -> np.ndarray:
"""
Generates a matrix of source points (i.e. loudspeakers).
Args:
dim: A two element tuple that denotes the dimension of
the source (loudspaker) array.
delta: A two element tuple that denotes the spacing between
sources in the speaker array.
center: A three element tuple that indicates the location of
the center of the source matrix in a 3D field.
Returns:
A numpy array of floats with dimensions ``dim[0] * dim[1]`` by 3.
Each row signifies a different source.
Each row is a coordinate in 3D space.
"""
L = dim[0] * dim[1]
y = np.zeros((L, 3))
for x in range(dim[0]):
for z in range(dim[1]):
y[x * dim[1] + z] = np.array([
center[0] + (x-dim[0]//2+0.5)*delta[0],
center[1],
center[2] + (z-dim[1]//2+0.5)*delta[1]])
return y
def get_verification_matrix(R: int=3, dim: tuple=(37, 1),
b: tuple=(90, 90)) -> np.ndarray:
"""
Genereates a matrix of verification points according to input
Think of the output as a matrix of fake microphones used to
help in calculations.
Args:
R: The radius of the hemispherical/semicircular region.
Optional, defaults to 3.
dim: A two element tuple that indicates where to put the
verification points.
Ex 1: ``dim=(37,1)`` indicates to construct a horizontal
semicircle with 37 control points
Ex 2: ``dim=(1,37)`` indicates to construct a vertical
emicircle with 37 control points
Ex 3: ``dim=(37,37)`` indicates to contruct a hemisphere
with 37 by 37 control points (a total of 1369)
b: A two element tuple that indicates which location
to put the bright point (i.e. where the signal should
be aimed). If one element in dim is 1, make sure to set
the corresponding index in ``b`` to ``90``.
Returns:
A numpy array with dimensions ``dim[0]*dim[1]`` by 3. Each row signifies
a different source. Each row is a coordinate in 3D space.
"""
M = dim[0] * dim[1]
delta_theta = 180 / (dim[0] - 1) * np.pi / 180 if dim[0] > 1 else 0
delta_phi = 180 / (dim[1] - 1) * np.pi / 180 if dim[1] > 1 else 0
xtmp = np.zeros((M, 3))
bidx, mindist, targpoint = \
-1, \
float('inf'), \
np.array([R*np.sin(np.radians(b[1]))*np.cos(np.radians(b[0])),
R*np.sin(np.radians(b[1]))*np.sin(np.radians(b[0])),
R*np.cos(np.radians(b[1]))])
if dim[0] <= 1: #semicircle with variable phi
for j in range(dim[1]):
xtmp[j] = \
np.array([0, R*np.sin(j*delta_phi), R*np.cos(j*delta_phi)])
if __l2(xtmp[j] - targpoint) < mindist:
bidx = j
mindist = __l2(xtmp[j] - targpoint)
elif dim[1] <= 1: #semicircle with variable theta
for i in range(dim[0]):
xtmp[i] = \
np.array(
[R*np.cos(i*delta_theta),
R*np.sin(i*delta_theta),
0])
if __l2(xtmp[i] - targpoint) < mindist:
bidx = i
mindist = __l2(xtmp[i] - targpoint)
else: #full hemisphere
for i in range(dim[0]):
for j in range(dim[1]):
idx = i * dim[1] + j
xtmp[idx] = np.array(
[R*np.cos(i*delta_theta)*np.sin(j*delta_phi),
R*np.sin(i*delta_theta)*np.sin(j*delta_phi),
R*np.cos(j*delta_phi)])
if __l2(xtmp[idx] - targpoint) < mindist:
bidx = idx
mindist = __l2(xtmp[idx] - targpoint)
x = xtmp.copy()
x[0], x[1:bidx+1] = xtmp[bidx], xtmp[:bidx]
return x
def get_DAS_filters(X: np.ndarray, Y: np.ndarray,
samp_freq: int=44100, samples: int=1024,
modeling_delay: float=0) -> np.matrix:
"""
Generates a matrix of complex filters using Delay & Sum.
Essentially used as a black-box to generate filters
for the :func:`~pybeam.map_filters` function.
Args:
X: A verification matrix
generated by func:`~pybeam.get_verification_matrix`.
Y: A source matrix generated by
:func:`~pybeam.get_source_matrix`.
samp_freq: The frequency at which the audio signal will
be sampled at.
samples: The number of samples per frame, any arbitrary
integer that is some power of 2.
modeling_delay: The modeling delay in seconds.
Returns:
A complex numpy matrix with frequency domain filters for
each loudspeaker.
"""
M, L = X.shape[0], Y.shape[0]
gamma = lambda x_b, y, l: 16 * np.pi**2 * __l2(x_b - y[l])**2 / L
freqs = np.fft.fftfreq(samples, 1 / samp_freq)
q_DAS = np.asmatrix(np.zeros((freqs.size, L), dtype="complex_"))
for i in range(freqs.size):
freq = freqs[i]
omega = 2 * np.pi * freq
z_b = np.asmatrix(np.zeros((L,1)), dtype="complex_")
for l in range(L):
z_b[l, 0] = __Zf(X[0], Y[l], omega)
Gamma = np.asmatrix(np.zeros((L,L)), dtype="complex_")
for l in range(L):
Gamma[l,l] = gamma(X[0], Y, l)
this_q = np.e**(-1j * omega * modeling_delay) \
* Gamma * np.conjugate(z_b)
q_DAS[i] = this_q.T
return q_DAS
def get_max_energy(Y: np.ndarray,
sigma: float=5, R: float=3) -> float:
"""
Returns the maximum energy consumption of a source matrix
Args:
sigma: An arbitrary constant, increase to use more energy.
R: The distance between verification points and the center
of the loudspeaker array.
Y: A source matrix generated by
:func:`~pybeam.get_source_matrix`.
Returns:
The maximum energy consumption of the source matrix
under the current constraints
"""
return sigma * (4 * np.pi * R)**2 / Y.shape[0]
def get_target_sound_pressures(X: np.ndarray,
onval: int=1, offval: int=0) -> np.matrix:
"""
Generates a matrix of target sound pressures
Args:
onval: The sound pressure at the _bright_ point.
offval: The sound pressure at _dark_ points.
X: A verificatjon matrix generated by
:func:`~pybeam.get_verification_matrix`.
Returns:
A complex numpy matrix of target sound pressures
"""
return np.asmatrix(np.array([onval] + [offval]*(X.shape[0]-1),
dtype="complex_")).T
def get_PM_filters(X: np.ndarray, Y: np.ndarray,
E_max: float, p_hat: np.matrix,
samp_freq: int=44100, samples: int=1024, modeling_delay: float=0,
verbose: bool=False) -> np.matrix:
"""
Generates a matrix of complex filters using Pressure Matching.
Essentially used as a black-box to generate filters
for the :func:`~pybeam.map_filters` function.
Args:
X: A verification matrix generated by
:func:`~pybeam.get_verification_matrix`.
Y: A source matrix generated by
:func:`~pybeam.get_source_matrix`.
E_max: The maximum energy consumption of the source matrix
as calculated by :func:`~pybeam.get_max_energy`.
p_hat: Matrix of target sound pressures generated by
:func:`pybeam.get_target_sound_pressures`.
samp_freq: The frequency at which the audio signal will
be sampled at.
samples: The number of samples per frame, any arbitrary
integer that is some power of 2.
modeling_delay: The modeling delay in seconds.
verbose: Flag for debug print statements.
Returns:
A complex numpy matrix with frequency domain filters for
each loudspeaker.
"""
M, L, p_b_hat = X.shape[0], Y.shape[0], p_hat[0]
W = lambda q, z_b: (p_b_hat / np.dot(z_b, q))[0, 0]
E = lambda q: np.dot(np.conjugate(q).T, q)[0, 0]
epsilon_beta, beta_min = 1e-5, 1e-19
freqs = np.fft.fftfreq(samples, 1 / samp_freq)
q_PM = np.asmatrix(np.zeros((freqs.size, L), dtype="complex_"))
for i in range(len(freqs)):
freq = freqs[i]
if(verbose): print('frequency:', freq, flush=True)
omega = 2 * np.pi * freq
beta = beta_min
Z = np.asmatrix(np.zeros((M, L), dtype="complex_"))
for m in range(M):
for l in range(L):
Z[m, l] = __Zf(X[m], Y[l], omega)
q_temp = np.linalg.inv(
np.conjugate(Z).T * Z + beta * np.asmatrix(np.eye(L))) \
* np.conjugate(Z).T * p_hat
q_hat = W(q_temp, Z[0]) * q_temp
while(E(q_hat) > E_max):
beta += epsilon_beta
q_temp = np.linalg.inv(
np.conjugate(Z).T * Z + beta * np.asmatrix(np.eye(L))) \
* np.conjugate(Z).T * p_hat
q_hat = W(q_temp, Z[0]) * q_temp
q_PM[i] = np.e**(-1j * omega * modeling_delay) * q_hat.T
return q_PM
def map_filters(filters: np.matrix,
signal: np.ndarray) -> np.ndarray:
"""
Maps filters onto an audio signal.
Args:
filters: A numpy matrix with complex filters for each
output loudspeaker. The ouput of either
:func:`~pybeam.get_DAS_filters` or
:func:`~pybeam.get_PM_filters`.
signal: An audio monosignal (i.e. a one-dimensional
numpy array).
Returns:
A numpy array with a new signal for each output
loudspeaker.
"""
#beamforming filters
samples, L = filters.shape[0], filters.shape[1]
o = np.zeros((L, len(signal)), dtype="complex_")
for i in range(0, len(signal), samples//4):
snapshot = np.pad(signal[i:i+samples],
(0, samples - len(signal[i:i+samples])),
'constant',
constant_values=0)
snapshot_fft = np.fft.fft(snapshot)
for l in range(L):
snapshot_filt = np.multiply(snapshot_fft, filters[:, l].T)
o[l, i:i+min(samples, len(o[l]) - i)] = \
np.fft.ifft(snapshot_filt)[:, :min(samples, len(o[l]) - i)]
abs_avgs = np.average(np.abs(o), axis=1)
max_avg = abs_avgs[np.argmax(abs_avgs)]
scaling_factor = np.average(np.abs(signal)) / max_avg
o *= scaling_factor
#butterworth filter
filtb, filta = scipy.signal.butter(8, 0.5)
o = scipy.signal.filtfilt(filtb, filta, o)
o = o.real.astype(signal.dtype)
return o
def read_wav_file(fname: str) -> Tuple[np.ndarray, int, np.dtype]:
"""
Reads a ``.wav`` file from the filesystem.
Args:
fname: The full path to the ``.wav`` file to be read.
Note that the ``.wav`` file should be a monosignal (i.e.
not stereo).
Returns:
: Tuple containing:
signal: A numpy array containting the monosignal read in.
samp_freq: The sampling frequency of the
``.wav`` file read in.
dtype: The numpy datatype of signal.
"""
data = scipy.io.wavfile.read(fname)
signal = data[1]
samp_freq = data[0]
dtype = signal.dtype
return signal, samp_freq, dtype
def write_wav_dir(directory: str, output_signal: np.ndarray,
mapping: dict, samp_freq: int=44100) -> None:
"""
Writes a directory that can be played back by multiple speakers.
A directory is created, then populated with .wav files
for each speaker pair and a pickle file of mapping.
Args:
directory: Writable path to which the directory is to be
written to. Warning, this function will delete the path
if the path exists and can be written to.
output_signal: A numpy array of time-domain signals to be
played back by the speaker array. This input is generated
by :func:`~pybeam.map_filters`.
mapping: A dictionary that points speaker pairs to audio
stream indexes from PyAudio.
A speaker pair is defined as two adjacent speakers, and
is assigned such that speakers 0 and 1 are speaker
pair 0, 2 and 3 are speaker pair 2, etc.
samp_freq: The sampling frequency of the signals
in ``output_signal``.
"""
if os.path.exists(directory):
shutil.rmtree(directory)
os.mkdir(directory)
#mapping = speaker pair # -> input id
for i in range(0, output_signal.shape[0], 2):
scipy.io.wavfile.write('{}/speaker{:d}.wav'.format(directory, i//2),
samp_freq,
output_signal[i:i+2].T)
pkl.dump(mapping, open('{}/mapping.pkl'.format(directory), 'wb'))
def playback_wav_dir(directory: str, chunks: int=1024) -> None:
"""
Plays back a directory generated by :func:`~pybeam.write_wav_dir`.
Args:
directory: A readable directory generated by
:func:`~pybeam.write_wav_dir`.
chunks: The number of samples to read each iteration.
Optional, defaults to 1024.
"""
#read mapping
mapping = pkl.load(open('{}/mapping.pkl'.format(directory), 'rb'))
nstreams = len(mapping)
p = pa.PyAudio()
#read wav files
wfiles = {}
for i in range(nstreams):
wavfile = wave.open('{}/speaker{:d}.wav'.format(directory, i), 'rb')
wfiles[mapping[i]] = wavfile
#create streams
streams = {}
for i in range(nstreams):
streams[mapping[i]] = p.open(
format=p.get_format_from_width(wfiles[mapping[i]].getsampwidth()),
channels=wfiles[mapping[i]].getnchannels(),
rate=wfiles[mapping[i]].getframerate(),
output=True,
output_device_index=mapping[i])
#playback wav files
data = [wfiles[mapping[i]].readframes(chunks) for i in range(nstreams)]
while not all([len(data[i]) == 0 for i in range(nstreams)]):
#print([len(_) for _ in data])
for i in range(nstreams): streams[mapping[i]].write(data[i])
data = [wfiles[mapping[i]].readframes(chunks) for i in range(nstreams)]
#print([len(_) for _ in data])
#close up shop
for i in range(nstreams):
streams[mapping[i]].stop_stream()
streams[mapping[i]].close()
p.terminate()
def visualize(Q: np.matrix, X: np.ndarray, Y: np.ndarray,
onval: float=1, R: float=3, test_index: float=100,
dpu: int=100, sample_size: int=1024, rate: int=44100,
verbose: bool=False) -> None:
"""
Generates a heatmap that helps visualize beamforming.
Plots the heatmap using matplotlib.
Args:
Q: A numpy matrix of complex filters for multiple
loudspeakers, the output of
:func:`~pybeam.get_DAS_filters` or
:func:`~pybeam.get_PM_filters`.
X: A verification matrix generated by
:func:`~pybeam.get_verification_matrix`.
Y: A source matrix generated by
:func:`~pybeam.get_source_matrix`.
onval: The on value specified during the computation
of filters.
R: The distance of verification points from the
speaker array specified during the
computation of filters.
test_index: The index of ``numpy.fft.fftfreq``
corresponding to the frequency of interest. Ensure
that ``numpy.fft.fftreq``'s parameter
``n`` and ``sample_size`` are equal.
dpu: The dots per unit for the plot. Higher values
produce a prettier plot but take longer to compute.
sample_size: The number of samples specified when
computing filters.
rate: The sampling rate specified when computing
filters.
verbose: Flag to enable debug print statements.
"""
M, L = X.shape[0], Y.shape[0]
M_test = int((R+1)**2 * dpu**2 * 2)
if(verbose): print(M_test)
X_test = np.ones((M_test, 3))
n = 0
for i in range(-dpu * (R+1), dpu * (R+1)):
for j in range(0, dpu * (R+1)):
X_test[n] = np.array([i / dpu, j / dpu, 0])
n += 1
Z = np.asmatrix(np.zeros((M_test, L), dtype='complex_'))
freq = np.fft.fftfreq(sample_size, 1 / rate)[test_index]
omega = 2 * np.pi * freq
if(verbose):
print('Freq:{:.0f}, AngVel:{:.0f}'.format(freq, omega), flush=True)
for m in range(M_test):
if m % 10000 == 0 and verbose: print(m, M_test, flush=True)
for l in range(L):
if __l2(X_test[m] - Y[l]) != 0:
Z[m, l] = __Zf(X_test[m], Y[l], omega)
else:
Z[m, l] = 0
p_test = Z * Q[test_index].T
output = np.zeros(((R+1)*dpu, 2*(R+1)*dpu))
for n in range(X_test.shape[0]):
output[(R+1)*dpu - 1 - int(X_test[n, 1]*dpu), int(X_test[n, 0] * dpu
+ (R+1)*dpu)] = np.abs(p_test[n, 0])
for i in range(output.shape[0]):
for j in range(output.shape[1]):
if output[i, j] == 0:
denom = 0
if i + 1 < output.shape[0] and output[i + 1, j] != 0:
output[i, j] += output[i + 1, j]
denom += 1
if i - 1 >= 0 and output[i - 1, j] != 0:
output[i, j] += output[i - 1, j]
denom += 1
if j + 1 < output.shape[1] and output[i, j + 1] != 0:
output[i, j] += output[i, j + 1]
denom += 1
if j - 1 >= 0 and output[i, j - 1] != 0:
output[i, j] += output[i, j - 1]
denom += 1
if denom > 0:
output[i, j] /= denom
fig = plt.figure()
plt.scatter(np.asarray(np.concatenate((X, Y))[:, 0]),
np.concatenate((X, Y))[:, 1],
c=['b'] + ['r']*(M-1) + ['g']*L, marker='o')
implot = plt.imshow(output,
interpolation='hamming',
cmap='inferno',
extent=[-R-1, R+1, 0, R+1],
vmin=0, vmax=onval)
plt.colorbar()
plt.title("{:.0f}Hz".format(freq), fontsize=32)
plt.xlabel("x, [m]", fontsize=12)
plt.ylabel("y, [m]", fontsize=12)
plt.show()
if __name__ == '__main__':
print("Hello, world!")