fix(audio): offline mix limiter -3dBFS, 2-stage normalize (-14 LUFS + hard peak -1 dBTP), OGG export

This commit is contained in:
2026-08-24 09:10:00 +07:00
parent 10997a65b9
commit 708e7de32b
7 changed files with 221 additions and 96 deletions
+48 -3
View File
@@ -223,6 +223,33 @@ def _apply_limiter(buf, params, sr):
x = np.clip(buf, -1.0, 1.0)
return np.tanh(x * k) / tanh_k
def _apply_mix_limiter(buf, sr):
"""Mixer summing cap (spec §2): smoothed brickwall -3 dBFS mirror C++
realtime (NativeInstrumentEngine.cpp renderAll) — ceiling 0.7071, attack
0.1 (~10 samples), release 0.0006, gain chung 2 kênh. Áp cho MASTER CHAIN
INPUT offline (realtime đã cap sau khi sum; offline trước đây thiếu).
ponytail: loop Python O(n) — thay numba/vectorized khi render >10 phút."""
if buf.size == 0:
return buf
k_ceil = 0.7071
k_attack = 0.1
k_release = 0.0006
eps = 1e-12
m_list = np.max(np.abs(buf), axis=0).tolist()
n = buf.shape[1]
g = 1.0
gains = np.empty(n, dtype=np.float32)
for i in range(n):
t = k_ceil / (m_list[i] + eps)
if t > 1.0:
t = 1.0
if t < g:
g += (t - g) * k_attack
else:
g += (1.0 - g) * k_release
gains[i] = g
return buf * gains
def _apply_exciter(buf, params, sr):
params = params or {}
drive = float(params.get("drive", 40) or 40)
@@ -862,6 +889,11 @@ class PythonRenderEngine:
_cache={},
)
# Mixer summing cap (spec §2): -3 dBFS brickwall mirror C++ realtime —
# áp cho MASTER CHAIN INPUT, TRƯỚC master FX chain/volume. Offline
# trước đây thiếu cap này (chỉ anti-clip >1.0 sau master chain).
master_buffer = _apply_mix_limiter(master_buffer, self.sample_rate)
# Masterbus FX chain (VST3 FX / builtin) — render qua native_bridge
# --render-fx, rồi volume_db master; backward compatible (không có
# master.fx_chain → giữ nguyên luồng cũ).
@@ -948,16 +980,29 @@ class PythonRenderEngine:
if normalize:
from app.core import loudness
if normalize_target == "-14lufs":
# Spec: master ra -14 LUFS (scale toàn cục) VÀ hard-peak
# <= -1.0 dBTP (chặn peak sau scale, deterministic — peak
# cap thắng khi nguồn crest cao, LUFS hạ xuống dưới -14).
# ponytail: lookahead limiter khi cần giữ -14 LUFS với
# nguồn crest cao (master thật hiếm gặp).
_l = loudness.integrated_lufs(master_buffer, out_sr)
if np.isfinite(_l):
master_buffer *= 10 ** ((-14.0 - _l) / 20.0)
_tp = loudness.true_peak_db(master_buffer, out_sr)
if np.isfinite(_tp) and _tp > -1.0:
master_buffer *= 10 ** ((-1.0 - _tp) / 20.0)
else: # "-1dbtp"
_tp = loudness.true_peak_db(master_buffer, out_sr)
if np.isfinite(_tp):
master_buffer *= 10 ** ((-1.0 - _tp) / 20.0)
# Write final output file (D3: PCM_16/PCM_24 + dither, FLOAT mac dinh)
if bit_depth == 16:
# Write final output file (D3: PCM_16/PCM_24 + dither, FLOAT mac dinh).
# OGG: Vorbis float lossy — bỏ dither/PCM/BWF (chunk WAV-only).
ext = os.path.splitext(output_filepath)[1].lower()
if ext == ".ogg":
sf.write(output_filepath, master_buffer.T, out_sr,
format="OGG", subtype="VORBIS")
elif bit_depth == 16:
sf.write(output_filepath, _quantize_pcm(master_buffer, 16, dither).T,
out_sr, subtype="PCM_16")
elif bit_depth == 24:
@@ -967,7 +1012,7 @@ class PythonRenderEngine:
sf.write(output_filepath, master_buffer.T, out_sr, subtype="FLOAT")
# Gap 13: BWF/INFO chunks (bext + LIST/INFO title/artist/ISRC) —
# chen sau khi ghi PCM, loi metadata khong lam hong file audio.
if metadata:
if metadata and ext != ".ogg":
try:
from app.core.wav_bwf import patch_bwf_metadata
patch_bwf_metadata(output_filepath, metadata)