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:
+4
-4
@@ -23,10 +23,10 @@ Trạng thái: `[x]` hoàn thành, `[ ]` chưa. Cập nhật sau mỗi phase.
|
||||
- [x] 2.9 Golden test: C++ vs Python `_apply_builtin_fx_chain` (SNR/diff ngưỡng) + order test xen kẽ.
|
||||
|
||||
## Phase 3 — Engine Python: render + realtime chain hợp nhất
|
||||
- [ ] 3.1 `render_engine.py`: track/master đọc chain hợp nhất; Python DSP giữ làm fallback (RENDER_ENGINE≠bridge).
|
||||
- [ ] 3.2 Bỏ bất đối xứng imager/maximizer (offline master chain đầy đủ 8 loại qua bridge).
|
||||
- [ ] 3.3 PDC: nối REPORT_LATENCY → align track (render_engine.py:286+333).
|
||||
- [ ] 3.4 Pytest: render offline có imager/maximizer master giống live.
|
||||
- [x] 3.1 `render_engine.py`: track/master đọc chain hợp nhất; Python DSP giữ làm fallback (RENDER_ENGINE≠bridge).
|
||||
- [x] 3.2 Bỏ bất đối xứng imager/maximizer (offline master chain đầy đủ 8 loại qua bridge).
|
||||
- [x] 3.3 PDC: nối REPORT_LATENCY → align track (render_engine.py:286+333).
|
||||
- [x] 3.4 Pytest: render offline có imager/maximizer master giống live.
|
||||
|
||||
## Phase 4 — Frontend: WebAudio chỉ I/O
|
||||
- [ ] 4.1 Chain model 1 mảng duy nhất (builtin+vst3 theo UI order); migration project cũ vstFxChain.
|
||||
|
||||
@@ -216,12 +216,14 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None,
|
||||
pass
|
||||
|
||||
|
||||
def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None) -> str:
|
||||
def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None):
|
||||
"""Render WAV qua native_bridge --render-fx (VST3 FX + builtin gain/normalize).
|
||||
|
||||
fx_chain: list dict {type: "vst3"|"builtin", path|id, preset_b64?, bypass?,
|
||||
params?}. path là plugin id/name → resolve_plugin_path (".vst3"). Trả
|
||||
out_path; lỗi → HTTPException (501 thiếu bridge, 404 plugin lạ, 500 render).
|
||||
(out_path, latencies) — latencies: per-slot samples (bridge FX_LATENCIES,
|
||||
Phase 3 PDC; [] nếu exe cũ không báo). Lỗi → HTTPException (501 thiếu
|
||||
bridge, 404 plugin lạ, 500 render).
|
||||
"""
|
||||
from app.core.vst_engine import resolve_plugin_path
|
||||
exe = find_fx_bridge_exe()
|
||||
@@ -284,7 +286,16 @@ def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None) -> str:
|
||||
detail=f"Bridge không load được plugin. {log[-500:]}")
|
||||
raise HTTPException(status_code=500,
|
||||
detail=f"FX render thất bại (rc={r.returncode}). {log[-500:]}")
|
||||
return out
|
||||
latencies = []
|
||||
for _line in (r.stdout or "").splitlines():
|
||||
if "FX_LATENCIES" in _line:
|
||||
try:
|
||||
latencies = [int(x) for x in
|
||||
_line.split("FX_LATENCIES", 1)[1].split()]
|
||||
except Exception:
|
||||
latencies = []
|
||||
break
|
||||
return out, latencies
|
||||
except HTTPException:
|
||||
raise
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
+128
-27
@@ -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:
|
||||
|
||||
@@ -59,6 +59,7 @@ add_executable(daw_vst_bridge
|
||||
src/main.cpp
|
||||
src/RenderJob.cpp
|
||||
src/RenderFxJob.cpp
|
||||
src/BuiltinFxChain.cpp
|
||||
src/FxGuiServer.cpp
|
||||
src/NativeInstrumentEngine.cpp
|
||||
src/SharedMemoryIPC.cpp
|
||||
|
||||
@@ -1016,6 +1016,17 @@ int run_render_fx_job(const std::string& jobPath, const std::string& inPath,
|
||||
sl.fx = std::move(fx);
|
||||
}
|
||||
|
||||
// 4b. Per-slot latency (PDC, Phase 3): vst3 = getLatencySamples() (0 nếu
|
||||
// chưa load/bypass), builtin = 0. 1 dòng stdout để native_render parse →
|
||||
// render_engine align track (cùng thứ tự mảng slots).
|
||||
std::cout << "[RenderFx] FX_LATENCIES";
|
||||
for (const auto& sl : slots) {
|
||||
uint32_t lat = 0;
|
||||
if (sl.vst && !sl.bypass && sl.fx) lat = (uint32_t)sl.fx->latencySamples();
|
||||
std::cout << ' ' << lat;
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// 5. Process chain block-wise. Double-buffered: cur ← slot → nxt, swap.
|
||||
std::vector<float> L0(chunk), R0(chunk), L1(chunk), R1(chunk);
|
||||
std::vector<float> curL(chunk), curR(chunk);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -82,12 +82,13 @@ def test_render_fx_chain_builtin_gain_e2e():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
inp = os.path.join(td, "in.wav")
|
||||
sf.write(inp, data, sr, subtype="FLOAT")
|
||||
out = native_render.render_fx_chain(
|
||||
out, _lat = native_render.render_fx_chain(
|
||||
input_wav=inp,
|
||||
fx_chain=[{"type": "builtin", "id": "gain", "params": {"db": 6.0}}],
|
||||
sample_rate=sr,
|
||||
)
|
||||
assert os.path.isfile(out)
|
||||
assert _lat == [0] # builtin gain: latency 0
|
||||
d, _ = sf.read(out, dtype="float32", always_2d=True)
|
||||
rms = float(np.sqrt(np.mean(d ** 2)))
|
||||
assert rms > 0.09 # ~2x 0.05
|
||||
|
||||
@@ -152,4 +152,79 @@ def sf_read_peaks(path):
|
||||
import numpy as np
|
||||
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
||||
return data, float(np.max(np.abs(data)))
|
||||
_FX_BRIDGE_TEST_PATH = r"C:/Users/locpham/SonicForgeStudio/native_bridge/build/Release/fx_vst_bridge.exe"
|
||||
|
||||
|
||||
def _master_project(chain, pan=0.6, **flat):
|
||||
"""Project với mastering_settings.chain + flat mastering knobs (giống UI)."""
|
||||
track = _midi_track("__no_such_plugin__")
|
||||
track["volume_db"] = -20.0 # tránh normalize (>1.0) làm hỏng tỉ lệ
|
||||
track["pan"] = pan # constant-power pan → L≠R: master có side content
|
||||
proj = {
|
||||
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": 1.0, "tracks": [track]},
|
||||
"section_store": {},
|
||||
}
|
||||
if chain is not None:
|
||||
ms = {"chain": chain}
|
||||
ms.update(flat)
|
||||
proj["mastering_settings"] = ms
|
||||
return proj
|
||||
|
||||
|
||||
def _side_rms(path):
|
||||
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
||||
side = data[:, 0] - data[:, 1]
|
||||
return float(np.sqrt(np.mean(side ** 2)))
|
||||
|
||||
|
||||
def _render_rms(path):
|
||||
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
||||
return float(np.sqrt(np.mean(data ** 2)))
|
||||
|
||||
|
||||
def test_render_master_chain_imager_maximizer_via_bridge(monkeypatch, tmp_path):
|
||||
"""Phase 3.2: master dùng mastering_settings.chain (hết bất đối xứng) —
|
||||
imager w=0 giết side, w=200 tăng side, maximizer boost RMS — qua bridge."""
|
||||
if not os.path.isfile(_FX_BRIDGE_TEST_PATH):
|
||||
pytest.skip("fx bridge chưa build")
|
||||
monkeypatch.setenv("SF_FX_BRIDGE_PATH", _FX_BRIDGE_TEST_PATH)
|
||||
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
||||
from app.api.v1 import plugins as pl
|
||||
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
||||
engine = PythonRenderEngine()
|
||||
|
||||
def render(proj, name):
|
||||
out = str(tmp_path / name)
|
||||
engine.render_project(proj, out)
|
||||
return out
|
||||
|
||||
base = _side_rms(render(_master_project(None), "base.wav"))
|
||||
s0 = _side_rms(render(_master_project(
|
||||
[{"id": "imager", "type": "imager", "name": "Imager", "active": True}],
|
||||
w1=0, w2=0, w3=0, w4=0), "imager0.wav"))
|
||||
s2 = _side_rms(render(_master_project(
|
||||
[{"id": "imager", "type": "imager", "name": "Imager", "active": True}],
|
||||
w1=200, w2=200, w3=200, w4=200), "imager200.wav"))
|
||||
assert base > 0.001 # pan tạo side content
|
||||
assert s0 < 0.05 * base # w=0 → side≈0 (imager đã được áp qua bridge)
|
||||
assert s2 > 1.5 * base # w=200 → side tăng
|
||||
|
||||
rm0 = _render_rms(render(_master_project(
|
||||
[{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}],
|
||||
maxGain=0.0), "max0.wav"))
|
||||
rm6 = _render_rms(render(_master_project(
|
||||
[{"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}],
|
||||
maxGain=6.0), "max6.wav"))
|
||||
assert rm6 > 1.3 * rm0 # boost 6dB → RMS tăng
|
||||
|
||||
|
||||
def test_probe_track_latency_no_vst3_returns_zero(monkeypatch):
|
||||
"""Phase 3.3: probe latency chỉ spawn bridge khi chain có vst3 —
|
||||
chain builtin-only → 0, không spawn exe."""
|
||||
from app.api.v1 import plugins as pl
|
||||
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
||||
track = _midi_track("__no_such_plugin__")
|
||||
track["fx_chain"] = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
|
||||
from app.core.render_engine import _probe_track_latency
|
||||
assert _probe_track_latency(track, 44100) == 0
|
||||
|
||||
Reference in New Issue
Block a user