diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..e8cc575 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*~ +*.pyc +midi/ +output/ diff --git a/Animator.py b/Animator.py old mode 100644 new mode 100755 diff --git a/ConvertColor.py b/ConvertColor.py old mode 100644 new mode 100755 diff --git a/MidiLexer.py b/MidiLexer.py old mode 100644 new mode 100755 diff --git a/MusAnimLauncher.py b/MusAnimLauncher.py new file mode 100755 index 0000000..fcd2ddc --- /dev/null +++ b/MusAnimLauncher.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +from MusAnimRenderer import MusAnimRenderer +from MusAnimLexer import MidiLexer +import sys,os.path + +PITCH_GRACE=5 +TRACK_WIDTH=24 + +def main(): + tracks = [ + { 'name': "track0", + 'color': (0.000, 0.996, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track1", + 'color': (0.996, 0.000, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track2", + 'color': (0.004, 0.996, 0.992), + 'width': TRACK_WIDTH, + }, + { 'name': "track3", + 'color': (0.996, 0.855, 0.398), + 'width': TRACK_WIDTH, + }, + { 'name': "track4", + 'color': (0.563, 0.980, 0.570), + 'width': TRACK_WIDTH, + }, + { 'name': "track5", + 'color': (0.000, 0.461, 0.996), + 'width': TRACK_WIDTH, + }, + { 'name': "track6", + 'color': (0.832, 0.996, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track7", + 'color': (0.996, 0.574, 0.492), + 'width': TRACK_WIDTH, + }, + { 'name': "track8", + 'color': (0.992, 0.535, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track9", + 'color': (0.520, 0.660, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track10", + 'color': (0.000, 0.680, 0.492), + 'width': TRACK_WIDTH, + }, + { 'name': "track11", + 'color': (0.738, 0.773, 0.996), + 'width': TRACK_WIDTH, + }, + { 'name': "track12", + 'color': (0.738, 0.824, 0.574), + 'width': TRACK_WIDTH, + }, + { 'name': "track13", + 'color': (0.000, 0.723, 0.090), + 'width': TRACK_WIDTH, + }, + { 'name': "track14", + 'color': (0.004, 0.813, 0.996), + 'width': TRACK_WIDTH, + }, + { 'name': "track15", + 'color': (0.566, 0.813, 0.793), + 'width': TRACK_WIDTH, + }, + { 'name': "track16", + 'color': (0.730, 0.531, 0.000), + 'width': TRACK_WIDTH, + }, + { 'name': "track17", + 'color': (0.867, 0.996, 0.453), + 'width': TRACK_WIDTH, + }, + { 'name': "track18", + 'color': (0.000, 0.996, 0.773), + 'width': TRACK_WIDTH, + }, + { 'name': "track19", + 'color': (0.996, 0.895, 0.008), + 'width': TRACK_WIDTH, + }, + { 'name': "track20", + 'color': (0.594, 0.996, 0.320), + 'width': TRACK_WIDTH, + }, + { 'name': "track21", + 'color': (0.000, 0.996, 0.469), + 'width': TRACK_WIDTH, + }, + { 'name': "track22", + 'color': (0.996, 0.430, 0.254), + 'width': TRACK_WIDTH, + }, + { 'name': "track23", + 'color': (0.645, 0.996, 0.820), + 'width': TRACK_WIDTH, + }, + { 'name': "track24", + 'color': (0.996, 0.691, 0.402), + 'width': TRACK_WIDTH, + }, + { 'name': "track25", + 'color': (0.000, 0.605, 0.996), + 'width': TRACK_WIDTH, + }, + ] + + ntracks=len(tracks) + for i in range(ntracks): + tracks[i]['z-index']=ntracks-i + + if len(sys.argv)<3: + print("Usage: python MusAnimLauncher.py input.mid outputDirectory [--dynamic]") + sys.exit(1) + return + + mid=sys.argv[1] + + lexer = MidiLexer() + lexer.lex(mid) + + frames_dir = sys.argv[2]+os.sep + if not os.path.isdir(frames_dir): + os.makedirs(frames_dir) + + dynamic=len(sys.argv)>=4 and sys.argv[3]=='--dynamic' + + speed_map = [{'time': 0.0, 'speed': 4}] + + dimensions = 1920, 1080 + #dimensions = 720, 480 + #dimensions = 426, 240 + + fps = 25 + + renderer=MusAnimRenderer() + renderer.introduction=False + renderer.render(mid,frames_dir,tracks,speed_map=speed_map,dimensions=dimensions,min_pitch=lexer.minPitch-PITCH_GRACE,max_pitch=lexer.maxPitch+PITCH_GRACE,fps=fps,dynamicmode=dynamic) + +if __name__ == '__main__': + main() diff --git a/MusAnimLexer.py b/MusAnimLexer.py old mode 100644 new mode 100755 index 15ef1b4..e086267 --- a/MusAnimLexer.py +++ b/MusAnimLexer.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import sys class MidiLexer: @@ -7,6 +8,11 @@ class MidiLexer: # beats, without making any pretenses about figuring out timing in seconds. # That has to be done later, once we have all the timing events sorted midi_events = [] + minPitch = sys.maxsize + maxPitch = -sys.maxsize-1 + + def debug(self, event): + print('Unknown event: ' + bin(event) + " (" + hex(event) + ")") def get_v_time(self, data): """Picks off the variable-length time from a block of data and returns @@ -27,11 +33,12 @@ def read_midi_event(self, track_data, time, track_num): # have to read off vtime first! d_time, track_data = self.get_v_time(track_data) time += d_time + #print time + event=((ord(track_data[0]) & 0xF0) >> 4) if track_data[0] == '\xff': # event is meta event, we do nothing unless it's a tempo event if ord(track_data[1]) == 0x51: - #print track_num, list(track_data) # tempo event mpqn = ((ord(track_data[3]) << 16) + (ord(track_data[4]) << 8) + ord(track_data[5])) # microseconds per quarter note @@ -44,7 +51,7 @@ def read_midi_event(self, track_data, time, track_num): return track_data[length+3:], time # otherwise we we assume it's a midi event - elif ((ord(track_data[0]) & 0xF0) >> 4) == 0x8: + elif event == 0x8: # note off event # don't add a note off event if keyswitch (pitch below 12) pitch = ord(track_data[1]) @@ -53,7 +60,7 @@ def read_midi_event(self, track_data, time, track_num): 'pitch': pitch, 'track_num': track_num}) return track_data[3:], time - elif ((ord(track_data[0]) & 0xF0) >> 4) == 0x9: + elif event == 0x9: # note on event pitch = ord(track_data[1]) if pitch < 12: # it's a keyswitch! @@ -62,19 +69,30 @@ def read_midi_event(self, track_data, time, track_num): elif pitch == 1: mode = "pizz" else: - raise Exception("Unknown keyswitch") - self.midi_events.append({'type': 'keyswitch', 'time': time, - 'track_num': track_num, 'mode': mode}) + print(("Unknown keyswitch: "+str(pitch))) + pitch=False + if pitch != False: + self.midi_events.append({'type': 'keyswitch', 'time': time, + 'track_num': track_num, 'mode': mode}) else: + if pitch > self.maxPitch: + self.maxPitch = pitch + if pitch < self.minPitch: + self.minPitch = pitch self.midi_events.append({'type': 'note_on', 'time': time, 'pitch': ord(track_data[1]), 'track_num': track_num}) return track_data[3:], time - elif ((ord(track_data[0]) & 0xF0) >> 4) == 0xC: + elif event == 0xC: return track_data[2:], time # ignore some other events - elif ((ord(track_data[0]) & 0xF0) >> 4) == 0xB: + elif event == 0xB: return track_data[3:], time + #kek + elif event == 0xF or event == 0xD: + self.debug(event) + return track_data[2:], time else: - raise Exception("Unknown midi file data event: " + str(ord(track_data[0]))) + self.debug(event) + return track_data[3:], time def lex(self, filename): """Returns block list for musanim from a midi file given in filename""" @@ -88,12 +106,12 @@ def lex(self, filename): # open and read file f = open(filename, 'rb') - s = f.read() + s = f.read().decode('latin-1') # grab header header = s[0:14] f_format = ord(s[8]) << 8 | ord(s[9]) - num_tracks = ord(s[10]) << 8 | ord(s[11]) + self.num_tracks = ord(s[10]) << 8 | ord(s[11]) self.ticks_per_quarter = ord(s[12]) << 8 | ord(s[13]) tracks_chunk = s[14:] @@ -110,13 +128,13 @@ def lex(self, filename): # convert all times from ticks to beats, for convenience for event in self.midi_events: - event['time'] = (event['time'] + 0.0) / 960 + event['time'] = (event['time'] + 0.0) / self.ticks_per_quarter #960 - self.midi_events.sort(lambda a, b: cmp(a['time'], b['time'])) + self.midi_events.sort(key=lambda a: a['time']) return self.midi_events if __name__ == '__main__': lexer = MidiLexer() blocks = lexer.lex('multitrackmidi01.MID') - print blocks \ No newline at end of file + print(blocks) diff --git a/MusAnimRenderer.py b/MusAnimRenderer.py old mode 100644 new mode 100755 index 4c383f8..60d9a5a --- a/MusAnimRenderer.py +++ b/MusAnimRenderer.py @@ -1,350 +1,391 @@ -import os -import sys -import colorsys -import cairo -import math -from collections import deque -from MusAnimLexer import MidiLexer - -class MusAnimRenderer: - def lyrics_deque(self, lyrics): - """Turns lyrics as a string into a lyrics deque, splitting by spaces and - removing newlines.""" - lyrics = lyrics.replace("\n", " ") - lyrics_list = lyrics.split(" ") - lyrics_list2 = [] - for word in lyrics_list: - """ - if word and word[-1] == '-': - word = word[0:-1] + ' -'""" - lyrics_list2.append(word) - return deque(lyrics_list2) - - def blockify(self, midi_events): - """Converts list of midi events given by the lexer into block data used - for animating.""" - blocks = [] - bpm = 120.0 - time_seconds = 0 - time_beats = 0 - tracks_mode = ['normal'] * 100 - for event in midi_events: - # increment times based on elapsed time in beats - d_time_beats = event['time'] - time_beats - time_seconds += (d_time_beats * 60.0) / bpm - time_beats = event['time'] - if event['type'] == 'tempo': # set tempo - bpm = event['bpm'] - elif event['type'] == 'note_on': # create new block in list - blocks.append({'start_time': time_seconds, 'pitch': - event['pitch'], 'track_num': event['track_num']}) - # set shape of last block to bar or circle - if tracks_mode[event['track_num']] == 'normal': - blocks[-1]['shape'] = 'bar' - elif tracks_mode[event['track_num']] == 'pizz': - blocks[-1]['shape'] = 'circle' - else: - raise Exception('Unknown track mode') - elif event['type'] == 'note_off': # add end_time to existing block - pitch = event['pitch'] - track_num = event['track_num'] - blocks_w_pitch = [block for block in blocks - if block['pitch'] == pitch and block['track_num'] - == track_num and 'end_time' not in block] - assert(blocks_w_pitch) # assume it has at least one element - # otherwise we have a faulty midi file! - blocks_w_pitch[0]['end_time'] = time_seconds - elif event['type'] == 'keyswitch': - tracks_mode[event['track_num']] = event['mode'] - else: - raise Exception('Unknown midi event') - return blocks - - def add_block_info(self, blocks, tracks, fps, speed_map, dimensions, - min_pitch, max_pitch): - """Adds essential information to each block dict in blocks, also returns - last_block_end to tell when animation is over""" - # need: start_time (seconds), end_time (seconds), pitch, track_num for - # each block - last_block_end = 0 - cur_speed = self.get_speed(speed_map, 0.0) - - for block in blocks: - # get track object that corresponds to block - track = tracks[block['track_num']] - block['width'] = track['width'] # set width - # get speed and calculate x offset from functions - cur_speed = self.get_speed(speed_map, block['start_time']) - x_offset = self.calc_offset(speed_map, block['start_time'], fps) - block['start_x'] = x_offset + dimensions[0] - # length of the block in time (time it stays highlighted) - block['length'] = block['end_time'] - block['start_time'] + 0.0 - # if a circle, length is same as width, otherwise length - # corresponds to time length - if block['shape'] == 'circle': - block['x_length'] = block['width'] - else: - block['x_length'] = block['length'] * fps * cur_speed - block['end_x'] = block['start_x'] + block['x_length'] - # set last_block_end as the end_x of the very rightmost block - if block['end_x'] > last_block_end: - last_block_end = block['end_x'] - # figure out draw coordinates - y_middle = ((0.0 + max_pitch - block['pitch']) / (max_pitch - - min_pitch)) * dimensions[1] - block['top_y'] = y_middle - (block['width'] / 2) - block['bottom_y'] = y_middle + (block['width'] / 2) - if 'z-index' not in track: - track['z-index'] = 0 - block['z-index'] = track['z-index'] - if 'layer' not in track: - track['layer'] = 0 - block['layer'] = track['layer'] - # round stuff for crisp rendering - #block['x_length'] = round(block['x_length']) - block['top_y'] = round(block['top_y']) - #block['start_x'] = round(block['start_x']) - - # sort by track_num so we get proper melisma length counting - blocks.sort(lambda a, b: cmp(a['track_num'], b['track_num'])) - - # can't add lyrics until we add in end_x for all blocks - block_num = 0 - for block in blocks: - track_num = block['track_num'] - track = tracks[block['track_num']] - if 'lyrics' in track and track['lyrics'][0]: - lyrics_text = track['lyrics'][0] - if lyrics_text[0] == '^': - lyrics_text = lyrics_text[1:] - block['lyrics_position'] = 'above' - elif lyrics_text[0] == '_': - lyrics_text = lyrics_text[1:] - block['lyrics_position'] = 'below' - else: - block['lyrics_position'] = 'middle' - - if track['lyrics'][0] != '*': - # for detecting melismas (* in lyrics text) - i = 0 - while (len(track['lyrics']) > (i + 1) - and track['lyrics'][i+1] == '*'): - i += 1 - block['lyrics_end_x'] = blocks[block_num+i]['end_x'] - block['lyrics'] = lyrics_text - - track['lyrics'].popleft() - - block_num += 1 - - # go back to sorting by start time - blocks.sort(lambda a, b: cmp(a['start_time'], b['start_time'])) - - return blocks, last_block_end - - def calc_offset(self, speed_map, time_offset, fps): - """Calculates the x-offset of a block given its time offset and a speed - map. Needed for laying out blocks because of variable block speed in the - animation.""" - x_offset = 0 - i = 0 - # speed is a dict with a speed and a time when we switch to speed - speeds = ([speed for speed in speed_map if speed['time'] < time_offset] - [0:-1]) - # add offsets from previous speed intervals - if speeds: - for speed in speeds: - x_offset += ((speed_map[i+1]['time'] - speed_map[i]['time']) - * fps * speed_map[i]['speed']) - i += 1 - # add offset from current speed - if time_offset > 0: - x_offset += ((time_offset - speed_map[i]['time']) * fps - * speed_map[i]['speed']) - return x_offset - - def get_speed(self, speed_map, time): - """Retrieves the correct block speed for a given point in time from the - speed map.""" - i = len(speed_map) - 1 - while time < speed_map[i]['time'] and i > 0: - i -= 1 - return speed_map[i]['speed'] - - def draw_block_cairo(self, block, tracks, dimensions, cr, transparent=False): - if block['start_x'] < (dimensions[0] / 2) and (block['end_x'] > - (dimensions[0] / 2)): - color = tracks[block['track_num']]['high_color'] - else: - color = tracks[block['track_num']]['color'] - if transparent: - r, g, b = color - cr.set_source_rgba(r, g, b, 0.5) - else: - cr.set_source_rgb(*color) - if block['shape'] == 'circle': - cr.arc(block['start_x'] + block['width']/2, block['top_y'] + block['width']/2, block['width']/2, 0, 2 * math.pi) - else: - cr.rectangle(block['start_x'], block['top_y'], block['x_length'], - block['width']) - cr.fill() - - def draw_lyrics_cairo(self, block, tracks, dimensions, cr): - cr.set_font_size(1.9*block['width']) - text = block['lyrics'] - x_bearing, y_bearing, width, height = cr.text_extents(text)[:4] - cr.set_source_rgba(0, 0, 0, 0.5) - if block['lyrics_position'] == 'above': - rect = (block['start_x'], int(block['top_y'])-0.7*block['width'], width + 2, block['width']+1) - elif block['lyrics_position'] == 'below': - rect = (block['start_x'], int(block['top_y'])+0.7*block['width'], width + 2, block['width']+1) - else: - rect = (block['start_x'], int(block['top_y']), width + 2, block['width']+1) - cr.rectangle(*rect) - cr.fill() - if block['start_x'] < (dimensions[0] / 2) and (block['lyrics_end_x'] > - (dimensions[0] / 2)): - color = (1, 1, 1) - else: - color = tracks[block['track_num']]['lyrics_color'] - cr.set_source_rgb(*color) - if block['lyrics_position'] == 'above': - corner = (block['start_x'] + 1, block['top_y']+0.18*block['width']) - elif block['lyrics_position'] == 'below': - corner = (block['start_x'] + 1, block['top_y']+1.58*block['width']) - else: - corner = (block['start_x'] + 1, block['top_y']+0.88*block['width']) - cr.move_to(*corner) - cr.show_text(text) - - def render(self, input_midi_filename, frame_save_dir, tracks, speed_map=[{'time':0.0,'speed':4}], - dimensions=(720,480), fps=29.97, min_pitch=34, max_pitch=86, first_frame=None, - last_frame=None, every_nth_frame=1, do_render=1): - """Render the animation!""" - - print "Beginning render..." - speed = speed_map[0]['speed'] - if first_frame == None: - first_frame = 0 - if last_frame == None: - last_frame = 10000000 # just a large number - - print "Lexing midi..." - blocks = [] - lexer = MidiLexer() - midi_events = lexer.lex(input_midi_filename) - - print "Blockifying midi..." - blocks = self.blockify(midi_events) # convert into list of blocks - - for track in tracks: - if 'color' in track: - base_color = colorsys.rgb_to_hls(*track['color']) - track['high_color'] = colorsys.hls_to_rgb(base_color[0], 0.95, - base_color[2]) - track['lyrics_color'] = colorsys.hls_to_rgb(base_color[0], 0.7, - base_color[2]) - if 'lyrics' in track: - track['lyrics'] = self.lyrics_deque(track['lyrics']) - - # do some useful calculations on all blocks - blocks, last_block_end = self.add_block_info(blocks, tracks, - fps, speed_map, dimensions, min_pitch, max_pitch) - - # following used for calculating percentage done to print to console - original_end = last_block_end - percent = 0 - last_percent = -1 - - # sort by z-index descending - blocks.sort(lambda a, b: cmp(b['z-index'], a['z-index'])) - - # for naming image files: - frame = 0 - # for keeping track of speed changes: - # need to initialize time - time = -dimensions[0]/(2.0*fps*speed_map[0]['speed']) - - if not do_render: - print "Skipping render pass, Done!" - return - - print "Rendering frames..." - # generate frames while there are blocks on the screen: - while last_block_end > (0 - speed): - # code only for rendering blocks - if frame >= first_frame and frame <= last_frame and frame % every_nth_frame == 0: - # cairo setup stuff - filename = frame_save_dir + ("frame%05i.png" % frame) - surface = cairo.ImageSurface(cairo.FORMAT_RGB24, *dimensions) - #surface = cairo.SVGSurface(filename, *dimensions) - cr = cairo.Context(surface) - cr.set_antialias(cairo.ANTIALIAS_GRAY) - cr.select_font_face("Garamond", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) - cr.set_font_size(19) - # add black background - cr.set_source_rgb(0, 0, 0) - cr.rectangle(0, 0, *dimensions) - cr.fill() - - # need to do two passes of drawing blocks, once in reverse order - # in full opacity, and a second time in ascending order in half- - # opacityto get fully-colored bars that blend together when - # overlapping - - # get list of blocks that are on screen - on_screen_blocks = [block for block in blocks - if block['start_x'] < dimensions[0] and block['end_x'] > 0] - on_screen_layers = list(set([block['layer'] for block in on_screen_blocks])) - - for layer in on_screen_layers: - layer_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *dimensions) - layer_context = cairo.Context(layer_surface) - - in_layer_blocks = [block for block in on_screen_blocks if block['layer'] == layer] - - # do first drawing pass - for block in in_layer_blocks: - self.draw_block_cairo(block, tracks, dimensions, layer_context) - - # do second drawing pass - on_screen_blocks.reverse() - for block in in_layer_blocks: - self.draw_block_cairo(block, tracks, dimensions, layer_context, transparent=True) - - cr.set_source_surface(layer_surface) - cr.paint() - - # do lyrics pass, sort by start x so starts of words are on top - on_screen_blocks.sort(lambda a, b: cmp(a['start_x'], b['start_x'])) - for block in on_screen_blocks: - if ('lyrics' in block): - self.draw_lyrics_cairo(block, tracks, dimensions, cr) - - #cr.save() - surface.write_to_png(filename) - - # other code needed to advance animation - frame += 1 - # need to set speed - speed = self.get_speed(speed_map, time) - for block in blocks: # move blocks to left - block['start_x'] -= speed - block['end_x'] -= speed - if 'lyrics_end_x' in block: - block['lyrics_end_x'] -= speed - last_block_end -= speed # move video endpoint left as well - percent = int(min((original_end - last_block_end) * 100.0 - / original_end, 100)) - if percent != last_percent: - print percent, "% done" - last_percent = percent - - time += (1/fps) - - print "Done!" - - -if __name__ == '__main__': - print ("Sorry, I don't really do anything useful as an executable, see " - "RunAnim.py for usage") \ No newline at end of file +# -*- coding: utf-8 -*- +import os +import sys +import colorsys +import cairo +import math +from collections import deque +from MusAnimLexer import MidiLexer + +class MusAnimRenderer: + first_highlight = False + + def lyrics_deque(self, lyrics): + """Turns lyrics as a string into a lyrics deque, splitting by spaces and + removing newlines.""" + lyrics = lyrics.replace("\n", " ") + lyrics_list = lyrics.split(" ") + lyrics_list2 = [] + for word in lyrics_list: + """ + if word and word[-1] == '-': + word = word[0:-1] + ' -'""" + lyrics_list2.append(word) + return deque(lyrics_list2) + + def blockify(self, midi_events): + """Converts list of midi events given by the lexer into block data used + for animating.""" + blocks = [] + bpm = 120.0 + time_seconds = 0 + time_beats = 0 + tracks_mode = ['normal'] * 100 + for event in midi_events: + # increment times based on elapsed time in beats + d_time_beats = event['time'] - time_beats + time_seconds += (d_time_beats * 60.0) / bpm + time_beats = event['time'] + if event['type'] == 'tempo': # set tempo + bpm = event['bpm'] + elif event['type'] == 'note_on': # create new block in list + blocks.append({'start_time': time_seconds, 'pitch': + event['pitch'], 'track_num': event['track_num']}) + # set shape of last block to bar or circle + if tracks_mode[event['track_num']] == 'normal': + blocks[-1]['shape'] = 'bar' + elif tracks_mode[event['track_num']] == 'pizz': + blocks[-1]['shape'] = 'circle' + else: + raise Exception('Unknown track mode') + elif event['type'] == 'note_off': # add end_time to existing block + pitch = event['pitch'] + track_num = event['track_num'] + blocks_w_pitch = [block for block in blocks + if block['pitch'] == pitch and block['track_num'] + == track_num and 'end_time' not in block] + assert(blocks_w_pitch) # assume it has at least one element + # otherwise we have a faulty midi file! + blocks_w_pitch[0]['end_time'] = time_seconds + elif event['type'] == 'keyswitch': + tracks_mode[event['track_num']] = event['mode'] + else: + raise Exception('Unknown midi event') + return blocks + + def add_block_info(self, blocks, tracks, fps, speed_map, dimensions, + min_pitch, max_pitch): + """Adds essential information to each block dict in blocks, also returns last_block_end to tell when animation is over""" + # need: start_time (seconds), end_time (seconds), pitch, track_num for + # each block + last_block_end = 0 + cur_speed = self.get_speed(speed_map, 0.0) + + for block in blocks: + # get track object that corresponds to block + track = tracks[block['track_num']] + block['width'] = track['width'] # set width + # get speed and calculate x offset from functions + cur_speed = self.get_speed(speed_map, block['start_time']) + x_offset = self.calc_offset(speed_map, block['start_time'], fps) + block['start_x'] = x_offset + dimensions[0] + # length of the block in time (time it stays highlighted) + block['length'] = block['end_time'] - block['start_time'] + 0.0 + # if a circle, length is same as width, otherwise length + # corresponds to time length + if block['shape'] == 'circle': + block['x_length'] = block['width'] + else: + block['x_length'] = block['length'] * fps * cur_speed + block['end_x'] = block['start_x'] + block['x_length'] + # set last_block_end as the end_x of the very rightmost block + if block['end_x'] > last_block_end: + last_block_end = block['end_x'] + # figure out draw coordinates + y_middle = ((0.0 + max_pitch - block['pitch']) / (max_pitch - + min_pitch)) * dimensions[1] + block['top_y'] = y_middle - (block['width'] / 2) + block['bottom_y'] = y_middle + (block['width'] / 2) + if 'z-index' not in track: + track['z-index'] = 0 + block['z-index'] = track['z-index'] + if 'layer' not in track: + track['layer'] = 0 + block['layer'] = track['layer'] + # round stuff for crisp rendering + #block['x_length'] = round(block['x_length']) + block['top_y'] = round(block['top_y']) + #block['start_x'] = round(block['start_x']) + + # sort by track_num so we get proper melisma length counting + blocks.sort(key=lambda a: a['track_num']) + + # can't add lyrics until we add in end_x for all blocks + block_num = 0 + for block in blocks: + track_num = block['track_num'] + track = tracks[block['track_num']] + if 'lyrics' in track and track['lyrics'][0]: + lyrics_text = track['lyrics'][0] + if lyrics_text[0] == '^': + lyrics_text = lyrics_text[1:] + block['lyrics_position'] = 'above' + elif lyrics_text[0] == '_': + lyrics_text = lyrics_text[1:] + block['lyrics_position'] = 'below' + else: + block['lyrics_position'] = 'middle' + + if track['lyrics'][0] != '*': + # for detecting melismas (* in lyrics text) + i = 0 + while (len(track['lyrics']) > (i + 1) + and track['lyrics'][i+1] == '*'): + i += 1 + block['lyrics_end_x'] = blocks[block_num+i]['end_x'] + block['lyrics'] = lyrics_text + + track['lyrics'].popleft() + + block_num += 1 + + # go back to sorting by start time + blocks.sort(key=lambda a: a['start_time']) + + return blocks, last_block_end + + def calc_offset(self, speed_map, time_offset, fps): + """Calculates the x-offset of a block given its time offset and a speed map. Needed for laying out blocks because of variable block speed in the animation.""" + x_offset = 0 + i = 0 + # speed is a dict with a speed and a time when we switch to speed + speeds = ([speed for speed in speed_map if speed['time'] < time_offset] + [0:-1]) + # add offsets from previous speed intervals + if speeds: + for speed in speeds: + x_offset += ((speed_map[i+1]['time'] - speed_map[i]['time']) + * fps * speed_map[i]['speed']) + i += 1 + # add offset from current speed + if time_offset > 0: + x_offset += ((time_offset - speed_map[i]['time']) * fps + * speed_map[i]['speed']) + return x_offset + + def get_speed(self, speed_map, time): + """Retrieves the correct block speed for a given point in time from the + speed map.""" + i = len(speed_map) - 1 + while time < speed_map[i]['time'] and i > 0: + i -= 1 + return speed_map[i]['speed'] + + def draw_block_cairo(self, block, tracks, dimensions, cr, transparent=False): + middle=dimensions[0] / 2 + start=block['start_x'] + if start< middle and block['end_x'] > middle: + color = tracks[block['track_num']]['color' if self.dynamicmode else 'high_color'] + self.first_highlight = True + else: + color = tracks[block['track_num']]['color'] + transparent=transparent or self.dynamicmode + if transparent: + alpha=0.5 + if self.dynamicmode: + alpha=block['end_x']#block position + alpha=2*(middle-alpha)#distance from middle + alpha=1-(alpha/middle)#percentage + alpha=alpha/3.0#force past blocks to be at most 33% opaque + cr.set_source_rgba(color[0], color[1], color[2], alpha) + else: + cr.set_source_rgb(*color) + if block['shape'] == 'circle': + halfwidth=block['width']/2 + cr.arc(start + halfwidth, block['top_y'] + halfwidth, halfwidth, 0, 2 * math.pi) + else: + cr.rectangle(start, block['top_y'], block['x_length'], + block['width']) + cr.fill() + + def draw_lyrics_cairo(self, block, tracks, dimensions, cr): + cr.set_font_size(1.9*block['width']) + text = block['lyrics'] + x_bearing, y_bearing, width, height = cr.text_extents(text)[:4] + cr.set_source_rgba(0, 0, 0, 0.5) + if block['lyrics_position'] == 'above': + rect = (block['start_x'], int(block['top_y'])-0.7*block['width'], width + 2, block['width']+1) + elif block['lyrics_position'] == 'below': + rect = (block['start_x'], int(block['top_y'])+0.7*block['width'], width + 2, block['width']+1) + else: + rect = (block['start_x'], int(block['top_y']), width + 2, block['width']+1) + cr.rectangle(*rect) + cr.fill() + if block['start_x'] < (dimensions[0] / 2) and (block['lyrics_end_x'] > + (dimensions[0] / 2)): + color = (1, 1, 1) + else: + color = tracks[block['track_num']]['lyrics_color'] + cr.set_source_rgb(*color) + if block['lyrics_position'] == 'above': + corner = (block['start_x'] + 1, block['top_y']+0.18*block['width']) + elif block['lyrics_position'] == 'below': + corner = (block['start_x'] + 1, block['top_y']+1.58*block['width']) + else: + corner = (block['start_x'] + 1, block['top_y']+0.88*block['width']) + cr.move_to(*corner) + cr.show_text(text) + + speed_map=[{'time':0.0,'speed':4}] + width=720 + height=480 + fps=29.97 + min_pitch=34 + max_pitch=86 + first_frame=None + last_frame=None + every_nth_frame=1 + do_render=1 + introduction=True + + def render(self, input_midi_filename, frame_save_dir, tracks, speed_map=speed_map,dimensions=(width, height), fps=fps, min_pitch=min_pitch, max_pitch=max_pitch, first_frame=first_frame,last_frame=last_frame, every_nth_frame=every_nth_frame, do_render=do_render,dynamicmode=False): + self.dynamicmode=dynamicmode + """Render the animation!""" + print("Beginning render...") + speed = speed_map[0]['speed'] + if first_frame == None: + first_frame = 0 + if last_frame == None: + last_frame = 10000000 # just a large number + + print("Lexing midi...") + blocks = [] + lexer = MidiLexer() + midi_events = lexer.lex(input_midi_filename) + + print("Blockifying midi...") + blocks = self.blockify(midi_events) # convert into list of blocks + print(str(len(blocks))+" blocks") + + for track in tracks: + if 'color' in track: + base_color = colorsys.rgb_to_hls(*track['color']) + track['high_color'] = colorsys.hls_to_rgb(base_color[0], 0.95, + base_color[2]) + track['lyrics_color'] = colorsys.hls_to_rgb(base_color[0], 0.7, + base_color[2]) + if 'lyrics' in track: + track['lyrics'] = self.lyrics_deque(track['lyrics']) + + # do some useful calculations on all blocks + blocks, last_block_end = self.add_block_info(blocks, tracks, + fps, speed_map, dimensions, min_pitch, max_pitch) + + # following used for calculating percentage done to print to console + original_end = last_block_end + percent = 0 + last_percent = -1 + + # sort by z-index descending + blocks.sort(key=lambda a: a['z-index'],reverse=True) + + # for naming image files: + frame = 0 + framefile = 0 + # for keeping track of speed changes: + # need to initialize time + time = -dimensions[0]/(2.0*fps*speed_map[0]['speed']) + + if not do_render: + print("Skipping render pass, Done!") + return + + print("Rendering frames...") + # generate frames while there are blocks on the screen: + while last_block_end > -speed: + # code only for rendering blocks + if first_frame < frame and frame <= last_frame and frame % every_nth_frame == 0: + # cairo setup stuff + filename = frame_save_dir + ("frame%05i.png" % framefile) + surface = cairo.ImageSurface(cairo.FORMAT_RGB24, *dimensions) + cr = cairo.Context(surface) + cr.set_antialias(cairo.ANTIALIAS_GRAY) + cr.select_font_face("Garamond", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(19) + # add black background + cr.set_source_rgb(0, 0, 0) + cr.rectangle(0, 0, *dimensions) + cr.fill() + + '''need to do two passes of drawing blocks, once in reverse order in full opacity, and a second time in ascending order in half-opacity to get fully-colored bars that blend together when overlapping''' + + # get list of blocks that are on screen + on_screen_blocks = [block for block in blocks + if block['start_x'] < dimensions[0]] + + for layer in {block['layer'] for block in on_screen_blocks}: + layer_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *dimensions) + layer_context = cairo.Context(layer_surface) + + in_layer_blocks = [block for block in on_screen_blocks if block['layer'] == layer] + + # do first drawing pass + for block in in_layer_blocks: + block=self.makedynamic(block, dimensions) + if block==None: + continue + self.draw_block_cairo(block, tracks, dimensions, layer_context) + + # do second drawing pass + on_screen_blocks.reverse() + for block in in_layer_blocks: + block=self.makedynamic(block, dimensions) + if block==None: + continue + self.draw_block_cairo(block, tracks, dimensions, layer_context, transparent=True) + + cr.set_source_surface(layer_surface) + cr.paint() + + # do lyrics pass, sort by start x so starts of words are on top + on_screen_blocks.sort(key=lambda a: a['start_x']) + for block in on_screen_blocks: + if ('lyrics' in block): + self.draw_lyrics_cairo(block, tracks, dimensions, cr) + + if self.introduction or self.first_highlight: + framefile += 1 + surface.write_to_png(filename) + + # other code needed to advance animation + frame += 1 + # need to set speed + speed = self.get_speed(speed_map, time) + for block in list(blocks): # move blocks to left + end=block['end_x']-speed + if end<0: + '''trash blocks no longer needed. benchmark shows this causes a 2% increase in processing time in medium-sized files (~1000 blocks) but up to 15% redution in big files (~10000 blocks)''' + blocks.remove(block) + continue + block['end_x'] = end + block['start_x'] -= speed + if 'lyrics_end_x' in block: + block['lyrics_end_x'] -= speed + last_block_end -= speed # move video endpoint left as well + percent = min(int((original_end - last_block_end) * 100.0 + / original_end), 100) + if percent != last_percent: + print(percent, "% done") + last_percent = percent + + time += (1/fps) + + print("Done!") + + + def makedynamic(self, block, dimensions): + if self.dynamicmode: + middle=dimensions[0] / 2 + if block['start_x'] > middle: + return None + if block['end_x'] > middle: + block=block.copy() + shorten=block['end_x']-(middle+1) + block['end_x']-=shorten + block['x_length']-=shorten + return block + return block + +if __name__ == '__main__': + print ("Sorry, I don't really do anything useful as an executable, see " + "RunAnim.py for usage") diff --git a/README.md b/README.md new file mode 100755 index 0000000..2607b31 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ + +#PyMusAnim + +A clone of Stephen Malinowski's Music Animation Machine in Python. + +This fork extends PyMusAnim so that Linux users can easily output a MPG video given an input MIDI file, using a command-line tool. + +The original Music Animaion Machine and it's open source clone, PyMusAnim, are awesome. Unfortunately though the original project was never open source and ran poorly, on Windows and couldn't create video files by itself. PyMusAnim, using a totally different approach, doesn't suffer from performance issues - but it's orginally more of a proof of concept than a functioning tool. + +## Dependencies + +You need those external programs installed on your system for this fork to work: + + * Python 2 https://www.python.org/ + * Timidity http://timidity.sourceforge.net/ + * FFMpeg https://ffmpeg.org/ + * MuseScore (optional) https://musescore.com/ + +On Debian and Ubuntu you can fulfill these requirementes by running the following command: `sudo apt-get install ffmpeg timidity python2.6 musescore` + +## How it works + +The core of PyMusAnim is virtually unchanged, the only difference being that instead of having to create your own Python configuration files the new module MusAnimLauncher does that for you (while still being configurable). This module is automatically called from two Linux command line (bash) utilities: + + ./pymusanim.sh file.mid outputdirectory [--dynamic] + +Use this to create a video. For example: `pymusanim.sh mysong.mid mysong` will create a MPG file of `mysong.mid` inside the directory `mysong`. + + ./batch.sh midisDirectory mode [threadsLimit] + +Use this one if you want to create several videos at once. Since PyMusAnim is single-threaded this will let you take advantage of a multi-core CPU if you have one (and you probably do). `midisDirectory` is the directory you have your MIDI files on and `threadsLimit` is an optional argument to explicitally set the number of threads to use (if not set the program will use all available processors). For each MIDI file a sub-directoy will be created inside the `output` folder, which will be created if it doesn't exist. For example: `batch.sh mymidis/` + +Remember to run all these commands from the project's root directory. + +## Dynamic mode + +This fork adds a new rendering mode that can be activated by passing the argument --dynamic as the last argument to `MusAnimLauncher.py` or `pymusanim.sh`. Note that when running `batch.sh` you'll always need to inform which mode you're using (either `--classic` or `--dynamic`). + +This mode tries to mimic somewhat the "shapes mode" of the original Music Animation Machine and focuses more on enhancing what's being played at each moment than on having a wider view of the composition being played. + +## MuseScore + +MuseScore is a great composition tool and offers a few featuress that work very well with PyMusAnim - namely being able to convert unsupported MIDI files into a supported format and also the ability to render them as audio files with higher quality than other tools. It is suggested that you install MuseScore in your system and, if you do then PyMusAnim will take full advantage of it when possible. Having it installed should help you automatically circumvent the issues detailed below. + +## Issues + +### A note about PyMusAnim and MIDI files + +Unfortunately PyMusAnim is pretty bad at reading MIDI files in weird formats. I would go as far as to say that it's unable to read most MIDI files found on the web. The good news is that as long as the file is well formatted it will work perfectly. + +Well... How is this even good news then? + +I have found that if I have trouble opening a MIDI file then I can use TuxGuitar to import that file and then export it again. This way the exported MIDI file will most likely work with PyMusAnim. TuxGuitar even has a batch conversion tool (under the Tools menu) that you can use to import and export many MIDI files at once automatically, making this a very fast if somewhat bothersome step. + +I haven't tried but other programs with MIDI import and export features could potentially work for this as well. + +### FreePats + +FreePats is the libre soundbank that is used by Timidity and other Linux MIDI software. Despite doing a great job of allowing you to play most MIDI files for free, it can be a bit quirky and unfortunately isn't updated very often. You can see some hacks around its shortcomings on the 'freepats' directory. If you happen to know of any others hacks or good alternative libre soundbanks, let me know! + +## Links + +Original PyMusAnim: https://github.com/zhanrnl/PyMusAnim + +PyMusAnim on YouTube: https://www.youtube.com/user/PyMusAnim + +Original Music Animation Machine by Stephen Malinowski: http://www.musanim.com/player/ + +Malinowski on YouTube https://www.youtube.com/user/smalin + +TuxGuitar: http://tuxguitar.herac.com.ar/ (also available via `sudo apt-get install tuxguitar` ) + +FreePats http://freepats.zenvoid.org/ + +## To do + +* Improve support for reading all formats of MIDI files (this would be huge, if you think you can help please contact me!) +* Make PyMusAnim run on Python 3 too (should be pretty easy) +* A simple visual interface (GUI) so that it would be even easier to create PyMusAnim videos +* There is a issue with file paths containing whitespace not loading properly +* There may be an issue in batch mode with Timidity blocking the virtual MIDI device, rendering some videos silent but it doesn't seem to happen often diff --git a/RunBeethoven74Anim.py b/RunBeethoven74Anim.py old mode 100644 new mode 100755 diff --git a/RunGouldAnim.py b/RunGouldAnim.py old mode 100644 new mode 100755 diff --git a/RunGouldAnim1080.py b/RunGouldAnim1080.py old mode 100644 new mode 100755 diff --git a/RunShosti10Anim.py b/RunShosti10Anim.py old mode 100644 new mode 100755 diff --git a/batch.sh b/batch.sh new file mode 100755 index 0000000..3630451 --- /dev/null +++ b/batch.sh @@ -0,0 +1,41 @@ +#!/bin/bash +#Converts a whole directory of MIDI files in parallel +OUT_DIR=output/ + +shopt -s nocaseglob +trap 'kill 0' EXIT +if [ ! $1 ]; then + echo "Usage: batch.sh midisDirectory mode [threadsLimit]" + exit 1 +fi +if [ ! $2 ]; then + echo "Please inform a mode: either --classic or --dynamic" + exit 1 +fi +mkdir $OUT_DIR +files=`ls -1 $1/*.mid*&>/dev/null` +if [ ! $? == 0 ]; then + echo "No files found, aborting." + exit +fi +for m in $1/*.mid*; do #call pymusanim if there is an idle thread + name=`basename $m .${m#*.}` + nice -n19 ./pymusanim.sh $m $OUT_DIR$name $2 &> $OUT_DIR${name}.log& + echo "Starting $name" + threadsLimit=$3 + if [ ! $threadsLimit ]; then + threadsLimit=`cat /proc/cpuinfo | grep processor | wc -l` + fi + while [ `jobs|grep Running|wc -l` == $threadsLimit ]; do + sleep 1 + done +done +echo "Waiting for last processes to finish..." +while [ `jobs|grep Running|wc -l` != 0 ]; do #wait until end + sleep 1 +done +echo "Cleaning..." +wait +echo "Done!" +first=1 +./checklogs.sh $OUT_DIR diff --git a/checklogs.sh b/checklogs.sh new file mode 100755 index 0000000..5fffb8c --- /dev/null +++ b/checklogs.sh @@ -0,0 +1,21 @@ +#!/bin/bash +#Check batch.sh logs. Comment/uncomment/add lines to your needs. +if [ ! $1 ]; then + echo "Usage: checklogs.sh logsDirectory" + exit 1 +fi + +out='' +for log in $1/*.log; do + error="`cat $log|grep -e"Aborting pymusanim.sh" -e"this instrument will not be heard" -e"Drum set .* is undefined"`" + if [ "$error" ]; then + #echo "$log: `echo $error|tail -n1`" #shows filename and first error (default, leave as only uncommented line upon commit) + + echo -e "${error}\n${log}\n\n`cat $log|grep -v '% done'|perl -ne '$H{$_}++ or print'`"|less #analyze each log showing error and filename as header and cleaning progress and duplicate lines + + #out=`echo -e "${out}\n${error}"|sort -u` #list each individual error + fi +done +if [ "$out" ]; then + echo "$out" +fi \ No newline at end of file diff --git a/freepats/FillFreepats.py b/freepats/FillFreepats.py new file mode 100755 index 0000000..8cea391 --- /dev/null +++ b/freepats/FillFreepats.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +#fills missing freepats.cfg tones with existing one from same group +import sys + +GROUP_START=[0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120] + +def addLine(line): + print line + " #filled" + +def write(line): + sys.stdout.write(line) + +def getTone(split): + return split[1][:-1] + +def fillLine(number,file): + addLine(' ' + str(number) + '\t' + file) + +class FillFreepats: + process=False + tone=0 + last=False + + def nextTone(self): + self.tone+=1 + if self.tone in GROUP_START: + self.last=False + print + + def readLine(self,line): + if line=="\n": + return + if not (line.startswith('dir') or line.startswith('#')): + if line.startswith('bank'): + self.process=True + elif self.process: + split=line.split('\t') + lineTone=split[0].lstrip() + if str(self.tone)!=lineTone: + while self.tonemid 2> /dev/null + echo python3 MusAnimLauncher.py $converted $2 $3 #TODO + python3 MusAnimLauncher.py $converted $2 $3 #second try + if ! [ -e "$2/frame00000.png" ]; then exit 1; fi #second try failed + fi +} + +if [ $# == 0 ]; then echo "Usage: ./pymusanim.sh file.mid outputdirectory/ [--dynamic]"; exit 2; fi +if [ -e $2 ]; then echo 'Output folder already exists...'; exit 3; fi +type mscore 2&>/dev/null;musescore=$? +checktime;allStart=$now +animate $* +printtime +./render.sh $2 +printtime diff --git a/render.sh b/render.sh new file mode 100755 index 0000000..98df40b --- /dev/null +++ b/render.sh @@ -0,0 +1,32 @@ +#!/bin/bash +#responsible from generating the OGG files and final MPG from PyMusLauncher's output +#being a separate step allows you to do some tricks like adding a few midi files if anything goes wrong and running this step again +function record(){ + rm *.ogg* 2&>/dev/null + for midi in *.mid*; do #call pymusanim if there is an idle thread + echo $midi + if [ $timidity ]; then + me=`whoami` + while [ `ps -u $me|grep timidity|wc -l` != 0 ]; do sleep 1; done #wait for other instances to finish + timidity $midi -Ov -o "$midi.timidity.ogg" + fi + if [ $musescore ] ; then + while [ `ps -u $me|grep mscore|wc -l` != 0 ]; do sleep 1; done #wait for other instances to finish + mscore $midi -o "$midi.musescore.ogg" + fi + done +} +function merge(){ + if [ -e frame00000.png ] ; then ffmpeg -f image2 -i frame%5d.png -qscale 0 silent.mpg; fi #png -> mpg + for ogg in *.ogg; do #call pymusanim if there is an idle thread + ffmpeg -i silent.mpg -i $ogg -vcodec copy "$ogg.mpg" + done + rm *.png 2&>/dev/null +} + +type mscore 2&>/dev/null;musescore=$? +type timidity 2&>/dev/null;timidity=$? +if [ $# == 0 ]; then echo "Usage: ./render.sh partialdirectory/"; exit 2; fi +cd $1 +record +merge diff --git a/replaceaudio.sh b/replaceaudio.sh new file mode 100755 index 0000000..942494c --- /dev/null +++ b/replaceaudio.sh @@ -0,0 +1,27 @@ +#!/bin/bash +function usage() { + echo "Usage: ./replaceaudio.sh directory/" + echo "" + echo "This utility takes a directory name and will use the following files" + echo "to create a new video with a given audio track:" + echo "- audio.mid (will be converted to ogg with timidity)" + echo "- audio.ogg (will exists will not process audio.mid)" + echo "- video.mpg (will be used as video input, current audio being ignored)" +} + +if [ $# == 0 ]; then + usage;exit 1 +fi +cd "$1" +if [ ! -e "video.mpg" ]; then + usage;exit 2 +fi +if [ ! -e "audio.ogg" ]; then + if [ ! -e "audio.mid" ]; then + usage;exit 2 + fi + timidity audio.mid -Ov -o audio.ogg +fi +ffmpeg -i video.mpg -codec copy -an silent.tmp.mpg +ffmpeg -i silent.tmp.mpg -i audio.ogg -vcodec copy replaced.mpg +rm silent.tmp.mpg