Files
uj-mastering-master/metrics.py
T

232 lines
7.0 KiB
Python
Raw Normal View History

"""
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"