Phase 3: engine render chain hop nhat — track/master doc mastering_settings.chain (imager/maximizer), PDC probe FX_LATENCIES, fix daw_vst_bridge link BuiltinFxChain, pytest 3.2/3.3

This commit is contained in:
2026-08-22 20:17:59 +07:00
parent 1390ad545b
commit e5c957fc87
11 changed files with 235 additions and 35 deletions
+128 -27
View File
@@ -293,6 +293,84 @@ def _track_latency_samples(track):
total += int(slot["latency_samples"])
return total
def _merge_track_fx_chain(track):
"""Chain hợp nhất track cho bridge (Phase 3.1): builtin fx_chain + vst3
vst_fx_chain, giữ NGUYÊN thứ tự UI. vst_fx_bypass → bỏ hẳn slot vst3
(builtin vẫn chạy)."""
merged = []
for slot in (track.get("fx_chain") or []):
if isinstance(slot, str):
slot = {"type": slot}
if isinstance(slot, dict):
merged.append(slot)
if not track.get("vst_fx_bypass"):
merged += list(track.get("vst_fx_chain") or [])
return merged
# Param mapping master chain entry → C++ BuiltinFx (Phase 3.2). UI chain entry
# chỉ lưu params cho eqpro; imager/maximizer/eq/... giữ knob ở flat mastering
# state (maxGain, w1..w4, ...). Offline fill từ flat state để khớp live
# (MASTER_MODULE_IO WebAudio đọc cùng state đó).
_BUILTIN_PARAM_MAP = {
"eq": [("g1", "eqLowGain"), ("g2", "eqMid1Gain"),
("g3", "eqMid2Gain"), ("g4", "eqHighGain")],
"compressor": [("threshold", "compThreshold"), ("ratio", "compRatio"),
("makeup", "compMakeup")],
"limiter": [("ceiling", "limThreshold")],
"exciter": [("drive", "excDrive")],
"rebalance": [("mid", "rebalMid"), ("side", "rebalSide")],
"imager": [("w1", "w1"), ("w2", "w2"), ("w3", "w3"), ("w4", "w4")],
"maximizer": [("boost_db", "maxGain"), ("soft_clip", "maxSoftClip"),
("upward", "maxUpward"), ("ceiling_db", "ceiling")],
}
def _master_chain_entry(ms, entry):
"""Entry master chain cho bridge: params entry (eqpro) + fill từ flat
mastering state (knob imager/maximizer/eq/...)."""
typ = entry.get("type")
if typ not in _BUILTIN_PARAM_MAP:
return entry
params = dict(entry.get("params") or {})
for cpp_key, js_key in _BUILTIN_PARAM_MAP[typ]:
v = ms.get(js_key)
if v is not None and cpp_key not in params:
params[cpp_key] = v
out = dict(entry)
out["params"] = params
return out
def _probe_track_latency(track, sample_rate):
"""Latency thật (samples) của track từ bridge --render-fx (Phase 3.3):
chạy chain qua probe silence ngắn, parse FX_LATENCIES. 0 nếu không đo
được. Chỉ chạy khi track có slot vst3 (builtin DSP latency = 0)."""
if settings.RENDER_ENGINE != "bridge":
return 0
merged = _merge_track_fx_chain(track)
if not any(isinstance(s, dict) and s.get("type") == "vst3" for s in merged):
return 0
from app.core.native_render import render_fx_chain
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
probe_in = os.path.join(
settings.PROCESSED_DIR,
f"latprobe_in_{os.getpid()}_{np.random.randint(100000)}.wav")
probe_out = probe_in.replace("latprobe_in_", "latprobe_out_")
try:
sf.write(probe_in, np.zeros((2, 64), dtype=np.float32).T,
sample_rate, subtype="FLOAT")
_, lats = render_fx_chain(input_wav=probe_in, fx_chain=merged,
sample_rate=sample_rate, out_path=probe_out)
return int(sum(lats)) if lats else 0
except Exception as e:
logger.warning("[RenderEngine] PDC probe failed: %s", e)
return 0
finally:
for f in (probe_in, probe_out):
if os.path.exists(f):
try:
os.remove(f)
except Exception:
pass
class PythonRenderEngine:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
@@ -332,12 +410,16 @@ class PythonRenderEngine:
# PDC pre-scan (spec §3): latency mỗi track từ chain slots; track ngắn
# hơn bị delay khi mix để mọi track sum sample-accurate phase-aligned.
# Phase 3.3: slot chưa khai latency_samples → probe bridge --render-fx
# (FX_LATENCIES) đo latency thật của VST3 (getLatencySamples).
_track_lat = {}
_max_lat = 0
for _t in tracks:
if solo_ids and _t.get("id") not in solo_ids:
continue
_lat = _track_latency_samples(_t)
if not _lat:
_lat = _probe_track_latency(_t, self.sample_rate)
_track_lat[_t.get("id")] = _lat
_max_lat = max(_max_lat, _lat)
@@ -595,31 +677,17 @@ class PythonRenderEngine:
continue
# ── Track FX chain (spec §2 Step 2): serial custom FX → VST FX ──
# Builtin DSP slots (fx_chain) → legacy fx_type (chorus/reverb) →
# VST FX (vst_fx_chain) qua native_bridge. Mỗi slot sửa buffer
# in-place trước khi chuyển tới slot kế — đúng Buffer_out =
# Plugin_n(...Plugin_1(Buffer_in)).
# Phase 3.1: RENDER_ENGINE=bridge → chain HỢP NHẤT (builtin
# fx_chain + vst3 vst_fx_chain xen kẽ đúng thứ tự UI) qua 1 lần
# native_bridge --render-fx. Mỗi slot sửa buffer in-place đúng
# Buffer_out = Plugin_n(...Plugin_1(Buffer_in)). Legacy
# chorus/reverb (fx_type cũ) chỉ có Python DSP → áp trước bridge;
# `ponytail:` track có cả fx_chain + fx_type sẽ chạy legacy trước
# chain (track cũ không có chain hiện đại — order không quan trọng).
fx_type = track.get("fx_type")
if track.get("fx_active", True):
try:
track_buffer = _apply_builtin_fx_chain(
track_buffer, track.get("fx_chain"), self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Builtin FX chain failed: %s", e)
fx_type = track.get("fx_type")
if fx_type == "chorus":
try:
track_buffer = _apply_legacy_chorus(track_buffer, self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
elif fx_type == "reverb":
try:
track_buffer = _apply_legacy_reverb(track_buffer, self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
track_fx_chain = track.get("vst_fx_chain") or []
if track_fx_chain and not track.get("vst_fx_bypass"):
if settings.RENDER_ENGINE == "bridge":
merged_fx = _merge_track_fx_chain(track)
if merged_fx and settings.RENDER_ENGINE == "bridge":
try:
from app.core.native_render import render_fx_chain
tmp_in = os.path.join(
@@ -627,8 +695,12 @@ class PythonRenderEngine:
f"trackfx_in_{os.getpid()}_{np.random.randint(100000)}.wav")
tmp_out = tmp_in.replace("trackfx_in_", "trackfx_out_")
try:
if fx_type == "chorus":
track_buffer = _apply_legacy_chorus(track_buffer, self.sample_rate)
elif fx_type == "reverb":
track_buffer = _apply_legacy_reverb(track_buffer, self.sample_rate)
sf.write(tmp_in, track_buffer.T, self.sample_rate, subtype="FLOAT")
render_fx_chain(input_wav=tmp_in, fx_chain=track_fx_chain,
render_fx_chain(input_wav=tmp_in, fx_chain=merged_fx,
sample_rate=self.sample_rate, out_path=tmp_out)
data, _ = sf.read(tmp_out, dtype="float32", always_2d=True)
if data.shape[1] >= 2:
@@ -645,7 +717,26 @@ class PythonRenderEngine:
except Exception as e:
logger.warning("[RenderEngine] Track FX chain skipped: %s", e)
else:
logger.warning("[RenderEngine] Track FX chain cần RENDER_ENGINE=bridge — bỏ qua")
# Fallback Python DSP (RENDER_ENGINE≠bridge) — giữ luồng cũ:
# builtin fx_chain → legacy fx_type. VST3 cần bridge.
try:
track_buffer = _apply_builtin_fx_chain(
track_buffer, track.get("fx_chain"), self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Builtin FX chain failed: %s", e)
if fx_type == "chorus":
try:
track_buffer = _apply_legacy_chorus(track_buffer, self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
elif fx_type == "reverb":
try:
track_buffer = _apply_legacy_reverb(track_buffer, self.sample_rate)
except Exception as e:
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
if (track.get("vst_fx_chain") and not track.get("vst_fx_bypass")
and settings.RENDER_ENGINE != "bridge"):
logger.warning("[RenderEngine] Track VST FX cần RENDER_ENGINE=bridge — bỏ qua")
# Process track volume (spec §2 Step 3: Gain_i scaling)
gain_linear = 10 ** (vol_db / 20.0)
@@ -694,8 +785,18 @@ class PythonRenderEngine:
# 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ũ).
# Phase 3.2: ưu tiên mastering_settings.chain (chain hợp nhất UI —
# builtin imager/maximizer + vst3 xen kẽ, params qua entry + flat
# mastering state) → bỏ bất đối xứng offline thiếu imager/maximizer;
# fallback master.fx_chain (project cũ, vst3-only).
master = main_session.get("master") or {}
fx_chain = master.get("fx_chain") or []
_ms = project_json.get("mastering_settings") or {}
_ms_chain = _ms.get("chain") if isinstance(_ms.get("chain"), list) else None
if _ms_chain:
fx_chain = [_master_chain_entry(_ms, s) for s in _ms_chain
if isinstance(s, dict)]
else:
fx_chain = master.get("fx_chain") or []
if fx_chain and not master.get("bypass"):
if settings.RENDER_ENGINE == "bridge":
try: