Files
S.P.L.U.R.T-Station-13/tools/midi2piano/midi2piano.py
2022-08-03 14:04:40 +08:00

309 lines
9.3 KiB
Python

"""
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 = 1000
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()