473 lines
20 KiB
Python
473 lines
20 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 ""
|
|
|
|
|
|
EXPRESSION_CC = 11 # CC11 expression
|
|
|
|
def _expression_events(notes, bpm, sample_rate, grid=64):
|
|
"""Sinh CC11 events sample-accurate từ expression_curve mỗi note.
|
|
|
|
curve: [{position_ratio 0..1, value 0..127}] — nội suy tuyến tính, grid
|
|
cố định (mặc định 64 samples ≈ 1.3ms @48k) → step CC <= 1-2, không zipper.
|
|
Event shape {sample, kind:2, a:11, b:value} — khớp RenderJob.cpp Ev.
|
|
"""
|
|
if not notes or not bpm:
|
|
return []
|
|
beat_sec = 60.0 / float(bpm)
|
|
events = []
|
|
for n in notes:
|
|
curve = n.get("expression_curve") if isinstance(n, dict) else None
|
|
if not curve:
|
|
continue
|
|
try:
|
|
pts = sorted((float(p["position_ratio"]), int(round(float(p["value"])))) for p in curve if p)
|
|
except Exception:
|
|
continue
|
|
if not pts:
|
|
continue
|
|
start_sample = int(round(float(n.get("start_beat", 0)) * beat_sec * float(sample_rate)))
|
|
dur_samples = int(round(float(n.get("duration_beats", 1)) * beat_sec * float(sample_rate)))
|
|
if dur_samples <= 0:
|
|
continue
|
|
|
|
def interp(ratio):
|
|
ratio = max(0.0, min(1.0, ratio))
|
|
if len(pts) == 1:
|
|
return pts[0][1]
|
|
if ratio <= pts[0][0]:
|
|
return pts[0][1]
|
|
if ratio >= pts[-1][0]:
|
|
return pts[-1][1]
|
|
for i in range(1, len(pts)):
|
|
if ratio <= pts[i][0]:
|
|
a, b = pts[i - 1], pts[i]
|
|
t = (ratio - a[0]) / (b[0] - a[0]) if b[0] > a[0] else 0.0
|
|
return int(round(a[1] + (b[1] - a[1]) * t))
|
|
return pts[-1][1]
|
|
|
|
pos = 0
|
|
while pos <= dur_samples:
|
|
ratio = pos / float(dur_samples)
|
|
events.append({
|
|
"sample": start_sample + int(pos),
|
|
"kind": 2,
|
|
"a": EXPRESSION_CC,
|
|
"b": max(0, min(127, interp(ratio))),
|
|
})
|
|
pos += max(1, int(grid))
|
|
events.sort(key=lambda e: e["sample"])
|
|
return events
|
|
|
|
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
|
|
events = _expression_events(notes, bpm, sample_rate)
|
|
if events:
|
|
job["events"] = events
|
|
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],
|
|
}
|
|
events = _expression_events(notes, bpm, sample_rate)
|
|
if events:
|
|
job["events"] = events
|
|
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
|