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:
Mikkeli Matlock
2026-05-30 00:42:45 +09:00
parent cf901a5686
commit 7bdf465799
10 changed files with 654 additions and 334 deletions
+222 -161
View File
@@ -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