feat: embed native VST GUI in mastering + fx rack panels (bridge --fx-gui frame capture + input relay)
Linux parts (0L1-4L1 per TASKS_LINUX_WINDOWS.md): - native_bridge: FxGuiServer.cpp HTTP server (Winsock) — /frame BitBlt + PrintWindow fallback -> JPEG (libjpeg-turbo q70), /input PostMessage relay (mouse/wheel/key, coords scaled), /close, /ping; RenderFxJob fxGuiSetup + run_fx_gui_server (offscreen); main.cpp --fx-gui dispatch; vcpkg + CMakeLists WIN32-only + JPEG. - api: open_fx_gui embed flag spawn --fx-gui, parse SF_FXGUI_PORT -> embed_url, fx-gui/close endpoint; 4 unit tests. - ui: VstGuiEmbed component (frame refresh 100ms, ping 2s, input relay, scale coords) shared by MasteringModal + FXRackModal; swap graph<->GUI, cleanup on unmount, dead-bridge toast fallback. - docs: TASKS_LINUX_WINDOWS.md, WALKTHROUGH_WINDOWS.md, PLAN status. Windows tasks 0W1-4W1 remain (build + E2E real VST).
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
# PLAN: Nhúng native VST FX GUI vào Mastering Panel + FX Rack Panel
|
||||
|
||||
Ngày: 2026-08-17
|
||||
Trạng thái: LINUX PHẦN HOÀN THÀNH (0L1, 0L2, 1L1, 2L1, 3L1, 4L1) — commit
|
||||
`<pending>`; WINDOWS PHẦN CÒN LẠI (0W1, 0W2, 1W1, 2W1, 3W1, 4W1) — làm theo
|
||||
`WALKTHROUGH_WINDOWS.md`. Tách task: `TASKS_LINUX_WINDOWS.md`.
|
||||
Kế thừa: `PLAN_MASTERBUS_FX_RACK_VST.md` Phase 0-3 HOÀN THÀNH (commit
|
||||
`b261eb0` render FX, `38f4cbd` mở native VST GUI dạng cửa sổ rời qua
|
||||
`--open-fx-gui`).
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Hoàn thiện vòng đời VST FX trong **MASTERING PANEL** (`MasteringModal`) và
|
||||
**FX RACK PANEL** (`FXRackModal`):
|
||||
|
||||
1. **Nút X trên slot plugin** → xóa plugin khỏi ô slot.
|
||||
→ **ĐÃ CÓ** (`removeVst`/`removeMasterVst` + `<button>` X trong `vstRow`,
|
||||
`app/static/js/app.jsx:11757`). Chỉ xác minh, không code lại.
|
||||
2. **Nút +** → thêm VST trực tiếp vào panel VÀ **load native VST GUI ngay
|
||||
trong panel** — thay vì cửa sổ rời như hiện tại (`SonicAPI.openFxGui` →
|
||||
bridge `--open-fx-gui` mở window 800x600 tách biệt).
|
||||
3. **Đóng GUI** → panel trở về graph GUI. **Click slot VST đã load** → mở lại
|
||||
GUI nhúng, **thay thế vùng graph GUI của panel** bằng native VST GUI ở đó.
|
||||
|
||||
## 2. Hiện trạng
|
||||
|
||||
| Hạng mục | Hiện tại | Delta cần |
|
||||
|---|---|---|
|
||||
| Slot VST masterbus | `masterVstChain` (`vstFxChain`) + `vstRow` | giữ |
|
||||
| Slot VST per-track | `track.vstFxChain` + `vstRow` | giữ |
|
||||
| Nút X xóa slot | có (`removeV*`) | xác minh |
|
||||
| Nút + thêm + mở GUI | có (`addVst`/`addMasterVst` → `openVstGui`/`openMasterVstGui`) | **đổi từ cửa sổ rời → GUI nhúng trong panel** |
|
||||
| GUI mở | bridge `--open-fx-gui` (RenderFxJob.cpp:889+, `FxGuiWndProc`, `fxGuiAttachSafe`, IPlugView attach HWND) | giữ làm nền, thêm capture + input relay |
|
||||
| Graph GUI của panel | MasteringModal: vùng canvas EQ/imager/meters (`eqCanvasRef`…); FXRackModal: `scopeCanvasRef`/`eqCurveRef` | vùng này bị thay thế khi GUI mở |
|
||||
| Tauri | src-tauri v2 có, app cũng chạy qua LAN browser | GUI nhúng phải chạy cả 2 môi trường → không dùng overlay native window |
|
||||
|
||||
**Quyết định kiến trúc:** app là web (browser/Tauri webview) → không thể nhúng
|
||||
HWND trực tiếp. Dùng **frame-capture + input relay** (kiểu VNC cho plugin
|
||||
window): bridge giữ native editor window, chụp bitmap → HTTP, frontend hiển
|
||||
thị trong panel và gửi pointer/key về bridge → `SendMessage` tới HWND plugin.
|
||||
|
||||
## 3. Kiến trúc đích
|
||||
|
||||
### 3.1. Bridge: mode `--fx-gui <job.json>` (C++, Windows, MIT)
|
||||
|
||||
Mở rộng từ `--open-fx-gui` hiện có (giữ nguyên phần attach IPlugView):
|
||||
|
||||
- Sau khi attach view (đã có `fxGuiAttachSafe`), chạy **HTTP server mini**
|
||||
(port ngẫu nhiên, ghi vào job output/stdout để Python đọc):
|
||||
- `GET /frame` → PNG/JPEG chụp từ HWND plugin bằng `BitBlt` (fallback
|
||||
`PrintWindow` với `PW_RENDERFULLCONTENT`) — trả `image/jpeg`, chất lượng
|
||||
~70 để giảm băng thông LAN.
|
||||
- `POST /input` `{ type: "mousedown|mousemove|mouseup|wheel|key", x, y,
|
||||
button, deltaY, keyCode }` → `PostMessage(hwnd, WM_...)` theo tọa độ đã
|
||||
scale (window plugin có thể lớn hơn vùng hiển thị → scale client rect).
|
||||
- `POST /close` → detach view, thoát tiến trình (exit 0).
|
||||
- `GET /ping` → alive check.
|
||||
- Giữ nguyên SEH guard + registry chống mở 2 GUI (`_FX_GUI_PROCESSES` phía
|
||||
Python).
|
||||
- Vị trí: `native_bridge/src/RenderFxJob.cpp` (hoặc file mới
|
||||
`FxGuiServer.cpp` cạnh nó — theo kích thước, tách nếu >300 dòng).
|
||||
|
||||
### 3.2. Python API (`app/api/v1/plugins.py` — mở rộng `open_fx_gui`)
|
||||
|
||||
- `POST /api/v1/plugins/fx-gui/open` `{ path, name }` → spawn bridge
|
||||
`--fx-gui`, đọc port, trả `{ success, embed_url: "http://127.0.0.1:<port>",
|
||||
already_running }`. Registry `_FX_GUI_PROCESSES` như hiện có.
|
||||
- `POST /api/v1/plugins/fx-gui/close` `{ path }` → kill process (đã có luồng
|
||||
cleanup ở `_register_fx_gui_process`).
|
||||
- Không cần proxy qua FastAPI: frontend gọi thẳng `embed_url` (CORS:
|
||||
`plugins.py` đã có middleware — kiểm tra thêm `Access-Control-Allow-Origin`
|
||||
cho port động).
|
||||
|
||||
### 3.3. UI — `app/static/js/app.jsx` (dùng chung cho 2 panel qua `vstRow`)
|
||||
|
||||
- `vstRow` thêm state per-slot: `guiOpen: { url } | null` (state nằm ở panel
|
||||
component, không phải trong `vstRow` — truyền qua props).
|
||||
- Render slot: nút GUI hiện tại → **mở/đóng toggle**: nếu `guiOpen` null →
|
||||
gọi `openFxGuiEmbed` (API mới); nếu đang mở → hiển thị vùng embed.
|
||||
- Vùng embed: `<div className="...">` chứa `<img src={url + '/frame'}>`
|
||||
refresh bằng `setInterval` ~100ms (10fps), overlay pointer handlers
|
||||
(`onMouseDown/Move/Up`, `onWheel`) → `fetch(url + '/input', POST)` với tọa độ
|
||||
relative tính theo bounding rect; keydown/keyup trên container.
|
||||
- Trong `MasteringModal`: vùng này **thay thế** vùng graph (khu vực
|
||||
canvas EQ/imager — xác định container chính xác lúc implement; ẩn bằng
|
||||
`hidden` class khi GUI mở, hiện lại khi close).
|
||||
- Trong `FXRackModal`: thay thế vùng scope/eq canvas.
|
||||
- Click vào **tên VST đã load trong slot** (span tên hoặc chính slot) khi GUI
|
||||
đang đóng → mở lại embed (re-attach qua `/frame` poll, ping trước).
|
||||
- Nút đóng GUI: nút X riêng trên vùng embed (hoặc nút GUI toggle) → gọi
|
||||
`/close`, dừng interval, trả graph về.
|
||||
- Xóa slot (X) khi GUI đang mở → cũng phải đóng GUI (gọi `/close`).
|
||||
- Nếu `/ping` fail (bridge chết ngoài ý muốn) → tự trả về graph + toast.
|
||||
|
||||
### 3.4. Không đổi
|
||||
|
||||
- Offline render (`--render-fx`), preset upload, Carla bridge, `--scan` — giữ
|
||||
nguyên. GUI nhúng chỉ là hiển thị/điều khiển, không đổi data model
|
||||
(`vstFxChain` giữ nguyên shape).
|
||||
|
||||
## 4. Phases
|
||||
|
||||
| Phase | Nội dung | Deliverable | Test |
|
||||
|---|---|---|---|
|
||||
| 0 | Bridge `--fx-gui`: HTTP server + BitBlt/PrintWindow capture + input relay | mở GUI Ozone trong browser tab thủ công qua URL | self-check: `/frame` trả JPEG kích thước khớp; `/input` click đúng vị trí |
|
||||
| 1 | Python: `open` trả `embed_url`, registry, close, CORS | curl: open → frame 200 | unit: process spawn/cleanup |
|
||||
| 2 | MasteringModal: embed + swap graph↔GUI + click mở lại | UI mastering hoàn chỉnh | E2E thủ công |
|
||||
| 3 | FXRackModal: tương tự | UI fx rack hoàn chỉnh | E2E thủ công |
|
||||
| 4 | Dọn: toast lỗi, cleanup interval, docs | commit | full test suite hiện có không vỡ |
|
||||
|
||||
Phase 0-1 có thể merge (cùng chạm bridge + plugins.py).
|
||||
|
||||
## 5. Rủi ro / giới hạn
|
||||
|
||||
- Plugin dùng layered window / DirectX surface → `PrintWindow` ra đen: fallback
|
||||
capture child control HWND hoặc dùng `BitBlt` từ `GetDC(NULL)` vùng rect —
|
||||
xử lý theo plugin cụ thể (Ozone chuẩn GDI, khả năng cao OK).
|
||||
- Input tọa độ: scale theo client rect; plugin bắt absolute tọa độ cần
|
||||
`WM_MOUSEMOVE` trước click (gửi mousemove kèm).
|
||||
- FPS ~10-15 đủ chỉnh thông số; không phải realtime video — ghi chú UI.
|
||||
- Chỉ Windows desktop có VST GUI (Linux bridge SF2/SFZ — giữ nguyên).
|
||||
- Keyboard input: chỉ forward khi panel đang focus, tránh nuốt phím toàn app.
|
||||
|
||||
## 6. License
|
||||
|
||||
- Không đổi: `native_bridge` MIT, không nhúng binary plugin, không quay lại
|
||||
pedalboard.
|
||||
|
||||
## 7. Ghi chú triển khai (Linux — đã làm)
|
||||
|
||||
- `native_bridge/src/FxGuiServer.cpp` (mới, ~316 dòng): HTTP server Winsock trên
|
||||
worker thread; `/frame` = `BitBlt` + fallback `PrintWindow PW_RENDERFULLCONTENT`
|
||||
khi ảnh toàn đen, JPEG quality 70 qua libjpeg-turbo (`jpeg_mem_dest`);
|
||||
`/input` relay `PostMessage` (mousedown/move/up, wheel deltaY*120, keydown/up,
|
||||
coords scale client rect); `/close` stop flag + `WM_CLOSE`; `GET /404`.
|
||||
`int fxGuiServerLoop(HWND)` chạy message pump STA, join HTTP thread.
|
||||
- `RenderFxJob.cpp`: tách `fxGuiSetup()` dùng chung; `run_open_fx_gui` giữ hành
|
||||
vi cũ; thêm `run_fx_gui_server(jobPath)` (offscreen, userData=0);
|
||||
stub `#else !_WIN32` trả 1. `main.cpp`: dispatch `--fx-gui <job.json>`.
|
||||
- `vcpkg.json` thêm `libjpeg-turbo`; `CMakeLists.txt` chỉ build FxGuiServer khi
|
||||
WIN32 + `find_package(JPEG REQUIRED)` + `JPEG::JPEG`.
|
||||
- `app/api/v1/plugins.py`: `FxGuiRequest.embed`, model `FxGuiCloseRequest`,
|
||||
`open_fx_gui` spawn `--fx-gui` đọc `SF_FXGUI_PORT=` từ stdout → `embed_url`,
|
||||
trả `{success, started, already_running, embed_url, cmd}`; thêm
|
||||
`POST /api/v1/plugins/fx-gui/close` (terminate → wait 5s → kill → prune).
|
||||
CORS `allow_origins=["*"]` + bridge tự gửi ACAO `*`.
|
||||
- `app/static/js/app.jsx`: component `VstGuiEmbed` (module scope, dùng chung 2
|
||||
panel) — img refresh 100ms, ping 2s, relay pointer/wheel/key với tọa độ scale,
|
||||
nút "✕ Đóng GUI" → `/close`. MasteringModal + FXRackModal: state
|
||||
`guiOpen {path,url}`, swap vùng graph↔GUI bằng ternary, cleanup unmount,
|
||||
bridge chết → toast + trả về graph.
|
||||
- Tests: `tests/test_plugin_api.py` +4 (mở embed trả url, không port → 500,
|
||||
close kill, close unknown) — mock `find_bridge_exe` + `Popen`.
|
||||
- Kết quả: `node build.mjs` BUILD OK; pytest 116 passed, 1 failed
|
||||
(`test_find_bridge_exe_dev` pre-existing — Linux thiếu binary bridge).
|
||||
- Giới hạn: bridge C++ chưa build/test thật (Linux không có CMake/Win32) —
|
||||
Windows làm 0W1/0W2; E2E GUI nhúng thật chưa chạy — Windows làm 1W1-4W1.
|
||||
@@ -0,0 +1,183 @@
|
||||
# TASKS: Nhúng VST GUI — tách theo máy (Linux / Windows)
|
||||
|
||||
Nguồn: `PLAN_MASTERBUS_VST_GUI_EMBED.md` (Phase 0-4). Mỗi task ghi rõ máy thực
|
||||
hiện, file đụng, lệnh, deliverable, test, phụ thuộc.
|
||||
|
||||
Nguyên tắc tách:
|
||||
- **Linux** (máy dev hiện tại): viết mọi source code (C++ bridge, Python API,
|
||||
frontend JSX), build frontend, chạy unit test (mock bridge), commit.
|
||||
- **Windows** (máy chạy VST thật): build bridge C++ (MSVC/vcpkg), mọi E2E thật
|
||||
với plugin (Ozone...) — BitBlt/PrintWindow/SendMessage chỉ chạy Windows.
|
||||
- Linux KHÔNG build được bridge (CMake bỏ VST3/GUI khi không WIN32; `main_linux.cpp`
|
||||
chỉ render offline). Linux KHÔNG có VST GUI để test E2E thật.
|
||||
|
||||
Ký hiệu: `[L]` = làm trên Linux · `[W]` = làm trên Windows.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0 — Bridge `--fx-gui` (HTTP server + capture + input relay)
|
||||
|
||||
### Task 0L1 — Viết `FxGuiServer.cpp` (hoặc mở rộng RenderFxJob.cpp nếu <300 dòng)
|
||||
- Máy: `[L]` · File: `native_bridge/src/FxGuiServer.cpp` (mới) + `native_bridge/src/RenderFxJob.cpp` (hook mode `--fx-gui`)
|
||||
- Nội dung (giữ nguyên phần attach IPlugView có sẵn `fxGuiAttachSafe`):
|
||||
- Mode `--fx-gui <job.json>`: sau khi attach view, khởi động HTTP server mini
|
||||
port ngẫu nhiên → in `SF_FXGUI_PORT=<port>` ra stdout (Python đọc).
|
||||
- `GET /frame` → chụp HWND plugin bằng `BitBlt` (fallback `PrintWindow`
|
||||
`PW_RENDERFULLCONTENT`) → trả `image/jpeg` quality ~70.
|
||||
- `POST /input` `{type, x, y, button, deltaY, keyCode}` → `PostMessage`/`SendMessage`
|
||||
theo tọa độ scale client rect (gửi `WM_MOUSEMOVE` trước click).
|
||||
- `POST /close` → detach view, thoát exit 0. `GET /ping` → alive.
|
||||
- Toàn bộ Win32 API → bọc `#ifdef _WIN32` hoặc chỉ build khi WIN32 (xem Task 0L2).
|
||||
- Lệnh kiểm tra: không build được trên Linux — chỉ kiểm tra bằng mắt/syntax
|
||||
(`g++ -fsyntax-only` không đủ do thiếu windows.h → bỏ qua, sang Task 0W1).
|
||||
- Deliverable: source bridge có mode `--fx-gui`.
|
||||
- Phụ thuộc: không.
|
||||
|
||||
### Task 0L2 — Cập nhật `CMakeLists.txt` cho source mới
|
||||
- Máy: `[L]` · File: `native_bridge/CMakeLists.txt`
|
||||
- Thêm source file mới vào target `daw_vst_bridge` CHỈ khi `WIN32` (giữ Linux build
|
||||
không vỡ — Linux hiện build `main_linux.cpp` render-only).
|
||||
- Lệnh kiểm tra: `cmake -S native_bridge -B /tmp/nb-linux-check && cmake --build /tmp/nb-linux-check --target daw_vst_bridge` → PASS trên Linux.
|
||||
- Deliverable: Linux build vẫn xanh, Windows build có file mới.
|
||||
- Phụ thuộc: 0L1.
|
||||
|
||||
### Task 0W1 — Build bridge trên Windows
|
||||
- Máy: `[W]` · Lệnh: `powershell -ExecutionPolicy Bypass -File build/scripts/build_native_bridge.ps1`
|
||||
(bootstrap vcpkg, fluidsynth, sfizz 1.2.3, vst3sdk submodule, CMake VS2019 x64,
|
||||
copy `daw_vst_bridge.exe` + `libfluidsynth-3.dll` → `src-tauri\binaries\`).
|
||||
- Điều kiện máy: VS Build Tools 2019+, CMake, git, đã pull submodule
|
||||
(`git submodule update --init --recursive -- native_bridge/vst3sdk`).
|
||||
- Deliverable: `native_bridge/build/Release/daw_vst_bridge.exe` + sidecar trong `src-tauri\binaries\`.
|
||||
- Test: exe tồn tại, chạy `--help` không crash.
|
||||
- Phụ thuộc: 0L1, 0L2 (pull code mới về Windows trước).
|
||||
|
||||
### Task 0W2 — Self-check bridge `--fx-gui` thủ công
|
||||
- Máy: `[W]`
|
||||
- Lệnh:
|
||||
1. Tạo `job.json`: `{"path": "C:\\VST\\Ozone11.vst3", "name": "Ozone 11"}`.
|
||||
2. `native_bridge\build\Release\daw_vst_bridge.exe --fx-gui job.json` → đọc port từ stdout.
|
||||
3. `curl http://127.0.0.1:<port>/ping` → alive.
|
||||
4. `curl -o frame.jpg http://127.0.0.1:<port>/frame` → JPEG, kích thước = kích thước GUI plugin.
|
||||
5. `curl -X POST http://127.0.0.1:<port>/input -H "Content-Type: application/json" -d '{"type":"mousemove","x":100,"y":100,"button":0}'` rồi `{"type":"mousedown","x":100,"y":100,"button":0}` + `mouseup` → click trúng nút trong GUI (quan sát đổi trạng thái).
|
||||
6. `curl -X POST http://127.0.0.1:<port>/close` → process thoát exit 0.
|
||||
- Deliverable: /frame trả ảnh đúng, /input điều khiển được, /close sạch.
|
||||
- Phụ thuộc: 0W1.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Python API (embed_url + registry + close + CORS)
|
||||
|
||||
### Task 1L1 — Mở rộng `open_fx_gui` + thêm `fx-gui/close`
|
||||
- Máy: `[L]` · File: `app/api/v1/plugins.py`
|
||||
- Nội dung:
|
||||
- Spawn bridge với `--fx-gui` (thay/giữ `--open-fx-gui`), đọc port từ stdout
|
||||
(`SF_FXGUI_PORT=`) với timeout → trả `{ success, embed_url: "http://127.0.0.1:<port>", already_running }`.
|
||||
- Giữ registry `_FX_GUI_PROCESSES` + `_sf_plugin_path` chống mở trùng.
|
||||
- Thêm `POST /fx-gui/close` `{path}` → tìm process theo `_sf_plugin_path` → terminate.
|
||||
- CORS: đảm bảo `Access-Control-Allow-Origin` cho origin app (port động) — kiểm tra
|
||||
middleware hiện có trong `plugins.py`, thêm nếu thiếu.
|
||||
- Lệnh kiểm tra: `python3 -m pytest tests/test_plugin_api.py -q` → PASS (thêm unit
|
||||
test: mock `subprocess.Popen` → assert spawn args, embed_url parse, close kill,
|
||||
registry prune).
|
||||
- Deliverable: API mới + unit test.
|
||||
- Phụ thuộc: 0L1 (chỉ cần biết contract stdout port; có thể làm song song 0W).
|
||||
|
||||
### Task 1W1 — Test API thật trên Windows
|
||||
- Máy: `[W]` · Điều kiện: chạy app (backend FastAPI) + bridge đã build (0W1).
|
||||
- Lệnh:
|
||||
1. `curl -X POST http://127.0.0.1:<app>/api/v1/plugins/fx-gui/open -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"path":"C:\\VST\\Ozone11.vst3","name":"Ozone 11"}'` → `embed_url`.
|
||||
2. `curl http://<embed_url>/frame` → 200 JPEG.
|
||||
3. `curl -X POST .../fx-gui/close -d '{"path":"C:\\VST\\Ozone11.vst3"}'` → process chết (Task Manager / `Get-Process`).
|
||||
- Deliverable: API chạy thật, embed_url truy cập được, close sạch.
|
||||
- Phụ thuộc: 1L1, 0W1.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — MasteringModal: embed GUI trong panel
|
||||
|
||||
### Task 2L1 — Code embed trong `app/static/js/app.jsx` (MasteringModal + vstRow)
|
||||
- Máy: `[L]` · File: `app/static/js/app.jsx`
|
||||
- Nội dung (state ở component panel, truyền qua props như đã làm với `showCarla`):
|
||||
- State per-slot `guiOpen: { url } | null` (MasteringModal).
|
||||
- Nút GUI → toggle: mở → gọi API `fx-gui/open` → lưu `embed_url`; đang mở → đóng.
|
||||
- Vùng embed: `<img src={url + '/frame'}>` refresh `setInterval` 100ms; overlay
|
||||
`onMouseDown/Move/Up` + `onWheel` → `fetch(url + '/input', POST)` tọa độ theo
|
||||
bounding rect; `onKeyDown/Up` trên container (chỉ khi panel focus).
|
||||
- Swap graph↔GUI: ẩn vùng canvas EQ/imager bằng `hidden` khi GUI mở, hiện khi đóng.
|
||||
- Click tên VST trong slot khi GUI đang đóng → mở lại embed (ping trước).
|
||||
- Nút X đóng GUI → `POST /close`, clearInterval, trả graph. Xóa slot khi GUI mở
|
||||
→ cũng gọi `/close`.
|
||||
- `/ping` fail (bridge chết) → trả graph + toast.
|
||||
- Lệnh kiểm tra:
|
||||
- `node build.mjs` (hoặc `npm run build`) → BUILD OK (syntax JSX).
|
||||
- Chạy backend Linux + mở app → verify UI không vỡ (GUI nhúng không test được
|
||||
thật vì thiếu bridge — dùng mock nếu cần: server node trả /frame JPEG fake + /input log).
|
||||
- Deliverable: MasteringModal nhúng GUI + swap + đóng sạch.
|
||||
- Phụ thuộc: 1L1 (contract API).
|
||||
|
||||
### Task 2W1 — E2E thật MasteringModal
|
||||
- Máy: `[W]` · Điều kiện: 2L1 + 0W1 + 1W1 pass.
|
||||
- Test tay: mở app → Mastering Panel → thêm Ozone → GUI nhúng hiện TRONG panel
|
||||
(thay vùng graph) → click/scroll điều khiển plugin → đóng → graph trả về →
|
||||
click tên VST mở lại → Xóa slot khi GUI mở → GUI đóng + slot xóa, không crash.
|
||||
- Deliverable: UI mastering hoàn chỉnh.
|
||||
- Phụ thuộc: 2L1, 1W1.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — FXRackModal: embed GUI trong panel
|
||||
|
||||
### Task 3L1 — Code embed trong FXRackModal
|
||||
- Máy: `[L]` · File: `app/static/js/app.jsx` (FXRackModal — vùng scope/eq canvas)
|
||||
- Nội dung: tương tự 2L1 nhưng trong FXRackModal; lưu ý vùng thay thế là
|
||||
`scopeCanvasRef`/`eqCurveRef` (đã có fallback Carla ở đây — không đụng).
|
||||
- Lệnh kiểm tra: `node build.mjs` → BUILD OK; backend Linux + mở app → UI không vỡ.
|
||||
- Deliverable: FXRackModal nhúng GUI + swap + đóng sạch.
|
||||
- Phụ thuộc: 2L1 (tái dùng pattern), 1L1.
|
||||
|
||||
### Task 3W1 — E2E thật FXRackModal
|
||||
- Máy: `[W]` · Điều kiện: 3L1.
|
||||
- Test tay: FX Rack Panel → thêm VST FX → GUI nhúng trong panel → điều khiển →
|
||||
đóng → graph trả về. Track cũ có module Carla → vẫn hiện fallback "đã bị gỡ" +
|
||||
nút gỡ module (không vỡ).
|
||||
- Deliverable: UI fx rack hoàn chỉnh.
|
||||
- Phụ thuộc: 3L1.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 — Dọn + commit + regression
|
||||
|
||||
### Task 4L1 — Dọn code, docs, commit
|
||||
- Máy: `[L]`
|
||||
- Nội dung:
|
||||
- Toast lỗi đầy đủ (mở GUI fail, bridge chết, close fail).
|
||||
- `clearInterval` khi unmount/close (tránh leak).
|
||||
- Cập nhật `PLAN_MASTERBUS_VST_GUI_EMBED.md` trạng thái HOÀN THÀNH + ghi chú
|
||||
giới hạn (FPS ~10-15, Windows-only).
|
||||
- `git add -A && git commit` (tin nhắn: `embed native VST GUI in mastering + fx rack`).
|
||||
- Lệnh kiểm tra:
|
||||
- `node build.mjs` → BUILD OK.
|
||||
- `python3 -m pytest tests/ -q` → toàn bộ test hiện có PASS (1 fail pre-existing
|
||||
`test_find_bridge_exe_dev` do thiếu binary Linux — ghi chú, không phải regression).
|
||||
- Deliverable: commit sạch, docs cập nhật.
|
||||
- Phụ thuộc: 2L1, 3L1 (code ổn).
|
||||
|
||||
### Task 4W1 — Regression full trên Windows
|
||||
- Máy: `[W]` · Điều kiện: 4L1 (pull code mới).
|
||||
- Test tay toàn bộ: Mastering + FX Rack GUI nhúng; render offline (`--render-fx`)
|
||||
không vỡ; Carla (MasteringModal) vẫn chạy; preset upload; scan plugin.
|
||||
- Deliverable: bản Windows sẵn sàng dùng.
|
||||
- Phụ thuộc: 4L1.
|
||||
|
||||
---
|
||||
|
||||
## Thứ tự thực hiện tối ưu
|
||||
|
||||
```
|
||||
Linux: 0L1 → 0L2 ──→ 1L1 ──→ 2L1 ──→ 3L1 ──→ 4L1 (commit)
|
||||
Windows: 0W1 → 0W2 ──→ 1W1 ──→ 2W1 ──→ 3W1 ──→ 4W1
|
||||
(pull code sau 0L2) (pull sau 2L1) ...
|
||||
```
|
||||
|
||||
Song song được: 1L1 (contract stdout) với 0W1/0W2; 2L1/3L1 với 0W/1W.
|
||||
Chặn: mọi task Windows cần pull code từ Linux trước.
|
||||
@@ -0,0 +1,157 @@
|
||||
# WALKTHROUGH WINDOWS — Nhúng native VST GUI (phần Windows)
|
||||
|
||||
Máy Windows thực hiện nốt phần còn lại theo `TASKS_LINUX_WINDOWS.md`
|
||||
(tasks 0W1, 0W2, 1W1, 2W1, 3W1, 4W1). Code Linux đã commit và push — **pull
|
||||
code mới về trước**:
|
||||
|
||||
```powershell
|
||||
git pull origin standalone-shm-bridge
|
||||
git submodule update --init --recursive -- native_bridge/vst3sdk
|
||||
```
|
||||
|
||||
Điều kiện máy: VS Build Tools 2019+, CMake, git, git-lfs (nếu vst3sdk dùng).
|
||||
|
||||
---
|
||||
|
||||
## 0W1 — Build bridge
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File build/scripts/build_native_bridge.ps1
|
||||
```
|
||||
|
||||
Lưu ý thay đổi mới trong phần này (so với lần build cũ):
|
||||
- `native_bridge/vcpkg.json` **thêm `libjpeg-turbo`** — vcpkg manifest tự cài,
|
||||
không cần làm gì thêm, nhưng lần build đầu sẽ tải thêm package này.
|
||||
- `native_bridge/CMakeLists.txt`: thêm `src/FxGuiServer.cpp` vào target
|
||||
`daw_vst_bridge` (chỉ khi WIN32) + `find_package(JPEG REQUIRED)` +
|
||||
link `JPEG::JPEG`. Nếu script build cũ không chạy `cmake -S native_bridge`
|
||||
mà dùng đường dẫn cố định, hãy đảm bảo nó re-configure (xóa
|
||||
`native_bridge/build` nếu cần).
|
||||
- Script phải copy `daw_vst_bridge.exe` + DLL (fluidsynth, jpeg) sang
|
||||
`src-tauri\binaries\` (chỉ các DLL cần thiết; libjpeg-turbo thường static
|
||||
theo manifest, nếu build động thì copy luôn `jpeg62.dll`).
|
||||
|
||||
**PASS khi**: `native_bridge\build\Release\daw_vst_bridge.exe` tồn tại và
|
||||
`daw_vst_bridge.exe --help` không crash.
|
||||
|
||||
---
|
||||
|
||||
## 0W2 — Self-check bridge `--fx-gui` (chưa cần app)
|
||||
|
||||
1. Tạo `job.json` cạnh exe:
|
||||
```json
|
||||
{"path": "C:\\VST\\Ozone11.vst3", "name": "Ozone 11"}
|
||||
```
|
||||
(đổi path sang plugin VST3 bất kỳ đã cài trên máy Windows).
|
||||
2. Chạy (giữ terminal mở):
|
||||
```powershell
|
||||
native_bridge\build\Release\daw_vst_bridge.exe --fx-gui job.json
|
||||
```
|
||||
- stdout in dòng `SF_FXGUI_PORT=<port>` → ghi lại port.
|
||||
- **Không có cửa sổ plugin hiện ra** — window bị đặt off-screen
|
||||
(-20000,-20000) để capture; điều khiển qua HTTP, đây là thiết kế.
|
||||
3. Kiểm tra từ terminal khác:
|
||||
```powershell
|
||||
curl http://127.0.0.1:<port>/ping
|
||||
curl -o frame.jpg http://127.0.0.1:<port>/frame
|
||||
curl -X POST http://127.0.0.1:<port>/input -H "Content-Type: application/json" -d '{"type":"mousemove","x":100,"y":100,"button":0}'
|
||||
curl -X POST http://127.0.0.1:<port>/input -H "Content-Type: application/json" -d '{"type":"mousedown","x":100,"y":100,"button":0}'
|
||||
curl -X POST http://127.0.0.1:<port>/input -H "Content-Type: application/json" -d '{"type":"mouseup","x":100,"y":100,"button":0}'
|
||||
curl -X POST http://127.0.0.1:<port>/input -H "Content-Type: application/json" -d '{"type":"wheel","x":100,"y":100,"deltaY":120}'
|
||||
curl -X POST http://127.0.0.1:<port>/close
|
||||
```
|
||||
- `/frame` → JPEG (mở `frame.jpg` xem GUI plugin hiển thị đúng, không đen).
|
||||
- `/input` → click trúng nút trong GUI (plugin đổi trạng thái — để biết
|
||||
được, hãy mở song song GUI plugin bằng app cũ hoặc quan sát frame.jpg
|
||||
trước/sau click).
|
||||
- `/close` → tiến trình thoát exit 0.
|
||||
|
||||
**PASS khi**: frame không đen, input điều khiển được, close sạch.
|
||||
|
||||
> Nếu `/frame` ra ảnh đen dù GUI thật hiển thị: bridge đã tự fallback
|
||||
> `PrintWindow PW_RENDERFULLCONTENT` khi `BitBlt` đen; plugin layered/DirectX
|
||||
> (hiếm) có thể cần xử lý thêm — ghi lại plugin nào bị để theo dõi.
|
||||
|
||||
---
|
||||
|
||||
## 1W1 — Test API thật (backend FastAPI chạy trên Windows)
|
||||
|
||||
Chạy app như bình thường (backend + frontend). Frontend gọi:
|
||||
- `POST /api/v1/plugins/fx-gui` `{path, name, embed: true}` → trả
|
||||
`{success, started, already_running, embed_url, cmd}`.
|
||||
- `POST /api/v1/plugins/fx-gui/close` `{path}` → trả `{success, closed}`.
|
||||
|
||||
Cách test nhanh bằng curl (cần token — lấy từ app sau khi login, hoặc dùng
|
||||
UI thay vì curl; endpoint yêu cầu `Authorization: Bearer <token>`):
|
||||
|
||||
```powershell
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/plugins/fx-gui -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"path":"C:\\VST\\Ozone11.vst3","name":"Ozone 11","embed":true}'
|
||||
curl http://<embed_url>/frame -o frame.jpg
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/plugins/fx-gui/close -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"path":"C:\\VST\\Ozone11.vst3"}'
|
||||
```
|
||||
|
||||
- `embed:true` (mặc định `false` → cửa sổ rời kiểu cũ, giữ nguyên).
|
||||
- Port backend có thể khác 8000 — xem log app.
|
||||
- CORS đã mở `*` + bridge tự trả `Access-Control-Allow-Origin: *` → browser
|
||||
LAN gọi thẳng `embed_url` được.
|
||||
|
||||
**PASS khi**: open trả `embed_url` truy cập được `/frame`; close làm process
|
||||
chết (`Get-Process daw_vst_bridge` không còn).
|
||||
|
||||
---
|
||||
|
||||
## 2W1 — E2E MasteringModal
|
||||
|
||||
1. Mở app → **Mastering Panel**.
|
||||
2. Thêm VST (Ozone) vào master chain → **bấm nút GUI** trên slot.
|
||||
3. Kỳ vọng: GUI plugin hiện **TRONG panel** (thay vùng graph EQ/imager),
|
||||
header hiện "VST GUI — nhúng (frame-capture · ~10fps)".
|
||||
4. Click/scroll/kéo trong vùng GUI → plugin phản hồi (thay preset, xoay nút).
|
||||
5. Bấm "✕ Đóng GUI" → về graph; bấm nút GUI lại → mở lại.
|
||||
6. Xóa slot khi GUI đang mở → GUI đóng + slot xóa, không crash.
|
||||
7. Tắt bridge thủ công (Task Manager kill `daw_vst_bridge`) → sau ~2s panel
|
||||
toast "Bridge VST GUI đã mất kết nối" và tự trả về graph.
|
||||
|
||||
**PASS khi**: toàn bộ thao tác trên ổn định.
|
||||
|
||||
---
|
||||
|
||||
## 3W1 — E2E FXRackModal
|
||||
|
||||
1. Mở app → **FX Rack Panel** (track bất kỳ).
|
||||
2. Thêm VST FX vào insert chain → bấm nút GUI → GUI nhúng trong panel (thay
|
||||
vùng WORKSPACE CONTROLS + WAVE OBSERVER).
|
||||
3. Điều khiển → đóng → graph trả về.
|
||||
4. Track cũ có module **Carla** → vẫn hiện fallback "Carla Bridge đã bị gỡ
|
||||
khỏi FX Rack" + nút gỡ module (không vỡ).
|
||||
|
||||
**PASS khi**: ổn định như 2W1.
|
||||
|
||||
---
|
||||
|
||||
## 4W1 — Regression full
|
||||
|
||||
Test tay toàn bộ:
|
||||
- Mastering + FX Rack GUI nhúng (2W1/3W1).
|
||||
- Render offline (`--render-fx`) không vỡ — export WAV có FX.
|
||||
- Carla (MasteringModal) vẫn chạy.
|
||||
- Preset upload, scan plugin vẫn OK.
|
||||
|
||||
**PASS khi**: không có regression so với trước embed.
|
||||
|
||||
---
|
||||
|
||||
## Ghi chú chung
|
||||
|
||||
- Frame capture ~10fps (img refresh 100ms) — đủ chỉnh thông số, không phải
|
||||
realtime video.
|
||||
- Feature chỉ chạy Windows desktop (có VST GUI); Linux giữ bridge SF2/SFZ.
|
||||
- Nếu sửa gì trên Windows: sửa ngược lên Linux repo (app.jsx là source,
|
||||
`app.precompiled.js` là bản build — đừng sửa tay).
|
||||
- Lỗi thường gặp:
|
||||
- `--fx-gui` không in port → check job.json path đúng VST3, bridge build
|
||||
có `libjpeg-turbo` (thiếu JPEG → lỗi link).
|
||||
- `/frame` đen → plugin layered window; ghi plugin + thử fallback đã có.
|
||||
- App không mở GUI → xem log backend: thiếu `embed` trong payload hoặc
|
||||
`find_bridge_exe` không thấy exe trong `install/` (cần copy exe + DLL
|
||||
vào `src-tauri\binaries\` và app dùng đúng thư mục đó).
|
||||
+58
-1
@@ -503,6 +503,14 @@ class FxGuiRequest(BaseModel):
|
||||
"""Mở native GUI của VST FX qua bridge (Windows desktop)."""
|
||||
path: str
|
||||
name: Optional[str] = None
|
||||
# embed=True → bridge --fx-gui (off-screen + HTTP frame/input server,
|
||||
# PLAN_MASTERBUS_VST_GUI_EMBED.md); embed=False → cửa sổ rời cũ.
|
||||
embed: bool = False
|
||||
|
||||
|
||||
class FxGuiCloseRequest(BaseModel):
|
||||
"""Đóng bridge fx-gui đã mở cho plugin (embed mode)."""
|
||||
path: str
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
@@ -717,12 +725,41 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise HTTPException(status_code=500, detail="Không ghi được job file cho bridge.")
|
||||
cmd = [exe, "--open-fx-gui", job_path]
|
||||
cmd = [exe, "--fx-gui" if req.embed else "--open-fx-gui", job_path]
|
||||
try:
|
||||
if req.embed:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True,
|
||||
close_fds=os.name != "nt")
|
||||
# Bridge prints SF_FXGUI_PORT=<port> after binding its HTTP server.
|
||||
embed_url = None
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = (line or "").strip()
|
||||
if line.startswith("SF_FXGUI_PORT="):
|
||||
port = int(line.split("=", 1)[1])
|
||||
embed_url = f"http://127.0.0.1:{port}"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not embed_url:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
raise HTTPException(status_code=500, detail="Bridge không báo port fx-gui (mở GUI thất bại).")
|
||||
proc._sf_plugin_path = plugin_path
|
||||
proc._sf_embed_url = embed_url
|
||||
_register_fx_gui_process(proc)
|
||||
return {"success": True, "started": True, "already_running": False,
|
||||
"embed_url": embed_url, "cmd": cmd}
|
||||
proc = subprocess.Popen(cmd, close_fds=os.name != "nt")
|
||||
proc._sf_plugin_path = plugin_path
|
||||
_register_fx_gui_process(proc)
|
||||
return {"success": True, "started": True, "already_running": False, "cmd": cmd}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
try:
|
||||
os.remove(job_path)
|
||||
@@ -731,6 +768,26 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
|
||||
raise HTTPException(status_code=500, detail=f"Không mở được GUI của VST: {e}")
|
||||
|
||||
|
||||
@router.post("/fx-gui/close")
|
||||
async def close_fx_gui(req: FxGuiCloseRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Đóng bridge fx-gui đang chạy cho plugin (embed mode) — frontend gọi khi
|
||||
user đóng panel embed. Không lỗi nếu plugin chưa mở (closed=False)."""
|
||||
enforce_password_changed(current_user)
|
||||
plugin_path = (req.path or "").strip()
|
||||
if not plugin_path:
|
||||
raise HTTPException(status_code=400, detail="Thiếu đường dẫn VST plugin.")
|
||||
for proc in _FX_GUI_PROCESSES:
|
||||
if proc is not None and proc.poll() is None and getattr(proc, "_sf_plugin_path", None) == plugin_path:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
_prune_fx_gui_processes()
|
||||
return {"success": True, "closed": True}
|
||||
return {"success": True, "closed": False}
|
||||
|
||||
|
||||
_FX_GUI_PROCESSES = [] # list[subprocess.Popen] — các bridge fx-gui do app spawn
|
||||
|
||||
def _register_fx_gui_process(proc):
|
||||
|
||||
+103
-64
@@ -11289,8 +11289,7 @@ const TRACK_FX_META = {
|
||||
compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' },
|
||||
limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' },
|
||||
exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' },
|
||||
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' },
|
||||
carla: { name: 'Carla Bridge (VST FX)', icon: 'sliders', color: '#14b8a6', sub: 'Native VST audio processing' }
|
||||
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' }
|
||||
};
|
||||
const TRACK_FX_DEFAULTS = {
|
||||
eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
|
||||
@@ -11298,8 +11297,7 @@ const TRACK_FX_DEFAULTS = {
|
||||
compressor: { threshold: -16, ratio: 3, makeup: 0 },
|
||||
limiter: { ceiling: -1.0 },
|
||||
exciter: { drive: 40 },
|
||||
rebalance: { mid: 0, side: 0 },
|
||||
carla: { plugin: '', plugin_path: '' }
|
||||
rebalance: { mid: 0, side: 0 }
|
||||
};
|
||||
|
||||
// EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid,
|
||||
@@ -11754,7 +11752,66 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
);
|
||||
};
|
||||
|
||||
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen, openGui) => (
|
||||
// --- Embedded VST GUI (PLAN_MASTERBUS_VST_GUI_EMBED.md) --------------------
|
||||
// Frame-capture + input relay: hiển thị native GUI qua HTTP /frame (~10fps),
|
||||
// gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal.
|
||||
// url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng;
|
||||
// onDead khi bridge chết (ping fail) → parent trả graph + toast.
|
||||
const VstGuiEmbed = ({ url, onClose, onDead }) => {
|
||||
const imgRef = React.useRef(null);
|
||||
const boxRef = React.useRef(null);
|
||||
const deadRef = React.useRef(false);
|
||||
const [dead, setDead] = React.useState(false);
|
||||
const post = (payload) => { try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {} };
|
||||
React.useEffect(() => {
|
||||
deadRef.current = false;
|
||||
setDead(false);
|
||||
const tick = setInterval(() => { const img = imgRef.current; if (img) img.src = url + '/frame?t=' + Date.now(); }, 100);
|
||||
const ping = setInterval(() => {
|
||||
fetch(url + '/ping').then(r => { if (!r.ok) throw new Error('bad'); }).catch(() => {
|
||||
if (!deadRef.current) { deadRef.current = true; setDead(true); onDead && onDead(); }
|
||||
});
|
||||
}, 2000);
|
||||
return () => { clearInterval(tick); clearInterval(ping); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
const pt = (e) => {
|
||||
const box = boxRef.current, img = imgRef.current;
|
||||
if (!box || !img || !img.naturalWidth || !img.naturalHeight) return null;
|
||||
const r = box.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return null;
|
||||
return {
|
||||
x: Math.round((e.clientX - r.left) / r.width * img.naturalWidth),
|
||||
y: Math.round((e.clientY - r.top) / r.height * img.naturalHeight)
|
||||
};
|
||||
};
|
||||
const handle = (e) => {
|
||||
const p = pt(e); if (!p) return;
|
||||
post({ type: e.type === 'mousedown' ? 'mousedown' : e.type === 'mouseup' ? 'mouseup' : 'mousemove', x: p.x, y: p.y, button: e.button });
|
||||
};
|
||||
const close = () => { try { fetch(url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} onClose && onClose(); };
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-slate-900/80 border border-slate-800 rounded-t-lg shrink-0">
|
||||
<span className="text-[10px] text-violet-300 font-mono">VST GUI — nhúng (frame-capture · ~10fps)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{dead && <span className="text-[9px] text-red-400 font-mono">bridge mất kết nối</span>}
|
||||
<button onClick={close} className="px-2 py-0.5 bg-red-900/60 hover:bg-red-800 text-red-100 text-[10px] font-semibold rounded transition" title="Đóng GUI và trả về graph">✕ Đóng GUI</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={boxRef} tabIndex={0} className="flex-1 relative bg-slate-950 border border-slate-800 rounded-b-lg overflow-hidden outline-none"
|
||||
onMouseDown={handle} onMouseUp={handle} onMouseMove={handle}
|
||||
onMouseLeave={() => post({ type: 'mouseup', x: -1, y: -1, button: 0 })}
|
||||
onWheel={e => { const p = pt(e); if (p) post({ type: 'wheel', x: p.x, y: p.y, deltaY: e.deltaY }); }}
|
||||
onKeyDown={e => { e.preventDefault(); post({ type: 'keydown', keyCode: e.keyCode || e.which }); }}
|
||||
onKeyUp={e => { e.preventDefault(); post({ type: 'keyup', keyCode: e.keyCode || e.which }); }}>
|
||||
<img ref={imgRef} alt="VST GUI" className="w-full h-full object-contain select-none pointer-events-none" draggable={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen, openGui, showCarla = true) => (
|
||||
<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>
|
||||
@@ -11765,9 +11822,9 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
{(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={() => 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 (Save preset)">Preset</button>
|
||||
{openGui && <button onClick={() => openGui(s)} disabled={!s.path} className="px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-violet-100 text-[10px] font-semibold rounded transition" title="Mở GUI của VST (chỉnh thông số trực tiếp)"><i data-lucide="monitor" className="w-3 h-3"></i> GUI</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>
|
||||
{showCarla && <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>
|
||||
@@ -11789,14 +11846,6 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
const scopeMeterRRef = React.useRef(null);
|
||||
const eqCurveRef = React.useRef(null);
|
||||
const scopeStateRef = React.useRef({ L: null, R: null, head: 0, len: 0, tmpL: null, tmpR: null, freq: null });
|
||||
// Carla Bridge (VST FX) — danh sách VST để load vào Carla
|
||||
const [fxCarlaVsts, setFxCarlaVsts] = React.useState(null); // null = chưa load
|
||||
React.useEffect(() => {
|
||||
if (track && window.SonicAPI && window.SonicAPI.listPlugins) {
|
||||
window.SonicAPI.listPlugins().then(d => setFxCarlaVsts((d && d.vst_instruments) || [])).catch(() => setFxCarlaVsts([]));
|
||||
}
|
||||
}, [track && track.id]);
|
||||
|
||||
// VST FX (native_bridge --render-fx) — list VST3 effect plugins + preview
|
||||
const [fxList, setFxList] = React.useState([]);
|
||||
React.useEffect(() => {
|
||||
@@ -11815,10 +11864,16 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
// ── 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 }); };
|
||||
// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3)
|
||||
const [fxGuiOpen, setFxGuiOpen] = React.useState(null); // { path, url } | null
|
||||
const closeFxGui = () => {
|
||||
if (fxGuiOpen) { try { fetch(fxGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} setFxGuiOpen(null); }
|
||||
};
|
||||
React.useEffect(() => () => { if (fxGuiOpen) { try { fetch(fxGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
|
||||
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 openVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path }).then(r => { if (r && r.success) window.showToast && window.showToast('Đã mở GUI của ' + (s.name || s.path) + ' — chỉnh xong đóng cửa sổ plugin', 'success'); else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn', 'info'); else window.showToast && window.showToast('Không mở được GUI', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
|
||||
const openVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (fxGuiOpen) closeFxGui(); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true }).then(r => { if (r && r.success && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url }); else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
|
||||
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 }]);
|
||||
openVstGui({ path: fx.path, name: fx.name || fx.id }); };
|
||||
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)); };
|
||||
@@ -12141,7 +12196,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
</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, openVstGui))}
|
||||
{vstChain.map((s, idx) => vstRow(s, idx, toggleVst, changeVstPlugin, uploadVstPreset, moveVst, removeVst, fxList, vstChain.length, openVstGui, false))}
|
||||
<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>)}
|
||||
@@ -12149,6 +12204,13 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
</div>
|
||||
|
||||
{/* WORKSPACE CONTROLS */}
|
||||
{fxGuiOpen ? (
|
||||
<div className="p-4">
|
||||
<VstGuiEmbed url={fxGuiOpen.url} onClose={closeFxGui}
|
||||
onDead={() => { window.showToast && window.showToast('Bridge VST GUI đã mất kết nối — trả về graph', 'error'); closeFxGui(); }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<span className="text-slate-400">Active Module: <strong className="text-cyan-400">{activeMod ? (TRACK_FX_META[activeMod.type]?.name || activeMod.type) : '—'}</strong></span>
|
||||
@@ -12232,52 +12294,14 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
)}
|
||||
|
||||
{activeMod && activeMod.type === 'carla' && activeIdx >= 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-teal-400 font-mono uppercase tracking-widest">CARLA BRIDGE — VST FX CHỈNH SỬA ÂM THANH</span>
|
||||
<span className="text-[9px] text-slate-500 font-mono">Carla chạy ngoài (user tự cài) · native GUI</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-teal-900/60 p-3 rounded-lg space-y-3">
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<div className="text-[9px] text-slate-500 font-mono mb-1">CHỌN VST FX (đã scan trên máy)</div>
|
||||
<select
|
||||
value={ap.plugin || ''}
|
||||
onChange={e => setParams(activeIdx, { plugin: e.target.value, plugin_path: '' })}
|
||||
className="w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1.5 text-xs outline-none focus:border-teal-500"
|
||||
>
|
||||
<option value="">— Chọn VST FX —</option>
|
||||
{(fxCarlaVsts || []).map(v => <option key={v.id || v.name} value={v.id || v.name}>{v.name || v.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!ap.plugin) { window.showToast && window.showToast('Chọn VST FX trước khi Load Carla', 'warning'); return; }
|
||||
window.SonicAPI.openInCarla(ap.plugin, ap.plugin_path).then(r => {
|
||||
if (r && r.success) { window.showToast && window.showToast('Đã mở Carla với ' + ap.plugin + ' (VST FX) — chỉnh âm thanh trong Carla', '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-4 py-1.5 bg-teal-700 hover:bg-teal-600 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<i data-lucide="play" className="w-3 h-3"></i> Load Carla Bridge
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) window.SonicCarlaMidi.stopBridge();
|
||||
window.showToast && window.showToast('Đã ngắt kết nối Carla Bridge', 'info');
|
||||
}}
|
||||
className="px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-red-300 text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<i data-lucide="square" className="w-3 h-3"></i> Stop / Unload
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono leading-relaxed">
|
||||
{ap.plugin
|
||||
? <>Đã chọn: <span className="text-teal-300">{ap.plugin}</span> — bấm <b>Load Carla Bridge</b> để mở native GUI VST và chỉnh sửa âm thanh.</>
|
||||
: 'Chọn VST FX từ danh sách đã scan, rồi bấm Load Carla Bridge để mở Carla (native GUI).'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-950/40 border border-amber-800/60 rounded-lg p-4 space-y-3">
|
||||
<div className="text-[10px] text-amber-300 font-mono">Carla Bridge đã bị gỡ khỏi FX Rack — track cũ vẫn giữ module để không mất cấu hình.</div>
|
||||
<button
|
||||
onClick={() => removeMod(activeIdx)}
|
||||
className="px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-amber-50 text-xs font-semibold rounded transition"
|
||||
>
|
||||
Gỡ module Carla khỏi track
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -12329,6 +12353,8 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ADD MODULE POPUP */}
|
||||
@@ -12374,11 +12400,17 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
|
||||
// ── VST FX masterbus (native_bridge --render-fx, áp dụng khi Export/Offline render) ──
|
||||
const masterVstChain = (ozState.vstFxChain || []);
|
||||
// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 2)
|
||||
const [masterGuiOpen, setMasterGuiOpen] = React.useState(null); // { path, url } | null
|
||||
const closeMasterGui = () => {
|
||||
if (masterGuiOpen) { try { fetch(masterGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} setMasterGuiOpen(null); }
|
||||
};
|
||||
React.useEffect(() => () => { if (masterGuiOpen) { try { fetch(masterGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
|
||||
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 openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path }).then(r => { if (r && r.success) window.showToast && window.showToast('Đã mở GUI của ' + (s.name || s.path) + ' — chỉnh xong đóng cửa sổ plugin', 'success'); else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn', 'info'); else window.showToast && window.showToast('Không mở được GUI', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
|
||||
const openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (masterGuiOpen) closeMasterGui(); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true }).then(r => { if (r && r.success && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url }); else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
|
||||
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 }]);
|
||||
openMasterVstGui({ path: fx.path, name: fx.name || fx.id }); };
|
||||
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)); };
|
||||
@@ -13159,6 +13191,12 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
<main className="flex-1 flex overflow-hidden min-h-0">
|
||||
|
||||
{/* LEFT: MODULE VIEWS */}
|
||||
{masterGuiOpen ? (
|
||||
<div className="flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar p-4">
|
||||
<VstGuiEmbed url={masterGuiOpen.url} onClose={closeMasterGui}
|
||||
onDead={() => { window.showToast && window.showToast('Bridge VST GUI đã mất kết nối — trả về graph', 'error'); closeMasterGui(); }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar">
|
||||
|
||||
{/* SUB HEADER TOOLBAR */}
|
||||
@@ -13568,6 +13606,7 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RIGHT SIDEBAR: I/O METERS */}
|
||||
<div className="w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0">
|
||||
|
||||
+1980
-1972
File diff suppressed because one or more lines are too long
@@ -59,6 +59,7 @@ add_executable(daw_vst_bridge
|
||||
src/main.cpp
|
||||
src/RenderJob.cpp
|
||||
src/RenderFxJob.cpp
|
||||
src/FxGuiServer.cpp
|
||||
src/NativeInstrumentEngine.cpp
|
||||
src/SharedMemoryIPC.cpp
|
||||
src/Vst3Instrument.cpp
|
||||
@@ -89,7 +90,8 @@ if(VST3_SDK_TARGET)
|
||||
endif()
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY})
|
||||
find_package(JPEG REQUIRED) # libjpeg-turbo (vcpkg) — FxGuiServer.cpp JPEG encode
|
||||
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY} JPEG::JPEG)
|
||||
# Required Windows libs
|
||||
target_link_libraries(daw_vst_bridge PRIVATE winmm imm32)
|
||||
else()
|
||||
|
||||
@@ -44,4 +44,13 @@ int run_scan_dir(const std::string& dirPath);
|
||||
// editor attach failure.
|
||||
int run_open_fx_gui(const std::string& jobPath);
|
||||
|
||||
// Embedded GUI server mode (Phase 0 of PLAN_MASTERBUS_VST_GUI_EMBED.md):
|
||||
// `daw_vst_bridge --fx-gui <job.json>` — same plugin load + editor attach as
|
||||
// --open-fx-gui but the host window is parked off-screen and a tiny HTTP
|
||||
// server (FxGuiServer.cpp) serves JPEG frames (GET /frame) and relays pointer/
|
||||
// key input (POST /input) so the web UI can embed the native GUI in a panel.
|
||||
// Prints `SF_FXGUI_PORT=<port>` to stdout. Windows only. Exit codes same as
|
||||
// --open-fx-gui.
|
||||
int run_fx_gui_server(const std::string& jobPath);
|
||||
|
||||
#endif // RENDER_FX_JOB_H
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// native_bridge/src/FxGuiServer.cpp
|
||||
// Embedded VST GUI server (Phase 0 of PLAN_MASTERBUS_VST_GUI_EMBED.md):
|
||||
// `daw_vst_bridge --fx-gui <job.json>` loads ONE VST3 effect plugin, attaches
|
||||
// its native editor to an off-screen host window, and serves the window as
|
||||
// JPEG frames over a tiny HTTP server so the web frontend can embed it in a
|
||||
// panel (frame-capture + input relay, VNC-style). Windows only.
|
||||
//
|
||||
// Routes:
|
||||
// GET /ping -> {"ok":true}
|
||||
// GET /frame -> image/jpeg (BitBlt, fallback PrintWindow)
|
||||
// POST /input -> {type:"mousedown|mousemove|mouseup|wheel|keydown|keyup",
|
||||
// x,y,button,deltaY,keyCode} -> PostMessage to HWND
|
||||
// POST /close -> detach + exit 0
|
||||
//
|
||||
// The HTTP server runs on a worker thread; the calling (STA) thread runs the
|
||||
// message pump — VST3 editors need COM apartment + pump on the same thread.
|
||||
// The listen port is random and printed to stdout as `SF_FXGUI_PORT=<port>`.
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
#include <ws2tcpip.h>
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
|
||||
#include <jpeglib.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// sheredom/json.h (vendored with the VST3 SDK) — same include as RenderFxJob.cpp.
|
||||
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// --- tiny JSON helpers (same API as RenderFxJob.cpp) ------------------------
|
||||
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;
|
||||
}
|
||||
int64_t memberInt(const json_object_s* o, const char* key, int64_t def) {
|
||||
const json_value_s* v = memberValue(o, key);
|
||||
if (v && v->type == json_type_number)
|
||||
return (int64_t)std::atof(static_cast<const json_number_s*>(v->payload)->number);
|
||||
return def;
|
||||
}
|
||||
|
||||
// --- HTTP plumbing -----------------------------------------------------------
|
||||
struct HttpReq {
|
||||
std::string method;
|
||||
std::string path;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
// Read one request (headers up to \r\n\r\n + Content-Length body). Returns
|
||||
// false on socket error or malformed request line.
|
||||
bool readRequest(SOCKET c, HttpReq& out) {
|
||||
std::string raw;
|
||||
char buf[4096];
|
||||
while (raw.find("\r\n\r\n") == std::string::npos) {
|
||||
if (raw.size() > 16384) return false;
|
||||
int n = recv(c, buf, (int)sizeof(buf), 0);
|
||||
if (n <= 0) return false;
|
||||
raw.append(buf, (size_t)n);
|
||||
}
|
||||
const size_t headEnd = raw.find("\r\n\r\n");
|
||||
const std::string head = raw.substr(0, headEnd);
|
||||
const size_t sp1 = head.find(' ');
|
||||
const size_t sp2 = head.find(' ', sp1 + 1);
|
||||
if (sp1 == std::string::npos || sp2 == std::string::npos) return false;
|
||||
out.method = head.substr(0, sp1);
|
||||
out.path = head.substr(sp1 + 1, sp2 - sp1 - 1);
|
||||
|
||||
long contentLength = 0;
|
||||
size_t pos = 0;
|
||||
while ((pos = head.find("\r\n", pos)) != std::string::npos) {
|
||||
const size_t lineEnd = head.find("\r\n", pos + 2);
|
||||
const std::string line = head.substr(pos + 2, lineEnd == std::string::npos ? std::string::npos : lineEnd - pos - 2);
|
||||
pos = lineEnd == std::string::npos ? std::string::npos : lineEnd;
|
||||
if (line.rfind("Content-Length:", 0) == 0 || line.rfind("content-length:", 0) == 0) {
|
||||
contentLength = std::atol(line.c_str() + 15);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (contentLength > 0) {
|
||||
while (raw.size() < headEnd + 4 + (size_t)contentLength) {
|
||||
int n = recv(c, buf, (int)sizeof(buf), 0);
|
||||
if (n <= 0) return false;
|
||||
raw.append(buf, (size_t)n);
|
||||
}
|
||||
out.body = raw.substr(headEnd + 4, (size_t)contentLength);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void sendResponse(SOCKET c, int code, const char* contentType,
|
||||
const void* data, size_t len) {
|
||||
char hdr[512];
|
||||
std::snprintf(hdr, sizeof(hdr),
|
||||
"HTTP/1.1 %d %s\r\nContent-Type: %s\r\n"
|
||||
"Content-Length: %zu\r\nAccess-Control-Allow-Origin: *\r\n"
|
||||
"Connection: close\r\n\r\n",
|
||||
code, code == 200 ? "OK" : "Error", contentType, len);
|
||||
send(c, hdr, (int)std::strlen(hdr), 0);
|
||||
if (len > 0) send(c, (const char*)data, (int)len, 0);
|
||||
}
|
||||
|
||||
// --- frame capture -----------------------------------------------------------
|
||||
// BitBlt the window client rect; if the result is all-black (plugin uses a
|
||||
// layered/DirectX surface GDI cannot see), fall back to PrintWindow with
|
||||
// PW_RENDERFULLCONTENT. Returns JPEG bytes in `out`.
|
||||
bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality) {
|
||||
RECT rc;
|
||||
if (!GetClientRect(hwnd, &rc)) return false;
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
HDC win = GetDC(hwnd);
|
||||
HDC mem = CreateCompatibleDC(win);
|
||||
HBITMAP bmp = CreateCompatibleBitmap(win, w, h);
|
||||
HGDIOBJ oldBmp = SelectObject(mem, bmp);
|
||||
|
||||
std::vector<unsigned char> px((size_t)w * h * 4);
|
||||
auto grab = [&]() -> bool {
|
||||
BITMAPINFO bi = {};
|
||||
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
bi.bmiHeader.biWidth = w;
|
||||
bi.bmiHeader.biHeight = -h; // top-down
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
return GetDIBits(mem, bmp, 0, h, px.data(), &bi, DIB_RGB_COLORS) == h;
|
||||
};
|
||||
auto isAllBlack = [&]() -> bool {
|
||||
// Sample a coarse grid of pixels; all zero => invisible to GDI.
|
||||
const int step = 8;
|
||||
for (int y = 0; y < h; y += step)
|
||||
for (int x = 0; x < w; x += step) {
|
||||
const size_t i = ((size_t)y * w + x) * 4;
|
||||
if (px[i] || px[i + 1] || px[i + 2]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
bool ok = BitBlt(mem, 0, 0, w, h, win, 0, 0, SRCCOPY | CAPTUREBLT) != 0 && grab();
|
||||
if (ok && isAllBlack()) {
|
||||
ok = PrintWindow(hwnd, mem, PW_RENDERFULLCONTENT) != 0 && grab();
|
||||
}
|
||||
|
||||
SelectObject(mem, oldBmp);
|
||||
DeleteObject(bmp);
|
||||
DeleteDC(mem);
|
||||
ReleaseDC(hwnd, win);
|
||||
if (!ok) return false;
|
||||
|
||||
// BGRA (GDI) -> RGB, then JPEG.
|
||||
std::vector<unsigned char> rgb((size_t)w * h * 3);
|
||||
for (int i = 0; i < w * h; ++i) {
|
||||
rgb[(size_t)i * 3 + 0] = px[(size_t)i * 4 + 2];
|
||||
rgb[(size_t)i * 3 + 1] = px[(size_t)i * 4 + 1];
|
||||
rgb[(size_t)i * 3 + 2] = px[(size_t)i * 4 + 0];
|
||||
}
|
||||
|
||||
jpeg_compress_struct cinfo = {};
|
||||
jpeg_error_mgr jerr;
|
||||
cinfo.err = jpeg_std_error(&jerr);
|
||||
jpeg_create_compress(&cinfo);
|
||||
unsigned char* memBuf = nullptr;
|
||||
unsigned long memSize = 0;
|
||||
jpeg_mem_dest(&cinfo, &memBuf, &memSize);
|
||||
cinfo.image_width = (JDIMENSION)w;
|
||||
cinfo.image_height = (JDIMENSION)h;
|
||||
cinfo.input_components = 3;
|
||||
cinfo.in_color_space = JCS_RGB;
|
||||
jpeg_set_defaults(&cinfo);
|
||||
jpeg_set_quality(&cinfo, quality, TRUE);
|
||||
jpeg_start_compress(&cinfo, TRUE);
|
||||
JSAMPROW row = rgb.data();
|
||||
while (cinfo.next_scanline < cinfo.image_height)
|
||||
jpeg_write_scanlines(&cinfo, &row, 1);
|
||||
jpeg_finish_compress(&cinfo);
|
||||
if (memBuf && memSize > 0) out.assign(memBuf, memBuf + memSize);
|
||||
std::free(memBuf);
|
||||
jpeg_destroy_compress(&cinfo);
|
||||
return !out.empty();
|
||||
}
|
||||
|
||||
// --- input relay -------------------------------------------------------------
|
||||
void relayInput(HWND hwnd, const json_object_s* o) {
|
||||
const std::string type = memberString(o, "type", "");
|
||||
const int x = (int)memberInt(o, "x", 0);
|
||||
const int y = (int)memberInt(o, "y", 0);
|
||||
const int button = (int)memberInt(o, "button", 0);
|
||||
const int deltaY = (int)memberInt(o, "deltaY", 0);
|
||||
const WPARAM keyCode = (WPARAM)memberInt(o, "keyCode", 0);
|
||||
const LPARAM lp = MAKELPARAM(x, y);
|
||||
|
||||
if (type == "mousemove") {
|
||||
PostMessageA(hwnd, WM_MOUSEMOVE, 0, lp);
|
||||
} else if (type == "mousedown") {
|
||||
UINT m = button == 2 ? WM_RBUTTONDOWN : button == 1 ? WM_MBUTTONDOWN : WM_LBUTTONDOWN;
|
||||
PostMessageA(hwnd, m, MAKEWPARAM(0, button == 2 ? MK_RBUTTON : button == 1 ? MK_MBUTTON : MK_LBUTTON), lp);
|
||||
PostMessageA(hwnd, WM_MOUSEMOVE, 0, lp); // plugins tracking absolute pos
|
||||
} else if (type == "mouseup") {
|
||||
UINT m = button == 2 ? WM_RBUTTONUP : button == 1 ? WM_MBUTTONUP : WM_LBUTTONUP;
|
||||
PostMessageA(hwnd, m, 0, lp);
|
||||
} else if (type == "wheel") {
|
||||
PostMessageA(hwnd, WM_MOUSEWHEEL, MAKEWPARAM(0, (short)(-(int64_t)deltaY * 120)), lp);
|
||||
} else if (type == "keydown") {
|
||||
SetFocus(hwnd);
|
||||
PostMessageA(hwnd, WM_KEYDOWN, keyCode, 0);
|
||||
} else if (type == "keyup") {
|
||||
PostMessageA(hwnd, WM_KEYUP, keyCode, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP server loop (worker thread) ---------------------------------------
|
||||
void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
WSADATA wsa;
|
||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { std::cerr << "[FxGuiServer] WSAStartup failed" << std::endl; return; }
|
||||
SOCKET lsock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (lsock == INVALID_SOCKET) { WSACleanup(); return; }
|
||||
sockaddr_in addr = {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = 0; // random free port
|
||||
if (bind(lsock, (sockaddr*)&addr, sizeof(addr)) != 0 || listen(lsock, 4) != 0) {
|
||||
closesocket(lsock);
|
||||
WSACleanup();
|
||||
std::cerr << "[FxGuiServer] bind/listen failed" << std::endl;
|
||||
return;
|
||||
}
|
||||
sockaddr_in bound = {};
|
||||
int blen = sizeof(bound);
|
||||
getsockname(lsock, (sockaddr*)&bound, &blen);
|
||||
std::cout << "SF_FXGUI_PORT=" << ntohs(bound.sin_port) << std::endl;
|
||||
std::cout.flush();
|
||||
|
||||
while (!stop.load()) {
|
||||
fd_set rf;
|
||||
FD_ZERO(&rf);
|
||||
FD_SET(lsock, &rf);
|
||||
timeval tv = { 0, 100000 }; // 100ms poll so /close can break out
|
||||
int sel = select(0, &rf, nullptr, nullptr, &tv);
|
||||
if (sel <= 0) continue;
|
||||
SOCKET c = accept(lsock, nullptr, nullptr);
|
||||
if (c == INVALID_SOCKET) continue;
|
||||
|
||||
HttpReq req;
|
||||
if (!readRequest(c, req)) { closesocket(c); continue; }
|
||||
if (req.path == "/ping") {
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
} else if (req.path == "/frame") {
|
||||
std::vector<unsigned char> jpg;
|
||||
if (captureHwndToJpeg(hwnd, jpg, 70))
|
||||
sendResponse(c, 200, "image/jpeg", jpg.data(), jpg.size());
|
||||
else
|
||||
sendResponse(c, 500, "application/json", "{\"error\":\"capture failed\"}", 25);
|
||||
} else if (req.path == "/input") {
|
||||
json_parse_result_s pres = {};
|
||||
json_value_s* root = json_parse_ex(req.body.data(), req.body.size(),
|
||||
json_parse_flags_default, nullptr, nullptr, &pres);
|
||||
if (root && root->type == json_type_object) {
|
||||
relayInput(hwnd, static_cast<const json_object_s*>(root->payload));
|
||||
std::free(root);
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
} else {
|
||||
if (root) std::free(root);
|
||||
sendResponse(c, 400, "application/json", "{\"error\":\"bad json\"}", 20);
|
||||
}
|
||||
} else if (req.path == "/close") {
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
stop.store(true);
|
||||
PostMessageA(hwnd, WM_CLOSE); // pump exits -> process returns
|
||||
} else {
|
||||
sendResponse(c, 404, "application/json", "{\"error\":\"not found\"}", 22);
|
||||
}
|
||||
closesocket(c);
|
||||
}
|
||||
closesocket(lsock);
|
||||
WSACleanup();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Runs the embedded GUI server: starts the HTTP worker thread, then pumps
|
||||
// messages on this (STA) thread until the host window is destroyed (user
|
||||
// closed it or POST /close). Returns 0.
|
||||
int fxGuiServerLoop(HWND hwnd) {
|
||||
std::atomic<bool> stop{false};
|
||||
std::thread http(runHttpServer, hwnd, std::ref(stop));
|
||||
MSG msg;
|
||||
while (IsWindow(hwnd)) {
|
||||
BOOL r = GetMessageA(&msg, nullptr, 0, 0);
|
||||
if (r <= 0) break;
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageA(&msg);
|
||||
}
|
||||
stop.store(true);
|
||||
if (http.joinable()) http.join();
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -931,12 +931,11 @@ static void fxGuiDetachSafe(void* fx) {
|
||||
#endif
|
||||
}
|
||||
|
||||
int run_open_fx_gui(const std::string& jobPath) {
|
||||
// The editor window and its message pump live on this (STA) thread for the
|
||||
// whole GUI session — VST3 editors need the COM apartment + pump together.
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
|
||||
// Shared setup for both GUI modes: parse job, load plugin, create host window
|
||||
// (offscreen for embedded capture), attach editor (SEH-guarded). Returns 0 ok,
|
||||
// 1 job/argument error, 2 plugin load or editor attach failure.
|
||||
static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
|
||||
bool offscreen, LONG_PTR userData) {
|
||||
// 1. Parse job { path, name }.
|
||||
std::ifstream jf(jobPath, std::ios::binary);
|
||||
if (!jf) { std::cerr << "[FxGui] cannot read job file: " << jobPath << std::endl; return 1; }
|
||||
@@ -954,31 +953,47 @@ int run_open_fx_gui(const std::string& jobPath) {
|
||||
if (path.empty()) { std::cerr << "[FxGui] job missing path" << std::endl; return 1; }
|
||||
|
||||
// 2. Load the plugin (fixed 44.1k/512 — GUI only, no audio processed here).
|
||||
Vst3Fx fx;
|
||||
if (!fx.load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
|
||||
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
|
||||
|
||||
// 3. Host window.
|
||||
// 3. Host window. Embedded mode parks it far off-screen (never steals
|
||||
// focus; still visible to GDI so BitBlt captures real pixels).
|
||||
WNDCLASSA wc = {};
|
||||
wc.lpfnWndProc = FxGuiWndProc;
|
||||
wc.hInstance = GetModuleHandleA(nullptr);
|
||||
wc.lpszClassName = kFxGuiWndClass;
|
||||
RegisterClassA(&wc);
|
||||
FxGuiCtx ctx;
|
||||
const int x = offscreen ? -20000 : CW_USEDEFAULT;
|
||||
const int y = offscreen ? -20000 : CW_USEDEFAULT;
|
||||
HWND hwnd = CreateWindowExA(0, kFxGuiWndClass,
|
||||
(name.empty() ? "VST FX" : name).c_str(),
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS,
|
||||
CW_USEDEFAULT, CW_USEDEFAULT, 800, 600,
|
||||
x, y, 800, 600,
|
||||
nullptr, nullptr, GetModuleHandleA(nullptr), nullptr);
|
||||
if (!hwnd) { std::cerr << "[FxGui] create window failed" << std::endl; return 1; }
|
||||
SetWindowLongPtrA(hwnd, GWLP_USERDATA, (LONG_PTR)&ctx);
|
||||
SetWindowLongPtrA(hwnd, GWLP_USERDATA, userData);
|
||||
|
||||
// 4. Attach the plugin editor (SEH-guarded — plugin GUI code may crash).
|
||||
if (!fxGuiAttachSafe(&fx, hwnd)) {
|
||||
if (!fxGuiAttachSafe(fx, hwnd)) {
|
||||
std::cerr << "[FxGui] editor attach failed for " << path << std::endl;
|
||||
DestroyWindow(hwnd);
|
||||
return 2;
|
||||
}
|
||||
std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl;
|
||||
*hwndOut = hwnd;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int run_open_fx_gui(const std::string& jobPath) {
|
||||
// The editor window and its message pump live on this (STA) thread for the
|
||||
// whole GUI session — VST3 editors need the COM apartment + pump together.
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
|
||||
Vst3Fx fx;
|
||||
HWND hwnd = nullptr;
|
||||
FxGuiCtx ctx;
|
||||
const int rc = fxGuiSetup(jobPath, &fx, &hwnd, /*offscreen=*/false, (LONG_PTR)&ctx);
|
||||
if (rc) return rc;
|
||||
|
||||
// 5. Pump messages until the window is destroyed.
|
||||
MSG msg;
|
||||
@@ -995,6 +1010,25 @@ int run_open_fx_gui(const std::string& jobPath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Embedded GUI server mode: same setup but off-screen, then hand the message
|
||||
// pump + HTTP capture/input to FxGuiServer.cpp (see PLAN_MASTERBUS_VST_GUI_EMBED.md).
|
||||
extern int fxGuiServerLoop(HWND hwnd);
|
||||
|
||||
int run_fx_gui_server(const std::string& jobPath) {
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
|
||||
Vst3Fx fx;
|
||||
HWND hwnd = nullptr;
|
||||
const int rc = fxGuiSetup(jobPath, &fx, &hwnd, /*offscreen=*/true, 0);
|
||||
if (rc) return rc;
|
||||
|
||||
const int srv = fxGuiServerLoop(hwnd);
|
||||
fxGuiDetachSafe(&fx);
|
||||
std::cout << "[FxGuiServer] closed" << std::endl;
|
||||
return srv;
|
||||
}
|
||||
|
||||
#else // !_WIN32
|
||||
|
||||
int run_open_fx_gui(const std::string& jobPath) {
|
||||
@@ -1003,6 +1037,12 @@ int run_open_fx_gui(const std::string& jobPath) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int run_fx_gui_server(const std::string& jobPath) {
|
||||
(void)jobPath;
|
||||
std::cerr << "[FxGui] GUI mode chỉ hỗ trợ Windows" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
// --- --scan: classify VST3 modules as instrument/effect ----------------------
|
||||
|
||||
@@ -646,6 +646,14 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
if (!fxGuiJob.empty()) return run_open_fx_gui(fxGuiJob);
|
||||
|
||||
// --fx-gui <job.json>: embedded GUI server (frame capture + input relay).
|
||||
// See PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 0.
|
||||
std::string fxGuiServerJob;
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--fx-gui") == 0) fxGuiServerJob = argv[i + 1];
|
||||
}
|
||||
if (!fxGuiServerJob.empty()) return run_fx_gui_server(fxGuiServerJob);
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version-string": "1.0.0",
|
||||
"dependencies": [
|
||||
"fluidsynth",
|
||||
"libjpeg-turbo",
|
||||
"pkgconf"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -197,3 +197,71 @@ class TestMidiRenderVSTi:
|
||||
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}], "bpm": 120})
|
||||
assert resp.status_code == 409
|
||||
|
||||
# --- fx-gui embed (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 1) --------------
|
||||
def test_fx_gui_open_embed_returns_embed_url(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.stdout = iter(["some log\n", "SF_FXGUI_PORT=54321\n"])
|
||||
with patch("app.core.native_render.find_bridge_exe",
|
||||
return_value="C:\\bridge\\daw_vst_bridge.exe"), \
|
||||
patch("app.api.v1.plugins.subprocess.Popen", return_value=fake_proc):
|
||||
resp = client.post("/api/v1/plugins/fx-gui",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"path": "C:\\VST\\Ozone11.vst3", "name": "Ozone 11", "embed": True})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["embed_url"] == "http://127.0.0.1:54321"
|
||||
assert data["started"] is True
|
||||
# registry có process + path để close tìm thấy
|
||||
from app.api.v1 import plugins as plugins_mod
|
||||
assert any(getattr(p, "_sf_plugin_path", None) == "C:\\VST\\Ozone11.vst3"
|
||||
for p in plugins_mod._FX_GUI_PROCESSES)
|
||||
plugins_mod._FX_GUI_PROCESSES.clear()
|
||||
|
||||
def test_fx_gui_open_no_port_is_error(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.stdout = iter(["never prints port\n"])
|
||||
with patch("app.core.native_render.find_bridge_exe",
|
||||
return_value="C:\\bridge\\daw_vst_bridge.exe"), \
|
||||
patch("app.api.v1.plugins.subprocess.Popen", return_value=fake_proc):
|
||||
resp = client.post("/api/v1/plugins/fx-gui",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"path": "C:\\VST\\Ozone11.vst3", "embed": True})
|
||||
assert resp.status_code == 500
|
||||
assert "port" in resp.json()["detail"]
|
||||
fake_proc.terminate.assert_called_once()
|
||||
|
||||
def test_fx_gui_close_kills_process(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
from app.api.v1 import plugins as plugins_mod
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
proc._sf_plugin_path = "C:\\VST\\Ozone11.vst3"
|
||||
plugins_mod._FX_GUI_PROCESSES.append(proc)
|
||||
try:
|
||||
resp = client.post("/api/v1/plugins/fx-gui/close",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"path": "C:\\VST\\Ozone11.vst3"})
|
||||
finally:
|
||||
plugins_mod._FX_GUI_PROCESSES.clear()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["closed"] is True
|
||||
proc.terminate.assert_called_once()
|
||||
|
||||
def test_fx_gui_close_unknown_plugin(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
resp = client.post("/api/v1/plugins/fx-gui/close",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"path": "C:\\VST\\NoSuchPlugin.vst3"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["closed"] is False
|
||||
|
||||
Reference in New Issue
Block a user