Skip to content

Commit 54dea44

Browse files
authored
Add configurable time weighting initial state (#49)
* Add time weighting initial state support * Address PR review feedback * Update runtime and dev dependencies * Add Dependabot update configuration * Resolve IDE warnings * Remove generated issue report artifacts * Address PR feedback and improve coverage
1 parent bc50b21 commit 54dea44

21 files changed

Lines changed: 529 additions & 252 deletions

.github/dependabot.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "github-actions"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
day: "monday"
8+
time: "06:00"
9+
timezone: "Etc/UTC"
10+
labels:
11+
- "dependencies"
12+
- "github-actions"
13+
groups:
14+
github-actions:
15+
patterns:
16+
- "*"
17+
18+
- package-ecosystem: "pip"
19+
directory: "/"
20+
schedule:
21+
interval: "weekly"
22+
day: "monday"
23+
time: "06:30"
24+
timezone: "Etc/UTC"
25+
labels:
26+
- "dependencies"
27+
- "python"
28+
open-pull-requests-limit: 5
29+
versioning-strategy: "increase"
30+
ignore:
31+
- dependency-name: "*"
32+
update-types:
33+
- "version-update:semver-minor"
34+
- "version-update:semver-patch"

.github/workflows/release.yml

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,20 @@ name: Publish to PyPI and Release
22

33
on:
44
push:
5-
branches:
6-
- main
7-
paths-ignore:
8-
- 'README.md'
9-
- '.github/**'
10-
workflow_dispatch:
5+
tags:
6+
- "v*"
117

128
permissions:
139
contents: write
1410

1511
jobs:
1612
release:
1713
runs-on: ubuntu-latest
18-
if: github.repository == 'jmrplens/PyOctaveBand' && !contains(github.event.head_commit.message, 'skip ci')
14+
if: github.repository == 'jmrplens/PyOctaveBand'
1915
steps:
2016
- uses: actions/checkout@v4
2117
with:
2218
fetch-depth: 0
23-
token: ${{ secrets.TOKEN_GH }}
2419

2520
- name: Set up Python
2621
uses: actions/setup-python@v5
@@ -32,41 +27,42 @@ jobs:
3227
python -m pip install --upgrade pip
3328
pip install build twine
3429
35-
- name: Bump version
36-
id: bump
30+
- name: Validate tag version
31+
id: version
3732
run: |
38-
# Read current version from pyproject.toml
39-
# Look for 'version =' specifically in the [project] section or at line start
40-
CURRENT_VERSION=$(grep -m 1 -oP '^version\s*=\s*"\K[^"]+' pyproject.toml)
41-
echo "Current version: $CURRENT_VERSION"
42-
43-
# Increment patch version
44-
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
45-
NEW_PATCH=$((patch + 1))
46-
NEW_VERSION="$major.$minor.$NEW_PATCH"
47-
echo "New version: $NEW_VERSION"
48-
49-
# Update pyproject.toml
50-
sed -i "s/^version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" pyproject.toml
51-
52-
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
33+
PACKAGE_VERSION=$(python - <<'PY'
34+
import ast
35+
from pathlib import Path
5336
54-
- name: Commit and push changes
55-
run: |
56-
git config --global user.name "github-actions[bot]"
57-
git config --global user.email "github-actions[bot]@users.noreply.github.com"
58-
git add pyproject.toml
59-
git commit -m "chore: bump version to ${{ steps.bump.outputs.version }} [skip ci]"
60-
git push origin main
37+
version_file = Path("src/pyoctaveband/_version.py")
38+
module = ast.parse(version_file.read_text(encoding="utf-8"))
6139
62-
- name: Create Git Tag
63-
run: |
64-
git tag v${{ steps.bump.outputs.version }}
65-
git push origin v${{ steps.bump.outputs.version }}
40+
for node in module.body:
41+
if isinstance(node, ast.Assign):
42+
for target in node.targets:
43+
if isinstance(target, ast.Name) and target.id == "__version__":
44+
print(ast.literal_eval(node.value))
45+
raise SystemExit(0)
46+
47+
raise SystemExit("Unable to find __version__ in src/pyoctaveband/_version.py")
48+
PY
49+
)
50+
TAG_VERSION="${GITHUB_REF_NAME#v}"
51+
52+
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
53+
echo "::error::Tag ${GITHUB_REF_NAME} does not match package version ${PACKAGE_VERSION}"
54+
exit 1
55+
fi
56+
57+
echo "version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
58+
echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
6659
6760
- name: Build package
6861
run: python -m build
6962

63+
- name: Check package
64+
run: python -m twine check dist/*
65+
7066
- name: Publish to PyPI
7167
env:
7268
TWINE_USERNAME: __token__
@@ -76,11 +72,11 @@ jobs:
7672
- name: Create GitHub Release
7773
uses: softprops/action-gh-release@v2
7874
with:
79-
tag_name: v${{ steps.bump.outputs.version }}
80-
name: Release v${{ steps.bump.outputs.version }}
75+
tag_name: ${{ steps.version.outputs.tag }}
76+
name: Release ${{ steps.version.outputs.tag }}
8177
body: |
82-
Automated release for version v${{ steps.bump.outputs.version }}
78+
Automated release for version ${{ steps.version.outputs.version }}
8379
draft: false
8480
prerelease: false
8581
env:
86-
GITHUB_TOKEN: ${{ secrets.TOKEN_GH }}
82+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,5 @@ coverage.xml
5555
*.wav
5656
tests/*.png
5757
snyk.sarif
58+
issues_final*.json
5859
node_modules/

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ All core functionality can be imported directly from the `pyoctaveband` package.
9090
| `octavefilter` | `function` | **High-level analysis.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `fraction`: 1, 3, etc. (Default: 1)<br>• `order`: Filter order (Default: 6)<br>• `limits`: [f_min, f_max] (Default: [12, 20000])<br>• `filter_type`: 'butter', 'cheby1', 'cheby2', 'ellip', 'bessel' (Default: 'butter')<br>• `sigbands`: Return time signals (Default: False)<br>• `detrend`: Remove DC offset (Default: True)<br>• `calibration_factor`: Sensitivity multiplier (Default: 1.0)<br>• `dbfs`: Output in dBFS instead of dB SPL (Default: False)<br>• `mode`: 'rms' or 'peak' (Default: 'rms')<br>• `show`: Plot response (Default: False)<br>• `plot_file`: Path to save plot (Default: None)<br>• `ripple`: Passband ripple [dB] (for cheby1/ellip)<br>• `attenuation`: Stopband atten. [dB] (for cheby2/ellip) | `spl, freq = octavefilter(x, fs, ...)`<br>• `spl`: levels [dB]<br>• `freq`: frequencies [Hz]<br><br>**With `sigbands=True`:**<br>`spl, freq, xb = octavefilter(x, fs, sigbands=True)`<br>• `xb`: List of filtered signals (one per band)<br><br>**Calibrated usage:**<br>`spl, f = octavefilter(x, fs, calibration_factor=0.05)` |
9191
| `OctaveFilterBank` | `class` | **Efficient bank implementation.**<br>• `fs`: Sample rate [Hz]<br>• `fraction`: 1, 3, etc.<br>• `order`: Filter order<br>• `limits`: [f_min, f_max] (Default: [12, 20000])<br>• `filter_type`: Architecture name<br>• `show`: Plot response (Default: False)<br>• `plot_file`: Path to save plot (Default: None)<br>• `calibration_factor`: Sensitivity multiplier<br>• `dbfs`: Use dBFS (Default: False)<br>• `ripple`: Passband ripple [dB]<br>• `attenuation`: Stopband attenuation [dB] | `bank = OctaveFilterBank(fs=48000, fraction=3, order=6, filter_type='butter', show=True)`<br>`spl, f = bank.filter(x, sigbands=False, mode='rms', detrend=True)`<br><br>• `bank`: Instance of the filter bank |
9292
| `weighting_filter` | `function` | **Acoustic weighting.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `curve`: 'A', 'C', or 'Z' (Default: 'A') | `y = weighting_filter(x, fs, curve='A')`<br><br>• `y`: 1D array of weighted signal |
93-
| `time_weighting` | `function` | **Energy capture.**<br>• `x`: Raw signal array (squared internally)<br>• `fs`: Sample rate [Hz]<br>• `mode`: 'fast', 'slow', or 'impulse' | `env = time_weighting(x, fs, mode='fast')`<br><br>• `env`: 1D array of energy envelope (Mean Square) |
93+
| `time_weighting` | `function` | **Energy capture.**<br>• `x`: Raw signal array (squared internally; time is the last axis)<br>• `fs`: Sample rate [Hz]<br>• `mode`: 'fast', 'slow', or 'impulse'<br>• `initial_state`: None, 'zero', 'first', scalar, or array (Default: None)<br>• `None`: use the default rest state (`y[-1] = 0`)<br>• `'zero'`: explicitly initialize `y[-1]` to zero<br>• scalar: broadcast to every channel<br>• array: must match/broadcast to `x.shape[:-1]`; for `x.shape == (n_channels, n_samples)`, use shape `(n_channels,)` | `env = time_weighting(x, fs, mode='fast', initial_state=None)`<br><br>• `env`: energy envelope (Mean Square), same shape as `x` |
9494
| `linkwitz_riley` | `function` | **Audio crossover.**<br>• `x`: Signal array<br>• `fs`: Sample rate [Hz]<br>• `freq`: Crossover frequency [Hz]<br>• `order`: Any even number (Default: 4) | `lo, hi = linkwitz_riley(x, fs, freq=1000, order=4)`<br><br>• `lo`: Low-pass filtered signal<br>• `hi`: High-pass filtered signal |
9595
| `calculate_sensitivity` | `function`| **SPL Calibration.**<br>• `ref_signal`: Calibration signal<br>• `target_spl`: Level of calibrator (Default: 94.0)<br>• `ref_pressure`: Reference pressure (Default: 20e-6) | `s = calculate_sensitivity(ref_signal, target_spl=94.0)`<br><br>• `s`: Float (multiplier for pressure) |
9696
| `getansifrequencies` | `function` | **ANSI Frequency generator.**<br>• `fraction`: 1, 3, etc. (Required)<br>• `limits`: [f_min, f_max] (Default: [12, 20000]) | `f_cen, f_low, f_high, labels = getansifrequencies(fraction=3)`<br><br>• `f_cen`: List of center frequencies [Hz]<br>• `f_low`: List of lower edges [Hz]<br>• `f_high`: List of upper edges [Hz]<br>• `labels`: IEC nominal frequency labels |
@@ -244,6 +244,24 @@ energy_envelope = time_weighting(signal, fs, mode='fast')
244244
spl_t = 10 * np.log10(energy_envelope / (2e-5)**2)
245245
```
246246

247+
By default, the exponential integrator starts from rest (`y[-1] = 0`). Passing `initial_state=None` leaves this default unspecified, while `initial_state='zero'` requests the same zero state explicitly. If the recorded segment begins after a steady signal is already present, you can start from the first sample energy instead:
248+
249+
```python
250+
energy_envelope = time_weighting(signal, fs, mode='fast', initial_state='first')
251+
```
252+
253+
For block processing, pass the last output value from the previous block as the next block's `initial_state` instead of resetting each block:
254+
255+
```python
256+
state = None
257+
258+
for block in audio_blocks:
259+
energy_envelope = time_weighting(block, fs, mode='fast', initial_state=state)
260+
state = energy_envelope[-1]
261+
```
262+
263+
For multichannel blocks with time on the last axis, carry one state per channel: use `state = energy_envelope[..., -1]`. A scalar `initial_state` is applied to every channel, while an array must match or broadcast to the non-time shape, such as `(n_channels,)` for input shaped `(n_channels, n_samples)`.
264+
247265
---
248266

249267
## ⚡ Performance: Multichannel & Vectorization
@@ -564,6 +582,8 @@ $$
564582

565583
Where `tau` is the time constant (e.g., 125ms for Fast).
566584

585+
The default initial condition is `y[-1] = 0`. Use `initial_state='first'` to start from the first input energy, or pass a scalar/array with the previous mean-square output state.
586+
567587
---
568588

569589
## 🧪 Development and Verification

issues_final.json

Lines changed: 0 additions & 1 deletion
This file was deleted.

issues_final_check.json

Lines changed: 0 additions & 1 deletion
This file was deleted.

pyproject.toml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "PyOctaveBand"
7-
version = "1.2.1"
7+
dynamic = ["version"]
88
authors = [
99
{ name="Jose Manuel Requena Plens", email="jmrplens@gmail.com" },
1010
]
@@ -17,10 +17,10 @@ classifiers = [
1717
"Operating System :: OS Independent",
1818
]
1919
dependencies = [
20-
"numpy",
21-
"scipy",
22-
"matplotlib",
23-
"numba",
20+
"numpy>=2.4.4",
21+
"scipy>=1.17.1",
22+
"matplotlib>=3.10.9",
23+
"numba>=0.65.1",
2424
]
2525

2626
[project.urls]
@@ -30,6 +30,9 @@ dependencies = [
3030
[tool.setuptools.packages.find]
3131
where = ["src"]
3232

33+
[tool.setuptools.dynamic]
34+
version = {attr = "pyoctaveband._version.__version__"}
35+
3336
[tool.mypy]
3437
python_version = "3.11"
3538
strict = true

requirements-dev.txt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
ruff
2-
mypy
3-
bandit
4-
types-setuptools
5-
pytest
6-
pytest-cov
1+
ruff>=0.15.12
2+
mypy>=2.0.0
3+
bandit>=1.9.4
4+
types-setuptools>=82.0.0.20260508
5+
pytest>=9.0.3
6+
pytest-cov>=7.1.0

requirements.txt

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
contourpy>=1.2.0
1+
contourpy>=1.3.3
22
cycler>=0.12.1
3-
fonttools>=4.50.0
4-
kiwisolver>=1.4.5
5-
matplotlib>=3.8.0
6-
numpy>=1.26.0
7-
packaging>=23.0
8-
pillow>=10.0.0
9-
pyparsing>=3.1.0
10-
python-dateutil>=2.9.0
11-
scipy>=1.10.0
12-
six>=1.16.0
13-
numba==0.63.1
3+
fonttools>=4.62.1
4+
kiwisolver>=1.5.0
5+
matplotlib>=3.10.9
6+
numpy>=2.4.4
7+
packaging>=26.2
8+
pillow>=12.2.0
9+
pyparsing>=3.3.2
10+
python-dateutil>=2.9.0.post0
11+
scipy>=1.17.1
12+
six>=1.17.0
13+
numba==0.65.1

src/pyoctaveband/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
from .core import OctaveFilterBank
1616
from .frequencies import getansifrequencies, normalizedfreq
1717
from .parametric_filters import WeightingFilter, linkwitz_riley, time_weighting, weighting_filter
18+
from ._version import __version__
1819

1920
# Use non-interactive backend for plots
2021
matplotlib.use("Agg")
2122

22-
__version__ = "1.2.0"
23-
2423
# Public methods
2524
__all__ = [
25+
"__version__",
2626
"octavefilter",
2727
"getansifrequencies",
2828
"normalizedfreq",
@@ -37,7 +37,7 @@
3737

3838
@overload
3939
def octavefilter(
40-
x: List[float] | np.ndarray,
40+
x: List[float] | np.ndarray, # NOSONAR - public API
4141
fs: int,
4242
fraction: float = 1,
4343
order: int = 6,
@@ -58,7 +58,7 @@ def octavefilter(
5858

5959
@overload
6060
def octavefilter(
61-
x: List[float] | np.ndarray,
61+
x: List[float] | np.ndarray, # NOSONAR - public API
6262
fs: int,
6363
fraction: float = 1,
6464
order: int = 6,
@@ -79,7 +79,7 @@ def octavefilter(
7979

8080
@overload
8181
def octavefilter(
82-
x: List[float] | np.ndarray,
82+
x: List[float] | np.ndarray, # NOSONAR - public API
8383
fs: int,
8484
fraction: float = 1,
8585
order: int = 6,
@@ -100,7 +100,7 @@ def octavefilter(
100100

101101
@overload
102102
def octavefilter(
103-
x: List[float] | np.ndarray,
103+
x: List[float] | np.ndarray, # NOSONAR - public API
104104
fs: int,
105105
fraction: float = 1,
106106
order: int = 6,
@@ -120,7 +120,7 @@ def octavefilter(
120120

121121

122122
def octavefilter(
123-
x: List[float] | np.ndarray,
123+
x: List[float] | np.ndarray, # NOSONAR - public API
124124
fs: int,
125125
fraction: float = 1,
126126
order: int = 6,

0 commit comments

Comments
 (0)