PyOctaveBand 1.0.1__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.
- pyoctaveband/__init__.py +96 -0
- pyoctaveband/calibration.py +32 -0
- pyoctaveband/core.py +191 -0
- pyoctaveband/filter_design.py +118 -0
- pyoctaveband/frequencies.py +142 -0
- pyoctaveband/parametric_filters.py +139 -0
- pyoctaveband/utils.py +53 -0
- pyoctaveband-1.0.1.dist-info/METADATA +361 -0
- pyoctaveband-1.0.1.dist-info/RECORD +12 -0
- pyoctaveband-1.0.1.dist-info/WHEEL +5 -0
- pyoctaveband-1.0.1.dist-info/licenses/LICENSE +674 -0
- pyoctaveband-1.0.1.dist-info/top_level.txt +1 -0
pyoctaveband/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright (c) 2020. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Octave-Band and Fractional Octave-Band filter for signals in the time domain.
|
|
4
|
+
Implementation according to ANSI s1.11-2004 and IEC 61260-1-2014.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import List, Tuple, cast
|
|
10
|
+
|
|
11
|
+
import matplotlib
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .calibration import calculate_sensitivity
|
|
15
|
+
from .core import OctaveFilterBank
|
|
16
|
+
from .frequencies import getansifrequencies, normalizedfreq
|
|
17
|
+
from .parametric_filters import linkwitz_riley, time_weighting, weighting_filter
|
|
18
|
+
|
|
19
|
+
# Use non-interactive backend for plots
|
|
20
|
+
matplotlib.use("Agg")
|
|
21
|
+
|
|
22
|
+
# Public methods
|
|
23
|
+
__all__ = [
|
|
24
|
+
"octavefilter",
|
|
25
|
+
"getansifrequencies",
|
|
26
|
+
"normalizedfreq",
|
|
27
|
+
"OctaveFilterBank",
|
|
28
|
+
"weighting_filter",
|
|
29
|
+
"time_weighting",
|
|
30
|
+
"linkwitz_riley",
|
|
31
|
+
"calculate_sensitivity",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def octavefilter(
|
|
36
|
+
x: List[float] | np.ndarray,
|
|
37
|
+
fs: int,
|
|
38
|
+
fraction: float = 1,
|
|
39
|
+
order: int = 6,
|
|
40
|
+
limits: List[float] | None = None,
|
|
41
|
+
show: bool = False,
|
|
42
|
+
sigbands: bool = False,
|
|
43
|
+
plot_file: str | None = None,
|
|
44
|
+
**kwargs: str | float | bool
|
|
45
|
+
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
|
|
46
|
+
"""
|
|
47
|
+
Filter a signal with octave or fractional octave filter bank.
|
|
48
|
+
|
|
49
|
+
This method uses a filter bank with Second-Order Sections (SOS) coefficients.
|
|
50
|
+
To obtain the correct coefficients, automatic subsampling is applied to the
|
|
51
|
+
signal in each filtered band.
|
|
52
|
+
|
|
53
|
+
Multichannel support: If x is 2D (channels, samples), each channel is filtered.
|
|
54
|
+
|
|
55
|
+
:param x: Input signal (1D array or 2D array [channels, samples]).
|
|
56
|
+
:type x: Union[List[float], np.ndarray]
|
|
57
|
+
:param fs: Sample rate in Hz.
|
|
58
|
+
:type fs: int
|
|
59
|
+
:param fraction: Bandwidth 'b'. Examples: 1/3-octave b=3, 1-octave b=1, 2/3-octave b=1.5. Default: 1.
|
|
60
|
+
:type fraction: float
|
|
61
|
+
:param order: Order of the filter. Default: 6.
|
|
62
|
+
:type order: int
|
|
63
|
+
:param limits: Minimum and maximum limit frequencies [f_min, f_max]. Default [12, 20000].
|
|
64
|
+
:type limits: Optional[List[float]]
|
|
65
|
+
:param show: If True, plot and show the filter response.
|
|
66
|
+
:type show: bool
|
|
67
|
+
:param sigbands: If True, also return the signal in the time domain divided into bands.
|
|
68
|
+
:type sigbands: bool
|
|
69
|
+
:param plot_file: Path to save the filter response plot.
|
|
70
|
+
:type plot_file: Optional[str]
|
|
71
|
+
:param filter_type: (Optional) Type of filter ('butter', 'cheby1', 'cheby2', 'ellip', 'bessel'). Default: 'butter'.
|
|
72
|
+
:param ripple: (Optional) Passband ripple in dB (for cheby1, ellip). Default: 0.1.
|
|
73
|
+
:param attenuation: (Optional) Stopband attenuation in dB (for cheby2, ellip). Default: 60.0.
|
|
74
|
+
:param calibration_factor: (Optional) Sensitivity multiplier. Default: 1.0.
|
|
75
|
+
:param dbfs: (Optional) If True, return results in dBFS. Default: False.
|
|
76
|
+
:param mode: (Optional) 'rms' or 'peak'. Default: 'rms'.
|
|
77
|
+
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
|
|
78
|
+
:rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[float], List[np.ndarray]]]
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# Use the class-based implementation
|
|
82
|
+
filter_bank = OctaveFilterBank(
|
|
83
|
+
fs=fs,
|
|
84
|
+
fraction=fraction,
|
|
85
|
+
order=order,
|
|
86
|
+
limits=limits,
|
|
87
|
+
filter_type=cast(str, kwargs.get("filter_type", "butter")),
|
|
88
|
+
ripple=cast(float, kwargs.get("ripple", 0.1)),
|
|
89
|
+
attenuation=cast(float, kwargs.get("attenuation", 60.0)),
|
|
90
|
+
show=show,
|
|
91
|
+
plot_file=plot_file,
|
|
92
|
+
calibration_factor=cast(float, kwargs.get("calibration_factor", 1.0)),
|
|
93
|
+
dbfs=cast(bool, kwargs.get("dbfs", False))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return filter_bank.filter(x, sigbands=sigbands, mode=cast(str, kwargs.get("mode", "rms")))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Calibration utilities for mapping digital signals to physical SPL levels.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def calculate_sensitivity(
|
|
14
|
+
ref_signal: List[float] | np.ndarray,
|
|
15
|
+
target_spl: float = 94.0,
|
|
16
|
+
ref_pressure: float = 2e-5
|
|
17
|
+
) -> float:
|
|
18
|
+
"""
|
|
19
|
+
Calculate the calibration factor (multiplier) to convert digital units
|
|
20
|
+
to Pascals based on a reference recording (e.g., 1kHz @ 94dB).
|
|
21
|
+
|
|
22
|
+
:param ref_signal: Recording of the calibration tone.
|
|
23
|
+
:param target_spl: The known SPL level of the calibrator (default 94 dB).
|
|
24
|
+
:param ref_pressure: Reference pressure (default 20 microPascals).
|
|
25
|
+
:return: Calibration factor (sensitivity multiplier).
|
|
26
|
+
"""
|
|
27
|
+
rms_ref = np.std(ref_signal)
|
|
28
|
+
if rms_ref == 0:
|
|
29
|
+
raise ValueError("Reference signal is silent, cannot calibrate.")
|
|
30
|
+
|
|
31
|
+
factor = (ref_pressure * 10**(target_spl / 20)) / rms_ref
|
|
32
|
+
return float(factor)
|
pyoctaveband/core.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Core processing logic and FilterBank class for pyoctaveband.
|
|
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
|
+
from .filter_design import _design_sos_filter
|
|
14
|
+
from .frequencies import _genfreqs
|
|
15
|
+
from .utils import _downsamplingfactor, _resample_to_length, _typesignal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OctaveFilterBank:
|
|
19
|
+
"""
|
|
20
|
+
A class-based representation of an Octave Filter Bank.
|
|
21
|
+
Allows for pre-calculating and reusing filter coefficients.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
fs: int,
|
|
27
|
+
fraction: float = 1,
|
|
28
|
+
order: int = 6,
|
|
29
|
+
limits: List[float] | None = None,
|
|
30
|
+
filter_type: str = "butter",
|
|
31
|
+
ripple: float = 0.1,
|
|
32
|
+
attenuation: float = 60.0,
|
|
33
|
+
show: bool = False,
|
|
34
|
+
plot_file: str | None = None,
|
|
35
|
+
calibration_factor: float = 1.0,
|
|
36
|
+
dbfs: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Initialize the Octave Filter Bank.
|
|
40
|
+
|
|
41
|
+
:param fs: Sample rate in Hz.
|
|
42
|
+
:param fraction: Bandwidth fraction (e.g., 1 for octave, 3 for 1/3 octave).
|
|
43
|
+
:param order: Filter order.
|
|
44
|
+
:param limits: Frequency limits [f_min, f_max].
|
|
45
|
+
:param filter_type: Type of filter ('butter', 'cheby1', 'cheby2', 'ellip', 'bessel').
|
|
46
|
+
:param ripple: Passband ripple in dB.
|
|
47
|
+
:param attenuation: Stopband attenuation in dB.
|
|
48
|
+
:param show: If True, show the filter response plot.
|
|
49
|
+
:param plot_file: Path to save the filter response plot.
|
|
50
|
+
:param calibration_factor: Calibration factor for SPL calculation.
|
|
51
|
+
:param dbfs: If True, calculate SPL in dBFS.
|
|
52
|
+
"""
|
|
53
|
+
if fs <= 0:
|
|
54
|
+
raise ValueError("Sample rate 'fs' must be positive.")
|
|
55
|
+
if fraction <= 0:
|
|
56
|
+
raise ValueError("Bandwidth 'fraction' must be positive.")
|
|
57
|
+
if order <= 0:
|
|
58
|
+
raise ValueError("Filter 'order' must be positive.")
|
|
59
|
+
if limits is None:
|
|
60
|
+
limits = [12, 20000]
|
|
61
|
+
if len(limits) != 2:
|
|
62
|
+
raise ValueError("Limits must be a list of two frequencies [f_min, f_max].")
|
|
63
|
+
if limits[0] <= 0 or limits[1] <= 0:
|
|
64
|
+
raise ValueError("Limit frequencies must be positive.")
|
|
65
|
+
if limits[0] >= limits[1]:
|
|
66
|
+
raise ValueError("The lower limit must be less than the upper limit.")
|
|
67
|
+
|
|
68
|
+
valid_filters = ["butter", "cheby1", "cheby2", "ellip", "bessel"]
|
|
69
|
+
if filter_type not in valid_filters:
|
|
70
|
+
raise ValueError(f"Invalid filter_type. Must be one of {valid_filters}")
|
|
71
|
+
|
|
72
|
+
self.fs = fs
|
|
73
|
+
self.fraction = fraction
|
|
74
|
+
self.order = order
|
|
75
|
+
self.limits = limits
|
|
76
|
+
self.filter_type = filter_type
|
|
77
|
+
self.ripple = ripple
|
|
78
|
+
self.attenuation = attenuation
|
|
79
|
+
self.calibration_factor = calibration_factor
|
|
80
|
+
self.dbfs = dbfs
|
|
81
|
+
|
|
82
|
+
# Generate frequencies
|
|
83
|
+
self.freq, self.freq_d, self.freq_u = _genfreqs(limits, fraction, fs)
|
|
84
|
+
self.num_bands = len(self.freq)
|
|
85
|
+
|
|
86
|
+
# Calculate factors and design SOS
|
|
87
|
+
self.factor = _downsamplingfactor(self.freq_u, fs)
|
|
88
|
+
self.sos = _design_sos_filter(
|
|
89
|
+
self.freq, self.freq_d, self.freq_u, fs, order, self.factor,
|
|
90
|
+
filter_type, ripple, attenuation, show, plot_file
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def filter(
|
|
94
|
+
self,
|
|
95
|
+
x: List[float] | np.ndarray,
|
|
96
|
+
sigbands: bool = False,
|
|
97
|
+
mode: str = "rms"
|
|
98
|
+
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
|
|
99
|
+
"""
|
|
100
|
+
Apply the pre-designed filter bank to a signal.
|
|
101
|
+
|
|
102
|
+
:param x: Input signal (1D array or 2D array [channels, samples]).
|
|
103
|
+
:param sigbands: If True, also return the signal in the time domain divided into bands.
|
|
104
|
+
:param mode: 'rms' for energy-based level, 'peak' for peak-holding level.
|
|
105
|
+
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Convert input to numpy array
|
|
109
|
+
x_proc = _typesignal(x)
|
|
110
|
+
|
|
111
|
+
# Handle multichannel detection
|
|
112
|
+
is_multichannel = x_proc.ndim > 1
|
|
113
|
+
if not is_multichannel:
|
|
114
|
+
x_proc = x_proc[np.newaxis, :] # Standardize to 2D
|
|
115
|
+
|
|
116
|
+
num_channels = x_proc.shape[0]
|
|
117
|
+
|
|
118
|
+
# Process signal across all bands and channels
|
|
119
|
+
spl, xb = self._process_bands(x_proc, num_channels, sigbands, mode=mode)
|
|
120
|
+
|
|
121
|
+
# Format output based on input dimensionality
|
|
122
|
+
if not is_multichannel:
|
|
123
|
+
spl = spl[0]
|
|
124
|
+
if sigbands and xb is not None:
|
|
125
|
+
xb = [band[0] for band in xb]
|
|
126
|
+
|
|
127
|
+
if sigbands and xb is not None:
|
|
128
|
+
return spl, self.freq, xb
|
|
129
|
+
else:
|
|
130
|
+
return spl, self.freq
|
|
131
|
+
|
|
132
|
+
def _process_bands(
|
|
133
|
+
self,
|
|
134
|
+
x_proc: np.ndarray,
|
|
135
|
+
num_channels: int,
|
|
136
|
+
sigbands: bool,
|
|
137
|
+
mode: str = "rms"
|
|
138
|
+
) -> Tuple[np.ndarray, List[np.ndarray] | None]:
|
|
139
|
+
"""
|
|
140
|
+
Process signal through each frequency band.
|
|
141
|
+
|
|
142
|
+
:param x_proc: Standardized 2D input signal [channels, samples].
|
|
143
|
+
:param num_channels: Number of channels.
|
|
144
|
+
:param sigbands: If True, return filtered bands.
|
|
145
|
+
:param mode: 'rms' or 'peak'.
|
|
146
|
+
:return: A tuple containing (SPL_array, Optional_List_of_filtered_signals).
|
|
147
|
+
"""
|
|
148
|
+
spl = np.zeros([num_channels, self.num_bands])
|
|
149
|
+
xb: List[np.ndarray] | None = [np.array([]) for _ in range(self.num_bands)] if sigbands else None
|
|
150
|
+
|
|
151
|
+
for idx in range(self.num_bands):
|
|
152
|
+
for ch in range(num_channels):
|
|
153
|
+
# Core DSP logic extracted to reduce complexity
|
|
154
|
+
filtered_signal = self._filter_and_resample(x_proc[ch], idx)
|
|
155
|
+
|
|
156
|
+
# Sound Level Calculation
|
|
157
|
+
spl[ch, idx] = self._calculate_level(filtered_signal, mode)
|
|
158
|
+
|
|
159
|
+
if sigbands and xb is not None:
|
|
160
|
+
# Restore original length
|
|
161
|
+
y_resampled = _resample_to_length(filtered_signal, int(self.factor[idx]), x_proc.shape[1])
|
|
162
|
+
if ch == 0:
|
|
163
|
+
xb[idx] = np.zeros([num_channels, x_proc.shape[1]])
|
|
164
|
+
xb[idx][ch] = y_resampled
|
|
165
|
+
return spl, xb
|
|
166
|
+
|
|
167
|
+
def _filter_and_resample(self, x_ch: np.ndarray, idx: int) -> np.ndarray:
|
|
168
|
+
"""Resample and filter a single channel for a specific band."""
|
|
169
|
+
if self.factor[idx] > 1:
|
|
170
|
+
sd = signal.resample_poly(x_ch, 1, self.factor[idx])
|
|
171
|
+
else:
|
|
172
|
+
sd = x_ch
|
|
173
|
+
|
|
174
|
+
return cast(np.ndarray, signal.sosfilt(self.sos[idx], sd))
|
|
175
|
+
|
|
176
|
+
def _calculate_level(self, y: np.ndarray, mode: str) -> float:
|
|
177
|
+
"""Calculate the level (RMS or Peak) in dB."""
|
|
178
|
+
if mode.lower() == "rms":
|
|
179
|
+
val_linear = np.std(y)
|
|
180
|
+
elif mode.lower() == "peak":
|
|
181
|
+
val_linear = np.max(np.abs(y))
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError("Invalid mode. Use 'rms' or 'peak'.")
|
|
184
|
+
|
|
185
|
+
if self.dbfs:
|
|
186
|
+
# dBFS: 0 dB is RMS = 1.0 or Peak = 1.0
|
|
187
|
+
return float(20 * np.log10(np.max([val_linear, np.finfo(float).eps])))
|
|
188
|
+
|
|
189
|
+
# Physical SPL: apply sensitivity and use 20uPa reference
|
|
190
|
+
pressure_pa = val_linear * self.calibration_factor
|
|
191
|
+
return float(20 * np.log10(np.max([pressure_pa, np.finfo(float).eps]) / 2e-5))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Filter design and visualization for pyoctaveband.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy import signal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _design_sos_filter(
|
|
16
|
+
freq: List[float],
|
|
17
|
+
freq_d: List[float],
|
|
18
|
+
freq_u: List[float],
|
|
19
|
+
fs: int,
|
|
20
|
+
order: int,
|
|
21
|
+
factor: np.ndarray,
|
|
22
|
+
filter_type: str,
|
|
23
|
+
ripple: float,
|
|
24
|
+
attenuation: float,
|
|
25
|
+
show: bool = False,
|
|
26
|
+
plot_file: str | None = None,
|
|
27
|
+
) -> List[np.ndarray]:
|
|
28
|
+
"""
|
|
29
|
+
Generate SOS coefficients for the filter bank.
|
|
30
|
+
|
|
31
|
+
:param freq: Center frequencies.
|
|
32
|
+
:param freq_d: Lower edge frequencies.
|
|
33
|
+
:param freq_u: Upper edge frequencies.
|
|
34
|
+
:param fs: Original sample rate.
|
|
35
|
+
:param order: Filter order.
|
|
36
|
+
:param factor: Downsampling factors per band.
|
|
37
|
+
:param filter_type: Type of filter.
|
|
38
|
+
:param ripple: Passband ripple (dB).
|
|
39
|
+
:param attenuation: Stopband attenuation (dB).
|
|
40
|
+
:param show: If True, plot response.
|
|
41
|
+
:param plot_file: Path to save plot.
|
|
42
|
+
:return: List of SOS coefficient arrays.
|
|
43
|
+
"""
|
|
44
|
+
sos = [np.array([]) for _ in range(len(freq))]
|
|
45
|
+
|
|
46
|
+
for idx, (lower, upper) in enumerate(zip(freq_d, freq_u)):
|
|
47
|
+
fsd = fs / factor[idx]
|
|
48
|
+
wn = np.array([lower, upper]) / (fsd / 2)
|
|
49
|
+
|
|
50
|
+
if filter_type == "butter":
|
|
51
|
+
sos[idx] = signal.butter(N=order, Wn=wn, btype="bandpass", output="sos")
|
|
52
|
+
elif filter_type == "cheby1":
|
|
53
|
+
sos[idx] = signal.cheby1(N=order, rp=ripple, Wn=wn, btype="bandpass", output="sos")
|
|
54
|
+
elif filter_type == "cheby2":
|
|
55
|
+
sos[idx] = signal.cheby2(N=order, rs=attenuation, Wn=wn, btype="bandpass", output="sos")
|
|
56
|
+
elif filter_type == "ellip":
|
|
57
|
+
sos[idx] = signal.ellip(N=order, rp=ripple, rs=attenuation, Wn=wn, btype="bandpass", output="sos")
|
|
58
|
+
elif filter_type == "bessel":
|
|
59
|
+
sos[idx] = signal.bessel(N=order, Wn=wn, btype="bandpass", norm="phase", output="sos")
|
|
60
|
+
|
|
61
|
+
if show or plot_file:
|
|
62
|
+
_showfilter(sos, freq, freq_u, freq_d, fs, factor, show, plot_file)
|
|
63
|
+
|
|
64
|
+
return sos
|
|
65
|
+
|
|
66
|
+
def _showfilter(
|
|
67
|
+
sos: List[np.ndarray],
|
|
68
|
+
freq: List[float],
|
|
69
|
+
freq_u: List[float],
|
|
70
|
+
freq_d: List[float],
|
|
71
|
+
fs: int,
|
|
72
|
+
factor: np.ndarray,
|
|
73
|
+
show: bool = False,
|
|
74
|
+
plot_file: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Visualize filter bank frequency response.
|
|
78
|
+
|
|
79
|
+
:param sos: List of SOS coefficients.
|
|
80
|
+
:param freq: Center frequencies.
|
|
81
|
+
:param freq_u: Upper edges.
|
|
82
|
+
:param freq_d: Lower edges.
|
|
83
|
+
:param fs: Original sample rate.
|
|
84
|
+
:param factor: Downsampling factors.
|
|
85
|
+
:param show: If True, show the plot.
|
|
86
|
+
:param plot_file: Path to save the plot.
|
|
87
|
+
"""
|
|
88
|
+
wn = 8192
|
|
89
|
+
w = np.zeros([wn, len(freq)])
|
|
90
|
+
h: np.ndarray = np.zeros([wn, len(freq)], dtype=np.complex128)
|
|
91
|
+
|
|
92
|
+
for idx in range(len(freq)):
|
|
93
|
+
fsd = fs / factor[idx]
|
|
94
|
+
w[:, idx], h[:, idx] = signal.sosfreqz(sos[idx], worN=wn, whole=False, fs=fsd)
|
|
95
|
+
|
|
96
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
97
|
+
ax.semilogx(w, 20 * np.log10(abs(h) + np.finfo(float).eps), color="#1f77b4", linewidth=1.2)
|
|
98
|
+
ax.axhline(-3, color="#d62728", linestyle="--", alpha=0.5, linewidth=1, label="-3 dB")
|
|
99
|
+
|
|
100
|
+
ax.set_title("Filter Bank Frequency Response", fontweight="bold", pad=15)
|
|
101
|
+
ax.set_xlabel("Frequency [Hz]")
|
|
102
|
+
ax.set_ylabel("Amplitude [dB]")
|
|
103
|
+
ax.grid(which="major", color="#e0e0e0", linestyle="-")
|
|
104
|
+
ax.grid(which="minor", color="#e0e0e0", linestyle=":", alpha=0.4)
|
|
105
|
+
|
|
106
|
+
plt.xlim(freq_d[0] * 0.8, freq_u[-1] * 1.2)
|
|
107
|
+
plt.ylim(-4, 1)
|
|
108
|
+
|
|
109
|
+
xticks = [16, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
|
110
|
+
xticklabels = ["16", "31.5", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"]
|
|
111
|
+
ax.set_xticks(xticks)
|
|
112
|
+
ax.set_xticklabels(xticklabels)
|
|
113
|
+
|
|
114
|
+
if plot_file:
|
|
115
|
+
plt.savefig(plot_file, dpi=150, bbox_inches="tight")
|
|
116
|
+
if show:
|
|
117
|
+
plt.show()
|
|
118
|
+
plt.close(fig)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Frequency calculation logic according to ANSI/IEC standards.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def getansifrequencies(
|
|
15
|
+
fraction: float,
|
|
16
|
+
limits: List[float] | None = None,
|
|
17
|
+
) -> Tuple[List[float], List[float], List[float]]:
|
|
18
|
+
"""
|
|
19
|
+
Calculate frequencies according to ANSI/IEC standards.
|
|
20
|
+
|
|
21
|
+
:param fraction: Bandwidth fraction (e.g., 1, 3).
|
|
22
|
+
:param limits: [f_min, f_max] limits.
|
|
23
|
+
:return: Tuple of (center_freqs, lower_edges, upper_edges).
|
|
24
|
+
"""
|
|
25
|
+
if limits is None:
|
|
26
|
+
limits = [12, 20000]
|
|
27
|
+
|
|
28
|
+
g = 10 ** (3 / 10)
|
|
29
|
+
fr = 1000
|
|
30
|
+
|
|
31
|
+
x = _initindex(limits[0], fr, g, fraction)
|
|
32
|
+
freq = np.array([_ratio(g, x, fraction) * fr])
|
|
33
|
+
|
|
34
|
+
freq_x = freq[0]
|
|
35
|
+
while freq_x * _bandedge(g, fraction) < limits[1]:
|
|
36
|
+
x += 1
|
|
37
|
+
freq_x = _ratio(g, x, fraction) * fr
|
|
38
|
+
freq = np.append(freq, freq_x)
|
|
39
|
+
|
|
40
|
+
freq_d = freq / _bandedge(g, fraction)
|
|
41
|
+
freq_u = freq * _bandedge(g, fraction)
|
|
42
|
+
|
|
43
|
+
return freq.tolist(), freq_d.tolist(), freq_u.tolist()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _initindex(f: float, fr: float, g: float, b: float) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Calculate starting index for band generation.
|
|
49
|
+
|
|
50
|
+
:param f: Frequency.
|
|
51
|
+
:param fr: Reference frequency.
|
|
52
|
+
:param g: Base ratio.
|
|
53
|
+
:param b: Bandwidth fraction.
|
|
54
|
+
:return: Index integer.
|
|
55
|
+
"""
|
|
56
|
+
if round(b) % 2:
|
|
57
|
+
return int(np.round((b * np.log(f / fr) + 30 * np.log(g)) / np.log(g)))
|
|
58
|
+
return int(np.round((2 * b * np.log(f / fr) + 59 * np.log(g)) / (2 * np.log(g))))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ratio(g: float, x: int, b: float) -> float:
|
|
62
|
+
"""
|
|
63
|
+
Calculate ratio for center frequency.
|
|
64
|
+
|
|
65
|
+
:param g: Base ratio.
|
|
66
|
+
:param x: Index.
|
|
67
|
+
:param b: Bandwidth fraction.
|
|
68
|
+
:return: Frequency ratio.
|
|
69
|
+
"""
|
|
70
|
+
if round(b) % 2:
|
|
71
|
+
return float(g ** ((x - 30) / b))
|
|
72
|
+
return float(g ** ((2 * x - 59) / (2 * b)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bandedge(g: float, b: float) -> float:
|
|
76
|
+
"""
|
|
77
|
+
Calculate band-edge ratio.
|
|
78
|
+
|
|
79
|
+
:param g: Base ratio.
|
|
80
|
+
:param b: Bandwidth fraction.
|
|
81
|
+
:return: Edge ratio.
|
|
82
|
+
"""
|
|
83
|
+
return float(g ** (1 / (2 * b)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _deleteouters(
|
|
87
|
+
freq: List[float], freq_d: List[float], freq_u: List[float], fs: int
|
|
88
|
+
) -> Tuple[List[float], List[float], List[float]]:
|
|
89
|
+
"""
|
|
90
|
+
Remove bands exceeding the Nyquist frequency.
|
|
91
|
+
|
|
92
|
+
:param freq: Center frequencies.
|
|
93
|
+
:param freq_d: Lower edges.
|
|
94
|
+
:param freq_u: Upper edges.
|
|
95
|
+
:param fs: Sample rate.
|
|
96
|
+
:return: Filtered (center, lower, upper) frequencies.
|
|
97
|
+
"""
|
|
98
|
+
freq_arr = np.array(freq)
|
|
99
|
+
freq_d_arr = np.array(freq_d)
|
|
100
|
+
freq_u_arr = np.array(freq_u)
|
|
101
|
+
|
|
102
|
+
idx = np.nonzero(freq_u_arr > fs / 2)[0]
|
|
103
|
+
if len(idx) > 0:
|
|
104
|
+
warnings.warn("Low sampling rate: frequencies above fs/2 removed", stacklevel=3)
|
|
105
|
+
freq_arr = np.delete(freq_arr, idx)
|
|
106
|
+
freq_d_arr = np.delete(freq_d_arr, idx)
|
|
107
|
+
freq_u_arr = np.delete(freq_u_arr, idx)
|
|
108
|
+
|
|
109
|
+
return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _genfreqs(limits: List[float], fraction: float, fs: int) -> Tuple[List[float], List[float], List[float]]:
|
|
113
|
+
"""
|
|
114
|
+
Determine band frequencies within limits.
|
|
115
|
+
|
|
116
|
+
:param limits: [f_min, f_max].
|
|
117
|
+
:param fraction: Bandwidth fraction.
|
|
118
|
+
:param fs: Sample rate.
|
|
119
|
+
:return: Tuple of center, lower, and upper frequencies.
|
|
120
|
+
"""
|
|
121
|
+
freq, freq_d, freq_u = getansifrequencies(fraction, limits)
|
|
122
|
+
return _deleteouters(freq, freq_d, freq_u, fs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def normalizedfreq(fraction: int) -> List[float]:
|
|
126
|
+
"""
|
|
127
|
+
Get standardized IEC center frequencies.
|
|
128
|
+
|
|
129
|
+
:param fraction: 1 or 3 (Octave or 1/3 Octave).
|
|
130
|
+
:return: List of standard frequencies.
|
|
131
|
+
"""
|
|
132
|
+
predefined = {
|
|
133
|
+
1: [16, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000],
|
|
134
|
+
3: [
|
|
135
|
+
12.5, 16, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500,
|
|
136
|
+
630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000,
|
|
137
|
+
12500, 16000, 20000,
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
if fraction not in predefined:
|
|
141
|
+
raise ValueError("Normalized frequencies only available for fraction=1 or 3")
|
|
142
|
+
return predefined[fraction]
|