feat: native host bridge integration — C++ bridge, Rust SHM, JS routing, build scripts, docs
- native_bridge/: InstrumentEngineManager multi-channel, sample-accurate, CC/program/pitchbend, transport, Vst3Instrument stub (HAVE_VST3SDK) - src-tauri: shm.rs, bridge spawn + audio pump + health monitor, open_vst_gui, externalBin, commands - app: UnifiedMidiRouter, NativeBridgeService, bridgeAudioNode, audioRoutingEngine, Plugin Manager UI, Bridge/WASM indicator, set_position sync - build: 3 ps1 (force-added, build/ ignored), verify_bundle --check-bridge, CI workflow - docs: TASKS.md, TEST_NOTES.md (Windows verify checklist), install/report updates
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
name: build-windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install Rust (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install MSVC + CMake deps
|
||||||
|
uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
|
||||||
|
- name: Build one-command pipeline (engine + bridge + tauri)
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
powershell -ExecutionPolicy Bypass -File build/scripts/build_windows.ps1
|
||||||
|
|
||||||
|
- name: Upload NSIS installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SonicForgeDAW-setup
|
||||||
|
path: |
|
||||||
|
src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
src-tauri/target/release/bundle/msi/*.msi
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "native_bridge/vst3sdk"]
|
||||||
|
path = native_bridge/vst3sdk
|
||||||
|
url = https://github.com/steinbergmedia/vst3sdk.git
|
||||||
@@ -302,3 +302,31 @@ Tất cả 7 task trong kế hoạch đã hoàn thành:
|
|||||||
7. ✅ Kiểm thử hệ thống
|
7. ✅ Kiểm thử hệ thống
|
||||||
|
|
||||||
**Hệ thống SonicForge Studio đã sẵn sàng sử dụng! 🎉**
|
**Hệ thống SonicForge Studio đã sẵn sàng sử dụng! 🎉**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 Bản 1.2 — Bản Desktop Windows (Tauri v2 + 2 sidecar)
|
||||||
|
|
||||||
|
> Report trên là môi trường **Docker/server** (dev). Từ bản 1.2, bản phát hành
|
||||||
|
> desktop là **Tauri v2 shell + 2 sidecar** — xem `DESKTOP_INSTALL_PLAN.md` §5.2.
|
||||||
|
|
||||||
|
- **Sidecar 1** `daw_engine.exe` (FastAPI/PyInstaller onedir, port 8000-8010):
|
||||||
|
bundle.resources → `src-tauri/resources/daw_engine/`; `SF_PARENT_PID` để
|
||||||
|
watchdog tự thoát khi Tauri đóng.
|
||||||
|
- **Sidecar 2** `daw_vst_bridge.exe` (C++ Native Host Bridge): vcpkg
|
||||||
|
(fluidsynth/sfizz) + vst3sdk submodule; `bundle.externalBin` →
|
||||||
|
`src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe`; trao đổi qua
|
||||||
|
shared memory `SonicForge_DAW_IPC` (struct 11160 bytes — sync
|
||||||
|
SharedMemoryIPC.h ↔ src-tauri/src/shm.rs ↔ tests/shm_selfcheck.cpp).
|
||||||
|
- **IPC control**: LOAD/TRANSPORT(panic|play|set_position)/OPEN_GUI(HWND) qua
|
||||||
|
SHM control queue; MIDI 0x9/0x8/0xB/0xC/0xE + sampleOffset.
|
||||||
|
- **API mới**: `GET /api/v1/bridge/status`, `POST /api/v1/bridge/load`,
|
||||||
|
`GET /api/v1/bridge/log`.
|
||||||
|
- **Build 1 lệnh**: `build/scripts/build_windows.ps1` (7 bước: kill → pip →
|
||||||
|
frontend → engine → copy → bridge → vc_redist → tauri build NSIS+MSI);
|
||||||
|
verify: `tools/verify_bundle.py --check-bridge`.
|
||||||
|
- **YC runtime**: VC++ 2015-2022 Redistributable cho cả 2 sidecar (NSIS hook
|
||||||
|
tự cài; MSI không chạy hooks → cài thủ công).
|
||||||
|
- **Trạng thái**: code + selfcheck SHM pass trên Linux; chưa build trên
|
||||||
|
Windows (thiếu rustc/CMake-MSVC tại máy dev) — chạy `test_standalone.ps1`
|
||||||
|
trên máy Windows sau khi cài.
|
||||||
|
|||||||
@@ -112,6 +112,40 @@ bash build_linux.sh
|
|||||||
bash build_macos.sh
|
bash build_macos.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 5.2 KIẾN TRÚC 2 SIDECAR (Native Host Bridge — bản 1.2)
|
||||||
|
|
||||||
|
Từ bản 1.2, bản desktop chạy **Tauri v2** shell + **2 sidecar** (không còn browser):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────── SonicForgeDAW (Tauri v2) ─────────────────────────┐
|
||||||
|
│ WebView2 (UI HTML5/JS — app.precompiled.js) │
|
||||||
|
│ ▲ invoke ▲ bridge-audio (event) │
|
||||||
|
│ Rust shell (src-tauri) │
|
||||||
|
│ ├── spawn+watchdog ──► daw_engine.exe (FastAPI sidecar, port 8000) │
|
||||||
|
│ └── spawn+health ────► daw_vst_bridge.exe (C++ Native Host Bridge) │
|
||||||
|
│ │ SharedMemory "SonicForge_DAW_IPC" │
|
||||||
|
│ │ (MidiEventIPC / ControlEventIPC / audio) │
|
||||||
|
│ └─ FluidSynth / sfizz / VST3 host │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **MIDI → âm**: Web MIDI/timeline → `push_midi_event` (Rust ghi SHM) →
|
||||||
|
bridge render block 256 (FluidSynth/sfizz/VST3) → Rust audio pump emit
|
||||||
|
`bridge-audio` → WebView2 ScriptProcessor → track FX → master → loa.
|
||||||
|
- **Fallback**: bridge chết → `SonicSF` (FluidSynth WASM) tự bật, app không crash.
|
||||||
|
- **YC runtime**: cả `daw_engine.exe` lẫn `daw_vst_bridge.exe` cần VC++ 2015-2022
|
||||||
|
Redistributable — `hooks.nsh` (NSIS) cài `vc_redist.x64.exe`; MSI không chạy
|
||||||
|
hooks → máy sạch nên cài NSIS hoặc tự cài redist.
|
||||||
|
- **Binaries**: engine = `src-tauri/resources/daw_engine/` (bundle.resources),
|
||||||
|
bridge = `src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe`
|
||||||
|
(`bundle.externalBin`).
|
||||||
|
- **Build bridge**: `build/scripts/build_native_bridge.ps1` (vcpkg fluidsynth/
|
||||||
|
sfizz + submodule vst3sdk + CMake MSVC) → copy exe vào `src-tauri/binaries/`.
|
||||||
|
- **API mới**: `GET /api/v1/bridge/status`, `POST /api/v1/bridge/load`,
|
||||||
|
`GET /api/v1/bridge/log` (engine giữ trung gian IPC file
|
||||||
|
`%APPDATA%/SonicForgeDAW/ipc/`).
|
||||||
|
- **Logs**: `%APPDATA%/SonicForgeDAW/logs/spawn.log` (engine+bridge), `bridge.log`.
|
||||||
|
|
||||||
## 6. CẬP NHẬT
|
## 6. CẬP NHẬT
|
||||||
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
||||||
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# TASKS — TRIỂN KHAI NATIVE HOST BRIDGE THEO PLAN
|
||||||
|
|
||||||
|
Nguồn: `PLAN.md` + spec. Mỗi task: file đích, hành động cụ thể, điều kiện hoàn thành (DoD) để verify.
|
||||||
|
Ký hiệu: **[MỚI]** tạo mới, **[THAY]** thay thế/đổi luồng hiện có, **[BỔ SUNG]** thêm vào code hiện có.
|
||||||
|
|
||||||
|
> **TRẠNG THÁI 2026-08-11:** ✅ = đã làm xong. Task ✅ liên quan Rust/Windows (B1-B4, C2, C5, C7)
|
||||||
|
> mới verify ở mức code (node --check JS / review struct) — **cần `cargo check` + chạy thật trên máy
|
||||||
|
> Windows** trước khi coi là hoàn tất; máy này (Linux) không có rustc/CMake-MSVC.
|
||||||
|
> **D2/D9:** đã đổi timeline → `scheduleMidiNoteDispatch` (bridge active) + transport play/stop +
|
||||||
|
> `bridgePlayheadSampleRef`; sampleOffset/set_position chỉ gửi khi C++ A11/A13 sẵn sàng (nay gửi
|
||||||
|
> sampleOffset=0 qua setTimeout). **E1-E3:** syntax OK (ast.parse); máy này SIGILL khi import
|
||||||
|
> `app.core.vst_engine` (numpy/pedalboard pre-existing) → chưa chạy thử API tại đây.
|
||||||
|
> **BATCH A10-A13 + D5 (2026-08-11):** C++ multi-instance (InstrumentEngineManager), CC/program/pitch
|
||||||
|
> bend (MidiEventIPC + data2/data3), TRANSPORT (type=3 STOP flush/PLAY/SET_POSITION + playheadSamples),
|
||||||
|
> sample-accurate segment render, JS dispatch MỞ RỘNG (CC/PROGRAM/PITCH_BEND), load theo channel,
|
||||||
|
> transport(playhead). **Struct SHM ĐỔI: 10872 → 11160 bytes** — đã sync C (selfcheck pass Linux) ↔ Rust
|
||||||
|
> shm.rs ↔ selfcheck; **bắt buộc `cargo check` + chạy shm_selfcheck trên Windows**. Sửa bug pre-existing:
|
||||||
|
> Rust push_control không set pathlen → C++ load giờ dùng strnlen(arg2) thay vì tin arg1. C++ chưa compile
|
||||||
|
> ở máy này (thiếu fluidsynth/sfizz headers) — verify bằng shm_selfcheck (không cần engine) + review.
|
||||||
|
> **BATCH B8-B10 (cùng ngày):** spawn bridge kèm SF_SAMPLE_RATE/SF_BLOCK_SIZE (C++ main đọc env, block cố
|
||||||
|
> định 256), open_vst_gui tạo child WebviewWindow + HWND raw_window_handle → control type=4, health monitor
|
||||||
|
> stall 3s → respawn 1 lần → bridge-down (đếm vào bridge.log). Rust chưa compile được (rustup không có
|
||||||
|
> toolchain) — review tay; `cargo check` trên Windows là điều kiện hoàn tất.
|
||||||
|
> **BATCH D7/D10/E4/E5/F1-F7/B6/B7/A7 (2026-08-11, cuối):** D7 UI Plugin Manager (badge Bridge ON/OFF +
|
||||||
|
> nút "Load Bridge" trên VST/SoundFont → `/api/v1/bridge/load` → loadInstrument); D10 header indicator
|
||||||
|
> Bridge/WASM + nút "Ép WASM" (setForceWasm) + service `onStatusChange`; E4 watchdog verify (không đụng
|
||||||
|
> bridge); E5 `GET /api/v1/bridge/log`; F1-F3 3 ps1 copy vào repo build/scripts/; F4 verify_bundle
|
||||||
|
> `--check-bridge`; F5 hooks.nsh OK; F6 docs 2 sidecar; F7 CI yml; B6 externalBin; B7 capabilities OK;
|
||||||
|
> A7 tách Vst3Instrument.h/.cpp (guard HAVE_VST3SDK, stub no-op khi thiếu submodule — A6 host thật vẫn
|
||||||
|
> TODO chờ vst3sdk trên Windows). Bundle rebuild OK; selfcheck 11160 pass Linux.
|
||||||
|
> **BATCH CUỐI (2026-08-11):** B5 ✅ (pump thread tồn tại trong lib.rs ~263 — review tay, cần cargo check
|
||||||
|
> Windows); F5 ✅ (hooks.nsh `customInstall` ExecWait vc_redist toàn máy — đủ cho cả 2 exe); F2 🟡
|
||||||
|
> (ps1 đã copy, chạy thật cần Windows); D9 bổ sung `transport('set_position', playhead)` ~mỗi giây trong
|
||||||
|
> `updatePlayhead` (JS là nguồn playhead duy nhất — C++ A13 không tự biết). Bundle rebuilt OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. NATIVE BRIDGE C++ (`native_bridge/`) — [MỚI]
|
||||||
|
|
||||||
|
Code skeleton đã chuẩn bị sẵn trong workspace `native_bridge/`; copy vào repo `/home/locpham/SonicForgeStudio/native_bridge/` rồi hoàn thiện từng task.
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ A1 | `native_bridge/` | Copy toàn bộ skeleton từ workspace vào repo | `native_bridge/CMakeLists.txt` + `include/*.h` + `src/*.cpp` tồn tại |
|
||||||
|
| ✅ A2 | `native_bridge/vst3sdk/` | `git submodule add https://github.com/steinbergmedia/vst3sdk.git` | thư mục `vst3sdk/CMakeLists.txt` tồn tại |
|
||||||
|
| ✅ A3 | `native_bridge/vcpkg.json` | Tạo manifest: `{"dependencies":["fluidsynth","sfizz","pkgconf"]}` | `vcpkg install` chạy từ manifest không lỗi |
|
||||||
|
| ✅ A4 | `native_bridge/include/SharedMemoryIPC.h` | Giữ struct spec + controlQueue (đã có); **bổ sung** field `uint64_t blockTimestamp` (đo latency IPC) | struct khớp layout Rust ở B3 |
|
||||||
|
| ✅ A5 | `native_bridge/src/main.cpp` | Hoàn thiện: đọc control LOAD (đã có), **thêm** xử lý `type=3 TRANSPORT_PLAY/STOP` (flush note-off khi Stop), log mỗi lần load instrument ra stdout | Stop timeline → bridge gửi note-off toàn pitch |
|
||||||
|
| A6 | `native_bridge/src/NativeInstrumentEngine.cpp` | Giữ FluidSynth + sfizz (đã có); **hoàn thiện Vst3Instrument**: host qua vst3sdk (`IAudioProcessor::process`, `IPlugView::attached` với HWND con), factory `make_instrument` trả về instance thật | load `.vst3` → noteon → PCM khác 0 |
|
||||||
|
| 🟡 A7 | `native_bridge/include/Vst3Instrument.h` + `src/Vst3Instrument.cpp` | Tách class VST3 riêng (hiện đang stub trong main.cpp); xử lý `openGUI(void* hwnd)` | compile OK khi có vst3sdk |
|
||||||
|
| A8 | `native_bridge/src/Vst2Instrument.cpp` | (Tùy chọn, GĐ sau) Host VST2 qua vestige | skip nếu không đủ thời gian; ghi TODO |
|
||||||
|
| ✅ A9 | `native_bridge/src/SharedMemoryIPC.cpp` | Bỏ no-op; thêm helper `open_shm(name)`, `write_midi`, `write_control` (tái dùng bởi test) | unit test nhỏ gọi helper pass |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. RUST / TAURI SHELL (`src-tauri/`) — [MỚI] + [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ B1 | `src-tauri/Cargo.toml` | **BỔ SUNG** dependency `windows-sys` (features `Win32_System_MemoryManagement`, `Win32_Foundation`) để CreateFileMapping/MapViewOfFile | `cargo check` pass |
|
||||||
|
| ✅ B2 | `src-tauri/src/shm.rs` | **[MỚI]** Module SHM: `create_shm(name) -> ShmHandle` tạo `SonicForge_DAW_IPC` (CreateFileMapping + MapViewOfFile, size = `sizeof(SharedAudioBufferIPC)`), expose `write_midi_event(evt)`, `read_audio_block()`, `reset_queue()` | `cargo check` pass; struct layout khớp A4 |
|
||||||
|
| ✅ B3 | `src-tauri/src/lib.rs` | **BỔ SUNG** `EngineProcess` thứ hai cho bridge: `bridge_candidates()` dò `binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe` (resource dir + exe dir), spawn với env `SF_SHM_NAME=SonicForge_DAW_IPC` + `SF_PARENT_PID`, redirect stdout→`%APPDATA%/SonicForgeDAW/logs/bridge.log`, kill khi window destroyed | spawn.log có dòng `[daw_vst_bridge] exists=True` |
|
||||||
|
| ✅ B4 | `src-tauri/src/lib.rs` | **BỔ SUNG** `invoke_handler` với commands: `push_midi_event(cmd,ch,pitch,vel,sampleOffset)`, `load_native_instrument(path,type)`, `open_vst_gui(pluginId)`, `bridge_status()`, `transport_control(kind)` | `window.__TAURI__.core.invoke(...)` trả về OK |
|
||||||
|
| ✅ B5 | `src-tauri/src/lib.rs` | **[MỚI]** Audio pump thread: mỗi ~5.8ms đọc SHM (`bridgeWriteIndex` tăng) → `app.emit("bridge-audio", {L: Float32Array, R: Float32Array})`; nếu index không tăng 1s → emit `bridge-down` | UI nhận event `bridge-audio` |
|
||||||
|
| B6 | `src-tauri/tauri.conf.json` | **BỔ SUNG** `bundle.externalBin: ["binaries/daw_vst_bridge"]` (giữ resources engine) | `npx tauri build` bundle chứa `daw_vst_bridge-x86_64-pc-windows-msvc.exe` |
|
||||||
|
| B7 | `src-tauri/capabilities/default.json` | Xác nhận không cần permission mới (commands app-level) — nếu dùng plugin window cho GUI VST thì thêm `core:window:allow-create` | `tauri dev` không cảnh báo permission |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. FRONTEND SERVICES MỚI (`app/static/js/services/`) — [MỚI]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ C1 | `unifiedMidiRouter.js` | **[MỚI]** Class `UnifiedMidiRouter`: `pushEvent({cmd,ch,pitch,vel,sampleOffset})` normalize `UnifiedMidiEvent`; `subscribe(source)` cho Web MIDI + Timeline; khi `bridgeConnected` → gọi `nativeBridgeService.dispatchMidiEvent`, ngược lại → gọi `SonicSF.playNote/stopNote` (fallback); `panic()` → `transport_control('panic')` | 1 router dùng chung 2 nguồn |
|
||||||
|
| ✅ C2 | `nativeBridgeService.js` | **[MỚI]** Như spec NHƯNG `dispatchMidiEvent` = `window.__TAURI__.core.invoke('push_midi_event', evt)` (không có `__TAURI_IPC_SHM__`); `loadInstrument(filePath,type)` invoke `load_native_instrument`; `openNativeGUI(pluginId)` invoke `open_vst_gui`; lắng nghe `bridge-audio`/`bridge-down`, expose `onAudio(cb)` | MIDI keyboard → note chạy qua bridge |
|
||||||
|
| ✅ C3 | `audioRoutingEngine.js` | **[MỚI]** `connectNativeBridgeToTrackFX(bridgeNode, trackId)` theo spec §VI: nối node vào `trackContext.sfEntry` → FX chain → `masterBus.input`; `disconnect()` khi bridge down | âm bridge qua EQ track + master maximizer |
|
||||||
|
| ✅ C4 | `app/templates/index.html` | **BỔ SUNG** 3 script tag sau `soundfontPlayer.js` (dòng 41): `nativeBridgeService.js`, `unifiedMidiRouter.js`, `audioRoutingEngine.js` (kèm `?v=` mới) | reload page → `window.SonicMidiRouter` tồn tại |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. WIRING `app/static/js/app.jsx` — [THAY]
|
||||||
|
|
||||||
|
| ID | Vị trí (dòng hiện tại) | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ D1 | `onmidimessage` ~15587 | **[THAY]** Nhánh NOTE_ON/OFF gọi `SonicSF.playNote`/`SonicCarlaMidi.noteOn` → gọi `UnifiedMidiRouter.pushEvent` trước; giữ fallback khi `!bridgeConnected` | bấm keyboard → router log + âm qua bridge |
|
||||||
|
| ✅ D2 | `updatePlayhead` ~20771+ (nhánh dispatch midiItems) | **[THAY]** Dispatch note timeline → `pushEvent` kèm `sampleOffset` tính từ playhead (bỏ `setTimeout` drift); Stop/Play → `transport_control` | play piano roll đúng nhịp, Stop hết âm ngân |
|
||||||
|
| ✅ D3 | `playNativeSfNote` / `SonicSF.playNote` call sites (track armed, keybed, sub-tab) | **[THAY]** Đổi đích tới router khi bridge active (cờ `SF_BRIDGE_AVAILABLE`) | mọi đường note đi qua 1 cổng |
|
||||||
|
| ✅ D4 | `SonicCarlaMidi` (runtime.js ~74) | **[THAY]** `shouldRouteCarla` trả false khi bridge connected (VSTi do bridge host); giữ Carla khi không có bridge | không kép âm VST (bridge + Carla) |
|
||||||
|
| ✅ D5 | `updateSfRouting` ~21255 + `setOutputDestination` | **[BỔ SUNG]** Nhánh mới: khi bridge active, `audioRoutingEngine.connectNativeBridgeToTrackFX(node, trackId)` thay vì `SonicSF.setOutputDestination` | âm bridge vào đúng track FX chain |
|
||||||
|
| D6 | `panic`/`stopAll` (soundfontPlayer.js) + Stop handler app.jsx | **[BỔ SUNG]** Stop/panic → `transport_control('panic')` tới bridge + vẫn gọi fallback cục bộ | không note kẹt sau Stop |
|
||||||
|
| D7 | UI Plugin Manager / Synth dropdown | **[BỔ SUNG]** Hiển thị trạng thái bridge (`bridge_status`); nút "Load vào Native Bridge" gọi `C2.loadInstrument` với path resolve từ API plugins (Bổ sung E1) | chọn VSTi/SF2 → bridge load, GUI VST mở được |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. BACKEND PYTHON (`app/`) — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ E1 | `app/api/v1/plugins.py` | **BỔ SUNG** endpoint `GET /api/v1/bridge/status` (đọc file `%APPDATA%/SonicForgeDAW/ipc/bridge_status` do Rust ghi, hoặc probe bridge.log) | `curl /api/v1/bridge/status` trả JSON có `connected` |
|
||||||
|
| ✅ E2 | `app/api/v1/plugins.py` | **BỔ SUNG** endpoint `POST /api/v1/bridge/load` — resolve path thật (sf2 trong storage/soundfonts, .vst3 trong plugin_dirs) rồi ghi file IPC `ipc/bridge_load.request` cho Rust đọc (giống cơ chế pick_dir hiện có) | Rust nhận request → bridge load → UI nhận `bridge-audio` |
|
||||||
|
| ✅ E3 | `app/core/vst_engine.py` | Giữ nguyên cho offline render; **thêm** comment + hàm `resolve_plugin_path(name)` tái dùng bởi E2 | không phá luồng offline hiện có |
|
||||||
|
| E4 | `desktop_engine.py` | Không đổi logic; kiểm tra watchdog không giết bridge (bridge do Rust spawn, engine chỉ quản lý chính nó) | chạy standalone vẫn OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. BUILD & ĐÓNG GÓI WINDOWS — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | `build/scripts/build_native_bridge.ps1` | Copy từ workspace (đã sẵn): vcpkg bootstrap + install fluidsynth/sfizz + submodule vst3sdk + cmake MSVC + copy exe | chạy trên máy Windows → exe trong `src-tauri/binaries/` |
|
||||||
|
| 🟡 F2 | `build/scripts/build_windows.ps1` | Copy từ workspace: pipeline 7 bước (0. kill engine+bridge, 1. pip, 2. frontend, 3. pyinstaller, 4. copy engine, 5. build bridge, 6. vc_redist, 7. tauri build) | build 1 lệnh ra NSIS+MSI |
|
||||||
|
| F3 | `build/scripts/test_standalone.ps1` | Copy từ workspace: 8 bước test (process, spawn.log, SHM, health, bridge/status, load SF2, checklist tay) | pass #1-#5 |
|
||||||
|
| F4 | `tools/verify_bundle.py` | **BỔ SUNG** kiểm tra thêm: `src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe` tồn tại trước tauri build | build fail sớm nếu thiếu bridge |
|
||||||
|
| ✅ F5 | `src-tauri/hooks.nsh` | Xác nhận `vc_redist.x64.exe` được cài cho cả `daw_engine.exe` + `daw_vst_bridge.exe` (bridge cần VC++ runtime) | máy sạch cài app → bridge chạy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. TEST — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | Nội dung | DoD |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | Unit test C++ (A9 helper) | assert-based check trong `native_bridge/tests/` chạy pass |
|
||||||
|
| G2 | Test SHM: Rust tạo → bridge mở → ghi MIDI → đọc audio | `bridgeWriteIndex` tăng; PCM khác 0 khi noteon |
|
||||||
|
| G3 | Test load SF2/SF3 (SGM-V2.01) + SFZ (SalamanderPiano) + VST3 (Vital) | spec VII mục 2 pass |
|
||||||
|
| G4 | Test live MIDI + timeline cùng lúc (Nektar SE49) | spec VII mục 3 pass, latency <10ms |
|
||||||
|
| G5 | Test Track FX + Master FX tác động âm bridge | spec VII mục 4 pass |
|
||||||
|
| G6 | Test fallback: kill bridge → SonicSF WASM tiếp tục, không crash | spec VII mục 6 pass |
|
||||||
|
| G7 | Chạy `test_standalone.ps1` trên máy cài standalone | 5/5 bước tự động pass |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. BỔ SUNG LIỀN MẠCH (MẠCH DỮ LIỆU HOÀN CHỈNH)
|
||||||
|
|
||||||
|
Các task nhóm H lấp những khâu còn hở giữa A-G để pipeline chạy end-to-end không đứt đoạn.
|
||||||
|
Đánh số tiếp theo nhóm tương ứng.
|
||||||
|
|
||||||
|
### H-A. Bridge C++ — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ A10 | `native_bridge/src/main.cpp` + `NativeInstrumentEngine.h` | **[MỚI]** Thay `currentInstrument` (1 instance) bằng `InstrumentEngineManager`: `std::map<uint32_t, std::unique_ptr<INativeInstrument>>` keyed theo MIDI channel; `assign_instrument(channel, path, type)`; MIDI event → instance theo `evt.channel`; channel chưa gán → silent (không crash) | 2 track (ch 0 + ch 2) 2 instrument phát đồng thời |
|
||||||
|
| ✅ A11 | `native_bridge/src/main.cpp` | **[BỔ SUNG]** Sample-accurate: không xử lý MIDI ngay; buffer event theo `sampleOffset`, rẽ block 256 thành các đoạn theo offset, gọi `noteOn/Off` đúng vị trí trước khi render đoạn đó | note timeline không lệch nhịp (>±1 sample test) |
|
||||||
|
| ✅ A12 | `native_bridge/include/SharedMemoryIPC.h` + engine | **[BỔ SUNG]** Mở rộng `MidiEventIPC`: field `data2` (+`data3` PB MSB); hỗ trợ `0xB CC` (FluidSynth `fluid_synth_cc`), `0xC program change`, `0xE pitch bend` (`fluid_synth_pitch_bend`); VST3 → tham số tương ứng | sustain/CC/pitchbend qua bridge hoạt động |
|
||||||
|
| ✅ A13 | `native_bridge/src/main.cpp` | **[BỔ SUNG]** Control `type=3 TRANSPORT`: `arg0=0 STOP` → flush note-off toàn channel + `fluid_synth_all_notes_off`; `arg0=1 PLAY` kèm `arg1=playheadSamples` → đồng bộ timeline | Stop → hết âm ngân tức thì |
|
||||||
|
|
||||||
|
### H-B. Rust — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ B8 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** Spawn bridge kèm env `SF_SAMPLE_RATE` + `SF_BLOCK_SIZE` (đọc từ `bridge_status`/cấu hình, mặc định 44100/256); nếu `AudioContext.sampleRate` khác → JS resampler (C5) — ghi chú trong code | SR khác 44100 vẫn nghe đúng cao độ |
|
||||||
|
| ✅ B9 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** `open_vst_gui(pluginId)`: tạo Tauri child window (WebviewWindowBuilder, title = plugin, childOf main) → lấy HWND (windows-sys) → ghi control `type=4 OPEN_GUI` + hwnd vào SHM → bridge `openGUI(hwnd)`; window đóng → `close_gui` | VST GUI hiện trong cửa sổ con, đóng sạch |
|
||||||
|
| ✅ B10 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** Health monitor: nếu `bridgeWriteIndex` đứng >3s và process chết → spawn lại 1 lần, vẫn fail → `emit("bridge-down")`; đếm số lần restart vào bridge.log | app tự phục hồi bridge 1 lần; UI fallback sau đó |
|
||||||
|
|
||||||
|
### H-C. Frontend — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ C5 | `app/static/js/services/bridgeAudioNode.js` | **[MỚI]** Sink phát âm: nhận `bridge-audio` (Float32Array L/R) → ring buffer depth 8 block (~46ms) chống underrun → ScriptProcessor/Worklet đẩy ra `AudioNode` (output chuẩn stereo); nút này là "bridgeNode" mà C3 nối; **VU analyser tap trên node** (`__vuAnalyser` như `_gainNode` cũ) để track VU nhảy; resampler tuyến tính nếu SR khác bridge | âm nghe được từ SHM; VU track hoạt động |
|
||||||
|
| ✅ C6 | `app/static/js/app.jsx` (useEffect khởi tạo ~gần `initMasterBus`) | **[BỔ SUNG]** Bootstrap bridge: gọi `bridge_status()` → set `SF_BRIDGE_AVAILABLE`; `nativeBridgeService.onAudio` nối vào `bridgeAudioNode`; subscribe `bridge-down` → ngắt node + quay về SonicSF | cờ đúng trước note đầu tiên; bridge chết → fallback tự động |
|
||||||
|
| ✅ C7 | `app/static/js/services/nativeBridgeService.js` | **[BỔ SUNG]** Dev-mode mock: không có `window.__TAURI__` (Linux dev / browser) → mọi invoke thành log + `onAudio` không emit → app chạy bằng SonicSF như cũ | dev trên Linux không crash |
|
||||||
|
|
||||||
|
### H-D. app.jsx — bổ sung
|
||||||
|
|
||||||
|
| ID | Vị trí | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D8 | router (C1) | **[BỔ SUNG]** Channel allocation: bảng `trackId → channel 0-15` thay `_engineChMap`/`allocateChannel`; percussion (bank 128) → ch 9; gửi `CC0/CC32/program change` qua bridge khi đổi instrument (A12) | 2 track 2 instrument không trùng channel |
|
||||||
|
| ✅ D9 | `updatePlayhead` | **[BỔ SUNG]** Playhead sample counter: `playheadSample += block*(dt*sr/block)`; `sampleOffset = playheadSample - lastBlockStartSamples`; gửi `transport_control('set_position', playheadSample)` mỗi bar | timeline chính xác sample |
|
||||||
|
| D10 | UI header/status | **[BỔ SUNG]** Indicator bridge (connected/down) + nút "Ép dùng WASM" (debug): gọi `SonicMidiRouter.setForceWasm(true)` | user biết engine nào đang phát |
|
||||||
|
|
||||||
|
### H-E. Backend — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E5 | `app/api/v1/plugins.py` | **[BỔ SUNG]** `GET /api/v1/bridge/log?lines=100` — trả tail `bridge.log` cho UI debug | UI xem được log bridge |
|
||||||
|
|
||||||
|
### H-F. Build — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F6 | `DESKTOP_INSTALL_PLAN.md` / `DEPLOYMENT_REPORT.md` | **[BỔ SUNG]** Cập nhật tài liệu: kiến trúc 2 sidecar (daw_engine + daw_vst_bridge), yêu cầu VC++ runtime, port/API mới | tài liệu khớp thực tế |
|
||||||
|
| F7 | `ci/build-windows.yml` | **[MỚI]** (Tùy chọn) GitHub Actions: runner windows-latest → build bridge + engine + tauri → artifact installer | pipeline CI ra NSIS setup |
|
||||||
|
|
||||||
|
### H-G. Test — bổ sung
|
||||||
|
|
||||||
|
| ID | Nội dung | DoD |
|
||||||
|
|---|---|---|
|
||||||
|
| G8 | Đo latency end-to-end: đọc `blockTimestamp` (A4) qua `bridge_status` + round-trip MIDI→loa (loopback/mic); so ngưỡng | IPC ≤0.5ms; end-to-end <10ms |
|
||||||
|
| G9 | Test multi-instance: track 1 SF2 (piano) + track 2 SFZ (string) chạy cùng lúc, đổi instrument live | không choke, đúng âm từng track |
|
||||||
|
| G10 | Test CC/sustain/pitchbend qua bridge (A12) | sustain pedal + pitch wheel + CC volume hoạt động |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SƠ ĐỒ MẠCH DỮ LIỆU HOÀN CHỈNH (SAU KHI ĐỦ TASKS)
|
||||||
|
|
||||||
|
```
|
||||||
|
Web MIDI keyboard ──┐
|
||||||
|
Timeline playhead ──┴─► UnifiedMidiRouter (C1) ─ invoke push_midi_event (B4/C2)
|
||||||
|
(D8: track→channel) │ bridge down → SonicSF fallback (C1/C6/C7)
|
||||||
|
▼
|
||||||
|
Rust shm.rs write_midi_event (B2) ──► SHM "SonicForge_DAW_IPC"
|
||||||
|
▼
|
||||||
|
bridge main.cpp (A11 sampleOffset, A12 CC/PC/PB, A13 transport)
|
||||||
|
→ InstrumentEngineManager (A10) → FluidSynth / sfizz / VST3 (A6-A8)
|
||||||
|
▼
|
||||||
|
SHM masterLeft/Right + bridgeWriteIndex + blockTimestamp (A4)
|
||||||
|
▼
|
||||||
|
Rust audio pump (B5/B10 health) ─ emit 'bridge-audio' ─► bridgeAudioNode (C5)
|
||||||
|
▼
|
||||||
|
audioRoutingEngine (C3) → track sfEntry → track FX → masterBus → speakers
|
||||||
|
▼
|
||||||
|
VU analyser tap (C5) → track VU nhảy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHỤ THUỘC (thứ tự thực hiện — đã cập nhật)
|
||||||
|
|
||||||
|
1. **A1-A4 → B1-B4 → C1-C2 + C5-C7** (skeleton + IPC + router + audio sink) — nền tảng.
|
||||||
|
2. **A10-A13 → B5, B8 → C3, C6 → D1-D2, D8-D9** (multi-instance + sample-accurate + audio pump + routing + wiring).
|
||||||
|
3. **A5-A7, A12 → B9-B10 → D3-D7, D10 → E1-E5** (engine thật + VST GUI + gỡ Carla + UI + backend).
|
||||||
|
4. **F1-F7 → G1-G10** (build pipeline + test) — cần máy Windows.
|
||||||
|
|
||||||
|
## CÒN THIẾU SO VỚI SPEC (đã bổ sung vào tasks — bản đầy đủ)
|
||||||
|
|
||||||
|
- SHM tạo bởi Rust (B2) — spec chỉ có bridge mở.
|
||||||
|
- Đường MIDI JS→bridge (B4/C2) — spec dùng `__TAURI_IPC_SHM__` không tồn tại.
|
||||||
|
- Audio pump SHM→WebView (B5/C5) — spec nói zero-copy nhưng WebView không zero-copy được.
|
||||||
|
- Transport/panic + playhead position (A5/A13/B4/C1/D6/D9) — spec thiếu, cần cho timeline Stop/đồng bộ.
|
||||||
|
- Nhiều instrument/track (A10/D8) — spec chỉ 1 `currentInstrument`.
|
||||||
|
- VST3 GUI child window (B9/A7) — spec có `openGUI` nhưng thiếu cơ chế HWND con từ Tauri.
|
||||||
|
- CC/program/pitchbend (A12) — spec chỉ note on/off; app hiện có sustain/pitch wheel.
|
||||||
|
- Sample-accurate scheduling (A11/D9) — spec xử lý MIDI ngay lập tức, bỏ qua sampleOffset.
|
||||||
|
- Resampler khi SR AudioContext ≠ bridge (B8/C5).
|
||||||
|
- Ring buffer chống underrun + VU tap (C5) — kế thừa hành vi `_gainNode.__vuAnalyser` cũ.
|
||||||
|
- Fallback SonicSF (C1/C6/C7/G6) — spec không đề cập; giữ để app không chết khi bridge lỗi.
|
||||||
|
- Dev-mode mock (C7) — chạy được trên Linux/browser không có `__TAURI__`.
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# TEST NOTES — NATIVE BRIDGE (test trên máy native/Windows)
|
||||||
|
|
||||||
|
Máy phát triển hiện tại (Linux, không rustc/CMake-MSVC, Python SIGILL khi import
|
||||||
|
`app.core.vst_engine`) KHÔNG chạy được pipeline. Danh sách này là "bộ nhớ" —
|
||||||
|
chạy từng mục trên máy Windows (hoặc OS native có đủ toolchain), đánh dấu ✅ khi pass.
|
||||||
|
|
||||||
|
## A. C++ bridge (`native_bridge/`)
|
||||||
|
- [x] A5+A13: TRANSPORT type=3 (STOP flush / PLAY / SET_POSITION) + log mỗi load — code OK, chưa chạy thật
|
||||||
|
- [x] A10: InstrumentEngineManager multi-channel (std::map channel→instrument) — code OK, chưa compile Windows
|
||||||
|
- [x] A11: sample-accurate segment render theo sampleOffset — code OK; JS hiện gửi sampleOffset=0 (best-effort)
|
||||||
|
- [x] A12: CC/program/pitch bend (MidiEventIPC data2+data3) — code OK
|
||||||
|
- [ ] A6: Vst3Instrument host thật qua vst3sdk — load .vst3 → noteon → PCM ≠ 0
|
||||||
|
- [ ] A7: Vst3Instrument tách class + openGUI(hwnd)
|
||||||
|
- [ ] **Windows verify sfizz API names**: `SfizzInstrument::controlChange` dùng `sfizzSynth.cc(cc,val)`, `pitchBend` dùng `sfizzSynth.pitchWheel(bend14)` — nếu bản sfizz vcpkg chỉ có `hdCC`/`hdPitchWheel` thì đổi; `programChange` đang no-op (TODO)
|
||||||
|
- [ ] **Windows verify**: `cargo check` + chạy `shm_selfcheck.exe` (sizeof struct = **11160 bytes**)
|
||||||
|
|
||||||
|
## B. Rust / Tauri (`src-tauri/`)
|
||||||
|
- [ ] B1-B4: `cargo check` pass trên Windows (windows-sys, shm.rs, lib.rs commands)
|
||||||
|
- [ ] B2: SHM struct layout khớp C header (**11160 bytes** — đã verify C bằng selfcheck Linux; cần Rust)
|
||||||
|
- [ ] B2: signature commands mới — `push_midi_event(..., data2, data3, sample_offset)`, `load_native_instrument(path, type, channel)`, `transport_control(kind, playhead: Option<u32>)` — JS `nativeBridgeService.js`/`unifiedMidiRouter.js` đã sync
|
||||||
|
- [ ] B5: audio pump thread emit `bridge-audio` ~5ms — ✅ code: pump thread trong lib.rs (~263-330): đọc bridgeWriteIndex → emit `bridge-audio`; stall 3s → B10 respawn; cần chạy thật Windows
|
||||||
|
- [x] B8: spawn bridge kèm `SF_SAMPLE_RATE=44100`/`SF_BLOCK_SIZE=256` + C++ main.cpp đọc env (block cố định 256, SR áp cho init engine) — code OK
|
||||||
|
- [x] B9: `open_vst_gui` tạo child WebviewWindow + HWND qua `raw_window_handle` → control type=4 arg1 — code OK; **close_gui khi window đóng chưa làm (chờ A7)**
|
||||||
|
- [x] B10: health monitor trong audio pump — stall 3s → respawn 1 lần → `bridge-down`; đếm restart vào bridge.log — code OK
|
||||||
|
- [ ] B6: tauri.conf.json externalBin chứa daw_vst_bridge — ✅ code: `["binaries/daw_vst_bridge"]` đã thêm (chưa `tauri build` verify)
|
||||||
|
- [ ] B7: capabilities — ✅ review: window VST tạo từ Rust (`WebviewWindowBuilder`), commands app-level, event listen nằm trong `core:default` — không cần permission mới
|
||||||
|
- [ ] B9 thật trên Windows: child window hiện + HWND hợp lệ (kiểm tra log bridge nhận type=4)
|
||||||
|
|
||||||
|
## C. Frontend services
|
||||||
|
- [x] C1-C7: node --check OK + bundle build OK (Linux)
|
||||||
|
- [ ] C5: ring buffer 8 block chống underrun thật trên Windows (âm nghe được, VU nhảy)
|
||||||
|
|
||||||
|
## D. app.jsx wiring
|
||||||
|
- [x] D1: onmidimessage NOTE_ON/OFF → router (code + bundle OK)
|
||||||
|
- [x] D2: timeline → scheduleMidiNoteDispatch + transport play/stop (code + bundle OK)
|
||||||
|
- [x] D9: bridgePlayheadSampleRef counter + `transport('set_position', playhead)` ~mỗi giây trong updatePlayhead (JS là nguồn playhead duy nhất; C++ A13 cập nhật anchor) — code + bundle OK
|
||||||
|
- [ ] D1-D2 thật: bấm keyboard + play piano roll → âm qua bridge, Stop hết âm ngân
|
||||||
|
- [x] D4: Carla không route khi bridge active (code, cần verify thật)
|
||||||
|
- [ ] D5: audio routing vào track FX chain
|
||||||
|
- [x] D6: panic/Stop → `SonicMidiRouter.panic()` (bridge transport panic + fallback SonicSF.stopAll) — 4 call site app.jsx đã đổi
|
||||||
|
- [x] D8: track→channel map + gửi CC0/CC32/PROGRAM trước NOTE_ON khi đổi instrument (scheduleMidiNoteDispatch)
|
||||||
|
- [x] D7: UI Plugin Manager — badge `● Bridge ON/OFF` (query `GET /api/v1/bridge/status`), nút "Load Bridge" trên mỗi VST + SoundFont → `POST /api/v1/bridge/load` (E2 resolve path) → `NativeBridgeService.loadInstrument(path, type, 0)`; code + bundle OK — chưa test thật trên Windows
|
||||||
|
- [x] D10: header indicator Bridge/WASM + nút "Ép WASM" (toggle `SonicMidiRouter.setForceWasm`); service thêm `onStatusChange(cb)` — code + bundle OK
|
||||||
|
|
||||||
|
## E. Backend Python
|
||||||
|
- [x] E1-E3: syntax OK (ast.parse)
|
||||||
|
- [x] E4: desktop_engine.py watchdog — verify: chỉ theo dõi `SF_PARENT_PID` (Tauri) để tự thoát; không spawn/kill gì khác → không đụng bridge (bridge do Rust spawn). Không cần sửa.
|
||||||
|
- [x] E5: `GET /api/v1/bridge/log?lines=N` tail bridge.log — thêm Query import; ast.parse OK
|
||||||
|
- [ ] `curl /api/v1/bridge/status` trả `connected` đúng — cần chạy trên máy không SIGILL
|
||||||
|
- [ ] `POST /api/v1/bridge/load` resolve sf2/vst3 → bridge load → UI nhận `bridge-audio`
|
||||||
|
- [ ] SIGILL khi import `app.core.vst_engine` trên máy dev (numpy/pedalboard pre-existing) — không phải do code mới
|
||||||
|
|
||||||
|
## F. Build Windows
|
||||||
|
- [x] F1-F3: 3 script đã copy vào repo `build/scripts/` (ASCII-only, PS5.1-safe) — build_native_bridge (vcpkg+submodule+cmake), build_windows (7 bước), test_standalone (8 bước)
|
||||||
|
- [x] F4: `tools/verify_bundle.py --check-bridge` — verify TOC + sidecar tồn tại; fail đúng cách trên Linux (chưa có exe) — test exit 1 OK
|
||||||
|
- [x] F5: hooks.nsh — vc_redist cài toàn máy (ExecWait /quiet) → đủ runtime cho cả daw_engine.exe + daw_vst_bridge.exe; MSI không chạy hooks → cài thủ công
|
||||||
|
- [x] F6: DESKTOP_INSTALL_PLAN.md §5.2 (kiến trúc 2 sidecar) + DEPLOYMENT_REPORT.md bản 1.2
|
||||||
|
- [x] F7: `.github/workflows/build-windows.yml` — windows-latest: MSVC + Rust + node + python → build_windows.ps1 → upload NSIS/MSI
|
||||||
|
- [ ] **Chạy thật trên Windows**: build_windows.ps1 trọn pipeline ra NSIS+MSI
|
||||||
|
|
||||||
|
## G. Test end-to-end
|
||||||
|
- [ ] G1: shm_selfcheck pass (đã pass trên Linux: "SHM self-check OK (**11160 bytes**)" — chạy lại trên Windows)
|
||||||
|
- [ ] G2: SHM Rust tạo → bridge mở → MIDI → audio
|
||||||
|
- [ ] G3: load SGM-V2.01 (SF2) + SalamanderPiano (SFZ) + Vital (VST3)
|
||||||
|
- [ ] G4: live MIDI + timeline cùng lúc, latency <10ms
|
||||||
|
- [ ] G5: Track FX + Master FX tác động âm bridge
|
||||||
|
- [ ] G6: kill bridge → SonicSF fallback, không crash
|
||||||
|
- [ ] G7: test_standalone.ps1 5/5
|
||||||
|
- [ ] G8: đo latency blockTimestamp IPC ≤0.5ms, end-to-end <10ms
|
||||||
|
- [ ] G9: multi-instance 2 track đồng thời (A10 — 2 SF2/SFZ khác nhau, đổi live)
|
||||||
|
- [ ] G10: sustain/CC/pitchbend qua bridge (A12 — CC64 sustain + pitch wheel + CC7 volume)
|
||||||
|
|
||||||
|
## Ghi chú batch 2026-08-11 (A10-A13 + D5)
|
||||||
|
- Struct SHM đổi 10872 → 11160: MidiEventIPC +data2/data3(+reserved) = 12B; ControlEventIPC +channel = 1040B.
|
||||||
|
Sync 3 nơi: `SharedMemoryIPC.h`, `shm.rs`, `shm_selfcheck.cpp` — đã khớp (selfcheck pass Linux).
|
||||||
|
- Bug pre-existing tìm thấy: `shm.rs push_control` không ghi pathlen vào arg1 → bridge cũ đọc path rỗng.
|
||||||
|
Sửa: C++ `main.cpp` load path dùng strnlen(arg2); helper C `shm_write_control` chỉ set arg1=pathlen khi type==2.
|
||||||
|
- `transport_control` Rust giờ nhận `playhead: Option<u32>`; JS `NativeBridgeService.transport(kind, playhead)`.
|
||||||
|
- `loadInstrument(filePath, type, channel)`; `dispatchMidiEvent(cmd,...,data2,data3)` hỗ trợ CC/PROGRAM/PITCH_BEND.
|
||||||
|
- Khi D8 (track→channel map) chưa xong: JS loadInstrument mặc định channel=0 — multi-instance chưa kích hoạt từ UI.
|
||||||
+118
-1
@@ -1,7 +1,7 @@
|
|||||||
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
|
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
@@ -1178,3 +1178,120 @@ async def render_project(
|
|||||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
||||||
|
|
||||||
|
# ── E1/E2: NATIVE HOST BRIDGE (daw_vst_bridge) ──────────────────────────────
|
||||||
|
# IPC dir giống _pick_dir_via_tauri_bridge: %APPDATA%/SonicForgeDAW/ipc — Rust
|
||||||
|
# (src-tauri) tạo, engine chỉ đọc/ghi file request/response.
|
||||||
|
BRIDGE_IPC_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "SonicForgeDAW", "ipc")
|
||||||
|
|
||||||
|
def _bridge_ipc_dir() -> Optional[str]:
|
||||||
|
if os.path.isdir(BRIDGE_IPC_DIR):
|
||||||
|
return BRIDGE_IPC_DIR
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.get("/bridge/status")
|
||||||
|
async def bridge_status(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""E1: trạng thái native bridge. Rust ghi bridge_status (JSON) khi spawn/
|
||||||
|
health-check; nếu chưa có → probe tail bridge.log (dòng started/exists)."""
|
||||||
|
ipc = _bridge_ipc_dir()
|
||||||
|
if not ipc:
|
||||||
|
return {"connected": False, "reason": "no_ipc_dir"}
|
||||||
|
st = os.path.join(ipc, "bridge_status")
|
||||||
|
if os.path.exists(st):
|
||||||
|
try:
|
||||||
|
with open(st, "r", encoding="utf-8") as fh:
|
||||||
|
raw = fh.read() or "{}"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
data.setdefault("connected", True)
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log = os.path.normpath(os.path.join(ipc, "..", "logs", "bridge.log"))
|
||||||
|
if os.path.exists(log):
|
||||||
|
try:
|
||||||
|
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
tail = fh.read().splitlines()[-5:]
|
||||||
|
joined = "\n".join(tail).lower()
|
||||||
|
return {"connected": ("started" in joined or "exists=true" in joined), "log_tail": tail}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"connected": False, "reason": "no_status_no_log"}
|
||||||
|
|
||||||
|
class BridgeLoadRequest(BaseModel):
|
||||||
|
name: str # tên asset hiển thị (sf_xxx / Vital.vst3 / ...)
|
||||||
|
path: Optional[str] = None # path tuyệt đối nếu đã biết
|
||||||
|
instrumentType: str = "SF2" # 'VST3'|'VST2'|'SF2'|'SF3'|'SFZ'
|
||||||
|
channel: Optional[int] = None
|
||||||
|
|
||||||
|
def _resolve_bridge_asset(name: str, instrument_type: str, path: Optional[str]) -> Optional[str]:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
return path
|
||||||
|
base = os.path.basename((path or name).replace("\\", "/"))
|
||||||
|
base_noext = os.path.splitext(base)[0].lower()
|
||||||
|
# sf2/sf3/sfz: storage/soundfonts + system + user plugin_dirs
|
||||||
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for fname in os.listdir(base_dir):
|
||||||
|
cand = os.path.join(base_dir, fname)
|
||||||
|
if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
|
||||||
|
(fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
|
||||||
|
return cand
|
||||||
|
ext = (".vst3" if instrument_type.upper() == "VST3" else
|
||||||
|
".vst2" if instrument_type.upper() == "VST2" else
|
||||||
|
".sfz" if instrument_type.upper() == "SFZ" else None)
|
||||||
|
if ext:
|
||||||
|
try:
|
||||||
|
from app.core.vst_engine import _load_user_plugin_dirs
|
||||||
|
dirs = _load_user_plugin_dirs() or []
|
||||||
|
except Exception:
|
||||||
|
dirs = []
|
||||||
|
if not dirs:
|
||||||
|
dirs = [settings.VST_DIR, settings.SOUNDFONT_DIR]
|
||||||
|
for base_dir in dirs:
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for root, _, files in os.walk(base_dir):
|
||||||
|
for fn in files:
|
||||||
|
if fn.lower().endswith(ext) and \
|
||||||
|
(fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext):
|
||||||
|
return os.path.join(root, fn)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.post("/bridge/load")
|
||||||
|
async def bridge_load(req: BridgeLoadRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""E2: resolve path thật của asset rồi (a) ghi bridge_load.request cho Rust
|
||||||
|
watcher (nếu có) và (b) trả path để JS gọi invoke load_native_instrument
|
||||||
|
trực tiếp — pipeline hoạt động ngay, không chờ watcher."""
|
||||||
|
resolved = _resolve_bridge_asset(req.name, req.instrumentType, req.path)
|
||||||
|
if not resolved:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
|
||||||
|
wrote_request = False
|
||||||
|
ipc = _bridge_ipc_dir()
|
||||||
|
if ipc:
|
||||||
|
try:
|
||||||
|
req_file = os.path.join(ipc, "bridge_load.request")
|
||||||
|
if os.path.exists(req_file):
|
||||||
|
os.remove(req_file)
|
||||||
|
with open(req_file, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump({"path": resolved, "type": req.instrumentType, "channel": req.channel}, fh)
|
||||||
|
wrote_request = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"success": True, "path": resolved, "type": req.instrumentType, "ipc_request_written": wrote_request}
|
||||||
|
|
||||||
|
@router.get("/bridge/log")
|
||||||
|
async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict = Depends(get_current_user)):
|
||||||
|
"""E5: tail bridge.log cho UI debug. Log Rust ghi tại %APPDATA%/SonicForgeDAW/logs/bridge.log."""
|
||||||
|
log = os.path.normpath(os.path.join(BRIDGE_IPC_DIR, "..", "logs", "bridge.log"))
|
||||||
|
if not os.path.exists(log):
|
||||||
|
return {"lines": [], "path": log, "exists": False}
|
||||||
|
try:
|
||||||
|
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
all_lines = fh.read().splitlines()
|
||||||
|
return {"lines": all_lines[-lines:], "path": log, "exists": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"lines": [], "path": log, "exists": False, "error": str(e)}
|
||||||
|
|||||||
@@ -551,6 +551,36 @@ def resolve_preset_path(preset_id_or_path: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_plugin_path(name_or_path: str, ext: str = "") -> str:
|
||||||
|
"""E3: resolve path thật của plugin/soundfont cho Native Bridge (E2).
|
||||||
|
ext: '.vst3' | '.vst2' | '.sf2' | '.sf3' | '.sfz' — filter phần mở rộng
|
||||||
|
khi quét plugin_dirs. Trả '' nếu không tìm thấy. Chống path traversal:
|
||||||
|
chỉ tên file (không separator) hoặc path tồn tại được chấp nhận."""
|
||||||
|
if not name_or_path:
|
||||||
|
return ""
|
||||||
|
p = name_or_path
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
base = os.path.basename(p.replace("\\", "/"))
|
||||||
|
base_noext = os.path.splitext(base)[0].lower()
|
||||||
|
want_ext = ext.lower() if ext else None
|
||||||
|
dirs = []
|
||||||
|
try:
|
||||||
|
dirs = list(_load_user_plugin_dirs()) or []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dirs += [settings.VST_DIR, settings.SOUNDFONT_DIR]
|
||||||
|
for base_dir in dirs:
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for root, _, files in os.walk(base_dir):
|
||||||
|
for fn in files:
|
||||||
|
if want_ext and not fn.lower().endswith(want_ext):
|
||||||
|
continue
|
||||||
|
if fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext:
|
||||||
|
return os.path.join(root, fn)
|
||||||
|
return ""
|
||||||
|
|
||||||
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
||||||
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
|
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
|
||||||
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
|
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
|
||||||
|
|||||||
+221
-31
@@ -1432,6 +1432,32 @@ function getAudioContext() {
|
|||||||
if (window.SonicSF && window.SonicSF.init) {
|
if (window.SonicSF && window.SonicSF.init) {
|
||||||
window.SonicSF.init(audioCtx);
|
window.SonicSF.init(audioCtx);
|
||||||
}
|
}
|
||||||
|
// C6: bootstrap native bridge 1 lần — query status, nối audio sink + router.
|
||||||
|
if (window.NativeBridgeService && window.SonicMidiRouter && !window.__bridgeBootstrapped) {
|
||||||
|
window.__bridgeBootstrapped = true;
|
||||||
|
window.SonicMidiRouter.onFallback = function (cmd, ch, pitch, vel) {
|
||||||
|
if (!window.SonicSF) return;
|
||||||
|
if (cmd === 'PANIC') { if (window.SonicSF.stopAll) window.SonicSF.stopAll(); return; }
|
||||||
|
if (cmd === 'NOTE_ON') window.SonicSF.playNote(pitch, vel, undefined, undefined, undefined, undefined, ch);
|
||||||
|
else window.SonicSF.stopNote(ch, pitch);
|
||||||
|
};
|
||||||
|
(async function () {
|
||||||
|
try {
|
||||||
|
var st = await window.NativeBridgeService.queryStatus();
|
||||||
|
var connected = !!(st && st.connected);
|
||||||
|
window.SonicMidiRouter.setBridgeConnected(connected);
|
||||||
|
console.log('[Bridge] bootstrap connected=', connected);
|
||||||
|
if (connected) {
|
||||||
|
window.BridgeAudioNode.init(audioCtx);
|
||||||
|
window.NativeBridgeService.onAudio(function (l, r) { window.BridgeAudioNode.onAudio(l, r); });
|
||||||
|
if (window.AudioRoutingEngine) window.AudioRoutingEngine.connect(window.BridgeAudioNode, null, null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Bridge] bootstrap error:', e);
|
||||||
|
window.SonicMidiRouter.setBridgeConnected(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
return audioCtx;
|
return audioCtx;
|
||||||
}
|
}
|
||||||
const formatTime = secs => {
|
const formatTime = secs => {
|
||||||
@@ -5525,6 +5551,13 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
|||||||
const [pmCarlaVersion, setPmCarlaVersion] = React.useState(0);
|
const [pmCarlaVersion, setPmCarlaVersion] = React.useState(0);
|
||||||
// Khai báo trực tiếp thư mục chứa carla.exe (nhập tay, không cần picker)
|
// Khai báo trực tiếp thư mục chứa carla.exe (nhập tay, không cần picker)
|
||||||
const [pmCarlaPathInput, setPmCarlaPathInput] = React.useState('');
|
const [pmCarlaPathInput, setPmCarlaPathInput] = React.useState('');
|
||||||
|
// D7: trạng thái Native Bridge (query khi mở modal) — badge header.
|
||||||
|
const [bridgeStatus, setBridgeStatus] = React.useState(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
window.SonicAPI.bridgeStatus().then(s => setBridgeStatus(s)).catch(() => setBridgeStatus(null));
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
window.SonicAPI.listPlugins()
|
window.SonicAPI.listPlugins()
|
||||||
@@ -5715,6 +5748,21 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
|||||||
setSfUploadStatus('Error: ' + err.message);
|
setSfUploadStatus('Error: ' + err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// D7: Load asset vào Native Bridge — backend resolve path thật (E2) → JS
|
||||||
|
// invoke load_native_instrument (C2) → bridge C++ load vào channel 0.
|
||||||
|
const loadToBridge = async (name, path, type) => {
|
||||||
|
try {
|
||||||
|
const r = await window.SonicAPI.bridgeLoad({ name: name, path: path || null, instrumentType: type, channel: 0 });
|
||||||
|
if (!r || !r.path) { window.showToast && window.showToast('Không resolve được asset: ' + name, 'error'); return false; }
|
||||||
|
const ok = await window.NativeBridgeService.loadInstrument(r.path, type, 0);
|
||||||
|
window.showToast && window.showToast(ok ? ('Đã load vào Native Bridge: ' + r.path) : 'Bridge không khả dụng (dev mode?)', ok ? 'success' : 'warning');
|
||||||
|
if (ok) window.SonicAPI.bridgeStatus().then(s => setBridgeStatus(s)).catch(() => {});
|
||||||
|
return ok;
|
||||||
|
} catch (err) {
|
||||||
|
window.showToast && window.showToast('Lỗi load bridge: ' + (err.message || err), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
const [pmTab, setPmTab] = React.useState('soundfont');
|
const [pmTab, setPmTab] = React.useState('soundfont');
|
||||||
return React.createElement('div', {
|
return React.createElement('div', {
|
||||||
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
|
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
|
||||||
@@ -5799,11 +5847,16 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
|||||||
React.createElement('h3', {
|
React.createElement('h3', {
|
||||||
className: 'text-base font-bold text-cyan-400 flex items-center gap-2'
|
className: 'text-base font-bold text-cyan-400 flex items-center gap-2'
|
||||||
}, React.createElement('i', { 'data-lucide': 'zap', className: 'w-4 h-4' }), 'Plugin Manager (SoundFont / VSTi)'),
|
}, React.createElement('i', { 'data-lucide': 'zap', className: 'w-4 h-4' }), 'Plugin Manager (SoundFont / VSTi)'),
|
||||||
|
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||||
|
React.createElement('span', {
|
||||||
|
className: `text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border ${bridgeStatus && bridgeStatus.connected ? 'text-emerald-400 border-emerald-700 bg-emerald-900/30' : 'text-zinc-500 border-zinc-700 bg-zinc-800'}`,
|
||||||
|
title: 'Native Bridge (C++ engine) — ' + (bridgeStatus ? JSON.stringify(bridgeStatus).slice(0, 120) : 'chưa query')
|
||||||
|
}, bridgeStatus && bridgeStatus.connected ? '● Bridge ON' : '● Bridge OFF'),
|
||||||
React.createElement('button', {
|
React.createElement('button', {
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
className: 'text-zinc-500 hover:text-zinc-200 transition'
|
className: 'text-zinc-500 hover:text-zinc-200 transition'
|
||||||
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
|
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
|
||||||
),
|
)),
|
||||||
// Left-right body
|
// Left-right body
|
||||||
React.createElement('div', {
|
React.createElement('div', {
|
||||||
className: 'flex flex-1 overflow-hidden',
|
className: 'flex flex-1 overflow-hidden',
|
||||||
@@ -5853,6 +5906,11 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
|||||||
className: 'text-[10px] bg-teal-800 hover:bg-teal-700 text-white px-2 py-1 rounded transition shrink-0',
|
className: 'text-[10px] bg-teal-800 hover:bg-teal-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||||
title: 'Mở trong Carla (native GUI)'
|
title: 'Mở trong Carla (native GUI)'
|
||||||
}, '🎛 Carla'),
|
}, '🎛 Carla'),
|
||||||
|
React.createElement('button', {
|
||||||
|
onClick: (e) => { e.stopPropagation(); loadToBridge(v.name || v.id, v.path, v.type || 'VST3'); },
|
||||||
|
className: 'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||||
|
title: 'Load vào Native Bridge (C++ engine)'
|
||||||
|
}, 'Load Bridge'),
|
||||||
React.createElement('span', { className: 'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30' }, v.type || 'VST3')
|
React.createElement('span', { className: 'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30' }, v.type || 'VST3')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -5883,6 +5941,11 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||||
|
React.createElement('button', {
|
||||||
|
onClick: (e) => { e.stopPropagation(); loadToBridge(sf.id, sf.file || sf.path, 'SF2'); },
|
||||||
|
className: 'text-[10px] bg-cyan-800 hover:bg-cyan-700 text-white px-2 py-1 rounded transition shrink-0',
|
||||||
|
title: 'Load vào Native Bridge (C++ engine)'
|
||||||
|
}, 'Load Bridge'),
|
||||||
React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
|
React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
|
||||||
React.createElement('button', {
|
React.createElement('button', {
|
||||||
onClick: (e) => { e.stopPropagation(); setSfToDelete(sf); },
|
onClick: (e) => { e.stopPropagation(); setSfToDelete(sf); },
|
||||||
@@ -8192,7 +8255,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
playNativeSfNote(pvTrk, n.pitch, n.velocity || 0.8, 200, undefined, 'pv_' + st.trackId);
|
playNativeSfNote(pvTrk, n.pitch, n.velocity || 0.8, 200, undefined, 'pv_' + st.trackId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine);
|
scheduleMidiNoteDispatch(pvTrk, n.pitch, n.velocity || 0.8, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8561,7 +8624,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
if (isStandaloneSf() && isSfTrackEngine(clCtx.synthEngine) && !shouldRouteCarla(clCtx.synthEngine)) {
|
if (isStandaloneSf() && isSfTrackEngine(clCtx.synthEngine) && !shouldRouteCarla(clCtx.synthEngine)) {
|
||||||
playNativeSfNote(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, undefined, 'pv_' + st.trackId);
|
playNativeSfNote(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, undefined, 'pv_' + st.trackId);
|
||||||
} else {
|
} else {
|
||||||
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine);
|
scheduleMidiNoteDispatch(clTrk, notes[clickedNoteIdx].pitch, notes[clickedNoteIdx].velocity || 0.8, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8829,7 +8892,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ
|
// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ
|
||||||
// là oscillator beep (sai âm với percussion/soundfont — user: note vẽ
|
// là oscillator beep (sai âm với percussion/soundfont — user: note vẽ
|
||||||
// mới nghe nhạc cụ track trước).
|
// mới nghe nhạc cụ track trước).
|
||||||
window.SonicSF.playNote(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
scheduleMidiNoteDispatch(dwTrk, dwPitch, brushVelocityRef.current || 0.8, dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -8927,10 +8990,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
playNativeSfNote(pvTrk, p, brushVelocityRef.current || 0.8, durMs, undefined, 'pvdraw_' + st.trackId);
|
playNativeSfNote(pvTrk, p, brushVelocityRef.current || 0.8, durMs, undefined, 'pvdraw_' + st.trackId);
|
||||||
} else if (window.SonicSF && window.SonicSF.playNote) {
|
} else if (window.SonicSF && window.SonicSF.playNote) {
|
||||||
var pvCtx = getAudioContext();
|
var pvCtx = getAudioContext();
|
||||||
var pvVel = Math.round(brushVelocityRef.current * 127);
|
|
||||||
// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator
|
// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator
|
||||||
// beep sai âm (percussion/soundfont).
|
// beep sai âm (percussion/soundfont).
|
||||||
window.SonicSF.playNote(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
scheduleMidiNoteDispatch(pvTrk, p, brushVelocityRef.current || 0.8, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
||||||
previewPitchRef.current = p;
|
previewPitchRef.current = p;
|
||||||
}
|
}
|
||||||
// MIDI → Carla (track VSTi + ARM + Carla local): preview realtime
|
// MIDI → Carla (track VSTi + ARM + Carla local): preview realtime
|
||||||
@@ -9320,7 +9382,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
if (kbNative) {
|
if (kbNative) {
|
||||||
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
||||||
}
|
}
|
||||||
if (window.SonicSF && !kbNative) {
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127 });
|
||||||
|
} else if (window.SonicSF && !kbNative) {
|
||||||
// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s)
|
// ⚠️ FIX: giữ note theo thời gian bấm phím — durationMs lớn (5s)
|
||||||
// chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng
|
// chỉ là auto-off phòng hờ; mouseup/mouseleave gọi stopNote dừng
|
||||||
// NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím).
|
// NGAY (trước đây 500ms → note tự tắt giữa chừng khi giữ phím).
|
||||||
@@ -9345,7 +9409,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
if (isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine)) {
|
if (isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine)) {
|
||||||
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
playNativeSfNote(kbTrk, pitch, 100 / 127, 5000, undefined, 'kb_' + st.trackId + '_' + pitch);
|
||||||
}
|
}
|
||||||
if (window.SonicSF && !(isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine))) {
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 100 / 127 });
|
||||||
|
} else if (window.SonicSF && !(isStandaloneSf() && isSfTrackEngine(kbCtx.synthEngine) && !shouldRouteCarla(kbCtx.synthEngine))) {
|
||||||
// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave
|
// giữ note khi kéo qua phím (mouse enter) — dừng bằng mouseup/leave
|
||||||
window.SonicSF.playNote(pitch, 100, 5000, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
|
window.SonicSF.playNote(pitch, 100, 5000, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
|
||||||
}
|
}
|
||||||
@@ -9362,7 +9428,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
// Dừng note khi thả phím — tránh kẹt âm (loop liên tục) với soundfont
|
// Dừng note khi thả phím — tránh kẹt âm (loop liên tục) với soundfont
|
||||||
try {
|
try {
|
||||||
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
||||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 0 });
|
||||||
|
} else if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (window.__carlaKeybedTimer) { clearTimeout(window.__carlaKeybedTimer); window.__carlaKeybedTimer = null; }
|
if (window.__carlaKeybedTimer) { clearTimeout(window.__carlaKeybedTimer); window.__carlaKeybedTimer = null; }
|
||||||
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
||||||
@@ -9372,7 +9440,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
// Kéo chuột ra khỏi phím → dừng note của phím đó
|
// Kéo chuột ra khỏi phím → dừng note của phím đó
|
||||||
try {
|
try {
|
||||||
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
if (isStandaloneSf()) stopNativeSfNote('kb_' + st.trackId + '_' + pitch);
|
||||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: kbTrk ? kbTrk.id : 0, pitch: pitch, velocity: 0 });
|
||||||
|
} else if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(kbCtx.ch, pitch);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
if (window.SonicCarlaMidi) { try { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); } catch (e) {} }
|
||||||
}
|
}
|
||||||
@@ -14063,7 +14133,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
|||||||
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||||
selectTokenRef.current++;
|
selectTokenRef.current++;
|
||||||
const token = selectTokenRef.current;
|
const token = selectTokenRef.current;
|
||||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||||
stopAllNativeSfNotes();
|
stopAllNativeSfNotes();
|
||||||
playMidiPreview(cur, token);
|
playMidiPreview(cur, token);
|
||||||
}
|
}
|
||||||
@@ -14539,7 +14609,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
|||||||
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||||
selectTokenRef.current++;
|
selectTokenRef.current++;
|
||||||
const token = selectTokenRef.current;
|
const token = selectTokenRef.current;
|
||||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||||
stopAllNativeSfNotes();
|
stopAllNativeSfNotes();
|
||||||
playMidiPreview(cur, token);
|
playMidiPreview(cur, token);
|
||||||
}
|
}
|
||||||
@@ -14710,7 +14780,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
|||||||
if (cur && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
if (cur && (cur.handle || cur.path || cur.file_id || cur.fileId || (cur.kind === 'midi' && cur.name))) {
|
||||||
selectTokenRef.current++;
|
selectTokenRef.current++;
|
||||||
const token = selectTokenRef.current;
|
const token = selectTokenRef.current;
|
||||||
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
|
||||||
stopAllNativeSfNotes();
|
stopAllNativeSfNotes();
|
||||||
playMidiPreview(cur, token);
|
playMidiPreview(cur, token);
|
||||||
}
|
}
|
||||||
@@ -15368,7 +15438,7 @@ const App = () => {
|
|||||||
const _nowVst = _hasInst && !isSfInstrument;
|
const _nowVst = _hasInst && !isSfInstrument;
|
||||||
if (_wasVst && !_nowVst && window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) {
|
if (_wasVst && !_nowVst && window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) {
|
||||||
window.SonicCarlaMidi.stopBridge();
|
window.SonicCarlaMidi.stopBridge();
|
||||||
try { if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
|
try { if (window.SonicMidiRouter) window.SonicMidiRouter.panic(); else if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
|
||||||
stopAllNativeSfNotes();
|
stopAllNativeSfNotes();
|
||||||
console.log('[Instrument] Carla bridge unloaded — track', trackId, 'switched from VSTi to non-VST');
|
console.log('[Instrument] Carla bridge unloaded — track', trackId, 'switched from VSTi to non-VST');
|
||||||
}
|
}
|
||||||
@@ -15608,7 +15678,15 @@ const App = () => {
|
|||||||
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {}
|
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {}
|
||||||
// Route MIDI input to ALL armed tracks on their dedicated channels
|
// Route MIDI input to ALL armed tracks on their dedicated channels
|
||||||
// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F)
|
// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F)
|
||||||
if (window.SonicSF) {
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
// D1: bridge active -> 1 cổng router (per-track channel alloc) →
|
||||||
|
// Rust SHM → C++ bridge; giữ SonicSF/Carla fallback ở nhánh else.
|
||||||
|
var bTracks = activeTracksRef.current || [];
|
||||||
|
bTracks.forEach(function (bt) {
|
||||||
|
if (!bt.isArmed) return;
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: bt.id, pitch: pitch, velocity: scaledVel / 127 }); } catch (e) {}
|
||||||
|
});
|
||||||
|
} else if (window.SonicSF) {
|
||||||
var allTracks = activeTracksRef.current || [];
|
var allTracks = activeTracksRef.current || [];
|
||||||
var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : [];
|
var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : [];
|
||||||
var armedTracks = allTracks.filter(function(t) { return t.isArmed; });
|
var armedTracks = allTracks.filter(function(t) { return t.isArmed; });
|
||||||
@@ -15677,7 +15755,13 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
// Stop the note on ALL tracks (not just armed) to prevent stuck notes
|
// Stop the note on ALL tracks (not just armed) to prevent stuck notes
|
||||||
// when ARM is toggled off while a key is held
|
// when ARM is toggled off while a key is held
|
||||||
if (window.SonicSF && window.SonicSF.stopNote) {
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
// D1: bridge note-off qua router (channel trùng NOTE_ON — track.id).
|
||||||
|
var bStopTracks = activeTracksRef.current || [];
|
||||||
|
bStopTracks.forEach(function (bst) {
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: bst.id, pitch: pitch, velocity: 0 }); } catch (e) {}
|
||||||
|
});
|
||||||
|
} else if (window.SonicSF && window.SonicSF.stopNote) {
|
||||||
var stopTracks = activeTracksRef.current || [];
|
var stopTracks = activeTracksRef.current || [];
|
||||||
stopTracks.forEach(function(st) {
|
stopTracks.forEach(function(st) {
|
||||||
// Only stop channels that actually carry this track's notes —
|
// Only stop channels that actually carry this track's notes —
|
||||||
@@ -16894,6 +16978,16 @@ const App = () => {
|
|||||||
|
|
||||||
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
|
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
|
||||||
const [pluginsData, setPluginsData] = useState(null);
|
const [pluginsData, setPluginsData] = useState(null);
|
||||||
|
// D10: bridge indicator + "Ép dùng WASM" debug toggle.
|
||||||
|
const [bridgeUi, setBridgeUi] = useState({ connected: false, forceWasm: false });
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!window.NativeBridgeService) return;
|
||||||
|
setBridgeUi(s => ({ ...s, connected: !!window.NativeBridgeService.isBridgeConnected }));
|
||||||
|
window.NativeBridgeService.onStatusChange(function (st) {
|
||||||
|
setBridgeUi(s => ({ ...s, connected: !!st.connected }));
|
||||||
|
});
|
||||||
|
return () => window.NativeBridgeService.onStatusChange(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadAudioBuffersForTracks = async (tracksList) => {
|
const loadAudioBuffersForTracks = async (tracksList) => {
|
||||||
let hasLoadedAny = false;
|
let hasLoadedAny = false;
|
||||||
@@ -17641,6 +17735,9 @@ const App = () => {
|
|||||||
const isDraggingSubTabRef = useRef(false);
|
const isDraggingSubTabRef = useRef(false);
|
||||||
const handlePlayPauseRef = useRef(null);
|
const handlePlayPauseRef = useRef(null);
|
||||||
const currentTimeRef = useRef(currentTime);
|
const currentTimeRef = useRef(currentTime);
|
||||||
|
// D9: playhead sample counter cho native bridge (A11/A13 dùng để đồng bộ
|
||||||
|
// timeline sample-accurate); cập nhật trong updatePlayhead khi bridge active.
|
||||||
|
const bridgePlayheadSampleRef = useRef(0);
|
||||||
// Resume main/session play khi rời PIANO ROLL tab: mở piano roll lúc main
|
// Resume main/session play khi rời PIANO ROLL tab: mở piano roll lúc main
|
||||||
// đang play → bấm Space (play piano roll) → stopAllPlayback dừng main →
|
// đang play → bấm Space (play piano roll) → stopAllPlayback dừng main →
|
||||||
// quay lại MAIN/SECTION → CÂM. Lưu {offset, audioTime} lúc mở tab → khi
|
// quay lại MAIN/SECTION → CÂM. Lưu {offset, audioTime} lúc mở tab → khi
|
||||||
@@ -20624,6 +20721,25 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updatePlayhead = () => {
|
const updatePlayhead = () => {
|
||||||
|
// D9: playhead sample counter cho bridge — C++ A11/A13 dùng để đồng bộ
|
||||||
|
// timeline sample-accurate (scheduling theo sampleOffset).
|
||||||
|
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected && (isPlaying || isPlayingRef.current)) {
|
||||||
|
try {
|
||||||
|
const _pctx = getAudioContext();
|
||||||
|
const _elapsed = _pctx.currentTime - (startAudioTimeRef.current || _pctx.currentTime);
|
||||||
|
bridgePlayheadSampleRef.current = Math.max(0, Math.floor((startOffsetTimeRef.current + _elapsed) * (_pctx.sampleRate || 44100)));
|
||||||
|
// set_position ~mỗi giây: C++ A13 cập nhật timeline anchor, tối ưu cho
|
||||||
|
// seek/loop (Rust pump + C++ không tự biết vị trí; JS là nguồn duy nhất).
|
||||||
|
try {
|
||||||
|
const _sr = _pctx.sampleRate || 44100;
|
||||||
|
if (!window.__bridgeLastPosSent) window.__bridgeLastPosSent = 0;
|
||||||
|
if (bridgePlayheadSampleRef.current - window.__bridgeLastPosSent > _sr) {
|
||||||
|
window.__bridgeLastPosSent = bridgePlayheadSampleRef.current;
|
||||||
|
window.NativeBridgeService.transport('set_position', bridgePlayheadSampleRef.current);
|
||||||
|
}
|
||||||
|
} catch (e2) {}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
// Realtime re-schedule: khi đang play (loop play) mà items (vị trí/speed/
|
// Realtime re-schedule: khi đang play (loop play) mà items (vị trí/speed/
|
||||||
// duration — kể cả nội dung section tab) thay đổi → dừng + schedule lại từ
|
// duration — kể cả nội dung section tab) thay đổi → dừng + schedule lại từ
|
||||||
// playhead hiện tại để item mới phát đúng vị trí mới (không phát nội dung
|
// playhead hiện tại để item mới phát đúng vị trí mới (không phát nội dung
|
||||||
@@ -21287,6 +21403,12 @@ const App = () => {
|
|||||||
const _prSubKeys = Object.keys(activeTrackNodesRef.current).filter(k => k.endsWith('_sub_' + _activeSub.trackId));
|
const _prSubKeys = Object.keys(activeTrackNodesRef.current).filter(k => k.endsWith('_sub_' + _activeSub.trackId));
|
||||||
_prNode = _prSubKeys.length ? activeTrackNodesRef.current[_prSubKeys[0]] : null;
|
_prNode = _prSubKeys.length ? activeTrackNodesRef.current[_prSubKeys[0]] : null;
|
||||||
}
|
}
|
||||||
|
// D5: bridge active → nối BridgeAudioNode vào node track (sfEntry →
|
||||||
|
// FX chain → mastering) thay vì SonicSF.setOutputDestination.
|
||||||
|
if (_prNode && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) {
|
||||||
|
window.AudioRoutingEngine.connect(window.BridgeAudioNode, _prNode, _activeSub.trackId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
if (_prNode.sfEntry) {
|
if (_prNode.sfEntry) {
|
||||||
window.SonicSF.setOutputDestination(_prNode.sfEntry);
|
window.SonicSF.setOutputDestination(_prNode.sfEntry);
|
||||||
@@ -21333,6 +21455,11 @@ const App = () => {
|
|||||||
// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when
|
// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when
|
||||||
// PWR ON). The ♪ button only switches the post-FX route (sfRouteGain /
|
// PWR ON). The ♪ button only switches the post-FX route (sfRouteGain /
|
||||||
// sfDryGain) to skip or include the Mastering FX Chain.
|
// sfDryGain) to skip or include the Mastering FX Chain.
|
||||||
|
// D5: bridge active → nối vào node track (sfEntry → FX chain).
|
||||||
|
if (node && window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.BridgeAudioNode) {
|
||||||
|
window.AudioRoutingEngine.connect(window.BridgeAudioNode, node, t.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
|
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
console.log('[SFRoute] dest=sfEntry node', t.id, 'sfRouteGain=' + (node.sfRouteGain ? node.sfRouteGain.gain.value : 'MISSING'), 'sfDryGain=' + (node.sfDryGain ? node.sfDryGain.gain.value : 'MISSING'));
|
console.log('[SFRoute] dest=sfEntry node', t.id, 'sfRouteGain=' + (node.sfRouteGain ? node.sfRouteGain.gain.value : 'MISSING'), 'sfDryGain=' + (node.sfDryGain ? node.sfDryGain.gain.value : 'MISSING'));
|
||||||
window.SonicSF.setOutputDestination(node.sfEntry);
|
window.SonicSF.setOutputDestination(node.sfEntry);
|
||||||
@@ -21362,7 +21489,11 @@ const App = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (window.SonicSF && window.SonicSF.setOutputDestination) {
|
// D5: bridge active + không track nào audible → ngắt khỏi graph (về
|
||||||
|
// masterBus qua updateSfRouting lần sau khi có track).
|
||||||
|
if (window.AudioRoutingEngine && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
window.AudioRoutingEngine.disconnect();
|
||||||
|
} else if (window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
window.SonicSF.setOutputDestination(null);
|
window.SonicSF.setOutputDestination(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -21578,8 +21709,49 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// D2: timeline MIDI note -> native bridge (khi bridge active) thay vì
|
||||||
|
// SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ);
|
||||||
|
// bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay
|
||||||
|
// setTimeout bằng sampleOffset đẩy thẳng vào SHM.
|
||||||
|
const scheduleMidiNoteDispatch = (track, pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine) => {
|
||||||
|
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const startAt = startTime || ctx.currentTime; // undefined/null = phát ngay
|
||||||
|
const delayMs = Math.max(0, (startAt - ctx.currentTime) * 1000);
|
||||||
|
// Guard chống note-on trễ sau Stop: CHỈ khi là timeline (startTime thật).
|
||||||
|
const guardPlay = startTime != null ? function () { return isPlayingRef.current; } : function () { return true; };
|
||||||
|
const trkId = track ? track.id : 0;
|
||||||
|
// D8: track đổi instrument → gửi CC0/CC32 (bank 0) + program change qua
|
||||||
|
// bridge (A12) một lần mỗi program mới mỗi channel.
|
||||||
|
if (program !== undefined && program !== null) {
|
||||||
|
const lastP = window.__bridgeLastProgram || (window.__bridgeLastProgram = {});
|
||||||
|
if (lastP[trkId] !== program) {
|
||||||
|
lastP[trkId] = program;
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: trkId, pitch: 0, velocity: 0, data2: 0 }); } catch (e) {}
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: trkId, pitch: 32, velocity: 0, data2: 0 }); } catch (e) {}
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'PROGRAM', channel: trkId, pitch: 0, velocity: 0, data2: program }); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!guardPlay()) return;
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: trkId, pitch: pitch || 60, velocity: velocity || 0.8 }); } catch (e) {}
|
||||||
|
}, delayMs);
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!guardPlay()) return;
|
||||||
|
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: trkId, pitch: pitch || 60, velocity: 0 }); } catch (e) {}
|
||||||
|
}, delayMs + (durMs || 1000) + 30);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.SonicSF.playNote(pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine);
|
||||||
|
};
|
||||||
|
|
||||||
const startTrackPlayback = offsetTime => {
|
const startTrackPlayback = offsetTime => {
|
||||||
const context = getAudioContext();
|
const context = getAudioContext();
|
||||||
|
// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
|
||||||
|
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
|
||||||
|
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
|
||||||
|
try { window.NativeBridgeService.transport('play'); } catch (e) {}
|
||||||
|
}
|
||||||
// ⚠️ FIX: đồng bộ mastering + Carla status NGAY khi play — MIDI item phải
|
// ⚠️ FIX: đồng bộ mastering + Carla status NGAY khi play — MIDI item phải
|
||||||
// qua mastering FX (khi bật) và qua Carla bridge (khi VSTi loaded).
|
// qua mastering FX (khi bật) và qua Carla bridge (khi VSTi loaded).
|
||||||
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (e) {}
|
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (e) {}
|
||||||
@@ -21675,8 +21847,8 @@ const App = () => {
|
|||||||
const delay = noteStartSec - offsetTime;
|
const delay = noteStartSec - offsetTime;
|
||||||
const startTime = context.currentTime + delay;
|
const startTime = context.currentTime + delay;
|
||||||
if (!routeToCarla && window.SonicSF) {
|
if (!routeToCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
track, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
durationMs,
|
durationMs,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -21703,8 +21875,8 @@ const App = () => {
|
|||||||
const playOffset = offsetTime - noteStartSec;
|
const playOffset = offsetTime - noteStartSec;
|
||||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||||
if (!routeToCarla && window.SonicSF) {
|
if (!routeToCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
track, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
remainingDurMs,
|
remainingDurMs,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
@@ -21832,8 +22004,8 @@ const App = () => {
|
|||||||
const startTime = context.currentTime + delay;
|
const startTime = context.currentTime + delay;
|
||||||
const playDurMs = (notePlayEndMain - noteStartMain) * 1000;
|
const playDurMs = (notePlayEndMain - noteStartMain) * 1000;
|
||||||
if (!subRouteCarla && window.SonicSF) {
|
if (!subRouteCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
subTrack, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
playDurMs,
|
playDurMs,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -21862,8 +22034,8 @@ const App = () => {
|
|||||||
} else {
|
} else {
|
||||||
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
||||||
if (!subRouteCarla && window.SonicSF) {
|
if (!subRouteCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
subTrack, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
remainingDurMs,
|
remainingDurMs,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
@@ -21961,8 +22133,8 @@ const App = () => {
|
|||||||
const delay = noteStartSec - offsetTime;
|
const delay = noteStartSec - offsetTime;
|
||||||
const startTime = context.currentTime + delay;
|
const startTime = context.currentTime + delay;
|
||||||
if (!routeToCarla && window.SonicSF) {
|
if (!routeToCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
track, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
durationMs,
|
durationMs,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -21977,8 +22149,8 @@ const App = () => {
|
|||||||
} else {
|
} else {
|
||||||
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
|
||||||
if (!routeToCarla && window.SonicSF) {
|
if (!routeToCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(
|
scheduleMidiNoteDispatch(
|
||||||
note.pitch || 60,
|
track, note.pitch || 60,
|
||||||
note.velocity || 0.8,
|
note.velocity || 0.8,
|
||||||
remainingDurMs,
|
remainingDurMs,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
@@ -22043,7 +22215,7 @@ const App = () => {
|
|||||||
// Carla — không play FluidSynth GM sai âm chồng lên)
|
// Carla — không play FluidSynth GM sai âm chồng lên)
|
||||||
const routeToCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(synthEngine));
|
const routeToCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(synthEngine));
|
||||||
if (!routeToCarla && window.SonicSF) {
|
if (!routeToCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
|
scheduleMidiNoteDispatch(track, note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
|
||||||
}
|
}
|
||||||
// MIDI items → Carla (track VSTi + Carla local): phát VSTi realtime
|
// MIDI items → Carla (track VSTi + Carla local): phát VSTi realtime
|
||||||
// (schedule theo audio clock bằng setTimeout — preview, timing gần đúng).
|
// (schedule theo audio clock bằng setTimeout — preview, timing gần đúng).
|
||||||
@@ -22079,7 +22251,7 @@ const App = () => {
|
|||||||
// Ghost note → Carla bridge khi ghost track VSTi (chỉ route Carla)
|
// Ghost note → Carla bridge khi ghost track VSTi (chỉ route Carla)
|
||||||
var gRouteCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(ghostSynth));
|
var gRouteCarla = !!(window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoutePlayback(ghostSynth));
|
||||||
if (!gRouteCarla && window.SonicSF) {
|
if (!gRouteCarla && window.SonicSF) {
|
||||||
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
|
scheduleMidiNoteDispatch(ghostTrack, note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
|
||||||
}
|
}
|
||||||
if (gRouteCarla) scheduleCarlaNote(ghostSynth, ghostCh, note.pitch || 60, note.velocity || 0.8, schedTime, durMs);
|
if (gRouteCarla) scheduleCarlaNote(ghostSynth, ghostCh, note.pitch || 60, note.velocity || 0.8, schedTime, durMs);
|
||||||
}
|
}
|
||||||
@@ -22210,6 +22382,11 @@ const App = () => {
|
|||||||
try { heldMidiNotesRef.current = {}; } catch (e) { }
|
try { heldMidiNotesRef.current = {}; } catch (e) { }
|
||||||
stopMidiCapture();
|
stopMidiCapture();
|
||||||
stopAllNativeSfNotes();
|
stopAllNativeSfNotes();
|
||||||
|
// D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết âm
|
||||||
|
// ngân tức thì; A13 C++ flush note-off).
|
||||||
|
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
|
||||||
|
try { window.NativeBridgeService.transport('stop'); } catch (e) {}
|
||||||
|
}
|
||||||
if (window.SonicSF) {
|
if (window.SonicSF) {
|
||||||
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
|
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
|
||||||
// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
|
// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
|
||||||
@@ -28515,6 +28692,19 @@ STRICT CONSTRAINTS:
|
|||||||
className: "font-mono text-[20px] text-zinc-400 tabular-nums",
|
className: "font-mono text-[20px] text-zinc-400 tabular-nums",
|
||||||
title: "Tổng thời gian dự án"
|
title: "Tổng thời gian dự án"
|
||||||
}, formatTime(projectEnd)), /*#__PURE__*/React.createElement("div", {
|
}, formatTime(projectEnd)), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "flex items-center gap-1.5 ml-3",
|
||||||
|
title: bridgeUi.connected && !bridgeUi.forceWasm ? "Engine phát qua Native Bridge (C++ FluidSynth/sfizz/VST3)" : "Engine phát qua SonicSF WASM"
|
||||||
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
|
className: "text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border " + (bridgeUi.connected && !bridgeUi.forceWasm ? "text-emerald-400 border-emerald-700 bg-emerald-900/30" : "text-zinc-500 border-zinc-700 bg-zinc-800")
|
||||||
|
}, bridgeUi.connected && !bridgeUi.forceWasm ? "Bridge" : "WASM"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: () => {
|
||||||
|
const v = !bridgeUi.forceWasm;
|
||||||
|
setBridgeUi(s => ({ ...s, forceWasm: v }));
|
||||||
|
if (window.SonicMidiRouter && window.SonicMidiRouter.setForceWasm) window.SonicMidiRouter.setForceWasm(v);
|
||||||
|
},
|
||||||
|
className: `px-1.5 py-0.5 rounded text-[9px] font-bold border transition ${bridgeUi.forceWasm ? "bg-amber-600 text-black border-amber-500" : "bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200"}`,
|
||||||
|
title: "Debug: ép dùng SonicSF WASM bỏ qua bridge"
|
||||||
|
}, "Ép WASM")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex-1"
|
className: "flex-1"
|
||||||
})),(() => {
|
})),(() => {
|
||||||
const dockPanels = {
|
const dockPanels = {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -113,6 +113,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||||
|
// Native Host Bridge (daw_vst_bridge C++): trạng thái + load asset +
|
||||||
|
// tail bridge.log (E1/E2/E5).
|
||||||
|
bridgeStatus: () => apiRequest('/api/v1/bridge/status', { method: 'GET' }),
|
||||||
|
bridgeLoad: (payload) => apiRequest('/api/v1/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
bridgeLog: (lines = 100) => apiRequest(`/api/v1/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||||
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// app/static/js/services/audioRoutingEngine.js
|
||||||
|
// Routes the native bridge audio node into the DAW graph:
|
||||||
|
// bridge node -> track.sfEntry (FX rack chain) -> fader/pan -> masterBus
|
||||||
|
// Falls back to masterBus.input when no per-track sfEntry exists.
|
||||||
|
(function () {
|
||||||
|
var engine = {
|
||||||
|
_connected: false,
|
||||||
|
_trackId: null,
|
||||||
|
|
||||||
|
isConnected: function () { return this._connected; },
|
||||||
|
|
||||||
|
/** bridgeNode = window.BridgeAudioNode; trackCtx = track node ({ sfEntry, gainNode }). */
|
||||||
|
connect: function (bridgeNode, trackCtx, trackId) {
|
||||||
|
if (!bridgeNode || !bridgeNode.getOutputNode) return false;
|
||||||
|
this.disconnect();
|
||||||
|
var dest = null;
|
||||||
|
if (trackCtx && trackCtx.sfEntry) dest = trackCtx.sfEntry;
|
||||||
|
else if (trackCtx && trackCtx.gainNode) dest = trackCtx.gainNode;
|
||||||
|
else if (window.masterBus && window.masterBus.input) dest = window.masterBus.input;
|
||||||
|
if (!dest) {
|
||||||
|
console.warn('[AudioRoutingEngine] no destination (no trackCtx / masterBus)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||||
|
bridgeNode.connect(dest);
|
||||||
|
this._connected = true;
|
||||||
|
this._trackId = trackId || null;
|
||||||
|
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: function () {
|
||||||
|
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||||
|
this._connected = false;
|
||||||
|
this._trackId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.AudioRoutingEngine = engine;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// app/static/js/services/bridgeAudioNode.js
|
||||||
|
// Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them
|
||||||
|
// through a ScriptProcessorNode into the WebAudio graph (track FX / master bus).
|
||||||
|
(function () {
|
||||||
|
var RING_DEPTH = 8; // 8 blocks * 256 samples ~= 46ms anti-underrun
|
||||||
|
var SP_BUFFER = 4096; // ScriptProcessor chunk (16 bridge blocks)
|
||||||
|
var _queue = [];
|
||||||
|
var _spn = null;
|
||||||
|
var _gainNode = null;
|
||||||
|
var _ctx = null;
|
||||||
|
var _initialized = false;
|
||||||
|
|
||||||
|
function _getCtx() {
|
||||||
|
if (_ctx) return _ctx;
|
||||||
|
if (typeof getAudioContext === 'function') _ctx = getAudioContext();
|
||||||
|
else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx;
|
||||||
|
else _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
return _ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(audioCtx) {
|
||||||
|
if (_initialized) return;
|
||||||
|
_ctx = audioCtx || _getCtx();
|
||||||
|
_gainNode = _ctx.createGain();
|
||||||
|
_gainNode.gain.value = 1.0;
|
||||||
|
_spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2);
|
||||||
|
_spn.onaudioprocess = function (e) {
|
||||||
|
var L = e.outputBuffer.getChannelData(0);
|
||||||
|
var R = e.outputBuffer.getChannelData(1);
|
||||||
|
L.fill(0); R.fill(0);
|
||||||
|
if (!_queue.length) return;
|
||||||
|
var f = _queue.shift();
|
||||||
|
L.set(f.l); R.set(f.r);
|
||||||
|
};
|
||||||
|
_spn.connect(_gainNode);
|
||||||
|
_initialized = true;
|
||||||
|
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudio(l, r) {
|
||||||
|
if (!_initialized) init();
|
||||||
|
_queue.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||||
|
if (_queue.length > RING_DEPTH) _queue.shift(); // drop oldest on overrun
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() { _queue = []; }
|
||||||
|
|
||||||
|
function getOutputNode() { return _gainNode; }
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
flush();
|
||||||
|
if (_spn && _gainNode) {
|
||||||
|
try { _spn.disconnect(); } catch (e) {}
|
||||||
|
try { _gainNode.disconnect(); } catch (e) {}
|
||||||
|
}
|
||||||
|
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||||
|
_initialized = false;
|
||||||
|
_spn = null;
|
||||||
|
_gainNode = null;
|
||||||
|
_ctx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.BridgeAudioNode = {
|
||||||
|
init: init,
|
||||||
|
onAudio: onAudio,
|
||||||
|
flush: flush,
|
||||||
|
getOutputNode: getOutputNode,
|
||||||
|
connect: function (dest) { if (_gainNode) _gainNode.connect(dest); },
|
||||||
|
disconnect: disconnect,
|
||||||
|
isReady: function () { return _initialized; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// app/static/js/services/nativeBridgeService.js
|
||||||
|
// JS client for the C++ Native Host Bridge (daw_vst_bridge.exe).
|
||||||
|
// All IPC goes through Tauri commands (Rust writes the shared memory) —
|
||||||
|
// WebView2 JS cannot map Windows shared memory directly.
|
||||||
|
(function () {
|
||||||
|
// Must match native_bridge/include/INativeInstrument.h enum InstrumentType
|
||||||
|
var INSTRUMENT_TYPE = { VST3: 0, VST2: 1, SF2: 2, SF3: 2, SFZ: 3 };
|
||||||
|
|
||||||
|
var service = {
|
||||||
|
isBridgeConnected: false,
|
||||||
|
activeInstrumentType: 'VST3',
|
||||||
|
_audioCb: null,
|
||||||
|
_statusCb: null,
|
||||||
|
_tauri: function () { return window.__TAURI__; },
|
||||||
|
|
||||||
|
/** D10: UI subscribes to bridge connection changes. cb({connected}) or null to clear. */
|
||||||
|
onStatusChange: function (cb) { this._statusCb = cb; },
|
||||||
|
_notifyStatus: function (connected) {
|
||||||
|
this.isBridgeConnected = !!connected;
|
||||||
|
if (this._statusCb) this._statusCb({ connected: !!connected });
|
||||||
|
},
|
||||||
|
|
||||||
|
_init: function () {
|
||||||
|
if (!this._tauri()) {
|
||||||
|
// Dev mode (Linux / plain browser): no Tauri -> log-only, app uses SonicSF.
|
||||||
|
console.log('[BridgeService] Dev mode: no __TAURI__, native bridge disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var self = this;
|
||||||
|
try {
|
||||||
|
window.__TAURI__.event.listen('bridge-audio', function (e) {
|
||||||
|
if (self._audioCb) self._audioCb(e.payload.l, e.payload.r);
|
||||||
|
});
|
||||||
|
window.__TAURI__.event.listen('bridge-down', function () {
|
||||||
|
self._notifyStatus(false);
|
||||||
|
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(false);
|
||||||
|
if (window.AudioRoutingEngine) window.AudioRoutingEngine.disconnect();
|
||||||
|
console.warn('[BridgeService] bridge-down event received.');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] event listen failed:', e);
|
||||||
|
}
|
||||||
|
this.queryStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
queryStatus: async function () {
|
||||||
|
if (!this._tauri()) return { connected: false };
|
||||||
|
try {
|
||||||
|
var s = await window.__TAURI__.core.invoke('bridge_status');
|
||||||
|
this._notifyStatus(!!s.connected);
|
||||||
|
return s;
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyStatus(false);
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. LOAD NEW INSTRUMENT INTO BRIDGE (VST3 / VST2 / SF2 / SF3 / SFZ)
|
||||||
|
* instrumentType: 'VST3' | 'VST2' | 'SF2' | 'SF3' | 'SFZ'
|
||||||
|
* channel: MIDI channel to assign this instrument to (A10 multi-instance).
|
||||||
|
*/
|
||||||
|
loadInstrument: async function (filePath, instrumentType, channel) {
|
||||||
|
this.activeInstrumentType = instrumentType;
|
||||||
|
console.log('[BridgeService] Loading ' + instrumentType + ' asset: ' + filePath + ' ch=' + channel);
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke('load_native_instrument', {
|
||||||
|
path: filePath,
|
||||||
|
instrumentType: INSTRUMENT_TYPE[instrumentType] !== undefined ? INSTRUMENT_TYPE[instrumentType] : 0,
|
||||||
|
channel: channel === undefined ? 0 : channel
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] loadInstrument failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. UNIFIED MIDI EVENT DISPATCH
|
||||||
|
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||||
|
* velocity 0..1; sampleOffset in samples within the current audio block.
|
||||||
|
* data2/data3 (A12): CC value / program / PB LSB|MSB.
|
||||||
|
*/
|
||||||
|
dispatchMidiEvent: function (cmd, channel, pitch, velocity, sampleOffset, data2, data3) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
var safeVelocity = Math.floor(Math.min(1.0, Math.max(0.0, velocity)) * 127);
|
||||||
|
var byteCmd;
|
||||||
|
switch (cmd) {
|
||||||
|
case 'CC': byteCmd = 0xB; break;
|
||||||
|
case 'PROGRAM': byteCmd = 0xC; break;
|
||||||
|
case 'PITCH_BEND': byteCmd = 0xE; break;
|
||||||
|
default: byteCmd = cmd === 'NOTE_ON' ? 0x9 : 0x8; break;
|
||||||
|
}
|
||||||
|
window.__TAURI__.core.invoke('push_midi_event', {
|
||||||
|
command: byteCmd,
|
||||||
|
channel: channel,
|
||||||
|
pitch: pitch,
|
||||||
|
velocity: safeVelocity,
|
||||||
|
data2: data2 === undefined ? 0 : data2,
|
||||||
|
data3: data3 === undefined ? 0 : data3,
|
||||||
|
sampleOffset: sampleOffset || 0
|
||||||
|
}).catch(function (e) { console.warn('[BridgeService] push_midi_event:', e); });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. OPEN FLOATING CHILD WINDOW NATIVE GUI
|
||||||
|
*/
|
||||||
|
openNativeGUI: async function (pluginId) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke('open_vst_gui', { pluginId: pluginId });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] open_vst_gui:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. TRANSPORT CONTROL ('play' | 'stop' | 'panic' | 'set_position')
|
||||||
|
* playhead: sample position (A13) — used by 'play' and 'set_position'.
|
||||||
|
*/
|
||||||
|
transport: function (kind, playhead) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
var args = { kind: kind };
|
||||||
|
if (playhead !== undefined) args.playhead = playhead;
|
||||||
|
window.__TAURI__.core.invoke('transport_control', args)
|
||||||
|
.catch(function (e) { console.warn('[BridgeService] transport_control:', e); });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. AUDIO SINK: register consumer of PCM frames from the bridge.
|
||||||
|
* cb(l: Float32Array, r: Float32Array)
|
||||||
|
*/
|
||||||
|
onAudio: function (cb) {
|
||||||
|
this._audioCb = cb;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.NativeBridgeService = service;
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { service._init(); });
|
||||||
|
} else {
|
||||||
|
service._init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -74,6 +74,9 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
|||||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||||
shouldRoute: function (synthEngine, isArmed) {
|
shouldRoute: function (synthEngine, isArmed) {
|
||||||
try {
|
try {
|
||||||
|
// D4: bridge active → VSTi do native bridge host, KHÔNG route Carla
|
||||||
|
// (tránh kép âm).
|
||||||
|
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
if (!c || !c.features || !c.features.carla_local) return false;
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
if (!isArmed) return false;
|
if (!isArmed) return false;
|
||||||
@@ -85,6 +88,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
|||||||
// user đã chủ động bấm Play trên item đó).
|
// user đã chủ động bấm Play trên item đó).
|
||||||
shouldRoutePlayback: function (synthEngine) {
|
shouldRoutePlayback: function (synthEngine) {
|
||||||
try {
|
try {
|
||||||
|
// D4: bridge active → KHÔNG route Carla (bridge host VSTi).
|
||||||
|
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
if (!c || !c.features || !c.features.carla_local) return false;
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
var se = synthEngine || {};
|
var se = synthEngine || {};
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// app/static/js/services/unifiedMidiRouter.js
|
||||||
|
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||||
|
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||||
|
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||||
|
(function () {
|
||||||
|
var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
||||||
|
|
||||||
|
var router = {
|
||||||
|
bridgeConnected: false,
|
||||||
|
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||||
|
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||||
|
_channels: {}, // trackId -> { ch, percussion }
|
||||||
|
_nextMelodicIdx: 0,
|
||||||
|
|
||||||
|
setBridgeConnected: function (flag) {
|
||||||
|
this.bridgeConnected = !!flag;
|
||||||
|
},
|
||||||
|
|
||||||
|
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
||||||
|
|
||||||
|
isBridgeActive: function () {
|
||||||
|
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
||||||
|
allocateChannel: function (trackId, isPercussion) {
|
||||||
|
if (this._channels[trackId]) return this._channels[trackId].ch;
|
||||||
|
var ch;
|
||||||
|
if (isPercussion) {
|
||||||
|
ch = 9;
|
||||||
|
} else {
|
||||||
|
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
||||||
|
this._nextMelodicIdx++;
|
||||||
|
}
|
||||||
|
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
||||||
|
return ch;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetChannels: function () {
|
||||||
|
this._channels = {};
|
||||||
|
this._nextMelodicIdx = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||||
|
* channel: MIDI channel (or trackId -> allocateChannel first)
|
||||||
|
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
||||||
|
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
||||||
|
*/
|
||||||
|
pushEvent: function (opts) {
|
||||||
|
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
||||||
|
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
||||||
|
var sampleOffset = opts.sampleOffset || 0;
|
||||||
|
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
||||||
|
if (ch === undefined || ch === null) ch = 0;
|
||||||
|
if (this.isBridgeActive()) {
|
||||||
|
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
||||||
|
panic: function () {
|
||||||
|
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
||||||
|
window.NativeBridgeService.transport('panic');
|
||||||
|
}
|
||||||
|
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.SonicMidiRouter = router;
|
||||||
|
})();
|
||||||
@@ -39,6 +39,10 @@
|
|||||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||||
|
<script src="/static/js/services/nativeBridgeService.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/bridgeAudioNode.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/unifiedMidiRouter.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/audioRoutingEngine.js?v=202608112200"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# build_native_bridge.ps1 - Build daw_vst_bridge.exe (C++ Native Host Bridge)
|
||||||
|
# Run on Windows x64 with VS Build Tools 2022 (C++ workload) installed.
|
||||||
|
# ASCII only (PowerShell 5.1 reads .ps1 without BOM as ANSI).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
$REPO_ROOT = Resolve-Path (Join-Path $PSScriptRoot "..\..") # ...\SonicForgeStudio
|
||||||
|
$NB_DIR = Join-Path $REPO_ROOT "native_bridge"
|
||||||
|
|
||||||
|
# 1. vcpkg (bootstrap once; skip if already present)
|
||||||
|
$VCPKG_ROOT = Join-Path $env:USERPROFILE "vcpkg"
|
||||||
|
if (-not (Test-Path (Join-Path $VCPKG_ROOT "vcpkg.exe"))) {
|
||||||
|
Write-Host "== [1/6] Bootstrap vcpkg =="
|
||||||
|
git clone https://github.com/microsoft/vcpkg $VCPKG_ROOT
|
||||||
|
& (Join-Path $VCPKG_ROOT "bootstrap-vcpkg.bat") -disableMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. vcpkg deps: fluidsynth + sfizz (+ pkgconf so pkg_check_modules may work)
|
||||||
|
Write-Host "== [2/6] vcpkg install fluidsynth sfizz =="
|
||||||
|
& (Join-Path $VCPKG_ROOT "vcpkg.exe") install fluidsynth sfizz --triplet x64-windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: vcpkg install failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 3. VST3 SDK submodule (Steinberg; NOT in vcpkg)
|
||||||
|
Write-Host "== [3/6] VST3 SDK (submodule) =="
|
||||||
|
if (-not (Test-Path (Join-Path $NB_DIR "vst3sdk"))) {
|
||||||
|
Push-Location $NB_DIR
|
||||||
|
git submodule add https://github.com/steinbergmedia/vst3sdk.git vst3sdk
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Configure with CMake + vcpkg toolchain (MSVC)
|
||||||
|
Write-Host "== [4/6] CMake configure =="
|
||||||
|
$BUILD_DIR = Join-Path $NB_DIR "build"
|
||||||
|
New-Item -ItemType Directory -Force $BUILD_DIR | Out-Null
|
||||||
|
cmake -S $NB_DIR -B $BUILD_DIR `
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE=(Join-Path $VCPKG_ROOT "scripts\buildsystems\vcpkg.cmake") `
|
||||||
|
-DCMAKE_BUILD_TYPE=Release `
|
||||||
|
-DVCPKG_TARGET_TRIPLET=x64-windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 5. Build Release
|
||||||
|
Write-Host "== [5/6] CMake build =="
|
||||||
|
cmake --build $BUILD_DIR --config Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 6. Copy sidecar to Tauri externalBin location
|
||||||
|
Write-Host "== [6/6] Copy to src-tauri\binaries =="
|
||||||
|
$BIN_DIR = Join-Path $REPO_ROOT "src-tauri\binaries"
|
||||||
|
New-Item -ItemType Directory -Force $BIN_DIR | Out-Null
|
||||||
|
$EXE = Join-Path $BUILD_DIR "Release\daw_vst_bridge.exe"
|
||||||
|
if (-not (Test-Path $EXE)) { Write-Host "ERROR: $EXE not found" -ForegroundColor Red; exit 1 }
|
||||||
|
Copy-Item $EXE (Join-Path $BIN_DIR "daw_vst_bridge-x86_64-pc-windows-msvc.exe") -Force
|
||||||
|
Write-Host "== DONE: daw_vst_bridge.exe copied to src-tauri\binaries =="
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe + daw_vst_bridge.exe + Tauri (NSIS+MSI)
|
||||||
|
# Run on Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# ASCII only (PowerShell 5.1 ANSI limitation).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location (Join-Path $PSScriptRoot "..\..") # repo root
|
||||||
|
|
||||||
|
Write-Host "== [0/7] Kill old sidecars (daw_engine, daw_vst_bridge) =="
|
||||||
|
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-Process -Name "daw_vst_bridge" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
Write-Host "== [1/7] Python dependencies =="
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||||
|
|
||||||
|
Write-Host "== [2/7] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) { npm install @babel/standalone --no-audit --no-fund }
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) { Write-Host "ERROR: @babel/standalone missing" -ForegroundColor Red; exit 1 }
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
Write-Host "== [3/7] Build daw_engine.exe (PyInstaller ONEDIR) =="
|
||||||
|
pyinstaller engine.spec --clean --noconfirm
|
||||||
|
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($specContent -notmatch "collect_data_files\('app'") { Write-Host "ERROR: engine.spec too old" -ForegroundColor Red; exit 1 }
|
||||||
|
python tools\verify_bundle.py
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: bundle missing assets" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
Write-Host "== [4/7] Copy engine onedir -> src-tauri\resources\daw_engine =="
|
||||||
|
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) { Write-Host "ERROR: onedir engine missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (Test-Path "src-tauri\resources\daw_engine") { Remove-Item -Recurse -Force "src-tauri\resources\daw_engine" }
|
||||||
|
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
||||||
|
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||||
|
|
||||||
|
Write-Host "== [5/7] Build native bridge (daw_vst_bridge.exe) =="
|
||||||
|
& (Join-Path $PSScriptRoot "build_native_bridge.ps1")
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: native bridge build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) {
|
||||||
|
Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [6/7] VC++ Redistributable for hooks.nsh =="
|
||||||
|
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||||
|
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [7/7] Tauri build (NSIS + MSI) =="
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) { Write-Host "ERROR: engine resource missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) { Write-Host "ERROR: engine _internal missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) { Write-Host "ERROR: bridge externalBin missing" -ForegroundColor Red; exit 1 }
|
||||||
|
python tools\verify_bundle.py --check-bridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1 }
|
||||||
|
npm install -D @tauri-apps/cli
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== DONE =="
|
||||||
|
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
|
||||||
|
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
|
||||||
|
Write-Host " Verify after install: %APPDATA%\SonicForgeDAW\logs\spawn.log exists=True for daw_engine AND daw_vst_bridge"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# test_standalone.ps1 - Post-install verification for Native Host Bridge
|
||||||
|
# Run on the Windows machine AFTER installing the NSIS setup (or from dev tree).
|
||||||
|
# ASCII only (PowerShell 5.1 ANSI limitation).
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
$APPDATA = Join-Path $env:APPDATA "SonicForgeDAW"
|
||||||
|
$LOG_DIR = Join-Path $APPDATA "logs"
|
||||||
|
$BRIDGE_LOG = Join-Path $LOG_DIR "bridge.log"
|
||||||
|
|
||||||
|
Write-Host "== [1/8] daw_vst_bridge.exe process =="
|
||||||
|
$proc = Get-Process -Name "daw_vst_bridge" -ErrorAction SilentlyContinue
|
||||||
|
if ($proc) { Write-Host " OK: running (PID $($proc.Id))" } else { Write-Host " FAIL: not running" }
|
||||||
|
|
||||||
|
Write-Host "== [2/8] daw_engine.exe process =="
|
||||||
|
$eng = Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue
|
||||||
|
if ($eng) { Write-Host " OK: running (PID $($eng.Id))" } else { Write-Host " FAIL: not running" }
|
||||||
|
|
||||||
|
Write-Host "== [3/8] spawn.log (engine + bridge lines) =="
|
||||||
|
$spawn = Join-Path $LOG_DIR "spawn.log"
|
||||||
|
if (Test-Path $spawn) {
|
||||||
|
$content = Get-Content $spawn -Raw
|
||||||
|
if ($content -match "daw_vst_bridge.*exists=True") { Write-Host " OK: bridge found in spawn.log" }
|
||||||
|
else { Write-Host " WARN: no daw_vst_bridge line in spawn.log (old build?)" }
|
||||||
|
if ($content -match "daw_engine.*exists=True") { Write-Host " OK: engine found in spawn.log" }
|
||||||
|
else { Write-Host " FAIL: engine not found in spawn.log" }
|
||||||
|
} else { Write-Host " FAIL: spawn.log missing" }
|
||||||
|
|
||||||
|
Write-Host "== [4/8] Shared Memory mapping SonicForge_DAW_IPC =="
|
||||||
|
# Requires admin? Usually not for read. Use a tiny C# helper to OpenFileMapping.
|
||||||
|
$shmCheck = @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class ShmCheck {
|
||||||
|
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||||
|
static extern IntPtr OpenFileMapping(uint dwDesiredAccess, bool bInheritHandle, string lpName);
|
||||||
|
public static int Check() {
|
||||||
|
IntPtr h = OpenFileMapping(0x6, false, "SonicForge_DAW_IPC"); // FILE_MAP_READ|FILE_MAP_WRITE
|
||||||
|
if (h == IntPtr.Zero) return Marshal.GetLastWin32Error();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
try {
|
||||||
|
Add-Type -TypeDefinition $shmCheck -Language CSharp -ErrorAction Stop
|
||||||
|
$r = [ShmCheck]::Check()
|
||||||
|
if ($r -eq 0) { Write-Host " OK: SonicForge_DAW_IPC exists" } else { Write-Host " FAIL: shared memory not found (win32 err $r)" }
|
||||||
|
} catch { Write-Host " SKIP: cannot check SHM (compile error: $($_.Exception.Message))" }
|
||||||
|
|
||||||
|
Write-Host "== [5/8] Engine HTTP health =="
|
||||||
|
$health = $null
|
||||||
|
for ($p = 8000; $p -le 8010 -and -not $health; $p++) {
|
||||||
|
try { $health = Invoke-RestMethod -Uri "http://127.0.0.1:$p/health" -TimeoutSec 2 } catch {}
|
||||||
|
}
|
||||||
|
if ($health) { Write-Host " OK: engine health on port $p -> $($health | ConvertTo-Json -Compress)" }
|
||||||
|
else { Write-Host " FAIL: no engine on 8000-8010" }
|
||||||
|
|
||||||
|
Write-Host "== [6/8] Bridge status via engine API =="
|
||||||
|
try {
|
||||||
|
$st = Invoke-RestMethod -Uri "http://127.0.0.1:$p/api/v1/bridge/status" -TimeoutSec 3
|
||||||
|
Write-Host " bridge_status: $($st | ConvertTo-Json -Compress)"
|
||||||
|
} catch { Write-Host " WARN: /api/v1/bridge/status not available yet (endpoint added in Giai doan 6)" }
|
||||||
|
|
||||||
|
Write-Host "== [7/8] Audio smoke test: load SF2 + note -> PCM in SHM =="
|
||||||
|
# Requires a test soundfont; adjust path to a real .sf2 on this machine.
|
||||||
|
$sf = "C:\SonicForgeDAW\soundfonts\SGM-V2.01.sf2"
|
||||||
|
if (Test-Path $sf) {
|
||||||
|
try {
|
||||||
|
$body = @{ path = $sf; type = "SF2" } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "http://127.0.0.1:$p/api/v1/bridge/load" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 30 | Out-Null
|
||||||
|
Write-Host " OK: bridge load SF2 requested. Press keys on MIDI keyboard and watch VU meters."
|
||||||
|
} catch { Write-Host " WARN: bridge/load failed ($($_.Exception.Message))" }
|
||||||
|
} else { Write-Host " SKIP: $sf not found - set a real .sf2 path" }
|
||||||
|
|
||||||
|
Write-Host "== [8/8] Manual checklist (spec VII) =="
|
||||||
|
Write-Host " [ ] Load VST3 (Vital.vst3) -> open floating GUI -> tweak knobs"
|
||||||
|
Write-Host " [ ] Load SF2 (SGM-V2.01.sf2) bank 0 prog 0 -> piano"
|
||||||
|
Write-Host " [ ] Load SFZ (SalamanderPiano.sfz) -> samples play"
|
||||||
|
Write-Host " [ ] MIDI keyboard live + Timeline play together -> no choke/dropout"
|
||||||
|
Write-Host " [ ] Track EQ cutoff + Master maximizer affect bridge audio instantly"
|
||||||
|
Write-Host " [ ] Kill daw_vst_bridge.exe -> app falls back to SonicSF WASM (no crash)"
|
||||||
|
Write-Host "== DONE =="
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(daw_vst_bridge LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
# ── 1. VST3 SDK (Steinberg, vendored as git submodule — NOT in vcpkg) ──
|
||||||
|
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/CMakeLists.txt")
|
||||||
|
add_subdirectory(vst3sdk EXCLUDE_FROM_ALL)
|
||||||
|
set(VST3_SDK_TARGET sdk)
|
||||||
|
else()
|
||||||
|
message(WARNING "vst3sdk submodule missing — VST3 host disabled; SF2/SF3/SFZ still build")
|
||||||
|
set(VST3_SDK_TARGET "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ── 2. FluidSynth + sfizz ──
|
||||||
|
# Windows: vcpkg toolchain (-DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake) cung cap
|
||||||
|
# headers/libs; pkg_check_modules khong co san tren MSVC.
|
||||||
|
# Linux/macOS: pkg-config duoc dung (spec goc).
|
||||||
|
if(WIN32)
|
||||||
|
find_path(FLUIDSYNTH_INCLUDE_DIR fluidsynth.h)
|
||||||
|
find_library(FLUIDSYNTH_LIBRARY NAMES fluidsynth libfluidsynth)
|
||||||
|
if(NOT FLUIDSYNTH_INCLUDE_DIR OR NOT FLUIDSYNTH_LIBRARY)
|
||||||
|
message(FATAL_ERROR "fluidsynth not found — cai qua vcpkg: vcpkg install fluidsynth")
|
||||||
|
endif()
|
||||||
|
# sfizz: header sfizz.hpp + lib sfizz (vcpkg export target sfizz::sfizz neu co)
|
||||||
|
find_path(SFIZZ_INCLUDE_DIR sfizz.hpp)
|
||||||
|
find_library(SFIZZ_LIBRARY NAMES sfizz)
|
||||||
|
if(NOT SFIZZ_INCLUDE_DIR OR NOT SFIZZ_LIBRARY)
|
||||||
|
message(FATAL_ERROR "sfizz not found — cai qua vcpkg: vcpkg install sfizz")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(FLUIDSYNTH REQUIRED fluidsynth)
|
||||||
|
pkg_check_modules(SFIZZ REQUIRED sfizz)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include_directories(
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
${FLUIDSYNTH_INCLUDE_DIRS}
|
||||||
|
${SFIZZ_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(daw_vst_bridge
|
||||||
|
src/main.cpp
|
||||||
|
src/NativeInstrumentEngine.cpp
|
||||||
|
src/SharedMemoryIPC.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
if(VST3_SDK_TARGET)
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${VST3_SDK_TARGET})
|
||||||
|
endif()
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY})
|
||||||
|
# Required Windows libs
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE winmm)
|
||||||
|
else()
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARIES} ${SFIZZ_LIBRARIES})
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// native_bridge/include/INativeInstrument.h
|
||||||
|
#ifndef I_NATIVE_INSTRUMENT_H
|
||||||
|
#define I_NATIVE_INSTRUMENT_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
enum class InstrumentType {
|
||||||
|
VST3,
|
||||||
|
VST2,
|
||||||
|
SOUNDFONT_SF2_SF3,
|
||||||
|
SFZ
|
||||||
|
};
|
||||||
|
|
||||||
|
class INativeInstrument {
|
||||||
|
public:
|
||||||
|
virtual ~INativeInstrument() = default;
|
||||||
|
|
||||||
|
// Initialize Engine with Sample Rate and Buffer Size
|
||||||
|
virtual bool init(double sampleRate, uint32_t maxBlockSize) = 0;
|
||||||
|
|
||||||
|
// Select Bank and Program Change
|
||||||
|
virtual void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) = 0;
|
||||||
|
|
||||||
|
// Dispatch MIDI Note On / Note Off events
|
||||||
|
virtual void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) = 0;
|
||||||
|
virtual void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) = 0;
|
||||||
|
|
||||||
|
// MIDI continuous controllers (A12): CC value, program change, 14-bit pitch bend
|
||||||
|
virtual void controlChange(uint32_t channel, uint32_t cc, uint32_t value) = 0;
|
||||||
|
virtual void programChange(uint32_t channel, uint32_t program) = 0;
|
||||||
|
virtual void pitchBend(uint32_t channel, uint32_t bend14) = 0;
|
||||||
|
|
||||||
|
// Open/close Native GUI window
|
||||||
|
virtual bool openGUI(void* parentWindowHandle) = 0;
|
||||||
|
virtual void closeGUI() = 0;
|
||||||
|
|
||||||
|
// Real-time Audio PCM Float32 rendering loop
|
||||||
|
virtual void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // I_NATIVE_INSTRUMENT_H
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// native_bridge/include/NativeInstrumentEngine.h
|
||||||
|
#ifndef NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
#define NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// FluidSynth (.sf2 / .sf3)
|
||||||
|
class FluidSynthInstrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
FluidSynthInstrument();
|
||||||
|
~FluidSynthInstrument() override;
|
||||||
|
|
||||||
|
bool loadSoundFontFile(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* settings; // fluid_settings_t*
|
||||||
|
void* synth; // fluid_synth_t*
|
||||||
|
int sfontId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// sfizz (.sfz)
|
||||||
|
class SfizzInstrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
bool loadSfzFile(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
sfizz::Synth sfizzSynth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-instrument host (A10): one engine instance per MIDI channel, so two
|
||||||
|
// tracks can play two different soundfonts/VSTs simultaneously. All channels
|
||||||
|
// render into the same output block (mixed), keyed by evt.channel.
|
||||||
|
class InstrumentEngineManager {
|
||||||
|
public:
|
||||||
|
// Create (or replace) the instrument on `channel` for `path`.
|
||||||
|
// Returns false if the type is unsupported or the file fails to load.
|
||||||
|
bool assign(uint32_t channel, InstrumentType type, const std::string& path,
|
||||||
|
double sampleRate, uint32_t blockSize);
|
||||||
|
|
||||||
|
INativeInstrument* get(uint32_t channel);
|
||||||
|
|
||||||
|
// Flush every sounding note on every assigned channel.
|
||||||
|
void allNotesOff();
|
||||||
|
|
||||||
|
// Zero L/R then sum each assigned instrument into it.
|
||||||
|
void renderAll(float* outputL, float* outputR, uint32_t numSamples);
|
||||||
|
|
||||||
|
size_t count() const { return channels_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<INativeInstrument> create_instrument(InstrumentType type);
|
||||||
|
|
||||||
|
std::map<uint32_t, std::unique_ptr<INativeInstrument>> channels_;
|
||||||
|
// Per-instrument scratch so engines that overwrite (not mix) stay additive.
|
||||||
|
std::vector<float> scratchL_, scratchR_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // NATIVE_INSTRUMENT_ENGINE_H
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// native_bridge/include/SharedMemoryIPC.h
|
||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#define AUDIO_BLOCK_SIZE 256
|
||||||
|
|
||||||
|
// WARNING: volatile is NOT a sync primitive. Real impl should use an
|
||||||
|
// interlocked/index-flag pair or a Win32 event (SetEvent) signalled by the
|
||||||
|
// writer. This struct follows the spec layout so Rust/JS mapping stays in sync.
|
||||||
|
struct SharedAudioBufferIPC {
|
||||||
|
// Synchronization Flags
|
||||||
|
volatile uint32_t clientReadIndex;
|
||||||
|
volatile uint32_t bridgeWriteIndex;
|
||||||
|
|
||||||
|
// PCM Float32 Audio Buffers
|
||||||
|
float masterLeft[AUDIO_BLOCK_SIZE];
|
||||||
|
float masterRight[AUDIO_BLOCK_SIZE];
|
||||||
|
|
||||||
|
// Latency probe: bridge stamps every rendered block (QueryPerformanceCounter)
|
||||||
|
volatile uint64_t blockTimestamp;
|
||||||
|
|
||||||
|
// MIDI Event Exchange Queue
|
||||||
|
struct MidiEventIPC {
|
||||||
|
uint8_t command; // 0x9 note on / 0x8 note off / 0xB CC / 0xC program / 0xE pitch bend
|
||||||
|
uint8_t channel;
|
||||||
|
uint8_t pitch; // note number (0x9/0x8) or CC number (0xB)
|
||||||
|
uint8_t velocity; // 0-127 (0x9 + vel=0 == note off)
|
||||||
|
uint8_t data2; // CC value / program number / PB LSB
|
||||||
|
uint8_t data3; // PB MSB (0xE only)
|
||||||
|
uint8_t reserved[2]; // explicit padding — keeps C/Rust layout stable
|
||||||
|
uint32_t sampleOffset;
|
||||||
|
} midiQueue[64];
|
||||||
|
|
||||||
|
volatile uint32_t midiQueueCount;
|
||||||
|
|
||||||
|
// Transport / Control (added vs spec: Stop/Play flush, instrument switch)
|
||||||
|
struct ControlEventIPC {
|
||||||
|
uint32_t type; // 0 = NONE, 1 = PANIC/ALL_NOTES_OFF, 2 = LOAD_INSTRUMENT,
|
||||||
|
// 3 = TRANSPORT, 4 = OPEN_GUI
|
||||||
|
uint32_t arg0; // LOAD: instrument type (InstrumentType);
|
||||||
|
// TRANSPORT: 0=STOP, 1=PLAY, 2=SET_POSITION
|
||||||
|
uint32_t arg1; // LOAD: string length (bytes) of path;
|
||||||
|
// TRANSPORT: playheadSamples (for timeline sync)
|
||||||
|
uint32_t channel; // LOAD: MIDI channel to assign instrument to (A10)
|
||||||
|
char arg2[1024]; // LOAD: UTF-8 path
|
||||||
|
} controlQueue[8];
|
||||||
|
volatile uint32_t controlQueueCount;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// native_bridge/include/Vst3Instrument.h
|
||||||
|
// VST3 host instrument (A7). Compiled ALWAYS; the real vst3sdk wiring is
|
||||||
|
// inside #ifdef HAVE_VST3SDK (set by CMake when the vst3sdk submodule is
|
||||||
|
// present). Without the SDK the class degrades to a no-op stub so the bridge
|
||||||
|
// still builds for SF2/SF3/SFZ.
|
||||||
|
#ifndef VST3_INSTRUMENT_H
|
||||||
|
#define VST3_INSTRUMENT_H
|
||||||
|
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class Vst3Instrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
Vst3Instrument();
|
||||||
|
~Vst3Instrument() override;
|
||||||
|
|
||||||
|
bool loadPlugin(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* module_; // Steinberg::IPluginFactory* (owned by module)
|
||||||
|
void* processor_; // Steinberg::Vst::IComponent*
|
||||||
|
void* controller_; // Steinberg::Vst::IEditController*
|
||||||
|
void* view_; // Steinberg::IPlugView*
|
||||||
|
std::string path_;
|
||||||
|
double sampleRate_;
|
||||||
|
uint32_t maxBlockSize_;
|
||||||
|
bool loaded_;
|
||||||
|
bool guiAttached_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // VST3_INSTRUMENT_H
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// native_bridge/src/NativeInstrumentEngine.cpp
|
||||||
|
#include "NativeInstrumentEngine.h"
|
||||||
|
|
||||||
|
#include <fluidsynth.h>
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 1. SOUNDFONT ENGINE (.SF2 / .SF3) VIA FLUIDSYNTH C API
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
FluidSynthInstrument::FluidSynthInstrument()
|
||||||
|
: settings(nullptr), synth(nullptr), sfontId(-1) {}
|
||||||
|
|
||||||
|
FluidSynthInstrument::~FluidSynthInstrument() {
|
||||||
|
if (synth) delete_fluid_synth(synth);
|
||||||
|
if (settings) delete_fluid_settings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::loadSoundFontFile(const std::string& path, double sampleRate) {
|
||||||
|
if (synth) { delete_fluid_synth(synth); synth = nullptr; }
|
||||||
|
if (settings) { delete_fluid_settings(settings); settings = nullptr; }
|
||||||
|
settings = new_fluid_settings();
|
||||||
|
fluid_settings_setnum(settings, "synth.sample-rate", sampleRate);
|
||||||
|
fluid_settings_setint(settings, "synth.polyphony", 256);
|
||||||
|
fluid_settings_setint(settings, "synth.verbose", 0);
|
||||||
|
synth = new_fluid_synth(settings);
|
||||||
|
if (!synth) return false;
|
||||||
|
sfontId = fluid_synth_sfload(synth, path.c_str(), 1);
|
||||||
|
if (sfontId == -1) return false;
|
||||||
|
// Reset all channels to font preset 0 (spec §VII: bank0/prog0 piano)
|
||||||
|
for (uint32_t ch = 0; ch < 16; ++ch) {
|
||||||
|
fluid_synth_program_select(synth, ch, sfontId, 0, 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
return synth != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_bank_select(synth, channel, bank);
|
||||||
|
fluid_synth_program_change(synth, channel, program);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
if (!synth) return;
|
||||||
|
int velInt = static_cast<int>(velocity * 127.0f);
|
||||||
|
fluid_synth_noteon(synth, channel, pitch, velInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_noteoff(synth, channel, pitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_cc(synth, channel, cc, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_program_change(synth, channel, program);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
if (!synth) return;
|
||||||
|
// fluid_synth_pitch_bend takes the raw 14-bit value (center 8192).
|
||||||
|
fluid_synth_pitch_bend(synth, channel, bend14);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::openGUI(void* parentWindowHandle) {
|
||||||
|
return false; // SoundFont uses Web GUI Manager / Reskinned Knobs
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::closeGUI() {}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_write_float(synth, numSamples, outputL, 0, 1, outputR, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 2. SFZ ENGINE (.SFZ) VIA SFIZZ C++ API
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
bool SfizzInstrument::loadSfzFile(const std::string& path, double sampleRate) {
|
||||||
|
sfizzSynth.setSampleRate(sampleRate);
|
||||||
|
return sfizzSynth.loadSfzFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SfizzInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
sfizzSynth.setSampleRate(sampleRate);
|
||||||
|
sfizzSynth.setSamplesPerBlock(maxBlockSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {}
|
||||||
|
|
||||||
|
void SfizzInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
sfizzSynth.hdNoteOn(sampleOffset, pitch, velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
sfizzSynth.hdNoteOff(sampleOffset, pitch, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
// ponytail: non-delayed cc() is stable across sfizz versions; switch to
|
||||||
|
// hdCC(cc, value) for sample-accurate CC when the installed sfizz has it.
|
||||||
|
sfizzSynth.cc(static_cast<int>(cc), static_cast<float>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
// TODO(A12): sfizz program-change API differs by version (hdProgramChange
|
||||||
|
// in >=0.6). Rarely used by SFZ instruments — no-op until verified.
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
sfizzSynth.pitchWheel(static_cast<int>(bend14));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SfizzInstrument::openGUI(void* parentWindowHandle) { return false; }
|
||||||
|
void SfizzInstrument::closeGUI() {}
|
||||||
|
|
||||||
|
void SfizzInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
float* channels[2] = { outputL, outputR };
|
||||||
|
sfizzSynth.renderBlock(channels, numSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 3. MULTI-CHANNEL INSTRUMENT MANAGER (A10)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
std::unique_ptr<INativeInstrument> InstrumentEngineManager::create_instrument(InstrumentType type) {
|
||||||
|
switch (type) {
|
||||||
|
case InstrumentType::SOUNDFONT_SF2_SF3: return std::make_unique<FluidSynthInstrument>();
|
||||||
|
case InstrumentType::SFZ: return std::make_unique<SfizzInstrument>();
|
||||||
|
// ponytail: VST3/VST2 host needs vst3sdk + Steinberg APIs — wired in
|
||||||
|
// Giai doan 2 (A6-A8); returns nullptr so the bridge degrades gracefully.
|
||||||
|
case InstrumentType::VST3:
|
||||||
|
case InstrumentType::VST2:
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type,
|
||||||
|
const std::string& path, double sampleRate,
|
||||||
|
uint32_t blockSize) {
|
||||||
|
if (channel >= 16) return false;
|
||||||
|
auto inst = create_instrument(type);
|
||||||
|
if (!inst) return false;
|
||||||
|
if (!inst->init(sampleRate, blockSize)) return false;
|
||||||
|
bool loaded = false;
|
||||||
|
if (type == InstrumentType::SOUNDFONT_SF2_SF3)
|
||||||
|
loaded = static_cast<FluidSynthInstrument*>(inst.get())->loadSoundFontFile(path, sampleRate);
|
||||||
|
else if (type == InstrumentType::SFZ)
|
||||||
|
loaded = static_cast<SfizzInstrument*>(inst.get())->loadSfzFile(path, sampleRate);
|
||||||
|
if (!loaded) return false;
|
||||||
|
// Replacing an existing instrument drops its voices with the old engine.
|
||||||
|
channels_[channel] = std::move(inst);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
INativeInstrument* InstrumentEngineManager::get(uint32_t channel) {
|
||||||
|
auto it = channels_.find(channel);
|
||||||
|
return it == channels_.end() ? nullptr : it->second.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstrumentEngineManager::allNotesOff() {
|
||||||
|
for (auto& [ch, inst] : channels_) {
|
||||||
|
for (uint32_t n = 0; n < 128; ++n) inst->noteOff(ch, n, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstrumentEngineManager::renderAll(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
std::memset(outputL, 0, numSamples * sizeof(float));
|
||||||
|
std::memset(outputR, 0, numSamples * sizeof(float));
|
||||||
|
if (channels_.empty()) return;
|
||||||
|
if (scratchL_.size() < numSamples) {
|
||||||
|
scratchL_.resize(numSamples);
|
||||||
|
scratchR_.resize(numSamples);
|
||||||
|
}
|
||||||
|
for (auto& [ch, inst] : channels_) {
|
||||||
|
std::memset(scratchL_.data(), 0, numSamples * sizeof(float));
|
||||||
|
std::memset(scratchR_.data(), 0, numSamples * sizeof(float));
|
||||||
|
inst->processAudioBlock(scratchL_.data(), scratchR_.data(), numSamples);
|
||||||
|
for (uint32_t i = 0; i < numSamples; ++i) {
|
||||||
|
outputL[i] += scratchL_[i];
|
||||||
|
outputR[i] += scratchR_[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// native_bridge/src/SharedMemoryIPC.cpp
|
||||||
|
#include "SharedMemoryIPC.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// ── Platform shared-memory helpers (used by main.cpp on Windows and by the
|
||||||
|
// self-check test on POSIX). The DAW (Rust) creates the mapping; the bridge
|
||||||
|
// opens it. Layout must match the Rust struct (see src-tauri/src/shm.rs).
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
struct ShmHandle {
|
||||||
|
HANDLE map = nullptr;
|
||||||
|
void* view = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
ShmHandle* shm_open(const char* name) {
|
||||||
|
ShmHandle* h = new ShmHandle();
|
||||||
|
h->map = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, name);
|
||||||
|
if (!h->map) { delete h; return nullptr; }
|
||||||
|
h->view = MapViewOfFile(h->map, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!h->view) { CloseHandle(h->map); delete h; return nullptr; }
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
void shm_close(ShmHandle* h) {
|
||||||
|
if (!h) return;
|
||||||
|
if (h->view) UnmapViewOfFile(h->view);
|
||||||
|
if (h->map) CloseHandle(h->map);
|
||||||
|
delete h;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
struct ShmHandle {
|
||||||
|
int fd = -1;
|
||||||
|
void* view = nullptr;
|
||||||
|
std::string name;
|
||||||
|
};
|
||||||
|
|
||||||
|
ShmHandle* shm_open(const char* name) {
|
||||||
|
size_t sz = sizeof(SharedAudioBufferIPC);
|
||||||
|
int fd = ::shm_open(name, O_CREAT | O_RDWR, 0666);
|
||||||
|
if (fd < 0) return nullptr;
|
||||||
|
if (ftruncate(fd, (off_t)sz) != 0) { ::close(fd); return nullptr; }
|
||||||
|
void* view = mmap(nullptr, sz, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||||
|
if (view == MAP_FAILED) { ::close(fd); return nullptr; }
|
||||||
|
ShmHandle* h = new ShmHandle();
|
||||||
|
h->fd = fd;
|
||||||
|
h->view = view;
|
||||||
|
h->name = name;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
void shm_close(ShmHandle* h) {
|
||||||
|
if (!h) return;
|
||||||
|
if (h->view) munmap(h->view, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (h->fd >= 0) ::close(h->fd);
|
||||||
|
shm_unlink(h->name.c_str());
|
||||||
|
delete h;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
ShmHandle* shm_open_default() { return shm_open("SonicForge_DAW_IPC"); }
|
||||||
|
|
||||||
|
SharedAudioBufferIPC* shm_ptr(ShmHandle* h) {
|
||||||
|
return h ? static_cast<SharedAudioBufferIPC*>(h->view) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shm_write_midi(ShmHandle* h, uint8_t cmd, uint8_t channel, uint8_t pitch,
|
||||||
|
uint8_t velocity, uint32_t sampleOffset, uint8_t data2 = 0, uint8_t data3 = 0) {
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
if (!ipc) return false;
|
||||||
|
// Ring overwrite guard: keep at most queue capacity pending events.
|
||||||
|
if (ipc->midiQueueCount >= 64) return false;
|
||||||
|
uint32_t i = ipc->midiQueueCount++;
|
||||||
|
SharedAudioBufferIPC::MidiEventIPC& e = ipc->midiQueue[i];
|
||||||
|
e.command = cmd;
|
||||||
|
e.channel = channel;
|
||||||
|
e.pitch = pitch;
|
||||||
|
e.velocity = velocity;
|
||||||
|
e.data2 = data2;
|
||||||
|
e.data3 = data3;
|
||||||
|
e.sampleOffset = sampleOffset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shm_write_control(ShmHandle* h, uint32_t type, uint32_t arg0, uint32_t arg1,
|
||||||
|
uint32_t channel, const char* path) {
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
if (!ipc) return false;
|
||||||
|
if (ipc->controlQueueCount >= 8) return false;
|
||||||
|
uint32_t i = ipc->controlQueueCount++;
|
||||||
|
SharedAudioBufferIPC::ControlEventIPC& c = ipc->controlQueue[i];
|
||||||
|
c.type = type;
|
||||||
|
c.arg0 = arg0;
|
||||||
|
c.channel = channel;
|
||||||
|
std::memset(c.arg2, 0, sizeof(c.arg2));
|
||||||
|
if (path && type == 2 /*LOAD*/) {
|
||||||
|
c.arg1 = (uint32_t)std::strlen(path); // path length in arg1
|
||||||
|
if (c.arg1 < sizeof(c.arg2)) std::memcpy(c.arg2, path, c.arg1);
|
||||||
|
} else {
|
||||||
|
c.arg1 = arg1; // TRANSPORT playhead / OPEN_GUI hwnd etc.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// native_bridge/src/Vst3Instrument.cpp
|
||||||
|
// VST3 host via the Steinberg VST3 SDK (submodule vst3sdk/).
|
||||||
|
//
|
||||||
|
// Without the SDK (HAVE_VST3SDK undefined — vst3sdk submodule missing) every
|
||||||
|
// method is a no-op so the bridge still builds for SF2/SF3/SFZ only.
|
||||||
|
//
|
||||||
|
// With the SDK: load the .vst3 module, create the component + edit controller,
|
||||||
|
// connect them, process MIDI events + audio blocks, and attach the editor
|
||||||
|
// view to a native HWND (openGUI) for the floating GUI (B9).
|
||||||
|
#include "Vst3Instrument.h"
|
||||||
|
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
#include "public.sdk/source/main/pluginfactory.h"
|
||||||
|
#include "pluginterfaces/base/ibstream.h"
|
||||||
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
|
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmidicontrollers.h"
|
||||||
|
#include "pluginterfaces/gui/iplugview.h"
|
||||||
|
|
||||||
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// With-SDK implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
#include "public.sdk/source/main/module.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
using Steinberg::Vst::IComponent;
|
||||||
|
using Steinberg::Vst::IEditController;
|
||||||
|
using Steinberg::Vst::IAudioProcessor;
|
||||||
|
using Steinberg::Vst::IComponentHandler;
|
||||||
|
using Steinberg::Vst::IParameterChanges;
|
||||||
|
using Steinberg::Vst::IEventList;
|
||||||
|
using Steinberg::Vst::Event;
|
||||||
|
using Steinberg::Vst::NoteOnEvent;
|
||||||
|
using Steinberg::Vst::NoteOffEvent;
|
||||||
|
using Steinberg::Vst::DataEvent;
|
||||||
|
using Steinberg::Vst::kMidiCC;
|
||||||
|
using Steinberg::Vst::kMidiPitchBend;
|
||||||
|
using Steinberg::Vst::kMidiProgramChange;
|
||||||
|
using Steinberg::IPlugView;
|
||||||
|
using Steinberg::tresult;
|
||||||
|
using Steinberg::kResultOk;
|
||||||
|
using Steinberg::kResultFalse;
|
||||||
|
|
||||||
|
// Minimal IComponentHandler so the plugin can inform the host of param edits.
|
||||||
|
class HostComponentHandler : public IComponentHandler {
|
||||||
|
public:
|
||||||
|
Steinberg::tresult queryInterface(const Steinberg::TUID&, void** v) override {
|
||||||
|
*v = nullptr;
|
||||||
|
return Steinberg::kNoInterface;
|
||||||
|
}
|
||||||
|
Steinberg::uint32 addRef() override { return 1; }
|
||||||
|
Steinberg::uint32 release() override { return 1; }
|
||||||
|
Steinberg::tresult beginEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||||
|
Steinberg::tresult performEdit(Steinberg::Vst::ParamID, Steinberg::Vst::ParamValue) override {
|
||||||
|
return Steinberg::kResultOk;
|
||||||
|
}
|
||||||
|
Steinberg::tresult endEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||||
|
Steinberg::tresult restartComponent(Steinberg::int32) override { return Steinberg::kResultOk; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bundle both components in one factory entry so module.load() gives us the
|
||||||
|
// component; controller is created via IComponent::createController.
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Vst3Instrument::Vst3Instrument()
|
||||||
|
: module_(nullptr),
|
||||||
|
processor_(nullptr),
|
||||||
|
controller_(nullptr),
|
||||||
|
view_(nullptr),
|
||||||
|
sampleRate_(44100.0),
|
||||||
|
maxBlockSize_(256),
|
||||||
|
loaded_(false),
|
||||||
|
guiAttached_(false) {}
|
||||||
|
|
||||||
|
Vst3Instrument::~Vst3Instrument() { closeGUI(); }
|
||||||
|
|
||||||
|
bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
(void)path; (void)sampleRate;
|
||||||
|
return false; // vst3sdk submodule missing — VST3 disabled
|
||||||
|
#else
|
||||||
|
if (loaded_) return true;
|
||||||
|
// ponytail: vst3sdk module loading is platform-specific
|
||||||
|
// (Module::create on Windows .vst3 bundle / macOS .vst3 framework). Keep
|
||||||
|
// the classic pluginfactory-based load for Windows bundles:
|
||||||
|
// void* handle = Steinberg::Vst::Module::create(...)
|
||||||
|
// and use module->getFactory().createInstance<...>(cid).
|
||||||
|
// Verified on Windows in A6 (G3 Vital.vst3). Until then VST3 returns false
|
||||||
|
// so SF2/SFZ keep working.
|
||||||
|
(void)path; (void)sampleRate;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Vst3Instrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
maxBlockSize_ = maxBlockSize;
|
||||||
|
return loaded_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||||
|
(void)channel; (void)bank; (void)program;
|
||||||
|
// ponytail: needs IEditController::setParamNormalized on program list params
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
(void)channel; (void)pitch; (void)velocity; (void)sampleOffset;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!processor_) return;
|
||||||
|
// TODO(A6): queue NoteOnEvent into the per-block event list; see
|
||||||
|
// processAudioBlock. Wiring requires IAudioProcessor::process with
|
||||||
|
// ProcessData — implemented together with loadPlugin on Windows.
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
(void)channel; (void)pitch; (void)sampleOffset;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!processor_) return;
|
||||||
|
// TODO(A6): queue NoteOffEvent (see noteOn)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
(void)channel; (void)cc; (void)value;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): controller_->setParamNormalized(kMidiCC | cc, value/127.0)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
(void)channel; (void)program;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): setParamNormalized(kMidiProgramChange | program, ...)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
(void)channel; (void)bend14;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): setParamNormalized(kMidiPitchBend, bend14/16383.0)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Vst3Instrument::openGUI(void* parentWindowHandle) {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
(void)parentWindowHandle;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
if (!processor_ || !controller_ || !parentWindowHandle) return false;
|
||||||
|
if (guiAttached_) return true;
|
||||||
|
// TODO(A6): FUnknownPtr<IPlugView> view(controller_);
|
||||||
|
// view->setFrame(parentWindowHandle); view->attached(...); view_ = view;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::closeGUI() {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (view_ && guiAttached_) {
|
||||||
|
// view_->removed(); view_->setFrame(nullptr);
|
||||||
|
}
|
||||||
|
view_ = nullptr;
|
||||||
|
guiAttached_ = false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
(void)outputL; (void)outputR; (void)numSamples;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
// TODO(A6): ProcessData with 2 output buffers + event list; called by
|
||||||
|
// InstrumentEngineManager::renderAll via processAudioBlock.
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// native_bridge/src/main.cpp
|
||||||
|
// Entry point of daw_vst_bridge.exe — opens shared memory created by the DAW
|
||||||
|
// (Rust/Tauri side), runs the real-time MIDI->audio loop, watches the parent.
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
#include "SharedMemoryIPC.h"
|
||||||
|
#include "NativeInstrumentEngine.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#include <mmsystem.h>
|
||||||
|
#include <process.h>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <thread>
|
||||||
|
#include <chrono>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// --- platform helpers -------------------------------------------------------
|
||||||
|
static bool parent_alive(uint32_t pid) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||||
|
if (!h) return false;
|
||||||
|
CloseHandle(h);
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return pid == 0 || (kill(pid, 0) == 0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_ms(uint32_t ms) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
Sleep(ms);
|
||||||
|
#else
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
std::cout << "[NativeBridge] Starting DAW Host Bridge Engine..." << std::endl;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (std::strcmp(argv[i], "--shm") == 0) shmName = argv[i + 1];
|
||||||
|
}
|
||||||
|
if (const char* e = std::getenv("SF_SHM_NAME")) shmName = e;
|
||||||
|
|
||||||
|
// Parent watchdog PID (set by Tauri sidecar spawner)
|
||||||
|
uint32_t parentPid = 0;
|
||||||
|
if (const char* e = std::getenv("SF_PARENT_PID")) parentPid = (uint32_t)std::atoi(e);
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Real-time-ish timing: 1ms scheduler resolution
|
||||||
|
timeBeginPeriod(1);
|
||||||
|
HANDLE hMapFile = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, shmName.c_str());
|
||||||
|
if (!hMapFile) {
|
||||||
|
std::cerr << "[NativeBridge] Failed to open Shared Memory mapping: " << shmName << std::endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto* shmIPC = (SharedAudioBufferIPC*)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!shmIPC) { CloseHandle(hMapFile); return 1; }
|
||||||
|
#else
|
||||||
|
(void)shmName; // POSIX shm mapping (shm_open) added when porting off Windows
|
||||||
|
auto* shmIPC = (SharedAudioBufferIPC*)std::calloc(1, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!shmIPC) return 1;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
InstrumentEngineManager instruments;
|
||||||
|
// B8: sample rate from the DAW (Rust spawns us with SF_SAMPLE_RATE).
|
||||||
|
// Block size is fixed by the SHM layout (AUDIO_BLOCK_SIZE) — SF_BLOCK_SIZE
|
||||||
|
// is accepted but must match, otherwise warned and ignored.
|
||||||
|
double sampleRate = 44100.0;
|
||||||
|
if (const char* e = std::getenv("SF_SAMPLE_RATE")) {
|
||||||
|
double sr = (double)std::atoi(e);
|
||||||
|
if (sr > 0) sampleRate = sr;
|
||||||
|
}
|
||||||
|
if (const char* e = std::getenv("SF_BLOCK_SIZE")) {
|
||||||
|
uint32_t b = (uint32_t)std::atoi(e);
|
||||||
|
if (b != AUDIO_BLOCK_SIZE)
|
||||||
|
std::cerr << "[NativeBridge] SHM block size fixed at " << AUDIO_BLOCK_SIZE
|
||||||
|
<< " (SF_BLOCK_SIZE=" << b << " ignored)" << std::endl;
|
||||||
|
}
|
||||||
|
const uint32_t block = AUDIO_BLOCK_SIZE;
|
||||||
|
uint64_t playheadSamples = 0;
|
||||||
|
|
||||||
|
auto dispatch = [&](const SharedAudioBufferIPC::MidiEventIPC& evt) {
|
||||||
|
auto* inst = instruments.get(evt.channel);
|
||||||
|
if (!inst) return; // channel chưa gán instrument → silent (A10)
|
||||||
|
switch (evt.command) {
|
||||||
|
case 0x9:
|
||||||
|
if (evt.velocity > 0)
|
||||||
|
inst->noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f, evt.sampleOffset);
|
||||||
|
else
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||||
|
break;
|
||||||
|
case 0x8:
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||||
|
break;
|
||||||
|
case 0xB: // CC: controller number in pitch, value in data2
|
||||||
|
inst->controlChange(evt.channel, evt.pitch, evt.data2);
|
||||||
|
break;
|
||||||
|
case 0xC: // program change: program in data2
|
||||||
|
inst->programChange(evt.channel, evt.data2);
|
||||||
|
break;
|
||||||
|
case 0xE: // 14-bit pitch bend: data2 = LSB, data3 = MSB
|
||||||
|
inst->pitchBend(evt.channel, evt.data2 | (uint32_t(evt.data3) << 7));
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render only [from, to) of the block — used by sample-accurate splitting.
|
||||||
|
auto renderSegment = [&](uint32_t from, uint32_t to) {
|
||||||
|
if (to <= from) return;
|
||||||
|
instruments.renderAll(shmIPC->masterLeft + from, shmIPC->masterRight + from, to - from);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP
|
||||||
|
while (true) {
|
||||||
|
// A. Control events — non-rt safe, drained first
|
||||||
|
for (uint32_t i = 0; i < shmIPC->controlQueueCount; ++i) {
|
||||||
|
const auto& c = shmIPC->controlQueue[i];
|
||||||
|
if (c.type == 2) { // LOAD_INSTRUMENT (A10: assign per MIDI channel)
|
||||||
|
// Robust path length: do not trust arg1 (Rust passes 0 for LOAD).
|
||||||
|
size_t plen = 0;
|
||||||
|
while (plen < sizeof(c.arg2) && c.arg2[plen]) ++plen;
|
||||||
|
std::string path(c.arg2, plen);
|
||||||
|
InstrumentType t = (InstrumentType)c.arg0;
|
||||||
|
uint32_t ch = c.channel & 0xF;
|
||||||
|
if (instruments.assign(ch, t, path, sampleRate, block)) {
|
||||||
|
std::cout << "[NativeBridge] instrument loaded ch=" << ch
|
||||||
|
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||||
|
} else {
|
||||||
|
std::cerr << "[NativeBridge] instrument load FAILED ch=" << ch
|
||||||
|
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||||
|
}
|
||||||
|
} else if (c.type == 1) { // PANIC
|
||||||
|
instruments.allNotesOff();
|
||||||
|
std::cout << "[NativeBridge] PANIC — all notes off" << std::endl;
|
||||||
|
} else if (c.type == 3) { // TRANSPORT (A13)
|
||||||
|
if (c.arg0 == 0) { // STOP → flush every note immediately
|
||||||
|
instruments.allNotesOff();
|
||||||
|
std::cout << "[NativeBridge] transport STOP — all notes off" << std::endl;
|
||||||
|
} else if (c.arg0 == 1) { // PLAY
|
||||||
|
playheadSamples = c.arg1;
|
||||||
|
std::cout << "[NativeBridge] transport PLAY playhead=" << playheadSamples << std::endl;
|
||||||
|
} else if (c.arg0 == 2) { // SET_POSITION (seek while stopped)
|
||||||
|
playheadSamples = c.arg1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shmIPC->controlQueueCount = 0;
|
||||||
|
|
||||||
|
// B. Snapshot queued MIDI events (bounded copy, queue reset immediately)
|
||||||
|
uint32_t nEvents = shmIPC->midiQueueCount > 64 ? 64 : shmIPC->midiQueueCount;
|
||||||
|
SharedAudioBufferIPC::MidiEventIPC evts[64];
|
||||||
|
for (uint32_t i = 0; i < nEvents; ++i) evts[i] = shmIPC->midiQueue[i];
|
||||||
|
shmIPC->midiQueueCount = 0;
|
||||||
|
|
||||||
|
// A11 sample-accurate: sort by sampleOffset, dispatch at each boundary,
|
||||||
|
// render the sub-block before the boundary. Events with offset 0 (live
|
||||||
|
// keyboard, current JS) are dispatched first and shape the whole block.
|
||||||
|
std::stable_sort(evts, evts + nEvents,
|
||||||
|
[](const SharedAudioBufferIPC::MidiEventIPC& a,
|
||||||
|
const SharedAudioBufferIPC::MidiEventIPC& b) {
|
||||||
|
return a.sampleOffset < b.sampleOffset;
|
||||||
|
});
|
||||||
|
uint32_t cursor = 0;
|
||||||
|
uint32_t ei = 0;
|
||||||
|
while (ei < nEvents && evts[ei].sampleOffset <= cursor) { dispatch(evts[ei]); ++ei; }
|
||||||
|
for (; ei < nEvents; ++ei) {
|
||||||
|
uint32_t off = evts[ei].sampleOffset < block ? evts[ei].sampleOffset : block;
|
||||||
|
if (off > cursor) { renderSegment(cursor, off); cursor = off; }
|
||||||
|
dispatch(evts[ei]);
|
||||||
|
}
|
||||||
|
if (cursor < block) renderSegment(cursor, block);
|
||||||
|
shmIPC->bridgeWriteIndex++;
|
||||||
|
|
||||||
|
// D. Parent died / window closed -> exit (no orphan process)
|
||||||
|
if (parentPid != 0 && !parent_alive(parentPid)) {
|
||||||
|
std::cout << "[NativeBridge] parent gone — exiting." << std::endl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E. Sleep briefly for the next frame tick (block@44100 ≈ 5.8ms)
|
||||||
|
sleep_ms(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
UnmapViewOfFile(shmIPC);
|
||||||
|
CloseHandle(hMapFile);
|
||||||
|
timeEndPeriod(1);
|
||||||
|
#else
|
||||||
|
std::free(shmIPC);
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// native_bridge/tests/shm_selfcheck.cpp
|
||||||
|
// Self-check for SharedMemoryIPC helpers (assert-based, no framework).
|
||||||
|
// POSIX path runs on Linux/macOS; Win32 path guarded (runs on Windows build).
|
||||||
|
#include "../include/SharedMemoryIPC.h"
|
||||||
|
#include "../src/SharedMemoryIPC.cpp"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// Use a test-only name to avoid clashing with a live DAW bridge.
|
||||||
|
ShmHandle* h = shm_open("SonicForge_SelfCheck_IPC");
|
||||||
|
assert(h != nullptr);
|
||||||
|
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
assert(ipc != nullptr);
|
||||||
|
// Reset state (POSIX shm may survive an aborted previous run).
|
||||||
|
std::memset(ipc, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
|
||||||
|
// 1. MIDI event write + read back (A12: data2/data3 for CC/program/pitch bend)
|
||||||
|
assert(shm_write_midi(h, 0x9, 2, 60, 100, 37));
|
||||||
|
assert(ipc->midiQueueCount == 1);
|
||||||
|
assert(ipc->midiQueue[0].command == 0x9);
|
||||||
|
assert(ipc->midiQueue[0].channel == 2);
|
||||||
|
assert(ipc->midiQueue[0].pitch == 60);
|
||||||
|
assert(ipc->midiQueue[0].velocity == 100);
|
||||||
|
assert(ipc->midiQueue[0].sampleOffset == 37);
|
||||||
|
assert(ipc->midiQueue[0].data2 == 0 && ipc->midiQueue[0].data3 == 0);
|
||||||
|
assert(shm_write_midi(h, 0xB, 3, 64, 0, 0, 127)); // CC64 sustain = 127
|
||||||
|
assert(shm_write_midi(h, 0xE, 3, 0, 0, 0, 0x00, 0x40)); // pitch bend MSB
|
||||||
|
assert(ipc->midiQueue[1].data2 == 127);
|
||||||
|
assert(ipc->midiQueue[2].data3 == 0x40);
|
||||||
|
assert(ipc->midiQueueCount == 3);
|
||||||
|
|
||||||
|
// 2. Control event with path + channel (A10)
|
||||||
|
assert(shm_write_control(h, 2, 1 /*SOUNDFONT_SF2_SF3*/, 0, 4, "C:\\sf\\SGM.sf2"));
|
||||||
|
assert(ipc->controlQueueCount == 1);
|
||||||
|
assert(ipc->controlQueue[0].type == 2);
|
||||||
|
assert(ipc->controlQueue[0].arg0 == 1);
|
||||||
|
assert(ipc->controlQueue[0].channel == 4);
|
||||||
|
assert(std::strcmp(ipc->controlQueue[0].arg2, "C:\\sf\\SGM.sf2") == 0);
|
||||||
|
// TRANSPORT (A13): STOP with playhead arg
|
||||||
|
assert(shm_write_control(h, 3, 0, 44100, 0, ""));
|
||||||
|
assert(ipc->controlQueue[1].type == 3 && ipc->controlQueue[1].arg0 == 0
|
||||||
|
&& ipc->controlQueue[1].arg1 == 44100);
|
||||||
|
|
||||||
|
// 3. Queue capacity guard
|
||||||
|
for (int i = 0; i < 70; ++i) shm_write_midi(h, 0x8, 0, 40, 0, 0);
|
||||||
|
assert(ipc->midiQueueCount == 64); // capped, not overflowed
|
||||||
|
|
||||||
|
// 4. Audio block round-trip
|
||||||
|
for (uint32_t i = 0; i < AUDIO_BLOCK_SIZE; ++i) ipc->masterLeft[i] = (float)i;
|
||||||
|
ipc->bridgeWriteIndex++;
|
||||||
|
ipc->blockTimestamp = 123456;
|
||||||
|
assert(ipc->masterLeft[255] == 255.0f);
|
||||||
|
assert(ipc->bridgeWriteIndex == 1);
|
||||||
|
assert(ipc->blockTimestamp == 123456);
|
||||||
|
|
||||||
|
// 5. Layout stability — MUST match Rust src-tauri/src/shm.rs
|
||||||
|
assert(sizeof(SharedAudioBufferIPC::MidiEventIPC) == 12);
|
||||||
|
assert(sizeof(SharedAudioBufferIPC::ControlEventIPC) == 1040);
|
||||||
|
|
||||||
|
std::printf("SHM self-check OK (sizeof struct = %zu bytes)\n", sizeof(SharedAudioBufferIPC));
|
||||||
|
shm_close(h);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "daw-vst-bridge",
|
||||||
|
"version-string": "1.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
"fluidsynth",
|
||||||
|
"sfizz",
|
||||||
|
"pkgconf"
|
||||||
|
]
|
||||||
|
}
|
||||||
Submodule
+1
Submodule native_bridge/vst3sdk added at 3cdf9ca5d1
@@ -17,6 +17,11 @@ tauri-plugin-shell = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Shared memory (SonicForge_DAW_IPC) for the Native Host Bridge
|
||||||
|
windows-sys = { version = "0.59", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_MemoryManagement",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
+305
-1
@@ -10,14 +10,25 @@
|
|||||||
// "daw_engine/" — đúng layout lib.rs chờ.
|
// "daw_engine/" — đúng layout lib.rs chờ.
|
||||||
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
|
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
|
||||||
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
|
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
|
||||||
use tauri::Manager;
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
use tauri_plugin_shell::process::CommandChild;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
mod shm;
|
||||||
|
use shm::{Shm, ShmState};
|
||||||
|
|
||||||
struct EngineProcess(Mutex<Option<CommandChild>>);
|
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||||
|
struct BridgeProcess(Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
|
/// Audio frame pushed to the WebView (bridge SHM -> `bridge-audio` event).
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct AudioFrame {
|
||||||
|
l: Vec<f32>,
|
||||||
|
r: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
|
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
|
||||||
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
||||||
@@ -46,6 +57,78 @@ fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Các vị trí có thể chứa daw_vst_bridge (Native Host Bridge sidecar).
|
||||||
|
fn bridge_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
||||||
|
let exe_name = if cfg!(windows) { "daw_vst_bridge.exe" } else { "daw_vst_bridge" };
|
||||||
|
let triple_name = if cfg!(windows) { "daw_vst_bridge-x86_64-pc-windows-msvc.exe" } else { exe_name };
|
||||||
|
vec![
|
||||||
|
(res_dir.join("binaries").join(exe_name), "resource_dir/binaries (bundle)".into()),
|
||||||
|
(res_dir.join(exe_name), "resource_dir (bundle root)".into()),
|
||||||
|
(res_dir.join("binaries").join(triple_name), "resource_dir/binaries (triple name)".into()),
|
||||||
|
(exe_dir.join(exe_name), "exe_dir (portable)".into()),
|
||||||
|
(exe_dir.join("..").join("resources").join("binaries").join(exe_name), "exe_dir/../resources/binaries (dev)".into()),
|
||||||
|
(exe_dir.join("..").join("resources").join(exe_name), "exe_dir/../resources (dev)".into()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn daw_vst_bridge.exe sidecar (B3/B8) with SHM + watchdog + audio env.
|
||||||
|
/// Appends candidate diagnostics to spawn.log; manages `BridgeProcess`.
|
||||||
|
/// Returns true when the child started.
|
||||||
|
fn spawn_bridge(
|
||||||
|
app: &AppHandle,
|
||||||
|
res_dir: &Path,
|
||||||
|
exe_dir: &Path,
|
||||||
|
log_line: &mut String,
|
||||||
|
spawn_log_path: &Path,
|
||||||
|
) -> bool {
|
||||||
|
let candidates = bridge_candidates(res_dir, exe_dir);
|
||||||
|
let mut found: Option<(PathBuf, String)> = None;
|
||||||
|
for (path, label) in &candidates {
|
||||||
|
let exists = path.exists();
|
||||||
|
log_line.push_str(&format!(" [bridge {label}] {} exists={}\n", path.display(), exists));
|
||||||
|
if found.is_none() && exists {
|
||||||
|
found = Some((path.clone(), label.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(spawn_log_path)
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = f.write_all(log_line.as_bytes());
|
||||||
|
}
|
||||||
|
match found {
|
||||||
|
Some((bridge_exe, label)) => {
|
||||||
|
match app
|
||||||
|
.shell()
|
||||||
|
.command(&bridge_exe)
|
||||||
|
.env("SF_SHM_NAME", shm::SHM_NAME)
|
||||||
|
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||||
|
.env("SF_SAMPLE_RATE", "44100") // B8: JS resampler handles mismatches (C5)
|
||||||
|
.env("SF_BLOCK_SIZE", "256")
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
app.manage(BridgeProcess(Mutex::new(Some(child))));
|
||||||
|
println!("Native Host Bridge started ({label}): {}", bridge_exe.display());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to spawn daw_vst_bridge {bridge_exe:?}: {e}");
|
||||||
|
app.manage(BridgeProcess(Mutex::new(None)));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!("daw_vst_bridge binary not found — app will use WASM fallback");
|
||||||
|
app.manage(BridgeProcess(Mutex::new(None)));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn list_dir_snippet(dir: &Path) -> String {
|
fn list_dir_snippet(dir: &Path) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
match std::fs::read_dir(dir) {
|
match std::fs::read_dir(dir) {
|
||||||
@@ -73,6 +156,13 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
push_midi_event,
|
||||||
|
load_native_instrument,
|
||||||
|
open_vst_gui,
|
||||||
|
bridge_status,
|
||||||
|
transport_control
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let res_dir = app
|
let res_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -157,6 +247,91 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Native Host Bridge (daw_vst_bridge.exe): SHM + sidecar spawn ──
|
||||||
|
let shm_created = Shm::create();
|
||||||
|
println!(
|
||||||
|
"SharedMemory {}: {}",
|
||||||
|
shm::SHM_NAME,
|
||||||
|
if shm_created.is_some() { "created" } else { "unavailable (non-windows or error)" }
|
||||||
|
);
|
||||||
|
app.manage(ShmState(Mutex::new(shm_created)));
|
||||||
|
|
||||||
|
// Append bridge diagnostics to spawn.log and spawn the sidecar (B3).
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
let _ = spawn_bridge(&app_handle, &res_dir, &exe_dir, &mut log_line, &spawn_log_path);
|
||||||
|
|
||||||
|
// ── Audio pump: bridge SHM -> WebView `bridge-audio` events ──
|
||||||
|
// B10 health: if bridgeWriteIndex stalls for 3s the bridge is dead —
|
||||||
|
// respawn once, then emit `bridge-down` so the UI falls back to WASM.
|
||||||
|
let pump_handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut last_index: u32 = 0;
|
||||||
|
let mut last_change = std::time::Instant::now();
|
||||||
|
let mut down_emitted = false;
|
||||||
|
let mut restart_attempts = 0u32;
|
||||||
|
loop {
|
||||||
|
let state = pump_handle.state::<ShmState>();
|
||||||
|
let guard = match state.0.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let advanced = guard.as_ref().map(|shm| {
|
||||||
|
let idx = shm.write_index();
|
||||||
|
if idx != last_index {
|
||||||
|
last_index = idx;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if advanced == Some(true) {
|
||||||
|
last_change = std::time::Instant::now();
|
||||||
|
down_emitted = false;
|
||||||
|
let (l, r) = guard.as_ref().map(|s| s.read_audio()).unwrap_or(([0f32; shm::AUDIO_BLOCK_SIZE], [0f32; shm::AUDIO_BLOCK_SIZE]));
|
||||||
|
let _ = pump_handle.emit(
|
||||||
|
"bridge-audio",
|
||||||
|
AudioFrame { l: l.to_vec(), r: r.to_vec() },
|
||||||
|
);
|
||||||
|
} else if last_change.elapsed() >= std::time::Duration::from_secs(3) && !down_emitted {
|
||||||
|
down_emitted = true;
|
||||||
|
if restart_attempts == 0 {
|
||||||
|
restart_attempts += 1;
|
||||||
|
let res_dir = pump_handle.path().resource_dir().unwrap_or_default();
|
||||||
|
let exe_dir = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||||
|
let log_path = std::path::Path::new(&log_dir)
|
||||||
|
.join("SonicForgeDAW").join("logs").join("spawn.log");
|
||||||
|
let mut line = String::from("[tauri] bridge stalled 3s — restart attempt #1\n");
|
||||||
|
if spawn_bridge(&pump_handle, &res_dir, &exe_dir, &mut line, &log_path) {
|
||||||
|
// count restarts into bridge.log (same file as stdout redirect)
|
||||||
|
let bridge_log = std::path::Path::new(&log_dir)
|
||||||
|
.join("SonicForgeDAW").join("logs").join("bridge.log");
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true).append(true).open(&bridge_log)
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = f.write_all(b"[tauri] bridge restarted (attempt 1)\n");
|
||||||
|
}
|
||||||
|
last_change = std::time::Instant::now();
|
||||||
|
down_emitted = false;
|
||||||
|
} else {
|
||||||
|
let _ = pump_handle.emit("bridge-down", ());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = pump_handle.emit("bridge-down", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
|
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
|
||||||
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
|
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
|
||||||
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
|
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
|
||||||
@@ -226,8 +401,137 @@ pub fn run() {
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
println!("daw_engine sidecar terminated.");
|
println!("daw_engine sidecar terminated.");
|
||||||
}
|
}
|
||||||
|
// Terminate Native Host Bridge khi DAW window dong
|
||||||
|
let bridge_child = window
|
||||||
|
.state::<BridgeProcess>()
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut lock| lock.take());
|
||||||
|
if let Some(child) = bridge_child {
|
||||||
|
let _ = child.kill();
|
||||||
|
println!("daw_vst_bridge sidecar terminated.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Native Host Bridge Tauri commands ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn push_midi_event(
|
||||||
|
app: AppHandle,
|
||||||
|
command: u8,
|
||||||
|
channel: u8,
|
||||||
|
pitch: u8,
|
||||||
|
velocity: u8,
|
||||||
|
data2: u8,
|
||||||
|
data3: u8,
|
||||||
|
sample_offset: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
shm.push_midi(command, channel, pitch, velocity, data2, data3, sample_offset)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("midi queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn load_native_instrument(app: AppHandle, path: String, instrument_type: u8, channel: u8) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
shm.push_control(2, instrument_type as u32, 0, channel as u32, &path)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_vst_gui(app: AppHandle, plugin_id: String) -> Result<(), String> {
|
||||||
|
// B9: child WebviewWindow acts as the VST GUI surface. The bridge receives
|
||||||
|
// its HWND (control type=4, arg1) so the VST3 editor can be attached (A7).
|
||||||
|
let url = tauri::WebviewUrl::External(
|
||||||
|
tauri::Url::parse("data:text/html,<h2>VST GUI</h2><p>Editor attaches from daw_vst_bridge (A7).</p>")
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
let win = tauri::WebviewWindowBuilder::new(&app, format!("vst-{}", plugin_id), url)
|
||||||
|
.title(format!("VST — {}", plugin_id))
|
||||||
|
.inner_size(800.0, 600.0)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
// ponytail: HWND truncated to u32 (HWNDs fit in practice); window-close →
|
||||||
|
// close_gui wiring lands together with A7 on Windows.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let hwnd = {
|
||||||
|
use tauri::raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
match win.window_handle().map(|h| h.as_raw()) {
|
||||||
|
Ok(RawWindowHandle::Win32(w)) => w.hwnd as usize as u32,
|
||||||
|
_ => 0u32,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let hwnd = 0u32;
|
||||||
|
shm.push_control(4, 0, hwnd, 0, &plugin_id)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct BridgeStatus {
|
||||||
|
connected: bool,
|
||||||
|
shm_name: &'static str,
|
||||||
|
write_index: u32,
|
||||||
|
block_timestamp: u64,
|
||||||
|
shm_size_bytes: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
block_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(match guard.as_ref() {
|
||||||
|
Some(shm) => BridgeStatus {
|
||||||
|
connected: true,
|
||||||
|
shm_name: shm::SHM_NAME,
|
||||||
|
write_index: shm.write_index(),
|
||||||
|
block_timestamp: shm.block_timestamp(),
|
||||||
|
shm_size_bytes: std::mem::size_of::<shm::SharedAudioBufferIPC>(),
|
||||||
|
sample_rate: 44100,
|
||||||
|
block_size: shm::AUDIO_BLOCK_SIZE as u32,
|
||||||
|
},
|
||||||
|
None => BridgeStatus {
|
||||||
|
connected: false,
|
||||||
|
shm_name: shm::SHM_NAME,
|
||||||
|
write_index: 0,
|
||||||
|
block_timestamp: 0,
|
||||||
|
shm_size_bytes: 0,
|
||||||
|
sample_rate: 0,
|
||||||
|
block_size: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn transport_control(app: AppHandle, kind: String, playhead: Option<u32>) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
// arg0: 0=STOP (flush), 1=PLAY, 2=SET_POSITION (seek, no flush)
|
||||||
|
let (arg0, arg1) = match kind.as_str() {
|
||||||
|
"play" => (1, playhead.unwrap_or(0)),
|
||||||
|
"set_position" => (2, playhead.unwrap_or(0)),
|
||||||
|
_ => (0, playhead.unwrap_or(0)), // stop / panic
|
||||||
|
};
|
||||||
|
shm.push_control(3, arg0, arg1, 0, "")
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// src-tauri/src/shm.rs
|
||||||
|
// Shared memory `SonicForge_DAW_IPC` — created by the DAW, opened by the
|
||||||
|
// C++ bridge (native_bridge). Layout MUST match native_bridge/include/
|
||||||
|
// SharedMemoryIPC.h (verified by native_bridge/tests/shm_selfcheck.cpp).
|
||||||
|
#![cfg_attr(not(windows), allow(dead_code))]
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
use windows_sys::Win32::System::MemoryManagement::{
|
||||||
|
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, PAGE_READWRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SHM_NAME: &str = "SonicForge_DAW_IPC";
|
||||||
|
pub const AUDIO_BLOCK_SIZE: usize = 256;
|
||||||
|
pub const MIDI_QUEUE_CAP: usize = 64;
|
||||||
|
pub const CONTROL_QUEUE_CAP: usize = 8;
|
||||||
|
pub const CONTROL_PATH_MAX: usize = 1024;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct MidiEventIPC {
|
||||||
|
pub command: u8,
|
||||||
|
pub channel: u8,
|
||||||
|
pub pitch: u8,
|
||||||
|
pub velocity: u8,
|
||||||
|
pub data2: u8, // CC value / program / PB LSB
|
||||||
|
pub data3: u8, // PB MSB (0xE only)
|
||||||
|
pub reserved: [u8; 2],
|
||||||
|
pub sample_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ControlEventIPC {
|
||||||
|
pub ctype: u32,
|
||||||
|
pub arg0: u32,
|
||||||
|
pub arg1: u32,
|
||||||
|
pub channel: u32, // LOAD: MIDI channel to assign (A10)
|
||||||
|
pub arg2: [u8; CONTROL_PATH_MAX],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct SharedAudioBufferIPC {
|
||||||
|
pub client_read_index: u32,
|
||||||
|
pub bridge_write_index: u32,
|
||||||
|
pub master_left: [f32; AUDIO_BLOCK_SIZE],
|
||||||
|
pub master_right: [f32; AUDIO_BLOCK_SIZE],
|
||||||
|
pub block_timestamp: u64,
|
||||||
|
pub midi_queue: [MidiEventIPC; MIDI_QUEUE_CAP],
|
||||||
|
pub midi_queue_count: u32,
|
||||||
|
pub control_queue: [ControlEventIPC; CONTROL_QUEUE_CAP],
|
||||||
|
pub control_queue_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Shm {
|
||||||
|
handle: HANDLE,
|
||||||
|
view: *mut SharedAudioBufferIPC,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for Shm {}
|
||||||
|
|
||||||
|
impl Shm {
|
||||||
|
/// Create (or open) the mapping. Returns None on non-Windows (dev fallback).
|
||||||
|
pub fn create() -> Option<Self> {
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name_wide: Vec<u16> = SHM_NAME.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let size = std::mem::size_of::<SharedAudioBufferIPC>() as u64;
|
||||||
|
let handle = unsafe {
|
||||||
|
CreateFileMappingW(
|
||||||
|
HANDLE::default(),
|
||||||
|
ptr::null(),
|
||||||
|
PAGE_READWRITE,
|
||||||
|
(size >> 32) as u32,
|
||||||
|
size as u32,
|
||||||
|
name_wide.as_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if handle == HANDLE::default() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, 0) };
|
||||||
|
if view.is_null() {
|
||||||
|
unsafe { CloseHandle(handle) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Shm {
|
||||||
|
handle,
|
||||||
|
view: view as *mut SharedAudioBufferIPC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipc(&self) -> &mut SharedAudioBufferIPC {
|
||||||
|
unsafe { &mut *self.view }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_midi(&self, cmd: u8, channel: u8, pitch: u8, velocity: u8, data2: u8, data3: u8, sample_offset: u32) -> bool {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
if ipc.midi_queue_count as usize >= MIDI_QUEUE_CAP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let i = ipc.midi_queue_count as usize;
|
||||||
|
ipc.midi_queue[i] = MidiEventIPC {
|
||||||
|
command: cmd,
|
||||||
|
channel,
|
||||||
|
pitch,
|
||||||
|
velocity,
|
||||||
|
data2,
|
||||||
|
data3,
|
||||||
|
reserved: [0u8; 2],
|
||||||
|
sample_offset,
|
||||||
|
};
|
||||||
|
ipc.midi_queue_count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_control(&self, ctype: u32, arg0: u32, arg1: u32, channel: u32, path: &str) -> bool {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
if ipc.control_queue_count as usize >= CONTROL_QUEUE_CAP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ev = ControlEventIPC {
|
||||||
|
ctype,
|
||||||
|
arg0,
|
||||||
|
arg1,
|
||||||
|
channel,
|
||||||
|
arg2: [0u8; CONTROL_PATH_MAX],
|
||||||
|
};
|
||||||
|
let bytes = path.as_bytes();
|
||||||
|
let n = bytes.len().min(CONTROL_PATH_MAX - 1);
|
||||||
|
ev.arg2[..n].copy_from_slice(&bytes[..n]);
|
||||||
|
// LOAD relies on C++ side strnlen() — arg1 is free for TRANSPORT playhead.
|
||||||
|
let i = ipc.control_queue_count as usize;
|
||||||
|
ipc.control_queue[i] = ev;
|
||||||
|
ipc.control_queue_count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_audio(&self) -> ([f32; AUDIO_BLOCK_SIZE], [f32; AUDIO_BLOCK_SIZE]) {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
(ipc.master_left, ipc.master_right)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_index(&self) -> u32 {
|
||||||
|
self.ipc().bridge_write_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block_timestamp(&self) -> u64 {
|
||||||
|
self.ipc().block_timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Shm {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.view.is_null() {
|
||||||
|
unsafe { UnmapViewOfFile(self.view as *const c_void) };
|
||||||
|
}
|
||||||
|
unsafe { CloseHandle(self.handle) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide shared-memory state for Tauri.
|
||||||
|
pub struct ShmState(pub Mutex<Option<Shm>>);
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"resources/daw_engine": "daw_engine/"
|
"resources/daw_engine": "daw_engine/"
|
||||||
},
|
},
|
||||||
"externalBin": [],
|
"externalBin": ["binaries/daw_vst_bridge"],
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installerHooks": "hooks.nsh"
|
"installerHooks": "hooks.nsh"
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
REQUIRED = ["app/static", "app/templates"]
|
REQUIRED = ["app/static", "app/templates"]
|
||||||
|
# F4: bridge sidecar phai co truoc khi tauri build (externalBin). Kiem tra tai
|
||||||
|
# day de build fail SOM nhat (build_windows.ps1 goi verify nay truoc tauri build).
|
||||||
|
BRIDGE_SIDECAR = os.path.join(
|
||||||
|
"src-tauri", "binaries", "daw_vst_bridge-x86_64-pc-windows-msvc.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_toc(build_dir: str) -> str:
|
def find_toc(build_dir: str) -> str:
|
||||||
@@ -31,6 +36,7 @@ def find_toc(build_dir: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
check_bridge = "--check-bridge" in sys.argv
|
||||||
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
||||||
toc = find_toc(build_dir)
|
toc = find_toc(build_dir)
|
||||||
if not toc:
|
if not toc:
|
||||||
@@ -54,6 +60,19 @@ def main() -> int:
|
|||||||
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
||||||
return 1
|
return 1
|
||||||
print("OK: app/static + app/templates co trong bundle.")
|
print("OK: app/static + app/templates co trong bundle.")
|
||||||
|
# F4: bridge sidecar (externalBin) — kiem tra o repo root (khong phai trong
|
||||||
|
# PyInstaller bundle): thieu -> tauri build se im lang bo qua bridge.
|
||||||
|
# Chi chay khi --check-bridge (goi o buoc [7/7] build_windows.ps1 — sau
|
||||||
|
# khi da build bridge o buoc [5/7]).
|
||||||
|
if check_bridge:
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
sidecar = os.path.join(repo_root, BRIDGE_SIDECAR)
|
||||||
|
if not os.path.exists(sidecar):
|
||||||
|
print(f"ERROR: Thieu bridge sidecar: {sidecar}")
|
||||||
|
print(" Chay build/scripts/build_native_bridge.ps1 truoc, hoac")
|
||||||
|
print(" build_windows.ps1 buoc [5/7] se build tu dong.")
|
||||||
|
return 1
|
||||||
|
print(f"OK: bridge sidecar co mat ({BRIDGE_SIDECAR}).")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user