Skip to content

Add stateful block-wise processing to OctaveFilterBank and WeightingFilter #42

Merged
jmrplens merged 9 commits intojmrplens:mainfrom
ninoblumer:feature/stateful-filter
Mar 8, 2026
Merged

Add stateful block-wise processing to OctaveFilterBank and WeightingFilter #42
jmrplens merged 9 commits intojmrplens:mainfrom
ninoblumer:feature/stateful-filter

Conversation

@ninoblumer
Copy link
Contributor

@ninoblumer ninoblumer commented Mar 2, 2026

This PR introduces stateful block-wise processing to OctaveFilterBank and WeightingFilter, allowing filters to maintain their internal states between successive calls. This enables efficient streaming or block-based processing without recalculating the entire signal each time.

Changes:

  • Added stateful and steady_ic flags to OctaveFilterBank.__init__
    • stateful=True → carry filter states across calls
    • steady_ic=True → initialize states using sosfilt_zi for steady-state conditions, not really necessary, could also be removed.
  • Modified _filter_and_resample() to propagate zi when stateful=True
  • Added tests demonstrating block-wise processing produces results equivalent to full-signal processing
  • Maintained strict typing with overloads in filter method

Example usage:

import soundfile as sf
from pyoctaveband import OctaveFilterBank, WeightingFilter

fs = 48000
octavefilter = OctaveFilterBank(fs, 1, stateful=True, resample=False)
afilter = WeightingFilter(fs, "A", stateful=True)

for block in sf.blocks("measurement.wav", blocksize=256, overlap=0):

    # Apply A-filter
    weighted = afilter.filter(block)

    # Split into octave bands
    block_spl, _, block_output = octavefilter.filter(weighted, sigbands=True, detrend=False)

    # further signal processing
    ...

Tests included:

  • test_stateful_steady_ic_initialization → covers sosfilt_zi initialization
  • test_block_processing_various_block_sizes → compares block-wise vs full-signal outputs
  • Parametrized tests for multiple block sizes and sample rates

Notes:

  • resample=True and stateful=True are currently not supported together. Raising an error if attempted. Reason is, that the resampler itself is not stateful.

Future work:

  • We may make the time weighting (exponential averages) stateful. Either by introducing a class that handles the state or by returning and accepting zi from time_weighting()
  • We may try to make resampling work with stateful resampler.

Summary by Sourcery

Introduce stateful, block-wise processing support for octave filter banks and weighting filters, including optional steady-state initialization and the ability to skip SPL calculation when only band signals are needed.

New Features:

  • Add optional stateful block-processing mode with persistent filter state to OctaveFilterBank and WeightingFilter.
  • Allow configuring steady-state initial conditions for stateful filters via a steady_ic parameter.
  • Add an option to disable resampling in OctaveFilterBank and support using the filter bank purely as a stateful band-splitting front-end.
  • Introduce a calculate_level flag to OctaveFilterBank.filter to return band signals without computing SPL levels.

Enhancements:

  • Guard against incompatible use of resampling with stateful block processing in OctaveFilterBank.
  • Warn when detrending is requested in combination with stateful block processing.

Documentation:

  • Document block-wise processing usage for OctaveFilterBank and WeightingFilter, including constraints around detrending and resampling, and provide an example streaming workflow in the README.

Tests:

  • Add tests verifying that stateful block-wise processing for OctaveFilterBank and WeightingFilter matches full-signal processing across multiple block sizes and curves.
  • Add tests covering steady-state initialization of internal filter state for both OctaveFilterBank and WeightingFilter.
  • Add tests ensuring resample and stateful usage raises an error, detrend with stateful usage raises a warning, and calculate_level=False skips SPL computation.

Summary by CodeRabbit

  • New Features
    • Stateful block-processing for octave and weighting filters, steady-state initial-condition option, ability to disable resampling, and option to skip SPL level calculation during filtering.
  • Bug Fixes / Safety
    • Error raised for incompatible stateful+resample selection; detrending with stateful block processing emits a warning.
  • Documentation
    • Added "Block processing" docs with runnable examples.
  • Tests
    • New tests for block vs full-signal parity, steady ICs, warnings, and option conflicts.

@sourcery-ai
Copy link

sourcery-ai bot commented Mar 2, 2026

Reviewer's Guide

Implements stateful, block-wise processing support for OctaveFilterBank and WeightingFilter, including persistent SOS filter state handling, optional steady-state initialization, an option to skip SPL computation, documentation updates, and tests validating equivalence to full-signal processing and enforcing unsupported combinations.

Sequence diagram for stateful block-wise processing pipeline

sequenceDiagram
    actor User
    participant SF as SoundFile
    participant WF as WeightingFilter
    participant OFB as OctaveFilterBank

    User->>WF: WeightingFilter(fs, curve="A", stateful=True, steady_ic=False)
    User->>OFB: OctaveFilterBank(fs, fraction=1, stateful=True, resample=False)

    User->>SF: blocks("measurement.wav", blocksize=256, overlap=0)
    loop for each block
        SF-->>User: block
        User->>WF: filter(block)
        alt stateful weighting filter
            WF->>WF: sosfilt(sos, block, zi)
            WF->>WF: update zi
        end
        WF-->>User: weighted

        User->>OFB: filter(weighted, sigbands=True, detrend=False, calculate_level=True)
        OFB->>OFB: _process_bands(x_proc, num_channels, sigbands, mode, calculate_level)
        loop for each band idx
            OFB->>OFB: _filter_and_resample(x_proc, idx)
            alt stateful octave filter bank
                OFB->>OFB: sosfilt(sos[idx], sd, zi[idx])
                OFB->>OFB: update zi[idx]
            else stateless
                OFB->>OFB: sosfilt(sos[idx], sd)
            end
            OFB->>OFB: _calculate_level(filtered_signal, mode)
        end
        OFB-->>User: block_spl, freq, block_output
    end
Loading

Class diagram for updated OctaveFilterBank and WeightingFilter

classDiagram
    class OctaveFilterBank {
        +int fs
        +int fraction
        +int order
        +str filter_type
        +str limits
        +bool stateful
        +bool resample
        +int num_bands
        +List[int] factor
        +List[np.ndarray] sos
        +List[np.ndarray] zi
        +OctaveFilterBank(int fs, int fraction, int order, str filter_type, str limits, bool stateful, bool steady_ic, bool resample, bool show, str plot_file, float calibration_factor, bool dbfs)
        +filter(List[float] x, bool sigbands, str mode, bool detrend, bool calculate_level)
        +_process_bands(np.ndarray x_proc, int num_channels, bool sigbands, str mode, bool calculate_level)
        +_filter_and_resample(np.ndarray x, int idx)
        +_calculate_level(np.ndarray y, str mode) float
    }

    class WeightingFilter {
        +int fs
        +str curve
        +bool stateful
        +np.ndarray sos
        +np.ndarray zi
        +WeightingFilter(int fs, str curve, bool stateful, bool steady_ic)
        +filter(List[float] x) np.ndarray
    }

    OctaveFilterBank "1" o-- "many" WeightingFilter : used_with_in_pipeline
Loading

File-Level Changes

Change Details Files
Add stateful, non-resampling-capable block-wise processing to OctaveFilterBank, including persistent filter state and optional level computation.
  • Extend constructor with stateful, steady_ic, and resample flags, validate fs/filter_type, and forbid using resample together with stateful via a ValueError.
  • Conditionally compute downsampling factors only when resample is enabled; otherwise set factor to 1 for all bands.
  • Allocate and optionally initialize per-band SOS filter state zi either as zeros or using scipy.signal.sosfilt_zi with the extra axis required for axis=-1 filtering.
  • Extend filter method signature and overloads to accept calculate_level, and document detrend and calculate_level behavior.
  • Block detrend usage when stateful by raising a Warning, since detrending per block breaks continuity.
  • Update _process_bands to optionally skip SPL calculation when calculate_level is False and to return None for spl in that case while still computing sigbands output.
  • Modify _filter_and_resample to reuse and update persistent SOS filter state zi when stateful, falling back to stateless sosfilt otherwise.
