[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.

perf: cache bank designs, make numba/matplotlib optional (#59)

* perf: cache filter bank designs across octavefilter() calls

octavefilter() redesigned the full SOS bank on every call, dominating
runtime in loops. Designs are now reused via lru_cache (32 entries) keyed
on all design parameters; plotting calls bypass the cache. Banks are
immutable in non-stateful mode, so reuse is safe.

test_filterbank_reuse_performance now clears the cache per functional
call to keep measuring the redesign cost it was written for.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* perf: make numba optional with pure-Python impulse kernel fallback

numba is only used by the 'impulse' time-weighting kernel but is a heavy
install that pins numpy versions. It moves to the [perf] extra (also in
[full]); without it the undecorated kernel runs identically, just slower.
requirements.txt keeps numba so CI exercises the jitted path.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* perf: import matplotlib lazily and move it to the [plot] extra

matplotlib is only needed for show=True/plot_file. Importing it inside
_showfilter drops it as a hard dependency and speeds up package import.
Missing matplotlib now raises an actionable ImportError.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* perf: skip no-op resampling and avoid np.append loop in freq generation

- _resample_to_length returned a full FIR resample_poly(y, 1, 1) copy per
non-decimated band; return the input directly when nothing changes.
- getansifrequencies grew the frequency array with np.append (O(n^2));
accumulate in a list instead.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* perf: address review feedback on cache and resampling

- Return a copy of the freq list from OctaveFilterBank.filter(): with the
design cache, callers mutating the returned list would corrupt results
of subsequent octavefilter() calls.
- Pass limits=None through to the bank instead of duplicating the default
(12, 20000) in the cache key.
- Skip resample_poly entirely for factor == 1 (identity FIR call).
- Harden the lazy-import AST test to walk the full tree, not just
top-level statements.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

authored by

José M. Requena Plens and committed by
GitHub
(Jul 5, 2026, 11:14 PM +0200) 1f7609c0 13a5239b

+212 -33
+5 -2
pyproject.toml
··· 19 19 dependencies = [ 20 20 "numpy>=2.4.4", 21 21 "scipy>=1.17.1", 22 - "matplotlib>=3.10.9", 23 - "numba>=0.65.1", 24 22 ] 23 + 24 + [project.optional-dependencies] 25 + perf = ["numba>=0.65.1"] 26 + plot = ["matplotlib>=3.10.9"] 27 + full = ["numba>=0.65.1", "matplotlib>=3.10.9"] 25 28 26 29 [project.urls] 27 30 "Homepage" = "https://github.com/jmrplens/PyOctaveBand"
+45
tests/test_basic.py
··· 77 77 78 78 assert len(xb) == len(freq) 79 79 assert xb[0].shape == y.shape 80 + 81 + 82 + def test_octavefilter_reuses_cached_bank(monkeypatch) -> None: 83 + """Repeated octavefilter calls with identical params must not redesign the bank.""" 84 + from pyoctaveband.core import OctaveFilterBank 85 + 86 + PyOctaveBand._cached_filter_bank.cache_clear() 87 + calls = {"n": 0} 88 + original_init = OctaveFilterBank.__init__ 89 + 90 + def counting_init(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 91 + calls["n"] += 1 92 + original_init(self, *args, **kwargs) 93 + 94 + monkeypatch.setattr(OctaveFilterBank, "__init__", counting_init) 95 + 96 + x = np.random.default_rng(0).standard_normal(4800) 97 + PyOctaveBand.octavefilter(x, 48000, fraction=3) 98 + PyOctaveBand.octavefilter(x, 48000, fraction=3) 99 + assert calls["n"] == 1 100 + 101 + PyOctaveBand.octavefilter(x, 48000, fraction=1) # different params -> new bank 102 + assert calls["n"] == 2 103 + PyOctaveBand._cached_filter_bank.cache_clear() 104 + 105 + 106 + def test_octavefilter_cached_results_identical() -> None: 107 + """The cached bank must return bit-identical results across calls.""" 108 + PyOctaveBand._cached_filter_bank.cache_clear() 109 + x = np.random.default_rng(1).standard_normal(4800) 110 + spl1, f1 = PyOctaveBand.octavefilter(x, 48000, fraction=3) 111 + spl2, f2 = PyOctaveBand.octavefilter(x, 48000, fraction=3) 112 + np.testing.assert_array_equal(spl1, spl2) 113 + assert f1 == f2 114 + 115 + 116 + def test_octavefilter_freq_list_is_mutation_safe() -> None: 117 + """Mutating the returned freq list must not corrupt the cached bank.""" 118 + PyOctaveBand._cached_filter_bank.cache_clear() 119 + x = np.random.default_rng(2).standard_normal(4800) 120 + _, freq1 = PyOctaveBand.octavefilter(x, 48000, fraction=1) 121 + freq1[0] = -999.0 # caller mutates the returned list 122 + _, freq2 = PyOctaveBand.octavefilter(x, 48000, fraction=1) 123 + assert freq2[0] != -999.0 124 + PyOctaveBand._cached_filter_bank.cache_clear()
+50
tests/test_matplotlib_backend.py
··· 36 36 timeout=30, 37 37 ) 38 38 assert result.returncode == 0, result.stderr 39 + 40 + 41 + def test_filter_design_has_no_toplevel_matplotlib_import() -> None: 42 + """matplotlib must be imported lazily so the package works without it.""" 43 + import ast 44 + import inspect 45 + 46 + from pyoctaveband import filter_design 47 + 48 + tree = ast.parse(inspect.getsource(filter_design)) 49 + 50 + # Only imports inside a function body are lazy; module-scope imports 51 + # (even wrapped in try/if blocks) still run at import time. 52 + inside_functions: set[ast.AST] = set() 53 + for func in ast.walk(tree): 54 + if isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)): 55 + for sub in ast.walk(func): 56 + inside_functions.add(sub) 57 + 58 + for node in ast.walk(tree): 59 + if isinstance(node, (ast.Import, ast.ImportFrom)): 60 + module = getattr(node, "module", None) or "" 61 + aliases = [a.name for a in node.names] 62 + if any("matplotlib" in n for n in [module, *aliases]): 63 + assert node in inside_functions, ( 64 + "matplotlib imported at module scope in filter_design" 65 + ) 66 + 67 + 68 + def test_showfilter_raises_helpful_error_without_matplotlib(monkeypatch) -> None: 69 + """Without matplotlib, plotting must fail with an actionable message.""" 70 + import builtins 71 + 72 + import numpy as np 73 + import pytest 74 + 75 + from pyoctaveband import filter_design 76 + 77 + real_import = builtins.__import__ 78 + 79 + def blocked_import(name, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 80 + if name.startswith("matplotlib"): 81 + raise ImportError("No module named 'matplotlib'") 82 + return real_import(name, *args, **kwargs) 83 + 84 + monkeypatch.setattr(builtins, "__import__", blocked_import) 85 + with pytest.raises(ImportError, match=r"pip install pyoctaveband\[plot\]"): 86 + filter_design._showfilter( 87 + [], [1000.0], [1122.0], [891.0], 48000, np.array([1]), show=True, plot_file=None 88 + )
+19
tests/test_parametric.py
··· 215 215 # Test reuse 216 216 spl2, _ = bank.filter(x * 0.5) 217 217 assert np.all(spl2 < spl) 218 + 219 + 220 + def test_impulse_kernel_python_fallback_matches_numba() -> None: 221 + """ 222 + Verify the pure-Python kernel matches the (possibly jitted) default. 223 + 224 + **Purpose:** 225 + numba is an optional dependency; without it, time_weighting 'impulse' 226 + falls back to the undecorated kernel, which must be functionally 227 + identical. 228 + """ 229 + from pyoctaveband import parametric_filters as pf 230 + 231 + rng = np.random.default_rng(3) 232 + x_t = np.ascontiguousarray(rng.standard_normal((500, 2)) ** 2) 233 + init = np.zeros(2) 234 + y_default = pf._apply_impulse_kernel(x_t, 0.5, 0.01, init.copy()) 235 + y_python = pf._impulse_kernel_py(x_t, 0.5, 0.01, init.copy()) 236 + np.testing.assert_allclose(y_python, y_default, rtol=1e-12)
+5 -1
tests/test_performance.py
··· 7 7 8 8 import numpy as np 9 9 10 + import pyoctaveband 10 11 from pyoctaveband import OctaveFilterBank, octavefilter 11 12 12 13 ··· 33 34 x = rng.standard_normal(int(fs * duration)) 34 35 num_iterations = 10 35 36 36 - # 1. Using functional API (re-designs every time) 37 + # 1. Using functional API with a cold design cache on every call. 38 + # octavefilter() now caches bank designs, so clear it each iteration to 39 + # measure the redesign cost this test is about. 37 40 start_func = time.time() 38 41 for _ in range(num_iterations): 42 + pyoctaveband._cached_filter_bank.cache_clear() 39 43 octavefilter(x, fs) 40 44 time_func = time.time() - start_func 41 45
+53 -15
src/pyoctaveband/__init__.py
··· 6 6 7 7 from __future__ import annotations 8 8 9 + from functools import lru_cache 9 10 from typing import List, Tuple, overload, Literal 10 11 11 12 import numpy as np ··· 29 30 "linkwitz_riley", 30 31 "calculate_sensitivity", 31 32 ] 33 + 34 + 35 + @lru_cache(maxsize=32) 36 + def _cached_filter_bank( 37 + fs: int, 38 + fraction: float, 39 + order: int, 40 + limits: Tuple[float, ...] | None, 41 + filter_type: str, 42 + ripple: float, 43 + attenuation: float, 44 + calibration_factor: float, 45 + dbfs: bool, 46 + ) -> OctaveFilterBank: 47 + """Design (or reuse) a stateless filter bank for octavefilter().""" 48 + return OctaveFilterBank( 49 + fs=fs, 50 + fraction=fraction, 51 + order=order, 52 + limits=list(limits) if limits is not None else None, 53 + filter_type=filter_type, 54 + ripple=ripple, 55 + attenuation=attenuation, 56 + calibration_factor=calibration_factor, 57 + dbfs=dbfs, 58 + ) 32 59 33 60 34 61 @overload ··· 174 201 Tuple[np.ndarray, List[str], List[np.ndarray]]] 175 202 """ 176 203 177 - # Use the class-based implementation 178 - filter_bank = OctaveFilterBank( 179 - fs=fs, 180 - fraction=fraction, 181 - order=order, 182 - limits=limits, 183 - filter_type=filter_type, 184 - ripple=ripple, 185 - attenuation=attenuation, 186 - show=show, 187 - plot_file=plot_file, 188 - calibration_factor=calibration_factor, 189 - dbfs=dbfs 190 - ) 191 - 204 + if show or plot_file: 205 + # Plotting has side effects: bypass the cache. 206 + filter_bank = OctaveFilterBank( 207 + fs=fs, 208 + fraction=fraction, 209 + order=order, 210 + limits=limits, 211 + filter_type=filter_type, 212 + ripple=ripple, 213 + attenuation=attenuation, 214 + show=show, 215 + plot_file=plot_file, 216 + calibration_factor=calibration_factor, 217 + dbfs=dbfs, 218 + ) 219 + else: 220 + # The bank is immutable in non-stateful mode: reuse the design. 221 + # Pass limits through as-is (tuple for hashability); the bank 222 + # constructor is the single place that validates them and owns 223 + # the default when None. 224 + limits_key = tuple(map(float, limits)) if limits is not None else None 225 + filter_bank = _cached_filter_bank( 226 + fs, fraction, order, limits_key, filter_type, 227 + ripple, attenuation, calibration_factor, dbfs, 228 + ) 229 + 192 230 return filter_bank.filter(x, sigbands=sigbands, mode=mode, detrend=detrend, nominal=nominal) # type: ignore[call-overload,no-any-return]
+3 -1
src/pyoctaveband/core.py
··· 278 278 if sigbands and xb is not None: 279 279 xb = [band[0] for band in xb] 280 280 281 - freq_out = self.nominal_freq if nominal else self.freq 281 + # Return a copy: the bank (possibly shared via the octavefilter() 282 + # design cache) must not be corrupted by callers mutating the list. 283 + freq_out: List[float] | List[str] = list(self.nominal_freq) if nominal else list(self.freq) 282 284 283 285 if sigbands and xb is not None: 284 286 return spl, freq_out, xb
+7 -1
src/pyoctaveband/filter_design.py
··· 7 7 8 8 from typing import List, Tuple 9 9 10 - import matplotlib.pyplot as plt 11 10 import numpy as np 12 11 from scipy import signal 13 12 ··· 152 151 :param show: If True, show the plot. 153 152 :param plot_file: Path to save the plot. 154 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 pyoctaveband[plot]" 159 + ) from exc 160 + 155 161 wn = 8192 156 162 w = np.zeros([wn, len(freq)]) 157 163 h: np.ndarray = np.zeros([wn, len(freq)], dtype=np.complex128)
+4 -5
src/pyoctaveband/frequencies.py
··· 30 30 fr = 1000 31 31 32 32 x = _initindex(limits[0], fr, g, fraction) 33 - freq = np.array([_ratio(g, x, fraction) * fr]) 33 + freq_list = [_ratio(g, x, fraction) * fr] 34 34 35 - freq_x = freq[0] 36 - while freq_x * _bandedge(g, fraction) < limits[1]: 35 + while freq_list[-1] * _bandedge(g, fraction) < limits[1]: 37 36 x += 1 38 - freq_x = _ratio(g, x, fraction) * fr 39 - freq = np.append(freq, freq_x) 37 + freq_list.append(_ratio(g, x, fraction) * fr) 38 + freq = np.array(freq_list) 40 39 41 40 freq_d = freq / _bandedge(g, fraction) 42 41 freq_u = freq * _bandedge(g, fraction)
+16 -7
src/pyoctaveband/parametric_filters.py
··· 10 10 from typing import List, Tuple, cast 11 11 12 12 import numpy as np 13 - from numba import jit 14 13 from scipy import signal 15 14 16 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 + _numba_jit = None 17 21 18 22 19 23 class WeightingFilter: ··· 207 211 ) from exc 208 212 209 213 210 - @jit(nopython=True, cache=True) # type: ignore 211 - def _apply_impulse_kernel( 214 + def _impulse_kernel_py( 212 215 x_t: np.ndarray, 213 216 alpha_rise: float, 214 217 alpha_fall: float, 215 218 initial_state: np.ndarray, 216 219 ) -> np.ndarray: 217 - """Numba-optimized kernel for asymmetric time weighting.""" 220 + """Asymmetric time-weighting kernel (pure Python; jitted when numba is present).""" 218 221 y_t = np.zeros_like(x_t) 219 222 curr_y = initial_state.copy() 220 - 223 + 221 224 for i in range(x_t.shape[0]): 222 225 val = x_t[i] 223 226 rising = val > curr_y 224 - 227 + 225 228 diff = val - curr_y 226 229 factor = np.where(rising, alpha_rise, alpha_fall) 227 230 curr_y += factor * diff 228 231 y_t[i] = curr_y 229 - 232 + 230 233 return y_t 234 + 235 + 236 + if _numba_jit is not None: 237 + _apply_impulse_kernel = _numba_jit(nopython=True, cache=True)(_impulse_kernel_py) 238 + else: # pragma: no cover - exercised only without numba installed 239 + _apply_impulse_kernel = _impulse_kernel_py 231 240 232 241 def time_weighting( 233 242 x: List[float] | np.ndarray,
+5 -1
src/pyoctaveband/utils.py
··· 35 35 :param target_length: Target length. 36 36 :return: Resampled signal. 37 37 """ 38 - y_resampled = cast(np.ndarray, signal.resample_poly(y, factor, 1, axis=-1)) 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)) 39 43 current_length = y_resampled.shape[-1] 40 44 41 45 if current_length > target_length: