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.
Files changed (72) hide show
  1. pyFDN/__init__.py +380 -0
  2. pyFDN/audio/example_process_fdn_output.wav +0 -0
  3. pyFDN/audio/s3_r4_o.wav +0 -0
  4. pyFDN/audio/synth_dry.wav +0 -0
  5. pyFDN/auxiliary/__init__.py +59 -0
  6. pyFDN/auxiliary/acoustics.py +545 -0
  7. pyFDN/auxiliary/allpass.py +311 -0
  8. pyFDN/auxiliary/audio.py +54 -0
  9. pyFDN/auxiliary/coupled_rooms.py +193 -0
  10. pyFDN/auxiliary/delay.py +132 -0
  11. pyFDN/auxiliary/flamo.py +648 -0
  12. pyFDN/auxiliary/flamo_graph.py +754 -0
  13. pyFDN/auxiliary/math.py +515 -0
  14. pyFDN/auxiliary/plot.py +1352 -0
  15. pyFDN/auxiliary/poles.py +104 -0
  16. pyFDN/auxiliary/tiny_rotation_matrix.py +109 -0
  17. pyFDN/auxiliary/utils.py +296 -0
  18. pyFDN/dsp/__init__.py +3 -0
  19. pyFDN/dsp/dfilt_matrix.py +53 -0
  20. pyFDN/dsp/feedback_delay.py +47 -0
  21. pyFDN/dsp/sos_filter_bank.py +57 -0
  22. pyFDN/dsp/time_varying_matrix.py +144 -0
  23. pyFDN/generate/SDN.py +718 -0
  24. pyFDN/generate/allpass_FDN/__init__.py +11 -0
  25. pyFDN/generate/allpass_FDN/allpass_completion.py +630 -0
  26. pyFDN/generate/allpass_FDN/homogeneous_allpass_fdn.py +116 -0
  27. pyFDN/generate/allpass_FDN/rand_admissible_homogeneous_allpass.py +57 -0
  28. pyFDN/generate/allpass_in_fdn.py +78 -0
  29. pyFDN/generate/anderson_matrix.py +46 -0
  30. pyFDN/generate/complete_orthogonal.py +56 -0
  31. pyFDN/generate/construct_cascaded_paraunitary_matrix.py +58 -0
  32. pyFDN/generate/construct_paraunitary_from_elementals.py +52 -0
  33. pyFDN/generate/construct_velvet_feedback_matrix.py +17 -0
  34. pyFDN/generate/degree_one_lossless.py +31 -0
  35. pyFDN/generate/fdn_matrix_gallery.py +560 -0
  36. pyFDN/generate/householder_matrix.py +24 -0
  37. pyFDN/generate/is_almost_zero.py +18 -0
  38. pyFDN/generate/nearest_orthogonal.py +24 -0
  39. pyFDN/generate/nearest_sign_agnostic_orthogonal.py +82 -0
  40. pyFDN/generate/random_matrix_shift.py +42 -0
  41. pyFDN/generate/random_orthogonal.py +10 -0
  42. pyFDN/generate/sample_delay_lengths.py +149 -0
  43. pyFDN/generate/schroeder_reverberator.py +85 -0
  44. pyFDN/generate/shift_matrix.py +45 -0
  45. pyFDN/generate/shift_matrix_distribute.py +24 -0
  46. pyFDN/graphicEQ/__init__.py +17 -0
  47. pyFDN/graphicEQ/absorption_geq.py +54 -0
  48. pyFDN/graphicEQ/bandpass_filter.py +34 -0
  49. pyFDN/graphicEQ/design_geq.py +73 -0
  50. pyFDN/graphicEQ/graphic_eq.py +74 -0
  51. pyFDN/graphicEQ/probe_sos.py +52 -0
  52. pyFDN/graphicEQ/shelving_filter.py +55 -0
  53. pyFDN/process.py +106 -0
  54. pyFDN/pyFDN.py +1 -0
  55. pyFDN/train/__init__.py +29 -0
  56. pyFDN/train/build.py +321 -0
  57. pyFDN/train/engine.py +186 -0
  58. pyFDN/train/objectives.py +207 -0
  59. pyFDN/translate/dss_to_flamo.py +215 -0
  60. pyFDN/translate/dss_to_impz.py +48 -0
  61. pyFDN/translate/dss_to_pr.py +222 -0
  62. pyFDN/translate/dss_to_ss.py +101 -0
  63. pyFDN/translate/dss_to_tf.py +92 -0
  64. pyFDN/translate/flamo_to_pr.py +893 -0
  65. pyFDN/translate/impz_to_res.py +53 -0
  66. pyFDN/translate/mtf_to_impz.py +51 -0
  67. pyFDN/translate/pr_to_impz.py +70 -0
  68. pyfdn-0.1.0.dist-info/METADATA +158 -0
  69. pyfdn-0.1.0.dist-info/RECORD +72 -0
  70. pyfdn-0.1.0.dist-info/WHEEL +5 -0
  71. pyfdn-0.1.0.dist-info/licenses/LICENSE +22 -0
  72. 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