feat: VST FX rack per-track + masterbus render via native_bridge
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# PLAN: Masterbus + FX Rack hỗ trợ chèn VST FX (Ozone, Scaler) — xử lý âm thanh
|
||||
|
||||
Ngày: 2026-08-15
|
||||
Trạng thái: ĐỀ XUẤT — chưa code.
|
||||
Kế thừa nền: `PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md` HOÀN THÀNH (Phase 0-5,
|
||||
commit `dc331f3`) — pedalboard GPL-3.0 đã gỡ, `RENDER_ENGINE` mặc định
|
||||
`"bridge"`, `native_bridge` (MIT) render VSTi offline qua
|
||||
`--render <job.json> --out <wav>`; tests 114 pass / 0 skip.
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Chèn **VST FX thật** (Ozone mastering chain, Scaler, …) vào 2 tầng xử lý âm
|
||||
thanh:
|
||||
|
||||
- **Masterbus**: chain FX áp lên toàn bộ mix (mastering).
|
||||
- **FX Rack per-track**: chain FX áp lên từng track (insert).
|
||||
|
||||
Offline export chạy qua `native_bridge` (giữ nguyên luồng RENDER_ENGINE hiện
|
||||
tại). Realtime trong browser **KHÔNG chạy được VST** → giữ WebAudio builtin,
|
||||
VST FX dùng quick_render preview (âm thật = âm export, như cơ chế preset đang có).
|
||||
|
||||
## 2. Hiện trạng (cần mở rộng)
|
||||
|
||||
| Tầng | Hiện tại | Hạn chế |
|
||||
|---|---|---|
|
||||
| Realtime browser (`app/static/js/app.jsx`) | FX chain WebAudio per-track (chorus/reverb), mastering chain Ozone-style (route dry/mastering, `createMasteringRoute`) | WebAudio node — KHÔNG chạy được binary `.vst3`; data model chỉ có `fxChain` + mastering toggle, chưa có slot plugin |
|
||||
| Offline export (`app/core/render_engine.py`) | Track FX: `fx_type=="chorus"|"reverb"` fallback scipy/numpy; master bus = sum track buffers + normalize peak (`max_peak`) | Không VST; master chỉ normalize, không chain; `fxChain` WebAudio không tái tạo offline |
|
||||
| Bridge (`native_bridge/src/*`) | Chỉ instrument host: MIDI → audio (`NativeInstrumentEngine`, VST3/SF2/SFZ) | Chưa có audio-effect processing path; `RenderJob.cpp` chỉ nhận notes, không nhận input WAV |
|
||||
| Scan plugin (`app/core/vst_engine.py`) | Quét `.vst3`/`.so`/`.dll`, list vst_instruments | Không phân loại instrument vs effect |
|
||||
|
||||
Tham chiếu doc cũ: `md/45_MASTERING_MODULE.md` (spec WebAudio Ozone) — giữ
|
||||
nguyên, chỉ bổ sung VST path. `app.jsx` tham chiếu `mastering_expand.md` +
|
||||
`unified_fx_rack_panel.md` — 2 file này không tồn tại → gỡ tham chiếu hoặc viết
|
||||
mới trong Phase 3.
|
||||
|
||||
## 3. Kiến trúc đích
|
||||
|
||||
### 3.1. Bridge: FX render mode (C++, MIT)
|
||||
|
||||
Thêm entrypoint CLI song song mode instrument:
|
||||
|
||||
```
|
||||
daw_vst_bridge(.exe) --render-fx <job.json> --in <input.wav> --out <output.wav>
|
||||
```
|
||||
|
||||
job.json (mở rộng format hiện có):
|
||||
```json
|
||||
{
|
||||
"sample_rate": 44100,
|
||||
"block_size": 512,
|
||||
"fx_chain": [
|
||||
{ "type": "vst3", "path": "C:/.../Ozone 11.vst3", "preset_b64": "...", "bypass": false },
|
||||
{ "type": "builtin", "id": "normalize", "params": { "peak": 0.95 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Luồng:
|
||||
1. Đọc input WAV (16/24/32-bit, mono/stereo → convert stereo float) — thêm WAV
|
||||
reader (WAV writer đã có).
|
||||
2. Với mỗi slot trong `fx_chain`, process toàn bộ buffer qua
|
||||
`NativeFxEngine::processAudio(audio_in, audio_out)` — audio-input,
|
||||
KHÔNG MIDI. VST3 effect: load `plugin_path`, `loadSerializedState` từ
|
||||
`preset_b64` (cùng cơ chế preset đã có), `processBlock` từng block;
|
||||
`bypass=true` → copy thẳng.
|
||||
3. `builtin` = fallback không cần VST (normalize, gain) — dùng chung cho
|
||||
Docker headless.
|
||||
4. Ghi WAV stereo 16-bit. Exit code như mode instrument (0 ok, 1 job error,
|
||||
2 load fail, 3 crash — SEH guard như `SandboxVst3Host`).
|
||||
|
||||
Lưu ý kỹ thuật:
|
||||
- Phân biệt class category VST3: instrument (`kInstrument`) vs effect
|
||||
(`kAudioEffect`) — dùng `getClassInfo`/category string để host đúng
|
||||
interface (audio-in/audio-out thay vì MIDI-in).
|
||||
- VST2 FX (`type:"vst2"`) thêm sau nếu cần; V1 chỉ VST3 + builtin.
|
||||
- Linux bridge chỉ SF2/SFZ (VST3 hosting Windows-only) → FX VST chỉ
|
||||
Windows/desktop; Docker fallback builtin. Capabilities API báo đúng
|
||||
(`features.vst_fx_render`).
|
||||
|
||||
### 3.2. Python client (`app/core/native_render.py` — mở rộng)
|
||||
|
||||
```python
|
||||
render_fx_chain(input_wav: str, fx_chain: list[dict], sample_rate: int) -> str
|
||||
```
|
||||
Spawn bridge `--render-fx`, trả path WAV. Chịu trách nhiệm: resolve plugin
|
||||
path, base64 encode preset file, timeout, rc → exception. Không import
|
||||
pedalboard.
|
||||
|
||||
### 3.3. Masterbus (offline)
|
||||
|
||||
Session thêm key `master`:
|
||||
```json
|
||||
"master": { "fx_chain": [...], "volume_db": 0, "mute": false, "bypass": false }
|
||||
```
|
||||
`render_project()`:
|
||||
1. `master_buffer = render_session_container(...)` (giữ nguyên).
|
||||
2. Nếu `master.bypass` false → `master_buffer = render_fx_chain(wav, master.fx_chain)`.
|
||||
3. Áp `volume_db`; giữ normalize-peak làm builtin slot cuối (hoặc bỏ khi chain
|
||||
có master limiter — config theo chain, mặc định giữ).
|
||||
|
||||
Session cũ không có `master` → skip, không vỡ (backward compatible).
|
||||
|
||||
### 3.4. FX Rack per-track (offline)
|
||||
|
||||
Track thêm key `fx_chain` (mảng insert slots, thay thế `fx_type` string):
|
||||
```json
|
||||
"fx_chain": [
|
||||
{ "plugin_id": "ozone", "path": "...", "preset_b64": "...", "bypass": false }
|
||||
]
|
||||
```
|
||||
Trong `render_session_container`, sau khi mix audio track (trước volume/pan):
|
||||
nếu `fx_chain` có VST slot → render track buffer qua bridge trước khi cộng vào
|
||||
session. `fx_type` legacy ("chorus"/"reverb") giữ nguyên fallback scipy — cùng
|
||||
lúc chỉ dùng một; ưu tiên `fx_chain` nếu có.
|
||||
|
||||
### 3.5. Scan + phân loại FX (`app/core/vst_engine.py`)
|
||||
|
||||
- Thêm `list_fx()`: quét cùng thư mục, đọc category VST3 để tách effect khỏi
|
||||
instrument (instrument = class `kInstrument`; effect = `kAudioEffect`/`kFx`).
|
||||
- Không có SDK VST3 ở Python? → dùng chính bridge: thêm mode `--scan <dir>`
|
||||
(C++ dùng VST3 `getClassInfo` đã có) trả JSON `[{path, is_fx, name}]`. Bridge
|
||||
scan là nguồn chân lý, Python chỉ cache.
|
||||
|
||||
### 3.6. API + UI (V1 tối thiểu)
|
||||
|
||||
- `POST /api/v1/plugins/fx-render` `{ input_wav_url, fx_chain }` → WAV
|
||||
(dùng cho preview + export thử nghiệm; pattern giống `/api/v1/plugins/midi-render`).
|
||||
- `GET /api/v1/plugins/fx` → danh sách FX (từ scan bridge).
|
||||
- UI: FX Rack panel per-track + masterbus — danh sách slot, chọn plugin,
|
||||
bypass toggle, order (up/down), nút "Mở trong Carla / Chỉnh preset" → save
|
||||
`.vstpreset` (cơ chế CARLA_BRIDGE có sẵn, `md/52_CARLA_BRIDGE.md`).
|
||||
- Preset-based V1: state plugin = `.vstpreset` file, **chưa có param
|
||||
automation** (khớp giới hạn preset bridge hiện tại). Dry/wet: bỏ qua V1
|
||||
(Ozone/Scaler xử lý trong chain), thêm sau nếu cần.
|
||||
|
||||
### 3.7. Realtime
|
||||
|
||||
- Browser không chạy VST: giữ nguyên WebAudio builtin FX + mastering chain.
|
||||
- Track/masterbus có VST slot → khi play, nếu slot không bypass: dùng
|
||||
quick_render preview (đã có `preview_mode: "quick_render"` — render clip ngắn
|
||||
qua bridge → WAV → phát), ghi chú "preview = âm export". Nếu chỉ builtin →
|
||||
WebAudio trực tiếp.
|
||||
- Chưa realtime VST streaming (Tauri SHM) trong phạm vi plan này — ghi là
|
||||
future work.
|
||||
|
||||
### 3.8. Scaler — OPEN QUESTION (quyết định trong Phase 1)
|
||||
|
||||
Scaler 2 là **MIDI/chord tool**, không phải audio-effect processor:
|
||||
- Chạy như VSTi insert (đường VSTi hiện có): nhận MIDI → output audio cho
|
||||
chord — không cần FX path mới, dùng `render_session_container` instrument
|
||||
nhánh có sẵn.
|
||||
- Hoặc chỉnh chord offline rồi render note events — cần UI riêng (ngoài plan).
|
||||
→ Đề xuất V1: **Scaler = VSTi**, không vào FX Rack; FX Rack/masterbus dành cho
|
||||
audio-effect VST3 (Ozone). Nếu user muốn Scaler trong FX Rack → đưa vào Phase 4
|
||||
như audio-FX bọc ngoài (unlikely, cần confirm).
|
||||
|
||||
## 4. Phases
|
||||
|
||||
| Phase | Nội dung | Deliverable |
|
||||
|---|---|---|
|
||||
| 0 | Bridge `--render-fx`: WAV reader, NativeFxEngine VST3 effect (audio-in/out), builtin normalize/gain, SEH guard | CLI render FX demo: WAV in → WAV out, test với Ozone |
|
||||
| 1 | Bridge `--scan`: phân loại instrument/effect; Python `native_render.render_fx_chain` + `vst_engine.list_fx` | `/api/v1/plugins/fx` + `/api/v1/plugins/fx-render` |
|
||||
| 2 | Masterbus: session key `master`, render_project chạy chain, backward compatible | Export session có mastering VST |
|
||||
| 3 | FX Rack per-track: track `fx_chain`, render_engine integrate; UI panel (slot/bypass/order/preset upload) | Track VST FX + UI |
|
||||
| 4 | Realtime preview quick_render cho slot VST; gỡ/làm rõ tham chiếu doc thiếu (`mastering_expand.md`) | Preview thật; doc sạch |
|
||||
|
||||
Mỗi phase kết thúc = commit + test pass. Phase 0-1 có thể merge (cùng chạm
|
||||
bridge).
|
||||
|
||||
## 5. Test
|
||||
|
||||
- Bridge: self-check WAV in→out (identity khi bypass; gain builtin đúng dB);
|
||||
`--scan` trên folder có 1 instrument + 1 effect giả → đúng nhãn.
|
||||
- Python: unit test `render_fx_chain` với builtin chain (không cần VST) —
|
||||
input wav → output wav tồn tại, đúng sr; master key vắng → output không đổi
|
||||
so với trước (regression).
|
||||
- E2E (thủ công, máy có Ozone): export session có master Ozone chain → nghe
|
||||
khác rõ so với không chain, không clip.
|
||||
- Chạy full test suite hiện có — không phá 114 tests.
|
||||
|
||||
## 6. License
|
||||
|
||||
- Ozone (iZotope/Native Instruments) + Scaler (PluginBoutique/Scale Software):
|
||||
plugin **thương mại** — KHÔNG nhúng, chỉ host bằng trình load của
|
||||
`native_bridge` (MIT). Không bundle binary plugin vào repo.
|
||||
- `native_bridge` giữ MIT; code mới (WAV reader, NativeFxEngine) cùng license.
|
||||
- Không quay lại pedalboard (GPL-3.0) dưới mọi hình thức.
|
||||
@@ -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)
|
||||
|
||||
@@ -10,5 +10,5 @@ const out = Babel.transform(src, {
|
||||
filename: 'app.jsx',
|
||||
sourceType: 'script',
|
||||
}).code;
|
||||
writeFileSync('app/static/js/app.precompiled.js', out);
|
||||
writeFileSync('app/static/js/app.precompiled.js', out.replace(/\r?\n/g, '\r\n')); // repo CRLF
|
||||
console.log('BUILD OK', out.length, 'bytes');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Mastering Expand — Extension Modules & Dynamic Chain
|
||||
|
||||
Tài liệu cho các module mở rộng của Mastering modal (MasteringModal) và Track FX chain.
|
||||
|
||||
## §II.1 EQ Preset Library
|
||||
- Thư viện preset EQ band-gain dùng chung cho track EQ.
|
||||
- Code: `app/static/js/app.jsx` (EQ Preset Library, `EQ_PRESETS`).
|
||||
- Ứng dụng: Mastering modal EQ module, Track Strip EQ.
|
||||
|
||||
## §II.2 Bus Compressor Module
|
||||
- Module nén masterbus đặt trong MasteringModal, giữa các module chain.
|
||||
- Code: `app/static/js/app.jsx` (Bus Compressor module trong MasteringModal JSX).
|
||||
- Điều khiển: threshold, ratio, attack, release, makeup gain; bypass riêng.
|
||||
|
||||
## §II.3 rebuildAudioGraph / Dynamic Module Chain
|
||||
- `rebuildAudioGraph`: dựng lại đồ thị WebAudio động theo thứ tự module chain.
|
||||
- Thứ tự module: chain strip trong MasteringModal cho phép thêm/xóa/bỏ/bypass
|
||||
module, thay đổi thứ tự — audio graph được rebuild tương ứng.
|
||||
- Default = chain cũ (`mastering_expand.md: extension modules + dynamic chain`).
|
||||
- Code: `app/static/js/app.jsx` (MODULE CHAIN STRIP dynamic).
|
||||
|
||||
## §II.4 Track FX Module Factory
|
||||
- Factory tạo module FX theo loại (EQ, compressor, ...) cho track FX chain.
|
||||
- Dùng chung cho Track FX chain editor trong track strip
|
||||
(`Track FX Chain (mastering_expand.md §II.4)`).
|
||||
- Code: `app/static/js/app.jsx` (Track FX module factory).
|
||||
|
||||
## §III ADD MODULE POPUP
|
||||
- Popup thêm module mới vào chain (chọn loại module rồi append).
|
||||
- Code: `app/static/js/app.jsx` (ADD MODULE POPUP trong MasteringModal).
|
||||
@@ -0,0 +1,16 @@
|
||||
# Unified FX Rack Panel
|
||||
|
||||
Một panel FX Rack dùng chung cho per-track (FXRackModal) và masterbus
|
||||
(MasteringModal), thống nhất trải nghiệm thêm/sắp xếp FX.
|
||||
|
||||
## §III.1 Track-EQ Preset Library
|
||||
- Thư viện preset EQ band-gains dùng cho track EQ (chỉ band gains).
|
||||
- Code: `app/static/js/app.jsx` (Track-EQ preset library).
|
||||
- Preset áp trực tiếp các giá trị gain band vào track EQ.
|
||||
|
||||
## Unified FX Rack Panel
|
||||
- Panel thống nhất: mỗi track mở FXRackModal từ track strip context
|
||||
(`Unified FX Rack target context: { trackId, trackName }`).
|
||||
- Bao gồm: WebAudio builtin chain + VST FX chain (xem thêm
|
||||
`unified_fx_rack_panel_update.md`), bypass từng slot và toàn chain.
|
||||
- Code: `app/static/js/app.jsx` (UNIFIED FX RACK PANEL + FXRackModal).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Unified FX Rack Panel Update — VST FX & Wave Observer
|
||||
|
||||
Cập nhật cho `unified_fx_rack_panel.md`: bổ sung VST FX chain và Wave Observer.
|
||||
|
||||
## VST FX Chain
|
||||
- FXRackModal (per-track) và MasteringModal (masterbus) có section "VST FX":
|
||||
thêm plugin từ `SonicAPI.listFx` (danh sách VST3/LV2 đã scan), upload preset
|
||||
dạng base64, bypass toàn chain, sắp xếp lên/xuống/xóa.
|
||||
- Render offline qua native_bridge `--render-fx` (xem `PLAN_MASTERBUS_FX_RACK_VST.md`).
|
||||
- Serialize: track `vst_fx_chain`/`vst_fx_bypass`; master
|
||||
`main_session.master.fx_chain`/`bypass`.
|
||||
|
||||
## §III.3 Wave Observer
|
||||
- Oscilloscope real-time xem dạng sóng tín hiệu tại điểm quan sát (master / track).
|
||||
- Code: `app/static/js/app.jsx` (WAVE OBSERVER — REAL-TIME OSCILLOSCOPE;
|
||||
`Wave Observer scope state`; `Wave Observer scope analysers`).
|
||||
- Scope: chọn master hoặc track; analysers dùng WebAudio AnalyserNode.
|
||||
@@ -58,6 +58,7 @@ if(WIN32)
|
||||
add_executable(daw_vst_bridge
|
||||
src/main.cpp
|
||||
src/RenderJob.cpp
|
||||
src/RenderFxJob.cpp
|
||||
src/NativeInstrumentEngine.cpp
|
||||
src/SharedMemoryIPC.cpp
|
||||
src/Vst3Instrument.cpp
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// native_bridge/include/RenderFxJob.h
|
||||
#ifndef RENDER_FX_JOB_H
|
||||
#define RENDER_FX_JOB_H
|
||||
|
||||
#include <string>
|
||||
|
||||
// Offline FX render mode (Phase 0 of PLAN_MASTERBUS_FX_RACK_VST.md):
|
||||
// `daw_vst_bridge --render-fx <job.json> --in <input.wav> --out <output.wav>`
|
||||
// reads a stereo WAV, runs the input through a chain of VST3 audio effects
|
||||
// (audio-in/audio-out, NO MIDI) plus builtin DSP (gain/normalize), and writes
|
||||
// a 16-bit stereo WAV. Same exit-code contract as --render: 0 ok, 1 job/argument
|
||||
// error, 2 plugin load failure, 3 plugin crashed mid-render (SEH guarded).
|
||||
//
|
||||
// job.json: { sample_rate, block_size?,
|
||||
// fx_chain: [
|
||||
// { type: "vst3", path, preset_b64?, bypass? },
|
||||
// { type: "builtin", id: "gain", params: { db } },
|
||||
// { type: "builtin", id: "normalize", params: { peak } }
|
||||
// ] }
|
||||
// vst3: path = .vst3 module; preset_b64 = base64 of a .vstpreset FILE (raw
|
||||
// SDK chunk format) or of the bridge state blob — both applied via
|
||||
// setState before processing. bypass=true → slot skipped (passthrough).
|
||||
// gain: multiply by 10^(db/20), block-wise.
|
||||
// normalize: post-chain global pass — scale down to peak (0.95) only when
|
||||
// the peak EXCEEDS it (mirrors render_project's clip guard).
|
||||
int run_render_fx_job(const std::string& jobPath, const std::string& inPath,
|
||||
const std::string& outPath);
|
||||
|
||||
// Plugin scan mode (Phase 1): `daw_vst_bridge --scan <dir>` walks <dir>
|
||||
// recursively, loads every .vst3 module and classifies its classes
|
||||
// (kVstAudioEffectClass without "Instrument" subcategory → effect, else
|
||||
// instrument), and prints JSON to stdout:
|
||||
// {"plugins":[{"path":"...","name":"...","is_fx":true,"is_instrument":false,"type":"VST3"}]}
|
||||
// .dll/.so (VST2) cannot be introspected headlessly → classified as instrument.
|
||||
// Returns 0 on success, non-zero on unrecoverable error (a single plugin that
|
||||
// crashes the module loader is skipped, not fatal).
|
||||
int run_scan_dir(const std::string& dirPath);
|
||||
|
||||
#endif // RENDER_FX_JOB_H
|
||||
@@ -475,7 +475,11 @@ void InstrumentEngineManager::renderAll(float* outputL, float* outputR, uint32_t
|
||||
float* spR = alignF(scratchR_, numSamples);
|
||||
std::memset(spL, 0, numSamples * sizeof(float));
|
||||
std::memset(spR, 0, numSamples * sizeof(float));
|
||||
#ifdef _WIN32
|
||||
DWORD sehCode = 0;
|
||||
#else
|
||||
uint32_t sehCode = 0;
|
||||
#endif
|
||||
if (!SafeProcessChannel(inst.get(), spL, spR, numSamples, &sehCode)) {
|
||||
// G3.3: plugin fault — mute this channel only, keep the bridge up.
|
||||
if (!crashedCh_[ch]) {
|
||||
|
||||
@@ -0,0 +1,928 @@
|
||||
// native_bridge/src/RenderFxJob.cpp
|
||||
// Offline audio-FX render mode (Phase 0+1 of PLAN_MASTERBUS_FX_RACK_VST.md):
|
||||
// `daw_vst_bridge --render-fx <job.json> --in <input.wav> --out <output.wav>`
|
||||
// processes an existing WAV through a VST3 effect chain (audio-in/audio-out,
|
||||
// no MIDI) plus builtin slots (gain/normalize). Also `--scan <dir>` which
|
||||
// classifies VST3 modules as instrument/effect.
|
||||
//
|
||||
// Same JSON/b64/WAV helpers as RenderJob.cpp (sheredom/json.h vendored with
|
||||
// the VST3 SDK). The VST3 host pattern is the Vst3Instrument.cpp one minus
|
||||
// MIDI/GUI — component + controller, audio buses, processData with input
|
||||
// buffers copied directly (setChannelBuffers is unusable: prepare() owns them).
|
||||
#include "RenderFxJob.h"
|
||||
|
||||
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
|
||||
#ifdef HAVE_VST3SDK
|
||||
#include "public.sdk/source/vst/hosting/module.h"
|
||||
#include "public.sdk/source/vst/hosting/hostclasses.h"
|
||||
#include "public.sdk/source/vst/hosting/processdata.h"
|
||||
#include "public.sdk/source/vst/vstpresetfile.h"
|
||||
#include "public.sdk/source/common/memorystream.h"
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||
#include "pluginterfaces/vst/ivstcomponent.h"
|
||||
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||
#include "pluginterfaces/vst/ivstprocesscontext.h"
|
||||
#include "pluginterfaces/vst/ivstmessage.h"
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// --- sheredom/json.h helpers (same as RenderJob.cpp) ------------------------
|
||||
const json_object_element_s* member(const json_object_s* o, const char* key) {
|
||||
for (const json_object_element_s* e = o ? o->start : nullptr; e; e = e->next)
|
||||
if (e->name && e->name->string && std::strcmp(e->name->string, key) == 0)
|
||||
return e;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const json_value_s* memberValue(const json_object_s* o, const char* key) {
|
||||
const json_object_element_s* m = member(o, key);
|
||||
return m ? m->value : nullptr;
|
||||
}
|
||||
|
||||
std::string memberString(const json_object_s* o, const char* key, const std::string& def) {
|
||||
const json_value_s* v = memberValue(o, key);
|
||||
if (v && v->type == json_type_string) {
|
||||
const auto* s = static_cast<const json_string_s*>(v->payload);
|
||||
return std::string(s->string, s->string_size);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
bool memberNumber(const json_object_s* o, const char* key, double& out) {
|
||||
const json_value_s* v = memberValue(o, key);
|
||||
if (v && v->type == json_type_number) {
|
||||
out = std::atof(static_cast<const json_number_s*>(v->payload)->number);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t memberInt(const json_object_s* o, const char* key, int64_t def) {
|
||||
double d;
|
||||
return memberNumber(o, key, d) ? (int64_t)d : def;
|
||||
}
|
||||
|
||||
bool memberBool(const json_object_s* o, const char* key, bool def) {
|
||||
const json_value_s* v = memberValue(o, key);
|
||||
if (v && v->type == json_type_true) return true;
|
||||
if (v && v->type == json_type_false) return false;
|
||||
return def;
|
||||
}
|
||||
|
||||
// --- WAV writer (stdlib only, 16-bit PCM stereo, little-endian) --------------
|
||||
void writeU16(std::ofstream& f, uint16_t v) {
|
||||
char b[2] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF) };
|
||||
f.write(b, 2);
|
||||
}
|
||||
void writeU32(std::ofstream& f, uint32_t v) {
|
||||
char b[4] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF),
|
||||
(char)((v >> 16) & 0xFF), (char)((v >> 24) & 0xFF) };
|
||||
f.write(b, 4);
|
||||
}
|
||||
void writeWavHeader(std::ofstream& f, uint32_t sampleRate) {
|
||||
f.write("RIFF", 4);
|
||||
writeU32(f, 36);
|
||||
f.write("WAVE", 4);
|
||||
f.write("fmt ", 4);
|
||||
writeU32(f, 16);
|
||||
writeU16(f, 1);
|
||||
writeU16(f, 2);
|
||||
writeU32(f, sampleRate);
|
||||
writeU32(f, sampleRate * 4);
|
||||
writeU16(f, 4);
|
||||
writeU16(f, 16);
|
||||
f.write("data", 4);
|
||||
writeU32(f, 0);
|
||||
}
|
||||
void finishWav(std::ofstream& f, uint64_t dataBytes) {
|
||||
f.seekp(4);
|
||||
writeU32(f, (uint32_t)(36 + dataBytes));
|
||||
f.seekp(40);
|
||||
writeU32(f, (uint32_t)dataBytes);
|
||||
f.flush();
|
||||
}
|
||||
void writeFrames(std::ofstream& f, const float* L, const float* R, uint32_t n) {
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
auto cl = [](float v) -> int {
|
||||
if (v > 1.0f) v = 1.0f;
|
||||
else if (v < -1.0f) v = -1.0f;
|
||||
return (int)(v * 32767.0f);
|
||||
};
|
||||
writeU16(f, (uint16_t)cl(L[i]));
|
||||
writeU16(f, (uint16_t)cl(R[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// --- WAV reader (stdlib only): PCM 16/24/32-bit + IEEE float32, mono/stereo
|
||||
// → stereo float32. Returns false on any parse error (job/argument error). ---
|
||||
struct WavIn {
|
||||
uint32_t sampleRate = 0;
|
||||
uint16_t channels = 0;
|
||||
uint16_t bits = 0;
|
||||
std::vector<float> L, R; // same length
|
||||
};
|
||||
|
||||
uint32_t readU32le(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
uint16_t readU16le(const uint8_t* p) {
|
||||
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
|
||||
}
|
||||
|
||||
bool readWav(const std::string& path, WavIn& out) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) { std::cerr << "[RenderFx] cannot read input: " << path << std::endl; return false; }
|
||||
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||
if (buf.size() < 44) return false;
|
||||
if (std::memcmp(buf.data(), "RIFF", 4) != 0 || std::memcmp(buf.data() + 8, "WAVE", 4) != 0)
|
||||
return false;
|
||||
|
||||
uint16_t fmtTag = 0, channels = 0, bits = 0;
|
||||
uint32_t sampleRate = 0;
|
||||
size_t dataOff = 0, dataLen = 0;
|
||||
size_t p = 12;
|
||||
bool foundFmt = false, foundData = false;
|
||||
while (p + 8 <= buf.size()) {
|
||||
const char* id = reinterpret_cast<const char*>(buf.data() + p);
|
||||
uint32_t sz = readU32le(buf.data() + p + 4);
|
||||
size_t chunkStart = p + 8;
|
||||
if (chunkStart + sz > buf.size()) break; // truncated chunk — stop
|
||||
if (std::memcmp(id, "fmt ", 4) == 0 && sz >= 16) {
|
||||
fmtTag = readU16le(buf.data() + chunkStart);
|
||||
channels = readU16le(buf.data() + chunkStart + 2);
|
||||
sampleRate = readU32le(buf.data() + chunkStart + 4);
|
||||
bits = readU16le(buf.data() + chunkStart + 14);
|
||||
foundFmt = true;
|
||||
} else if (std::memcmp(id, "data", 4) == 0) {
|
||||
dataOff = chunkStart;
|
||||
dataLen = sz;
|
||||
foundData = true;
|
||||
break;
|
||||
}
|
||||
p = chunkStart + sz + (sz & 1);
|
||||
}
|
||||
if (!foundFmt || !foundData) return false;
|
||||
// Supported: PCM 16/24/32 and IEEE float 32; mono or stereo.
|
||||
const bool isFloat = (fmtTag == 3);
|
||||
if (!(fmtTag == 1 || isFloat)) {
|
||||
std::cerr << "[RenderFx] unsupported WAV format tag " << fmtTag << " (need PCM/float)" << std::endl;
|
||||
return false;
|
||||
}
|
||||
if (channels < 1 || channels > 2) {
|
||||
std::cerr << "[RenderFx] unsupported WAV channel count " << channels << std::endl;
|
||||
return false;
|
||||
}
|
||||
const uint16_t bytes = (bits + 7) / 8;
|
||||
if (bytes != 2 && bytes != 3 && bytes != 4) return false;
|
||||
|
||||
const size_t frameBytes = (size_t)channels * bytes;
|
||||
const size_t nFrames = dataLen / frameBytes;
|
||||
out.sampleRate = sampleRate;
|
||||
out.channels = channels;
|
||||
out.bits = bits;
|
||||
out.L.assign(nFrames, 0.0f);
|
||||
out.R.assign(nFrames, 0.0f);
|
||||
const uint8_t* d = buf.data() + dataOff;
|
||||
for (size_t i = 0; i < nFrames; ++i) {
|
||||
float l = 0.f, r = 0.f;
|
||||
for (uint16_t c = 0; c < channels; ++c) {
|
||||
const uint8_t* s = d + (i * channels + c) * bytes;
|
||||
float v = 0.f;
|
||||
if (isFloat && bytes == 4) {
|
||||
uint32_t u = readU32le(s);
|
||||
float fv;
|
||||
std::memcpy(&fv, &u, 4);
|
||||
v = fv;
|
||||
} else if (bytes == 2) {
|
||||
v = (float)(int16_t)((uint16_t)s[0] | ((uint16_t)s[1] << 8)) / 32768.f;
|
||||
} else if (bytes == 3) {
|
||||
int32_t iv = (int32_t)s[0] | ((int32_t)s[1] << 8) | ((int32_t)s[2] << 16);
|
||||
if (iv & 0x800000) iv |= ~0xFFFFFF; // sign extend
|
||||
v = (float)iv / 8388608.f;
|
||||
} else { // bytes == 4, PCM int32
|
||||
int32_t iv = (int32_t)readU32le(s);
|
||||
v = (float)iv / 2147483648.f;
|
||||
}
|
||||
if (c == 0) l = v; else r = v;
|
||||
}
|
||||
out.L[i] = l;
|
||||
out.R[i] = (channels == 1) ? l : r;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- base64 (RFC 4648) — same helpers as RenderJob.cpp ----------------------
|
||||
static const char* kBase64Tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
static std::string base64Encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
uint32_t n = (uint32_t)data[i] << 16;
|
||||
if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8;
|
||||
if (i + 2 < len) n |= (uint32_t)data[i + 2];
|
||||
out += kBase64Tbl[(n >> 18) & 63];
|
||||
out += kBase64Tbl[(n >> 12) & 63];
|
||||
out += (i + 1 < len) ? kBase64Tbl[(n >> 6) & 63] : '=';
|
||||
out += (i + 2 < len) ? kBase64Tbl[n & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
static int b64val(unsigned char c) {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
}
|
||||
static bool base64Decode(const std::string& in, std::vector<uint8_t>& out) {
|
||||
out.clear();
|
||||
out.reserve((in.size() / 4) * 3);
|
||||
uint32_t acc = 0;
|
||||
int bits = 0;
|
||||
for (unsigned char ch : in) {
|
||||
if (ch == '=' || ch == '\n' || ch == '\r' || ch == ' ') continue;
|
||||
int v = b64val(ch);
|
||||
if (v < 0) return false;
|
||||
acc = (acc << 6) | (uint32_t)v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push_back((uint8_t)((acc >> bits) & 0xFF));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- VST3 audio-effect host (offline; no MIDI, no GUI) ----------------------
|
||||
#ifdef HAVE_VST3SDK
|
||||
using Steinberg::tresult;
|
||||
using Steinberg::kResultOk;
|
||||
using Steinberg::kResultTrue;
|
||||
using Steinberg::kResultFalse;
|
||||
using Steinberg::kNoInterface;
|
||||
using Steinberg::FUnknownPtr;
|
||||
using Steinberg::IPtr;
|
||||
using Steinberg::owned;
|
||||
using Steinberg::FIDString;
|
||||
using Steinberg::int32;
|
||||
using Steinberg::uint32;
|
||||
|
||||
using Steinberg::Vst::IComponent;
|
||||
using Steinberg::Vst::IEditController;
|
||||
using Steinberg::Vst::IAudioProcessor;
|
||||
using Steinberg::Vst::IComponentHandler;
|
||||
using Steinberg::Vst::IConnectionPoint;
|
||||
using Steinberg::Vst::ProcessSetup;
|
||||
using Steinberg::Vst::ProcessContext;
|
||||
using Steinberg::Vst::HostProcessData;
|
||||
using Steinberg::Vst::HostApplication;
|
||||
using Steinberg::Vst::BusInfo;
|
||||
using Steinberg::Vst::kAudio;
|
||||
using Steinberg::Vst::kEvent;
|
||||
using Steinberg::Vst::kInput;
|
||||
using Steinberg::Vst::kOutput;
|
||||
using Steinberg::Vst::kRealtime;
|
||||
using Steinberg::Vst::kSample32;
|
||||
|
||||
class HostComponentHandlerFx : public IComponentHandler {
|
||||
public:
|
||||
tresult queryInterface(const char*, void** v) override { *v = nullptr; return kNoInterface; }
|
||||
Steinberg::uint32 addRef() override { return 1; }
|
||||
Steinberg::uint32 release() override { return 1; }
|
||||
tresult beginEdit(Steinberg::Vst::ParamID) override { return kResultOk; }
|
||||
tresult performEdit(Steinberg::Vst::ParamID, Steinberg::Vst::ParamValue) override { return kResultOk; }
|
||||
tresult endEdit(Steinberg::Vst::ParamID) override { return kResultOk; }
|
||||
tresult restartComponent(Steinberg::int32) override { return kResultOk; }
|
||||
};
|
||||
|
||||
struct Vst3FxState {
|
||||
VST3::Hosting::Module::Ptr module; // destroyed LAST (owns the factory)
|
||||
IPtr<IComponent> component;
|
||||
IPtr<IEditController> controller;
|
||||
IPtr<HostApplication> hostApp;
|
||||
HostProcessData processData;
|
||||
ProcessContext processContext;
|
||||
HostComponentHandlerFx componentHandler;
|
||||
int32 inputChannels = 2;
|
||||
int32 outputChannels = 2;
|
||||
bool controllerIsComponent = false;
|
||||
};
|
||||
|
||||
// Class selection for FX: any kVstAudioEffectClass whose subcategory does NOT
|
||||
// contain "Instrument" (Ozone/ScalerAudio et al.). Pick the first match.
|
||||
bool pickFxClass(const VST3::Hosting::PluginFactory& factory,
|
||||
VST3::Hosting::ClassInfo& out) {
|
||||
auto infos = factory.classInfos(); // temporary vector — copy, never keep pointer
|
||||
for (const auto& ci : infos) {
|
||||
if (ci.category() != kVstAudioEffectClass) continue;
|
||||
if (ci.subCategoriesString().find("Instrument") != std::string::npos) continue;
|
||||
out = ci;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must live OUTSIDE any __try (C2712: no C++ unwinding inside SEH frames).
|
||||
bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate,
|
||||
int32 maxBlockSize, std::string& err) {
|
||||
using namespace VST3::Hosting;
|
||||
Module::Ptr module = Module::create(path, err);
|
||||
if (!module) return false;
|
||||
const PluginFactory& factory = module->getFactory();
|
||||
ClassInfo chosen;
|
||||
if (!pickFxClass(factory, chosen)) {
|
||||
err = "no audio-effect class in " + path;
|
||||
return false;
|
||||
}
|
||||
IPtr<IComponent> component = factory.createInstance<IComponent>(chosen.ID());
|
||||
if (!component) { err = "createInstance<IComponent> failed"; return false; }
|
||||
IPtr<HostApplication> hostApp = owned(new HostApplication());
|
||||
FUnknownPtr<Steinberg::IPluginBase> plugBase(component.get());
|
||||
if (!plugBase || plugBase->initialize(hostApp) != kResultOk) {
|
||||
err = "component initialize failed"; return false;
|
||||
}
|
||||
IPtr<IEditController> controller;
|
||||
bool isSingle = false;
|
||||
if (component->queryInterface(IEditController::iid, (void**)&controller) == kResultTrue) {
|
||||
isSingle = true;
|
||||
} else {
|
||||
Steinberg::TUID cid = {};
|
||||
tresult cidRes = component->getControllerClassId(cid);
|
||||
if (cidRes == kResultTrue || cidRes == kResultOk) {
|
||||
controller = factory.createInstance<IEditController>(VST3::UID(cid));
|
||||
if (controller) {
|
||||
FUnknownPtr<Steinberg::IPluginBase> ctrlBase(controller.get());
|
||||
if (!ctrlBase || ctrlBase->initialize(hostApp) != kResultOk) controller = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!controller) { err = "no edit controller"; return false; }
|
||||
|
||||
s->controllerIsComponent = isSingle;
|
||||
controller->setComponentHandler(&s->componentHandler);
|
||||
FUnknownPtr<IConnectionPoint> compCP(component);
|
||||
FUnknownPtr<IConnectionPoint> ctrlCP(controller);
|
||||
if (compCP && ctrlCP) { compCP->connect(ctrlCP); ctrlCP->connect(compCP); }
|
||||
|
||||
int32 numAudioInputs = component->getBusCount(kAudio, kInput);
|
||||
for (int32 i = 0; i < numAudioInputs; ++i) component->activateBus(kAudio, kInput, i, true);
|
||||
int32 numAudioOutputs = component->getBusCount(kAudio, kOutput);
|
||||
for (int32 i = 0; i < numAudioOutputs; ++i) component->activateBus(kAudio, kOutput, i, true);
|
||||
int32 numEventInputs = component->getBusCount(kEvent, kInput);
|
||||
for (int32 i = 0; i < numEventInputs; ++i) component->activateBus(kEvent, kInput, i, true);
|
||||
int32 numEventOutputs = component->getBusCount(kEvent, kOutput);
|
||||
for (int32 i = 0; i < numEventOutputs; ++i) component->activateBus(kEvent, kOutput, i, true);
|
||||
if (numAudioInputs < 1 || numAudioOutputs < 1) {
|
||||
err = "plugin has no audio in/out (instrument?)"; return false;
|
||||
}
|
||||
|
||||
FUnknownPtr<IAudioProcessor> processor(component);
|
||||
if (!processor) { err = "no IAudioProcessor"; return false; }
|
||||
ProcessSetup setup{kRealtime, kSample32, maxBlockSize, sampleRate};
|
||||
if (processor->setupProcessing(setup) != kResultOk) { err = "setupProcessing failed"; return false; }
|
||||
if (component->setActive(true) != kResultOk) { err = "setActive failed"; return false; }
|
||||
processor->setProcessing(true);
|
||||
if (!s->processData.prepare(*component, maxBlockSize, kSample32)) {
|
||||
err = "processData.prepare failed"; return false;
|
||||
}
|
||||
|
||||
BusInfo bi = {};
|
||||
if (component->getBusInfo(kAudio, kInput, 0, bi) == kResultOk && bi.channelCount > 0)
|
||||
s->inputChannels = bi.channelCount;
|
||||
bi = {};
|
||||
if (component->getBusInfo(kAudio, kOutput, 0, bi) == kResultOk && bi.channelCount > 0)
|
||||
s->outputChannels = bi.channelCount;
|
||||
|
||||
s->module = std::move(module);
|
||||
s->component = std::move(component);
|
||||
s->controller = std::move(controller);
|
||||
s->hostApp = std::move(hostApp);
|
||||
s->processContext.sampleRate = sampleRate;
|
||||
s->processContext.tempo = 120.0;
|
||||
s->processContext.timeSigNumerator = 4;
|
||||
s->processContext.timeSigDenominator = 4;
|
||||
s->processContext.state = ProcessContext::kPlaying | ProcessContext::kTempoValid |
|
||||
ProcessContext::kTimeSigValid | ProcessContext::kProjectTimeMusicValid;
|
||||
std::cout << "[RenderFx] VST3 FX loaded " << path << " (" << chosen.name()
|
||||
<< ") in=" << s->inputChannels << " out=" << s->outputChannels << std::endl;
|
||||
return true;
|
||||
}
|
||||
#endif // HAVE_VST3SDK
|
||||
|
||||
class Vst3Fx {
|
||||
public:
|
||||
Vst3Fx() = default;
|
||||
~Vst3Fx() {
|
||||
#ifdef HAVE_VST3SDK
|
||||
if (!state_) return;
|
||||
auto* s = static_cast<Vst3FxState*>(state_);
|
||||
if (s->component) {
|
||||
FUnknownPtr<IAudioProcessor> processor(s->component);
|
||||
if (processor) processor->setProcessing(false);
|
||||
s->component->setActive(false);
|
||||
s->component->terminate();
|
||||
}
|
||||
if (s->controller && !s->controllerIsComponent) s->controller->terminate();
|
||||
s->processData.unprepare();
|
||||
delete s;
|
||||
state_ = nullptr;
|
||||
#endif
|
||||
}
|
||||
bool load(const std::string& path, double sampleRate, int32 maxBlockSize) {
|
||||
#ifndef HAVE_VST3SDK
|
||||
(void)path; (void)sampleRate; (void)maxBlockSize;
|
||||
return false;
|
||||
#else
|
||||
if (state_) return true;
|
||||
auto* s = new Vst3FxState();
|
||||
std::string err;
|
||||
if (!vst3FxLoadInner(s, path, sampleRate, maxBlockSize, err)) {
|
||||
std::cerr << "[RenderFx] VST3 FX load FAILED: " << err << std::endl;
|
||||
delete s;
|
||||
return false;
|
||||
}
|
||||
state_ = s;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
// Audio in → audio out, one block. Must be SEH-wrapped by the caller.
|
||||
void processAudio(const float* inL, const float* inR,
|
||||
float* outL, float* outR, uint32_t n) {
|
||||
#ifndef HAVE_VST3SDK
|
||||
(void)inL; (void)inR; (void)outL; (void)outR; (void)n;
|
||||
#else
|
||||
auto* s = static_cast<Vst3FxState*>(state_);
|
||||
if (!s || !s->component || n == 0) return;
|
||||
FUnknownPtr<IAudioProcessor> processor(s->component);
|
||||
if (!processor) return;
|
||||
s->processData.processMode = kRealtime;
|
||||
s->processData.numSamples = (int32)n;
|
||||
s->processData.inputEvents = nullptr;
|
||||
s->processData.inputParameterChanges = nullptr;
|
||||
s->processData.processContext = &s->processContext;
|
||||
s->processContext.projectTimeSamples += n;
|
||||
s->processContext.projectTimeMusic =
|
||||
(double)s->processContext.projectTimeSamples / s->processContext.sampleRate *
|
||||
(s->processContext.tempo / 60.0);
|
||||
// Copy input into the prepared (owned) input buffers — direct write,
|
||||
// setChannelBuffers is unusable (channelBufferOwner=true after prepare).
|
||||
if (s->processData.numInputs > 0) {
|
||||
const Steinberg::Vst::AudioBusBuffers& ib = s->processData.inputs[0];
|
||||
for (int32 c = 0; c < ib.numChannels; ++c) {
|
||||
float* dst = ib.channelBuffers32[c];
|
||||
if (!dst) continue;
|
||||
const float* src = (c == 0) ? inL : (c == 1 ? inR : inL);
|
||||
std::memcpy(dst, src, n * sizeof(float));
|
||||
}
|
||||
}
|
||||
// Host buffers must be zeroed before process (plugins skip output
|
||||
// leave garbage otherwise — same rule as Vst3Instrument).
|
||||
if (s->processData.numOutputs > 0) {
|
||||
for (int32 b = 0; b < s->processData.numOutputs; ++b) {
|
||||
const Steinberg::Vst::AudioBusBuffers& ob = s->processData.outputs[b];
|
||||
for (int32 c = 0; c < ob.numChannels; ++c)
|
||||
if (ob.channelBuffers32[c])
|
||||
std::memset(ob.channelBuffers32[c], 0, n * sizeof(float));
|
||||
}
|
||||
}
|
||||
tresult pr = processor->process(s->processData);
|
||||
if (pr >= 0 && s->processData.numOutputs > 0) {
|
||||
const Steinberg::Vst::AudioBusBuffers& out = s->processData.outputs[0];
|
||||
const float* buf0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||
const float* buf1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
|
||||
if (buf0) std::memcpy(outL, buf0, n * sizeof(float));
|
||||
else std::memset(outL, 0, n * sizeof(float));
|
||||
if (buf1) std::memcpy(outR, buf1, n * sizeof(float));
|
||||
else if (buf0) std::memcpy(outR, buf0, n * sizeof(float)); // mono → stereo
|
||||
else std::memset(outR, 0, n * sizeof(float));
|
||||
} else {
|
||||
std::memset(outL, 0, n * sizeof(float));
|
||||
std::memset(outR, 0, n * sizeof(float));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Apply preset: base64 of a raw .vstpreset FILE (VST3 magic + chunk list)
|
||||
// OR of the bridge state blob [4B BE compLen][comp][4B BE ctrlLen][ctrl].
|
||||
bool applyPreset(const std::string& b64) {
|
||||
#ifndef HAVE_VST3SDK
|
||||
(void)b64;
|
||||
return false;
|
||||
#else
|
||||
std::vector<uint8_t> bytes;
|
||||
if (!base64Decode(b64, bytes) || bytes.empty()) return false;
|
||||
std::vector<uint8_t> comp, ctrl;
|
||||
if (bytes.size() >= 4 && std::memcmp(bytes.data(), "VST3", 4) == 0) {
|
||||
// Raw .vstpreset file: parse with the SDK PresetFile.
|
||||
Steinberg::MemoryStream stream;
|
||||
if (stream.write(bytes.data(), (int32)bytes.size(), nullptr) != Steinberg::kResultOk) return false;
|
||||
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
|
||||
Steinberg::Vst::PresetFile pf(&stream);
|
||||
if (!pf.readChunkList()) return false;
|
||||
auto readChunk = [&](Steinberg::Vst::ChunkType which, std::vector<uint8_t>& outc) -> bool {
|
||||
const Steinberg::Vst::PresetFile::Entry* e = pf.getEntry(which);
|
||||
if (!e) return true;
|
||||
bool seeked = (which == Steinberg::Vst::kComponentState)
|
||||
? pf.seekToComponentState() : pf.seekToControllerState();
|
||||
if (!seeked) return false;
|
||||
outc.resize((size_t)e->size);
|
||||
if (e->size == 0) return true;
|
||||
int32 got = 0;
|
||||
return stream.read(outc.data(), (int32)e->size, &got) == Steinberg::kResultOk &&
|
||||
got == e->size;
|
||||
};
|
||||
if (!readChunk(Steinberg::Vst::kComponentState, comp) ||
|
||||
!readChunk(Steinberg::Vst::kControllerState, ctrl))
|
||||
return false;
|
||||
} else {
|
||||
// Bridge state blob.
|
||||
auto read32 = [&bytes](size_t off) -> uint32_t {
|
||||
return ((uint32_t)bytes[off] << 24) | ((uint32_t)bytes[off + 1] << 16) |
|
||||
((uint32_t)bytes[off + 2] << 8) | (uint32_t)bytes[off + 3];
|
||||
};
|
||||
if (bytes.size() < 8) return false;
|
||||
size_t off = 0;
|
||||
uint32_t clen = read32(off); off += 4;
|
||||
if (off + clen > bytes.size()) return false;
|
||||
comp.assign(bytes.begin() + off, bytes.begin() + off + clen);
|
||||
off += clen;
|
||||
if (off + 4 > bytes.size()) return false;
|
||||
uint32_t klen = read32(off); off += 4;
|
||||
if (off + klen > bytes.size()) return false;
|
||||
ctrl.assign(bytes.begin() + off, bytes.begin() + off + klen);
|
||||
}
|
||||
auto* s = static_cast<Vst3FxState*>(state_);
|
||||
if (!s) return false;
|
||||
if (!comp.empty()) {
|
||||
Steinberg::MemoryStream stream;
|
||||
stream.write(comp.data(), (int32)comp.size(), nullptr);
|
||||
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
|
||||
if (!s->component || s->component->setState(&stream) != kResultOk) return false;
|
||||
}
|
||||
if (!ctrl.empty()) {
|
||||
Steinberg::MemoryStream stream;
|
||||
stream.write(ctrl.data(), (int32)ctrl.size(), nullptr);
|
||||
stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr);
|
||||
if (!s->controller || s->controller->setState(&stream) != kResultOk) return false;
|
||||
}
|
||||
std::cout << "[RenderFx] preset applied component=" << comp.size()
|
||||
<< " controller=" << ctrl.size() << std::endl;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
bool loaded() const { return state_ != nullptr; }
|
||||
|
||||
private:
|
||||
void* state_ = nullptr;
|
||||
};
|
||||
|
||||
// One FX slot from the job. builtin id: "gain" | "normalize".
|
||||
struct FxSlot {
|
||||
bool vst = false; // true = VST3, false = builtin
|
||||
std::string path; // vst only
|
||||
std::string presetB64; // vst only
|
||||
bool bypass = false;
|
||||
std::string builtinId; // builtin only
|
||||
double db = 0.0; // gain param
|
||||
double peak = 0.95; // normalize param
|
||||
std::unique_ptr<Vst3Fx> fx;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// SEH-guarded single-slot process call. Kept OUT of run_render_fx_job's frame
|
||||
// (C2712: __try cannot live in a function whose C++ objects need unwinding).
|
||||
// This frame holds only references/raw pointers — nothing to unwind.
|
||||
static bool runFxSlotSafe(const FxSlot& sl, const float* inL, const float* inR,
|
||||
float* outL, float* outR, uint32_t n, uint32_t* crashCode) {
|
||||
#ifdef _WIN32
|
||||
__try {
|
||||
sl.fx->processAudio(inL, inR, outL, outR, n);
|
||||
return true;
|
||||
} __except (*crashCode = (uint32_t)GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
sl.fx->processAudio(inL, inR, outL, outR, n);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
int run_render_fx_job(const std::string& jobPath, const std::string& inPath,
|
||||
const std::string& outPath) {
|
||||
#ifdef _WIN32
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
#endif
|
||||
|
||||
int rc = 1;
|
||||
std::ofstream wav;
|
||||
auto fail = [&](const std::string& msg) {
|
||||
std::cerr << "[RenderFx] " << msg << std::endl;
|
||||
if (wav.is_open()) wav.close();
|
||||
std::remove(outPath.c_str());
|
||||
};
|
||||
|
||||
// 1. Read input WAV.
|
||||
WavIn in;
|
||||
if (!readWav(inPath, in)) { fail("cannot read input WAV: " + inPath); return rc; }
|
||||
if (in.L.empty()) { fail("input WAV is empty"); return rc; }
|
||||
|
||||
// 2. Parse job.
|
||||
std::ifstream jf(jobPath, std::ios::binary);
|
||||
if (!jf) { fail("cannot read job file: " + jobPath); return rc; }
|
||||
std::string data((std::istreambuf_iterator<char>(jf)), std::istreambuf_iterator<char>());
|
||||
json_parse_result_s pres = {};
|
||||
json_value_s* root = json_parse_ex(data.data(), data.size(), json_parse_flags_default,
|
||||
nullptr, nullptr, &pres);
|
||||
if (!root) { fail("job JSON parse error (code " + std::to_string(pres.error) + ")"); return rc; }
|
||||
struct RootHolder { json_value_s* p = nullptr; ~RootHolder() { if (p) std::free(p); } } rootH;
|
||||
rootH.p = root;
|
||||
if (root->type != json_type_object) { fail("job root must be a JSON object"); return rc; }
|
||||
const json_object_s* job = static_cast<const json_object_s*>(root->payload);
|
||||
|
||||
double srD = (double)in.sampleRate;
|
||||
memberNumber(job, "sample_rate", srD);
|
||||
if (srD < 8000.0 || srD > 192000.0) { fail("bad sample_rate: " + std::to_string(srD)); return rc; }
|
||||
const uint32_t sampleRate = (uint32_t)srD;
|
||||
if (in.sampleRate != sampleRate) {
|
||||
fail("input WAV sr (" + std::to_string(in.sampleRate) + ") != job sample_rate ("
|
||||
+ std::to_string(sampleRate) + ")"); return rc;
|
||||
}
|
||||
int64_t block = memberInt(job, "block_size", 512);
|
||||
block = std::max<int64_t>(32, std::min<int64_t>(block, 4096));
|
||||
const uint32_t chunk = (uint32_t)block;
|
||||
|
||||
// 3. Build slot list.
|
||||
std::vector<FxSlot> slots;
|
||||
const json_value_s* chainV = memberValue(job, "fx_chain");
|
||||
if (chainV && chainV->type == json_type_array) {
|
||||
const json_array_s* chain = static_cast<const json_array_s*>(chainV->payload);
|
||||
for (const json_array_element_s* el = chain->start; el; el = el->next) {
|
||||
const json_value_s* v = el->value;
|
||||
if (!v || v->type != json_type_object) continue;
|
||||
const json_object_s* o = static_cast<const json_object_s*>(v->payload);
|
||||
const std::string type = memberString(o, "type", "");
|
||||
FxSlot sl;
|
||||
sl.bypass = memberBool(o, "bypass", false);
|
||||
if (type == "vst3" || type == "vst") {
|
||||
sl.vst = true;
|
||||
sl.path = memberString(o, "path", "");
|
||||
sl.presetB64 = memberString(o, "preset_b64", "");
|
||||
} else if (type == "builtin") {
|
||||
sl.vst = false;
|
||||
sl.builtinId = memberString(o, "id", "");
|
||||
const json_value_s* pv = memberValue(o, "params");
|
||||
if (pv && pv->type == json_type_object) {
|
||||
const json_object_s* po = static_cast<const json_object_s*>(pv->payload);
|
||||
double d = sl.db; memberNumber(po, "db", d); sl.db = d;
|
||||
double pk = sl.peak; memberNumber(po, "peak", pk); sl.peak = pk;
|
||||
}
|
||||
} else {
|
||||
fail("unknown fx_chain slot type: " + type); return rc;
|
||||
}
|
||||
slots.push_back(std::move(sl));
|
||||
}
|
||||
}
|
||||
if (slots.empty()) { fail("fx_chain is empty — nothing to render"); return rc; }
|
||||
|
||||
// 4. Load VST3 slots (bypassed slots stay unloaded → passthrough).
|
||||
for (auto& sl : slots) {
|
||||
if (!sl.vst || sl.bypass) continue;
|
||||
if (sl.path.empty()) { fail("vst3 slot missing path"); return rc; }
|
||||
auto fx = std::make_unique<Vst3Fx>();
|
||||
if (!fx->load(sl.path, sampleRate, (int32)chunk)) { rc = 2; fail("VST3 FX load failed: " + sl.path); return rc; }
|
||||
if (!sl.presetB64.empty() && !fx->applyPreset(sl.presetB64)) {
|
||||
fail("preset import FAILED for " + sl.path); return rc;
|
||||
}
|
||||
sl.fx = std::move(fx);
|
||||
}
|
||||
|
||||
// 5. Process chain block-wise. Double-buffered: cur ← slot → nxt, swap.
|
||||
std::vector<float> L0(chunk), R0(chunk), L1(chunk), R1(chunk);
|
||||
std::vector<float> curL(chunk), curR(chunk);
|
||||
bool normalizePending = false;
|
||||
double normalizePeak = 0.95;
|
||||
uint32_t crashCode = 0;
|
||||
for (const auto& sl : slots) {
|
||||
if (sl.bypass) continue;
|
||||
if (sl.vst && !sl.fx) continue; // defensive: bypassed/unloaded
|
||||
if (!sl.vst && sl.builtinId == "normalize") {
|
||||
normalizePending = true;
|
||||
normalizePeak = std::max(0.01, std::min(1.0, sl.peak));
|
||||
continue; // global post-pass
|
||||
}
|
||||
const bool isVst = sl.vst;
|
||||
const double gainLin = sl.vst ? 1.0 : std::pow(10.0, sl.db / 20.0);
|
||||
for (uint64_t pos = 0; pos < in.L.size(); pos += chunk) {
|
||||
const uint32_t n = (uint32_t)std::min<uint64_t>(chunk, in.L.size() - pos);
|
||||
std::memcpy(curL.data(), in.L.data() + pos, n * sizeof(float));
|
||||
std::memcpy(curR.data(), in.R.data() + pos, n * sizeof(float));
|
||||
if (isVst) {
|
||||
bool ok = runFxSlotSafe(sl, curL.data(), curR.data(), L0.data(), R0.data(),
|
||||
n, &crashCode);
|
||||
if (!ok) { rc = 3; fail("VST3 FX crashed inside processAudio (SEH code=0x"
|
||||
+ std::to_string(crashCode) + ")"); return rc; }
|
||||
std::memcpy(in.L.data() + pos, L0.data(), n * sizeof(float));
|
||||
std::memcpy(in.R.data() + pos, R0.data(), n * sizeof(float));
|
||||
} else {
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
in.L[pos + i] = curL[i] * (float)gainLin;
|
||||
in.R[pos + i] = curR[i] * (float)gainLin;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isVst) std::cout << "[RenderFx] slot processed: " << sl.path << std::endl;
|
||||
else std::cout << "[RenderFx] slot processed: builtin " << sl.builtinId << std::endl;
|
||||
}
|
||||
|
||||
// 6. Global normalize pass (only reduces when peak exceeds target — mirrors
|
||||
// render_project's clip guard; the final mastering slot by convention).
|
||||
if (normalizePending) {
|
||||
float peak = 0.f;
|
||||
for (float v : in.L) peak = std::max(peak, std::fabs(v));
|
||||
for (float v : in.R) peak = std::max(peak, std::fabs(v));
|
||||
if (peak > (float)normalizePeak && peak > 1e-9f) {
|
||||
const float k = (float)normalizePeak / peak;
|
||||
for (float& v : in.L) v *= k;
|
||||
for (float& v : in.R) v *= k;
|
||||
std::cout << "[RenderFx] normalize: peak " << peak << " → " << normalizePeak << std::endl;
|
||||
} else {
|
||||
std::cout << "[RenderFx] normalize: peak " << peak << " ≤ " << normalizePeak << " (no-op)" << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Write output WAV.
|
||||
wav.open(outPath, std::ios::binary);
|
||||
if (!wav) { fail("cannot open output: " + outPath); return rc; }
|
||||
writeWavHeader(wav, sampleRate);
|
||||
uint64_t dataBytes = 0;
|
||||
for (uint64_t pos = 0; pos < in.L.size(); pos += chunk) {
|
||||
const uint32_t n = (uint32_t)std::min<uint64_t>(chunk, in.L.size() - pos);
|
||||
writeFrames(wav, in.L.data() + pos, in.R.data() + pos, n);
|
||||
dataBytes += (uint64_t)n * 4;
|
||||
}
|
||||
finishWav(wav, dataBytes);
|
||||
wav.close();
|
||||
std::cout << "[RenderFx] wrote " << outPath << " (" << in.L.size() << " frames)" << std::endl;
|
||||
rc = 0;
|
||||
return rc;
|
||||
}
|
||||
|
||||
// --- --scan: classify VST3 modules as instrument/effect ----------------------
|
||||
namespace {
|
||||
|
||||
std::string jsonEscape(const std::string& s) {
|
||||
std::string o;
|
||||
o.reserve(s.size() + 8);
|
||||
for (char ch : s) {
|
||||
switch (ch) {
|
||||
case '"': o += "\\\""; break;
|
||||
case '\\': o += "\\\\"; break;
|
||||
case '\n': o += "\\n"; break;
|
||||
case '\r': o += "\\r"; break;
|
||||
case '\t': o += "\\t"; break;
|
||||
default: o += ch;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
#ifdef HAVE_VST3SDK
|
||||
struct ScanEntry {
|
||||
std::string path;
|
||||
std::string name;
|
||||
bool isFx = false;
|
||||
bool isInstrument = false;
|
||||
};
|
||||
|
||||
// C++ locals live here (no __try in this frame) — a crashing module loader
|
||||
// is caught by safeScanModule's SEH and skipped, not fatal to the whole scan.
|
||||
bool scanModuleInner(const std::string& path, std::vector<ScanEntry>& out) {
|
||||
using namespace VST3::Hosting;
|
||||
std::string err;
|
||||
Module::Ptr module = Module::create(path, err);
|
||||
if (!module) { std::cerr << "[Scan] skip (module load failed): " << path << " — " << err << std::endl; return false; }
|
||||
const PluginFactory& factory = module->getFactory();
|
||||
auto infos = factory.classInfos();
|
||||
int added = 0;
|
||||
for (const auto& ci : infos) {
|
||||
if (ci.category() != kVstAudioEffectClass) continue;
|
||||
const bool isInstr = ci.subCategoriesString().find("Instrument") != std::string::npos;
|
||||
ScanEntry e;
|
||||
e.path = path;
|
||||
e.name = ci.name();
|
||||
e.isFx = !isInstr;
|
||||
e.isInstrument = isInstr;
|
||||
out.push_back(std::move(e));
|
||||
++added;
|
||||
}
|
||||
if (added == 0)
|
||||
std::cerr << "[Scan] " << path << ": no audio classes" << std::endl;
|
||||
return added > 0;
|
||||
}
|
||||
|
||||
bool safeScanModule(const std::string& path, std::vector<ScanEntry>& out) {
|
||||
#ifdef _WIN32
|
||||
__try { return scanModuleInner(path, out); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
std::cerr << "[Scan] skip (crashed module): " << path << std::endl;
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
return scanModuleInner(path, out);
|
||||
#endif
|
||||
}
|
||||
#endif // HAVE_VST3SDK
|
||||
|
||||
} // namespace
|
||||
|
||||
int run_scan_dir(const std::string& dirPath) {
|
||||
#ifdef _WIN32
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
#endif
|
||||
std::vector<std::string> vst3Paths; // folders or files ending .vst3
|
||||
std::vector<std::string> vst2Paths; // .dll / .so (cannot introspect)
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
auto isExt = [](const std::string& p, const char* ext) {
|
||||
const size_t n = std::strlen(ext);
|
||||
return p.size() >= n && p.compare(p.size() - n, n, ext) == 0;
|
||||
};
|
||||
try {
|
||||
fs::recursive_directory_iterator it(dirPath), end;
|
||||
for (; it != end; ++it) {
|
||||
const std::string p = it->path().string();
|
||||
std::string low = p;
|
||||
for (auto& c : low) c = (char)std::tolower((unsigned char)c);
|
||||
if (it->is_directory()) {
|
||||
if (isExt(low, ".vst3")) {
|
||||
vst3Paths.push_back(p);
|
||||
it.disable_recursion_pending(); // bundle: don't walk Contents/
|
||||
}
|
||||
} else if (it->is_regular_file()) {
|
||||
if (isExt(low, ".vst3")) vst3Paths.push_back(p);
|
||||
else if (isExt(low, ".dll") || isExt(low, ".so")) vst2Paths.push_back(p);
|
||||
}
|
||||
}
|
||||
} catch (...) { std::cerr << "[Scan] walk failed: " << dirPath << std::endl; }
|
||||
|
||||
std::cout << "{\"plugins\":[";
|
||||
bool first = true;
|
||||
auto emit = [&](const std::string& path, const std::string& name,
|
||||
bool isFx, bool isInstr, const char* type) {
|
||||
if (!first) std::cout << ",";
|
||||
first = false;
|
||||
std::cout << "{\"path\":\"" << jsonEscape(path)
|
||||
<< "\",\"name\":\"" << jsonEscape(name)
|
||||
<< "\",\"is_fx\":" << (isFx ? "true" : "false")
|
||||
<< ",\"is_instrument\":" << (isInstr ? "true" : "false")
|
||||
<< ",\"type\":\"" << type << "\"}";
|
||||
};
|
||||
#ifdef HAVE_VST3SDK
|
||||
for (const auto& p : vst3Paths) {
|
||||
std::vector<ScanEntry> entries;
|
||||
if (!safeScanModule(p, entries)) {
|
||||
// Module unreadable (or crashed): report as opaque instrument-like
|
||||
// entry so the UI still lists it (VST2 default behavior).
|
||||
emit(p, p.substr(p.find_last_of("/\\") + 1), false, true, "VST3");
|
||||
continue;
|
||||
}
|
||||
for (const auto& e : entries) emit(e.path, e.name, e.isFx, e.isInstrument, "VST3");
|
||||
}
|
||||
#else
|
||||
for (const auto& p : vst3Paths) {
|
||||
std::string name = p.substr(p.find_last_of("/\\") + 1);
|
||||
emit(p, name, false, true, "VST3");
|
||||
}
|
||||
#endif
|
||||
for (const auto& p : vst2Paths) {
|
||||
std::string name = p.substr(p.find_last_of("/\\") + 1);
|
||||
emit(p, name, false, true, "VST2");
|
||||
}
|
||||
std::cout << "]}" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "NativeInstrumentEngine.h"
|
||||
#include "StateStore.h"
|
||||
#include "RenderJob.h"
|
||||
#include "RenderFxJob.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -614,6 +615,29 @@ int main(int argc, char* argv[]) {
|
||||
return run_render_job(renderJob, renderOut);
|
||||
}
|
||||
|
||||
// --render-fx <job.json> --in <input.wav> --out <output.wav>: one-shot
|
||||
// offline FX render (VST3 effects + builtins), no SHM/loop.
|
||||
std::string renderFxJob, renderFxIn, renderFxOut;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--render-fx") == 0 && i + 1 < argc) renderFxJob = argv[i + 1];
|
||||
else if (std::strcmp(argv[i], "--in") == 0 && i + 1 < argc) renderFxIn = argv[i + 1];
|
||||
else if (std::strcmp(argv[i], "--out") == 0 && i + 1 < argc) renderFxOut = argv[i + 1];
|
||||
}
|
||||
if (!renderFxJob.empty()) {
|
||||
if (renderFxIn.empty() || renderFxOut.empty()) {
|
||||
std::cerr << "[RenderFxJob] --render-fx requires --in <input.wav> --out <output.wav>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
return run_render_fx_job(renderFxJob, renderFxIn, renderFxOut);
|
||||
}
|
||||
|
||||
// --scan <dir>: one-shot plugin scan, no SHM/loop.
|
||||
std::string scanDir;
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--scan") == 0) scanDir = argv[i + 1];
|
||||
}
|
||||
if (!scanDir.empty()) return run_scan_dir(scanDir);
|
||||
|
||||
// 1. Shared memory name: argv --shm <name> | env SF_SHM_NAME | default
|
||||
std::string shmName = "SonicForge_DAW_IPC";
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import json
|
||||
import pytest
|
||||
from app.core.render_engine import PythonRenderEngine
|
||||
import numpy as np
|
||||
|
||||
def test_render_engine_init():
|
||||
engine = PythonRenderEngine()
|
||||
@@ -87,3 +88,68 @@ def test_render_session_fx_reverb_scipy_fallback():
|
||||
assert buf.shape == (2, 44100)
|
||||
assert (buf != 0.0).any()
|
||||
|
||||
|
||||
_BRIDGE_TEST_PATH = r"C:/Users/locpham/SonicForgeStudio/native_bridge/build/Release/daw_vst_bridge.exe"
|
||||
|
||||
|
||||
def _peak(buf):
|
||||
return float(np.max(np.abs(buf)))
|
||||
|
||||
|
||||
def test_render_track_fx_chain_gain_via_bridge(monkeypatch):
|
||||
"""Track fx_chain (builtin gain +6dB) qua native_bridge → biên độ x2."""
|
||||
import numpy as np
|
||||
if not os.path.isfile(_BRIDGE_TEST_PATH):
|
||||
pytest.skip("bridge chưa build")
|
||||
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
||||
from app.api.v1 import plugins as pl
|
||||
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
||||
engine = PythonRenderEngine()
|
||||
base = _midi_track("__no_such_plugin__")
|
||||
fx = _midi_track("__no_such_plugin__")
|
||||
fx["vst_fx_chain"] = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}]
|
||||
b0 = engine.render_session_container({"tracks": [base]}, {}, 120.0, 4, 44100)
|
||||
b1 = engine.render_session_container({"tracks": [fx]}, {}, 120.0, 4, 44100)
|
||||
assert _peak(b1) > 1.5 * _peak(b0)
|
||||
|
||||
|
||||
def test_render_project_master_fx_chain_gain_via_bridge(monkeypatch, tmp_path):
|
||||
"""Masterbus master.fx_chain (gain +6dB) qua native_bridge → peak x2."""
|
||||
import numpy as np
|
||||
if not os.path.isfile(_BRIDGE_TEST_PATH):
|
||||
pytest.skip("bridge chưa build")
|
||||
monkeypatch.setenv("SF_BRIDGE_PATH", _BRIDGE_TEST_PATH)
|
||||
from app.api.v1 import plugins as pl
|
||||
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
||||
engine = PythonRenderEngine()
|
||||
|
||||
def project(with_master):
|
||||
track = _midi_track("__no_such_plugin__")
|
||||
track["volume_db"] = -20.0 # tránh normalize (>1.0) làm hỏng tỉ lệ 2x
|
||||
proj = {
|
||||
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": 1.0, "tracks": [track]},
|
||||
"section_store": {},
|
||||
}
|
||||
if with_master:
|
||||
proj["main_session"]["master"] = {
|
||||
"fx_chain": [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}],
|
||||
"bypass": False,
|
||||
}
|
||||
return proj
|
||||
|
||||
out0 = str(tmp_path / "no_master.wav")
|
||||
out1 = str(tmp_path / "with_master.wav")
|
||||
engine.render_project(project(False), out0)
|
||||
engine.render_project(project(True), out1)
|
||||
_, d0 = sf_read_peaks(out0)
|
||||
_, d1 = sf_read_peaks(out1)
|
||||
assert d1 > 1.5 * d0
|
||||
|
||||
import soundfile as _sf
|
||||
|
||||
def sf_read_peaks(path):
|
||||
import numpy as np
|
||||
data, _ = _sf.read(path, dtype="float32", always_2d=True)
|
||||
return data, float(np.max(np.abs(data)))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user