Source code for mutwo.midi_converters.frontends

"""Render midi files (SMF) from mutwo data.

"""

import functools
import itertools
import operator
import typing

import mido  # type: ignore

from mutwo import core_constants
from mutwo import core_converters
from mutwo import core_events
from mutwo import core_parameters
from mutwo import core_utilities
from mutwo import midi_converters
from mutwo import music_converters
from mutwo import music_parameters

__all__ = (
    "SimpleEventToControlMessageTuple",
    "CentDeviationToPitchBendingNumber",
    "MutwoPitchToMidiPitch",
    "EventToMidiFile",
)

ConvertableEvent = (
    core_events.SimpleEvent
    | core_events.Consecution[core_events.SimpleEvent]
    | core_events.Concurrence[
        core_events.Consecution[core_events.SimpleEvent]
    ]
)


[docs]class SimpleEventToControlMessageTuple(core_converters.SimpleEventToAttribute): """Convert :class:`mutwo.core_events.SimpleEvent` to a tuple of control messages""" def __init__( self, attribute_name: typing.Optional[str] = None, exception_value: tuple[mido.Message, ...] = tuple([]), ): super().__init__( attribute_name or midi_converters.configurations.DEFAULT_CONTROL_MESSAGE_TUPLE_ATTRIBUTE_NAME, exception_value, )
[docs]class CentDeviationToPitchBendingNumber(core_converters.abc.Converter): """Convert cent deviation to midi pitch bend number. :param maximum_pitch_bend_deviation: sets the maximum pitch bending range in cents. This value depends on the particular used software synthesizer and its settings, because it is up to the respective synthesizer how to interpret the pitch bending messages. By default mutwo sets the value to 200 cents which seems to be the most common interpretation among different manufacturers. :type maximum_pitch_bend_deviation: int """ def __init__(self, maximum_pitch_bend_deviation: typing.Optional[float] = None): self._logger = core_utilities.get_cls_logger(type(self)) self._maximum_pitch_bend_deviation = maximum_pitch_bend_deviation = ( maximum_pitch_bend_deviation or midi_converters.configurations.DEFAULT_MAXIMUM_PITCH_BEND_DEVIATION_IN_CENTS ) self._pitch_bending_warning = ( f"Maximum pitch bending is {maximum_pitch_bend_deviation} cents up or down!" ) def _warn_pitch_bending(self, cent_deviation: core_constants.Real): self._logger.warning( f"Maximum pitch bending is {self._maximum_pitch_bend_deviation} " "cents up or down! Found prohibited necessity for pitch " f"bending with cent_deviation = {cent_deviation}. " "Mutwo normalized pitch bending to the allowed border." " Increase the 'maximum_pitch_bend_deviation' argument in the " "CentDeviationToPitchBendingNumber instance." )
[docs] def convert( self, cent_deviation: core_constants.Real, ) -> int: if cent_deviation >= self._maximum_pitch_bend_deviation: self._warn_pitch_bending(cent_deviation) cent_deviation = self._maximum_pitch_bend_deviation elif cent_deviation <= -self._maximum_pitch_bend_deviation: self._warn_pitch_bending(cent_deviation) cent_deviation = -self._maximum_pitch_bend_deviation return round( core_utilities.scale( cent_deviation, -self._maximum_pitch_bend_deviation, self._maximum_pitch_bend_deviation, -midi_converters.constants.NEUTRAL_PITCH_BEND, midi_converters.constants.NEUTRAL_PITCH_BEND, ) )
[docs]class MutwoPitchToMidiPitch(core_converters.abc.Converter): """Convert mutwo pitch to midi pitch number and midi pitch bend number. :param maximum_pitch_bend_deviation: sets the maximum pitch bending range in cents. This value depends on the particular used software synthesizer and its settings, because it is up to the respective synthesizer how to interpret the pitch bending messages. By default mutwo sets the value to 200 cents which seems to be the most common interpretation among different manufacturers. :type maximum_pitch_bend_deviation: int """ def __init__( self, cent_deviation_to_pitch_bending_number: CentDeviationToPitchBendingNumber = CentDeviationToPitchBendingNumber(), ): self._cent_deviation_to_pitch_bending_number = ( cent_deviation_to_pitch_bending_number )
[docs] def convert( self, mutwo_pitch_to_convert: music_parameters.abc.Pitch, midi_note: typing.Optional[int] = None, ) -> midi_converters.constants.MidiPitch: """Find midi note and pitch bending for given mutwo pitch :param mutwo_pitch_to_convert: The mutwo pitch which shall be converted. :type mutwo_pitch_to_convert: music_parameters.abc.Pitch :param midi_note: Can be set to a midi note value if one wants to force the converter to calculate the pitch bending deviation for the passed midi note. If this argument is ``None`` the converter will simply use the closest midi pitch number to the passed mutwo pitch. Default to ``None``. :type midi_note: typing.Optional[int] """ f = mutwo_pitch_to_convert.frequency if midi_note: closest_midi_pitch = midi_note else: closest_midi_pitch = core_utilities.find_closest_index( f, music_parameters.constants.MIDI_PITCH_FREQUENCY_TUPLE ) Δcents_to_closest_midi_pitch = music_parameters.abc.Pitch.hertz_to_cents( music_parameters.constants.MIDI_PITCH_FREQUENCY_TUPLE[closest_midi_pitch], f, ) pb = self._cent_deviation_to_pitch_bending_number.convert( Δcents_to_closest_midi_pitch ) return closest_midi_pitch, pb
[docs]class EventToMidiFile(core_converters.abc.Converter): """Class for rendering standard midi files (SMF) from mutwo data. Mutwo offers a wide range of options how the respective midi file shall be rendered and how mutwo data shall be translated. This is necessary due to the limited and not always unambiguous nature of musical encodings in midi files. In this way the user can tweak the conversion routine to her or his individual needs. :param chronon_to_pitch_list: Function to extract from a :class:`mutwo.core_events.SimpleEvent` a tuple that contains pitch objects (objects that inherit from :class:`mutwo.music_parameters.abc.Pitch`). By default it asks the Event for its :attr:`pitch_list` attribute (because by default :class:`mutwo.music_events.NoteLike` objects are expected). When using different Event classes than ``NoteLike`` with a different name for their pitch property, this argument should be overridden. If the function call raises an :obj:`AttributeError` (e.g. if no pitch can be extracted), mutwo will interpret the event as a rest. :type chronon_to_pitch_list: typing.Callable[ [core_events.SimpleEvent], tuple[music_parameters.abc.Pitch, ...]] :param chronon_to_volume: Function to extract the volume from a :class:`mutwo.core_events.SimpleEvent` in the purpose of generating midi notes. The function should return an object that inhertis from :class:`mutwo.music_parameters.abc.Volume`. By default it asks the Event for its :attr:`volume` attribute (because by default :class:`mutwo.music_events.NoteLike` objects are expected). When using different Event classes than ``NoteLike`` with a different name for their volume property, this argument should be overridden. If the function call raises an :obj:`AttributeError` (e.g. if no volume can be extracted), mutwo will interpret the event as a rest. :type chronon_to_volume: typing.Callable[ [core_events.SimpleEvent], music_parameters.abc.Volume] :param chronon_to_control_message_tuple: Function to generate midi control messages from a chronon. By default no control messages are generated. If the function call raises an AttributeError (e.g. if an expected control value isn't available) mutwo will interpret the event as a rest. :type chronon_to_control_message_tuple: typing.Callable[ [core_events.SimpleEvent], tuple[mido.Message, ...]] :param midi_file_type: Can either be 0 (for one-track midi files) or 1 (for synchronous multi-track midi files). Mutwo doesn't offer support for generating type 2 midi files (midi files with asynchronous tracks). :type midi_file_type: int :param available_midi_channel_tuple: tuple containing integer where each integer represents the number of the used midi channel. Integer can range from 0 to 15. Higher numbers of available_midi_channel_tuple (like all 16) are recommended when rendering microtonal music. It shall be remarked that midi-channel 9 (or midi channel 10 when starting to count from 1) is often ignored by several software synthesizer, because this channel is reserved for percussion instruments. :type available_midi_channel_tuple: tuple[int, ...] :param distribute_midi_channels: This parameter is only relevant if more than one :class:`~mutwo.core_events.Consecution` is passed to the convert method. If set to ``True`` each :class:`~mutwo.core_events.Consecution` only makes use of exactly n_midi_channel (see next parameter). If set to ``False`` each converted :class:`Consecution` is allowed to make use of all available channels. If set to ``True`` and the amount of necessary MidiTracks is higher than the amount of available channels, mutwo will silently cycle through the list of available midi channel. :type distribute_midi_channels: bool :param midi_channel_count_per_track: This parameter is only relevant for distribute_midi_channels == True. It sets how many midi channels are assigned to one Consecution. If microtonal chords shall be played by one Consecution (via pitch bending messages) a higher number than 1 is recommended. Defaults to 1. :type midi_channel_count_per_track: int :param mutwo_pitch_to_midi_pitch: class to convert from mutwo pitches to midi pitches. Default to :class:`MutwoPitchToMidiPitch`. :type mutwo_pitch_to_midi_pitch: :class:`MutwoPitchToMidiPitch` :param ticks_per_beat: Sets the timing precision of the midi file. From the mido documentation: "Typical values range from 96 to 480 but some use even more ticks per beat". :type ticks_per_beat: int :param instrument_name: Sets the midi instrument of all channels. :type instrument_name: str :param tempo_envelope: All Midi files should specify their tempo. The default value of mutwo is 120 BPM (this is also the value that is assumed by any midi-file-reading-software if no tempo has been specified). Tempo changes are supported (and will be written to the resulting midi file). :type tempo_envelope: core_events.TempoEnvelope **Example**: >>> from mutwo import midi_converters >>> from mutwo import music_parameters >>> # midi file converter that assign a middle c to all events >>> midi_converter = midi_converters.EventToMidiFile( ... chronon_to_pitch_list=lambda event: (music_parameters.WesternPitch('c'),) ... ) **Disclaimer**: The current implementation doesn't support time-signatures (the written time signature is always 4/4 for now). """ _tempo_point_converter = core_converters.TempoPointToBeatLengthInSeconds() def __init__( self, chronon_to_pitch_list: typing.Callable[ [core_events.SimpleEvent], tuple[music_parameters.abc.Pitch, ...] ] = music_converters.SimpleEventToPitchList(), # type: ignore chronon_to_volume: typing.Callable[ [core_events.SimpleEvent], music_parameters.abc.Volume ] = music_converters.SimpleEventToVolume(), # type: ignore chronon_to_control_message_tuple: typing.Callable[ [core_events.SimpleEvent], tuple[mido.Message, ...] ] = SimpleEventToControlMessageTuple(), midi_file_type: int = None, available_midi_channel_tuple: tuple[int, ...] = None, distribute_midi_channels: bool = False, midi_channel_count_per_track: typing.Optional[int] = None, mutwo_pitch_to_midi_pitch: MutwoPitchToMidiPitch = MutwoPitchToMidiPitch(), ticks_per_beat: typing.Optional[int] = None, instrument_name: typing.Optional[str] = None, tempo_envelope: typing.Optional[core_events.TempoEnvelope] = None, ): self._logger = core_utilities.get_cls_logger(type(self)) self._midi_file_type = ( midi_file_type or midi_converters.configurations.DEFAULT_MIDI_FILE_TYPE ) self._available_midi_channel_tuple = ( available_midi_channel_tuple or midi_converters.configurations.DEFAULT_AVAILABLE_MIDI_CHANNEL_TUPLE ) self._midi_channel_count_per_track = ( midi_channel_count_per_track or midi_converters.configurations.DEFAULT_MIDI_CHANNEL_COUNT_PER_TRACK ) self._ticks_per_beat = ( ticks_per_beat or midi_converters.configurations.DEFAULT_TICKS_PER_BEAT ) self._instrument_name = ( instrument_name or midi_converters.configurations.DEFAULT_MIDI_INSTRUMENT_NAME ) self._tempo_envelope = ( tempo_envelope or midi_converters.configurations.DEFAULT_TEMPO_ENVELOPE ) self._chronon_to_pitch_list = chronon_to_pitch_list self._chronon_to_volume = chronon_to_volume self._chronon_to_control_message_tuple = ( chronon_to_control_message_tuple ) self._distribute_midi_channels = distribute_midi_channels self._mutwo_pitch_to_midi_pitch = mutwo_pitch_to_midi_pitch self._assert_midi_file_type_has_correct_value(self._midi_file_type) self._assert_available_midi_channel_tuple_has_correct_value( self._available_midi_channel_tuple ) # ###################################################################### # # static methods # # ###################################################################### # @staticmethod def _assert_midi_file_type_has_correct_value(midi_file_type: int): try: assert midi_file_type in (0, 1) except AssertionError: raise ValueError( f"Unknown midi_file_type '{midi_file_type}'. " "Only midi type 0 and 1 are supported." ) @staticmethod def _assert_available_midi_channel_tuple_has_correct_value( available_midi_channel_tuple: tuple[int, ...], ): # check for correct range of each number for mc in available_midi_channel_tuple: if not (mc in midi_converters.constants.ALLOWED_MIDI_CHANNEL_TUPLE): raise ValueError( f"Found unknown midi channel '{mc}' " "in available_midi_channel_tuple." " Only midi channel " f"'{midi_converters.constants.ALLOWED_MIDI_CHANNEL_TUPLE}' " "are allowed." ) # check for duplicate if len(available_midi_channel_tuple) != len(set(available_midi_channel_tuple)): raise ValueError( "Found duplicate in available_midi_channel_tuple " f"'{available_midi_channel_tuple}'." ) # ###################################################################### # # helper methods # # ###################################################################### # def _adjust_beat_length_in_microseconds( self, tempo_point: core_constants.Real | core_parameters.DirectTempoPoint, beat_length_in_microseconds: int, ) -> int: """This method makes sure that ``beat_length_in_microseconds`` isn't too big. Standard midi files define a slowest allowed tempo which is around 3.5 BPM. In case the tempo is lower than this slowest allowed tempo, `mutwo` will automatically set the tempo to the lowest allowed tempo. """ bl = beat_length_in_microseconds if bl >= midi_converters.constants.MAXIMUM_MICROSECONDS_PER_BEAT: bl = midi_converters.constants.MAXIMUM_MICROSECONDS_PER_BEAT bpm = mido.tempo2bpm( midi_converters.constants.MAXIMUM_MICROSECONDS_PER_BEAT ) self._logger.warning( f"TempoPoint '{tempo_point}' is too slow for " "Standard Midi Files. " f"The slowest possible tempo is '{bpm}' BPM." "Tempo has been set to" f" '{bpm}' BPM.", ) return bl def _beats_per_minute_to_beat_length_in_microseconds( self, beats_per_minute: core_constants.Real ) -> int: """Method for converting beats per minute (BPM) to midi tempo. Midi tempo is stated in beat length in microseconds. """ bl_in_seconds = self._tempo_point_converter.convert(beats_per_minute) return int(bl_in_seconds * midi_converters.constants.MIDI_TEMPO_FACTOR) def _find_available_midi_channel_tuple_per_consecution( self, simultaneous_event: core_events.Concurrence[ core_events.Consecution[core_events.SimpleEvent] ], ) -> tuple[tuple[int, ...], ...]: """Find midi channels for each Consecution. Depending on whether distribute_midi_channels has been set to True this method distributes all available midi channels on the respective Consecutions. """ if self._distribute_midi_channels: mchannel_cycle = itertools.cycle(self._available_midi_channel_tuple) return tuple( tuple( next(mchannel_cycle) for _ in range(self._midi_channel_count_per_track) ) for _ in simultaneous_event ) else: return tuple(self._available_midi_channel_tuple for _ in simultaneous_event) def _beats_to_ticks( self, absolute_time: core_parameters.abc.Duration | typing.Any ) -> int: abs_t = core_events.configurations.UNKNOWN_OBJECT_TO_DURATION(absolute_time) return int(self._ticks_per_beat * abs_t.duration) # ###################################################################### # # methods for converting mutwo data to midi data # # ###################################################################### # def _tempo_envelope_to_midi_message_tuple( self, tempo_envelope: core_events.TempoEnvelope ) -> tuple[mido.MetaMessage, ...]: """Converts a Consecution of ``EnvelopeEvent`` to midi Tempo messages.""" offset_iterator = core_utilities.accumulate_from_n( tempo_envelope.get_parameter("duration"), core_parameters.DirectDuration(0) ) mlist = [] for abs_t, tempo_point in zip(offset_iterator, tempo_envelope.value_tuple): absolute_tick = self._beats_to_ticks(abs_t) bl = self._beats_per_minute_to_beat_length_in_microseconds(tempo_point) bl = self._adjust_beat_length_in_microseconds(tempo_point, bl) tempom = mido.MetaMessage("set_tempo", tempo=bl, time=absolute_tick) mlist.append(tempom) return tuple(mlist) def _tune_pitch( self, absolute_tick_start: int, absolute_tick_end: int, pitch_to_tune: music_parameters.abc.Pitch, midi_channel: int, ) -> tuple[midi_converters.constants.MidiNote, tuple[mido.Message, ...]]: tick_count = absolute_tick_end - absolute_tick_start # We replace the original pitch object with a pitch object that doesn't # start any complex computations when asking for its 'frequency' attribute. pitch_to_tune = music_parameters.DirectPitch( pitch_to_tune.frequency, envelope=pitch_to_tune.envelope ) # We have to use one tick less, so that at # "pitch_envelope.value_at(tick_count)" we already reached the # end of the envelope. penvelope = pitch_to_tune.resolve_envelope(tick_count - 1) # We will convert the pitch envelope to numerical values for better performance numerical_penvelope = core_events.Envelope( [ [absolute_time, value, event.curve_shape] for absolute_time, value, event in zip( penvelope.absolute_time_tuple, penvelope.value_tuple, penvelope, ) ] ) end = 1 if not penvelope.duration else None avg_cent = numerical_penvelope.get_average_parameter(end=end) avg_p = penvelope.value_to_parameter(avg_cent) midi_pitch, pb = self._mutwo_pitch_to_midi_pitch.convert(avg_p) first_pitch_bending_message_time = absolute_tick_start if absolute_tick_start != 0: # if possible add bending one tick earlier to avoid glitches first_pitch_bending_message_time -= 1 pbm_list = [] # pitch bending messages if numerical_penvelope.is_static: pbm_list.append( mido.Message( "pitchwheel", channel=midi_channel, pitch=pb, time=first_pitch_bending_message_time, ) ) else: avg_p_f = avg_p.frequency for t in range(0, tick_count): abstract_cents = numerical_penvelope.parameter_at(t) f = penvelope.value_to_parameter(abstract_cents).frequency pb = self._mutwo_pitch_to_midi_pitch._cent_deviation_to_pitch_bending_number.convert( music_parameters.abc.Pitch.hertz_to_cents(avg_p_f, f) ) pitch_bending_message = mido.Message( "pitchwheel", channel=midi_channel, pitch=pb, time=t + absolute_tick_start, ) pbm_list.append(pitch_bending_message) return midi_pitch, tuple(pbm_list) def _note_information_to_midi_message_tuple( self, absolute_tick_start: int, absolute_tick_end: int, velocity: int, pitch: music_parameters.abc.Pitch, midi_channel: int, ) -> tuple[mido.Message, ...]: """Generate 'pitch bending', 'note on' and 'note off' messages for one tone.""" p, pitch_bending_message_tuple = self._tune_pitch( absolute_tick_start, absolute_tick_end, pitch, midi_channel, ) midi_message_list = list(pitch_bending_message_tuple) for t, m in ( (absolute_tick_start, "note_on"), (absolute_tick_end, "note_off"), ): midi_message_list.append( mido.Message(m, note=p, velocity=velocity, time=t, channel=midi_channel) ) return tuple(midi_message_list) def _extracted_data_to_midi_message_tuple( self, absolute_time: core_parameters.abc.Duration, duration: core_constants.DurationType, available_midi_channel_tuple_cycle: typing.Iterator, pitch_list: tuple[music_parameters.abc.Pitch, ...], volume: music_parameters.abc.Volume, control_message_tuple: tuple[mido.Message, ...], ) -> tuple[mido.Message, ...]: """Generates pitch-bend / note-on / note-off messages for each tone in a chord. Concatenates the midi messages for every played tone with the global control messages. Gets as an input relevant data for midi message generation that has been extracted from a :class:`mutwo.core_events.abc.Event` object. """ abs_tick_start = self._beats_to_ticks(absolute_time) abs_tick_end = abs_tick_start + self._beats_to_ticks(duration) velocity = volume.midi_velocity mlist = [] # add control messages for cm in control_message_tuple: cm.time = abs_tick_start mlist.append(cm) # add note related messages for p in pitch_list: mlist.extend( self._note_information_to_midi_message_tuple( abs_tick_start, abs_tick_end, velocity, p, next(available_midi_channel_tuple_cycle), ) ) return tuple(mlist) def _chronon_to_midi_message_tuple( self, chronon: core_events.SimpleEvent, absolute_time: core_parameters.abc.Duration, available_midi_channel_tuple_cycle: typing.Iterator, ) -> tuple[mido.Message, ...]: """Converts ``SimpleEvent`` (or any object that inherits from ``SimpleEvent``). Return tuple filled with midi messages that represent the mutwo data in the midi format. The timing here is absolute. Only later at the `_midi_message_tuple_to_midi_track` method the timing becomes relative """ extracted_data_list = [] # try to extract the relevant data is_rest = False for p, extraction_function in ( ("pitch_list", self._chronon_to_pitch_list), ("volume", self._chronon_to_volume), ("control_message_tuple", self._chronon_to_control_message_tuple), ): try: d = extraction_function(chronon) except AttributeError: is_rest = True else: if d is None: self._logger.warning( "Extracting '{p}' from event '{chronon}' " "returned 'None'! Converter autoset this event to " "a rest." ) is_rest = True if is_rest: break extracted_data_list.append(d) # if not all relevant data could be extracted, simply ignore the # event if is_rest: return tuple([]) # otherwise generate midi messages from the extracted data return self._extracted_data_to_midi_message_tuple( absolute_time, chronon.duration, available_midi_channel_tuple_cycle, *extracted_data_list, # type: ignore ) def _consecution_to_midi_message_tuple( self, consecution: core_events.Consecution[ core_events.SimpleEvent | core_events.Consecution ], available_midi_channel_tuple: tuple[int, ...], absolute_time: core_parameters.abc.Duration = core_parameters.DirectDuration(0), ) -> tuple[mido.Message, ...]: """Iterates through the ``Consecution`` and converts each ``SimpleEvent``. Return unsorted tuple of Midi messages where the time attribute of each message is the absolute time in ticks. """ mlist: list[mido.Message] = [] mchannel_cycle = itertools.cycle(available_midi_channel_tuple) # fill midi track with the content of the consecution for local_abs_time, sim_or_seq in zip( consecution.absolute_time_tuple, consecution ): global_abs_time = local_abs_time + absolute_time if isinstance(sim_or_seq, core_events.SimpleEvent): mtuple = self._chronon_to_midi_message_tuple( sim_or_seq, global_abs_time, mchannel_cycle ) self._logger.debug( f"SimpleEvent -> MidiMessageData:\n\t{sim_or_seq} -> {mtuple}" ) else: mtuple = self._consecution_to_midi_message_tuple( sim_or_seq, available_midi_channel_tuple, global_abs_time, ) mlist.extend(mtuple) return tuple(mlist) def _midi_message_tuple_to_midi_track( self, midi_message_tuple: tuple[mido.Message | mido.MetaMessage, ...], duration: core_constants.DurationType, is_first_track: bool = False, ) -> mido.MidiTrack: """Convert unsorted midi message with absolute timing to a midi track. In the resulting midi track the timing of the messages is relative. """ self._logger.debug( "Convert midi messages -> MidiTrack\n\t" f"msg-tuple: {midi_message_tuple}" ) track = mido.MidiTrack([]) track.append(mido.MetaMessage("instrument_name", name=self._instrument_name)) if is_first_track: # standard time signature 4/4 track.append(mido.MetaMessage("time_signature", numerator=4, denominator=4)) midi_message_tuple += self._tempo_envelope_to_midi_message_tuple( self._tempo_envelope ) # If event is empty and it isn't the first track # (e.g. no tempo envelope was added) if not midi_message_tuple: return track sorted_m = sorted(midi_message_tuple, key=lambda message: message.time) sorted_m.append( mido.MetaMessage( "end_of_track", time=max((sorted_m[-1].time, self._beats_to_ticks(duration))), ) ) # absolute time => relative time Δticks = tuple(m1.time - m0.time for m0, m1 in zip(sorted_m, sorted_m[1:])) Δticks = (sorted_m[0].time,) + Δticks for Δt, message in zip(Δticks, sorted_m): message.time = Δt track.extend(sorted_m) return track # ###################################################################### # # methods for filling the midi file (only called once) # # ###################################################################### # def _add_chronon_to_midi_file( self, chronon: core_events.SimpleEvent, midi_file: mido.MidiFile ) -> None: self._add_consecution_to_midi_file( core_events.Consecution([chronon]), midi_file ) def _add_consecution_to_midi_file( self, consecution: core_events.Consecution[core_events.SimpleEvent], midi_file: mido.MidiFile, ) -> None: self._add_simultaneous_event_to_midi_file( core_events.Concurrence([consecution]), midi_file ) def _add_simultaneous_event_to_midi_file( self, simultaneous_event: core_events.Concurrence[ core_events.Consecution[core_events.SimpleEvent] ], midi_file: mido.MidiFile, ) -> None: # Depending on the midi_file_type either adds a tuple of MidiTrack # objects (for midi_file_type = 1) or adds only one MidiTrack # (for midi_file_type = 0). midi_channel_data = ( self._find_available_midi_channel_tuple_per_consecution( simultaneous_event ) ) midi_data_per_seq_tuple = tuple( self._consecution_to_midi_message_tuple(seq, m) for seq, m in zip(simultaneous_event, midi_channel_data) ) duration = simultaneous_event.duration # midi file type 0 -> only one track if self._midi_file_type == 0: midi_data_for_one_track = functools.reduce( operator.add, midi_data_per_seq_tuple ) midi_track = self._midi_message_tuple_to_midi_track( midi_data_for_one_track, duration, is_first_track=True ) midi_file.tracks.append(midi_track) # midi file type 1 else: midi_track_iterator = ( self._midi_message_tuple_to_midi_track( m, duration, is_first_track=i == 0 ) for i, m in enumerate(midi_data_per_seq_tuple) ) midi_file.tracks.extend(midi_track_iterator) def _event_to_midi_file(self, event_to_convert: ConvertableEvent) -> mido.MidiFile: """Convert mutwo event object to mido `MidiFile` object.""" midi_file = mido.MidiFile( ticks_per_beat=self._ticks_per_beat, type=self._midi_file_type ) # depending on the event types timing structure different methods are called match event_to_convert: case core_events.Concurrence(): self._logger.debug("Concurrence -> MidiFile") self._add_simultaneous_event_to_midi_file(event_to_convert, midi_file) case core_events.Consecution(): self._logger.debug("Consecution -> MidiFile") self._add_consecution_to_midi_file(event_to_convert, midi_file) case core_events.SimpleEvent(): self._logger.debug("SimpleEvent -> MidiFile") self._add_chronon_to_midi_file(event_to_convert, midi_file) case _: raise TypeError( f"Can't convert object '{event_to_convert}' " f"of type '{type(event_to_convert)}' to a MidiFile. " "Supported types include all inherited classes " f"from '{ConvertableEvent}'." ) return midi_file # ###################################################################### # # public methods for interaction with the user # # ###################################################################### #
[docs] def convert( self, event_to_convert: ConvertableEvent, path: typing.Optional[str] = None ) -> mido.MidiFile: """Render a Midi file to the converters path attribute from the given event. :param event_to_convert: The given event that shall be translated to a Midi file. :type event_to_convert: core_events.SimpleEvent | core_events.Consecution[core_events.SimpleEvent] | core_events.Concurrence[core_events.Consecution[core_events.SimpleEvent]] :param path: If this is a string the method will write a midi file to the given path. The typical file type extension '.mid' is recommended, but not mandatory. If set to `None` the method won't write a midi file to the disk, but it will simply return a :class:`mido.MidiFile` object. Default to `None`. :type path: typing.Optional[str] The following example generates a midi file that contains a simple ascending pentatonic scale: >>> from mutwo import core_events >>> from mutwo import music_events >>> from mutwo import music_parameters >>> from mutwo import midi_converters >>> ascending_scale = core_events.Consecution( ... [ ... music_events.NoteLike(music_parameters.WesternPitch(pitch), duration=1, volume=0.5) ... for pitch in 'c d e g a'.split(' ') ... ] ... ) >>> midi_converter = midi_converters.EventToMidiFile( ... available_midi_channel_tuple=(0,) ... ) >>> # '.convert' creates a file, but also returns the >>> # respective 'mido.MidiFile' object >>> midifile = midi_converter.convert(ascending_scale, 'ascending_scale.mid') **Disclaimer:** when passing nested structures, make sure that the nested object matches the expected type. Unlike other mutwo converter classes (like :class:`mutwo.core_converters.TempoConverter`) :class:`EventToMidiFile` can't convert infinitely nested structures (due to the particular way how Midi files are defined). The deepest potential structure is a :class:`mutwo.core_events.Concurrence` (representing the complete MidiFile) that contains :class:`mutwo.core_events.Consecution` (where each ``Consecution`` represents one MidiTrack) that contains :class:`mutwo.core_events.SimpleEvent` (where each ``SimpleEvent`` represents one midi note). If only one ``Consecution`` is send, this ``Consecution`` will be read as one MidiTrack in a MidiFile. If only one ``SimpleEvent`` get passed, this ``SimpleEvent`` will be interpreted as one MidiEvent (note_on and note_off) inside one MidiTrack inside one MidiFile. """ midi_file = self._event_to_midi_file(event_to_convert) if path is not None: try: midi_file.save(filename=path) except Exception: raise AssertionError(midi_file) return midi_file