From b51d5776dc978c17504e787fbb1f7aa062148317 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Thu, 23 Jul 2026 15:09:40 +0200 Subject: [PATCH 1/8] Add test for preserving order of index --- tests/test_process.py | 93 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/test_process.py b/tests/test_process.py index cd0f764..42a8452 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -49,6 +49,20 @@ def signal_modification(signal, sampling_rate, subtract=False): return signal +def index_durations(index: pd.Index, root: str = None) -> pd.Series: + r"""Get series with duration of all index elements.""" + if len(index) == 0: + return pd.Series(index=index) + segmented_index = audformat.utils.to_segmented_index( + index, allow_nat=False, root=root + ) + starts = segmented_index.get_level_values(audformat.define.IndexField.START) + ends = segmented_index.get_level_values(audformat.define.IndexField.END) + durations = (ends - starts).to_series() + durations.index = index + return durations + + @pytest.mark.parametrize( "process_func, segment, signal, sampling_rate, start, end, keep_nat, " "channels, mixdown, expected_output", @@ -390,7 +404,7 @@ def test_process_file( @pytest.mark.parametrize( - "process_func, num_files, signal, sampling_rate, starts, ends, " "expected_output", + "process_func, num_files, signal, sampling_rate, starts, ends, expected_output", [ ( signal_duration, @@ -710,6 +724,81 @@ def test_process_index(tmpdir, num_workers, multiprocessing, preserve_index): pd.testing.assert_series_equal(y, y_cached) +@pytest.mark.parametrize( + "index,durations", + [ + (audformat.filewise_index([f"f{i}.wav" for i in range(10)]), [1.0] * 10), + ( + audformat.segmented_index( + files=[f"f{i}.wav" for i in range(5)], + starts=[0, 0, 1, 1, 2], + ends=[1, 1, 2, 2, 3], + ), + [1.0, 2.0, 3.0, 3.0, 3.0], + ), + ], +) +def test_process_index_order(tmpdir, index, durations): + cache_root = os.path.join(tmpdir, "cache") + process = audinterface.Process( + process_func=None, + sampling_rate=None, + resample=False, + verbose=False, + ) + # Create signals for the index + sampling_rate = 8000 + signals = [ + np.random.uniform(-1.0, 1.0, (1, int(duration * sampling_rate))) + for duration in durations + ] + root = str(tmpdir.mkdir("wav")) + paths = [] + files = [] + for i, signal in enumerate(signals): + if audformat.is_segmented_index(index): + file, _, _ = index[i] + else: + file = index[i] + path = os.path.join(root, file) + af.write(path, signal, sampling_rate) + paths.append(file) + files.append(file) + + # Run process once with caching + process.process_index(index, root=root, cache_root=cache_root) + + # Run process again but on reverse index + reverse_index = index[::-1] + reverse_durations = index_durations(reverse_index, root=root) + y_reverse = process.process_index(reverse_index, root=root, cache_root=cache_root) + + # Make sure the index is as expected + expected_index = audformat.segmented_index( + files=files[::-1], + starts=[0] * len(reverse_index), + ends=reverse_durations.values, + ) + pd.testing.assert_index_equal(y_reverse.index, expected_index) + # Make sure the files contain the expected signals + for (path, start, end), value in y_reverse.items(): + signal, sampling_rate = audinterface.utils.read_audio( + path, start=start, end=end, root=root + ) + np.testing.assert_equal(signal, value) + + # Run process again on reverse index but with preserve_index=True + y_reverse = process.process_index( + reverse_index, root=root, cache_root=cache_root, preserve_index=True + ) + # Make sure the index order is equal to the input order + pd.testing.assert_index_equal(y_reverse.index, reverse_index) + # Make sure the files contain the expected signals + for i, (path, value) in enumerate(y_reverse.items()): + signal, sampling_rate = audinterface.utils.read_audio(path, root=root) + np.testing.assert_equal(signal, value) + + def test_process_index_filewise_end_times(tmpdir): # Ensure the resulting segmented index # returned by audinterface.process_index() @@ -1138,7 +1227,7 @@ def test_process_signal_from_index( @pytest.mark.parametrize( - "process_func, signal, sampling_rate, min_signal_dur, " "max_signal_dur, expected", + "process_func, signal, sampling_rate, min_signal_dur, max_signal_dur, expected", [ ( None, From 4c7238dc8fdebb8855b2993eb18f02fff0b5f3ce Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Thu, 23 Jul 2026 15:40:13 +0200 Subject: [PATCH 2/8] Fix and simplify test --- tests/test_process.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/test_process.py b/tests/test_process.py index 42a8452..e955a94 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -738,7 +738,8 @@ def test_process_index(tmpdir, num_workers, multiprocessing, preserve_index): ), ], ) -def test_process_index_order(tmpdir, index, durations): +@pytest.mark.parametrize("preserve_index", [False, True]) +def test_process_index_order(tmpdir, index, durations, preserve_index): cache_root = os.path.join(tmpdir, "cache") process = audinterface.Process( process_func=None, @@ -755,8 +756,9 @@ def test_process_index_order(tmpdir, index, durations): root = str(tmpdir.mkdir("wav")) paths = [] files = [] + is_segmented_index = audformat.is_segmented_index(index) for i, signal in enumerate(signals): - if audformat.is_segmented_index(index): + if is_segmented_index: file, _, _ = index[i] else: file = index[i] @@ -771,33 +773,31 @@ def test_process_index_order(tmpdir, index, durations): # Run process again but on reverse index reverse_index = index[::-1] reverse_durations = index_durations(reverse_index, root=root) - y_reverse = process.process_index(reverse_index, root=root, cache_root=cache_root) - - # Make sure the index is as expected - expected_index = audformat.segmented_index( - files=files[::-1], - starts=[0] * len(reverse_index), - ends=reverse_durations.values, + y_reverse = process.process_index( + reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index ) + if preserve_index or is_segmented_index: + expected_index = reverse_index + else: + # Make sure the index is as expected + expected_index = audformat.segmented_index( + files=files[::-1], + starts=[0] * len(reverse_index), + ends=reverse_durations.values, + ) pd.testing.assert_index_equal(y_reverse.index, expected_index) # Make sure the files contain the expected signals - for (path, start, end), value in y_reverse.items(): + for idx, value in y_reverse.items(): + if not preserve_index or is_segmented_index: + path, start, end = idx + else: + path = idx + start = end = None signal, sampling_rate = audinterface.utils.read_audio( path, start=start, end=end, root=root ) np.testing.assert_equal(signal, value) - # Run process again on reverse index but with preserve_index=True - y_reverse = process.process_index( - reverse_index, root=root, cache_root=cache_root, preserve_index=True - ) - # Make sure the index order is equal to the input order - pd.testing.assert_index_equal(y_reverse.index, reverse_index) - # Make sure the files contain the expected signals - for i, (path, value) in enumerate(y_reverse.items()): - signal, sampling_rate = audinterface.utils.read_audio(path, root=root) - np.testing.assert_equal(signal, value) - def test_process_index_filewise_end_times(tmpdir): # Ensure the resulting segmented index From 39be1a747aef5dcb7668d06d36d2a77f6227317e Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Thu, 23 Jul 2026 15:40:48 +0200 Subject: [PATCH 3/8] Use strict index hash --- audinterface/core/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audinterface/core/process.py b/audinterface/core/process.py index f8e9789..f42d921 100644 --- a/audinterface/core/process.py +++ b/audinterface/core/process.py @@ -603,7 +603,7 @@ def process_index( if cache_root is not None: cache_root = audeer.mkdir(cache_root) - hash = audformat.utils.hash(index) + hash = audformat.utils.hash(index, strict=True) cache_path = os.path.join(cache_root, f"{hash}.pkl") if cache_path and os.path.exists(cache_path): From 87f9ebc39429459cb7d58e3ef85f3d4081d75fc3 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Thu, 23 Jul 2026 15:45:21 +0200 Subject: [PATCH 4/8] Remove unused variable --- tests/test_process.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_process.py b/tests/test_process.py index e955a94..2709c32 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -754,7 +754,6 @@ def test_process_index_order(tmpdir, index, durations, preserve_index): for duration in durations ] root = str(tmpdir.mkdir("wav")) - paths = [] files = [] is_segmented_index = audformat.is_segmented_index(index) for i, signal in enumerate(signals): @@ -764,7 +763,6 @@ def test_process_index_order(tmpdir, index, durations, preserve_index): file = index[i] path = os.path.join(root, file) af.write(path, signal, sampling_rate) - paths.append(file) files.append(file) # Run process once with caching From 10c6772080a5a42f45c894f268a24711c51e18c4 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Thu, 23 Jul 2026 15:48:53 +0200 Subject: [PATCH 5/8] Add clarifying comment --- tests/test_process.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_process.py b/tests/test_process.py index 2709c32..eae9531 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -740,6 +740,9 @@ def test_process_index(tmpdir, num_workers, multiprocessing, preserve_index): ) @pytest.mark.parametrize("preserve_index", [False, True]) def test_process_index_order(tmpdir, index, durations, preserve_index): + # Ensure the returned index is as expected + # when the same index but different order have already been cached + # https://github.com/audeering/audinterface/issues/203 cache_root = os.path.join(tmpdir, "cache") process = audinterface.Process( process_func=None, @@ -774,10 +777,10 @@ def test_process_index_order(tmpdir, index, durations, preserve_index): y_reverse = process.process_index( reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index ) + # Make sure the index is as expected if preserve_index or is_segmented_index: expected_index = reverse_index else: - # Make sure the index is as expected expected_index = audformat.segmented_index( files=files[::-1], starts=[0] * len(reverse_index), From c2917f287fc0c379ba556b819e42b8ad828bb932 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Fri, 24 Jul 2026 09:04:31 +0200 Subject: [PATCH 6/8] Test if two files exist in cache --- tests/test_process.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_process.py b/tests/test_process.py index eae9531..32ddf7d 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -767,13 +767,14 @@ def test_process_index_order(tmpdir, index, durations, preserve_index): path = os.path.join(root, file) af.write(path, signal, sampling_rate) files.append(file) + segment_durations = index_durations(index, root=root) # Run process once with caching process.process_index(index, root=root, cache_root=cache_root) # Run process again but on reverse index reverse_index = index[::-1] - reverse_durations = index_durations(reverse_index, root=root) + reverse_durations = segment_durations[::-1] y_reverse = process.process_index( reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index ) @@ -799,6 +800,11 @@ def test_process_index_order(tmpdir, index, durations, preserve_index): ) np.testing.assert_equal(signal, value) + # Make sure there is one cache file for the original index + # and one file for the reversed index + files_in_cache = audeer.list_file_names(cache_root) + assert len(files_in_cache) == 2 + def test_process_index_filewise_end_times(tmpdir): # Ensure the resulting segmented index From 79b06ccdf820aa14bcbd920d6acd994801862031 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Fri, 24 Jul 2026 09:05:13 +0200 Subject: [PATCH 7/8] Add test for Feature --- tests/test_feature.py | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_feature.py b/tests/test_feature.py index e529c8f..4db9595 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -30,6 +30,20 @@ def segment(signal, sampling_rate): SEGMENT = audinterface.Segment(process_func=segment) +def index_durations(index: pd.Index, root: str = None) -> pd.Series: + r"""Get series with duration of all index elements.""" + if len(index) == 0: + return pd.Series(index=index) + segmented_index = audformat.utils.to_segmented_index( + index, allow_nat=False, root=root + ) + starts = segmented_index.get_level_values(audformat.define.IndexField.START) + ends = segmented_index.get_level_values(audformat.define.IndexField.END) + durations = (ends - starts).to_series() + durations.index = index + return durations + + def feature_extractor(signal, _): return np.ones((NUM_CHANNELS, NUM_FEATURES)) @@ -531,6 +545,78 @@ def test_process_index(tmpdir, preserve_index): pd.testing.assert_frame_equal(df, df_cached) +@pytest.mark.parametrize( + "index,durations", + [ + (audformat.filewise_index([f"f{i}.wav" for i in range(10)]), [1.0] * 10), + ( + audformat.segmented_index( + files=[f"f{i}.wav" for i in range(5)], + starts=[0, 0, 1, 1, 2], + ends=[1, 1, 2, 2, 3], + ), + [1.0, 2.0, 3.0, 3.0, 3.0], + ), + ], +) +@pytest.mark.parametrize("preserve_index", [False, True]) +def test_process_index_order(tmpdir, index, durations, preserve_index): + # Ensure the returned index is as expected + # when the same index but different order have already been cached + # https://github.com/audeering/audinterface/issues/203 + cache_root = os.path.join(tmpdir, "cache") + feature = audinterface.Feature( + feature_names="mean", + process_func=mean, + ) + # Create signals for the index + sampling_rate = 8000 + signals = [ + np.random.uniform(-1.0, 1.0, (1, int(duration * sampling_rate))) + for duration in durations + ] + root = str(tmpdir.mkdir("wav")) + files = [] + is_segmented_index = audformat.is_segmented_index(index) + for i, signal in enumerate(signals): + if is_segmented_index: + file, _, _ = index[i] + else: + file = index[i] + path = os.path.join(root, file) + af.write(path, signal, sampling_rate) + files.append(file) + segment_durations = index_durations(index, root=root) + + # Run process once with caching + df = feature.process_index(index, root=root, cache_root=cache_root) + + # Run process again but on reverse index + reverse_index = index[::-1] + reverse_durations = segment_durations[::-1] + df_reverse = feature.process_index( + reverse_index, root=root, cache_root=cache_root, preserve_index=preserve_index + ) + # Make sure the resulting reverse features are as expected + if preserve_index or is_segmented_index: + expected_index = reverse_index + else: + expected_index = audformat.segmented_index( + files=files[::-1], + starts=[0] * len(reverse_index), + ends=reverse_durations.values, + ) + expected_df = pd.DataFrame( + index=expected_index, data={col: df[col].values[::-1] for col in df.columns} + ) + pd.testing.assert_frame_equal(df_reverse, expected_df) + + # Make sure there is one cache file for the original index + # and one file for the reversed index + files_in_cache = audeer.list_file_names(cache_root) + assert len(files_in_cache) == 2 + + @pytest.mark.parametrize( "process_func, applies_sliding_window, num_feat, signal, start, end, " "is_mono, expected", From 6f288da17489ddbe104304db8dea796381001bf9 Mon Sep 17 00:00:00 2001 From: Anna Derington Date: Fri, 24 Jul 2026 09:08:40 +0200 Subject: [PATCH 8/8] Use strict=True in Feature --- audinterface/core/feature.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/audinterface/core/feature.py b/audinterface/core/feature.py index a6cb34f..8d98235 100644 --- a/audinterface/core/feature.py +++ b/audinterface/core/feature.py @@ -545,7 +545,7 @@ def process_index( if cache_root is not None: cache_root = audeer.mkdir(cache_root) - hash = audformat.utils.hash(index) + hash = audformat.utils.hash(index, strict=True) cache_path = os.path.join(cache_root, f"{hash}.pkl") if cache_path and os.path.exists(cache_path): @@ -731,7 +731,7 @@ def _reshape_3d(self, features: np.ndarray | pd.Series): n_frames = features.shape[1] else: raise RuntimeError( - f"Cannot determine feature shape from " f"{features.shape}, ", + f"Cannot determine feature shape from {features.shape}, ", f"when expected shape is " f"({self.num_channels, self.num_features, -1}).", ) @@ -743,17 +743,11 @@ def _reshape_3d(self, features: np.ndarray | pd.Series): # assert channels and features have expected length if n_channels != self.num_channels: raise RuntimeError( - f"Number of channels must be" - f" {self.num_channels}, " - f"not " - f"{n_channels}." + f"Number of channels must be {self.num_channels}, not {n_channels}." ) if n_features != self.num_features: raise RuntimeError( - f"Number of features must be " - f"{self.num_features}, " - f"not " - f"{n_features}." + f"Number of features must be {self.num_features}, not {n_features}." ) # reshape features to (channels, features, frames) @@ -857,9 +851,7 @@ def _values_to_frame( features = features.reshape(new_shape).T if n_frames > 1 and self.win_dur is None: - raise RuntimeError( - f"Got " f"{n_frames} " f"frames, but 'win_dur' is not set." - ) + raise RuntimeError(f"Got {n_frames} frames, but 'win_dur' is not set.") return features