Fbrowser/MidPlay.py
2025-04-06 20:00:26 -05:00

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