Repository: pyfar/pyfar

Issue:
Reconstructing filterbank taking too long to process multidimensional signal
## General

* pyfar version: 0.7.0
* Python version: 3.12
* Operating System: MacOS
* Did you install pyfar via pip: yes

## Description

While applying a perfect reconstruction filterbank to a signal of size NxL where N = 838 and L = 120000, using `pf.dsp.filter.reconstructing_fractional_octave_bands(signal, num_fractions=1, frequency_range=(63, 8000), n_samples=4096)` the thread hangs/computation takes unreasonably long. This is because the filter's (FilterFIR) process function uses scipy's lfilter which does time domain convolution. This is much slower than scipy's fftconvolve.
 
pyfar/classes/filter.py should check if the filter type is FIR or IIR and accordingly use lfilter or fftconvolve.

```
Paste the command(s) you ran and the output.
If there was a crash, please include the traceback here.

File "/opt/anaconda3/lib/python3.12/site-packages/pyfar/dsp/filter/fractional_octaves.py", line 519, in reconstructing_fractional_octave_bands
    signal_filt = filt.process(signal)
                  ^^^^^^^^^^^^^^^^^^^^
  File "/opt/anaconda3/lib/python3.12/site-packages/pyfar/classes/filter.py", line 248, in process
    filtered_signal_data[idx, ...] = self._process(
                                     ^^^^^^^^^^^^^^
  File "/opt/anaconda3/lib/python3.12/site-packages/pyfar/classes/filter.py", line 388, in _process
    return spsignal.lfilter(coefficients[0], 1, data, zi=zi)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/anaconda3/lib/python3.12/site-packages/scipy/signal/_signaltools.py", line 2139, in lfilter
    out_full = np.apply_along_axis(lambda y: np.convolve(b, y), axis, x)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/anaconda3/lib/python3.12/site-packages/numpy/lib/shape_base.py", line 402, in apply_along_axis
    buff[ind] = asanyarray(func1d(inarr_view[ind], *args, **kwargs))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/anaconda3/lib/python3.12/site-packages/scipy/signal/_signaltools.py", line 2139, in <lambda>
    out_full = np.apply_along_axis(lambda y: np.convolve(b, y), axis, x)
                                             ^^^^^^^^^^^^^^^^^
```

Discussion:
@f-brinkmann: Thanks for reporting - we will take a look!@mberz: That's indeed a good suggestion. We're aware of this issue. 
There's however an additional point to consider here. For continuous filtering of data, i.e. multiple signals representing from the same audio source , the filter class supports saving the state of the buffer elements. 
This is not supported by fftconvolve straight out of the box as far as I remember. Please correct me if I'm wrong there. @f-brinkmann: Yes, @hoyer-a discovered that as well. We could discuss if we want to use fftconvolve if the state is not used to speed things up.@mberz: I would vote for adding a respective argument to the `process` method or class property specifying which backend to be used. 

Solution patch:
diff --git a/.github/workflows/has_version_label.yml b/.github/workflows/has_label.yml
similarity index 54%
rename from .github/workflows/has_version_label.yml
rename to .github/workflows/has_label.yml
index 97e01ea1..1750854a 100644
--- a/.github/workflows/has_version_label.yml
+++ b/.github/workflows/has_label.yml
@@ -1,4 +1,4 @@
-name: PR Check Label for version Label
+name: pull_request label
 
 on:
   pull_request:
@@ -12,17 +12,15 @@ jobs:
   check-labels:
     runs-on: ubuntu-latest
     steps:
-      - name: Check for version label
+      - name: pull_request label
         run: |
-          echo "Checking for version label on pull request..."
+          echo "Checking for label on pull request..."
           PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }})
           LABEL_NAMES=$(echo "$PR_DATA" | jq -r '.labels[].name')
           echo "Labels: $LABEL_NAMES"
 
-          REGEX="^v[0-9]+\.[0-9]+\.[0-9]+$"
-          MATCHES=$(echo "$LABEL_NAMES" | grep -E "$REGEX")
-          if [ -z "$MATCHES" ]; then
-            echo "Error: No version label found on this pull request. Please add a label in the format vX.Y.Z."
+          if [ -z "$LABEL_NAMES" ]; then
+            echo "Error: No label found on this pull request. Please add a label."
             exit 1
           fi
         env:
diff --git a/.github/workflows/has_version_milestone.yml b/.github/workflows/has_version_milestone.yml
new file mode 100644
index 00000000..1a2bf74a
--- /dev/null
+++ b/.github/workflows/has_version_milestone.yml
@@ -0,0 +1,29 @@
+name: pull_request version milestone
+
+on:
+  pull_request:
+    types:
+      - opened
+      - labeled
+      - unlabeled
+      - synchronize
+
+jobs:
+  check-labels:
+    runs-on: ubuntu-latest
+    steps:
+      - name: pull_request version milestone
+        run: |
+          echo "Checking for version milestone on pull request..."
+          PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }})
+          MILESTONE_NAME=$(echo "$PR_DATA" | jq -r '.milestone.title')
+          echo "Milestone: $MILESTONE_NAME"
+
+          REGEX="^v[0-9]+\.[0-9]+\.[0-9]+$"
+          MATCHES=$(echo "$MILESTONE_NAME" | grep -E "$REGEX")
+          if [ -z "$MATCHES" ]; then
+            echo "Error: No version milestone found on this pull request. Please add a milestone in the format vX.Y.Z."
+            exit 1
+          fi
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/pyfar/classes/audio.py b/pyfar/classes/audio.py
index d86eeb1f..f98554f5 100644
--- a/pyfar/classes/audio.py
+++ b/pyfar/classes/audio.py
@@ -35,7 +35,6 @@ import deepdiff
 import numpy as np
 import pyfar.dsp.fft as fft
 from typing import Callable
-from pyfar.classes.warnings import PyfarDeprecationWarning
 
 
 class _Audio():
@@ -1112,15 +1111,6 @@ class Signal(FrequencyData, TimeData):
 
         return repr_string
 
-    def __len__(self):
-        """Length of the object which is the number of samples stored.
-        """
-        warnings.warn(
-            ("len(Signal) will be deprecated in pyfar 0.8.0 "
-             "Use Signal.n_samples instead"),
-             PyfarDeprecationWarning, stacklevel=2)
-        return self.n_samples
-
     def __iter__(self):
         """Iterator for :py:func:`Signal` objects.
 
diff --git a/pyfar/classes/filter.py b/pyfar/classes/filter.py
index be82fc20..42bf5238 100644
--- a/pyfar/classes/filter.py
+++ b/pyfar/classes/filter.py
@@ -159,11 +159,25 @@ class Filter(object):
         self._sampling_rate = sampling_rate
         self.comment = comment
 
-    def init_state(self, state='zeros'):
-        """Initialize the buffer elements to pre-defined initial conditions."""
+    def init_state(self, state):
+        """
+        Initialize the buffer elements to pre-defined initial conditions.
+
+        This method is overwritten in the child classes and called after
+        setting the state there.
+        """
         self._state = state
         self._initialized = True
 
+    @staticmethod
+    def _check_state_keyword(state):
+        """
+        Check the value of the state keyword for 'ini_state' class methods.
+        """
+        if state not in ['zeros', 'step']:
+            raise ValueError(
+                f"state is '{state}' but must be 'zeros' or 'step'")
+
     @property
     def coefficients(self):
         """
@@ -262,6 +276,41 @@ class Filter(object):
 
         return filtered_signal
 
+    def impulse_response(self, n_samples):
+        """
+        Compute or approximate the impulse response of the filter.
+
+        See `impulse_response` methods in derived classes for more details.
+
+        Parameters
+        ----------
+        n_samples : int
+            Length in samples for which the impulse response is computed.
+
+        Returns
+        -------
+        impulse_response : Signal
+            The impulse response of the filter.
+        """
+
+        # track the state (better than copying the entire filter)
+        if self.state is not None:
+            state = self.state.copy()
+            self._state = None
+            reset = True
+        else:
+            reset = False
+
+        # get impulse response
+        impulse_response = self.process(pf.signals.impulse(
+            n_samples, sampling_rate=self.sampling_rate), reset=reset)
+
+        # reset the state if required
+        if reset:
+            self._state = state
+
+        return impulse_response
+
     def reset(self):
         """Reset the filter state by filling it with zeros."""
         if self._state is not None:
@@ -377,19 +426,84 @@ class FilterFIR(Filter):
             an empty filter, or ``'step'`` which constructs the initial
             conditions for step response steady-state. The default is 'zeros'.
         """
+        self._check_state_keyword(state)
+
         new_state = np.zeros((self.n_channels, *cshape, self.order))
         if state == 'step':
             for idx, coeff in enumerate(self._coefficients):
                 new_state[idx, ...] = spsignal.lfilter_zi(coeff[0], coeff[1])
         super().init_state(state=new_state)
 
+    def impulse_response(self, n_samples=None):
+        """
+        Compute the finite impulse response of the filter.
+
+        Parameters
+        ----------
+        n_samples : int, optional
+            Length in samples for which the impulse response is computed. The
+            default is ``None`` in which case the length of the impulse
+            response is determined from the filter :py:func:`~FilterFIR.order`.
+            A warning is returned if ``n_samples`` is to short to compute the
+            entire impulse response.
+
+        Returns
+        -------
+        impulse_response : Signal
+            The impulse response of the filter.
+        """
+
+        # set or check the impulse response length
+        if n_samples is None:
+            n_samples = self.coefficients.shape[-1]
+        elif self.coefficients.shape[-1] > n_samples:
+            warnings.warn(
+                ('n_samples should be at least as long as the filter, '
+                 f'which is {self.coefficients.shape[-1]}'), stacklevel=1)
+
+        return super().impulse_response(n_samples)
+
     @staticmethod
     def _process(coefficients, data, zi=None):
         """Process a single filter channel.
         This is a hidden static method required for a shared processing
         function in the parent class.
