fix(tcp): open VSTi GUI via bridge API on LAN browser; scale vol/pan ruler wheel by deltaY
This commit is contained in:
@@ -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
@@ -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
@@ -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' }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user