Repository: pytorch/audio

Issue:
lfilter: clamp in [-1, 1] to be optional
## 🚀 Feature
<!-- A clear and concise description of the feature proposal -->

Make clamp [-1, 1] behavior to be optional by adding an optional argument `clamp=False or True` to `torchaudio.functional.lfilter`.

## Motivation

<!-- Please outline the motivation for the proposal. Is your feature request related to a problem? e.g., I'm always frustrated when [...]. If this is related to another GitHub issue, please link here too -->

I want to apply filters for non-waveform signals, which have values outside [-1, 1].
## Pitch

<!-- A clear and concise description of what you want to happen. -->

It seems that the input and output are assumed to be *waveform* ([-1, 1]). However, we can apply filters on arbitrary signals, which may not be in the range of [-1, 1]. Removing or making the clipping behavior optional should give more flexibility to users.

I think the clipping behavior should be disabled in general, but we'd want to make it optional and enabled by default for backward compatibility.

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->

https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.lfilter.html

scipy.signal.lfilter doesn't apply clipping.

A different topic though, function arguments (name, order, etc) are not same as the scipy's one and I was confused at first. We may want to improve compatibility. Any comments are appreciated.


Discussion:
Thanks for the idea!

Some of the transform may assume the input is in the range `[-1,1]` even without clamp, so removing clamp doesn't mean that the transform does what one would want :)

I agree with the idea nonetheless. This also gives the freedom to the user to decide how to keep the waveform within the range.

Either we add a toggle to disable `clamp` or we deprecate it altogether. Even in the latter case, we should give the user at least one release to update their code. And to do so we would need a toggle anyway. Let's add an option `clamp` that defaults to `True`. Would you like to open a pull request?

We can decide later to add a deprecation warning that the option will be remove and that the future behavior will be `clamp=False` without an option.
I think this assumption of signals being within range [-1, 1] is something that is a bit larger than this particular ask. If various operations make this assumptions they should be clearly flagged as such as we might want to consider some kind of dtype dependent standardization around this.

In my experience [-1, 1] is used for conversions of audio signals into float32, whereas for integers the full range is used at their width often corresponds to the bit depths of the original signal (e.g. int16 for CD audio). We, however, mostly operate on floats for many reasons.
I am not quite familiar with other transforms which may or may not assume signals being in the range [-1, 1]. Let me first fix this for the particular `lfilter` case. I will open a PR for adding a toggle. 

For your information, my real use-case is that I wanted to apply digital filters on a fundamental frequency sequence of speech. In speech synthesis, there are also other cases where we want to apply filters on signals not being in the range [-1, 1]. .e.g., mel-cepstrum, band aperiodicity features.

 

Resolving patch:
diff --git a/torchaudio/functional.py b/torchaudio/functional.py
--- a/torchaudio/functional.py
+++ b/torchaudio/functional.py
@@ -671,21 +671,23 @@ def phase_vocoder(
 def lfilter(
         waveform: Tensor,
         a_coeffs: Tensor,
-        b_coeffs: Tensor
+        b_coeffs: Tensor,
+        clamp: bool = True,
 ) -> Tensor:
     r"""Perform an IIR filter by evaluating difference equation.
 
     Args:
-        waveform (Tensor): audio waveform of dimension of `(..., time)`.  Must be normalized to -1 to 1.
-        a_coeffs (Tensor): denominator coefficients of difference equation of dimension of `(n_order + 1)`.
-                                Lower delays coefficients are first, e.g. `[a0, a1, a2, ...]`.
+        waveform (Tensor): audio waveform of dimension of ``(..., time)``.  Must be normalized to -1 to 1.
+        a_coeffs (Tensor): denominator coefficients of difference equation of dimension of ``(n_order + 1)``.
+                                Lower delays coefficients are first, e.g. ``[a0, a1, a2, ...]``.
                                 Must be same size as b_coeffs (pad with 0's as necessary).
-        b_coeffs (Tensor): numerator coefficients of difference equation of dimension of `(n_order + 1)`.
-                                 Lower delays coefficients are first, e.g. `[b0, b1, b2, ...]`.
+        b_coeffs (Tensor): numerator coefficients of difference equation of dimension of ``(n_order + 1)``.
+                                 Lower delays coefficients are first, e.g. ``[b0, b1, b2, ...]``.
                                  Must be same size as a_coeffs (pad with 0's as necessary).
+        clamp (bool, optional): If ``True``, clamp the output signal to be in the range [-1, 1] (Default: ``True``)
 
     Returns:
-        Tensor: Waveform with dimension of `(..., time)`.  Output will be clipped to -1 to 1.
+        Tensor: Waveform with dimension of ``(..., time)``.
     """
     # pack batch
     shape = waveform.size()
@@ -729,7 +731,10 @@ def lfilter(
         o0.addmv_(windowed_output_signal, a_coeffs_flipped, alpha=-1)
         padded_output_waveform[:, i_sample + n_order - 1] = o0
 
-    output = torch.clamp(padded_output_waveform[:, (n_order - 1):], min=-1., max=1.)
+    output = padded_output_waveform[:, (n_order - 1):]
+
+    if clamp:
+        output = torch.clamp(output, min=-1., max=1.)
 
     # unpack batch
     output = output.reshape(shape[:-1] + output.shape[-1:])


Test patch:
diff --git a/test/test_functional.py b/test/test_functional.py
--- a/test/test_functional.py
+++ b/test/test_functional.py
@@ -28,6 +28,15 @@ def test_simple(self):
 
         torch.testing.assert_allclose(output_waveform[:, 3:], waveform[:, 0:-3], atol=1e-5, rtol=1e-5)
 
+    def test_clamp(self):
+        input_signal = torch.ones(1, 44100 * 1, dtype=self.dtype, device=self.device)
+        b_coeffs = torch.tensor([1, 0], dtype=self.dtype, device=self.device)
+        a_coeffs = torch.tensor([1, -0.95], dtype=self.dtype, device=self.device)
+        output_signal = F.lfilter(input_signal, a_coeffs, b_coeffs, clamp=True)
+        self.assertTrue(output_signal.max() <= 1)
+        output_signal = F.lfilter(input_signal, a_coeffs, b_coeffs, clamp=False)
+        self.assertTrue(output_signal.max() > 1)
+
 
 class TestLfilterFloat32CPU(_LfilterMixin, unittest.TestCase):
     device = torch.device('cpu')