Source code for muspy.outputs.midi

"""MIDI output interface."""
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Tuple, Union

import pretty_midi
from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo
from pretty_midi import Instrument
from pretty_midi import KeySignature as PmKeySignature
from pretty_midi import Lyric as PmLyric
from pretty_midi import Note as PmNote
from pretty_midi import PrettyMIDI
from pretty_midi import TimeSignature as PmTimeSignature

from ..classes import (
    DEFAULT_VELOCITY,
    KeySignature,
    Lyric,
    Note,
    Tempo,
    TimeSignature,
    Track,
)

if TYPE_CHECKING:
    from ..music import Music

PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]


def to_delta_time(midi_track: MidiTrack):
    """Convert a mido MidiTrack object from absolute time to delta time.

    Parameters
    ----------
    midi_track : :class:`mido.MidiTrack` object
        mido MidiTrack object to convert.

    """
    # Sort messages by absolute time
    midi_track.sort(key=lambda x: x.time)

    # Convert to delta time
    time = 0
    for msg in midi_track:
        time_ = msg.time
        msg.time -= time
        time = time_


def to_mido_tempo(tempo: Tempo) -> MetaMessage:
    """Return a Tempo object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage(
        "set_tempo", time=tempo.time, tempo=bpm2tempo(tempo.qpm),
    )


def to_mido_key_signature(
    key_signature: KeySignature,
) -> Optional[MetaMessage]:
    """Return a KeySignature object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    suffix = "m" if key_signature.mode == "minor" else ""
    if key_signature.root is None:
        return None
    return MetaMessage(
        "key_signature",
        time=key_signature.time,
        key=PITCH_NAMES[key_signature.root] + suffix,
    )


def to_mido_time_signature(time_signature: TimeSignature) -> MetaMessage:
    """Return a TimeSignature object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage(
        "time_signature",
        time=time_signature.time,
        numerator=time_signature.numerator,
        denominator=time_signature.denominator,
    )


def to_mido_meta_track(music: "Music") -> MidiTrack:
    """Return a mido MidiTrack containing metadata of a Music object.

    Parameters
    ----------
    music : :class:`muspy.Music` object
        Music object to convert.

    Returns
    -------
    :class:`mido.MidiTrack` object
        Converted mido MidiTrack object.

    """
    # Create a track to store the metadata
    meta_track = MidiTrack()

    # Song title
    if music.metadata.title is not None:
        meta_track.append(MetaMessage("track_name", name=music.metadata.title))

    # Tempos
    for tempo in music.tempos:
        meta_track.append(to_mido_tempo(tempo))

    # Key signatures
    for key_signature in music.key_signatures:
        mido_key_signature = to_mido_key_signature(key_signature)
        if mido_key_signature is not None:
            meta_track.append(mido_key_signature)

    # Time signatures
    for time_signature in music.time_signatures:
        meta_track.append(to_mido_time_signature(time_signature))

    # Lyrics
    for lyric in music.lyrics:
        meta_track.append(to_mido_lyric(lyric))

    # Annotations
    for annotation in music.annotations:
        # Marker messages
        if annotation.group == "marker":
            meta_track.append(
                MetaMessage("marker", text=annotation.annotation)
            )
        # Text messages
        elif isinstance(annotation.annotation, str):
            meta_track.append(
                MetaMessage(
                    "text", time=annotation.time, text=annotation.annotation
                )
            )

    # End of track message
    meta_track.append(MetaMessage("end_of_track"))

    # Convert to delta time
    to_delta_time(meta_track)

    return meta_track


def to_mido_lyric(lyric: Lyric) -> MetaMessage:
    """Return a Lyric object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage("lyrics", time=lyric.time, text=lyric.lyric)


def to_mido_note_on_note_off(
    note: Note, channel: int, use_note_on_as_note_off: bool = True
) -> Tuple[Message, Message]:
    """Return a Note object as mido Message objects.

    Timing is in absolute time, NOT in delta time.

    Parameters
    ----------
    note : :class:`muspy.Note` object
        Note object to convert.
    channel : int
        Channel of the MIDI message.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message. Defaults to True.

    Returns
    -------
    :class:`mido.Message` object
        Converted mido Message object for note on.
    :class:`mido.Message` object
        Converted mido Message object for note off.

    """
    velocity = note.velocity if note.velocity is not None else DEFAULT_VELOCITY
    note_on_msg = Message(
        "note_on",
        time=note.time,
        note=note.pitch,
        velocity=velocity,
        channel=channel,
    )
    if use_note_on_as_note_off:
        note_off_msg = Message(
            "note_on",
            time=note.end,
            note=note.pitch,
            velocity=0,
            channel=channel,
        )
    else:
        note_off_msg = Message(
            "note_off",
            time=note.end,
            note=note.pitch,
            velocity=velocity,
            channel=channel,
        )

    return note_on_msg, note_off_msg


