Source code for muspy.outputs.music21

"""Music21 converter interface."""
from typing import TYPE_CHECKING, Tuple, Dict, List
from warnings import warn
from fractions import Fraction
from itertools import groupby
from re import sub
from copy import deepcopy

from music21.metadata import Contributor, Copyright
from music21.metadata import Metadata as M21MetaData
from music21.meter import TimeSignature as M21TimeSignature
from music21.key import KeySignature as M21KeySignature
from music21.note import Note as M21Note, noteheadTypeNames
from music21.stream import Part, Score
from music21.exceptions21 import StreamException
from music21.duration import Duration
from music21.clef import PercussionClef
from music21.instrument import instrumentFromMidiProgram
from music21.chord import Chord as M21Chord
from music21.harmony import ChordSymbol as M21ChordSymbol
from music21 import tempo as M21Tempo
from music21 import articulations as M21Articulation
from music21 import expressions as M21Expression
from music21 import spanner as M21Spanner
from music21 import dynamics as M21Dynamic
import music21.musicxml.archiveTools
import music21.beam

from ..classes import Metadata, Annotation
from .midi import PITCH_NAMES, DRUM_CHANNEL, clean_up_subtype

if TYPE_CHECKING:
    from ..music import Music

# silence weird warnings from music21
def noop(input):
    pass
music21.musicxml.archiveTools.environLocal.warn = noop
music21.beam.environLocal.warn = noop


