Source code for jams.sonify

#!/usr/bin/env python
# CREATED:2015-12-12 18:20:37 by Brian McFee <brian.mcfee@nyu.edu>
r'''
Sonification
------------

.. autosummary::
    :toctree: generated/

    sonify
'''

from itertools import product
from collections import OrderedDict, defaultdict
import six
import numpy as np
import mir_eval.sonify
from mir_eval.util import filter_kwargs
from .eval import coerce_annotation, hierarchy_flatten
from .exceptions import NamespaceError

__all__ = ['sonify']


def mkclick(freq, sr=22050, duration=0.1):
    '''Generate a click sample.

    This replicates functionality from mir_eval.sonify.clicks,
    but exposes the target frequency and duration.
    '''

    times = np.arange(int(sr * duration))
    click = np.sin(2 * np.pi * times * freq / float(sr))
    click *= np.exp(- times / (1e-2 * sr))

    return click


def clicks(annotation, sr=22050, length=None, **kwargs):
    '''Sonify events with clicks.

    This uses mir_eval.sonify.clicks, and is appropriate for instantaneous
    events such as beats or segment boundaries.
    '''

    interval, _ = annotation.to_interval_values()

    return filter_kwargs(mir_eval.sonify.clicks, interval[:, 0],
                         fs=sr, length=length, **kwargs)


def downbeat(annotation, sr=22050, length=None, **kwargs):
    '''Sonify beats and downbeats together.
    '''

    beat_click = mkclick(440 * 2, sr=sr)
    downbeat_click = mkclick(440 * 3, sr=sr)

    intervals, values = annotation.to_interval_values()

    beats, downbeats = [], []

    for time, value in zip(intervals[:, 0], values):
        if value['position'] == 1:
            downbeats.append(time)
        else:
            beats.append(time)

    if length is None:
        length = int(sr * np.max(intervals)) + len(beat_click) + 1

    y = filter_kwargs(mir_eval.sonify.clicks,
                      np.asarray(beats),
                      fs=sr, length=length, click=beat_click)

    y += filter_kwargs(mir_eval.sonify.clicks,
                       np.asarray(downbeats),
                       fs=sr, length=length, click=downbeat_click)

    return y


def multi_segment(annotation, sr=22050, length=None, **kwargs):
    '''Sonify multi-level segmentations'''

    # Pentatonic scale, because why not
    PENT = [1, 32./27, 4./3, 3./2, 16./9]
    DURATION = 0.1

    h_int, _ = hierarchy_flatten(annotation)

    if length is None:
        length = int(sr * (max(np.max(_) for _ in h_int) + 1. / DURATION) + 1)

    y = 0.0
    for ints, (oc, scale) in zip(h_int, product(range(3, 3 + len(h_int)),
                                                PENT)):
        click = mkclick(440.0 * scale * oc, sr=sr, duration=DURATION)
        y = y + filter_kwargs(mir_eval.sonify.clicks,
                              np.unique(ints),
                              fs=sr, length=length,
                              click=click)
    return y


def chord(annotation, sr=22050, length=None, **kwargs):
    '''Sonify chords

    This uses mir_eval.sonify.chords.
    '''

    intervals, chords = annotation.to_interval_values()

    return filter_kwargs(mir_eval.sonify.chords,
                         chords, intervals,
                         fs=sr, length=length,
                         **kwargs)


def pitch_contour(annotation, sr=22050, length=None, **kwargs):
    '''Sonify pitch contours.

    This uses mir_eval.sonify.pitch_contour, and should only be applied
    to pitch annotations using the pitch_contour namespace.

    Each contour is sonified independently, and the resulting waveforms
    are summed together.
    '''

    # Map contours to lists of observations

    times = defaultdict(list)
    freqs = defaultdict(list)

    for obs in annotation:
        times[obs.value['index']].append(obs.time)
        freqs[obs.value['index']].append(obs.value['frequency'] *
                                         (-1)**(~obs.value['voiced']))

    y_out = 0.0
    for ix in times:
        y_out = y_out + filter_kwargs(mir_eval.sonify.pitch_contour,
                                      np.asarray(times[ix]),
                                      np.asarray(freqs[ix]),
                                      fs=sr, length=length,
                                      **kwargs)
        if length is None:
            length = len(y_out)

    return y_out


def piano_roll(annotation, sr=22050, length=None, **kwargs):
    '''Sonify a piano-roll

    This uses mir_eval.sonify.time_frequency, and is appropriate
    for sparse transcription data, e.g., annotations in the `note_midi`
    namespace.
    '''

    intervals, pitches = annotation.to_interval_values()

    # Construct the pitchogram
    pitch_map = {f: idx for idx, f in enumerate(np.unique(pitches))}

    gram = np.zeros((len(pitch_map), len(intervals)))

    for col, f in enumerate(pitches):
        gram[pitch_map[f], col] = 1

    return filter_kwargs(mir_eval.sonify.time_frequency,
                         gram, pitches, intervals,
                         sr, length=length, **kwargs)


SONIFY_MAPPING = OrderedDict()
SONIFY_MAPPING['beat_position'] = downbeat
SONIFY_MAPPING['beat'] = clicks
SONIFY_MAPPING['multi_segment'] = multi_segment
SONIFY_MAPPING['segment_open'] = clicks
SONIFY_MAPPING['onset'] = clicks
SONIFY_MAPPING['chord'] = chord
SONIFY_MAPPING['note_hz'] = piano_roll
SONIFY_MAPPING['pitch_contour'] = pitch_contour


[docs]def sonify(annotation, sr=22050, duration=None, **kwargs): '''Sonify a jams annotation through mir_eval Parameters ---------- annotation : jams.Annotation The annotation to sonify sr = : positive number The sampling rate of the output waveform duration : float (optional) Optional length (in seconds) of the output waveform kwargs Additional keyword arguments to mir_eval.sonify functions Returns ------- y_sonified : np.ndarray The waveform of the sonified annotation Raises ------ NamespaceError If the annotation has an un-sonifiable namespace ''' length = None if duration is None: duration = annotation.duration if duration is not None: length = int(duration * sr) # If the annotation can be directly sonified, try that first if annotation.namespace in SONIFY_MAPPING: ann = coerce_annotation(annotation, annotation.namespace) return SONIFY_MAPPING[annotation.namespace](ann, sr=sr, length=length, **kwargs) for namespace, func in six.iteritems(SONIFY_MAPPING): try: ann = coerce_annotation(annotation, namespace) return func(ann, sr=sr, length=length, **kwargs) except NamespaceError: pass raise NamespaceError('Unable to sonify annotation of namespace="{:s}"' .format(annotation.namespace))