""" This module allows user to convert MIDI melodies to SS13 sheet music ready for copy-and-paste """ from functools import reduce import midi as mi import easygui as egui import pyperclip as pclip LINE_LENGTH_LIM = 50 LINES_LIMIT = 200 TICK_LAG = 0.5 OVERALL_IMPORT_LIM = 2*LINE_LENGTH_LIM*LINES_LIMIT END_OF_LINE_CHAR = """ """ # BYOND can't parse \n and I am forced to define my own NEWLINE char OCTAVE_TRANSPOSE = 0 # Change here to transpose melodies by octaves FLOAT_PRECISION = 2 # Change here to allow more or less numbers after dot in floats OCTAVE_KEYS = 12 HIGHEST_OCTAVE = 8 time_quanta = 100 * TICK_LAG """ class Meta(): version = 1.0 integer = 1 anti_integer = -1 maximum = 1000 epsilon = 0.51 delta_epsilon = -0.1 integral = [] tensor = [[],[],[]] o_complexity = epsilon**2 random_variance = 0.01 """ # UTILITY FUNCTIONS def condition(event): """ This function check if given MIDI event is meaningful """ if event[0] == 'track_name' and event[2] == 'Drums': # Percussion return False if event[0] == 'note': # Only thing that matters return True return False def notenum2string(num, accidentals, octaves): """ This function converts given notenum to SS13 note according to previous runs expressed using _accidentals_ and _octaves_ """ names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] convert_table = {1:0, 3:1, 6:2, 8:3, 10:4} inclusion_table = {0:0, 2:1, 5:2, 7:3, 9:4} num += OCTAVE_KEYS * OCTAVE_TRANSPOSE octave = int(num / OCTAVE_KEYS) if octave < 1 or octave > HIGHEST_OCTAVE: return ["", accidentals, octaves] accidentals = accidentals.copy() octaves = octaves.copy() output_octaves = list(octaves) name_indx = num % OCTAVE_KEYS accidental = (len(names[name_indx]) == 2) output_octaves[name_indx] = octave add_n = False if accidental: accidentals[convert_table[name_indx]] = True else: if name_indx in inclusion_table: add_n = accidentals[inclusion_table[name_indx]] accidentals[inclusion_table[name_indx]] = False return [ ( names[name_indx]+ ("n" if add_n else "")+ str((octave if octave != octaves[name_indx] else "")) ), accidentals, output_octaves ] def dur2mod(dur, bpm_mod=1.0): """ This functions returns float representation of duration ready to be added to the note after / """ mod = bpm_mod / dur mod = round(mod, FLOAT_PRECISION) return str(mod).rstrip('0').rstrip('.') # END OF UTILITY FUNCTIONS # CONVERSION FUNCTIONS def obtain_midi_file(): """ Asks user to select MIDI and returns this file opened in binary mode for reading """ file = egui.fileopenbox(msg='Choose MIDI file to convert', title='MIDI file selection', filetypes=[['*.mid', 'MID files']]) if not file: return None file = open(file, mode='rb').read() return file def midi2score_without_ticks(midi_file): """ Transforms aforementioned file into a score, truncates it and returns it """ opus = mi.midi2opus(midi_file) opus = mi.to_millisecs(opus) score = mi.opus2score(opus) return score[1:] # Ticks don't matter anymore, it is always 1000 def filter_events_from_score(score): """ Filters out irrevelant events and returns new score """ return list(map( # For each score track lambda score_track: list(filter( # Filter irrevelant events condition, score_track )), score )) def filter_empty_tracks(score): """ Filters out empty tracks and returns new score """ return list(filter( lambda score_track: score_track, score)) def filter_start_time_and_note_num(score): """ Recreates score with only note numbers and start time of each note and returns new score """ return list(map( lambda score_track: list(map( lambda event: [event[1], event[4]], score_track)), score)) def merge_events(score): """Merges all tracks together and returns new score""" return list(reduce( lambda lst1, lst2: lst1+lst2, score)) def sort_score_by_event_times(score): """Sorts events by start time and returns new score""" return list(map( lambda index: score[index], sorted( list(range(len(score))), key=lambda indx: score[indx][0]) )) def convert_into_delta_times(score): """ Transform start_time into delta_time and returns new score """ return list(map( lambda super_event: ( [ super_event[1][0]-super_event[0][0], super_event[0][1] ]), # [ [1, 2], [3, 4] ] -> [ [2, 2] ] zip(score[:-1], score[1:]) # Shifted association. [1, 2, 3] -> [ (1, 2), (2, 3) ] ))+[[1000, score[-1][1]]] # Add 1 second note to the end def perform_roundation(score): """ Rounds delta times to the nearest multiple of time quanta as BYOND can't process duration less than that and returns new score """ return list(map( lambda event: [time_quanta*round(event[0]/time_quanta), event[1]], score)) def obtain_common_duration(score): """ Returns the most frequent duration throughout the whole melody """ # Parse durations and filter out 0s durs = list(filter(lambda x: x, list(map(lambda event: event[0], score)))) unique_durs = [] for dur in durs: if dur not in unique_durs: unique_durs.append(dur) # How many such durations occur throughout the melody? counter = [durs.count(dur) for dur in unique_durs] highest_counter = max(counter) # Highest counter dur_n_count = list(zip(durs, counter)) dur_n_count = list(filter(lambda e: e[1] == highest_counter, dur_n_count)) return dur_n_count[0][0] # Will be there def reduce_score_to_chords(score): """ Reforms score into a chord-duration list: [[chord_notes], duration_of_chord] and returns it """ new_score = [] new_chord = [[], 0] # [ [chord notes], duration of chord ] for event in score: new_chord[0].append(event[1]) # Append new note to the chord if event[0] == 0: continue # Add new notes to the chord until non-zero duration is hit new_chord[1] = event[0] # This is the duration of chord new_score.append(new_chord) # Append chord to the list new_chord = [[], 0] # Reset the chord return new_score def obtain_sheet_music(score, most_frequent_dur): """ Returns unformated sheet music from score """ result = "" octaves = [3 for i in range(12)] accidentals = [False for i in range(7)] for event in score: for note_indx in range(len(event[0])): data = notenum2string(event[0][note_indx], accidentals, octaves) result += data[0] accidentals = data[1] octaves = data[2] if note_indx != len(event[0])-1: result += '-' if event[1] != most_frequent_dur: # Quarters are default result += '/' result += dur2mod(event[1], most_frequent_dur) result += ',' return result def explode_sheet_music(sheet_music): """ Splits unformatted sheet music into formated lines of LINE_LEN_LIM and such and returns a list of such lines """ split_music = sheet_music.split(',') split_music = list(map(lambda note: note+',', split_music)) split_list = [] counter = 0 line_counter = 1 for note in split_music: if line_counter > LINES_LIMIT-1: break if counter+len(note) > LINE_LENGTH_LIM-2: split_list[-1] = split_list[-1].rstrip(',') split_list[-1] += END_OF_LINE_CHAR counter = 0 line_counter += 1 split_list.append(note) counter += len(note) return split_list def finalize_sheet_music(split_music, most_frequent_dur): """ Recreates sheet music from exploded sheet music, truncates it and returns it """ sheet_music = "" for note in split_music: sheet_music += note sheet_music = sheet_music.rstrip(',') # Trim the last , sheet_music = "BPM: " + str(int(60000 / most_frequent_dur)) + END_OF_LINE_CHAR + sheet_music return sheet_music[:min(len(sheet_music), OVERALL_IMPORT_LIM)] # END OF CONVERSION FUNCTIONS def main_cycle(): """ Activate the script """ while True: midi_file = obtain_midi_file() if not midi_file: return # Cancel score = midi2score_without_ticks(midi_file) score = filter_events_from_score(score) score = filter_start_time_and_note_num(score) score = filter_empty_tracks(score) score = merge_events(score) score = sort_score_by_event_times(score) score = convert_into_delta_times(score) score = perform_roundation(score) most_frequent_dur = obtain_common_duration(score) score = reduce_score_to_chords(score) sheet_music = obtain_sheet_music(score, most_frequent_dur) split_music = explode_sheet_music(sheet_music) sheet_music = finalize_sheet_music(split_music, most_frequent_dur) pclip.copy(sheet_music) main_cycle()