"""Classes for single-track piano rolls.
Classes
-------
- BinaryTrack
- StandardTrack
- Track
Variables
---------
- DEFAULT_PROGRAM
- DEFAULT_IS_DRUM
"""
from typing import Any, TypeVar
import numpy as np
from matplotlib.axes import Axes
from numpy import ndarray
from .visualization import plot_track
__all__ = [
"BinaryTrack",
"StandardTrack",
"Track",
"DEFAULT_PROGRAM",
"DEFAULT_IS_DRUM",
]
DEFAULT_PROGRAM = 0
DEFAULT_IS_DRUM = False
TrackType = TypeVar("TrackType", bound="Track")
StandardTrackType = TypeVar("StandardTrackType", bound="StandardTrack")
[docs]class Track:
"""A generic container for single-track piano rolls.
Attributes
----------
name : str, optional
Track name.
program : int, 0-127, default: `pypianoroll.DEFAULT_PROGRAM` (0)
Program number according to General MIDI specification [1].
Defaults to 0 (Acoustic Grand Piano).
is_drum : bool, `pypianoroll.DEFAULT_IS_DRUM` (False)
Whether it is a percussion track.
pianoroll : ndarray, shape=(?, 128), optional
Piano-roll matrix. The first dimension represents time, and the
second dimension represents pitch.
References
----------
1. https://www.midi.org/specifications/item/gm-level-1-sound-set
"""
def __init__(
self,
name: str = None,
program: int = None,
is_drum: bool = None,
pianoroll: ndarray = None,
):
self.name = name
self.program = program if program is not None else DEFAULT_PROGRAM
self.is_drum = is_drum if is_drum is not None else DEFAULT_IS_DRUM
if pianoroll is None:
self.pianoroll = np.zeros((0, 128))
else:
self.pianoroll = np.asarray(pianoroll)
def __repr__(self) -> str:
to_join = [
f"name={repr(self.name)}",
f"program={repr(self.program)}",
f"is_drum={repr(self.is_drum)}",
f"pianoroll=array(shape={self.pianoroll.shape}, "
f"dtype={self.pianoroll.dtype})",
]
return f"Track({', '.join(to_join)})"
def __len__(self) -> int:
return len(self.pianoroll)
def __getitem__(self, key) -> ndarray:
return self.pianoroll[key]
def __setitem__(self, key: int, value: Any):
self.pianoroll[key] = value
def _validate_type(self, attr):
if getattr(self, attr) is None:
if attr in ("program", "is_drum", "pianoroll"):
raise TypeError(f"`{attr}` must not be None.")
return
if attr == "program":
if not isinstance(self.program, int):
raise TypeError(
"`program` must be of type int, not "
f"{type(self.program)}."
)
elif attr == "is_drum":
if not isinstance(self.is_drum, bool):
raise TypeError(
"`is_drum` must be of type bool, not "
f"{type(self.is_drum)}."
)
elif attr == "name":
if not isinstance(self.name, str):
raise TypeError(
f"`name` must be of type str, not {type(self.name)}."
)
elif attr == "pianoroll":
if not isinstance(self.pianoroll, ndarray):
raise TypeError(
"`pianoroll` must be a NumPy array, not "
f"{type(self.pianoroll)}."
)
[docs] def validate_type(self, attr=None):
"""Raise an error if an attribute has an invalid type.
Parameters
----------
attr : str
Attribute to validate. Defaults to validate all attributes.
Returns
-------
Object itself.
"""
if attr is None:
for attribute in ("program", "is_drum", "name", "pianoroll"):
self._validate_type(attribute)
else:
self._validate_type(attr)
return self
def _validate(self, attr):
if getattr(self, attr) is None:
if attr in ("program", "is_drum", "pianoroll"):
raise TypeError(f"`{attr}` must not be None.")
return
self._validate_type(attr)
if attr == "program":
if self.program < 0 or self.program > 127:
raise ValueError("`program` must be in between 0 to 127.")
elif attr == "pianoroll":
if self.pianoroll.ndim != 2:
raise ValueError(
"`pianoroll` must have exactly two dimensions."
)
if self.pianoroll.shape[1] != 128:
raise ValueError(
"Length of the second axis of `pianoroll` must be 128."
)
[docs] def validate(self, attr=None):
"""Raise an error if an attribute has an invalid type or value.
Parameters
----------
attr : str
Attribute to validate. Defaults to validate all attributes.
Returns
-------
Object itself.
"""
if attr is None:
for attribute in ("program", "is_drum", "name", "pianoroll"):
self._validate(attribute)
else:
self._validate(attr)
return self
[docs] def is_valid_type(self, attr: str = None) -> bool:
"""Return True if an attribute is of a valid type.
Parameters
----------
attr : str
Attribute to validate. Defaults to validate all attributes.
Returns
-------
bool
Whether the attribute is of a valid type.
"""
try:
self.validate_type(attr)
except TypeError:
return False
return True
[docs] def is_valid(self, attr: str = None) -> bool:
"""Return True if an attribute is valid.
Parameters
----------
attr : str
Attribute to validate. Defaults to validate all attributes.
Returns
-------
bool
Whether the attribute has a valid type and value.
"""
try:
self.validate(attr)
except (TypeError, ValueError):
return False
return True
[docs] def get_length(self) -> int:
"""Return the active length of the piano roll.
Returns
-------
int
Length (in time steps) of the piano roll without trailing
silence.
"""
nonzero_steps = np.any(self.pianoroll, axis=1)
inv_last_nonzero_step = int(np.argmax(np.flip(nonzero_steps, axis=0)))
return self.pianoroll.shape[0] - inv_last_nonzero_step
[docs] def copy(self: "Track") -> "Track":
"""Return a copy of the track.
Returns
-------
A copy of the object itself.
Notes
-----
The piano-roll array is copied using :func:`numpy.copy`.
"""
return Track(
name=self.name,
program=self.program,
is_drum=self.is_drum,
pianoroll=self.pianoroll.copy(),
)
[docs] def pad(self: TrackType, pad_length: int) -> TrackType:
"""Pad the piano roll.
Parameters
----------
pad_length : int
Length to pad along the time axis.
Returns
-------
Object itself.
See Also
--------
:func:`pypianoroll.Track.pad_to_multiple` : Pad the piano
roll so that its length is some multiple.
"""
self.pianoroll = np.pad(
self.pianoroll, ((0, pad_length), (0, 0)), "constant"
)
return self
[docs] def pad_to_multiple(self: TrackType, factor: int) -> TrackType:
"""Pad the piano roll so that its length is some multiple.
Pad the piano roll at the end along the time axis of the minimum
length that makes the length of the resulting piano roll a
multiple of `factor`.
Parameters
----------
factor : int
The value which the length of the resulting piano roll will
be a multiple of.
Returns
-------
Object itself.
See Also
--------
:func:`pypianoroll.Track.pad` : Pad the piano roll.
"""
remainder = self.pianoroll.shape[0] % factor
if remainder:
pad_width = ((0, (factor - remainder)), (0, 0))
self.pianoroll = np.pad(self.pianoroll, pad_width, "constant")
return self
[docs] def transpose(self: TrackType, semitone: int) -> TrackType:
"""Transpose the piano roll by a number of semitones.
Parameters
----------
semitone : int
Number of semitones to transpose. A positive value raises
the pitches, while a negative value lowers the pitches.
Returns
-------
Object itself.
"""
if 0 < semitone < 128:
self.pianoroll[:, semitone:] = self.pianoroll[
:, : (128 - semitone)
]
self.pianoroll[:, :semitone] = 0
elif -128 < semitone < 0:
self.pianoroll[:, : (128 + semitone)] = self.pianoroll[
:, -semitone:
]
self.pianoroll[:, (128 + semitone) :] = 0
return self
[docs] def trim(self: TrackType, start: int = None, end: int = None) -> TrackType:
"""Trim the piano roll.
Parameters
----------
start : int, default: 0
Start time.
end : int, optional
End time. Defaults to active length.
Returns
-------
Object itself.
"""
if start is None:
start = 0
elif start < 0:
raise ValueError("`start` must be nonnegative.")
if end is None:
end = self.get_length()
elif end > len(self.pianoroll):
raise ValueError(
"`end` must be shorter than the piano roll length."
)
self.pianoroll = self.pianoroll[start:end]
return self
[docs] def standardize(self: "Track") -> "StandardTrack":
"""Standardize the track.
This will clip the piano roll to [0, 127] and cast to np.uint8.
Returns
-------
Converted StandardTrack object.
"""
return StandardTrack(
name=self.name,
program=self.program,
is_drum=self.is_drum,
pianoroll=np.clip(self.pianoroll, 0, 127),
)
[docs] def binarize(self, threshold: float = 0) -> "BinaryTrack":
"""Binarize the track.
This will binarize the piano roll by the given threshold.
Parameters
----------
threshold : int or float, default: 0
Threshold.
Returns
-------
Converted BinaryTrack object.
"""
return BinaryTrack(
program=self.program,
is_drum=self.is_drum,
name=self.name,
pianoroll=(self.pianoroll > threshold),
)
[docs] def plot(self, ax: Axes = None, **kwargs) -> Axes:
"""Plot the piano roll.
Refer to :func:`pypianoroll.plot_track` for full documentation.
"""
return plot_track(self, ax, **kwargs)
[docs]class StandardTrack(Track):
"""A container for single-track piano rolls with velocities.
Attributes
----------
name : str, optional
Track name.
program : int, 0-127, default: `pypianoroll.DEFAULT_PROGRAM` (0)
Program number according to General MIDI specification [1].
Defaults to 0 (Acoustic Grand Piano).
is_drum : bool, default: `pypianoroll.DEFAULT_IS_DRUM` (False)
Whether it is a percussion track.
pianoroll : ndarray, dtype=uint8, shape=(?, 128), optional
Piano-roll matrix. The first dimension represents time, and the
second dimension represents pitch. Cast to uint8 if not of data
type uint8.
References
----------
1. https://www.midi.org/specifications/item/gm-level-1-sound-set
"""
def __init__(
self,
name: str = None,
program: int = None,
is_drum: bool = None,
pianoroll: ndarray = None,
):
super().__init__(name, program, is_drum, pianoroll)
if self.pianoroll.dtype != np.uint8:
self.pianoroll = self.pianoroll.astype(np.uint8)
def __repr__(self):
to_join = [
f"name={repr(self.name)}",
f"program={repr(self.program)}",
f"is_drum={repr(self.is_drum)}",
f"pianoroll=array(shape={self.pianoroll.shape}, "
f"dtype={self.pianoroll.dtype})",
]
return f"StandardTrack({', '.join(to_join)})"
def _validate_type(self, attr):
super()._validate_type(attr)
if attr == "pianoroll" and self.pianoroll.dtype != np.uint8:
raise TypeError(
"`pianoroll` must be of data type uint8, not "
f"{self.pianoroll.dtype}."
)
def _validate(self, attr):
super()._validate(attr)
if attr == "pianoroll" and np.any(self.pianoroll > 127):
raise ValueError(
"`pianoroll` must contain only integers between 0 to 127."
)
[docs] def set_nonzeros(self: StandardTrackType, value: int) -> StandardTrackType:
"""Assign a constant value to all nonzeros entries.
Arguments
---------
value : int
Value to assign.
Returns
-------
Object itself.
"""
self.pianoroll[self.pianoroll.nonzero()] = value
return self
[docs] def clip(
self: StandardTrackType, lower: int = 0, upper: int = 127
) -> StandardTrackType:
"""Clip (limit) the the piano roll into [`lower`, `upper`].
Parameters
----------
lower : int, default: 0
Lower bound.
upper : int, default: 127
Upper bound.
Returns
-------
Object itself.
"""
if not isinstance(lower, int):
raise ValueError("`lower` must be of type int.")
if not isinstance(upper, int):
raise ValueError("`upper` must be of type int.")
self.pianoroll = self.pianoroll.clip(lower, upper)
return self
[docs] def copy(self: "StandardTrack") -> "StandardTrack":
"""Return a copy of the track.
Returns
-------
A copy of the object itself.
Notes
-----
The piano-roll array is copied using :func:`numpy.copy`.
"""
return StandardTrack(
name=self.name,
program=self.program,
is_drum=self.is_drum,
pianoroll=self.pianoroll.copy(),
)
[docs]class BinaryTrack(Track):
"""A container for single-track, binary piano rolls.
Attributes
----------
name : str, optional
Track name.
program : int, 0-127, default: `pypianoroll.DEFAULT_PROGRAM` (0)
Program number according to General MIDI specification [1].
Defaults to 0 (Acoustic Grand Piano).
is_drum : bool, default: `pypianoroll.DEFAULT_IS_DRUM` (False)
Whether it is a percussion track.
pianoroll : ndarray, dtype=bool, shape=(?, 128), optional
Piano-roll matrix. The first dimension represents time, and the
second dimension represents pitch. Cast to bool if not of data
type bool.
References
----------
1. https://www.midi.org/specifications/item/gm-level-1-sound-set
"""
def __init__(
self,
name: str = None,
program: int = None,
is_drum: bool = None,
pianoroll: ndarray = None,
):
super().__init__(name, program, is_drum, pianoroll)
if self.pianoroll.dtype != np.bool_:
self.pianoroll = self.pianoroll.astype(np.bool_)
def __repr__(self):
to_join = [
f"name={repr(self.name)}",
f"program={repr(self.program)}",
f"is_drum={repr(self.is_drum)}",
f"pianoroll=array(shape={self.pianoroll.shape}, "
f"dtype={self.pianoroll.dtype})",
]
return f"BinaryTrack({', '.join(to_join)})"
def _validate_type(self, attr):
super()._validate_type(attr)
if attr == "pianoroll" and self.pianoroll.dtype != np.bool_:
raise TypeError(
"`pianoroll` must be of data type bool, not "
f"{self.pianoroll.dtype}."
)
[docs] def set_nonzeros(self, value: int) -> "StandardTrack":
"""Assign a constant value to all nonzeros entries.
Arguments
---------
value : int
Value to assign.
Returns
-------
Converted StandardTrack object.
"""
pianoroll = np.zeros(self.pianoroll.shape, np.uint8)
pianoroll[self.pianoroll.nonzero()] = value
return StandardTrack(
name=self.name,
program=self.program,
is_drum=self.is_drum,
pianoroll=pianoroll,
)
[docs] def copy(self: "BinaryTrack") -> "BinaryTrack":
"""Return a copy of the track.
Returns
-------
A copy of the object itself.
Notes
-----
The piano-roll array is copied using :func:`numpy.copy`.
"""
return BinaryTrack(
name=self.name,
program=self.program,
is_drum=self.is_drum,
pianoroll=self.pianoroll.copy(),
)