284 lines
11 KiB
Python
284 lines
11 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 bridge từ phase 5.
|
|
"""
|
|
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)]
|
|
# PyInstaller onedir: daw_engine.exe nam <app_root>/daw_engine/, bridge
|
|
# sidecar nam canh app root (khong trong _MEIPASS) - tim ca exe_dir.
|
|
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
|
cands += [os.path.join(exe_dir, "binaries", BRIDGE_EXE),
|
|
os.path.join(exe_dir, BRIDGE_EXE),
|
|
os.path.join(os.path.dirname(exe_dir), "binaries", BRIDGE_EXE),
|
|
os.path.join(os.path.dirname(exe_dir), 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 normalize_audio_peak(audio, target_db=0.0):
|
|
"""Scale audio (numpy array, peak) về target_db nếu vượt; giữ nguyên nếu dưới.
|
|
|
|
Chống clip do note MIDI chồng (polyphony) — spec midi_polyphony_loudness §II:
|
|
render offline nhiều note đồng thời → tổng biên độ vượt 0dBFS → WAV vỡ đỉnh
|
|
(hard clip, méo harmonic) trước khi tới client; limiter master không cứu được.
|
|
"""
|
|
try:
|
|
import numpy as np
|
|
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
|
limit = 10 ** (target_db / 20)
|
|
if peak > limit and peak > 1e-9:
|
|
return audio * (limit / peak)
|
|
except Exception:
|
|
pass
|
|
return audio
|
|
|
|
|
|
def normalize_peak_file(path, target_db=0.0):
|
|
"""Post-render WAV: scan peak, vượt target_db → scale toàn file (giữ format)."""
|
|
try:
|
|
info = sf.info(path)
|
|
data, sr = sf.read(path, dtype="float32", always_2d=True)
|
|
data = normalize_audio_peak(data, target_db)
|
|
sf.write(path, data, sr, subtype=info.subtype)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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:]}")
|
|
normalize_peak_file(out_path)
|
|
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
|
|
|
|
|
|
def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None) -> str:
|
|
"""Render WAV qua native_bridge --render-fx (VST3 FX + builtin gain/normalize).
|
|
|
|
fx_chain: list dict {type: "vst3"|"builtin", path|id, preset_b64?, bypass?,
|
|
params?}. path là plugin id/name → resolve_plugin_path (".vst3"). Trả
|
|
out_path; lỗi → HTTPException (501 thiếu bridge, 404 plugin lạ, 500 render).
|
|
"""
|
|
from app.core.vst_engine import resolve_plugin_path
|
|
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",
|
|
)
|
|
slots = []
|
|
for fx in fx_chain or []:
|
|
typ = fx.get("type")
|
|
if typ == "vst3":
|
|
p = (fx.get("path") or "").strip()
|
|
resolved = p if os.path.isfile(p) else resolve_plugin_path(p, ".vst3")
|
|
if not resolved:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Không tìm thấy VST3 FX: {p} — bấm Plugins Manager → Scan trước.",
|
|
)
|
|
slots.append({"type": "vst3", "path": resolved,
|
|
"preset_b64": fx.get("preset_b64", "") or "",
|
|
"bypass": bool(fx.get("bypass"))})
|
|
elif typ == "builtin":
|
|
slots.append({"type": "builtin", "id": fx.get("id", "gain"),
|
|
"params": dict(fx.get("params") or {}),
|
|
"bypass": bool(fx.get("bypass"))})
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"fx_chain slot không hợp lệ: {typ}")
|
|
if not slots:
|
|
raise HTTPException(status_code=400, detail="fx_chain trống — không có FX để render")
|
|
job = {"sample_rate": int(sample_rate), "block_size": 512, "fx_chain": slots}
|
|
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
|
out = out_path or os.path.join(settings.PROCESSED_DIR, f"fx_{uuid.uuid4().hex[:10]}.wav")
|
|
job_path = out + ".job.json"
|
|
try:
|
|
with open(job_path, "w", encoding="utf-8") as fh:
|
|
json.dump(job, fh)
|
|
r = subprocess.run([exe, "--render-fx", job_path, "--in", input_wav, "--out", out],
|
|
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):
|
|
if os.path.exists(out):
|
|
try:
|
|
os.remove(out)
|
|
except Exception:
|
|
pass
|
|
if r.returncode == 2:
|
|
raise HTTPException(status_code=500,
|
|
detail=f"Bridge không load được plugin. {log[-500:]}")
|
|
raise HTTPException(status_code=500,
|
|
detail=f"FX render thất bại (rc={r.returncode}). {log[-500:]}")
|
|
return out
|
|
except HTTPException:
|
|
raise
|
|
except subprocess.TimeoutExpired:
|
|
if os.path.exists(out):
|
|
try:
|
|
os.remove(out)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail="FX render quá lâu (timeout 120s)")
|
|
except Exception as e:
|
|
if os.path.exists(out):
|
|
try:
|
|
os.remove(out)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=f"FX render thất bại: {e}")
|
|
finally:
|
|
if os.path.exists(job_path):
|
|
try:
|
|
os.remove(job_path)
|
|
except Exception:
|
|
pass
|