import sys
import mido
from chiptunesak.base import *
from chiptunesak.chirp import Note, ChirpTrack, ChirpSong
def sort_midi_events(msg):
if msg.type == 'note_off':
return (msg.time, 9)
elif msg.type == 'note_on':
return (msg.time, 10)
elif msg.type == 'program_change':
return (msg.time, 5)
elif msg.type == 'track_name':
return (msg.time, 0)
else:
return (msg.time, 7)
[docs]class MIDI(ChiptuneSAKIO):
"""
Import/Export MIDI files to and from Chirp songs.
The Chirp format is most closely tied to the MIDI standard. As a result, conversion between MIDI
files and ChirpSong objects is one of the most common ways to import and export music using the
ChiptuneSAK framework.
The MIDI class does not implement the standard to_bin() method because it uses the `mido`_ library to
process low-level midi messages, and mido only deals with MIDI files.
The Chirp framework can import both MIDI type 0 and type 1 files. It will only write MIDI type 1 files.
.. _mido: https://mido.readthedocs.io/en/latest/
"""
@classmethod
def cts_type(cls):
return "MIDI"
def __init__(self):
ChiptuneSAKIO.__init__(self)
self.midi_song = mido.MidiFile()
[docs] def to_chirp(self, filename, **kwargs):
"""
Import a midi file to Chirp format
:param filename: filename to import
:type filename: str
:return: chirp song
:rtype: ChirpSong
:keyword options:
* **keyswitch** (bool) Remove keyswitch notes with midi number <=8 (default True)
* **polyphony** (bool) Allow polyphony (removal occurs after any quantization) (default True)
* **quantize** (str)
- 'auto': automatically determines required quantization
- '8', '16', '32', etc. : quantize to the named duration
"""
self.set_options(**kwargs)
return self.import_midi_to_chirp(filename)
[docs] def to_file(self, song, filename, **kwargs):
"""
Exports a ChirpSong to a midi file.
:param song: chirp song
:type song: chirpSong
:param filename: filename for export
:type filename: str
:return: True on success
:rtype: bool
"""
self.set_options(**kwargs)
return self.export_chirp_to_midi(song, filename)
[docs] def midi_track_to_chirp_track(self, chirp_song, midi_track):
"""
Parse a MIDI track into notes, track name, and program changes. This method uses the `mido`
library for MIDI messges within the track.
:param midi_track: midi track
:type midi_track: MIDO midi track
"""
chirp_track = ChirpTrack(chirp_song)
# Find the first note_on event and use its channel to set the channel for this track.
ch_msg = next((msg for msg in midi_track if msg.type == 'note_on'), None)
if ch_msg:
chirp_track.channel = ch_msg.channel
chirp_track.name = 'Channel %d' % chirp_track.channel
# Find the name meta message to get the track's name. Default is the channel.
name_msg = next((msg for msg in midi_track if msg.type == 'track_name'), None)
if name_msg:
if len(name_msg.name.strip()) > 0:
chirp_track.name = name_msg.name.strip()
# Convert Midi events in the track into notes and durations
current_time = 0
current_notes_on = {}
chirp_track.notes = [] # list of notes
chirp_track.other = [] # list of other things int the track, such as patch changes or pitchwheel
channels = set()
for msg in midi_track:
current_time += msg.time
if not msg.is_meta:
# Keep track of unique channels for non-meta messages
channels.add(msg.channel)
# Some MIDI devices use a note_on with velocity of 0 to turn notes off.
if msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
# If this note is not in our dictionary of notes that are on, ignore the note_off
if msg.note in current_notes_on:
current_note = current_notes_on[msg.note]
start = current_note.start_time
delta = current_time - start
if delta > 0:
current_note.duration = delta
chirp_track.notes.append(current_note)
elif delta < 0:
raise ChiptuneSAKValueError("Error in MIDI import: Illegal note length %d" % delta)
# Remove the note from the dictionary of notes that are on.
del current_notes_on[msg.note]
elif msg.type == 'note_on':
# Keep a dictionary of all notes that are currently on
if msg.note not in current_notes_on:
current_notes_on[msg.note] = Note(current_time, msg.note, 0, msg.velocity)
# Program changes get their own list
elif msg.type == 'program_change':
chirp_track.program_changes.append(ProgramEvent(current_time, int(msg.program)))
elif msg.is_meta and msg.type == 'track_name':
chirp_track.name = msg.name.strip()
# Other messages of interest in the track are stored in a separate list as native MIDI messages
elif msg.is_meta or (msg.type in ChirpTrack.other_message_types):
chirp_track.other.append(OtherMidiEvent(current_time, msg))
# Turn off any notes left on
for n in current_notes_on:
start = current_notes_on[n].start_time
delta = current_time - start
if delta > 0:
current_notes_on[n].duration = delta
chirp_track.notes.append(current_notes_on[n])
# Check that there was only one channel used in the track
if len(channels) > 1:
raise ChiptuneSAKException('Non-unique channel for track: %d channels in track %s'
% (len(channels), chirp_track.name))
# Now sort the notes by the time they turn on. They were inserted into the list in
# the order they were turned off. To do the sort, take advatage of automatic sorting of tuples.
chirp_track.notes.sort(key=lambda n: (n.start_time, -n.note_num))
chirp_track.program_changes.sort(key=lambda n: n.start_time)
return chirp_track
[docs] def import_midi_to_chirp(self, input_filename):
"""
Open and import a MIDI file into the ChirpSong representation. THis method can handle MIDI type 0 and 1 files.
:param input_filename: MIDI filename.
"""
chirp_song = ChirpSong()
# Clear everything
chirp_song.reset_all()
# Open the midi file using the Python mido library
in_midi = mido.MidiFile(input_filename)
chirp_song.metadata.ppq = in_midi.ticks_per_beat # Pulses Per Quarter Note (usually 480, but Sibelius uses 960)
# If MIDI file is not a Type 0 or 1 file, barf
if int(in_midi.type) > 1:
print("Error: Midi type %d detected. Only midi type 0 and 1 files supported." % (in_midi.type),
file=sys.stderr)
sys.exit(1)
# Parse and process the MIDI file into tracks
# if this is a MIDI type 0 file, then there will only be one track with all the data in it.
if in_midi.type == 0:
# Splits into tracks: track 0 (metadata), and tracks 1-16 are note data.
in_midi = self.split_midi_zero_into_tracks(in_midi)
# Process meta commands in ALL tracks
chirp_song.time_signature_changes = []
chirp_song.key_signature_changes = []
midi_meta_tracks = []
for i, track in enumerate(in_midi.tracks):
if i == 0:
midi_meta_tracks.append(track)
chirp_song = self.get_meta(chirp_song, track, True if i == 0 else False, True)
else:
chirp_song = self.get_meta(chirp_song, track, False, False)
# Sort all time changes from meta tracks into a single time signature change list
chirp_song.time_signature_changes = sorted(chirp_song.time_signature_changes)
chirp_song.key_signature_changes = sorted(chirp_song.key_signature_changes)
chirp_song.tempo_changes = sorted(chirp_song.tempo_changes)
# Find all tracks that contain notes
midi_note_tracks = [t for t in in_midi.tracks if sum(1 for m in t if m.type == 'note_on') > 0]
# Now generate the note tracks
for track in midi_note_tracks:
chirp_track = self.midi_track_to_chirp_track(chirp_song, track)
chirp_song.tracks.append(chirp_track)
if self.get_option('keyswitch', True):
chirp_song.remove_keyswitches(ks_max=8)
q_type = self.get_option('quantization', None)
if q_type is not None:
if q_type == 'auto':
chirp_song.quantize(*chirp_song.estimate_quantization())
elif isinstance(q_type, int) or all(c.isdigit() for c in q_type):
chirp_song.quantize_from_note_name(str(q_type))
if not self.get_option('polyphony', 'True'):
chirp_song.remove_polyphony()
return chirp_song
[docs] def split_midi_zero_into_tracks(self, midi_song):
"""
For MIDI Type 0 files, split the notes into tracks. To accomplish this, we
move the metadata into Track 0 and then assign tracks 1-16 to the note data.
"""
last_times = [0 for i in range(17)]
tracks = [mido.MidiTrack() for i in range(17)]
current_time = 0
for msg in midi_song.tracks[0]:
current_time += msg.time
# Move all the meta messages into a single track. Midi type 0 files should not
# contain any track-specific meta-messages, so this is safe.
if msg.is_meta:
msg.time = current_time - last_times[0]
last_times[0] = current_time
tracks[0].append(msg)
# All other messages get assigned to tracks based on their channel.
elif msg.type != 'sysex':
ch = msg.channel + 1
msg.time = current_time - last_times[ch]
last_times[ch] = current_time
tracks[ch].append(msg)
midi_song.type = 1 # Change the midi type for the mido object to Type 1
# Eliminate tracks that have no events in them.
midi_song.tracks = [t for t in tracks if len(t) > 0]
return midi_song
[docs] def chirp_track_to_midi_track(self, chirp_track):
"""
Convert ChirpTrack to a midi track.
"""
midiTrack = mido.MidiTrack()
events = [mido.MetaMessage('track_name', name=chirp_track.name, time=0)]
for n in chirp_track.notes:
# For the sake of sorting, create the midi event with the absolute time (which will be
# changed to a delta time before returning).
if n.note_num < 0 or n.note_num > 127:
print(n.note_num)
events.append(mido.Message('note_on',
note=n.note_num, channel=chirp_track.channel,
velocity=n.velocity, time=n.start_time))
events.append(mido.Message('note_off',
note=n.note_num, channel=chirp_track.channel,
velocity=0, time=n.start_time + n.duration))
for t, program in chirp_track.program_changes:
events.append(mido.Message('program_change',
channel=chirp_track.channel, program=program, time=t))
for t, msg in chirp_track.other:
msg.time = t
events.append(msg)
# Because 'note_off' comes before 'note_on' this sort will keep note_off events before
# note_on events.
events.sort(key=sort_midi_events)
last_time = 0
# Turn the absolute times into delta times.
for msg in events:
current_time = msg.time
msg.time -= last_time
midiTrack.append(msg)
last_time = current_time
return midiTrack
[docs] def export_chirp_to_midi(self, chirp_song, output_filename):
"""
Exports the song to a MIDI Type 1 file. Exporting to the midi format is privileged because this class
is tied to many midi concepts and uses midid messages explicitly for some content.
"""
if chirp_song.cts_type() != 'Chirp':
raise ChiptuneSAKNotImplemented("Only ChirpSong objects can be exported to midi")
out_midi_file = mido.MidiFile(ticks_per_beat=chirp_song.metadata.ppq)
out_midi_file.tracks.append(self.meta_to_midi_track(chirp_song))
for t in chirp_song.tracks:
out_midi_file.tracks.append(self.chirp_track_to_midi_track(t))
out_midi_file.save(output_filename)
return True