# Midi Simple Processing Library
#
import copy
import bisect
import more_itertools as moreit
from chiptunesak.base import *
from chiptunesak import mchirp
from chiptunesak import rchirp
from chiptunesak import constants
[docs]class Note:
"""
This class represents a note in human-friendly form: as a note with a start time,
a duration, and a velocity.
"""
def __init__(self, start, note, duration, velocity=100, tied_from=False, tied_to=False):
self.note_num = note #: MIDI note number
self.start_time = start #: In ticks since tick 0
self.duration = duration #: In ticks
self.velocity = velocity #: MIDI velocity 0-127
self.tied_from = tied_from #: Is the next note tied from this note?
self.tied_to = tied_to #: Is this note tied from the previous note?
def __eq__(self, other):
""" Two notes are equal when their note numbers and durations are the same """
return (self.note_num == other.note_num) and (self.duration == other.duration)
[docs] def split(self, tick_position):
"""
Splits a note into two notes at time tick_position, if the tick position falls
within the note's duration.
:param tick_position: position to split at
:type tick_position: int
:return: list with split note
:rtype: list of Note
"""
if tick_position < self.start_time or tick_position >= self.start_time + self.duration:
return [self]
else:
new_duration = self.start_time + self.duration - tick_position
new_note = Note(tick_position, self.note_num, new_duration, self.velocity, tied_to=True)
self.duration = tick_position - self.start_time
self.tied_from = True
return [n for n in [self, new_note] if n.duration > 0]
def __str__(self):
return "pit=%3d st=%4d dur=%4d vel=%4d, tfrom=%d tto=%d" \
% (self.note_num, self.start_time, self.duration, self.velocity, self.tied_from, self.tied_to)
[docs]class ChirpTrack:
"""
This class represents a track (or a voice) from a song. It is basically a list of Notes with some
other context information.
ASSUMPTION: The track contains notes for only ONE instrument (midi channel). Tracks with notes
from more than one instrument will produce undefined results.
"""
# Define the message types to preserve as a static variable
other_message_types = ['pitchwheel', 'control_change']
def __init__(self, chirp_song, mchirp_track=None):
self.chirp_song = chirp_song #: Parent song
self.name = 'none' #: Track name
self.channel = 0 #: This track's midi channel. Each track should have notes from only one channel.
self.notes = [] #: The notes in the track
self.program_changes = [] #: Program (patch) changes in the track
self.other = [] #: Other events in the track (includes voice changes and pitchwheel)
self.qticks_notes = chirp_song.qticks_notes #: Not start quantization from song
self.qticks_durations = chirp_song.qticks_durations #: Note duration quantization
if mchirp_track is not None:
if not isinstance(mchirp_track, mchirp.MChirpTrack):
raise ChiptuneSAKTypeError("ChirpTrack init can only import MChirpTrack objects")
else:
self.import_mchirp_track(mchirp_track)
[docs] def import_mchirp_track(self, mchirp_track):
"""
Imports an MChirpTrack
:param mchirp_track: track to import
:type mchirp_track: MChirpTrack
"""
def _anneal_notes(notes):
"""
This function anneals, or combines, notes that crossed measure boundaries. It's a local
function that only exists here.
"""
ret_val = []
current_note = None
for n in notes:
if current_note is not None:
assert current_note.tied_from, "Continued note should be tied from: %s" % current_note
assert n.tied_to, "Note should be tied to since last note was tied from: %s" % n
assert n.start_time == current_note.start_time + current_note.duration, "Tied notes not adjacent"
current_note.duration += n.duration
if n.tied_from:
current_note.tied_from = n.tied_from
else:
ret_val.append(current_note)
current_note = None
else:
if n.tied_from:
current_note = copy.copy(n)
else:
ret_val.append(n)
current_note = None
return ret_val
self.name = mchirp_track.name
self.channel = mchirp_track.channel
# Preserve the quantization from the MChirp
self.qticks_notes, self.qticks_durations = mchirp_track.qticks_notes, mchirp_track.qticks_durations
temp_notes = [e for m in mchirp_track.measures for e in m.events if isinstance(e, Note)]
temp_triplets = [e for m in mchirp_track.measures for e in m.events if isinstance(e, Triplet)]
temp_notes.extend([e for tp in temp_triplets for e in tp.content if isinstance(e, Note)])
self.program_changes = [e for m in mchirp_track.measures for e in m.events if isinstance(e, ProgramEvent)]
self.other = [e for m in mchirp_track.measures for e in m.events if isinstance(e, OtherMidiEvent)]
temp_notes.sort(key=lambda n: n.start_time)
self.notes = _anneal_notes(temp_notes)
self.notes.sort(key=lambda n: (n.start_time, -n.note_num))
self.program_changes.sort(key=lambda e: e.start_time)
self.other.sort(key=lambda n: n.start_time)
[docs] def estimate_quantization(self):
"""
This method estimates the optimal quantization for note starts and durations from the note
data itself. This version only uses the current track for the optimization. If the track
is a part with long notes or not much movement, I recommend using the get_quantization()
on the entire song instead. Many pieces have fairly well-defined note start spacing, but
no discernable duration quantization, so in that case the default is half the note start
quantization. These values are easily overridden.
:return: tuple of quantization values for (start, duration)
:rtype: tuple of ints
"""
tmpNotes = [n.start_time for n in self.notes]
self.qticks_notes = find_quantization(tmpNotes, self.chirp_song.metadata.ppq)
tmpNotes = [n.duration for n in self.notes]
self.qticks_durations = find_duration_quantization(tmpNotes, self.qticks_notes)
if self.qticks_durations < self.qticks_notes:
self.qticks_durations = self.qticks_notes // 2
return (self.qticks_notes, self.qticks_durations)
[docs] def quantize(self, qticks_notes=None, qticks_durations=None):
"""
This method applies quantization to both note start times and note durations. If you
want either to remain unquantized, simply specify either qticks parameter to be 1, so
that it will quantize to the nearest tick (i.e. leave everything unchanged)
:param qticks_notes: Resolution of note starts in ticks
:type qticks_notes: int
:param qticks_durations: Resolution of note durations in ticks. Also length of shortest note.
:type qticks_durations: int
"""
# Update the members to reflect the quantization applied
if qticks_notes:
self.qticks_notes = qticks_notes
if qticks_durations:
self.qticks_durations = qticks_durations
for i, n in enumerate(self.notes):
# Store the "before" values for statistics
start_before = n.start_time
duration_before = n.duration
# Quantize the start times and durations
n.start_time = quantize_fn(n.start_time, self.qticks_notes)
n.duration = quantize_fn(n.duration, self.qticks_durations)
# Never quantize a note duration to less than the minimum
if n.duration < self.qticks_durations:
n.duration = self.qticks_durations
self.notes[i] = n
# Quantize the other MIDI messages in the track
for i, m in enumerate(self.other):
self.other[i] = OtherMidiEvent(quantize_fn(m.start_time, self.qticks_notes), m.msg)
[docs] def quantize_long(self, qticks):
"""
Quantizes only notes longer than 3/4 qticks; quantizes both start time and duration.
This function is useful for quantization that also preserves some ornaments, such as
grace notes.
:param qticks: Quantization for notes and durations
:type qticks: int
"""
min_length = qticks * 3 // 4
for i, n in enumerate(self.notes):
if n.duration >= min_length:
n.start_time = quantize_fn(n.start_time, qticks)
n.duration = quantize_fn(n.duration, qticks)
self.notes[i] = n
self.notes.sort(key=lambda n: (n.start_time, -n.note_num))
[docs] def merge_notes(self, max_merge_length_ticks):
"""
Merges immediately adjacent notes if they are short and have the same note number.
:param max_merge_length_ticks: Length of the longest note to merge, in ticks
:type max_merge_length_ticks: int
"""
ret_notes = []
last = self.notes[0]
for n in self.notes[1:]:
if n.start_time == last.start_time + last.duration \
and n.note_num == last.note_num \
and n.duration <= max_merge_length_ticks:
last.duration += n.duration
continue
else:
ret_notes.append(last)
last = n
ret_notes.append(last)
self.notes = ret_notes
self.notes.sort(key=lambda n: (n.start_time, -n.note_num))
[docs] def remove_short_notes(self, max_duration_ticks):
"""
Removes notes shorter than max_duration_ticks from the track.
:param max_duration_ticks: maximum duration of notes to remove, in ticks
:type max_duration_ticks: int
"""
ret_notes = []
for n in self.notes:
if n.duration > max_duration_ticks:
ret_notes.append(n)
self.notes = ret_notes
self.notes.sort(key=lambda n: (n.start_time, -n.note_num))
[docs] def set_min_note_len(self, min_len_ticks):
"""
Sets the minimum note length for the track. Notes shorter than min_len_ticks will
be lengthened and any notes that overlap will have their start times adjusted to allow
the new longer note.
:param min_len_ticks: Minimum note length
:type min_len_ticks: int
"""
self.notes.sort(key=lambda n: (n.start_time, -n.note_num)) # Notes must be sorted
for i, n in enumerate(self.notes):
if 0 < n.duration < min_len_ticks:
n.duration = min_len_ticks
self.notes[i] = n
last_end = n.start_time + n.duration
j = i + 1
while j < len(self.notes):
if self.notes[j].start_time < last_end:
tmp_end = self.notes[j].start_time + self.notes[j].duration
self.notes[j].start_time = last_end
self.notes[j].duration = tmp_end - self.notes[j].start_time
j += 1
else:
break
self.notes = [n for n in self.notes if n.duration >= min_len_ticks]
self.notes.sort(key=lambda n: (n.start_time, -n.note_num)) # Notes must be sorted
[docs] def remove_polyphony(self):
"""
This function eliminates polyphony, so that in each channel there is only one note
active at a time. If a chord is struck all at the same time, it will retain the highest
note. Otherwise, when a new note is started, the previous note is truncated.
"""
ret_notes = []
last = self.notes[0]
for n in self.notes[1:]:
if n.start_time == last.start_time:
continue
elif n.start_time < last.start_time + last.duration:
last.duration = n.start_time - last.start_time
if last.duration > 0:
ret_notes.append(last)
last = n
ret_notes.append(last)
self.notes = ret_notes
self.notes.sort(key=lambda n: (n.start_time, -n.note_num))
[docs] def is_polyphonic(self):
"""
Returns whether the track is polyphonic; if any notes overlap it is.
:return: True if track is polyphonic.
:rtype: bool
"""
return any(b.start_time - a.start_time < a.duration for a, b in moreit.pairwise(self.notes))
[docs] def is_quantized(self):
"""
Returns whether the current track is quantized or not. Since a quantization of 1 is
equivalent to no quantization, a track quantized to tick will return False.
:return: True if the track is quantized.
:rtype: bool
"""
if self.qticks_notes < 2 or self.qticks_durations < 2:
return False
return all(n.start_time % self.qticks_notes == 0
and n.duration % self.qticks_durations == 0
for n in self.notes)
[docs] def remove_keyswitches(self, ks_max=8):
"""
Removes all MIDI notes with values less than or equal to ks_max. Some MIDI devices
and applications use these extremely low notes to convey patch change or other
information, so removing them (especially if you do not want polyphony) is a good idea.
:param ks_max: maximum note number for keyswitches in the track (often 8)
:type ks_max: int
"""
self.notes = [n for n in self.notes if n.note_num > ks_max]
[docs] def truncate(self, max_tick):
"""
Truncate the track to max_tick
:param max_tick: maximum tick number for events to start (track will play to end of
any notes started)
:type max_tick: int
"""
self.notes = [n for n in self.notes if n.start_time <= max_tick]
self.program_changes = [p for p in self.program_changes if p.start_time <= max_tick]
self.other = [e for e in self.other if e.start_time <= max_tick]
[docs] def transpose(self, semitones):
"""
Transposes track in-place by semitones, which can be positive (transpose up) or
negative (transpose down)
:param semitones: Number of semitones to transpose
"""
for i, n in enumerate(self.notes):
new_note_num = n.note_num + semitones
if 0 <= new_note_num <= 127:
self.notes[i].note_num = new_note_num
else:
self.notes[i].duration = 0 # Set duration to zero for later deletion
self.notes = [n for n in self.notes if n.duration > 0]
[docs] def modulate(self, num, denom):
"""
Modulates this track metrically by a factor of num / denom
:param num: Numerator of modulation
:param denom: Denominator of modulation
"""
f = Fraction(num, denom).limit_denominator(32)
num = f.numerator
denom = f.denominator
# Change the start times of all the "other" events
for i, (t, m) in enumerate(self.other):
t = (t * num) // denom
self.other[i] = OtherMidiEvent(t, m)
# Change all the note start times and durations
for i, n in enumerate(self.notes):
n.start_time = (n.start_time * num) // denom
n.duration = (n.duration * num) // denom
self.notes[i] = n
# Now adjust the quantizations in case quantization has been applied to reflect the
# new lengths
self.qticks_notes = (self.qticks_notes * num) // denom
self.qticks_durations = (self.qticks_durations * num) // denom
[docs] def scale_ticks(self, scale_factor):
"""
Scales the ticks for this track by scale_factor.
:param scale_factor:
"""
for i, (t, m) in enumerate(self.other):
t = int(round(t * scale_factor, 0))
self.other[i] = OtherMidiEvent(t, m)
for i, p in enumerate(self.program_changes):
t = int(round(p.start_time * scale_factor, 0))
self.program_changes[i] = ProgramEvent(t, p.program)
# Change all the note start times and durations
for i, n in enumerate(self.notes):
n.start_time = int(round(n.start_time * scale_factor, 0))
n.duration = int(round(n.duration * scale_factor, 0))
self.notes[i] = n
self.qticks_notes = int(round(self.qticks_notes * scale_factor, 0))
self.qticks_durations = int(round(self.qticks_durations * scale_factor, 0))
[docs] def move_ticks(self, offset_ticks):
"""
Moves all the events in this track by offset_ticks. Any events that would have a time
in ticks less than 0 are set to time zero.
:param offset_ticks:
:type offset_ticks: int (signed)
"""
for i, (t, m) in enumerate(self.other):
t = max(t + offset_ticks, 0)
self.other[i] = OtherMidiEvent(t, m)
for i, p in enumerate(self.program_changes):
t = max(p.start_time + offset_ticks, 0)
self.program_changes[i] = ProgramEvent(t, p.program)
# Change all the note start times and durations
for i, n in enumerate(self.notes):
n.start_time = max(n.start_time + offset_ticks, 0)
self.notes[i] = copy.copy(n)
[docs] def set_program(self, program):
'''
Sets the default program (instrument) for the track at the start and
removes any existing program changes.
:param program: program number
:type program: int
'''
self.program_changes = [ProgramEvent(0, int(program))]
def __str__(self):
ret_val = "Track: %s (channel %d)\n" % (self.name, self.channel)
return ret_val + '\n'.join(str(n) for n in self.notes)
[docs]class ChirpSong(ChiptuneSAKBase):
"""
This class represents a song. It stores notes in an intermediate representation that
approximates traditional music notation (as pitch-duration). It also stores other
information, such as time signatures and tempi, in a similar way.
"""
@classmethod
def cts_type(cls):
return 'Chirp'
def __init__(self, mchirp_song=None):
ChiptuneSAKBase.__init__(self)
self.metadata = SongMetadata()
self.metadata.ppq = constants.DEFAULT_MIDI_PPQN #: Pulses (ticks) per quarter note. Default is 960.
self.qticks_notes = self.metadata.ppq #: Quantization for note starts, in ticks
self.qticks_durations = self.metadata.ppq #: Quantization for note durations, in ticks
self.tracks = [] #: List of ChirpTrack tracks
self.other = [] #: List of all meta events that apply to the song as a whole
self.midi_meta_tracks = [] #: list of all the midi tracks that only contain metadata
self.midi_note_tracks = [] #: list of all the tracks that contain notes
self.time_signature_changes = [] #: List of time signature changes
self.key_signature_changes = [] #: List of key signature changes
self.tempo_changes = [] #: List of tempo changes
if mchirp_song is not None:
if mchirp_song.cts_type() != 'MChirp':
raise ChiptuneSAKTypeError("ChirpSong init can only import MChirpSong objects")
else:
self.import_mchirp_song(mchirp_song)
[docs] def reset_all(self):
"""
Clear all tracks and reinitialize to default values
"""
self.metadata = SongMetadata()
self.metadata.ppq = constants.DEFAULT_MIDI_PPQN #: Pulses (ticks) per quarter note.
self.qticks_notes = self.metadata.ppq #: Quantization for note starts, in ticks
self.qticks_durations = self.metadata.ppq #: Quantization for note durations, in ticks
self.tracks = [] #: List of ChirpTrack tracks
self.other = [] #: List of all meta events that apply to the song as a whole
self.midi_meta_tracks = [] #: list of all the midi tracks that only contain metadata
self.midi_note_tracks = [] #: list of all the tracks that contain notes
self.time_signature_changes = [] #: List of time signature changes
self.key_signature_changes = [] #: List of key signature changes
self.tempo_changes = [] #: List of tempo changes
[docs] def to_rchirp(self, **kwargs):
"""
Convert to RChirp. This calls the creation of an RChirp object
:return: new RChirp object
:rtype: rchirp.RChirpSong
"""
self.set_options(**kwargs)
self.set_metadata()
return rchirp.RChirpSong(self)
[docs] def to_mchirp(self, **kwargs):
"""
Convert to MChirp. This calls the creation of an MChirp object
:return: new MChirp object
:rtype: MChirpSong
"""
self.set_options(**kwargs)
self.set_metadata()
return mchirp.MChirpSong(self)
[docs] def import_mchirp_song(self, mchirp_song):
"""
Imports an MChirpSong
:param mchirp_song:
:type mchirp_song: MChirpSong
"""
self.reset_all()
for t in mchirp_song.tracks:
self.tracks.append(ChirpTrack(self, t))
self.metadata = copy.deepcopy(mchirp_song.metadata)
# Now transfer over key signature, time signature, and tempo changes
# these are stored inside measures for ALL tracks so we only have to extract them from one.
t = mchirp_song.tracks[0]
self.time_signature_changes = [e for m in t.measures for e in m.events if isinstance(e, TimeSignatureEvent)]
self.key_signature_changes = [e for m in t.measures for e in m.events if isinstance(e, KeySignatureEvent)]
self.tempo_changes = [e for m in t.measures for e in m.events if isinstance(e, TempoEvent)]
self.other = copy.deepcopy(mchirp_song.other)
self.set_metadata()
[docs] def estimate_quantization(self):
"""
This method estimates the optimal quantization for note starts and durations from the note
data itself. This version all note data in the tracks. Many pieces have no discernable
duration quantization, so in that case the default is half the note start quantization.
These values are easily overridden.
"""
tmp_notes = [n.start_time for t in self.tracks for n in t.notes]
self.qticks_notes = find_quantization(tmp_notes, self.metadata.ppq)
tmp_durations = [n.duration for t in self.tracks for n in t.notes]
self.qticks_durations = find_duration_quantization(tmp_durations, self.qticks_notes)
if self.qticks_durations < self.qticks_notes:
self.qticks_durations = self.qticks_notes // 2
return (self.qticks_notes, self.qticks_durations)
[docs] def quantize(self, qticks_notes=None, qticks_durations=None):
"""
This method applies quantization to both note start times and note durations. If you
want either to remain unquantized, simply specify a qticks parameter to be 1 (quantization
of 1 tick).
:param qticks_notes: Quantization for note starts, in MIDI ticks
:type qticks_notes: int
:param qticks_durations: Quantization for note durations, in MIDI ticks
:type qticks_durations: int
"""
if qticks_notes:
self.qticks_notes = qticks_notes
if qticks_durations:
self.qticks_durations = qticks_durations
for t in self.tracks:
t.quantize(self.qticks_notes, self.qticks_durations)
for i, m in enumerate(self.tempo_changes):
self.tempo_changes[i] = TempoEvent(quantize_fn(m.start_time, self.qticks_notes), m.qpm)
for i, m in enumerate(self.time_signature_changes):
self.time_signature_changes[i] = \
TimeSignatureEvent(quantize_fn(m.start_time, self.qticks_notes), m.num, m.denom)
for i, m in enumerate(self.key_signature_changes):
self.key_signature_changes[i] = KeySignatureEvent(quantize_fn(m.start_time, self.qticks_notes), m.key)
for i, m in enumerate(self.other):
self.other[i] = OtherMidiEvent(quantize_fn(m.start_time, self.qticks_notes), m.msg)
[docs] def quantize_from_note_name(self, min_note_duration_string, dotted_allowed=False, triplets_allowed=False):
"""
Quantize song with more user-friendly input than ticks. Allowed quantizations are the keys for the
constants.DURATION_STR dictionary. If an input contains a '.' or a '-3' the corresponding
values for dotted_allowed and triplets_allowed will be overridden.
:param min_note_duration_string: Quantization note value
:type min_note_duration_string: str
:param dotted_allowed: If true, dotted notes are allowed
:type dotted_allowed: bool
:param triplets_allowed: If true, triplets (of the specified quantization) are allowed
:type triplets_allowed: bool
"""
if '.' in min_note_duration_string:
dotted_allowed = True
min_note_duration_string = min_note_duration_string.replace('.', '')
if '-3' in min_note_duration_string:
triplets_allowed = True
min_note_duration_string = min_note_duration_string.replace('-3', '')
qticks = int(self.metadata.ppq * constants.DURATION_STR[min_note_duration_string])
if dotted_allowed:
qticks //= 2
if triplets_allowed:
qticks //= 3
self.quantize(qticks, qticks)
[docs] def is_quantized(self):
"""
Has the song been quantized? This requires that all the tracks have been quantized
with their current qticks_notes and qticks_durations values.
:return: Boolean True if all tracks in the song are quantized
"""
return all(t.is_quantized() for t in self.tracks)
[docs] def explode_polyphony(self, i_track):
"""
'Explodes' a single track into multi-track polyphony. The new tracks replace the old
track in the song's list of tracks, so later tracks will be pushed to higher indexes.
The new tracks are named using the name of the original track with '_sx' appended, where
x is a number for the split notes.
The polyphony is split using a first-available-track algorithm, which works well for splitting chords.
:param i_track: zero-based index of the track for the song (ignore the meta track - first track is 0)
:type i_track: int
"""
def _get_available_tracks(note, current_notes):
ret = []
for it, n in enumerate(current_notes):
if note.start_time >= n.start_time + n.duration:
ret.append(it)
# ret.sort(key=lambda n: (-current_notes[n].note_num))
return ret
old_track = self.tracks.pop(i_track)
old_track.notes.sort(key=lambda n: (n.start_time, -n.note_num))
new_tracks = [ChirpTrack(self)]
current_notes = [Note(0, 0, 0, 0)]
for note in old_track.notes:
possible = _get_available_tracks(note, current_notes)
if len(possible) == 0:
new_tracks.append(ChirpTrack(self))
new_tracks[-1].notes.append(note)
current_notes.append(note)
else:
it = possible[0]
new_tracks[it].notes.append(note)
current_notes[it] = note
for i, t in enumerate(new_tracks):
new_tracks[i].other = copy.deepcopy(old_track.other)
new_tracks[i].channel = old_track.channel
new_tracks[i].name = old_track.name + ' s%d' % i
for t in new_tracks[::-1]:
self.tracks.insert(i_track, t)
[docs] def remove_polyphony(self):
"""
Eliminate polyphony from all tracks.
"""
for t in self.tracks:
t.remove_polyphony()
[docs] def is_polyphonic(self):
"""
Is the song polyphonic? Returns true if ANY of the tracks contains polyphony of any kind.
:return: Boolean True if any track in the song is polyphonic
:rtype: bool
"""
return any(t.is_polyphonic() for t in self.tracks)
[docs] def remove_keyswitches(self, ks_max=8):
"""
Some MIDI programs use extremely low notes as a signaling mechanism.
This method removes notes with pitch <= ks_max from all tracks.
:param ks_max: Maximum note number for the control notes
:type ks_max: int
"""
for t in self.tracks:
t.remove_keyswitches(ks_max)
[docs] def truncate(self, max_tick):
"""
Truncate the song to max_tick
:param max_tick: maximum tick number for events to start (song will play to end of any
notes started)
:type max_tick: int
"""
self.time_signature_changes = [ts for ts in self.time_signature_changes if ts.start_time <= max_tick]
self.key_signature_changes = [ks for ks in self.key_signature_changes if ks.start_time <= max_tick]
self.tempo_changes = [t for t in self.tempo_changes if t.start_time <= max_tick]
self.other = [e for e in self.other if e.start_time <= max_tick]
for t in self.tracks:
t.truncate(max_tick)
[docs] def transpose(self, semitones, minimize_accidentals=True):
"""
Transposes the song by semitones
:param semitones: number of semitones to transpose by. Positive transposes to higher pitch.
:type semitones: int
:param minimize_accidentals: True to choose key signature to minimize number of accidentals
:type minimize_accidentals: bool
"""
# First, transpose key signatures
for ik, ks in enumerate(self.key_signature_changes):
new_key = ks.key.transpose(semitones)
if minimize_accidentals:
new_key.minimize_accidentals()
self.key_signature_changes[ik] = KeySignatureEvent(ks.start_time, new_key)
if ik == 0:
self.metadata.key_signature = self.key_signature_changes[0]
# Now transpose the tracks
for it, t in enumerate(self.tracks):
self.tracks[it].transpose(semitones)
[docs] def modulate(self, num, denom):
"""
This method performs metric modulation. It does so by multiplying the length of all notes by num/denom,
and also automatically adjusts the time signatures and tempos such that the resulting music will sound
identical to the original.
:param num: Numerator of metric modulation
:type num: int
:param denom: Denominator of metric modulation
:type denom: int
"""
f = Fraction(num, denom).limit_denominator(32)
num = f.numerator
denom = f.denominator
# First adjust the time signatures
for i, ts in enumerate(self.time_signature_changes):
# The time signature always has to be whole numbers so if the new numerator is not an integer fix that
# by multiplying by 3/2
t, n, d = ts
new_time_signature = (n * num, d * denom)
if num < denom:
if all((v % 4) == 0 for v in new_time_signature):
factor = new_time_signature[1] // 4
if all((v % factor) == 0 for v in new_time_signature):
new_time_signature = (v // factor for v in new_time_signature)
self.time_signature_changes[i] = TimeSignatureEvent((t * num) // denom, *new_time_signature)
if i == 0:
self.metadata.time_signature = self.time_signature_changes[0]
# Now the key signatures
for i, ks in enumerate(self.key_signature_changes):
# The time signature always has to be whole numbers so if the new numerator is not an integer fix that
# by multiplying by 3/2
t, k = ks
self.key_signature_changes[i] = KeySignatureEvent((t * num) // denom, k)
# Next the tempos
for i, tm in enumerate(self.tempo_changes):
t, qpm = tm
self.tempo_changes[i] = TempoEvent((t * num) // denom, (qpm * num) // denom)
# Now all the rest of the meta messages
for i, ms in enumerate(self.other):
t, m = ms
self.other[i] = OtherMidiEvent((t * num) // denom, m)
# Finally, modulate each track
for i, _ in enumerate(self.tracks):
self.tracks[i].modulate(num, denom)
# Now adjust the quantizations in case quantization has been applied to reflect the new lengths
self.qticks_notes = (self.qticks_notes * num) // denom
self.qticks_durations = (self.qticks_durations * num) // denom
# Now adjust everything to be self-consistent
self.set_metadata()
[docs] def scale_ticks(self, scale_factor):
"""
Scales the ticks for all events in the song. Multiplies the time for each event by scale_factor.
This method also changes the ppq by the scale factor.
:param scale_factor: Floating-point scale factor to multiply all events.
:type scale_factor: float
"""
self.metadata.ppq = int(round(self.metadata.ppq * scale_factor, 0))
# First adjust the time signatures
for i, ts in enumerate(self.time_signature_changes):
# The time signature always has to be whole numbers so if the new numerator is not an integer fix that
# by multiplying by 3/2
t = int(round(ts.start_time * scale_factor, 0))
self.time_signature_changes[i] = TimeSignatureEvent(t, ts.num, ts.denom)
# Now the key signature changes
for i, ks in enumerate(self.key_signature_changes):
t = int(round(ks.start_time * scale_factor, 0))
self.key_signature_changes[i] = KeySignatureEvent(t, ks.key)
# Next the tempos
for i, tm in enumerate(self.tempo_changes):
t = int(round(tm.start_time * scale_factor, 0))
self.tempo_changes[i] = TempoEvent(t, tm.qpm)
# Now all the rest of the meta messages
for i, ms in enumerate(self.other):
t = int(round(ms.start_time * scale_factor, 0))
self.other[i] = OtherMidiEvent(t, ms.msg)
# Now adjust the quantizations in case quantization has been applied to reflect the new lengths
self.qticks_notes = int(round(self.qticks_notes * scale_factor, 0))
self.qticks_durations = int(round(self.qticks_durations * scale_factor, 0))
# Finally, scale each track
for i, _ in enumerate(self.tracks):
self.tracks[i].scale_ticks(scale_factor)
[docs] def move_ticks(self, offset_ticks):
"""
Moves all notes in the song a given number of ticks. Adds the offset to the current tick for every event.
If the resulting event has a negative starting time in ticks, it is set to 0.
:param offset_ticks: Offset in ticks
:type offset_ticks: int
"""
# First adjust the time signatures
for i, ts in enumerate(self.time_signature_changes):
# The time signature always has to be whole numbers so if the new numerator is not an integer fix that
# by multiplying by 3/2
t = max(ts.start_time + offset_ticks, 0)
self.time_signature_changes[i] = TimeSignatureEvent(t, ts.num, ts.denom)
# Now the key signature changes
for i, ks in enumerate(self.key_signature_changes):
t = max(ks.start_time + offset_ticks, 0)
self.key_signature_changes[i] = KeySignatureEvent(t, ks.key)
# Next the tempos
for i, tm in enumerate(self.tempo_changes):
t = max(tm.start_time + offset_ticks, 0)
self.tempo_changes[i] = TempoEvent(t, tm.qpm)
# Now all the rest of the meta messages
for i, ms in enumerate(self.other):
t = max(ms.start_time + offset_ticks, 0)
self.other[i] = OtherMidiEvent(t, ms.msg)
# Finally, offset each track
for i, _ in enumerate(self.tracks):
self.tracks[i].move_ticks(offset_ticks)
[docs] def set_qpm(self, qpm):
"""
Sets the tempo in QPM for the entire song. Any existing tempo events will be removed.
:param qpm: quarter-notes per minute tempo
:type qpm: int
"""
self.metadata.qpm = qpm
self.tempo_changes = [TempoEvent(0, qpm)]
[docs] def set_time_signature(self, num, denom):
"""
Sets the time signature for the entire song. Any existing time signature changes will be removed.
:param num:
:type num:
:param denom:
:type num:
"""
self.time_signature_changes = [TimeSignatureEvent(0, num, denom)]
[docs] def set_key_signature(self, new_key):
"""
Sets the key signature for the entire song. Any existing key signatures and changes will be removed.
:param new_key: Key signature. String such as 'A#' or 'Abm'
:type new_key: str
"""
self.key_signature_changes = [KeySignatureEvent(0, key.ChirpKey(new_key))]
[docs] def end_time(self):
"""
Finds the end time of the last note in the song.
:return: Time (in ticks) of the end of the last note in the song.
:rtype: int
"""
return max(n.start_time + n.duration for t in self.tracks for n in t.notes)
[docs] def measure_starts(self):
"""
Returns the starting time for measures in the song. Calculated using time_signature_changes.
:return: List of measure starting times in MIDI ticks
:rtype: list
"""
return [m.start_time for m in self.measures_and_beats() if m.beat == 1]
[docs] def measures_and_beats(self):
"""
Returns the positions of all measures and beats in the song. Calculated using time_signature_changes.
:return: List of MeasureBeat objects for each beat of the song.
:rtype: list
"""
measures = []
max_time = self.end_time()
time_signature_changes = sorted(self.time_signature_changes)
if len(time_signature_changes) == 0 or time_signature_changes[0].start_time != 0:
raise ChiptuneSAKValueError("No starting time signature")
last = time_signature_changes[0]
t, m, b = 0, 1, 1
for s in time_signature_changes:
while t < s.start_time:
measures.append(Beat(t, m, b))
t += (self.metadata.ppq * 4) // last.denom
b += 1
if b > last.num:
m += 1
b = 1
last = s
while t <= max_time:
measures.append(Beat(t, m, b))
t += (self.metadata.ppq * 4) // last.denom
b += 1
if b > last.num:
m += 1
b = 1
return measures
[docs] def get_measure_beat(self, time_in_ticks):
"""
This method returns a (measure, beat) tuple for a given time; the time is greater than or
equal to the returned measure and beat but less than the next. The result should be
interpreted as the time being during the measure and beat returned.
:param time_in_ticks: Time during the song, in MIDI ticks
:type time_in_ticks: int
:return: MeasureBeat object with the current measure and beat
:rtype: MeasureBeat
"""
measure_beats = self.measures_and_beats()
# Make a list of start times from the list of measure-beat times.
tmp = [m.start_time for m in measure_beats]
# Find the index of the desired time in the list.
pos = bisect.bisect_right(tmp, time_in_ticks)
# Return the corresponding measure/beat
return measure_beats[pos - 1]
[docs] def get_active_time_signature(self, time_in_ticks):
"""
Get the active time signature at a given time (in ticks) during the song.
:param time_in_ticks: Time during the song, in MIDI ticks
:type time_in_ticks: int
:return: Active time signature at the time
:rtype: TimeSignatureChange
"""
itime = 0
if len(self.time_signature_changes) == 0 or self.time_signature_changes[0].start_time != 0:
raise ChiptuneSAKValueError("No starting time signature")
n_time_signature_changes = len(self.time_signature_changes)
while itime < n_time_signature_changes and self.time_signature_changes[itime].start_time < time_in_ticks:
itime += 1
return self.time_signature_changes[itime - 1]
[docs] def get_active_key_signature(self, time_in_ticks):
"""
Get the active key signature at a given time (in ticks) during the song.
:param time_in_ticks: Time during the song, in MIDI ticks
:type time_in_ticks: int
:return: Key signature active at the time
:rtype: KeySignatureChange
"""
ikey = 0
if len(self.key_signature_changes) == 0 or self.key_signature_changes[0].start_time != 0:
raise ChiptuneSAKValueError("No starting time signature")
n_key_signature_changes = len(self.key_signature_changes)
while ikey < n_key_signature_changes and self.key_signature_changes[ikey].start_time < time_in_ticks:
ikey += 1
return self.key_signature_changes[ikey - 1]
# --------------------------------------------------------------------------------------
#
# Utility functions
#
# --------------------------------------------------------------------------------------
def quantization_error(t_ticks, q_ticks):
"""
Calculate the error, in ticks, for the given time for a quantization of q ticks.
:param t_ticks: time in ticks
:type t_ticks: int
:param q_ticks: quantization in ticks
:type q_ticks: int
:return: quantization error, in ticks
:rtype: int
"""
j = t_ticks // q_ticks
return int(min(abs(t_ticks - q_ticks * j), abs(t_ticks - q_ticks * (j + 1))))
def objective_error(note_start_times, test_quantization):
"""
This is the objective function for getting the error for the entire set of notes for a
given quantization in ticks. The function used here could be a sum, RMS, or other
statistic, but empirical tests indicate that the max used here works well and is robust.
:param note_start_times: note start times in ticks
:type note_start_times: list of int
:param test_quantization: test quantization, in ticks
:type test_quantization: int
:return: objective error function value
:rtype: int
"""
return max(quantization_error(n, test_quantization) for n in note_start_times)
[docs]def find_quantization(time_series, ppq):
"""
Find the optimal quantization in ticks to use for a given set of times. The algorithm given
here is by no means universal or guaranteed, but it usually gives a sensible answer.
The algorithm works as follows:
- Starting with quarter notes, obtain the error from quantization of the entire set of times.
- Then obtain the error from quantization by 2/3 that value (i.e. triplets).
- Then go to the next power of two (e.g. 8th notes, a6th notes, etc.) and repeat
A minimum in quantization error will be observed at the "right" quantization. In either case
above, the next quantization tested will be incommensurate (either a factor of 2/3 or a factor
of 3/4) which will make the quantization error worse.
Thus, the first minimum that appears will be the correct value.
The algorithm does not seem to work as well for note durations as it does for note starts, probably
because performed music rarely has clean note cutoffs.
:param time_series: a series times, usually note start times, in ticks
:type time_series: list of int
:param ppq: ppq value (ticks per quarter note)
:type ppq: int
:return: quantization in ticks
:rtype: int
"""
last_err = len(time_series) * ppq
last_q = ppq
note_value = 4
while note_value <= 128: # We have arbitrarily chosen 128th notes as the fastest possible
test_quantization = ppq * 4 // note_value
e = objective_error(time_series, test_quantization)
# print(test_quantization, e) # This was useful for observing the behavior of real-world music
if e == 0: # Perfect quantization! We are done.
return test_quantization
# If this is worse than the last one, the last one was the right one.
elif e > last_err:
return last_q
last_q = test_quantization
last_err = e
# Now test the quantization for triplets of the current note value.
test_quantization = test_quantization * 2 // 3
e = objective_error(time_series, test_quantization)
# print(test_quantization, e) # This was useful for observing the behavior of real-world music
if e == 0: # Perfect quantization! We are done.
return test_quantization
# If this is worse than the last one, the last one was the right one.
elif e > last_err:
return last_q
last_q = test_quantization
last_err = e
# Try the next power of two
note_value *= 2
return 1 # Return a 1 for failed quantization means 1 tick resolution
[docs]def find_duration_quantization(durations, qticks_note):
"""
The duration quantization is determined from the shortest note length.
The algorithm starts from the estimated quantization for note starts.
:param durations: durations from which to estimate quantization
:type durations: list of int
:param qticks_note: quantization already determined for note start times
:type qticks_note: int
:return: estimated duration quantization, in ticks
:rtype: int
"""
min_length = min(durations)
if not (min_length > 0):
raise ChiptuneSAKQuantizationError("Illegal minimum note length (%d)" % min_length)
current_q = qticks_note
ratio = min_length / current_q
while ratio < 0.9:
# Try a triplet
tmp_q = current_q
current_q = current_q * 3 // 2
ratio = min_length / current_q
if ratio > 0.9:
break
current_q = tmp_q // 2
ratio = min_length / current_q
return current_q
[docs]def quantize_fn(t, qticks):
"""
This function quantizes a time or duration to a certain number of ticks. It snaps to the
nearest quantized value.
:param t: a start time or duration, in ticks
:type t: int
:param qticks: quantization in ticks
:type qticks: int
:return: quantized start time or duration
:rtype: int
"""
current = t // qticks
next = current + 1
current *= qticks
next *= qticks
if abs(t - current) <= abs(next - t):
return current
else:
return next