diff --git a/analysis_results_manager.py b/analysis_results_manager.py new file mode 100644 index 0000000..0ec23e4 --- /dev/null +++ b/analysis_results_manager.py @@ -0,0 +1,124 @@ +""" +Analysis Results Manager - Bridge between audio processing and GUI. +Manages analysis queue and coordinates between components. +""" + +from PyQt5.QtCore import QObject, pyqtSignal +from dataclasses import dataclass +from typing import Optional +import os + +from master_core import AudioFile +from plotting_engine import PlottingEngine + + +@dataclass +class AnalysisResult: + """Container for audio analysis results.""" + file_path: str + song_name: str + bpm: float + max_amplitude: float + avg_amplitude: float + times: list + rms_array: list + analysis_successful: bool = True + error_message: str = "" + + +class AnalysisResultsManager(QObject): + """ + Manages audio file analysis and coordinates between processing and GUI. + Threading-ready architecture for future background processing. + """ + + # Signals for GUI communication + analysisStarted = pyqtSignal(str) # file_path + analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult + analysisError = pyqtSignal(str, str) # file_path, error_message + + def __init__(self): + super().__init__() + self.results_cache = {} # Store analysis results + self.plotting_engine = PlottingEngine() + + def analyze_file(self, file_path: str, window: int = 10, hop: int = 2): + """ + Analyze an audio file and emit results. + Currently synchronous - ready for threading later. + + Args: + file_path: Path to audio file + window: RMS analysis window size in seconds + hop: Analysis hop size in seconds + """ + if not os.path.exists(file_path): + error_msg = f"File not found: {file_path}" + self.analysisError.emit(file_path, error_msg) + return + + # Emit analysis started signal + self.analysisStarted.emit(file_path) + + try: + # Create AudioFile and perform analysis + audio_file = AudioFile(file_path) + + # Get RMS analysis data + audio_file.get_energy_levels_over_time(window=window, hop=hop) + + # Extract analysis results + result = AnalysisResult( + file_path=file_path, + song_name=audio_file.song_name, + bpm=audio_file.get_bpm(), + max_amplitude=audio_file.max_amplitude, + avg_amplitude=audio_file.avg_amplitude, + times=audio_file._get_times(), # We'll need to add this method + rms_array=audio_file.rms_array, + analysis_successful=True + ) + + # Cache the result + self.results_cache[file_path] = result + + # Emit completion signal + self.analysisCompleted.emit(file_path, result) + + except Exception as e: + error_msg = f"Analysis failed: {str(e)}" + self.analysisError.emit(file_path, error_msg) + + def get_analysis_figure(self, file_path: str): + """ + Get matplotlib figure for a previously analyzed file. + + Returns: + matplotlib.figure.Figure or None + """ + if file_path not in self.results_cache: + return None + + result = self.results_cache[file_path] + return self.plotting_engine.create_power_analysis_figure( + result.times, result.rms_array, result.file_path + ) + + def get_metadata_text(self, file_path: str) -> str: + """Get formatted metadata text for a file.""" + if file_path not in self.results_cache: + return "No analysis data available" + + result = self.results_cache[file_path] + return self.plotting_engine.create_metadata_display_text( + result.song_name, result.bpm, + result.max_amplitude, result.avg_amplitude + ) + + def clear_cache(self): + """Clear all cached analysis results.""" + self.results_cache.clear() + + def is_file_analyzed(self, file_path: str) -> bool: + """Check if a file has been analyzed.""" + return file_path in self.results_cache \ No newline at end of file diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py new file mode 100644 index 0000000..a5001be --- /dev/null +++ b/audio_visualization_widget.py @@ -0,0 +1,126 @@ +""" +Audio visualization widget with embedded matplotlib canvas. +Pure display responsibility - receives plotting data and shows graphs. +""" + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import matplotlib.pyplot as plt + + +class AudioVisualizationWidget(QWidget): + """Widget for displaying audio analysis graphs with embedded matplotlib.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.initUI() + + def initUI(self): + """Initialize the UI components.""" + layout = QVBoxLayout() + + # Create matplotlib canvas + self.figure = Figure(figsize=(10, 4), facecolor='white') + self.canvas = FigureCanvas(self.figure) + + # Add canvas to layout + layout.addWidget(self.canvas) + + # Status label for feedback + self.status_label = QLabel("Ready for audio analysis...") + layout.addWidget(self.status_label) + + self.setLayout(layout) + + # Initialize with empty plot + self._create_empty_plot() + + def _create_empty_plot(self): + """Creates an empty placeholder plot.""" + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.text(0.5, 0.5, 'Drop an audio file to see analysis', + ha='center', va='center', transform=ax.transAxes, + fontsize=14, alpha=0.7) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_xticks([]) + ax.set_yticks([]) + self.canvas.draw() + + def display_analysis_figure(self, figure): + """ + Display a matplotlib figure in the widget. + + Args: + figure: matplotlib.figure.Figure to display + """ + # Clear current figure + self.figure.clear() + + # Copy the provided figure to our canvas + # Get the subplot from the provided figure + source_ax = figure.get_axes()[0] + + # Create new subplot in our figure + ax = self.figure.add_subplot(111) + + # Copy all the plot elements + for child in source_ax.get_children(): + if hasattr(child, 'get_data'): + # Copy line plots + try: + x_data, y_data = child.get_data() + ax.plot(x_data, y_data, color=child.get_color(), + linewidth=child.get_linewidth()) + except: + pass + + # Copy collections (fill_between creates PolyCollection) + for collection in source_ax.collections: + ax.add_collection(collection) + + # Copy axis properties + ax.set_xlim(source_ax.get_xlim()) + ax.set_ylim(source_ax.get_ylim()) + ax.set_xlabel(source_ax.get_xlabel()) + ax.set_ylabel(source_ax.get_ylabel()) + ax.set_title(source_ax.get_title()) + + # Copy colorbar if it exists + if hasattr(figure, '_colorbar') or len(figure.get_axes()) > 1: + # Try to copy colorbar + try: + cbar = figure.colorbar(source_ax.collections[-1], ax=ax, label='RMS Power') + except: + pass + + self.figure.tight_layout() + self.canvas.draw() + self.status_label.setText("Analysis complete - displaying power graph") + + def display_figure_direct(self, figure): + """ + Display a figure by replacing our canvas figure entirely. + More reliable than copying elements. + + Args: + figure: matplotlib.figure.Figure to display + """ + # Remove old canvas + layout = self.layout() + layout.removeWidget(self.canvas) + self.canvas.deleteLater() + + # Create new canvas with the provided figure + self.figure = figure + self.canvas = FigureCanvas(self.figure) + layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label) + + self.canvas.draw() + self.status_label.setText("Analysis complete - displaying power graph") + + def set_status(self, message): + """Update the status label.""" + self.status_label.setText(message) \ No newline at end of file diff --git a/main.py b/main.py index e1afb5e..c9044e7 100644 --- a/main.py +++ b/main.py @@ -1,41 +1,171 @@ import sys -from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QLabel +import os +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QSplitter, QLabel, QListWidget, + QTextEdit, QListWidgetItem) from PyQt5.QtCore import Qt -import master_core -class AudioDragDropWidget(QWidget): +from audio_visualization_widget import AudioVisualizationWidget +from analysis_results_manager import AnalysisResultsManager + + +class MainWindow(QMainWindow): + """Main application window with modular audio analysis interface.""" + def __init__(self): super().__init__() + self.analysis_manager = AnalysisResultsManager() self.initUI() + self.connect_signals() def initUI(self): - self.setWindowTitle('Drag and Drop Audio Analysis') - self.setGeometry(100, 100, 400, 200) # x, y, width, height + """Initialize the user interface.""" + self.setWindowTitle('Audio Mastering Analysis Toolkit') + self.setGeometry(100, 100, 1200, 700) self.setAcceptDrops(True) - # Layout and label for displaying messages - layout = QVBoxLayout() - self.label = QLabel('Drag and drop an audio file here', self) - self.label.setAlignment(Qt.AlignCenter) - layout.addWidget(self.label) - self.setLayout(layout) + # Create central widget with splitter + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main horizontal layout + main_layout = QHBoxLayout(central_widget) + splitter = QSplitter(Qt.Horizontal) + main_layout.addWidget(splitter) + + # Left panel - File list and metadata + left_panel = self.create_left_panel() + splitter.addWidget(left_panel) + + # Right panel - Visualization + self.visualization_widget = AudioVisualizationWidget() + splitter.addWidget(self.visualization_widget) + + # Set splitter proportions + splitter.setSizes([300, 900]) # Left panel narrower than visualization + + def create_left_panel(self): + """Create the left panel with file list and metadata.""" + panel = QWidget() + layout = QVBoxLayout(panel) + + # File list + self.file_list_label = QLabel("Analyzed Files:") + layout.addWidget(self.file_list_label) + + self.file_list = QListWidget() + self.file_list.itemClicked.connect(self.on_file_selected) + layout.addWidget(self.file_list) + + # Metadata display + self.metadata_label = QLabel("File Information:") + layout.addWidget(self.metadata_label) + + self.metadata_display = QTextEdit() + self.metadata_display.setReadOnly(True) + self.metadata_display.setMaximumHeight(150) + layout.addWidget(self.metadata_display) + + # Instructions + instructions = QLabel( + "Drag and drop audio files (.mp3, .wav) onto this window to analyze them." + ) + instructions.setWordWrap(True) + instructions.setStyleSheet("color: gray; font-style: italic;") + layout.addWidget(instructions) + + return panel + + def connect_signals(self): + """Connect analysis manager signals to GUI updates.""" + self.analysis_manager.analysisStarted.connect(self.on_analysis_started) + self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed) + self.analysis_manager.analysisError.connect(self.on_analysis_error) def dragEnterEvent(self, event): + """Handle drag enter event for file drops.""" if event.mimeData().hasUrls(): - event.accept() - else: - event.ignore() + # Check if any files have audio extensions + urls = event.mimeData().urls() + for url in urls: + file_path = url.toLocalFile() + if file_path.lower().endswith(('.mp3', '.wav', '.flac')): + event.accept() + return + event.ignore() def dropEvent(self, event): + """Handle file drop event.""" files = [u.toLocalFile() for u in event.mimeData().urls()] - for file_path in files: - self.label.setText(f'File dropped: {file_path}') - # Here, you would call your plot function with the dropped file path - # For example: plot_macro_time_power_graph_colormap(file_path) - break # This example only processes the first dropped file + audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))] + + if audio_files: + # Analyze the first audio file + # TODO: Add support for multiple file queue + file_path = audio_files[0] + self.analysis_manager.analyze_file(file_path) + else: + self.visualization_widget.set_status("No audio files detected in drop") + + def on_analysis_started(self, file_path): + """Called when analysis starts.""" + filename = os.path.basename(file_path) + self.visualization_widget.set_status(f"Analyzing: {filename}...") + + def on_analysis_completed(self, file_path, result): + """Called when analysis completes successfully.""" + filename = os.path.basename(file_path) + + # Add to file list if not already there + existing_items = [self.file_list.item(i).text() + for i in range(self.file_list.count())] + if filename not in existing_items: + item = QListWidgetItem(filename) + item.setData(Qt.UserRole, file_path) # Store full path + self.file_list.addItem(item) + + # Get and display the analysis figure + figure = self.analysis_manager.get_analysis_figure(file_path) + if figure: + self.visualization_widget.display_figure_direct(figure) + + # Update metadata display + metadata_text = self.analysis_manager.get_metadata_text(file_path) + self.metadata_display.setText(metadata_text) + + # Select the analyzed file in the list + for i in range(self.file_list.count()): + item = self.file_list.item(i) + if item.data(Qt.UserRole) == file_path: + self.file_list.setCurrentItem(item) + break + + def on_analysis_error(self, file_path, error_message): + """Called when analysis fails.""" + filename = os.path.basename(file_path) + self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}") + + def on_file_selected(self, item): + """Called when a file is selected from the list.""" + file_path = item.data(Qt.UserRole) + + # Display the analysis figure + figure = self.analysis_manager.get_analysis_figure(file_path) + if figure: + self.visualization_widget.display_figure_direct(figure) + + # Update metadata display + metadata_text = self.analysis_manager.get_metadata_text(file_path) + self.metadata_display.setText(metadata_text) + if __name__ == '__main__': app = QApplication(sys.argv) - ex = AudioDragDropWidget() - ex.show() + + # Set application style + app.setStyle('Fusion') # Modern cross-platform style + + window = MainWindow() + window.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/master_core.py b/master_core.py index a86ca86..ed290ad 100644 --- a/master_core.py +++ b/master_core.py @@ -69,6 +69,12 @@ class AudioFile: # Calculate RMS over the rolling windows self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples) + + def _get_times(self): + """Get time array for RMS data. Internal method for GUI integration.""" + if not hasattr(self, 'rms_array'): + self.get_energy_levels_over_time() + return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr) def plot_energy_levels_over_time(self, display='window'): """_summary_ @@ -202,24 +208,32 @@ def find_mp3_files(directory): return mp3_files -# Replace 'path/to/your/audiofile.mp3' with the path to your audio file -file_path = [] -with open('./files.txt', 'r') as f: - for line in f: - if line[0] != '#' and line[0] != ';': - file_path.append(line.strip()) +if __name__ == '__main__': + # Legacy batch processing mode - runs when master_core.py is executed directly + # For GUI usage, run main.py instead + + print("Running legacy batch analysis mode...") + print("For the new GUI interface, please run: python main.py") + print() + + # Replace 'path/to/your/audiofile.mp3' with the path to your audio file + file_path = [] + with open('./files.txt', 'r') as f: + for line in f: + if line[0] != '#' and line[0] != ';': + file_path.append(line.strip()) -for file in file_path: - # max_amplitude, avg_amplitude, avg_power, avg_power_stft = analyze_track_librosa(file) - # # read_mp3_tags(file) - # print(f"Maximum Amplitude: {max_amplitude:.2f} dBFS") - # print(f"Average Amplitude: {avg_amplitude:.2f} dBFS") - # print(f"Average Power: {avg_power:.2f} dBFS") - # print(f"Average Power (STFT): {avg_power_stft:.2f} dBFS") - currentsong = AudioFile(file) - currentsong.display_song_name() - print(f"BPM: {currentsong.get_bpm()}") - currentsong.plot_energy_levels_over_time() - # plot_macro_time_power_graph(file) + for file in file_path: + # max_amplitude, avg_amplitude, avg_power, avg_power_stft = analyze_track_librosa(file) + # # read_mp3_tags(file) + # print(f"Maximum Amplitude: {max_amplitude:.2f} dBFS") + # print(f"Average Amplitude: {avg_amplitude:.2f} dBFS") + # print(f"Average Power: {avg_power:.2f} dBFS") + # print(f"Average Power (STFT): {avg_power_stft:.2f} dBFS") + currentsong = AudioFile(file) + currentsong.display_song_name() + print(f"BPM: {currentsong.get_bpm()}") + currentsong.plot_energy_levels_over_time() + # plot_macro_time_power_graph(file) -plt.show() + plt.show() diff --git a/plotting_engine.py b/plotting_engine.py new file mode 100644 index 0000000..d3538bc --- /dev/null +++ b/plotting_engine.py @@ -0,0 +1,79 @@ +""" +Audio visualization plotting engine. +Separates plotting logic from audio processing for clean GUI integration. +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.colors as mcolors +import matplotlib.cm as cm +from matplotlib.figure import Figure +import os + + +class PlottingEngine: + """Handles all matplotlib visualization logic for audio analysis.""" + + @staticmethod + def create_power_analysis_figure(times, rms_array, file_path, figsize=(10, 4)): + """ + Creates a matplotlib Figure for power analysis visualization. + + Args: + times: Array of time points + rms_array: RMS power values over time + file_path: Path to the audio file for title + figsize: Figure size tuple + + Returns: + matplotlib.figure.Figure: Ready-to-embed figure + """ + # Determine color scale based on headroom detection + local_max_power = np.max(rms_array) + if local_max_power > 0.3: + norm = mcolors.Normalize(vmin=0, vmax=0.6) + maxpower = 0.6 + else: + norm = mcolors.Normalize(vmin=0, vmax=0.3) + maxpower = 0.3 + + # Create figure and axis + fig = Figure(figsize=figsize, facecolor='white') + ax = fig.add_subplot(111) + + # Color map + cmap = cm.autumn + + # Plot power levels as colored bars + ax.set_ylim(0., maxpower) + for i in range(len(times)-1): + ax.fill_between(times[i:i+2], 0, rms_array[0][i], + color=cmap(norm(rms_array[0][i])), edgecolor='none') + + # Add colorbar + sm = cm.ScalarMappable(cmap=cmap, norm=norm) + sm.set_array([]) + cbar = fig.colorbar(sm, ax=ax, label='RMS Power') + + # Labels and title + ax.set_ylabel('Power') + ax.set_xlabel('Time (seconds)') + ax.set_title(f'{os.path.basename(file_path)}') + + # Tight layout for better appearance in GUI + fig.tight_layout() + + return fig + + @staticmethod + def create_metadata_display_text(song_name, bpm, max_amplitude, avg_amplitude): + """ + Creates formatted text for metadata display. + + Returns: + str: Formatted metadata text + """ + return f"""Track: {song_name} +BPM: {bpm:.1f} +Max Amplitude: {max_amplitude:.3f} +Avg Amplitude: {avg_amplitude:.3f}""" \ No newline at end of file