def get_octave_from_pitch(pitch: int) -> int:
    """Given the MIDI pitch number, return the octave of that note."""
    return (pitch // 12) - 1


def convert_chromatic_to_circle_of_fifths_steps(transpose_chromatic: int = 0) -> int:
    """
    Given a number of semitones to transpose, return the necessary number of steps to
    make the same transposition on the circle of fifths.
    """
    transpose_chromatic = transpose_chromatic % -12 # get in range 0 through 12
    if (-transpose_chromatic % 2 == 0):
        return transpose_chromatic
    else:
        return (transpose_chromatic + 6) % -12


def to_music21_metadata(metadata: Metadata) -> M21MetaData:
    """Return a Metadata object as a music21 Metadata object.

    Parameters
    ----------
    metadata : :class:`muspy.Metadata`
        Metadata object to convert.

    Returns
    -------
    `music21.metadata.Metadata`
        Converted music21 Metadata object.

    """
    meta = M21MetaData()

    # Title is usually stored in movement-title. See
    # https://www.musicxml.com/tutorial/file-structure/score-header-entity/
    if metadata.title:
        meta.movementName = metadata.title

    if metadata.copyright:
        meta.copyright = Copyright(metadata.copyright)
    for creator in metadata.creators:
        meta.addContributor(Contributor(name=creator))
    return meta


[docs]def to_music21(music: "Music") -> Score: """Return a Music object as a music21 Score object. Parameters ---------- music : :class:`muspy.Music` Music object to convert. Returns ------- `music21.stream.Score` Converted music21 Score object. """ # so that we do not operate on the original music object music = deepcopy(music) # make sure music is not in real time if music.real_time: raise RuntimeError("Cannot convert to music21 when `music` is in real time (`music.real_time` == True).") # create a new score score = Score() # helper functions get_offset = lambda time: time / music.resolution def get_duration(duration: int) -> Duration: """ Helper function to get the duration of something, returning a music21 Duration object. Make sure the duration is valid so no bugs are thrown. """ m21_duration = Duration(quarterLength = Fraction(duration, music.resolution)) if any((tup.durationNormal.quarterLength <= (1 / (2 ** 9)) for tup in m21_duration.tuplets)): # check for invalid durations return Duration(quarterLength = 0) else: return m21_duration get_time_from_offset = lambda offset: offset * music.resolution # metadata if music.metadata is not None: score.metadata = to_music21_metadata(metadata = music.metadata) # tracks for i, track in enumerate(music.tracks): # create a new part part = Part() part.partName = track.name part_id = f"P{i + 1}" # set instrument if track.is_drum: part.clef = PercussionClef() channel = DRUM_CHANNEL else: channel = i % 15 # .mid has 15 channels for instruments other than drums channel += int(channel >= DRUM_CHANNEL) # avoid drum channel by adding one if the channel is greater than 8 instrument = instrumentFromMidiProgram(number = track.program) instrument.partId = part_id instrument.partName = track.name instrument.instrumentId = f"{part_id}-T1" instrument.instrumentName = track.name instrument.midiChannel = channel part.append(instrument) # amount to adjust tone part.atSoundingPitch = False transpose_chromatic = instrument.transposition.chromatic.semitones if instrument.transposition is not None else 0 def get_note_str_and_octave(pitch: int, pitch_str: str) -> Tuple[str, int]: """Helper function for extracting the note's string and octave for music21.""" if pitch_str is None: pitch_str = PITCH_NAMES[pitch % len(PITCH_NAMES)] n_accidentals = pitch_str[1:].count("#") - pitch_str[1:].count("b") note_str = PITCH_NAMES[(PITCH_NAMES.index(pitch_str[0]) + n_accidentals - transpose_chromatic) % len(PITCH_NAMES)].replace("b", "-") note_octave = get_octave_from_pitch(pitch = pitch - transpose_chromatic) # adjust pitch for tuning of instrument return note_str, note_octave # add tempos for tempo in music.tempos: # loop through tempos if tempo.text is None or tempo.text.startswith("<sym>"): # bpm is explicitly supplied in text or not supplied at all m21_tempo = M21Tempo.MetronomeMark(number = tempo.qpm) # create tempo marking with bpm else: # bpm is implied by tempo name m21_tempo = M21Tempo.MetronomeMark(text = tempo.text, numberSounding = tempo.qpm) # create tempo marking with text m21_tempo.offset = get_offset(time = tempo.time) # define offset part.insert(offsetOrItemOrList = m21_tempo.offset, itemOrNone = m21_tempo) # add time signatures for time_signature in music.time_signatures: # loop through time signatures if (time_signature.numerator is None) or (time_signature.denominator is None): continue m21_time_signature = M21TimeSignature(value = f"{time_signature.numerator}/{time_signature.denominator}") # instantiate time signature object m21_time_signature.offset = get_offset(time = time_signature.time) # define offset part.insert(offsetOrItemOrList = m21_time_signature.offset, itemOrNone = m21_time_signature) # add key signatures for key_signature in music.key_signatures: # loop through key signatures m21_key_signature = M21KeySignature(sharps = (key_signature.fifths - convert_chromatic_to_circle_of_fifths_steps(transpose_chromatic = transpose_chromatic)) if key_signature.fifths is not None else 0) # create key object m21_key_signature = m21_key_signature.asKey(mode = key_signature.mode.lower() if key_signature.mode is not None else "major") m21_key_signature.offset = get_offset(time = key_signature.time) # define offset part.insert(offsetOrItemOrList = m21_key_signature.offset, itemOrNone = m21_key_signature) # add notes to part (and grace notes, lyrics, noteheads, and articulations) lyrics: Dict[int, str] = {lyric.time: lyric.lyric for lyric in track.lyrics} # put lyrics into dictionary (where keys are the time) # chords: Dict[Tuple[int, int, bool], list] = dict() # loop through notes note_notations = [] # store any note notations as annotations for note in track.notes: # create M21 Note object note_str, note_octave = get_note_str_and_octave(pitch = note.pitch, pitch_str = note.pitch_str) m21_note = M21Note(name = note_str, octave = note_octave) # create note object # check if grace note if note.is_grace: # check if grace note m21_note.type = "eighth" # so it looks like a normal grace note with a flag m21_note = m21_note.getGrace() # convert to grace note # if the note is a normal note else: m21_note.duration = get_duration(duration = note.duration) if m21_note.duration.type == "zero": # ignore notes with invalid durations continue # check if there is a lyric at that note if note.time in lyrics.keys(): m21_note.lyric = lyrics[note.time] # add lyric # check if there are any relevant notations if note.notations is not None: # add notations to note_notations note_notations.extend([Annotation(time = note.time, annotation = notation) for notation in note.notations]) # check if there is a notehead at that note note_noteheads = list(map( lambda notehead: notehead.subtype.lower(), filter(lambda notation: notation.__class__.__name__ == "Notehead", note.notations) )) if len(note_noteheads) > 0 and note_noteheads[0] in noteheadTypeNames: # check if notehead is a valid notehead for music21 m21_note.notehead = note_noteheads[0] # add notehead del note_noteheads # check if there is an articulation(s) at that note note_articulations = list(map( lambda articulation: clean_up_subtype(subtype = articulation.subtype), filter(lambda notation: notation.__class__.__name__ == "Articulation" or notation.__class__.__name__ == "Symbol", note.notations) )) for articulation in note_articulations: if articulation is None: continue if "accent" in articulation: m21_note.articulations.append(M21Articulation.Accent()) if "staccato" in articulation: m21_note.articulations.append(M21Articulation.Staccato()) if "staccatissimo" in articulation: m21_note.articulations.append(M21Articulation.Staccatissimo()) if "tenuto" in articulation: m21_note.articulations.append(M21Articulation.Tenuto()) if "pizzicato" in articulation: if "snap" in articulation: m21_note.articulations.append(M21Articulation.SnapPizzicato()) elif "nail" in articulation: m21_note.articulations.append(M21Articulation.NailPizzicato()) else: # normal pizzicato is default m21_note.articulations.append(M21Articulation.Pizzicato()) if "spiccato" in articulation: m21_note.articulations.append(M21Articulation.Spiccato()) if "bow" in articulation: if "up" in articulation: m21_note.articulations.append(M21Articulation.UpBow()) else: # down is default m21_note.articulations.append(M21Articulation.DownBow()) if any((keyword in articulation for keyword in ("mute", "close"))): # brass mute m21_note.articulations.append(M21Articulation.BrassIndication(name = "muted")) if any((keyword in articulation for keyword in ("open", "ouvert"))): # open if "string" in articulation: m21_note.articulations.append(M21Articulation.OpenString()) else: # defaults to brass open mute m21_note.articulations.append(M21Articulation.BrassIndication(name = "open")) if "doit" in articulation: m21_note.articulations.append(M21Articulation.Doit()) if "fall" in articulation: m21_note.articulations.append(M21Articulation.Falloff()) if "plop" in articulation: m21_note.articulations.append(M21Articulation.Plop()) if "scoop" in articulation: m21_note.articulations.append(M21Articulation.Scoop()) if "harmonic" in articulation: m21_note.articulations.append(M21Articulation.Harmonic()) if "stopped" in articulation: m21_note.articulations.append(M21Articulation.Stopped()) if "stress" in articulation: m21_note.articulations.append(M21Articulation.Stress()) if "unstress" in articulation: m21_note.articulations.append(M21Articulation.Unstress()) del note_articulations # add note to chords # chord_descriptor_tuple = (note.time, note.duration, note.is_grace) # if chord_descriptor_tuple not in chords.keys(): # chords[chord_descriptor_tuple] = [] # chords[chord_descriptor_tuple].append(m21_note) # add note to part m21_note.offset = get_offset(time = note.time) part.insert(offsetOrItemOrList = m21_note.offset, itemOrNone = m21_note) # add chords to part # for chord_descriptor_tuple, chord_notes in chords.items(): # m21_chord = M21Chord(notes = chord_notes) # m21_chord.offset = get_offset(time = chord_descriptor_tuple[0]) # part.insert(offsetOrItemOrList = m21_chord.offset, itemOrNone = m21_chord) # del chords # clean up some memory # clean up some memory del lyrics # add expressive features to part for annotation in sorted(music.annotations + track.annotations + note_notations, key = lambda annotation: annotation.time): # clean up subtype if necessary if hasattr(annotation.annotation, "subtype"): if annotation.annotation.subtype is None: continue else: annotation.annotation.subtype = clean_up_subtype(subtype = annotation.annotation.subtype) # clean up the subtype # some boolean flags annotation_type = annotation.annotation.__class__.__name__ is_fermata = (annotation_type == "Fermata") # fermata if any((annotation_type == keyword for keyword in ("Articulation", "Symbol"))): # instance where fermatas are hidden as articulations is_fermata = "fermata" in annotation.annotation.subtype.lower() # Text, TextSpanner if any((annotation_type == keyword for keyword in ("Text", "TextSpanner"))): m21_annotation = M21Expression.TextExpression(content = annotation.annotation.text) if annotation_type == "TextSpanner": # text spanners m21_annotation.duration = get_duration(duration = annotation.annotation.duration) if m21_annotation.duration.type == "zero": # ignore notes with invalid durations continue # RehearsalMark elif annotation_type == "RehearsalMark": m21_annotation = M21Expression.RehearsalMark(content = annotation.annotation.text) # Dynamic elif annotation_type == "Dynamic": m21_annotation = M21Dynamic.Dynamic(value = annotation.annotation.subtype) # Chord Symbol elif annotation_type == "ChordSymbol": try: m21_annotation = M21ChordSymbol(annotation.annotation.root_str.replace("b", "-") + (annotation.annotation.name.lower() if annotation.annotation.name else "")) except (ValueError): # not all chords are supported, so we do our best, and ignore unknown chord symbols continue # Fermata elif is_fermata: m21_annotation = M21Expression.Fermata() # Ornament, Articulation, Symbol, TechAnnotation elif any((annotation_type == keyword for keyword in ("Ornament", "Articulation", "Symbol", "TechAnnotation"))): if annotation_type == "TechAnnotation": # just to make things easier annotation.annotation.subtype = annotation.annotation.tech_type if annotation.annotation.tech_type is not None else annotation.annotation.text # add subtype field to techannotation annotation.annotation.subtype = clean_up_subtype(subtype = annotation.annotation.subtype) # clean up the subtype for tech annotation if "mordent" in annotation.annotation.subtype: # mordent if any((keyword in annotation.annotation.subtype for keyword in ("reverse", "invert"))): m21_annotation = M21Expression.InvertedMordent() else: # default is normal m21_annotation = M21Expression.Mordent() elif "trill" in annotation.annotation.subtype: # trill if any((keyword in annotation.annotation.subtype for keyword in ("reverse", "invert"))): m21_annotation = M21Expression.InvertedTrill() else: # default is normal m21_annotation = M21Expression.Trill() elif "schleifer" in annotation.annotation.subtype: # schleifer m21_annotation = M21Expression.Schleifer() elif "shake" in annotation.annotation.subtype: # shake m21_annotation = M21Expression.Shake() elif "tremolo" in annotation.annotation.subtype: # tremolo m21_annotation = M21Expression.Tremolo() elif "turn" in annotation.annotation.subtype: # turn if "reverse" in annotation.annotation.subtype: m21_annotation = M21Expression.InvertedTurn() else: # default is normal turn m21_annotation = M21Expression.Turn() else: # unknown ornament or articulation continue # TrillSpanner, HairPinSpanner, SlurSpanner, GlissandoSpanner, OttavaSpanner, TrillSpanner; anything that spans multiple notes elif any((annotation_type == keyword for keyword in ("TrillSpanner", "HairPinSpanner", "SlurSpanner", "GlissandoSpanner", "OttavaSpanner", "TrillSpanner"))): spanned_notes = list(filter(lambda note: get_time_from_offset(offset = note.offset) >= annotation.time and get_time_from_offset(offset = note.offset) <= (annotation.time + annotation.annotation.duration), part.getElementsByClass(M21Note))) # TempoSpanner if annotation_type == "TempoSpanner": if any((annotation.annotation.subtype.startswith(prefix) for prefix in ("accel", "leg"))): # speed-ups; accelerando, leggiero m21_annotation = M21Tempo.AccelerandoSpanner(*spanned_notes) else: # slow-downs; lentando, rallentando, ritardando, smorzando, sostenuto, allargando, etc.; the default m21_annotation = M21Tempo.RitardandoSpanner(*spanned_notes) # HairPinSpanner elif annotation_type == "HairPinSpanner": if any((keyword in "".join(annotation.annotation.subtype.split("-")) for keyword in ("dim", "decres"))): # diminuendo m21_annotation = M21Dynamic.Diminuendo(*spanned_notes) else: # crescendo m21_annotation = M21Dynamic.Crescendo(*spanned_notes) # SlurSpanner elif annotation_type == "SlurSpanner": m21_annotation = M21Spanner.Slur(*spanned_notes) # GlissandoSpanner elif annotation_type == "GlissandoSpanner": m21_annotation = M21Spanner.Glissando(*spanned_notes, lineType = "wavy" if annotation.annotation.is_wavy else "solid") # OttavaSpanner elif annotation_type == "OttavaSpanner": try: m21_annotation = M21Spanner.Ottava(type = annotation.annotation.subtype, transposing = False) except (M21Spanner.SpannerException): # some unknown type of ottava continue # TrillSpanner elif annotation_type == "TrillSpanner" and len(spanned_notes) >= 2: m21_annotation = M21Expression.TrillExtension(spanned_notes[0], spanned_notes[1]) # unknown spanner else: continue # Arpeggio elif annotation_type == "Arpeggio": if annotation.annotation.subtype == "bracket": arpeggio_type = "bracket" elif "up" in annotation.annotation.subtype: arpeggio_type = "up" elif "down" in annotation.annotation.subtype: arpeggio_type = "down" else: arpeggio_type = "normal" m21_annotation = M21Expression.ArpeggioMark(arpeggioType = arpeggio_type) # Tremolo elif annotation_type == "Tremolo": number_of_marks = sub(pattern = "[^\d]", repl = "", string = annotation.annotation.subtype) m21_annotation = M21Expression.Tremolo(numberOfMarks = (int(number_of_marks) // 8) if len(number_of_marks) > 0 else 3) del number_of_marks # TremoloBar # elif annotation_type == "TremoloBar": # pass # to be implemented on a later date # ChordLine # elif annotation_type == "ChordLine": # pass # to be implemented on a later data # Bend # elif annotation_type == "Bend": # pass # to be implemented on a later date # PedalSpanner # elif annotation_type == "PedalSpanner": # pass # music21 does not have pedals # VibratoSpanner # elif annotation_type == "VibratoSpanner": # pass # so rare that this is not worth implementing # if the annotation is unknown, skip it else: continue # insert annotation into part try: m21_annotation.offset = get_offset(time = annotation.time) part.insert(offsetOrItemOrList = m21_annotation.offset, itemOrNone = deepcopy(m21_annotation)) # as to avoid the StreamException object * is already found in this Stream except StreamException as stream_exception: warn(str(stream_exception), RuntimeWarning) # append the part to score score.append(part) # return the score return score