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:
@@ -6,7 +6,7 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
|
|
||||||
### Core features
|
### Core features
|
||||||
- **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV/FLAC support)
|
- **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV/FLAC support)
|
||||||
- **Power Visualization**: Generates colorized power magnitude graphs over time
|
- **Pluggable Metrics**: Switchable visualizations (RMS Power, Waveform, LUFS; DR next) via a `Metric` ABC
|
||||||
- **Metadata Extraction**: Reads ID3 tags from MP3 files for better file identification
|
- **Metadata Extraction**: Reads ID3 tags from MP3 files for better file identification
|
||||||
- **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support
|
- **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support
|
||||||
- **Font Management**: Comprehensive CJK-compatible font system with user-provided font support
|
- **Font Management**: Comprehensive CJK-compatible font system with user-provided font support
|
||||||
@@ -29,7 +29,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
|
|
||||||
#### `analysis_results_manager.py`
|
#### `analysis_results_manager.py`
|
||||||
- Background threading for audio analysis
|
- Background threading for audio analysis
|
||||||
- Results caching and management
|
- Caches both the loaded `AudioFile` and per-metric `compute()` output, so
|
||||||
|
metric/font switches re-render from cache without reloading librosa
|
||||||
- Progress tracking and error handling
|
- Progress tracking and error handling
|
||||||
|
|
||||||
#### `audio_visualization_widget.py`
|
#### `audio_visualization_widget.py`
|
||||||
@@ -40,7 +41,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
- Unified font control system with clustered interface
|
- Unified font control system with clustered interface
|
||||||
- Auto-detection of custom fonts from `fonts/` directory
|
- Auto-detection of custom fonts from `fonts/` directory
|
||||||
- System font discovery and CJK compatibility
|
- System font discovery and CJK compatibility
|
||||||
- Auto-regeneration of plots when fonts change
|
- Font changes trigger a cheap re-render of the cached metric data
|
||||||
|
|
||||||
|
#### `plot_control_widget.py`
|
||||||
|
- Metric selector dropdown driven by the `metrics.METRICS` registry
|
||||||
|
- Houses the `Refresh Plot` button (foundation for upcoming style controls)
|
||||||
|
|
||||||
|
#### `metrics.py`
|
||||||
|
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread)
|
||||||
|
and `render(data, file_path) -> Figure` (cheap, GUI thread)
|
||||||
|
- Current registry: `RMSPowerMetric`, `WaveformMetric`, `LUFSMetric`
|
||||||
|
(BS.1770 short-term + integrated, via pyloudnorm) — drop in new ones (DR,
|
||||||
|
spectrum) by appending an instance to `METRICS`
|
||||||
|
|
||||||
#### `master_core.py`
|
#### `master_core.py`
|
||||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection
|
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection
|
||||||
@@ -57,22 +69,20 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
|
|
||||||
### GUI features
|
### GUI features
|
||||||
- **File management**: Drag-and-drop and file dialog for audio selection
|
- **File management**: Drag-and-drop and file dialog for audio selection
|
||||||
- **Font control**: Unified font selector with size control and plot regeneration
|
- **Font control**: Unified font selector with size control
|
||||||
|
- **Plot control**: Metric selector + refresh-plot button
|
||||||
- **Analysis display**: Real-time visualization with metadata panels
|
- **Analysis display**: Real-time visualization with metadata panels
|
||||||
- **Modular architecture**: Self-contained widgets for easy layout management
|
- **Modular architecture**: Self-contained widgets for easy layout management
|
||||||
|
|
||||||
## Future development plans
|
## Future development plans
|
||||||
|
|
||||||
### Short-term (urgent)
|
### Short-term (urgent)
|
||||||
1. **Plot control widget cluster**
|
1. **Plot control widget cluster** *(metric selector + Refresh Plot done; still TODO)*
|
||||||
- Move 'Refresh Plot' into dedicated plot/graph widget cluster
|
- Plot style controller (colormap, line vs bar, etc.)
|
||||||
- Add metric selection widget (choose which analysis to display)
|
- Foundation for mastering comparison features
|
||||||
- Implement plot style controller (colormap, line vs bar, etc.)
|
|
||||||
- Prepare foundation for mastering comparison features
|
|
||||||
|
|
||||||
### Short-term (not urgent)
|
### Short-term (not urgent)
|
||||||
1. **Enhanced metrics**
|
1. **Enhanced metrics** *(plug new ones into `metrics.METRICS`)*
|
||||||
- LUFS loudness measurement implementation
|
|
||||||
- Dynamic range measurement (DR meter)
|
- Dynamic range measurement (DR meter)
|
||||||
- Peak-to-average ratio analysis
|
- Peak-to-average ratio analysis
|
||||||
- Frequency spectrum analysis
|
- Frequency spectrum analysis
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Developed with Claude Code assistance.
|
|||||||
|
|
||||||
### Roadmap
|
### Roadmap
|
||||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||||
plot-control widget cluster, LUFS, dynamic range, interactive axis controls.
|
dynamic range, plot-style controls, interactive axis controls.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -40,19 +40,27 @@ selector. The directory is gitignored to avoid bundling licensed font data.
|
|||||||
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `PyQt5` — all pinned through
|
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
||||||
`uv.lock`. Python 3.10+.
|
through `uv.lock`. Python 3.10+.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||||
| `analysis_results_manager.py` | Background `QThread` worker, result cache |
|
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
|
||||||
| `master_core.py` | `AudioFile`: librosa loading, RMS rolling window, BPM |
|
| `master_core.py` | `AudioFile`: librosa loading, RMS rolling window, BPM |
|
||||||
| `plotting_engine.py` | Matplotlib `Figure` builder for the power graph |
|
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS Power, Waveform, …) |
|
||||||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||||||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||||||
| `font_control_widget.py` | Font picker, size slider, refresh-plot button |
|
| `font_control_widget.py` | Font picker + size slider |
|
||||||
|
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
||||||
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||||
|
|
||||||
|
### Adding a metric
|
||||||
|
|
||||||
|
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
|
||||||
|
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure`
|
||||||
|
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at
|
||||||
|
the bottom of the file — it shows up in the dropdown automatically.
|
||||||
|
|||||||
+209
-148
@@ -4,180 +4,241 @@ Manages analysis queue and coordinates between components.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal, QThread
|
from PyQt5.QtCore import QObject, pyqtSignal, QThread
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from master_core import AudioFile
|
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
|
@dataclass
|
||||||
class AnalysisResult:
|
class AnalysisResult:
|
||||||
"""Container for audio analysis results."""
|
"""Container for audio analysis results."""
|
||||||
file_path: str
|
file_path: str
|
||||||
song_name: str
|
audio_file: AudioFile
|
||||||
bpm: float
|
song_name: str
|
||||||
max_amplitude: float
|
bpm: float
|
||||||
avg_amplitude: float
|
max_amplitude: float
|
||||||
times: list
|
avg_amplitude: float
|
||||||
rms_array: list
|
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||||
analysis_successful: bool = True
|
analysis_successful: bool = True
|
||||||
error_message: str = ""
|
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):
|
class AudioAnalysisWorker(QThread):
|
||||||
"""
|
"""Worker thread that loads audio and computes a single metric."""
|
||||||
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
|
||||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
|
||||||
|
|
||||||
def __init__(self, file_path: str, window: int = 10, hop: int = 2):
|
def __init__(self, file_path: str, metric: Metric):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.window = window
|
self.metric = metric
|
||||||
self.hop = hop
|
self.logger = logging.getLogger(__name__)
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Main thread execution - performs audio analysis."""
|
try:
|
||||||
try:
|
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
||||||
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
self.progressUpdate.emit("Loading audio file...", 10)
|
||||||
self.progressUpdate.emit("Loading audio file...", 10)
|
|
||||||
|
|
||||||
# Create AudioFile and load audio data
|
audio_file = AudioFile(self.file_path)
|
||||||
audio_file = AudioFile(self.file_path)
|
self.progressUpdate.emit("Audio loaded, detecting tempo...", 30)
|
||||||
self.progressUpdate.emit("Audio loaded, detecting tempo...", 30)
|
|
||||||
|
|
||||||
# BPM is already calculated in __init__, now do RMS analysis
|
self.progressUpdate.emit(f"Computing {self.metric.display_name}...", 60)
|
||||||
self.progressUpdate.emit("Computing RMS power levels...", 60)
|
metric_data = {self.metric.id: self.metric.compute(audio_file)}
|
||||||
audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop)
|
|
||||||
|
|
||||||
self.progressUpdate.emit("Finalizing analysis...", 90)
|
self.progressUpdate.emit("Finalizing analysis...", 90)
|
||||||
|
|
||||||
# Extract analysis results
|
result = AnalysisResult(
|
||||||
result = AnalysisResult(
|
file_path=self.file_path,
|
||||||
file_path=self.file_path,
|
audio_file=audio_file,
|
||||||
song_name=audio_file.song_name,
|
song_name=audio_file.song_name,
|
||||||
bpm=audio_file.get_bpm(),
|
bpm=audio_file.get_bpm(),
|
||||||
max_amplitude=audio_file.max_amplitude,
|
max_amplitude=audio_file.max_amplitude,
|
||||||
avg_amplitude=audio_file.avg_amplitude,
|
avg_amplitude=audio_file.avg_amplitude,
|
||||||
times=audio_file.get_times(),
|
metric_data=metric_data,
|
||||||
rms_array=audio_file.rms_array,
|
analysis_successful=True,
|
||||||
analysis_successful=True
|
)
|
||||||
)
|
|
||||||
|
|
||||||
self.progressUpdate.emit("Analysis complete!", 100)
|
self.progressUpdate.emit("Analysis complete!", 100)
|
||||||
self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})")
|
self.logger.info(
|
||||||
|
f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})"
|
||||||
|
)
|
||||||
|
self.analysisCompleted.emit(self.file_path, result)
|
||||||
|
|
||||||
# Emit success signal
|
except Exception as e:
|
||||||
self.analysisCompleted.emit(self.file_path, result)
|
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)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Analysis failed: {str(e)}"
|
class MetricComputeWorker(QThread):
|
||||||
self.logger.error(f"Analysis error for {self.file_path}: {error_msg}")
|
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
||||||
self.analysisError.emit(self.file_path, error_msg)
|
|
||||||
|
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):
|
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.
|
result = self.results_cache.get(file_path)
|
||||||
Now uses background threads to prevent GUI freezing.
|
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)
|
||||||
|
|
||||||
# Signals for GUI communication
|
def get_metadata_text(self, file_path: str) -> str:
|
||||||
analysisStarted = pyqtSignal(str) # file_path
|
result = self.results_cache.get(file_path)
|
||||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
if result is None:
|
||||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
return "No analysis data available"
|
||||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
return result.metadata_text()
|
||||||
|
|
||||||
def __init__(self):
|
def clear_cache(self):
|
||||||
super().__init__()
|
self.results_cache.clear()
|
||||||
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):
|
def is_file_analyzed(self, file_path: str) -> bool:
|
||||||
"""
|
return file_path in self.results_cache
|
||||||
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
|
|
||||||
|
|||||||
+1
-26
@@ -6,7 +6,7 @@ Self-contained widget for easy layout management.
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout,
|
from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout,
|
||||||
QLabel, QSlider, QPushButton, QGroupBox)
|
QLabel, QSlider, QGroupBox)
|
||||||
from PyQt5.QtCore import pyqtSignal, Qt
|
from PyQt5.QtCore import pyqtSignal, Qt
|
||||||
|
|
||||||
from font_manager import get_font_manager
|
from font_manager import get_font_manager
|
||||||
@@ -19,13 +19,11 @@ class FontControlWidget(QWidget):
|
|||||||
Provides clustered interface for:
|
Provides clustered interface for:
|
||||||
- Font selection (unified for Qt and matplotlib)
|
- Font selection (unified for Qt and matplotlib)
|
||||||
- Qt font size adjustment
|
- Qt font size adjustment
|
||||||
- Plot regeneration controls
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Signals
|
# Signals
|
||||||
fontChanged = pyqtSignal(str, str) # (font_name, font_type)
|
fontChanged = pyqtSignal(str, str) # (font_name, font_type)
|
||||||
fontSizeChanged = pyqtSignal(int) # font_size
|
fontSizeChanged = pyqtSignal(int) # font_size
|
||||||
plotRefreshRequested = pyqtSignal() # manual refresh request
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
"""Initialize the font control widget."""
|
"""Initialize the font control widget."""
|
||||||
@@ -60,10 +58,6 @@ class FontControlWidget(QWidget):
|
|||||||
size_section = self._create_font_size_section()
|
size_section = self._create_font_size_section()
|
||||||
group_layout.addWidget(size_section)
|
group_layout.addWidget(size_section)
|
||||||
|
|
||||||
# Control buttons section
|
|
||||||
button_section = self._create_button_section()
|
|
||||||
group_layout.addWidget(button_section)
|
|
||||||
|
|
||||||
layout.addWidget(group_box)
|
layout.addWidget(group_box)
|
||||||
|
|
||||||
def _create_font_selector_section(self) -> QWidget:
|
def _create_font_selector_section(self) -> QWidget:
|
||||||
@@ -119,20 +113,6 @@ class FontControlWidget(QWidget):
|
|||||||
|
|
||||||
return section
|
return section
|
||||||
|
|
||||||
def _create_button_section(self) -> QWidget:
|
|
||||||
"""Create the control buttons section."""
|
|
||||||
section = QWidget()
|
|
||||||
layout = QHBoxLayout(section)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
|
|
||||||
# Refresh plot button
|
|
||||||
self.refresh_button = QPushButton("Refresh Plot")
|
|
||||||
self.refresh_button.setToolTip("Regenerate current plot with new font settings")
|
|
||||||
self.refresh_button.clicked.connect(self.on_refresh_plot_clicked)
|
|
||||||
layout.addWidget(self.refresh_button)
|
|
||||||
|
|
||||||
return section
|
|
||||||
|
|
||||||
def refresh_font_list(self):
|
def refresh_font_list(self):
|
||||||
"""Refresh the list of available fonts."""
|
"""Refresh the list of available fonts."""
|
||||||
self.logger.debug("Refreshing font list...")
|
self.logger.debug("Refreshing font list...")
|
||||||
@@ -243,11 +223,6 @@ class FontControlWidget(QWidget):
|
|||||||
# Emit signal for external listeners
|
# Emit signal for external listeners
|
||||||
self.fontSizeChanged.emit(size)
|
self.fontSizeChanged.emit(size)
|
||||||
|
|
||||||
def on_refresh_plot_clicked(self):
|
|
||||||
"""Handle manual plot refresh button click."""
|
|
||||||
self.logger.info("Manual plot refresh requested")
|
|
||||||
self.plotRefreshRequested.emit()
|
|
||||||
|
|
||||||
def _apply_font_change(self, font_name: str, font_type: str):
|
def _apply_font_change(self, font_name: str, font_type: str):
|
||||||
"""Apply the font change to both Qt and matplotlib."""
|
"""Apply the font change to both Qt and matplotlib."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from analysis_results_manager import AnalysisResultsManager
|
|||||||
from logger_setup import setup_logging, parse_log_args
|
from logger_setup import setup_logging, parse_log_args
|
||||||
from font_manager import initialize_fonts, get_font_manager
|
from font_manager import initialize_fonts, get_font_manager
|
||||||
from font_control_widget import FontControlWidget
|
from font_control_widget import FontControlWidget
|
||||||
|
from plot_control_widget import PlotControlWidget
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -63,9 +64,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.font_control = FontControlWidget()
|
self.font_control = FontControlWidget()
|
||||||
self.font_control.fontChanged.connect(self.on_font_changed)
|
self.font_control.fontChanged.connect(self.on_font_changed)
|
||||||
self.font_control.fontSizeChanged.connect(self.on_font_size_changed)
|
self.font_control.fontSizeChanged.connect(self.on_font_size_changed)
|
||||||
self.font_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
|
||||||
layout.addWidget(self.font_control)
|
layout.addWidget(self.font_control)
|
||||||
|
|
||||||
|
# Plot control cluster (metric selector + refresh)
|
||||||
|
self.plot_control = PlotControlWidget()
|
||||||
|
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||||
|
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||||
|
layout.addWidget(self.plot_control)
|
||||||
|
|
||||||
# File list
|
# File list
|
||||||
self.file_list_label = QLabel("Analyzed Files:")
|
self.file_list_label = QLabel("Analyzed Files:")
|
||||||
layout.addWidget(self.file_list_label)
|
layout.addWidget(self.file_list_label)
|
||||||
@@ -99,6 +105,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
|
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
|
||||||
self.analysis_manager.analysisError.connect(self.on_analysis_error)
|
self.analysis_manager.analysisError.connect(self.on_analysis_error)
|
||||||
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
|
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
|
||||||
|
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||||
|
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||||
|
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
||||||
|
|
||||||
def dragEnterEvent(self, event):
|
def dragEnterEvent(self, event):
|
||||||
"""Handle drag enter event for file drops."""
|
"""Handle drag enter event for file drops."""
|
||||||
@@ -124,7 +133,7 @@ class MainWindow(QMainWindow):
|
|||||||
# TODO: Add support for multiple file queue
|
# TODO: Add support for multiple file queue
|
||||||
file_path = audio_files[0]
|
file_path = audio_files[0]
|
||||||
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
||||||
self.analysis_manager.analyze_file(file_path)
|
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||||
else:
|
else:
|
||||||
self.visualization_widget.set_status("No audio files detected in drop")
|
self.visualization_widget.set_status("No audio files detected in drop")
|
||||||
self.logger.warning("No supported audio files found in drop")
|
self.logger.warning("No supported audio files found in drop")
|
||||||
@@ -140,7 +149,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
if file_path: # User selected a file (didn't cancel)
|
if file_path: # User selected a file (didn't cancel)
|
||||||
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
||||||
self.analysis_manager.analyze_file(file_path)
|
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||||
|
|
||||||
def on_analysis_started(self, file_path):
|
def on_analysis_started(self, file_path):
|
||||||
"""Called when analysis starts."""
|
"""Called when analysis starts."""
|
||||||
@@ -159,11 +168,6 @@ class MainWindow(QMainWindow):
|
|||||||
item.setData(Qt.UserRole, file_path) # Store full path
|
item.setData(Qt.UserRole, file_path) # Store full path
|
||||||
self.file_list.addItem(item)
|
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
|
# Update metadata display
|
||||||
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
||||||
self.metadata_display.setText(metadata_text)
|
self.metadata_display.setText(metadata_text)
|
||||||
@@ -175,6 +179,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.file_list.setCurrentItem(item)
|
self.file_list.setCurrentItem(item)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Render the currently-selected metric (cached, or async-compute it)
|
||||||
|
self._render_or_request(file_path)
|
||||||
|
|
||||||
def on_analysis_error(self, file_path, error_message):
|
def on_analysis_error(self, file_path, error_message):
|
||||||
"""Called when analysis fails."""
|
"""Called when analysis fails."""
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
@@ -190,53 +197,81 @@ class MainWindow(QMainWindow):
|
|||||||
"""Called when a file is selected from the list."""
|
"""Called when a file is selected from the list."""
|
||||||
file_path = item.data(Qt.UserRole)
|
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
|
# Update metadata display
|
||||||
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
||||||
self.metadata_display.setText(metadata_text)
|
self.metadata_display.setText(metadata_text)
|
||||||
|
|
||||||
|
# Render the currently-selected metric (cached, or async-compute it)
|
||||||
|
self._render_or_request(file_path)
|
||||||
|
|
||||||
def on_font_changed(self, font_name: str, font_type: str):
|
def on_font_changed(self, font_name: str, font_type: str):
|
||||||
"""Called when font selection changes."""
|
"""Called when font selection changes."""
|
||||||
self.logger.info(f"Font changed via GUI: {font_name} ({font_type})")
|
self.logger.info(f"Font changed via GUI: {font_name} ({font_type})")
|
||||||
# Auto-regenerate current plot with new font
|
# Cheap re-render — cached metric data, redraws under the new font.
|
||||||
self._regenerate_current_plot()
|
self._render_or_request(self._current_file_path())
|
||||||
|
|
||||||
def on_font_size_changed(self, font_size: int):
|
def on_font_size_changed(self, font_size: int):
|
||||||
"""Called when Qt font size changes."""
|
"""Called when Qt font size changes."""
|
||||||
self.logger.info(f"Qt font size changed via GUI: {font_size}pt")
|
self.logger.info(f"Qt font size changed via GUI: {font_size}pt")
|
||||||
# Qt font size doesn't affect matplotlib plots, so no regeneration needed
|
# Qt font size doesn't affect matplotlib plots, so no regeneration needed
|
||||||
|
|
||||||
|
def on_metric_changed(self, metric_id: str):
|
||||||
|
"""Called when the metric selector changes."""
|
||||||
|
self.logger.info(f"Metric changed via GUI: {metric_id}")
|
||||||
|
self._render_or_request(self._current_file_path())
|
||||||
|
|
||||||
def on_plot_refresh_requested(self):
|
def on_plot_refresh_requested(self):
|
||||||
"""Called when manual plot refresh is requested."""
|
"""Called when manual plot refresh is requested."""
|
||||||
self.logger.info("Manual plot refresh requested via GUI")
|
self.logger.info("Manual plot refresh requested via GUI")
|
||||||
self._regenerate_current_plot()
|
self._render_or_request(self._current_file_path())
|
||||||
|
|
||||||
def _regenerate_current_plot(self):
|
def on_metric_compute_started(self, file_path: str, metric_id: str):
|
||||||
"""Regenerate the current plot with updated font settings."""
|
"""Called when an off-thread metric compute starts."""
|
||||||
try:
|
if file_path != self._current_file_path():
|
||||||
# Get the currently selected file
|
return # selection moved on; status bar shouldn't lie
|
||||||
current_item = self.file_list.currentItem()
|
from metrics import METRICS
|
||||||
if not current_item:
|
metric = METRICS.get(metric_id)
|
||||||
self.logger.debug("No file selected for plot regeneration")
|
display = metric.display_name if metric else metric_id
|
||||||
return
|
self.visualization_widget.set_status(f"Computing {display}...")
|
||||||
|
|
||||||
file_path = current_item.data(Qt.UserRole)
|
def on_metric_ready(self, file_path: str, metric_id: str):
|
||||||
if not file_path:
|
"""Called when metric data is available (cached hit or async finish)."""
|
||||||
self.logger.debug("No file path found for current selection")
|
if file_path != self._current_file_path():
|
||||||
return
|
return # stale — user moved on
|
||||||
|
if metric_id != self.plot_control.current_metric_id():
|
||||||
|
return # user already switched to a different metric
|
||||||
|
figure = self.analysis_manager.get_metric_figure(file_path, metric_id)
|
||||||
|
if figure:
|
||||||
|
self.visualization_widget.display_figure_direct(figure)
|
||||||
|
|
||||||
self.logger.info(f"Regenerating plot for: {os.path.basename(file_path)}")
|
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str):
|
||||||
|
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
|
||||||
|
if file_path == self._current_file_path():
|
||||||
|
self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}")
|
||||||
|
|
||||||
# Re-analyze the file to regenerate plots with new font
|
def _current_file_path(self):
|
||||||
self.analysis_manager.analyze_file(file_path)
|
item = self.file_list.currentItem()
|
||||||
|
return item.data(Qt.UserRole) if item else None
|
||||||
|
|
||||||
except Exception as e:
|
def _render_or_request(self, file_path):
|
||||||
self.logger.error(f"Error regenerating plot: {e}")
|
"""Render the current metric from cache, or kick off async compute if missing.
|
||||||
self.visualization_widget.set_status(f"Error regenerating plot: {e}")
|
|
||||||
|
Falls back to a full analyse_file if the file hasn't been processed yet
|
||||||
|
(e.g. font change on an empty session — defensive).
|
||||||
|
"""
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
metric_id = self.plot_control.current_metric_id()
|
||||||
|
figure = self.analysis_manager.get_metric_figure(file_path, metric_id)
|
||||||
|
if figure:
|
||||||
|
self.visualization_widget.display_figure_direct(figure)
|
||||||
|
return
|
||||||
|
# Not cached yet — try async compute if the file has been loaded.
|
||||||
|
if self.analysis_manager.is_file_analyzed(file_path):
|
||||||
|
self.analysis_manager.request_metric(file_path, metric_id)
|
||||||
|
else:
|
||||||
|
# No AudioFile yet either; kick off a full analysis with this metric.
|
||||||
|
self.analysis_manager.analyze_file(file_path, metric_id)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Pluggable analysis metrics.
|
||||||
|
|
||||||
|
A `Metric` knows how to compute a series from an `AudioFile` and how to render
|
||||||
|
that series into a matplotlib `Figure`. Compute is the heavy step (runs on the
|
||||||
|
worker thread); render is cheap and reruns on font / refresh.
|
||||||
|
|
||||||
|
To add a metric: subclass `Metric`, implement `compute` and `render`, and
|
||||||
|
register the instance in `METRICS` at the bottom of this file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.colors as mcolors
|
||||||
|
import matplotlib.cm as cm
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
import pyloudnorm as pyln
|
||||||
|
|
||||||
|
from font_manager import safe_title
|
||||||
|
from master_core import AudioFile
|
||||||
|
|
||||||
|
|
||||||
|
class Metric(ABC):
|
||||||
|
"""A pluggable analysis metric."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compute(self, audio_file: AudioFile) -> Any:
|
||||||
|
"""Compute and return the metric's data from a loaded AudioFile.
|
||||||
|
|
||||||
|
The returned object is cached and later passed to `render`. This is the
|
||||||
|
heavy step and runs on the worker thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(self, data: Any, file_path: str, figsize=(10, 4)) -> Figure:
|
||||||
|
"""Render a Figure from precomputed data. Cheap; runs on the GUI thread."""
|
||||||
|
|
||||||
|
|
||||||
|
class RMSPowerMetric(Metric):
|
||||||
|
id = "rms_power"
|
||||||
|
display_name = "RMS Power"
|
||||||
|
|
||||||
|
def __init__(self, window: int = 10, hop: int = 2):
|
||||||
|
self.window = window
|
||||||
|
self.hop = hop
|
||||||
|
|
||||||
|
def compute(self, audio_file: AudioFile):
|
||||||
|
audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop)
|
||||||
|
return {
|
||||||
|
"times": audio_file.get_times(),
|
||||||
|
"rms_array": audio_file.rms_array,
|
||||||
|
}
|
||||||
|
|
||||||
|
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||||
|
times = data["times"]
|
||||||
|
rms_array = data["rms_array"]
|
||||||
|
|
||||||
|
# Adaptive colour scale: bump headroom for loud masters.
|
||||||
|
maxpower = 0.6 if np.max(rms_array) > 0.3 else 0.3
|
||||||
|
norm = mcolors.Normalize(vmin=0, vmax=maxpower)
|
||||||
|
cmap = cm.autumn
|
||||||
|
|
||||||
|
fig = Figure(figsize=figsize, facecolor="white")
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||||
|
sm.set_array([])
|
||||||
|
fig.colorbar(sm, ax=ax, label="RMS Power")
|
||||||
|
ax.set_ylabel("Power")
|
||||||
|
ax.set_xlabel("Time (seconds)")
|
||||||
|
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
class WaveformMetric(Metric):
|
||||||
|
"""Raw mono waveform with a min/max envelope downsample for plotting speed."""
|
||||||
|
|
||||||
|
id = "waveform"
|
||||||
|
display_name = "Waveform"
|
||||||
|
|
||||||
|
def __init__(self, target_columns: int = 4000):
|
||||||
|
self.target_columns = target_columns
|
||||||
|
|
||||||
|
def compute(self, audio_file: AudioFile):
|
||||||
|
y = audio_file.y_mono
|
||||||
|
sr = audio_file.sr
|
||||||
|
n = len(y)
|
||||||
|
if n <= self.target_columns:
|
||||||
|
times = np.arange(n) / sr
|
||||||
|
return {"times": times, "lo": y, "hi": y}
|
||||||
|
|
||||||
|
chunk = n // self.target_columns
|
||||||
|
trimmed = y[: chunk * self.target_columns]
|
||||||
|
reshaped = trimmed.reshape(self.target_columns, chunk)
|
||||||
|
lo = reshaped.min(axis=1)
|
||||||
|
hi = reshaped.max(axis=1)
|
||||||
|
times = (np.arange(self.target_columns) * chunk + chunk / 2) / sr
|
||||||
|
return {"times": times, "lo": lo, "hi": hi}
|
||||||
|
|
||||||
|
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||||
|
times = data["times"]
|
||||||
|
lo = data["lo"]
|
||||||
|
hi = data["hi"]
|
||||||
|
|
||||||
|
fig = Figure(figsize=figsize, facecolor="white")
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
ax.fill_between(times, lo, hi, color="#3a7ad6", linewidth=0)
|
||||||
|
ax.axhline(0, color="black", linewidth=0.5, alpha=0.3)
|
||||||
|
# Fixed full-scale range with a touch of headroom for float-wav signals.
|
||||||
|
ax.set_ylim(-1.1, 1.1)
|
||||||
|
ax.set_xlim(times[0], times[-1])
|
||||||
|
ax.set_ylabel("Amplitude")
|
||||||
|
ax.set_xlabel("Time (seconds)")
|
||||||
|
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
class LUFSMetric(Metric):
|
||||||
|
"""ITU-R BS.1770 loudness: short-term (3 s) time series + integrated value.
|
||||||
|
|
||||||
|
Powered by pyloudnorm. The time series slides `meter.integrated_loudness`
|
||||||
|
across the track because pyloudnorm doesn't expose a per-block series.
|
||||||
|
Slightly redundant work, but the per-call cost is small.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = "lufs"
|
||||||
|
display_name = "LUFS"
|
||||||
|
|
||||||
|
# Short-term as defined by EBU R128 / BS.1770: 3-second window.
|
||||||
|
WINDOW_S = 3.0
|
||||||
|
HOP_S = 0.5
|
||||||
|
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
|
||||||
|
|
||||||
|
def compute(self, audio_file: AudioFile):
|
||||||
|
y = audio_file.y_mono.astype(np.float64, copy=False)
|
||||||
|
sr = audio_file.sr
|
||||||
|
meter = pyln.Meter(sr)
|
||||||
|
|
||||||
|
# pyloudnorm warns on clipping and on too-short audio; we handle both.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
integrated = self._safe_integrated(meter, y)
|
||||||
|
|
||||||
|
window_n = int(self.WINDOW_S * sr)
|
||||||
|
hop_n = int(self.HOP_S * sr)
|
||||||
|
|
||||||
|
if len(y) < window_n:
|
||||||
|
# Track shorter than 3 s — just one data point at the centre.
|
||||||
|
times = np.array([len(y) / (2.0 * sr)])
|
||||||
|
lufs = np.array([integrated if np.isfinite(integrated) else self.SILENCE_FLOOR])
|
||||||
|
else:
|
||||||
|
n_windows = 1 + (len(y) - window_n) // hop_n
|
||||||
|
lufs = np.empty(n_windows)
|
||||||
|
for i in range(n_windows):
|
||||||
|
start = i * hop_n
|
||||||
|
lufs[i] = self._safe_integrated(meter, y[start:start + window_n])
|
||||||
|
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
|
||||||
|
|
||||||
|
lufs = np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR)
|
||||||
|
lufs = np.clip(lufs, self.SILENCE_FLOOR, 0.0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"times": times,
|
||||||
|
"lufs": lufs,
|
||||||
|
"integrated": float(integrated),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_integrated(meter: "pyln.Meter", segment: np.ndarray) -> float:
|
||||||
|
try:
|
||||||
|
return float(meter.integrated_loudness(segment))
|
||||||
|
except (ValueError, FloatingPointError):
|
||||||
|
return float("-inf")
|
||||||
|
|
||||||
|
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||||
|
times = data["times"]
|
||||||
|
lufs = data["lufs"]
|
||||||
|
integrated = data["integrated"]
|
||||||
|
|
||||||
|
fig = Figure(figsize=figsize, facecolor="white")
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
ax.plot(times, lufs, color="#2a9d8f", linewidth=1.4, label="Short-term (3 s)")
|
||||||
|
|
||||||
|
if np.isfinite(integrated):
|
||||||
|
ax.axhline(
|
||||||
|
integrated, color="#e76f51", linestyle="--", linewidth=1.5,
|
||||||
|
label=f"Integrated: {integrated:.1f} LUFS",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Streaming target reference (Spotify normalises to -14 LUFS).
|
||||||
|
ax.axhline(-14.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||||
|
ax.text(
|
||||||
|
times[-1], -14.0, " -14 LUFS (streaming target)",
|
||||||
|
va="center", ha="left", fontsize=8, alpha=0.6,
|
||||||
|
)
|
||||||
|
|
||||||
|
ax.set_ylim(-50.0, 0.0)
|
||||||
|
ax.set_xlim(times[0], times[-1])
|
||||||
|
ax.set_ylabel("LUFS")
|
||||||
|
ax.set_xlabel("Time (seconds)")
|
||||||
|
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(loc="lower right", fontsize=8)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
METRICS: dict[str, Metric] = {
|
||||||
|
m.id: m for m in (
|
||||||
|
RMSPowerMetric(),
|
||||||
|
WaveformMetric(),
|
||||||
|
LUFSMetric(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DEFAULT_METRIC_ID = "rms_power"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Plot control widget: pick which metric to display and refresh the current plot.
|
||||||
|
|
||||||
|
Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally
|
||||||
|
next to each other in the left panel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox,
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import pyqtSignal
|
||||||
|
|
||||||
|
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||||
|
|
||||||
|
|
||||||
|
class PlotControlWidget(QWidget):
|
||||||
|
"""Metric selector + manual plot refresh."""
|
||||||
|
|
||||||
|
metricChanged = pyqtSignal(str) # metric_id
|
||||||
|
plotRefreshRequested = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.initUI()
|
||||||
|
|
||||||
|
def initUI(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
|
||||||
|
group_box = QGroupBox("Plot")
|
||||||
|
group_layout = QVBoxLayout(group_box)
|
||||||
|
|
||||||
|
group_layout.addWidget(QLabel("Metric:"))
|
||||||
|
self.metric_combo = QComboBox()
|
||||||
|
for metric_id, metric in METRICS.items():
|
||||||
|
self.metric_combo.addItem(metric.display_name, metric_id)
|
||||||
|
default_idx = self.metric_combo.findData(DEFAULT_METRIC_ID)
|
||||||
|
if default_idx >= 0:
|
||||||
|
self.metric_combo.setCurrentIndex(default_idx)
|
||||||
|
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
|
||||||
|
group_layout.addWidget(self.metric_combo)
|
||||||
|
|
||||||
|
button_row = QHBoxLayout()
|
||||||
|
self.refresh_button = QPushButton("Refresh Plot")
|
||||||
|
self.refresh_button.setToolTip("Re-render the current plot with current settings")
|
||||||
|
self.refresh_button.clicked.connect(self.plotRefreshRequested.emit)
|
||||||
|
button_row.addWidget(self.refresh_button)
|
||||||
|
group_layout.addLayout(button_row)
|
||||||
|
|
||||||
|
layout.addWidget(group_box)
|
||||||
|
|
||||||
|
def _on_metric_changed(self, _index: int):
|
||||||
|
metric_id = self.metric_combo.currentData()
|
||||||
|
if metric_id:
|
||||||
|
self.logger.info(f"Metric changed: {metric_id}")
|
||||||
|
self.metricChanged.emit(metric_id)
|
||||||
|
|
||||||
|
def current_metric_id(self) -> str:
|
||||||
|
return self.metric_combo.currentData() or DEFAULT_METRIC_ID
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
from font_manager import safe_title
|
|
||||||
|
|
||||||
|
|
||||||
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(safe_title(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: {safe_title(song_name)}
|
|
||||||
BPM: {bpm:.1f}
|
|
||||||
Max Amplitude: {max_amplitude:.3f}
|
|
||||||
Avg Amplitude: {avg_amplitude:.3f}"""
|
|
||||||
+3
-1
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"numpy",
|
"numpy",
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"mutagen",
|
"mutagen",
|
||||||
|
"pyloudnorm",
|
||||||
"PyQt5>=5.15.10",
|
"PyQt5>=5.15.10",
|
||||||
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
||||||
# versions are Linux/macOS only.
|
# versions are Linux/macOS only.
|
||||||
@@ -26,9 +27,10 @@ py-modules = [
|
|||||||
"analysis_results_manager",
|
"analysis_results_manager",
|
||||||
"audio_visualization_widget",
|
"audio_visualization_widget",
|
||||||
"master_core",
|
"master_core",
|
||||||
"plotting_engine",
|
"metrics",
|
||||||
"font_manager",
|
"font_manager",
|
||||||
"font_control_widget",
|
"font_control_widget",
|
||||||
|
"plot_control_widget",
|
||||||
"logger_setup",
|
"logger_setup",
|
||||||
"setup_fonts",
|
"setup_fonts",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1187,6 +1187,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyloudnorm"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
|
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/00/f915eaa75326f4209941179c2b93ac477f2040e4aeff5bb21d16eb8058f9/pyloudnorm-0.2.0.tar.gz", hash = "sha256:8bf597658ea4e1975c275adf490f6deb5369ea409f2901f939915efa4b681b16", size = 14037, upload-time = "2026-01-04T11:43:35.265Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/b6/65a49a05614b2548edbba3aab118f2ebe7441dfd778accdcdce9f6567f20/pyloudnorm-0.2.0-py3-none-any.whl", hash = "sha256:9bb69afb904f59d007a7f9ba3d75d16fb8aeef35c44d6df822a9f192d69cf13f", size = 10879, upload-time = "2026-01-04T11:43:34.534Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
@@ -1642,6 +1657,7 @@ dependencies = [
|
|||||||
{ name = "mutagen" },
|
{ name = "mutagen" },
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
|
{ name = "pyloudnorm" },
|
||||||
{ name = "pyqt5" },
|
{ name = "pyqt5" },
|
||||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
@@ -1652,6 +1668,7 @@ requires-dist = [
|
|||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
{ name = "mutagen" },
|
{ name = "mutagen" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
|
{ name = "pyloudnorm" },
|
||||||
{ name = "pyqt5", specifier = ">=5.15.10" },
|
{ name = "pyqt5", specifier = ">=5.15.10" },
|
||||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
|
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user