def to_mido_track(
    track: Track, use_note_on_as_note_off: bool = True
) -> MidiTrack:
    """Return a Track object as a mido MidiTrack object.

    Parameters
    ----------
    track : :class:`muspy.Track` object
        Track object to convert.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message.

    Returns
    -------
    :class:`mido.MidiTrack` object
        Converted mido MidiTrack object.

    """
    # Create a new MIDI track
    midi_track = MidiTrack()

    # Track name messages
    if track.name is not None:
        midi_track.append(MetaMessage("track_name", name=track.name))

    # Program change messages
    channel = 9 if track.is_drum else 0
    midi_track.append(
        Message("program_change", program=track.program, channel=channel,)
    )

    # Note on and note off messages
    for note in track.notes:
        midi_track.extend(
            to_mido_note_on_note_off(note, channel, use_note_on_as_note_off)
        )

    # End of track message
    midi_track.append(MetaMessage("end_of_track"))

    # Convert to delta time
    to_delta_time(midi_track)

    return midi_track


[docs]def to_mido(music: "Music", use_note_on_as_note_off: bool = True): """Return a Music object as a MidiFile object. Parameters ---------- music : :class:`muspy.Music` object Music object to convert. use_note_on_as_note_off : bool Whether to use a note on message with zero velocity instead of a note off message. Returns ------- :class:`mido.MidiFile` Converted MidiFile object. """ # Create a MIDI file object midi = MidiFile(type=1, ticks_per_beat=music.resolution) # Append meta track midi.tracks.append(to_mido_meta_track(music)) # Iterate over music tracks for track in music.tracks: midi.tracks.append(to_mido_track(track, use_note_on_as_note_off)) return midi
def write_midi_mido( path: Union[str, Path], music: "Music", use_note_on_as_note_off: bool = True, ): """Write a Music object to a MIDI file using mido as backend. Parameters ---------- path : str or Path Path to write the MIDI file. music : :class:`muspy.Music` object Music object to write. use_note_on_as_note_off : bool Whether to use a note on message with zero velocity instead of a note off message. """ midi = to_mido(music, use_note_on_as_note_off=use_note_on_as_note_off) midi.save(str(path)) def to_pretty_midi_key_signature( key_signature: KeySignature, ) -> PmKeySignature: """Return a KeySignature object as a pretty_midi KeySignature object.""" return PmKeySignature( pretty_midi.key_name_to_key_number( "{} {}".format(key_signature.root, key_signature.mode) ), key_signature.time, ) def to_pretty_midi_time_signature( time_signature: TimeSignature, ) -> PmTimeSignature: """Return a KeySignature object as a pretty_midi TimeSignature object.""" return PmTimeSignature( numerator=time_signature.numerator, denominator=time_signature.denominator, time=time_signature.time, ) def to_pretty_midi_lyric(lyric: Lyric) -> PmLyric: """Return a Lyric object as a pretty_midi Lyric object.""" return PmLyric(lyric.lyric, lyric.time) def to_pretty_midi_note(note: Note) -> PmNote: """Return a Note object as a pretty_midi Note object.""" velocity = note.velocity if note.velocity is not None else DEFAULT_VELOCITY return PmNote( velocity=velocity, pitch=note.pitch, start=note.time, end=note.end ) def to_pretty_midi_instrument(track: Track) -> Instrument: """Return a Track object as a pretty_midi Instrument object.""" instrument = Instrument( program=track.program, is_drum=track.is_drum, name=track.name ) for note in track.notes: instrument.notes.append(to_pretty_midi_note(note)) return instrument
[docs]def to_pretty_midi(music: "Music") -> PrettyMIDI: """Return a Music object as a PrettyMIDI object. Tempo changes are not supported yet. Parameters ---------- music : :class:`muspy.Music` object Music object to convert. Returns ------- :class:`pretty_midi.PrettyMIDI` Converted PrettyMIDI object. """ # Create an PrettyMIDI instance midi = PrettyMIDI() # Key signatures for key_signature in music.key_signatures: midi.key_signature_changes.append( to_pretty_midi_key_signature(key_signature) ) # Time signatures for time_signature in music.time_signatures: midi.time_signature_changes.append( to_pretty_midi_time_signature(time_signature) ) # Lyrics for lyric in music.lyrics: midi.lyrics.append(to_pretty_midi_lyric(lyric)) # Tracks for track in music.tracks: midi.instruments.append(to_pretty_midi_instrument(track)) # TODO: Adjust timings return midi
def write_midi_pretty_midi(path: Union[str, Path], music: "Music"): """Write a Music object to a MIDI file using pretty_midi as backend. Tempo changes are not supported yet. Parameters ---------- path : str or Path Path to write the MIDI file. music : :class:`muspy.Music` object Music object to convert. """ midi = to_pretty_midi(music) midi.write(str(path))
[docs]def write_midi( path: Union[str, Path], music: "Music", backend: str = "mido", **kwargs: Any ): """Write a Music object to a MIDI file. Parameters ---------- path : str or Path Path to write the MIDI file. music : :class:`muspy.Music` Music object to write. backend: {'mido', 'pretty_midi'} Backend to use. Defaults to 'mido'. """ if backend == "mido": return write_midi_mido(path, music, **kwargs) if backend == "pretty_midi": return write_midi_pretty_midi(path, music) raise ValueError("`backend` must by one of 'mido' and 'pretty_midi'.")