Skip to content

Instantly share code, notes, and snippets.

@LiteApplication
Created March 4, 2023 21:39
Show Gist options
  • Save LiteApplication/ccc6d722c15b1c9f2bfd4830a894b76f to your computer and use it in GitHub Desktop.
Save LiteApplication/ccc6d722c15b1c9f2bfd4830a894b76f to your computer and use it in GitHub Desktop.
A midi visualizer quickly written in python.
# 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