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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Weighting filters (A, C, Z) and time weighting utilities for audio analysis.
|
|
4
|
+
Implementation according to IEC 61672-1:2013.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import List, Tuple, cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy import signal
|
|
14
|
+
|
|
15
|
+
from .utils import _typesignal
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from numba import jit as _numba_jit
|
|
19
|
+
except ImportError: # pragma: no cover - depends on install extras
|
|
20
|
+
# unused-ignore: with numba absent its import is Any and the ignore is
|
|
21
|
+
# unnecessary; with numba installed the assignment needs it.
|
|
22
|
+
_numba_jit = None # type: ignore[assignment, unused-ignore]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WeightingFilter:
|
|
26
|
+
"""
|
|
27
|
+
Class-based frequency weighting filter (A, C, Z).
|
|
28
|
+
Allows pre-calculating and reusing filter coefficients.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, fs: int, curve: str = "A",
|
|
32
|
+
stateful: bool = False, steady_ic: bool = False,
|
|
33
|
+
high_accuracy: bool | None = None) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Initialize the weighting filter.
|
|
36
|
+
|
|
37
|
+
:param fs: Sample rate in Hz.
|
|
38
|
+
:param curve: 'A', 'C' or 'Z'.
|
|
39
|
+
:param stateful: If True, the weighting filter is stateful. Useful for block processing.
|
|
40
|
+
:param steady_ic: If True, calculate steady state initial conditions for filter.
|
|
41
|
+
:param high_accuracy: If True, design and run the filter at an internal
|
|
42
|
+
oversampled rate (>= 96 kHz) so the response stays within
|
|
43
|
+
IEC 61672-1 class 1 tolerances up to 16 kHz. The plain bilinear
|
|
44
|
+
design exceeds class 1 limits at 12.5 kHz for fs <= 48 kHz.
|
|
45
|
+
Defaults to True except in stateful mode (the internal FIR
|
|
46
|
+
resampling is incompatible with block processing).
|
|
47
|
+
"""
|
|
48
|
+
if fs <= 0:
|
|
49
|
+
raise ValueError("Sample rate 'fs' must be positive.")
|
|
50
|
+
if high_accuracy is None:
|
|
51
|
+
high_accuracy = not stateful
|
|
52
|
+
if high_accuracy and stateful:
|
|
53
|
+
raise ValueError("high_accuracy is not compatible with stateful processing.")
|
|
54
|
+
|
|
55
|
+
self.fs = fs
|
|
56
|
+
self.curve = curve.upper()
|
|
57
|
+
self.stateful = stateful
|
|
58
|
+
self.high_accuracy = high_accuracy
|
|
59
|
+
self._oversample = min(8, max(1, math.ceil(96000 / fs))) if high_accuracy else 1
|
|
60
|
+
|
|
61
|
+
if self.curve == "Z":
|
|
62
|
+
self.sos = np.array([])
|
|
63
|
+
if self.stateful:
|
|
64
|
+
self.zi = np.array([])
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if self.curve not in ["A", "C"]:
|
|
68
|
+
raise ValueError("Weighting curve must be 'A', 'C' or 'Z'")
|
|
69
|
+
|
|
70
|
+
# Analog ZPK for A and C weighting
|
|
71
|
+
# f1, f2, f3, f4 constants as per IEC 61672-1
|
|
72
|
+
f1 = 20.598997
|
|
73
|
+
f4 = 12194.217
|
|
74
|
+
|
|
75
|
+
if self.curve == "A":
|
|
76
|
+
f2 = 107.65265
|
|
77
|
+
f3 = 737.86223
|
|
78
|
+
# Zeros at 0 Hz
|
|
79
|
+
z = np.array([0, 0, 0, 0])
|
|
80
|
+
# Poles
|
|
81
|
+
p = np.array(
|
|
82
|
+
[
|
|
83
|
+
-2 * np.pi * f1,
|
|
84
|
+
-2 * np.pi * f1,
|
|
85
|
+
-2 * np.pi * f4,
|
|
86
|
+
-2 * np.pi * f4,
|
|
87
|
+
-2 * np.pi * f2,
|
|
88
|
+
-2 * np.pi * f3,
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
# k chosen to give 0 dB at 1000 Hz
|
|
92
|
+
k = 3.5174303309e13
|
|
93
|
+
|
|
94
|
+
else: # C weighting
|
|
95
|
+
z = np.array([0, 0])
|
|
96
|
+
p = np.array([-2 * np.pi * f1, -2 * np.pi * f1, -2 * np.pi * f4, -2 * np.pi * f4])
|
|
97
|
+
k = 5.91797e8
|
|
98
|
+
|
|
99
|
+
# Recalculate k to ensure 0dB at 1kHz
|
|
100
|
+
w = 2 * np.pi * 1000
|
|
101
|
+
h = k * np.prod(1j * w - z) / np.prod(1j * w - p)
|
|
102
|
+
k = k / np.abs(h)
|
|
103
|
+
|
|
104
|
+
design_fs = self.fs * self._oversample
|
|
105
|
+
zd, pd, kd = signal.bilinear_zpk(z, p, k, design_fs)
|
|
106
|
+
self.sos = signal.zpk2sos(zd, pd, kd)
|
|
107
|
+
|
|
108
|
+
# Initialize filter state for stateful block-wise processing.
|
|
109
|
+
# Uses lazy allocation: zi is sized on first filter() call so that
|
|
110
|
+
# the channel dimension matches the actual input shape.
|
|
111
|
+
if self.stateful:
|
|
112
|
+
self.zi = np.array([])
|
|
113
|
+
self._steady_ic = steady_ic
|
|
114
|
+
|
|
115
|
+
def _init_filter_state(self, x_proc: np.ndarray) -> None:
|
|
116
|
+
"""Allocate or reallocate ``zi`` to match the input shape."""
|
|
117
|
+
n_sections = self.sos.shape[0]
|
|
118
|
+
if x_proc.ndim == 1:
|
|
119
|
+
if self._steady_ic:
|
|
120
|
+
self.zi = signal.sosfilt_zi(self.sos)
|
|
121
|
+
else:
|
|
122
|
+
self.zi = np.zeros((n_sections, 2))
|
|
123
|
+
else:
|
|
124
|
+
n_channels = x_proc.shape[0]
|
|
125
|
+
if self._steady_ic:
|
|
126
|
+
zi_base = signal.sosfilt_zi(self.sos)
|
|
127
|
+
self.zi = np.tile(zi_base[:, np.newaxis, :], (1, n_channels, 1))
|
|
128
|
+
else:
|
|
129
|
+
self.zi = np.zeros((n_sections, n_channels, 2))
|
|
130
|
+
|
|
131
|
+
def _needs_zi_reinit(self, x_proc: np.ndarray) -> bool:
|
|
132
|
+
"""Check whether ``zi`` must be (re)allocated for *x_proc*."""
|
|
133
|
+
if self.zi.size == 0:
|
|
134
|
+
return True
|
|
135
|
+
if x_proc.ndim == 1:
|
|
136
|
+
return self.zi.ndim != 2
|
|
137
|
+
return self.zi.ndim != 3 or self.zi.shape[1] != x_proc.shape[0]
|
|
138
|
+
|
|
139
|
+
def filter(self, x: List[float] | np.ndarray) -> np.ndarray:
|
|
140
|
+
"""
|
|
141
|
+
Apply the weighting filter to a signal.
|
|
142
|
+
|
|
143
|
+
:param x: Input signal (1D or 2D [channels, samples]).
|
|
144
|
+
:return: Weighted signal.
|
|
145
|
+
"""
|
|
146
|
+
x_proc = _typesignal(x)
|
|
147
|
+
if self.curve == "Z":
|
|
148
|
+
return x_proc
|
|
149
|
+
|
|
150
|
+
if self.stateful:
|
|
151
|
+
if self._needs_zi_reinit(x_proc):
|
|
152
|
+
self._init_filter_state(x_proc)
|
|
153
|
+
y, self.zi = signal.sosfilt(self.sos, x_proc, axis=-1, zi=self.zi)
|
|
154
|
+
elif self._oversample > 1:
|
|
155
|
+
if x_proc.shape[-1] == 0:
|
|
156
|
+
return x_proc # resample_poly rejects empty input
|
|
157
|
+
up = signal.resample_poly(x_proc, self._oversample, 1, axis=-1)
|
|
158
|
+
y_up = signal.sosfilt(self.sos, up, axis=-1)
|
|
159
|
+
y = signal.resample_poly(y_up, 1, self._oversample, axis=-1)
|
|
160
|
+
else:
|
|
161
|
+
y = signal.sosfilt(self.sos, x_proc, axis=-1)
|
|
162
|
+
|
|
163
|
+
return cast(np.ndarray, y)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def weighting_filter(
|
|
167
|
+
x: List[float] | np.ndarray, fs: int, curve: str = "A", high_accuracy: bool = True
|
|
168
|
+
) -> np.ndarray:
|
|
169
|
+
"""
|
|
170
|
+
Apply frequency weighting (A or C) to a signal.
|
|
171
|
+
|
|
172
|
+
:param x: Input signal.
|
|
173
|
+
:param fs: Sample rate.
|
|
174
|
+
:param curve: 'A', 'C' or 'Z' (Z is zero weighting/bypass).
|
|
175
|
+
:param high_accuracy: Use internal oversampling for IEC 61672-1 class 1
|
|
176
|
+
accuracy at high frequencies (default True).
|
|
177
|
+
:return: Weighted signal.
|
|
178
|
+
"""
|
|
179
|
+
wf = WeightingFilter(fs, curve, high_accuracy=high_accuracy)
|
|
180
|
+
return wf.filter(x)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _prepare_time_weighting_initial_state(
|
|
184
|
+
x_sq: np.ndarray,
|
|
185
|
+
initial_state: str | float | np.ndarray | None,
|
|
186
|
+
) -> np.ndarray:
|
|
187
|
+
"""Return the previous output state ``y[-1]`` for time weighting."""
|
|
188
|
+
invalid_initial_state_message = "initial_state must be None, 'zero', 'first', a scalar, or an array"
|
|
189
|
+
state_shape = x_sq.shape[:-1]
|
|
190
|
+
|
|
191
|
+
if initial_state is None:
|
|
192
|
+
return np.zeros(state_shape, dtype=x_sq.dtype)
|
|
193
|
+
|
|
194
|
+
if isinstance(initial_state, str):
|
|
195
|
+
state_name = initial_state.lower()
|
|
196
|
+
if state_name == "zero":
|
|
197
|
+
return np.zeros(state_shape, dtype=x_sq.dtype)
|
|
198
|
+
if state_name == "first":
|
|
199
|
+
if x_sq.shape[-1] == 0:
|
|
200
|
+
raise ValueError(invalid_initial_state_message)
|
|
201
|
+
return np.asarray(np.take(x_sq, 0, axis=-1), dtype=x_sq.dtype).copy()
|
|
202
|
+
raise ValueError(invalid_initial_state_message)
|
|
203
|
+
|
|
204
|
+
state = np.asarray(initial_state, dtype=x_sq.dtype)
|
|
205
|
+
if state.shape == ():
|
|
206
|
+
return np.full(state_shape, state.item(), dtype=x_sq.dtype)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
return np.broadcast_to(state, state_shape).astype(x_sq.dtype, copy=True)
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"initial_state must be scalar or broadcastable to the input shape without the time axis"
|
|
213
|
+
) from exc
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _impulse_kernel_py(
|
|
217
|
+
x_t: np.ndarray,
|
|
218
|
+
alpha_rise: float,
|
|
219
|
+
alpha_fall: float,
|
|
220
|
+
initial_state: np.ndarray,
|
|
221
|
+
) -> np.ndarray:
|
|
222
|
+
"""Asymmetric time-weighting kernel (pure Python; jitted when numba is present)."""
|
|
223
|
+
y_t = np.zeros_like(x_t)
|
|
224
|
+
curr_y = initial_state.copy()
|
|
225
|
+
|
|
226
|
+
for i in range(x_t.shape[0]):
|
|
227
|
+
val = x_t[i]
|
|
228
|
+
rising = val > curr_y
|
|
229
|
+
|
|
230
|
+
diff = val - curr_y
|
|
231
|
+
factor = np.where(rising, alpha_rise, alpha_fall)
|
|
232
|
+
curr_y += factor * diff
|
|
233
|
+
y_t[i] = curr_y
|
|
234
|
+
|
|
235
|
+
return y_t
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if _numba_jit is not None:
|
|
239
|
+
_apply_impulse_kernel = _numba_jit(nopython=True, cache=True)(_impulse_kernel_py)
|
|
240
|
+
else: # pragma: no cover - exercised only without numba installed
|
|
241
|
+
_apply_impulse_kernel = _impulse_kernel_py
|
|
242
|
+
|
|
243
|
+
def time_weighting(
|
|
244
|
+
x: List[float] | np.ndarray,
|
|
245
|
+
fs: int,
|
|
246
|
+
mode: str = "fast",
|
|
247
|
+
initial_state: str | float | np.ndarray | None = None,
|
|
248
|
+
) -> np.ndarray:
|
|
249
|
+
"""
|
|
250
|
+
Apply time weighting to a signal (Exponential averaging).
|
|
251
|
+
|
|
252
|
+
:param x: Input signal (raw pressure/voltage). The function squares it internally.
|
|
253
|
+
:param fs: Sample rate.
|
|
254
|
+
:param mode: 'fast' (125ms), 'slow' (1000ms), 'impulse' (35ms rise, 1500ms fall).
|
|
255
|
+
:param initial_state: Previous mean-square output state ``y[-1]``. Use None/'zero' for
|
|
256
|
+
zero initialization (default), 'first' to initialize from the first input energy,
|
|
257
|
+
or a scalar/array broadcastable to the input shape without the time axis.
|
|
258
|
+
:return: Time-weighted squared signal (sound pressure level envelope).
|
|
259
|
+
"""
|
|
260
|
+
x_proc = _typesignal(x)
|
|
261
|
+
if fs <= 0:
|
|
262
|
+
raise ValueError("Sample rate 'fs' must be positive.")
|
|
263
|
+
x_sq = x_proc**2
|
|
264
|
+
initial = _prepare_time_weighting_initial_state(x_sq, initial_state)
|
|
265
|
+
|
|
266
|
+
mode_lower = mode.lower()
|
|
267
|
+
|
|
268
|
+
if mode_lower in ["fast", "slow"]:
|
|
269
|
+
tau = 0.125 if mode_lower == "fast" else 1.0
|
|
270
|
+
alpha = 1 - np.exp(-1 / (fs * tau))
|
|
271
|
+
b = [alpha]
|
|
272
|
+
a = [1, -(1 - alpha)]
|
|
273
|
+
# We apply the weighting to the squared signal to get the Mean Square value
|
|
274
|
+
zi = np.expand_dims((1 - alpha) * initial, axis=-1)
|
|
275
|
+
y, _ = signal.lfilter(b, a, x_sq, axis=-1, zi=zi)
|
|
276
|
+
return cast(np.ndarray, y)
|
|
277
|
+
|
|
278
|
+
elif mode_lower == "impulse":
|
|
279
|
+
# IEC 61672-1: 35ms for rising, 1500ms for falling
|
|
280
|
+
tau_rise = 0.035
|
|
281
|
+
tau_fall = 1.5
|
|
282
|
+
|
|
283
|
+
alpha_rise = 1 - np.exp(-1 / (fs * tau_rise))
|
|
284
|
+
alpha_fall = 1 - np.exp(-1 / (fs * tau_fall))
|
|
285
|
+
|
|
286
|
+
# Move time axis to front for iteration
|
|
287
|
+
x_t = np.moveaxis(x_sq, -1, 0)
|
|
288
|
+
|
|
289
|
+
# Ensure contiguous array for Numba
|
|
290
|
+
x_t = np.ascontiguousarray(x_t)
|
|
291
|
+
initial_kernel = initial if initial.ndim == 0 else np.ascontiguousarray(initial)
|
|
292
|
+
y_t = _apply_impulse_kernel(x_t, alpha_rise, alpha_fall, initial_kernel)
|
|
293
|
+
|
|
294
|
+
# Move time axis back
|
|
295
|
+
return np.moveaxis(y_t, 0, -1)
|
|
296
|
+
|
|
297
|
+
else:
|
|
298
|
+
raise ValueError("Invalid time weighting mode. Use ['fast', 'slow', 'impulse']")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class TimeWeighting:
|
|
302
|
+
"""
|
|
303
|
+
Stateful time weighting for block processing.
|
|
304
|
+
|
|
305
|
+
Wraps :func:`time_weighting` carrying the exponential integrator state
|
|
306
|
+
across blocks, so concatenated block outputs equal a single continuous call.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, fs: int, mode: str = "fast") -> None:
|
|
310
|
+
"""
|
|
311
|
+
:param fs: Sample rate in Hz.
|
|
312
|
+
:param mode: 'fast' (125 ms), 'slow' (1000 ms) or 'impulse' (35 ms / 1.5 s).
|
|
313
|
+
"""
|
|
314
|
+
if fs <= 0:
|
|
315
|
+
raise ValueError("Sample rate 'fs' must be positive.")
|
|
316
|
+
if mode.lower() not in ("fast", "slow", "impulse"):
|
|
317
|
+
raise ValueError("Invalid time weighting mode. Use ['fast', 'slow', 'impulse']")
|
|
318
|
+
self.fs = fs
|
|
319
|
+
self.mode = mode.lower()
|
|
320
|
+
self._state: np.ndarray | None = None
|
|
321
|
+
|
|
322
|
+
def process(self, x: List[float] | np.ndarray) -> np.ndarray:
|
|
323
|
+
"""Apply time weighting to a block, continuing from the previous block."""
|
|
324
|
+
x_proc = _typesignal(x)
|
|
325
|
+
if x_proc.shape[-1] == 0:
|
|
326
|
+
return x_proc # nothing to process; keep the carried state
|
|
327
|
+
env = time_weighting(x_proc, self.fs, mode=self.mode, initial_state=self._state)
|
|
328
|
+
self._state = np.asarray(env[..., -1]).copy()
|
|
329
|
+
return env
|
|
330
|
+
|
|
331
|
+
def reset(self) -> None:
|
|
332
|
+
"""Forget the carried state (the next block starts from rest)."""
|
|
333
|
+
self._state = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def linkwitz_riley(
|
|
337
|
+
x: List[float] | np.ndarray,
|
|
338
|
+
fs: int,
|
|
339
|
+
freq: float,
|
|
340
|
+
order: int = 4
|
|
341
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
342
|
+
"""
|
|
343
|
+
Linkwitz-Riley crossover filter (Butterworth squared).
|
|
344
|
+
Splits signal into low and high bands with flat sum response.
|
|
345
|
+
|
|
346
|
+
:param x: Input signal.
|
|
347
|
+
:param fs: Sample rate.
|
|
348
|
+
:param freq: Crossover frequency.
|
|
349
|
+
:param order: Total order (must be even, typically 2 or 4).
|
|
350
|
+
:return: (low_pass_signal, high_pass_signal)
|
|
351
|
+
"""
|
|
352
|
+
x_proc = _typesignal(x)
|
|
353
|
+
if order % 2 != 0:
|
|
354
|
+
raise ValueError("Linkwitz-Riley order must be even (typically 2 or 4).")
|
|
355
|
+
|
|
356
|
+
# A Linkwitz-Riley filter of order N is two Butterworth filters of order N/2 in series
|
|
357
|
+
half_order = order // 2
|
|
358
|
+
wn = freq / (fs / 2)
|
|
359
|
+
|
|
360
|
+
sos_lp = signal.butter(half_order, wn, btype='low', output='sos')
|
|
361
|
+
sos_hp = signal.butter(half_order, wn, btype='high', output='sos')
|
|
362
|
+
|
|
363
|
+
# Pass twice
|
|
364
|
+
lp = signal.sosfilt(sos_lp, x_proc)
|
|
365
|
+
lp = signal.sosfilt(sos_lp, lp)
|
|
366
|
+
|
|
367
|
+
hp = signal.sosfilt(sos_hp, x_proc)
|
|
368
|
+
hp = signal.sosfilt(sos_hp, hp)
|
|
369
|
+
|
|
370
|
+
return lp, hp
|
phonometry/py.typed
ADDED
|
File without changes
|
phonometry/utils.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Signal processing utilities for phonometry.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List, Tuple, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy import signal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _typesignal(x: List[float] | np.ndarray | Tuple[float, ...]) -> np.ndarray:
|
|
15
|
+
"""
|
|
16
|
+
Ensure signal is a float64 numpy array.
|
|
17
|
+
|
|
18
|
+
Integer inputs (e.g. int16 audio from ``scipy.io.wavfile.read``) are
|
|
19
|
+
converted to float64 to prevent silent overflow when the signal is
|
|
20
|
+
squared internally. Float64 arrays are passed through without copying.
|
|
21
|
+
|
|
22
|
+
:param x: Input signal.
|
|
23
|
+
:return: Numpy float64 array.
|
|
24
|
+
"""
|
|
25
|
+
return np.atleast_1d(np.asarray(x, dtype=np.float64))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resample_to_length(y: np.ndarray, factor: int, target_length: int) -> np.ndarray:
|
|
29
|
+
"""
|
|
30
|
+
Resample signal and ensure the output matches target_length exactly.
|
|
31
|
+
Handles both 1D and 2D (channels, samples) arrays.
|
|
32
|
+
|
|
33
|
+
:param y: Input signal.
|
|
34
|
+
:param factor: Resampling factor.
|
|
35
|
+
:param target_length: Target length.
|
|
36
|
+
:return: Resampled signal.
|
|
37
|
+
"""
|
|
38
|
+
if factor == 1:
|
|
39
|
+
# Nothing to resample: fall through to the slice/pad logic only.
|
|
40
|
+
y_resampled = y
|
|
41
|
+
else:
|
|
42
|
+
y_resampled = cast(np.ndarray, signal.resample_poly(y, factor, 1, axis=-1))
|
|
43
|
+
current_length = y_resampled.shape[-1]
|
|
44
|
+
|
|
45
|
+
if current_length > target_length:
|
|
46
|
+
# Slice along the last axis (works for both 1D and 2D)
|
|
47
|
+
y_resampled = y_resampled[..., :target_length]
|
|
48
|
+
|
|
49
|
+
elif current_length < target_length:
|
|
50
|
+
diff = target_length - current_length
|
|
51
|
+
# Pad only the last axis. This works for both 1D and 2D arrays.
|
|
52
|
+
# For 1D, pad_width becomes `[(0, diff)]`.
|
|
53
|
+
# For 2D, pad_width becomes `[(0, 0), (0, diff)]`.
|
|
54
|
+
pad_width: List[Tuple[int, int]] = [(0, 0)] * (y_resampled.ndim - 1) + [(0, diff)]
|
|
55
|
+
|
|
56
|
+
y_resampled = np.pad(y_resampled, pad_width, mode='constant')
|
|
57
|
+
|
|
58
|
+
return y_resampled
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _downsamplingfactor(freq: List[float], fs: int, headroom: float = 1.25) -> np.ndarray:
|
|
62
|
+
"""
|
|
63
|
+
Compute optimal downsampling factors for filter stability.
|
|
64
|
+
|
|
65
|
+
:param freq: Band upper-edge frequencies.
|
|
66
|
+
:param fs: Sample rate.
|
|
67
|
+
:param headroom: Required ratio between the decimated Nyquist and the
|
|
68
|
+
band's upper edge. 1.25 reproduces the classic ``fs / (2 + 0.5)``
|
|
69
|
+
guard; filter types whose design extends above the upper edge
|
|
70
|
+
(cheby2 stopband) need more.
|
|
71
|
+
:return: Array of factors.
|
|
72
|
+
"""
|
|
73
|
+
factor = (np.floor((fs / 2) / (headroom * np.array(freq)))).astype("int")
|
|
74
|
+
return cast(np.ndarray, np.clip(factor, 1, 500))
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phonometry
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Acoustic measurement toolkit: fractional octave-band filters, A/C/Z and time weighting, and sound level metrology per IEC 61260-1 and IEC 61672-1.
|
|
5
|
+
Author-email: Jose Manuel Requena Plens <jmrplens@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/jmrplens/phonometry
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/jmrplens/phonometry/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=2.4.4
|
|
21
|
+
Requires-Dist: scipy>=1.17.1
|
|
22
|
+
Provides-Extra: perf
|
|
23
|
+
Requires-Dist: numba>=0.65.1; extra == "perf"
|
|
24
|
+
Provides-Extra: plot
|
|
25
|
+
Requires-Dist: matplotlib>=3.10.9; extra == "plot"
|
|
26
|
+
Provides-Extra: full
|
|
27
|
+
Requires-Dist: numba>=0.65.1; extra == "full"
|
|
28
|
+
Requires-Dist: matplotlib>=3.10.9; extra == "full"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
<!-- Package -->
|
|
32
|
+
[](https://pypi.org/project/phonometry/)
|
|
33
|
+
[](https://pypi.org/project/phonometry/)
|
|
34
|
+
[](https://github.com/jmrplens/phonometry/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
<!-- Quality -->
|
|
37
|
+
[](https://github.com/jmrplens/phonometry/actions/workflows/python-app.yml)
|
|
38
|
+
[](https://sonarcloud.io/summary/overall?id=jmrplens_PyOctaveBand)
|
|
39
|
+
[](https://codecov.io/gh/jmrplens/phonometry)
|
|
40
|
+
|
|
41
|
+
<!-- Citation & support -->
|
|
42
|
+
[](https://doi.org/10.5281/zenodo.21215280)
|
|
43
|
+
|
|
44
|
+
# phonometry
|
|
45
|
+
|
|
46
|
+
> *phonometry* β the measurement of sound. Formerly published as **PyOctaveBand**.
|
|
47
|
+
|
|
48
|
+
Acoustic measurement toolkit for Python: fractional octave-band filter banks, frequency and time weighting, and sound level metrology β conformance-tested against **IEC 61260-1:2014 / ANSI S1.11-2004** (filters) and **IEC 61672-1:2013** (weighting and levels) class 1 tolerance limits.
|
|
49
|
+
|
|
50
|
+
<img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/filter_type_comparison.png" alt="Magnitude response comparison of the five filter architectures for the 1 kHz octave band, with a zoom at the -3 dB crossover" width="80%">
|
|
51
|
+
|
|
52
|
+
## β¨ Highlights
|
|
53
|
+
|
|
54
|
+
- ποΈ 1/1, 1/3 and arbitrary fractional octave filter banks (stable SOS + multirate decimation)
|
|
55
|
+
- ποΈ Five architectures: Butterworth, Chebyshev I/II, Elliptic, Bessel β all with β3 dB points on the ANSI band edges
|
|
56
|
+
- π A/C/Z frequency weighting within IEC 61672-1 class 1 tolerances
|
|
57
|
+
- β±οΈ Fast/Slow/Impulse time ballistics, `Leq`, `LAeq` and `L10/L50/L90` statistical levels
|
|
58
|
+
- πΊοΈ Octave spectrogram (band levels over time) and zero-phase offline filtering
|
|
59
|
+
- π Physical SPL calibration and dBFS modes
|
|
60
|
+
- β‘ Vectorized multichannel processing and stateful block (real-time) workflows
|
|
61
|
+
|
|
62
|
+
## π Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install phonometry
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Optional extras: `phonometry[plot]` (matplotlib for response plots), `phonometry[perf]` (numba for faster impulse ballistics), `phonometry[full]` (both).
|
|
69
|
+
|
|
70
|
+
## π Documentation
|
|
71
|
+
|
|
72
|
+
**Full documentation website: https://jmrplens.github.io/phonometry/** (English / EspaΓ±ol)
|
|
73
|
+
|
|
74
|
+
Or browse the Markdown docs on GitHub:
|
|
75
|
+
|
|
76
|
+
| Page | Contents |
|
|
77
|
+
| :--- | :--- |
|
|
78
|
+
| [Getting Started](https://github.com/jmrplens/phonometry/blob/main/docs/getting-started.md) | Installation, first analysis, WAV files |
|
|
79
|
+
| [Filter Banks](https://github.com/jmrplens/phonometry/blob/main/docs/filter-banks.md) | Architectures, response gallery, band decomposition, zero-phase |
|
|
80
|
+
| [Frequency Weighting](https://github.com/jmrplens/phonometry/blob/main/docs/weighting.md) | A/C/Z curves, class 1 high-accuracy mode |
|
|
81
|
+
| [Time Weighting](https://github.com/jmrplens/phonometry/blob/main/docs/time-weighting.md) | Fast/Slow/Impulse ballistics, initial state |
|
|
82
|
+
| [Levels](https://github.com/jmrplens/phonometry/blob/main/docs/levels.md) | Leq, LAeq, L10/L50/L90, octave spectrogram |
|
|
83
|
+
| [Calibration and dBFS](https://github.com/jmrplens/phonometry/blob/main/docs/calibration.md) | Physical SPL, digital full-scale, RMS vs peak |
|
|
84
|
+
| [Block Processing](https://github.com/jmrplens/phonometry/blob/main/docs/block-processing.md) | Stateful streaming workflows |
|
|
85
|
+
| [Multichannel](https://github.com/jmrplens/phonometry/blob/main/docs/multichannel.md) | Vectorized multichannel analysis, performance |
|
|
86
|
+
| [API Reference](https://github.com/jmrplens/phonometry/blob/main/docs/api-reference.md) | Every public function and class |
|
|
87
|
+
| [Theory](https://github.com/jmrplens/phonometry/blob/main/docs/theory.md) | Standards, math, design decisions |
|
|
88
|
+
| [Why phonometry](https://github.com/jmrplens/phonometry/blob/main/docs/why-phonometry.md) | IEC compliance verification vs other libraries |
|
|
89
|
+
|
|
90
|
+
## β‘ Quick start
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import numpy as np
|
|
94
|
+
from phonometry import octavefilter
|
|
95
|
+
|
|
96
|
+
fs = 48000
|
|
97
|
+
t = np.linspace(0, 1, fs, endpoint=False)
|
|
98
|
+
# Composite signal: 100Hz + 1000Hz
|
|
99
|
+
signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t)
|
|
100
|
+
|
|
101
|
+
# Apply 1/3 octave filter bank
|
|
102
|
+
spl, freq = octavefilter(signal, fs=fs, fraction=3)
|
|
103
|
+
|
|
104
|
+
print(f"Bands: {freq}")
|
|
105
|
+
print(f"SPL [dB]: {spl}")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
<img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/signal_response_fraction_3.png" alt="One-third-octave spectrum analysis of a multi-tone signal with the raw PSD in the background" width="80%">
|
|
109
|
+
|
|
110
|
+
*1/3 Octave Band spectrum analysis of a complex signal. More examples in the
|
|
111
|
+
[documentation](https://jmrplens.github.io/phonometry/).*
|
|
112
|
+
|
|
113
|
+
## π§ͺ Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
make install # dependencies + editable install
|
|
117
|
+
make check # ruff + mypy + bandit + tests
|
|
118
|
+
make graphs # regenerate documentation images
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
See https://github.com/jmrplens/phonometry/blob/main/CONTRIBUTING.md and the
|
|
122
|
+
https://github.com/jmrplens/phonometry/blob/main/CHANGELOG.md
|
|
123
|
+
|
|
124
|
+
## π License
|
|
125
|
+
|
|
126
|
+
[MIT](https://github.com/jmrplens/phonometry/blob/main/LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
phonometry/__init__.py,sha256=jFIpT1TzUSkVztO-LiFNpkA0Nqmiwh1SdgKTI-0qoTE,8160
|
|
2
|
+
phonometry/_version.py,sha256=GbetSl2f2RooDvKeN_2ANFqlVg6x_TSQZme_To4_N3M,626
|
|
3
|
+
phonometry/calibration.py,sha256=wxDu4jGMqEo9nWigsAlWlpc_r7KzCreecq_cU2LMxvE,4079
|
|
4
|
+
phonometry/compliance.py,sha256=VuAOWkJAnkAI7YKX9-fQK7AG9U4D3hmDEfrUXhRMFjo,8161
|
|
5
|
+
phonometry/core.py,sha256=1TDW7KWsE9v1S5-CJcw6kjfjeLgmq0dzZa1v4Zcs_9I,18684
|
|
6
|
+
phonometry/filter_design.py,sha256=T7mYPCCVaIW62eO3cVkVNOlnwX3_rAwH1co73fHri-M,7587
|
|
7
|
+
phonometry/frequencies.py,sha256=9Wk7NILiN8VTu68qc5NdKKOqOMuGaH8JzvQ1bxkPlNo,5871
|
|
8
|
+
phonometry/levels.py,sha256=RlNlZGg3Cd1NMtEdus1ulgY75JxPfu5je1h8xT-pKGM,9808
|
|
9
|
+
phonometry/parametric_filters.py,sha256=WJ863nmO1Rye3RFDPSLsfoIbA2rVncL_UlsKbq_YxGI,13393
|
|
10
|
+
phonometry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
phonometry/utils.py,sha256=ZWdWSjCSmYHCl749cH0MuGYhKZ4A5GOf8aYrboJAwoo,2639
|
|
12
|
+
phonometry-3.0.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
13
|
+
phonometry-3.0.0.dist-info/METADATA,sha256=GQRPDiJj_fuUuGpPCWcXCf2_hkJRr96o4u18WiZQrgs,6652
|
|
14
|
+
phonometry-3.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
15
|
+
phonometry-3.0.0.dist-info/top_level.txt,sha256=ditjBTAq4gCoEFT8KPKY0PMNts0TlNu3uuzpcjQQtiM,11
|
|
16
|
+
phonometry-3.0.0.dist-info/RECORD,,
|