Initial commit

This commit is contained in:
Stan44 2025-04-06 20:00:26 -05:00
commit 3b909bb756
7 changed files with 2364 additions and 0 deletions

113
.gitignore vendored Normal file
View File

@ -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

736
Fbrowser.py Normal file
View File

@ -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())

524
MidPlay.py Normal file
View File

@ -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

651
ScanOrg101.py Normal file
View File

@ -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()

127
readme.md Normal file
View File

@ -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!*

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
PyQt6
matplotlib
rarfile
py7zr
mido
numpy
pyFluidSynth
mutagen
sounddevice

204
timer_m.py Normal file
View File

@ -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()