Repository: audeering/auglib

Issue:
Incorrect order of segments for `Augment.augment()`
`Augment.augment()` can return the augmented segments in a different order than the input order.

Example:

```python
import pandas as pd

import audb
import audformat
import auglib


db = audb.load(
    "emodb",
    media=["wav/03a01Fa.wav", "wav/03a01Nc.wav"],
    version="1.4.1",
    verbose=False,
    full_path=False,
)
df = pd.DataFrame(
    index=audformat.segmented_index(
        files=[db.files[0], db.files[1], db.files[0], db.files[1]],
        starts=[0.5, 0, 0, 0.5],
        ends=[1.0, 0.5, 0.5, 1.0],
    ),
)
print(df.index.get_level_values("file"))

transform = auglib.transform.PinkNoise(snr_db=10)
augment = auglib.Augment(transform)

augmented_df = augment.augment(
    df, data_root=db.root, remove_root=db.root, cache_root="cache"
)
print(augmented_df.index.get_level_values("file"))

```

Prints
```
Index(['wav/03a01Fa.wav', 'wav/03a01Nc.wav', 'wav/03a01Fa.wav',
       'wav/03a01Nc.wav'],
      dtype='string', name='file')
Index(['/home/user/cache/2b02f9b8/4915663582982473074/0/wav/03a01Fa-0.wav',
       '/home/user/cache/2b02f9b8/4915663582982473074/0/wav/03a01Fa-1.wav',
       '/home/user/cache/2b02f9b8/4915663582982473074/0/wav/03a01Nc-0.wav',
       '/home/user/cache/2b02f9b8/4915663582982473074/0/wav/03a01Nc-1.wav'],
      dtype='string', name='file')
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/audeering/auglib/pull/36?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 [(`b72762f`)](https://app.codecov.io/gh/audeering/auglib/commit/b72762f60a39018e8f1588929134a3656ee58bd6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering).
> Report is 1 commits behind head on main.

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


| [Files](https://app.codecov.io/gh/audeering/auglib/pull/36?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/interface.py](https://app.codecov.io/gh/audeering/auglib/pull/36?src=pr&el=tree&filepath=auglib%2Fcore%2Finterface.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering#diff-YXVnbGliL2NvcmUvaW50ZXJmYWNlLnB5) | `100.0% <100.0%> (ø)` | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/audeering/auglib/pull/36/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=audeering)

</details>

Patch:
diff --git a/auglib/core/interface.py b/auglib/core/interface.py
index c7bb2ad..113765b 100644
--- a/auglib/core/interface.py
+++ b/auglib/core/interface.py
@@ -377,111 +377,70 @@ class Augment(audinterface.Process, audobject.Object):
         remove_root: str,
         description: str,
     ) -> pd.Index:
-        r"""Augment segments and store augmented files to cache."""
-        files = index.get_level_values(0).unique()
-        augmented_files = _augmented_files(
-            files,
-            cache_root,
-            remove_root,
-        )
+        r"""Augment segments and store augmented files to cache.
+
+        Args:
+            index: segmented index
+            cache_root: cache root for augmented files
+            remove_root: directory that should be removed from
+                the beginning of the original file path before joining with
+                ``cache_root``
+            description: text to display in progress bar
+
+        Returns:
+            segmented index of augmented files
+
+        """
+        files = index.get_level_values("file")
+        starts = index.get_level_values("start")
+        ends = index.get_level_values("end")
+        augmented_files = _augmented_files(files, cache_root, remove_root)
         params = [
-            (
-                (
-                    file,
-                    out_file,
-                    index[index.get_level_values(0) == file].droplevel(
-                        0
-                    ),  # start, end values for given file
-                ),
-                {},
-            )
-            for file, out_file in zip(files, augmented_files)
+            ((file, start, end, out_file), {})
+            for file, start, end, out_file in zip(files, starts, ends, augmented_files)
         ]
-
-        verbose = self.verbose
-        self.verbose = False  # avoid nested progress bar
-        augmented_indices = audeer.run_tasks(
+        durations = audeer.run_tasks(
             self._augment_file_to_cache,
             params,
             num_workers=self.num_workers,
             multiprocessing=self.multiprocessing,
-            progress_bar=verbose,
+            progress_bar=self.verbose,
             task_description=description,
         )
-        self.verbose = verbose
-
-        augmented_index = audformat.utils.union(augmented_indices)
-
-        return augmented_index
-
-    def _augment_file(
-        self,
-        file: str,
-        index: pd.Index,
-    ) -> typing.Tuple[typing.List, typing.List, typing.List, int]:
-        r"""Augment file at every segment."""
-        signal, sampling_rate = audiofile.read(file, always_2d=True)
-        signals, starts, ends, augmented_rate = self._augment_signal(
-            signal,
-            sampling_rate,
-            index,
+        augmented_index = audformat.segmented_index(
+            augmented_files,
+            [0] * len(durations),
+            durations,
         )
-        return signals, starts, ends, augmented_rate
+        return augmented_index
 
     def _augment_file_to_cache(
         self,
         file: str,
+        start: pd.Timedelta,
+        end: pd.Timedelta,
         augmented_file: str,
-        index: pd.Index,  # containing (several) start, end values
-    ) -> pd.Index:
+    ) -> float:
         r"""Augment file and store to cache.
 
-        Store augmented signals in separate files,
-        adding a counter at the end of the filename:
-
-            <augmented_file>-0
-            <augmented_file>-1
-            ...
-
-        If we have more than 10 files,
-        the counter will use two digits:
+        Before augmenting the file,
+        it is also resampled,
+        or remixed,
+        if required.
 
-            <augmented_file>-00
-            <augmented_file>-01
-            ...
+        Args:
+            file: path to incoming audio file
+            start: start time to read ``file``
+            end: end time to read ``file``
+            augmented_file: path of augmented file
 
-        and so on.
+        Returns:
+            duration of augmented file in seconds
 
         """
-        signals, starts, ends, sampling_rate = self._augment_file(file, index)
-
-        audeer.mkdir(os.path.dirname(augmented_file))
-        if len(signals) > 1:
-            # number of needed digits for file names
-            digits = len(str(len(signals) - 1))
-            root, ext = os.path.splitext(augmented_file)
-            files = [f"{root}-{str(n).zfill(digits)}{ext}" for n in range(len(signals))]
-        else:
-            files = [augmented_file]
-        for file, signal in zip(files, signals):
-            audiofile.write(file, signal, sampling_rate)
-
-        # insert augmented file name at first level
-        augmented_index_with_file = audformat.segmented_index(
-            files,
-            starts,
-            ends,
+        signal, sampling_rate = audinterface.utils.read_audio(
+            file, start=start, end=end
         )
-
-        return augmented_index_with_file
-
-    def _augment_signal(
-        self,
-        signal: np.ndarray,
-        sampling_rate: int,
-        index: pd.Index,
-    ) -> typing.Tuple[typing.List, typing.List, typing.List, int]:
-        r"""Augment signal at every segment in index."""
         signal, sampling_rate = preprocess_signal(
             signal,
             sampling_rate=sampling_rate,
@@ -490,17 +449,11 @@ class Augment(audinterface.Process, audobject.Object):
             channels=self.channels,
             mixdown=self.mixdown,
         )
-        y = self.process_signal_from_index(signal, sampling_rate, index)
-        # adjust index to always start at 0
-        # and end at NaT or duration
-        signals = list(y.values)
-        starts = [0] * len(signals)
-        ends = y.index.get_level_values("end")
-        ends = [
-            end if pd.isna(end) else signal.shape[1] / sampling_rate
-            for signal, end in zip(signals, ends)
-        ]
-        return signals, starts, ends, sampling_rate
+        augmented_signal = self(signal, sampling_rate)
+        audeer.mkdir(os.path.dirname(augmented_file))
+        audiofile.write(augmented_file, augmented_signal, sampling_rate)
+        duration = augmented_signal.shape[1] / sampling_rate
+        return duration
 
     @staticmethod
     def _process_func(
@@ -555,8 +508,55 @@ def _augmented_files(
     files: typing.Sequence[str],
     cache_root: str,
     remove_root: str = None,
-) -> typing.Sequence[str]:
-    r"""Return cache file names by joining with the cache directory."""
+) -> typing.List[str]:
+    r"""Return destination path for augmented files.
+
+    If files contain the same filename several times,
+    e.g. when augmenting segments,
+    it will convert them into unique filenames:
+
+        <augmented_file>-0
+        <augmented_file>-1
+        ...
+
+    As segments are stored as single files.
+
+    If we have more than 10 files,
+    the counter will use two digits:
+
+        <augmented_file>-00
+        <augmented_file>-01
+        ...
+
+    and so on.
+
+    Args:
+        files: files to augment
+        cache_root: cache root of augmented files
+        remove_root: directory that should be removed from
+            the beginning of the original file path before joining with
+            ``cache_root``
+
+    Returns:
+        path of augmented files
+
+    """
+    # Estimate number of segments/samples for each file
+    unique_files, counts = np.unique(files, return_counts=True)
+    counts = {file: count for file, count in zip(unique_files, counts)}
+    current_count = {file: 0 for file in unique_files}
+
+    augmented_files = []
+    for file in files:
+        if counts[file] > 1:
+            digits = len(str(counts[file] - 1))
+            root, ext = os.path.splitext(file)
+            augmented_file = f"{root}-{str(current_count[file]).zfill(digits)}{ext}"
+        else:
+            augmented_file = file
+        current_count[file] += 1
+        augmented_files.append(augmented_file)
+
     if remove_root is None:
 
         def join(path1: str, path2: str) -> str:
@@ -566,14 +566,16 @@ def _augmented_files(
                 os.path.splitdrive(path2)[1].lstrip(seps),
             )
 
-        augmented_files = [join(cache_root, file) for file in files]
+        augmented_files = [join(cache_root, file) for file in augmented_files]
     else:
         remove_root = audeer.path(remove_root)
-        dirs = [os.path.dirname(file) for file in files]
+        dirs = [os.path.dirname(file) for file in unique_files]
         common_root = audeer.common_directory(dirs)
         if not audeer.common_directory([remove_root, common_root]) == remove_root:
             raise RuntimeError(
                 f"Cannot remove '{remove_root}' " f"from '{common_root}'."
             )
-        augmented_files = [file.replace(remove_root, cache_root, 1) for file in files]
+        augmented_files = [
+            file.replace(remove_root, cache_root, 1) for file in augmented_files
+        ]
     return augmented_files


Test patch:
diff --git a/tests/test_interface.py b/tests/test_interface.py
index 3f35a6c..796d47d 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -374,6 +374,29 @@ import auglib
                 np.ones((1, 1)),
             ),
         ),
+        # Unsorted index
+        (
+            audformat.segmented_index(
+                ["f1.wav", "f2.wav", "f1.wav", "f2.wav"],
+                [0.2, 0.0, 0.0, 0.3],
+                [1.0, 0.3, 0.2, 1.0],
+            ),
+            np.array([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], dtype="float32"),
+            10,
+            auglib.transform.Function(lambda x, _: x + 1),
+            True,
+            audformat.segmented_index(
+                ["f1-0.wav", "f2-0.wav", "f1-1.wav", "f2-1.wav"],
+                [0, 0, 0, 0],
+                [0.8, 0.3, 0.2, 0.7],
+            ),
+            (
+                np.array([[3, 4, 5, 6, 7, 8, 9, 10]], dtype="float32"),
+                np.array([[1, 2, 3]], dtype="float32"),
+                np.array([[1, 2]], dtype="float32"),
+                np.array([[4, 5, 6, 7, 8, 9, 10]], dtype="float32"),
+            ),
+        ),
     ],
 )
 def test_augment(