Spaces:
Sleeping
Sleeping
# Copyright 2022 The Magenta Authors. | |
# | |
# Licensed under the Apache License, Version 2.0 (the "License"); | |
# you may not use this file except in compliance with the License. | |
# You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, software | |
# distributed under the License is distributed on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
# See the License for the specific language governing permissions and | |
# limitations under the License. | |
"""MusicXML parser. | |
Simple MusicXML parser used to convert MusicXML into NoteSequence. | |
""" | |
import fractions | |
import xml.etree.ElementTree as ET | |
import zipfile | |
import constants | |
Fraction = fractions.Fraction | |
DEFAULT_MIDI_PROGRAM = 0 # Default MIDI Program (0 = grand piano) | |
DEFAULT_MIDI_CHANNEL = 0 # Default MIDI Channel (0 = first channel) | |
MUSICXML_MIME_TYPE = 'application/vnd.recordare.musicxml+xml' | |
class MusicXMLParseError(Exception): | |
"""Exception thrown when the MusicXML contents cannot be parsed.""" | |
pass | |
class PitchStepParseError(MusicXMLParseError): | |
"""Exception thrown when a pitch step cannot be parsed. | |
Will happen if pitch step is not one of A, B, C, D, E, F, or G | |
""" | |
pass | |
class ChordSymbolParseError(MusicXMLParseError): | |
"""Exception thrown when a chord symbol cannot be parsed.""" | |
pass | |
class MultipleTimeSignatureError(MusicXMLParseError): | |
"""Exception thrown when multiple time signatures found in a measure.""" | |
pass | |
class AlternatingTimeSignatureError(MusicXMLParseError): | |
"""Exception thrown when an alternating time signature is encountered.""" | |
pass | |
class TimeSignatureParseError(MusicXMLParseError): | |
"""Exception thrown when the time signature could not be parsed.""" | |
pass | |
class UnpitchedNoteError(MusicXMLParseError): | |
"""Exception thrown when an unpitched note is encountered. | |
We do not currently support parsing files with unpitched notes (e.g., | |
percussion scores). | |
http://www.musicxml.com/tutorial/percussion/unpitched-notes/ | |
""" | |
pass | |
class KeyParseError(MusicXMLParseError): | |
"""Exception thrown when a key signature cannot be parsed.""" | |
pass | |
class InvalidNoteDurationTypeError(MusicXMLParseError): | |
"""Exception thrown when a note's duration type is invalid.""" | |
pass | |
class MusicXMLParserState(object): | |
"""Maintains internal state of the MusicXML parser.""" | |
def __init__(self): | |
# Default to one division per measure | |
# From the MusicXML documentation: "The divisions element indicates | |
# how many divisions per quarter note are used to indicate a note's | |
# duration. For example, if duration = 1 and divisions = 2, | |
# this is an eighth note duration." | |
self.divisions = 1 | |
# Default to a tempo of 120 quarter notes per minute | |
# MusicXML calls this tempo, but Magenta calls this qpm | |
# Therefore, the variable is called qpm, but reads the | |
# MusicXML tempo attribute | |
# (120 qpm is the default tempo according to the | |
# Standard MIDI Files 1.0 Specification) | |
self.qpm = 120 | |
# Duration of a single quarter note in seconds | |
self.seconds_per_quarter = 0.5 | |
# Running total of time for the current event in seconds. | |
# Resets to 0 on every part. Affected by <forward> and <backup> elements | |
self.time_position = 0 | |
# Default to a MIDI velocity of 64 (mf) | |
self.velocity = 64 | |
# Default MIDI program (0 = grand piano) | |
self.midi_program = DEFAULT_MIDI_PROGRAM | |
# Current MIDI channel (usually equal to the part number) | |
self.midi_channel = DEFAULT_MIDI_CHANNEL | |
# Keep track of previous note to get chord timing correct | |
# This variable stores an instance of the Note class (defined below) | |
self.previous_note = None | |
# Keep track of current transposition level in +/- semitones. | |
self.transpose = 0 | |
# Keep track of current time signature. Does not support polymeter. | |
self.time_signature = None | |
class MusicXMLDocument(object): | |
"""Internal representation of a MusicXML Document. | |
Represents the top level object which holds the MusicXML document | |
Responsible for loading the .xml or .mxl file using the _get_score method | |
If the file is .mxl, this class uncompresses it | |
After the file is loaded, this class then parses the document into memory | |
using the parse method. | |
""" | |
def __init__(self, filename): | |
self._score = self._get_score(filename) | |
self.parts = [] | |
# ScoreParts indexed by id. | |
self._score_parts = {} | |
self.midi_resolution = constants.STANDARD_PPQ | |
self._state = MusicXMLParserState() | |
# Total time in seconds | |
self.total_time_secs = 0 | |
self._parse() | |
def _get_score(score_string): | |
"""Given a MusicXML file, return the score as an xml.etree.ElementTree. | |
Given a MusicXML file, return the score as an xml.etree.ElementTree | |
If the file is compress (ends in .mxl), uncompress it first | |
Args: | |
filename: The path of a MusicXML file | |
Returns: | |
The score as an xml.etree.ElementTree. | |
Raises: | |
MusicXMLParseError: if the file cannot be parsed. | |
""" | |
score = None | |
score = ET.fromstring(score_string) | |
return score | |
def _parse(self): | |
"""Parse the uncompressed MusicXML document.""" | |
# Parse part-list | |
xml_part_list = self._score.find('part-list') | |
if xml_part_list is not None: | |
for element in xml_part_list: | |
if element.tag == 'score-part': | |
score_part = ScorePart(element) | |
self._score_parts[score_part.id] = score_part | |
# Parse parts | |
for score_part_index, child in enumerate(self._score.findall('part')): | |
part = Part(child, self._score_parts, self._state) | |
self.parts.append(part) | |
score_part_index += 1 | |
if self._state.time_position > self.total_time_secs: | |
self.total_time_secs = self._state.time_position | |
def get_chord_symbols(self): | |
"""Return a list of all the chord symbols used in this score.""" | |
chord_symbols = [] | |
for part in self.parts: | |
for measure in part.measures: | |
for chord_symbol in measure.chord_symbols: | |
if chord_symbol not in chord_symbols: | |
# Prevent duplicate chord symbols | |
chord_symbols.append(chord_symbol) | |
return chord_symbols | |
def get_time_signatures(self): | |
"""Return a list of all the time signatures used in this score. | |
Does not support polymeter (i.e. assumes all parts have the same | |
time signature, such as Part 1 having a time signature of 6/8 | |
while Part 2 has a simultaneous time signature of 2/4). | |
Ignores duplicate time signatures to prevent Magenta duplicate | |
time signature error. This happens when multiple parts have the | |
same time signature is used in multiple parts at the same time. | |
Example: If Part 1 has a time siganture of 4/4 and Part 2 also | |
has a time signature of 4/4, then only instance of 4/4 is sent | |
to Magenta. | |
Returns: | |
A list of all TimeSignature objects used in this score. | |
""" | |
time_signatures = [] | |
for part in self.parts: | |
for measure in part.measures: | |
if measure.time_signature is not None: | |
if measure.time_signature not in time_signatures: | |
# Prevent duplicate time signatures | |
time_signatures.append(measure.time_signature) | |
return time_signatures | |
def get_key_signatures(self): | |
"""Return a list of all the key signatures used in this score. | |
Support different key signatures in different parts (score in | |
written pitch). | |
Ignores duplicate key signatures to prevent Magenta duplicate key | |
signature error. This happens when multiple parts have the same | |
key signature at the same time. | |
Example: If the score is in written pitch and the | |
flute is written in the key of Bb major, the trombone will also be | |
written in the key of Bb major. However, the clarinet and trumpet | |
will be written in the key of C major because they are Bb transposing | |
instruments. | |
If no key signatures are found, create a default key signature of | |
C major. | |
Returns: | |
A list of all KeySignature objects used in this score. | |
""" | |
key_signatures = [] | |
for part in self.parts: | |
for measure in part.measures: | |
if measure.key_signature is not None: | |
if measure.key_signature not in key_signatures: | |
# Prevent duplicate key signatures | |
key_signatures.append(measure.key_signature) | |
if not key_signatures: | |
# If there are no key signatures, add C major at the beginning | |
key_signature = KeySignature(self._state) | |
key_signature.time_position = 0 | |
key_signatures.append(key_signature) | |
return key_signatures | |
def get_tempos(self): | |
"""Return a list of all tempos in this score. | |
If no tempos are found, create a default tempo of 120 qpm. | |
Returns: | |
A list of all Tempo objects used in this score. | |
""" | |
tempos = [] | |
if self.parts: | |
part = self.parts[0] # Use only first part | |
for measure in part.measures: | |
for tempo in measure.tempos: | |
tempos.append(tempo) | |
# If no tempos, add a default of 120 at beginning | |
if not tempos: | |
tempo = Tempo(self._state) | |
tempo.qpm = self._state.qpm | |
tempo.time_position = 0 | |
tempos.append(tempo) | |
return tempos | |
class ScorePart(object): | |
""""Internal representation of a MusicXML <score-part>. | |
A <score-part> element contains MIDI program and channel info | |
for the <part> elements in the MusicXML document. | |
If no MIDI info is found for the part, use the default MIDI channel (0) | |
and default to the Grand Piano program (MIDI Program #1). | |
""" | |
def __init__(self, xml_score_part=None): | |
self.id = '' | |
self.part_name = '' | |
self.midi_channel = DEFAULT_MIDI_CHANNEL | |
self.midi_program = DEFAULT_MIDI_PROGRAM | |
if xml_score_part is not None: | |
self._parse(xml_score_part) | |
def _parse(self, xml_score_part): | |
"""Parse the <score-part> element to an in-memory representation.""" | |
self.id = xml_score_part.attrib['id'] | |
if xml_score_part.find('part-name') is not None: | |
self.part_name = xml_score_part.find('part-name').text or '' | |
xml_midi_instrument = xml_score_part.find('midi-instrument') | |
if (xml_midi_instrument is not None and | |
xml_midi_instrument.find('midi-channel') is not None and | |
xml_midi_instrument.find('midi-program') is not None): | |
self.midi_channel = int(xml_midi_instrument.find('midi-channel').text) | |
self.midi_program = int(xml_midi_instrument.find('midi-program').text) | |
else: | |
# If no MIDI info, use the default MIDI channel. | |
self.midi_channel = DEFAULT_MIDI_CHANNEL | |
# Use the default MIDI program | |
self.midi_program = DEFAULT_MIDI_PROGRAM | |
def __str__(self): | |
score_str = 'ScorePart: ' + self.part_name | |
score_str += ', Channel: ' + str(self.midi_channel) | |
score_str += ', Program: ' + str(self.midi_program) | |
return score_str | |
class Part(object): | |
"""Internal represention of a MusicXML <part> element.""" | |
def __init__(self, xml_part, score_parts, state): | |
self.id = '' | |
self.score_part = None | |
self.measures = [] | |
self._state = state | |
self._parse(xml_part, score_parts) | |
def _parse(self, xml_part, score_parts): | |
"""Parse the <part> element.""" | |
if 'id' in xml_part.attrib: | |
self.id = xml_part.attrib['id'] | |
if self.id in score_parts: | |
self.score_part = score_parts[self.id] | |
else: | |
# If this part references a score-part id that was not found in the file, | |
# construct a default score-part. | |
self.score_part = ScorePart() | |
# Reset the time position when parsing each part | |
self._state.time_position = 0 | |
self._state.midi_channel = self.score_part.midi_channel | |
self._state.midi_program = self.score_part.midi_program | |
self._state.transpose = 0 | |
xml_measures = xml_part.findall('measure') | |
for measure in xml_measures: | |
# Issue #674: Repair measures that do not contain notes | |
# by inserting a whole measure rest | |
self._repair_empty_measure(measure) | |
parsed_measure = Measure(measure, self._state) | |
self.measures.append(parsed_measure) | |
def _repair_empty_measure(self, measure): | |
"""Repair a measure if it is empty by inserting a whole measure rest. | |
If a <measure> only consists of a <forward> element that advances | |
the time cursor, remove the <forward> element and replace | |
with a whole measure rest of the same duration. | |
Args: | |
measure: The measure to repair. | |
""" | |
# Issue #674 - If the <forward> element is in a measure without | |
# any <note> elements, treat it as if it were a whole measure | |
# rest by inserting a rest of that duration | |
forward_count = len(measure.findall('forward')) | |
note_count = len(measure.findall('note')) | |
if note_count == 0 and forward_count == 1: | |
# Get the duration of the <forward> element | |
xml_forward = measure.find('forward') | |
xml_duration = xml_forward.find('duration') | |
forward_duration = int(xml_duration.text) | |
# Delete the <forward> element | |
measure.remove(xml_forward) | |
# Insert the new note | |
new_note = '<note>' | |
new_note += '<rest /><duration>' + str(forward_duration) + '</duration>' | |
new_note += '<voice>1</voice><type>whole</type><staff>1</staff>' | |
new_note += '</note>' | |
new_note_xml = ET.fromstring(new_note) | |
measure.append(new_note_xml) | |
def __str__(self): | |
part_str = 'Part: ' + self.score_part.part_name | |
return part_str | |
class Measure(object): | |
"""Internal represention of the MusicXML <measure> element.""" | |
def __init__(self, xml_measure, state): | |
self.xml_measure = xml_measure | |
self.notes = [] | |
self.chord_symbols = [] | |
self.tempos = [] | |
self.time_signature = None | |
self.key_signature = None | |
# Cumulative duration in MusicXML duration. | |
# Used for time signature calculations | |
self.duration = 0 | |
self.state = state | |
# Record the starting time of this measure so that time signatures | |
# can be inserted at the beginning of the measure | |
self.start_time_position = self.state.time_position | |
self._parse() | |
# Update the time signature if a partial or pickup measure | |
self._fix_time_signature() | |
def _parse(self): | |
"""Parse the <measure> element.""" | |
for child in self.xml_measure: | |
if child.tag == 'attributes': | |
self._parse_attributes(child) | |
elif child.tag == 'backup': | |
self._parse_backup(child) | |
elif child.tag == 'direction': | |
self._parse_direction(child) | |
elif child.tag == 'forward': | |
self._parse_forward(child) | |
elif child.tag == 'note': | |
note = Note(child, self.state) | |
self.notes.append(note) | |
# Keep track of current note as previous note for chord timings | |
self.state.previous_note = note | |
# Sum up the MusicXML durations in voice 1 of this measure | |
if note.voice == 1 and not note.is_in_chord: | |
self.duration += note.note_duration.duration | |
elif child.tag == 'harmony': | |
chord_symbol = ChordSymbol(child, self.state) | |
self.chord_symbols.append(chord_symbol) | |
else: | |
# Ignore other tag types because they are not relevant to Magenta. | |
pass | |
def _parse_attributes(self, xml_attributes): | |
"""Parse the MusicXML <attributes> element.""" | |
for child in xml_attributes: | |
if child.tag == 'divisions': | |
self.state.divisions = int(child.text) | |
elif child.tag == 'key': | |
self.key_signature = KeySignature(self.state, child) | |
elif child.tag == 'time': | |
if self.time_signature is None: | |
self.time_signature = TimeSignature(self.state, child) | |
self.state.time_signature = self.time_signature | |
else: | |
raise MultipleTimeSignatureError('Multiple time signatures') | |
elif child.tag == 'transpose': | |
transpose = int(child.find('chromatic').text) | |
self.state.transpose = transpose | |
if self.key_signature is not None: | |
# Transposition is chromatic. Every half step up is 5 steps backward | |
# on the circle of fifths, which has 12 positions. | |
key_transpose = (transpose * -5) % 12 | |
new_key = self.key_signature.key + key_transpose | |
# If the new key has >6 sharps, translate to flats. | |
# TODO(fjord): Could be more smart about when to use sharps vs. flats | |
# when there are enharmonic equivalents. | |
if new_key > 6: | |
new_key %= -6 | |
self.key_signature.key = new_key | |
else: | |
# Ignore other tag types because they are not relevant to Magenta. | |
pass | |
def _parse_backup(self, xml_backup): | |
"""Parse the MusicXML <backup> element. | |
This moves the global time position backwards. | |
Args: | |
xml_backup: XML element with tag type 'backup'. | |
""" | |
xml_duration = xml_backup.find('duration') | |
backup_duration = int(xml_duration.text) | |
midi_ticks = backup_duration * (constants.STANDARD_PPQ | |
/ self.state.divisions) | |
seconds = ((midi_ticks / constants.STANDARD_PPQ) | |
* self.state.seconds_per_quarter) | |
self.state.time_position -= seconds | |
def _parse_direction(self, xml_direction): | |
"""Parse the MusicXML <direction> element.""" | |
for child in xml_direction: | |
if child.tag == 'sound': | |
if child.get('tempo') is not None: | |
tempo = Tempo(self.state, child) | |
self.tempos.append(tempo) | |
self.state.qpm = tempo.qpm | |
self.state.seconds_per_quarter = 60 / self.state.qpm | |
if child.get('dynamics') is not None: | |
self.state.velocity = int(child.get('dynamics')) | |
def _parse_forward(self, xml_forward): | |
"""Parse the MusicXML <forward> element. | |
This moves the global time position forward. | |
Args: | |
xml_forward: XML element with tag type 'forward'. | |
""" | |
xml_duration = xml_forward.find('duration') | |
forward_duration = int(xml_duration.text) | |
midi_ticks = forward_duration * (constants.STANDARD_PPQ | |
/ self.state.divisions) | |
seconds = ((midi_ticks / constants.STANDARD_PPQ) | |
* self.state.seconds_per_quarter) | |
self.state.time_position += seconds | |
def _fix_time_signature(self): | |
"""Correct the time signature for incomplete measures. | |
If the measure is incomplete or a pickup, insert an appropriate | |
time signature into this Measure. | |
""" | |
# Compute the fractional time signature (duration / divisions) | |
# Multiply divisions by 4 because division is always parts per quarter note | |
numerator = self.duration | |
denominator = self.state.divisions * 4 | |
fractional_time_signature = Fraction(numerator, denominator) | |
if self.state.time_signature is None and self.time_signature is None: | |
# No global time signature yet and no measure time signature defined | |
# in this measure (no time signature or senza misura). | |
# Insert the fractional time signature as the time signature | |
# for this measure | |
self.time_signature = TimeSignature(self.state) | |
self.time_signature.numerator = fractional_time_signature.numerator | |
self.time_signature.denominator = fractional_time_signature.denominator | |
self.state.time_signature = self.time_signature | |
else: | |
fractional_state_time_signature = Fraction( | |
self.state.time_signature.numerator, | |
self.state.time_signature.denominator) | |
# Check for pickup measure. Reset time signature to smaller numerator | |
pickup_measure = False | |
if numerator < self.state.time_signature.numerator: | |
pickup_measure = True | |
# Get the current time signature denominator | |
global_time_signature_denominator = self.state.time_signature.denominator | |
# If the fractional time signature = 1 (e.g. 4/4), | |
# make the numerator the same as the global denominator | |
if fractional_time_signature == 1 and not pickup_measure: | |
new_time_signature = TimeSignature(self.state) | |
new_time_signature.numerator = global_time_signature_denominator | |
new_time_signature.denominator = global_time_signature_denominator | |
else: | |
# Otherwise, set the time signature to the fractional time signature | |
# Issue #674 - Use the original numerator and denominator | |
# instead of the fractional one | |
new_time_signature = TimeSignature(self.state) | |
new_time_signature.numerator = numerator | |
new_time_signature.denominator = denominator | |
new_time_sig_fraction = Fraction(numerator, denominator) | |
if new_time_sig_fraction == fractional_time_signature: | |
new_time_signature.numerator = fractional_time_signature.numerator | |
new_time_signature.denominator = fractional_time_signature.denominator | |
# Insert a new time signature only if it does not equal the global | |
# time signature. | |
if (pickup_measure or | |
(self.time_signature is None | |
and (fractional_time_signature != fractional_state_time_signature))): | |
new_time_signature.time_position = self.start_time_position | |
self.time_signature = new_time_signature | |
self.state.time_signature = new_time_signature | |
class Note(object): | |
"""Internal representation of a MusicXML <note> element.""" | |
def __init__(self, xml_note, state): | |
self.xml_note = xml_note | |
self.voice = 1 | |
self.is_rest = False | |
self.is_in_chord = False | |
self.is_grace_note = False | |
self.pitch = None # Tuple (Pitch Name, MIDI number) | |
self.note_duration = NoteDuration(state) | |
self.state = state | |
self._parse() | |
def _parse(self): | |
"""Parse the MusicXML <note> element.""" | |
self.midi_channel = self.state.midi_channel | |
self.midi_program = self.state.midi_program | |
self.velocity = self.state.velocity | |
for child in self.xml_note: | |
if child.tag == 'chord': | |
self.is_in_chord = True | |
elif child.tag == 'duration': | |
self.note_duration.parse_duration(self.is_in_chord, self.is_grace_note, | |
child.text) | |
elif child.tag == 'pitch': | |
self._parse_pitch(child) | |
elif child.tag == 'rest': | |
self.is_rest = True | |
elif child.tag == 'voice': | |
self.voice = int(child.text) | |
elif child.tag == 'dot': | |
self.note_duration.dots += 1 | |
elif child.tag == 'type': | |
self.note_duration.type = child.text | |
elif child.tag == 'time-modification': | |
# A time-modification element represents a tuplet_ratio | |
self._parse_tuplet(child) | |
elif child.tag == 'unpitched': | |
raise UnpitchedNoteError('Unpitched notes are not supported') | |
else: | |
# Ignore other tag types because they are not relevant to Magenta. | |
pass | |
def _parse_pitch(self, xml_pitch): | |
"""Parse the MusicXML <pitch> element.""" | |
step = xml_pitch.find('step').text | |
alter_text = '' | |
alter = 0.0 | |
if xml_pitch.find('alter') is not None: | |
alter_text = xml_pitch.find('alter').text | |
octave = xml_pitch.find('octave').text | |
# Parse alter string to a float (floats represent microtonal alterations) | |
if alter_text: | |
alter = float(alter_text) | |
# Check if this is a semitone alter (i.e. an integer) or microtonal (float) | |
alter_semitones = int(alter) # Number of semitones | |
is_microtonal_alter = (alter != alter_semitones) | |
# Visual pitch representation | |
alter_string = '' | |
if alter_semitones == -2: | |
alter_string = 'bb' | |
elif alter_semitones == -1: | |
alter_string = 'b' | |
elif alter_semitones == 1: | |
alter_string = '#' | |
elif alter_semitones == 2: | |
alter_string = 'x' | |
if is_microtonal_alter: | |
alter_string += ' (+microtones) ' | |
# N.B. - pitch_string does not account for transposition | |
pitch_string = step + alter_string + octave | |
# Compute MIDI pitch number (C4 = 60, C1 = 24, C0 = 12) | |
midi_pitch = self.pitch_to_midi_pitch(step, alter, octave) | |
# Transpose MIDI pitch | |
midi_pitch += self.state.transpose | |
self.pitch = (pitch_string, midi_pitch) | |
def _parse_tuplet(self, xml_time_modification): | |
"""Parses a tuplet ratio. | |
Represented in MusicXML by the <time-modification> element. | |
Args: | |
xml_time_modification: An xml time-modification element. | |
""" | |
numerator = int(xml_time_modification.find('actual-notes').text) | |
denominator = int(xml_time_modification.find('normal-notes').text) | |
self.note_duration.tuplet_ratio = Fraction(numerator, denominator) | |
def pitch_to_midi_pitch(step, alter, octave): | |
"""Convert MusicXML pitch representation to MIDI pitch number.""" | |
pitch_class = 0 | |
if step == 'C': | |
pitch_class = 0 | |
elif step == 'D': | |
pitch_class = 2 | |
elif step == 'E': | |
pitch_class = 4 | |
elif step == 'F': | |
pitch_class = 5 | |
elif step == 'G': | |
pitch_class = 7 | |
elif step == 'A': | |
pitch_class = 9 | |
elif step == 'B': | |
pitch_class = 11 | |
else: | |
# Raise exception for unknown step (ex: 'Q') | |
raise PitchStepParseError('Unable to parse pitch step ' + step) | |
pitch_class = (pitch_class + int(alter)) % 12 | |
midi_pitch = (12 + pitch_class) + (int(octave) * 12) | |
return midi_pitch | |
def __str__(self): | |
note_string = '{duration: ' + str(self.note_duration.duration) | |
note_string += ', midi_ticks: ' + str(self.note_duration.midi_ticks) | |
note_string += ', seconds: ' + str(self.note_duration.seconds) | |
if self.is_rest: | |
note_string += ', rest: ' + str(self.is_rest) | |
else: | |
note_string += ', pitch: ' + self.pitch[0] | |
note_string += ', MIDI pitch: ' + str(self.pitch[1]) | |
note_string += ', voice: ' + str(self.voice) | |
note_string += ', velocity: ' + str(self.velocity) + '} ' | |
note_string += '(@time: ' + str(self.note_duration.time_position) + ')' | |
return note_string | |
class NoteDuration(object): | |
"""Internal representation of a MusicXML note's duration properties.""" | |
TYPE_RATIO_MAP = {'maxima': Fraction(8, 1), 'long': Fraction(4, 1), | |
'breve': Fraction(2, 1), 'whole': Fraction(1, 1), | |
'half': Fraction(1, 2), 'quarter': Fraction(1, 4), | |
'eighth': Fraction(1, 8), '16th': Fraction(1, 16), | |
'32nd': Fraction(1, 32), '64th': Fraction(1, 64), | |
'128th': Fraction(1, 128), '256th': Fraction(1, 256), | |
'512th': Fraction(1, 512), '1024th': Fraction(1, 1024)} | |
def __init__(self, state): | |
self.duration = 0 # MusicXML duration | |
self.midi_ticks = 0 # Duration in MIDI ticks | |
self.seconds = 0 # Duration in seconds | |
self.time_position = 0 # Onset time in seconds | |
self.dots = 0 # Number of augmentation dots | |
self._type = 'quarter' # MusicXML duration type | |
self.tuplet_ratio = Fraction(1, 1) # Ratio for tuplets (default to 1) | |
self.is_grace_note = True # Assume true until not found | |
self.state = state | |
def parse_duration(self, is_in_chord, is_grace_note, duration): | |
"""Parse the duration of a note and compute timings.""" | |
self.duration = int(duration) | |
# Due to an error in Sibelius' export, force this note to have the | |
# duration of the previous note if it is in a chord | |
if is_in_chord: | |
self.duration = self.state.previous_note.note_duration.duration | |
self.midi_ticks = self.duration | |
self.midi_ticks *= (constants.STANDARD_PPQ / self.state.divisions) | |
self.seconds = (self.midi_ticks / constants.STANDARD_PPQ) | |
self.seconds *= self.state.seconds_per_quarter | |
self.time_position = self.state.time_position | |
# Not sure how to handle durations of grace notes yet as they | |
# steal time from subsequent notes and they do not have a | |
# <duration> tag in the MusicXML | |
self.is_grace_note = is_grace_note | |
if is_in_chord: | |
# If this is a chord, set the time position to the time position | |
# of the previous note (i.e. all the notes in the chord will have | |
# the same time position) | |
self.time_position = self.state.previous_note.note_duration.time_position | |
else: | |
# Only increment time positions once in chord | |
self.state.time_position += self.seconds | |
def _convert_type_to_ratio(self): | |
"""Convert the MusicXML note-type-value to a Python Fraction. | |
Examples: | |
- whole = 1/1 | |
- half = 1/2 | |
- quarter = 1/4 | |
- 32nd = 1/32 | |
Returns: | |
A Fraction object representing the note type. | |
""" | |
return self.TYPE_RATIO_MAP[self.type] | |
def duration_ratio(self): | |
"""Compute the duration ratio of the note as a Python Fraction. | |
Examples: | |
- Whole Note = 1 | |
- Quarter Note = 1/4 | |
- Dotted Quarter Note = 3/8 | |
- Triplet eighth note = 1/12 | |
Returns: | |
The duration ratio as a Python Fraction. | |
""" | |
# Get ratio from MusicXML note type | |
duration_ratio = Fraction(1, 1) | |
type_ratio = self._convert_type_to_ratio() | |
# Compute tuplet ratio | |
duration_ratio /= self.tuplet_ratio | |
type_ratio /= self.tuplet_ratio | |
# Add augmentation dots | |
one_half = Fraction(1, 2) | |
dot_sum = Fraction(0, 1) | |
for dot in range(self.dots): | |
dot_sum += (one_half ** (dot + 1)) * type_ratio | |
duration_ratio = type_ratio + dot_sum | |
# If the note is a grace note, force its ratio to be 0 | |
# because it does not have a <duration> tag | |
if self.is_grace_note: | |
duration_ratio = Fraction(0, 1) | |
return duration_ratio | |
def duration_float(self): | |
"""Return the duration ratio as a float.""" | |
ratio = self.duration_ratio() | |
return ratio.numerator / ratio.denominator | |
def type(self): | |
return self._type | |
def type(self, new_type): | |
if new_type not in self.TYPE_RATIO_MAP: | |
raise InvalidNoteDurationTypeError( | |
'Note duration type "{}" is not valid'.format(new_type)) | |
self._type = new_type | |
class ChordSymbol(object): | |
"""Internal representation of a MusicXML chord symbol <harmony> element. | |
This represents a chord symbol with four components: | |
1) Root: a string representing the chord root pitch class, e.g. "C#". | |
2) Kind: a string representing the chord kind, e.g. "m7" for minor-seventh, | |
"9" for dominant-ninth, or the empty string for major triad. | |
3) Scale degree modifications: a list of strings representing scale degree | |
modifications for the chord, e.g. "add9" to add an unaltered ninth scale | |
degree (without the seventh), "b5" to flatten the fifth scale degree, | |
"no3" to remove the third scale degree, etc. | |
4) Bass: a string representing the chord bass pitch class, or None if the bass | |
pitch class is the same as the root pitch class. | |
There's also a special chord kind "N.C." representing no harmony, for which | |
all other fields should be None. | |
Use the `get_figure_string` method to get a string representation of the chord | |
symbol as might appear in a lead sheet. This string representation is what we | |
use to represent chord symbols in NoteSequence protos, as text annotations. | |
While the MusicXML representation has more structure, using an unstructured | |
string provides more flexibility and allows us to ingest chords from other | |
sources, e.g. guitar tabs on the web. | |
""" | |
# The below dictionary maps chord kinds to an abbreviated string as would | |
# appear in a chord symbol in a standard lead sheet. There are often multiple | |
# standard abbreviations for the same chord type, e.g. "+" and "aug" both | |
# refer to an augmented chord, and "maj7", "M7", and a Delta character all | |
# refer to a major-seventh chord; this dictionary attempts to be consistent | |
# but the choice of abbreviation is somewhat arbitrary. | |
# | |
# The MusicXML-defined chord kinds are listed here: | |
# http://usermanuals.musicxml.com/MusicXML/Content/ST-MusicXML-kind-value.htm | |
CHORD_KIND_ABBREVIATIONS = { | |
# These chord kinds are in the MusicXML spec. | |
'major': '', | |
'minor': 'm', | |
'augmented': 'aug', | |
'diminished': 'dim', | |
'dominant': '7', | |
'major-seventh': 'maj7', | |
'minor-seventh': 'm7', | |
'diminished-seventh': 'dim7', | |
'augmented-seventh': 'aug7', | |
'half-diminished': 'm7b5', | |
'major-minor': 'm(maj7)', | |
'major-sixth': '6', | |
'minor-sixth': 'm6', | |
'dominant-ninth': '9', | |
'major-ninth': 'maj9', | |
'minor-ninth': 'm9', | |
'dominant-11th': '11', | |
'major-11th': 'maj11', | |
'minor-11th': 'm11', | |
'dominant-13th': '13', | |
'major-13th': 'maj13', | |
'minor-13th': 'm13', | |
'suspended-second': 'sus2', | |
'suspended-fourth': 'sus', | |
'pedal': 'ped', | |
'power': '5', | |
'none': 'N.C.', | |
# These are not in the spec, but show up frequently in the wild. | |
'dominant-seventh': '7', | |
'augmented-ninth': 'aug9', | |
'minor-major': 'm(maj7)', | |
# Some abbreviated kinds also show up frequently in the wild. | |
'': '', | |
'min': 'm', | |
'aug': 'aug', | |
'dim': 'dim', | |
'7': '7', | |
'maj7': 'maj7', | |
'min7': 'm7', | |
'dim7': 'dim7', | |
'm7b5': 'm7b5', | |
'minMaj7': 'm(maj7)', | |
'6': '6', | |
'min6': 'm6', | |
'maj69': '6(add9)', | |
'9': '9', | |
'maj9': 'maj9', | |
'min9': 'm9', | |
'sus47': 'sus7' | |
} | |
def __init__(self, xml_harmony, state): | |
self.xml_harmony = xml_harmony | |
self.time_position = -1 | |
self.root = None | |
self.kind = '' | |
self.degrees = [] | |
self.bass = None | |
self.state = state | |
self._parse() | |
def _alter_to_string(self, alter_text): | |
"""Parse alter text to a string of one or two sharps/flats. | |
Args: | |
alter_text: A string representation of an integer number of semitones. | |
Returns: | |
A string, one of 'bb', 'b', '#', '##', or the empty string. | |
Raises: | |
ChordSymbolParseError: If `alter_text` cannot be parsed to an integer, | |
or if the integer is not a valid number of semitones between -2 and 2 | |
inclusive. | |
""" | |
# Parse alter text to an integer number of semitones. | |
try: | |
alter_semitones = int(alter_text) | |
except ValueError: | |
raise ChordSymbolParseError('Non-integer alter: ' + str(alter_text)) | |
# Visual alter representation | |
if alter_semitones == -2: | |
alter_string = 'bb' | |
elif alter_semitones == -1: | |
alter_string = 'b' | |
elif alter_semitones == 0: | |
alter_string = '' | |
elif alter_semitones == 1: | |
alter_string = '#' | |
elif alter_semitones == 2: | |
alter_string = '##' | |
else: | |
raise ChordSymbolParseError('Invalid alter: ' + str(alter_semitones)) | |
return alter_string | |
def _parse(self): | |
"""Parse the MusicXML <harmony> element.""" | |
self.time_position = self.state.time_position | |
for child in self.xml_harmony: | |
if child.tag == 'root': | |
self._parse_root(child) | |
elif child.tag == 'kind': | |
if child.text is None: | |
# Seems like this shouldn't happen but frequently does in the wild... | |
continue | |
kind_text = str(child.text).strip() | |
if kind_text not in self.CHORD_KIND_ABBREVIATIONS: | |
raise ChordSymbolParseError('Unknown chord kind: ' + kind_text) | |
self.kind = self.CHORD_KIND_ABBREVIATIONS[kind_text] | |
elif child.tag == 'degree': | |
self.degrees.append(self._parse_degree(child)) | |
elif child.tag == 'bass': | |
self._parse_bass(child) | |
elif child.tag == 'offset': | |
# Offset tag moves chord symbol time position. | |
try: | |
offset = int(child.text) | |
except ValueError: | |
raise ChordSymbolParseError('Non-integer offset: ' + str(child.text)) | |
midi_ticks = offset * constants.STANDARD_PPQ / self.state.divisions | |
seconds = (midi_ticks / constants.STANDARD_PPQ * | |
self.state.seconds_per_quarter) | |
self.time_position += seconds | |
else: | |
# Ignore other tag types because they are not relevant to Magenta. | |
pass | |
if self.root is None and self.kind != 'N.C.': | |
raise ChordSymbolParseError('Chord symbol must have a root') | |
def _parse_pitch(self, xml_pitch, step_tag, alter_tag): | |
"""Parse and return the pitch-like <root> or <bass> element.""" | |
if xml_pitch.find(step_tag) is None: | |
raise ChordSymbolParseError('Missing pitch step') | |
step = xml_pitch.find(step_tag).text | |
alter_string = '' | |
if xml_pitch.find(alter_tag) is not None: | |
alter_text = xml_pitch.find(alter_tag).text | |
alter_string = self._alter_to_string(alter_text) | |
if self.state.transpose: | |
raise ChordSymbolParseError( | |
'Transposition of chord symbols currently unsupported') | |
return step + alter_string | |
def _parse_root(self, xml_root): | |
"""Parse the <root> tag for a chord symbol.""" | |
self.root = self._parse_pitch(xml_root, step_tag='root-step', | |
alter_tag='root-alter') | |
def _parse_bass(self, xml_bass): | |
"""Parse the <bass> tag for a chord symbol.""" | |
self.bass = self._parse_pitch(xml_bass, step_tag='bass-step', | |
alter_tag='bass-alter') | |
def _parse_degree(self, xml_degree): | |
"""Parse and return the <degree> scale degree modification element.""" | |
if xml_degree.find('degree-value') is None: | |
raise ChordSymbolParseError('Missing scale degree value in harmony') | |
value_text = xml_degree.find('degree-value').text | |
if value_text is None: | |
raise ChordSymbolParseError('Missing scale degree') | |
try: | |
value = int(value_text) | |
except ValueError: | |
raise ChordSymbolParseError( | |
'Non-integer scale degree: ' + str(value_text)) | |
alter_string = '' | |
if xml_degree.find('degree-alter') is not None: | |
alter_text = xml_degree.find('degree-alter').text | |
alter_string = self._alter_to_string(alter_text) | |
if xml_degree.find('degree-type') is None: | |
raise ChordSymbolParseError('Missing degree modification type') | |
type_text = xml_degree.find('degree-type').text | |
if type_text == 'add': | |
if not alter_string: | |
# When adding unaltered scale degree, use "add" string. | |
type_string = 'add' | |
else: | |
# When adding altered scale degree, "add" not necessary. | |
type_string = '' | |
elif type_text == 'subtract': | |
type_string = 'no' | |
# Alter should be irrelevant when removing scale degree. | |
alter_string = '' | |
elif type_text == 'alter': | |
if not alter_string: | |
raise ChordSymbolParseError('Degree alteration by zero semitones') | |
# No type string necessary as merely appending e.g. "#9" suffices. | |
type_string = '' | |
else: | |
raise ChordSymbolParseError( | |
'Invalid degree modification type: ' + str(type_text)) | |
# Return a scale degree modification string that can be appended to a chord | |
# symbol figure string. | |
return type_string + alter_string + str(value) | |
def __str__(self): | |
if self.kind == 'N.C.': | |
note_string = '{kind: ' + self.kind + '} ' | |
else: | |
note_string = '{root: ' + self.root | |
note_string += ', kind: ' + self.kind | |
note_string += ', degrees: [%s]' % ', '.join(degree | |
for degree in self.degrees) | |
note_string += ', bass: ' + self.bass + '} ' | |
note_string += '(@time: ' + str(self.time_position) + ')' | |
return note_string | |
def get_figure_string(self): | |
"""Return a chord symbol figure string.""" | |
if self.kind == 'N.C.': | |
return self.kind | |
else: | |
degrees_string = ''.join('(%s)' % degree for degree in self.degrees) | |
figure = self.root + self.kind + degrees_string | |
if self.bass: | |
figure += '/' + self.bass | |
return figure | |
class TimeSignature(object): | |
"""Internal representation of a MusicXML time signature. | |
Does not support: | |
- Composite time signatures: 3+2/8 | |
- Alternating time signatures 2/4 + 3/8 | |
- Senza misura | |
""" | |
def __init__(self, state, xml_time=None): | |
self.xml_time = xml_time | |
self.numerator = -1 | |
self.denominator = -1 | |
self.time_position = 0 | |
self.state = state | |
if xml_time is not None: | |
self._parse() | |
def _parse(self): | |
"""Parse the MusicXML <time> element.""" | |
if (len(self.xml_time.findall('beats')) > 1 or | |
len(self.xml_time.findall('beat-type')) > 1): | |
# If more than 1 beats or beat-type found, this time signature is | |
# not supported (ex: alternating meter) | |
raise AlternatingTimeSignatureError('Alternating Time Signature') | |
beats = self.xml_time.find('beats').text | |
beat_type = self.xml_time.find('beat-type').text | |
try: | |
self.numerator = int(beats) | |
self.denominator = int(beat_type) | |
except ValueError: | |
raise TimeSignatureParseError( | |
'Could not parse time signature: {}/{}'.format(beats, beat_type)) | |
self.time_position = self.state.time_position | |
def __str__(self): | |
time_sig_str = str(self.numerator) + '/' + str(self.denominator) | |
time_sig_str += ' (@time: ' + str(self.time_position) + ')' | |
return time_sig_str | |
def __eq__(self, other): | |
isequal = self.numerator == other.numerator | |
isequal = isequal and (self.denominator == other.denominator) | |
isequal = isequal and (self.time_position == other.time_position) | |
return isequal | |
def __ne__(self, other): | |
return not self.__eq__(other) | |
class KeySignature(object): | |
"""Internal representation of a MusicXML key signature.""" | |
def __init__(self, state, xml_key=None): | |
self.xml_key = xml_key | |
# MIDI and MusicXML identify key by using "fifths": | |
# -1 = F, 0 = C, 1 = G etc. | |
self.key = 0 | |
# mode is "major" or "minor" only: MIDI only supports major and minor | |
self.mode = 'major' | |
self.time_position = -1 | |
self.state = state | |
if xml_key is not None: | |
self._parse() | |
def _parse(self): | |
"""Parse the MusicXML <key> element into a MIDI compatible key. | |
If the mode is not minor (e.g. dorian), default to "major" | |
because MIDI only supports major and minor modes. | |
Raises: | |
KeyParseError: If the fifths element is missing. | |
""" | |
fifths = self.xml_key.find('fifths') | |
if fifths is None: | |
raise KeyParseError( | |
'Could not find fifths attribute in key signature.') | |
self.key = int(self.xml_key.find('fifths').text) | |
mode = self.xml_key.find('mode') | |
# Anything not minor will be interpreted as major | |
if mode != 'minor': | |
mode = 'major' | |
self.mode = mode | |
self.time_position = self.state.time_position | |
def __str__(self): | |
keys = (['Cb', 'Gb', 'Db', 'Ab', 'Eb', 'Bb', 'F', 'C', 'G', 'D', | |
'A', 'E', 'B', 'F#', 'C#']) | |
key_string = keys[self.key + 7] + ' ' + self.mode | |
key_string += ' (@time: ' + str(self.time_position) + ')' | |
return key_string | |
def __eq__(self, other): | |
isequal = self.key == other.key | |
isequal = isequal and (self.mode == other.mode) | |
isequal = isequal and (self.time_position == other.time_position) | |
return isequal | |
class Tempo(object): | |
"""Internal representation of a MusicXML tempo.""" | |
def __init__(self, state, xml_sound=None): | |
self.xml_sound = xml_sound | |
self.qpm = -1 | |
self.time_position = -1 | |
self.state = state | |
if xml_sound is not None: | |
self._parse() | |
def _parse(self): | |
"""Parse the MusicXML <sound> element and retrieve the tempo. | |
If no tempo is specified, default to DEFAULT_QUARTERS_PER_MINUTE | |
""" | |
self.qpm = float(self.xml_sound.get('tempo')) | |
if self.qpm == 0: | |
# If tempo is 0, set it to default | |
self.qpm = constants.DEFAULT_QUARTERS_PER_MINUTE | |
self.time_position = self.state.time_position | |
def __str__(self): | |
tempo_str = 'Tempo: ' + str(self.qpm) | |
tempo_str += ' (@time: ' + str(self.time_position) + ')' | |
return tempo_str | |