feat: VST FX rack per-track + masterbus render via native_bridge
This commit is contained in:
@@ -1002,6 +1002,82 @@ def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/fx")
|
||||
async def list_fx_plugins(current_user: dict = Depends(get_current_user),
|
||||
refresh: bool = Query(False)):
|
||||
"""List VST effect plugins (is_fx) — native_bridge --scan trên các dir.
|
||||
|
||||
Chỉ plugin VST3 effect (Ozone, Scaler Audio, ...); instrument/VST2 bị
|
||||
bridge loại. refresh=true → quét lại (Plugins Manager → Scan)."""
|
||||
from app.core.vst_engine import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
return {"success": True, "plugins": pm.list_fx(refresh=refresh)}
|
||||
|
||||
|
||||
class FxRenderRequest(BaseModel):
|
||||
"""Render WAV qua chuỗi FX (VST3 effect + builtin gain/normalize).
|
||||
|
||||
Dùng cho masterbus (render xong project → Ozone trên bus) và FX Rack
|
||||
per-track (preview offline qua bridge; realtime vẫn WebAudio)."""
|
||||
input_file_id: Optional[str] = None # file trong PROCESSED_DIR
|
||||
input_path: Optional[str] = None # path tuyệt đối (test)
|
||||
fx_chain: list = []
|
||||
sample_rate: int = 44100
|
||||
|
||||
|
||||
@router.post("/fx-render")
|
||||
async def fx_render(req: FxRenderRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Áp fx_chain lên WAV đầu vào → WAV mới trong PROCESSED_DIR."""
|
||||
enforce_password_changed(current_user)
|
||||
src = (req.input_path or "").strip()
|
||||
if not src:
|
||||
fid = (req.input_file_id or "").strip()
|
||||
if not fid:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Cần input_file_id hoặc input_path")
|
||||
src = os.path.join(settings.PROCESSED_DIR, os.path.basename(fid))
|
||||
if not os.path.isfile(src):
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"File đầu vào không tồn tại: {src}")
|
||||
user_id = current_user["user_id"]
|
||||
out_name = f"user_{user_id}_fx_{uuid.uuid4().hex[:10]}.wav"
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, out_name)
|
||||
try:
|
||||
_render_fx_locked(src, req.fx_chain, req.sample_rate, out_path)
|
||||
duration = sf.info(out_path).frames / max(1, req.sample_rate)
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": out_name,
|
||||
"url": f"/static/audio/processed/{out_name}",
|
||||
"path": out_path,
|
||||
"duration_sec": round(duration, 3),
|
||||
"render_mode": settings.RENDER_ENGINE,
|
||||
}
|
||||
except HTTPException:
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"FX render thất bại: {e}")
|
||||
|
||||
|
||||
@_render_locked
|
||||
def _render_fx_locked(input_path: str, fx_chain: list, sample_rate: int, out_path: str):
|
||||
"""FX render qua native_bridge --render-fx (không thread-safe → lock)."""
|
||||
if settings.RENDER_ENGINE != "bridge":
|
||||
raise HTTPException(status_code=501,
|
||||
detail="Chỉ hỗ trợ native_bridge render — set SF_RENDER_ENGINE=bridge")
|
||||
from app.core.native_render import render_fx_chain
|
||||
render_fx_chain(input_wav=input_path, fx_chain=fx_chain,
|
||||
sample_rate=sample_rate, out_path=out_path)
|
||||
|
||||
|
||||
class SoundfontRenderRequest(BaseModel):
|
||||
"""Render MIDI notes → WAV bằng soundfont THẬT qua native FluidSynth
|
||||
(pyfluidsynth) — luồng âm instrument cho môi trường STANDALONE (xử lí
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+200
-2
@@ -10885,6 +10885,8 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
synth_engine: t.synth_engine || undefined,
|
||||
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
|
||||
fx_chain: (t.fxChain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type, active: m.active !== false }),
|
||||
vst_fx_chain: (t.vstFxChain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
|
||||
vst_fx_bypass: !!t.vstFxBypass,
|
||||
server_file_id: t.serverFileId || null,
|
||||
items: items
|
||||
};
|
||||
@@ -10985,6 +10987,8 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore, visite
|
||||
soundfont_program: t.soundfont_program !== null ? t.soundfont_program : undefined,
|
||||
synth_engine: t.synth_engine || undefined,
|
||||
fxChain: (t.fx_chain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type || m, active: m.active !== false }),
|
||||
vstFxChain: (t.vst_fx_chain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
|
||||
vstFxBypass: t.vst_fx_bypass !== undefined ? !!t.vst_fx_bypass : false,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -11094,7 +11098,15 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
|
||||
return maxBar;
|
||||
})(),
|
||||
auto_compute_length: true,
|
||||
tracks: mainTracks
|
||||
tracks: mainTracks,
|
||||
...(masteringSettings && Array.isArray(masteringSettings.vstFxChain) && masteringSettings.vstFxChain.length > 0 ? {
|
||||
master: {
|
||||
fx_chain: masteringSettings.vstFxChain.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
|
||||
volume_db: 0,
|
||||
mute: false,
|
||||
bypass: !!masteringSettings.vstBypass
|
||||
}
|
||||
} : {})
|
||||
},
|
||||
sub_tabs: subTabs,
|
||||
section_store: sectionStore,
|
||||
@@ -11177,12 +11189,19 @@ const deserializeProjectFromSchema = (schemaObj) => {
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const _migratedMastering = _migrateMasteringSettings(schemaObj.mastering_settings) || {};
|
||||
const _masterBus = (schemaObj.main_session && schemaObj.main_session.master) || {};
|
||||
const _masterFx = Array.isArray(_masterBus.fx_chain) ? _masterBus.fx_chain : [];
|
||||
return {
|
||||
bpm: bpmVal,
|
||||
tracks: restoredTracks,
|
||||
sessionTabs: restoredSessionTabs,
|
||||
subTabs: restoredSubTabs,
|
||||
masteringSettings: _migrateMasteringSettings(schemaObj.mastering_settings),
|
||||
masteringSettings: {
|
||||
..._migratedMastering,
|
||||
vstFxChain: _masterFx.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
|
||||
vstBypass: !!_masterBus.bypass
|
||||
},
|
||||
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0
|
||||
};
|
||||
};
|
||||
@@ -11735,6 +11754,25 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
);
|
||||
};
|
||||
|
||||
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-slate-900/60 border border-slate-800 rounded-lg px-2 py-1.5 flex-wrap">
|
||||
<button onClick={() => toggleV(idx)} className={`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass ? 'bg-teal-500 text-slate-950' : 'bg-slate-700 text-slate-300'}`} title={s.bypass ? 'Bypass' : 'Active'}>
|
||||
<i data-lucide="power" className="w-2.5 h-2.5"></i>
|
||||
</button>
|
||||
<span className="text-[10px] font-bold text-slate-300 font-mono w-6 shrink-0">{idx + 1}</span>
|
||||
<select value={s.path || ''} onChange={e => changeV(idx, e.target.value)} className="flex-1 min-w-[200px] bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none focus:border-teal-500">
|
||||
<option value="">— Chọn VST FX —</option>
|
||||
{(fxl || []).map(f => <option key={f.path} value={f.path}>{f.name || f.id}</option>)}
|
||||
</select>
|
||||
<span className={`text-[9px] font-mono ${s.preset_b64 ? 'text-emerald-400' : 'text-slate-600'}`}>{s.preset_b64 ? '✓ preset' : 'no preset'}</span>
|
||||
<button onClick={() => uploadV(idx)} className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[10px] font-semibold rounded transition" title="Upload .vstpreset xuất từ Carla (Save preset)">Preset</button>
|
||||
<button onClick={() => { if (!s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } window.SonicAPI.openInCarla(s.name || s.path, s.path).then(r => { if (r && r.success) window.showToast && window.showToast('Đã mở Carla với ' + (s.name || s.path) + ' — chỉnh âm trong Carla rồi Save preset', 'success'); else window.showToast && window.showToast('Không mở được Carla', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở Carla: ' + (err.message || err), 'error')); }} className="px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition" title="Mở native GUI trong Carla">Carla</button>
|
||||
<button onClick={() => moveV(idx, -1)} disabled={idx === 0} className="w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30" title="Lên"><i data-lucide="chevron-up" className="w-3 h-3"></i></button>
|
||||
<button onClick={() => moveV(idx, 1)} disabled={idx === chainLen - 1} className="w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30" title="Xuống"><i data-lucide="chevron-down" className="w-3 h-3"></i></button>
|
||||
<button onClick={() => removeV(idx)} className="text-slate-600 hover:text-red-400 text-xs px-0.5" title="Xóa slot"><i data-lucide="x" className="w-3 h-3"></i></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
const [activeType, setActiveType] = React.useState('eq');
|
||||
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
||||
@@ -11758,9 +11796,59 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
}
|
||||
}, [track && track.id]);
|
||||
|
||||
// VST FX (native_bridge --render-fx) — list VST3 effect plugins + preview
|
||||
const [fxList, setFxList] = React.useState([]);
|
||||
React.useEffect(() => {
|
||||
if (window.SonicAPI && window.SonicAPI.listFx) {
|
||||
window.SonicAPI.listFx().then(d => setFxList((d && d.plugins) || [])).catch(() => setFxList([]));
|
||||
}
|
||||
}, [track && track.id]);
|
||||
const previewAudioRef = React.useRef(null);
|
||||
const [vstBypass, setVstBypass] = React.useState(!!(track && track.vstFxBypass));
|
||||
const [addVstSel, setAddVstSel] = React.useState('');
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
const chain = track.fxChain || [];
|
||||
|
||||
// ── VST FX chain (native_bridge --render-fx, áp dụng khi Export/Offline render) ──
|
||||
const vstChain = (track.vstFxChain || []);
|
||||
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); };
|
||||
const toggleVst = (idx) => setVstChain(vstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
|
||||
const removeVst = (idx) => setVstChain(vstChain.filter((_, i) => i !== idx));
|
||||
const moveVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= vstChain.length) return; const next = [...vstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setVstChain(next); };
|
||||
const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path); if (!fx) return; setVstChain([...vstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]); };
|
||||
const changeVstPlugin = (idx, path) => { const fx = (fxList || []).find(f => f.path === path); setVstChain(vstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
|
||||
const uploadVstPreset = (idx) => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
inp.accept = '.vstpreset,.fxp,.fxb,.dspreset';
|
||||
inp.onchange = () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const b64 = String(reader.result || '').split(',')[1] || '';
|
||||
setVstChain(vstChain.map((s, i) => i === idx ? { ...s, preset_b64: b64 } : s));
|
||||
window.showToast && window.showToast('Đã gắn preset .vstpreset vào slot ' + (idx + 1), 'success');
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
inp.click();
|
||||
};
|
||||
const previewFx = async () => {
|
||||
try {
|
||||
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null; }
|
||||
if (!window.__renderProjectPreview) { window.showToast && window.showToast('Không có hàm render preview', 'warning'); return; }
|
||||
window.showToast && window.showToast('Đang render offline (bridge)...', 'info');
|
||||
const res = await window.__renderProjectPreview();
|
||||
const au = new Audio((window.API_BASE_URL || '') + res.url);
|
||||
previewAudioRef.current = au;
|
||||
au.onended = () => { previewAudioRef.current = null; };
|
||||
au.play().then(() => window.showToast && window.showToast('Preview offline — âm thật = âm export (VST FX)', 'success')).catch(() => {});
|
||||
} catch (e) { /* toast ở __renderProjectPreview */ }
|
||||
};
|
||||
|
||||
const setChain = (next) => {
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: next });
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
|
||||
@@ -12033,6 +12121,30 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* VST FX CHAIN (native_bridge --render-fx — áp dụng khi Export/Offline render) */}
|
||||
<div className="bg-slate-950 border border-teal-900/60 rounded-xl px-3 py-2 flex flex-col gap-2 shrink-0 select-none">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[10px] font-bold text-teal-400 uppercase tracking-widest font-mono shrink-0">VST FX:</span>
|
||||
<span className="text-[9px] text-slate-500 font-mono truncate">Plugin VST3 (Ozone...) — realtime browser vẫn WebAudio builtin; VST áp dụng lúc Export/Offline render (preview = âm export)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={previewFx} className="px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition flex items-center gap-1">
|
||||
<i data-lucide="play" className="w-3 h-3"></i> Nghe thử (render offline)
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer">
|
||||
<input type="checkbox" checked={vstBypass} onChange={e => { const val = e.target.checked; setVstBypass(val); if (onUpdateTrack) onUpdateTrack(track.id, { vstFxBypass: val }); }} className="w-3 h-3" /> Bypass
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{vstChain.length === 0 && <div className="text-[10px] text-slate-500 font-mono">Chưa có VST FX — chọn plugin bên dưới để thêm vào insert chain (WebAudio chain → VST FX → Fader).</div>}
|
||||
{vstChain.map((s, idx) => vstRow(s, idx, toggleVst, changeVstPlugin, uploadVstPreset, moveVst, removeVst, fxList, vstChain.length))}
|
||||
<select value={addVstSel} onChange={e => { setAddVstSel(e.target.value); if (e.target.value) { addVst(e.target.value); setAddVstSel(''); } }} className="w-full bg-slate-950 border border-dashed border-slate-700 hover:border-teal-600 text-teal-300 rounded px-2 py-1.5 text-[11px] outline-none" title="Thêm VST FX">
|
||||
<option value="">+ Thêm VST FX...</option>
|
||||
{(fxList || []).map(f => <option key={f.path} value={f.path}>{f.name || f.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* WORKSPACE CONTROLS */}
|
||||
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-xl p-4 flex flex-col gap-4 min-h-[240px] overflow-y-auto oz-scrollbar">
|
||||
<div className="h-8 bg-slate-900/80 border-b border-slate-800/80 px-3 flex items-center justify-between text-xs font-mono rounded-t-lg shrink-0">
|
||||
@@ -12257,6 +12369,44 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
}));
|
||||
};
|
||||
|
||||
// ── VST FX masterbus (native_bridge --render-fx, áp dụng khi Export/Offline render) ──
|
||||
const masterVstChain = (ozState.vstFxChain || []);
|
||||
const setMasterVstChain = (next) => setOzState(prev => ({ ...prev, vstFxChain: next }));
|
||||
const toggleMasterVst = (idx) => setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
|
||||
const removeMasterVst = (idx) => setMasterVstChain(masterVstChain.filter((_, i) => i !== idx));
|
||||
const moveMasterVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= masterVstChain.length) return; const next = [...masterVstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setMasterVstChain(next); };
|
||||
const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.path === path); if (!fx) return; setMasterVstChain([...masterVstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]); };
|
||||
const changeMasterVstPlugin = (idx, path) => { const fx = (masterFxList || []).find(f => f.path === path); setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
|
||||
const uploadMasterVstPreset = (idx) => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
inp.accept = '.vstpreset,.fxp,.fxb,.dspreset';
|
||||
inp.onchange = () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const b64 = String(reader.result || '').split(',')[1] || '';
|
||||
setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, preset_b64: b64 } : s));
|
||||
window.showToast && window.showToast('Đã gắn preset .vstpreset vào slot ' + (idx + 1), 'success');
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
inp.click();
|
||||
};
|
||||
const previewMasterFx = async () => {
|
||||
try {
|
||||
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null; }
|
||||
if (!window.__renderProjectPreview) { window.showToast && window.showToast('Không có hàm render preview', 'warning'); return; }
|
||||
window.showToast && window.showToast('Đang render offline (bridge)...', 'info');
|
||||
const res = await window.__renderProjectPreview();
|
||||
const au = new Audio((window.API_BASE_URL || '') + res.url);
|
||||
previewAudioRef.current = au;
|
||||
au.onended = () => { previewAudioRef.current = null; };
|
||||
au.play().then(() => window.showToast && window.showToast('Preview offline — âm thật = âm export (VST FX)', 'success')).catch(() => {});
|
||||
} catch (e) { /* toast ở __renderProjectPreview */ }
|
||||
};
|
||||
|
||||
const audioRef = React.useRef({ source: null });
|
||||
const eqCanvasRef = React.useRef(null);
|
||||
const imagerCanvasRef = React.useRef(null);
|
||||
@@ -12266,6 +12416,16 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
const lufsAccRef = React.useRef([]);
|
||||
const knobsInitializedRef = React.useRef(false);
|
||||
|
||||
// VST FX masterbus (native_bridge --render-fx) — list VST3 effect plugins + preview
|
||||
const [masterFxList, setMasterFxList] = React.useState([]);
|
||||
React.useEffect(() => {
|
||||
if (window.SonicAPI && window.SonicAPI.listFx) {
|
||||
window.SonicAPI.listFx().then(d => setMasterFxList((d && d.plugins) || [])).catch(() => setMasterFxList([]));
|
||||
}
|
||||
}, []);
|
||||
const previewAudioRef = React.useRef(null);
|
||||
const [addMasterVstSel, setAddMasterVstSel] = React.useState('');
|
||||
|
||||
// Wave Observer Refs & States
|
||||
const woCanvasRef = React.useRef(null);
|
||||
const woLeftHistoryRef = React.useRef(new Float32Array(400).fill(0));
|
||||
@@ -12966,6 +13126,30 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* VST FX MASTERBUS (native_bridge --render-fx — áp dụng khi Export/Offline render) */}
|
||||
<div className="bg-slate-950 border-b border-teal-900/60 px-4 py-2 flex flex-col gap-2 shrink-0">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[10px] font-bold text-teal-400 uppercase tracking-widest oz-font-mono shrink-0">VST FX MASTERBUS:</span>
|
||||
<span className="text-[9px] text-slate-500 oz-font-mono truncate">Plugin VST3 (Ozone...) — realtime browser vẫn WebAudio builtin; VST áp dụng lúc Export/Offline render (preview = âm export)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={previewMasterFx} className="px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition flex items-center gap-1">
|
||||
<i data-lucide="play" className="w-3 h-3"></i> Nghe thử (render offline)
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-[10px] text-slate-400 oz-font-mono cursor-pointer">
|
||||
<input type="checkbox" checked={!!ozState.vstBypass} onChange={e => setOzState(prev => ({ ...prev, vstBypass: e.target.checked }))} className="w-3 h-3" /> Bypass
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{masterVstChain.length === 0 && <div className="text-[10px] text-slate-500 oz-font-mono">Chưa có VST FX masterbus — chọn plugin bên dưới (Ozone...) để chạy chain sau khi mix WebAudio.</div>}
|
||||
{masterVstChain.map((s, idx) => vstRow(s, idx, toggleMasterVst, changeMasterVstPlugin, uploadMasterVstPreset, moveMasterVst, removeMasterVst, masterFxList, masterVstChain.length))}
|
||||
<select value={addMasterVstSel} onChange={e => { setAddMasterVstSel(e.target.value); if (e.target.value) { addMasterVst(e.target.value); setAddMasterVstSel(''); } }} className="w-full bg-slate-950 border border-dashed border-slate-700 hover:border-teal-600 text-teal-300 rounded px-2 py-1.5 text-[11px] outline-none" title="Thêm VST FX">
|
||||
<option value="">+ Thêm VST FX...</option>
|
||||
{(masterFxList || []).map(f => <option key={f.path} value={f.path}>{f.name || f.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* MAIN WORKSPACE */}
|
||||
<main className="flex-1 flex overflow-hidden min-h-0">
|
||||
|
||||
@@ -17417,6 +17601,18 @@ const App = () => {
|
||||
// Unified FX Rack target context (unified_fx_rack_panel.md): { trackId, trackName } | null
|
||||
const [fxRackTarget, setFxRackTarget] = useState(null);
|
||||
window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName });
|
||||
// Render offline toàn project qua native_bridge (VST FX masterbus + track) → WAV.
|
||||
// Dùng cho nút "Nghe thử" trong FX Rack / Mastering — preview = âm export.
|
||||
window.__renderProjectPreview = async () => {
|
||||
const schema = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
|
||||
try {
|
||||
const res = await window.SonicAPI.renderProject(schema, 'preview_' + Date.now() + '.wav');
|
||||
return res;
|
||||
} catch (e) {
|
||||
window.showToast && window.showToast('Render preview lỗi: ' + (e.message || e), 'error');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const [masteringSettings, setMasteringSettings] = useState({
|
||||
masterConnected: true, // ⚠️ FIX: bật ON mặc định — MIDI item/preview PHẢI được xử lí qua mastering FX ngay khi chạy app (không cần bật/tắt power)
|
||||
activeModule: 'eq',
|
||||
@@ -17443,6 +17639,8 @@ const App = () => {
|
||||
limActive: false, limThreshold: -1.0,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
vstFxChain: [],
|
||||
vstBypass: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -90,6 +90,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
||||
// Render MIDI notes → WAV bằng VSTi (pedalboard, âm thật) — trả file_id
|
||||
// để UI preview / gán clip vào track / download
|
||||
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
listFx: () => apiRequest('/api/v1/plugins/fx', { method: 'GET' }),
|
||||
fxRender: (payload) => apiRequest('/api/v1/plugins/fx-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
|
||||
// pedalboard không render được plugin (VD VST2)
|
||||
|
||||
Reference in New Issue
Block a user