src/pyoctaveband/core.py
Document and test block-wise processing behavior, including non-level use cases and the resample/stateful restriction.
  • Add README section describing block processing with OctaveFilterBank and WeightingFilter, including required flags (stateful=True, resample=False, detrend=False) and an example loop over file blocks.
  • Add test ensuring _process_bands can be called with calculate_level=False while still returning band signals.
  • Add test verifying resample=True and stateful=True combination raises ValueError.
  • Add test verifying stateful + steady_ic initialization creates per-band zi arrays with expected 3D shape matching SOS sections.
  • Add test verifying a Warning is raised when calling filter with detrend=True on a stateful OctaveFilterBank.
README.md
tests/test_coverage_fix.py
tests/test_stateful_octave_filter_bank.py
Add stateful block-wise processing support to WeightingFilter with optional steady-state initialization and tests for equivalence to stateless processing.
  • Extend WeightingFilter constructor to accept stateful and steady_ic flags, storing stateful on the instance and handling the Z-curve SOS-less case with an empty zi when stateful.
  • Initialize SOS filter state zi either to zeros or via scipy.signal.sosfilt_zi when stateful and curve is not Z.
  • Update filter method to call scipy.signal.sosfilt with or without zi based on stateful, updating the stored zi for subsequent calls.
  • Add parametrized tests comparing stateful block-wise filtering to single-pass filtering for A, C, and Z curves, and verifying steady_ic initialization shape.
src/pyoctaveband/parametric_filters.py
tests/test_stateful_weighting_filter.py

Possibly linked issues

  • #(no number provided): PR directly implements the requested optional stateful sosfilt mode for block/stream processing, plus tests and documentation.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the pyoctaveband library by enabling OctaveFilterBank and WeightingFilter to perform stateful block-wise processing. This feature is crucial for applications requiring real-time or chunk-based audio analysis, as it allows the filters to seamlessly process segments of a signal while preserving their internal memory. The changes ensure that the output remains consistent whether the signal is processed in blocks or as a whole, improving the library's utility for continuous data streams.

Highlights

  • Stateful Block-Wise Processing: Introduced stateful block-wise processing for OctaveFilterBank and WeightingFilter, allowing filters to maintain internal states across successive calls for efficient streaming or block-based signal processing.
  • New Initialization Parameters: Added stateful and steady_ic boolean parameters to the constructors of both OctaveFilterBank and WeightingFilter to control state persistence and initial condition calculation (using sosfilt_zi).
  • Resampling and Detrending Restrictions: Implemented checks to prevent simultaneous use of resample=True and stateful=True in OctaveFilterBank, and added a warning when detrend=True is used with a stateful filter.
  • Filter Method Enhancements: Modified the filter method in OctaveFilterBank to include calculate_level parameter and added new overloads for strict typing, allowing users to optionally skip SPL calculation.
  • Comprehensive Testing: Added extensive new test cases to verify that block-wise processing produces results equivalent to full-signal processing, and to ensure correct initialization of steady-state initial conditions.
Changelog
  • README.md
    • Updated documentation to include a new section on block processing with an example demonstrating stateful OctaveFilterBank and WeightingFilter usage.
    • Added notes on restrictions for stateful OctaveFilterBank, specifically regarding detrend=False and resample=False.
  • src/pyoctaveband/core.py
    • Extended OctaveFilterBank.__init__ with stateful, steady_ic, and resample parameters.
    • Implemented a ValueError check to prevent resample=True and stateful=True from being used simultaneously.
    • Modified self.factor calculation to respect the new resample parameter.
    • Added logic to calculate and store initial conditions (self.zi) based on stateful and steady_ic flags.
    • Added new overload definitions and a calculate_level parameter to the filter method.
    • Introduced a Warning if detrend=True is used with a stateful filter.
    • Updated _process_bands to conditionally calculate SPL based on the calculate_level parameter.
    • Modified _filter_and_resample to use signal.sosfilt with zi for stateful filtering, updating the internal state.
  • src/pyoctaveband/parametric_filters.py
    • Extended WeightingFilter.__init__ with stateful and steady_ic parameters.
    • Initialized self.zi for 'Z' curve filters when stateful is True.
    • Added logic to calculate and store initial conditions (self.zi) for WeightingFilter based on stateful and steady_ic flags.
    • Modified the filter method to use signal.sosfilt with zi for stateful filtering, updating the internal state.
  • tests/test_coverage_fix.py
    • Added test_dont_calculate_level to verify that _process_bands correctly returns None for SPL when calculate_level=False.
  • tests/test_stateful_octave_filter_bank.py
    • Added test_block_processing_matches_full_signal to compare block-wise processing against full-signal processing for OctaveFilterBank.
    • Added test_resample_and_stateful to ensure a ValueError is raised when resample=True and stateful=True are combined.
    • Added test_stateful_steady_ic_initialization to verify correct zi initialization for stateful OctaveFilterBank.
    • Added test_detrend_stateful_warning to confirm a Warning is raised when detrending a stateful filter.
  • tests/test_stateful_weighting_filter.py
    • Added test_weighting_filter_block_processing_matches_full_signal to compare block-wise processing against full-signal processing for WeightingFilter.
    • Added test_weighting_filter_steady_ic_initialization to verify correct zi initialization for stateful WeightingFilter.
Activity
  • The pull request was opened by ninoblumer to introduce stateful block-wise processing to OctaveFilterBank and WeightingFilter.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This PR introduces stateful block-wise processing to OctaveFilterBank and WeightingFilter, a valuable enhancement for streaming audio analysis. The implementation is well-structured and includes thorough tests to ensure correctness. My review focuses on improving the warning mechanism to prevent unintended program termination and enhancing the clarity of the documentation for the new feature. With these minor adjustments, this will be an excellent addition to the library.

Comment on lines +192 to +193
if self.stateful:
raise Warning("You should not detrend when doing block processing!")

Choose a reason for hiding this comment

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

high

Using raise Warning(...) will raise an exception and terminate the program flow, which is likely more disruptive than intended for a warning. The standard practice for issuing a warning without halting execution is to use the warnings module. This allows the user to be notified of the potential issue with detrending during block processing (which can cause discontinuities) without stopping their workflow.

Suggested change
if self.stateful:
raise Warning("You should not detrend when doing block processing!")
if self.stateful:
import warnings
warnings.warn(
"Detrending is not recommended for stateful block processing as it can "
"introduce discontinuities between blocks.",
UserWarning,
)

README.md Outdated
Create a stateful filter bank with `stateful=True`. The internal state is zero-initialized by default
but may be initialized for step-response steady-state (like `scipy.signal.sosfilt_zi`) with `steady_ic=True`.
Notes when using a stateful `OctaveFilterBank`:
- You should not remove the DC-content when block processing (`detrend=False`).

Choose a reason for hiding this comment

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

medium

The current phrasing is slightly ambiguous. It could be interpreted as "you should not detrend, which is done by setting detrend=False". Clarifying that detrend=False is the recommended setting would improve readability for users of this new feature.

Suggested change
- You should not remove the DC-content when block processing (`detrend=False`).
- Detrending should be disabled when block processing (`detrend=False`), as it can introduce discontinuities between blocks.

Comment on lines +87 to +88
with pytest.raises(Warning):
bank.filter(signal, detrend=True)

Choose a reason for hiding this comment

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

medium

To align with the suggested change of using warnings.warn instead of raise Warning for a non-halting warning, this test should be updated to use pytest.warns. This correctly tests for the presence of a warning without treating it as a program-halting exception.

Suggested change
with pytest.raises(Warning):
bank.filter(signal, detrend=True)
with pytest.warns(UserWarning, match="Detrending is not recommended"):
bank.filter(signal, detrend=True)

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 8 issues, and left some high level feedback:

  • The new calculate_level flag can cause spl to be None while filter() and its overloads still advertise np.ndarray in the return type; consider tightening the typing/overloads or hiding this flag in a private helper so the public API contract remains consistent.
  • Using raise Warning in filter() for the stateful+detrend case is unusual for runtime warnings; consider switching to warnings.warn(..., RuntimeWarning) (or a custom exception) so callers can manage it via the standard warnings or exception mechanisms.
  • The stateful OctaveFilterBank initial conditions are sized with a single channel dimension ((n_sections, 1, 2)), so multiple channels will share the same filter state; if per-channel independence is desired, you may want to allocate zi per channel or document that only mono input is supported for stateful mode.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `calculate_level` flag can cause `spl` to be `None` while `filter()` and its overloads still advertise `np.ndarray` in the return type; consider tightening the typing/overloads or hiding this flag in a private helper so the public API contract remains consistent.
