525 lines
17 KiB
Python
525 lines
17 KiB
Python
# 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
|