Files
SonicForgeStudio/app/core/native_render.py
T
admin 3d963eb8dd phase2: fix velocity double-scaling in bridge job, bank/program parity, golden RMS test
- render_offline: send velocity as float 0..1 (RenderJob.cpp scales *127);
  old int(0.8*127) was scaled again -> clamp 127, velocity ignored
- forward soundfont_bank/program into bridge job (presence-based, matches
  midi_events_to_messages)
- new tests: bridge branch dispatch (SF_RENDER_ENGINE=bridge -> 404),
  bridge vs pedalboard golden RMS comparison (plan section 6)
2026-08-17 11:04:01 +07:00

165 lines
6.2 KiB
Python

# 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,
soundfont_bank=None, soundfont_program=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": float(n.get("velocity", 0.8)), # bridge job: float 0..1 (RenderJob.cpp *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
# bank/program (giữ parity với midi_events_to_messages cũ: key có mặt
# kể cả giá trị 0 — bridge xử lý theo presence, không theo truthiness)
if soundfont_bank is not None:
job["soundfont_bank"] = int(soundfont_bank) & 0x7F
if soundfont_program is not None:
job["soundfont_program"] = int(soundfont_program) & 0x7F
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