Repository: audeering/auglib

Issue:
Handle read-only input signals
A `np.ndarray` can be flagged as read-only with the `writeable` [flag](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.flags.html).

The [example](https://audeering.github.io/auglib/external.html#sox) in the documentation using `sox` for pitch shift creates such a signal. This can lead to an error when combining multiple transforms, for example:

```python
import numpy as np

import audb
import auglib
import audiofile

def sox_transform(
    signal: np.array,
    sampling_rate: int,
):
    import sox

    tfm = sox.Transformer()
    tfm.pitch(2)
    signal_augmented = tfm.build_array(
        input_array=signal.squeeze(),
        sample_rate_in=sampling_rate,
    )
    return signal_augmented

transform = auglib.transform.Compose(
    [
        auglib.transform.Function(
            sox_transform,
        ),
        auglib.transform.WhiteNoiseGaussian(snr_db=20),
    ]
)
files = audb.load_media(
    "emodb",
    "wav/03a01Fa.wav",
    version="1.4.1",
    verbose=False,
)
signal, sampling_rate = audiofile.read(files[0])
signal_aug = transform(signal, sampling_rate)

```
which leads to the error
```
...
    signal += from_db(gain_db) * noise_generator.normal(0, stddev, signal.shape)
ValueError: output array is read-only
```

This could probably be solved by not using `+=` but `signal = signal +` for all transforms.


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/audeering/auglib/pull/35?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.0%. Comparing base [(`aa87c89`)](https://app.codecov.io/gh/audeering/auglib/commit/aa87c89d40f9fa5a5afd5a1ace53c029b4a5770b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering) to head [(`94b3413`)](https://app.codecov.io/gh/audeering/auglib/commit/94b3413016814663b6c0cb80e20ae11e2dfd2e06?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering).

<details><summary>Additional details and impacted files</summary>


| [Files](https://app.codecov.io/gh/audeering/auglib/pull/35?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering) | Coverage Δ | |
|---|---|---|
| [auglib/core/transform.py](https://app.codecov.io/gh/audeering/auglib/pull/35?src=pr&el=tree&filepath=auglib%2Fcore%2Ftransform.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering#diff-YXVnbGliL2NvcmUvdHJhbnNmb3JtLnB5) | `100.0% <100.0%> (ø)` | |

</details>

Patch:
diff --git a/auglib/core/transform.py b/auglib/core/transform.py
index 7387129..9f85edb 100644
--- a/auglib/core/transform.py
+++ b/auglib/core/transform.py
@@ -203,6 +203,11 @@ class Base(audobject.Object):
             if signal.dtype != DTYPE:
                 signal = signal.astype(DTYPE)
 
+            # Ensure signal is not read-only
+            # (https://github.com/audeering/auglib/issues/31)
+            if not signal.flags["WRITEABLE"]:
+                signal = signal.copy()
+
             if preserve_level:
                 signal_level = rms_db(signal)
 


Test patch:
diff --git a/tests/test_read_only_array.py b/tests/test_read_only_array.py
new file mode 100644
index 0000000..33b5333
--- /dev/null
+++ b/tests/test_read_only_array.py
@@ -0,0 +1,91 @@
+import numpy as np
+import pytest
+
+import auglib
+
+
+auglib.seed(0)
+
+
+def identity(signal, sampling_rate):
+    return signal
+
+
+def read_only(
+    signal: np.array,
+    sampling_rate: int,
+):
+    signal.setflags(write=False)
+    return signal
+
+
+@pytest.mark.parametrize("signal", [[1, 1]])
+@pytest.mark.parametrize("sampling_rate", [8000])
+@pytest.mark.parametrize(
+    "transform",
+    [
+        auglib.transform.AMRNB(4750),
+        auglib.transform.Append(np.ones((1, 1))),
+        auglib.transform.AppendValue(1, unit="samples"),
+        auglib.transform.BabbleNoise([np.ones((1, 2))]),
+        auglib.transform.BandPass(1000, 200),
+        auglib.transform.BandStop(1000, 200),
+        auglib.transform.Clip(),
+        auglib.transform.ClipByRatio(0.05),
+        auglib.transform.CompressDynamicRange(-15, 1 / 4),
+        auglib.transform.Fade(in_dur=0.2, out_dur=0.7),
+        auglib.transform.FFTConvolve(np.ones((1, 1))),
+        auglib.transform.Function(identity),
+        auglib.transform.GainStage(3),
+        auglib.transform.HighPass(3000),
+        auglib.transform.LowPass(100),
+        auglib.transform.Mask(auglib.transform.Clip()),
+        auglib.transform.Mix(np.ones((1, 1))),
+        auglib.transform.NormalizeByPeak(),
+        auglib.transform.PinkNoise(),
+        auglib.transform.Prepend(np.ones((1, 1))),
+        auglib.transform.PrependValue(1, unit="samples"),
+        auglib.transform.Resample(4000),
+        auglib.transform.Shift(1, unit="samples"),
+        auglib.transform.Tone(100),
+        auglib.transform.Trim(start_pos=0, end_pos=1, unit="samples"),
+        auglib.transform.WhiteNoiseGaussian(),
+        auglib.transform.WhiteNoiseUniform(),
+    ],
+)
+def test_compose_read_only(
+    signal: np.array,
+    sampling_rate: int,
+    transform: auglib.transform.Base,
+):
+    r"""Test applying transform on read-only array.
+
+    Certain custom transforms
+    (e.g. when using sox.Transformer)
+    can return numpy arrays in read-only mode.
+
+    If other transforms try to write to this array,
+    without making a copy first,
+    they will fail, see
+    https://github.com/audeering/auglib/issues/31
+
+    Args:
+        signal: signal
+        sampling_rate: sampling rate in Hz
+        transform: transform
+
+    """
+    signal = np.array(signal, dtype=auglib.core.transform.DTYPE)
+
+    # Apply transform to read-only signal
+    signal.setflags(write=False)
+    augmented_signal = transform(signal, sampling_rate)
+    assert augmented_signal.flags["WRITEABLE"]
+
+    # Apply transform in compose
+    # after transform that makes signal read-only
+    compose_transform = auglib.transform.Compose(
+        [auglib.transform.Function(read_only), transform]
+    )
+    augmented_signal = compose_transform(signal, sampling_rate)
+    assert augmented_signal.flags["WRITEABLE"]