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:
+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"
|
||||
Reference in New Issue
Block a user