diff --git a/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md b/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md index ef7a46d..453c8e0 100644 --- a/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md +++ b/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md @@ -1,7 +1,7 @@ # PLAN: thay pedalboard (GPL-3.0) bằng native_bridge — render VSTi offline Ngày: 2026-08-15 -Trạng thái: Phase 1 xong (import .vstpreset -> loadSerializedState trong --render, golden file Nexus, test 20/20). Windows sửa code bridge/desktop; +Trạng thái: Phase 2 xong (app/core/native_render.py client spawn bridge --render; plugins.py preview + /midi-render dùng bridge khi SF_RENDER_ENGINE=bridge; test Python 109 pass, e2e Nexus VST3+preset RMS khớp). Windows sửa code bridge/desktop; Linux làm bridge Linux + Docker + test so sánh. ## 1. Mục tiêu diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index f621739..0925240 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -874,7 +874,8 @@ def _write_carla_project(plugin_name: str, plugin_path: str) -> str: @router.post("/preview") async def preview_instrument(req: PreviewRequest): """Quick-render preview VSTi (âm thật, cùng code path với export).""" - if not HAS_PEDALBOARD: + # SF_RENDER_ENGINE=bridge: preview qua native_bridge, không cần pedalboard + if settings.RENDER_ENGINE != "bridge" and not HAS_PEDALBOARD: raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này") if not req.notes: raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview") @@ -950,7 +951,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c "url": f"/static/audio/processed/{os.path.basename(out_path)}", "path": out_path, "duration_sec": round(duration_sec, 3), - "render_mode": "pedalboard", + "render_mode": settings.RENDER_ENGINE, } except HTTPException: # Không để lại file rác nếu thất bại giữa chừng @@ -980,9 +981,21 @@ def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float, sample_rate: int, preset_id=None, preset_path=None, preset_data_b64=None, soundfont_bank=None, soundfont_program=None) -> tuple: - """Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR. + """Render MIDI notes (VSTi + preset) → WAV trong PROCESSED_DIR — qua + pedalboard (mặc định) hoặc native_bridge --render (SF_RENDER_ENGINE=bridge). Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được.""" + if settings.RENDER_ENGINE == "bridge": + from app.core import native_render + return native_render.render_offline( + instrument_id=instrument_id, + notes=notes, + bpm=bpm, + sample_rate=sample_rate, + preset_id=preset_id, + preset_path=preset_path, + preset_data_b64=preset_data_b64, + ) if not HAS_PEDALBOARD: raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này") pm = PluginManager() diff --git a/app/config.py b/app/config.py index 179326a..49c75ae 100644 --- a/app/config.py +++ b/app/config.py @@ -67,4 +67,10 @@ class Settings: # storage (Windows desktop). PRESET_DIR: str = os.path.join(STORAGE_DIR, "presets") + # Render engine offline (preview/export VSTi): 'pedalboard' (cũ, GPL-3.0) + # hoặc 'bridge' (native_bridge --render, MIT). Mặc định pedalboard trong + # giai đoạn chuyển; đảo sang 'bridge' ở phase 5 + # (PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md). + RENDER_ENGINE: str = os.getenv("SF_RENDER_ENGINE", "pedalboard") + settings = Settings() diff --git a/app/core/native_render.py b/app/core/native_render.py new file mode 100644 index 0000000..c74a3b0 --- /dev/null +++ b/app/core/native_render.py @@ -0,0 +1,157 @@ +# app/core/native_render.py +"""Render MIDI notes → WAV qua native_bridge CLI (daw_vst_bridge --render). + +Thay thế pedalboard (GPL-3.0) bằng bridge (MIT) cho preview/export offline: +cùng chữ ký (out_path, duration_sec) nên plugins.py/render_engine thay trực +tiếp. KHÔNG import pedalboard. Kích hoạt bằng SF_RENDER_ENGINE=bridge +(settings.RENDER_ENGINE); mặc định pedalboard trong giai đoạn chuyển. +""" +import base64 +import json +import os +import subprocess +import sys +import uuid + +import soundfile as sf +from fastapi import HTTPException + +from app.config import settings + +BRIDGE_EXE = "daw_vst_bridge.exe" if os.name == "nt" else "daw_vst_bridge" + + +def find_bridge_exe() -> str: + """Đường dẫn bridge exe: env SF_BRIDGE_PATH → bundle (frozen) → install/ (dev).""" + env = os.getenv("SF_BRIDGE_PATH", "").strip() + if env and os.path.isfile(env): + return env + cands = [] + if getattr(sys, "frozen", False): + root = getattr(sys, "_MEIPASS", "") + if root: + cands += [os.path.join(root, "binaries", BRIDGE_EXE), + os.path.join(root, BRIDGE_EXE)] + cands.append(os.path.join(settings.BASE_DIR, "install", BRIDGE_EXE)) + for p in cands: + if p and os.path.isfile(p): + return p + return "" + + +def classify_instrument(path: str) -> int: + """InstrumentType enum của bridge: 0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ.""" + low = path.lower() + if low.endswith(".vst3"): + return 0 + if low.endswith((".dll", ".so")): + return 1 + if low.endswith((".sf2", ".sf3")): + return 2 + if low.endswith(".sfz"): + return 3 + return 0 + + +def _resolve_instrument(instrument_id: str): + from app.core.vst_engine import PluginManager + path = PluginManager()._scan_plugins().get(instrument_id) + if not path: + return None, None + return classify_instrument(path), path + + +def _preset_b64(preset_id, preset_path, preset_data_b64) -> str: + """Base64 .vstpreset cho job: bytes nhúng ưu tiên, sau đó id/path file.""" + if preset_data_b64: + return preset_data_b64 + from app.core.vst_engine import resolve_preset_path + p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "") + if p: + try: + with open(p, "rb") as fh: + return base64.b64encode(fh.read()).decode("ascii") + except Exception: + return "" + return "" + + +def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None, + preset_path=None, preset_data_b64=None) -> tuple: + """Render MIDI notes qua native_bridge --render → (out_path, duration_sec). + + Giữ response shape của _render_midi_notes_pedalboard. Lỗi → HTTPException + (404 instrument lạ, 501 thiếu bridge, 500 lỗi render — file rác bị xóa). + """ + itype, path = _resolve_instrument(instrument_id) + if not path: + raise HTTPException( + status_code=404, + detail=f"Không tìm thấy VSTi: {instrument_id} — chưa scan thấy plugin này. " + "Kiểm tra Plugins Manager → Scan.", + ) + exe = find_bridge_exe() + if not exe: + raise HTTPException( + status_code=501, + detail="native_bridge không khả dụng — đặt SF_BRIDGE_PATH hoặc cài daw_vst_bridge", + ) + job = { + "instrument_type": itype, + "plugin_path": path, + "sample_rate": int(sample_rate), + "bpm": float(bpm), + "notes": [{ + "pitch": int(n.get("pitch", 60)), + "velocity": int(float(n.get("velocity", 0.8)) * 127), + "start_beat": float(n.get("start_beat", 0)), + "duration_beats": float(n.get("duration_beats", 1)), + } for n in notes], + } + if itype == 0: # preset chỉ áp dụng cho VST3 (loadSerializedState) + preset = _preset_b64(preset_id, preset_path, preset_data_b64) + if preset: + job["preset"] = preset + os.makedirs(settings.PROCESSED_DIR, exist_ok=True) + out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav") + job_path = out_path + ".job.json" + try: + with open(job_path, "w", encoding="utf-8") as fh: + json.dump(job, fh) + r = subprocess.run([exe, "--render", job_path, "--out", out_path], + capture_output=True, text=True, timeout=120) + log = ((r.stdout or "") + (r.stderr or "")).strip() + if r.returncode != 0 or not os.path.isfile(out_path): + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + if r.returncode == 2: + raise HTTPException(status_code=500, + detail=f"Bridge không load được plugin {instrument_id}. {log[-500:]}") + raise HTTPException(status_code=500, + detail=f"Render thất bại (rc={r.returncode}). {log[-500:]}") + return out_path, sf.info(out_path).frames / float(sample_rate) + except HTTPException: + raise + except subprocess.TimeoutExpired: + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + raise HTTPException(status_code=500, detail="Render quá lâu (timeout 120s)") + except Exception as e: + if os.path.exists(out_path): + try: + os.remove(out_path) + except Exception: + pass + raise HTTPException(status_code=500, detail=f"Render thất bại: {e}") + finally: + if os.path.exists(job_path): + try: + os.remove(job_path) + except Exception: + pass diff --git a/tests/test_native_render.py b/tests/test_native_render.py new file mode 100644 index 0000000..152d0b6 --- /dev/null +++ b/tests/test_native_render.py @@ -0,0 +1,58 @@ +"""Phase 2: native_render client (thay pedalboard bằng native_bridge --render). + +Unit: phân loại plugin, tìm bridge, instrument lạ → 404. E2E nhỏ: bridge render +SF2 thật ra WAV đúng duration (giống native_bridge/tests/test_offline_render.py). +Skip nếu máy thiếu bridge exe hoặc file SF2 fixture.""" +import json +import os +import subprocess +import tempfile + +import pytest +import soundfile as sf +from fastapi import HTTPException + +from app.core import native_render + + +def test_classify_instrument(): + assert native_render.classify_instrument(r"C:\VST\Vital.vst3") == 0 + assert native_render.classify_instrument(r"C:\VST\foo.dll") == 1 + assert native_render.classify_instrument(r"C:\SF\a.sf2") == 2 + assert native_render.classify_instrument(r"C:\SF\a.sf3") == 2 + assert native_render.classify_instrument(r"C:\SF\a.sfz") == 3 + + +def test_find_bridge_exe_dev(): + exe = native_render.find_bridge_exe() + assert exe, "không tìm thấy bridge exe (install/ hoặc SF_BRIDGE_PATH)" + assert os.path.isfile(exe) + + +def test_render_offline_unknown_instrument_404(): + with pytest.raises(HTTPException) as ei: + native_render.render_offline("__no_such_plugin__", [], bpm=120.0, sample_rate=44100) + assert ei.value.status_code == 404 + + +def test_bridge_render_sf2_e2e(): + exe = native_render.find_bridge_exe() + if not exe: + pytest.skip("bridge exe missing") + sf2 = r"C:\Users\locpham\SonicForgeStudio\app\storage\soundfonts\518e850f-a5d3-4790-b1f9-0c90c203c524.sf2" + if not os.path.isfile(sf2): + pytest.skip("SF2 fixture missing") + with tempfile.TemporaryDirectory() as td: + job = {"instrument_type": 2, "plugin_path": sf2, "sample_rate": 44100, "bpm": 120.0, + "notes": [{"pitch": 60, "velocity": 101, "start_beat": 0.0, "duration_beats": 1.0}]} + jp = os.path.join(td, "job.json") + out = os.path.join(td, "out.wav") + with open(jp, "w", encoding="utf-8") as fh: + json.dump(job, fh) + r = subprocess.run([exe, "--render", jp, "--out", out], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, ((r.stdout or "") + (r.stderr or ""))[-500:] + assert os.path.isfile(out) + info = sf.info(out) + assert info.samplerate == 44100 + assert info.frames == 22050