Source code for partitura.performance
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This module contains a lightweight ontology to represent a performance in a
MIDI-like format. A performance is defined at the highest level by a
:class:`~partitura.performance.PerformedPart`. This object contains performed
notes as well as continuous control parameters, such as sustain pedal.
"""
from typing import Union, List, Optional, Iterator, Iterable as Itertype
import numpy as np
from partitura.utils import note_array_from_part_list
from partitura.utils.music import seconds_to_midi_ticks
__all__ = [
"PerformedPart",
"Performance",
]
[docs]class PerformedPart(object):
"""Represents a performed part, e.g. all notes and related
controller/modifiers of one single instrument.
Performed notes are stored as a list of dictionaries, where each
dictionary represents a performed note, should have at least the
keys "note_on", "note_off", the onset and offset times of the note
in seconds, respectively.
Continuous controls are also stored as a list of dictionaries,
where each dictionary represents a control change. Each dictionary
should have a key "type" (the name of the control, e.g.
"sustain_pedal", "soft_pedal"), "time" (in seconds), and "value"
(a number).
Parameters
----------
notes : list
A list of dictionaries containing performed note information.
id : str
The identifier of the part
controls : list
A list of dictionaries containing continuous control information
part_name : str
Name for the part
sustain_pedal_threshold : int
The threshold above which sustain pedal values are considered
to be equivalent to on. For values below the threshold the
sustain pedal is treated as off. Defaults to 64.
ppq : int
Parts per Quarter (ppq) of the MIDI encoding. Defaults to 480.
mpq : int
Microseconds per Quarter (mpq) tempo of the MIDI encoding.
Defaults to 500000.
Attributes
----------
notes : list
A list of dictionaries containing performed note information.
id : str
The identifier of the part
part_name : str
Name for the part
controls : list
A list of dictionaries containing continuous control
information
programs : list
List of dictionaries containing program change information
"""
def __init__(
self,
notes: List[dict],
id: str = None,
part_name: str = None,
controls: List[dict] = None,
programs: List[dict] = None,
sustain_pedal_threshold: int = 64,
ppq: int = 480,
mpq: int = 500000,
track: int = 0,
) -> None:
super().__init__()
self.id = id
self.part_name = part_name
self.notes = notes
self.controls = controls or []
self.programs = programs or []
self.ppq = ppq
self.mpq = mpq
self.track = track
self.sustain_pedal_threshold = sustain_pedal_threshold
@property
def sustain_pedal_threshold(self) -> int:
"""The threshold value (number) above which sustain pedal values
are considered to be equivalent to on. For values below the
threshold the sustain pedal is treated as off. Defaults to 64.
Based on the control items of type "sustain_pedal", in
combination with the value of the "sustain_pedal_threshold"
attribute, the note dictionaries will be extended with a key
"sound_off". This key represents the time the note will stop
sounding. When the sustain pedal is off, `sound_off` will
coincide with `note_off`. When the sustain pedal is on,
`sound_off` will equal the earliest time the sustain pedal is
off after `note_off`. The `sound_off` values of notes will be
automatically recomputed each time the
`sustain_pedal_threshold` is set.
"""
return self._sustain_pedal_threshold
@sustain_pedal_threshold.setter
def sustain_pedal_threshold(self, value: int) -> None:
"""
Set the pedal threshold and update the sound_off
of the notes. The threshold is a MIDI CC value
between 0 and 127. The higher the threshold, the
more restrained the pedal use and the drier the
performance. Set to 127 to deactivate pedal.
"""
self._sustain_pedal_threshold = value
if len(self.notes) > 0:
adjust_offsets_w_sustain(
self.notes, self.controls, self._sustain_pedal_threshold
)
@property
def num_tracks(self) -> int:
"""Number of tracks"""
return len(
set(
[n.get("track", -1) for n in self.notes]
+ [c.get("track", -1) for c in self.controls]
+ [p.get("track", -1) for p in self.programs]
)
)
[docs] def note_array(self, *args, **kwargs) -> np.ndarray:
"""Structured array containing performance information.
The fields are 'id', 'pitch', 'onset_div', 'duration_div',
'onset_sec', 'duration_sec' and 'velocity'.
"""
fields = [
("onset_sec", "f4"),
("duration_sec", "f4"),
("onset_tick", "i4"),
("duration_tick", "i4"),
("pitch", "i4"),
("velocity", "i4"),
("track", "i4"),
("channel", "i4"),
("id", "U256"),
]
note_array = []
for n in self.notes:
note_on_sec = n["note_on"]
note_on_tick = n.get(
"note_on_tick",
seconds_to_midi_ticks(n["note_on"], mpq=self.mpq, ppq=self.ppq),
)
offset = n.get("sound_off", n["note_off"])
duration_sec = offset - note_on_sec
duration_tick = (
n.get(
"note_off_tick",
seconds_to_midi_ticks(n["note_off"], mpq=self.mpq, ppq=self.ppq),
)
- note_on_tick
)
note_array.append(
(
note_on_sec,
duration_sec,
note_on_tick,
duration_tick,
n["midi_pitch"],
n["velocity"],
n.get("track", 0),
n.get("channel", 1),
n["id"],
)
)
return np.array(note_array, dtype=fields)
[docs] @classmethod
def from_note_array(
cls,
note_array: np.ndarray,
id: str = None,
part_name: str = None,
):
"""Create an instance of PerformedPart from a note_array.
Note that this property does not include non-note information (i.e.
controls such as sustain pedal).
"""
if "id" not in note_array.dtype.names:
n_ids = ["n{0}".format(i) for i in range(len(note_array))]
else:
n_ids = note_array["id"]
if "track" not in note_array.dtype.names:
tracks = np.zeros(len(note_array), dtype=int)
else:
tracks = note_array["track"]
if "channel" not in note_array.dtype.names:
channels = np.ones(len(note_array), dtype=int)
else:
channels = note_array["channel"]
notes = []
for nid, note, track, channel in zip(n_ids, note_array, tracks, channels):
notes.append(
dict(
id=nid,
midi_pitch=note["pitch"],
note_on=note["onset_sec"],
note_off=note["onset_sec"] + note["duration_sec"],
sound_off=note["onset_sec"] + note["duration_sec"],
track=track,
channel=channel,
velocity=note["velocity"],
)
)
return cls(id=id, part_name=part_name, notes=notes, controls=None)
def adjust_offsets_w_sustain(
notes: List[dict],
controls: List[dict],
threshold=64,
) -> None:
# get all note offsets
offs = np.fromiter((n["note_off"] for n in notes), dtype=float)
first_off = np.min(offs)
last_off = np.max(offs)
# Get pedal times
pedal = np.array(
[(x["time"], x["value"] > threshold) for x in controls if x["number"] == 64]
)
if len(pedal) == 0:
for note in notes:
note["sound_off"] = note["note_off"]
return
# sort, just in case
pedal = pedal[np.argsort(pedal[:, 0]), :]
# reduce the pedal info to just the times where there is a change in pedal state
pedal = np.vstack(
(
(min(pedal[0, 0] - 1, first_off - 1), 0),
pedal[0, :],
# if there is an onset before the first pedal info, assume pedal is off
pedal[np.where(np.diff(pedal[:, 1]) != 0)[0] + 1, :],
# if there is an offset after the last pedal info, assume pedal is off
(max(pedal[-1, 0] + 1, last_off + 1), 0),
)
)
last_pedal_change_before_off = np.searchsorted(pedal[:, 0], offs) - 1
pedal_state_at_off = pedal[last_pedal_change_before_off, 1]
pedal_down_at_off = pedal_state_at_off == 1
next_pedal_time = pedal[last_pedal_change_before_off + 1, 0]
offs[pedal_down_at_off] = next_pedal_time[pedal_down_at_off]
for offset, note in zip(offs, notes):
note["sound_off"] = offset
[docs]class Performance(object):
"""Main object for representing a performance.
The `Performance` object is basically an iterable that provides access to all
`PerformedPart` objects in a musical score.
Parameters
----------
id : str
The identifier of the performance.
performer: str, optional.
The person or machine performing.
title: str, optional
Title of the score.
subtitle: str, optional
Subtitle of the score.
composer: str, optional
Composer of the score.
lyricist: str, optional
Lyricist of the score.
copyright: str, optional.
Copyright notice of the score.
Attributes
----------
id : str
See parameters.
performer: str
See parameters.
title: str
See parameters.
subtitle: str
See parameters.
composer: str
See parameters.
lyricist: str
See parameters.
copyright: str.
See parameters.
"""
id: Optional[str]
title: Optional[str]
subtitle: Optional[str]
lyricist: Optional[str]
copyright: Optional[str]
performedparts: List[PerformedPart]
def __init__(
self,
performedparts: Union[PerformedPart, Itertype[PerformedPart]],
id: str = None,
performer: Optional[str] = None,
title: Optional[str] = None,
subtitle: Optional[str] = None,
composer: Optional[str] = None,
lyricist: Optional[str] = None,
copyright: Optional[str] = None,
ensure_unique_tracks: bool = True,
) -> None:
self.id = id
if isinstance(performedparts, PerformedPart):
self.performedparts = [performedparts]
elif isinstance(performedparts, Itertype):
if not all([isinstance(pp, PerformedPart) for pp in performedparts]):
raise ValueError(
"`performedparts` should be a list of `PerformedPart` objects!"
)
self.performedparts = list(performedparts)
else:
raise ValueError(
"`performedparts` should be a `PerformedPart` or a list of "
f"`PerformedPart` objects but is {type(performedparts)}."
)
# Metadata
self.performer = performer
self.title = title
self.subtitle = subtitle
self.composer = composer
self.lyricist = lyricist
self.copyright = copyright
if ensure_unique_tracks:
self.sanitize_track_numbers()
@property
def num_tracks(self) -> int:
"""
Number of tracks in the performance
"""
n_tracks = len(
set(
[(i, n.get("track", -1)) for i, pp in enumerate(self) for n in pp.notes]
+ [
(i, c.get("track", -1))
for i, pp in enumerate(self)
for c in pp.controls
]
+ [
(i, p.get("track", -1))
for i, pp in enumerate(self)
for p in pp.programs
]
)
)
return n_tracks
[docs] def sanitize_track_numbers(self) -> None:
"""
Ensure that the track number info in each `PerformedPart` in
self.performedparts is unique (i.e., that a track number does not appear
in multiple `PerformedPart` instances)
"""
unique_track_ids = list(
set(
[(i, n.get("track", -1)) for i, pp in enumerate(self) for n in pp.notes]
+ [
(i, c.get("track", -1))
for i, pp in enumerate(self)
for c in pp.controls
]
+ [
(i, p.get("track", -1))
for i, pp in enumerate(self)
for p in pp.programs
]
)
)
track_map = dict([(tid, ti) for ti, tid in enumerate(unique_track_ids)])
for i, ppart in enumerate(self):
for note in ppart.notes:
note["track"] = track_map[(i, note.get("track", -1))]
for control in ppart.controls:
control["track"] = track_map[(i, control.get("track", -1))]
for program in ppart.programs:
program["track"] = track_map[(i, program.get("track", -1))]
def __getitem__(self, index: int) -> PerformedPart:
"""Get `Part in the score by index"""
return self.performedparts[index]
def __setitem__(self, index: int, pp: PerformedPart) -> None:
"""Set `Part` in the score by index"""
# TODO: How to update the score structure as well?
self.performedparts[index] = pp
def __iter__(self) -> Iterator[PerformedPart]:
self.iter_idx = 0
return self
def __next__(self) -> PerformedPart:
if self.iter_idx == len(self.performedparts):
raise StopIteration
res = self[self.iter_idx]
self.iter_idx += 1
return res
def __len__(self) -> int:
"""
The lenght of the score is the number of part objects in `self.parts`
"""
return len(self.performedparts)
[docs] def note_array(self, *args, **kwargs) -> np.ndarray:
"""
Get a note array that concatenates the note arrays of all Part/PartGroup
objects in the score.
"""
return note_array_from_part_list(self.performedparts, *args, **kwargs)
# Alias for typing performance-like objects
PerformanceLike = Union[List[PerformedPart], PerformedPart, Performance]