diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 587ed35..eaaf3f6 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -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)}") diff --git a/app/core/loudness.py b/app/core/loudness.py new file mode 100644 index 0000000..3979fbc --- /dev/null +++ b/app/core/loudness.py @@ -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) diff --git a/tests/test_loudness.py b/tests/test_loudness.py new file mode 100644 index 0000000..cde3cb0 --- /dev/null +++ b/tests/test_loudness.py @@ -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