Source code for muspy.outputs.midi

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

import numpy as np
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 pretty_midi import key_name_to_key_number

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"]
DEFAULT_TEMPO = 120


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.

    """
    # TODO: `key_signature.root_str` might be given
    if key_signature.root is None:
        return None
    if key_signature.mode not in ("major", "minor"):
        return None
    suffix = "m" if key_signature.mode == "minor" else ""
    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_off_message: bool = False
) -> 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_off_message : bool, default: False
        Whether to use note-off messages. If False, note-on messages
        with zero velocity are used instead. The advantage to using
        note-on messages at zero velocity is that it can avoid sending
        additional status bytes when Running Status is employed.

    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_off_message:
        note_off_msg = Message(
            "note_off",
            time=note.end,
            note=note.pitch,
            velocity=64,
            channel=channel,
        )
    else:
        note_off_msg = Message(
            "note_on",
            time=note.end,
            note=note.pitch,
            velocity=0,
            channel=channel,
        )

    return note_on_msg, note_off_msg


def to_mido_track(
    track: Track,
    channel: int = None,
    use_note_off_message: bool = False,
) -> MidiTrack:
    """Return a Track object as a mido MidiTrack object.

    Parameters
    ----------
    track : :class:`muspy.Track` object
        Track object to convert.
    channel : int, optional
        Channel number. Defaults to 10 for drums and 0 for other
        instruments.
    use_note_off_message : bool, default: False
        Whether to use note-off messages. If False, note-on messages
        with zero velocity are used instead. The advantage to using
        note-on messages at zero velocity is that it can avoid sending
        additional status bytes when Running Status is employed.

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

    """
    if channel is None:
        channel = 9 if track.is_drum else 0

    # 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
    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=channel,
                use_note_off_message=use_note_off_message,
            )
        )

    # 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_off_message: bool = False): """Return a Music object as a MidiFile object. Parameters ---------- music : :class:`muspy.Music` object Music object to convert. use_note_off_message : bool, default: False Whether to use note-off messages. If False, note-on messages with zero velocity are used instead. The advantage to using note-on messages at zero velocity is that it can avoid sending additional status bytes when Running Status is employed. 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 i, track in enumerate(music.tracks): # NOTE: Many softwares use the same instrument for messages of # the same channel in different tracks. Thus, we want to assign # a unique channel number for each track. MIDI has 15 channels # for instruments other than drums, so we increment the channel # number for each track (skipping the drum channel) and go back # to 0 once we run out of channels. # Assign channel number if track.is_drum: # Mido numbers channels 0 to 15 instead of 1 to 16 channel = 9 else: # MIDI has 15 channels for instruments other than drums channel = i % 15 # Avoid drum channel if channel > 8: channel += 1 midi.tracks.append( to_mido_track( track, channel=channel, use_note_off_message=use_note_off_message, ) ) return midi
def write_midi_mido( path: Union[str, Path], music: "Music", use_note_off_message: bool = False ): """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_off_message : bool, default: False Whether to use note-off messages. If False, note-on messages with zero velocity are used instead. The advantage to using note-on messages at zero velocity is that it can avoid sending additional status bytes when Running Status is employed. """ midi = to_mido(music, use_note_off_message=use_note_off_message) midi.save(str(path)) def to_pretty_midi_key_signature( key_signature: KeySignature, ) -> Optional[PmKeySignature]: """Return a KeySignature object as a pretty_midi KeySignature.""" # TODO: `key_signature.root_str` might be given if key_signature.root is None: return None if key_signature.mode not in ("major", "minor"): return None key_name = PITCH_NAMES[key_signature.root] + " " + key_signature.mode return PmKeySignature( key_number=key_name_to_key_number(key_name), time=key_signature.time ) def to_pretty_midi_time_signature( time_signature: TimeSignature, ) -> PmTimeSignature: """Return a TimeSignature object as a pretty_midi TimeSignature.""" 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. Notes ----- Tempo information will not be included in the output. """ # Create an PrettyMIDI instance midi = PrettyMIDI() # Compute tempos tempo_times, tempi = [0], [float(DEFAULT_TEMPO)] for tempo in music.tempos: tempo_times.append(tempo.time) tempi.append(tempo.qpm) # Remove unnecessary tempo changes to speed up the search if len(tempi) > 1: last_tempo = tempi[0] last_time = tempo_times[0] i = 1 while i < len(tempo_times): if tempi[i] == last_tempo: del tempo_times[i] del tempi[i] elif tempo_times[i] == last_time: del tempo_times[i - 1] del tempi[i - 1] else: last_tempo = tempi[i] i += 1 if len(tempi) == 1: def map_time(time): return time * 60.0 / (music.resolution * tempi[0]) else: tempo_times_np = np.array(tempo_times) tempi_np = np.array(tempi) # Compute the tempo time in absolute timing of each tempo change tempo_realtimes = np.cumsum( np.diff(tempo_times_np) * 60.0 / (music.resolution * tempi_np[:-1]) ).tolist() tempo_realtimes.insert(0, 0.0) def map_time(time): idx = np.searchsorted(tempo_times_np, time, side="right") - 1 residual = time - tempo_times_np[idx] factor = 60.0 / (music.resolution * tempi_np[idx]) return tempo_realtimes[idx] + residual * factor # Key signatures for key_signature in music.key_signatures: pm_key_signature = to_pretty_midi_key_signature(key_signature) if pm_key_signature is not None: pm_key_signature.time = map_time(pm_key_signature.time) midi.key_signature_changes.append(pm_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)) 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. Notes ----- Tempo information will not be included in the output. """ 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'}, default: 'mido' Backend to use. See Also -------- write_midi_mido : Write a Music object to a MIDI file using mido as backend. write_midi_pretty_midi : Write a Music object to a MIDI file using pretty_midi as backend. """ 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'.")