[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add IEC 61260-1 nominal frequency labels (#46)

Add nominal frequency label support per IEC 61260-1:2014.

- New nominal parameter in octavefilter() and OctaveFilterBank.filter()
- getansifrequencies() returns 4-tuple with labels (breaking change)
- Bump version to 1.2.0
- Address review findings: np.isclose matching, lru_cache, sonar exclusions

Closes #44

Co-authored-by: ninoblumer <40544174+ninoblumer@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

authored by

José M. Requena Plens
ninoblumer
Copilot
and committed by
GitHub
(Mar 8, 2026, 11:37 AM +0100) eb67e63b acad961a

+298 -21
+1 -1
pyproject.toml
··· 4 4 5 5 [project] 6 6 name = "PyOctaveBand" 7 - version = "1.1.5" 7 + version = "1.2.0" 8 8 authors = [ 9 9 { name="Jose Manuel Requena Plens", email="jmrplens@gmail.com" }, 10 10 ]
+2
sonar-project.properties
··· 18 18 # Exclusions 19 19 sonar.exclusions=**/__pycache__/**, **/*.png, **/*.md 20 20 sonar.test.inclusions=tests/**/test_*.py 21 + # Exclude overload-heavy type-stub files from duplication analysis 22 + sonar.cpd.exclusions=src/pyoctaveband/__init__.py,src/pyoctaveband/core.py 21 23 22 24 # Increase authorized parameters for scientific APIs 23 25 sonar.issue.ignore.multicriteria=S107
+3 -1
tests/test_coverage_fix.py
··· 139 139 spl, freq = octavefilter(np.random.randn(1000), 1000, limits=None) 140 140 assert len(spl) > 0 141 141 # Also directly call it 142 - f1, f2, f3 = getansifrequencies(1, limits=None) 142 + f1, f2, f3, labels = getansifrequencies(1, limits=None) 143 143 assert len(f1) > 0 144 + assert len(f1) == len(f2) == len(f3) == len(labels) 145 + assert all(isinstance(label, str) for label in labels) 144 146 145 147 def test_calculate_level_invalid(): 146 148 from pyoctaveband.core import OctaveFilterBank
+130
tests/test_nominal_frequencies.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for IEC 61260-1 nominal frequency helpers and opt-in nominal label support. 4 + """ 5 + 6 + import numpy as np 7 + 8 + from pyoctaveband.frequencies import ( 9 + _format_nominal_freq, 10 + _iec_e3_round, 11 + _nominal_freq_for_band, 12 + getansifrequencies, 13 + ) 14 + from pyoctaveband import OctaveFilterBank, octavefilter 15 + 16 + 17 + # --- _iec_e3_round --- 18 + 19 + def test_iec_e3_round_msd_1_to_4(): 20 + assert _iec_e3_round(1234.5) == 1230.0 # MSD=1, step=10 21 + assert _iec_e3_round(456.7) == 457.0 # MSD=4, step=1 22 + 23 + 24 + def test_iec_e3_round_msd_5_to_9(): 25 + assert _iec_e3_round(5678.0) == 5700.0 # MSD=5, step=100 26 + assert _iec_e3_round(99.1) == 99.0 # MSD=9, step=10 27 + 28 + 29 + def test_iec_e3_round_nonpositive(): 30 + assert _iec_e3_round(0) == 0 31 + assert _iec_e3_round(-1) == -1 32 + 33 + 34 + # --- _nominal_freq_for_band --- 35 + 36 + def test_nominal_freq_fraction1(): 37 + assert _nominal_freq_for_band(15.849, 1) == 16.0 38 + assert _nominal_freq_for_band(997.2, 1) == 1000.0 39 + 40 + 41 + def test_nominal_freq_fraction3(): 42 + assert _nominal_freq_for_band(12.589, 3) == 12.5 43 + assert _nominal_freq_for_band(1995.3, 3) == 2000.0 44 + 45 + 46 + def test_nominal_freq_other_fraction(): 47 + assert _nominal_freq_for_band(706.0, 2) == 710.0 # MSD=7 → 2 sig figs 48 + 49 + 50 + # --- _format_nominal_freq --- 51 + 52 + def test_format_below_1k(): 53 + assert _format_nominal_freq(31.5) == "31.5" 54 + assert _format_nominal_freq(500.0) == "500" 55 + 56 + 57 + def test_format_1k_and_above(): 58 + assert _format_nominal_freq(1000.0) == "1k" 59 + assert _format_nominal_freq(16000.0) == "16k" 60 + assert _format_nominal_freq(1250.0) == "1.25k" 61 + 62 + 63 + # --- getansifrequencies — 4-tuple --- 64 + 65 + def test_getansifrequencies_returns_labels(): 66 + freq, fd, fu, labels = getansifrequencies(fraction=3) 67 + assert isinstance(labels, list) 68 + assert all(isinstance(label, str) for label in labels) 69 + assert len(labels) == len(freq) 70 + assert "1k" in labels 71 + assert "31.5" in labels 72 + 73 + 74 + def test_getansifrequencies_fraction1_labels(): 75 + freq, fd, fu, labels = getansifrequencies(fraction=1) 76 + assert "1k" in labels 77 + assert len(labels) == len(freq) 78 + 79 + 80 + # --- OctaveFilterBank.nominal_freq attribute --- 81 + 82 + def test_filterbank_nominal_freq_attribute(): 83 + fb = OctaveFilterBank(fs=48000, fraction=1) 84 + assert hasattr(fb, "nominal_freq") 85 + assert isinstance(fb.nominal_freq, list) 86 + assert all(isinstance(label, str) for label in fb.nominal_freq) 87 + assert "1k" in fb.nominal_freq 88 + assert len(fb.nominal_freq) == fb.num_bands 89 + 90 + 91 + # --- filter(nominal=False) — default exact floats --- 92 + 93 + def test_filter_nominal_false_returns_floats(): 94 + fb = OctaveFilterBank(fs=48000, fraction=3) 95 + x = np.zeros(4800) 96 + _, freq = fb.filter(x, nominal=False) 97 + assert all(isinstance(f, float) for f in freq) 98 + 99 + 100 + # --- filter(nominal=True) — nominal string labels --- 101 + 102 + def test_filter_nominal_true_returns_strings(): 103 + fb = OctaveFilterBank(fs=48000, fraction=3) 104 + x = np.zeros(4800) 105 + _, freq = fb.filter(x, nominal=True) 106 + assert all(isinstance(f, str) for f in freq) 107 + assert "1k" in freq 108 + 109 + 110 + def test_filter_nominal_true_with_sigbands(): 111 + fb = OctaveFilterBank(fs=48000, fraction=1) 112 + x = np.zeros(4800) 113 + _, freq, xb = fb.filter(x, sigbands=True, nominal=True) 114 + assert all(isinstance(f, str) for f in freq) 115 + assert len(xb) == len(freq) 116 + 117 + 118 + # --- octavefilter(nominal=True) --- 119 + 120 + def test_octavefilter_nominal_true(): 121 + x = np.zeros(4800) 122 + _, freq = octavefilter(x, fs=48000, fraction=3, nominal=True) 123 + assert all(isinstance(f, str) for f in freq) 124 + assert "1k" in freq 125 + 126 + 127 + def test_octavefilter_nominal_false_default(): 128 + x = np.zeros(4800) 129 + _, freq = octavefilter(x, fs=48000, fraction=3) 130 + assert all(isinstance(f, float) for f in freq)
+53 -7
src/pyoctaveband/__init__.py
··· 19 19 # Use non-interactive backend for plots 20 20 matplotlib.use("Agg") 21 21 22 - __version__ = "1.1.4" 22 + __version__ = "1.2.0" 23 23 24 24 # Public methods 25 25 __all__ = [ ··· 52 52 calibration_factor: float = 1.0, 53 53 dbfs: bool = False, 54 54 mode: str = "rms", 55 + nominal: Literal[False] = False, 55 56 ) -> Tuple[np.ndarray, List[float]]: ... 56 57 57 58 ··· 72 73 calibration_factor: float = 1.0, 73 74 dbfs: bool = False, 74 75 mode: str = "rms", 76 + nominal: Literal[False] = False, 75 77 ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ... 78 + 79 + 80 + @overload 81 + def octavefilter( 82 + x: List[float] | np.ndarray, 83 + fs: int, 84 + fraction: float = 1, 85 + order: int = 6, 86 + limits: List[float] | None = None, 87 + show: bool = False, 88 + sigbands: Literal[False] = False, 89 + plot_file: str | None = None, 90 + detrend: bool = True, 91 + filter_type: str = "butter", 92 + ripple: float = 0.1, 93 + attenuation: float = 60.0, 94 + calibration_factor: float = 1.0, 95 + dbfs: bool = False, 96 + mode: str = "rms", 97 + nominal: Literal[True] = ..., 98 + ) -> Tuple[np.ndarray, List[str]]: ... 99 + 100 + 101 + @overload 102 + def octavefilter( 103 + x: List[float] | np.ndarray, 104 + fs: int, 105 + fraction: float = 1, 106 + order: int = 6, 107 + limits: List[float] | None = None, 108 + show: bool = False, 109 + sigbands: Literal[True] = True, 110 + plot_file: str | None = None, 111 + detrend: bool = True, 112 + filter_type: str = "butter", 113 + ripple: float = 0.1, 114 + attenuation: float = 60.0, 115 + calibration_factor: float = 1.0, 116 + dbfs: bool = False, 117 + mode: str = "rms", 118 + nominal: Literal[True] = ..., 119 + ) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ... 76 120 77 121 78 122 def octavefilter( ··· 91 135 calibration_factor: float = 1.0, 92 136 dbfs: bool = False, 93 137 mode: str = "rms", 94 - ) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]: 138 + nominal: bool = False, 139 + ) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[str]] | Tuple[np.ndarray, List[float], List[np.ndarray]] | Tuple[np.ndarray, List[str], List[np.ndarray]]: 95 140 """ 96 141 Filter a signal with octave or fractional octave filter bank. 97 142 ··· 125 170 :param calibration_factor: Calibration factor for SPL calculation. Default: 1.0. 126 171 :param dbfs: If True, return results in dBFS. Default: False. 127 172 :param mode: 'rms' or 'peak'. Default: 'rms'. 173 + :param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats. 128 174 :return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals). 129 - :rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[float], List[np.ndarray]]] 175 + When *nominal=True*, the frequency list contains ``List[str]`` labels instead of floats. 176 + :rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[str]], 177 + Tuple[np.ndarray, List[float], List[np.ndarray]], 178 + Tuple[np.ndarray, List[str], List[np.ndarray]]] 130 179 """ 131 180 132 181 # Use the class-based implementation ··· 144 193 dbfs=dbfs 145 194 ) 146 195 147 - if sigbands: 148 - return filter_bank.filter(x, sigbands=True, mode=mode, detrend=detrend) 149 - else: 150 - return filter_bank.filter(x, sigbands=False, mode=mode, detrend=detrend) 196 + return filter_bank.filter(x, sigbands=sigbands, mode=mode, detrend=detrend, nominal=nominal) # type: ignore[call-overload,no-any-return]
+57 -5
src/pyoctaveband/core.py
··· 92 92 self.stateful = stateful 93 93 94 94 # Generate frequencies 95 - self.freq, self.freq_d, self.freq_u = _genfreqs(limits, fraction, fs) 95 + self.freq, self.freq_d, self.freq_u, self.nominal_freq = _genfreqs(limits, fraction, fs) 96 96 self.num_bands = len(self.freq) 97 97 98 98 ··· 137 137 mode: str = "rms", 138 138 detrend: bool = True, 139 139 calculate_level: Literal[True] = True, 140 + nominal: Literal[False] = False, 140 141 ) -> Tuple[np.ndarray, List[float]]: ... 141 142 142 143 @overload ··· 147 148 mode: str = "rms", 148 149 detrend: bool = True, 149 150 calculate_level: Literal[True] = True, 151 + nominal: Literal[False] = False, 150 152 ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ... 151 153 152 154 @overload ··· 157 159 mode: str = "rms", 158 160 detrend: bool = True, 159 161 calculate_level: Literal[False] = False, 162 + nominal: Literal[False] = False, 160 163 ) -> Tuple[None, List[float]]: ... 161 164 162 165 @overload ··· 167 170 mode: str = "rms", 168 171 detrend: bool = True, 169 172 calculate_level: Literal[False] = False, 173 + nominal: Literal[False] = False, 170 174 ) -> Tuple[None, List[float], List[np.ndarray]]: ... 175 + 176 + @overload 177 + def filter( 178 + self, 179 + x: List[float] | np.ndarray, 180 + sigbands: Literal[False] = False, 181 + mode: str = "rms", 182 + detrend: bool = True, 183 + calculate_level: Literal[True] = True, 184 + nominal: Literal[True] = ..., 185 + ) -> Tuple[np.ndarray, List[str]]: ... 186 + 187 + @overload 188 + def filter( 189 + self, 190 + x: List[float] | np.ndarray, 191 + sigbands: Literal[True], 192 + mode: str = "rms", 193 + detrend: bool = True, 194 + calculate_level: Literal[True] = True, 195 + nominal: Literal[True] = ..., 196 + ) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ... 197 + 198 + @overload 199 + def filter( 200 + self, 201 + x: List[float] | np.ndarray, 202 + sigbands: Literal[False] = False, 203 + mode: str = "rms", 204 + detrend: bool = True, 205 + calculate_level: Literal[False] = False, 206 + nominal: Literal[True] = ..., 207 + ) -> Tuple[None, List[str]]: ... 208 + 209 + @overload 210 + def filter( 211 + self, 212 + x: List[float] | np.ndarray, 213 + sigbands: Literal[True], 214 + mode: str = "rms", 215 + detrend: bool = True, 216 + calculate_level: Literal[False] = False, 217 + nominal: Literal[True] = ..., 218 + ) -> Tuple[None, List[str], List[np.ndarray]]: ... 171 219 172 220 def filter( 173 221 self, ··· 176 224 mode: str = "rms", 177 225 detrend: bool = True, 178 226 calculate_level: bool = True, 179 - ) -> Tuple[np.ndarray | None, List[float]] | Tuple[np.ndarray | None, List[float], List[np.ndarray]]: 227 + nominal: bool = False, 228 + ) -> Tuple[np.ndarray | None, List[float] | List[str]] | Tuple[np.ndarray | None, List[float] | List[str], List[np.ndarray]]: 180 229 """ 181 230 Apply the pre-designed filter bank to a signal. 182 231 ··· 184 233 :param sigbands: If True, also return the signal in the time domain divided into bands. 185 234 :param mode: 'rms' for energy-based level, 'peak' for peak-holding level. 186 235 :param detrend: If True, remove DC offset from signal before filtering (Default: True). 187 - :param calculate_level: If True, calculate SPL 236 + :param calculate_level: If True, calculate SPL. 237 + :param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats. 188 238 :return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals). 189 239 """ 190 240 ··· 220 270 if sigbands and xb is not None: 221 271 xb = [band[0] for band in xb] 222 272 273 + freq_out = self.nominal_freq if nominal else self.freq 274 + 223 275 if sigbands and xb is not None: 224 - return spl, self.freq, xb 276 + return spl, freq_out, xb 225 277 else: 226 - return spl, self.freq 278 + return spl, freq_out 227 279 228 280 def _process_bands( 229 281 self,
+52 -7
src/pyoctaveband/frequencies.py
··· 6 6 from __future__ import annotations 7 7 8 8 import warnings 9 + from functools import lru_cache 9 10 from typing import List, Tuple 10 11 11 12 import numpy as np ··· 14 15 def getansifrequencies( 15 16 fraction: float, 16 17 limits: List[float] | None = None, 17 - ) -> Tuple[List[float], List[float], List[float]]: 18 + ) -> Tuple[List[float], List[float], List[float], List[str]]: 18 19 """ 19 20 Calculate frequencies according to ANSI/IEC standards. 20 21 21 22 :param fraction: Bandwidth fraction (e.g., 1, 3). 22 23 :param limits: [f_min, f_max] limits. 23 - :return: Tuple of (center_freqs, lower_edges, upper_edges). 24 + :return: Tuple of (center_freqs, lower_edges, upper_edges, nominal_labels). 24 25 """ 25 26 if limits is None: 26 27 limits = [12, 20000] ··· 40 41 freq_d = freq / _bandedge(g, fraction) 41 42 freq_u = freq * _bandedge(g, fraction) 42 43 43 - return freq.tolist(), freq_d.tolist(), freq_u.tolist() 44 + labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq.tolist()] 45 + return freq.tolist(), freq_d.tolist(), freq_u.tolist(), labels 44 46 45 47 46 48 def _initindex(f: float, fr: float, g: float, b: float) -> int: ··· 109 111 return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist() 110 112 111 113 112 - def _genfreqs(limits: List[float], fraction: float, fs: int) -> Tuple[List[float], List[float], List[float]]: 114 + def _genfreqs( 115 + limits: List[float], fraction: float, fs: int 116 + ) -> Tuple[List[float], List[float], List[float], List[str]]: 113 117 """ 114 118 Determine band frequencies within limits. 115 119 116 120 :param limits: [f_min, f_max]. 117 121 :param fraction: Bandwidth fraction. 118 122 :param fs: Sample rate. 119 - :return: Tuple of center, lower, and upper frequencies. 123 + :return: Tuple of center, lower, upper frequencies, and nominal labels. 120 124 """ 121 - freq, freq_d, freq_u = getansifrequencies(fraction, limits) 122 - return _deleteouters(freq, freq_d, freq_u, fs) 125 + freq, freq_d, freq_u, labels = getansifrequencies(fraction, limits) 126 + freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, fs) 127 + # _deleteouters only removes trailing bands above Nyquist, so slice labels 128 + labels = labels[: len(freq)] 129 + return freq, freq_d, freq_u, labels 130 + 131 + 132 + def _iec_e3_round(f: float) -> float: 133 + """IEC 61260-1 Annex E.3: 3 sig figs if MSD 1–4, 2 sig figs if MSD 5–9.""" 134 + if f <= 0: 135 + return f 136 + exponent = int(np.floor(np.log10(f))) 137 + msd = f / (10.0 ** exponent) 138 + step = 10.0 ** (exponent - 2) if msd < 5.0 else 10.0 ** (exponent - 1) 139 + return round(f / step) * step 140 + 141 + 142 + @lru_cache(maxsize=4) 143 + def _extended_preferred(frac: int) -> List[float]: 144 + """Cached expansion of the IEC preferred frequency table across decades.""" 145 + base = normalizedfreq(frac) 146 + return [f * (10 ** d) for d in range(-3, 4) for f in base] 147 + 148 + 149 + def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float: 150 + """Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency. 151 + 152 + For standard fractions (1, 3), snaps to the IEC preferred table via 153 + ``normalizedfreq``. For non-standard fractions, falls back to Annex E.3 154 + significant-figure rounding (``_iec_e3_round``). 155 + """ 156 + frac = round(fraction) 157 + if np.isclose(fraction, frac) and frac in (1, 3): 158 + extended = _extended_preferred(frac) 159 + return min(extended, key=lambda f: abs(np.log(f / exact_freq))) 160 + return _iec_e3_round(exact_freq) 161 + 162 + 163 + def _format_nominal_freq(f: float) -> str: 164 + """Format a nominal frequency as a human-readable label string.""" 165 + if f >= 1000: 166 + return f"{f / 1000:g}k" 167 + return f"{f:g}" 123 168 124 169 125 170 def normalizedfreq(fraction: int) -> List[float]: