Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "PyOctaveBand"
version = "1.1.5"
version = "1.2.0"
authors = [
{ name="Jose Manuel Requena Plens", email="jmrplens@gmail.com" },
]
Expand Down
2 changes: 2 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ sonar.python.coverage.reportPaths=coverage.xml
# Exclusions
sonar.exclusions=**/__pycache__/**, **/*.png, **/*.md
sonar.test.inclusions=tests/**/test_*.py
# Exclude overload-heavy type-stub files from duplication analysis
sonar.cpd.exclusions=src/pyoctaveband/__init__.py,src/pyoctaveband/core.py

# Increase authorized parameters for scientific APIs
sonar.issue.ignore.multicriteria=S107
Expand Down
60 changes: 53 additions & 7 deletions src/pyoctaveband/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
# Use non-interactive backend for plots
matplotlib.use("Agg")

__version__ = "1.1.4"
__version__ = "1.2.0"

# Public methods
__all__ = [
Expand Down Expand Up @@ -52,6 +52,7 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float]]: ...


Expand All @@ -72,9 +73,52 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...


@overload
def octavefilter(
x: List[float] | np.ndarray,
fs: int,
fraction: float = 1,
order: int = 6,
limits: List[float] | None = None,
show: bool = False,
sigbands: Literal[False] = False,
plot_file: str | None = None,
detrend: bool = True,
filter_type: str = "butter",
ripple: float = 0.1,
attenuation: float = 60.0,
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str]]: ...


@overload
def octavefilter(
x: List[float] | np.ndarray,
fs: int,
fraction: float = 1,
order: int = 6,
limits: List[float] | None = None,
show: bool = False,
sigbands: Literal[True] = True,
plot_file: str | None = None,
detrend: bool = True,
filter_type: str = "butter",
ripple: float = 0.1,
attenuation: float = 60.0,
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...


def octavefilter(
x: List[float] | np.ndarray,
fs: int,
Expand All @@ -91,7 +135,8 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
nominal: bool = False,
) -> 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]]:
"""
Filter a signal with octave or fractional octave filter bank.

Expand Down Expand Up @@ -125,8 +170,12 @@ def octavefilter(
:param calibration_factor: Calibration factor for SPL calculation. Default: 1.0.
:param dbfs: If True, return results in dBFS. Default: False.
:param mode: 'rms' or 'peak'. Default: 'rms'.
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
:rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[float], List[np.ndarray]]]
When *nominal=True*, the frequency list contains ``List[str]`` labels instead of floats.
:rtype: Union[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]]]
"""

# Use the class-based implementation
Expand All @@ -144,7 +193,4 @@ def octavefilter(
dbfs=dbfs
)

if sigbands:
return filter_bank.filter(x, sigbands=True, mode=mode, detrend=detrend)
else:
return filter_bank.filter(x, sigbands=False, mode=mode, detrend=detrend)
return filter_bank.filter(x, sigbands=sigbands, mode=mode, detrend=detrend, nominal=nominal) # type: ignore[call-overload,no-any-return]
62 changes: 57 additions & 5 deletions src/pyoctaveband/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def __init__(
self.stateful = stateful

# Generate frequencies
self.freq, self.freq_d, self.freq_u = _genfreqs(limits, fraction, fs)
self.freq, self.freq_d, self.freq_u, self.nominal_freq = _genfreqs(limits, fraction, fs)
self.num_bands = len(self.freq)


Expand Down Expand Up @@ -137,6 +137,7 @@ def filter(
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[True] = True,
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float]]: ...

@overload
Expand All @@ -147,6 +148,7 @@ def filter(
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[True] = True,
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...

@overload
Expand All @@ -157,6 +159,7 @@ def filter(
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[False] = False,
nominal: Literal[False] = False,
) -> Tuple[None, List[float]]: ...

@overload
Expand All @@ -167,24 +170,71 @@ def filter(
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[False] = False,
nominal: Literal[False] = False,
) -> Tuple[None, List[float], List[np.ndarray]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
sigbands: Literal[False] = False,
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[True] = True,
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
sigbands: Literal[True],
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[True] = True,
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
sigbands: Literal[False] = False,
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[False] = False,
nominal: Literal[True] = ...,
) -> Tuple[None, List[str]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
sigbands: Literal[True],
mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[False] = False,
nominal: Literal[True] = ...,
) -> Tuple[None, List[str], List[np.ndarray]]: ...

def filter(
self,
x: List[float] | np.ndarray,
sigbands: bool = False,
mode: str = "rms",
detrend: bool = True,
calculate_level: bool = True,
) -> Tuple[np.ndarray | None, List[float]] | Tuple[np.ndarray | None, List[float], List[np.ndarray]]:
nominal: bool = False,
) -> Tuple[np.ndarray | None, List[float] | List[str]] | Tuple[np.ndarray | None, List[float] | List[str], List[np.ndarray]]:
"""
Apply the pre-designed filter bank to a signal.

:param x: Input signal (1D array or 2D array [channels, samples]).
:param sigbands: If True, also return the signal in the time domain divided into bands.
:param mode: 'rms' for energy-based level, 'peak' for peak-holding level.
:param detrend: If True, remove DC offset from signal before filtering (Default: True).
:param calculate_level: If True, calculate SPL
:param calculate_level: If True, calculate SPL.
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
"""

Expand Down Expand Up @@ -220,10 +270,12 @@ def filter(
if sigbands and xb is not None:
xb = [band[0] for band in xb]

freq_out = self.nominal_freq if nominal else self.freq

if sigbands and xb is not None:
return spl, self.freq, xb
return spl, freq_out, xb
else:
return spl, self.freq
return spl, freq_out

def _process_bands(
self,
Expand Down
59 changes: 52 additions & 7 deletions src/pyoctaveband/frequencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import warnings
from functools import lru_cache
from typing import List, Tuple

import numpy as np
Expand All @@ -14,13 +15,13 @@
def getansifrequencies(
fraction: float,
limits: List[float] | None = None,
) -> Tuple[List[float], List[float], List[float]]:
) -> Tuple[List[float], List[float], List[float], List[str]]:
"""
Calculate frequencies according to ANSI/IEC standards.

:param fraction: Bandwidth fraction (e.g., 1, 3).
:param limits: [f_min, f_max] limits.
:return: Tuple of (center_freqs, lower_edges, upper_edges).
:return: Tuple of (center_freqs, lower_edges, upper_edges, nominal_labels).
"""
Comment on lines 15 to 25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat the 4-tuple return as a versioned API break.

This changes a public unpacking contract. Please pair it with the planned 1.2.0 runtime/package version bump before release; otherwise existing callers still on 1.1.x will fail with too many values to unpack.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pyoctaveband/frequencies.py` around lines 14 - 24, The public API of
getansifrequencies was changed to return a 4-tuple which breaks existing
callers; update the package/runtime version to 1.2.0 to signal the breaking
change and include a clear changelog entry and release note referencing
getansifrequencies so users are aware of the new return shape, or alternatively
restore backward compatibility by providing a wrapper overload that returns a
3-tuple for older callers and mark it deprecated. Ensure the version bump (to
1.2.0), changelog entry, and any deprecation comment reference
getansifrequencies and the new 4-tuple return.

if limits is None:
limits = [12, 20000]
Expand All @@ -40,7 +41,8 @@ def getansifrequencies(
freq_d = freq / _bandedge(g, fraction)
freq_u = freq * _bandedge(g, fraction)

return freq.tolist(), freq_d.tolist(), freq_u.tolist()
labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq.tolist()]
return freq.tolist(), freq_d.tolist(), freq_u.tolist(), labels


def _initindex(f: float, fr: float, g: float, b: float) -> int:
Expand Down Expand Up @@ -109,17 +111,60 @@ def _deleteouters(
return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist()


def _genfreqs(limits: List[float], fraction: float, fs: int) -> Tuple[List[float], List[float], List[float]]:
def _genfreqs(
limits: List[float], fraction: float, fs: int
) -> Tuple[List[float], List[float], List[float], List[str]]:
"""
Determine band frequencies within limits.

:param limits: [f_min, f_max].
:param fraction: Bandwidth fraction.
:param fs: Sample rate.
:return: Tuple of center, lower, and upper frequencies.
:return: Tuple of center, lower, upper frequencies, and nominal labels.
"""
freq, freq_d, freq_u, labels = getansifrequencies(fraction, limits)
freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, fs)
# _deleteouters only removes trailing bands above Nyquist, so slice labels
labels = labels[: len(freq)]
return freq, freq_d, freq_u, labels


def _iec_e3_round(f: float) -> float:
"""IEC 61260-1 Annex E.3: 3 sig figs if MSD 1–4, 2 sig figs if MSD 5–9."""
if f <= 0:
return f
exponent = int(np.floor(np.log10(f)))
msd = f / (10.0 ** exponent)
step = 10.0 ** (exponent - 2) if msd < 5.0 else 10.0 ** (exponent - 1)
return round(f / step) * step


@lru_cache(maxsize=4)
def _extended_preferred(frac: int) -> List[float]:
"""Cached expansion of the IEC preferred frequency table across decades."""
base = normalizedfreq(frac)
return [f * (10 ** d) for d in range(-3, 4) for f in base]


def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float:
"""Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.

For standard fractions (1, 3), snaps to the IEC preferred table via
``normalizedfreq``. For non-standard fractions, falls back to Annex E.3
significant-figure rounding (``_iec_e3_round``).
"""
freq, freq_d, freq_u = getansifrequencies(fraction, limits)
return _deleteouters(freq, freq_d, freq_u, fs)
frac = round(fraction)
if np.isclose(fraction, frac) and frac in (1, 3):
extended = _extended_preferred(frac)
return min(extended, key=lambda f: abs(np.log(f / exact_freq)))
return _iec_e3_round(exact_freq)
Comment on lines +149 to +160
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The extended list of preferred frequencies is re-calculated for every frequency band inside getansifrequencies. This is inefficient. You can cache this list as it only depends on the fraction. Here is a suggestion using a function attribute as a simple cache, which avoids re-computation without needing module-level variables or new imports.

Suggested change
def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float:
"""Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.
For standard fractions (1, 3), snaps to the IEC preferred table via
``normalizedfreq``. For non-standard fractions, falls back to Annex E.3
significant-figure rounding (``_iec_e3_round``).
"""
freq, freq_d, freq_u = getansifrequencies(fraction, limits)
return _deleteouters(freq, freq_d, freq_u, fs)
frac = round(fraction)
if frac in (1, 3):
base = normalizedfreq(frac)
extended: List[float] = [f * (10 ** d) for d in range(-3, 4) for f in base]
return min(extended, key=lambda f: abs(np.log(f / exact_freq)))
return _iec_e3_round(exact_freq)
def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float:
"""Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.
For standard fractions (1, 3), snaps to the IEC preferred table via
``normalizedfreq``. For non-standard fractions, falls back to Annex E.3
significant-figure rounding (``_iec_e3_round``).
"""
frac = round(fraction)
if frac in (1, 3):
if not hasattr(_nominal_freq_for_band, "_cache"):
_nominal_freq_for_band._cache = {} # type: ignore
if frac not in _nominal_freq_for_band._cache:
base = normalizedfreq(frac)
_nominal_freq_for_band._cache[frac] = [f * (10 ** d) for d in range(-3, 4) for f in base]
extended = _nominal_freq_for_band._cache[frac]
return min(extended, key=lambda f: abs(np.log(f / exact_freq)))
return _iec_e3_round(exact_freq)

Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _format_nominal_freq(f: float) -> str:
"""Format a nominal frequency as a human-readable label string."""
if f >= 1000:
return f"{f / 1000:g}k"
return f"{f:g}"


def normalizedfreq(fraction: int) -> List[float]:
Expand Down
4 changes: 3 additions & 1 deletion tests/test_coverage_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,10 @@ def test_octavefilter_limits_none():
spl, freq = octavefilter(np.random.randn(1000), 1000, limits=None)
assert len(spl) > 0
# Also directly call it
f1, f2, f3 = getansifrequencies(1, limits=None)
f1, f2, f3, labels = getansifrequencies(1, limits=None)
assert len(f1) > 0
assert len(f1) == len(f2) == len(f3) == len(labels)
assert all(isinstance(label, str) for label in labels)

def test_calculate_level_invalid():
from pyoctaveband.core import OctaveFilterBank
Expand Down
Loading