feat: native host bridge integration — C++ bridge, Rust SHM, JS routing, build scripts, docs
- native_bridge/: InstrumentEngineManager multi-channel, sample-accurate, CC/program/pitchbend, transport, Vst3Instrument stub (HAVE_VST3SDK) - src-tauri: shm.rs, bridge spawn + audio pump + health monitor, open_vst_gui, externalBin, commands - app: UnifiedMidiRouter, NativeBridgeService, bridgeAudioNode, audioRoutingEngine, Plugin Manager UI, Bridge/WASM indicator, set_position sync - build: 3 ps1 (force-added, build/ ignored), verify_bundle --check-bridge, CI workflow - docs: TASKS.md, TEST_NOTES.md (Windows verify checklist), install/report updates
This commit is contained in:
+118
-1
@@ -1,7 +1,7 @@
|
||||
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
@@ -1178,3 +1178,120 @@ async def render_project(
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
||||
|
||||
# ── E1/E2: NATIVE HOST BRIDGE (daw_vst_bridge) ──────────────────────────────
|
||||
# IPC dir giống _pick_dir_via_tauri_bridge: %APPDATA%/SonicForgeDAW/ipc — Rust
|
||||
# (src-tauri) tạo, engine chỉ đọc/ghi file request/response.
|
||||
BRIDGE_IPC_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "SonicForgeDAW", "ipc")
|
||||
|
||||
def _bridge_ipc_dir() -> Optional[str]:
|
||||
if os.path.isdir(BRIDGE_IPC_DIR):
|
||||
return BRIDGE_IPC_DIR
|
||||
return None
|
||||
|
||||
@router.get("/bridge/status")
|
||||
async def bridge_status(current_user: dict = Depends(get_current_user)):
|
||||
"""E1: trạng thái native bridge. Rust ghi bridge_status (JSON) khi spawn/
|
||||
health-check; nếu chưa có → probe tail bridge.log (dòng started/exists)."""
|
||||
ipc = _bridge_ipc_dir()
|
||||
if not ipc:
|
||||
return {"connected": False, "reason": "no_ipc_dir"}
|
||||
st = os.path.join(ipc, "bridge_status")
|
||||
if os.path.exists(st):
|
||||
try:
|
||||
with open(st, "r", encoding="utf-8") as fh:
|
||||
raw = fh.read() or "{}"
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = {}
|
||||
data.setdefault("connected", True)
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
log = os.path.normpath(os.path.join(ipc, "..", "logs", "bridge.log"))
|
||||
if os.path.exists(log):
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
tail = fh.read().splitlines()[-5:]
|
||||
joined = "\n".join(tail).lower()
|
||||
return {"connected": ("started" in joined or "exists=true" in joined), "log_tail": tail}
|
||||
except Exception:
|
||||
pass
|
||||
return {"connected": False, "reason": "no_status_no_log"}
|
||||
|
||||
class BridgeLoadRequest(BaseModel):
|
||||
name: str # tên asset hiển thị (sf_xxx / Vital.vst3 / ...)
|
||||
path: Optional[str] = None # path tuyệt đối nếu đã biết
|
||||
instrumentType: str = "SF2" # 'VST3'|'VST2'|'SF2'|'SF3'|'SFZ'
|
||||
channel: Optional[int] = None
|
||||
|
||||
def _resolve_bridge_asset(name: str, instrument_type: str, path: Optional[str]) -> Optional[str]:
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
base = os.path.basename((path or name).replace("\\", "/"))
|
||||
base_noext = os.path.splitext(base)[0].lower()
|
||||
# sf2/sf3/sfz: storage/soundfonts + system + user plugin_dirs
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for fname in os.listdir(base_dir):
|
||||
cand = os.path.join(base_dir, fname)
|
||||
if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
|
||||
(fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
|
||||
return cand
|
||||
ext = (".vst3" if instrument_type.upper() == "VST3" else
|
||||
".vst2" if instrument_type.upper() == "VST2" else
|
||||
".sfz" if instrument_type.upper() == "SFZ" else None)
|
||||
if ext:
|
||||
try:
|
||||
from app.core.vst_engine import _load_user_plugin_dirs
|
||||
dirs = _load_user_plugin_dirs() or []
|
||||
except Exception:
|
||||
dirs = []
|
||||
if not dirs:
|
||||
dirs = [settings.VST_DIR, settings.SOUNDFONT_DIR]
|
||||
for base_dir in dirs:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for fn in files:
|
||||
if fn.lower().endswith(ext) and \
|
||||
(fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext):
|
||||
return os.path.join(root, fn)
|
||||
return None
|
||||
|
||||
@router.post("/bridge/load")
|
||||
async def bridge_load(req: BridgeLoadRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""E2: resolve path thật của asset rồi (a) ghi bridge_load.request cho Rust
|
||||
watcher (nếu có) và (b) trả path để JS gọi invoke load_native_instrument
|
||||
trực tiếp — pipeline hoạt động ngay, không chờ watcher."""
|
||||
resolved = _resolve_bridge_asset(req.name, req.instrumentType, req.path)
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
|
||||
wrote_request = False
|
||||
ipc = _bridge_ipc_dir()
|
||||
if ipc:
|
||||
try:
|
||||
req_file = os.path.join(ipc, "bridge_load.request")
|
||||
if os.path.exists(req_file):
|
||||
os.remove(req_file)
|
||||
with open(req_file, "w", encoding="utf-8") as fh:
|
||||
json.dump({"path": resolved, "type": req.instrumentType, "channel": req.channel}, fh)
|
||||
wrote_request = True
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True, "path": resolved, "type": req.instrumentType, "ipc_request_written": wrote_request}
|
||||
|
||||
@router.get("/bridge/log")
|
||||
async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict = Depends(get_current_user)):
|
||||
"""E5: tail bridge.log cho UI debug. Log Rust ghi tại %APPDATA%/SonicForgeDAW/logs/bridge.log."""
|
||||
log = os.path.normpath(os.path.join(BRIDGE_IPC_DIR, "..", "logs", "bridge.log"))
|
||||
if not os.path.exists(log):
|
||||
return {"lines": [], "path": log, "exists": False}
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
all_lines = fh.read().splitlines()
|
||||
return {"lines": all_lines[-lines:], "path": log, "exists": True}
|
||||
except Exception as e:
|
||||
return {"lines": [], "path": log, "exists": False, "error": str(e)}
|
||||
|
||||
@@ -551,6 +551,36 @@ def resolve_preset_path(preset_id_or_path: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_plugin_path(name_or_path: str, ext: str = "") -> str:
|
||||
"""E3: resolve path thật của plugin/soundfont cho Native Bridge (E2).
|
||||
ext: '.vst3' | '.vst2' | '.sf2' | '.sf3' | '.sfz' — filter phần mở rộng
|
||||
khi quét plugin_dirs. Trả '' nếu không tìm thấy. Chống path traversal:
|
||||
chỉ tên file (không separator) hoặc path tồn tại được chấp nhận."""
|
||||
if not name_or_path:
|
||||
return ""
|
||||
p = name_or_path
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
base = os.path.basename(p.replace("\\", "/"))
|
||||
base_noext = os.path.splitext(base)[0].lower()
|
||||
want_ext = ext.lower() if ext else None
|
||||
dirs = []
|
||||
try:
|
||||
dirs = list(_load_user_plugin_dirs()) or []
|
||||
except Exception:
|
||||
pass
|
||||
dirs += [settings.VST_DIR, settings.SOUNDFONT_DIR]
|
||||
for base_dir in dirs:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for fn in files:
|
||||
if want_ext and not fn.lower().endswith(want_ext):
|
||||
continue
|
||||
if fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext:
|
||||
return os.path.join(root, fn)
|
||||
return ""
|
||||
|
||||
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
||||
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
|
||||
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
|
||||
|
||||
+225
-35
@@ -1432,6 +1432,32 @@ function getAudioContext() {
|
||||
if (window.SonicSF && window.SonicSF.init) {
|
||||
window.SonicSF.init(audioCtx);
|
||||
}
|
||||
// C6: bootstrap native bridge 1 lần — query status, nối audio sink + router.
|
||||
if (window.NativeBridgeService && window.SonicMidiRouter && !window.__bridgeBootstrapped) {
|
||||
window.__bridgeBootstrapped = true;
|
||||
window.SonicMidiRouter.onFallback = function (cmd, ch, pitch, vel) {
|
||||
if (!window.SonicSF) return;
|
||||
if (cmd === 'PANIC') { if (window.SonicSF.stopAll) window.SonicSF.stopAll(); return; }
|
||||
if (cmd === 'NOTE_ON') window.SonicSF.playNote(pitch, vel, undefined, undefined, undefined, undefined, ch);
|
||||
else window.SonicSF.stopNote(ch, pitch);
|
||||
};
|
||||
(async function () {
|
||||
try {
|
||||
var st = await window.NativeBridgeService.queryStatus();
|
||||
var connected = !!(st && st.connected);
|
||||
window.SonicMidiRouter.setBridgeConnected(connected);
|
||||
console.log('[Bridge] bootstrap connected=', connected);
|
||||
if (connected) {
|
||||
window.BridgeAudioNode.init(audioCtx);
|
||||
window.NativeBridgeService.onAudio(function (l, r) { window.BridgeAudioNode.onAudio(l, r); });
|
||||
if (window.AudioRoutingEngine) window.AudioRoutingEngine.connect(window.BridgeAudioNode, null, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Bridge] bootstrap error:', e);
|
||||
window.SonicMidiRouter.setBridgeConnected(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
const formatTime = secs => {
|
||||
@@ -5525,6 +5551,13 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
||||
const [pmCarlaVersion, setPmCarlaVersion] = React.useState(0);
|
||||
// Khai báo trực tiếp thư mục chứa carla.exe (nhập tay, không cần picker)
|
||||
const [pmCarlaPathInput, setPmCarlaPathInput] = React.useState('');
|
||||
// D7: trạng thái Native Bridge (query khi mở modal) — badge header.
|
||||
const [bridgeStatus, setBridgeStatus] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.SonicAPI.bridgeStatus().then(s => setBridgeStatus(s)).catch(() => setBridgeStatus(null));
|
||||
}
|
||||
}, [isOpen]);
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.SonicAPI.listPlugins()
|
||||
@@ -5715,6 +5748,21 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
||||
setSfUploadStatus('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
// D7: Load asset vào Native Bridge — backend resolve path thật (E2) → JS
|
||||
// invoke load_native_instrument (C2) → bridge C++ load vào channel 0.
|
||||
const loadToBridge = async (name, path, type) => {
|
||||
try {
|
||||
const r = await window.SonicAPI.bridgeLoad({ name: name, path: path || null, instrumentType: type, channel: 0 });
|
||||
if (!r || !r.path) { window.showToast && window.showToast('Không resolve được asset: ' + name, 'error'); return false; }
|
||||
const ok = await window.NativeBridgeService.loadInstrument(r.path, type, 0);
|
||||
window.showToast && window.showToast(ok ? ('Đã load vào Native Bridge: ' + r.path) : 'Bridge không khả dụng (dev mode?)', ok ? 'success' : 'warning');
|
||||
if (ok) window.SonicAPI.bridgeStatus().then(s => setBridgeStatus(s)).catch(() => {});
|
||||
return ok;
|
||||
} catch (err) {
|
||||
window.showToast && window.showToast('Lỗi load bridge: ' + (err.message || err), 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const [pmTab, setPmTab] = React.useState('soundfont');
|
||||
return React.createElement('div', {
|
||||
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
|
||||
@@ -5799,11 +5847,16 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
||||
React.createElement('h3', {
|
||||
className: 'text-base font-bold text-cyan-400 flex items-center gap-2'
|
||||
}, React.createElement('i', { 'data-lucide': 'zap', className: 'w-4 h-4' }), 'Plugin Manager (SoundFont / VSTi)'),
|
||||
React.createElement('button', {
|
||||
onClick: onClose,
|
||||
className: 'text-zinc-500 hover:text-zinc-200 transition'
|
||||
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
|
||||
),
|
||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||
React.createElement('span', {
|
||||
className: `text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border ${bridgeStatus && bridgeStatus.connected ? 'text-emerald-400 border-emerald-700 bg-emerald-900/30' : 'text-zinc-500 border-zinc-700 bg-zinc-800'}`,
|
||||
title: 'Native Bridge (C++ engine) — ' + (bridgeStatus ? JSON.stringify(bridgeStatus).slice(0, 120) : 'chưa query')
|
||||
}, bridgeStatus && bridgeStatus.connected ? '● Bridge ON' : '● Bridge OFF'),
|
||||
React.createElement('button', {
|
||||
onClick: onClose,
|
||||
className: 'text-zinc-500 hover:text-zinc-200 transition'
|
||||
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
|
||||
)),
|
||||
// Left-right body
|
||||
React.createElement('div', {
|
||||
className: 'flex flex-1 overflow-hidden',
|
||||
@@ -5853,6 +5906,11 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
||||
className: 'text-[10px] bg-teal-800 hover:bg-teal-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||
title: 'Mở trong Carla (native GUI)'
|
||||
}, '🎛 Carla'),
|
||||
React.createElement('button', {
|
||||
onClick: (e) => { e.stopPropagation(); loadToBridge(v.name || v.id, v.path, v.type || 'VST3'); },
|
||||
className: 'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||
title: 'Load vào Native Bridge (C++ engine)'
|
||||
}, 'Load Bridge'),
|
||||
React.createElement('span', { className: 'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30' }, v.type || 'VST3')
|
||||
)
|
||||
)
|
||||
@@ -5883,6 +5941,11 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
||||
)
|
||||
),
|
||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||
React.createElement('button', {
|
||||
onClick: (e) => { e.stopPropagation(); loadToBridge(sf.id, sf.file || sf.path, 'SF2'); },
|
||||
className: 'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||
title: 'Load vào Native Bridge (C++ engine)'
|
||||
}, 'Load Bridge'),
|
||||
React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
|
||||
React.createElement('button', {
|
||||
onClick: (e) => { e.stopPropagation(); setSfToDelete(sf); },
|
||||
@@ -8192,7 +8255,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
playNativeSfNote(pvTrk, n.pitch, n.velocity || 0.8, 200, undefined, 'pv_' + st.trackId);
|
||||
return;
|
||||
}
|
||||
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine);
|
||||
scheduleMidiNoteDispatch(pvTrk, n.pitch, n.velocity || 0.8, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8561,7 +8624,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (isStandaloneSf() && isSfTrackEngine(clCtx.synthEngine) && !shouldRouteCarla(clCtx.synthEngine)) {
|
||||
playNativeSfNote(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, undefined, 'pv_' + st.trackId);
|
||||
} else {
|
||||
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine);
|
||||
scheduleMidiNoteDispatch(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8829,7 +8892,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ
|
||||
// là oscillator beep (sai âm với percussion/soundfont — user: note vẽ
|
||||
// mới nghe nhạc cụ track trước).
|
||||
window.SonicSF.playNote(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
||||
scheduleMidiNoteDispatch(dwTrk, dwPitch, brushVelocityRef.current || 0.8, dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -8927,10 +8990,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
playNativeSfNote(pvTrk, p, brushVelocityRef.current || 0.8, durMs, undefined, 'pvdraw_' + st.trackId);
|
||||
} else if (window.SonicSF && window.SonicSF.playNote) {
|
||||
var pvCtx = getAudioContext();
|
||||
var pvVel = Math.round(brushVelocityRef.current * 127);
|
||||
// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator
|
||||
// beep sai âm (percussion/soundfont).
|
||||
window.SonicSF.playNote(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
||||
scheduleMidiNoteDispatch(pvTrk, p, brushVelocityRef.current || 0.8, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
||||
previewPitchRef.current = p;
|
||||
}
|
||||
// MIDI → Carla (track VSTi + ARM + Carla local): preview realtime
|
||||
@@ -9320,7 +9382,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (kbNative) {
|
||||
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
||||
}
|
||||
if (window.SonicSF && !kbNative) {
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127 });
|
||||
} else if (window.SonicSF && !kbNative) {
|
||||
// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s)
|
||||
// chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng
|
||||
// NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím).
|
||||
@@ -9345,7 +9409,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine)) {
|
||||
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
||||
}
|
||||
if (window.SonicSF && !(isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine))) {
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127 });
|
||||
} else if (window.SonicSF && !(isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine))) {
|
||||
// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave
|
||||
window.SonicSF.playNote(pitch, 100, 5000, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
|
||||
}
|
||||
@@ -9362,7 +9428,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
// Dừng note khi thả phím — tránh kẹt âm (loop liên tục) với soundfont
|
||||
try {
|
||||
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 0 });
|
||||
} else if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||
} catch (e) {}
|
||||
if (window.__carlaKeybedTimer) { clearTimeout(window.__carlaKeybedTimer); window.__carlaKeybedTimer = null; }
|
||||
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
||||
@@ -9372,7 +9440,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
// Kéo chuột ra khỏi phím → dừng note của phím đó
|
||||
try {
|
||||
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 0 });
|
||||
} else if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||
} catch (e) {}
|
||||
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
||||
}
|
||||
@@ -14063,7 +14133,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||
selectTokenRef.current++;
|
||||
const token = selectTokenRef.current;
|
||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
stopAllNativeSfNotes();
|
||||
playMidiPreview(cur, token);
|
||||
}
|
||||
@@ -14539,7 +14609,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||
selectTokenRef.current++;
|
||||
const token = selectTokenRef.current;
|
||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
stopAllNativeSfNotes();
|
||||
playMidiPreview(cur, token);
|
||||
}
|
||||
@@ -14710,7 +14780,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
if (cur && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||
selectTokenRef.current++;
|
||||
const token = selectTokenRef.current;
|
||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||
stopAllNativeSfNotes();
|
||||
playMidiPreview(cur, token);
|
||||
}
|
||||
@@ -15368,7 +15438,7 @@ const App = () => {
|
||||
const _nowVst = _hasInst && !isSfInstrument;
|
||||
if (_wasVst && !_nowVst && window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) {
|
||||
window.SonicCarlaMidi.stopBridge();
|
||||
try { if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
|
||||
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
|
||||
stopAllNativeSfNotes();
|
||||
console.log('[Instrument] Carla bridge unloaded — track', trackId, 'switched from VSTi to non-VST');
|
||||
}
|
||||
@@ -15608,7 +15678,15 @@ const App = () => {
|
||||
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {}
|
||||
// Route MIDI input to ALL armed tracks on their dedicated channels
|
||||
// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F)
|
||||
if (window.SonicSF) {
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
// D1: bridge active -> 1 cổng router (per-track channel alloc) →
|
||||
// Rust SHM → C++ bridge; giữ SonicSF/Carla fallback ở nhánh else.
|
||||
var bTracks = activeTracksRef.current || [];
|
||||
bTracks.forEach(function (bt) {
|
||||
if (!bt.isArmed) return;
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: bt.id, pitch: pitch, velocity: scaledVel / 127 }); } catch (e) {}
|
||||
});
|
||||
} else if (window.SonicSF) {
|
||||
var allTracks = activeTracksRef.current || [];
|
||||
var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : [];
|
||||
var armedTracks = allTracks.filter(function(t) { return t.isArmed; });
|
||||
@@ -15677,7 +15755,13 @@ const App = () => {
|
||||
}
|
||||
// Stop the note on ALL tracks (not just armed) to prevent stuck notes
|
||||
// when ARM is toggled off while a key is held
|
||||
if (window.SonicSF && window.SonicSF.stopNote) {
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
// D1: bridge note-off qua router (channel trùng NOTE_ON — track.id).
|
||||
var bStopTracks = activeTracksRef.current || [];
|
||||
bStopTracks.forEach(function (bst) {
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: bst.id, pitch: pitch, velocity: 0 }); } catch (e) {}
|
||||
});
|
||||
} else if (window.SonicSF && window.SonicSF.stopNote) {
|
||||
var stopTracks = activeTracksRef.current || [];
|
||||
stopTracks.forEach(function(st) {
|
||||
// Only stop channels that actually carry this track's notes —
|
||||
@@ -16894,6 +16978,16 @@ const App = () => {
|
||||
|
||||
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
|
||||
const [pluginsData, setPluginsData] = useState(null);
|
||||
// D10: bridge indicator + "Ép dùng WASM" debug toggle.
|
||||
const [bridgeUi, setBridgeUi] = useState({ connected: false, forceWasm: false });
|
||||
React.useEffect(() => {
|
||||
if (!window.NativeBridgeService) return;
|
||||
setBridgeUi(s => ({ ...s, connected: !!window.NativeBridgeService.isBridgeConnected }));
|
||||
window.NativeBridgeService.onStatusChange(function (st) {
|
||||
setBridgeUi(s => ({ ...s, connected: !!st.connected }));
|
||||
});
|
||||
return () => window.NativeBridgeService.onStatusChange(null);
|
||||
}, []);
|
||||
|
||||
const loadAudioBuffersForTracks = async (tracksList) => {
|
||||
let hasLoadedAny = false;
|
||||
@@ -17641,6 +17735,9 @@ const App = () => {
|
||||
const isDraggingSubTabRef = useRef(false);
|
||||
const handlePlayPauseRef = useRef(null);
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
// D9: playhead sample counter cho native bridge (A11/A13 dùng để đồng bộ
|
||||
// timeline sample-accurate); cập nhật trong updatePlayhead khi bridge active.
|
||||
const bridgePlayheadSampleRef = useRef(0);
|
||||
// Resume main/session play khi rời PIANO ROLL tab: mở piano roll lúc main
|
||||
// đang play → bấm Space (play piano roll) → stopAllPlayback dừng main →
|
||||
// quay lại MAIN/SECTION → CÂM. Lưu {offset, audioTime} lúc mở tab → khi
|
||||
@@ -20624,6 +20721,25 @@ const App = () => {
|
||||
};
|
||||
|
||||
const updatePlayhead = () => {
|
||||
// D9: playhead sample counter cho bridge — C++ A11/A13 dùng để đồng bộ
|
||||
// timeline sample-accurate (scheduling theo sampleOffset).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected && (isPlaying || isPlayingRef.current)) {
|
||||
try {
|
||||
const _pctx = getAudioContext();
|
||||
const _elapsed = _pctx.currentTime - (startAudioTimeRef.current || _pctx.currentTime);
|
||||
bridgePlayheadSampleRef.current = Math.max(0, Math.floor((startOffsetTimeRef.current + _elapsed) * (_pctx.sampleRate || 44100)));
|
||||
// set_position ~mỗi giây: C++ A13 cập nhật timeline anchor, tối ưu cho
|
||||
// seek/loop (Rust pump + C++ không tự biết vị trí; JS là nguồn duy nhất).
|
||||
try {
|
||||
const _sr = _pctx.sampleRate || 44100;
|
||||
if (!window.__bridgeLastPosSent) window.__bridgeLastPosSent = 0;
|
||||
if (bridgePlayheadSampleRef.current - window.__bridgeLastPosSent > _sr) {
|
||||
window.__bridgeLastPosSent = bridgePlayheadSampleRef.current;
|
||||
window.NativeBridgeService.transport('set_position', bridgePlayheadSampleRef.current);
|
||||
}
|
||||
} catch (e2) {}
|
||||
} catch (e) {}
|
||||
}
|
||||
// Realtime re-schedule: khi đang play (loop play) mà items (vị trí/speed/
|
||||
// duration — kể cả nội dung section tab) thay đổi → dừng + schedule lại từ
|
||||
// playhead hiện tại để item mới phát đúng vị trí mới (không phát nội dung
|
||||
@@ -21287,6 +21403,12 @@ const App = () => {
|
||||
const _prSubKeys = Object.keys(activeTrackNodesRef.current).filter(k => k.endsWith('_sub_' + _activeSub.trackId));
|
||||
_prNode = _prSubKeys.length ? activeTrackNodesRef.current[_prSubKeys[0]] : null;
|
||||
}
|
||||
// D5: bridge active → nối BridgeAudioNode vào node track (sfEntry →
|
||||
// FX chain → mastering) thay vì SonicSF.setOutputDestination.
|
||||
if (_prNode && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) {
|
||||
window.AudioRoutingEngine.connect(window.BridgeAudioNode, _prNode, _activeSub.trackId);
|
||||
return;
|
||||
}
|
||||
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
if (_prNode.sfEntry) {
|
||||
window.SonicSF.setOutputDestination(_prNode.sfEntry);
|
||||
@@ -21333,6 +21455,11 @@ const App = () => {
|
||||
// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when
|
||||
// PWR ON). The ♪ button only switches the post-FX route (sfRouteGain /
|
||||
// sfDryGain) to skip or include the Mastering FX Chain.
|
||||
// D5: bridge active → nối vào node track (sfEntry → FX chain).
|
||||
if (node && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) {
|
||||
window.AudioRoutingEngine.connect(window.BridgeAudioNode, node, t.id);
|
||||
return;
|
||||
}
|
||||
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
console.log('[SFRoute] dest=sfEntry node', t.id, 'sfRouteGain=' + (node.sfRouteGain ? node.sfRouteGain.gain.value : 'MISSING'), 'sfDryGain=' + (node.sfDryGain ? node.sfDryGain.gain.value : 'MISSING'));
|
||||
window.SonicSF.setOutputDestination(node.sfEntry);
|
||||
@@ -21362,7 +21489,11 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
// D5: bridge active + không track nào audible → ngắt khỏi graph (về
|
||||
// masterBus qua updateSfRouting lần sau khi có track).
|
||||
if (window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
window.AudioRoutingEngine.disconnect();
|
||||
} else if (window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
window.SonicSF.setOutputDestination(null);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -21578,8 +21709,49 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// D2: timeline MIDI note -> native bridge (khi bridge active) thay vì
|
||||
// SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ);
|
||||
// bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay
|
||||
// setTimeout bằng sampleOffset đẩy thẳng vào SHM.
|
||||
const scheduleMidiNoteDispatch = (track, pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine) => {
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||
const ctx = getAudioContext();
|
||||
const startAt = startTime || ctx.currentTime; // undefined/null = phát ngay
|
||||
const delayMs = Math.max(0, (startAt - ctx.currentTime) * 1000);
|
||||
// Guard chống note-on trễ sau Stop: CHỈ khi là timeline (startTime thật).
|
||||
const guardPlay = startTime != null ? function () { return isPlayingRef.current; } : function () { return true; };
|
||||
const trkId = track ? track.id : 0;
|
||||
// D8: track đổi instrument → gửi CC0/CC32 (bank 0) + program change qua
|
||||
// bridge (A12) một lần mỗi program mới mỗi channel.
|
||||
if (program !== undefined && program !== null) {
|
||||
const lastP = window.__bridgeLastProgram || (window.__bridgeLastProgram = {});
|
||||
if (lastP[trkId] !== program) {
|
||||
lastP[trkId] = program;
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: trkId, pitch: 0, velocity: 0, data2: 0 }); } catch (e) {}
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: trkId, pitch: 32, velocity: 0, data2: 0 }); } catch (e) {}
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'PROGRAM', channel: trkId, pitch: 0, velocity: 0, data2: program }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (!guardPlay()) return;
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: trkId, pitch: pitch || 60, velocity: velocity || 0.8 }); } catch (e) {}
|
||||
}, delayMs);
|
||||
setTimeout(function () {
|
||||
if (!guardPlay()) return;
|
||||
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: trkId, pitch: pitch || 60, velocity: 0 }); } catch (e) {}
|
||||
}, delayMs + (durMs || 1000) + 30);
|
||||
return;
|
||||
}
|
||||
window.SonicSF.playNote(pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine);
|
||||
};
|
||||
|
||||
const startTrackPlayback = offsetTime => {
|
||||
const context = getAudioContext();
|
||||
// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
|
||||
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
|
||||
try { window.NativeBridgeService.transport('play'); } catch (e) {}
|
||||
}
|
||||
// ⚠️ FIX: đồng bộ mastering + Carla status NGAY khi play — MIDI item phải
|
||||
// qua mastering FX (khi bật) và qua Carla bridge (khi VSTi loaded).
|
||||
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (e) {}
|
||||
@@ -21675,8 +21847,8 @@ const App = () => {
|
||||
const delay = noteStartSec - offsetTime;
|
||||
const startTime = context.currentTime + delay;
|
||||
if (!routeToCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
track, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
durationMs,
|
||||
startTime,
|
||||
@@ -21703,8 +21875,8 @@ const App = () => {
|
||||
const playOffset = offsetTime - noteStartSec;
|
||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||
if (!routeToCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
track, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
remainingDurMs,
|
||||
context.currentTime,
|
||||
@@ -21832,8 +22004,8 @@ const App = () => {
|
||||
const startTime = context.currentTime + delay;
|
||||
const playDurMs = (notePlayEndMain - noteStartMain) * 1000;
|
||||
if (!subRouteCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
subTrack, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
playDurMs,
|
||||
startTime,
|
||||
@@ -21862,8 +22034,8 @@ const App = () => {
|
||||
} else {
|
||||
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
||||
if (!subRouteCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
subTrack, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
remainingDurMs,
|
||||
context.currentTime,
|
||||
@@ -21961,8 +22133,8 @@ const App = () => {
|
||||
const delay = noteStartSec - offsetTime;
|
||||
const startTime = context.currentTime + delay;
|
||||
if (!routeToCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
track, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
durationMs,
|
||||
startTime,
|
||||
@@ -21977,8 +22149,8 @@ const App = () => {
|
||||
} else {
|
||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||
if (!routeToCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(
|
||||
note.pitch || 60,
|
||||
scheduleMidiNoteDispatch(
|
||||
track, note.pitch || 60,
|
||||
note.velocity || 0.8,
|
||||
remainingDurMs,
|
||||
context.currentTime,
|
||||
@@ -22043,7 +22215,7 @@ const App = () => {
|
||||
// Carla — không play FluidSynth GM sai âm chồng lên)
|
||||
const routeToCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(synthEngine));
|
||||
if (!routeToCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
|
||||
scheduleMidiNoteDispatch(track, note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
|
||||
}
|
||||
// MIDI items → Carla (track VSTi + Carla local): phát VSTi realtime
|
||||
// (schedule theo audio clock bằng setTimeout — preview, timing gần đúng).
|
||||
@@ -22079,7 +22251,7 @@ const App = () => {
|
||||
// Ghost note → Carla bridge khi ghost track VSTi (chỉ route Carla)
|
||||
var gRouteCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(ghostSynth));
|
||||
if (!gRouteCarla && window.SonicSF) {
|
||||
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
|
||||
scheduleMidiNoteDispatch(ghostTrack, note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
|
||||
}
|
||||
if (gRouteCarla) scheduleCarlaNote(ghostSynth, ghostCh, note.pitch || 60, note.velocity || 0.8, schedTime, durMs);
|
||||
}
|
||||
@@ -22210,6 +22382,11 @@ const App = () => {
|
||||
try { heldMidiNotesRef.current = {}; } catch (e) { }
|
||||
stopMidiCapture();
|
||||
stopAllNativeSfNotes();
|
||||
// D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết âm
|
||||
// ngân tức thì; A13 C++ flush note-off).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
|
||||
try { window.NativeBridgeService.transport('stop'); } catch (e) {}
|
||||
}
|
||||
if (window.SonicSF) {
|
||||
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
|
||||
// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
|
||||
@@ -28515,6 +28692,19 @@ STRICT CONSTRAINTS:
|
||||
className: "font-mono text-[20px] text-zinc-400 tabular-nums",
|
||||
title: "Tổng thời gian dự án"
|
||||
}, formatTime(projectEnd)), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-1.5 ml-3",
|
||||
title: bridgeUi.connected && !bridgeUi.forceWasm ? "Engine phát qua Native Bridge (C++ FluidSynth/sfizz/VST3)" : "Engine phát qua SonicSF WASM"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border " + (bridgeUi.connected && !bridgeUi.forceWasm ? "text-emerald-400 border-emerald-700 bg-emerald-900/30" : "text-zinc-500 border-zinc-700 bg-zinc-800")
|
||||
}, bridgeUi.connected && !bridgeUi.forceWasm ? "Bridge" : "WASM"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => {
|
||||
const v = !bridgeUi.forceWasm;
|
||||
setBridgeUi(s => ({ ...s, forceWasm: v }));
|
||||
if (window.SonicMidiRouter && window.SonicMidiRouter.setForceWasm) window.SonicMidiRouter.setForceWasm(v);
|
||||
},
|
||||
className: `px-1.5 py-0.5 rounded text-[9px] font-bold border transition ${bridgeUi.forceWasm ? "bg-amber-600 text-black border-amber-500" : "bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200"}`,
|
||||
title: "Debug: ép dùng SonicSF WASM bỏ qua bridge"
|
||||
}, "Ép WASM")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1"
|
||||
})),(() => {
|
||||
const dockPanels = {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -113,6 +113,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
// Native Host Bridge (daw_vst_bridge C++): trạng thái + load asset +
|
||||
// tail bridge.log (E1/E2/E5).
|
||||
bridgeStatus: () => apiRequest('/api/v1/bridge/status', { method: 'GET' }),
|
||||
bridgeLoad: (payload) => apiRequest('/api/v1/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
bridgeLog: (lines = 100) => apiRequest(`/api/v1/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// app/static/js/services/audioRoutingEngine.js
|
||||
// Routes the native bridge audio node into the DAW graph:
|
||||
// bridge node -> track.sfEntry (FX rack chain) -> fader/pan -> masterBus
|
||||
// Falls back to masterBus.input when no per-track sfEntry exists.
|
||||
(function () {
|
||||
var engine = {
|
||||
_connected: false,
|
||||
_trackId: null,
|
||||
|
||||
isConnected: function () { return this._connected; },
|
||||
|
||||
/** bridgeNode = window.BridgeAudioNode; trackCtx = track node ({ sfEntry, gainNode }). */
|
||||
connect: function (bridgeNode, trackCtx, trackId) {
|
||||
if (!bridgeNode || !bridgeNode.getOutputNode) return false;
|
||||
this.disconnect();
|
||||
var dest = null;
|
||||
if (trackCtx && trackCtx.sfEntry) dest = trackCtx.sfEntry;
|
||||
else if (trackCtx && trackCtx.gainNode) dest = trackCtx.gainNode;
|
||||
else if (window.masterBus && window.masterBus.input) dest = window.masterBus.input;
|
||||
if (!dest) {
|
||||
console.warn('[AudioRoutingEngine] no destination (no trackCtx / masterBus)');
|
||||
return false;
|
||||
}
|
||||
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||
bridgeNode.connect(dest);
|
||||
this._connected = true;
|
||||
this._trackId = trackId || null;
|
||||
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||
return true;
|
||||
},
|
||||
|
||||
disconnect: function () {
|
||||
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||
this._connected = false;
|
||||
this._trackId = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.AudioRoutingEngine = engine;
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
// app/static/js/services/bridgeAudioNode.js
|
||||
// Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them
|
||||
// through a ScriptProcessorNode into the WebAudio graph (track FX / master bus).
|
||||
(function () {
|
||||
var RING_DEPTH = 8; // 8 blocks * 256 samples ~= 46ms anti-underrun
|
||||
var SP_BUFFER = 4096; // ScriptProcessor chunk (16 bridge blocks)
|
||||
var _queue = [];
|
||||
var _spn = null;
|
||||
var _gainNode = null;
|
||||
var _ctx = null;
|
||||
var _initialized = false;
|
||||
|
||||
function _getCtx() {
|
||||
if (_ctx) return _ctx;
|
||||
if (typeof getAudioContext === 'function') _ctx = getAudioContext();
|
||||
else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx;
|
||||
else _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
function init(audioCtx) {
|
||||
if (_initialized) return;
|
||||
_ctx = audioCtx || _getCtx();
|
||||
_gainNode = _ctx.createGain();
|
||||
_gainNode.gain.value = 1.0;
|
||||
_spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2);
|
||||
_spn.onaudioprocess = function (e) {
|
||||
var L = e.outputBuffer.getChannelData(0);
|
||||
var R = e.outputBuffer.getChannelData(1);
|
||||
L.fill(0); R.fill(0);
|
||||
if (!_queue.length) return;
|
||||
var f = _queue.shift();
|
||||
L.set(f.l); R.set(f.r);
|
||||
};
|
||||
_spn.connect(_gainNode);
|
||||
_initialized = true;
|
||||
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||
}
|
||||
|
||||
function onAudio(l, r) {
|
||||
if (!_initialized) init();
|
||||
_queue.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||
if (_queue.length > RING_DEPTH) _queue.shift(); // drop oldest on overrun
|
||||
}
|
||||
|
||||
function flush() { _queue = []; }
|
||||
|
||||
function getOutputNode() { return _gainNode; }
|
||||
|
||||
function disconnect() {
|
||||
flush();
|
||||
if (_spn && _gainNode) {
|
||||
try { _spn.disconnect(); } catch (e) {}
|
||||
try { _gainNode.disconnect(); } catch (e) {}
|
||||
}
|
||||
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||
_initialized = false;
|
||||
_spn = null;
|
||||
_gainNode = null;
|
||||
_ctx = null;
|
||||
}
|
||||
|
||||
window.BridgeAudioNode = {
|
||||
init: init,
|
||||
onAudio: onAudio,
|
||||
flush: flush,
|
||||
getOutputNode: getOutputNode,
|
||||
connect: function (dest) { if (_gainNode) _gainNode.connect(dest); },
|
||||
disconnect: disconnect,
|
||||
isReady: function () { return _initialized; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,150 @@
|
||||
// app/static/js/services/nativeBridgeService.js
|
||||
// JS client for the C++ Native Host Bridge (daw_vst_bridge.exe).
|
||||
// All IPC goes through Tauri commands (Rust writes the shared memory) —
|
||||
// WebView2 JS cannot map Windows shared memory directly.
|
||||
(function () {
|
||||
// Must match native_bridge/include/INativeInstrument.h enum InstrumentType
|
||||
var INSTRUMENT_TYPE = { VST3: 0, VST2: 1, SF2: 2, SF3: 2, SFZ: 3 };
|
||||
|
||||
var service = {
|
||||
isBridgeConnected: false,
|
||||
activeInstrumentType: 'VST3',
|
||||
_audioCb: null,
|
||||
_statusCb: null,
|
||||
_tauri: function () { return window.__TAURI__; },
|
||||
|
||||
/** D10: UI subscribes to bridge connection changes. cb({connected}) or null to clear. */
|
||||
onStatusChange: function (cb) { this._statusCb = cb; },
|
||||
_notifyStatus: function (connected) {
|
||||
this.isBridgeConnected = !!connected;
|
||||
if (this._statusCb) this._statusCb({ connected: !!connected });
|
||||
},
|
||||
|
||||
_init: function () {
|
||||
if (!this._tauri()) {
|
||||
// Dev mode (Linux / plain browser): no Tauri -> log-only, app uses SonicSF.
|
||||
console.log('[BridgeService] Dev mode: no __TAURI__, native bridge disabled.');
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
try {
|
||||
window.__TAURI__.event.listen('bridge-audio', function (e) {
|
||||
if (self._audioCb) self._audioCb(e.payload.l, e.payload.r);
|
||||
});
|
||||
window.__TAURI__.event.listen('bridge-down', function () {
|
||||
self._notifyStatus(false);
|
||||
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(false);
|
||||
if (window.AudioRoutingEngine) window.AudioRoutingEngine.disconnect();
|
||||
console.warn('[BridgeService] bridge-down event received.');
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] event listen failed:', e);
|
||||
}
|
||||
this.queryStatus();
|
||||
},
|
||||
|
||||
queryStatus: async function () {
|
||||
if (!this._tauri()) return { connected: false };
|
||||
try {
|
||||
var s = await window.__TAURI__.core.invoke('bridge_status');
|
||||
this._notifyStatus(!!s.connected);
|
||||
return s;
|
||||
} catch (e) {
|
||||
this._notifyStatus(false);
|
||||
return { connected: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 1. LOAD NEW INSTRUMENT INTO BRIDGE (VST3 / VST2 / SF2 / SF3 / SFZ)
|
||||
* instrumentType: 'VST3' | 'VST2' | 'SF2' | 'SF3' | 'SFZ'
|
||||
* channel: MIDI channel to assign this instrument to (A10 multi-instance).
|
||||
*/
|
||||
loadInstrument: async function (filePath, instrumentType, channel) {
|
||||
this.activeInstrumentType = instrumentType;
|
||||
console.log('[BridgeService] Loading ' + instrumentType + ' asset: ' + filePath + ' ch=' + channel);
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('load_native_instrument', {
|
||||
path: filePath,
|
||||
instrumentType: INSTRUMENT_TYPE[instrumentType] !== undefined ? INSTRUMENT_TYPE[instrumentType] : 0,
|
||||
channel: channel === undefined ? 0 : channel
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] loadInstrument failed:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 2. UNIFIED MIDI EVENT DISPATCH
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* velocity 0..1; sampleOffset in samples within the current audio block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB.
|
||||
*/
|
||||
dispatchMidiEvent: function (cmd, channel, pitch, velocity, sampleOffset, data2, data3) {
|
||||
if (!this._tauri()) return false;
|
||||
var safeVelocity = Math.floor(Math.min(1.0, Math.max(0.0, velocity)) * 127);
|
||||
var byteCmd;
|
||||
switch (cmd) {
|
||||
case 'CC': byteCmd = 0xB; break;
|
||||
case 'PROGRAM': byteCmd = 0xC; break;
|
||||
case 'PITCH_BEND': byteCmd = 0xE; break;
|
||||
default: byteCmd = cmd === 'NOTE_ON' ? 0x9 : 0x8; break;
|
||||
}
|
||||
window.__TAURI__.core.invoke('push_midi_event', {
|
||||
command: byteCmd,
|
||||
channel: channel,
|
||||
pitch: pitch,
|
||||
velocity: safeVelocity,
|
||||
data2: data2 === undefined ? 0 : data2,
|
||||
data3: data3 === undefined ? 0 : data3,
|
||||
sampleOffset: sampleOffset || 0
|
||||
}).catch(function (e) { console.warn('[BridgeService] push_midi_event:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 3. OPEN FLOATING CHILD WINDOW NATIVE GUI
|
||||
*/
|
||||
openNativeGUI: async function (pluginId) {
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('open_vst_gui', { pluginId: pluginId });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] open_vst_gui:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 4. TRANSPORT CONTROL ('play' | 'stop' | 'panic' | 'set_position')
|
||||
* playhead: sample position (A13) — used by 'play' and 'set_position'.
|
||||
*/
|
||||
transport: function (kind, playhead) {
|
||||
if (!this._tauri()) return false;
|
||||
var args = { kind: kind };
|
||||
if (playhead !== undefined) args.playhead = playhead;
|
||||
window.__TAURI__.core.invoke('transport_control', args)
|
||||
.catch(function (e) { console.warn('[BridgeService] transport_control:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 5. AUDIO SINK: register consumer of PCM frames from the bridge.
|
||||
* cb(l: Float32Array, r: Float32Array)
|
||||
*/
|
||||
onAudio: function (cb) {
|
||||
this._audioCb = cb;
|
||||
}
|
||||
};
|
||||
|
||||
window.NativeBridgeService = service;
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { service._init(); });
|
||||
} else {
|
||||
service._init();
|
||||
}
|
||||
})();
|
||||
@@ -74,6 +74,9 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
shouldRoute: function (synthEngine, isArmed) {
|
||||
try {
|
||||
// D4: bridge active → VSTi do native bridge host, KHÔNG route Carla
|
||||
// (tránh kép âm).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
if (!isArmed) return false;
|
||||
@@ -85,6 +88,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
// user đã chủ động bấm Play trên item đó).
|
||||
shouldRoutePlayback: function (synthEngine) {
|
||||
try {
|
||||
// D4: bridge active → KHÔNG route Carla (bridge host VSTi).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
var se = synthEngine || {};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// app/static/js/services/unifiedMidiRouter.js
|
||||
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||
(function () {
|
||||
var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
||||
|
||||
var router = {
|
||||
bridgeConnected: false,
|
||||
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||
_channels: {}, // trackId -> { ch, percussion }
|
||||
_nextMelodicIdx: 0,
|
||||
|
||||
setBridgeConnected: function (flag) {
|
||||
this.bridgeConnected = !!flag;
|
||||
},
|
||||
|
||||
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
||||
|
||||
isBridgeActive: function () {
|
||||
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
||||
},
|
||||
|
||||
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
||||
allocateChannel: function (trackId, isPercussion) {
|
||||
if (this._channels[trackId]) return this._channels[trackId].ch;
|
||||
var ch;
|
||||
if (isPercussion) {
|
||||
ch = 9;
|
||||
} else {
|
||||
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
||||
this._nextMelodicIdx++;
|
||||
}
|
||||
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
||||
return ch;
|
||||
},
|
||||
|
||||
resetChannels: function () {
|
||||
this._channels = {};
|
||||
this._nextMelodicIdx = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* channel: MIDI channel (or trackId -> allocateChannel first)
|
||||
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
||||
*/
|
||||
pushEvent: function (opts) {
|
||||
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
||||
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
||||
var sampleOffset = opts.sampleOffset || 0;
|
||||
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
||||
if (ch === undefined || ch === null) ch = 0;
|
||||
if (this.isBridgeActive()) {
|
||||
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
||||
return;
|
||||
}
|
||||
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
||||
},
|
||||
|
||||
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
||||
panic: function () {
|
||||
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
||||
window.NativeBridgeService.transport('panic');
|
||||
}
|
||||
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
window.SonicMidiRouter = router;
|
||||
})();
|
||||
@@ -39,6 +39,10 @@
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/nativeBridgeService.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/bridgeAudioNode.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/unifiedMidiRouter.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/audioRoutingEngine.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
|
||||
Reference in New Issue
Block a user