# Path: MidPlay.py # Name: MidPlay # Author: Stanton # Description: This file contains the class MidPlay # which is used to play MIDI files. # It includes FluidSynth initialization, MIDI playback controls. # playlist management, volume control, and MIDI file validation. # The script handles MIDI file loading, threading for playback, # and soundfont configuration. # flake8: noqa: E501 # Imports import fluidsynth # typed: ignore import time import mido # typed: ignore import os import platform from queue import Queue from threading import Lock import threading from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QLabel, QFileDialog, QSlider, QListWidget, QHBoxLayout, QProgressBar, ) from PyQt6.QtCore import Qt, QTimer def find_default_soundfont(): """Find the default soundfont based on the operating system""" system = platform.system() # List of possible soundfont paths by OS if system == "Windows": possible_paths = [ # Common FluidSynth installation locations os.path.expandvars( r"%PROGRAMFILES%\FluidSynth\share\soundfonts\FluidR3_GM.sf2" ), # Common user locations os.path.join( os.path.expanduser("~"), "Documents", "SoundFonts", "FluidR3_GM.sf2", ), # Application directory os.path.join( os.path.dirname(os.path.abspath(__file__)), "SoundFonts", "FluidR3_GM.sf2", ), # Development fallback "C:\\Users\\East\\Documents\\Dev\\Tests\\MidPlay\\SoundFonts\\tales_of_phantasia.sf2", ] elif system == "Darwin": # macOS possible_paths = [ "/Library/Audio/Sounds/SF2/FluidR3_GM.sf2", "/usr/local/share/soundfonts/FluidR3_GM.sf2", "/usr/share/soundfonts/FluidR3_GM.sf2", os.path.expanduser("~/Library/Audio/Sounds/SF2/FluidR3_GM.sf2"), ] else: # Linux and others possible_paths = [ "/usr/share/soundfonts/FluidR3_GM.sf2", "/usr/share/sounds/sf2/FluidR3_GM.sf2", # Common on many distros "/usr/local/share/soundfonts/FluidR3_GM.sf2", ] # Check if files exist and return the first one found for path in possible_paths: if os.path.isfile(path): return path # If no soundfont found, look for a soundfont in the current directory # or in a "SoundFonts" subdirectory current_dir = os.path.dirname(os.path.abspath(__file__)) soundfont_dir = os.path.join(current_dir, "SoundFonts") if os.path.isdir(soundfont_dir): for file in os.listdir(soundfont_dir): if file.lower().endswith(".sf2"): return os.path.join(soundfont_dir, file) # Last check - any .sf2 file in the current directory for file in os.listdir(current_dir): if file.lower().endswith(".sf2"): return os.path.join(current_dir, file) return None # --- Fluidsynth Initialization --- # try: default_soundfont = find_default_soundfont() if default_soundfont: print(f"Using soundfont: {default_soundfont}") else: print("No soundfont found. Please select one manually.") except Exception as e: print(f"Error finding soundfont: {e}") default_soundfont = None current_sf = "No soundfont loaded" fs = fluidsynth.Synth() sfid = None if default_soundfont: try: sfid = fs.sfload(default_soundfont) fs.program_select(0, sfid, 0, 0) current_sf = default_soundfont except Exception as e: print(f"Error loading soundfont: {e}") settings = fluidsynth.new_fluid_settings() # typed: ignore # Use appropriate audio driver based on OS if platform.system() == "Windows": fs.start(driver="dsound") elif platform.system() == "Darwin": # macOS fs.start(driver="coreaudio") else: # Linux fs.start(driver="alsa") class MidPlay: """The Heart of Midi Playback""" def __init__(self): self.queue = Queue() self.thread = threading.Thread(target=self.midi_threading) self.thread.daemon = True self.thread.start() self.playback_lock = Lock() self.playlist = [] self.current_midi = None self.playing = False self.current_index = 0 self.midi_start_time = 0 self.current_volume = 100 self.midi_length = 0 self.ui = None @staticmethod def is_valid_midi(filepath): try: with open(filepath, "rb") as f: header = f.read(4) return header == b"MThd" except IOError: return False def showUI(self): if not self.ui: self.ui = MidPlayGUI(self) self.ui.show() def set_volume(self, value): self.current_volume = value for channel in range(16): fs.cc(channel, 7, int(value * 127)) def load_midi(self, filepath: str) -> None: if not self.is_valid_midi(filepath): print(f"Debug: Invalid MIDI file: {filepath}") return try: self.current_midi = mido.MidiFile(filepath) self.midi_length = self.current_midi.length if filepath in self.playlist: self.current_index = self.playlist.index(filepath) except Exception as e: print(f"Error loading MIDI file {filepath}: {e}") def add_to_playlist(self, filepath: str) -> None: if filepath not in self.playlist: self.playlist.append(filepath) def stop_midi(self): fs.all_notes_off(-1) # -1 means all channels in MIDI CC fs.all_sounds_off(-1) fs.system_reset() self.playing = False self.current_midi = None def _play_midi(self, filepath): try: self.stop_midi() mid = mido.MidiFile(filepath) self.playing = True self.midi_start_time = time.time() tempo = mido.bpm2tempo(120) for msg in mido.merge_tracks(mid.tracks): if not self.playing: break if msg.is_meta: if msg.type == "set_tempo": # print(f"Set Tempo: {mido.tempo2bpm(msg.tempo)} BPM") tempo = msg.tempo else: time.sleep( mido.tick2second(msg.time, mid.ticks_per_beat, tempo) ) if msg.type == "note_on": fs.noteon(0, msg.note, msg.velocity) elif msg.type == "note_off": fs.noteoff(0, msg.note) if self.playing: self.next_song() finally: self.playback_lock.release() self.playing = False def pause(self): if self.playing: fs.all_notes_off(-1) self.playing = False def previous_song(self) -> None: if not self.playlist: return self.stop_midi() self.current_index = (self.current_index - 1) % len(self.playlist) prev_file = self.playlist[self.current_index] self.load_midi(prev_file) self.play_midi(prev_file) def next_song(self) -> None: if not self.playlist: return self.stop_midi() self.current_index = (self.current_index + 1) % len(self.playlist) filepath = self.playlist[self.current_index] self.load_midi(filepath) self.set_volume(self.current_volume) self.play_midi(filepath) def play_midi(self, filepath): if self.playback_lock.acquire(blocking=False): try: self.stop_midi() thread = threading.Thread( target=self._play_midi, args=(filepath,) ) thread.daemon = True thread.start() except IOError: self.playback_lock.release() def midi_threading(self): while True: file_path = self.queue.get() try: self._play_midi(file_path) self.playing = True except Exception as e: print(f"Error playing MIDI file {file_path}: {e}") self.queue.task_done() self.next_song() def stop(self): self.stop_midi() self.playing = False self.current_midi = None fs.all_notes_off(-1) fs.all_sounds_off(-1) fs.program_unset(0) # MidPlayGUI class class MidPlayGUI(QWidget): def __init__(self, player=None): super().__init__() self.setWindowTitle("MidPlay") self.status_label = QLabel(f"SoundFont: {current_sf}") if player: self.player = player else: self.player = MidPlay() self.midi_length = 0 self.playing = False self.current_index = 0 self.init_ui() self.timer = QTimer() self.timer.timeout.connect(self.update_progress) self.timer.start(100) # If no soundfont is loaded, prompt the user immediately if current_sf == "No soundfont loaded": QTimer.singleShot(500, self.change_soundfont) self.timer = QTimer() self.timer.timeout.connect(self.update_progress) self.timer.start(100) def set_volume(self, value): volume = value / 100.0 for channel in range(16): fs.cc(channel, 7, int(volume * 127)) self.player.current_volume = value def update_progress(self): if self.playing and self.player.midi_length > 0: elapsed_time = time.time() - self.player.midi_start_time if elapsed_time >= self.player.midi_length: self.handle_song_end() else: progress = min( (elapsed_time / self.player.midi_length) * 100, 100 ) self.progress_bar.setValue(int(progress)) if self.player.playing: progress = min( (elapsed_time / self.player.midi_length) * 100, 100 ) self.progress_bar.setValue(int(progress)) if progress >= 100: self.player.next_song() def change_soundfont(self): new_sf_path, _ = QFileDialog.getOpenFileName( None, "Select SoundFont", filter="SoundFont (*.sf2)" ) if new_sf_path: # If a file was chosen global sfid, current_sf try: # Only try to unload if sfid is not None if sfid is not None: fs.sfunload(sfid) # Load the new soundfont sfid = fs.sfload(new_sf_path) fs.program_select(0, sfid, 0, 0) current_sf = new_sf_path self.status_label.setText(f"SoundFont: {current_sf}") except Exception as e: self.status_label.setText(f"Error loading SoundFont: {e}") def init_ui(self): # Widgets self.progress_bar = QProgressBar() self.volume_slider = QSlider(Qt.Orientation.Horizontal) self.volume_slider.setMinimum(0) self.volume_slider.setMaximum(100) self.volume_slider.setValue(100) self.volume_slider.valueChanged.connect(self.set_volume) self.current_midi_label = QLabel("Current MIDI: None") self.playlist_widget = QListWidget() self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song) # Buttons play_button = QPushButton("Play") play_button.clicked.connect(self.handle_play) pause_button = QPushButton("Pause") pause_button.clicked.connect(self.handle_pause) stop_button = QPushButton("Stop") stop_button.clicked.connect(self.handle_stop) next_button = QPushButton("Next") next_button.clicked.connect(self.handle_next) back_button = QPushButton("Back") back_button.clicked.connect(self.handle_previous) add_button = QPushButton("Add to Playlist") add_button.clicked.connect(self.load_midi_file) add_folder_button = QPushButton("Add Folder to Playlist") add_folder_button.clicked.connect(self.load_folder) clear_button = QPushButton("Clear Playlist") clear_button.clicked.connect(self.clear_playlist) change_sf_button = QPushButton("Change SoundFont") change_sf_button.clicked.connect(self.change_soundfont) # Window Layout layout = QVBoxLayout() layout.addWidget(self.current_midi_label) layout.addWidget(self.playlist_widget) # Progress and volume layout progress_volume_layout = QHBoxLayout() progress_volume_layout.addWidget(self.progress_bar) progress_volume_layout.addWidget(self.volume_slider) layout.addLayout(progress_volume_layout) # Playback controls layout.addWidget(play_button) layout.addWidget(pause_button) layout.addWidget(stop_button) layout.addWidget(next_button) layout.addWidget(back_button) # Playlist management layout.addWidget(add_button) layout.addWidget(add_folder_button) layout.addWidget(clear_button) layout.addWidget(change_sf_button) layout.addWidget(self.status_label) self.setLayout(layout) # Event handlers def handle_play(self): if self.playlist_widget.currentItem(): self.play_selected_song(self.playlist_widget.currentItem()) def handle_pause(self): if self.playing: self.player.pause() self.playing = False else: if self.current_index < len(self.player.playlist): self.player.play_midi(self.player.playlist[self.current_index]) self.playing = True def handle_stop(self): self.player.stop_midi() self.playing = False self.progress_bar.setValue(0) def handle_next(self): if not self.player.playlist: return self.playing = False self.progress_bar.setValue(0) self.player.next_song() next_index = (self.current_index + 1) % len(self.player.playlist) self.current_index = next_index self.playlist_widget.setCurrentRow(next_index) # Update the current MIDI label filename = os.path.basename(self.player.playlist[next_index]) self.current_midi_label.setText(f"Current MIDI: {filename}") self.playing = True def handle_previous(self): if not self.player.playlist: return self.playing = False self.progress_bar.setValue(0) self.player.previous_song() prev_index = (self.current_index - 1) % len(self.player.playlist) self.current_index = prev_index self.playlist_widget.setCurrentRow(prev_index) # Update the current MIDI label filename = os.path.basename(self.player.playlist[prev_index]) self.current_midi_label.setText(f"Current MIDI: {filename}") self.playing = True def handle_song_end(self): self.handle_next() def play_selected_song(self, item): if not item or not self.player.playlist: return self.playing = False index = self.playlist_widget.row(item) if 0 <= index < len(self.player.playlist): self.current_index = index filepath = self.player.playlist[self.current_index] self.player.load_midi(filepath) self.player.play_midi(filepath) filename = os.path.basename(filepath) self.current_midi_label.setText(f"Current MIDI: {filename}") self.playing = True self.progress_bar.setValue(0) self.player.midi_start_time = time.time() def load_midi_file(self): filepath, _ = QFileDialog.getOpenFileName( self, "Select MIDI File", filter="MIDI files (*.mid *.midi)" ) if filepath: filename = os.path.basename(filepath) self.player.add_to_playlist(filepath) self.playlist_widget.addItem(filename) # If this is the first file added, load it if len(self.player.playlist) == 1: self.player.load_midi(filepath) self.current_midi_label.setText(f"Current MIDI: {filename}") def load_folder(self): folder_path = QFileDialog.getExistingDirectory(self, "Select Folder") if folder_path: filenames = [ f for f in os.listdir(folder_path) if f.lower().endswith(".mid") or f.lower().endswith(".midi") ] for filename in filenames: filepath = os.path.join(folder_path, filename) self.player.add_to_playlist(filepath) self.playlist_widget.addItem(filename) # If this is the first file added, load it if len(self.player.playlist) == 1: self.player.load_midi(filepath) self.current_midi_label.setText(f"Current MIDI: {filename}") def clear_playlist(self): self.player.playlist = [] self.playlist_widget.clear() self.current_midi_label.setText("Current MIDI: None") self.current_index = -1