Files
uj-mastering-master/analysis_results_manager.py
T
Mikkeli Matlock 7bdf465799 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>
2026-05-30 00:42:45 +09:00

245 lines
8.7 KiB
Python

"""
Analysis Results Manager - Bridge between audio processing and GUI.
Manages analysis queue and coordinates between components.
"""
from PyQt5.QtCore import QObject, pyqtSignal, QThread
from dataclasses import dataclass, field
from typing import Any, Optional
import os
import logging
from master_core import AudioFile
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
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 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.
"""
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.
"""
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