Phase 2: native_render Python client (bridge --render) + SF_RENDER_ENGINE flag cho preview/midi-render
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user