bmcfee: @craffel @justinsalamon ready for CR I think.
craffel: One minor comment otherwise LGTM.
diff --git a/mir_eval/sonify.py b/mir_eval/sonify.py
index f614684..a40a0d0 100644
--- a/mir_eval/sonify.py
+++ b/mir_eval/sonify.py
@@ -5,6 +5,8 @@ All functions return a raw signal at the specified sampling rate.
 
 import numpy as np
 from numpy.lib.stride_tricks import as_strided
+from scipy.interpolate import interp1d
+
 from . import util
 from . import chord
 
@@ -140,6 +142,59 @@ def time_frequency(gram, frequencies, times, fs, function=np.sin, length=None):
     return output
 
 
+def pitch_contour(times, frequencies, fs, function=np.sin, length=None,
+                  kind='linear'):
+    '''Sonify a pitch contour.
+
+    Parameters
+    ----------
+    times : np.ndarray
+        time indices for each frequency measurement, in seconds
+
+    frequencies : np.ndarray
+        frequency measurements, in Hz.
+        Non-positive measurements will be interpreted as un-voiced samples.
+
+    fs : int
+        desired sampling rate of the output signal
+
+    function : function
+        function to use to synthesize notes, should be 2pi-periodic
+
+    length : int
+        desired number of samples in the output signal,
+        defaults to ``max(times)*fs``
+
+    kind : str
+        Interpolation mode for the frequency estimator.
+        See: ``scipy.interpolate.interp1d`` for valid settings.
+
+    Returns
+    -------
+    output : np.ndarray
+        synthesized version of the pitch contour
+    '''
+
+    fs = float(fs)
+
+    if length is None:
+        length = int(times.max() * fs)
+
+    # Squash the negative frequencies.
+    # wave(0) = 0, so clipping here will un-voice the corresponding instants
+    frequencies = np.maximum(frequencies, 0.0)
+
+    # Build a frequency interpolator
+    f_interp = interp1d(times * fs, 2 * np.pi * frequencies / fs, kind=kind,
+                        fill_value=0.0, bounds_error=False, copy=False)
+
+    # Estimate frequency at sample points
+    f_est = f_interp(np.arange(length))
+
+    # Sonify the waveform
+    return function(np.cumsum(f_est))
+
+
 def chroma(chromagram, times, fs, **kwargs):
     """Reverse synthesis of a chromagram (semitone matrix)
 

continuous pitch sonification
As per [this discussion](https://github.com/marl/jams/pull/91), we could pull in the code from @justinsalamon 's [melosynth](https://github.com/justinsalamon/melosynth) package.
diff --git a/tests/test_sonify.py b/tests/test_sonify.py
index a1975c9..4d0d564 100644
--- a/tests/test_sonify.py
+++ b/tests/test_sonify.py
@@ -2,6 +2,7 @@
 
 import mir_eval
 import numpy as np
+import scipy
 
 
 def test_clicks():
@@ -53,3 +54,37 @@ def test_chords():
             ['C', 'C:maj', 'D:min7', 'E:min', 'C#', 'C', 'C', 'C', 'C', 'C'],
             intervals, fs, length=fs*11)
         assert len(signal) == 11*fs
+
+
+def test_pitch_contour():
+
+    # Generate some random pitch
+    fs = 8000
+    times = np.linspace(0, 5, num=5 * fs, endpoint=True)
+
+    noise = scipy.ndimage.gaussian_filter1d(np.random.randn(len(times)),
+                                            sigma=256)
+    freqs = 440.0 * 2.0**(16 * noise)
+
+    # negate a bunch of sequences
+    idx = np.unique(np.random.randint(0, high=len(times), size=32))
+    for start, end in zip(idx[::2], idx[1::2]):
+        freqs[start:end] *= -1
+
+    # Test with inferring duration
+    x = mir_eval.sonify.pitch_contour(times, freqs, fs)
+    assert len(x) == fs * 5
+
+    # Test with an explicit duration
+    # This forces the interpolator to go off the end of the sampling grid,
+    # which should result in a constant sequence in the output
+    x = mir_eval.sonify.pitch_contour(times, freqs, fs, length=fs * 7)
+    assert len(x) == fs * 7
+    assert np.allclose(x[-fs * 2:], x[-fs * 2])
+
+    # Test with an explicit duration and a fixed offset
+    # This forces the interpolator to go off the beginning of
+    # the sampling grid, which should result in a constant output
+    x = mir_eval.sonify.pitch_contour(times + 5.0, freqs, fs, length=fs * 7)
+    assert len(x) == fs * 7
+    assert np.allclose(x[:fs * 5], x[0])

pip install -e .
parse_log_pytest
numpy>=1.7.0 scipy>=0.9.0 future six
nose pytest
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup==1.2.2
future @ file:///croot/future_1730902796226/work
iniconfig==2.1.0
-e git+https://github.com/craffel/mir_eval.git@f858df347c05c83159875e8f6de84f0041dbabca#egg=mir_eval
nose==1.3.7
numpy @ file:///croot/numpy_and_numpy_base_1736283260865/work/dist/numpy-2.0.2-cp39-cp39-linux_x86_64.whl#sha256=3387e3e62932fa288bc18e8f445ce19e998b418a65ed2064dd40a054f976a6c7
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
scipy @ file:///croot/scipy_1733756309941/work/dist/scipy-1.13.1-cp39-cp39-linux_x86_64.whl#sha256=3b247b926209f2d9f719ebae39faf3ff891b2596150ed8f8349adfc3eb19441c
six @ file:///tmp/build/80754af9/six_1644875935023/work
tomli==2.2.1

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
  - blas=1.0=openblas
  - ca-certificates=2025.2.25=h06a4308_0
  - future=1.0.0=py39h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgfortran-ng=11.2.0=h00389a5_1
  - libgfortran5=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libopenblas=0.3.21=h043d6bf_0
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - numpy=2.0.2=py39heeff2f4_0
  - numpy-base=2.0.2=py39h8a23956_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - pybind11-abi=4=hd3eb1b0_1
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - scipy=1.13.1=py39heeff2f4_1
  - setuptools=72.1.0=py39h06a4308_0
  - six=1.16.0=pyhd3eb1b0_1
  - 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:
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - nose==1.3.7
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - tomli==2.2.1
prefix: /opt/conda/envs/mir_eval

tests/test_sonify.py::test_pitch_contour
tests/test_sonify.py::test_clicks tests/test_sonify.py::test_chords
tests/test_sonify.py::test_time_frequency tests/test_sonify.py::test_chroma
mir_eval/sonify.py
mir_eval/sonify.py