"""Score representation, rendering, and MIDI ingestion.
A :class:`Score` is a list of :class:`Event` — onset-based musical events.
``Score`` behaves like a regular Python list (subclass of
:class:`collections.UserList`): you can iterate, index, slice, concatenate
with ``+``, repeat with ``*``, append, etc. — all preserving ``Score`` type.
A minimal end-to-end example — build a score, define an instrument, render it
to :class:`~pyquist.audio.Audio`::
import numpy as np
import pyquist as pq
from pyquist.helper import pitch_to_frequency
from pyquist.score import Score, BasicMetronome
# Each (time, kwargs) tuple is one event; Score coerces tuples to Events.
score = Score([
(0, {"pitch": 60, "duration": 0.5}),
(1, {"pitch": 64, "duration": 0.5}),
])
def sine(pitch, duration, sample_rate=44100, **kwargs):
t = np.arange(int(duration * sample_rate)) / sample_rate
freq = pitch_to_frequency(pitch)
return pq.Audio(0.3 * np.sin(2 * np.pi * freq * t), sample_rate=sample_rate)
# With a metronome, event times are beats; at 120 BPM, 1 beat = 0.5 s.
audio = score.render(sine, metronome=BasicMetronome(bpm=120))
The rest of this module fills in the details behind that flow.
Each event carries a ``time`` (in *seconds* if rendered without a metronome,
in *ticks* otherwise — a "tick" being whatever discrete time unit the
metronome maps to seconds) and an instrument-specific ``kwargs`` dict. The
metronome is kept as a separate object (a :class:`Metronome` instance) and
passed explicitly to :meth:`Score.render` when needed.
To turn a ``Score`` into audio, call :meth:`Score.render` with an
:class:`Instrument` — a callable invoked as ``instrument(**event.kwargs)``
that returns :class:`Audio`. An instrument simply declares the kwargs it
cares about and absorbs the rest with ``**kwargs``::
def sine(pitch, duration, **kwargs):
...
return Audio(samples, sample_rate=sr)
For per-event dispatch (e.g. different sounds for drums vs. pitched notes),
capture the deciding key as a named parameter and forward the rest::
def my_instrument(is_drum, **kwargs):
if is_drum:
return drum_kit(**kwargs)
return sine(**kwargs)
Because instruments are called with ``**event.kwargs``, every kwargs key
should be a valid Python identifier.
Common "tick" units:
* **beats** — paired with :class:`BasicMetronome` (e.g., 120 BPM).
* **MIDI ticks** — paired with :class:`MIDIMetronome` (subdivisions of a
quarter note as defined by the MIDI file's PPQ resolution and tempo map).
For MIDI ingestion, see :meth:`Score.from_midi`.
"""
import abc
import bisect
import math
import os
from collections import UserList
from typing import (
IO,
Any,
Callable,
Dict,
List,
NamedTuple,
Optional,
Tuple,
TypeAlias,
Union,
)
import mido
from .audio import Audio
_KwargsDict: TypeAlias = Dict[str, Any]
# An off-by-this-amount epsilon used as the right boundary of Score.segment.
_SEGMENT_EPS = 1e-9
[docs]
class Event(NamedTuple):
"""A timestamped, instrument-agnostic musical event.
``time`` is in seconds when the score is rendered without a metronome,
or in ticks (whatever unit the metronome maps to seconds — beats, MIDI
ticks, ...) when a metronome is supplied. ``kwargs`` is opaque to the
score; it is forwarded to the instrument as ``instrument(**kwargs)`` at
render time, so every key should be a valid Python identifier (e.g.,
avoid dashes).
``Event`` is a ``NamedTuple``: it unpacks like a regular tuple
(``time, kwargs = event``), can be constructed positionally
(``Event(0.5, {"pitch": 60})``) or by keyword.
You rarely need to construct an ``Event`` explicitly: :class:`Score`
converts any ``(time, kwargs)`` 2-tuple into an ``Event`` automatically, so
``Score([(0.0, {"pitch": 60})])`` and ``Score([Event(0.0, {"pitch": 60})])``
are equivalent.
"""
time: float
kwargs: _KwargsDict
def _coerce_event(item: Union["Event", Tuple[float, _KwargsDict]]) -> "Event":
"""Returns ``item`` as an :class:`Event`, coercing a bare 2-tuple.
Accepts an existing :class:`Event` (returned unchanged) or a
``(time, kwargs)`` tuple where ``time`` is a real number and ``kwargs`` is
a ``dict``. Anything else raises ``TypeError``.
"""
if isinstance(item, Event):
return item
if (
isinstance(item, tuple)
and len(item) == 2
and isinstance(item[0], (int, float))
and not isinstance(item[0], bool)
and isinstance(item[1], dict)
):
return Event(float(item[0]), item[1])
raise TypeError(
"Score items must be Event or (time: float, kwargs: dict) tuples; "
f"got {item!r}."
)
# A callable invoked as ``instrument(**event.kwargs)`` that returns the
# rendered Audio for one event. Declare the kwargs you use and absorb the
# rest with ``**kwargs``.
Instrument: TypeAlias = Callable[..., Audio]
# ---------------------------------------------------------------------------
# Metronome
# ---------------------------------------------------------------------------
[docs]
class Metronome(abc.ABC):
"""Converts between score-time *ticks* and wall-clock *seconds*.
A "tick" here is the abstract time unit a :class:`Score` uses; different
``Metronome`` subclasses define what a tick means concretely (a beat for
:class:`BasicMetronome`, a MIDI PPQ tick for :class:`MIDIMetronome`).
"""
[docs]
@abc.abstractmethod
def tick_to_seconds(self, tick: float) -> float:
"""Returns the wall-clock time in seconds at which ``tick`` occurs."""
[docs]
@abc.abstractmethod
def seconds_to_tick(self, seconds: float) -> float:
"""Returns the score tick at the given wall-clock time."""
[docs]
class BasicMetronome(Metronome):
"""A fixed-tempo metronome: a constant number of ticks per second.
A *tick* is the canonical unit of score time (see :class:`Metronome`).
Depending on the score it might stand for a beat, a MIDI tick, a second, or
any other unit — ``BasicMetronome`` just maps ticks to seconds at a single
constant rate, :attr:`ticks_per_second`.
Set that rate with *exactly one* of two keyword arguments:
* ``tps`` — ticks per second, the canonical parameter. The default,
``tps=1.0``, makes 1 tick = 1 second: an identity mapping convenient for
scores whose ``time`` field is already in seconds.
* ``bpm`` — beats per minute, a musical convenience for the common case
where a tick is a beat. BPM is just that same rate expressed per minute
rather than per second, converted internally via ``tps = bpm / 60`` — so
``BasicMetronome(bpm=120)`` is exactly ``BasicMetronome(tps=2.0)``. When
given, ``bpm`` takes precedence over ``tps``.
Args:
tps: Ticks per second. Defaults to ``1.0`` (1 tick = 1 second).
bpm: Beats per minute. If given, overrides ``tps`` (``tps = bpm / 60``).
Raises:
ValueError: if both ``tps`` and ``bpm`` are ``None``.
"""
def __init__(
self,
*,
tps: Optional[float] = 1.0,
bpm: Optional[float] = None,
):
if bpm is not None:
tps = bpm / 60.0
if tps is None:
raise ValueError("Specify exactly one of bpm or tps.")
self.ticks_per_second = tps
self.seconds_per_tick = 1.0 / tps
[docs]
def tick_to_seconds(self, tick: float) -> float:
return tick * self.seconds_per_tick
[docs]
def seconds_to_tick(self, seconds: float) -> float:
return seconds * self.ticks_per_second
# ---------------------------------------------------------------------------
# Score
# ---------------------------------------------------------------------------
[docs]
class Score(UserList):
"""A list of :class:`Event` with music-specific helpers.
``Score`` subclasses :class:`collections.UserList`, so all the standard
list operations work and preserve ``Score`` type — ``+``, ``*``,
slicing, ``+=``, ``.copy()``, ``.append()``, etc.
The ``time`` field of each event is interpreted in *seconds* unless you
pass a :class:`Metronome` to :meth:`render`, in which case ``time`` is
treated as *ticks* and converted via ``metronome.tick_to_seconds``. The
metronome is not stored on the score — keep it as a separate variable
(or use the ``(score, metronome)`` tuple returned by :meth:`from_midi`).
Construct from any iterable of events. Bare ``(time, kwargs)`` tuples are
converted to :class:`Event` automatically, so these are equivalent::
Score([Event(0.0, {"pitch": 60}), Event(1.0, {"pitch": 64})])
Score([(0.0, {"pitch": 60}), (1.0, {"pitch": 64})])
Or load from a MIDI file via :meth:`from_midi`.
"""
def __init__(self, initlist=None):
"""Builds a ``Score``, coercing any ``(time, kwargs)`` tuples to events."""
super().__init__()
if initlist is not None:
self.data = [_coerce_event(item) for item in initlist]
# --- list mutation (coerce tuples to Event) ----------------------------
def __setitem__(self, index, value):
if isinstance(index, slice):
self.data[index] = [_coerce_event(v) for v in value]
else:
self.data[index] = _coerce_event(value)
[docs]
def append(self, item) -> None:
self.data.append(_coerce_event(item))
[docs]
def insert(self, index, item) -> None:
self.data.insert(index, _coerce_event(item))
[docs]
def extend(self, other) -> None:
self.data.extend(_coerce_event(item) for item in other)
# --- properties --------------------------------------------------------
@property
def start_time(self) -> float:
"""The earliest event's ``time``. Raises ``ValueError`` if empty."""
if not self:
raise ValueError("Empty score has no start_time.")
return min(e.time for e in self)
@property
def end_time(self) -> float:
"""The latest event's ``time``. Raises ``ValueError`` if empty.
Note: this is the latest *onset*, not the end of any sustained note.
Events have no intrinsic duration at the score level — only their
instrument knows.
"""
if not self:
raise ValueError("Empty score has no end_time.")
return max(e.time for e in self)
@property
def duration(self) -> float:
"""``end_time - start_time``. Raises ``ValueError`` if empty."""
return self.end_time - self.start_time
# --- factory + slicing methods -----------------------------------------
[docs]
@classmethod
def from_midi(
cls,
midi: Union[str, os.PathLike, IO, mido.MidiFile],
*,
as_notes: bool = True,
all_events: bool = False,
) -> Tuple["Score", "MIDIMetronome"]:
"""Parses a MIDI file into a ``(score, metronome)`` pair.
Every emitted :class:`Event` has ``time`` set to its absolute MIDI
tick and ``kwargs["mtype"]`` (message type) identifying the kind of
event. The exact kwargs schema depends on the flags below.
When ``as_notes=True`` (default), each ``note_on``/``note_off`` pair
across all tracks is collapsed into a single ``"note"`` event with:
* ``kwargs["mtype"]`` — the literal string ``"note"``.
* ``kwargs["duration"]`` — the note's duration in seconds.
* ``kwargs["duration_ticks"]`` — the note's duration in MIDI ticks.
* ``kwargs["pitch"]`` — MIDI pitch (0–127).
* ``kwargs["velocity"]`` — MIDI NOTE_ON velocity (0–127).
* ``kwargs["program"]`` — MIDI program (instrument) number (0–127).
* ``kwargs["is_drum"]`` — ``True`` if the note is on MIDI channel
10 (zero-indexed: 9), the conventional percussion channel.
* ``kwargs["channel"]`` — MIDI channel (0-15).
When ``as_notes=False``, raw ``"note_on"`` and ``"note_off"`` events
are emitted as separate :class:`Event`\\ s instead — each with
``kwargs`` equal to the underlying ``mido`` message's attributes
(with ``"type"`` renamed to ``"mtype"``) sans its delta-``time``
field.
When ``all_events=True``, all other MIDI messages (tempo changes,
program changes, control changes, ...) are also emitted with the
same barebones ``msg.dict()``-style kwargs.
Pass the returned ``metronome`` to :meth:`render` so ticks are
converted to seconds using the file's actual tempo map.
Args:
midi: A path, a file-like object, or an already-parsed
:class:`mido.MidiFile`.
as_notes: If ``True`` (default), collapse ``note_on``/``note_off``
pairs into a single ``"note"`` event. If ``False``, emit them
as separate events.
all_events: If ``True``, also emit non-note MIDI messages
(``set_tempo``, ``program_change``, ``control_change``, ...).
Defaults to ``False`` (note events only).
"""
mid = _load_midi(midi)
metronome = MIDIMetronome(mid)
events = _parse_midi_events(
mid, metronome, as_notes=as_notes, all_events=all_events
)
events.sort(key=lambda e: e.time)
return cls(events), metronome
[docs]
def segment(
self,
*,
offset: Optional[float] = None,
duration: Optional[float] = None,
relativize: bool = True,
) -> "Score":
"""Returns the events whose ``time`` falls in ``[offset, offset+duration)``.
The right boundary is open with a small epsilon (``offset + duration
- 1e-9``) to suppress floating-point off-by-ones at exact-end matches.
Args:
offset: Lower bound on event time (inclusive). Defaults to the
beginning of the score (``0.0``).
duration: Length of the window. Defaults to the rest of the score
(no upper bound).
relativize: If ``True`` (default), shift every kept event's
``time`` by ``-offset`` so the returned score begins at 0.
If ``False``, keep the original timestamps.
"""
start = offset or 0.0
end = float("inf") if duration is None else start + duration - _SEGMENT_EPS
shift = start if relativize else 0.0
return Score(
Event(e.time - shift, e.kwargs) for e in self if start <= e.time < end
)
[docs]
def render(
self,
instrument: Instrument,
metronome: Optional[Metronome] = None,
) -> Audio:
"""Renders this score to a single mixed :class:`Audio`.
Each event is rendered by calling ``instrument(**event.kwargs)`` and
mixed in at its onset time. Onsets are interpreted as ticks (and
converted via ``metronome.tick_to_seconds``) when a metronome is
given, otherwise as seconds.
The instrument is time-agnostic: it receives only the event's
``kwargs``, not its ``time``. To vary an instrument by onset, copy
the time into ``kwargs`` when building the score. For per-event
dispatch (e.g. different instruments for drums vs. pitched notes),
branch inside the instrument.
All instrument outputs must share a ``sample_rate`` and a
``num_channels``; otherwise ``ValueError`` is raised. An empty
score returns a zero-length, mono ``Audio``.
"""
if not self:
return Audio.zeros(0, 1)
rendered: List[Tuple[float, Audio]] = []
for event in self:
audio = instrument(**event.kwargs)
if not isinstance(audio, Audio):
raise TypeError(
f"instrument(**event.kwargs) must return an Audio; "
f"got {type(audio).__name__}."
)
seconds = (
event.time
if metronome is None
else metronome.tick_to_seconds(event.time)
)
rendered.append((seconds, audio))
# All rendered audios must agree on sample_rate and num_channels.
sample_rates = {audio.sample_rate for _, audio in rendered}
if len(sample_rates) != 1:
raise ValueError(f"Inconsistent sample rates: {sample_rates}.")
sample_rate = sample_rates.pop()
if sample_rate is None:
raise ValueError("Rendered audio is missing a sample_rate.")
channel_counts = {audio.num_channels for _, audio in rendered}
if len(channel_counts) != 1:
raise ValueError(f"Inconsistent channel counts: {channel_counts}.")
num_channels = channel_counts.pop()
# Allocate the output and mix in each event.
duration = max(seconds + audio.duration for seconds, audio in rendered)
output = Audio.zeros(
math.ceil(duration * sample_rate),
num_channels,
sample_rate=sample_rate,
)
for seconds, audio in rendered:
sample = int(seconds * sample_rate)
output[sample : sample + audio.num_samples, :] += audio
return output
# ---------------------------------------------------------------------------
# MIDI ingestion (mido-based)
# ---------------------------------------------------------------------------
# A MIDI tempo map: list of (absolute_tick, microseconds_per_quarter_note),
# sorted by tick, with an entry at tick 0.
_TempoMap: TypeAlias = List[Tuple[int, int]]
# Default MIDI tempo if a file has no set_tempo events: 120 BPM.
_DEFAULT_MSPB = 500_000
def _load_midi(midi: Union[str, os.PathLike, IO, mido.MidiFile]) -> mido.MidiFile:
"""Returns a :class:`mido.MidiFile` from a path, file-like, or instance."""
if isinstance(midi, mido.MidiFile):
return midi
if isinstance(midi, (str, os.PathLike)):
return mido.MidiFile(midi)
return mido.MidiFile(file=midi)
def _extract_tempo_map(mid: mido.MidiFile) -> _TempoMap:
"""Returns a sorted list of ``(absolute_tick, microseconds_per_beat)``.
Always begins with an entry at tick 0 (uses the default 120 BPM if no
earlier ``set_tempo`` event exists).
"""
events: List[Tuple[int, int]] = []
for track in mid.tracks:
abs_tick = 0
for msg in track:
abs_tick += msg.time
if msg.type == "set_tempo":
events.append((abs_tick, msg.tempo))
events.sort(key=lambda e: e[0])
if not events or events[0][0] != 0:
events.insert(0, (0, _DEFAULT_MSPB))
return events
[docs]
class MIDIMetronome(Metronome):
"""A metronome that follows a MIDI file's PPQ resolution and tempo map.
1 tick = 1 MIDI tick (a PPQ subdivision of a quarter note). Tempo
changes inside the file are honored, so ``tick_to_seconds`` is
generally non-linear across the file.
Args:
midi: A path, a file-like object, or an already-parsed
:class:`mido.MidiFile`.
"""
def __init__(self, midi: Union[str, os.PathLike, IO, mido.MidiFile]):
mid = _load_midi(midi)
self.ticks_per_beat = mid.ticks_per_beat
tempo_map = _extract_tempo_map(mid)
# Precompute (start_tick, start_seconds, mspb) per tempo segment so
# both directions of the conversion are O(log n).
self._tick_starts: List[int] = []
self._second_starts: List[float] = []
self._mspbs: List[int] = []
seconds = 0.0
prev_tick, prev_mspb = tempo_map[0]
self._tick_starts.append(prev_tick)
self._second_starts.append(0.0)
self._mspbs.append(prev_mspb)
for next_tick, next_mspb in tempo_map[1:]:
seconds += mido.tick2second(
next_tick - prev_tick, self.ticks_per_beat, prev_mspb
)
self._tick_starts.append(next_tick)
self._second_starts.append(seconds)
self._mspbs.append(next_mspb)
prev_tick, prev_mspb = next_tick, next_mspb
[docs]
def tick_to_seconds(self, tick: float) -> float:
idx = max(0, bisect.bisect_right(self._tick_starts, tick) - 1)
start_tick = self._tick_starts[idx]
start_seconds = self._second_starts[idx]
mspb = self._mspbs[idx]
return start_seconds + mido.tick2second(
tick - start_tick, self.ticks_per_beat, mspb
)
[docs]
def seconds_to_tick(self, seconds: float) -> float:
idx = max(0, bisect.bisect_right(self._second_starts, seconds) - 1)
start_tick = self._tick_starts[idx]
start_seconds = self._second_starts[idx]
mspb = self._mspbs[idx]
return start_tick + mido.second2tick(
seconds - start_seconds, self.ticks_per_beat, mspb
)
def _parse_midi_events(
mid: mido.MidiFile,
metronome: "MIDIMetronome",
*,
as_notes: bool = True,
all_events: bool = False,
) -> List[Event]:
"""Walks all tracks and converts MIDI messages to :class:`Event`\\ s.
See :meth:`Score.from_midi` for the semantics of ``as_notes`` and
``all_events``.
"""
events: List[Event] = []
for track in mid.tracks:
abs_tick = 0
# Per-track-and-channel program (MIDI Program Change is per-channel,
# but channels mean different things in different tracks).
programs: Dict[int, int] = {}
# Active notes keyed by (channel, pitch); two simultaneous notes on
# the same channel/pitch in the same track aren't standard MIDI.
active: Dict[Tuple[int, int], Tuple[int, int, int]] = {}
for msg in track:
abs_tick += msg.time
is_note_on = msg.type == "note_on" and msg.velocity > 0
is_note_off = msg.type == "note_off" or (
msg.type == "note_on" and msg.velocity == 0
)
is_note = is_note_on or is_note_off
if msg.type == "program_change":
programs[msg.channel] = msg.program
if is_note and as_notes:
# Pair note_on/note_off into a single "note" event.
if is_note_on:
active[(msg.channel, msg.note)] = (
abs_tick,
msg.velocity,
programs.get(msg.channel, 0),
)
else: # is_note_off
key = (msg.channel, msg.note)
if key not in active:
continue # Stray note_off; skip silently.
on_tick, velocity, program = active.pop(key)
off_tick = abs_tick
duration = metronome.tick_to_seconds(
off_tick
) - metronome.tick_to_seconds(on_tick)
events.append(
Event(
time=on_tick,
kwargs={
"mtype": "note",
"duration": duration,
"duration_ticks": off_tick - on_tick,
"pitch": msg.note,
"velocity": velocity,
"program": program,
"is_drum": msg.channel == 9,
"channel": msg.channel,
},
)
)
elif (is_note and not as_notes) or (not is_note and all_events):
# Barebones path: kwargs = msg attributes (sans delta-time).
# Rename mido's ``"type"`` to ``"mtype"`` (message type) so it
# doesn't shadow the ``type`` builtin when unpacked as **kwargs.
kwargs = msg.dict()
kwargs.pop("time", None)
kwargs["mtype"] = kwargs.pop("type")
events.append(Event(time=abs_tick, kwargs=kwargs))
return events