# Copyright (C) 2025 Jelle van der Werff
#
# This file is part of thebeat.
#
# thebeat is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# thebeat is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with thebeat. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import copy
import math
import os
import re
import textwrap
import warnings
from collections import namedtuple
from fractions import Fraction
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
import sounddevice
import thebeat._warnings
import thebeat.core
import thebeat.helpers
import thebeat.utils
from thebeat._decorators import requires_lilypond
# Optional imports
try:
import abjad
except ImportError:
abjad = None
def _get_abjad_note_durations(note_values):
return [abjad.Duration(nv.numerator, nv.denominator) for nv in note_values]
def _get_abjad_ties(durations, time_signature):
full_bar = abjad.Duration(time_signature[0], time_signature[1])
split_notes = [] # Return a list of list of split note durations
# Keep track of how full the current bar is
bar_fullness = abjad.Duration(0)
for note in durations:
split_notes.append([])
# if the not does not fit the rest of the bar
while bar_fullness + note > full_bar:
# calculate how much still fits in the same bar
split_duration = full_bar - bar_fullness
split_notes[-1].append(split_duration)
note = note - split_duration
bar_fullness = abjad.Duration(0)
# add the note, or the tied part of the note
split_notes[-1].append(note)
bar_fullness += note
# if bar is full set bar_fullness to zero
if bar_fullness == full_bar:
bar_fullness = abjad.Duration(0)
return split_notes
def _make_abjad_notes_and_rests(pitches, split_note_durations, is_played):
notes = []
for pitch, durations, is_plyd in zip(pitches, split_note_durations, is_played):
if is_plyd:
# All but the last of a split note duration needs to be tied
for note_duration in durations[:-1]:
these_notes = abjad.makers.make_notes([pitch], [note_duration])
abjad.attach(abjad.Tie(), these_notes[-1])
notes.extend(these_notes)
# The last split-up duration doesn't need to be tied
notes.extend(abjad.makers.make_notes([pitch], [durations[-1]]))
else:
notes.extend([abjad.Rest(note_duration) for note_duration in durations])
return notes
[docs]
class Rhythm(thebeat.core.sequence.BaseSequence):
"""
The :py:class:`Rhythm` class can be used for working sequences that are rhythmical in the musical sense.
This means that in addition to having inter-onset intervals (IOIs) that represent the timing of the events in the
sequence, :py:class:`Rhythm` objects have musical properties such as a time signature, a beat duration,
and they may contain rests.
The :py:class:`Rhythm` class is also used as the basis for a :py:class:`~thebeat.music.Melody`.
For more info on these properties, please refer to the class's :py:meth:`~thebeat.music.Rhythm.__init__`.
"""
[docs]
def __init__(
self,
iois: np.ndarray | list,
time_signature: tuple[int, int] = (4, 4),
beat_ms: float = 500,
is_played: np.typing.ArrayLike[bool] | None = None,
name: str | None = None,
):
r"""
Constructs a :py:class:`Rhythm` object.
Parameters
----------
iois
An iterable of inter-onset intervals (IOIs). For instance: ``[500, 500, 400, 200]``.
time_signature
A musical time signature, for instance: ``(4, 4)``. As a reminder: the upper number indicates
*how many beats* there are in a bar. The lower number indicates the denominator of the value that
indicates *one beat*. So, in ``(4, 8)`` time, a bar would be filled if we have four
:math:`\frac{1}{8}` th notes.
Note: This parameter is a tuple and not a :py:class:`~fraction.Fraction`, since time signatures
should not be simplified (e.g., a 6/8 signature will not be simplified to 3/4).
beat_ms
The value (in milliseconds) for the beat, i.e. the duration of a :math:`\frac{1}{4}` th note if the lower
number in the time signature is 4.
is_played
A list or array containing booleans indicating whether a note should be played or not.
Defaults to ``[True, True, True, ...]``.
name
Optionally, you can give the Sequence object a name. This is used when printing, plotting, or writing
the Sequence object. It can always be retrieved and changed via :py:attr:`Rhythm.name`.
Examples
--------
>>> iois = [500, 250, 250, 500, 500]
>>> r = Rhythm(iois)
>>> print(r.onsets)
[ 0. 500. 750. 1000. 1500.]
>>> iois = [500, 250, 250, 500]
>>> r = Rhythm(iois=iois, time_signature=(3, 4), beat_ms=250)
>>> print(r.note_values)
[Fraction(1, 2), Fraction(1, 4), Fraction(1, 4), Fraction(1, 2)]
"""
# Save attributes
# Note: time_signature is a tuple instead of a Fraction, as it should not be
# simplified to lowest terms. For signatures, 4/4 isn't 1/1, and 6/8 isn't 3/4.
self.time_signature = time_signature
self.beat_ms = beat_ms
self.is_played = [True] * len(iois) if not is_played else list(is_played)
# Calculate n_bars and check whether that makes whole bars
n_bars = np.sum(iois) / time_signature[0] / beat_ms
if not math.isclose(n_bars, round(n_bars)):
raise ValueError("The provided inter-onset intervals do not amount to whole bars.")
self.n_bars = round(n_bars)
# Call initializer of super class
super().__init__(iois=iois, end_with_interval=True, name=name)
def __str__(self):
return (
f"Object of type Rhythm.\n"
f"Time signature: {self.time_signature}\n"
f"Number of bars: {self.n_bars}\n"
f"Beat (ms): {self.beat_ms}\n"
f"Number of events: {len(self.onsets)}\n"
f"IOIs: {self.iois}\n"
f"Onsets: {self.onsets}\n"
f"Name: {self.name}\n"
)
def __repr__(self):
if self.name:
return f"Rhythm(name={self.name}, n_bars={self.n_bars}, time_signature={self.time_signature})"
return f"Rhythm(n_bars={self.n_bars}, time_signature={self.time_signature})"
def __add__(self, other):
return thebeat.utils.concatenate_rhythms([self, other])
def __mul__(self, other):
return self._repeat(times=other)
@property
def integer_ratios(self) -> np.ndarray:
r"""Calculate how to describe the rhythm in integer ratio numerators from
the total duration of the sequence by finding the least common multiplier.
Example
-------
A sequence of IOIs ``[250, 500, 1000, 250]`` has a total duration of 2000 ms.
This can be described using the least common multiplier as
:math:`\frac{1}{8}, \frac{2}{8}, \frac{4}{8}, \frac{1}{8}`,
so this method returns the numerators ``[1, 2, 4, 1]``.
Notes
-----
The method for calculating the integer ratios is based on :cite:t:`jacobyIntegerRatioPriors2017`.
Caution
-------
This function uses rounding to find the nearest integer ratio.
Examples
--------
>>> r = Rhythm([250, 500, 1000, 250])
>>> print(r.integer_ratios)
[1 2 4 1]
"""
fractions = [Fraction(estimate).limit_denominator() for estimate in self.iois / self.duration]
lcm = np.lcm.reduce([fr.denominator for fr in fractions])
return np.array([int(fr * lcm) for fr in fractions])
@property
def note_values(self):
"""
This property returns the denominators of the note values in this sequence, calculated from the
inter-onset intervals (IOIs). A note value of ``1/2`` means a half note. A note value of ``1/4`` means a
quarternote, etc. Three triplet eighth notes would be ``[1/12, 1/12, 1/12]``.
Caution
-------
Please note that this function is basic (e.g. there is no support for dotted notes etc.). That's beyond
the scope of this package.
Examples
--------
>>> r = Rhythm([500, 1000, 250, 250], time_signature=(4, 4), beat_ms=500)
>>> print(r.note_values)
[Fraction(1, 4), Fraction(1, 2), Fraction(1, 8), Fraction(1, 8)]
>>> r = Rhythm([166.66666667, 166.66666667, 166.66666667, 500, 500, 500], beat_ms=500)
>>> print(r.note_values)
[Fraction(1, 12), Fraction(1, 12), Fraction(1, 12), Fraction(1, 4), Fraction(1, 4), Fraction(1, 4)]
"""
return [Fraction(estimate).limit_denominator() / self.time_signature[1] for estimate in self.iois / self.beat_ms]
[docs]
@classmethod
def from_note_values(
cls,
fractions: list | np.ndarray,
time_signature: tuple[int, int] = (4, 4),
beat_ms: int = 500,
is_played: np.typing.ArrayLike[bool] = None,
name: str | None = None,
) -> Rhythm:
r"""
This class method can be used for creating a Rhythm on the basis of fractions (i.e., note values). The fractions
can be input either as floats (e.g. 0.25) or as :class:`fractions.Fraction` objects.
Parameters
----------
fractions
The fractions of the rhythm. For instance: ``[1/4, 1/2, 1/4]``.
time_signature
The time signature of the rhythm. For instance: ``(4, 4)``.
beat_ms
The duration of a beat in milliseconds. This refers to the duration of the denominator of the time
signature.
is_played
A list of booleans indicating which notes are played. If None, all notes are played.
name
A name for the rhythm.
Example
-------
A sequence of IOIs ``[250, 500, 1000, 250]`` has a total duration of 2000 ms. If the
time signature is 4/4 (``time_signature=(4, 4)``), and each quarter-note beat corresponds
to 250 ms (``beat_ms=250``), then the note values correspond to a quarter note, a half
note, a full note, and another quarter note. Consequently, this method will return
``[Fraction(1, 4), Fraction(1, 2), Fraction(1, 1), Fraction(1, 4)]``.
Examples
--------
>>> r = Rhythm.from_note_values([1/4, 1/4, 1/4, 1/4], time_signature=(4, 4), beat_ms=500)
>>> import fractions
>>> dotted_halfnote = fractions.Fraction(3, 4)
>>> halfnote = fractions.Fraction(1, 2)
>>> r = Rhythm.from_note_values([dotted_halfnote, halfnote], time_signature=(5, 4), beat_ms=500)
>>> r = Rhythm.from_note_values([1/8, 1/8, 1/8, 1/8], time_signature=(4, 8), beat_ms=500)
"""
beat_fractions = [Fraction(f).limit_denominator() * time_signature[1] for f in fractions]
iois = np.array([float(f * beat_ms) for f in beat_fractions])
return cls(
iois=iois,
time_signature=time_signature,
beat_ms=beat_ms,
is_played=is_played,
name=name,
)
[docs]
@classmethod
def from_integer_ratios(
cls,
numerators: npt.ArrayLike[float],
time_signature: tuple[int, int] = (4, 4),
beat_ms: int = 500,
is_played: np.typing.ArrayLike[bool] = None,
name: str | None = None,
) -> Rhythm:
r"""
Very simple conveniance class method that constructs a Rhythm object by calculating the inter-onset intervals
(IOIs) as ``numerators * beat_ms``.
Parameters
----------
numerators
Contains the numerators of the integer ratios. For instance: ``[1, 2, 4]``.
time_signature
A musical time signature, for instance: ``(4, 4)``. As a reminder: the upper number indicates
*how many beats* there are in a bar. The lower number indicates the denominator of the value that
indicates *one beat*. So, in ``(4, 8)`` time, a bar would be filled if we have four
:math:`\frac{1}{8}` th notes.
beat_ms
The value (in milliseconds) for the beat, i.e. the duration of a :math:`\frac{1}{4}` th note if the lower
number in the time signature is 4.
is_played
A list or array containing booleans indicating whether a note should be played or not.
Defaults to ``[True, True, True, ...]``.
name
Optionally, you can give the Sequence object a name. This is used when printing, plotting, or writing
the Sequence object. It can always be retrieved and changed via :py:attr:`Rhythm.name`.
"""
numerators = np.array(numerators)
return cls(
iois=numerators * beat_ms,
beat_ms=beat_ms,
time_signature=time_signature,
is_played=is_played,
name=name,
)
[docs]
@classmethod
def generate_random_rhythm(
cls,
n_bars: int = 1,
beat_ms: int = 500,
time_signature: tuple[int, int] = (4, 4),
allowed_note_values: np.typing.ArrayLike[Fraction, float, int] | None = None,
n_rests: int = 0,
rng: np.random.Generator | None = None,
name: str | None = None,
) -> Rhythm:
r"""
This function generates a random rhythmic sequence on the basis of the provided parameters.
It does so by first generating all possible combinations of note values that amount to one bar based on the
``allowed_note_values`` parameter, and then selecting (with replacement) ``n_bars`` combinations out of
the possibilities.
Parameters
----------
n_bars
The desired number of musical bars.
beat_ms
The value (in milliseconds) for the beat, i.e. the duration of a :math:`\frac{1}{4}` th note if the lower
number in the time signature is 4.
time_signature
A musical time signature, for instance: ``(4, 4)``. As a reminder: the upper number indicates
*how many beats* there are in a bar. The lower number indicates the denominator of the value that
indicates *one beat*. So, in ``(4, 8)`` time, a bar would be filled if we have four
:math:`\frac{1}{8}` th notes.
allowed_note_values
A list or array containing the allowed note values. A note value of ``1/2`` means a half
note, a note value of ``1/4`` means a quarternote etc. Defaults to ``[1/4, 1/8, 1/16]``.
n_rests
If desired, one can provide a number of rests to be inserted at random locations. These are placed after
the random selection of note values.
rng
A :class:`numpy.random.Generator` object. If not supplied :func:`numpy.random.default_rng` is
used.
name
If desired, one can give a rhythm a name. This is for instance used when printing the rhythm,
or when plotting the rhythm. It can always be retrieved and changed via :py:attr:`Rhythm.name`.
Examples
--------
>>> import numpy as np # not required, here for reproducability
>>> generator = np.random.default_rng(seed=321) # not required, for reproducability
>>> r = Rhythm.generate_random_rhythm(rng=generator)
>>> print(r.iois)
[125. 250. 125. 125. 500. 125. 125. 125. 500.]
>>> import numpy as np # not required, here for reproducability
>>> generator = np.random.default_rng(seed=321) # not required, here for reproducability
>>> r = Rhythm.generate_random_rhythm(beat_ms=1000, allowed_note_values=[1/2, 1/4, 1/8], rng=generator)
>>> print(r.iois)
[ 500. 1000. 500. 500. 1000. 500.]
"""
if rng is None:
rng = np.random.default_rng()
if allowed_note_values is None:
allowed_note_values = [Fraction(1, 4), Fraction(1, 8), Fraction(1, 16)]
else:
allowed_note_values = [Fraction(v).limit_denominator() for v in allowed_note_values]
all_combinations = thebeat.helpers.all_note_combinations(allowed_note_values, time_signature)
bar_combination_idx = rng.choice(len(all_combinations), n_bars, replace=True)
iois = np.concatenate([all_combinations[i] * time_signature[1] * beat_ms for i in bar_combination_idx])
# Make rests
if n_rests > len(iois):
raise ValueError("The provided number of rests is higher than the number of onsets.")
elif n_rests > 0:
is_played = n_rests * [False] + (len(iois) - n_rests) * [True]
rng.shuffle(is_played)
else:
is_played = None
return cls(
iois=iois,
time_signature=time_signature,
beat_ms=beat_ms,
is_played=is_played,
name=name,
)
[docs]
@classmethod
def generate_isochronous(
cls,
n_bars: int = 1,
time_signature: tuple[int, int] = (4, 4),
beat_ms: int = 500,
is_played: np.typing.ArrayLike[bool] | None = None,
name: str | None = None,
) -> Rhythm:
r"""
Simply generates an isochronous (i.e. with equidistant inter-onset intervals) rhythm. Will have
the bars filled with intervals of ``beat_ms``.
Parameters
----------
n_bars
The desired number of musical bars.
time_signature
A musical time signature, for instance: ``(4, 4)``. As a reminder: the upper number indicates
*how many beats* there are in a bar. The lower number indicates the denominator of the value that
indicates *one beat*. So, in ``(4, 8)`` time, a bar would be filled if we have four
:math:`\frac{1}{8}` th notes.
beat_ms
The value (in milliseconds) for the beat, i.e. the duration of a :math:`\frac{1}{4}` th note if the lower
number in the time signature is 4.
is_played
A list or array containing booleans indicating whether a note should be played or not.
Defaults to ``[True, True, True, ...]``.
name
If desired, one can give a rhythm a name. This is for instance used when printing the rhythm,
or when plotting the rhythm. It can always be retrieved and changed via :py:attr:`Rhythm.name`.
"""
n_iois = time_signature[0] * n_bars
iois = n_iois * [beat_ms]
return cls(
iois=iois,
time_signature=time_signature,
beat_ms=beat_ms,
is_played=is_played,
name=name,
)
[docs]
@requires_lilypond
def plot_rhythm(
self,
filepath: os.PathLike | str = None,
staff_type: str = "rhythm",
print_staff: bool = True,
title: str | None = None,
figsize: tuple[float, float] | None = None,
dpi: int = 600,
ax: plt.Axes | None = None,
) -> tuple[plt.Figure, plt.Axes]:
"""
Make a plot containing the musical notation of the rhythm. This function requires an installation of 'abjad' and
'lilypond'. You can install both through ``pip install 'thebeat[music-notation]'``.
For more details, see https://thebeat.readthedocs.io/en/latest/installation.html.
The plot is returned as a :class:`matplotlib.figure.Figure` and :class:`matplotlib.axes.Axes` object,
which you can manipulate.
.. figure:: images/plot_rhythm_rhythmstaff.png
:scale: 100 %
A plot with the default ``print_staff=True`` and the default ``staff_type="rhythm"``.
.. figure:: images/plot_rhythm_withstaff.png
:scale: 50 %
A plot with the default ``print_staff=True`` and ``staff_type="percussion"``.
.. figure:: images/plot_rhythm_nostaff.png
:scale: 50 %
A plot with ``print_staff=False``.
Parameters
----------
filepath
Optionally, you can save the plot to a file. Supported file formats are only '.png' and '.pdf'.
The desired file format will be selected based on what the filepath ends with.
staff_type
Either 'percussion' or 'rhythm'. 'Rhythm' is a single line (like a woodblock score). Percussion
is drum notation.
print_staff
If desired, you can choose to print a musical staff (the default is not to do this). The staff will be a
`percussion staff <https://en.wikipedia.org/wiki/Percussion_notation>`_.
title
A title for the plot. Note that this is not considered when saving the plot as an .eps file.
figsize
The figure size in inches, as a tuple of floats. This refers to the figsize argument in :func:`matplotlib.pyplot.figure`.
dpi
The resolution of the plot in dots per inch.
ax
Optionally, you can provide an existing :class:`matplotlib.axes.Axes` object to plot the rhythm on.
Examples
--------
>>> r = Rhythm([500, 250, 1000, 250], beat_ms=500)
>>> r.plot_rhythm() # doctest: +SKIP
>>> r = Rhythm([250, 250, 500, 500, 1500], time_signature=(3, 4))
>>> fig, ax = r.plot_rhythm(print_staff=True) # doctest: +SKIP
>>> plt.show() # doctest: +SKIP
>>> r = Rhythm.from_note_values([1/4, 1/4, 1/4, 1/4])
>>> r.plot_rhythm(filepath='isochronous_rhythm.pdf') # doctest: +SKIP
"""
# Check whether abjad is installed
if abjad is None:
raise ImportError(
"This function requires the abjad package. Install, for instance by typing "
"`pip install abjad` or `pip install thebeat[music-notation]` into your terminal.\n"
"For more details, see https://thebeat.readthedocs.io/en/latest/installation.html."
)
# Preliminaries
time_signature = abjad.TimeSignature(self.time_signature)
remove_footers = """\n\\paper {\nindent = 0\\mm\nline-width = 110\\mm\noddHeaderMarkup = ""\nevenHeaderMarkup = ""
oddFooterMarkup = ""\nevenFooterMarkup = ""\n} """
# Get note durations and make the notes and rests
note_durations = _get_abjad_note_durations(self.note_values)
split_note_durations = _get_abjad_ties(note_durations, self.time_signature)
pitches = [abjad.NamedPitch("A3")] * len(note_durations)
is_played = self.is_played
notes = _make_abjad_notes_and_rests(pitches, split_note_durations, is_played)
# Plot the notes
staff = abjad.Staff(notes)
# Change clef and staff type
if staff_type == "percussion":
abjad.attach(abjad.Clef("percussion"), staff[0])
elif staff_type == "rhythm":
# In Abjad 3.26 and lower, this was a propety; now it's a setter member function.
# Abjad 2.28 requires at least Python 3.12; so this is a patch for backwards compatibility.
# Remove once support for 3.11 gets dropped.
try:
staff.set_lilypond_type("RhythmicStaff")
except AttributeError:
staff.lilypond_type = "RhythmicStaff"
abjad.attach(time_signature, staff[0])
# Make cleff transparent if necessary
if print_staff is False:
abjad.override(staff).clef.transparent = "##t"
# Make the score and convert to lilypond object
score = abjad.Score([staff])
score_lp = abjad.lilypond(score)
# Make lilypond string, adding the remove footers string (removes all unnecessary stuff, changes page size etc.)
lpf = abjad.LilyPondFile([remove_footers, score_lp])
lpf_str = abjad.lilypond(lpf)
# Stop the staff if necessary (i.e. the horizontal lines behind the notes)
if print_staff is False:
lpf_str = lpf_str.replace(r"\time", r"\stopStaff \time")
# Plot!
fig, ax = thebeat.helpers.plot_lp(
lp=lpf_str,
filepath=filepath,
title=title,
figsize=figsize,
dpi=dpi,
ax=ax,
)
return fig, ax
[docs]
def copy(self, deep: bool = True):
"""Returns a copy of itself. See :py:func:`copy.copy` for more information.
Parameters
----------
deep
If ``True``, a deep copy is returned. If ``False``, a shallow copy is returned.
"""
if deep is True:
return copy.deepcopy(self)
else:
return copy.copy(self)
def _repeat(self, times: int) -> Rhythm:
"""
Repeat the Rhythm ``times`` times. Returns a new Rhythm object. The old one remains unchanged.
Parameters
----------
times
How many times the Rhythm should be repeated.
"""
if not isinstance(times, int):
raise TypeError("You can only multiply Sequence objects by integers.")
new_iois = np.tile(self.iois, reps=times)
is_played = self.is_played * times
return Rhythm(
new_iois,
beat_ms=self.beat_ms,
time_signature=self.time_signature,
is_played=is_played,
name=self.name,
)
[docs]
def to_sequence(self) -> thebeat.core.Sequence:
"""
Convert the rhythm to a :class:`~thebeat.core.Sequence` object.
"""
return thebeat.core.Sequence(
iois=self.iois, first_onset=0.0, end_with_interval=True, name=self.name
)
[docs]
class Melody(thebeat.core.sequence.BaseSequence):
"""
A :py:class:`Melody` object contains a both a **rhythm** and **pitch information**.
It does not contain sound. However, the :py:class:`Melody` can be synthesized and played or written to
disk, for instance using the :py:meth:`~Melody.synthesize_and_play()` method.
See the :py:meth:`~Melody.__init__` to learn how a :py:class:`Melody` object is constructed, or use one
of the different class methods, such as the
:py:meth:`~Melody.generate_random_melody` method.
Most of the functions require you to install `abjad <https://abjad.github.io/>`_. Please note that the
current version of `abjad` requires Python 3.12. The last version that supported Python 3.10-3.11 is
`Abjad 3.19 <https://pypi.org/project/abjad/3.19/>`_. The correct version will be installed automatically
when you install `thebeat` with ``pip install thebeat[music-notation]``.
For more details, see https://thebeat.readthedocs.io/en/latest/installation.html.
"""
[docs]
def __init__(
self,
rhythm: thebeat.music.Rhythm,
pitch_names: npt.NDArray[np.str_] | list[str] | str,
octave: int | None = None,
key: str | None = None,
is_played: list | None = None,
name: str | None = None,
):
"""
Parameters
----------
rhythm
A :py:class:`~thebeat.music.Rhythm` object.
pitch_names
An array or list containing note names. They can be in a variety of formats, such as
``"G4"`` for a G note in the fourth octave, or ``"g'"``, or simply ``G``. The names are
processed by :class:`abjad.pitch.NamedPitch`. Follow the link to find examples of the different
formats. Alternatively it can be a string, but only in the formats: ``'CCGGC'`` or ``'C4C4G4G4C4'``.
key
Optionally, you can provide a key. This is for instance used when plotting a :py:class:`Melody` object.
is_played
Optionally, you can indicate if you want rests in the :py:class:`Melody`. Provide an array or list of
booleans, for instance: ``[True, True, False, True]`` would mean a rest in place of the third event.
The default is True for each event.
name
Optionally, the :py:class:`Melody` object can have a name. This is saved to the :py:attr:`Melody.name`
attribute.
Examples
--------
>>> r = thebeat.music.Rhythm.from_note_values([1/4, 1/4, 1/4, 1/4, 1/4, 1/4, 1/2])
>>> mel = Melody(r, 'CCGGAAG')
"""
# Initialize namedtuple. The namedtuple template is saved as an attribute.
self.Event = namedtuple("event", "onset_ms duration_ms note_value pitch_name is_played")
# Make is_played if None supplied
if is_played is None:
is_played = [True] * len(rhythm.onsets)
# Process pitch names
if isinstance(pitch_names, str):
pitch_names_list = re.split(r"([A-Z])([0-9]?)", pitch_names)
pitch_names_list = list(filter(None, pitch_names_list))
search = re.search(r"[0-9]", pitch_names)
if search is None:
if octave is None:
pitch_names_list = [pitch + str(4) for pitch in pitch_names_list]
elif octave is not None:
pitch_names_list = [pitch + str(octave) for pitch in pitch_names_list]
else:
pitch_names_list = pitch_names
self.pitch_names = [str(pitch_name) for pitch_name in pitch_names_list]
# Add events as named tuples
self.events = self._make_namedtuples(
rhythm=rhythm,
iois=rhythm.iois,
note_values=rhythm.note_values,
pitch_names=self.pitch_names,
is_played=is_played,
)
# Save rhythmic/musical attributes
self.time_signature = rhythm.time_signature
self.beat_ms = rhythm.beat_ms
self.key = key
self.integer_ratios = rhythm.integer_ratios # TODO: Remove or refactor
self.is_played = is_played # TODO: Duplicate information with self.events; remove or refactor
# Check whether the provided IOIs result in a sequence only containing whole bars
n_bars = np.sum(rhythm.iois) / self.time_signature[0] / self.beat_ms
if not math.isclose(n_bars, round(n_bars)):
raise ValueError("The provided inter-onset intervals do not amount to whole bars.")
# Save number of bars as an attribute
self.n_bars = round(n_bars)
# Call BaseSequence constructor
super().__init__(iois=rhythm.iois, end_with_interval=True, name=name)
# todo add __str__ __add__ _mul__ etc.
def __repr__(self):
if self.name:
return f"Melody(name={self.name}, n_bars={self.n_bars}, key={self.key})"
return f"Melody(n_bars={self.n_bars}, key={self.key})"
[docs]
@classmethod
def generate_random_melody(
cls,
n_bars: int = 1,
beat_ms: int = 500,
time_signature: tuple = (4, 4),
key: str = "C",
octave: int = 4,
n_rests: int = 0,
allowed_note_values: list = None,
rng: np.random.Generator = None,
name: str | None = None,
) -> Melody:
r"""
Generate a random rhythm as well as a melody, based on the given parameters. Internally, for the
rhythm, the :py:meth:`Rhythm.generate_random_rhythm` method is used. The melody is a random selection
of pitch values based on the provided key and octave.
Parameters
----------
n_bars
The desired number of musical bars.
beat_ms
The value (in milliseconds) for the beat, i.e. the duration of a :math:`\frac{1}{4}` th note if the lower
number in the time signature is 4.
time_signature
A musical time signature, for instance: ``(4, 4)``. As a reminder: the upper number indicates
*how many beats* there are in a bar. The lower number indicates the denominator of the value that
indicates *one beat*. So, in ``(4, 8)`` time, a bar would be filled if we have four
:math:`\frac{1}{8}` th notes.
key
The musical key used for randomly selecting the notes. Only major keys are supported for now.
octave
The musical octave. The default is concert pitch, i.e. ``4``.
n_rests
If desired, one can provide a number of rests to be inserted at random locations. These are placed after
the random selection of note values.
allowed_note_values
A list or array containing the allowed note values. A note value of ``1/2`` means a half
note, a note value of ``1/4`` means a quarternote etc. Defaults to ``[1/4, 1/8, 1/16]``. Note that the meaning of the note values
depends on the time signature.
rng
A :class:`numpy.random.Generator` object. If not supplied :func:`numpy.random.default_rng` is
used.
name
If desired, one can give the melody a name. This is for instance used when printing the rhythm,
or when plotting the rhythm. It can always be retrieved and changed via :py:attr:`Rhythm.name`.
Examples
--------
>>> generator = np.random.default_rng(seed=123)
>>> m = Melody.generate_random_melody(rng=generator)
>>> print(m.note_values) # doctest: +ELLIPSIS
[Fraction(1, 16), Fraction(1, 16), Fraction(1, 16), Fraction(1, 16), ...]
>>> print(m.pitch_names)
["a'", "g'", "c'", "c''", "d'", "e'", "d'", "e'", "d'", "e'", "b'", "f'", "c''"]
"""
if abjad is None:
raise ImportError(
"This function requires the abjad package. Install, for instance by typing "
"`pip install abjad` or `pip install thebeat[music-notation]` into your terminal.\n"
"For more details, see https://thebeat.readthedocs.io/en/latest/installation.html."
)
if rng is None:
rng = np.random.default_rng()
if allowed_note_values is None:
allowed_note_values = [1/4, 1/8, 1/16]
# Generate random rhythm and random tone_heights
rhythm = thebeat.music.Rhythm.generate_random_rhythm(
n_bars=n_bars,
beat_ms=beat_ms,
time_signature=time_signature,
allowed_note_values=allowed_note_values,
rng=rng,
)
# In Abjad 3.26 and lower, these values were properties; now they are member functions.
# Abjad 2.28 requires at least Python 3.12; so this is a patch for backwards compatibility.
# Remove once support for 3.11 gets dropped.
def get_name(named_pitch):
try:
return named_pitch.name()
except TypeError:
return named_pitch.name
pitch_names_possible = [
get_name(pitch) for pitch in thebeat.utils.get_major_scale(tonic=key, octave=octave)
]
pitch_names_chosen = list(rng.choice(pitch_names_possible, size=len(rhythm.onsets)))
if n_rests > len(rhythm.onsets):
raise ValueError("The provided number of rests is higher than the number of sounds.")
# Make the rests and shuffle
is_played = n_rests * [False] + (len(rhythm.onsets) - n_rests) * [True]
rng.shuffle(is_played)
return cls(
rhythm=rhythm, pitch_names=pitch_names_chosen, is_played=is_played, name=name, key=key
)
@property
def note_values(self):
"""
This property returns the denominators of the note values in this sequence, calculated from the
inter-onset intervals (IOIs). A note value of ``1/2`` means a half note. A note value of ``1/4`` means a
quarternote, etc. Three triplet eighth notes would be ``[1/12, 1/12, 1/12]``.
Examples
--------
>>> r = thebeat.music.Rhythm([500, 1000, 250, 250], time_signature=(4, 4), beat_ms=500)
>>> m = Melody(r, pitch_names='CCGC')
>>> print(r.note_values)
[Fraction(1, 4), Fraction(1, 2), Fraction(1, 8), Fraction(1, 8)]
>>> r = thebeat.music.Rhythm([166.66666667, 166.66666667, 166.66666667, 500, 500, 500], beat_ms=500)
>>> print(r.note_values)
[Fraction(1, 12), Fraction(1, 12), Fraction(1, 12), Fraction(1, 4), Fraction(1, 4), Fraction(1, 4)]
"""
return [Fraction(estimate).limit_denominator() / self.time_signature[1] for estimate in self.iois / self.beat_ms]
[docs]
def copy(self, deep: bool = True):
"""Returns a copy of itself. See :py:func:`copy.copy` for more information.
Parameters
----------
deep
If ``True``, a deep copy is returned. If ``False``, a shallow copy is returned.
"""
if deep is True:
return copy.deepcopy(self)
else:
return copy.copy(self)
[docs]
@requires_lilypond
def plot_melody(
self,
filepath: os.PathLike | str | None = None,
key: str | None = None,
figsize: tuple[float, float] | None = None,
dpi: int = 600,
) -> tuple[plt.Figure, plt.Axes]:
"""
Use this function to plot the melody in musical notes. It requires lilypond to be installed. See
:py:meth:`Rhythm.plot_rhythm` for installation instructions.
.. figure:: images/plot_melody.png
:scale: 50 %
An example of a melody plotted with this method.
Parameters
----------
filepath
Optionally, you can save the plot to a file. Supported file formats are only '.png' and '.eps'.
The desired file format will be selected based on what the filepath ends with.
key
The musical key to plot in. Can differ from the key used to construct the :class:`Melody` object.
Say you want to emphasize the accidentals (sharp or flat note), you can choose to plot the melody
in 'C'. The default is to plot in the key that was used to construct the object.
figsize
The figure size in inches, as a tuple of floats. This refers to the figsize argument in :func:`matplotlib.pyplot.figure`.
dpi
The resolution of the plot in dots per inch.
Examples
--------
>>> r = thebeat.music.Rhythm(iois=[250, 500, 250, 500], time_signature=(3, 4))
>>> m = Melody(r, 'CCGC')
>>> m.plot_melody() # doctest: +SKIP
>>> m.plot_melody(filepath='mymelody.png') # doctest: +SKIP
>>> fig, ax = m.plot_melody(key='C') # doctest: +SKIP
"""
if abjad is None:
raise ImportError(
"This function requires the abjad package. Install, for instance by typing "
"`pip install abjad` or `pip install thebeat[music-notation]` into your terminal.\n"
"For more details, see https://thebeat.readthedocs.io/en/latest/installation.html."
)
lp = self._get_lp_from_events(key=key)
fig, ax = thebeat.helpers.plot_lp(
lp, filepath=filepath, figsize=figsize, dpi=dpi
)
return fig, ax
[docs]
def synthesize_and_return(
self,
event_durations_ms: list[int] | npt.NDArray[int] | int | None = None,
fs: int = 48000,
n_channels: int = 1,
amplitude: float = 1.0,
oscillator: str = "sine",
onramp_ms: int = 0,
offramp_ms: int = 0,
ramp_type: str = "linear",
metronome: bool = False,
metronome_amplitude: float = 1.0,
) -> tuple[np.ndarray, int]:
"""Since :py:class:`Melody` objects do not contain any sound information, you can use this method to
synthesize the sound. It returnes a tuple containing the sound samples as a NumPy 1-D array,
and the sampling frequency.
Note
----
Theoretically, four quarternotes played after each other constitute one long sound. This
behaviour is the default here. However, in many cases it will probably be best to supply
``event_durations``, which means the events are played in the rhythm of the melody (i.e. according
to the inter-onset intervals of the rhythm), but using a supplied duration.
Parameters
----------
event_durations_ms
Can be supplied as a single integer, which means that duration will be used for all events
in the melody, or as an array of list containing individual durations for each event. That of course
requires an array or list with a size equal to the number of notes in the melody.
fs
The desired sampling frequency in hertz.
n_channels
The desired number of channels. Can be 1 (mono) or 2 (stereo).
amplitude
Factor with which sound is amplified. Values between 0 and 1 result in sounds that are less loud,
values higher than 1 in louder sounds. Defaults to 1.0.
oscillator
The oscillator used for generating the sound. Either 'sine' (the default), 'square' or 'sawtooth'.
onramp_ms
The sound's 'attack' in milliseconds.
offramp_ms
The sound's 'decay' in milliseconds.
ramp_type
The type of on- and offramp_ms used. Either 'linear' (the default) or 'raised-cosine'.
metronome
If ``True``, a metronome sound is added to the samples. It uses :py:attr:`Melody.beat_ms` as the inter-onset
interval.
metronome_amplitude
If desired, when synthesizing the object with a metronome sound you can adjust the
metronome amplitude. A value between 0 and 1 means a less loud metronome, a value larger than 1 means
a louder metronome sound.
Examples
--------
>>> mel = Melody.generate_random_melody()
>>> samples, fs = mel.synthesize_and_return()
"""
samples = self._make_melody_sound(
fs=fs,
oscillator=oscillator,
amplitude=amplitude,
onramp_ms=onramp_ms,
n_channels=n_channels,
offramp_ms=offramp_ms,
ramp_type=ramp_type,
event_durations_ms=event_durations_ms,
)
if metronome is True:
samples = thebeat.helpers.get_sound_with_metronome(
samples=samples,
fs=fs,
metronome_ioi=self.beat_ms,
metronome_amplitude=metronome_amplitude,
)
return samples, fs
[docs]
def synthesize_and_play(
self,
event_durations_ms: list[int] | npt.NDArray[int] | int | None = None,
fs: int = 48000,
n_channels: int = 1,
amplitude: float = 1.0,
oscillator: str = "sine",
onramp_ms: int = 0,
offramp_ms: int = 0,
ramp_type: str = "linear",
metronome: bool = False,
metronome_amplitude: float = 1.0,
):
"""
Since :py:class:`Melody` objects do not contain any sound information, you can use this method to
first synthesize the sound, and subsequently have it played via the internally used :func:`sounddevice.play`.
Note
----
Theoretically, four quarternotes played after each other constitute one long sound. This
behaviour is the default here. However, in many cases it will probably be best to supply
``event_durations``, which means the events are played in the rhythm of the melody (i.e. according
to the inter-onset intervals of the rhythm), but using a supplied duration.
Parameters
----------
event_durations_ms
Can be supplied as a single integer, which means that duration will be used for all events
in the melody, or as an array of list containing individual durations for each event. That of course
requires an array or list with a size equal to the number of notes in the melody.
fs
The desired sampling frequency in hertz.
n_channels
The desired number of channels. Can be 1 (mono) or 2 (stereo).
amplitude
Factor with which sound is amplified. Values between 0 and 1 result in sounds that are less loud,
values higher than 1 in louder sounds. Defaults to 1.0.
oscillator
The oscillator used for generating the sound. Either 'sine' (the default), 'square' or 'sawtooth'.
onramp_ms
The sound's 'attack' in milliseconds.
offramp_ms
The sound's 'decay' in milliseconds.
ramp_type
The type of on- and offramp used. Either 'linear' (the default) or 'raised-cosine'.
metronome
If ``True``, a metronome sound is added for playback. It uses :py:attr:`Melody.beat_ms` as the inter-onset
interval.
metronome_amplitude
If desired, when writing the object with a metronome sound you can adjust the
metronome amplitude. A value between 0 and 1 means a less loud metronome, a value larger than 1 means
a louder metronome sound.
Examples
--------
>>> mel = Melody.generate_random_melody()
>>> mel.synthesize_and_play() # doctest: +SKIP
>>> mel.synthesize_and_play(event_durations_ms=50) # doctest: +SKIP
"""
samples, _ = self.synthesize_and_return(
event_durations_ms=event_durations_ms,
fs=fs,
n_channels=n_channels,
amplitude=amplitude,
oscillator=oscillator,
onramp_ms=onramp_ms,
offramp_ms=offramp_ms,
ramp_type=ramp_type,
metronome=metronome,
metronome_amplitude=metronome_amplitude,
)
sounddevice.play(samples, samplerate=fs)
sounddevice.wait()
[docs]
def synthesize_and_write(
self,
filepath: str | os.PathLike,
event_durations_ms: list[int] | npt.NDArray[int] | int | None = None,
fs: int = 48000,
n_channels: int = 1,
amplitude: float = 1.0,
dtype: str | np.dtype = np.int16,
oscillator: str = "sine",
onramp_ms: int = 0,
offramp_ms: int = 0,
ramp_type: str = "linear",
metronome: bool = False,
metronome_amplitude: float = 1.0,
):
"""Since :py:class:`Melody` objects do not contain any sound information, you can use this method to
first synthesize the sound, and subsequently write it to disk as a wave file.
Note
----
Theoretically, four quarternotes played after each other constitute one long sound. This
behaviour is the default here. However, in many cases it will probably be best to supply
``event_durations``, which means the events are played in the rhythm of the melody (i.e. according
to the inter-onset intervals of the rhythm), but using a supplied duration.
Parameters
----------
filepath
The output destination for the .wav file. Either pass e.g. a ``Path`` object, or a string.
Of course be aware of OS-specific filepath conventions.
event_durations_ms
Can be supplied as a single integer, which means that duration will be used for all events
in the melody, or as an array of list containing individual durations for each event. That of course
requires an array or list with a size equal to the number of notes in the melody.
fs
The desired sampling frequency in hertz.
n_channels
The desired number of channels. Can be 1 (mono) or 2 (stereo).
amplitude
Factor with which sound is amplified. Values between 0 and 1 result in sounds that are less loud,
values higher than 1 in louder sounds. Defaults to 1.0.
dtype
The desired data type for the output file. Defaults to ``np.int16``.
This means that the output file will be 16-bit PCM.
oscillator
The oscillator used for generating the sound. Either 'sine' (the default), 'square' or 'sawtooth'.
onramp_ms
The sound's 'attack' in milliseconds.
offramp_ms
The sound's 'decay' in milliseconds.
ramp_type
The type of on- and offramp used. Either 'linear' (the default) or 'raised-cosine'.
metronome
If ``True``, a metronome sound is added to the output file. It uses :py:attr:`Melody.beat_ms` as the inter-onset
interval.
metronome_amplitude
If desired, when playing the object with a metronome sound you can adjust the
metronome amplitude. A value between 0 and 1 means a less loud metronome, a value larger than 1 means
a louder metronome sound.
Examples
--------
>>> mel = Melody.generate_random_melody()
>>> mel.synthesize_and_write(filepath='random_melody.wav') # doctest: +SKIP
"""
samples, _ = self.synthesize_and_return(
event_durations_ms=event_durations_ms,
fs=fs,
n_channels=n_channels,
amplitude=amplitude,
oscillator=oscillator,
onramp_ms=onramp_ms,
offramp_ms=offramp_ms,
ramp_type=ramp_type,
metronome=metronome,
metronome_amplitude=metronome_amplitude,
)
thebeat.helpers.write_wav(
samples=samples,
fs=fs,
filepath=filepath,
dtype=dtype,
metronome=metronome,
metronome_ioi=self.beat_ms,
metronome_amplitude=metronome_amplitude,
)
def _make_namedtuples(self, rhythm, iois, note_values, pitch_names, is_played) -> list:
events = []
for event in zip(rhythm.onsets, iois, note_values, pitch_names, is_played):
entry = self.Event(event[0], event[1], event[2], event[3], event[4])
events.append(entry)
return events
def _make_melody_sound(
self,
fs: int,
n_channels: int,
oscillator: str,
amplitude: float,
onramp_ms: int,
offramp_ms: int,
ramp_type: str,
event_durations_ms: list[int] | npt.NDArray[int] | int | None = None,
):
# Calculate required number of frames
total_duration_ms = np.sum(self.iois)
n_frames = total_duration_ms / 1000 * fs
# Avoid rounding issues
if not n_frames.is_integer():
warnings.warn(thebeat._warnings.framerounding_melody)
n_frames = round(n_frames)
# Create empty array with length n_frames
if n_channels == 1:
samples = np.zeros(n_frames, dtype=np.float64)
else:
samples = np.zeros((n_frames, 2), dtype=np.float64)
# Set event durations to the IOIs if no event durations were supplied (i.e. use full length notes)
if event_durations_ms is None:
event_durations = self.iois
# If a single integer is passed, use that value for all the events
elif isinstance(event_durations_ms, (int, float)):
event_durations = np.tile(event_durations_ms, len(self.events))
else:
event_durations = event_durations_ms
# In Abjad 3.26 and lower, these values were properties; now they are member functions.
# Abjad 2.28 requires at least Python 3.12; so this is a patch for backwards compatibility.
# Remove once support for 3.11 gets dropped.
def get_hertz(named_pitch):
try:
return named_pitch.hertz()
except TypeError:
return named_pitch.hertz
# Loop over the events, synthesize event sound, and add all of them to the samples array at the appropriate
# times.
for event, duration_ms in zip(self.events, event_durations):
if event.is_played is True:
event_samples = thebeat.helpers.synthesize_sound(
duration_ms=duration_ms,
fs=fs,
freq=get_hertz(abjad.NamedPitch(event.pitch_name)),
n_channels=n_channels,
oscillator=oscillator,
amplitude=amplitude,
)
if onramp_ms or offramp_ms:
event_samples = thebeat.helpers.make_ramps(
samples=event_samples,
fs=fs,
onramp_ms=onramp_ms,
offramp_ms=offramp_ms,
ramp_type=ramp_type,
)
# Calculate start- and end locations for inserting the event into the output array
# and warn if the location in terms of frames was rounded off.
start_pos = event.onset_ms / 1000 * fs
if not start_pos.is_integer():
warnings.warn(thebeat._warnings.framerounding_melody)
start_pos = round(start_pos)
end_pos = start_pos + event_samples.shape[0]
# Add event samples to output array
samples[start_pos:end_pos] = samples[start_pos:end_pos] + event_samples
else:
pass
if np.max(samples) > 1:
warnings.warn(thebeat._warnings.normalization)
samples = thebeat.helpers.normalize_audio(samples)
return samples
def _get_lp_from_events(self, key: str | None):
time_signature = abjad.TimeSignature(self.time_signature)
pitch = abjad.NamedPitchClass(key if key is not None else self.key)
key = abjad.KeySignature(pitch)
preamble = textwrap.dedent(
r"""
\version "2.22.1"
\language "english"
\paper {
indent = 0\mm
line-width = 110\mm
oddHeaderMarkup = ""
evenHeaderMarkup = ""
oddFooterMarkup = ""
evenFooterMarkup = ""
}
"""
)
# Get note durations and make the notes and rests
note_durations = _get_abjad_note_durations(self.note_values)
split_note_durations = _get_abjad_ties(note_durations, self.time_signature)
pitches = [abjad.NamedPitch(event.pitch_name) for event in self.events]
is_played = [event.is_played for event in self.events]
notes = _make_abjad_notes_and_rests(pitches, split_note_durations, is_played)
# Plot the notes
staff = abjad.Staff(notes)
abjad.attach(time_signature, staff[0])
abjad.attach(key, staff[0])
score = abjad.Score([staff])
score_lp = abjad.lilypond(score)
lpf_str = preamble + score_lp
return lpf_str