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