Implement modular GUI architecture with embedded matplotlib

Major refactor from popup-based to persistent PyQt5 interface:
- Extract plotting logic from AudioFile class into separate PlottingEngine
- Create AudioVisualizationWidget with embedded matplotlib canvas
- Add AnalysisResultsManager as bridge between processing and GUI
- Replace simple drag-drop widget with professional splitter layout
- Preserve legacy batch processing mode with execution guard

Features:
- Drag-and-drop audio analysis (.mp3/.wav/.flac support)
- File list with metadata display (BPM, amplitudes, track info)
- Persistent visualization area (no more matplotlib popups)
- Multi-file support with click-to-view functionality
- Threading-ready architecture for future background processing

Technical improvements:
- Clean separation of concerns (analysis/visualization/GUI)
- Qt signal-slot communication pattern
- Modular component design ready for multithreading
- Proper import guards prevent legacy code interference

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2025-08-21 22:43:11 +09:00
parent a9a4b1725c
commit b07b363454
5 changed files with 513 additions and 40 deletions
+124
View File
@@ -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
+126
View File
@@ -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)
+151 -21
View File
@@ -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_())
+33 -19
View File
@@ -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()
+79
View File
@@ -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}"""