bmcfee: Note: coverage percentage decrease here is because the rewrite of `interpolate_intervals` is shorter than the previous implementation.
bmcfee: > Nice, elegant solution. Can you add a test which checks that it has the expected behavior with non-contiguous intervals?

Done.
diff --git a/mir_eval/util.py b/mir_eval/util.py
index 1df1142..0ce9130 100644
--- a/mir_eval/util.py
+++ b/mir_eval/util.py
@@ -129,11 +129,11 @@ def intervals_to_samples(intervals, labels, offset=0, sample_size=0.1,
 def interpolate_intervals(intervals, labels, time_points, fill_value=None):
     """Assign labels to a set of points in time given a set of intervals.
 
-    Note: Times outside of the known boundaries are mapped to None by default.
+    Time points that do not lie within an interval are mapped to `fill_value`.
 
     Parameters
     ----------
-    intervals : np.ndarray, shape=(n, d)
+    intervals : np.ndarray, shape=(n, 2)
         An array of time intervals, as returned by
         :func:`mir_eval.io.load_intervals()`.
         The ``i`` th interval spans time ``intervals[i, 0]`` to
@@ -145,7 +145,8 @@ def interpolate_intervals(intervals, labels, time_points, fill_value=None):
         The annotation for each interval
 
     time_points : array_like, shape=(m,)
-        Points in time to assign labels.
+        Points in time to assign labels.  These must be in
+        non-decreasing order.
 
     fill_value : type(labels[0])
         Object to use for the label with out-of-range time points.
@@ -156,22 +157,26 @@ def interpolate_intervals(intervals, labels, time_points, fill_value=None):
     aligned_labels : list
         Labels corresponding to the given time points.
 
+    Raises
+    ------
+    ValueError
+        If `time_points` is not in non-decreasing order.
     """
 
-    # Sort the intervals by start time
-    intervals, labels = sort_labeled_intervals(intervals, labels)
+    # Verify that time_points is sorted
+    time_points = np.asarray(time_points)
+
+    if np.any(time_points[1:] < time_points[:-1]):
+        raise ValueError('time_points must be in non-decreasing order')
+
+    aligned_labels = [fill_value] * len(time_points)
 
-    start, end = intervals.min(), intervals.max()
+    starts = np.searchsorted(time_points, intervals[:, 0], side='left')
+    ends = np.searchsorted(time_points, intervals[:, 1], side='right')
 
-    aligned_labels = []
+    for (start, end, lab) in zip(starts, ends, labels):
+        aligned_labels[start:end] = [lab] * (end - start)
 
-    for tpoint in time_points:
-        # This logic isn't correct if there's a gap in intervals
-        if start <= tpoint <= end:
-            index = np.argmax(intervals[:, 0] > tpoint) - 1
-            aligned_labels.append(labels[index])
-        else:
-            aligned_labels.append(fill_value)
     return aligned_labels
 
 

mir_eval.util.intervals_to_samples expected behavior
What's the intended behavior for `mir_eval.util.intervals_to_samples`?

In the following example:
```python
import numpy as np
import mir_eval

intervals = np.array(
    [[0.2, 0.49],
    [0.8, 0.9]]
)
labels = ['a', 'b']
sample_times, sample_labels = mir_eval.util.intervals_to_samples(intervals, labels, offset=0, sample_size=0.1, fill_value=None)
```
I expected something close to:
```python
sample_times = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
sample_labels = np.array([None, None, 'a', 'a', 'a', None, None, None, 'b'])
```
(i.e. time periods where there is no labeled interval get filled in with `None`)
but instead get:
```python
sample_times = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
sample_labels = np.array([None, None, 'a', 'a', 'a', 'a', 'a', 'a', 'b'])
```
Am I missing something?
diff --git a/tests/test_util.py b/tests/test_util.py
index b4103d8..913e529 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -21,6 +21,27 @@ def test_interpolate_intervals():
             expected_ans)
 
 
+def test_interpolate_intervals_gap():
+    """Check that an interval set is interpolated properly, with gaps."""
+    labels = list('abc')
+    intervals = np.array([[0.5, 1.0], [1.5, 2.0], [2.5, 3.0]])
+    time_points = [0.0, 0.75, 1.25, 1.75, 2.25, 2.75, 3.5]
+    expected_ans = ['N', 'a', 'N', 'b', 'N', 'c', 'N']
+    assert (util.interpolate_intervals(intervals, labels, time_points, 'N') ==
+            expected_ans)
+
+
+@nose.tools.raises(ValueError)
+def test_interpolate_intervals_badtime():
+    """Check that interpolate_intervals throws an exception if
+    input is unordered.
+    """
+    labels = list('abc')
+    intervals = np.array([(n, n + 1.0) for n in range(len(labels))])
+    time_points = [-1.0, 0.1, 0.9, 0.8, 2.3, 4.0]
+    mir_eval.util.interpolate_intervals(intervals, labels, time_points)
+
+
 def test_intervals_to_samples():
     """Check that an interval set is sampled properly, with boundaries
     conditions and out-of-range values.

pip install -e .[display,testing]
parse_log_pytest
requirements.txt
nose nose-cov pytest
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
contourpy==1.3.0
cov-core==1.15.0
coverage==7.8.0
cycler==0.12.1
exceptiongroup==1.2.2
fonttools==4.56.0
future==1.0.0
importlib_resources==6.5.2
iniconfig==2.1.0
kiwisolver==1.4.7
matplotlib==3.9.4
-e git+https://github.com/craffel/mir_eval.git@6f8ee58f437dd9b8f54b3c9700e7b4dc3879853f#egg=mir_eval
nose==1.3.7
nose-cov==1.6
numpy==2.0.2
packaging==24.2
pillow==11.1.0
pluggy==1.5.0
pyparsing==3.2.3
pytest==8.3.5
python-dateutil==2.9.0.post0
scipy==1.13.1
six==1.17.0
tomli==2.2.1
zipp==3.21.0

name: mir_eval
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - contourpy==1.3.0
      - cov-core==1.15.0
      - coverage==7.8.0
      - cycler==0.12.1
      - exceptiongroup==1.2.2
      - fonttools==4.56.0
      - future==1.0.0
      - importlib-resources==6.5.2
      - iniconfig==2.1.0
      - kiwisolver==1.4.7
      - matplotlib==3.9.4
      - nose==1.3.7
      - nose-cov==1.6
      - numpy==2.0.2
      - packaging==24.2
      - pillow==11.1.0
      - pluggy==1.5.0
      - pyparsing==3.2.3
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - scipy==1.13.1
      - six==1.17.0
      - tomli==2.2.1
      - zipp==3.21.0
prefix: /opt/conda/envs/mir_eval

tests/test_util.py::test_interpolate_intervals_gap tests/test_util.py::test_interpolate_intervals_badtime
tests/test_util.py::test_interpolate_intervals tests/test_util.py::test_intervals_to_samples tests/test_util.py::test_intersect_files tests/test_util.py::test_merge_labeled_intervals tests/test_util.py::test_boundaries_to_intervals tests/test_util.py::test_adjust_events tests/test_util.py::test_bipartite_match tests/test_util.py::test_outer_distance_mod_n tests/test_util.py::test_outer_distance tests/test_util.py::test_match_events tests/test_util.py::test_validate_intervals tests/test_util.py::test_validate_events tests/test_util.py::test_validate_frequencies
mir_eval/util.py
mir_eval/util.py