pyFDN 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyFDN/__init__.py +380 -0
- pyFDN/audio/example_process_fdn_output.wav +0 -0
- pyFDN/audio/s3_r4_o.wav +0 -0
- pyFDN/audio/synth_dry.wav +0 -0
- pyFDN/auxiliary/__init__.py +59 -0
- pyFDN/auxiliary/acoustics.py +545 -0
- pyFDN/auxiliary/allpass.py +311 -0
- pyFDN/auxiliary/audio.py +54 -0
- pyFDN/auxiliary/coupled_rooms.py +193 -0
- pyFDN/auxiliary/delay.py +132 -0
- pyFDN/auxiliary/flamo.py +648 -0
- pyFDN/auxiliary/flamo_graph.py +754 -0
- pyFDN/auxiliary/math.py +515 -0
- pyFDN/auxiliary/plot.py +1352 -0
- pyFDN/auxiliary/poles.py +104 -0
- pyFDN/auxiliary/tiny_rotation_matrix.py +109 -0
- pyFDN/auxiliary/utils.py +296 -0
- pyFDN/dsp/__init__.py +3 -0
- pyFDN/dsp/dfilt_matrix.py +53 -0
- pyFDN/dsp/feedback_delay.py +47 -0
- pyFDN/dsp/sos_filter_bank.py +57 -0
- pyFDN/dsp/time_varying_matrix.py +144 -0
- pyFDN/generate/SDN.py +718 -0
- pyFDN/generate/allpass_FDN/__init__.py +11 -0
- pyFDN/generate/allpass_FDN/allpass_completion.py +630 -0
- pyFDN/generate/allpass_FDN/homogeneous_allpass_fdn.py +116 -0
- pyFDN/generate/allpass_FDN/rand_admissible_homogeneous_allpass.py +57 -0
- pyFDN/generate/allpass_in_fdn.py +78 -0
- pyFDN/generate/anderson_matrix.py +46 -0
- pyFDN/generate/complete_orthogonal.py +56 -0
- pyFDN/generate/construct_cascaded_paraunitary_matrix.py +58 -0
- pyFDN/generate/construct_paraunitary_from_elementals.py +52 -0
- pyFDN/generate/construct_velvet_feedback_matrix.py +17 -0
- pyFDN/generate/degree_one_lossless.py +31 -0
- pyFDN/generate/fdn_matrix_gallery.py +560 -0
- pyFDN/generate/householder_matrix.py +24 -0
- pyFDN/generate/is_almost_zero.py +18 -0
- pyFDN/generate/nearest_orthogonal.py +24 -0
- pyFDN/generate/nearest_sign_agnostic_orthogonal.py +82 -0
- pyFDN/generate/random_matrix_shift.py +42 -0
- pyFDN/generate/random_orthogonal.py +10 -0
- pyFDN/generate/sample_delay_lengths.py +149 -0
- pyFDN/generate/schroeder_reverberator.py +85 -0
- pyFDN/generate/shift_matrix.py +45 -0
- pyFDN/generate/shift_matrix_distribute.py +24 -0
- pyFDN/graphicEQ/__init__.py +17 -0
- pyFDN/graphicEQ/absorption_geq.py +54 -0
- pyFDN/graphicEQ/bandpass_filter.py +34 -0
- pyFDN/graphicEQ/design_geq.py +73 -0
- pyFDN/graphicEQ/graphic_eq.py +74 -0
- pyFDN/graphicEQ/probe_sos.py +52 -0
- pyFDN/graphicEQ/shelving_filter.py +55 -0
- pyFDN/process.py +106 -0
- pyFDN/pyFDN.py +1 -0
- pyFDN/train/__init__.py +29 -0
- pyFDN/train/build.py +321 -0
- pyFDN/train/engine.py +186 -0
- pyFDN/train/objectives.py +207 -0
- pyFDN/translate/dss_to_flamo.py +215 -0
- pyFDN/translate/dss_to_impz.py +48 -0
- pyFDN/translate/dss_to_pr.py +222 -0
- pyFDN/translate/dss_to_ss.py +101 -0
- pyFDN/translate/dss_to_tf.py +92 -0
- pyFDN/translate/flamo_to_pr.py +893 -0
- pyFDN/translate/impz_to_res.py +53 -0
- pyFDN/translate/mtf_to_impz.py +51 -0
- pyFDN/translate/pr_to_impz.py +70 -0
- pyfdn-0.1.0.dist-info/METADATA +158 -0
- pyfdn-0.1.0.dist-info/RECORD +72 -0
- pyfdn-0.1.0.dist-info/WHEEL +5 -0
- pyfdn-0.1.0.dist-info/licenses/LICENSE +22 -0
- pyfdn-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Acoustics and RT related functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
from scipy.signal import firwin2, sosfreqz
|
|
10
|
+
from scipy.special import erfc
|
|
11
|
+
|
|
12
|
+
from pyFDN.auxiliary.utils import db_to_lin, hertz_to_unit, lin_to_db
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def rt_to_slope(rt: ArrayLike, fs: float) -> np.ndarray:
|
|
16
|
+
"""Convert reverb time (RT, seconds) to energy decay slope (dB per sample)."""
|
|
17
|
+
|
|
18
|
+
rt_arr = np.asarray(rt, dtype=float)
|
|
19
|
+
return -60.0 / (rt_arr * fs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def slope_to_rt(slope: ArrayLike, fs: float) -> np.ndarray:
|
|
23
|
+
"""Convert slope (dB/sample) to reverb time in seconds."""
|
|
24
|
+
slope_arr = np.asarray(slope, dtype=float)
|
|
25
|
+
return -60.0 / (slope_arr * fs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rt_to_gain_per_sample(rt: float, fs: float) -> float:
|
|
29
|
+
"""Convert reverb time (seconds) to gain coefficient per sample.
|
|
30
|
+
|
|
31
|
+
The gain g satisfies g^(rt*fs) = 10^(-3), i.e. about -30 dB after rt seconds.
|
|
32
|
+
"""
|
|
33
|
+
return 10 ** (-3 / (rt * fs))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def edc(ir: ArrayLike, axis: int = 0) -> np.ndarray:
|
|
37
|
+
"""Energy decay curve: backward cumulative sum of squared signal along an axis.
|
|
38
|
+
|
|
39
|
+
EDC(t) = sum(ir[t:]^2), so the curve decreases from total energy to zero.
|
|
40
|
+
Typically used with impulse responses with shape (n_samples, n_channels).
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
ir : array-like
|
|
45
|
+
Signal(s). If 1D, EDC of that signal. If 2D (e.g. samples x channels),
|
|
46
|
+
EDC is computed along the time axis for each channel.
|
|
47
|
+
axis : int, optional
|
|
48
|
+
Axis along which time runs (default 0). EDC is computed along this axis.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
np.ndarray
|
|
53
|
+
Same shape as ir. Values are non-negative and non-increasing along axis.
|
|
54
|
+
"""
|
|
55
|
+
ir = np.asarray(ir, dtype=float)
|
|
56
|
+
rev = np.flip(ir, axis=axis)
|
|
57
|
+
cum = np.cumsum(rev**2, axis=axis)
|
|
58
|
+
return np.flip(cum, axis=axis)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def absorption_filters(
|
|
62
|
+
frequency: ArrayLike,
|
|
63
|
+
target_rt: np.ndarray,
|
|
64
|
+
filterOrder: int,
|
|
65
|
+
delays: ArrayLike,
|
|
66
|
+
fs: float,
|
|
67
|
+
) -> np.ndarray:
|
|
68
|
+
"""
|
|
69
|
+
Generate FIR absorption filters for each channel.
|
|
70
|
+
frequency: [freq_points]
|
|
71
|
+
target_rt: shape (freq_points, channels)
|
|
72
|
+
delays: array of length channels
|
|
73
|
+
"""
|
|
74
|
+
delays_arr = np.asarray(delays, dtype=float)
|
|
75
|
+
num_channels = len(delays_arr)
|
|
76
|
+
unit_freq = hertz_to_unit(frequency, fs)
|
|
77
|
+
FIR = np.zeros((num_channels, filterOrder + 1))
|
|
78
|
+
|
|
79
|
+
if filterOrder == 0:
|
|
80
|
+
rt = target_rt[0, :]
|
|
81
|
+
db = delays_arr * rt_to_slope(rt, fs)
|
|
82
|
+
FIR[:, 0] = db_to_lin(db)
|
|
83
|
+
else:
|
|
84
|
+
for ch in range(num_channels):
|
|
85
|
+
rt = target_rt[:, ch]
|
|
86
|
+
delay = delays_arr[ch] + int(np.ceil(filterOrder / 2))
|
|
87
|
+
db = delay * rt_to_slope(rt, fs)
|
|
88
|
+
target_amp = db_to_lin(db)
|
|
89
|
+
# firwin2 expects normalized [0..1] freqs and gain values
|
|
90
|
+
FIR[ch, :] = firwin2(filterOrder + 1, unit_freq, target_amp)
|
|
91
|
+
return FIR
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def absorption_to_rt(
|
|
95
|
+
filterCoeffs: np.ndarray,
|
|
96
|
+
delays: ArrayLike,
|
|
97
|
+
nfft: int,
|
|
98
|
+
fs: float,
|
|
99
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
100
|
+
"""Compute reverb time from recursive absorption filter with delay."""
|
|
101
|
+
delays_arr = np.asarray(delays, dtype=float)
|
|
102
|
+
filterLen = filterCoeffs.shape[1]
|
|
103
|
+
response = np.fft.fft(filterCoeffs, nfft, axis=1)
|
|
104
|
+
freq = np.linspace(0, fs / 2, nfft // 2, endpoint=False)
|
|
105
|
+
|
|
106
|
+
response = response[:, : nfft // 2]
|
|
107
|
+
freq = freq[: nfft // 2]
|
|
108
|
+
|
|
109
|
+
totalDelay = delays_arr[:, None] + filterLen / 2
|
|
110
|
+
decayPerSample = lin_to_db(np.abs(response)) / totalDelay
|
|
111
|
+
rt = slope_to_rt(decayPerSample, fs)
|
|
112
|
+
return rt.T, freq # shape: (freq_points, channels)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def echo_density(
|
|
116
|
+
ir: ArrayLike,
|
|
117
|
+
n: int = 1024,
|
|
118
|
+
fs: float = 48000.0,
|
|
119
|
+
pre_delay: int = 0,
|
|
120
|
+
mixing_thresh: float = 1.0,
|
|
121
|
+
hop: int = 500,
|
|
122
|
+
) -> tuple[float, np.ndarray]:
|
|
123
|
+
"""Echo density and mixing time (Abel & Huang 2006).
|
|
124
|
+
|
|
125
|
+
Computes the transition time between early reflections and stochastic
|
|
126
|
+
reverberation assuming sound pressure in a reverberant field is
|
|
127
|
+
Gaussian distributed.
|
|
128
|
+
|
|
129
|
+
Reference: Abel & Huang (2006), "A simple, robust measure of
|
|
130
|
+
reverberation echo density", Proc. 121st AES Convention, San Francisco.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
ir : array-like
|
|
135
|
+
Impulse response (1 channel only). Converted to 1D.
|
|
136
|
+
n : int, optional
|
|
137
|
+
Window length (must be even). Default 1024.
|
|
138
|
+
fs : float, optional
|
|
139
|
+
Sampling rate in Hz. Default 48000.
|
|
140
|
+
pre_delay : int, optional
|
|
141
|
+
Onset delay in samples for mixing time. Default 0.
|
|
142
|
+
mixing_thresh : float, optional
|
|
143
|
+
Normalized echo density threshold for mixing time (Abel & Huang use 1).
|
|
144
|
+
Default 1.0.
|
|
145
|
+
hop : int, optional
|
|
146
|
+
Hop size in samples for sparse analysis. Default 500.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
t_abel : float
|
|
151
|
+
Mixing time in milliseconds (time at which echo density first
|
|
152
|
+
exceeds mixing_thresh, relative to pre_delay). 0 if not found.
|
|
153
|
+
echo_dens : np.ndarray
|
|
154
|
+
Echo density vector (length = len(ir)), normalized; interpolated
|
|
155
|
+
from sparse analysis.
|
|
156
|
+
"""
|
|
157
|
+
ir_arr = np.asarray(ir, dtype=float).ravel()
|
|
158
|
+
len_ir = len(ir_arr)
|
|
159
|
+
if n % 2 != 0:
|
|
160
|
+
raise ValueError("Window length n must be even.")
|
|
161
|
+
if len_ir < n:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"IR length {len_ir} is shorter than analysis window {n}. "
|
|
164
|
+
"Provide at least an IR of some 100 ms."
|
|
165
|
+
)
|
|
166
|
+
half_win = n // 2
|
|
167
|
+
w_tau = np.hanning(n)
|
|
168
|
+
w_tau = w_tau / np.sum(w_tau)
|
|
169
|
+
|
|
170
|
+
sparse_ind = np.arange(0, len_ir, hop, dtype=int)
|
|
171
|
+
if sparse_ind[-1] != len_ir - 1 and len_ir - 1 not in sparse_ind:
|
|
172
|
+
sparse_ind = np.append(sparse_ind, len_ir - 1)
|
|
173
|
+
echo_dens_sparse = np.zeros(len(sparse_ind))
|
|
174
|
+
|
|
175
|
+
for ii, n_center in enumerate(sparse_ind):
|
|
176
|
+
if n_center <= half_win:
|
|
177
|
+
h_tau = ir_arr[0 : n_center + half_win]
|
|
178
|
+
w_t = w_tau[-(n_center + half_win) :]
|
|
179
|
+
elif n_center <= len_ir - half_win - 1:
|
|
180
|
+
h_tau = ir_arr[n_center - half_win : n_center + half_win]
|
|
181
|
+
w_t = w_tau.copy()
|
|
182
|
+
else:
|
|
183
|
+
h_tau = ir_arr[n_center - half_win : len_ir]
|
|
184
|
+
w_t = w_tau[: len(h_tau)].copy()
|
|
185
|
+
|
|
186
|
+
s = np.sqrt(np.sum(w_t * (h_tau**2)))
|
|
187
|
+
tip_ct = (np.abs(h_tau) > s).astype(float)
|
|
188
|
+
echo_dens_sparse[ii] = np.sum(w_t * tip_ct)
|
|
189
|
+
|
|
190
|
+
echo_dens_sparse = echo_dens_sparse / erfc(1.0 / np.sqrt(2))
|
|
191
|
+
echo_dens = np.interp(
|
|
192
|
+
np.arange(len_ir, dtype=float),
|
|
193
|
+
sparse_ind.astype(float),
|
|
194
|
+
echo_dens_sparse,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
d = np.where(echo_dens > mixing_thresh)[0]
|
|
198
|
+
if d.size > 0:
|
|
199
|
+
first_idx = int(d[0])
|
|
200
|
+
t_abel = (first_idx - pre_delay) / fs * 1000.0
|
|
201
|
+
if t_abel < 0:
|
|
202
|
+
t_abel = 0.0
|
|
203
|
+
else:
|
|
204
|
+
t_abel = 0.0
|
|
205
|
+
warnings.warn(
|
|
206
|
+
"Mixing time not found within given limits.", UserWarning, stacklevel=2
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return float(t_abel), echo_dens
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def estimate_rt_bands(
|
|
213
|
+
ir: ArrayLike,
|
|
214
|
+
fs: float,
|
|
215
|
+
fc: float = 1000.0,
|
|
216
|
+
start: float = -4.0,
|
|
217
|
+
n: int = 8,
|
|
218
|
+
filter_order: int = 8,
|
|
219
|
+
decay_db: float = 30.0,
|
|
220
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
221
|
+
"""Estimate RT in octave bands via Butterworth bandpass filtering.
|
|
222
|
+
|
|
223
|
+
Filters the impulse response into octave bands using
|
|
224
|
+
``pyroomacoustics.bandpass_filterbank``, then estimates RT per band
|
|
225
|
+
using ``pyroomacoustics.measure_rt60`` (extrapolated from ``decay_db``).
|
|
226
|
+
|
|
227
|
+
Default bands: 63, 125, 250, 500, 1000, 2000, 4000, 8000 Hz (``start=-4, n=8``).
|
|
228
|
+
Bands whose upper edge exceeds ``fs/2`` are dropped.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
ir : array-like, 1-D
|
|
233
|
+
Impulse response.
|
|
234
|
+
fs : float
|
|
235
|
+
Sampling rate in Hz.
|
|
236
|
+
fc : float
|
|
237
|
+
Octave-band reference centre frequency in Hz (default 1000).
|
|
238
|
+
start : float
|
|
239
|
+
Octave offset of the lowest band relative to ``fc`` (default -4 → 62.5 Hz).
|
|
240
|
+
n : int
|
|
241
|
+
Number of octave bands (default 8).
|
|
242
|
+
filter_order : int
|
|
243
|
+
Butterworth filter order (default 8).
|
|
244
|
+
decay_db : float
|
|
245
|
+
Decay range in dB used for the linear fit. The default 30 dB fit is
|
|
246
|
+
extrapolated to a 60 dB reverberation time.
|
|
247
|
+
|
|
248
|
+
Returns
|
|
249
|
+
-------
|
|
250
|
+
rt : (n_bands,) ndarray
|
|
251
|
+
Estimated RT in seconds per band.
|
|
252
|
+
f_centre : (n_bands,) ndarray
|
|
253
|
+
Centre frequencies in Hz corresponding to each RT value.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
import pyroomacoustics as pra
|
|
257
|
+
except ImportError as exc:
|
|
258
|
+
raise ImportError(
|
|
259
|
+
"estimate_rt_bands requires pyroomacoustics (pip install pyroomacoustics)"
|
|
260
|
+
) from exc
|
|
261
|
+
|
|
262
|
+
from scipy.signal import sosfilt
|
|
263
|
+
|
|
264
|
+
ir = np.asarray(ir, dtype=float).ravel()
|
|
265
|
+
bands, f_centre = pra.octave_bands(fc=fc, start=start, n=n)
|
|
266
|
+
|
|
267
|
+
# drop bands whose upper edge is at or above Nyquist
|
|
268
|
+
valid = bands[:, 1] < fs / 2
|
|
269
|
+
bands = bands[valid]
|
|
270
|
+
f_centre = f_centre[valid]
|
|
271
|
+
|
|
272
|
+
sos_bank = pra.bandpass_filterbank(bands, fs=fs, order=filter_order, output="sos")
|
|
273
|
+
rt = np.zeros(len(f_centre))
|
|
274
|
+
for k, sos in enumerate(sos_bank):
|
|
275
|
+
ir_band = sosfilt(sos, ir)
|
|
276
|
+
rt[k] = pra.measure_rt60(ir_band, fs=fs, decay_db=decay_db)
|
|
277
|
+
|
|
278
|
+
return rt, f_centre
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def estimate_initial_level_bands(
|
|
282
|
+
ir: ArrayLike,
|
|
283
|
+
rt: ArrayLike,
|
|
284
|
+
fs: float,
|
|
285
|
+
fc: float = 1000.0,
|
|
286
|
+
start: float = -4.0,
|
|
287
|
+
n: int = 8,
|
|
288
|
+
filter_order: int = 8,
|
|
289
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
290
|
+
"""Estimate the initial level of the exponential decay per octave band.
|
|
291
|
+
|
|
292
|
+
Companion to :func:`estimate_rt_bands` (same octave filterbank). Models
|
|
293
|
+
the squared band-filtered impulse response as ``L^2 * 10^(-6 t / T)`` and
|
|
294
|
+
matches the total band energy: ``E = L^2 * T * fs / (6 ln 10)``, hence
|
|
295
|
+
``L = sqrt(6 ln(10) E / (T fs))``. This replaces the DecayFitNet
|
|
296
|
+
initial-level estimate used in the MATLAB ``example_RIR2FDN``.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
ir : array-like, 1-D
|
|
301
|
+
Impulse response, starting at the onset.
|
|
302
|
+
rt : array-like
|
|
303
|
+
RT in seconds per band, as returned by :func:`estimate_rt_bands`
|
|
304
|
+
with the same band parameters.
|
|
305
|
+
fs : float
|
|
306
|
+
Sampling rate in Hz.
|
|
307
|
+
fc, start, n, filter_order
|
|
308
|
+
Octave filterbank parameters, see :func:`estimate_rt_bands`.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
level : (n_bands,) ndarray
|
|
313
|
+
Initial level (linear amplitude) per band.
|
|
314
|
+
f_centre : (n_bands,) ndarray
|
|
315
|
+
Centre frequencies in Hz corresponding to each level.
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
import pyroomacoustics as pra
|
|
319
|
+
except ImportError as exc:
|
|
320
|
+
raise ImportError(
|
|
321
|
+
"estimate_initial_level_bands requires pyroomacoustics "
|
|
322
|
+
"(pip install pyroomacoustics)"
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
from scipy.signal import sosfilt
|
|
326
|
+
|
|
327
|
+
ir = np.asarray(ir, dtype=float).ravel()
|
|
328
|
+
rt = np.asarray(rt, dtype=float).ravel()
|
|
329
|
+
bands, f_centre = pra.octave_bands(fc=fc, start=start, n=n)
|
|
330
|
+
|
|
331
|
+
valid = bands[:, 1] < fs / 2
|
|
332
|
+
bands = bands[valid]
|
|
333
|
+
f_centre = f_centre[valid]
|
|
334
|
+
if rt.size != len(f_centre):
|
|
335
|
+
raise ValueError("rt must have one entry per octave band")
|
|
336
|
+
|
|
337
|
+
sos_bank = pra.bandpass_filterbank(bands, fs=fs, order=filter_order, output="sos")
|
|
338
|
+
level = np.zeros(len(f_centre))
|
|
339
|
+
for k, sos in enumerate(sos_bank):
|
|
340
|
+
ir_band = sosfilt(sos, ir)
|
|
341
|
+
energy = np.sum(ir_band**2)
|
|
342
|
+
level[k] = np.sqrt(6.0 * np.log(10.0) * energy / (rt[k] * fs))
|
|
343
|
+
|
|
344
|
+
return level, f_centre
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def one_pole_absorption(
|
|
348
|
+
rt_dc: float, rt_ny: float, delays: ArrayLike, fs: float
|
|
349
|
+
) -> np.ndarray:
|
|
350
|
+
"""Design one-pole absorption filters according to specified reverb time.
|
|
351
|
+
|
|
352
|
+
Returns a one-section per-channel SOS bank of shape ``(1, 6, N)`` (the
|
|
353
|
+
canonical SOS bank layout; section rows are ``[b0, b1, b2, a0, a1, a2]``).
|
|
354
|
+
"""
|
|
355
|
+
delays_arr = np.asarray(delays, dtype=float)
|
|
356
|
+
|
|
357
|
+
# Calculate target gains
|
|
358
|
+
slope_dc = rt_to_slope(rt_dc, fs)
|
|
359
|
+
slope_ny = rt_to_slope(rt_ny, fs)
|
|
360
|
+
|
|
361
|
+
# Convert to linear magnitude
|
|
362
|
+
h_dc = db_to_lin(delays_arr * slope_dc)
|
|
363
|
+
h_ny = db_to_lin(delays_arr * slope_ny)
|
|
364
|
+
|
|
365
|
+
# Design filters
|
|
366
|
+
r = h_dc / h_ny
|
|
367
|
+
a1 = (1.0 - r) / (1.0 + r)
|
|
368
|
+
b0 = (1.0 - a1) * h_ny
|
|
369
|
+
|
|
370
|
+
num_filters = h_dc.size
|
|
371
|
+
sos = np.zeros((1, 6, num_filters))
|
|
372
|
+
sos[0, 0, :] = b0 # b0
|
|
373
|
+
sos[0, 3, :] = 1.0 # a0
|
|
374
|
+
sos[0, 4, :] = a1 # a1
|
|
375
|
+
|
|
376
|
+
return sos
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def first_order_absorption(
|
|
380
|
+
rt_dc: float,
|
|
381
|
+
rt_ny: float,
|
|
382
|
+
delays: ArrayLike,
|
|
383
|
+
fs: float,
|
|
384
|
+
crossover_frequency: float | None = None,
|
|
385
|
+
) -> np.ndarray:
|
|
386
|
+
"""Design first-order shelving absorption filters according to specified reverb time.
|
|
387
|
+
|
|
388
|
+
Each delay line gets a first-order shelving filter whose gain matches the
|
|
389
|
+
target decay (rt_dc at DC, rt_ny at Nyquist) for its delay length, with the
|
|
390
|
+
shelf transition at crossover_frequency.
|
|
391
|
+
|
|
392
|
+
Reference: Jot, J. M., "Proportional parametric equalizers - Application to
|
|
393
|
+
digital reverberation and environmental audio processing", AES 2015.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
rt_dc : float
|
|
398
|
+
Reverberation time in seconds at DC.
|
|
399
|
+
rt_ny : float
|
|
400
|
+
Reverberation time in seconds at Nyquist.
|
|
401
|
+
delays : array-like
|
|
402
|
+
Delay lengths in samples, one per channel.
|
|
403
|
+
fs : float
|
|
404
|
+
Sampling rate in Hz.
|
|
405
|
+
crossover_frequency : float, optional
|
|
406
|
+
Shelf crossover frequency in Hz. Defaults to fs/8, the midpoint of the
|
|
407
|
+
warped (bilinear) frequency axis. Values above fs/5 are clamped to fs/5
|
|
408
|
+
since a too high crossover leads to an unstable filter (fs/4 is the limit).
|
|
409
|
+
|
|
410
|
+
Returns
|
|
411
|
+
-------
|
|
412
|
+
np.ndarray
|
|
413
|
+
One-section per-channel SOS bank of shape ``(1, 6, N)`` (the canonical
|
|
414
|
+
SOS bank layout); section rows are ``[b0, b1, b2, a0, a1, a2]``
|
|
415
|
+
(b2 = a2 = 0 for these first-order filters).
|
|
416
|
+
"""
|
|
417
|
+
delays_arr = np.asarray(delays, dtype=float)
|
|
418
|
+
|
|
419
|
+
h_dc = db_to_lin(delays_arr * rt_to_slope(rt_dc, fs))
|
|
420
|
+
h_ny = db_to_lin(delays_arr * rt_to_slope(rt_ny, fs))
|
|
421
|
+
return _first_order_shelf(h_dc, h_ny, fs, crossover_frequency)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _first_order_shelf(
|
|
425
|
+
h_dc: np.ndarray,
|
|
426
|
+
h_ny: np.ndarray,
|
|
427
|
+
fs: float,
|
|
428
|
+
crossover_frequency: float | None = None,
|
|
429
|
+
) -> np.ndarray:
|
|
430
|
+
"""First-order shelving SOS bank from target linear gains at DC and Nyquist.
|
|
431
|
+
|
|
432
|
+
Shared core of :func:`first_order_absorption` and
|
|
433
|
+
:func:`first_order_shelving_eq`. ``h_dc`` and ``h_ny`` are linear-magnitude
|
|
434
|
+
gains (one per channel); returns a one-section per-channel SOS bank of shape
|
|
435
|
+
``(1, 6, N)``.
|
|
436
|
+
"""
|
|
437
|
+
h_dc = np.asarray(h_dc, dtype=float)
|
|
438
|
+
h_ny = np.asarray(h_ny, dtype=float)
|
|
439
|
+
|
|
440
|
+
if crossover_frequency is None:
|
|
441
|
+
crossover_frequency = fs / 8.0
|
|
442
|
+
crossover_frequency = min(crossover_frequency, fs / 5.0)
|
|
443
|
+
omega = crossover_frequency / fs * 2.0 * np.pi
|
|
444
|
+
|
|
445
|
+
t = np.tan(omega)
|
|
446
|
+
sqrt_k = np.sqrt(h_dc / h_ny)
|
|
447
|
+
|
|
448
|
+
b0 = (t * sqrt_k + 1.0) * h_ny
|
|
449
|
+
b1 = (t * sqrt_k - 1.0) * h_ny
|
|
450
|
+
a0 = t / sqrt_k + 1.0
|
|
451
|
+
a1 = t / sqrt_k - 1.0
|
|
452
|
+
|
|
453
|
+
sos = np.zeros((1, 6, h_dc.size))
|
|
454
|
+
sos[0, 0, :] = b0 / a0
|
|
455
|
+
sos[0, 1, :] = b1 / a0
|
|
456
|
+
sos[0, 3, :] = 1.0
|
|
457
|
+
sos[0, 4, :] = a1 / a0
|
|
458
|
+
return sos
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def first_order_shelving_eq(
|
|
462
|
+
db_dc: ArrayLike,
|
|
463
|
+
db_nyquist: ArrayLike,
|
|
464
|
+
fs: float,
|
|
465
|
+
crossover_frequency: float | None = None,
|
|
466
|
+
) -> np.ndarray:
|
|
467
|
+
"""Design first-order shelving EQ filters from gains in dB at DC and Nyquist.
|
|
468
|
+
|
|
469
|
+
Unlike :func:`first_order_absorption` (whose gains are derived from a
|
|
470
|
+
reverberation time and a delay length), the shelf endpoints are specified
|
|
471
|
+
directly as decibel gains. Useful as a per-output tone correction (post EQ).
|
|
472
|
+
|
|
473
|
+
Parameters
|
|
474
|
+
----------
|
|
475
|
+
db_dc : array-like
|
|
476
|
+
Gain in dB at DC, scalar or one value per channel.
|
|
477
|
+
db_nyquist : array-like
|
|
478
|
+
Gain in dB at Nyquist, scalar or one value per channel. Broadcast
|
|
479
|
+
against ``db_dc`` to a common number of channels.
|
|
480
|
+
fs : float
|
|
481
|
+
Sampling rate in Hz.
|
|
482
|
+
crossover_frequency : float, optional
|
|
483
|
+
Shelf crossover frequency in Hz. Defaults to fs/8; clamped to fs/5.
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
np.ndarray
|
|
488
|
+
One-section per-channel SOS bank of shape ``(1, 6, N)`` (canonical SOS
|
|
489
|
+
bank layout); section rows are ``[b0, b1, b2, a0, a1, a2]``.
|
|
490
|
+
"""
|
|
491
|
+
db_dc_arr, db_ny_arr = np.broadcast_arrays(
|
|
492
|
+
np.asarray(db_dc, dtype=float).ravel(),
|
|
493
|
+
np.asarray(db_nyquist, dtype=float).ravel(),
|
|
494
|
+
)
|
|
495
|
+
return _first_order_shelf(
|
|
496
|
+
db_to_lin(db_dc_arr), db_to_lin(db_ny_arr), fs, crossover_frequency
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def sos_gain_per_sample_curves(
|
|
501
|
+
sos: np.ndarray,
|
|
502
|
+
delays: ArrayLike,
|
|
503
|
+
nfft: int = 512,
|
|
504
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
505
|
+
"""Magnitude response (gain per sample vs angle) for a per-channel SOS bank.
|
|
506
|
+
|
|
507
|
+
Evaluates :math:`|H(e^{j\\omega})|` at ``nfft`` angles from 0 to :math:`\\pi`
|
|
508
|
+
(Nyquist) for each channel's SOS cascade, then scales by delay length so that
|
|
509
|
+
the result is gain per sample: for channel j with delay m_j, the curve is
|
|
510
|
+
:math:`|H|^{1/m_j}`, so that after m_j samples the effective gain is
|
|
511
|
+
:math:`|H|`. Useful for plotting absorption/gain curves (e.g. on a pole plot).
|
|
512
|
+
|
|
513
|
+
Parameters
|
|
514
|
+
----------
|
|
515
|
+
sos : (n_sections, 6, N) array
|
|
516
|
+
Per-channel SOS bank; section rows are ``[b0, b1, b2, a0, a1, a2]``. Same
|
|
517
|
+
format as :func:`one_pole_absorption` / :func:`first_order_absorption`
|
|
518
|
+
return.
|
|
519
|
+
delays : (N,) array-like
|
|
520
|
+
Delay lengths in samples, one per channel. Used to scale gain to per-sample.
|
|
521
|
+
nfft : int
|
|
522
|
+
Number of frequency points (default 512).
|
|
523
|
+
|
|
524
|
+
Returns
|
|
525
|
+
-------
|
|
526
|
+
angles : (nfft,) array
|
|
527
|
+
Angles in rad/sample, 0 to pi.
|
|
528
|
+
magnitude : (nfft, N) array
|
|
529
|
+
Gain per sample (linear), i.e. :math:`|H(e^{j\\omega})|^{1/m}` per channel.
|
|
530
|
+
"""
|
|
531
|
+
sos = np.asarray(sos, dtype=np.float64)
|
|
532
|
+
delays_arr = np.asarray(delays, dtype=np.float64).ravel()
|
|
533
|
+
if sos.ndim != 3 or sos.shape[1] != 6:
|
|
534
|
+
raise ValueError("sos must have shape (n_sections, 6, N)")
|
|
535
|
+
N = sos.shape[2]
|
|
536
|
+
if delays_arr.shape[0] != N:
|
|
537
|
+
raise ValueError("delays must have length N (number of channels in sos)")
|
|
538
|
+
if np.any(delays_arr < 1):
|
|
539
|
+
raise ValueError("delays must be >= 1")
|
|
540
|
+
magnitude = np.zeros((nfft, N), dtype=np.float64)
|
|
541
|
+
angles = np.zeros(nfft, dtype=np.float64)
|
|
542
|
+
for ch in range(N):
|
|
543
|
+
angles, h = sosfreqz(sos[:, :, ch], worN=nfft)
|
|
544
|
+
magnitude[:, ch] = np.power(np.abs(h), 1.0 / delays_arr[ch])
|
|
545
|
+
return angles, magnitude
|