diff --git a/TASKS_DAW_A.md b/TASKS_DAW_A.md index def019e..f5c0eb0 100644 --- a/TASKS_DAW_A.md +++ b/TASKS_DAW_A.md @@ -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. diff --git a/app/core/native_render.py b/app/core/native_render.py index eee3c72..99ee9c6 100644 --- a/app/core/native_render.py +++ b/app/core/native_render.py @@ -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: diff --git a/app/core/render_engine.py b/app/core/render_engine.py index a079d4b..64a71d6 100644 --- a/app/core/render_engine.py +++ b/app/core/render_engine.py @@ -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: diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index 3694b0a..1ffea3d 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -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 diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index 98ef77e..dc1c45e 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.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 L0(chunk), R0(chunk), L1(chunk), R1(chunk); std::vector curL(chunk), curR(chunk); diff --git a/src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe b/src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe index 34cf129..b14b918 100644 Binary files a/src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe and b/src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe differ diff --git a/src-tauri/binaries/daw_vst_bridge.exe b/src-tauri/binaries/daw_vst_bridge.exe index 34cf129..b14b918 100644 Binary files a/src-tauri/binaries/daw_vst_bridge.exe and b/src-tauri/binaries/daw_vst_bridge.exe differ diff --git a/src-tauri/binaries/fx_vst_bridge-x86_64-pc-windows-msvc.exe b/src-tauri/binaries/fx_vst_bridge-x86_64-pc-windows-msvc.exe index 1c8300c..1e8f180 100644 Binary files a/src-tauri/binaries/fx_vst_bridge-x86_64-pc-windows-msvc.exe and b/src-tauri/binaries/fx_vst_bridge-x86_64-pc-windows-msvc.exe differ diff --git a/src-tauri/binaries/fx_vst_bridge.exe b/src-tauri/binaries/fx_vst_bridge.exe index 1c8300c..1e8f180 100644 Binary files a/src-tauri/binaries/fx_vst_bridge.exe and b/src-tauri/binaries/fx_vst_bridge.exe differ diff --git a/tests/test_native_render.py b/tests/test_native_render.py index 8da2bf5..27478a4 100644 --- a/tests/test_native_render.py +++ b/tests/test_native_render.py @@ -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 diff --git a/tests/test_render_engine.py b/tests/test_render_engine.py index 0f8c17f..e5fe0c1 100644 --- a/tests/test_render_engine.py +++ b/tests/test_render_engine.py @@ -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