- Using `raise Warning` in `filter()` for the stateful+detrend case is unusual for runtime warnings; consider switching to `warnings.warn(..., RuntimeWarning)` (or a custom exception) so callers can manage it via the standard warnings or exception mechanisms.
- The stateful `OctaveFilterBank` initial conditions are sized with a single channel dimension (`(n_sections, 1, 2)`), so multiple channels will share the same filter state; if per-channel independence is desired, you may want to allocate `zi` per channel or document that only mono input is supported for stateful mode.

## Individual Comments

### Comment 1
<location path="src/pyoctaveband/core.py" line_range="110-119" />
<code_context>
         )

+        # Calculate initial conditions for filter state
+        if self.stateful:
+            self.zi = [None for _ in range(self.num_bands)]
+            for idx in range(self.num_bands):
</code_context>
<issue_to_address>
**issue (bug_risk):** Raising `Warning` as an exception here is likely unintended and makes the default `detrend=True` unusable for stateful filters.

Using `raise Warning(...)` turns this into an exception, so `stateful=True` with the default `detrend=True` will always fail at runtime. If the intent is to discourage detrending with block processing, consider instead:
- changing the default to `detrend=False` when `stateful=True`,
- using `warnings.warn(...)`, or
- raising a more specific exception (e.g. `ValueError`) and documenting that `detrend` must be `False` for stateful processing.
The current behavior is a runtime trap and `Warning` is not typically used as an exception type.
</issue_to_address>

### Comment 2
<location path="src/pyoctaveband/core.py" line_range="174" />
<code_context>
         mode: str = "rms",
