2026-05-30 00:42:45 +09:00
|
|
|
"""
|
|
|
|
|
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
|
2026-05-31 21:22:36 +09:00
|
|
|
from scipy import signal as scipy_signal
|
2026-05-30 00:42:45 +09:00
|
|
|
|
|
|
|
|
from font_manager import safe_title
|
|
|
|
|
from master_core import AudioFile
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 21:22:36 +09:00
|
|
|
# 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))
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 00:42:45 +09:00
|
|
|
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):
|
2026-05-31 21:22:36 +09:00
|
|
|
"""ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA.
|
2026-05-30 00:42:45 +09:00
|
|
|
|
|
|
|
|
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])
|
2026-05-31 21:22:36 +09:00
|
|
|
lra = float("nan")
|
2026-05-30 00:42:45 +09:00
|
|
|
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
|
2026-05-31 21:22:36 +09:00
|
|
|
try:
|
|
|
|
|
lra = float(meter.loudness_range(y))
|
|
|
|
|
except (ValueError, FloatingPointError):
|
|
|
|
|
lra = float("nan")
|
2026-05-30 00:42:45 +09:00
|
|
|
|
|
|
|
|
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),
|
2026-05-31 21:22:36 +09:00
|
|
|
"lra": lra,
|
2026-05-30 00:42:45 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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"]
|
2026-05-31 21:22:36 +09:00
|
|
|
lra = data.get("lra", float("nan"))
|
2026-05-30 00:42:45 +09:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-31 21:22:36 +09:00
|
|
|
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")
|
|
|
|
|
|
2026-05-30 00:42:45 +09:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 21:22:36 +09:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 00:42:45 +09:00
|
|
|
METRICS: dict[str, Metric] = {
|
|
|
|
|
m.id: m for m in (
|
|
|
|
|
RMSPowerMetric(),
|
|
|
|
|
WaveformMetric(),
|
|
|
|
|
LUFSMetric(),
|
2026-05-31 21:22:36 +09:00
|
|
|
CrestFactorMetric(),
|
|
|
|
|
PSRMetric(),
|
|
|
|
|
TruePeakMetric(),
|
2026-05-30 00:42:45 +09:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
DEFAULT_METRIC_ID = "rms_power"
|