+
+        Parameters
+        ----------
+        coefficients : array
+            Coefficients of the filter channel.
+        data : array
+            The data to be filtered of shape ``(cshape, n_samples)``.
+        zi : array, optional
+            The initial filter state of shape ``(cshape, order)`` where
+            ``cshape`` is the channel shape of the signal to be filtered.
+            The default is ``None``.
+
+        Returns
+        -------
+        out : array
+            The filtered data.
+        zf : array
+            The final filter state. Only returned if ``zi is not None``.
         """
-        return spsignal.lfilter(coefficients[0], 1, data, zi=zi)
+        b = coefficients[0]
+        # broadcast b to match ndim of data for convolution
+        b = np.broadcast_to(b, (1, ) * (data.ndim - b.ndim) + b.shape)
+
+        out_full = spsignal.oaconvolve(data, b, mode='full')
+
+        # Ensure that the output has the same shape as the input
+        out = out_full[..., 0:data.shape[-1]]
+
+        if zi is not None:
+            # add initial filter state to the beginning of the output
+            out[..., 0:zi.shape[-1]] += zi
+            # get new filter state
+            zf = out_full[..., data.shape[-1]:]
+            return out, zf
+        else:
+            return out
 
     def __repr__(self):
         """Representation of the filter object."""
@@ -436,7 +550,7 @@ class FilterIIR(Filter):
         """The order of the filter."""
         return np.max(self._coefficients.shape[-2:]) - 1
 
-    def init_state(self, cshape, state):
+    def init_state(self, cshape, state='zeros'):
         """Initialize the buffer elements to pre-defined initial conditions.
 
         Parameters
@@ -449,12 +563,35 @@ class FilterIIR(Filter):
             an empty filter, or ``'step'`` which constructs the initial
             conditions for step response steady-state. The default is 'zeros'.
         """
+        self._check_state_keyword(state)
+
         new_state = np.zeros((self.n_channels, *cshape, self.order))
         if state == 'step':
             for idx, coeff in enumerate(self._coefficients):
                 new_state[idx, ...] = spsignal.lfilter_zi(coeff[0], coeff[1])
         return super().init_state(state=new_state)
 
+    def impulse_response(self, n_samples):
+        """
+        Approximate the infinite impulse response of the filter by a finite
+        impulse response.
+
+        Note that the number of samples must be sufficiently long for
+        `impulse_response` to be a good approximation of the theoretically
+        infinitely long impulse response of the filter.
+
+        Parameters
+        ----------
+        n_samples : int
+            Length in samples for which the impulse response is computed.
+
+        Returns
+        -------
+        impulse_response : Signal
+            The impulse response of the filter.
+        """
+        return super().impulse_response(n_samples)
+
     @staticmethod
     def _process(coefficients, data, zi=None):
         """Process a single filter channel.
@@ -543,12 +680,35 @@ class FilterSOS(Filter):
             an empty filter, or ``'step'`` which constructs the initial
             conditions for step response steady-state. The default is 'zeros'.
         """
+        self._check_state_keyword(state)
+
         new_state = np.zeros((self.n_channels, *cshape, self.n_sections, 2))
         if state == 'step':
             for idx, coeff in enumerate(self._coefficients):
                 new_state[idx, ...] = spsignal.sosfilt_zi(coeff)
         return super().init_state(state=new_state)
 
+    def impulse_response(self, n_samples):
+        """
+        Approximate the infinite impulse response of the filter by a finite
+        impulse response.
+
+        Note that the number of samples must be sufficiently long for
+        `impulse_response` to be a good approximation of the theoretically
+        infinitely long impulse response of the filter.
+
+        Parameters
+        ----------
+        n_samples : int
+            Length in samples for which the impulse response is computed.
+
+        Returns
+        -------
+        impulse_response : Signal
+            The impulse response of the filter.
+        """
+        return super().impulse_response(n_samples)
+
     @staticmethod
     def _process(sos, data, zi=None):
         """Process a single filter channel.
diff --git a/pyfar/dsp/dsp.py b/pyfar/dsp/dsp.py
index 6399467e..49ce0ad4 100644
--- a/pyfar/dsp/dsp.py
+++ b/pyfar/dsp/dsp.py
@@ -4,8 +4,6 @@ import numpy as np
 from scipy import signal as sgn
 import pyfar
 from pyfar.dsp import fft
-from pyfar.classes.warnings import PyfarDeprecationWarning
-from pyfar._utils import rename_arg
 import warnings
 import scipy.fft as sfft
 
@@ -748,9 +746,6 @@ def _time_window_symmetric_interval_four(interval, window):
     return win, win_start, win_stop
 
 
-@rename_arg({"freq_range": "frequency_range"},
-            "freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range")
 def regularized_spectrum_inversion(
         signal, frequency_range,
         regu_outside=1., regu_inside=10**(-200/20), regu_final=None,
@@ -797,11 +792,6 @@ def regularized_spectrum_inversion(
     normalized : bool
         Flag to indicate if the normalized spectrum (according to
         `signal.fft_norm`) should be inverted. The default is ``True``.
-    freq_range: tuple, array_like, double
-        The upper and lower frequency limits outside of which the
-        regularization factor is to be applied.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
 
 
     Returns
@@ -1043,13 +1033,6 @@ def pad_zeros(signal, pad_width, mode='end'):
     if not isinstance(signal, pyfar.Signal):
         raise TypeError('Input data has to be of type: Signal.')
 
-    if mode in ['before', 'after']:
-        warnings.warn(('Mode "before" and "after" will be renamed into '
-                       '"beginning" and "end" and can no longer be used in '
-                       'Pyfar 0.8.0.'), PyfarDeprecationWarning, stacklevel=2)
-
-        mode = 'beginning' if mode == 'before' else 'end'
-
     padded_signal = signal.flatten()
 
     if mode in ['end', 'center']:
@@ -1433,7 +1416,7 @@ def find_impulse_response_start(
 
     """
     modes = ['real', 'complex'] if impulse_response.complex else ['real']
-    ir_start = np.zeros(((len(modes), ) + impulse_response.cshape))
+    ir_start = np.zeros(((len(modes), ) + impulse_response.cshape), dtype=int)
     for idx, mode in enumerate(modes):
         ir = impulse_response.time
         ir_squared = np.real(ir)**2 if mode == 'real' else np.imag(ir)**2
@@ -1483,9 +1466,6 @@ def find_impulse_response_start(
     return ir_start
 
 
-@rename_arg({"freq_range": "frequency_range"},
-            "freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range")
 def deconvolve(system_output, system_input, fft_length=None,
                frequency_range=None, **kwargs):
     r"""Calculate transfer functions by spectral deconvolution of two signals.
@@ -1535,14 +1515,6 @@ def deconvolve(system_output, system_input, fft_length=None,
     kwargs : key value arguments
         Key value arguments to control the inversion of :math:`H(\omega)` are
         passed to to :py:func:`~pyfar.dsp.regularized_spectrum_inversion`.
-    freq_range: tuple, array_like, double
-        The upper and lower frequency limits outside of which the
-        regularization factor is to be applied. The default ``None``
-        bypasses the regularization, which might cause numerical
-        instabilities in case of band-limited `system_input`. Also see
-        :py:func:`~pyfar.dsp.regularized_spectrum_inversion`.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
 
 
     Returns
diff --git a/pyfar/dsp/filter/fractional_octaves.py b/pyfar/dsp/filter/fractional_octaves.py
index af70cfae..c11af644 100644
--- a/pyfar/dsp/filter/fractional_octaves.py
+++ b/pyfar/dsp/filter/fractional_octaves.py
@@ -3,7 +3,6 @@ import warnings
 import numpy as np
 import scipy.signal as spsignal
 import pyfar as pf
-from pyfar._utils import rename_arg
 from pyfar.classes.warnings import PyfarDeprecationWarning
 
 
@@ -161,9 +160,6 @@ def _center_frequencies_fractional_octaves_iec(num_fractions):
     return nominal, exact
 
 
-@rename_arg({"freq_range": "frequency_range"},
-            "freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range")
 def fractional_octave_bands(
         signal,
         num_fractions,
@@ -205,12 +201,6 @@ def fractional_octave_bands(
         ``frequency_range=(20, 20e3)``.
     order : int, optional
         Order of the Butterworth filter. The default is ``14``.
-    freq_range: array, tuple, optional
-        The lower and upper frequency limits. The default is
-        ``frequency_range=(20, 20e3)``.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
-
 
     Returns
     -------
diff --git a/pyfar/dsp/filter/gammatone.py b/pyfar/dsp/filter/gammatone.py
index d458ed73..787ed387 100644
--- a/pyfar/dsp/filter/gammatone.py
+++ b/pyfar/dsp/filter/gammatone.py
@@ -4,9 +4,6 @@ import scipy.signal as sgn
 from copy import deepcopy
 from deepdiff import DeepDiff
 import pyfar as pf
-import warnings
-from pyfar.classes.warnings import PyfarDeprecationWarning
-from pyfar._utils import rename_arg
 
 
 class GammatoneBands():
@@ -50,12 +47,6 @@ class GammatoneBands():
     sampling_rate : number
         The sampling rate of the filter bank in Hz. The default is ``44100``
         Hz.
-    freq_range: array_like
-        The upper and lower frequency in Hz between which the filter bank is
-        constructed. Values must be larger than 0 and not exceed half the
-        sampling rate.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
 
     Examples
     --------
@@ -123,9 +114,6 @@ class GammatoneBands():
     .. [#] https://amtoolbox.org/
     """
 
-    @rename_arg({"freq_range": "frequency_range"},
-                "freq_range parameter will be deprecated in pyfar 0.8.0 in "
-                "favor of frequency_range")
     def __init__(self, frequency_range, resolution=1,
                  reference_frequency=1000, delay=0.004, sampling_rate=44100):
 
@@ -172,20 +160,6 @@ class GammatoneBands():
         """Check for equality of two objects."""
         return not DeepDiff(self.__dict__, other.__dict__)
 
-    @property
-    def freq_range(self):
-        """Get the frequency range of the filter bank in Hz.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
-        """
-
-        # Deprecation warning for freq_range parameter
-        warnings.warn((
-            'freq_range parameter will be deprecated in pyfar 0.8.0 in favor'
-            ' of frequency_range'),
-                PyfarDeprecationWarning, stacklevel=2)
-        return self._frequency_range
-
     @property
     def frequency_range(self):
         """Get the frequency range of the filter bank in Hz."""
@@ -529,9 +503,6 @@ class GammatoneBands():
         return obj
 
 
-@rename_arg({"freq_range": "frequency_range"},
-            "freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range")
 def erb_frequencies(frequency_range, resolution=1, reference_frequency=1000):
     """
     Get frequencies that are linearly spaced on the ERB frequency scale.
@@ -559,11 +530,6 @@ def erb_frequencies(frequency_range, resolution=1, reference_frequency=1000):
     reference_frequency : number, optional
         The reference frequency in Hz relative to which the frequency vector
         is constructed. The default is ``1000``.
-    freq_range: array_like
-        The upper and lower frequency limits in Hz between which the frequency
-        vector is computed.
-        ``'freq_range'`` parameter will be deprecated in pyfar 0.8.0 in favor
-        of ``'frequency_range'``.
 
     Returns
     -------
diff --git a/pyfar/io/io.py b/pyfar/io/io.py
index 46fa820a..6e8aa36b 100644
--- a/pyfar/io/io.py
+++ b/pyfar/io/io.py
@@ -30,6 +30,7 @@ except (ModuleNotFoundError, OSError):
         "help: https://python-soundfile.readthedocs.io")
 
 from pyfar import Signal, FrequencyData, Coordinates, TimeData
+from pyfar._utils import rename_arg
 from . import _codec as codec
 import pyfar.classes.filter as fo
 
@@ -195,7 +196,10 @@ def _sofa_pos(pos_type, coordinates):
             coordinates[:, 2],
         )
     else:
-        raise ValueError("Position:Type {pos_type} is not supported.")
+        raise ValueError(
+            f"Position: Type '{pos_type}' is not supported."
+            "Allowed types are 'cartesian' and 'spherical'.",
+            )
 
 
 def read(filename):
@@ -505,7 +509,7 @@ def audio_formats():
     return soundfile.available_formats()
 
 
-@pf._utils.rename_arg(
+@rename_arg(
         {"format" : "audio_format"},
         "'format' will be deprecated in "
         "pyfar 0.9.0 in favor of 'audio_format'")


Test patch:
diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py
index 190eec60..3fc27a8b 100644
--- a/tests/test_deprecations.py
+++ b/tests/test_deprecations.py
@@ -12,7 +12,6 @@ Test deprecations. For each deprecation two things must be tested:
 """
 import numpy as np
 from packaging import version
-import re
 
 import pytest
 
@@ -63,10 +62,6 @@ def test__check_time_unit():
 
 # deprecate in 0.8.0 ----------------------------------------------------------
 def test_pad_zero_modi():
-    with pytest.warns(PyfarDeprecationWarning,
-                      match='Mode "before" and "after" will be renamed into'):
-        pf.dsp.pad_zeros(pf.Signal([1], 44100), 5, 'before')
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         match = 'Unknown padding mode.'
         with pytest.raises(ValueError, match=match):
@@ -141,12 +136,9 @@ def test_deprecations_0_8_0_set_sh_order():
 
 
 def test_signal_len():
-    with pytest.warns(PyfarDeprecationWarning,
-                      match=re.escape("len(Signal) will be deprecated")):
-        len(pf.Signal([1, 2, 3], 44100))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
-        with pytest.raises(TypeError, match=re.escape("had no len()")):
+        match = "object of type 'Signal' has no len"
+        with pytest.raises(TypeError, match=match):
             # remove Signal.__len__ from pyfar 0.8.0!
             len(pf.Signal([1, 2, 3], 44100))
 
@@ -206,101 +198,34 @@ def test_deprecations_find_nearest_sph():
 def test_deprecations_freq_range_parameter_warnings():
     sweep = pfs.exponential_sweep_time(256, (100, 10000))
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        pf.dsp.deconvolve(sweep, sweep, 256, freq_range=(20, 20e3))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         with pytest.raises(TypeError):
             pf.dsp.deconvolve(sweep, sweep, 256, freq_range=(20, 20e3))
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        pf.dsp.regularized_spectrum_inversion(sweep, freq_range=(20, 20e3))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         with pytest.raises(TypeError):
             pf.dsp.regularized_spectrum_inversion(sweep, freq_range=(20, 20e3))
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        gt = pf.dsp.filter.GammatoneBands(freq_range=(20, 20e3))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         with pytest.raises(TypeError):
             gt = pf.dsp.filter.GammatoneBands(freq_range=(20, 20e3))
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        npt.assert_equal((20, 20e3), gt.freq_range)
+    gt = pf.dsp.filter.GammatoneBands(frequency_range=(20, 20e3))
 
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
-        with pytest.raises(TypeError):
+        with pytest.raises(AttributeError):
             npt.assert_equal((20, 20e3), gt.freq_range)
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        pf.dsp.filter.erb_frequencies(freq_range=(20, 20e3))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         with pytest.raises(TypeError):
             pf.dsp.filter.erb_frequencies(freq_range=(20, 20e3))
 
-    with pytest.warns(
-            PyfarDeprecationWarning,
-            match="freq_range parameter will be deprecated in pyfar 0.8.0 in "
-            "favor of frequency_range"):
-        pf.dsp.filter.fractional_octave_bands(sweep, 8, freq_range=(20, 20e3))
-
     if version.parse(pf.__version__) >= version.parse('0.8.0'):
         with pytest.raises(TypeError):
             pf.dsp.filter.fractional_octave_bands(sweep, 8,
                                                   freq_range=(20, 20e3))
 
 
-def test_deprecations_freq_range_parameter_renaming_results():
-    sweep = pfs.exponential_sweep_time(256, (100, 10000))
-
-    np.testing.assert_allclose(
-        pf.dsp.deconvolve(sweep, sweep, 256, freq_range=(20, 20e3)).time,
-        pf.dsp.deconvolve(sweep, sweep, 256, frequency_range=(20, 20e3)).time,
-        rtol=0)
-
-    np.testing.assert_allclose(
-        pf.dsp.regularized_spectrum_inversion(sweep,
-                                              freq_range=(20, 20e3)).time,
-        pf.dsp.regularized_spectrum_inversion(sweep,
-                                              frequency_range=(20, 20e3)).time,
-        rtol=0)
-
-    np.testing.assert_allclose(
-        pf.dsp.filter.GammatoneBands(freq_range=(20, 20e3)).frequencies,
-        pf.dsp.filter.GammatoneBands(frequency_range=(20, 20e3)).frequencies,
-        rtol=0)
-
-    np.testing.assert_allclose(
-        pf.dsp.filter.erb_frequencies(freq_range=(20, 20e3)),
-        pf.dsp.filter.erb_frequencies(frequency_range=(20, 20e3)),
-        rtol=0)
-
-    np.testing.assert_allclose(
-        pf.dsp.filter.fractional_octave_bands(sweep, 8,
-                                              freq_range=(20, 20e3)).time,
-        pf.dsp.filter.fractional_octave_bands(sweep, 8,
-                                              frequency_range=(20, 20e3)).time,
-        rtol=0)
-
-
 def test_deprecation_shelve_functions():
     # test high_shelve()
     with pytest.warns(
diff --git a/tests/test_dsp.py b/tests/test_dsp.py
index ff16aa3f..e8a18f4d 100644
--- a/tests/test_dsp.py
+++ b/tests/test_dsp.py
@@ -834,6 +834,7 @@ def test_impulse_response_start():
     ir = pf.Signal(ir, 44100)
 
     start_sample_est = dsp.find_impulse_response_start(ir)
+    assert start_sample_est.dtype == int
     assert start_sample_est == start_sample - 1
 
     ir_awgn = ir + noise
diff --git a/tests/test_dsp_deconvolution.py b/tests/test_dsp_deconvolution.py
index 4dc0db74..b12d254b 100644
--- a/tests/test_dsp_deconvolution.py
+++ b/tests/test_dsp_deconvolution.py
@@ -50,7 +50,7 @@ def test_default():
     """Test the default parameters of deconvolution function."""
     res = deconvolve(impulse(6, sampling_rate=44100),
                      impulse(5, sampling_rate=44100),
-                     freq_range=(1, 22050))
+                     frequency_range=(1, 22050))
     assert res.n_samples == 6
     assert res.fft_norm == 'none'
     assert res.sampling_rate == 44100
@@ -58,21 +58,21 @@ def test_default():
 
 def test_output_type():
     """Test Type of returned Signal and basic deconvolution with impulses."""
-    res = deconvolve(impulse(3), impulse(3), freq_range=(1, 22050))
+    res = deconvolve(impulse(3), impulse(3), frequency_range=(1, 22050))
     assert isinstance(res, pf.Signal)
     npt.assert_allclose(impulse(3).time, res.time, rtol=1e-9)
 
 
 def test_output_length():
     """Test if output length is correct."""
-    res = deconvolve(impulse(3), impulse(3), freq_range=(1, 22050))
+    res = deconvolve(impulse(3), impulse(3), frequency_range=(1, 22050))
     assert res.n_samples == 3
-    res = deconvolve(impulse(5), impulse(3), freq_range=(1, 22050))
+    res = deconvolve(impulse(5), impulse(3), frequency_range=(1, 22050))
     assert res.n_samples == 5
-    res = deconvolve(impulse(3), impulse(5), freq_range=(1, 22050))
+    res = deconvolve(impulse(3), impulse(5), frequency_range=(1, 22050))
     assert res.n_samples == 5
     res = deconvolve(impulse(3), impulse(5), fft_length=7,
-                     freq_range=(1, 22050))
+                     frequency_range=(1, 22050))
     assert res.n_samples == 7
 
 
@@ -98,7 +98,7 @@ def test_output_sweep(is_complex_sig1, is_complex_sig2):
 
     res = pf.dsp.dsp.deconvolve(
         sweep_1, sweep_2,
-        freq_range=(1, 44100)).freq[0, eval_range_start+10:-12500]
+        frequency_range=(1, 44100)).freq[0, eval_range_start+10:-12500]
     npt.assert_allclose(np.ones_like(res), res, atol=1e-9)
 
 
@@ -107,16 +107,16 @@ def test_fft_norm():
     for two example inputs.
     """
     sig1 = pf.Signal([1, 0, 0, 0], 44100, fft_norm='amplitude')
-    res = pf.dsp.dsp.deconvolve(sig1, sig1, freq_range=(1, 44100))
+    res = pf.dsp.dsp.deconvolve(sig1, sig1, frequency_range=(1, 44100))
     assert res.fft_norm == 'none'
     sig2 = pf.Signal([1, 0, 0, 0], 44100, fft_norm='none')
     sig3 = pf.Signal([1, 0, 0, 0], 44100, fft_norm='amplitude')
     with pytest.raises(ValueError, match="Either fft_norm_2 "):
-        deconvolve(sig2, sig3, freq_range=(1, 44100))
+        deconvolve(sig2, sig3, frequency_range=(1, 44100))
 
 
-def test_freq_range():
-    """Test if freq_range default is correct."""
-    res1 = deconvolve(impulse(3), impulse(3), freq_range=None)
-    res2 = deconvolve(impulse(3), impulse(3), freq_range=(0, 22050))
+def test_frequency_range():
+    """Test if frequency_range default is correct."""
+    res1 = deconvolve(impulse(3), impulse(3), frequency_range=None)
+    res2 = deconvolve(impulse(3), impulse(3), frequency_range=(0, 22050))
     npt.assert_allclose(res1.time, res2.time, rtol=1e-14)
diff --git a/tests/test_filter_classes.py b/tests/test_filter_classes.py
index 79c3f6e8..26367a30 100644
--- a/tests/test_filter_classes.py
+++ b/tests/test_filter_classes.py
@@ -3,6 +3,7 @@ import numpy as np
 import numpy.testing as npt
 import pyfar.classes.filter as fo
 import pyfar as pf
+import re
 from scipy import signal as spsignal
 
 
@@ -43,6 +44,90 @@ def test_filter_comment():
         pf.Signal([1, 2, 3], 44100, comment=[1, 2, 3])
 
 
+@pytest.mark.parametrize('filter_object', [
+    # FIR filter, single-channel
+    fo.FilterFIR(np.array([[1, -1, .1, -.1]]), 48000),
+    # FIR filter, multi-channel
+    fo.FilterFIR(np.array([[1, -1, .1, -.1], [2, -2, .2, -.2]]), 48000),
+    # IIR filter, single-channel
+    fo.FilterIIR(np.array([[[1, -1, .1, -.1], [1, .1, -.1, 0]]]), 48000),
+    # IIR filter, multi-channel
+    fo.FilterIIR(np.array([[[1, -1, .1, -.1], [1, .1, -.1, 0]],
+                           [[1, -2, .2, -.2], [1, .2, -.2, 0]]]), 48000),
+    # SOS filter, single-channel
+    fo.FilterSOS(np.array([[[1, -1, .1, 1, .1, -.1]]]), 48000),
+    # SOS filter, multi-channel
+    fo.FilterSOS(np.array([[[1, -1, .1, 1, .1, -.1]],
+                           [[2, -2, .2, 1, .2, -.2]]]), 48000),
+])
+def test_filter_impulse_response(filter_object):
+    """Test 'impulse_response' class methods for all Filter types."""
+
+    # test signal
+    n_samples = 256
+    impulse = pf.signals.impulse(n_samples, sampling_rate=48000)
+
+    # filter without state
+    impulse_response = filter_object.impulse_response(n_samples)
+    # test output
+    assert isinstance(impulse_response, pf.Signal)
+    assert impulse_response.n_samples == n_samples
+    npt.assert_equal(impulse_response, filter_object.process(impulse))
+    assert filter_object.state is None
+
+    # filter with state
+    filter_object.init_state(impulse.cshape, 'zeros')
+    state_before = filter_object.state.copy()
+    impulse_response_2 = filter_object.impulse_response(n_samples)
+    state_after = filter_object.state.copy()
+    # test output
+    assert isinstance(impulse_response, pf.Signal)
+    assert impulse_response.n_samples == n_samples
+    npt.assert_equal(impulse_response_2, impulse_response)
+    assert filter_object.state is not None
+    npt.assert_equal(state_after, state_before)
+
+
+def test_filter_fir_impulse_response_n_samples_warning():
+    """Test warning if impulse response length is less then filter length."""
+    filter_object = fo.FilterFIR([[1, 0, 0, 1]], 48000)
+    match = "n_samples should be at least as long as the filter, which is 4"
+    with pytest.warns(UserWarning, match=re.escape(match)):
+        _ = filter_object.impulse_response(2)
+
+
+def test_filter_fir_impulse_response_n_samples_default():
+    """Test default value for impulse response length."""
+    filter_object = fo.FilterFIR([[1, 0, 0, 1]], 48000)
+    impulse_response = filter_object.impulse_response()
+    assert impulse_response.n_samples == 4
+
+
+@pytest.mark.parametrize('filter_object', [
+    (fo.FilterFIR([[1, -1]], 48000)),
+    (fo.FilterIIR([[1, -1], [1, -1]], 48000)),
+    (fo.FilterSOS([[[1, -1, 1, 1, -1, 1]]], 48000)),
+])
+def test_filter_init_state_default_and_error(filter_object):
+    """
+    Test the default for the state keyword and if an error is raised if passing
+    an invalid value.
+    """
+
+    # set state explicitly
+    filter_object.init_state((1, ), 'zeros')
+    state_explicit = filter_object.state.copy()
+    # set state implicitly using the default
+    filter_object.init_state((1, ))
+    state_implicit = filter_object.state.copy()
+    # assert states
+    npt.assert_equal(state_implicit, state_explicit)
+
+    # test raising the error for an invalid value
+    with pytest.raises(ValueError, match="state is 'random' but must be"):
+        filter_object.init_state((1, ), 'random')
+
+
 def test_filter_iir_init():
     coeff = np.array([[1, 1/2, 0], [1, 0, 0]])
     filt = fo.FilterIIR(coeff, sampling_rate=2*np.pi)
@@ -163,6 +248,17 @@ def test_filter_fir_process(impulse):
     npt.assert_allclose(res.time[0, :3], coeff)
 
 
+@pytest.mark.parametrize('shape', [(2,), (4, 1), (1, 2, 3)])
+def test_filter_fir_process_multichannel_signal(shape):
+    impulse = pf.signals.impulse(44100, amplitude=np.ones(shape))
+    coeff = np.array([1, 1/2, 0])
+    filt = fo.FilterFIR(coeff, impulse.sampling_rate)
+    res = filt.process(impulse)
+    desired = np.tile(coeff, (*shape, 1))
+
+    npt.assert_allclose(res.time[..., :3], desired)
+
+
 def test_filter_fir_process_complex(impulse_complex):
     coeff = np.array([1, 1/2, 0])
     filt = fo.FilterFIR(coeff, impulse_complex.sampling_rate)
@@ -178,7 +274,7 @@ def test_filter_fir_process_state(impulse):
     state = filt.state
 
     npt.assert_allclose([[[0, 0, 0, 0, 0]]], state)
-    npt.assert_allclose(res.time[:, :6], np.atleast_2d(coeff))
+    npt.assert_allclose(res.time[:, :6], np.atleast_2d(coeff), atol=1e-16)
 
 
 def test_filter_fir_init_state(impulse):
@@ -396,8 +492,10 @@ def test_blockwise_processing(Filter):
     block_a = Filter.process(pf.Signal(signal.time[0, :3], 44100), reset=False)
     block_b = Filter.process(pf.Signal(signal.time[0, 3:], 44100), reset=False)
     # outputs have to be identical in this case
-    npt.assert_array_equal(np.atleast_2d(complete.time[0, :3]), block_a.time)
-    npt.assert_array_equal(np.atleast_2d(complete.time[0, 3:]), block_b.time)
+    npt.assert_allclose(np.atleast_2d(complete.time[0, :3]), block_a.time,
+                        atol=1e-15)
+    npt.assert_allclose(np.atleast_2d(complete.time[0, 3:]), block_b.time,
+                        atol=1e-15)
 
 
 def test_blockwise_processing_with_coefficients_exchange():
diff --git a/tests/test_filter_gammatone.py b/tests/test_filter_gammatone.py
index 85855ed1..e5206aef 100644
--- a/tests/test_filter_gammatone.py
+++ b/tests/test_filter_gammatone.py
@@ -131,7 +131,7 @@ def test_gammatone_bands_reset_state():
 def test_gammatone_bands_assertions():
     """Test all assertions."""
 
-    # wrong values in freq_range
+    # wrong values in frequency_range
     with pytest.raises(ValueError, match="Values in frequency_range must be"):
         pf.dsp.filter.GammatoneBands([-1, 22050])
     with pytest.raises(ValueError, match="Values in frequency_range must be"):
@@ -195,12 +195,12 @@ def test_erb_frequencies():
 def test_erb_frequencies_assertions():
     """Test assertions for erb_frequencies."""
 
-    # freq_range must be an array of length 2
+    # frequency_range must be an array of length 2
     with pytest.raises(ValueError, match="frequency_range must be an array"):
         pf.dsp.filter.erb_frequencies(1)
     with pytest.raises(ValueError, match="frequency_range must be an array"):
         pf.dsp.filter.erb_frequencies([1])
-    # values freq_range must be increasing
+    # values frequency_range must be increasing
     with pytest.raises(ValueError, match="The first value of frequency_range"):
         pf.dsp.filter.erb_frequencies([1, 0])
     # resolution must be > 0
diff --git a/tests/test_io.py b/tests/test_io.py
index 6c547cc8..03124051 100644
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -728,3 +728,11 @@ def test_default_audio_subtype(default_audio_subtype_mock):
     subtype_return = pyfar.io.default_audio_subtype(format=audio_format)
     assert subtype_return == 'bla'
     default_audio_subtype_mock.assert_called_with(audio_format)
+
+
+def test__sofa_pos_error():
+    """Test error message for wrong position type."""
+    error_message = re.escape(
+        "Position: Type 'bla' is not supported")
+    with pytest.raises(ValueError, match=error_message):
+        io.io._sofa_pos('bla', np.array([1, 2, 3]))
diff --git a/tests/test_signals.py b/tests/test_signals.py
index d4578e3e..887acc28 100644
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -200,7 +200,8 @@ def test_noise_rms_pink_spectrum():
     # filtered pink noise
     # (use only center octaves, because the spectrum is less stochastic there)
     signal = pfs.noise(5000, "pink", seed=1, sampling_rate=48000)
-    signal = pff.fractional_octave_bands(signal, 1, freq_range=(1e3, 16e3))
+    signal = pff.fractional_octave_bands(
+        signal, 1, frequency_range=(1e3, 16e3))
     # check if stdandard deviation is less then 1%
     rms = np.atleast_1d(np.sqrt(np.mean(signal.time**2, axis=-1)))
     assert np.std(rms) < .01