Created
March 4, 2023 21:39
-
-
Save LiteApplication/ccc6d722c15b1c9f2bfd4830a894b76f to your computer and use it in GitHub Desktop.
A midi visualizer quickly written in python.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This program uses the tkinter module to create a video from a midi file | |
# The video will be a visual representation of the midi file | |
# This requires the following modules: | |
# - pretty_midi | |
# - tkinter | |
# - PIL | |
# This also requires ffmpeg to be installed and added to the PATH | |
# On a linux system, you can install ffmpeg with the following command: | |
# sudo apt install ffmpeg | |
# sudo apt install python3-tk | |
# The modules can be installed with the following commands: | |
# pip install pretty_midi | |
# pip install pillow | |
import math | |
import multiprocessing | |
import os | |
import random | |
import shutil | |
import subprocess | |
import time | |
import tkinter as tk | |
import pretty_midi | |
from PIL import Image | |
# Midi file to use | |
MIDI_INPUT = "melody.mid" | |
# Audio file to use in FFmpeg | |
AUDIO_INPUT = "melody.wav" | |
# Output video file | |
VIDEO_OUTPUT = "output.mp4" | |
# SIze of the final video in pixels | |
IMAGE_SIZE = 1024 | |
# Number of frames per second | |
FPS = 60 | |
# Number of frames to skip before drawing a new frame | |
SPEED = 10 | |
# Time needed to fade out the tail of the particles | |
VISUAL_DECAY = 25 | |
# Amount of velocity lost per second (0 = no decay, -1 = automatic (based on the smallest velocity)) | |
VELOCITY_DECAY = 50 # -1 for automatic | |
# Instruments that will be affected by VELOCITY_DECAY | |
DECAY_INSTRUMENTS = ["Piano"] | |
# Number of particles per instrument | |
SPLIT = 5 | |
# Size of each particle | |
PARTICLE_SIZE = 20 | |
# Their speed on the screen | |
PARTICLES_SPEED = 2 | |
# Path to the folder where the frames will be exported | |
EXPORT_FRAMES_PATH = "frames/" | |
# Number of seconds to wait before ending the video | |
BLACK_POSTROLL = 2 # seconds | |
# Enable the export of the frames (this will create a video but it will be very slow) | |
EXPORT = True | |
# If True, the program will resume from the last exported frame (will not overwrite existing frames, will not work no export was done previously) | |
RESUME = True | |
# If True, the program will go above the FPS limit | |
TURBO = True | |
# Number of processes to use when transforming the frames to PNG | |
MAX_PROCESSES = 8 # -1 for unlimited | |
os.environ["PRETTY_MIDI_IGNORE_TRACKS"] = "1" | |
# Set up the screen | |
screen = tk.Tk() | |
screen.title("Album Cover") | |
screen.geometry("{}x{}".format(IMAGE_SIZE, IMAGE_SIZE)) | |
screen.configure(bg="black") | |
if TURBO: | |
screen.after = lambda x: None | |
# Create the canvas | |
cv = tk.Canvas(screen, width=IMAGE_SIZE, height=IMAGE_SIZE, bg="black") | |
# no margin | |
cv.pack(expand=True, fill="both") | |
# Read the MIDI file | |
mid = pretty_midi.PrettyMIDI(MIDI_INPUT) | |
_processes = [] | |
def export_canvas(canvas: tk.Canvas, frame_id: int): | |
"""Export the canvas to a file.""" | |
if not EXPORT: | |
return | |
frame_id = str(frame_id).zfill(5) | |
ps_file = os.path.join(EXPORT_FRAMES_PATH, frame_id + ".ps") | |
png_file = os.path.join(EXPORT_FRAMES_PATH, frame_id + ".png") | |
if RESUME and os.path.exists(png_file): | |
return | |
elif os.path.exists(png_file): | |
os.remove(png_file) | |
if os.path.exists(ps_file): | |
os.remove(ps_file) | |
canvas.postscript(file=ps_file, colormode="color") | |
def _process_thread(): | |
img = Image.open(ps_file) | |
# Set the background to black | |
img = img.convert("RGB") | |
datas = img.getdata() | |
newData = [] | |
for item in datas: | |
if item[0] == 255 and item[1] == 255 and item[2] == 255: | |
newData.append((0, 0, 0)) | |
else: | |
newData.append(item) | |
img.putdata(newData) | |
# Resize the image | |
img = img.resize((IMAGE_SIZE, IMAGE_SIZE)) | |
img.save(png_file, "PNG") | |
os.remove(ps_file) | |
p = multiprocessing.Process(target=_process_thread) | |
_processes.append(p) | |
if len(_processes) >= MAX_PROCESSES and MAX_PROCESSES > 0: | |
_processes[0].join() | |
_processes.pop(0) | |
p.start() | |
else: | |
p.start() | |
def post_process(): | |
"""Create a MP4 file from the exported frames.""" | |
if not EXPORT: | |
return | |
# Wait for all the processes to finish | |
print("Waiting for all the processes to finish...") | |
for p in _processes: | |
p.join() | |
# Remove the output file if it already exists | |
if os.path.exists("output.mp4"): | |
input("/!\ Press enter to delete the existing output.mp4 file...") | |
os.remove("output.mp4") | |
print("Creating the video... (this may take a while)") | |
# Combine the frames into a video, use melody.wav as the audio | |
subprocess.run( | |
[ | |
"ffmpeg", | |
"-hide_banner", | |
"-loglevel", | |
"error", | |
"-stats", | |
"-framerate", | |
str(FPS), | |
"-i", | |
os.path.join(EXPORT_FRAMES_PATH, "%05d.png"), | |
"-i", | |
AUDIO_INPUT, | |
"-c:v", | |
"libx264", | |
"-pix_fmt", | |
"yuv420p", | |
"-r", | |
str(FPS), | |
"-shortest", | |
VIDEO_OUTPUT, | |
] | |
) | |
# Create a function that takes an HTML color and returns a darkened version | |
def darken(color, qty): | |
"""Return a darkened version of the given color.""" | |
# Remove the # from the color | |
color = color[1:] | |
# Convert the color to RGB | |
r = int(color[:2], 16) | |
g = int(color[2:4], 16) | |
b = int(color[4:], 16) | |
# Darken the color | |
r -= qty | |
g -= qty | |
b -= qty | |
# Ensure the color is not negative | |
r = max(r, 0) | |
g = max(g, 0) | |
b = max(b, 0) | |
# Convert the color back to hex | |
r = hex(r)[2:] | |
g = hex(g)[2:] | |
b = hex(b)[2:] | |
# Ensure the color is 2 characters long | |
if len(r) == 1: | |
r = "0" + r | |
if len(g) == 1: | |
g = "0" + g | |
if len(b) == 1: | |
b = "0" + b | |
# Add the # back to the color | |
color = "#" + r + g + b | |
return color | |
_FadingPointList: "FadingPoint" = [] | |
def fade_all(): | |
for point in _FadingPointList: | |
point.fade() | |
# Create a FadingPoint class | |
class FadingPoint: | |
def __init__(self, x, y, color, size, fade_speed=1): | |
self.x = x | |
self.y = y | |
self.color = color | |
self.size = size | |
self.fade_speed = fade_speed | |
self.id = None | |
_FadingPointList.append(self) | |
def draw(self): | |
if self.id is None: | |
self.id = cv.create_oval( | |
self.x, | |
self.y, | |
self.x + self.size, | |
self.y + self.size, | |
width=0, | |
state="normal", | |
) | |
# Change the color of the point | |
cv.itemconfig(self.id, fill=self.color, outline=self.color) | |
def fade(self): | |
self.color = darken(self.color, self.fade_speed) | |
if self.color == "#000000": | |
self.delete() | |
self.draw() | |
def delete(self): | |
if self.id is not None: | |
cv.delete(self.id) | |
_FadingPointList.remove(self) | |
def color_gen(): | |
# Vibrant colors | |
color_list = [ | |
"#FF0000", | |
"#00FF00", | |
"#0000FF", | |
"#FFFF00", | |
"#FF00FF", | |
"#00FFFF", | |
"#FF8000", | |
"#FF0080", | |
"#8000FF", | |
"#0080FF", | |
"#FF0080", | |
"#00FF80", | |
] | |
for color in color_list: | |
yield color | |
# Generate random colors with a random hue | |
while True: | |
yield "#" + "".join([hex(random.randint(0, 15))[2:] for _ in range(6)]) | |
class PenFade: | |
""" | |
This class represents a pen that leaves a fading trail behind it.""" | |
def __init__(self, x, y, color, size, orientation, speed=1, pen_down=True): | |
self.x = x | |
self.y = y | |
self.color = color | |
self.size = size | |
self.orientation = orientation | |
self.speed = speed | |
self.is_pen_down = pen_down | |
def pen_up(self): | |
self.is_pen_down = False | |
def pen_down(self): | |
self.is_pen_down = True | |
def turn_left(self, degrees): | |
self.orientation -= degrees | |
def turn_right(self, degrees): | |
self.orientation += degrees | |
def forward(self, distance): | |
assert isinstance(distance, int) | |
# step_decay = VISUAL_DECAY // distance | |
# colors_temp = [self.color] | |
# for i in range(distance): | |
# colors_temp.append(darken(colors_temp[-1], step_decay)) | |
if self.is_pen_down: | |
FadingPoint(self.x, self.y, self.color, self.size, self.speed) | |
for i in range(distance): | |
self.x += math.cos(math.radians(self.orientation)) | |
self.y += math.sin(math.radians(self.orientation)) | |
if self.is_pen_down: | |
FadingPoint(self.x, self.y, self.color, self.size, self.speed) | |
class SymetricalPen: | |
"""This pen is the same as multiple pens symetrical to each other from the middle of the screen.""" | |
def __init__(self, color, size, orientation, number, speed=1, pen_down=True): | |
self.color = color | |
self.size = size | |
self.orientation = orientation | |
self.number = number | |
self.speed = speed | |
self.is_pen_down = pen_down | |
self.pens = [] | |
self.instrument = None | |
self.instrument_number = None | |
self._lost = 0 | |
for i in range(self.number): | |
self.pens.append( | |
PenFade( | |
IMAGE_SIZE // 2, | |
IMAGE_SIZE // 2, | |
self.color, | |
self.size, | |
self.orientation + i * 360 / number, | |
self.speed, | |
self.is_pen_down, | |
) | |
) | |
self.last_note = None | |
def pen_up(self): | |
self.is_pen_down = False | |
for pen in self.pens: | |
pen.pen_up() | |
def pen_down(self): | |
self.is_pen_down = True | |
for pen in self.pens: | |
pen.pen_down() | |
def turn_left(self, degrees): | |
self.orientation -= degrees | |
for pen in self.pens: | |
pen.turn_left(degrees) | |
def turn_right(self, degrees): | |
self.orientation += degrees | |
for pen in self.pens: | |
pen.turn_right(degrees) | |
@staticmethod | |
def is_too_far(x, y): | |
return (x - IMAGE_SIZE // 2) * (x - IMAGE_SIZE // 2) + (y - IMAGE_SIZE // 2) * ( | |
y - IMAGE_SIZE // 2 | |
) > IMAGE_SIZE * IMAGE_SIZE // 4 | |
def forward(self, distance): | |
assert isinstance(distance, int) | |
assert SPLIT > 0 | |
distance = abs(distance) | |
pen = self.pens[0] | |
if SymetricalPen.is_too_far(pen.x, pen.y): | |
# Make the pen face towards the center of the screen | |
angle, x, y = pen.orientation, pen.x, pen.y | |
self.turn_right( | |
180 | |
+ math.degrees(math.atan2(y - IMAGE_SIZE // 2, x - IMAGE_SIZE // 2)) | |
- angle | |
) | |
self._lost += 1 | |
if self._lost > 10: | |
print("Lost too many times, resetting the pen.") | |
self._lost = 0 | |
for pen in self.pens: | |
pen.x = IMAGE_SIZE // 2 | |
pen.y = IMAGE_SIZE // 2 | |
else: | |
self._lost = 0 | |
for pen in self.pens: | |
pen.forward(distance) | |
def process_note(self, note: tuple[int, int]): | |
pitch, velocity = note | |
if self.last_note is not None: | |
pen.turn_right((pitch - self.last_note[0]) * 20) | |
pen.forward(int(velocity // 20 * PARTICLES_SPEED)) | |
self.last_note = note | |
# get the list of instruments | |
instruments = mid.instruments | |
# Assign a pen to each instrument | |
pens = {} | |
color_gen = color_gen() | |
def add_pen(instrument, voice): | |
pens[(instrument, voice)] = SymetricalPen( | |
next(color_gen), PARTICLE_SIZE, 0, SPLIT, VISUAL_DECAY, True | |
) | |
pens[(instrument, voice)].instrument = instrument | |
pens[(instrument, voice)].instrument_number = voice | |
for instrument in instruments: | |
add_pen(instrument, 0) | |
# We then need to create a timeline, a list of (instrument, note) tuples for each time step | |
# Get the total number of ticks in the MIDI file | |
total_ticks = int(mid.get_end_time() * FPS) | |
print("Total time:", mid.get_end_time()) | |
print("Total ticks:", total_ticks) | |
print("Instruments:", len(instruments)) | |
for instrument in instruments: | |
print(" -", instrument.name, len(instrument.notes)) | |
timeline = [[] for i in range(total_ticks)] | |
velocity_decay_rate = VELOCITY_DECAY / FPS | |
if velocity_decay_rate == -1: | |
# Calculate the decay rate based on the note with the lowest velocity | |
velocity_decay_rate = 0 | |
smallest_velocity_duration = 0 | |
for instrument in instruments: | |
if instrument.name in DECAY_INSTRUMENTS: | |
for note in instrument.notes: | |
if note.velocity < velocity_decay_rate: | |
velocity_decay_rate = note.velocity | |
smallest_velocity_duration = note.end - note.start | |
velocity_decay_rate /= smallest_velocity_duration * FPS | |
print("Processing notes...") | |
# Iterate over all the tracks | |
for instrument in instruments: | |
# Iterate over all the notes | |
for note in instrument.notes: | |
# Get the start and end time of the note | |
start = int(note.start * FPS) | |
end = int(note.end * FPS) | |
# Counteract the first velocity decay | |
if instrument.name in DECAY_INSTRUMENTS: | |
note.velocity = note.velocity + velocity_decay_rate | |
# Add the note to the timeline | |
for i in range(start, end): | |
voices = 0 | |
for pen, _ in timeline[i]: | |
if pen.instrument == instrument: | |
voices += 1 | |
if (instrument, voices) not in pens: | |
add_pen(instrument, voices) | |
if instrument.name in DECAY_INSTRUMENTS and i > 0: | |
note.velocity = max(note.velocity - velocity_decay_rate, 0) | |
if note.velocity == 0: # This note is dead | |
break | |
# print("Decaying", note.velocity, old_note.velocity) | |
timeline[i].append( | |
(pens[(instrument, voices)], (note.pitch, note.velocity)) | |
) | |
delta_i = SPEED | |
if not RESUME: | |
if not os.path.exists(EXPORT_FRAMES_PATH): | |
os.mkdir(EXPORT_FRAMES_PATH) | |
else: | |
shutil.rmtree(EXPORT_FRAMES_PATH) | |
os.mkdir(EXPORT_FRAMES_PATH) | |
i = 0 | |
while i < total_ticks: | |
begin = time.time() | |
# Draw the timeline | |
for pen, note in timeline[i]: | |
pen.process_note(note) | |
fade_all() | |
screen.update() | |
export_canvas(cv, i // delta_i) | |
end = time.time() | |
print( | |
"Took {:.1f}%\t of the frame time, progress :\t".format( | |
(end - begin) * FPS * 100 | |
), | |
i, | |
"/", | |
total_ticks, | |
end="\r", | |
) | |
screen.after(max(0, int(1000 // FPS - (end - begin) * 1000))) | |
i += delta_i | |
print() | |
# Fade all points | |
for _ in range(256 // VISUAL_DECAY + 1): | |
begin = time.time() | |
fade_all() | |
screen.update() | |
export_canvas(cv, i // delta_i) | |
end = time.time() | |
print( | |
"Took {:.1f}%\t of the frame time, progress :\t".format( | |
(end - begin) * FPS * 100 | |
), | |
i, | |
"/", | |
total_ticks, | |
end="\r", | |
) | |
screen.after(max(0, int(1000 // FPS - (end - begin) * 1000))) | |
i += delta_i | |
# Add a few frames of black | |
# Delete all points | |
for fading_point in _FadingPointList: | |
cv.delete(fading_point.id) | |
for _ in range(FPS * BLACK_POSTROLL): | |
begin = time.time() | |
screen.update() | |
export_canvas(cv, i // delta_i) | |
end = time.time() | |
print( | |
"Took {:.1f}%\t of the frame time, progress :\t".format( | |
(end - begin) * FPS * 100 | |
), | |
i, | |
"/", | |
total_ticks, | |
end="\r", | |
) | |
screen.after(max(0, int(1000 // FPS - (end - begin) * 1000))) | |
i += delta_i | |
print() | |
post_process() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment