feat: VST FX rack per-track + masterbus render via native_bridge

This commit is contained in:
2026-08-17 21:31:57 +07:00
parent 8fbacd1d2b
commit b261eb0d7e
18 changed files with 1805 additions and 14 deletions
+82
View File
@@ -192,3 +192,85 @@ def render_offline(instrument_id, notes, bpm, sample_rate, preset_id=None,
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
+66
View File
@@ -390,6 +390,39 @@ class PythonRenderEngine:
except Exception as e:
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
# Track FX chain (VST3/builtin) — FX Rack per-track: render track
# buffer qua native_bridge --render-fx. Ưu tiên fx_chain; fx_type
# cũ (chorus/reverb scipy) giữ làm fallback khi không có fx_chain.
track_fx_chain = track.get("vst_fx_chain") or []
if track_fx_chain and not track.get("vst_fx_bypass"):
if settings.RENDER_ENGINE == "bridge":
try:
from app.core.native_render import render_fx_chain
tmp_in = os.path.join(
settings.PROCESSED_DIR,
f"trackfx_in_{os.getpid()}_{np.random.randint(100000)}.wav")
tmp_out = tmp_in.replace("trackfx_in_", "trackfx_out_")
try:
sf.write(tmp_in, track_buffer.T, self.sample_rate, subtype="FLOAT")
render_fx_chain(input_wav=tmp_in, fx_chain=track_fx_chain,
sample_rate=self.sample_rate, out_path=tmp_out)
data, _ = sf.read(tmp_out, dtype="float32", always_2d=True)
if data.shape[1] >= 2:
track_buffer = data.T[:2, :]
else:
track_buffer = np.repeat(data.T, 2, axis=0)
finally:
for f in (tmp_in, tmp_out):
if os.path.exists(f):
try:
os.remove(f)
except Exception:
pass
except Exception as e:
logger.warning("[RenderEngine] Track FX chain skipped: %s", e)
else:
logger.warning("[RenderEngine] Track FX chain cần RENDER_ENGINE=bridge — bỏ qua")
# Process track volume
gain_linear = 10 ** (vol_db / 20.0)
processed_track = track_buffer * gain_linear
@@ -427,6 +460,39 @@ class PythonRenderEngine:
_cache={},
)
# Masterbus FX chain (VST3 FX / builtin) — render qua native_bridge
# --render-fx, rồi volume_db master; backward compatible (không có
# master.fx_chain → giữ nguyên luồng cũ).
master = main_session.get("master") or {}
fx_chain = master.get("fx_chain") or []
if fx_chain and not master.get("bypass"):
if settings.RENDER_ENGINE == "bridge":
try:
from app.core.native_render import render_fx_chain
tmp_in = os.path.join(settings.PROCESSED_DIR,
f"master_in_{os.getpid()}_{np.random.randint(100000)}.wav")
tmp_out = tmp_in.replace("master_in_", "master_out_")
try:
sf.write(tmp_in, master_buffer.T, self.sample_rate, subtype="FLOAT")
render_fx_chain(input_wav=tmp_in, fx_chain=fx_chain,
sample_rate=self.sample_rate, out_path=tmp_out)
data, _ = sf.read(tmp_out, dtype="float32", always_2d=True)
if data.shape[1] >= 2:
master_buffer = data.T[:2, :]
else:
master_buffer = np.repeat(data.T, 2, axis=0)
finally:
for f in (tmp_in, tmp_out):
if os.path.exists(f):
try:
os.remove(f)
except Exception:
pass
except Exception as e:
logger.warning("[RenderEngine] Masterbus FX skipped: %s", e)
else:
logger.warning("[RenderEngine] Masterbus FX cần RENDER_ENGINE=bridge — bỏ qua")
# Normalization to prevent clipping
max_peak = np.max(np.abs(master_buffer))
if max_peak > 1.0:
+49
View File
@@ -5,6 +5,8 @@ import numpy as np
import functools
import threading
from ctypes import c_char_p
import subprocess
# Native render (bridge/pyfluidsynth) không thread-safe: tuần tự hóa
RENDER_LOCK = threading.RLock()
@@ -280,6 +282,7 @@ class PluginManager:
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
self._sf_scan_cache = None # cache for _scan_soundfonts()
self._fx_scan_cache = None # cache for list_fx()
def _scan_plugins(self) -> dict:
plugins = {}
@@ -453,6 +456,52 @@ class PluginManager:
"soundfonts": self._scan_soundfonts()
}
def list_fx(self, refresh=False) -> list:
"""VST3 effect plugins (is_fx) từ native_bridge --scan (SEH-safe).
Bridge tự phân loại FX/instrument theo VST3 subCategory; VST2 (.dll/.so)
không introspect được nên bridge báo instrument — bỏ qua ở đây. Cache
process-wide; refresh=True quét lại (Plugins Manager → Scan).
"""
if not refresh and self._fx_scan_cache is not None:
return self._fx_scan_cache
from app.core.native_render import find_bridge_exe
exe = find_bridge_exe()
results = []
if exe:
dirs = [d for d in [self.vst_dir] + self.extra_vst_dirs
if d and os.path.isdir(d)]
for d in dirs:
try:
r = subprocess.run([exe, "--scan", d], capture_output=True,
text=True, timeout=300)
except Exception:
continue
if r.returncode != 0:
continue
try:
data = json.loads(r.stdout[r.stdout.find("{"):])
except Exception:
continue
for p in data.get("plugins", []):
if not p.get("is_fx"):
continue
path = p.get("path", "")
if not path:
continue
name = p.get("name") or os.path.splitext(os.path.basename(path))[0]
results.append({"id": name, "name": name, "path": path,
"type": p.get("type", "VST3")})
seen, uniq = set(), []
for fx in results:
if fx["path"] in seen:
continue
seen.add(fx["path"])
uniq.append(fx)
uniq.sort(key=lambda x: x["name"].lower())
self._fx_scan_cache = uniq
return uniq
@staticmethod
def midi_events_to_messages(midi_events: list, bpm: float, sr: int, bank: int = None, program: int = None) -> list:
beat_duration_sec = 60.0 / max(30.0, bpm)