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/__init__.py +248 -0
- phonometry/_version.py +17 -0
- phonometry/calibration.py +98 -0
- phonometry/compliance.py +203 -0
- phonometry/core.py +470 -0
- phonometry/filter_design.py +214 -0
- phonometry/frequencies.py +186 -0
- phonometry/levels.py +243 -0
- phonometry/parametric_filters.py +370 -0
- phonometry/py.typed +0 -0
- phonometry/utils.py +74 -0
- phonometry-3.0.0.dist-info/METADATA +126 -0
- phonometry-3.0.0.dist-info/RECORD +16 -0
- phonometry-3.0.0.dist-info/WHEEL +5 -0
- phonometry-3.0.0.dist-info/licenses/LICENSE +674 -0
- phonometry-3.0.0.dist-info/top_level.txt +1 -0
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)
|