"""MusicXML output interface."""
import os
from pathlib import Path
from typing import TYPE_CHECKING, Union, Tuple
import xml.etree.ElementTree as ET
from music21.musicxml.archiveTools import compressXML
from music21.instrument import instrumentFromMidiProgram
from music21.interval import Interval
from .music21 import to_music21, PITCH_NAMES
from .midi import DRUM_CHANNEL
if TYPE_CHECKING:
from ..music import Music
DRUMSET_INSTRUMENTS = [
("Acoustic Bass Drum", 36),
("Bass Drum 1", 37),
("Side Stick", 38),
("Acoustic Snare", 39),
("Hand Clap", 40),
("Electric Snare", 41),
("Low Floor Tom", 42),
("Closed Hi Hat", 43),
("High Floor Tom", 44),
("Pedal Hi-Hat", 45),
("Low Tom", 46),
("Open Hi-Hat", 47),
("Low-Mid Tom", 48),
("Hi Mid Tom", 49),
("Crash Cymbal 1", 50),
("High Tom", 51),
("Ride Cymbal 1", 52),
("Chinese Cymbal", 53),
("Ride Bell", 54),
("Tambourine", 55),
("Splash Cymbal", 56),
("Cowbell", 57),
("Crash Cymbal 2", 58),
("Vibraslap", 59),
("Ride Cymbal 2", 60),
("Hi Bongo", 61),
("Low Bongo", 62),
("Mute Hi Conga", 63),
("Open Hi Conga", 64),
("Low Conga", 65),
("High Timbale", 66),
("Low Timbale", 67),
("High Agogo", 68),
("Low Agogo", 69),
("Cabasa", 70),
("Maracas", 71),
("Short Whistle", 72),
("Long Whistle", 73),
("Short Guiro", 74),
("Long Guiro", 75),
("Claves", 76),
("Hi Wood Block", 77),
("Low Wood Block", 78),
("Mute Cuica", 79),
("Open Cuica", 80),
("Mute Triangle", 81),
("Open Triangle", 82),
]
def get_transpose_element(transposition: Interval = None) -> ET.Element:
"""Return the XML Element object that represents the transpose information for the given transposition."""
# get information about the transposition (chromatic, diatonic, octave)
genericSteps = transposition.diatonic.generic.directed if transposition is not None else 1
musicxmlOctaveShift, musicxmlDiatonic = divmod(abs(genericSteps) - 1, 7)
musicxmlChromatic = abs(transposition.chromatic.semitones) % 12 if transposition is not None else 0
if genericSteps < 0:
musicxmlDiatonic *= -1
musicxmlOctaveShift *= -1
musicxmlChromatic *= -1
# create MusicXML elements
mxTranspose = ET.Element("transpose")
mxDiatonic = ET.SubElement(mxTranspose, "diatonic") # diatonic shift
mxDiatonic.text = str(musicxmlDiatonic)
mxChromatic = ET.SubElement(mxTranspose, "chromatic") # chromatic shift
mxChromatic.text = str(musicxmlChromatic)
if musicxmlOctaveShift != 0: # if there is an octave shift
mxOctaveChange = ET.SubElement(mxTranspose, "octave-change")
mxOctaveChange.text = str(musicxmlOctaveShift)
# return element
return mxTranspose
def get_instrument_elements(instrument_name: str, percussion_map_pitch: int, part_id: str) -> Tuple[ET.Element, ET.Element]:
"""Return two XML elements, representing the score-instrument and midi-instrument elements for a given instrument."""
# get the instrument id
instrument_id = f"{part_id}-T{percussion_map_pitch}"
# create score instrument
score_instrument = ET.Element("score-instrument", id = instrument_id)
instrument_name_element = ET.SubElement(score_instrument, "instrument-name")
instrument_name_element.text = instrument_name
# create midi instrument
midi_instrument = ET.Element("midi-instrument", id = instrument_id)
midi_channel = ET.SubElement(midi_instrument, "midi-channel")
midi_channel.text = str(DRUM_CHANNEL) # set to the drum channel
midi_program = ET.SubElement(midi_instrument, "midi-program")
midi_program.text = "1" # default
midi_unpitched = ET.SubElement(midi_instrument, "midi-unpitched")
midi_unpitched.text = str(percussion_map_pitch) # midi pitch that maps to this instrument
midi_volume = ET.SubElement(midi_instrument, "volume")
midi_volume.text = "78.7402" # MuseScore default
midi_pan = ET.SubElement(midi_instrument, "pan")
midi_pan.text = "0" # no pan of the instrument
# return the score and midi instrument elements
return score_instrument, midi_instrument
def get_unpitched_and_instrument_elements(note: ET.Element, part_id: str) -> Tuple[ET.Element, ET.Element]:
"""
Return two XML elements, the unpitched element of a pitched drum note,
and the instrument element associated with the new unpitched drum note.
"""
# get info about pitch
pitch_element = note.find(path = "pitch")
pitch_step = pitch_element.find(path = "step").text
pitch_octave = int(pitch_element.find(path = "octave").text)
pitch_alter = pitch_element.find(path = "alter")
pitch_alter = int(0 if pitch_alter is None else pitch_alter.text)
# reconstruct midi pitch from the pitch information gathered in previous step
percussion_map_pitch = PITCH_NAMES.index(pitch_step) + (12 * (pitch_octave + 1)) + pitch_alter + 1
# remove any pitch-related elements from note
note.remove(pitch_element)
accidental_element = note.find(path = "accidental")
if accidental_element is not None:
note.remove(accidental_element)
# create unpitched XML element
unpitched = ET.Element("unpitched")
display_step = ET.SubElement(unpitched, "display-step")
display_step.text = pitch_step
display_octave = ET.SubElement(unpitched, "display-octave")
display_octave.text = str(pitch_octave)
# create note instrument element
note_instrument = ET.Element("instrument", id = f"{part_id}-T{percussion_map_pitch}")
# return elements
return unpitched, note_instrument
[docs]def write_musicxml(
path: Union[str, Path], music: "Music", compressed: bool = None
):
"""Write a Music object to a MusicXML file.
Parameters
----------
path : str or Path
Path to write the MusicXML file.
music : :class:`muspy.Music`
Music object to write.
compressed : bool, optional
Whether to write to a compressed MusicXML file. If None, infer
from the extension of the filename ('.xml' and '.musicxml' for
an uncompressed file, '.mxl' for a compressed file).
"""
# ensure path is a string
path = str(path)
# make sure music is not in real time
if music.real_time:
raise RuntimeError("Cannot write to MusicXML when `music` is in real time (`music.real_time` == True).")
# get music as a music21 score
score = to_music21(music = music)
# write as xml temporarily, correct some of music21's mistakes
path_temp = f"{path}.temp.xml"
score.write(fmt = "xml", fp = path_temp)
root = ET.parse(path_temp).getroot()
# make sure transpositions are present in XML file
for i, elem in enumerate(root.findall(path = "part")):
first_measure = elem.find(path = "measure")
transposition = instrumentFromMidiProgram(number = music.tracks[i].program).transposition # get the transposition
if transposition is not None and first_measure.find(path = "attributes/transpose") is None:
attributes = first_measure.find(path = "attributes")
if attributes is None:
attributes = ET.Element("attributes")
first_measure.insert(0, attributes)
attributes.append(get_transpose_element(transposition = transposition))
# get indicies of drum tracks
drum_track_indicies = set(filter(lambda i: music.tracks[i].is_drum, range(len(music.tracks))))
# make sure drum midi instruments are written correctly in the part list
for i, elem in enumerate(root.findall(path = "part-list/score-part")):
if i not in drum_track_indicies: # skip over non-drum tracks
continue
elem.remove(elem.find(path = "part-abbreviation"))
elem.remove(elem.find(path = "score-instrument"))
elem.remove(elem.find(path = "midi-instrument"))
midi_instruments = []
for instrument_name, percussion_map_pitch in DRUMSET_INSTRUMENTS:
score_instrument, midi_instrument = get_instrument_elements(instrument_name = instrument_name, percussion_map_pitch = percussion_map_pitch, part_id = elem.get("id"))
elem.append(score_instrument)
midi_instruments.append(midi_instrument)
elem.append(ET.Element("midi-device", port = "1"))
for midi_instrument in midi_instruments:
elem.append(midi_instrument)
# make sure to update pitched drum notes to unpitched
for i, elem in enumerate(root.findall(path = "part")):
if i not in drum_track_indicies: # skip over non-drum tracks
continue
for note in elem.findall(path = "measure/note"):
if note.find(path = "rest") is not None: # skip over rests
continue
unpitched, note_instrument = get_unpitched_and_instrument_elements(note = note, part_id = elem.get("id"))
unpitched_position = int(note.find(path = "chord") is not None)
note.insert(unpitched_position, unpitched)
note.insert(unpitched_position + 2, note_instrument)
# infer compression
if compressed is None:
if path.endswith((".xml", ".musicxml")):
compressed = False
elif path.endswith(".mxl"):
compressed = True
else:
raise ValueError("Cannot infer file type from the extension.")
# compress the file (or not)
tree = ET.ElementTree(root)
# ET.indent(tree, space = "\t", level = 0)
tree.write(path_temp)
if compressed:
compressXML(filename = path_temp, deleteOriginal = True)
os.rename(src = f"{path}.temp.mxl", dst = path)
else: # don't compress
os.rename(src = path_temp, dst = path)