Skip to content

Instantly share code, notes, and snippets.

@damp11113
Last active November 16, 2024 07:41
Show Gist options
  • Save damp11113/c648a6e275322518dd5d7c67a5fb54d2 to your computer and use it in GitHub Desktop.
Save damp11113/c648a6e275322518dd5d7c67a5fb54d2 to your computer and use it in GitHub Desktop.
The Midi player in python with visualizer
import sys
import threading
import easygui
import pygame
import psutil
import time
import mido
import pyfluidsynth
# Global Constants
output_mode = "KDMAPI" # Internal, Midi output device (External), KDMAPI (Omnimidi)
internal_soundfont = r""
midi_source = "MIDI" # MIDI, Midi input device (External)
max_fps = 60
falling_speed = 30
inter_note_falling = 7.5
screen_resolution = (1280, 720)
autoptimize = False
class MIDIVisualizer:
def __init__(self, notes, title):
pygame.init()
self.notes = notes
self.title = title
self.passed_notes = 0
self.stop_visualizer = threading.Event()
self.screen_resolution = screen_resolution
self.screen = None
self.clock = None
self.falling_notes = []
self.width, self.height = screen_resolution
self.font = pygame.font.Font(None, 36)
self.text_color = (255, 255, 255)
self.text_color_bg = (150, 150, 150)
self.channel_colors = self._get_channel_colors()
self.is_running = False
self.current_is_loading = True
self.current_loading_text = ""
self.falling_speed = falling_speed
self.inter_note_falling = inter_note_falling
self.viewmode = 0
self.autoptimize = autoptimize
self.config = {
'thresholds': [
{'max_notes': 200, 'inter_note_falling': 3, 'falling_speed': 10}, # Extremely Low Load
{'max_notes': 500, 'inter_note_falling': 5, 'falling_speed': 12}, # Very Low Load
{'max_notes': 1000, 'inter_note_falling': 10, 'falling_speed': 15}, # Low Load
{'max_notes': 1500, 'inter_note_falling': 12, 'falling_speed': 17}, # Low-Medium Load
{'max_notes': 2000, 'inter_note_falling': 15, 'falling_speed': 20}, # Medium Load
{'max_notes': 2500, 'inter_note_falling': 18, 'falling_speed': 23}, # Moderate Load
{'max_notes': 3000, 'inter_note_falling': 20, 'falling_speed': 25}, # Medium-High Load
{'max_notes': 3500, 'inter_note_falling': 25, 'falling_speed': 28}, # High Load
{'max_notes': 4000, 'inter_note_falling': 30, 'falling_speed': 30}, # Very High Load
{'max_notes': 4500, 'inter_note_falling': 35, 'falling_speed': 33}, # Ultra High Load
{'max_notes': 4750, 'inter_note_falling': 40, 'falling_speed': 35}, # Extremely High Load
{'max_notes': 5000, 'inter_note_falling': 45, 'falling_speed': 37}, # Maximum Load
{'max_notes': 5500, 'inter_note_falling': 50, 'falling_speed': 40}, # Overload Warning (Optional)
]
}
def adjust_falling_params(self, total_falling_notes):
# Loop through thresholds to adjust inter_note_falling and falling_speed based on load
for threshold in self.config['thresholds']:
if total_falling_notes <= threshold['max_notes']:
self.inter_note_falling = threshold['inter_note_falling']
self.falling_speed = threshold['falling_speed']
break
def _get_channel_colors(self):
return {
0: (255, 0, 0), # Red
1: (0, 255, 0), # Green
2: (0, 0, 255), # Blue
3: (255, 255, 0), # Yellow
4: (255, 0, 255), # Magenta
5: (0, 255, 255), # Cyan
6: (192, 192, 192), # Light Gray
7: (128, 128, 128), # Gray
8: (255, 128, 0), # Orange
9: (128, 0, 255), # Purple
10: (0, 255, 128), # Light Green
11: (255, 128, 128), # Light Red
12: (128, 255, 128), # Light Green
13: (128, 128, 255), # Light Blue
14: (255, 255, 128), # Light Yellow
15: (255, 128, 255) # Light Magenta
}
def render_falling_notes(self):
new_falling_notes = []
for note_data in self.falling_notes:
if isinstance(note_data, tuple) and len(note_data) == 2 and isinstance(note_data[1], tuple) and len(
note_data[1]) == 4:
note, (channel, velocity, y_pos, timestamp) = note_data
x = note * (self.width / 128)
bar_height = (self.inter_note_falling / 127.0) * self.height
base_color = self.channel_colors.get(channel, (255, 255, 255))
shaded_color = (*base_color[:3], velocity)
pygame.draw.rect(self.screen, shaded_color, (x, y_pos, self.width / 128, bar_height))
# Update the y_pos and append the new falling note
if y_pos + self.falling_speed < self.height:
new_y_pos = y_pos + self.falling_speed # + (note % self.falling_speed) # Adjust speed based on note and fix overlap here by render layers
new_falling_notes.append((note, (channel, velocity, new_y_pos, timestamp)))
self.falling_notes = new_falling_notes
# Handle adding new falling notes for currently pressed notes
notes_copy = self.notes.copy()
for note, value in notes_copy.items():
# Ensure value is a list of tuples and unpack correctly
if isinstance(value, list):
for (channel, velocity, timestamp) in value:
self.falling_notes.append((note, (channel, velocity, 0, time.time()))) # Start at the top
def render_note_notes(self):
notes_copy = self.notes.copy()
track_height = self.height // len(self.channel_colors)
for i, channel in enumerate(self.channel_colors):
channel_y_pos = i * track_height
channel_label = self.font.render(f"CH {channel + 1}", True, self.text_color_bg)
channel_label_rect = channel_label.get_rect(center=(50, channel_y_pos + track_height // 2))
self.screen.blit(channel_label, channel_label_rect)
pygame.draw.line(self.screen, self.text_color_bg, (60, channel_y_pos + track_height),
(self.width, channel_y_pos + track_height), 2)
for note, value in notes_copy.items():
# Check if value is a list (which it should be now)
if isinstance(value, list):
for (channel, velocity, timestamp) in value: # Unpack the values in the list
channel_y_pos = channel * track_height
transparency = int(255 - (velocity * 2))
shaded_color = (*self.channel_colors.get(channel, (255, 255, 255))[:3], transparency)
x_pos = note * (self.width / 128)
note_width = 10
pygame.draw.rect(self.screen, shaded_color,
(x_pos, channel_y_pos + 5, note_width, track_height - 10))
def draw_loading(self):
# Get current memory usage using psutil
process = psutil.Process()
memory_info = process.memory_info()
memory_usage = memory_info.rss / (1024 * 1024) # Convert to MB
# Create the loading text and memory usage text
loading_text = self.font.render(self.current_loading_text, True, self.text_color)
memory_text = self.font.render(f"Memory: {memory_usage:.2f} MB", True, self.text_color)
# Get the positions to display the texts
loading_rect = loading_text.get_rect(center=(self.width // 2, self.height // 2 - 20))
memory_rect = memory_text.get_rect(center=(self.width // 2, self.height // 2 + 20))
# Blit the texts onto the screen
self.screen.blit(loading_text, loading_rect)
self.screen.blit(memory_text, memory_rect)
def update_title(self, nps):
moreoption = ""
if self.viewmode == 0:
moreoption += f" | Rendered Notes: {len(self.falling_notes)}"
if self.autoptimize:
moreoption += f" | Inter-note Falling: {self.inter_note_falling} | Falling Speed: {self.falling_speed}"
pygame.display.set_caption(f'PyMider v2 | {self.title} | FPS: {self.clock.get_fps():.2f} | NPS: {nps:.2f} | Passed Notes: {self.passed_notes}' + moreoption)
def visualizer_thread(self):
self.screen = pygame.display.set_mode(self.screen_resolution)
pygame.display.set_caption(f'PyMider v2 | {self.title}')
self.clock = pygame.time.Clock()
# Ensure screen is initialized before using it
self.screen.fill((0, 0, 0)) # Clear screen to black before drawing
pygame.display.flip()
start_time = time.time()
self.is_running = True
while not self.stop_visualizer.is_set() and self.is_running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.stop_visualizer.set()
self.is_running = False
pygame.quit()
return
elif event.type == pygame.KEYDOWN: # Use KEYDOWN to ensure you capture the key press
if event.key == pygame.K_v: # Check if the 'v' key was pressed
self.viewmode = (self.viewmode + 1) % 2 # Toggle between 0 and 1
self.screen.fill((0, 0, 0))
if self.current_is_loading:
self.draw_loading()
else:
if self.viewmode == 0:
self.render_falling_notes()
if self.autoptimize:
# Auto-optimization based on the number of falling notes
total_falling_notes = len(self.falling_notes)
# Adjust inter_note_falling dynamically based on the number of falling notes using thresholds
self.adjust_falling_params(total_falling_notes)
elif self.viewmode == 1:
self.render_note_notes()
else:
pass
elapsed_time = time.time() - start_time
nps = self.passed_notes / elapsed_time if elapsed_time > 0 else 0
self.update_title(nps)
pygame.display.flip()
self.clock.tick(max_fps)
class Synth:
def __init__(self):
self.fs = pyfluidsynth.Synth(samplerate=48000.0)
self.fs.start()
self.fs.sfload(internal_soundfont)
def send(self, msg):
if msg.type == 'program_change':
self.fs.program_change(msg.channel, msg.program)
elif msg.type == "control_change":
self.fs.cc(msg.channel, msg.control, msg.value)
elif msg.type == "pitchwheel":
self.fs.pitch_bend(msg.channel, msg.pitch)
elif msg.type == 'note_on':
self.fs.noteon(msg.channel, msg.note, msg.velocity)
elif msg.type == 'note_off':
self.fs.noteoff(msg.channel, msg.note)
def main():
if midi_source.lower() == "midi":
print('Select MIDI File')
try:
midi_file = sys.argv[1]
if not midi_file.endswith(('.mid', '.midi')):
raise TypeError("File Not Supported")
except:
midi_file = easygui.fileopenbox(filetypes=['*.mid', '*.midi'], title='Select a MIDI file')
if not midi_file.endswith(('.mid', '.midi')):
exit()
filename = midi_file.split("\\")[-1].split('.mid')[0]
else:
filename = midi_source
notes = {}
# Start visualizer thread
visualizer = MIDIVisualizer(notes, filename)
visualizer_thread = threading.Thread(target=visualizer.visualizer_thread)
visualizer_thread.daemon = True
visualizer_thread.start()
while not visualizer.is_running:
pass
if midi_source.lower() != "midi" and output_mode.lower() == "kdmapi":
print("MIDI input device can't be use with KDMAPI")
exit()
try:
if output_mode.lower() == "internal":
visualizer.current_loading_text = "Initializing... FluidSynth"
out = Synth()
elif output_mode.lower() == "kdmapi":
visualizer.current_loading_text = 'Initializing... KDMAPI'
mido.set_backend("kdmapi.mido_backend")
out = mido.open_output()
else:
visualizer.current_loading_text = 'Initializing... ' + output_mode
out = mido.open_output(output_mode)
except Exception as e:
print(e)
exit()
def send_midi_event(i):
if i.type == 'note_on':
out.send(i)
if i.velocity == 0:
# Check if the note is in the dictionary
if i.note in notes:
# Ensure notes[i.note] is a list before attempting to access elements
if isinstance(notes[i.note], list) and notes[i.note]:
# Find the oldest note based on timestamp (assumes it's a tuple with a timestamp at index 2)
oldest_note = min(notes[i.note],
key=lambda x: x[2]) # Find the oldest note based on timestamp
notes[i.note].remove(oldest_note) # Remove the oldest note from the list
else:
if i.note not in notes:
notes[i.note] = [] # Initialize an empty list if the note doesn't exist in the dictionary
visualizer.passed_notes += 1
notes[i.note].append((i.channel, i.velocity, time.time())) # Add timestamp for note on
elif i.type == 'note_off':
out.send(i)
# Check if the note is in the dictionary
if i.note in notes:
# Ensure notes[i.note] is a list before attempting to access elements
if isinstance(notes[i.note], list) and notes[i.note]:
# Find the oldest note based on timestamp (assumes it's a tuple with a timestamp at index 2)
oldest_note = min(notes[i.note], key=lambda x: x[2]) # Find the oldest note based on timestamp
notes[i.note].remove(oldest_note) # Remove the oldest note from the list
else:
out.send(i)
try:
if midi_source.lower() == "midi":
visualizer.current_loading_text = f"Reading {filename}"
midi = mido.MidiFile(midi_file, clip=True)
visualizer.current_is_loading = False
for i in midi.play():
if not visualizer.is_running:
print("Visualizer closed, stopping playback.")
break
send_midi_event(i)
else:
visualizer.current_loading_text = f"Opening {midi_source}"
midi = mido.open_input(midi_source)
visualizer.current_is_loading = False
for i in midi:
if not visualizer.is_running:
print("Visualizer closed, stopping input.")
break
send_midi_event(i)
except Exception as e:
raise e
finally:
print("Exiting...")
visualizer.stop_visualizer.set()
visualizer_thread.join()
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment