"""MuseScore input interface."""
import warnings
import xml.etree.ElementTree as ET
from collections import OrderedDict
from fractions import Fraction
from functools import reduce
from operator import attrgetter
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypeVar, Union
from xml.etree.ElementTree import Element
from zipfile import ZipFile
from ..classes import (
Beat,
KeySignature,
Lyric,
Metadata,
Note,
Tempo,
TimeSignature,
Track,
)
from ..music import Music
from ..utils import (
CIRCLE_OF_FIFTHS,
MODE_CENTERS,
NOTE_TYPE_MAP,
TONAL_PITCH_CLASSES,
)
from .musicxml import get_beats
T = TypeVar("T")
[docs]class MuseScoreError(Exception):
"""A class for MuseScore related errors."""
class MuseScoreWarning(Warning):
"""A class for MuseScore related warnings."""
def _gcd(a: int, b: int) -> int:
"""Return greatest common divisor using Euclid's Algorithm.
Code copied from https://stackoverflow.com/a/147539.
"""
while b:
a, b = b, a % b
return a
def _lcm_two_args(a: int, b: int) -> int:
"""Return least common multiple.
Code copied from https://stackoverflow.com/a/147539.
"""
return a * b // _gcd(a, b)
def _lcm(*args: int) -> int:
"""Return lcm of args.
Code copied from https://stackoverflow.com/a/147539.
"""
return reduce(_lcm_two_args, args) # type: ignore
def _get_text(
element: Element,
path: str,
default: T = None,
remove_newlines: bool = False,
) -> Union[str, T]:
"""Return the text of the first matching element."""
elem = element.find(path)
if elem is not None and elem.text is not None:
if remove_newlines:
return " ".join(elem.text.splitlines())
return elem.text
return default # type: ignore
def _get_required(element: Element, path: str) -> Element:
"""Return a required child element of an element.
Raise a MuseScoreError if not found.
"""
elem = element.find(path)
if elem is None:
raise MuseScoreError(
f"Element `{path}` is required for an '{element.tag}' element."
)
return elem
def _get_required_attr(element: Element, attr: str) -> str:
"""Return a required attribute of an element.
Raise a MuseScoreError if not found.
"""
attribute = element.get(attr)
if attribute is None:
raise MuseScoreError(
f"Attribute '{attr}' is required for an '{element.tag}' element."
)
return attribute
def _get_required_text(
element: Element, path: str, remove_newlines: bool = False
) -> str:
"""Return a required text from a child element of an element.
Raise a MuseScoreError otherwise.
"""
elem = _get_required(element, path)
if elem.text is None:
raise MuseScoreError(
f"Text content '{path}' of an element '{element.tag}' must not be "
"empty."
)
if remove_newlines:
return " ".join(elem.text.splitlines())
return elem.text
def parse_metronome_elem(elem: Element) -> Optional[float]:
"""Return a qpm value parsed from a metronome element."""
beat_unit = _get_text(elem, "beat-unit")
if beat_unit is not None:
per_minute = _get_text(elem, "per-minute")
if per_minute is not None and beat_unit in NOTE_TYPE_MAP:
qpm = NOTE_TYPE_MAP[beat_unit] * float(per_minute)
if elem.find("beat-unit-dot") is not None:
qpm *= 1.5
return qpm
return None
def parse_time_elem(elem: Element) -> Tuple[int, int]:
"""Return the numerator and denominator of a time element."""
# Numerator
beats = _get_text(elem, "sigN")
if beats is None:
beats = _get_text(elem, "nom1")
if beats is None:
raise MuseScoreError(
"Neither 'sigN' nor 'nom1' element is found for a TimeSig element."
)
if "+" in beats:
numerator = sum(int(beat) for beat in beats.split("+"))
else:
numerator = int(beats)
# Denominator
beat_type = _get_text(elem, "sigD")
if beat_type is None:
beat_type = _get_text(elem, "den")
if beat_type is None:
raise MuseScoreError(
"Neither 'sigD' nor 'den' element is found for a TimeSig element."
)
if "+" in beat_type:
raise RuntimeError(
"Compound time signatures with separate fractions "
"are not supported."
)
denominator = int(beat_type)
return numerator, denominator
def parse_key_elem(elem: Element) -> Tuple[int, str, int, str]:
"""Return the key parsed from a key element."""
mode = _get_text(elem, "mode", "major")
fifths_text = _get_text(elem, "accidental") # MuseScore 2.x and 3.x
if fifths_text is None:
fifths_text = _get_text(elem, "subtype") # MuseScore 1.x
if fifths_text is None:
raise MuseScoreError(
"Neither 'accidental' nor 'subtype' element is found for a KeySig "
"element."
)
fifths = int(fifths_text)
if mode is None:
return None, None, fifths, None
idx = MODE_CENTERS[mode] + fifths
if idx < 0 or idx > 20:
return None, mode, fifths, None # type: ignore
root, root_str = CIRCLE_OF_FIFTHS[MODE_CENTERS[mode] + fifths]
return root, mode, fifths, root_str
def parse_lyric_elem(elem: Element) -> str:
"""Return the lyric text parsed from a lyric element."""
text = _get_required_text(elem, "text")
syllabic_elem = elem.find("syllabic")
if syllabic_elem is not None:
if syllabic_elem.text == "begin":
text = f"{text} -"
elif syllabic_elem.text == "middle":
text = f"- {text} -"
elif syllabic_elem.text == "end":
text = f"- {text}"
return text
def parse_marker_measure_map(elem: Element) -> Dict[str, int]:
"""Return a marker-measure map parsed from a staff element."""
# Initialize with a start marker
markers: Dict[str, int] = {"start": 0}
# Find all markers in all measures
for i, measure_elem in enumerate(elem.findall("Measure")):
for marker_elem in measure_elem.findall("Marker"):
label = _get_text(marker_elem, "label")
if label is not None:
markers[label] = i
return markers
def get_measure_ordering(elem: Element) -> List[int]:
"""Return a list of measure indices parsed from a staff element.
This function returns the ordering of measures, considering all
repeats and jumps.
"""
# Measure indices
measure_indices = []
# Repeats
last_repeat = 0
count_repeat = 1
count_ending = 1
# Flags
is_after_jump = False
is_after_play_until = False
# Jump-related measure indices
jump_to_idx = None
play_until_idx = None
continue_at_idx = None
# Get the marker measure map
marker_measure_map = parse_marker_measure_map(elem)
# Iterate over all measures
measure_idx = 0
measure_elems = list(elem.findall("Measure"))
while measure_idx < len(measure_elems):
# Get the measure element
measure_elem = measure_elems[measure_idx]
# Handle jumps
#
# [Example]
# jump
# v
# ║----|----|----|----|----|----|----║
# ^ ^ ^
# jump-to play-until continue at
#
# [Expansion]
#
# ║----|----|----|----|----| (a)
# ┌──────<─────<──────┘ (b)
# |----|----| (c)
# └────>────>────┐ (d)
# |----║ (e)
# (Stages b and c)
if is_after_jump and not is_after_play_until:
# (Stage b) Look for the jump-to measure
if jump_to_idx is not None and measure_idx < jump_to_idx:
# Skip the current measure if it is not the correct one
measure_idx += 1
continue
# (Stage c) Look for the play-until measure
if play_until_idx is not None and measure_idx > play_until_idx:
# If we reach the play-until measure but no continue-at
# measure is given, we reach the end of the score, e.g.,
# a "D.C. al Fine" or "D.S. al Fine".
if continue_at_idx is None:
break
# Otherwise, we skip the current measure and look for
# the continue-at measure.
is_after_play_until = True
measure_idx = 0
continue
# (Stages d and e)
if is_after_play_until:
# Should never enter this if statement but have it here in
# case the file is corrupted.
if continue_at_idx is None:
break
# (Stage d) Look for the continue-at measure
if measure_idx < continue_at_idx:
measure_idx += 1
continue
# (Stage e) Reset all the flags to allow another jump
is_after_jump = False
is_after_play_until = False
jump_to_idx = None
play_until_idx = None
continue_at_idx = None
# Set the default next measure
next_measure_idx = measure_idx + 1
# Jump elements
if not is_after_jump:
jump_elem = measure_elem.find("Jump")
if jump_elem is not None:
jump_to_idx = marker_measure_map.get(
_get_text(jump_elem, "jumpTo")
)
play_until_idx = marker_measure_map.get(
_get_text(jump_elem, "playUntil")
)
continue_at_idx = marker_measure_map.get(
_get_text(jump_elem, "continueAt")
)
is_after_jump = True
# Get back to the first measure to look for the
# jump-to measure
next_measure_idx = 0
# Repeat elements (forward)
if measure_elem.find("startRepeat"):
last_repeat = measure_idx
# Volta elements
is_wrong_ending = False
for volta_elem in measure_elem.findall("voice/Spanner/Volta"):
ending_num_text = _get_required_text(volta_elem, "endings")
ending_num = [int(num) for num in ending_num_text.split(",")]
# Skip this measure if it is not the correct ending
if count_ending not in ending_num:
is_wrong_ending = True
# Skip this measure if it is not the correct ending
if is_wrong_ending:
measure_idx += 1
continue
# Repeat elements (backward)
end_repeat_element = measure_elem.find("endRepeat")
if end_repeat_element is not None:
# Get repeat times
if end_repeat_element.text is None:
repeat_times = 2
else:
repeat_times = int(end_repeat_element.text)
# Check if repeat times has reached
if count_repeat < repeat_times:
count_repeat += 1
count_ending += 1
next_measure_idx = last_repeat
else:
# Reset repeat counters
count_repeat = 1
count_ending = 1
# Append the current measure index to the list to be return
measure_indices.append(measure_idx)
# Proceed to the next measure
measure_idx = next_measure_idx
return measure_indices
def parse_meta_staff_elem(
staff_elem: Element, resolution: int, measure_indices: List[int]
) -> Tuple[List[Tempo], List[KeySignature], List[TimeSignature], List[Beat]]:
"""Return data parsed from a meta staff element.
This function only parses the tempos, key and time signatures. Use
`parse_staff_elem` to parse the notes and lyrics.
"""
# Initialize lists
tempos: List[Tempo] = []
key_signatures: List[KeySignature] = []
time_signatures: List[TimeSignature] = []
# Initialize variables
time = 0
measure_len = round(resolution * 4)
is_tuple = False
downbeat_times: List[int] = []
# Iterate over all elements
measure_elems = list(staff_elem.findall("Measure"))
for measure_idx in measure_indices:
# Get the measure element
measure_elem = measure_elems[measure_idx]
# Collect the measure start times
downbeat_times.append(time)
# Get measure length
measure_len_text = measure_elem.get("len")
if measure_len_text is not None:
measure_len = round(resolution * 4 * Fraction(measure_len))
# Initialize position
position = 0
# Get voice elements
voice_elems = list(measure_elem.findall("voice")) # MuseScore 3.x
if not voice_elems:
voice_elems = [measure_elem] # MuseScore 1.x and 2.x
# Iterate over voice elements
for voice_elem in voice_elems:
# Initialize position
position = 0
# Iterate over child elements
for elem in voice_elem:
# Key signatures
if elem.tag == "KeySig":
root, mode, fifths, root_str = parse_key_elem(elem)
key_signatures.append(
KeySignature(
time=time + position,
root=root,
mode=mode,
fifths=fifths,
root_str=root_str,
)
)
# Time signatures
if elem.tag == "TimeSig":
numerator, denominator = parse_time_elem(elem)
time_signatures.append(
TimeSignature(
time=time + position,
numerator=numerator,
denominator=denominator,
)
)
# Tempo elements
if elem.tag == "Tempo":
tempos.append(
Tempo(
time + position,
60 * float(_get_required_text(elem, "tempo")),
)
)
# Tuplet elements
if elem.tag == "Tuplet":
is_tuple = True
normal_notes = int(_get_required_text(elem, "normalNotes"))
actual_notes = int(_get_required_text(elem, "actualNotes"))
tuple_ratio = normal_notes / actual_notes
# Rest elements
if elem.tag == "Rest":
# Move time position forward if it is a rest
duration_type = _get_required_text(elem, "durationType")
if duration_type == "measure":
duration_text = _get_text(elem, "duration")
if duration_text is not None:
duration = (
resolution * 4 * float(Fraction(duration_text))
)
else:
duration = measure_len
position += round(duration)
continue
duration = NOTE_TYPE_MAP[duration_type] * resolution
position += round(duration)
continue
# Chord elements
if elem.tag == "Chord":
# Compute duration
duration_type = _get_required_text(elem, "durationType")
duration = NOTE_TYPE_MAP[duration_type] * resolution
# Handle tuplets
if is_tuple:
duration *= tuple_ratio
# Handle dots
dots_elem = elem.find("dots")
if dots_elem is not None and dots_elem.text:
duration *= 2 - 0.5 ** int(dots_elem.text)
# Round the duration
duration = round(duration)
# Grace notes
is_grace = False
for child in elem:
if "grace" in child.tag or child.tag in (
"appoggiatura",
"acciaccatura",
):
is_grace = True
if not is_grace:
position += duration
# Handle last tuplet note
if elem.tag == "endTuplet":
old_duration = round(
NOTE_TYPE_MAP[duration_type] * resolution
)
new_duration = normal_notes * old_duration - (
actual_notes - 1
) * round(old_duration * tuple_ratio)
if duration != new_duration:
position += int(new_duration - duration)
is_tuple = False
time += position
# Sort tempos, key and time signatures
tempos.sort(key=attrgetter("time"))
key_signatures.sort(key=attrgetter("time"))
time_signatures.sort(key=attrgetter("time"))
# Get the beats
beats = get_beats(
downbeat_times, time_signatures, resolution, is_sorted=True
)
return tempos, key_signatures, time_signatures, beats
def parse_staff_elem(
staff_elem: Element, resolution: int, measure_indices: List[int]
) -> Tuple[List[Note], List[Lyric]]:
"""Return notes and lyrics parsed from a staff element.
This function only parses the notes and lyrics. Use
`parse_meta_staff_elem` to parse the tempos, key and time signatures.
"""
# Initialize lists
notes: List[Note] = []
lyrics: List[Lyric] = []
# Initialize variables
time = 0
velocity = 64
measure_len = round(resolution * 4)
is_tuple = False
# Create a dictionary to handle ties
ties: Dict[int, int] = {}
# Iterate over all elements
measure_elems = list(staff_elem.findall("Measure"))
for measure_idx in measure_indices:
# Get the measure element
measure_elem = measure_elems[measure_idx]
# Get measure length
measure_len_text = measure_elem.get("len")
if measure_len_text is not None:
measure_len = round(resolution * 4 * Fraction(measure_len))
# Initialize position
position = 0
# Get voice elements
voice_elems = list(measure_elem.findall("voice")) # MuseScore 3.x
if not voice_elems:
voice_elems = [measure_elem] # MuseScore 1.x and 2.x
# Iterate over voice elements
for voice_elem in voice_elems:
# Initialize position
position = 0
# Iterate over child elements
for elem in voice_elem:
# Dynamic elements
if elem.tag == "Dynamic":
velocity = round(
float(_get_text(elem, "velocity", velocity))
)
# Tuplet elements
if elem.tag == "Tuplet":
is_tuple = True
normal_notes = int(_get_required_text(elem, "normalNotes"))
actual_notes = int(_get_required_text(elem, "actualNotes"))
tuple_ratio = normal_notes / actual_notes
# Rest elements
if elem.tag == "Rest":
# Move time position forward if it is a rest
duration_type = _get_required_text(elem, "durationType")
if duration_type == "measure":
duration_text = _get_text(elem, "duration")
if duration_text is not None:
duration = (
resolution * 4 * float(Fraction(duration_text))
)
else:
duration = measure_len
position += round(duration)
continue
duration = NOTE_TYPE_MAP[duration_type] * resolution
position += round(duration)
continue
# Chord elements
if elem.tag == "Chord":
# Compute duration
duration_type = _get_required_text(elem, "durationType")
duration = NOTE_TYPE_MAP[duration_type] * resolution
# Handle tuplets
if is_tuple:
duration *= tuple_ratio
# Handle dots
dots_elem = elem.find("dots")
if dots_elem is not None and dots_elem.text:
duration *= 2 - 0.5 ** int(dots_elem.text)
# Round the duration
duration = round(duration)
# Grace notes
is_grace = False
for child in elem:
if "grace" in child.tag or child.tag in (
"appoggiatura",
"acciaccatura",
):
is_grace = True
# Check if it is a tied chord
is_outgoing_tie = False
for spanner_elem in elem.findall("Spanner"):
if (
spanner_elem.get("type") == "Tie"
and spanner_elem.find("next/location") is not None
):
is_outgoing_tie = True
# Collect notes
for note_elem in elem.findall("Note"):
# Get pitch
pitch = int(_get_required_text(note_elem, "pitch"))
pitch_str = TONAL_PITCH_CLASSES[
int(_get_required_text(note_elem, "tpc"))
]
# Handle grace note
if is_grace:
# Append a new note to the note list
notes.append(
Note(
time=time + position,
pitch=pitch,
duration=duration,
velocity=velocity,
pitch_str=pitch_str,
)
)
continue
# Check if it is a tied note
for spanner_elem in note_elem.findall("Spanner"):
if (
spanner_elem.get("type") == "Tie"
and spanner_elem.find("next/location")
is not None
):
is_outgoing_tie = True
# Check if it is an incoming tied note
if pitch in ties:
note_idx = ties[pitch]
notes[note_idx].duration += duration
if is_outgoing_tie:
ties[pitch] = note_idx
else:
del ties[pitch]
else:
# Append a new note to the note list
notes.append(
Note(
time=time + position,
pitch=pitch,
duration=duration,
velocity=velocity,
pitch_str=pitch_str,
)
)
if is_outgoing_tie:
ties[pitch] = len(notes) - 1
# Lyrics
lyric_elem = elem.find("Lyrics")
if lyric_elem is not None:
lyric_text = parse_lyric_elem(lyric_elem)
lyrics.append(
Lyric(time=time + position, lyric=lyric_text)
)
if not is_grace:
position += duration
# Handle last tuplet note
if elem.tag == "endTuplet":
old_duration = round(
NOTE_TYPE_MAP[duration_type] * resolution
)
new_duration = normal_notes * old_duration - (
actual_notes - 1
) * round(old_duration * tuple_ratio)
if notes[-1].duration != new_duration:
notes[-1].duration = new_duration
position += int(new_duration - duration)
is_tuple = False
time += position
# Sort notes
notes.sort(key=attrgetter("time", "pitch", "duration", "velocity"))
# Sort lyrics
lyrics.sort(key=attrgetter("time"))
return notes, lyrics
def parse_metadata(root: Element) -> Metadata:
"""Return a Metadata object parsed from a MuseScore file."""
# Creators and copyrights
title = None
creators = []
copyrights = []
# Iterate over meta tags
for meta_tag in root.findall("Score/metaTag"):
name = _get_required_attr(meta_tag, "name")
if name == "movementTitle":
title = meta_tag.text
# Only use 'workTitle' when movementTitle is not found
if title is None and name == "workTitle":
title = meta_tag.text
if name in ("arranger", "composer", "lyricist"):
if meta_tag.text is not None:
creators.append(meta_tag.text)
if name == "copyright":
if meta_tag.text is not None:
copyrights.append(meta_tag.text)
return Metadata(
title=title,
creators=creators,
copyright=" ".join(copyrights) if copyrights else None,
source_format="musescore",
)
def _get_root(path: Union[str, Path], compressed: bool = None):
"""Return root of the element tree."""
if compressed is None:
compressed = str(path).endswith(".mscz")
if not compressed:
tree = ET.parse(str(path))
return tree.getroot()
# Find out the main MSCX file in the compressed ZIP archive
zip_file = ZipFile(str(path))
if "META-INF/container.xml" not in zip_file.namelist():
raise MuseScoreError("Container file ('container.xml') not found.")
container = ET.fromstring(zip_file.read("META-INF/container.xml"))
rootfile = container.find("rootfiles/rootfile")
if rootfile is None:
raise MuseScoreError(
"Element 'rootfile' tag not found in the container file "
"('container.xml')."
)
filename = _get_required_attr(rootfile, "full-path")
return ET.fromstring(zip_file.read(filename))
def _get_divisions(root: Element):
"""Return a list of divisions."""
divisions = []
for division_elem in root.findall("Score/Division"):
if division_elem.text is None:
continue
if not float(division_elem.text).is_integer():
raise MuseScoreError(
"Noninteger 'division' values are not supported."
)
divisions.append(int(division_elem.text))
return divisions
def parse_part_elem_info(
elem: Element, has_staff_id: bool = True
) -> Tuple[Optional[List[str]], OrderedDict]:
"""Return part information parsed from a score part element."""
part_info: OrderedDict = OrderedDict()
# Staff IDs
if has_staff_id:
staff_ids = [
_get_required_attr(staff_elem, "id")
for staff_elem in elem.findall("Staff")
] # MuseScore 2.x and 3.x
else:
staff_ids = None # MuseScore 1.x
# Instrument
instrument_elem = _get_required(elem, "Instrument")
part_info["id"] = _get_text(instrument_elem, "instrumentId")
part_info["name"] = _get_text(elem, "trackName", remove_newlines=True)
# MIDI program and channel
program_elem = instrument_elem.find("Channel/program")
if program_elem is not None:
program = program_elem.get("value")
part_info["program"] = int(program) if program is not None else 0
else:
part_info["program"] = 0
part_info["is_drum"] = (
int(_get_text(instrument_elem, "Channel/midiChannel", 0)) == 10
)
return staff_ids, part_info
[docs]def read_musescore(
path: Union[str, Path], resolution: int = None, compressed: bool = None
) -> Music:
"""Read a MuseScore file into a Music object.
Parameters
----------
path : str or Path
Path to the MuseScore file to read.
resolution : int, optional
Time steps per quarter note. Defaults to the least common
multiple of all divisions.
compressed : bool, optional
Whether it is a compressed MuseScore file. Defaults to infer
from the filename.
Returns
-------
:class:`muspy.Music`
Converted Music object.
Note
----
This function is based on MuseScore 3. Files created by an earlier
version of MuseScore might not be read correctly.
"""
# Get element tree root
root = _get_root(path, compressed)
# Detect MuseScore version
musescore_version = root.get("version")
if not musescore_version.startswith("3."):
warnings.warn(
f"Detected a legacy MuseScore version of {musescore_version}. "
"Data might not be loaded correctly.",
MuseScoreWarning,
)
# Get the score element
score_elem = root.find("Score") # MuseScore 3.x
if score_elem is None:
score_elem = root # MuseScore 1.x and 2.x
# Meta data
metadata = parse_metadata(root)
metadata.source_filename = Path(path).name
# Set resolution to the least common multiple of all divisions
if resolution is None:
divisions = _get_divisions(root)
resolution = _lcm(*divisions) if divisions else 1
# Detect if has staff id is available
has_staff_id = True
first_staff_elem = score_elem.find("part/Staff")
if first_staff_elem is None or first_staff_elem.get("id") is None:
has_staff_id = False
# Staff information
part_info: List[OrderedDict] = []
staff_part_map: OrderedDict = OrderedDict()
staff_id = 1
for part_id, part_elem in enumerate(score_elem.findall("Part")):
staff_ids, part_elem_info = parse_part_elem_info(
part_elem, has_staff_id
)
part_info.append(part_elem_info)
if has_staff_id: # MuseScore 2.x and 3.x
for staff_id in staff_ids: # type: ignore
staff_part_map[staff_id] = part_id
else: # MuseScore 1.x
for _ in range(len(part_elem.findall("Staff"))):
staff_part_map[str(staff_id)] = part_id
staff_id += 1
# Raise an error if part-list information is missing
if not part_info:
raise MuseScoreError("Part information is missing.")
# Get the meta staff, assuming the first staff
meta_staff_elem = score_elem.find("Staff")
# Return empty music object with metadata if no staff is found
if meta_staff_elem is None:
return Music(metadata=metadata, resolution=resolution)
# Parse measure ordering from the meta staff, expanding all repeats
# and jumps
measure_indices = get_measure_ordering(meta_staff_elem)
# Parse the meta part element
tempos, key_signatures, time_signatures, beats = parse_meta_staff_elem(
meta_staff_elem, resolution, measure_indices
)
# Initialize lists
tracks: List[Track] = []
# Iterate over all staffs
part_track_map: Dict[int, int] = {}
for staff_elem in score_elem.findall("Staff"):
staff_id = staff_elem.get("id") # type: ignore
if staff_id is None:
if len(score_elem.findall("Staff")) > 1:
continue
staff_id = next(iter(staff_part_map))
if staff_id not in staff_part_map:
continue
# Parse the staff
notes, lyrics = parse_staff_elem(
staff_elem, resolution, measure_indices
)
# Extend lists
part_id = staff_part_map[staff_id]
if part_id in part_track_map:
track_id = part_track_map[part_id]
tracks[track_id].notes.extend(notes)
tracks[track_id].lyrics.extend(lyrics)
else:
part_track_map[part_id] = len(tracks)
tracks.append(
Track(
program=part_info[part_id]["program"],
is_drum=part_info[part_id]["is_drum"],
name=part_info[part_id]["name"],
notes=notes,
lyrics=lyrics,
)
)
# Make sure everything is sorted
tempos.sort(key=attrgetter("time"))
key_signatures.sort(key=attrgetter("time"))
time_signatures.sort(key=attrgetter("time"))
for track in tracks:
track.notes.sort(
key=attrgetter("time", "pitch", "duration", "velocity")
)
track.lyrics.sort(key=attrgetter("time"))
return Music(
metadata=metadata,
resolution=resolution,
tempos=tempos,
key_signatures=key_signatures,
time_signatures=time_signatures,
beats=beats,
tracks=tracks,
)