Add pluggable metrics architecture with LUFS
Introduce a Metric ABC (compute on worker thread, render on GUI thread) with a METRICS registry, and refactor the analysis pipeline around it. RMS power (ported), raw waveform, and BS.1770 LUFS (via pyloudnorm) ship as the initial three; new metrics drop in by appending to METRICS. - metrics.py: Metric ABC + RMSPowerMetric, WaveformMetric (locked to +/-1.1 y-range for float headroom), LUFSMetric (short-term 3 s window + integrated value, with streaming-target reference line). - plot_control_widget.py: metric selector dropdown + Refresh Plot button (moved out of FontControlWidget). - analysis_results_manager.py: AnalysisResult caches the AudioFile and a per-metric data dict; new MetricComputeWorker runs metric switches off the GUI thread via metricComputeStarted/metricReady/metricComputeError signals, so LUFS on a 12-minute track no longer stalls the UI. - main.py: all redraw paths funnel through one _render_or_request helper; stale-result guards keep slow computes from overwriting fresh selections. - plotting_engine.py removed (metadata text moved onto AnalysisResult; figure construction lives in each Metric). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+222
-161
@@ -4,180 +4,241 @@ Manages analysis queue and coordinates between components.
|
||||
"""
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, QThread
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
from master_core import AudioFile
|
||||
from plotting_engine import PlottingEngine
|
||||
from font_manager import safe_title
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID, Metric
|
||||
|
||||
|
||||
@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 = ""
|
||||
"""Container for audio analysis results."""
|
||||
file_path: str
|
||||
audio_file: AudioFile
|
||||
song_name: str
|
||||
bpm: float
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||
analysis_successful: bool = True
|
||||
error_message: str = ""
|
||||
|
||||
def metadata_text(self) -> str:
|
||||
return (
|
||||
f"Track: {safe_title(self.song_name)}\n"
|
||||
f"BPM: {self.bpm:.1f}\n"
|
||||
f"Max Amplitude: {self.max_amplitude:.3f}\n"
|
||||
f"Avg Amplitude: {self.avg_amplitude:.3f}"
|
||||
)
|
||||
|
||||
|
||||
class AudioAnalysisWorker(QThread):
|
||||
"""
|
||||
Worker thread for audio analysis to prevent GUI freezing.
|
||||
Performs heavy librosa operations in background.
|
||||
"""
|
||||
|
||||
# Signals for communicating with main thread
|
||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
|
||||
def __init__(self, file_path: str, window: int = 10, hop: int = 2):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.window = window
|
||||
self.hop = hop
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
"""Main thread execution - performs audio analysis."""
|
||||
try:
|
||||
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
||||
self.progressUpdate.emit("Loading audio file...", 10)
|
||||
|
||||
# Create AudioFile and load audio data
|
||||
audio_file = AudioFile(self.file_path)
|
||||
self.progressUpdate.emit("Audio loaded, detecting tempo...", 30)
|
||||
|
||||
# BPM is already calculated in __init__, now do RMS analysis
|
||||
self.progressUpdate.emit("Computing RMS power levels...", 60)
|
||||
audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop)
|
||||
|
||||
self.progressUpdate.emit("Finalizing analysis...", 90)
|
||||
|
||||
# Extract analysis results
|
||||
result = AnalysisResult(
|
||||
file_path=self.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(),
|
||||
rms_array=audio_file.rms_array,
|
||||
analysis_successful=True
|
||||
)
|
||||
|
||||
self.progressUpdate.emit("Analysis complete!", 100)
|
||||
self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})")
|
||||
|
||||
# Emit success signal
|
||||
self.analysisCompleted.emit(self.file_path, result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Analysis failed: {str(e)}"
|
||||
self.logger.error(f"Analysis error for {self.file_path}: {error_msg}")
|
||||
self.analysisError.emit(self.file_path, error_msg)
|
||||
"""Worker thread that loads audio and computes a single metric."""
|
||||
|
||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
|
||||
def __init__(self, file_path: str, metric: Metric):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.metric = metric
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
||||
self.progressUpdate.emit("Loading audio file...", 10)
|
||||
|
||||
audio_file = AudioFile(self.file_path)
|
||||
self.progressUpdate.emit("Audio loaded, detecting tempo...", 30)
|
||||
|
||||
self.progressUpdate.emit(f"Computing {self.metric.display_name}...", 60)
|
||||
metric_data = {self.metric.id: self.metric.compute(audio_file)}
|
||||
|
||||
self.progressUpdate.emit("Finalizing analysis...", 90)
|
||||
|
||||
result = AnalysisResult(
|
||||
file_path=self.file_path,
|
||||
audio_file=audio_file,
|
||||
song_name=audio_file.song_name,
|
||||
bpm=audio_file.get_bpm(),
|
||||
max_amplitude=audio_file.max_amplitude,
|
||||
avg_amplitude=audio_file.avg_amplitude,
|
||||
metric_data=metric_data,
|
||||
analysis_successful=True,
|
||||
)
|
||||
|
||||
self.progressUpdate.emit("Analysis complete!", 100)
|
||||
self.logger.info(
|
||||
f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})"
|
||||
)
|
||||
self.analysisCompleted.emit(self.file_path, result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Analysis failed: {str(e)}"
|
||||
self.logger.error(f"Analysis error for {self.file_path}: {error_msg}")
|
||||
self.analysisError.emit(self.file_path, error_msg)
|
||||
|
||||
|
||||
class MetricComputeWorker(QThread):
|
||||
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
||||
|
||||
completed = pyqtSignal(str, str, object) # file_path, metric_id, data
|
||||
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
|
||||
|
||||
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.audio_file = audio_file
|
||||
self.metric = metric
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
|
||||
)
|
||||
data = self.metric.compute(self.audio_file)
|
||||
self.completed.emit(self.file_path, self.metric.id, data)
|
||||
except Exception as e:
|
||||
msg = f"{self.metric.display_name} compute failed: {e}"
|
||||
self.logger.error(msg)
|
||||
self.failed.emit(self.file_path, self.metric.id, str(e))
|
||||
|
||||
|
||||
class AnalysisResultsManager(QObject):
|
||||
"""Manages audio file analysis and coordinates between processing and GUI."""
|
||||
|
||||
# Full-analysis (load + initial metric) signals.
|
||||
analysisStarted = pyqtSignal(str)
|
||||
analysisCompleted = pyqtSignal(str, object)
|
||||
analysisError = pyqtSignal(str, str)
|
||||
progressUpdate = pyqtSignal(str, int)
|
||||
|
||||
# Metric-only signals (used for switches after analysis has completed).
|
||||
metricComputeStarted = pyqtSignal(str, str) # file_path, metric_id
|
||||
metricReady = pyqtSignal(str, str) # file_path, metric_id
|
||||
metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.results_cache: dict[str, AnalysisResult] = {}
|
||||
self.current_worker: Optional[AudioAnalysisWorker] = None
|
||||
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def analyze_file(self, file_path: str, metric_id: str = DEFAULT_METRIC_ID):
|
||||
"""Kick off background analysis for the given file and metric."""
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"File not found: {file_path}"
|
||||
self.logger.error(error_msg)
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
error_msg = f"Unknown metric: {metric_id}"
|
||||
self.logger.error(error_msg)
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
if self.current_worker and self.current_worker.isRunning():
|
||||
self.logger.info("Stopping previous analysis to start new one")
|
||||
self.current_worker.quit()
|
||||
self.current_worker.wait()
|
||||
|
||||
self.analysisStarted.emit(file_path)
|
||||
self.logger.info(
|
||||
f"Queuing analysis: {os.path.basename(file_path)} ({metric.display_name})"
|
||||
)
|
||||
|
||||
self.current_worker = AudioAnalysisWorker(file_path, metric)
|
||||
self.current_worker.progressUpdate.connect(self.progressUpdate.emit)
|
||||
self.current_worker.analysisCompleted.connect(self._on_worker_completed)
|
||||
self.current_worker.analysisError.connect(self.analysisError.emit)
|
||||
self.current_worker.start()
|
||||
|
||||
def _on_worker_completed(self, file_path: str, result: AnalysisResult):
|
||||
self.results_cache[file_path] = result
|
||||
self.analysisCompleted.emit(file_path, result)
|
||||
|
||||
def request_metric(self, file_path: str, metric_id: str) -> bool:
|
||||
"""Ensure the metric's data exists for the file; emit metricReady when ready.
|
||||
|
||||
Returns True if the data was already cached (metricReady emitted synchronously)
|
||||
or successfully kicked off (will emit later). Returns False if the file hasn't
|
||||
been analysed yet or the metric id is unknown — in that case the caller
|
||||
should wait for analysisCompleted or correct the metric id.
|
||||
"""
|
||||
Manages audio file analysis and coordinates between processing and GUI.
|
||||
Now uses background threads to prevent GUI freezing.
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
self.logger.warning(f"Unknown metric requested: {metric_id}")
|
||||
return False
|
||||
|
||||
if metric_id in result.metric_data:
|
||||
# Cached — emit immediately so the caller can re-render.
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
return True
|
||||
|
||||
key = (file_path, metric_id)
|
||||
existing = self.metric_workers.get(key)
|
||||
if existing is not None and existing.isRunning():
|
||||
self.logger.debug(f"Metric compute already in flight: {metric_id} for {os.path.basename(file_path)}")
|
||||
return True
|
||||
|
||||
worker = MetricComputeWorker(file_path, result.audio_file, metric)
|
||||
worker.completed.connect(self._on_metric_completed)
|
||||
worker.failed.connect(self._on_metric_failed)
|
||||
self.metric_workers[key] = worker
|
||||
self.metricComputeStarted.emit(file_path, metric_id)
|
||||
worker.start()
|
||||
return True
|
||||
|
||||
def _on_metric_completed(self, file_path: str, metric_id: str, data: object):
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is not None:
|
||||
result.metric_data[metric_id] = data
|
||||
self.metric_workers.pop((file_path, metric_id), None)
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
|
||||
def _on_metric_failed(self, file_path: str, metric_id: str, error_message: str):
|
||||
self.metric_workers.pop((file_path, metric_id), None)
|
||||
self.metricComputeError.emit(file_path, metric_id, error_message)
|
||||
|
||||
def get_metric_figure(self, file_path: str, metric_id: str):
|
||||
"""Render a Figure from cached metric data. Returns None if not cached.
|
||||
|
||||
Never triggers compute — call `request_metric` first and listen for
|
||||
`metricReady` if you need on-demand computation.
|
||||
"""
|
||||
|
||||
# Signals for GUI communication
|
||||
analysisStarted = pyqtSignal(str) # file_path
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.results_cache = {} # Store analysis results
|
||||
self.plotting_engine = PlottingEngine()
|
||||
self.current_worker = None # Track active worker thread
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def analyze_file(self, file_path: str, window: int = 10, hop: int = 2):
|
||||
"""
|
||||
Analyze an audio file using background thread to prevent GUI freezing.
|
||||
|
||||
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.logger.error(error_msg)
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
# Stop any existing worker
|
||||
if self.current_worker and self.current_worker.isRunning():
|
||||
self.logger.info("Stopping previous analysis to start new one")
|
||||
self.current_worker.quit()
|
||||
self.current_worker.wait()
|
||||
|
||||
# Emit analysis started signal
|
||||
self.analysisStarted.emit(file_path)
|
||||
self.logger.info(f"Queuing analysis: {os.path.basename(file_path)}")
|
||||
|
||||
# Create and start worker thread
|
||||
self.current_worker = AudioAnalysisWorker(file_path, window, hop)
|
||||
|
||||
# Connect worker signals
|
||||
self.current_worker.progressUpdate.connect(self.progressUpdate.emit)
|
||||
self.current_worker.analysisCompleted.connect(self._on_worker_completed)
|
||||
self.current_worker.analysisError.connect(self.analysisError.emit)
|
||||
|
||||
# Start the background analysis
|
||||
self.current_worker.start()
|
||||
|
||||
def _on_worker_completed(self, file_path: str, result: AnalysisResult):
|
||||
"""Handle completion of worker thread analysis."""
|
||||
# Cache the result
|
||||
self.results_cache[file_path] = result
|
||||
|
||||
# Forward the signal to GUI
|
||||
self.analysisCompleted.emit(file_path, result)
|
||||
|
||||
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
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return None
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
return None
|
||||
data = result.metric_data.get(metric_id)
|
||||
if data is None:
|
||||
return None
|
||||
return metric.render(data, file_path)
|
||||
|
||||
def get_metadata_text(self, file_path: str) -> str:
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return "No analysis data available"
|
||||
return result.metadata_text()
|
||||
|
||||
def clear_cache(self):
|
||||
self.results_cache.clear()
|
||||
|
||||
def is_file_analyzed(self, file_path: str) -> bool:
|
||||
return file_path in self.results_cache
|
||||
|
||||
Reference in New Issue
Block a user