Files
SonicForgeStudio/app/core/native_render.py
T
admin 26086cb331 fix(render): VSTi export lay preset dung bridge channel (midi_channel, khong phai track.id)
render_session_container da tinh midi_channel round-robin melodic giong
unifiedMidiRouter.allocateChannel, nhung VSTi branch truyen track.id vao
bridge_state_preset_b64 -> lay preset cua channel stale (VD track5 -> ch5
trong khi realtime choi ch2) -> export sai loai nhac cu. Dung midi_channel.
2026-08-25 21:33:33 +07:00

411 lines
18 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"
FX_BRIDGE_EXE = "fx_vst_bridge.exe" if os.name == "nt" else "fx_vst_bridge"
def find_bridge_exe(exe_name=BRIDGE_EXE, env_key="SF_BRIDGE_PATH") -> str:
"""Đường dẫn bridge exe: env <env_key> → bundle (frozen) → src-tauri/binaries → install/ (dev)."""
env = os.getenv(env_key, "").strip()
if env and os.path.isfile(env):
return env
# Tauri externalBin renames sidecars → <name>-x86_64-pc-windows-msvc.exe.
sidecar = exe_name[:-4] + "-x86_64-pc-windows-msvc.exe" if exe_name.lower().endswith(".exe") else exe_name
cands = []
if getattr(sys, "frozen", False):
root = getattr(sys, "_MEIPASS", "")
if root:
cands += [os.path.join(root, "binaries", exe_name),
os.path.join(root, "binaries", sidecar),
os.path.join(root, exe_name),
os.path.join(root, sidecar)]
# 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", exe_name),
os.path.join(exe_dir, "binaries", sidecar),
os.path.join(exe_dir, exe_name),
os.path.join(exe_dir, sidecar),
os.path.join(os.path.dirname(exe_dir), "binaries", exe_name),
os.path.join(os.path.dirname(exe_dir), "binaries", sidecar),
os.path.join(os.path.dirname(exe_dir), exe_name),
os.path.join(os.path.dirname(exe_dir), sidecar)]
# Canonical build output (build_native_bridge.ps1) + legacy install/ (dev).
cands.append(os.path.join(settings.BASE_DIR, "src-tauri", "binaries", sidecar))
cands.append(os.path.join(settings.BASE_DIR, "install", exe_name))
for p in cands:
if p and os.path.isfile(p):
return p
return ""
def find_fx_bridge_exe() -> str:
"""Đường dẫn FX bridge (fx_vst_bridge.exe): SF_FX_BRIDGE_PATH → bundle → install/."""
return find_bridge_exe(FX_BRIDGE_EXE, "SF_FX_BRIDGE_PATH")
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 get_plugin_manager, resolve_plugin_path
# get_plugin_manager gộp plugin_dirs user (Plugin Manager) — bản cũ dùng
# PluginManager() chỉ scan VST_DIR mặc định → VSTi trong thư mục user
# (vd C:\Program Files\Steinberg\VSTPlugins) 404 → render_engine fallback
# oscillator synth → bounce sai tiếng ("synthwave lạ"). resolve_plugin_path
# là lưới an toàn thứ 2 (so tên file case-insensitive, walk cả dir user).
path = get_plugin_manager()._scan_plugins().get(instrument_id)
if not path:
path = resolve_plugin_path(instrument_id)
if not path:
return None, None
# resolve_plugin_path so theo stem — chặn false positive (vd Synapse Audio
# → Synapse Audio.nksf, preset file không phải plugin).
if not path.lower().endswith((".vst3", ".dll", ".so", ".sf2", ".sf3", ".sfz")):
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 bridge_state_preset_b64(channel=None, plugin_path=None) -> str:
"""presetBase64 từ bridge_state.json — bridge C++ đang giữ preset user
chỉnh realtime (capture mỗi ~2s). Export tạo bridge mới không có preset →
plugin load init patch → âm khác realtime (user tưởng "fallback"). Fallback
này nối: dùng preset mà bridge đã capture cho channel đó.
Match: channel == midi_channel (round-robin melodic — cùng quy ước
unifiedMidiRouter.allocateChannel, KHÔNG phải track.id) + path khớp plugin
(basename, case-insensitive). Không tìm thấy → "" (giữ hành vi cũ — không
đoán bừa preset channel khác)."""
try:
st_path = os.path.join(os.environ.get("APPDATA", ""), "SonicForgeDAW",
"state", "bridge_state.json")
if not os.path.isfile(st_path):
return ""
with open(st_path, encoding="utf-8", errors="replace") as fh:
st = json.load(fh)
try:
ch = int(channel) if channel is not None else None
except Exception:
ch = None
want = None
if plugin_path:
want = os.path.basename(os.fspath(plugin_path)).lower()
for ins in st.get("instruments") or []:
if ch is not None and ins.get("channel") != ch:
continue
ip = ins.get("path") or ""
if want and os.path.basename(ip).lower() != want:
continue
pb = ins.get("presetBase64") or ""
if pb:
return pb
except Exception:
pass
return ""
def _run_notes_job(job: dict, label: str) -> tuple:
"""Chạy job MIDI render qua native_bridge --render → (out_path, duration_sec).
Dùng chung cho VSTi (render_offline) + SoundFont (render_soundfont_offline).
Lỗi → HTTPException (501 thiếu bridge, 500 lỗi render — file rác bị xóa).
"""
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",
)
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,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
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 {label}. {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(job.get("sample_rate", 44100))
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_offline(instrument_id, notes, bpm, sample_rate, preset_id=None,
preset_path=None, preset_data_b64=None,
soundfont_bank=None, soundfont_program=None,
plugin_path=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 = None, None
# plugin_path (từ schema synth_engine.plugin_path) ưu tiên — không phụ
# thuộc scan; fallback resolve theo tên instrument_id.
if plugin_path and os.path.exists(plugin_path):
path = os.fspath(plugin_path)
itype = classify_instrument(path)
if not path:
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.",
)
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
return _run_notes_job(job, instrument_id)
def render_soundfont_offline(sf_path, notes, bpm, sample_rate, bank=0, program=0) -> tuple:
"""Render MIDI notes bằng SoundFont (.sf2/.sf3) qua native_bridge --render.
Thay pyfluidsynth khi thiếu FluidSynth lib: bridge nhúng FluidSynth C API
(NativeInstrumentEngine.cpp, InstrumentType::SOUNDFONT_SF2_SF3) — cùng job
shape với render_offline nhưng instrument_type=2 + plugin_path là file SF2.
Lỗi → HTTPException (501 thiếu bridge, 500 lỗi render)."""
job = {
"instrument_type": 2,
"plugin_path": os.fspath(sf_path),
"sample_rate": int(sample_rate),
"bpm": float(bpm),
"soundfont_bank": int(bank) & 0x7F,
"soundfont_program": int(program) & 0x7F,
"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],
}
return _run_notes_job(job, os.fspath(sf_path))
def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None):
"""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, latencies) — latencies: per-slot samples (bridge FX_LATENCIES,
Phase 3 PDC; [] nếu exe cũ không báo). Lỗi → HTTPException (501 thiếu
bridge, 404 plugin lạ, 500 render).
"""
from app.core.vst_engine import resolve_plugin_path
exe = find_fx_bridge_exe()
if not exe:
raise HTTPException(
status_code=501,
detail="native_bridge không khả dụng — đặt SF_FX_BRIDGE_PATH hoặc cài fx_vst_bridge",
)
slots = []
for fx in fx_chain or []:
if not isinstance(fx, dict) or fx.get("active") is False:
continue
typ = fx.get("type")
if typ == "vst3":
p = (fx.get("path") or "").strip()
resolved = p if os.path.exists(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 in ("eq", "eqpro", "imager", "maximizer", "compressor",
"limiter", "exciter", "rebalance", "multiband", "deesser"):
# Builtin DSP — chain hợp nhất (spec PLAN_DAW_A.md Phase 1). C++
# RenderFxJob xử lý theo id/params (Phase 2); giữ NGUYÊN thứ tự.
slots.append({"type": "builtin", "id": typ,
"params": dict(fx.get("params") or {}),
"bypass": bool(fx.get("bypass"))})
elif typ == "builtin":
# Legacy: {type: builtin, id: gain} — giữ cho project cũ.
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,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
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:]}")
latencies = []
for _line in (r.stdout or "").splitlines():
if "FX_LATENCIES" in _line:
try:
latencies = [int(x) for x in
_line.split("FX_LATENCIES", 1)[1].split()]
except Exception:
latencies = []
break
return out, latencies
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