Last active
November 16, 2024 07:41
-
-
Save damp11113/c648a6e275322518dd5d7c67a5fb54d2 to your computer and use it in GitHub Desktop.
The Midi player in python with visualizer
This file contains hidden or 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
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