Files
uj-mastering-master/metrics.py
T

462 lines
15 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 scipy import signal as scipy_signal
from font_manager import safe_title
from master_core import AudioFile
# Small constant to keep 20*log10(...) from blowing up on perfect silence.
_EPS = 1e-12
def _to_dbfs(linear: np.ndarray | float) -> np.ndarray | float:
"""Convert a linear magnitude to dBFS, floored at _EPS."""
return 20.0 * np.log10(np.maximum(linear, _EPS))
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 + LRA.
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)
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:
times = np.array([len(y) / (2.0 * sr)])
lufs = np.array([integrated if np.isfinite(integrated) else self.SILENCE_FLOOR])
lra = float("nan")
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
try:
lra = float(meter.loudness_range(y))
except (ValueError, FloatingPointError):
lra = float("nan")
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),
"lra": lra,
}
@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"]
lra = data.get("lra", float("nan"))
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",
)
if np.isfinite(lra):
# Invisible plot entry to surface LRA in the legend without adding a line.
ax.plot([], [], " ", label=f"LRA: {lra:.1f} LU")
# 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
class CrestFactorMetric(Metric):
"""Crest factor = 20*log10(peak / RMS) per sliding window, in dB."""
id = "crest_factor"
display_name = "Crest Factor"
WINDOW_S = 1.0
HOP_S = 0.25
def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float64, copy=False)
sr = audio_file.sr
window_n = int(self.WINDOW_S * sr)
hop_n = int(self.HOP_S * sr)
if len(y) < window_n:
times = np.array([len(y) / (2.0 * sr)])
peak = float(np.max(np.abs(y))) if len(y) else 0.0
rms = float(np.sqrt(np.mean(y * y))) if len(y) else 0.0
crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS))
return {"times": times, "crest_db": np.array([crest])}
# RMS via cumulative-sum-of-squares (O(N)); peaks via sliding window view.
y2 = y * y
cumsum = np.concatenate(([0.0], np.cumsum(y2)))
n_windows = 1 + (len(y) - window_n) // hop_n
starts = np.arange(n_windows) * hop_n
ends = starts + window_n
mean_sq = (cumsum[ends] - cumsum[starts]) / window_n
rms = np.sqrt(np.maximum(mean_sq, _EPS))
abs_y = np.abs(y)
peaks = np.empty(n_windows)
for i in range(n_windows):
peaks[i] = np.max(abs_y[starts[i]:ends[i]])
crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms)
times = (starts + window_n / 2.0) / sr
return {"times": times, "crest_db": crest_db}
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
times = data["times"]
crest_db = data["crest_db"]
fig = Figure(figsize=figsize, facecolor="white")
ax = fig.add_subplot(111)
ax.plot(times, crest_db, color="#e09f3e", linewidth=1.4, label=f"Crest factor (1 s)")
# Rules of thumb: ~12 dB = roomy, ~6 dB = heavily limited.
ax.axhline(12.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
ax.text(times[-1], 12.0, " 12 dB", va="center", ha="left", fontsize=8, alpha=0.6)
ax.axhline(6.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
ax.text(times[-1], 6.0, " 6 dB (squashed)", va="center", ha="left", fontsize=8, alpha=0.6)
ax.set_ylim(0.0, 25.0)
ax.set_xlim(times[0], times[-1])
ax.set_ylabel("Crest factor (dB)")
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
class PSRMetric(Metric):
"""Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU.
PSR = sample_peak_dBFS - short_term_LUFS over the same 3 s windows used by
LUFSMetric. High PSR = punchy transients; low PSR = heavily limited.
"""
id = "psr"
display_name = "PSR"
WINDOW_S = 3.0
HOP_S = 0.5
SILENCE_FLOOR = -70.0
def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float64, copy=False)
sr = audio_file.sr
meter = pyln.Meter(sr)
window_n = int(self.WINDOW_S * sr)
hop_n = int(self.HOP_S * sr)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
if len(y) < window_n:
times = np.array([len(y) / (2.0 * sr)])
peak_db = _to_dbfs(np.max(np.abs(y))) if len(y) else self.SILENCE_FLOOR
lufs = LUFSMetric._safe_integrated(meter, y)
psr = peak_db - lufs if np.isfinite(lufs) else 0.0
return {"times": times, "psr": np.array([psr])}
n_windows = 1 + (len(y) - window_n) // hop_n
abs_y = np.abs(y)
lufs_series = np.empty(n_windows)
peaks_db = np.empty(n_windows)
for i in range(n_windows):
start = i * hop_n
end = start + window_n
peaks_db[i] = _to_dbfs(np.max(abs_y[start:end]))
lufs_series[i] = LUFSMetric._safe_integrated(meter, y[start:end])
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
# PSR is meaningless where the loudness reading is below the absolute gate.
valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR)
psr = np.where(valid, peaks_db - lufs_series, np.nan)
return {"times": times, "psr": psr}
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
times = data["times"]
psr = data["psr"]
fig = Figure(figsize=figsize, facecolor="white")
ax = fig.add_subplot(111)
ax.plot(times, psr, color="#7251b5", linewidth=1.4, label="PSR (3 s)")
# Ian Shepherd's rough thresholds.
ax.axhline(10.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
ax.text(times[-1], 10.0, " 10 LU (good punch)", va="center", ha="left", fontsize=8, alpha=0.6)
ax.axhline(4.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
ax.text(times[-1], 4.0, " 4 LU (squashed)", va="center", ha="left", fontsize=8, alpha=0.6)
ax.set_ylim(0.0, 25.0)
ax.set_xlim(times[0], times[-1])
ax.set_ylabel("PSR (LU)")
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
class TruePeakMetric(Metric):
"""ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP.
Per-window true peak with a moderate hop so it renders quickly. Windows are
oversampled independently — slight edge under-detection at window boundaries
is masked by the 60% overlap.
"""
id = "true_peak"
display_name = "True Peak"
WINDOW_S = 0.25
HOP_S = 0.1
OVERSAMPLE = 4
def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float32, copy=False)
sr = audio_file.sr
window_n = int(self.WINDOW_S * sr)
hop_n = int(self.HOP_S * sr)
if len(y) < window_n:
y_up = scipy_signal.resample_poly(y, self.OVERSAMPLE, 1) if len(y) else np.zeros(1, dtype=np.float32)
peak_db = _to_dbfs(np.max(np.abs(y_up))) if len(y_up) else -70.0
return {
"times": np.array([len(y) / (2.0 * sr)]),
"tp_db": np.array([peak_db]),
"integrated_tp_db": float(peak_db),
}
n_windows = 1 + (len(y) - window_n) // hop_n
tp_db = np.empty(n_windows)
for i in range(n_windows):
start = i * hop_n
w = y[start:start + window_n]
w_up = scipy_signal.resample_poly(w, self.OVERSAMPLE, 1)
tp_db[i] = _to_dbfs(np.max(np.abs(w_up)))
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
integrated_tp_db = float(np.max(tp_db))
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
times = data["times"]
tp_db = data["tp_db"]
integrated = data.get("integrated_tp_db", float("nan"))
fig = Figure(figsize=figsize, facecolor="white")
ax = fig.add_subplot(111)
ax.plot(times, tp_db, color="#c1121f", linewidth=1.0, label="True Peak (250 ms)")
# 0 dBTP = sample-level clip; -1 dBTP a common mastering ceiling.
ax.axhline(0.0, color="black", linestyle="--", linewidth=1.0, alpha=0.8)
ax.text(times[-1], 0.0, " 0 dBTP (clip)", va="center", ha="left", fontsize=8, alpha=0.7)
ax.axhline(-1.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
ax.text(times[-1], -1.0, " -1 dBTP (typical ceiling)", va="center", ha="left", fontsize=8, alpha=0.6)
if np.isfinite(integrated):
ax.plot([], [], " ", label=f"Max: {integrated:.2f} dBTP")
ax.set_ylim(-30.0, 6.0)
ax.set_xlim(times[0], times[-1])
ax.set_ylabel("dBTP")
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(),
CrestFactorMetric(),
PSRMetric(),
TruePeakMetric(),
)
}
DEFAULT_METRIC_ID = "rms_power"