# Copyright (C) 2022-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
from fractions import Fraction
import numpy as np
import pandas as pd
import thebeat.core
import thebeat.music
try:
import abjad
except ImportError:
abjad = None
[docs]
def get_ioi_df(
sequences: (
thebeat.core.Sequence | list[thebeat.core.Sequence] | np.ndarray[thebeat.core.Sequence]
),
additional_functions: list[callable] | None = None,
) -> pd.DataFrame:
"""
This function exports a Pandas :class:`pandas.DataFrame` with information about the provided
:py:class:`thebeat.core.Sequence` objects in
`tidy data <https://cran.r-project.org/web/packages/tidyr/vignettes/tidy-data.html>`_ format.
The DataFrame always has the columns:
* ``Sequence_index``: The index of the Sequence object in the list of Sequences.
* ``IOI_i``: The index of the IOI in the Sequence.
* ``IOI``: The IOI.
Additionally it has a column ``Sequence_name`` if at least one of the provided Sequence objects
has a name.
Moreover, one can provide a list of functions that will be applied to each sequence's IOIs.
The results will be added as additional columns in the output DataFrame. See under 'Examples' for an
illustration.
Parameters
----------
sequences
The Sequence object(s) to be exported.
additional_functions
A list of functions that will be applied to the IOIs for each individual sequence,
and the results of which will be added as additional columns.
Returns
-------
pd.DataFrame
A Pandas DataFrame with information about the provided Sequence objects in tidy data format.
Examples
--------
>>> rng = np.random.default_rng(123)
>>> seqs = [thebeat.core.Sequence.generate_random_normal(n_events=10, mu=500, sigma=25, rng=rng) for _ in range(10)]
>>> df = get_ioi_df(seqs)
>>> print(df.head())
sequence_i ioi_i ioi
0 0 0 475.271966
1 0 1 490.805334
2 0 2 532.198132
3 0 3 504.849360
4 0 4 523.005772
>>> import numpy as np
>>> df = get_ioi_df(seqs, additional_functions=[np.mean, np.std])
>>> print(df.head())
sequence_i mean std ioi_i ioi
0 0 503.364499 17.923263 0 475.271966
1 0 503.364499 17.923263 1 490.805334
2 0 503.364499 17.923263 2 532.198132
3 0 503.364499 17.923263 3 504.849360
4 0 503.364499 17.923263 4 523.005772
"""
# Checks
if not all(isinstance(sequence, thebeat.core.Sequence) for sequence in sequences):
raise TypeError("The provided sequences must be Sequence objects.")
if additional_functions is not None and not all(callable(f) for f in additional_functions):
raise TypeError("The functions in additional_functions must be callable.")
# Create output dictionary
output_df = None
# Loop over sequences to fill output_dict with the columns that we always have
for i, sequence in enumerate(sequences):
# Start with the sequence index and the sequence name if it exists
sequence_dict = {
"sequence_i": i,
"sequence_name": sequence.name if sequence.name else np.nan,
}
# If functions were provided, add those columns
if additional_functions is not None:
for f in additional_functions:
# todo consider what sorts of error handling to do here
sequence_dict[f.__name__] = f(sequence.iois)
# Add the IOI index and the IOI itself
sequence_dict["ioi_i"] = np.arange(len(sequence.iois))
sequence_dict["ioi"] = sequence.iois
# Concatenate the new DataFrame to the output DataFrame
# If this is the first iteration, we need to create the output DataFrame first
if output_df is None:
output_df = pd.DataFrame(sequence_dict)
else:
output_df = pd.concat([output_df, pd.DataFrame(sequence_dict)], ignore_index=True)
# Check if all names are None, if so, drop the column
if output_df["sequence_name"].isnull().all():
output_df.drop("sequence_name", axis=1, inplace=True)
return output_df
[docs]
def get_major_scale(tonic: str, octave: int) -> list[abjad.pitch.NamedPitch]:
"""Get the major scale for a given tonic and octave. Returns a list of :class:`abjad.pitch.NamedPitch` objects.
Note
----
This function requires abjad to be installed. It can be installed with ``pip install abjad`` or
``pip install thebeat[music-notation]``.
For more details, see https://thebeat.readthedocs.io/en/latest/installation.html.
Parameters
----------
tonic
The tonic of the scale, e.g. 'G'.
octave
The octave of the scale, e.g. 4.
Returns
-------
pitches
A list of :class:`abjad.pitch.NamedPitch` objects.
"""
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."
)
intervals = "M2 M2 m2 M2 M2 M2 m2".split()
intervals = [abjad.NamedInterval(interval) for interval in intervals]
pitches = []
pitch = abjad.NamedPitch(tonic, octave=octave)
pitches.append(pitch)
for interval in intervals:
pitch = pitch + interval
pitches.append(pitch)
return pitches
[docs]
def concatenate_sequences(sequences: np.typing.ArrayLike, name: str | None = None) -> thebeat.core.Sequence:
"""Concatenate an array or list of :py:class:`~thebeat.core.Sequence` objects.
The resulting :py:class:`~thebeat.core.Sequence` object contains the sequences in order.
Note
----
Only works for Sequence objects where all but the last provided object has an
``end_with_interval=True`` flag.
Parameters
----------
sequences
The to-be-concatenated objects.
name
Optionally, you can give the returned Sequence object a name.
Returns
-------
object
The concatenated Sequence
"""
if not all(isinstance(obj, thebeat.core.Sequence) for obj in sequences):
raise TypeError("Please pass only Sequence objects.")
if not all(obj.end_with_interval for obj in sequences[:-1]):
raise ValueError(
"All passed Sequence objects except for the final one need to end with an interval."
"Otherwise we miss an interval between the onset of the "
"final event in a Sequence and the onset of the first event in the next sequence."
)
if not all(obj.onsets[0] == 0.0 for obj in sequences):
raise ValueError("Please only pass sequences that have their first event at onset 0.0")
# Whether the sequence ends with an interval depends only on the final object passed
end_with_interval = sequences[-1].end_with_interval
# concatenate iois and create new Sequence
iois = np.concatenate([obj.iois for obj in sequences])
return thebeat.core.Sequence(iois, end_with_interval=end_with_interval, name=name)
[docs]
def concatenate_soundsequences(sound_sequences: np.typing.ArrayLike, name: str | None = None) -> thebeat.core.SoundSequence:
"""Concatenate an array or list of :py:class:`~thebeat.core.SoundSequence` objects.
The resulting :py:class:`~thebeat.core.SoundSequence` object contains the sound sequences in order.
Note
----
Only works for SoundSequence objects where all but the last provided object has an
``end_with_interval=True`` flag.
Parameters
----------
sound_sequences
The to-be-concatenated objects.
name
Optionally, you can give the returned SoundSequence object a name.
Returns
-------
object
The concatenated SoundSequence
"""
if not all(isinstance(obj, thebeat.core.SoundSequence) for obj in sound_sequences):
raise TypeError("Please pass only SoundSequence objects.")
if not all(obj.end_with_interval for obj in sound_sequences[:-1]):
raise ValueError(
"All passed SoundSequence objects except for the final one need to end with an interval."
"Otherwise we miss an interval between the onset of the "
"final event in a Sequence and the onset of the first event in the next sequence."
)
# Whether the sequence ends with an interval depends only on the final object passed
end_with_interval = sound_sequences[-1].end_with_interval
# concatenate iois and create new Sequence
iois = np.concatenate([obj.iois for obj in sound_sequences])
seq = thebeat.core.Sequence(iois, end_with_interval=end_with_interval)
# concatenate sounds
all_sounds = [sound_obj for obj in sound_sequences for sound_obj in obj.sound_objects]
return thebeat.core.SoundSequence(sound=all_sounds, sequence=seq, name=name)
[docs]
def concatenate_soundstimuli(sound_stimuli: np.ndarray | list, name: str | None = None):
"""Concatenate an array or list of :py:class:`~thebeat.core.SoundStimulus` objects.
The resulting :py:class:`~thebeat.core.SoundStimulus` object contains the sound stimuli in order.
Parameters
----------
sound_stimuli
The to-be-concatenated objects.
name
Optionally, you can give the returned SoundStimulus object a name.
Returns
-------
object
The concatenated SoundStimulus
"""
if not all(isinstance(obj, thebeat.core.SoundStimulus) for obj in sound_stimuli):
raise TypeError("Please pass only SoundStimulus objects.")
thebeat.helpers.check_sound_properties_sameness(sound_stimuli)
samples = np.concatenate([obj.samples for obj in sound_stimuli])
fs = sound_stimuli[0].fs
return thebeat.core.SoundStimulus(samples, fs, name=name)
[docs]
def concatenate_rhythms(rhythms: np.typing.ArrayLike, name: str | None = None) -> thebeat.music.Rhythm:
"""Concatenate an array or list of :py:class:`~thebeat.music.Rhythm` objects.
The resulting :py:class:`~thebeat.music.Rhythm` object contains the rhythms in order.
Parameters
----------
rhythms
The to-be-concatenated objects.
name
Optionally, you can give the returned :py:class:`~thebeat.music.Rhythm`
object a name.
Returns
-------
object
The concatenated Rhythm
"""
if not len(rhythms) >= 1:
raise ValueError("At least one Rhythm object is required to concatenate.")
# Check whether all the objects are of the same type
if not all(isinstance(obj, thebeat.music.Rhythm) for obj in rhythms):
raise TypeError("Please pass only Rhythm objects.")
time_signature = rhythms[0].time_signature
if not all(rhythm.time_signature == time_signature for rhythm in rhythms):
raise ValueError("Provided rhythms should have the same time signatures.")
beat_ms = rhythms[0].beat_ms
if not all(rhythm.beat_ms == beat_ms for rhythm in rhythms):
raise ValueError("Provided rhythms should have same tempo (beat_ms).")
iois = np.concatenate([rhythm.iois for rhythm in rhythms])
return thebeat.music.Rhythm(iois, time_signature=time_signature, beat_ms=beat_ms, name=name)
[docs]
def merge_soundstimuli(
sound_stimuli: np.typing.ArrayLike[thebeat.SoundStimulus], name: str | None = None
) -> thebeat.core.SoundStimulus:
"""Merge an array or list of :py:class:`~thebeat.core.SoundStimulus` objects.
The sound samples for each of the objects will be overlaid on top of each other.
Parameters
----------
sound_stimuli
The to-be-merged objects.
name
Optionally, you can give the returned SoundStimulus object a name.
Returns
-------
object
The merged SoundStimulus
"""
if not all(isinstance(obj, thebeat.core.SoundStimulus) for obj in sound_stimuli):
raise TypeError(
"Can only overlay another SoundStimulus object on this SoundStimulus object."
)
# Check sameness of number of channels etc.
thebeat.helpers.check_sound_properties_sameness(sound_stimuli)
# Overlay sounds
samples = thebeat.helpers.overlay_samples([obj.samples for obj in sound_stimuli])
return thebeat.core.SoundStimulus(samples=samples, fs=sound_stimuli[0].fs, name=name)
[docs]
def merge_sequences(sequences: np.typing.ArrayLike[thebeat.core.Sequence], name: str | None = None) -> thebeat.core.Sequence:
"""Merge an array or list of :py:class:`~thebeat.core.Sequence` objects.
The the event onsets in each of the objects will be overlaid on top of each other.
Parameters
----------
sequences
The to-be-merged objects.
name
Optionally, you can give the returned Sequence object a name.
Returns
-------
object
The merged Sequence
"""
# check if only Sequence objects were passed
if not all(isinstance(obj, thebeat.core.Sequence) for obj in sequences):
raise TypeError("Please pass only Sequence objects.")
# concatenate onsets and sort
onsets = np.concatenate([obj.onsets for obj in sequences])
onsets.sort()
# Check for duplicates
if np.any(onsets[1:] == onsets[:-1]):
raise ValueError("The merged Sequence object would contain duplicate onsets.")
return thebeat.core.Sequence.from_onsets(onsets, name=name)
[docs]
def merge_soundsequences(
sound_sequences: list[thebeat.core.SoundSequence], name: str | None = None
) -> thebeat.core.SoundSequence:
"""Merge a list or array of :py:class:`~thebeat.core.SoundSequence` objects.
The event onsets in each of the objects will be overlaid on top of each other.
Parameters
----------
sound_sequences
The to-be-merged objects.
name
Optionally, you can give the returned SoundSequence object a name.
Returns
-------
object
The merged SoundSequence
"""
# check if only SoundSequence objects were passed
if not all(isinstance(obj, thebeat.core.SoundSequence) for obj in sound_sequences):
raise TypeError("Please pass only SoundSequence objects.")
# Get all onsets and sounds
all_onsets = np.concatenate([obj.onsets for obj in sound_sequences])
all_sounds = [sound_obj for obj in sound_sequences for sound_obj in obj.sound_objects]
# Sort sounds in same order as onsets
sounds_sorted = [all_sounds[i] for i in np.argsort(all_onsets)]
# Sort onsets onsets and create new Sequence
onsets_sorted = np.sort(all_onsets)
seq = thebeat.Sequence.from_onsets(onsets_sorted)
return thebeat.core.SoundSequence(sound=sounds_sorted, sequence=seq, name=name)
[docs]
def rhythm_to_binary(rhythm: thebeat.music.Rhythm, smallest_note_value: float | Fraction = Fraction(1, 16)) -> np.ndarray[np.uint8]:
"""Convert a rhythm to a binary representation, consisting of zeros and ones.
The time range of :py:class:`~thebeat.music.Rhythm` will be discretized based on the
provided smallest note value. For example, for a ``smallest_note_value`` of 1/6,
each 4/4 bar will result in a list of 16 ones and zeros. Each event (or note) within
the :py:class:`~thebeat.music.Rhythm` object will be respresented as a ``1``, and all
other entries will be ``0``, resulting in a binary representation of the rhythm.
Examples
--------
>>> rhythm = thebeat.music.Rhythm.from_note_values([1/4, 1/2, 1/8, 1/8])
>>> rhythm_to_binary(rhythm, smallest_note_value=Fraction(1, 16))
array([1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0], dtype=uint8)
>>> rhythm_to_binary(rhythm, smallest_note_value=1/8)
array([1, 0, 1, 0, 0, 0, 1, 1], dtype=uint8)
Parameters
----------
rhythm
The object to be converted to a binary representation.
smallest_note_value
The note value to be used as grid size for the discretization.
Default value: ``Fraction(1, 16)``.
Returns
-------
np.ndarray[np.uint8]
The binary representation of the rhythm.
"""
smallest_note_value = Fraction(smallest_note_value).limit_denominator()
n_positions = (rhythm.n_bars / smallest_note_value) * rhythm.time_signature[0] / rhythm.time_signature[1]
if not n_positions.denominator == 1:
raise ValueError(
"Something went wrong while making the rhythmic grid. Try supplying a different "
"'smallest_note_value'."
)
n_positions = int(n_positions)
# Create empty zeros array
signal = np.zeros(n_positions, dtype=np.uint8)
# We multiply each fraction by the total length of the zeros array to get the respective positions
# and add zero for the first onset
indices_float = rhythm.onsets / rhythm.duration * n_positions
# Check if any of the indices are not integers
if np.any(indices_float % 1 != 0):
raise ValueError(
"The smallest_note_value that you provided is longer than the shortest note in the "
"rhythm. Please provide a shorter note value as the smallest_note_value (i.e. a larger "
"number)."
)
indices = np.round(indices_float).astype(int)
played_indices = indices[rhythm.is_played]
signal[played_indices] = 1
return signal
[docs]
def sequence_to_binary(sequence: thebeat.core.Sequence, resolution: int | float) -> np.ndarray[np.uint8]:
"""Convert a sequence to a binary representation, consisting of ones and zeros.
The time range of :py:class:`~thebeat.core.Sequence`is discretized based on the
provided resolution. The full duration is split up into parts, each part
corresponding to the provided ``resolution``. Each event of the
:py:class:`~thebeat.core.Sequence` object will be respresented as a ``1``,
and all others element ``0``, in the resulting binary representation of the
sequence.
Examples
--------
>>> seq = thebeat.Sequence([110, 185, 90])
>>> sequence_to_binary(seq, resolution=100)
array([1, 1, 0, 1, 1], dtype=uint8)
>>> sequence_to_binary(seq, resolution=50)
array([1, 0, 1, 0, 0, 0, 1, 0, 1], dtype=uint8)
Parameters
----------
rhythm
The object to be converted to a binary representation.
resolution
The resolution of the temporal discretization.
Returns
-------
np.ndarray[np.uint8]
The binary representation of the sequence.
"""
if sequence.onsets[0] < 0:
raise ValueError(
"Cannot turn a sequence to binary with onsets before time 0.\n"
f"First onset: {sequence.onsets[0]}\n"
"This can be easily fixed by shifting your events:\n"
"`sequence.onsets = sequence.onsets - sequence.onsets[0]`."
)
sequence_end = sequence.onsets[-1]
if sequence.end_with_interval:
sequence_end += sequence.iois[-1]
n_samples = round(sequence_end / resolution)
if not sequence.end_with_interval:
n_samples += 1
signal = np.zeros(n_samples, dtype=np.uint8)
one_indices = np.round(sequence.onsets / resolution).astype(int)
signal[one_indices] = 1
return signal