fix(tcp): open VSTi GUI via bridge API on LAN browser; scale vol/pan ruler wheel by deltaY

This commit is contained in:
2026-08-17 20:44:44 +07:00
parent bbde74f1a7
commit 8fbacd1d2b
5 changed files with 190 additions and 16 deletions
+134
View File
@@ -1291,3 +1291,137 @@ async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict
return {"lines": all_lines[-lines:], "path": log, "exists": True}
except Exception as e:
return {"lines": [], "path": log, "exists": False, "error": str(e)}
# ── E6: LAN/standalone fallback — mở GUI VSTi qua API (không cần Tauri) ─────
# Browser client (LAN) không có window.__TAURI__ → NativeBridgeService chỉ log,
# không invoke được open_vst_gui. Engine đẩy LOAD_INSTRUMENT + OPEN_GUI thẳng
# vào SHM của daw_vst_bridge (layout SharedMemoryIPC.h: controlQueue @2836,
# stride 1040, controlQueueCount @11156 — offsets verified bằng ctypes test).
# GUI hiện trên desktop máy chạy server.
_IT_ENUM = {"VST3": 0, "VST2": 1, "SF2": 2, "SF3": 2, "SFZ": 3}
class BridgeGuiRequest(BaseModel):
name: str # plugin id / tên asset
path: Optional[str] = None # path tuyệt đối nếu đã biết
instrumentType: Optional[str] = None # VST3|VST2|SF2|SF3|SFZ; None → tự suy
channel: int = 0 # MIDI channel bridge assign/open
def _resolve_bridge_asset_any(name: str, path: Optional[str], hint: Optional[str] = None):
"""Thử resolve asset qua từng loại instrument (hint trước, rồi VST3→VST2→
SF…). Client LAN không chắc type — backend tự tìm, tránh 404 nhầm (VST2
.dll mà client đoán VST3). Trả (path, instrument_type suy từ extension)."""
order = []
if hint and hint.upper() in _IT_ENUM:
order.append(hint.upper())
for t in order + [x for x in ("VST3", "VST2", "SF2", "SF3", "SFZ") if x not in order]:
p = _resolve_bridge_asset(name, t, path)
if p:
low = p.lower()
if low.endswith(".dll"):
t = "VST2"
elif low.endswith(".sfz"):
t = "SFZ"
elif low.endswith(".sf2") or low.endswith(".sf3"):
t = "SF2"
else:
t = "VST3"
return p, t
return None, None
def _running_sonicforge_pids() -> list:
pids = []
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-Process sonicforge-daw,daw_engine -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"],
capture_output=True, text=True, timeout=10).stdout
for line in out.splitlines():
line = line.strip()
if line.isdigit():
pids.append(int(line))
except Exception:
pass
return pids
def _shm_exists(name: str) -> bool:
import ctypes, ctypes.wintypes as w
k = ctypes.windll.kernel32
k.OpenFileMappingA.argtypes = [w.DWORD, w.BOOL, ctypes.c_char_p]
k.OpenFileMappingA.restype = ctypes.c_void_p
h = k.OpenFileMappingA(0xF001F, False, name.encode())
if h:
k.CloseHandle(h)
return True
return False
def _shm_push_gui(shm_name: str, path: str, enum: int, plugin_id: str, channel: int):
"""Push LOAD_INSTRUMENT (type=2) + OPEN_GUI (type=4) vào SHM bridge.
C++ xử lý async — LOAD xong mới fulfil OPEN_GUI (pending-GUI sweep)."""
import ctypes, ctypes.wintypes as w, struct
k = ctypes.windll.kernel32
k.OpenFileMappingA.argtypes = [w.DWORD, w.BOOL, ctypes.c_char_p]
k.OpenFileMappingA.restype = ctypes.c_void_p
k.MapViewOfFile.argtypes = [ctypes.c_void_p, w.DWORD, w.DWORD, w.DWORD, ctypes.c_size_t]
k.MapViewOfFile.restype = ctypes.c_void_p
k.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
k.CloseHandle.argtypes = [ctypes.c_void_p]
h = k.OpenFileMappingA(0xF001F, False, shm_name.encode())
if not h:
return False, "open shm fail"
ptr = k.MapViewOfFile(h, 0xF001F, 0, 0, 0)
if not ptr:
k.CloseHandle(h)
return False, "map shm fail"
try:
for i in range(8): # zero queue trước — tránh slot cũ lọt vào count mới
ctypes.memmove(ptr + 2836 + i * 1040, b"\x00" * 1040, 1040)
# LOAD_INSTRUMENT: type=2, arg0=InstrumentType, arg1=0 (Rust bỏ qua), channel, arg2=path
b = path.encode("utf-8")[:1023] + b"\x00"
ctypes.memmove(ptr + 2836, struct.pack("IIII", 2, enum, 0, channel), 16)
ctypes.memmove(ptr + 2836 + 16, b, len(b))
# OPEN_GUI: type=4, arg0=0, arg1=0 (hwnd=0 → bridge tự tạo native window), channel, arg2=plugin id
pb = plugin_id.encode("utf-8")[:1023] + b"\x00"
off = 2836 + 1040
ctypes.memmove(ptr + off, struct.pack("IIII", 4, 0, 0, channel), 16)
ctypes.memmove(ptr + off + 16, pb, len(pb))
ctypes.memmove(ptr + 11156, struct.pack("I", 2), 4) # controlQueueCount = 2
return True, None
finally:
k.UnmapViewOfFile(ptr)
k.CloseHandle(h)
@router.post("/bridge/gui")
async def bridge_gui(req: BridgeGuiRequest, current_user: dict = Depends(get_current_user)):
"""E6: mở native GUI VSTi trên máy chạy server — fallback cho browser LAN
không có Tauri. Pipeline giống invoke open_vst_gui: LOAD_INSTRUMENT rồi
OPEN_GUI qua SHM daw_vst_bridge."""
resolved, itype = _resolve_bridge_asset_any(req.name, req.path, req.instrumentType)
if not resolved:
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
enum = _IT_ENUM.get(itype.upper(), 0)
shm_name = None
ipc = _bridge_ipc_dir()
if ipc:
st = os.path.join(ipc, "bridge_status")
if os.path.exists(st):
try:
with open(st, "r", encoding="utf-8") as fh:
data = json.loads(fh.read() or "{}")
shm_name = data.get("shm_name")
except Exception:
pass
if not shm_name:
for pid in _running_sonicforge_pids():
cand = f"SonicForge_DAW_IPC_{pid}"
if _shm_exists(cand):
shm_name = cand
break
if not shm_name:
raise HTTPException(status_code=503,
detail="Bridge chưa chạy (không tìm thấy SHM) — hãy mở app desktop trên máy chạy server trước")
ch = req.channel & 0xF
ok, err = _shm_push_gui(shm_name, resolved, enum, req.name, ch)
if not ok:
raise HTTPException(status_code=503, detail=f"Không ghi được SHM {shm_name}: {err}")
return {"success": True, "path": resolved, "type": itype, "enum": enum,
"channel": ch, "shm": shm_name}
+39 -10
View File
@@ -6482,10 +6482,21 @@ const loadVstToBridge = async (track) => {
} catch (e) { console.warn('[Bridge] loadVstToBridge fail:', track.id, e); return false; }
};
const ensureAndOpenVstGui = async (trackId, instrumentId) => {
if (!window.SonicMidiRouter || !window.SonicMidiRouter.isBridgeActive() || !window.NativeBridgeService) return;
if (!instrumentId || (typeof instrumentId === 'string' && instrumentId.startsWith('sf_'))) return;
if (!window.__bridgeLoadedChannels) window.__bridgeLoadedChannels = {};
const bch = window.SonicMidiRouter.allocateChannel(trackId, false);
const bch = (window.SonicMidiRouter && window.SonicMidiRouter.allocateChannel) ? window.SonicMidiRouter.allocateChannel(trackId, false) : 0;
// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không
// invoke được open_vst_gui → nhờ engine đẩy LOAD+OPEN_GUI vào SHM qua route
// /bridge/gui (E6). GUI hiện trên desktop máy chạy server.
if (!window.SonicMidiRouter || !window.SonicMidiRouter.isBridgeActive() || !window.NativeBridgeService) {
try {
if (!window.SonicAPI || !window.SonicAPI.bridgeOpenGui) return;
const r = await window.SonicAPI.bridgeOpenGui({ name: instrumentId, path: null, instrumentType: null, channel: bch });
if (r && r.success) console.log('[Bridge] GUI open via API', instrumentId, 'ch', bch, r.path);
else console.warn('[Bridge] GUI via API response:', r);
} catch (e) { console.warn('[Bridge] GUI via API fail:', trackId, e); }
return;
}
if (!window.__bridgeLoadedChannels[bch]) {
const ok = await loadVstToBridge({ id: trackId, instrumentId });
if (!ok) return;
@@ -6505,6 +6516,20 @@ const openVstGuiRetry = async (instrumentId, bch, attempts) => {
}
return true;
};
// Wheel trên range input (Vol/Pan TCP): scale step theo deltaY — trackpad
// smooth gửi deltaY nhỏ (~1-10px/sự kiện) nhưng rất nhiều sự kiện; wheel
// notch gửi ~100-120px. Tích lũy theo key → trackpad lăn 100px = 1 nấc
// (0.5dB vol / 5 pan), wheel notch giữ nguyên tốc độ cũ. deltaMode:
// 0=px, 1=dòng (≈40px), 2=trang.
const _wheelAcc = new Map();
const wheelSteps = (key, deltaY, deltaMode, pxPerStep = 100) => {
const norm = deltaMode === 1 ? deltaY * 40 : deltaMode === 2 ? deltaY * 100 : deltaY;
// c dương = cuộn lên (deltaY<0) = tăng — đúng convention cũ (deltaY>0 → giảm)
const c = (_wheelAcc.get(key) || 0) - norm;
const steps = Math.trunc(c / pxPerStep);
_wheelAcc.set(key, c - steps * pxPerStep);
return steps;
};
// Module-level so both ProfileModal (open project) and App (auto-restore) can
// warm the FluidSynth font cache for each track's instrument. This only loads
// the soundfonts; the per-track channel re-selection happens at note time.
@@ -30181,8 +30206,9 @@ STRICT CONSTRAINTS:
onChange: e => updateTrackVolumeDb(track.id, parseFloat(e.target.value)),
onWheel: e => {
e.preventDefault();
const step = e.deltaY > 0 ? -0.5 : 0.5;
const nv = Math.max(-50, Math.min(7, (track.volumeDb ?? 0) + step));
const steps = wheelSteps('vol:' + track.id, e.deltaY, e.deltaMode, 100);
if (!steps) return;
const nv = Math.max(-50, Math.min(7, (track.volumeDb ?? 0) + steps * 0.5));
updateTrackVolumeDb(track.id, nv);
},
onDoubleClick: e => { e.stopPropagation(); updateTrackVolumeDb(track.id, 0); },
@@ -30205,8 +30231,9 @@ STRICT CONSTRAINTS:
onChange: e => updateTrackPan(track.id, parseInt(e.target.value)),
onWheel: e => {
e.preventDefault();
const step = e.deltaY > 0 ? -5 : 5;
const np = Math.max(-100, Math.min(100, (track.pan ?? 0) + step));
const steps = wheelSteps('pan:' + track.id, e.deltaY, e.deltaMode, 100);
if (!steps) return;
const np = Math.max(-100, Math.min(100, (track.pan ?? 0) + steps * 5));
updateTrackPan(track.id, np);
},
onDoubleClick: e => { e.stopPropagation(); updateTrackPan(track.id, 0); },
@@ -30736,8 +30763,9 @@ STRICT CONSTRAINTS:
onChange: e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value)),
onWheel: e => {
e.preventDefault();
const step = e.deltaY > 0 ? -0.5 : 0.5;
const nv = Math.max(-50, Math.min(7, (vTrack.volumeDb ?? 0) + step));
const steps = wheelSteps('vol:' + vTrack.id, e.deltaY, e.deltaMode, 100);
if (!steps) return;
const nv = Math.max(-50, Math.min(7, (vTrack.volumeDb ?? 0) + steps * 0.5));
updateTrackVolumeDb(vTrack.id, nv);
},
onDoubleClick: e => { e.stopPropagation(); updateTrackVolumeDb(vTrack.id, 0); },
@@ -30762,8 +30790,9 @@ STRICT CONSTRAINTS:
onChange: e => updateTrackPan(vTrack.id, parseInt(e.target.value)),
onWheel: e => {
e.preventDefault();
const step = e.deltaY > 0 ? -5 : 5;
const np = Math.max(-100, Math.min(100, (vTrack.pan ?? 0) + step));
const steps = wheelSteps('pan:' + vTrack.id, e.deltaY, e.deltaMode, 100);
if (!steps) return;
const np = Math.max(-100, Math.min(100, (vTrack.pan ?? 0) + steps * 5));
updateTrackPan(vTrack.id, np);
},
onDoubleClick: e => { e.stopPropagation(); updateTrackPan(vTrack.id, 0); },
File diff suppressed because one or more lines are too long
+2
View File
@@ -118,6 +118,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
bridgeStatus: () => apiRequest('/api/v1/plugins/bridge/status', { method: 'GET' }),
bridgeLoad: (payload) => apiRequest('/api/v1/plugins/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
bridgeLog: (lines = 100) => apiRequest(`/api/v1/plugins/bridge/log?lines=${lines}`, { method: 'GET' }),
// E6: mở native GUI VSTi qua engine (browser LAN không Tauri) — GUI hiện trên máy chạy server
bridgeOpenGui: (payload) => apiRequest('/api/v1/plugins/bridge/gui', { method: 'POST', body: JSON.stringify(payload) }),
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' }),
+2 -2
View File
@@ -34,7 +34,7 @@
<script src="/static/vendor/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/runtime.js?v=202608101800"></script>
<script src="/static/js/services/api.js?v=202608101800"></script>
<script src="/static/js/services/api.js?v=202608152300"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
@@ -50,7 +50,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=20260817185530" defer></script>
<script src="/static/js/app.precompiled.js?v=202608152300" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {