Files
SonicForgeStudio/app/core/native_render.py
T

195 lines
7.3 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)]
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=-3.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=-3.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