Repository: craffel/mir_eval

Issue:
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.

Discussion:
bmcfee: @craffel @justinsalamon ready for CR I think.
craffel: One minor comment otherwise LGTM.

Patch:
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)
 


Test patch:
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])