commit 3b909bb75662e6fc25c147895806b4d0b824dccb Author: Stan44 Date: Sun Apr 6 20:00:26 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb19389 --- /dev/null +++ b/.gitignore @@ -0,0 +1,113 @@ +# Python ignores +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +.hypothesis/ +.venv +venv/ +ENV/ +env.bak/ +venv.bak/ +.pytype/ +.mypy_cache/ + +# C# ignores +[Bb]in/ +[Oo]bj/ +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]uild/ +msbuild.log +msbuild.err +msbuild.wrn +*.suo +*.user +*.userosscache +*.sln.docstates +*.userprefs +.vs/ +Generated\ Files/ +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +.localhistory/ +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +*.pfx +*.publishsettings +*.nupkg +**/packages/* +!**/packages/build/ +*.nuget.props +*.nuget.targets + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Specific file exclusion +maintest.py + +# SoundFonts can be large - optional +*.sf2 + +# Generated MIDI files - optional +*.mid +*.midi diff --git a/Fbrowser.py b/Fbrowser.py new file mode 100644 index 0000000..91645ba --- /dev/null +++ b/Fbrowser.py @@ -0,0 +1,736 @@ +# flake8: noqa: E501 + +"""File browser implementation with tools and audio playback. +this includes a midi player""" + +import sys +import os +import warnings +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QPushButton, + QTreeView, + QLabel, + QHBoxLayout, + QLineEdit, + QProgressBar, + QSplitter, + QMessageBox, + QSlider, + QMenu, + QFileDialog, + QDialog, + QToolTip, + QFrame, +) +from PyQt6.QtCore import Qt, QDir, QUrl, QTimer, QEvent, QPoint +from PyQt6.QtGui import QFileSystemModel, QCursor +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from MidPlay import MidPlay +from timer_m import Timer_Ui + + +from ScanOrg101 import ( + Organizer, + FileFilterProxyModel, + DirectoryFilterProxyModel, + ArchiveExtractor, + mutagen, +) + +warnings.filterwarnings("ignore", category=DeprecationWarning) + + +class MetadataTooltip(QFrame): + """Custom styled tooltip for displaying audio metadata""" + + def __init__(self, parent=None): + super().__init__( + parent, + Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint, + ) + self.setStyleSheet( + """ + MetadataTooltip { + background-color: #2a2a2a; + border: 1px solid #555555; + border-radius: 5px; + color: white; + padding: 5px; + } + QLabel { + color: white; + } + QLabel#title { + font-weight: bold; + font-size: 14px; + color: #e0e0e0; + } + """ + ) + + layout = QVBoxLayout(self) + self.title_label = QLabel("") + self.title_label.setObjectName("title") + self.artist_label = QLabel("") + self.album_label = QLabel("") + self.genre_label = QLabel("") + self.year_label = QLabel("") + self.size_label = QLabel("") + + layout.addWidget(self.title_label) + layout.addWidget(self.artist_label) + layout.addWidget(self.album_label) + layout.addWidget(self.genre_label) + layout.addWidget(self.year_label) + layout.addWidget(self.size_label) + + self.setLayout(layout) + self.hide() + + def set_metadata(self, metadata): + """Update the tooltip with metadata""" + self.title_label.setText(metadata["title"]) + self.artist_label.setText(f"Artist: {metadata['artist']}") + self.album_label.setText(f"Album: {metadata['album']}") + self.genre_label.setText(f"Genre: {metadata['genre']}") + self.year_label.setText(f"Year: {metadata['year']}") + self.size_label.setText(f"Size: {metadata['size']}") + + # Adjust size to fit content + self.adjustSize() + + +class Fbrowser(QMainWindow): + """Fbrowser main class""" + + def __init__(self): + super().__init__() + self.midplay = None + self.current_path = QDir.homePath() + self.organizer = Organizer() + self.player = QMediaPlayer() + self.audio_output = QAudioOutput() + self.player.setAudioOutput(self.audio_output) + self.audio_output.setVolume(0.5) # 50% volume + self.init_ui() + self.timer = Timer_Ui() + self.history = [] + self.history_position = -1 + self.hover_timer = QTimer() + self.hover_timer.setSingleShot(True) + self.hover_timer.timeout.connect(self.on_hover_timeout) + self.hover_position = None + self.hover_path = None + self.metadata_cache = {} + + def init_ui(self): + """initilize UI""" + self.setWindowTitle("File Browser") + central_widget = QWidget() + layout = QVBoxLayout(central_widget) + + # Address bar + address_layout = QHBoxLayout() + self.address_bar = QLineEdit(self.current_path) + self.address_bar.returnPressed.connect(self.navigate_to_address) + address_layout.addWidget(self.address_bar) + + # Navigation buttons + back_button = QPushButton("Back") + back_button.clicked.connect(self.go_back) + address_layout.addWidget(back_button) + + forward_button = QPushButton("Forward") + forward_button.clicked.connect(self.go_forward_directory) + address_layout.addWidget(forward_button) + + up_button = QPushButton("Up") + up_button.clicked.connect(self.go_up_directory) + address_layout.addWidget(up_button) + + layout.addLayout(address_layout) + + # Add a progress bar + self.progress_bar = QProgressBar() + layout.addWidget(self.progress_bar) + + # File view with splitter + splitter = QSplitter() + + # Directory tree + self.tree_model = QFileSystemModel() + self.tree_model.setRootPath(self.current_path) + self.directory_model = DirectoryFilterProxyModel() + self.directory_model.setSourceModel(self.tree_model) + + self.file_tree = QTreeView() + self.file_tree.setModel(self.directory_model) + self.file_tree.setRootIndex( + self.directory_model.mapFromSource( + self.tree_model.index(self.current_path) + ) + ) + self.file_tree.setHeaderHidden(True) + self.file_tree.clicked.connect(self.change_directory) + splitter.addWidget(self.file_tree) + + # File list + self.list_model = QFileSystemModel() + self.list_model.setRootPath(self.current_path) + self.file_filter_model = FileFilterProxyModel() + self.file_filter_model.setSourceModel(self.list_model) + + self.folder_contents_view = QTreeView() + self.folder_contents_view.setModel(self.file_filter_model) + self.folder_contents_view.setRootIndex( + self.file_filter_model.mapFromSource( + self.list_model.index(self.current_path) + ) + ) + self.folder_contents_view.setEditTriggers( + QTreeView.EditTrigger.NoEditTriggers + ) + self.folder_contents_view.doubleClicked.connect( + self.on_item_double_clicked + ) + splitter.addWidget(self.folder_contents_view) + + # Set up context menu for both views + self.folder_contents_view.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.folder_contents_view.customContextMenuRequested.connect( + self.on_folder_contents_context_menu + ) + + self.file_tree.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.file_tree.customContextMenuRequested.connect( + self.on_file_tree_context_menu + ) + + layout.addWidget(splitter) + + # Current directory label + self.current_dir_label = QLabel(self.current_path) + layout.addWidget(self.current_dir_label) + + # Media controls + media_controls = QHBoxLayout() + + play_button = QPushButton("Play") + play_button.clicked.connect(self.player.play) + media_controls.addWidget(play_button) + + pause_button = QPushButton("Pause") + pause_button.clicked.connect(self.player.pause) + media_controls.addWidget(pause_button) + + stop_button = QPushButton("Stop") + stop_button.clicked.connect(self.player.stop) + media_controls.addWidget(stop_button) + + volume_slider = QSlider(Qt.Orientation.Horizontal) + volume_slider.setRange(0, 100) + volume_slider.setValue(50) + volume_slider.valueChanged.connect(self.set_volume) + media_controls.addWidget(volume_slider) + + layout.addLayout(media_controls) + + # MidPlay button + open_midplay_button = QPushButton("Open MidPlay") + open_midplay_button.clicked.connect(self.open_midplay) + layout.addWidget(open_midplay_button) + + # Timer Button + self.timer_button = QPushButton("Start Timer") + self.timer_button.clicked.connect(self.open_timer) + layout.addWidget(self.timer_button) + + # Exit button + exit_button = QPushButton("Exit") + exit_button.clicked.connect(self.show_exit_popup) + layout.addWidget(exit_button) + + self.setCentralWidget(central_widget) + self.resize(800, 600) + self.folder_contents_view.setMouseTracking(True) + self.folder_contents_view.viewport().setMouseTracking(True) # type: ignore + self.folder_contents_view.viewport().installEventFilter(self) # type: ignore + + def set_volume(self, volume): + """Set Volume""" + self.audio_output.setVolume(volume / 100.0) + + def show_exit_popup(self): + """Exit Prompt""" + reply = QMessageBox.question( + self, + "Exit", + "Are you sure you want to exit?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + sys.exit() + + def navigate_to_address(self): + """Navigation Via Address Bar""" + new_path = self.address_bar.text() + if os.path.isdir(new_path): + self.add_to_history(self.current_path) + self.current_path = new_path + self.update_file_views(new_path) + else: + self.address_bar.setText(self.current_path) + + def add_to_history(self, path): + """History""" + # If we're not at the end of the history, truncate it + if self.history_position < len(self.history) - 1: + self.history = self.history[: self.history_position + 1] + + self.history.append(path) + self.history_position = len(self.history) - 1 + + def change_directory(self, index): + """Change dir to user dir""" + index = self.directory_model.mapToSource(index) + try: + file_path = self.tree_model.filePath(index) + if os.path.isdir(file_path): + self.add_to_history(self.current_path) + self.current_path = file_path + self.update_file_views(file_path) + except (OSError, IOError) as e: + print(f"Error Changing Dirs.: {e}") + + def update_file_views(self, path): + """Updates the File trees""" + self.address_bar.setText(path) + self.current_dir_label.setText(path) + + # Update tree view + self.file_tree.setRootIndex( + self.directory_model.mapFromSource(self.tree_model.index(path)) + ) + + # Update file list view + self.list_model.setRootPath(path) + self.folder_contents_view.setRootIndex( + self.file_filter_model.mapFromSource(self.list_model.index(path)) + ) + + def on_item_double_clicked(self, index): + """on double click do stuff""" + source_index = self.file_filter_model.mapToSource(index) + path = self.list_model.filePath(source_index) + + if os.path.isdir(path): + self.add_to_history(self.current_path) + self.current_path = path + self.update_file_views(path) + else: + self.play_file(path) + + def play_file(self, file_path): + """Play a Audio file if midi load midi player""" + if file_path.lower().endswith((".mid", ".midi")): + if not self.midplay: + self.midplay = MidPlay() + self.midplay.add_to_playlist(file_path) + self.midplay.load_midi(file_path) + self.midplay.play_midi(file_path) + else: + # For audio files + self.player.setSource(QUrl.fromLocalFile(file_path)) + self.player.play() + + def go_back(self): + """Go back a dir""" + if self.history_position > 0: + self.history_position -= 1 + path = self.history[self.history_position] + self.current_path = path + self.update_file_views(path) + + def go_forward_directory(self): + """Go forward a dir""" + if self.history_position < len(self.history) - 1: + self.history_position += 1 + path = self.history[self.history_position] + self.current_path = path + self.update_file_views(path) + + def go_up_directory(self): + """Similar to back""" + parent_dir = os.path.dirname(self.current_path) + if parent_dir != self.current_path: + self.add_to_history(self.current_path) + self.current_path = parent_dir + self.update_file_views(parent_dir) + + def open_midplay(self): + """Opens the midi player""" + if not self.midplay: + self.midplay = MidPlay() + self.midplay.showUI() + + def open_timer(self): + """Opens the Timer""" + self.timer.showUI() + + def create_context_menu(self, position, view): + """Create and display a context menu for files and folders""" + # Get the index at position + if view == self.folder_contents_view: + index = self.folder_contents_view.indexAt(position) + if not index.isValid(): + return + + # Map to source model to get the actual file path + source_index = self.file_filter_model.mapToSource(index) + path = self.list_model.filePath(source_index) + else: # Directory tree + index = self.file_tree.indexAt(position) + if not index.isValid(): + return + + source_index = self.directory_model.mapToSource(index) + path = self.tree_model.filePath(source_index) + + # Create menu + menu = QMenu() + + if os.path.isdir(path): + # Directory options + scan_action = menu.addAction("Scan Directory") + scan_action.triggered.connect(lambda: self.scan_directory(path)) # type: ignore + + extract_metadata_action = menu.addAction("Extract Audio Metadata") + extract_metadata_action.triggered.connect( # type: ignore + lambda: self.extract_audio_metadata(path) + ) + + menu.addSeparator() + + elif os.path.isfile(path): + # File options + if path.lower().endswith((".zip", ".rar", ".7z")): + # Archive options + extract_here_action = menu.addAction("Extract Here") + extract_here_action.triggered.connect( # type: ignore + lambda: self.extract_archive(path, self.current_path) + ) + + extract_to_action = menu.addAction("Extract To...") + extract_to_action.triggered.connect( # type: ignore + lambda: self.extract_archive_to(path) + ) + + if path.lower().endswith( + (".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi") + ): + # Audio file options + play_action = menu.addAction("Play") + play_action.triggered.connect(lambda: self.play_file(path)) # type: ignore + + extract_metadata_action = menu.addAction("Extract Metadata") + extract_metadata_action.triggered.connect( # type: ignore + lambda: self.extract_file_metadata(path) + ) + + # Add general options + menu.addSeparator() + refresh_action = menu.addAction("Refresh") + refresh_action.triggered.connect( # type: ignore + lambda: self.update_file_views(self.current_path) + ) + + # Show menu + menu.exec(view.viewport().mapToGlobal(position)) + + def on_folder_contents_context_menu(self, position): + """Handle context menu in folder contents view""" + self.create_context_menu(position, self.folder_contents_view) + + def on_file_tree_context_menu(self, position): + """Handle context menu in file tree view""" + self.create_context_menu(position, self.file_tree) + + def scan_directory(self, path): + """Scan a directory using ScanOrg101""" + self.progress_bar.setValue(0) + + # Connect organizer signals + self.organizer.on_progress_update = self.update_progress # type: ignore + self.organizer.on_scan_complete = lambda: self.show_scan_results(path) # type: ignore + + # Start scan + self.organizer.start_scan(path) + + def extract_archive(self, archive_path, extract_dir): + """Extract an archive to specified directory""" + self.progress_bar.setValue(0) + + # Create an extractor instance + self.extract_dir = extract_dir + self.archive_extractor = self.organizer.archive_extractor = ( + ArchiveExtractor(archive_path, extract_dir) + ) + + # Connect signals + self.archive_extractor.extraction_progress.connect( + self.update_progress + ) + self.archive_extractor.extraction_complete.connect( + self.extraction_complete + ) + self.archive_extractor.extraction_error.connect( + self.show_error_message + ) + + # Start extraction + self.archive_extractor.start() + + def extract_archive_to(self, archive_path): + """Extract an archive to a user-selected directory""" + extract_dir = QFileDialog.getExistingDirectory( + self, "Select Extraction Directory", self.current_path + ) + + if extract_dir: + self.extract_archive(archive_path, extract_dir) + + def extraction_complete(self, extracted_files): + """Handle archive extraction completion""" + self.progress_bar.setValue(100) + self.update_file_views(self.extract_dir) + self.show_message( + f"Extraction complete: {len(extracted_files)} files extracted" + ) + + def extract_audio_metadata(self, directory_path): + """Extract metadata from audio files in a directory""" + self.progress_bar.setValue(0) + + # Scan directory first to get file list + self.organizer.on_progress_update = self.update_progress # type: ignore + self.organizer.on_scan_complete = ( # type: ignore + lambda: self.start_metadata_extraction(directory_path) + ) + + self.organizer.start_scan(directory_path) + + def start_metadata_extraction(self, directory_path): + """Start metadata extraction after scanning is complete""" + self.organizer.on_progress_update = self.update_progress # type: ignore + self.organizer.on_metadata_complete = ( # type: ignore + lambda: self.show_metadata_results() + ) + + self.organizer.extract_metadata() + + def extract_file_metadata(self, file_path): + """Extract metadata from a single audio file""" + self.progress_bar.setValue(0) + + # Create a temporary file list with just this file + self.organizer.file_list = [file_path] + + # Set up extraction + self.organizer.on_progress_update = self.update_progress # type: ignore + self.organizer.on_metadata_complete = ( # type: ignore + lambda: self.show_metadata_results(single_file=True) + ) + + # Start extraction + self.organizer.extract_metadata() + + def update_progress(self, value): + """Update the progress bar""" + self.progress_bar.setValue(value) + + def show_scan_results(self, path): + """Display scan results""" + self.progress_bar.setValue(100) + file_count = len(self.organizer.file_list) + dir_count = len(self.organizer.dir_list) + self.show_message( + f"Scan complete: {file_count} files, {dir_count} directories found" + ) + + def show_metadata_results(self, single_file=False): + """Display metadata extraction results""" + self.progress_bar.setValue(100) + + if single_file: + self.show_message("Metadata extraction complete") + # dialog to show metadata in a context menu + self.show_metadata_dialog() + + else: + artists = len(self.organizer.artists) + albums = len(self.organizer.albums) + genres = len(self.organizer.genres) + self.show_message( + f"Metadata extraction complete: {artists} artists, {albums} albums, {genres} genres found" + ) + + def show_metadata_dialog(self): + """Show a dialog with metadata information""" + # Create a dialog with metadata information + dialog = QDialog(self) + dialog.setWindowTitle("Metadata Information") + # layout = QVBoxLayout() + + def show_message(self, message): + """Show a message in a message box""" + QMessageBox.information(self, "Information", message) + + def show_error_message(self, error): + """Show an error message""" + QMessageBox.critical(self, "Error", error) + + def eventFilter(self, obj, event): + """Handle hover events for metadata tooltips""" + if obj == self.folder_contents_view.viewport(): + if event.type() == QEvent.Type.HoverMove: + pos = event.position().toPoint() + # Get the item under the cursor + index = self.folder_contents_view.indexAt(pos) + if index.isValid(): + source_index = self.file_filter_model.mapToSource(index) + path = self.list_model.filePath(source_index) + + # Only proceed for audio files + if path.lower().endswith( + ( + ".mp3", + ".wav", + ".flac", + ".m4a", + ".wma", + ".mid", + ".midi", + ) + ): + # If we've moved to a new position or new file, restart the timer + if self.hover_path != path or ( + self.hover_position + and ( + abs(self.hover_position.x() - pos.x()) > 5 + or abs(self.hover_position.y() - pos.y()) > 5 + ) + ): + self.hover_timer.stop() + self.hover_position = pos + self.hover_path = path + self.hover_timer.start(1200) + return True + else: + # Not an audio file, stop any running timer + self.hover_timer.stop() + self.hover_path = None + + else: + # No valid item under cursor + self.hover_timer.stop() + self.hover_path = None + + elif event.type() == QEvent.Type.Leave: + # Mouse left the widget, stop the timer + self.hover_timer.stop() + self.hover_path = None + QToolTip.hideText() + + return super().eventFilter(obj, event) + + def on_hover_timeout(self): + """Called when the hover timer expires, extract and show metadata""" + if not self.hover_path: + return + + # Check if we already have metadata for this file in cache + if self.hover_path in self.metadata_cache: + self.show_metadata_tooltip(self.metadata_cache[self.hover_path]) + else: + # Extract metadata in background + self.extract_hover_metadata(self.hover_path) + + def extract_hover_metadata(self, file_path): + """Extract metadata for the hovered file""" + try: + # Use mutagen directly for speed + audio = mutagen.File(file_path) # type: ignore + if not audio: + return + + # Extract basic metadata + metadata = { + "file_path": file_path, + "filename": os.path.basename(file_path), + "size": f"{os.path.getsize(file_path) / (1024*1024):.2f} MB", + "artist": self._get_tag(audio, "artist", "Unknown Artist"), + "album": self._get_tag(audio, "album", "Unknown Album"), + "title": self._get_tag( + audio, "title", os.path.basename(file_path) + ), + "genre": self._get_tag(audio, "genre", "Unknown Genre"), + "year": self._get_tag(audio, "date", "Unknown Year"), + } + + # Cache the metadata + self.metadata_cache[file_path] = metadata + + # Show the tooltip if we're still hovering over the same file + if self.hover_path == file_path: + self.show_metadata_tooltip(metadata) + + except Exception as e: + print(f"Error extracting metadata for hover: {e}") + + def _get_tag(self, audio, tag_name, default_value): + """Helper method to safely extract tags from audio files""" + try: + if tag_name in audio: + value = audio[tag_name] + if isinstance(value, list) and len(value) > 0: + return str(value[0]) + return str(value) + except Exception: + pass + return default_value + + def show_metadata_tooltip(self, metadata): + """Display metadata in a stylish custom tooltip""" + # Create or get the tooltip + if not hasattr(self, "metadata_tooltip"): + self.metadata_tooltip = MetadataTooltip() + + # Update tooltip content + self.metadata_tooltip.set_metadata(metadata) + + # Position near but not under cursor + cursor_pos = QCursor.pos() + tooltip_pos = QPoint(cursor_pos.x() + 15, cursor_pos.y() + 15) + + # Show the tooltip + self.metadata_tooltip.move(tooltip_pos) + self.metadata_tooltip.show() + + # Auto-hide after 8 seconds + QTimer.singleShot(8000, self.metadata_tooltip.hide) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + ex = Fbrowser() + ex.show() + sys.exit(app.exec()) diff --git a/MidPlay.py b/MidPlay.py new file mode 100644 index 0000000..26965bd --- /dev/null +++ b/MidPlay.py @@ -0,0 +1,524 @@ +# 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 diff --git a/ScanOrg101.py b/ScanOrg101.py new file mode 100644 index 0000000..eede8d5 --- /dev/null +++ b/ScanOrg101.py @@ -0,0 +1,651 @@ +""" +ScanOrg101.py - Enhanced file scanning and organization module +""" + +# flake8: noqa: E501 +import os +import concurrent.futures +import zipfile +import py7zr +import rarfile # typed: ignore +import mutagen +from PyQt6.QtCore import Qt, QThread, QSortFilterProxyModel, pyqtSignal + + +# Directory Filter Proxy Model +class DirectoryFilterProxyModel(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setFilterKeyColumn(0) + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + if source_model is None: + return False + + index = source_model.index(source_row, 0, source_parent) + + if hasattr(source_model, "isDir"): + return source_model.isDir(index) # type: ignore + return False + + +# File Filter Proxy Model +class FileFilterProxyModel(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setFilterKeyColumn(0) + self.allowed_extensions = [ + ".zip", + ".mp3", + ".wav", + ".flac", + ".mid", + ".midi", + ".aiff", + ".aif", + ".aifc", + ".au", + ".snd", + ".wv", + ".wma", + ".m4a", + ".7z", + ".rar", + ] + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + if source_model is None: + return False + + index = source_model.index(source_row, 0, source_parent) + + if hasattr(source_model, "isDir") and source_model.isDir(index): # type: ignore + return True + + if hasattr(source_model, "fileName"): + return source_model.fileName(index).endswith( # type: ignore + tuple(self.allowed_extensions) + ) + + return False + + +# Enhanced File Scanner with optimizations +class FileScanner(QThread): + items_found = pyqtSignal(list) # Now emits batches of items + scan_complete = pyqtSignal() + progress_update = pyqtSignal(int) + directory_scanned = pyqtSignal(str) # New signal for lazy loading + + def __init__(self, path, batch_size=500, max_workers=4): + """ + Initialize the file scanner with performance optimizations + + Args: + path: Starting path to scan + batch_size: Number of items to collect before emitting a batch + max_workers: Maximum number of parallel scanning threads + """ + super().__init__() + self.path = path + self.stop_requested = False + self.cache = {} + self.scanned_directories = ( + set() + ) # Track which directories have been scanned + self.batch_size = batch_size + self.max_workers = max_workers + self.allowed_extensions = { + ".mid", + ".midi", + ".mp3", + ".wav", + ".ogg", + ".flac", + ".aac", + ".m4a", + ".wma", + ".flp", + ".als", + ".logic", + ".logicx", + ".ptx", + ".pts", + ".cpr", + ".rpp", + ".reason", + ".sng", + ".ardour", + ".bwproject", + ".zip", + ".7z", + ".rar", + } + + def run(self): + """Main thread run method - only scans the root path initially""" + # Check cache first + if self.path in self.cache: + self.items_found.emit(self.cache[self.path]) + self.scan_complete.emit() + return + + # Only scan the top level directory initially (lazy loading) + self.scan_single_directory(self.path) + self.scan_complete.emit() + + def scan_directory_recursive(self, path): + """ + Recursively scan a directory - used when explicitly requesting + a full scan of all subdirectories + """ + if path in self.cache: + return self.cache[path] + + items = [] + batch = [] + dirs_to_scan = deque([path]) # type: ignore + + # For progress estimation + start_time = time.time() # type: ignore + progress_update_interval = 0.2 # seconds + last_update_time = start_time + entries_processed = 0 + + # Estimate total number of items + try: + sample_count = len(list(os.scandir(path))) + estimated_total = sample_count * 10 # Simple heuristic + except (PermissionError, OSError): + estimated_total = 1000 # Fallback estimate + + with concurrent.futures.ThreadPoolExecutor( + max_workers=self.max_workers + ) as executor: + futures = {} + + while dirs_to_scan and not self.stop_requested: + # Process directories in parallel + while dirs_to_scan and len(futures) < self.max_workers: + dir_path = dirs_to_scan.popleft() + if dir_path not in self.scanned_directories: + futures[ + executor.submit( + self.scan_single_directory_helper, dir_path + ) + ] = dir_path + + # Process completed directories + for future in list( + concurrent.futures.as_completed(futures.keys()) + ): + dir_path = futures.pop(future) + + try: + dir_items, subdirs = future.result() + entries_processed += len(dir_items) + + # Add results to our list + items.extend(dir_items) + batch.extend(dir_items) + + # Add subdirectories to our queue + dirs_to_scan.extend(subdirs) + + # Mark directory as scanned + self.scanned_directories.add(dir_path) + self.directory_scanned.emit(dir_path) + + # Emit batch if it's full + if len(batch) >= self.batch_size: + self.items_found.emit(batch) + batch = [] + + # Update progress periodically + current_time = time.time() # type: ignore + if ( + current_time - last_update_time + > progress_update_interval + ): + # Simple progress estimation + progress = min( + 99, + int(entries_processed / estimated_total * 100), + ) + self.progress_update.emit(progress) + last_update_time = current_time + except Exception as e: + print(f"Error scanning directory {dir_path}: {e}") + + # Emit any remaining items in the final batch + if batch and not self.stop_requested: + self.items_found.emit(batch) + + # Store in cache + self.cache[path] = items + self.progress_update.emit(100) # Final update + return items + + def scan_single_directory(self, path): + """ + Scan a single directory without recursion - supports lazy loading + """ + if self.stop_requested: + return [] + + if path in self.cache: + items = self.cache[path] + self.items_found.emit(items) + return items + + try: + items = [] + with os.scandir(path) as entries: + for entry in entries: + if self.stop_requested: + break + + if entry.is_dir(): + # For directories, just add them to the list + # but don't scan them yet (lazy loading) + items.append((entry.path, True)) + elif entry.is_file() and entry.name.lower().endswith( + tuple(self.allowed_extensions) + ): + items.append((entry.path, False)) + + # Store in cache and emit + self.cache[path] = items + self.items_found.emit(items) + self.scanned_directories.add(path) + self.directory_scanned.emit(path) + self.progress_update.emit(100) # Show complete for this directory + return items + except PermissionError: + print(f"Permission denied: {path}") + return [] + except OSError as e: + print(f"Error accessing {path}: {e}") + return [] + + def scan_single_directory_helper(self, path): + """Helper method for parallel directory scanning""" + items = [] + subdirs = [] + + try: + with os.scandir(path) as entries: + for entry in entries: + if self.stop_requested: + break + + if entry.is_dir(): + items.append((entry.path, True)) + subdirs.append(entry.path) + elif entry.is_file() and entry.name.lower().endswith( + tuple(self.allowed_extensions) + ): + items.append((entry.path, False)) + except (PermissionError, OSError) as e: + print(f"Error accessing {path}: {e}") + + return items, subdirs + + def request_directory_scan(self, path): + """Request a scan of a specific directory (for lazy loading)""" + if path in self.scanned_directories: + return + + items = self.scan_single_directory(path) + return items + + def request_full_scan(self): + """Request a full recursive scan of all subdirectories""" + items = self.scan_directory_recursive(self.path) + self.scan_complete.emit() + return items + + def stop(self): + self.stop_requested = True + + +# Metadata Extractor +class MetadataExtractor(QThread): + metadata_extracted = pyqtSignal(dict) + extraction_complete = pyqtSignal() + progress_update = pyqtSignal(int) + + def __init__(self, file_list): + super().__init__() + self.file_list = file_list + self.stop_requested = False + self.metadata_cache = {} + + def run(self): + total_files = len(self.file_list) + processed_files = 0 + + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [] + for file_path in self.file_list: + if self.stop_requested: + break + if file_path in self.metadata_cache: + self.metadata_extracted.emit( + self.metadata_cache[file_path] + ) + processed_files += 1 + self.progress_update.emit( + int(processed_files / total_files * 100) + ) + else: + futures.append( + executor.submit(self.extract_metadata, file_path) + ) + + for future in concurrent.futures.as_completed(futures): + if self.stop_requested: + break + try: + metadata = future.result() + if metadata: + self.metadata_extracted.emit(metadata) + except Exception as e: + print(f"Error extracting metadata: {e}") + + processed_files += 1 + self.progress_update.emit( + int(processed_files / total_files * 100) + ) + + self.extraction_complete.emit() + + def extract_metadata(self, file_path): + try: + if not os.path.isfile(file_path): + return None + + # Skip non-audio files + if not file_path.lower().endswith( + (".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi") + ): + return None + + audio = mutagen.File(file_path) # type: ignore + if not audio: + return None + + metadata = { + "file_path": file_path, + "artist": self._get_tag(audio, "artist", "Unknown Artist"), + "album": self._get_tag(audio, "album", "Unknown Album"), + "title": self._get_tag( + audio, "title", os.path.basename(file_path) + ), + "genre": self._get_tag(audio, "genre", "Unknown Genre"), + "year": self._get_tag(audio, "date", "Unknown Year"), + } + + # Cache the result + self.metadata_cache[file_path] = metadata + return metadata + except Exception as e: + print(f"Error processing {file_path}: {e}") + return None + + def _get_tag(self, audio, tag_name, default_value): + """Helper method to safely extract tags from audio files""" + try: + if tag_name in audio: + value = audio[tag_name] + if isinstance(value, list) and len(value) > 0: + return str(value[0]) + return str(value) + except Exception: + pass + return default_value + + def stop(self): + self.stop_requested = True + + +# Archive Extractor not fully tested or implemented +class ArchiveExtractor(QThread): + extraction_progress = pyqtSignal(int) + extraction_complete = pyqtSignal(list) # Emits list of extracted files + extraction_error = pyqtSignal(str) + + def __init__(self, archive_path, extraction_dir): + super().__init__() + self.archive_path = archive_path + self.extraction_dir = extraction_dir + self.stop_requested = False + + def run(self): + try: + extracted_files = [] + + if self.archive_path.lower().endswith(".zip"): + extracted_files = self._extract_zip() + elif self.archive_path.lower().endswith(".7z"): + extracted_files = self._extract_7z() + elif self.archive_path.lower().endswith(".rar"): + extracted_files = self._extract_rar() + else: + self.extraction_error.emit( + f"Unsupported archive format: {self.archive_path}" + ) + return + + self.extraction_complete.emit(extracted_files) + except Exception as e: + self.extraction_error.emit(f"Extraction error: {str(e)}") + + def _extract_zip(self): + extracted_files = [] + try: + with zipfile.ZipFile(self.archive_path, "r") as zip_ref: + file_list = zip_ref.namelist() + total_files = len(file_list) + + for i, file in enumerate(file_list): + if self.stop_requested: + break + zip_ref.extract(file, self.extraction_dir) + extracted_files.append( + os.path.join(self.extraction_dir, file) + ) + self.extraction_progress.emit( + int((i + 1) / total_files * 100) + ) + except Exception as e: + self.extraction_error.emit(f"ZIP extraction error: {str(e)}") + + return extracted_files + + def _extract_7z(self): + extracted_files = [] + try: + with py7zr.SevenZipFile(self.archive_path, mode="r") as z: + file_list = z.getnames() + total_files = len(file_list) + + for i, file in enumerate(file_list): + if self.stop_requested: + break + z.extract(self.extraction_dir, [file]) + extracted_files.append( + os.path.join(self.extraction_dir, file) + ) + self.extraction_progress.emit( + int((i + 1) / total_files * 100) + ) + except Exception as e: + self.extraction_error.emit(f"7Z extraction error: {str(e)}") + + return extracted_files + + def _extract_rar(self): + extracted_files = [] + try: + with rarfile.RarFile(self.archive_path) as rf: + file_list = rf.namelist() + total_files = len(file_list) + + for i, file in enumerate(file_list): + if self.stop_requested: + break + rf.extract(file, self.extraction_dir) + extracted_files.append( + os.path.join(self.extraction_dir, file) + ) + self.extraction_progress.emit( + int((i + 1) / total_files * 100) + ) + except Exception as e: + self.extraction_error.emit(f"RAR extraction error: {str(e)}") + + return extracted_files + + def stop(self): + self.stop_requested = True + + +# Main Organizer class +class Organizer: + def __init__(self): + self.file_list = [] + self.dir_list = [] + self.scanner = None + self.metadata_extractor = None + self.archive_extractor = None + + # Metadata organization + self.artists = set() + self.albums = set() + self.genres = set() + self.years = set() + + # Signals for UI updates + self.on_scan_complete = None + self.on_progress_update = None + self.on_metadata_complete = None + + def start_scan(self, path): + """Start scanning a directory for files""" + self.file_list.clear() + self.dir_list.clear() + + self.scanner = FileScanner(path) + self.scanner.items_found.connect(self.add_items) + self.scanner.scan_complete.connect(self.scan_finished) + + # Connect progress signal if handler exists + if self.on_progress_update: + self.scanner.progress_update.connect(self.on_progress_update) + + self.scanner.start() + + def add_items(self, items): + """Process items found during scanning""" + for path, is_dir in items: + if is_dir: + self.dir_list.append(path) + else: + self.file_list.append(path) + + def scan_finished(self): + """Handle scan completion""" + print( + f"Scan complete. Found {len(self.dir_list)} directories\ + and {len(self.file_list)} files." + ) + if self.on_scan_complete: + self.on_scan_complete() + + def stop_scan(self): + """Stop the current scan operation""" + if self.scanner: + self.scanner.stop() + self.scanner.wait() + + def extract_metadata(self): + """Extract metadata from audio files""" + if not self.file_list: + print("No files to extract metadata from") + return + + self.metadata_extractor = MetadataExtractor(self.file_list) + self.metadata_extractor.metadata_extracted.connect( + self.process_metadata + ) + self.metadata_extractor.extraction_complete.connect( + self.metadata_extraction_complete + ) + + # Connect progress signal if handler exists + if self.on_progress_update: + self.metadata_extractor.progress_update.connect( + self.on_progress_update + ) + + self.metadata_extractor.start() + + def process_metadata(self, metadata): + """Process extracted metadata""" + if "artist" in metadata and metadata["artist"]: + self.artists.add(metadata["artist"]) + if "album" in metadata and metadata["album"]: + self.albums.add(metadata["album"]) + if "genre" in metadata and metadata["genre"]: + self.genres.add(metadata["genre"]) + if "year" in metadata and metadata["year"]: + self.years.add(metadata["year"]) + + def metadata_extraction_complete(self): + """Handle metadata extraction completion""" + print( + f"Metadata extraction complete. Artists: {len(self.artists)},\ + Albums: {len(self.albums)}, Genres: {len(self.genres)},\ + Years: {len(self.years)}" + ) + if self.on_metadata_complete: + self.on_metadata_complete() + + def extract_archives(self): + """Extract archives""" + if not self.file_list: + print("No files to extract archives from") + return + + self.archive_extractor = ArchiveExtractor( + self.file_list, extraction_dir=None + ) + self.archive_extractor.extraction_complete.connect( + self.archive_extraction_complete + ) + + # Connect progress signal if handler exists + if self.on_progress_update: + self.archive_extractor.extraction_progress.connect( + self.on_progress_update + ) + + self.archive_extractor.start() + + def archive_extraction_complete(self): + """Handle archive extraction completion""" + print("Archive extraction complete.") + + if self.on_progress_update: + self.on_progress_update(100) + + def stop_extraction(self): + """Stop the current extraction operation""" + if self.archive_extractor: + self.archive_extractor.stop() + self.archive_extractor.wait() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0f1c198 --- /dev/null +++ b/readme.md @@ -0,0 +1,127 @@ +# FBrowser - Enhanced File Browser with Audio Tools + +A powerful file browser application with special focus on audio file handling, MIDI playback, and archive management. + +## Features + +- **Advanced File Browser**: Browse directories with tree view and file list +- **Audio Playback**: Built-in player for various audio formats +- **MIDI Player**: Full-featured MIDI player with playlist management +- **Metadata Extraction**: View and explore audio file metadata +- **Archive Management**: Extract ZIP, RAR, and 7z archives +- **Timer Utility**: Built-in countdown timer with audio alerts +- **Hover Tooltips**: Quick metadata preview by hovering over audio files + +## Requirements + +- Python 3.6+ +- PyQt6 +- FluidSynth (for MIDI playback) +- Mido (MIDI handling library) +- Mutagen (audio metadata extraction) +- py7zr and rarfile (archive handling) +- SoundFont (.sf2) files for MIDI playback + +## Installation + +### 1. Clone the repository +```bash +git clone https://github.com/yourusername/fbroswer-master.git +cd fbroswer-master +``` + +### 2. Install dependencies +```bash +pip install pyqt6 fluidsynth-midi mido mutagen py7zr rarfile numpy sounddevice +``` + +### 3. Install FluidSynth + +- **Windows**: Download from [FluidSynth website](https://www.fluidsynth.org/) +- **macOS**: `brew install fluid-synth` +- **Linux**: `sudo apt-get install fluidsynth` + +### 4. Download a SoundFont + +- Download a .sf2 file (like FluidR3_GM.sf2) +- Place it in a "SoundFonts" directory in the application folder + +## Usage + +### Starting the Application +```bash +python Fbrowser.py +``` + +### Basic Navigation +- Use the tree view on the left to navigate directories +- Double-click files in the right panel to open/play them +- Use address bar for direct navigation +- Back/Forward/Up buttons for quick navigation + +### Audio and MIDI Playback +- Double-click audio files to play them +- Use media controls at the bottom for playback +- Click "Open MidPlay" for advanced MIDI playback + +### Advanced Features +- Right-click on files/folders for context menu options +- Extract archives directly from the browser +- Scan directories to find audio files +- Extract and view metadata + +## Components Overview + +### Fbrowser.py +The main application file implementing the file browser interface with audio playback capabilities. + +### MidPlay.py +A specialized MIDI player with: +- Playlist management +- SoundFont selection +- Volume control +- Playback controls (play, pause, next, previous) + +### ScanOrg101.py +Handles file scanning, metadata extraction, and archive operations: +- Recursive directory scanning +- Audio metadata extraction using Mutagen +- Archive extraction (ZIP, RAR, 7z) + +### timer_m.py +A countdown timer with: +- Customizable hours, minutes, seconds +- Audio alerts on completion +- Start/stop/reset functionality + +## Customization + +### Changing Default SoundFont +The application will attempt to find a suitable SoundFont file automatically. If none is found, you'll be prompted to select one. You can change it anytime via the MidPlay interface. + +### File Type Filtering +Edit the `allowed_extensions` list in `FileFilterProxyModel` class in ScanOrg101.py to change which file types are displayed. + +## Troubleshooting + +### No Sound in MIDI Playback +- Ensure FluidSynth is properly installed +- Verify a valid SoundFont (.sf2) file is loaded +- Check system audio settings + +### Archive Extraction Issues +- Ensure you have proper permissions in the target directory +- For RAR files, make sure UnRAR is installed on your system + +## License + +MIT License + +## Credits +- FluidSynth for MIDI synthesis +- PyQt6 for the user interface +- Mutagen for audio metadata handling +- Stanton for Project fbrowser, Timer_m, and MidPlay the fbrowser is the foundation of this project. +--- + +*Note: This project is under development. Feedback and contributions are welcome!* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce2d5d7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +PyQt6 +matplotlib +rarfile +py7zr +mido +numpy +pyFluidSynth +mutagen +sounddevice \ No newline at end of file diff --git a/timer_m.py b/timer_m.py new file mode 100644 index 0000000..67ab957 --- /dev/null +++ b/timer_m.py @@ -0,0 +1,204 @@ +# flake8: noqa: E501 +import time +import threading + +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + QHBoxLayout, + QLineEdit, +) +from PyQt6.QtCore import QTimer +import numpy as np +import sounddevice as sd # type: ignore + + +class Timer: + def __init__(self): + self.remaining = 0 + self.running = False + self.timer_thread = None + + def set(self, hours, minutes, seconds): + self.remaining = hours * 3600 + minutes * 60 + seconds + + def start(self): + if self.remaining > 0: + self.running = True + self.timer_thread = threading.Thread(target=self._run_timer) + self.timer_thread.start() + + def _run_timer(self): + while self.running and self.remaining > 0: + time.sleep(1) + self.remaining -= 1 + if self.running: + self._alarm() + + def stop(self): + self.running = False + if self.timer_thread: + self.timer_thread.join() + + def get_remaining_time(self): + return self.remaining + + def _alarm(self): + print("Time's up!") + try: + self._play_tone() + # sleep = 1.2 + self._play_tone2() + # sleep = 1 + self._play_tone3() + # sleep = 1 + self._play_tone4() + # sleep = 1 + self._play_tone5() + + except Exception as e: + print(f"Error generating alarm tone: {e}") + + def _play_tone(self, frequency=587, duration=0.2, repeat=4): + """Generate and play a tone with given frequency, duration, and repeat count.""" + sample_rate = 44100 + t = np.linspace( + 0, duration, int(sample_rate * duration), endpoint=False + ) + tone = 0.5 * np.sin(2 * np.pi * frequency * t) + for _ in range(repeat): + sd.play(tone, samplerate=sample_rate) + sd.wait() + + def _play_tone2(self, frequency=698, duration=0.2, repeat=3): + """Generate and play a tone with given frequency, duration, and repeat count.""" + sample_rate = 44100 + t = np.linspace( + 0, duration, int(sample_rate * duration), endpoint=False + ) + tone = 0.5 * np.sin(2 * np.pi * frequency * t) + for _ in range(repeat): + sd.play(tone, samplerate=sample_rate) + sd.wait() + + def _play_tone3(self, frequency=659, duration=0.2, repeat=3): + """Generate and play a tone with given frequency, duration, and repeat count.""" + sample_rate = 44100 + t = np.linspace( + 0, duration, int(sample_rate * duration), endpoint=False + ) + tone = 0.5 * np.sin(2 * np.pi * frequency * t) + for _ in range(repeat): + sd.play(tone, samplerate=sample_rate) + sd.wait() + + def _play_tone4(self, frequency=554, duration=0.4, repeat=1): + """Generate and play a tone with given frequency, duration, and repeat count.""" + sample_rate = 44100 + t = np.linspace( + 0, duration, int(sample_rate * duration), endpoint=False + ) + tone = 0.5 * np.sin(2 * np.pi * frequency * t) + for _ in range(repeat): + sd.play(tone, samplerate=sample_rate) + sd.wait() + + def _play_tone5(self, frequency=554, duration=0.8, repeat=1): + """Generate and play a tone with given frequency, duration, and repeat count.""" + sample_rate = 44100 + t = np.linspace( + 0, duration, int(sample_rate * duration), endpoint=False + ) + tone = 0.5 * np.sin(2 * np.pi * frequency * t) + for _ in range(repeat): + sd.play(tone, samplerate=sample_rate) + sd.wait() + + +class Timer_Ui(QWidget): + + def __init__(self): + super().__init__() + self.setWindowTitle("Timer") + self.setFixedWidth(400) + self.setFixedHeight(200) + self.timer = Timer() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout() + self.setLayout(layout) + + self.timer_label = QLabel("Timer: Not Set") + layout.addWidget(self.timer_label) + + timer_input_layout = QHBoxLayout() + self.hours_input = QLineEdit() + self.minutes_input = QLineEdit() + self.seconds_input = QLineEdit() + timer_input_layout.addWidget(QLabel("Hours:")) + timer_input_layout.addWidget(self.hours_input) + timer_input_layout.addWidget(QLabel("Minutes:")) + timer_input_layout.addWidget(self.minutes_input) + timer_input_layout.addWidget(QLabel("Seconds:")) + timer_input_layout.addWidget(self.seconds_input) + layout.addLayout(timer_input_layout) + + self.set_timer_button = QPushButton("Set Timer") + self.set_timer_button.clicked.connect(self.set_timer) + layout.addWidget(self.set_timer_button) + + self.start_timer_button = QPushButton("Start Timer") + self.start_timer_button.clicked.connect(self.start_timer) + layout.addWidget(self.start_timer_button) + + self.stop_timer_button = QPushButton("Stop Timer") + self.stop_timer_button.clicked.connect(self.stop_timer) + layout.addWidget(self.stop_timer_button) + + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update_timer_display) + self.update_timer.start(1000) + + def set_timer(self): + try: + hours = int(self.hours_input.text() or 0) + minutes = int(self.minutes_input.text() or 0) + seconds = int(self.seconds_input.text() or 0) + self.timer.set(hours, minutes, seconds) + self.timer_label.setText( + f"Timer: Set to {hours:02d}:{minutes:02d}:{seconds:02d}" + ) + except ValueError: + self.timer_label.setText("Timer: Invalid input") + + def start_timer(self): + if self.timer.get_remaining_time() > 0: + self.timer.start() + else: + self.timer_label.setText("Timer: Not set") + + def stop_timer(self): + self.timer.stop() + self.timer_label.setText("Timer: Stopped") + + def update_timer_display(self): + if self.timer.running: + remaining = self.timer.get_remaining_time() + hours, remainder = divmod(remaining, 3600) + minutes, seconds = divmod(remainder, 60) + self.timer_label.setText( + f"Timer: {hours:02d}:{minutes:02d}:{seconds:02d}" + ) + elif ( + self.timer.get_remaining_time() == 0 + and self.timer_label.text() != "Timer: Not Set" + ): + self.timer_label.setText("Timer: Time's up!") + + def showUI(self): + self.setWindowTitle("Timer") + self.setGeometry(100, 100, 300, 200) + super().show()