phonometry 3.0.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.
phonometry/core.py ADDED
@@ -0,0 +1,470 @@
1
+ # Copyright (c) 2026. Jose M. Requena-Plens
2
+ """
3
+ Core processing logic and FilterBank class for phonometry.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import warnings
9
+ from typing import List, Tuple, cast, overload, Literal
10
+
11
+ import numpy as np
12
+ from scipy import signal
13
+
14
+ from .filter_design import _cheby2_headroom, _design_sos_filter
15
+ from .frequencies import _genfreqs
16
+ from .utils import _downsamplingfactor, _resample_to_length, _typesignal
17
+
18
+
19
+ class OctaveFilterBank:
20
+ """
21
+ A class-based representation of an Octave Filter Bank.
22
+ Allows for pre-calculating and reusing filter coefficients.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ fs: int,
28
+ fraction: float = 1,
29
+ order: int = 6,
30
+ limits: List[float] | None = None,
31
+ filter_type: str = "butter",
32
+ ripple: float = 0.1,
33
+ attenuation: float = 60.0,
34
+ show: bool = False,
35
+ plot_file: str | None = None,
36
+ calibration_factor: float = 1.0,
37
+ dbfs: bool = False,
38
+ stateful: bool = False,
39
+ steady_ic: bool = False,
40
+ resample: bool = True,
41
+ ) -> None:
42
+ """
43
+ Initialize the Octave Filter Bank.
44
+
45
+ :param fs: Sample rate in Hz.
46
+ :param fraction: Bandwidth fraction (e.g., 1 for octave, 3 for 1/3 octave).
47
+ :param order: Filter order.
48
+ :param limits: Frequency limits [f_min, f_max].
49
+ :param filter_type: Type of filter ('butter', 'cheby1', 'cheby2', 'ellip', 'bessel').
50
+ :param ripple: Passband ripple in dB.
51
+ :param attenuation: Stopband attenuation in dB.
52
+ :param show: If True, show the filter response plot.
53
+ :param plot_file: Path to save the filter response plot.
54
+ :param calibration_factor: Calibration factor for SPL calculation.
55
+ :param dbfs: If True, calculate SPL in dBFS.
56
+ :param stateful: If True, carry filter state between calls. Useful for block processing.
57
+ :param steady_ic: If True, calculate steady state initial conditions for filter.
58
+ :param resample: If True, resampling is performed.
59
+ """
60
+ if fs <= 0:
61
+ raise ValueError("Sample rate 'fs' must be positive.")
62
+ if fraction <= 0:
63
+ raise ValueError("Bandwidth 'fraction' must be positive.")
64
+ if order <= 0:
65
+ raise ValueError("Filter 'order' must be positive.")
66
+ if limits is None:
67
+ limits = [12, 20000]
68
+ if len(limits) != 2:
69
+ raise ValueError("Limits must be a list of two frequencies [f_min, f_max].")
70
+ if limits[0] <= 0 or limits[1] <= 0:
71
+ raise ValueError("Limit frequencies must be positive.")
72
+ if limits[0] >= limits[1]:
73
+ raise ValueError("The lower limit must be less than the upper limit.")
74
+
75
+ valid_filters = ["butter", "cheby1", "cheby2", "ellip", "bessel"]
76
+ if filter_type not in valid_filters:
77
+ raise ValueError(f"Invalid filter_type. Must be one of {valid_filters}")
78
+
79
+ if resample and stateful:
80
+ raise ValueError("Resampling and stateful behaviour (block processing) are not supported.")
81
+ # a stateful resampling algorithm would be required...
82
+
83
+ self.fs = fs
84
+ self.fraction = fraction
85
+ self.order = order
86
+ self.limits = limits
87
+ self.filter_type = filter_type
88
+ self.ripple = ripple
89
+ self.attenuation = attenuation
90
+ self.calibration_factor = calibration_factor
91
+ self.dbfs = dbfs
92
+ self.stateful = stateful
93
+
94
+ # Generate frequencies
95
+ self.freq, self.freq_d, self.freq_u, self.nominal_freq = _genfreqs(limits, fraction, fs)
96
+ self.num_bands = len(self.freq)
97
+
98
+
99
+ # Calculate factors and design SOS
100
+ if resample:
101
+ headroom = 1.25
102
+ if filter_type == "cheby2":
103
+ # The cheby2 stopband extends above the band's upper edge;
104
+ # 5% safety margin so it clears the decimated Nyquist.
105
+ headroom = max(headroom, 1.05 * _cheby2_headroom(fraction, order, attenuation))
106
+ self.factor = _downsamplingfactor(self.freq_u, fs, headroom)
107
+ else:
108
+ self.factor = np.ones(self.num_bands, dtype=int)
109
+
110
+ self.sos = _design_sos_filter(
111
+ self.freq, self.freq_d, self.freq_u, fs, order, self.factor,
112
+ filter_type, ripple, attenuation, show, plot_file
113
+ )
114
+
115
+ # Calculate initial conditions for filter state
116
+ if self.stateful:
117
+ self._init_filter_state(steady_ic)
118
+
119
+
120
+ def _init_filter_state(self, steady_ic: bool) -> None:
121
+ """Initialize filter state (zi) for stateful block-wise processing.
122
+
123
+ Uses lazy initialization: zi arrays are allocated on first use in
124
+ _filter_and_resample() so the channel count matches the actual input.
125
+ """
126
+ self.zi: List[np.ndarray] = [np.array([]) for _ in range(self.num_bands)]
127
+ self._steady_ic = steady_ic
128
+
129
+
130
+ def __repr__(self) -> str:
131
+ return (
132
+ f"OctaveFilterBank(fs={self.fs}, fraction={self.fraction}, order={self.order}, "
133
+ f"limits={self.limits}, filter_type='{self.filter_type}', "
134
+ f"num_bands={self.num_bands})"
135
+ )
136
+
137
+ @overload
138
+ def filter(
139
+ self,
140
+ x: List[float] | np.ndarray,
141
+ sigbands: Literal[False] = False,
142
+ mode: str = "rms",
143
+ detrend: bool = True,
144
+ calculate_level: Literal[True] = True,
145
+ nominal: Literal[False] = False,
146
+ zero_phase: bool = False,
147
+ ) -> Tuple[np.ndarray, List[float]]: ...
148
+
149
+ @overload
150
+ def filter(
151
+ self,
152
+ x: List[float] | np.ndarray,
153
+ sigbands: Literal[True],
154
+ mode: str = "rms",
155
+ detrend: bool = True,
156
+ calculate_level: Literal[True] = True,
157
+ nominal: Literal[False] = False,
158
+ zero_phase: bool = False,
159
+ ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
160
+
161
+ @overload
162
+ def filter(
163
+ self,
164
+ x: List[float] | np.ndarray,
165
+ sigbands: Literal[False] = False,
166
+ mode: str = "rms",
167
+ detrend: bool = True,
168
+ calculate_level: Literal[False] = False,
169
+ nominal: Literal[False] = False,
170
+ zero_phase: bool = False,
171
+ ) -> Tuple[None, List[float]]: ...
172
+
173
+ @overload
174
+ def filter(
175
+ self,
176
+ x: List[float] | np.ndarray,
177
+ sigbands: Literal[True],
178
+ mode: str = "rms",
179
+ detrend: bool = True,
180
+ calculate_level: Literal[False] = False,
181
+ nominal: Literal[False] = False,
182
+ zero_phase: bool = False,
183
+ ) -> Tuple[None, List[float], List[np.ndarray]]: ...
184
+
185
+ @overload
186
+ def filter(
187
+ self,
188
+ x: List[float] | np.ndarray,
189
+ sigbands: Literal[False] = False,
190
+ mode: str = "rms",
191
+ detrend: bool = True,
192
+ calculate_level: Literal[True] = True,
193
+ nominal: Literal[True] = ...,
194
+ zero_phase: bool = False,
195
+ ) -> Tuple[np.ndarray, List[str]]: ...
196
+
197
+ @overload
198
+ def filter(
199
+ self,
200
+ x: List[float] | np.ndarray,
201
+ sigbands: Literal[True],
202
+ mode: str = "rms",
203
+ detrend: bool = True,
204
+ calculate_level: Literal[True] = True,
205
+ nominal: Literal[True] = ...,
206
+ zero_phase: bool = False,
207
+ ) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...
208
+
209
+ @overload
210
+ def filter(
211
+ self,
212
+ x: List[float] | np.ndarray,
213
+ sigbands: Literal[False] = False,
214
+ mode: str = "rms",
215
+ detrend: bool = True,
216
+ calculate_level: Literal[False] = False,
217
+ nominal: Literal[True] = ...,
218
+ zero_phase: bool = False,
219
+ ) -> Tuple[None, List[str]]: ...
220
+
221
+ @overload
222
+ def filter(
223
+ self,
224
+ x: List[float] | np.ndarray,
225
+ sigbands: Literal[True],
226
+ mode: str = "rms",
227
+ detrend: bool = True,
228
+ calculate_level: Literal[False] = False,
229
+ nominal: Literal[True] = ...,
230
+ zero_phase: bool = False,
231
+ ) -> Tuple[None, List[str], List[np.ndarray]]: ...
232
+
233
+ def filter(
234
+ self,
235
+ x: List[float] | np.ndarray,
236
+ sigbands: bool = False,
237
+ mode: str = "rms",
238
+ detrend: bool = True,
239
+ calculate_level: bool = True,
240
+ nominal: bool = False,
241
+ zero_phase: bool = False,
242
+ ) -> Tuple[np.ndarray | None, List[float] | List[str]] | Tuple[np.ndarray | None, List[float] | List[str], List[np.ndarray]]:
243
+ """
244
+ Apply the pre-designed filter bank to a signal.
245
+
246
+ :param x: Input signal (1D array or 2D array [channels, samples]).
247
+ :param sigbands: If True, also return the signal in the time domain divided into bands.
248
+ :param mode: 'rms' for energy-based level, 'peak' for peak-holding level.
249
+ Note: 'peak' includes the filter's onset transient; a tone that
250
+ starts abruptly can overshoot by ~1 dB. For steady signals,
251
+ discard the first ~5/f_low seconds or use longer signals.
252
+ :param detrend: If True, remove DC offset from signal before filtering (Default: True).
253
+ :param calculate_level: If True, calculate SPL.
254
+ :param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
255
+ :param zero_phase: If True, filter with ``sosfiltfilt`` (forward-backward):
256
+ no group delay, but the effective stopband attenuation doubles.
257
+ Offline analysis only; incompatible with stateful mode.
258
+ :return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
259
+ """
260
+ if zero_phase and self.stateful:
261
+ raise ValueError("zero_phase is not compatible with stateful processing.")
262
+
263
+ # Convert input to numpy array
264
+ x_proc = _typesignal(x)
265
+
266
+ # Handle DC offset removal
267
+ if detrend:
268
+ if self.stateful:
269
+ warnings.warn(
270
+ "Detrending is not recommended during block processing "
271
+ "as it can introduce discontinuities between blocks.",
272
+ UserWarning,
273
+ stacklevel=2,
274
+ )
275
+ # Axis -1 handles both 1D and 2D arrays correctly
276
+ x_proc = signal.detrend(x_proc, axis=-1, type='constant')
277
+
278
+ # Handle multichannel detection
279
+ is_multichannel = x_proc.ndim > 1
280
+ if not is_multichannel:
281
+ x_proc = x_proc[np.newaxis, :] # Standardize to 2D
282
+
283
+ num_channels = x_proc.shape[0]
284
+
285
+ # Process signal across all bands and channels
286
+ spl, xb = self._process_bands(
287
+ x_proc, num_channels, sigbands, mode=mode,
288
+ calculate_level=calculate_level, zero_phase=zero_phase,
289
+ )
290
+
291
+ # Format output based on input dimensionality
292
+ if not is_multichannel:
293
+ if spl is not None:
294
+ spl = spl[0]
295
+ if sigbands and xb is not None:
296
+ xb = [band[0] for band in xb]
297
+
298
+ # Return a copy: the bank (possibly shared via the octavefilter()
299
+ # design cache) must not be corrupted by callers mutating the list.
300
+ freq_out: List[float] | List[str] = list(self.nominal_freq) if nominal else list(self.freq)
301
+
302
+ if sigbands and xb is not None:
303
+ return spl, freq_out, xb
304
+ else:
305
+ return spl, freq_out
306
+
307
+ def spectrogram(
308
+ self,
309
+ x: List[float] | np.ndarray,
310
+ window_time: float = 0.125,
311
+ overlap: float = 0.5,
312
+ mode: str = "rms",
313
+ detrend: bool = True,
314
+ zero_phase: bool = False,
315
+ ) -> Tuple[np.ndarray, List[float], np.ndarray]:
316
+ """
317
+ Short-time fractional-octave analysis: level per band over time.
318
+
319
+ :param x: Input signal (1D array or 2D array [channels, samples]).
320
+ :param window_time: Analysis window length in seconds.
321
+ :param overlap: Window overlap fraction in [0, 1).
322
+ :param mode: 'rms' or 'peak' (per window).
323
+ :param detrend: If True, remove DC offset before filtering.
324
+ :param zero_phase: If True, filter bands forward-backward so their
325
+ group delays don't skew the frames (offline analysis only).
326
+ :return: Tuple (levels, freq, times). ``levels`` has shape
327
+ (num_bands, num_frames) for 1D input and
328
+ (channels, num_bands, num_frames) for 2D input; ``times`` holds
329
+ each window's center in seconds.
330
+ """
331
+ if self.stateful:
332
+ raise ValueError("spectrogram() is not supported on stateful banks.")
333
+ if not 0 <= overlap < 1:
334
+ raise ValueError("overlap must be in [0, 1).")
335
+
336
+ x_proc = _typesignal(x)
337
+ is_multichannel = x_proc.ndim > 1
338
+ n_samples = x_proc.shape[-1]
339
+ win = int(round(window_time * self.fs))
340
+ hop = max(1, int(round(win * (1 - overlap))))
341
+ if win <= 0 or win > n_samples:
342
+ raise ValueError("window_time must be positive and shorter than the signal.")
343
+
344
+ # Filter once per band at full rate so windows stay time-aligned
345
+ # across bands regardless of per-band decimation.
346
+ if detrend:
347
+ x_proc = signal.detrend(x_proc, axis=-1, type='constant')
348
+ x_2d = x_proc if is_multichannel else x_proc[np.newaxis, :]
349
+ _, bands_opt = self._process_bands(
350
+ x_2d, x_2d.shape[0], sigbands=True, mode=mode,
351
+ calculate_level=False, zero_phase=zero_phase,
352
+ )
353
+ # sigbands=True always fills the band list
354
+ bands = cast(List[np.ndarray], bands_opt)
355
+
356
+ starts = np.arange(0, n_samples - win + 1, hop)
357
+ times = (starts + win / 2) / self.fs
358
+
359
+ levels = np.zeros((x_2d.shape[0], self.num_bands, len(starts)))
360
+ # Frames are processed in chunks: the strided view itself is free,
361
+ # but reducing it materializes temporaries of chunk*win samples, so
362
+ # chunking keeps memory bounded for long signals / high overlap.
363
+ frame_chunk = 256
364
+ for b, yb in enumerate(bands):
365
+ windows = np.lib.stride_tricks.sliding_window_view(yb, win, axis=-1)[:, ::hop, :]
366
+ for j0 in range(0, windows.shape[1], frame_chunk):
367
+ seg = windows[:, j0:j0 + frame_chunk, :]
368
+ levels[:, b, j0:j0 + frame_chunk] = self._calculate_level(seg, mode)
369
+
370
+ if not is_multichannel:
371
+ return levels[0], list(self.freq), times
372
+ return levels, list(self.freq), times
373
+
374
+ def _process_bands(
375
+ self,
376
+ x_proc: np.ndarray,
377
+ num_channels: int,
378
+ sigbands: bool,
379
+ mode: str = "rms",
380
+ calculate_level: bool = True,
381
+ zero_phase: bool = False,
382
+ ) -> Tuple[np.ndarray | None, List[np.ndarray] | None]:
383
+ """
384
+ Process signal through each frequency band.
385
+
386
+ :param x_proc: Standardized 2D input signal [channels, samples].
387
+ :param num_channels: Number of channels.
388
+ :param sigbands: If True, return filtered bands.
389
+ :param mode: 'rms' or 'peak'.
390
+ :param calculate_level: If True, calculate SPL
391
+ :param zero_phase: If True, use forward-backward filtering.
392
+ :return: A tuple containing (SPL_array, Optional_List_of_filtered_signals).
393
+ """
394
+ if calculate_level:
395
+ spl = np.zeros([num_channels, self.num_bands])
396
+ else:
397
+ spl = None
398
+ xb: List[np.ndarray] | None = [np.array([]) for _ in range(self.num_bands)] if sigbands else None
399
+
400
+ for idx in range(self.num_bands):
401
+ # Vectorized processing for all channels
402
+ filtered_signal = self._filter_and_resample(x_proc, idx, zero_phase)
403
+
404
+ if calculate_level and spl is not None:
405
+ # Sound Level Calculation (returns array of shape [num_channels])
406
+ spl[:, idx] = self._calculate_level(filtered_signal, mode)
407
+
408
+ if sigbands and xb is not None:
409
+ # Restore original length
410
+ # filtered_signal is [channels, downsampled_samples]
411
+ y_resampled = _resample_to_length(filtered_signal, int(self.factor[idx]), x_proc.shape[1])
412
+ xb[idx] = y_resampled
413
+
414
+ return spl, xb
415
+
416
+
417
+ def _filter_and_resample(self, x: np.ndarray, idx: int, zero_phase: bool = False) -> np.ndarray:
418
+ """Resample and filter for a specific band (vectorized)."""
419
+ if self.factor[idx] > 1:
420
+ # axis=-1 is default for resample_poly, but being explicit is good
421
+ sd = signal.resample_poly(x, 1, self.factor[idx], axis=-1)
422
+ else:
423
+ sd = x
424
+
425
+ if zero_phase:
426
+ # sosfiltfilt requires padlen < n - 1; heavily decimated bands can
427
+ # be shorter than the default padding, so clamp it.
428
+ n_sections = self.sos[idx].shape[0]
429
+ padlen = min(3 * (2 * n_sections + 1), max(sd.shape[-1] - 2, 0))
430
+ y = signal.sosfiltfilt(self.sos[idx], sd, axis=-1, padlen=padlen)
431
+ elif self.stateful:
432
+ n_channels = sd.shape[0]
433
+ # Lazy init: allocate zi with correct channel count on first use
434
+ if self.zi[idx].ndim < 3 or self.zi[idx].shape[1] != n_channels:
435
+ n_sections = self.sos[idx].shape[0]
436
+ if not self._steady_ic:
437
+ self.zi[idx] = np.zeros((n_sections, n_channels, 2))
438
+ else:
439
+ zi_base = signal.sosfilt_zi(self.sos[idx])
440
+ self.zi[idx] = np.tile(zi_base[:, np.newaxis, :], (1, n_channels, 1))
441
+ y, self.zi[idx] = signal.sosfilt(self.sos[idx], sd, axis=-1, zi=self.zi[idx])
442
+ else:
443
+ y = signal.sosfilt(self.sos[idx], sd, axis=-1)
444
+
445
+ # sosfilt supports axis=-1 by default
446
+ return cast(np.ndarray, y)
447
+
448
+ def _calculate_level(self, y: np.ndarray, mode: str) -> float | np.ndarray:
449
+ """Calculate the level (RMS or Peak) in dB."""
450
+ if mode.lower() == "rms":
451
+ # Use norm for better performance and reduced memory overhead
452
+ # RMS = ||y|| / sqrt(N)
453
+ val_linear = np.linalg.norm(y, axis=-1) / np.sqrt(y.shape[-1])
454
+ elif mode.lower() == "peak":
455
+ val_linear = np.max(np.abs(y), axis=-1)
456
+ else:
457
+ raise ValueError("Invalid mode. Use 'rms' or 'peak'.")
458
+
459
+ eps = np.finfo(float).eps
460
+
461
+ # Ensure val_linear is at least eps to avoid log(0)
462
+ val_linear = np.maximum(val_linear, eps)
463
+
464
+ if self.dbfs:
465
+ # dBFS: 0 dB is RMS = 1.0 or Peak = 1.0
466
+ return cast(np.ndarray, 20 * np.log10(val_linear))
467
+
468
+ # Physical SPL: apply sensitivity and use 20uPa reference
469
+ pressure_pa = val_linear * self.calibration_factor
470
+ return cast(np.ndarray, 20 * np.log10(np.maximum(pressure_pa, eps) / 2e-5))
@@ -0,0 +1,214 @@
1
+ # Copyright (c) 2026. Jose M. Requena-Plens
2
+ """
3
+ Filter design and visualization for phonometry.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Tuple
9
+
10
+ import numpy as np
11
+ from scipy import signal
12
+
13
+
14
+ def _cheby2_transition_ratio(order: int, attenuation: float) -> float:
15
+ """Ratio between stopband-edge and -3 dB frequencies for a Chebyshev II prototype."""
16
+ # Below ~3.01 dB the -3 dB point does not exist (arccosh argument < 1).
17
+ if attenuation <= 10 * np.log10(2):
18
+ raise ValueError("cheby2 'attenuation' must be greater than 3.01 dB.")
19
+ eps_term = np.sqrt(10 ** (attenuation / 10) - 1)
20
+ return float(np.cosh(np.arccosh(eps_term) / order))
21
+
22
+
23
+ def _cheby2_stopband_edges(
24
+ fd: float, fu: float, order: int, attenuation: float, fs: float | None = None
25
+ ) -> Tuple[float, float]:
26
+ """
27
+ Map desired -3 dB band edges (fd, fu) to the Chebyshev II stopband
28
+ edges that scipy expects as ``Wn``.
29
+
30
+ Uses the analog lowpass-to-bandpass transform: the stopband keeps the
31
+ geometric center (f1*f2 = fd*fu) and widens the bandwidth by the
32
+ prototype transition ratio.
33
+
34
+ When ``fs`` is given, the mapping is done in the pre-warped analog
35
+ domain of the bilinear transform. Decimated bands sit at a large
36
+ fraction of Nyquist, where the tan() frequency warping would otherwise
37
+ shift the -3 dB points well away from the band edges.
38
+ """
39
+ ratio = _cheby2_transition_ratio(order, attenuation)
40
+
41
+ if fs is not None:
42
+ ad = (fs / np.pi) * np.tan(np.pi * fd / fs)
43
+ au = (fs / np.pi) * np.tan(np.pi * fu / fs)
44
+ else:
45
+ ad, au = fd, fu
46
+
47
+ bw_stop = (au - ad) * ratio
48
+ a1 = (-bw_stop + np.sqrt(bw_stop**2 + 4 * ad * au)) / 2
49
+ a2 = a1 + bw_stop
50
+
51
+ if fs is not None:
52
+ f1 = (fs / np.pi) * np.arctan(np.pi * a1 / fs)
53
+ f2 = (fs / np.pi) * np.arctan(np.pi * a2 / fs)
54
+ return float(f1), float(f2)
55
+ return float(a1), float(a2)
56
+
57
+
58
+ def _cheby2_headroom(fraction: float, order: int, attenuation: float) -> float:
59
+ """
60
+ Headroom factor ``f2_stop / f_upper_edge`` needed above the band's upper
61
+ edge. Constant across bands of the same fraction (bands are geometric).
62
+ """
63
+ g = 10 ** (3 / 10)
64
+ edge = g ** (1 / (2 * fraction))
65
+ fd, fu = 1.0 / edge, edge # normalized band around fc=1
66
+ _, f2 = _cheby2_stopband_edges(fd, fu, order, attenuation)
67
+ return float(f2 / fu)
68
+
69
+
70
+ def _design_sos_filter(
71
+ freq: List[float],
72
+ freq_d: List[float],
73
+ freq_u: List[float],
74
+ fs: int,
75
+ order: int,
76
+ factor: np.ndarray,
77
+ filter_type: str,
78
+ ripple: float,
79
+ attenuation: float,
80
+ show: bool = False,
81
+ plot_file: str | None = None,
82
+ ) -> List[np.ndarray]:
83
+ """
84
+ Generate SOS coefficients for the filter bank.
85
+
86
+ :param freq: Center frequencies.
87
+ :param freq_d: Lower edge frequencies.
88
+ :param freq_u: Upper edge frequencies.
89
+ :param fs: Original sample rate.
90
+ :param order: Filter order.
91
+ :param factor: Downsampling factors per band.
92
+ :param filter_type: Type of filter.
93
+ :param ripple: Passband ripple (dB).
94
+ :param attenuation: Stopband attenuation (dB).
95
+ :param show: If True, plot response.
96
+ :param plot_file: Path to save plot.
97
+ :return: List of SOS coefficient arrays.
98
+ """
99
+ sos = [np.array([]) for _ in range(len(freq))]
100
+
101
+ for idx, (lower, upper) in enumerate(zip(freq_d, freq_u)):
102
+ fsd = fs / factor[idx]
103
+ wn = np.array([lower, upper]) / (fsd / 2)
104
+
105
+ if filter_type == "butter":
106
+ sos[idx] = signal.butter(N=order, Wn=wn, btype="bandpass", output="sos")
107
+ elif filter_type == "cheby1":
108
+ sos[idx] = signal.cheby1(N=order, rp=ripple, Wn=wn, btype="bandpass", output="sos")
109
+ elif filter_type == "cheby2":
110
+ # Wn in cheby2 is the STOPBAND edge; map the desired -3 dB band
111
+ # edges to stopband edges so the passband matches the ANSI band.
112
+ f1_stop, f2_stop = _cheby2_stopband_edges(lower, upper, order, attenuation, fsd)
113
+ nyq = fsd / 2
114
+ if f2_stop >= nyq:
115
+ # Top band without decimation margin: compress the upper
116
+ # transition to stay below Nyquist (edge gain degrades there).
117
+ f2_stop = 0.999 * nyq
118
+ wn_stop = np.array([f1_stop, f2_stop]) / nyq
119
+ sos[idx] = signal.cheby2(N=order, rs=attenuation, Wn=wn_stop, btype="bandpass", output="sos")
120
+ elif filter_type == "ellip":
121
+ sos[idx] = signal.ellip(N=order, rp=ripple, rs=attenuation, Wn=wn, btype="bandpass", output="sos")
122
+ elif filter_type == "bessel":
123
+ # norm="mag" places the -3 dB point at Wn (band edges);
124
+ # norm="phase" would shift it to ~-10 dB at the edges.
125
+ sos[idx] = signal.bessel(N=order, Wn=wn, btype="bandpass", norm="mag", output="sos")
126
+
127
+ if show or plot_file:
128
+ _showfilter(sos, freq, freq_u, freq_d, fs, factor, show, plot_file)
129
+
130
+ return sos
131
+
132
+ def _showfilter(
133
+ sos: List[np.ndarray],
134
+ freq: List[float],
135
+ freq_u: List[float],
136
+ freq_d: List[float],
137
+ fs: int,
138
+ factor: np.ndarray,
139
+ show: bool = False,
140
+ plot_file: str | None = None,
141
+ ) -> None:
142
+ """
143
+ Visualize filter bank frequency response.
144
+
145
+ :param sos: List of SOS coefficients.
146
+ :param freq: Center frequencies.
147
+ :param freq_u: Upper edges.
148
+ :param freq_d: Lower edges.
149
+ :param fs: Original sample rate.
150
+ :param factor: Downsampling factors.
151
+ :param show: If True, show the plot.
152
+ :param plot_file: Path to save the plot.
153
+ """
154
+ try:
155
+ import matplotlib.pyplot as plt
156
+ except ImportError as exc:
157
+ raise ImportError(
158
+ "Plotting requires matplotlib. Install it with: pip install phonometry[plot]"
159
+ ) from exc
160
+
161
+ wn = 8192
162
+ w = np.zeros([wn, len(freq)])
163
+ h: np.ndarray = np.zeros([wn, len(freq)], dtype=np.complex128)
164
+
165
+ for idx in range(len(freq)):
166
+ fsd = fs / factor[idx]
167
+ w[:, idx], h[:, idx] = signal.sosfreqz(sos[idx], worN=wn, whole=False, fs=fsd)
168
+
169
+ fig, ax = plt.subplots(figsize=(10, 6))
170
+ ax.semilogx(w, 20 * np.log10(abs(h) + np.finfo(float).eps), color="#1f77b4", linewidth=1.2)
171
+ ax.axhline(-3, color="#d62728", linestyle="--", alpha=0.5, linewidth=1, label="-3 dB")
172
+
173
+ ax.set_title("Filter Bank Frequency Response", fontweight="bold", pad=15)
174
+ ax.set_xlabel("Frequency [Hz]")
175
+ ax.set_ylabel("Amplitude [dB]")
176
+ ax.grid(which="major", color="#e0e0e0", linestyle="-")
177
+ ax.grid(which="minor", color="#e0e0e0", linestyle=":", alpha=0.4)
178
+
179
+ plt.xlim(freq_d[0] * 0.8, freq_u[-1] * 1.2)
180
+ plt.ylim(-4, 1)
181
+
182
+ # Dynamic ticks based on range
183
+ f_min = freq_d[0] * 0.8
184
+ f_max = freq_u[-1] * 1.2
185
+
186
+ # Standard frequencies for ticks
187
+ all_ticks = [
188
+ 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500,
189
+ 1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000
190
+ ]
191
+ xticks = [f for f in all_ticks if f_min <= f <= f_max]
192
+
193
+ # If too few ticks, use simpler logic
194
+ if len(xticks) < 3:
195
+ # Fallback to powers of 10
196
+ p_min = int(np.floor(np.log10(f_min)))
197
+ p_max = int(np.ceil(np.log10(f_max)))
198
+ xticks = [10**p for p in range(p_min, p_max + 1)]
199
+
200
+ xticklabels = []
201
+ for f in xticks:
202
+ if f >= 1000:
203
+ xticklabels.append(f"{f/1000:g}k")
204
+ else:
205
+ xticklabels.append(f"{f:g}")
206
+
207
+ ax.set_xticks(xticks)
208
+ ax.set_xticklabels(xticklabels)
209
+
210
+ if plot_file:
211
+ plt.savefig(plot_file, dpi=150, bbox_inches="tight")
212
+ if show:
213
+ plt.show()
214
+ plt.close(fig)