Phase 2: native_render Python client (bridge --render) + SF_RENDER_ENGINE flag cho preview/midi-render

This commit is contained in:
2026-08-17 10:49:54 +07:00
parent bbb11f8066
commit 235dee103d
5 changed files with 238 additions and 4 deletions
+1 -1
View File
@@ -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
+16 -3
View File
@@ -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()
+6
View File
@@ -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()
+157
View File
@@ -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
+58
View File
@@ -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