D1: LUFS EBU R128 + true peak sau render (app/core/loudness.py), API /plugins/render tra them lufs/true_peak_db, test 4 cases
This commit is contained in:
+14
-1
@@ -1181,7 +1181,20 @@ def render_project(
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
# EBU R128 loudness + true peak (D1) — trả kèm response để mastering
|
||||
# panel hiển thị LUFS/dBTP ngay sau export mà không cần đọc lại file.
|
||||
data, _sr = sf.read(result_path, dtype="float32", always_2d=True)
|
||||
audio = data.T
|
||||
from app.core import loudness
|
||||
resp = {
|
||||
"url": f"/static/audio/processed/{os.path.basename(result_path)}",
|
||||
"path": result_path,
|
||||
}
|
||||
_lufs = loudness.integrated_lufs(audio, _sr)
|
||||
_tp = loudness.true_peak_db(audio, _sr)
|
||||
resp["lufs"] = round(_lufs, 2) if np.isfinite(_lufs) else None
|
||||
resp["true_peak_db"] = round(_tp, 2) if np.isfinite(_tp) else None
|
||||
return resp
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""EBU R128 loudness + true peak (D1).
|
||||
|
||||
- integrated_lufs: K-weighting (BS.1770) + 400 ms windows, 100 ms hop,
|
||||
absolute (-70 LUFS) + relative (-10 LU) gating -> LUFS.
|
||||
- true_peak_db: 4x oversample (Kaiser-16) -> max |x| in dBTP.
|
||||
|
||||
Stereo-only (channel weight 1.0 L/R); mono broadcast to 2 ch = same result.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.signal import lfilter, resample_poly
|
||||
|
||||
|
||||
def _k_weighting_coeffs(sr: float):
|
||||
"""Two biquad stages per ITU-R BS.1770-4 (RBJ cookbook form)."""
|
||||
# Stage 1: high shelf +4 dB, f0=1681.97 Hz, Q=0.7072
|
||||
G = 3.999843853973347
|
||||
f0 = 1681.974450955533
|
||||
Q = 0.7071752369554196
|
||||
A = 10 ** (G / 40.0)
|
||||
w0 = 2 * np.pi * f0 / sr
|
||||
alpha = np.sin(w0) / (2 * Q)
|
||||
cosw = np.cos(w0)
|
||||
b = np.array([
|
||||
A * ((A + 1) + (A - 1) * cosw + 2 * np.sqrt(A) * alpha),
|
||||
-2 * A * ((A - 1) + (A + 1) * cosw),
|
||||
A * ((A + 1) + (A - 1) * cosw - 2 * np.sqrt(A) * alpha),
|
||||
])
|
||||
a = np.array([
|
||||
(A + 1) - (A - 1) * cosw + 2 * np.sqrt(A) * alpha,
|
||||
2 * ((A - 1) - (A + 1) * cosw),
|
||||
(A + 1) - (A - 1) * cosw - 2 * np.sqrt(A) * alpha,
|
||||
])
|
||||
# Stage 2: high-pass f0=38.14 Hz, Q=0.5003
|
||||
f1 = 38.13547087602444
|
||||
Q1 = 0.5003270373238773
|
||||
w1 = 2 * np.pi * f1 / sr
|
||||
alpha1 = np.sin(w1) / (2 * Q1)
|
||||
cosw1 = np.cos(w1)
|
||||
hp_b = np.array([(1 + cosw1) / 2, -(1 + cosw1), (1 + cosw1) / 2])
|
||||
hp_a = np.array([1 + alpha1, -2 * cosw1, 1 - alpha1])
|
||||
return b / a[0], a / a[0], hp_b, hp_a
|
||||
|
||||
|
||||
def _k_weighted(audio: np.ndarray, sr: float) -> np.ndarray:
|
||||
b1, a1, b2, a2 = _k_weighting_coeffs(sr)
|
||||
out = np.empty_like(audio)
|
||||
for c in range(audio.shape[0]):
|
||||
out[c] = lfilter(b2, a2, lfilter(b1, a1, audio[c]))
|
||||
return out
|
||||
|
||||
|
||||
def integrated_lufs(audio: np.ndarray, sr: float) -> float:
|
||||
"""Integrated loudness in LUFS (EBU R128 / BS.1770-4). -inf for silence."""
|
||||
audio = np.asarray(audio, dtype=np.float64)
|
||||
if audio.ndim == 1:
|
||||
audio = audio[np.newaxis, :]
|
||||
if audio.shape[1] == 0:
|
||||
return -np.inf
|
||||
k = _k_weighted(audio, sr)
|
||||
block = int(round(0.4 * sr))
|
||||
hop = int(round(0.1 * sr))
|
||||
n = max(1, (k.shape[1] - block) // hop + 1)
|
||||
powers = np.empty(n)
|
||||
for i in range(n):
|
||||
seg = k[:, i * hop:i * hop + block]
|
||||
powers[i] = np.mean(seg ** 2)
|
||||
# Absolute gate: -70 LUFS -> power = 10^((-70 + 0.691)/10)
|
||||
abs_gate = 10 ** ((-70.0 + 0.691) / 10.0)
|
||||
above_abs = powers[powers > abs_gate]
|
||||
if above_abs.size == 0:
|
||||
return -np.inf
|
||||
abs_loud = -0.691 + 10.0 * np.log10(np.mean(above_abs))
|
||||
# Relative gate: abs_loud - 10 LU
|
||||
rel_gate = 10 ** ((abs_loud - 10.0 + 0.691) / 10.0)
|
||||
gated = above_abs[above_abs > rel_gate]
|
||||
if gated.size == 0:
|
||||
gated = above_abs
|
||||
return float(-0.691 + 10.0 * np.log10(np.mean(gated)))
|
||||
|
||||
|
||||
def true_peak_db(audio: np.ndarray, sr: float, oversample: int = 4) -> float:
|
||||
"""True peak in dBTP (4x oversample). -inf for digital silence."""
|
||||
audio = np.asarray(audio, dtype=np.float64)
|
||||
if audio.ndim == 1:
|
||||
audio = audio[np.newaxis, :]
|
||||
if audio.shape[1] == 0:
|
||||
return -np.inf
|
||||
up = resample_poly(audio, oversample, 1, axis=1, window=("kaiser", 16))
|
||||
peak = float(np.max(np.abs(up)))
|
||||
if peak <= 1e-12:
|
||||
return -np.inf
|
||||
return 20.0 * np.log10(peak)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""D1: EBU R128 loudness + true peak metrics (app/core/loudness.py)."""
|
||||
import numpy as np
|
||||
|
||||
from app.core import loudness
|
||||
|
||||
|
||||
def _sine(dbfs, sr=44100, sec=3.0, freq=1000.0):
|
||||
t = np.arange(int(sr * sec)) / sr
|
||||
x = (10 ** (dbfs / 20.0)) * np.sin(2 * np.pi * freq * t)
|
||||
return np.stack([x, x])
|
||||
|
||||
|
||||
def test_sine_minus20_lufs():
|
||||
# 1 kHz sine @ -20 dBFS: RMS -23.01 dB + K-weight +0.44 dB - 0.691 offset
|
||||
# => ~ -23.3 LUFS (0 dBFS sine ~ -3.7 LUFS per EBU R128).
|
||||
audio = _sine(-20.0)
|
||||
lufs = loudness.integrated_lufs(audio, 44100)
|
||||
assert -24.5 < lufs < -22.0, lufs
|
||||
tp = loudness.true_peak_db(audio, 44100)
|
||||
assert abs(tp - (-20.0)) < 0.2, tp
|
||||
|
||||
|
||||
def test_true_peak_inter_sample_overshoot():
|
||||
# Square-ish: sample peak 0 dBFS, oversampled true peak > 0 dBTP.
|
||||
sr = 44100
|
||||
x = np.repeat([1.0, 1.0, -1.0, -1.0], int(sr * 0.125))
|
||||
audio = np.stack([x, x])
|
||||
assert loudness.true_peak_db(audio, sr) > 0.05
|
||||
|
||||
|
||||
def test_silence_inf():
|
||||
audio = np.zeros((2, 44100))
|
||||
assert loudness.integrated_lufs(audio, 44100) == -np.inf
|
||||
assert loudness.true_peak_db(audio, 44100) == -np.inf
|
||||
|
||||
|
||||
def test_mono_and_stereo_same():
|
||||
sr = 44100
|
||||
t = np.arange(sr * 2) / sr
|
||||
x = 0.2 * np.sin(2 * np.pi * 440 * t)
|
||||
mono = loudness.integrated_lufs(x, sr)
|
||||
stereo = loudness.integrated_lufs(np.stack([x, x]), sr)
|
||||
assert abs(mono - stereo) < 1e-6
|
||||
Reference in New Issue
Block a user