fix(audio): offline mix limiter -3dBFS, 2-stage normalize (-14 LUFS + hard peak -1 dBTP), OGG export
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user