-        detrend: bool = True
+        detrend: bool = True,
+        calculate_level: bool =True
     ) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
         """
</code_context>
<issue_to_address>
**issue (bug_risk):** When `calculate_level=False`, `spl` becomes `None` but the public API and overloads still advertise a `np.ndarray` return type.

`_process_bands` sets `spl` to `None` when `calculate_level=False`, and that value is returned from `filter` as part of `(spl, self.freq, xb)`. Callers will expect `spl` to be an array based on the type hints and docstring, and your new overloads for `calculate_level: Literal[False]` still advertise `Tuple[np.ndarray, List[float]]` / `Tuple[np.ndarray, List[float], List[np.ndarray]]`. This contract/type mismatch can cause runtime errors and confuse static type checkers.

Please either ensure `spl` is always an array (e.g. zeros/NaNs when `calculate_level=False`), or update the `filter` signature and overloads so that `spl` can be `None` or omitted when `calculate_level=False` (potentially with a distinct overload for the bands-only case).
</issue_to_address>

### Comment 3
<location path="src/pyoctaveband/core.py" line_range="112-117" />
<code_context>
+        # Calculate initial conditions for filter state
+        if self.stateful:
+            self.zi = [None for _ in range(self.num_bands)]
+            for idx in range(self.num_bands):
+                if not steady_ic:
+                    self.zi[idx] = np.zeros((self.sos[idx].shape[0], 1, 2))
+                else:
+                    zi = signal.sosfilt_zi(self.sos[idx])
+                    self.zi[idx] = zi[:, np.newaxis, :] # add a dimension since we are filtering along an axis in a 2D-array
+
+
</code_context>
<issue_to_address>
**issue (bug_risk):** The shape of `zi` for stateful filtering likely does not match `sosfilt`’s expectations for multi-channel input.

For stateful runs, `self.zi[idx]` is created as `(n_sections, 1, 2)` or `(n_sections, channels, 2)` and then passed to `signal.sosfilt(..., axis=-1)`. However, `sosfilt` expects `zi` to be shaped `(n_sections, ...)` where `...` matches the input with the filter axis removed (e.g., `(n_sections, num_channels)` for a `(num_channels, num_samples)` input). The extra trailing `2` dimension is closer to the `lfilter` API and may not broadcast correctly. Please confirm the SciPy contract for `zi` with your `x_proc` shape and adjust the initialization (e.g. drop the last dim) to avoid shape errors or incorrect cross-channel state handling.
</issue_to_address>

### Comment 4
<location path="src/pyoctaveband/parametric_filters.py" line_range="43-47" />
<code_context>
         )

+        # Calculate initial conditions for filter state
+        if self.stateful:
+            self.zi = [None for _ in range(self.num_bands)]
+            for idx in range(self.num_bands):
</code_context>
<issue_to_address>
**suggestion:** Stateful `WeightingFilter` uses a `zi` shape tailored for 1D signals; multi-channel inputs may not be handled correctly.

`self.zi` is currently sized `(n_sections, 2)` (or zeros of that shape), which fits 1D signals with `axis=-1` but not multi-channel inputs (e.g. `(channels, samples)`), where `sosfilt` would expect `(n_sections, channels)`. Consider either enforcing 1D-only input in `_typesignal` or making `zi` initialization depend on the input shape to avoid incorrect state alignment if/when multi-channel data is passed in.
</issue_to_address>

### Comment 5
<location path="tests/test_stateful_octave_filter_bank.py" line_range="4-13" />
<code_context>
+@pytest.mark.parametrize("block_size", [8, 256, 1024])
</code_context>
<issue_to_address>
**suggestion (testing):** Extend block-wise vs full-signal test to cover SPL output and a multichannel case

This test currently only compares `block_output_signal` vs `full_output_signal` for 1D input and doesn’t exercise SPL or multichannel behavior.

To strengthen it:
1. Also compare SPL outputs by calling `filter(..., sigbands=True, calculate_level=True)` for both full and block-wise processing and asserting that the concatenated per-band SPL values match.
2. Add a multichannel case (e.g. parameterize `n_channels` for 1 and 2, or add a separate `(n_channels, n_samples)` test) so the stateful logic and per-band `zi` handling are exercised for multi-channel input as well.

Suggested implementation:

```python
import numpy as np
import pytest

@pytest.mark.parametrize("block_size", [8, 256, 1024])
@pytest.mark.parametrize("n_channels", [1, 2])
def test_block_processing_matches_full_signal(block_size: int, n_channels: int):
    from pyoctaveband import OctaveFilterBank
    """
    Ensure that block-wise processing with preserved filter state
    produces the same result as filtering the full signal at once,
    for both single-channel and multichannel input.

    Also verify that SPL outputs produced via full-signal and
    block-wise processing are identical when concatenated.
    """

    rng = np.random.default_rng(42)

    fs = 48000

```

To fully implement the requested behavior, you should update the body of `test_block_processing_matches_full_signal` (after `fs = 48000`) roughly as follows:

1. **Create multi-channel input**  
   - Define a number of samples, e.g. `n_samples = 48000`.
   - Generate a 2D signal when `n_channels > 1`:
     ```python
     if n_channels == 1:
         x = rng.standard_normal(n_samples)
     else:
         x = rng.standard_normal((n_channels, n_samples))
     ```
     Adjust shape/order if the rest of your tests expect `(n_samples, n_channels)` instead.

2. **Instantiate the filter bank**  
   - Reuse whatever center frequencies / bands you use in the existing version of this test, e.g.:
     ```python
     ofb = OctaveFilterBank(fs=fs, fraction=1, order=4)
     ```

3. **Full-signal processing (time-domain output + SPL)**  
   - Call the filter for the full signal, once for band signals, once for SPL:
     ```python
     full_output_signal = ofb.filter(x)
     full_output_signal_spl, full_spl = ofb.filter(
         x, sigbands=True, calculate_level=True
     )
     ```
   - If `.filter` already returns both band signals and SPL when `calculate_level=True`, adapt accordingly (e.g. `full_output_signal_spl, full_spl = ofb.filter(..., calculate_level=True)`).

4. **Block-wise processing (time-domain output + SPL)**  
   - Reset/init the filter bank state before block-wise processing if required by your API.
   - Iterate over the signal in steps of `block_size`, preserving filter state:
     ```python
     block_outputs = []
     block_outputs_spl = []
     block_spl_values = []

     for start in range(0, n_samples, block_size):
         stop = min(start + block_size, n_samples)

         x_block = x[..., start:stop]  # works for 1D and multi-channel

         y_block = ofb.filter(x_block)
         block_outputs.append(y_block)

         y_block_spl, spl_block = ofb.filter(
             x_block, sigbands=True, calculate_level=True
         )
         block_outputs_spl.append(y_block_spl)
         block_spl_values.append(spl_block)
     ```

5. **Concatenate block-wise outputs**  
   - Concatenate the lists along the time axis:
     ```python
     block_output_signal = np.concatenate(block_outputs, axis=-1)
     block_output_signal_spl = np.concatenate(block_outputs_spl, axis=-1)
     block_spl = np.concatenate(block_spl_values, axis=-1)
     ```

6. **Assertions for time-domain equality (existing behavior)**  
   - Keep or adapt the existing assertion comparing `block_output_signal` vs `full_output_signal`, using `np.allclose` with appropriate tolerances and axes to handle `n_channels`.

7. **Assertions for SPL equality (new behavior)**  
   - Add assertions to verify SPL outputs match for full vs block-wise processing:
     ```python
     assert block_output_signal_spl.shape == full_output_signal_spl.shape
     np.testing.assert_allclose(block_output_signal_spl, full_output_signal_spl, rtol=1e-6, atol=1e-9)

     assert block_spl.shape == full_spl.shape
     np.testing.assert_allclose(block_spl, full_spl, rtol=1e-6, atol=1e-9)
     ```

Adjust argument ordering (`sigbands`, `calculate_level`), return values, and array shapes to match the actual `OctaveFilterBank.filter` API and the conventions used elsewhere in your tests.
</issue_to_address>

### Comment 6
<location path="tests/test_stateful_weighting_filter.py" line_range="4-6" />
<code_context>
+import numpy as np
+import pytest
+
+@pytest.mark.parametrize("block_size", [8, 256, 1024])
+def test_block_processing_matches_full_signal(block_size: int):
+    from pyoctaveband import OctaveFilterBank
</code_context>
<issue_to_address>
**suggestion (testing):** Add explicit coverage for stateful weighting filter with steady-state initial conditions

The current parametrized test only covers the default `steady_ic=False` path. Please also add coverage for `WeightingFilter(..., stateful=True, steady_ic=True)`, either via a separate test or by extending the parametrization, to ensure the steady-state initial-conditions path preserves block vs full-signal equivalence.
</issue_to_address>

### Comment 7
<location path="tests/test_stateful_octave_filter_bank.py" line_range="53-62" />
<code_context>
+        bank = OctaveFilterBank(48000, resample=True, stateful=True)
+
+
+def test_stateful_steady_ic_initialization():
+    from pyoctaveband.core import OctaveFilterBank
+    # Create a stateful filter bank with steady_ic=True
+    bank = OctaveFilterBank(
+        fs=48000,
+        stateful=True,
+        steady_ic=True,
+        order=2,  # small order is enough for coverage
+        fraction=1,  # 1-octave
+        resample=False  # avoid the resampling branch
+    )
+
+    # Check that zi is a list of numpy arrays with the expected shape
+    for idx, zi in enumerate(bank.zi):
+        # zi should have 3 dimensions: (n_sections, 1, 2)
+        assert isinstance(zi, np.ndarray)
+        assert zi.ndim == 3
+        n_sections = bank.sos[idx].shape[0]
+        assert zi.shape[0] == n_sections
+        assert zi.shape[1] == 1
+        assert zi.shape[2] == 2
+
+def test_detrend_stateful_warning():
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that stateful OctaveFilterBank with steady_ic=True also behaves correctly on actual data

Right now this only validates the structure of `zi`. To also exercise behavior, consider extending the test to process a small known signal with both:
- a stateless `OctaveFilterBank`, and
- a stateful `OctaveFilterBank` with `steady_ic=True` (and `calculate_level=False` if you only care about band outputs),
then assert that concatenated block-wise outputs match the full-signal outputs, similar to `test_block_processing_matches_full_signal`.

Suggested implementation:

```python
def test_stateful_steady_ic_initialization():
    from pyoctaveband.core import OctaveFilterBank
    # Create a stateful filter bank with steady_ic=True
    bank = OctaveFilterBank(
        fs=48000,
        stateful=True,
        steady_ic=True,
        order=2,  # small order is enough for coverage
        fraction=1,  # 1-octave
        resample=False  # avoid the resampling branch
    )

    # Check that zi is a list of numpy arrays with the expected shape
    for idx, zi in enumerate(bank.zi):
        # zi should have 3 dimensions: (n_sections, 1, 2)
        assert isinstance(zi, np.ndarray)
        assert zi.ndim == 3
        n_sections = bank.sos[idx].shape[0]
        assert zi.shape[0] == n_sections
        assert zi.shape[1] == 1
        assert zi.shape[2] == 2

    # Additionally verify that a stateful bank with steady_ic=True behaves
    # consistently with a stateless bank when processing actual data.
    rng = np.random.default_rng(0)

    fs = 48000
    duration = 0.25
    n_samples = int(fs * duration)

    # Random test signal
    signal = rng.standard_normal(n_samples)

    # Stateless reference (process full signal at once)
    stateless_bank = OctaveFilterBank(
        fs=fs,
        stateful=False,
        steady_ic=False,
        order=2,
        fraction=1,
        resample=False,
        calculate_level=False,  # only care about band outputs
    )
    full_output = stateless_bank.filter(signal)

    # Stateful bank with steady_ic=True (process in blocks)
    stateful_bank = OctaveFilterBank(
        fs=fs,
        stateful=True,
        steady_ic=True,
        order=2,
        fraction=1,
        resample=False,
        calculate_level=False,  # only care about band outputs
    )

    block_size = 64
    block_outputs = []
    for start in range(0, n_samples, block_size):
        block = signal[start:start + block_size]
        y_block = stateful_bank.filter(block)
        block_outputs.append(y_block)

    # Concatenate block-wise outputs and compare to full-signal stateless output
    concat_output = np.concatenate(block_outputs, axis=-1)

    assert concat_output.shape == full_output.shape
    np.testing.assert_allclose(concat_output, full_output, rtol=1e-6, atol=1e-8)


def test_detrend_stateful_warning():

```

The above implementation assumes:
1. `OctaveFilterBank` exposes a `filter(signal: np.ndarray)` method that returns band outputs with a time axis that can be concatenated along `axis=-1`. If your API instead uses a different method name (e.g. `process`, `__call__`, or `filter_block`) or a different output shape, update the calls to `stateless_bank.filter(...)`, `stateful_bank.filter(...)`, and the `axis` argument in `np.concatenate` accordingly.
2. The constructor of `OctaveFilterBank` accepts `calculate_level`. If this argument does not exist or is named differently, remove or rename it to match the actual signature used elsewhere in the tests (e.g. copy the usage from `test_block_processing_matches_full_signal`).
3. If your tests use a helper to perform the "block vs full" comparison (e.g. a shared fixture or function in this file), you can refactor the block-processing part to use that helper for consistency.
</issue_to_address>

### Comment 8
<location path="tests/test_coverage_fix.py" line_range="152-158" />
<code_context>
     with pytest.raises(ValueError):
         bank._calculate_level(np.array([1.0]), "invalid_mode")
+
+def test_dont_calculate_level():
+    from pyoctaveband.core import OctaveFilterBank
+    bank = OctaveFilterBank(48000)
+    x = np.zeros((bank.num_bands, 100))
+    spl, y = bank._process_bands(x, num_channels=bank.num_bands, calculate_level=False, sigbands=True)
+    assert spl is None
+    assert y is not None
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for the public calculate_level=False API and strengthen assertions on band outputs

This currently only exercises the private `_process_bands` helper. Since `calculate_level` is a public `filter` argument, please also add tests that call:
- `OctaveFilterBank.filter(x, sigbands=False, calculate_level=False)` and assert the SPL return matches the intended contract (e.g. `None`), and
- `OctaveFilterBank.filter(x, sigbands=True, calculate_level=False)` and assert both outputs are as expected.

It would also help to strengthen the assertions on `y` by checking the number of bands and shapes, e.g. `len(y) == bank.num_bands` and each element has shape `(num_channels, n_samples)` after resampling. This will better validate the new option and reduce reliance on private methods in tests.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +43 to 47
if self.stateful:
self.zi = np.array([])
return

if self.curve not in ["A", "C"]:
Copy link

Choose a reason for hiding this comment

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

suggestion: Stateful WeightingFilter uses a zi shape tailored for 1D signals; multi-channel inputs may not be handled correctly.

self.zi is currently sized (n_sections, 2) (or zeros of that shape), which fits 1D signals with axis=-1 but not multi-channel inputs (e.g. (channels, samples)), where sosfilt would expect (n_sections, channels). Consider either enforcing 1D-only input in _typesignal or making zi initialization depend on the input shape to avoid incorrect state alignment if/when multi-channel data is passed in.

Comment on lines +4 to +13
@pytest.mark.parametrize("block_size", [8, 256, 1024])
def test_block_processing_matches_full_signal(block_size: int):
from pyoctaveband import OctaveFilterBank
"""
Ensure that block-wise processing with preserved filter state
produces the same result as filtering the full signal at once.
"""

rng = np.random.default_rng(42)

Copy link

Choose a reason for hiding this comment

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

suggestion (testing): Extend block-wise vs full-signal test to cover SPL output and a multichannel case

This test currently only compares block_output_signal vs full_output_signal for 1D input and doesn’t exercise SPL or multichannel behavior.

To strengthen it:

  1. Also compare SPL outputs by calling filter(..., sigbands=True, calculate_level=True) for both full and block-wise processing and asserting that the concatenated per-band SPL values match.
  2. Add a multichannel case (e.g. parameterize n_channels for 1 and 2, or add a separate (n_channels, n_samples) test) so the stateful logic and per-band zi handling are exercised for multi-channel input as well.

Suggested implementation:

import numpy as np
import pytest

@pytest.mark.parametrize("block_size", [8, 256, 1024])
@pytest.mark.parametrize("n_channels", [1, 2])
def test_block_processing_matches_full_signal(block_size: int, n_channels: int):
    from pyoctaveband import OctaveFilterBank
    """
    Ensure that block-wise processing with preserved filter state
    produces the same result as filtering the full signal at once,
    for both single-channel and multichannel input.

    Also verify that SPL outputs produced via full-signal and
    block-wise processing are identical when concatenated.
    """

    rng = np.random.default_rng(42)

    fs = 48000

To fully implement the requested behavior, you should update the body of test_block_processing_matches_full_signal (after fs = 48000) roughly as follows:

  1. Create multi-channel input

    • Define a number of samples, e.g. n_samples = 48000.
    • Generate a 2D signal when n_channels > 1:
      if n_channels == 1:
          x = rng.standard_normal(n_samples)
      else:
          x = rng.standard_normal((n_channels, n_samples))
      Adjust shape/order if the rest of your tests expect (n_samples, n_channels) instead.
  2. Instantiate the filter bank

    • Reuse whatever center frequencies / bands you use in the existing version of this test, e.g.:
      ofb = OctaveFilterBank(fs=fs, fraction=1, order=4)
  3. Full-signal processing (time-domain output + SPL)

    • Call the filter for the full signal, once for band signals, once for SPL:
      full_output_signal = ofb.filter(x)
      full_output_signal_spl, full_spl = ofb.filter(
          x, sigbands=True, calculate_level=True
      )
    • If .filter already returns both band signals and SPL when calculate_level=True, adapt accordingly (e.g. full_output_signal_spl, full_spl = ofb.filter(..., calculate_level=True)).
  4. Block-wise processing (time-domain output + SPL)

    • Reset/init the filter bank state before block-wise processing if required by your API.
    • Iterate over the signal in steps of block_size, preserving filter state:
      block_outputs = []
      block_outputs_spl = []
      block_spl_values = []
      
      for start in range(0, n_samples, block_size):
          stop = min(start + block_size, n_samples)
      
          x_block = x[..., start:stop]  # works for 1D and multi-channel
      
          y_block = ofb.filter(x_block)
          block_outputs.append(y_block)
      
          y_block_spl, spl_block = ofb.filter(
              x_block, sigbands=True, calculate_level=True
          )
          block_outputs_spl.append(y_block_spl)
          block_spl_values.append(spl_block)
  5. Concatenate block-wise outputs

    • Concatenate the lists along the time axis:
      block_output_signal = np.concatenate(block_outputs, axis=-1)
      block_output_signal_spl = np.concatenate(block_outputs_spl, axis=-1)
      block_spl = np.concatenate(block_spl_values, axis=-1)
  6. Assertions for time-domain equality (existing behavior)

    • Keep or adapt the existing assertion comparing block_output_signal vs full_output_signal, using np.allclose with appropriate tolerances and axes to handle n_channels.
  7. Assertions for SPL equality (new behavior)

    • Add assertions to verify SPL outputs match for full vs block-wise processing:
      assert block_output_signal_spl.shape == full_output_signal_spl.shape
      np.testing.assert_allclose(block_output_signal_spl, full_output_signal_spl, rtol=1e-6, atol=1e-9)
      
      assert block_spl.shape == full_spl.shape
      np.testing.assert_allclose(block_spl, full_spl, rtol=1e-6, atol=1e-9)

Adjust argument ordering (sigbands, calculate_level), return values, and array shapes to match the actual OctaveFilterBank.filter API and the conventions used elsewhere in your tests.

Comment on lines +4 to +6
@pytest.mark.parametrize("block_size", [8, 256, 1024])
@pytest.mark.parametrize("filter_type", ["A", "C", "Z"])
def test_weighting_filter_block_processing_matches_full_signal(block_size: int, filter_type: str):
Copy link

Choose a reason for hiding this comment

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

suggestion (testing): Add explicit coverage for stateful weighting filter with steady-state initial conditions

The current parametrized test only covers the default steady_ic=False path. Please also add coverage for WeightingFilter(..., stateful=True, steady_ic=True), either via a separate test or by extending the parametrization, to ensure the steady-state initial-conditions path preserves block vs full-signal equivalence.

Comment on lines +53 to +62
def test_stateful_steady_ic_initialization():
from pyoctaveband.core import OctaveFilterBank
# Create a stateful filter bank with steady_ic=True
bank = OctaveFilterBank(
fs=48000,
stateful=True,
steady_ic=True,
order=2, # small order is enough for coverage
fraction=1, # 1-octave
resample=False # avoid the resampling branch
Copy link

Choose a reason for hiding this comment

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

suggestion (testing): Consider asserting that stateful OctaveFilterBank with steady_ic=True also behaves correctly on actual data

Right now this only validates the structure of zi. To also exercise behavior, consider extending the test to process a small known signal with both:

  • a stateless OctaveFilterBank, and
  • a stateful OctaveFilterBank with steady_ic=True (and calculate_level=False if you only care about band outputs),
    then assert that concatenated block-wise outputs match the full-signal outputs, similar to test_block_processing_matches_full_signal.

Suggested implementation:

def test_stateful_steady_ic_initialization():
    from pyoctaveband.core import OctaveFilterBank
    # Create a stateful filter bank with steady_ic=True
    bank = OctaveFilterBank(
        fs=48000,
        stateful=True,
        steady_ic=True,
        order=2,  # small order is enough for coverage
        fraction=1,  # 1-octave
        resample=False  # avoid the resampling branch
    )

    # Check that zi is a list of numpy arrays with the expected shape
    for idx, zi in enumerate(bank.zi):
        # zi should have 3 dimensions: (n_sections, 1, 2)
        assert isinstance(zi, np.ndarray)
        assert zi.ndim == 3
        n_sections = bank.sos[idx].shape[0]
        assert zi.shape[0] == n_sections
        assert zi.shape[1] == 1
        assert zi.shape[2] == 2

    # Additionally verify that a stateful bank with steady_ic=True behaves
    # consistently with a stateless bank when processing actual data.
    rng = np.random.default_rng(0)

    fs = 48000
    duration = 0.25
    n_samples = int(fs * duration)

    # Random test signal
    signal = rng.standard_normal(n_samples)

    # Stateless reference (process full signal at once)
    stateless_bank = OctaveFilterBank(
        fs=fs,
        stateful=False,
        steady_ic=False,
        order=2,
        fraction=1,
        resample=False,
        calculate_level=False,  # only care about band outputs
    )
    full_output = stateless_bank.filter(signal)

    # Stateful bank with steady_ic=True (process in blocks)
    stateful_bank = OctaveFilterBank(
        fs=fs,
        stateful=True,
        steady_ic=True,
        order=2,
        fraction=1,
        resample=False,
        calculate_level=False,  # only care about band outputs
    )

    block_size = 64
    block_outputs = []
    for start in range(0, n_samples, block_size):
        block = signal[start:start + block_size]
        y_block = stateful_bank.filter(block)
        block_outputs.append(y_block)

    # Concatenate block-wise outputs and compare to full-signal stateless output
    concat_output = np.concatenate(block_outputs, axis=-1)

    assert concat_output.shape == full_output.shape
    np.testing.assert_allclose(concat_output, full_output, rtol=1e-6, atol=1e-8)


def test_detrend_stateful_warning():

The above implementation assumes:

  1. OctaveFilterBank exposes a filter(signal: np.ndarray) method that returns band outputs with a time axis that can be concatenated along axis=-1. If your API instead uses a different method name (e.g. process, __call__, or filter_block) or a different output shape, update the calls to stateless_bank.filter(...), stateful_bank.filter(...), and the axis argument in np.concatenate accordingly.
  2. The constructor of OctaveFilterBank accepts calculate_level. If this argument does not exist or is named differently, remove or rename it to match the actual signature used elsewhere in the tests (e.g. copy the usage from test_block_processing_matches_full_signal).
  3. If your tests use a helper to perform the "block vs full" comparison (e.g. a shared fixture or function in this file), you can refactor the block-processing part to use that helper for consistency.

Comment on lines +152 to +158
def test_dont_calculate_level():
from pyoctaveband.core import OctaveFilterBank
bank = OctaveFilterBank(48000)
x = np.zeros((bank.num_bands, 100))
spl, y = bank._process_bands(x, num_channels=bank.num_bands, calculate_level=False, sigbands=True)
assert spl is None
assert y is not None
Copy link

Choose a reason for hiding this comment

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

suggestion (testing): Add tests for the public calculate_level=False API and strengthen assertions on band outputs

This currently only exercises the private _process_bands helper. Since calculate_level is a public filter argument, please also add tests that call:

  • OctaveFilterBank.filter(x, sigbands=False, calculate_level=False) and assert the SPL return matches the intended contract (e.g. None), and
  • OctaveFilterBank.filter(x, sigbands=True, calculate_level=False) and assert both outputs are as expected.

It would also help to strengthen the assertions on y by checking the number of bands and shapes, e.g. len(y) == bank.num_bands and each element has shape (num_channels, n_samples) after resampling. This will better validate the new option and reduce reliance on private methods in tests.

@coderabbitai
Copy link

coderabbitai bot commented Mar 2, 2026

Warning

Rate limit exceeded

@jmrplens has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 21 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 07a6142b-3b50-45bb-88d1-1609b45cc34b

📥 Commits

Reviewing files that changed from the base of the PR and between 4422a9e and 18c6f48.

📒 Files selected for processing (2)
  • src/pyoctaveband/core.py
  • tests/test_stateful_octave_filter_bank.py
📝 Walkthrough

Walkthrough

Adds stateful block-processing to OctaveFilterBank and WeightingFilter (new ctor options stateful, steady_ic, resample), optional SPL calculation via calculate_level, validation for incompatible options, tests, and README documentation with a runnable block-processing example.

Changes

Cohort / File(s) Summary
Core Implementation
src/pyoctaveband/core.py
Add stateful, steady_ic, resample ctor flags; initialize and retain per-band zi for stateful filtering; guard against stateful + resample; add calculate_level option to skip SPL computation; route filtering to use stored zi.
Parametric Filters
src/pyoctaveband/parametric_filters.py
Extend WeightingFilter.__init__ with stateful and steady_ic; initialize zi (zeros or steady IC) for SOS filters; update filter() to use and update zi when stateful.
Documentation
README.md
Add "Block processing" section documenting stateful mode, steady vs zero IC initialization, constraints (no DC removal when block processing with detrend=False, resampling disabled for block processing), and a runnable example for block iteration.
Tests
tests/test_coverage_fix.py, tests/test_stateful_octave_filter_bank.py, tests/test_stateful_weighting_filter.py
Add tests verifying block-wise outputs match full-signal processing across block sizes, steady_ic zi shapes, error on stateful+resample, and warning when detrending in stateful/block mode.

Sequence Diagram(s)

sequenceDiagram
    participant User as User Code
    participant FB as OctaveFilterBank<br/>(stateful=True)
    participant SOS as SOS Filter<br/>(sosfilt)
    participant State as State Storage (zi)

    User->>FB: __init__(stateful=True, steady_ic=False)
    FB->>State: initialize per-band zi (zeros or steady IC)

    User->>FB: filter(block_1, calculate_level=True)
    FB->>SOS: sosfilt(block_1, zi_old)
    SOS-->>FB: filtered_block_1, zi_new
    FB->>State: store zi_new
    FB-->>User: filtered_block_1 (+ level if requested)

    User->>FB: filter(block_2)
    FB->>SOS: sosfilt(block_2, zi_updated)
    SOS-->>FB: filtered_block_2, zi_new
    FB->>State: store zi_new
    FB-->>User: filtered_block_2
Loading
sequenceDiagram
    participant User as User Code
    participant FB as OctaveFilterBank
    participant Validator as Init Validation

    User->>FB: __init__(stateful=True, resample=True)
    FB->>Validator: check config
    Validator-->>FB: incompatible
    FB-->>User: ValueError (resample + stateful not allowed)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Block by block I softly tread,
zi in paw, through filters spread.
Steady starts or zeros, too—
bands align, outputs true,
hop, process, and then we're fed!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding stateful block-wise processing capabilities to OctaveFilterBank and WeightingFilter.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Line 143: The fenced code block opened with ``` in README.md lacks a language
specifier (triggers MD040); edit the opening fence to include the language token
(e.g., change ``` to ```python) so the code block is syntax-highlighted and the
linter warning is resolved—look for the triple-backtick code block start in the
README and add the appropriate language label.

In `@src/pyoctaveband/core.py`:
- Around line 145-166: The bug is that when calculate_level is False,
_process_bands returns spl = None but filter() unconditionally does spl = spl[0]
for 1D inputs; update filter() to only index spl when spl is not None (e.g.,
replace spl = spl[0] with spl = spl[0] if spl is not None else None) and
likewise guard the other occurrences where spl is indexed (the similar blocks
around the other overload-handling code at the noted spots); ensure branches
that build the return tuple handle spl being None so single-channel calls with
calculate_level=False return the expected tuple shapes without raising.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9189b72 and 5d0110b.

📒 Files selected for processing (6)
  • README.md
  • src/pyoctaveband/core.py
  • src/pyoctaveband/parametric_filters.py
  • tests/test_coverage_fix.py
  • tests/test_stateful_octave_filter_bank.py
  • tests/test_stateful_weighting_filter.py

Copy link
Owner

@jmrplens jmrplens left a comment

Choose a reason for hiding this comment

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

🔍 Code Review — PR #42: Stateful block-wise processing

Great work on this feature, @ninoblumer! The overall design of stateful filtering is well thought out, and the block-vs-full equivalence tests are exactly what's needed to validate correctness.

I found 2 functional bugs, 1 CI failure, and 4 improvements that should be addressed before merge. Details in the inline comments below 👇

Summary:

# Severity File Issue
1 🔴 Bug core.py:193 raise Warning(...) raises exception instead of warning
2 🔴 Bug core.py:209 spl[0] crashes when calculate_level=False
3 ⚠️ Types core.py:154 calculate_level=False overloads promise np.ndarray but return None
4 🔧 CI test:50 Unused bank variable (ruff F841) — breaks CI lint
5 🔧 Test test:87 pytest.raises(Warning) coupled to bug #1
6 📝 Docs README:143 Code fence missing python (MD040)
7 📝 Docs README:139 Ambiguous detrend wording

We'll prepare a commit with all the fixes. 🚀

# Handle DC offset removal
if detrend:
if self.stateful:
raise Warning("You should not detrend when doing block processing!")
Copy link
Owner

Choose a reason for hiding this comment

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

🐛 Bug: raise Warning(...) raises an exception instead of emitting a warning

This is a subtle but critical bug. raise Warning("...") doesn't emit a warning — it raises a Warning exception (which inherits from Exception). In practice, calling filter() with stateful=True and detrend=True (the default!) always crashes with an uncaught exception.

A user simply doing bank.filter(signal) on a stateful bank gets a crash instead of a friendly heads-up.

Fix: Use warnings.warn(...) from the stdlib warnings module:

import warnings
warnings.warn(
    "Detrending is not recommended during block processing "
    "as it can introduce discontinuities between blocks.",
    UserWarning,
    stacklevel=2
)

The stacklevel=2 makes the warning point to the caller's code, not this internal line.


# Process signal across all bands and channels
spl, xb = self._process_bands(x_proc, num_channels, sigbands, mode=mode)
spl, xb = self._process_bands(x_proc, num_channels, sigbands, mode=mode, calculate_level=calculate_level)
Copy link
Owner

Choose a reason for hiding this comment

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

🐛 Bug: spl[0] crash when calculate_level=False on mono signals

When calculate_level=False, _process_bands() returns spl = None. But a few lines below (line 209), spl = spl[0] runs unconditionally for 1D inputs → TypeError: 'NoneType' is not subscriptable.

This affects any user calling with calculate_level=False on a single-channel signal (the most common case).

Fix: Guard with a None check on line 209:

if not is_multichannel:
    if spl is not None:
        spl = spl[0]
    if sigbands and xb is not None:
        xb = [band[0] for band in xb]

mode: str = "rms",
detrend: bool = True,
calculate_level: Literal[False] = False
) -> Tuple[np.ndarray, List[float]]:
Copy link
Owner

Choose a reason for hiding this comment

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

⚠️ Incorrect type hints: calculate_level=False overloads promise np.ndarray but return None

The overloads with calculate_level: Literal[False] declare a return type of Tuple[np.ndarray, List[float]], but when calculate_level=False the SPL is actually None, not an np.ndarray.

This is a broken type contract — type checkers like mypy/pyright will assume spl is an array and won't warn users about potential None access.

Fix: Change the return types to Tuple[None, List[float]] and Tuple[None, List[float], List[np.ndarray]] for the sigbands=True variant.

def test_resample_and_stateful():
from pyoctaveband.core import OctaveFilterBank
with pytest.raises(ValueError):
bank = OctaveFilterBank(48000, resample=True, stateful=True)
Copy link
Owner

Choose a reason for hiding this comment

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

🔧 CI failure cause: unused variable bank (ruff F841)

This is the root cause of the quality job failure in CI. ruff flags bank as assigned but never used — makes sense since the test only verifies the constructor raises ValueError.

Fix: Drop the assignment:

with pytest.raises(ValueError):
    OctaveFilterBank(48000, resample=True, stateful=True)

signal = rng.standard_normal(n_samples)

bank = OctaveFilterBank(fs, stateful=True, resample=False)
with pytest.raises(Warning):
Copy link
Owner

Choose a reason for hiding this comment

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

🔧 Test coupled to the raise Warning bug

This test works right now because raise Warning(...) raises an exception, and pytest.raises(Warning) catches it. But once we fix the raisewarnings.warn bug (see comment on core.py:193), this test will break since there won't be an exception to catch anymore.

Fix: Switch to pytest.warns, which is the correct mechanism for testing warnings:

with pytest.warns(UserWarning, match="block processing"):
    bank.filter(signal, detrend=True)

README.md Outdated
- Resampling is not supported for block processing, so you need to set `resample=False`.

Example
```
Copy link
Owner

Choose a reason for hiding this comment

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

📝 Missing language specifier on fenced code block (MD040)

The code fence is missing python after the triple backticks. This prevents syntax highlighting on GitHub/IDEs and triggers a markdownlint MD040 warning.

Fix: ``````python

README.md Outdated
Create a stateful filter bank with `stateful=True`. The internal state is zero-initialized by default
but may be initialized for step-response steady-state (like `scipy.signal.sosfilt_zi`) with `steady_ic=True`.
Notes when using a stateful `OctaveFilterBank`:
- You should not remove the DC-content when block processing (`detrend=False`).
Copy link
Owner

Choose a reason for hiding this comment

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

📝 Ambiguous wording about detrending

"You should not remove the DC-content when block processing" could be read as "don't use detrend" or "don't remove DC by other means". For new users of the library, it's better to be explicit about which parameter to set.

Suggestion:

Detrending should be disabled during block processing (detrend=False), as it can introduce discontinuities between blocks.

- Replace 'raise Warning(...)' with 'warnings.warn(...)' to emit a
  proper UserWarning instead of throwing an exception (core.py)
- Guard 'spl[0]' with 'if spl is not None' to prevent TypeError when
  calculate_level=False (core.py)
- Fix overload return types and implementation signature to use
  Optional[np.ndarray] when calculate_level=False (core.py)
- Remove unused variable 'bank' to fix ruff F841 lint error
  (test_stateful_octave_filter_bank.py)
- Change pytest.raises(Warning) to pytest.warns(UserWarning) to match
  the corrected warning behavior (test_stateful_octave_filter_bank.py)
- Add 'python' language specifier to fenced code block (README.md)
- Clarify detrend guidance wording (README.md)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/pyoctaveband/core.py (2)

79-81: Unreachable comment after raise.

The comment on line 81 appears after the raise statement, making it unreachable code. Consider moving it above the if block or to the docstring for better discoverability.

♻️ Suggested improvement
+        # Note: a stateful resampling algorithm would be required to support this combination
         if resample and stateful:
             raise ValueError("Resampling and stateful behaviour (block processing) are not supported.")
-            # a stateful resampling algorithm would be required...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pyoctaveband/core.py` around lines 79 - 81, The inline comment after the
raise in the if block checking "if resample and stateful" is unreachable; move
the explanatory comment ("a stateful resampling algorithm would be required...")
to a reachable location such as directly above the if statement or into the
function/class docstring that contains the check so it is discoverable; ensure
you update the comment near the symbols resample, stateful, and the ValueError
to clearly explain why the combination is unsupported.

128-167: Consider adding explicit calculate_level: Literal[True] overloads for complete type coverage.

The existing overloads at lines 128-144 don't include calculate_level in their signatures. While they handle the default case (calculate_level defaults to True), explicit calls like filter(x, calculate_level=True) may not resolve correctly with some type checkers.

♻️ Suggested addition for complete overload coverage
    `@overload`
    def filter(
        self, 
        x: List[float] | np.ndarray, 
        sigbands: Literal[False] = False,
        mode: str = "rms",
        detrend: bool = True,
        calculate_level: Literal[True] = True
    ) -> Tuple[np.ndarray, List[float]]: ...

    `@overload`
    def filter(
        self, 
        x: List[float] | np.ndarray, 
        sigbands: Literal[True],
        mode: str = "rms",
        detrend: bool = True,
        calculate_level: Literal[True] = True
    ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pyoctaveband/core.py` around lines 128 - 167, The overload set for the
filter method is missing explicit variants for calculate_level: Literal[True],
which can confuse type checkers; add two overloads mirroring the existing
sigbands=False/True signatures but with calculate_level: Literal[True] = True
and the correct return types (first element np.ndarray rather than None) so that
filter(self, ..., calculate_level=True) is properly typed—update the overloads
surrounding the existing filter definitions (the overloads and the filter
method) to include these two calculate_level=True variants.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/pyoctaveband/core.py`:
- Around line 110-119: The zi initialization currently hardcodes a
single-channel shape (n_sections, 1, 2) causing a mismatch for multichannel
inputs; modify lazy initialization so self.zi is sized per-band based on actual
channel count when _filter_and_resample() first receives data: keep the existing
placeholder list in __init__ or where stateful is set (self.zi = [None ...]) but
defer creating per-band arrays until _filter_and_resample() examines the
incoming array shape (channels = data.shape[0] if 2D else 1) and then allocate
zi[idx] = np.zeros((self.sos[idx].shape[0], channels, 2)) for steady_ic False or
use signal.sosfilt_zi(self.sos[idx]) and reshape to (n_sections, channels, 2)
for steady_ic True; ensure you update any code paths that assume a 1-channel zi
and add a small branch in _filter_and_resample() to initialize per-band zi when
self.zi[idx] is None or has mismatched second-dimension size.

---

Nitpick comments:
In `@src/pyoctaveband/core.py`:
- Around line 79-81: The inline comment after the raise in the if block checking
"if resample and stateful" is unreachable; move the explanatory comment ("a
stateful resampling algorithm would be required...") to a reachable location
such as directly above the if statement or into the function/class docstring
that contains the check so it is discoverable; ensure you update the comment
near the symbols resample, stateful, and the ValueError to clearly explain why
the combination is unsupported.
- Around line 128-167: The overload set for the filter method is missing
explicit variants for calculate_level: Literal[True], which can confuse type
checkers; add two overloads mirroring the existing sigbands=False/True
signatures but with calculate_level: Literal[True] = True and the correct return
types (first element np.ndarray rather than None) so that filter(self, ...,
calculate_level=True) is properly typed—update the overloads surrounding the
existing filter definitions (the overloads and the filter method) to include
these two calculate_level=True variants.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7e733152-4058-4ffa-8d35-d06e5ea7ab04

📥 Commits

Reviewing files that changed from the base of the PR and between 5d0110b and 972319e.

📒 Files selected for processing (3)
  • README.md
  • src/pyoctaveband/core.py
  • tests/test_stateful_octave_filter_bank.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • tests/test_stateful_octave_filter_bank.py

jmrplens added 2 commits March 8, 2026 05:59
- Fix mypy line 103: use np.ones() instead of list comprehension for self.factor
- Fix mypy line 115: type self.zi as List[np.ndarray] with proper initialization
- Fix mypy line 255: add spl is not None guard for type narrowing
- Reduce cyclomatic complexity: extract _init_filter_state() from __init__
zi arrays were hardcoded to shape (n_sections, 1, 2), assuming single-channel
input. When multichannel input (e.g. stereo) was passed, scipy.sosfilt raised
ValueError because zi shape didn't match the channel dimension.

Changed to lazy initialization: zi is allocated on first filter() call with
the correct shape (n_sections, n_channels, 2) based on actual input channels.
This also handles channel count changes between calls.

Added test_stateful_multichannel to verify 4-channel stateful processing.
Updated test_stateful_steady_ic_initialization for lazy init behavior.

Addresses CodeRabbit review: r2901335966
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/pyoctaveband/core.py (1)

133-180: ⚠️ Potential issue | 🟡 Minor

The overload set is incomplete for calculate_level=True.

Typed calls using filter(..., calculate_level=True) will fail type checking despite the implementation accepting it. The first two overloads (without calculate_level listed) cannot match explicit keyword arguments, and the next two require Literal[False]. Add overloads for calculate_level: Literal[True] returning the filtered arrays.

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

 `@overload`
 def filter(
     self, 
     x: List[float] | np.ndarray, 
     sigbands: Literal[True],
     mode: str = "rms",
-    detrend: bool = True
+    detrend: bool = True,
+    calculate_level: Literal[True] = True,
 ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pyoctaveband/core.py` around lines 133 - 180, The current overloads for
the method filter are missing signatures for calculate_level=True, so type
checkers can't match calls like filter(..., calculate_level=True); add two
overloads mirroring the existing ones but with calculate_level: Literal[True]
(one for sigbands: Literal[False] returning Tuple[np.ndarray, List[float]] and
one for sigbands: Literal[True] returning Tuple[np.ndarray, List[float],
List[np.ndarray]]), keeping the same mode and detrend defaults and ensuring the
runtime implementation signature (def filter(..., calculate_level: bool = True))
matches these overloads.
♻️ Duplicate comments (1)
src/pyoctaveband/core.py (1)

115-123: ⚠️ Potential issue | 🔴 Critical

Stateful zi is still hard-coded to a single channel.

filter() still accepts 2D [channels, samples] input, but these initial states are always created as (n_sections, 1, 2). In stateful mode that reaches Line 280 unchanged, so any multichannel block will hand sosfilt() a zi whose channel dimension does not match sd.shape[0]. This breaks stateful multichannel processing at runtime.

🔧 Suggested fix
 def _init_filter_state(self, steady_ic: bool) -> None:
     """Initialize filter state (zi) for stateful block-wise processing."""
-    self.zi: List[np.ndarray] = [np.array([]) for _ in range(self.num_bands)]
-    for idx in range(self.num_bands):
-        if not steady_ic:
-            self.zi[idx] = np.zeros((self.sos[idx].shape[0], 1, 2))
-        else:
-            zi = signal.sosfilt_zi(self.sos[idx])
-            self.zi[idx] = zi[:, np.newaxis, :] # add a dimension since we are filtering along an axis in a 2D-array
+    self._steady_ic = steady_ic
+    self.zi: List[np.ndarray | None] = [None for _ in range(self.num_bands)]
@@
         if self.stateful:
+            channels = sd.shape[0] if sd.ndim > 1 else 1
+            if self.zi[idx] is None or self.zi[idx].shape[1] != channels:
+                if not self._steady_ic:
+                    self.zi[idx] = np.zeros((self.sos[idx].shape[0], channels, 2))
+                else:
+                    zi = signal.sosfilt_zi(self.sos[idx])
+                    self.zi[idx] = np.repeat(zi[:, np.newaxis, :], channels, axis=1)
             y, self.zi[idx] = signal.sosfilt(self.sos[idx], sd, axis=-1, zi=self.zi[idx])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pyoctaveband/core.py` around lines 115 - 123, _init_filter_state
currently creates zi with a hard-coded single-channel shape (n_sections, 1, 2),
which breaks multichannel stateful filtering in filter(); update
_init_filter_state to allocate the channel dimension to match the actual number
of channels used during filtering (use self.num_channels if available, or add a
num_channels parameter to _init_filter_state and pass it from filter()), and
ensure both the steady_ic branch (use zeros of shape (n_sections, num_channels,
2)) and the steady-state branch (broadcast the result of
signal.sosfilt_zi(self.sos[idx]) to shape (n_sections, num_channels, 2), e.g. by
inserting a newaxis and repeating along the channel axis) so that self.zi has
the correct per-band, per-channel shape before calling signal.sosfilt in
filter().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/pyoctaveband/core.py`:
- Around line 133-180: The current overloads for the method filter are missing
signatures for calculate_level=True, so type checkers can't match calls like
filter(..., calculate_level=True); add two overloads mirroring the existing ones
but with calculate_level: Literal[True] (one for sigbands: Literal[False]
returning Tuple[np.ndarray, List[float]] and one for sigbands: Literal[True]
returning Tuple[np.ndarray, List[float], List[np.ndarray]]), keeping the same
mode and detrend defaults and ensuring the runtime implementation signature (def
filter(..., calculate_level: bool = True)) matches these overloads.

---

Duplicate comments:
In `@src/pyoctaveband/core.py`:
- Around line 115-123: _init_filter_state currently creates zi with a hard-coded
single-channel shape (n_sections, 1, 2), which breaks multichannel stateful
filtering in filter(); update _init_filter_state to allocate the channel
dimension to match the actual number of channels used during filtering (use
self.num_channels if available, or add a num_channels parameter to
_init_filter_state and pass it from filter()), and ensure both the steady_ic
branch (use zeros of shape (n_sections, num_channels, 2)) and the steady-state
branch (broadcast the result of signal.sosfilt_zi(self.sos[idx]) to shape
(n_sections, num_channels, 2), e.g. by inserting a newaxis and repeating along
the channel axis) so that self.zi has the correct per-band, per-channel shape
before calling signal.sosfilt in filter().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f59add5a-6079-4700-937d-37e106a0a58e

📥 Commits

Reviewing files that changed from the base of the PR and between 972319e and 4422a9e.

📒 Files selected for processing (1)
  • src/pyoctaveband/core.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants