Repository: iver56/audiomentations
Pull request #385 (2025-04-02 09:04:43)

Apply smoother fade in TimeMask
We also default to fade=True from now on, to avoid unwanted clicks. That is a better default.

Close https://github.com/iver56/audiomentations/issues/380


Patch:
diff --git a/audiomentations/augmentations/repeat_part.py b/audiomentations/augmentations/repeat_part.py
index e219d81..96d23e5 100644
--- a/audiomentations/augmentations/repeat_part.py
+++ b/audiomentations/augmentations/repeat_part.py
@@ -6,7 +6,7 @@ import numpy as np
 from numpy.typing import NDArray
 
 from audiomentations.core.transforms_interface import BaseWaveformTransform
-from audiomentations.core.utils import get_crossfade_mask_pair
+from audiomentations.core.utils import get_crossfade_mask_pair, get_crossfade_length
 
 # 0.00025 seconds corresponds to 2 samples at 8000 Hz
 DURATION_EPSILON = 0.00025
@@ -126,7 +126,9 @@ class RepeatPart(BaseWaveformTransform):
                 int(self.max_part_duration * sample_rate),
             )
 
-            crossfade_length = self.get_crossfade_length(sample_rate)
+            crossfade_length = get_crossfade_length(
+                sample_rate, self.crossfade_duration
+            )
             half_crossfade_length = crossfade_length // 2
             if (
                 half_crossfade_length
@@ -147,20 +149,6 @@ class RepeatPart(BaseWaveformTransform):
                 self.min_repeats, self.max_repeats
             )
 
-    def get_crossfade_length(self, sample_rate: int) -> int:
-        if not self.crossfade:
-            return 0
-        crossfade_length = int(self.crossfade_duration * sample_rate)
-        if crossfade_length < 2:
-            warnings.warn(
-                "crossfade_duration is too small for the given sample rate. Using a"
-                " crossfade length of 2 samples."
-            )
-            crossfade_length = 2
-        elif crossfade_length % 2 == 1:
-            crossfade_length += 1
-        return crossfade_length
-
     def apply(
         self, samples: NDArray[np.float32], sample_rate: int
     ) -> NDArray[np.float32]:
@@ -170,7 +158,9 @@ class RepeatPart(BaseWaveformTransform):
         equal_energy_fade_out_mask = None
         last_crossfade_type = "equal_energy"
         if self.crossfade:
-            crossfade_length = self.get_crossfade_length(sample_rate)
+            crossfade_length = get_crossfade_length(
+                sample_rate, self.crossfade_duration
+            )
             half_crossfade_length = crossfade_length // 2
             equal_energy_fade_in_mask, equal_energy_fade_out_mask = (
                 get_crossfade_mask_pair(crossfade_length)
diff --git a/audiomentations/augmentations/time_mask.py b/audiomentations/augmentations/time_mask.py
index 5a5e787..71e5481 100644
--- a/audiomentations/augmentations/time_mask.py
+++ b/audiomentations/augmentations/time_mask.py
@@ -4,6 +4,7 @@ import numpy as np
 from numpy.typing import NDArray
 
 from audiomentations.core.transforms_interface import BaseWaveformTransform
+from audiomentations.core.utils import get_crossfade_mask_pair
 
 
 class TimeMask(BaseWaveformTransform):
@@ -18,7 +19,7 @@ class TimeMask(BaseWaveformTransform):
         self,
         min_band_part: float = 0.0,
         max_band_part: float = 0.5,
-        fade: bool = False,
+        fade: bool = True,
         p: float = 0.5,
     ):
         """
@@ -26,7 +27,7 @@ class TimeMask(BaseWaveformTransform):
             total sound length. Must be between 0.0 and 1.0
         :param max_band_part: Maximum length of the silent part as a fraction of the
             total sound length. Must be between 0.0 and 1.0
-        :param fade: When set to True, a linear fade-in and fade-out is added to the silent part.
+        :param fade: When set to True, a smooth fade-in and fade-out is added to the silent part.
             This can smooth out unwanted abrupt changes between consecutive samples, which might
             otherwise sound like transients/clicks/pops.
         :param p: The probability of applying this transform
@@ -58,11 +59,12 @@ class TimeMask(BaseWaveformTransform):
         new_samples = samples.copy()
         t = self.parameters["t"]
         t0 = self.parameters["t0"]
-        mask = np.zeros(t)
+        mask = np.zeros(t, dtype=np.float32)
         if self.fade:
             fade_length = min(int(sample_rate * 0.01), int(t * 0.1))
-            if fade_length:
-                mask[0:fade_length] = np.linspace(1, 0, num=fade_length)
-                mask[-fade_length:] = np.linspace(0, 1, num=fade_length)
+            if fade_length >= 2:
+                fade_in, fade_out = get_crossfade_mask_pair(fade_length, equal_energy=False)
+                mask[:fade_length] = fade_out
+                mask[-fade_length:] = fade_in
         new_samples[..., t0 : t0 + t] *= mask
         return new_samples
diff --git a/audiomentations/core/utils.py b/audiomentations/core/utils.py
index 742a39c..fca167a 100644
--- a/audiomentations/core/utils.py
+++ b/audiomentations/core/utils.py
@@ -1,5 +1,6 @@
 import math
 import os
+import warnings
 from functools import lru_cache
 from pathlib import Path
 from typing import List, Union, Tuple, Any
@@ -22,6 +23,7 @@ SUPPORTED_EXTENSIONS = (
     ".wav",
 )
 
+
 def format_args(args_dict: dict[str, Any]) -> str:
     formatted_args = []
     for k, v in args_dict.items():
@@ -29,6 +31,7 @@ def format_args(args_dict: dict[str, Any]) -> str:
         formatted_args.append(f"{k}={v_formatted}")
     return ", ".join(formatted_args)
 
+
 def find_audio_files(
     root_path,
     filename_endings=SUPPORTED_EXTENSIONS,
@@ -223,6 +226,21 @@ def get_crossfade_mask_pair(
     return fade_in, fade_out
 
 
+def get_crossfade_length(sample_rate: int, crossfade_duration: float) -> int:
+    if crossfade_duration == 0.0:
+        return 0
+    crossfade_length = int(crossfade_duration * sample_rate)
+    if crossfade_length < 2:
+        warnings.warn(
+            "crossfade_duration is too small for the given sample rate. Using a"
+            " crossfade length of 2 samples."
+        )
+        crossfade_length = 2
+    elif crossfade_length % 2 == 1:
+        crossfade_length += 1
+    return crossfade_length
+
+
 def get_max_abs_amplitude(samples: NDArray):
     min_amplitude, max_amplitude = numpy_minmax.minmax(samples)
     max_abs_amplitude = max(abs(min_amplitude), abs(max_amplitude))
diff --git a/demo/demo.py b/demo/demo.py
index 7173ac5..132c6c9 100644
--- a/demo/demo.py
+++ b/demo/demo.py
@@ -487,7 +487,8 @@ if __name__ == "__main__":
             "name": "ShiftSeconds",
         },
         {"instance": TanhDistortion(p=1.0), "num_runs": 5},
-        {"instance": TimeMask(p=1.0), "num_runs": 5},
+        {"instance": TimeMask(fade=True, p=1.0), "num_runs": 5, "name": "TimeMaskWithFade"},
+        {"instance": TimeMask(fade=False, p=1.0), "num_runs": 5, "name": "TimeMaskWithoutFade"},
         {
             "instance": TimeStretch(
                 min_rate=0.8, max_rate=1.25, method="signalsmith_stretch", p=1.0


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index df8d005..f838c37 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -10,6 +10,7 @@ from audiomentations.core.utils import (
     calculate_rms,
     calculate_rms_without_silence,
     a_weighting_frequency_envelope,
+    get_crossfade_length,
 )
 from demo.demo import DEMO_DIR
 
@@ -109,3 +110,15 @@ def test_a_weighting_curve(sample_rate, n_fft):
     assert a_weighting_frequency_envelope(n_fft, sample_rate) == pytest.approx(
         weighting, abs=0.01
     )
+
+
+def test_get_crossfade_duration_too_short_duration():
+    with pytest.warns(UserWarning, match="crossfade_duration is too small"):
+        result = get_crossfade_length(sample_rate=44_100, crossfade_duration=0.00001)
+    assert result == 2
+
+
+def test_get_crossfade_duration_odd_length_case():
+    result = get_crossfade_length(sample_rate=10, crossfade_duration=0.3)
+    # The function should add 1 to 3, resulting in an even value of 4.
+    assert result == 4