Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f59c2c4c2 | |||
| d168328004 | |||
| 49549aba2e | |||
| f8a981b27f | |||
| 36fb546d58 | |||
| aac0f03e20 | |||
| d8f8506077 | |||
| c3368d0a91 |
@@ -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
|
||||||
+5
-10
@@ -33,18 +33,13 @@ node_modules
|
|||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/binaries/
|
src-tauri/binaries/
|
||||||
src-tauri/resources/daw_engine/
|
src-tauri/resources/daw_engine/
|
||||||
src-tauri/resources/native_host/*
|
|
||||||
!src-tauri/resources/native_host/.gitkeep
|
|
||||||
!src-tauri/resources/native_host/*.dll
|
|
||||||
src-tauri/vc_redist.x64.exe
|
src-tauri/vc_redist.x64.exe
|
||||||
app/storage/plugin_dirs.json
|
app/storage/plugin_dirs.json
|
||||||
app/storage/sf_scan_state.json.bak-root
|
app/storage/sf_scan_state.json.bak-root
|
||||||
app/storage/soundfonts/
|
app/storage/soundfonts/
|
||||||
|
|
||||||
# Native host build artifacts
|
# build-time vendor trees & accidental artifacts (never commit)
|
||||||
native_host/tests/*.dll
|
native_bridge/vcpkg_installed/
|
||||||
native_host/tests/*.exp
|
native_bridge/sfizz-src/
|
||||||
native_host/tests/*.lib
|
app/static/js/_diag.txt
|
||||||
native_host/tests/*.obj
|
src-tauri/resources/daw_engine" && cp -r*
|
||||||
native_host/tests/*.exe
|
|
||||||
native_host/fluidsynth_runtime/
|
|
||||||
|
|||||||
@@ -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,247 @@
|
|||||||
|
# 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.
|
||||||
|
> **BATCH YÊU CẦU 1-2 (2026-08-12, 2 luồng bắt buộc):** SF2→âm (Req 1) + VST3 GUI→âm (Req 2):
|
||||||
|
> 1. **FIX âm thanh (critical):** `bridgeAudioNode.js` ScriptProcessor 4096 chỉ đổ 1 block 256/callback →
|
||||||
|
> 15/16 câm → viết lại FIFO drain toàn bộ block/callback, RING_DEPTH 8→24 (~139ms, đủ 1 chu kỳ callback).
|
||||||
|
> 2. **FIX VST3 GUI:** `openNativeGUI` trước đây KHÔNG có caller → wire vào `loadTrackInstrumentToBridge`
|
||||||
|
> (nút Synth/track chọn VST3) + `loadToBridge` (Plugin Manager "Load Bridge" VST) sau loadInstrument OK.
|
||||||
|
> 3. **FIX race C++:** main.cpp type=4 (OPEN_GUI) poll ≤10s chờ LOAD hoàn tất trên worker trước khi attach
|
||||||
|
> (trước: check ngay → "no instrument loaded" khi VST3 init chưa xong).
|
||||||
|
> C++ VST3 host thật đã có đủ (Vst3Instrument.cpp 592 dòng, HAVE_VST3SDK). Bundle rebuilt OK (1209801 B);
|
||||||
|
> `bridgeAudioNode.js` + `app.precompiled.js` version bump trong index.html. Chạy Windows theo TEST_NOTES.
|
||||||
|
> **KẾT QUẢ TEST WINDOWS 2026-08-12:** cả 2 yêu cầu PASS — SF2 (024_BigSymphny) PR play peak 0.026468; VST3 ACDD: native GUI mở (class SonicForge_Native_VST3_Class), keybed 0.115554, dispatch 0.153984, PR play nút Play 0.073984. Chi tiết: TEST_NOTES.md §“Kết quả test THẬT Windows”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,88 @@
|
|||||||
|
# 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)
|
||||||
|
- [x] C5: ring buffer 8 block chống underrun thật trên Windows (âm nghe được, VU nhảy) — **FIX 2026-08-12:** ScriptProcessor 4096 chỉ đổ 1 block 256/callback → 15/16 câm; đã sửa FIFO drain toàn bộ block/callback + RING_DEPTH 8→24 (đủ 1 chu kỳ callback ~139ms). Chạy thật Windows xác nhận.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- [x] D1-D2 thật: bấm keyboard + play piano roll → âm qua bridge, Stop hết âm ngân — **YÊU CẦU 1:** SF2 load vào track (nút Synth) → bridge channel = `allocateChannel(trackId)`; keyboard/play item pushEvent channel trùng → C++ FluidSynth render → pump → BridgeAudioNode (đã fix FIFO). Cần chạy Windows xác nhận âm.
|
||||||
|
- [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] **YÊU CẦU 2 (VST3 GUI):** chọn VST3 qua nút Synth → `loadTrackInstrumentToBridge` load bridge + `openNativeGUI(instrumentId)`; Plugin Manager "Load Bridge" VST → `openNativeGUI(name)`; C++ main.cpp type=4 **FIX 2026-08-12:** poll ≤10s chờ LOAD hoàn tất trước attach GUI (trước: race "no instrument loaded"). VST3 host thật (Vst3Instrument.cpp, HAVE_VST3SDK) — cần build Windows + test Vital/Nexus/Scaler2.
|
||||||
|
- [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)`; **FIX 2026-08-12:** sau khi load VST3 OK → `openNativeGUI(name)` mở native GUI (trước đây không gọi). 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.
|
||||||
|
## Kết quả test THẬT Windows 2026-08-12 (build HEAD 36fb546, app.precompiled.js đã sửa 2 guard)
|
||||||
|
- ✅ **YÊU CẦU 1 (SF2 → âm):** Track 01 load `024_BigSymphny` (SF2) → insert MIDI item + note C4 → PR play qua nút Play: `[Play] PianoRoll schedule: notes=1 | track=1` → **peak SHM 0.026468**. NOTE_ON dispatch → 0.032. Keybed C4 → 0.0536.
|
||||||
|
- ✅ **YÊU CẦU 2 (VST3 native GUI + âm):** Track 01 chọn VST3 **ACDD** (Ample Sound, `C:\Program Files\Common Files\VST3\Ample Sound\ACDD.vst3`) → `[Bridge] track 1 -> bridge VST3 ch 0 OK` → native GUI mở: class `SonicForge_Native_VST3_Class`, title `ACDD`, client 784x561, child JUCE window. Keybed C4 → **peak 0.115554**; dispatch NOTE_ON ch0 → **0.153984**; PR play qua nút Play → **0.073984** (`[Play] ... sfEngine=no | dest=ok`).
|
||||||
|
- ⚠️ Phím Space (shortcut transport) qua CDP synthetic/trusted event KHÔNG kích play trên app đang chạy (webview đang giữ bundle cũ cache từ lúc start; cần restart app để load bản v=1700 mới). Nút Play trên transport hoạt động chuẩn. Chưa xác định được lỗi thật của Space — không thuộc 2 yêu cầu bắt buộc.
|
||||||
|
- ⚠️ Nexus VST3 silent (license). Dùng ACDD demo để chứng minh âm.
|
||||||
|
- ⚠️ daw_engine.exe serve index.html từ memory lúc start (v=202608121600) dù file trên đĩa đã bump v=202608121700 → restart app mới nhận build mới.
|
||||||
|
- Ghi chú deploy: copy `app/static/js/app.precompiled.js` + `app/templates/index.html` → `dist/`, `src-tauri/resources/`, `src-tauri/target/release/` (cùng dưới `daw_engine/_internal/app/`), bump `?v=` chống cache.
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# SonicForge Client Logs API — client gửi log hành động (MIDI/mastering/drag
|
|
||||||
# errors) qua POST /api/v1/logs. Không lưu DB — in ra server log (uvicorn
|
|
||||||
# console). Public: log không chứa dữ liệu nhạy (client tự quyết định).
|
|
||||||
import logging
|
|
||||||
from typing import Any, Optional
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
_logger = logging.getLogger("sonicforge.client_logs")
|
|
||||||
|
|
||||||
|
|
||||||
class ClientLogEntry(BaseModel):
|
|
||||||
category: str = "GENERAL"
|
|
||||||
level: str = "info"
|
|
||||||
message: str = ""
|
|
||||||
data: Optional[dict[str, Any]] = None
|
|
||||||
url: Optional[str] = None
|
|
||||||
ts: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def post_client_log(entry: ClientLogEntry):
|
|
||||||
"""Ghi log từ client vào server log (fire-and-forget, luôn 200)."""
|
|
||||||
tag = f"[ClientLog][{entry.category}][{entry.level}]"
|
|
||||||
line = f"{tag} {entry.message}"
|
|
||||||
if entry.data:
|
|
||||||
line += f" {entry.data}"
|
|
||||||
if entry.ts:
|
|
||||||
line += f" (ts={entry.ts})"
|
|
||||||
if entry.url:
|
|
||||||
line += f" url={entry.url}"
|
|
||||||
if entry.level == "error":
|
|
||||||
_logger.error(line)
|
|
||||||
elif entry.level == "warn":
|
|
||||||
_logger.warning(line)
|
|
||||||
else:
|
|
||||||
_logger.info(line)
|
|
||||||
return {"ok": True}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
# SonicForge Native Audio API (T15) — track instrument đi thẳng vào engine
|
|
||||||
# native (SF host / VST2 / VST3 bridge DLL), KHÔNG qua masterBus WebAudio.
|
|
||||||
# Master fader + track gain/pan áp native qua NativeMixer — một engine duy nhất.
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import List, Optional
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.core.native_audio_service import get_service
|
|
||||||
from app.core.render_engine import _find_sf2_path
|
|
||||||
from app.core.vst_engine import get_plugin_manager
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class MasterGainRequest(BaseModel):
|
|
||||||
gain_db: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class TrackGainPanRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
gain_db: float = 0.0
|
|
||||||
pan: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class SfEnsureRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
sf_path: Optional[str] = None
|
|
||||||
sf_id: Optional[str] = None # resolve path server-side (JS chỉ có UUID)
|
|
||||||
bank: int = 0
|
|
||||||
program: int = 0
|
|
||||||
channel: int = 0
|
|
||||||
live: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class SfNoteRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
velocity: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
class SfNoteOffRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
|
|
||||||
|
|
||||||
class Vst2EnsureRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
plugin_path: Optional[str] = None
|
|
||||||
plugin_id: Optional[str] = None # resolve path server-side
|
|
||||||
live: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class Vst2NoteRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
velocity: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
class Vst2NoteOffRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
|
|
||||||
class Vst3EnsureRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
plugin_path: Optional[str] = None
|
|
||||||
plugin_id: Optional[str] = None # resolve path server-side
|
|
||||||
live: bool = True
|
|
||||||
|
|
||||||
class Vst3NoteRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
velocity: int = 100
|
|
||||||
|
|
||||||
class Vst3NoteOffRequest(BaseModel):
|
|
||||||
track_id: str
|
|
||||||
channel: int = 0
|
|
||||||
pitch: int
|
|
||||||
|
|
||||||
|
|
||||||
class RenderNote(BaseModel):
|
|
||||||
note: int = 60
|
|
||||||
velocity: int = 100
|
|
||||||
start_beat: float = 0.0
|
|
||||||
duration_beats: float = 1.0
|
|
||||||
|
|
||||||
|
|
||||||
class RenderRequest(BaseModel):
|
|
||||||
kind: str # "sf" | "vst2"
|
|
||||||
path: str
|
|
||||||
bank: int = 0
|
|
||||||
program: int = 0
|
|
||||||
notes: List[RenderNote] = []
|
|
||||||
bpm: float = 120.0
|
|
||||||
gain_db: float = 0.0
|
|
||||||
pan: float = 0.0
|
|
||||||
lim_active: bool = False
|
|
||||||
threshold_db: float = -1.0
|
|
||||||
master_gain_db: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _svc():
|
|
||||||
return get_service()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_plugin_path(plugin_id: str = None, plugin_path: str = None) -> str:
|
|
||||||
"""Resolve plugin path server-side: uu tien plugin_path truc tiep, con
|
|
||||||
khong thi plugin_id = ten plugin trong registry scan (JS chi co plugin_id)."""
|
|
||||||
path = (plugin_path or "").strip()
|
|
||||||
if path:
|
|
||||||
return path
|
|
||||||
pid = (plugin_id or "").strip()
|
|
||||||
if pid:
|
|
||||||
try:
|
|
||||||
plugins = get_plugin_manager()._scan_plugins()
|
|
||||||
if pid in plugins:
|
|
||||||
return plugins[pid]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def status():
|
|
||||||
return _svc().status()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set_master_gain")
|
|
||||||
async def set_master_gain(req: MasterGainRequest):
|
|
||||||
return _svc().set_master_gain(req.gain_db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/track_gain_pan")
|
|
||||||
async def track_gain_pan(req: TrackGainPanRequest):
|
|
||||||
return _svc().set_track_gain_pan(req.track_id, req.gain_db, req.pan)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sf/ensure")
|
|
||||||
async def sf_ensure(req: SfEnsureRequest):
|
|
||||||
path = (req.sf_path or "").strip()
|
|
||||||
if not path and req.sf_id:
|
|
||||||
path = _find_sf2_path(req.sf_id)
|
|
||||||
if not path or not os.path.exists(path):
|
|
||||||
raise HTTPException(status_code=400, detail=f"không tìm thấy SF2/SF3 "
|
|
||||||
f"(sf_path={req.sf_path!r} sf_id={req.sf_id!r})")
|
|
||||||
try:
|
|
||||||
return _svc().ensure_sf(req.track_id, path, req.bank,
|
|
||||||
req.program, req.channel, req.live)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sf/note_on")
|
|
||||||
async def sf_note_on(req: SfNoteRequest):
|
|
||||||
try:
|
|
||||||
return _svc().sf_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sf/note_off")
|
|
||||||
async def sf_note_off(req: SfNoteOffRequest):
|
|
||||||
try:
|
|
||||||
return _svc().sf_note_off(req.track_id, req.channel, req.pitch)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sf/audio_stop")
|
|
||||||
async def sf_audio_stop(req: SfNoteOffRequest):
|
|
||||||
return _svc().sf_audio_stop(req.track_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vst2/ensure")
|
|
||||||
async def vst2_ensure(req: Vst2EnsureRequest):
|
|
||||||
path = _resolve_plugin_path(req.plugin_id, req.plugin_path)
|
|
||||||
if not path or not os.path.exists(path):
|
|
||||||
raise HTTPException(status_code=400, detail=f"khong tim thay plugin "
|
|
||||||
f"(plugin_id={req.plugin_id!r} plugin_path={req.plugin_path!r})")
|
|
||||||
try:
|
|
||||||
return _svc().ensure_vst2(req.track_id, path, req.live)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vst2/note_on")
|
|
||||||
async def vst2_note_on(req: Vst2NoteRequest):
|
|
||||||
try:
|
|
||||||
return _svc().vst2_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/vst2/note_off")
|
|
||||||
async def vst2_note_off(req: Vst2NoteOffRequest):
|
|
||||||
try:
|
|
||||||
return _svc().vst2_note_off(req.track_id, req.channel, req.pitch)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/vst3/ensure")
|
|
||||||
async def vst3_ensure(req: Vst3EnsureRequest):
|
|
||||||
path = _resolve_plugin_path(req.plugin_id, req.plugin_path)
|
|
||||||
if not path or not os.path.exists(path):
|
|
||||||
raise HTTPException(status_code=400, detail=f"khong tim thay plugin "
|
|
||||||
f"(plugin_id={req.plugin_id!r} plugin_path={req.plugin_path!r})")
|
|
||||||
try:
|
|
||||||
return _svc().ensure_vst3(req.track_id, path, req.live)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/vst3/note_on")
|
|
||||||
async def vst3_note_on(req: Vst3NoteRequest):
|
|
||||||
try:
|
|
||||||
return _svc().vst3_note_on(req.track_id, req.channel, req.pitch, req.velocity)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/vst3/note_off")
|
|
||||||
async def vst3_note_off(req: Vst3NoteOffRequest):
|
|
||||||
try:
|
|
||||||
return _svc().vst3_note_off(req.track_id, req.channel, req.pitch)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/render")
|
|
||||||
async def render(req: RenderRequest):
|
|
||||||
"""Offline render cho test matrix spec V. Trả về số liệu (không trả audio
|
|
||||||
bytes) — peak/rms đủ để so sánh âm lượng/mastering."""
|
|
||||||
svc = _svc()
|
|
||||||
notes = [n.model_dump() for n in req.notes]
|
|
||||||
try:
|
|
||||||
if req.kind == "sf":
|
|
||||||
y = svc.render_sf_offline(req.path, req.bank, req.program, notes,
|
|
||||||
req.bpm, gain_db=req.gain_db, pan=req.pan,
|
|
||||||
lim_active=req.lim_active,
|
|
||||||
threshold_db=req.threshold_db,
|
|
||||||
master_gain_db=req.master_gain_db)
|
|
||||||
elif req.kind == "vst2":
|
|
||||||
y = svc.render_vst2_offline(req.path, notes, req.bpm,
|
|
||||||
gain_db=req.gain_db, pan=req.pan,
|
|
||||||
lim_active=req.lim_active,
|
|
||||||
threshold_db=req.threshold_db,
|
|
||||||
master_gain_db=req.master_gain_db)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400,
|
|
||||||
detail="kind phải là 'sf' hoặc 'vst2'")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
import numpy as np
|
|
||||||
return {"ok": True, "kind": req.kind, "samples": int(y.shape[1]),
|
|
||||||
"peak": float(np.abs(y).max()),
|
|
||||||
"rms": float(np.sqrt((y ** 2).mean()))}
|
|
||||||
+135
-131
@@ -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
|
||||||
@@ -490,7 +490,6 @@ async def download_soundfont_asset(sf_id: str):
|
|||||||
class RenderRequest(BaseModel):
|
class RenderRequest(BaseModel):
|
||||||
project_json: dict
|
project_json: dict
|
||||||
output_filename: Optional[str] = "render_output.wav"
|
output_filename: Optional[str] = "render_output.wav"
|
||||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
|
||||||
|
|
||||||
|
|
||||||
class OpenInCarlaRequest(BaseModel):
|
class OpenInCarlaRequest(BaseModel):
|
||||||
@@ -574,7 +573,7 @@ def _carla_osc_ready() -> bool:
|
|||||||
import socket
|
import socket
|
||||||
port = _carla_osc_port()
|
port = _carla_osc_port()
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.bind(("0.0.0.0", port))
|
s.bind(("127.0.0.1", port))
|
||||||
s.close()
|
s.close()
|
||||||
return False
|
return False
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -750,20 +749,12 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
|
|||||||
_send_carla_all_notes_off()
|
_send_carla_all_notes_off()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 2. Terminate tiến trình Carla đã spawn — Windows PHẢI giết cả process
|
# 2. Terminate tiến trình Carla đã spawn
|
||||||
# tree (Carla.exe spawn carla-backend child giữ cổng OSC 22752; terminate
|
|
||||||
# đơn lẻ chỉ giết cha → child sống → cổng chưa giải phóng → lần mở sau
|
|
||||||
# open_in_carla thấy already_running dù Carla đã chết → GUI không xuất
|
|
||||||
# hiện lại (Bug 3 standalone)).
|
|
||||||
killed = 0
|
killed = 0
|
||||||
_prune_carla_processes()
|
_prune_carla_processes()
|
||||||
for proc in list(_CARLA_PROCESSES):
|
for proc in list(_CARLA_PROCESSES):
|
||||||
try:
|
try:
|
||||||
if os.name == "nt" and proc.pid:
|
proc.terminate()
|
||||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
||||||
capture_output=True, timeout=15)
|
|
||||||
else:
|
|
||||||
proc.terminate()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
|
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
|
||||||
@@ -783,15 +774,6 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_CARLA_PROCESSES.clear()
|
_CARLA_PROCESSES.clear()
|
||||||
# 3. Chờ cổng OSC UDP thực sự được giải phóng (child có thể thoát chậm
|
|
||||||
# hơn cha) — nếu không, mở lại Carla ngay sẽ bị gate already_running.
|
|
||||||
try:
|
|
||||||
import time as _t
|
|
||||||
deadline = _t.time() + 5.0
|
|
||||||
while _t.time() < deadline and _carla_osc_ready():
|
|
||||||
_t.sleep(0.1)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"stopped": True,
|
"stopped": True,
|
||||||
@@ -934,7 +916,6 @@ class MidiRenderRequest(BaseModel):
|
|||||||
preset_id: Optional[str] = None
|
preset_id: Optional[str] = None
|
||||||
preset_path: Optional[str] = None
|
preset_path: Optional[str] = None
|
||||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
||||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/midi-render")
|
@router.post("/midi-render")
|
||||||
@@ -962,7 +943,6 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
|||||||
preset_id=req.preset_id,
|
preset_id=req.preset_id,
|
||||||
preset_path=req.preset_path,
|
preset_path=req.preset_path,
|
||||||
preset_data_b64=req.preset_data,
|
preset_data_b64=req.preset_data,
|
||||||
bit_depth=req.bit_depth,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -998,7 +978,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
|||||||
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||||
sample_rate: int, preset_id=None, preset_path=None,
|
sample_rate: int, preset_id=None, preset_path=None,
|
||||||
preset_data_b64=None, soundfont_bank=None,
|
preset_data_b64=None, soundfont_bank=None,
|
||||||
soundfont_program=None, bit_depth: int = 16) -> tuple:
|
soundfont_program=None) -> tuple:
|
||||||
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
|
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
|
||||||
|
|
||||||
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
|
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
|
||||||
@@ -1044,8 +1024,7 @@ def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
|||||||
buf = vst(midi_messages, sample_rate=sample_rate,
|
buf = vst(midi_messages, sample_rate=sample_rate,
|
||||||
duration=total_needed / float(sample_rate), num_channels=2)
|
duration=total_needed / float(sample_rate), num_channels=2)
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
|
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
|
||||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
sf.write(out_path, buf.T, sample_rate)
|
||||||
sf.write(out_path, buf.T, sample_rate, subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
|
||||||
return out_path, buf.shape[1] / float(sample_rate)
|
return out_path, buf.shape[1] / float(sample_rate)
|
||||||
|
|
||||||
|
|
||||||
@@ -1060,7 +1039,6 @@ class SoundfontRenderRequest(BaseModel):
|
|||||||
notes: list = []
|
notes: list = []
|
||||||
bpm: float = 120.0
|
bpm: float = 120.0
|
||||||
sample_rate: int = 44100
|
sample_rate: int = 44100
|
||||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/soundfont-render")
|
@router.post("/soundfont-render")
|
||||||
@@ -1096,8 +1074,7 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
|||||||
bank=req.bank, program=req.program,
|
bank=req.bank, program=req.program,
|
||||||
sr=req.sample_rate, bpm=req.bpm,
|
sr=req.sample_rate, bpm=req.bpm,
|
||||||
)
|
)
|
||||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
sf.write(out_path, audio.T, req.sample_rate)
|
||||||
sf.write(out_path, audio.T, req.sample_rate, subtype=subtype_map.get(int(req.bit_depth), "PCM_16"))
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"file_id": os.path.basename(out_path),
|
"file_id": os.path.basename(out_path),
|
||||||
@@ -1118,106 +1095,6 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
|||||||
raise HTTPException(status_code=500, detail=f"Render soundfont thất bại: {e}")
|
raise HTTPException(status_code=500, detail=f"Render soundfont thất bại: {e}")
|
||||||
|
|
||||||
|
|
||||||
class AutosampleRequest(BaseModel):
|
|
||||||
"""Auto-sample VSTi preset -> SF2 (client FluidSynth WASM live playback).
|
|
||||||
|
|
||||||
Server goi tools.autosample_vsti.autosample_sf2 (pedalboard render tung
|
|
||||||
note voi DUNG plugin + preset nhu export) -> luu {uuid}.sf2 vao
|
|
||||||
UPLOAD_SF_DIR + .meta -> client tai qua /soundfonts/download/{uuid}.
|
|
||||||
SF2 only (SF3 can ffmpeg/libvorbis; client WASM khong decode SF3)."""
|
|
||||||
instrument_id: str
|
|
||||||
preset_id: Optional[str] = None
|
|
||||||
preset_path: Optional[str] = None
|
|
||||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhung)
|
|
||||||
low: int = 36
|
|
||||||
high: int = 96
|
|
||||||
step: int = 2
|
|
||||||
duration: float = 2.5
|
|
||||||
release: float = 1.0
|
|
||||||
velocity: int = 100
|
|
||||||
sample_rate: int = 44100
|
|
||||||
|
|
||||||
@router.post("/autosample")
|
|
||||||
async def autosample_vsti(req: AutosampleRequest, current_user: dict = Depends(get_current_user)):
|
|
||||||
"""Auto-sample VSTi -> SF2 cho live playback client-side (FluidSynth WASM).
|
|
||||||
|
|
||||||
Tra sf_id (bare uuid) de client tai SF2 va phat qua SonicSF nhu soundfont
|
|
||||||
thuong. Chi phi autosample mot lan -> cache o client (localStorage)."""
|
|
||||||
enforce_password_changed(current_user)
|
|
||||||
_dirs = _effective_dirs().get("plugin_dirs") or []
|
|
||||||
|
|
||||||
# tools/ nam ngoai package app - import voi fallback path
|
|
||||||
try:
|
|
||||||
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
|
|
||||||
except ImportError:
|
|
||||||
_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
if _root not in sys.path:
|
|
||||||
sys.path.insert(0, _root)
|
|
||||||
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
|
|
||||||
|
|
||||||
if not HAS_PEDALBOARD:
|
|
||||||
# VST2 native bridge khong can pedalboard — chi chan khi plugin la VST3
|
|
||||||
_p = PluginManager(extra_vst_dirs=_dirs)._scan_plugins().get(req.instrument_id)
|
|
||||||
if not _p or not _is_vst2_path(_p):
|
|
||||||
raise HTTPException(status_code=501, detail="pedalboard khong kha dung tren may nay")
|
|
||||||
|
|
||||||
file_uuid = str(uuid.uuid4())
|
|
||||||
dest_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".sf2")
|
|
||||||
log_lines = []
|
|
||||||
|
|
||||||
def _log(msg):
|
|
||||||
log_lines.append(msg)
|
|
||||||
print(f"[autosample] {msg}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = _autosample_sf2(
|
|
||||||
req.instrument_id, dest_path,
|
|
||||||
preset_id=req.preset_id,
|
|
||||||
preset_path=req.preset_path,
|
|
||||||
preset_data_b64=req.preset_data,
|
|
||||||
low=req.low, high=req.high, step=req.step,
|
|
||||||
duration=req.duration, release=req.release,
|
|
||||||
velocity=req.velocity, sample_rate=req.sample_rate,
|
|
||||||
log=_log,
|
|
||||||
extra_vst_dirs=_dirs,
|
|
||||||
)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
if os.path.exists(dest_path):
|
|
||||||
os.remove(dest_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(status_code=500, detail=f"Auto-sample that bai: {e}")
|
|
||||||
|
|
||||||
meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
|
|
||||||
try:
|
|
||||||
with open(meta_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({"original_name": f"autosample_{file_uuid[:8]}.sf2",
|
|
||||||
"uuid": file_uuid, "file": file_uuid + ".sf2",
|
|
||||||
"plugin_id": req.instrument_id, "kind": "autosampled"}, f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_scanner().scan_once()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[autosample] scan_once failed: {e}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"sf_id": file_uuid,
|
|
||||||
"file": file_uuid + ".sf2",
|
|
||||||
"name": f"autosample_{file_uuid[:8]}.sf2",
|
|
||||||
"url": f"/api/v1/plugins/soundfonts/download/{file_uuid}",
|
|
||||||
"size_bytes": result["size_bytes"],
|
|
||||||
"note_count": result["note_count"],
|
|
||||||
"plugin_id": req.instrument_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
class CarlaPlayNotesRequest(BaseModel):
|
class CarlaPlayNotesRequest(BaseModel):
|
||||||
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
|
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
|
||||||
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
|
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
|
||||||
@@ -1297,7 +1174,134 @@ async def render_project(
|
|||||||
safe_name += ".wav"
|
safe_name += ".wav"
|
||||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||||
try:
|
try:
|
||||||
result_path = engine.render_project(req.project_json, output_path, bit_depth=req.bit_depth)
|
result_path = engine.render_project(req.project_json, output_path)
|
||||||
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
|
||||||
|
sf_dirs = [UPLOAD_SF_DIR, SYSTEM_SF_DIR]
|
||||||
|
try:
|
||||||
|
from app.core.vst_engine import _load_user_plugin_dirs
|
||||||
|
sf_dirs += _load_user_plugin_dirs() or []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for base_dir in dict.fromkeys(d for d in sf_dirs if d):
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for root, _, files in os.walk(base_dir):
|
||||||
|
for fname in files:
|
||||||
|
if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
|
||||||
|
(fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
|
||||||
|
return os.path.join(root, fname)
|
||||||
|
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 = []
|
||||||
|
# Luon gop VST_DIR/SOUNDFONT_DIR: neu chi dung user plugin_dirs,
|
||||||
|
# VST trong C:/Program Files/Common Files/VST3 khong resolve
|
||||||
|
# duoc -> bridge load fail -> VSTi cam / khong mo GUI.
|
||||||
|
for d in (settings.VST_DIR, settings.SOUNDFONT_DIR):
|
||||||
|
if d and d not in dirs:
|
||||||
|
dirs.append(d)
|
||||||
|
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)}
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int
|
|||||||
y, sr_read = sf.read(temp_wav)
|
y, sr_read = sf.read(temp_wav)
|
||||||
|
|
||||||
# Xác định subtype mã hóa bit-depth
|
# Xác định subtype mã hóa bit-depth
|
||||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||||
|
|
||||||
# Ghi tệp WAV chất lượng cao
|
# Ghi tệp WAV chất lượng cao
|
||||||
@@ -254,7 +254,7 @@ def export_audio(input_path: str, output_path: str, format: str = "wav",
|
|||||||
sound.export(temp_wav, format="wav")
|
sound.export(temp_wav, format="wav")
|
||||||
y, sr_read = sf.read(temp_wav)
|
y, sr_read = sf.read(temp_wav)
|
||||||
|
|
||||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||||
|
|
||||||
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
"""apply_mastering — offline mastering chain, mirror chính xác client WebAudio
|
|
||||||
(app.jsx initMasterBus / applyMasteringSettings / rebuildMasteringGraph).
|
|
||||||
|
|
||||||
Render export (pedalboard VSTi / soundfont) chạy qua ĐÚNG chain như live
|
|
||||||
playback: thứ tự module từ settings['chain'] (mặc định EQ -> Imager ->
|
|
||||||
Maximizer), mỗi module bật/tắt theo settings tương ứng. Bỏ oversample
|
|
||||||
(WaveShaper '2x'/'4x') — offline render không cần anti-alias.
|
|
||||||
|
|
||||||
Công thức khớp client:
|
|
||||||
- RBJ Audio-EQ-Cookbook biquad (eqproBiquadMagDb)
|
|
||||||
- Imager 4-band M/S crossfeed g1=(w+100)/200, g2=(100-w)/200
|
|
||||||
- Maximizer: boost -> atan soft-clip -> upward comp (dry + gain*comp) -> hard clip ceiling
|
|
||||||
- Compressor: soft-knee DynamicsCompressor + one-pole attack/release envelope
|
|
||||||
- Limiter: WaveShaper tanh k=1/tLin
|
|
||||||
- Exciter: highpass 2k -> atan(k=3), dry 1.0, wet 0.6*drive/100
|
|
||||||
- Rebalance: M/S L/R crossfeed a=(m+s)/2, b=(m-s)/2
|
|
||||||
"""
|
|
||||||
import numpy as np
|
|
||||||
from scipy.signal import sosfilt
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp(v, lo, hi, default=0.0):
|
|
||||||
"""Mirror client clamp(): missing/NaN -> default (neutral), khong bao gio NaN."""
|
|
||||||
try:
|
|
||||||
n = float(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
if not np.isfinite(n):
|
|
||||||
return default
|
|
||||||
return min(hi, max(lo, n))
|
|
||||||
|
|
||||||
|
|
||||||
def _biquad(kind, f0, fs, gain_db=0.0, q=0.707):
|
|
||||||
"""RBJ biquad (b, a) normalized a0=1 — dung cong thuc client eqproBiquadMagDb."""
|
|
||||||
f0 = min(max(float(f0), 20.0), fs * 0.45)
|
|
||||||
w0 = 2 * np.pi * f0 / fs
|
|
||||||
cw = np.cos(w0)
|
|
||||||
sw = np.sin(w0)
|
|
||||||
alpha = sw / (2 * max(0.05, q))
|
|
||||||
A = 10 ** (_clamp(gain_db, -24, 24) / 40)
|
|
||||||
if kind == "peaking":
|
|
||||||
b = [1 + alpha * A, -2 * cw, 1 - alpha * A]
|
|
||||||
a = [1 + alpha / A, -2 * cw, 1 - alpha / A]
|
|
||||||
elif kind == "lowshelf":
|
|
||||||
b = [A * ((A + 1) - (A - 1) * cw + 2 * np.sqrt(A) * alpha),
|
|
||||||
2 * A * ((A - 1) - (A + 1) * cw),
|
|
||||||
A * ((A + 1) - (A - 1) * cw - 2 * np.sqrt(A) * alpha)]
|
|
||||||
a = [(A + 1) + (A - 1) * cw + 2 * np.sqrt(A) * alpha,
|
|
||||||
-2 * ((A - 1) + (A + 1) * cw),
|
|
||||||
(A + 1) + (A - 1) * cw - 2 * np.sqrt(A) * alpha]
|
|
||||||
elif kind == "highshelf":
|
|
||||||
b = [A * ((A + 1) + (A - 1) * cw + 2 * np.sqrt(A) * alpha),
|
|
||||||
-2 * A * ((A - 1) + (A + 1) * cw),
|
|
||||||
A * ((A + 1) + (A - 1) * cw - 2 * np.sqrt(A) * alpha)]
|
|
||||||
a = [(A + 1) - (A - 1) * cw + 2 * np.sqrt(A) * alpha,
|
|
||||||
2 * ((A - 1) - (A + 1) * cw),
|
|
||||||
(A + 1) - (A - 1) * cw - 2 * np.sqrt(A) * alpha]
|
|
||||||
elif kind == "highpass":
|
|
||||||
b = [(1 + cw) / 2, -(1 + cw), (1 + cw) / 2]
|
|
||||||
a = [1 + alpha, -2 * cw, 1 - alpha]
|
|
||||||
elif kind == "lowpass":
|
|
||||||
b = [(1 - cw) / 2, 1 - cw, (1 - cw) / 2]
|
|
||||||
a = [1 + alpha, -2 * cw, 1 - alpha]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unsupported biquad kind: {kind}")
|
|
||||||
b = np.asarray(b, dtype=np.float64) / a[0]
|
|
||||||
a = np.asarray(a, dtype=np.float64) / a[0]
|
|
||||||
return np.concatenate([b, a])
|
|
||||||
|
|
||||||
|
|
||||||
def _sosfilt_chain(x, sos_list):
|
|
||||||
"""Ap day biquad noi tiep len (2, N) — mot lan sosfilt moi kenh."""
|
|
||||||
if not sos_list:
|
|
||||||
return np.array(x, dtype=np.float64, copy=True)
|
|
||||||
sos = np.asarray(sos_list, dtype=np.float64)
|
|
||||||
out = np.empty((2, x.shape[1]), dtype=np.float64)
|
|
||||||
for ch in range(2):
|
|
||||||
out[ch] = sosfilt(sos, np.asarray(x[ch], dtype=np.float64))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _compressor(x, threshold_db, ratio, knee, attack_s, release_s,
|
|
||||||
makeup_db=0.0, fs=44100.0):
|
|
||||||
"""Soft-knee feedforward compressor (gan dung DynamicsCompressor cua WebAudio)
|
|
||||||
— stereo-linked (detect tren max |L|,|R|), one-pole attack/release."""
|
|
||||||
x64 = np.asarray(x, dtype=np.float64)
|
|
||||||
n = x64.shape[1]
|
|
||||||
if n == 0:
|
|
||||||
return x64
|
|
||||||
level_db = 20 * np.log10(np.max(np.abs(x64), axis=0) + 1e-12)
|
|
||||||
t = threshold_db
|
|
||||||
k = knee
|
|
||||||
gr_db = np.zeros(n, dtype=np.float64)
|
|
||||||
above = level_db >= t + k / 2
|
|
||||||
mid = (level_db > t - k / 2) & (level_db < t + k / 2)
|
|
||||||
gr_db[above] = (1 - 1 / ratio) * (t - level_db[above])
|
|
||||||
gr_db[mid] = (1 - 1 / ratio) * (level_db[mid] - t + k / 2) ** 2 / (2 * k)
|
|
||||||
|
|
||||||
a_att = np.exp(-1 / (attack_s * fs)) if attack_s > 0 else 0.0
|
|
||||||
a_rel = np.exp(-1 / (release_s * fs)) if release_s > 0 else 0.0
|
|
||||||
env = np.empty(n, dtype=np.float64)
|
|
||||||
cur = 0.0
|
|
||||||
prev = 0.0
|
|
||||||
for i in range(n):
|
|
||||||
g = gr_db[i]
|
|
||||||
if g < prev:
|
|
||||||
cur = a_att * cur + (1 - a_att) * g
|
|
||||||
else:
|
|
||||||
cur = a_rel * cur + (1 - a_rel) * g
|
|
||||||
env[i] = cur
|
|
||||||
prev = g
|
|
||||||
gain_lin = 10 ** ((env + makeup_db) / 20)
|
|
||||||
return x64 * gain_lin
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_eq(x, s, fs):
|
|
||||||
eq_on = bool(s.get("eqActive"))
|
|
||||||
sos = [
|
|
||||||
_biquad("lowshelf", 100, fs, _clamp(s.get("eqLowGain"), -24, 24) if eq_on else 0),
|
|
||||||
_biquad("peaking", 822, fs, _clamp(s.get("eqMid1Gain"), -24, 24) if eq_on else 0, q=0.7),
|
|
||||||
_biquad("peaking", 3200, fs, _clamp(s.get("eqMid2Gain"), -24, 24) if eq_on else 0, q=1.2),
|
|
||||||
_biquad("highshelf", 10000, fs, _clamp(s.get("eqHighGain"), -24, 24) if eq_on else 0),
|
|
||||||
]
|
|
||||||
return _sosfilt_chain(x, sos)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_imager(x, s, fs):
|
|
||||||
im_on = bool(s.get("imagerActive"))
|
|
||||||
bands = [
|
|
||||||
[("lowpass", 100)],
|
|
||||||
[("highpass", 100), ("lowpass", 1000)],
|
|
||||||
[("highpass", 1000), ("lowpass", 6000)],
|
|
||||||
[("highpass", 6000)],
|
|
||||||
]
|
|
||||||
widths = [s.get(f"w{i}") for i in (1, 2, 3, 4)]
|
|
||||||
out = np.zeros_like(x)
|
|
||||||
for bf, w in zip(bands, widths):
|
|
||||||
width = _clamp(w, 0, 200, 100) if im_on else 100
|
|
||||||
g1 = (width + 100) / 200
|
|
||||||
g2 = (100 - width) / 200
|
|
||||||
y = _sosfilt_chain(x, [_biquad(k, f, fs) for k, f in bf])
|
|
||||||
out[0] += g1 * y[0] + g2 * y[1]
|
|
||||||
out[1] += g1 * y[1] + g2 * y[0]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_maximizer(x, s):
|
|
||||||
on = bool(s.get("maximizerActive"))
|
|
||||||
boost = 10 ** (_clamp(s.get("maxGain"), -60, 30) / 20) if on else 1.0
|
|
||||||
boosted = x * boost
|
|
||||||
soft = _clamp(s.get("maxSoftClip"), 0, 100, 0)
|
|
||||||
if on and soft > 0:
|
|
||||||
k = 1 + (soft / 100) * 10
|
|
||||||
dry = np.arctan(boosted * k) / np.arctan(k)
|
|
||||||
else:
|
|
||||||
dry = boosted
|
|
||||||
up = _clamp(s.get("maxUpward"), 0, 30, 0)
|
|
||||||
if on and up > 0:
|
|
||||||
up_gain = 10 ** (up / 20) - 1.0
|
|
||||||
comp = _compressor(boosted, threshold_db=-30, ratio=4.0, knee=10.0,
|
|
||||||
attack_s=0.01, release_s=0.1, makeup_db=0.0)
|
|
||||||
out = dry + up_gain * comp
|
|
||||||
else:
|
|
||||||
out = dry
|
|
||||||
ceil_db = _clamp(s.get("ceiling"), -60, 0, -0.1) if on else -0.1
|
|
||||||
c = 10 ** (ceil_db / 20)
|
|
||||||
return np.clip(out, -c, c)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_compressor(x, s):
|
|
||||||
if not bool(s.get("compActive")):
|
|
||||||
return x
|
|
||||||
makeup = 10 ** (_clamp(s.get("compMakeup"), 0, 12) / 20)
|
|
||||||
return _compressor(x, threshold_db=_clamp(s.get("compThreshold"), -60, 0),
|
|
||||||
ratio=_clamp(s.get("compRatio"), 1, 20), knee=8.0,
|
|
||||||
attack_s=0.02, release_s=0.25, makeup_db=20 * np.log10(makeup))
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_limiter(x, s):
|
|
||||||
if not bool(s.get("limActive")):
|
|
||||||
return x
|
|
||||||
t_lin = 10 ** (_clamp(s.get("limThreshold"), -24, 0, -1) / 20)
|
|
||||||
k = 1 / max(0.02, t_lin)
|
|
||||||
tk = np.tanh(k)
|
|
||||||
return np.tanh(x * k) / tk
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_exciter(x, s, fs):
|
|
||||||
if not bool(s.get("excActive")):
|
|
||||||
return x
|
|
||||||
wet = (_clamp(s.get("excDrive"), 0, 100, 0) / 100) * 0.6
|
|
||||||
if wet <= 0:
|
|
||||||
return x
|
|
||||||
hp = _sosfilt_chain(x, [_biquad("highpass", 2000, fs, q=0.7)])
|
|
||||||
k = 3
|
|
||||||
sh = np.arctan(hp * k) / np.arctan(k)
|
|
||||||
return x + wet * sh
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_rebalance(x, s):
|
|
||||||
if not bool(s.get("rebalActive")):
|
|
||||||
return x
|
|
||||||
mid = 10 ** (_clamp(s.get("rebalMid"), -24, 24, 0) / 20)
|
|
||||||
side = 10 ** (_clamp(s.get("rebalSide"), -24, 24, 0) / 20)
|
|
||||||
a = (mid + side) / 2
|
|
||||||
b = (mid - side) / 2
|
|
||||||
L, R = x[0], x[1]
|
|
||||||
return np.stack([a * L + b * R, b * L + a * R])
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_eqpro(x, mod, fs):
|
|
||||||
params = mod.get("params") or {}
|
|
||||||
amount = _clamp(params.get("amount"), 0, 200, 100) / 100.0
|
|
||||||
sos = []
|
|
||||||
for b in params.get("bands") or []:
|
|
||||||
kind = b.get("type") or "peaking"
|
|
||||||
f0 = _clamp(b.get("freq"), 20, 20000, 1000)
|
|
||||||
q = _clamp(b.get("q"), 0.1, 18, 1.0)
|
|
||||||
gain = (b.get("gain") or 0) * amount if b.get("active") is not False else 0
|
|
||||||
sos.append(_biquad(kind, f0, fs, gain, q))
|
|
||||||
return _sosfilt_chain(x, sos)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_mastering(buffer, settings, sample_rate):
|
|
||||||
"""Ap mastering chain (client format mastering_settings) len (2, N) audio.
|
|
||||||
|
|
||||||
buffer: (2, N) — L row 0. settings: dict hoac None. Tra (2, N) float64
|
|
||||||
(copy) da xu li; neu gate tat / khong co module -> tra copy khong doi."""
|
|
||||||
if buffer.ndim != 2 or buffer.shape[0] != 2:
|
|
||||||
raise ValueError("buffer phai la (2, N) stereo")
|
|
||||||
x = np.asarray(buffer, dtype=np.float64)
|
|
||||||
if not settings:
|
|
||||||
return x.copy()
|
|
||||||
if not settings.get("masterConnected") or settings.get("isBypassed"):
|
|
||||||
return x.copy()
|
|
||||||
chain = settings.get("chain") or []
|
|
||||||
mods = [m for m in chain if isinstance(m, dict) and m.get("active")]
|
|
||||||
if not mods:
|
|
||||||
return x.copy()
|
|
||||||
fs = float(sample_rate) if sample_rate else 44100.0
|
|
||||||
for m in mods:
|
|
||||||
t = m.get("type")
|
|
||||||
if t == "eq":
|
|
||||||
x = _apply_eq(x, settings, fs)
|
|
||||||
elif t == "imager":
|
|
||||||
x = _apply_imager(x, settings, fs)
|
|
||||||
elif t == "maximizer":
|
|
||||||
x = _apply_maximizer(x, settings)
|
|
||||||
elif t == "compressor":
|
|
||||||
x = _apply_compressor(x, settings)
|
|
||||||
elif t == "limiter":
|
|
||||||
x = _apply_limiter(x, settings)
|
|
||||||
elif t == "exciter":
|
|
||||||
x = _apply_exciter(x, settings, fs)
|
|
||||||
elif t == "rebalance":
|
|
||||||
x = _apply_rebalance(x, settings)
|
|
||||||
elif t == "eqpro":
|
|
||||||
x = _apply_eqpro(x, m, fs)
|
|
||||||
# type 'carla' = pass-through (VST FX ngoai) — mirror client, bo qua
|
|
||||||
return x
|
|
||||||
@@ -1,659 +0,0 @@
|
|||||||
"""Native audio service (T15) — một engine duy nhất cho track instrument.
|
|
||||||
|
|
||||||
Load các bridge native_host DLL qua ctypes (SF host / VST2 / VST3) và giữ
|
|
||||||
instance theo track_id. Mọi track instrument (SF + VSTi) đi qua CÙNG mixer
|
|
||||||
native (track gain/pan + master limiter + master fader) — hết drift Bug 2:
|
|
||||||
JS không còn route instrument qua masterBus WebAudio.
|
|
||||||
|
|
||||||
- Live: AudioStart (WASAPI) → note_on/note_off qua SPSC trên audio thread.
|
|
||||||
- Offline SF: SF_FS_RenderBlock (write_float + mixer native trong C++).
|
|
||||||
- Offline VST2: SF_VST2_Process (raw plugin) + mixer_math (mirror NativeMixer/
|
|
||||||
mastering_engine.py) trong Python — bridge Process không áp mixer.
|
|
||||||
- VST3 offline: bridge chưa có export Process — matrix ghi limitation (T15);
|
|
||||||
VST3 live dùng AudioStart + SendNoteOn như VST2 (AudioEngine chung).
|
|
||||||
|
|
||||||
master_gain (dB) áp cho MỌI instance — mirror masterBus.output.gain WebAudio
|
|
||||||
(sau limiter + clip, linear, không re-clip).
|
|
||||||
"""
|
|
||||||
import ctypes
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
_RES_DIR = os.getenv("SF_RESOURCE_DIR")
|
|
||||||
_NATIVE_DIR = (
|
|
||||||
os.getenv("SONICFORGE_NATIVE_DIR")
|
|
||||||
or (os.path.join(_RES_DIR, "native_host") if _RES_DIR else None)
|
|
||||||
or os.path.join(settings.BASE_DIR, "native_host", "build", "Release")
|
|
||||||
)
|
|
||||||
|
|
||||||
_ERR = None # khoi tao buffer sau khi dinh nghia _errbuf()
|
|
||||||
_LOCK = threading.RLock() # reentrant: _hidden_hwnd() goi trong ensure_*
|
|
||||||
|
|
||||||
|
|
||||||
def native_dir() -> str:
|
|
||||||
return _NATIVE_DIR
|
|
||||||
|
|
||||||
|
|
||||||
def _dll(name: str) -> str:
|
|
||||||
return os.path.join(_NATIVE_DIR, name)
|
|
||||||
|
|
||||||
|
|
||||||
def _errbuf():
|
|
||||||
return ctypes.create_string_buffer(256)
|
|
||||||
|
|
||||||
|
|
||||||
def _errval(buf) -> str:
|
|
||||||
return buf.value.decode(errors="replace") if buf and buf.value else ""
|
|
||||||
|
|
||||||
_ERR = _errbuf() # module-level err buffer (dung chung, 256 bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def _hidden_hwnd():
|
|
||||||
"""Tao 1 hidden window dung lam parent cho VST3 editor headless (live).
|
|
||||||
SF_VST3_Attach yeu cau parent_hwnd != NULL; window 0-size khong hien thi."""
|
|
||||||
import ctypes as _ct
|
|
||||||
from ctypes import wintypes as _wt
|
|
||||||
if getattr(_hidden_hwnd, "_hwnd", 0):
|
|
||||||
return _hidden_hwnd._hwnd
|
|
||||||
with _LOCK:
|
|
||||||
if getattr(_hidden_hwnd, "_hwnd", 0):
|
|
||||||
return _hidden_hwnd._hwnd
|
|
||||||
_u = _ct.windll.user32
|
|
||||||
_k = _ct.windll.kernel32
|
|
||||||
_u.DefWindowProcW.argtypes = [_wt.HWND, _wt.UINT, _ct.c_void_p, _ct.c_void_p]
|
|
||||||
_u.DefWindowProcW.restype = _ct.c_long
|
|
||||||
_WNDPROC = _ct.WINFUNCTYPE(_ct.c_long, _wt.HWND, _wt.UINT, _ct.c_void_p, _ct.c_void_p) # WPARAM/LPARAM 64-bit
|
|
||||||
class _WC(_ct.Structure):
|
|
||||||
_fields_ = [("style", _ct.c_uint), ("lpfnWndProc", _WNDPROC), ("cbClsExtra", _ct.c_int),
|
|
||||||
("cbWndExtra", _ct.c_int), ("hInstance", _wt.HINSTANCE), ("hIcon", _wt.HICON),
|
|
||||||
("hCursor", _wt.HANDLE), ("hbrBackground", _wt.HBRUSH), ("lpszMenuName", _wt.LPCWSTR),
|
|
||||||
("lpszClassName", _wt.LPCWSTR)]
|
|
||||||
_wc = _WC()
|
|
||||||
_wc.lpfnWndProc = _WNDPROC(_u.DefWindowProcW)
|
|
||||||
_wc.lpszClassName = "SonicForgeNativeHidden"
|
|
||||||
_wc.hInstance = _k.GetModuleHandleW(None)
|
|
||||||
if _u.RegisterClassW(_ct.byref(_wc)) or _k.GetLastError() == 1410: # 1410 = class ton tai
|
|
||||||
_h = _u.CreateWindowExW(0, "SonicForgeNativeHidden", "sf", 0, 0, 0, 0, 0,
|
|
||||||
None, None, _wc.hInstance, None)
|
|
||||||
_hidden_hwnd._hwnd = _h or 0
|
|
||||||
return _hidden_hwnd._hwnd
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
|
||||||
return os.path.isfile(_dll("sf_host_bridge.dll"))
|
|
||||||
|
|
||||||
|
|
||||||
# ── mixer math (offline VST) — MIRROR NativeMixer.cpp + mastering_engine.py ──
|
|
||||||
def _clamp_db(v, lo, hi, default):
|
|
||||||
if v != v: # NaN
|
|
||||||
return default
|
|
||||||
return max(lo, min(hi, v))
|
|
||||||
|
|
||||||
|
|
||||||
def apply_track_mixer(y, gain_db=0.0, pan=0.0, lim_active=False,
|
|
||||||
threshold_db=-1.0, master_lin=1.0):
|
|
||||||
"""y: (2, N) float32. Áp track gain/pan + limiter + master fader."""
|
|
||||||
y = y * (10.0 ** (gain_db / 20.0))
|
|
||||||
if pan != 0.0:
|
|
||||||
theta = ((max(-1.0, min(1.0, pan)) + 1.0) / 2.0) * (np.pi / 2.0)
|
|
||||||
y = y.copy()
|
|
||||||
y[0, :] *= np.cos(theta)
|
|
||||||
y[1, :] *= np.sin(theta)
|
|
||||||
if lim_active:
|
|
||||||
t = _clamp_db(threshold_db, -24.0, 0.0, -1.0)
|
|
||||||
t_lin = 10.0 ** (t / 20.0)
|
|
||||||
k = 1.0 / max(0.02, t_lin)
|
|
||||||
tk = np.tanh(k)
|
|
||||||
y = np.tanh(y * k) / tk
|
|
||||||
np.clip(y, -1.0, 1.0, out=y)
|
|
||||||
y = y * master_lin
|
|
||||||
return y
|
|
||||||
|
|
||||||
|
|
||||||
class _SfCtx:
|
|
||||||
"""ctypes wrapper một instance sf_host_bridge (handle theo track)."""
|
|
||||||
|
|
||||||
def __init__(self, dll: ctypes.WinDLL):
|
|
||||||
self.dll = dll
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def create(self, sr, block):
|
|
||||||
self.handle = self.dll.SF_FS_Create(sr, block, _ERR, 256)
|
|
||||||
return self.handle
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.handle:
|
|
||||||
try:
|
|
||||||
self.dll.SF_FS_Close(self.handle, _ERR, 256)
|
|
||||||
finally:
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return getattr(self.dll, "SF_FS_" + name)
|
|
||||||
|
|
||||||
|
|
||||||
class _Vst2Ctx:
|
|
||||||
def __init__(self, dll: ctypes.WinDLL):
|
|
||||||
self.dll = dll
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def create(self, plugin_path):
|
|
||||||
w = ctypes.c_int32(0)
|
|
||||||
h = ctypes.c_int32(0)
|
|
||||||
self.handle = self.dll.SF_VST2_Load(plugin_path.encode("utf-8"), 0,
|
|
||||||
ctypes.byref(w), ctypes.byref(h),
|
|
||||||
_ERR, 256)
|
|
||||||
return self.handle
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.handle:
|
|
||||||
try:
|
|
||||||
self.dll.SF_VST2_Close(self.handle, _ERR, 256)
|
|
||||||
finally:
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return getattr(self.dll, "SF_VST2_" + name)
|
|
||||||
|
|
||||||
|
|
||||||
class _Vst3Ctx:
|
|
||||||
def __init__(self, dll: ctypes.WinDLL):
|
|
||||||
self.dll = dll
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def create(self, plugin_path):
|
|
||||||
# Attach can plugin_name = ten class VST3 (ClassInfo::name) + parent
|
|
||||||
# hwnd. Ten class thuong = ten file/folder .vst3; hidden window cho
|
|
||||||
# live headless. Neu ten class khong khop -> rc 0 -> caller fallback.
|
|
||||||
base = os.path.basename(plugin_path.rstrip("\\/"))
|
|
||||||
if base.lower().endswith(".vst3"):
|
|
||||||
plugin_name = base[:-5]
|
|
||||||
else:
|
|
||||||
plugin_name = os.path.splitext(base)[0]
|
|
||||||
w = ctypes.c_int32(0)
|
|
||||||
h = ctypes.c_int32(0)
|
|
||||||
self.handle = self.dll.SF_VST3_Attach(plugin_path.encode("utf-8"),
|
|
||||||
plugin_name.encode("utf-8"),
|
|
||||||
_hidden_hwnd(),
|
|
||||||
ctypes.byref(w), ctypes.byref(h),
|
|
||||||
_ERR, 256)
|
|
||||||
return self.handle
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.handle:
|
|
||||||
try:
|
|
||||||
self.dll.SF_VST3_Close(self.handle, _ERR, 256)
|
|
||||||
finally:
|
|
||||||
self.handle = 0
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return getattr(self.dll, "SF_VST3_" + name)
|
|
||||||
|
|
||||||
|
|
||||||
class NativeAudioService:
|
|
||||||
"""Singleton: registry track_id → instance; master_gain toàn cục."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._sf = {}
|
|
||||||
self._vst2 = {}
|
|
||||||
self._vst3 = {}
|
|
||||||
self._sf_dll = None
|
|
||||||
self._vst2_dll = None
|
|
||||||
self._vst3_dll = None
|
|
||||||
self.master_gain_db = 0.0
|
|
||||||
self._master_lin = 1.0
|
|
||||||
self.sample_rate = 44100
|
|
||||||
self.block_size = 256
|
|
||||||
|
|
||||||
# ── master fader (IPC set_master_gain) ──
|
|
||||||
def set_master_gain(self, gain_db):
|
|
||||||
with _LOCK:
|
|
||||||
self.master_gain_db = float(gain_db)
|
|
||||||
self._master_lin = 0.0 if self.master_gain_db <= -50 else \
|
|
||||||
10.0 ** (self.master_gain_db / 20.0)
|
|
||||||
for ctx in list(self._sf.values()) + list(self._vst2.values()) + \
|
|
||||||
list(self._vst3.values()):
|
|
||||||
if ctx and ctx.handle:
|
|
||||||
try:
|
|
||||||
ctx.SetMasterGain(ctx.handle, self._master_lin)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True, "master_gain_db": self.master_gain_db,
|
|
||||||
"linear": self._master_lin}
|
|
||||||
|
|
||||||
def _load_sf_dll(self):
|
|
||||||
if self._sf_dll is None:
|
|
||||||
if not os.path.isfile(_dll("sf_host_bridge.dll")):
|
|
||||||
raise RuntimeError(f"thiếu {_dll('sf_host_bridge.dll')} — "
|
|
||||||
f"build native_host Release (T9–T14)")
|
|
||||||
dll = ctypes.WinDLL(_dll("sf_host_bridge.dll"))
|
|
||||||
i32 = ctypes.c_int32
|
|
||||||
dll.SF_FS_Create.argtypes = [i32, i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_FS_Create.restype = i32
|
|
||||||
dll.SF_FS_LoadSF2.argtypes = [i32, ctypes.c_char_p, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_FS_LoadSF2.restype = i32
|
|
||||||
dll.SF_FS_SelectInstrument.argtypes = [i32, i32, i32, i32, i32]
|
|
||||||
dll.SF_FS_SelectInstrument.restype = i32
|
|
||||||
dll.SF_FS_NoteOn.argtypes = [i32, i32, i32, i32]
|
|
||||||
dll.SF_FS_NoteOn.restype = i32
|
|
||||||
dll.SF_FS_NoteOff.argtypes = [i32, i32, i32]
|
|
||||||
dll.SF_FS_NoteOff.restype = i32
|
|
||||||
dll.SF_FS_RenderBlock.argtypes = [i32, i32,
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS")]
|
|
||||||
dll.SF_FS_RenderBlock.restype = i32
|
|
||||||
dll.SF_FS_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_FS_AudioStart.restype = i32
|
|
||||||
dll.SF_FS_AudioStop.argtypes = [i32]
|
|
||||||
dll.SF_FS_AudioStop.restype = i32
|
|
||||||
dll.SF_FS_AudioUnderruns.argtypes = [i32]
|
|
||||||
dll.SF_FS_AudioUnderruns.restype = i32
|
|
||||||
dll.SF_FS_AudioBlocks.argtypes = [i32]
|
|
||||||
dll.SF_FS_AudioBlocks.restype = i32
|
|
||||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
|
||||||
getattr(dll, "SF_FS_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
|
||||||
getattr(dll, "SF_FS_" + fn).restype = i32
|
|
||||||
dll.SF_FS_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
|
||||||
dll.SF_FS_SetMasterGain.restype = i32
|
|
||||||
dll.SF_FS_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_FS_Close.restype = i32
|
|
||||||
self._sf_dll = dll
|
|
||||||
return self._sf_dll
|
|
||||||
|
|
||||||
def ensure_sf(self, track_id, sf_path, bank=0, program=0, channel=0,
|
|
||||||
live=True, gain_db=None, pan=None):
|
|
||||||
"""Tạo (hoặc tái dùng) instance SF cho track. sf_path: đường dẫn SF2/SF3."""
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
dll = self._load_sf_dll()
|
|
||||||
ctx = self._sf.get(track_id)
|
|
||||||
if ctx is None:
|
|
||||||
ctx = _SfCtx(dll)
|
|
||||||
h = ctx.create(self.sample_rate, self.block_size)
|
|
||||||
if h <= 0:
|
|
||||||
raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}")
|
|
||||||
rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256)
|
|
||||||
if rc != 0:
|
|
||||||
ctx.close()
|
|
||||||
raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}: {_errval(_ERR)}")
|
|
||||||
# sfontId<0 → dùng sfid của font vừa load (T14)
|
|
||||||
rc = ctx.SelectInstrument(h, channel, -1, int(bank), int(program))
|
|
||||||
if rc != 0:
|
|
||||||
ctx.close()
|
|
||||||
raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}: "
|
|
||||||
f"bank={bank} prog={program}")
|
|
||||||
ctx.SetMasterGain(h, self._master_lin)
|
|
||||||
if live:
|
|
||||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
|
||||||
if rc != 0:
|
|
||||||
ctx.close()
|
|
||||||
raise RuntimeError(f"SF_FS_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
|
||||||
self._sf[track_id] = ctx
|
|
||||||
else:
|
|
||||||
h = ctx.handle
|
|
||||||
ctx.SelectInstrument(h, channel, -1, int(bank), int(program))
|
|
||||||
if gain_db is not None and pan is not None:
|
|
||||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
|
||||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
|
||||||
|
|
||||||
def sf_note_on(self, track_id, channel, pitch, velocity=100):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._sf.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track SF chưa ensure")
|
|
||||||
return {"ok": ctx.NoteOn(ctx.handle, int(channel), int(pitch),
|
|
||||||
int(max(1, min(127, velocity)))) == 0}
|
|
||||||
|
|
||||||
def sf_note_off(self, track_id, channel, pitch):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._sf.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track SF chưa ensure")
|
|
||||||
return {"ok": ctx.NoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
|
||||||
|
|
||||||
def set_track_gain_pan(self, track_id, gain_db, pan):
|
|
||||||
"""Áp gain/pan cho instance track (SF/VST2/VST3)."""
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = (self._sf.get(track_id) or self._vst2.get(track_id) or
|
|
||||||
self._vst3.get(track_id))
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track chưa ensure")
|
|
||||||
rc = ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
|
||||||
return {"ok": rc == 0, "track_id": track_id,
|
|
||||||
"gain_db": gain_db, "pan": pan}
|
|
||||||
|
|
||||||
def sf_stats(self, track_id):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._sf.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
return None
|
|
||||||
return {"blocks": ctx.AudioBlocks(ctx.handle),
|
|
||||||
"underruns": ctx.AudioUnderruns(ctx.handle)}
|
|
||||||
|
|
||||||
def sf_audio_stop(self, track_id):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._sf.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
return {"ok": False}
|
|
||||||
return {"ok": ctx.AudioStop(ctx.handle) == 0}
|
|
||||||
|
|
||||||
def render_sf_offline(self, sf_path, bank, program, notes, bpm=120.0,
|
|
||||||
sr=44100, gain_db=0.0, pan=0.0, lim_active=False,
|
|
||||||
threshold_db=-1.0, master_gain_db=0.0):
|
|
||||||
"""Render offline 1 đoạn MIDI bằng instance TẠM (không AudioStart) —
|
|
||||||
RenderBlock áp mixer native (gain/pan/limiter/master) trong C++."""
|
|
||||||
with _LOCK:
|
|
||||||
dll = self._load_sf_dll()
|
|
||||||
ctx = _SfCtx(dll)
|
|
||||||
h = ctx.create(sr, 256)
|
|
||||||
if h <= 0:
|
|
||||||
raise RuntimeError(f"SF_FS_Create fail: {_errval(_ERR)}")
|
|
||||||
try:
|
|
||||||
rc = ctx.LoadSF2(h, os.fspath(sf_path).encode("utf-8"), _ERR, 256)
|
|
||||||
if rc != 0:
|
|
||||||
raise RuntimeError(f"SF_FS_LoadSF2 fail rc={rc}")
|
|
||||||
rc = ctx.SelectInstrument(h, 0, -1, int(bank), int(program))
|
|
||||||
if rc != 0:
|
|
||||||
raise RuntimeError(f"SF_FS_SelectInstrument fail rc={rc}")
|
|
||||||
ctx.SetTrackGainPan(h, float(gain_db), float(pan))
|
|
||||||
ctx.SetMasterLimiter(h, int(lim_active), float(threshold_db))
|
|
||||||
master_lin = 0.0 if master_gain_db <= -50 else \
|
|
||||||
10.0 ** (float(master_gain_db) / 20.0)
|
|
||||||
ctx.SetMasterGain(h, master_lin)
|
|
||||||
|
|
||||||
beat_sec = 60.0 / max(30.0, bpm)
|
|
||||||
events = []
|
|
||||||
total = 0.0
|
|
||||||
for ev in notes:
|
|
||||||
start_s = float(ev.get("start_beat", 0)) * beat_sec
|
|
||||||
dur_s = float(ev.get("duration_beats", 1)) * beat_sec
|
|
||||||
pitch = int(ev.get("note", 60))
|
|
||||||
vel = int(max(1, min(127, float(ev.get("velocity", 100)))))
|
|
||||||
events.append((int(start_s * sr), "on", pitch, vel))
|
|
||||||
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
|
||||||
total = max(total, start_s + dur_s)
|
|
||||||
events.sort(key=lambda e: e[0])
|
|
||||||
n = int((max(total, 0.25) + 0.5) * sr)
|
|
||||||
out = np.zeros((2, n), dtype=np.float32)
|
|
||||||
block = 256
|
|
||||||
pos = 0
|
|
||||||
ev_idx = 0
|
|
||||||
while pos < n:
|
|
||||||
while ev_idx < len(events) and events[ev_idx][0] <= pos:
|
|
||||||
_, kind, pitch, vel = events[ev_idx]
|
|
||||||
if kind == "on":
|
|
||||||
ctx.NoteOn(h, 0, pitch, vel)
|
|
||||||
else:
|
|
||||||
ctx.NoteOff(h, 0, pitch)
|
|
||||||
ev_idx += 1
|
|
||||||
take = min(block, n - pos)
|
|
||||||
l = np.zeros(take, np.float32)
|
|
||||||
r = np.zeros(take, np.float32)
|
|
||||||
ctx.RenderBlock(h, take, l, r)
|
|
||||||
out[0, pos:pos + take] = l
|
|
||||||
out[1, pos:pos + take] = r
|
|
||||||
pos += take
|
|
||||||
return out
|
|
||||||
finally:
|
|
||||||
ctx.close()
|
|
||||||
|
|
||||||
# ── VST2 (live + offline qua SF_VST2_Process) ──
|
|
||||||
def _load_vst2_dll(self):
|
|
||||||
if self._vst2_dll is None:
|
|
||||||
if not os.path.isfile(_dll("vst2_host_bridge.dll")):
|
|
||||||
raise RuntimeError(f"thiếu {_dll('vst2_host_bridge.dll')}")
|
|
||||||
dll = ctypes.WinDLL(_dll("vst2_host_bridge.dll"))
|
|
||||||
i32 = ctypes.c_int32
|
|
||||||
dll.SF_VST2_Load.argtypes = [ctypes.c_char_p, ctypes.c_void_p,
|
|
||||||
ctypes.POINTER(i32), ctypes.POINTER(i32),
|
|
||||||
ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST2_Load.restype = i32
|
|
||||||
dll.SF_VST2_SendNoteOn.argtypes = [i32, i32, i32, i32]
|
|
||||||
dll.SF_VST2_SendNoteOn.restype = i32
|
|
||||||
dll.SF_VST2_SendNoteOff.argtypes = [i32, i32, i32]
|
|
||||||
dll.SF_VST2_SendNoteOff.restype = i32
|
|
||||||
dll.SF_VST2_Process.argtypes = [i32,
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
|
||||||
i32, i32]
|
|
||||||
dll.SF_VST2_Process.restype = i32
|
|
||||||
dll.SF_VST2_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST2_AudioStart.restype = i32
|
|
||||||
dll.SF_VST2_AudioStop.argtypes = [i32]
|
|
||||||
dll.SF_VST2_AudioStop.restype = i32
|
|
||||||
dll.SF_VST2_AudioUnderruns.argtypes = [i32]
|
|
||||||
dll.SF_VST2_AudioUnderruns.restype = i32
|
|
||||||
dll.SF_VST2_AudioBlocks.argtypes = [i32]
|
|
||||||
dll.SF_VST2_AudioBlocks.restype = i32
|
|
||||||
dll.SF_VST2_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST2_Close.restype = i32
|
|
||||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
|
||||||
getattr(dll, "SF_VST2_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
|
||||||
getattr(dll, "SF_VST2_" + fn).restype = i32
|
|
||||||
dll.SF_VST2_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
|
||||||
dll.SF_VST2_SetMasterGain.restype = i32
|
|
||||||
self._vst2_dll = dll
|
|
||||||
return self._vst2_dll
|
|
||||||
|
|
||||||
def ensure_vst2(self, track_id, plugin_path, live=True, gain_db=None, pan=None):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
dll = self._load_vst2_dll()
|
|
||||||
ctx = self._vst2.get(track_id)
|
|
||||||
if ctx is None:
|
|
||||||
ctx = _Vst2Ctx(dll)
|
|
||||||
h = ctx.create(plugin_path)
|
|
||||||
if h <= 0:
|
|
||||||
raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}")
|
|
||||||
ctx.SetMasterGain(h, self._master_lin)
|
|
||||||
if live:
|
|
||||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
|
||||||
if rc != 0:
|
|
||||||
ctx.close()
|
|
||||||
raise RuntimeError(f"SF_VST2_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
|
||||||
self._vst2[track_id] = ctx
|
|
||||||
if gain_db is not None and pan is not None:
|
|
||||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
|
||||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
|
||||||
|
|
||||||
def vst2_note_on(self, track_id, channel, pitch, velocity=100):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._vst2.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track VST2 chưa ensure")
|
|
||||||
return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch),
|
|
||||||
int(max(1, min(127, velocity)))) == 0}
|
|
||||||
|
|
||||||
def vst2_note_off(self, track_id, channel, pitch):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._vst2.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track VST2 chưa ensure")
|
|
||||||
return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
|
||||||
|
|
||||||
def vst2_stats(self, track_id):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._vst2.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
return None
|
|
||||||
return {"blocks": ctx.AudioBlocks(ctx.handle),
|
|
||||||
"underruns": ctx.AudioUnderruns(ctx.handle)}
|
|
||||||
|
|
||||||
def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100,
|
|
||||||
gain_db=0.0, pan=0.0, lim_active=False,
|
|
||||||
threshold_db=-1.0, master_gain_db=0.0, tail_sec=0.5):
|
|
||||||
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror).
|
|
||||||
|
|
||||||
tail_sec: duoi im lang sau note cuoi (autosample muon release tail)."""
|
|
||||||
with _LOCK:
|
|
||||||
dll = self._load_vst2_dll()
|
|
||||||
ctx = _Vst2Ctx(dll)
|
|
||||||
h = ctx.create(plugin_path)
|
|
||||||
if h <= 0:
|
|
||||||
raise RuntimeError(f"SF_VST2_Load fail: {_errval(_ERR)}")
|
|
||||||
try:
|
|
||||||
beat_sec = 60.0 / max(30.0, bpm)
|
|
||||||
events = []
|
|
||||||
total = 0.0
|
|
||||||
for ev in notes:
|
|
||||||
start_s = float(ev.get("start_beat", 0)) * beat_sec
|
|
||||||
dur_s = float(ev.get("duration_beats", 1)) * beat_sec
|
|
||||||
pitch = int(ev.get("note", 60))
|
|
||||||
vel = int(max(1, min(127, float(ev.get("velocity", 100)))))
|
|
||||||
events.append((int(start_s * sr), "on", pitch, vel))
|
|
||||||
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
|
||||||
total = max(total, start_s + dur_s)
|
|
||||||
events.sort(key=lambda e: e[0])
|
|
||||||
n = int((max(total, 0.25) + float(tail_sec)) * sr)
|
|
||||||
out = np.zeros((2, n), dtype=np.float32)
|
|
||||||
block = 256
|
|
||||||
pos = 0
|
|
||||||
ev_idx = 0
|
|
||||||
buf = np.zeros((4, block), np.float32) # inputs+outputs (fake: 2+2)
|
|
||||||
while pos < n:
|
|
||||||
while ev_idx < len(events) and events[ev_idx][0] <= pos:
|
|
||||||
_, kind, pitch, vel = events[ev_idx]
|
|
||||||
if kind == "on":
|
|
||||||
ctx.SendNoteOn(h, 0, pitch, vel)
|
|
||||||
else:
|
|
||||||
ctx.SendNoteOff(h, 0, pitch)
|
|
||||||
ev_idx += 1
|
|
||||||
take = min(block, n - pos)
|
|
||||||
buf.fill(0)
|
|
||||||
ctx.Process(h, buf, 4, take)
|
|
||||||
# ponytail: giả định plugin synth 0-input → output ở buf[0:2]
|
|
||||||
# (bridge xếp outputs tại offset numInputs; khi cần plugin
|
|
||||||
# có input → thêm export getNumIO rồi đọc offset đó)
|
|
||||||
out[0, pos:pos + take] = buf[0, :take]
|
|
||||||
out[1, pos:pos + take] = buf[1, :take]
|
|
||||||
pos += take
|
|
||||||
master_lin = 0.0 if master_gain_db <= -50 else \
|
|
||||||
10.0 ** (float(master_gain_db) / 20.0)
|
|
||||||
return apply_track_mixer(out, gain_db, pan, lim_active,
|
|
||||||
threshold_db, master_lin)
|
|
||||||
finally:
|
|
||||||
ctx.close()
|
|
||||||
|
|
||||||
# ── VST3: live qua bridge (offline: limitation — chưa có Process export) ──
|
|
||||||
def _load_vst3_dll(self):
|
|
||||||
if self._vst3_dll is None:
|
|
||||||
if not os.path.isfile(_dll("vst3_host_bridge.dll")):
|
|
||||||
raise RuntimeError(f"thiếu {_dll('vst3_host_bridge.dll')}")
|
|
||||||
dll = ctypes.WinDLL(_dll("vst3_host_bridge.dll"))
|
|
||||||
i32 = ctypes.c_int32
|
|
||||||
dll.SF_VST3_Attach.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
|
|
||||||
ctypes.c_void_p, ctypes.POINTER(i32),
|
|
||||||
ctypes.POINTER(i32), ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST3_Attach.restype = i32
|
|
||||||
dll.SF_VST3_SendNoteOn.argtypes = [i32, i32, i32, i32]
|
|
||||||
dll.SF_VST3_SendNoteOn.restype = i32
|
|
||||||
dll.SF_VST3_SendNoteOff.argtypes = [i32, i32, i32]
|
|
||||||
dll.SF_VST3_SendNoteOff.restype = i32
|
|
||||||
dll.SF_VST3_AudioStart.argtypes = [i32, i32, i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST3_AudioStart.restype = i32
|
|
||||||
dll.SF_VST3_AudioStop.argtypes = [i32]
|
|
||||||
dll.SF_VST3_AudioStop.restype = i32
|
|
||||||
dll.SF_VST3_AudioUnderruns.argtypes = [i32]
|
|
||||||
dll.SF_VST3_AudioUnderruns.restype = i32
|
|
||||||
dll.SF_VST3_AudioBlocks.argtypes = [i32]
|
|
||||||
dll.SF_VST3_Close.argtypes = [i32, ctypes.c_char_p, i32]
|
|
||||||
dll.SF_VST3_Close.restype = i32
|
|
||||||
for fn in ("SetTrackGainPan", "SetMasterLimiter"):
|
|
||||||
getattr(dll, "SF_VST3_" + fn).argtypes = [i32, ctypes.c_float, ctypes.c_float]
|
|
||||||
getattr(dll, "SF_VST3_" + fn).restype = i32
|
|
||||||
dll.SF_VST3_SetMasterGain.argtypes = [i32, ctypes.c_float]
|
|
||||||
dll.SF_VST3_SetMasterGain.restype = i32
|
|
||||||
self._vst3_dll = dll
|
|
||||||
return self._vst3_dll
|
|
||||||
|
|
||||||
def ensure_vst3(self, track_id, plugin_path, live=True, gain_db=None, pan=None):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
dll = self._load_vst3_dll()
|
|
||||||
ctx = self._vst3.get(track_id)
|
|
||||||
if ctx is None:
|
|
||||||
ctx = _Vst3Ctx(dll)
|
|
||||||
h = ctx.create(plugin_path)
|
|
||||||
if h <= 0:
|
|
||||||
raise RuntimeError(f"SF_VST3_Attach fail: {_errval(_ERR)}")
|
|
||||||
ctx.SetMasterGain(h, self._master_lin)
|
|
||||||
if live:
|
|
||||||
rc = ctx.AudioStart(h, self.sample_rate, self.block_size, _ERR, 256)
|
|
||||||
if rc != 0:
|
|
||||||
ctx.close()
|
|
||||||
raise RuntimeError(f"SF_VST3_AudioStart fail rc={rc}: {_errval(_ERR)}")
|
|
||||||
self._vst3[track_id] = ctx
|
|
||||||
if gain_db is not None and pan is not None:
|
|
||||||
ctx.SetTrackGainPan(ctx.handle, float(gain_db), float(pan))
|
|
||||||
return {"ok": True, "track_id": track_id, "handle": ctx.handle}
|
|
||||||
|
|
||||||
def vst3_note_on(self, track_id, channel, pitch, velocity=100):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._vst3.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track VST3 chưa ensure")
|
|
||||||
return {"ok": ctx.SendNoteOn(ctx.handle, int(channel), int(pitch),
|
|
||||||
int(max(1, min(127, velocity)))) == 0}
|
|
||||||
|
|
||||||
def vst3_note_off(self, track_id, channel, pitch):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
ctx = self._vst3.get(track_id)
|
|
||||||
if not ctx or not ctx.handle:
|
|
||||||
raise RuntimeError("track VST3 chưa ensure")
|
|
||||||
return {"ok": ctx.SendNoteOff(ctx.handle, int(channel), int(pitch)) == 0}
|
|
||||||
|
|
||||||
def close_track(self, track_id):
|
|
||||||
track_id = str(track_id)
|
|
||||||
with _LOCK:
|
|
||||||
for reg in (self._sf, self._vst2, self._vst3):
|
|
||||||
ctx = reg.pop(track_id, None)
|
|
||||||
if ctx:
|
|
||||||
try:
|
|
||||||
ctx.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def close_all(self):
|
|
||||||
with _LOCK:
|
|
||||||
for reg in (self._sf, self._vst2, self._vst3):
|
|
||||||
for ctx in list(reg.values()):
|
|
||||||
try:
|
|
||||||
ctx.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
reg.clear()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def status(self):
|
|
||||||
return {
|
|
||||||
"available": is_available(),
|
|
||||||
"dll_dir": _NATIVE_DIR,
|
|
||||||
"master_gain_db": self.master_gain_db,
|
|
||||||
"sf_tracks": sorted(self._sf.keys()),
|
|
||||||
"vst2_tracks": sorted(self._vst2.keys()),
|
|
||||||
"vst3_tracks": sorted(self._vst3.keys()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_service = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_service() -> NativeAudioService:
|
|
||||||
global _service
|
|
||||||
if _service is None:
|
|
||||||
_service = NativeAudioService()
|
|
||||||
return _service
|
|
||||||
@@ -450,7 +450,7 @@ class PythonRenderEngine:
|
|||||||
|
|
||||||
return session_buffer
|
return session_buffer
|
||||||
|
|
||||||
def render_project(self, project_json: dict, output_filepath: str, bit_depth: int = 16):
|
def render_project(self, project_json: dict, output_filepath: str):
|
||||||
bpm = project_json["metadata"]["bpm"]
|
bpm = project_json["metadata"]["bpm"]
|
||||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||||
main_session = project_json["main_session"]
|
main_session = project_json["main_session"]
|
||||||
@@ -470,28 +470,11 @@ class PythonRenderEngine:
|
|||||||
_cache={},
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mastering chain — mirror client WebAudio (app.jsx applyMasteringSettings /
|
# Normalization to prevent clipping
|
||||||
# rebuildMasteringGraph). Truoc day mastering_settings bi BO QUA hoan toan:
|
max_peak = np.max(np.abs(master_buffer))
|
||||||
# export WAV khong qua EQ/Imager/Maximizer -> file khac am nghe live.
|
if max_peak > 1.0:
|
||||||
# Gio ap dung dung chain (thu tu settings['chain']) khi mastering bat.
|
master_buffer /= max_peak
|
||||||
mastering_settings = project_json.get("mastering_settings")
|
|
||||||
mastered = bool(mastering_settings and mastering_settings.get("masterConnected")
|
|
||||||
and not mastering_settings.get("isBypassed"))
|
|
||||||
if mastered:
|
|
||||||
# Import lazy (scipy.signal) — giam thoi gian khoi dong engine
|
|
||||||
from app.core.mastering_engine import apply_mastering
|
|
||||||
master_buffer = apply_mastering(master_buffer, mastering_settings,
|
|
||||||
self.sample_rate)
|
|
||||||
# Hard clip [-1,1] — khớp WAV encoder client (browser) sau mastering
|
|
||||||
np.clip(master_buffer, -1.0, 1.0, out=master_buffer)
|
|
||||||
else:
|
|
||||||
# Normalization to prevent clipping
|
|
||||||
max_peak = np.max(np.abs(master_buffer))
|
|
||||||
if max_peak > 1.0:
|
|
||||||
master_buffer /= max_peak
|
|
||||||
|
|
||||||
# Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
|
# Write final output file
|
||||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
sf.write(output_filepath, master_buffer.T, self.sample_rate)
|
||||||
sf.write(output_filepath, master_buffer.T, self.sample_rate,
|
|
||||||
subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
|
||||||
return output_filepath
|
return output_filepath
|
||||||
|
|||||||
@@ -5,31 +5,6 @@ import logging
|
|||||||
import wave
|
import wave
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
def _ensure_fluidsynth_runtime() -> None:
|
|
||||||
"""Dua thu muc chua libfluidsynth DLL vao PATH de pyfluidsynth import duoc
|
|
||||||
(find_library do theo PATH tren Windows). Khong lam gi tren non-Windows."""
|
|
||||||
if os.name != "nt":
|
|
||||||
return
|
|
||||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
candidates = []
|
|
||||||
res_dir = os.getenv("SF_RESOURCE_DIR")
|
|
||||||
if res_dir:
|
|
||||||
candidates.append(os.path.join(res_dir, "native_host", "fluidsynth_runtime"))
|
|
||||||
candidates += [
|
|
||||||
os.path.join(root, "native_host", "build", "Release", "fluidsynth_runtime"),
|
|
||||||
os.path.join(root, "native_host", "fluidsynth_runtime"),
|
|
||||||
]
|
|
||||||
path = os.environ.get("PATH", "")
|
|
||||||
parts = [os.path.normcase(p) for p in path.split(os.pathsep)]
|
|
||||||
for d in candidates:
|
|
||||||
if os.path.isdir(d) and any(
|
|
||||||
f.lower().startswith("libfluidsynth") and f.lower().endswith(".dll")
|
|
||||||
for f in os.listdir(d)
|
|
||||||
):
|
|
||||||
if os.path.normcase(d) not in parts:
|
|
||||||
os.environ["PATH"] = d + os.pathsep + path
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
SF_TARGET_DIRS = [
|
SF_TARGET_DIRS = [
|
||||||
"/opt/daw_engine/soundfonts",
|
"/opt/daw_engine/soundfonts",
|
||||||
@@ -218,8 +193,8 @@ class SoundFontConverter:
|
|||||||
# OGG loop pointers are relative to the individual decompressed sample
|
# OGG loop pointers are relative to the individual decompressed sample
|
||||||
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
||||||
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
|
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
|
||||||
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
|
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
|
||||||
new_stype = sampletype | 0x10
|
new_stype = sampletype | 0x20
|
||||||
new_shdr += data[base:base + 20] # sample name
|
new_shdr += data[base:base + 20] # sample name
|
||||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
||||||
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
|
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
|
||||||
@@ -307,28 +282,35 @@ class SoundFontConverter:
|
|||||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||||
against shipping malformed SF3 files that silently play nothing).
|
against shipping malformed SF3 files that silently play nothing).
|
||||||
|
|
||||||
Uses the high-level Synth() binding (sfload/get_samples).
|
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||||
|
high-level Synth() class does not exist in this binding, so it is never
|
||||||
|
used here.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
_ensure_fluidsynth_runtime()
|
|
||||||
try:
|
try:
|
||||||
import fluidsynth as _fs
|
import fluidsynth as _fs
|
||||||
import numpy as np
|
import numpy as np
|
||||||
_synth = _fs.Synth()
|
_settings = _fs.new_fluid_settings()
|
||||||
|
_fl = _fs.new_fluid_synth(_settings)
|
||||||
try:
|
try:
|
||||||
fid = _synth.sfload(path)
|
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||||
if fid == -1:
|
if h < 0:
|
||||||
return False
|
return False
|
||||||
_synth.program_select(0, fid, 0, 0)
|
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||||
_synth.noteon(0, 60, 100)
|
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||||
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
|
frames = 8820 # 0.2s
|
||||||
_synth.noteoff(0, 60)
|
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||||
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
|
_fs.fluid_synth_write_float(
|
||||||
|
_fl, frames, buf.ctypes.data, 0, 1,
|
||||||
|
buf.ctypes.data + frames * 4, 0, 1
|
||||||
|
)
|
||||||
|
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||||
|
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||||
return rms > 1e-4
|
return rms > 1e-4
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
_synth.delete()
|
_fs.delete_fluid_synth(_fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -471,7 +453,7 @@ class SoundFontConverter:
|
|||||||
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
||||||
new_loopend = loopend + new_start if (loopstart or loopend) else 0
|
new_loopend = loopend + new_start if (loopstart or loopend) else 0
|
||||||
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
|
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
|
||||||
new_stype = sampletype & ~0x10
|
new_stype = sampletype & ~0x20
|
||||||
new_shdr += name
|
new_shdr += name
|
||||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
||||||
new_shdr += data[base + 40:base + 44]
|
new_shdr += data[base + 40:base + 44]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
-13
@@ -20,8 +20,6 @@ from app.api.v1.plugins import router as plugins_router
|
|||||||
from app.api.v1.media import router as media_router
|
from app.api.v1.media import router as media_router
|
||||||
from app.api.v1.system import router as system_router
|
from app.api.v1.system import router as system_router
|
||||||
from app.api.v1.presets import router as presets_router
|
from app.api.v1.presets import router as presets_router
|
||||||
from app.api.v1.native import router as native_router
|
|
||||||
from app.api.v1.logs import router as logs_router
|
|
||||||
from app.core.auth import seed_admin
|
from app.core.auth import seed_admin
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
|
|
||||||
@@ -114,8 +112,6 @@ app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
|||||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||||
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
||||||
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
||||||
app.include_router(native_router, prefix="/api/v1/native", tags=["native"])
|
|
||||||
app.include_router(logs_router, prefix="/api/v1/logs", tags=["logs"])
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -136,15 +132,6 @@ async def get_index():
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/vst_gui.html", response_class=HTMLResponse)
|
|
||||||
async def get_vst_gui():
|
|
||||||
"""Cửa sổ VST GUI (Bug 1): Rust mở WebviewUrl::App("vst_gui.html?...") —
|
|
||||||
trong devUrl (localhost:8000) cần route riêng vì file nằm /static/vst_gui.html."""
|
|
||||||
vst_gui_path = os.path.join(STATIC_DIR, "vst_gui.html")
|
|
||||||
if os.path.exists(vst_gui_path):
|
|
||||||
return FileResponse(vst_gui_path, media_type="text/html")
|
|
||||||
return HTMLResponse(content="<h1>vst_gui.html not found</h1>", status_code=404)
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def get_favicon():
|
async def get_favicon():
|
||||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||||
|
|||||||
+519
-633
File diff suppressed because it is too large
Load Diff
+150
-142
File diff suppressed because one or more lines are too long
@@ -294,7 +294,7 @@ ${rules.join('\n')}` },
|
|||||||
async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) {
|
async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) {
|
||||||
const base = baseUrl.replace(/\/$/, '');
|
const base = baseUrl.replace(/\/$/, '');
|
||||||
const url = `${base}/chat/completions`;
|
const url = `${base}/chat/completions`;
|
||||||
const origin = window.location.origin;
|
const origin = (window.API_BASE_URL || window.location.origin).replace(/\/+$/, '');
|
||||||
const urlOrigin = parseOrigin(url);
|
const urlOrigin = parseOrigin(url);
|
||||||
const appOrigin = parseOrigin(origin);
|
const appOrigin = parseOrigin(origin);
|
||||||
const sameOrigin = urlOrigin === appOrigin;
|
const sameOrigin = urlOrigin === appOrigin;
|
||||||
@@ -341,7 +341,21 @@ ${rules.join('\n')}` },
|
|||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
const ct = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (!ct.includes('application/json') && !ct.includes('text/json')) {
|
||||||
|
const raw = (await response.text()).slice(0, 300);
|
||||||
|
throw new Error(`Phản hồi không phải JSON (HTTP ${response.status}, ${ct || 'no content-type'}).
|
||||||
|
Có thể proxy trỏ sai địa chỉ hoặc server trả trang HTML.
|
||||||
|
Raw: ${raw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
const raw = (await response.text()).slice(0, 300);
|
||||||
|
throw new Error(`JSON parse lỗi từ server AI (HTTP ${response.status}): ${e.message}
|
||||||
|
Raw: ${raw}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFunctionCalls(completion) {
|
function extractFunctionCalls(completion) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// SonicForge Studio API Service
|
// SonicForge Studio API Service
|
||||||
window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\/+$/, '');
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
@@ -91,8 +91,6 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
// để UI preview / gán clip vào track / download
|
// để UI preview / gán clip vào track / download
|
||||||
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
// Autosample VSTi → SF2 (backend pedalboard) — live playback client-side qua WASM
|
|
||||||
autosampleVsti: (payload) => apiRequest('/api/v1/plugins/autosample', { method: 'POST', body: JSON.stringify(payload) }),
|
|
||||||
// Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
|
// Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
|
||||||
// pedalboard không render được plugin (VD VST2)
|
// pedalboard không render được plugin (VD VST2)
|
||||||
carlaPlayNotes: (payload) => apiRequest('/api/v1/plugins/carla-play-notes', { method: 'POST', body: JSON.stringify(payload) }),
|
carlaPlayNotes: (payload) => apiRequest('/api/v1/plugins/carla-play-notes', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
@@ -115,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/plugins/bridge/status', { method: 'GET' }),
|
||||||
|
bridgeLoad: (payload) => apiRequest('/api/v1/plugins/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
bridgeLog: (lines = 100) => apiRequest(`/api/v1/plugins/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||||
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' }),
|
||||||
@@ -143,12 +146,6 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
throw new Error(err.detail || 'Upload failed');
|
throw new Error(err.detail || 'Upload failed');
|
||||||
}
|
}
|
||||||
return resp.json();
|
return resp.json();
|
||||||
},
|
|
||||||
// fetch thô có Authorization header — trả Response (dùng cho download bytes/media browse)
|
|
||||||
authFetch: (url, options = {}) => {
|
|
||||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
|
||||||
if (options.body instanceof FormData) delete headers['Content-Type'];
|
|
||||||
return fetch(url, { ...options, headers });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
// SonicForge App Logger — ghi hành động quan trọng của client (console + POST
|
|
||||||
// /api/v1/logs để server log). Fire-and-forget: không throw, không block UI.
|
|
||||||
// Các category dùng chung:
|
|
||||||
// INSTRUMENT — lỗi load instrument (soundfont select/load fail)
|
|
||||||
// VSTI — VSTi load/autosample/preview fail
|
|
||||||
// MIDI_ITEM — MIDI item qua mastering fx chain / main out (playback)
|
|
||||||
// DRAG_MIDI — lỗi drag MIDI từ MEDIA EXPLORER hoặc window explorer
|
|
||||||
window.SonicAppLogger = window.SonicAppLogger || (function () {
|
|
||||||
function base() {
|
|
||||||
return window.API_BASE_URL || window.location.origin || '';
|
|
||||||
}
|
|
||||||
function emit(category, level, message, data) {
|
|
||||||
try {
|
|
||||||
var line = '[AppLog][' + category + '][' + level + '] ' + message;
|
|
||||||
if (data !== undefined && data !== null) {
|
|
||||||
try { line += ' ' + JSON.stringify(data); } catch (e) {}
|
|
||||||
}
|
|
||||||
if (level === 'error') console.error(line); else if (level === 'warn') console.warn(line); else console.log(line);
|
|
||||||
try {
|
|
||||||
fetch(base() + '/api/v1/logs', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
category: category,
|
|
||||||
level: level,
|
|
||||||
message: message,
|
|
||||||
data: data !== undefined ? data : null,
|
|
||||||
url: window.location ? window.location.href : '',
|
|
||||||
ts: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}).catch(function () {});
|
|
||||||
} catch (e) {}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
info: function (category, message, data) { emit(category, 'info', message, data); },
|
|
||||||
warn: function (category, message, data) { emit(category, 'warn', message, data); },
|
|
||||||
error: function (category, message, data) { emit(category, 'error', message, data); }
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 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,
|
||||||
|
_dest: 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Idempotent: updateSfRouting/ensureMasteringRouting chay tren MOI keydown/
|
||||||
|
// note-on — neu da noi dung dest thi KHONG disconnect (disconnect flush ring
|
||||||
|
// -> am phim dang vang bi cat). Chi doi khi dest/track thuc su khac.
|
||||||
|
if (this._connected && this._dest === dest && this._trackId === (trackId || null)) return true;
|
||||||
|
this.disconnect();
|
||||||
|
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||||
|
bridgeNode.connect(dest);
|
||||||
|
this._connected = true;
|
||||||
|
this._trackId = trackId || null;
|
||||||
|
this._dest = dest;
|
||||||
|
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: function () {
|
||||||
|
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||||
|
this._connected = false;
|
||||||
|
this._trackId = null;
|
||||||
|
this._dest = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.AudioRoutingEngine = engine;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// 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 () {
|
||||||
|
// Bridge renders one 256-sample block per ~5.8ms; ScriptProcessor fires once
|
||||||
|
// per 4096 samples (~16 blocks @44.1k). The ring MUST cover one full callback
|
||||||
|
// period or we underrun (silence gaps) — 24 blocks ~= 139ms headroom.
|
||||||
|
var RING_DEPTH = 24;
|
||||||
|
var SP_BUFFER = 4096;
|
||||||
|
var _chunks = []; // [{l: Float32Array(256), r: Float32Array(256)}]
|
||||||
|
var _spn = null;
|
||||||
|
var _gainNode = null;
|
||||||
|
var _ctx = null;
|
||||||
|
var _initialized = false;
|
||||||
|
var _dest = null; // destination gain node da noi (chong connect trung)
|
||||||
|
|
||||||
|
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);
|
||||||
|
var outLen = L.length;
|
||||||
|
L.fill(0); R.fill(0);
|
||||||
|
if (!_chunks.length) return;
|
||||||
|
// Drain as many queued blocks as fit in this callback (16 max) — filling
|
||||||
|
// only one block per callback previously played 1/16 of the audio.
|
||||||
|
var written = 0;
|
||||||
|
while (written < outLen && _chunks.length) {
|
||||||
|
var blk = _chunks[0];
|
||||||
|
var n = Math.min(blk.l.length, outLen - written);
|
||||||
|
L.set(blk.l.subarray(0, n), written);
|
||||||
|
R.set(blk.r.subarray(0, n), written);
|
||||||
|
written += n;
|
||||||
|
if (n >= blk.l.length) {
|
||||||
|
_chunks.shift();
|
||||||
|
} else {
|
||||||
|
// Partial block (only possible if block size != 256): keep remainder.
|
||||||
|
blk.l = blk.l.subarray(n);
|
||||||
|
blk.r = blk.r.subarray(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_spn.connect(_gainNode);
|
||||||
|
_initialized = true;
|
||||||
|
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudio(l, r) {
|
||||||
|
if (!_initialized) init();
|
||||||
|
_chunks.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||||
|
// Drop oldest on overrun — keeps latency bounded (~139ms max).
|
||||||
|
while (_chunks.length > RING_DEPTH) _chunks.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() { _chunks = []; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
_dest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.BridgeAudioNode = {
|
||||||
|
init: init,
|
||||||
|
onAudio: onAudio,
|
||||||
|
flush: flush,
|
||||||
|
getOutputNode: getOutputNode,
|
||||||
|
connect: function (dest) {
|
||||||
|
if (!_gainNode) return;
|
||||||
|
if (_dest === dest) return; // da noi — khong noi trung (double-sum)
|
||||||
|
if (_dest) { try { _gainNode.disconnect(_dest); } catch (e) {} }
|
||||||
|
_gainNode.connect(dest);
|
||||||
|
_dest = dest;
|
||||||
|
},
|
||||||
|
disconnect: disconnect,
|
||||||
|
isReady: function () { return _initialized; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
if (window.__FLUIDSYNTH_CDN) {
|
if (window.__FLUIDSYNTH_CDN) {
|
||||||
FLUIDSYNTH_JS_URL = window.__FLUIDSYNTH_CDN;
|
FLUIDSYNTH_JS_URL = window.__FLUIDSYNTH_CDN;
|
||||||
|
} else if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
FLUIDSYNTH_JS_URL = 'https://cdn.jsdelivr.net/npm/@enikey87/fluidsynth-emscripten@0.1.1/dist/libfluidsynth-2.3.0-sf3.js';
|
||||||
} else {
|
} else {
|
||||||
// Luôn dùng WASM local — Tauri webview hostname=localhost nên CDN (jsdelivr) bị
|
|
||||||
// chặn làm FluidSynth không load, phải rơi về beep. File có sẵn trong source+dist.
|
|
||||||
FLUIDSYNTH_JS_URL = '/static/js/vendor/libfluidsynth-2.3.0-sf3.js';
|
FLUIDSYNTH_JS_URL = '/static/js/vendor/libfluidsynth-2.3.0-sf3.js';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
// SonicForge Native Audio Client (T15) — IPC JS → native engine.
|
|
||||||
// Track instrument KHÔNG còn đi masterBus WebAudio: master fader + track
|
|
||||||
// gain/pan áp native qua NativeMixer (SF host / VST2 / VST3 bridge DLL).
|
|
||||||
// WebAudio chỉ giữ cho UI/aux. Mọi call fire-and-forget — native engine
|
|
||||||
// tự âm thanh; không block UI.
|
|
||||||
window.SonicNativeAudio = window.SonicNativeAudio || {};
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var BASE = window.API_BASE_URL || window.location.origin;
|
|
||||||
|
|
||||||
function headers() {
|
|
||||||
var h = { 'Content-Type': 'application/json' };
|
|
||||||
var token = localStorage.getItem('sonic_token') || '';
|
|
||||||
if (token) h['Authorization'] = 'Bearer ' + token;
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function post(path, body) {
|
|
||||||
var resp = await fetch(BASE + '/api/v1/native' + path, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify(body || {})
|
|
||||||
});
|
|
||||||
var data = await resp.json().catch(function () { return {}; });
|
|
||||||
if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(path) {
|
|
||||||
var resp = await fetch(BASE + '/api/v1/native' + path, { headers: headers() });
|
|
||||||
var data = await resp.json().catch(function () { return {}; });
|
|
||||||
if (!resp.ok) throw new Error(data.detail || 'native API lỗi ' + resp.status);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
var _masterTimer = null;
|
|
||||||
|
|
||||||
window.SonicNativeAudio = {
|
|
||||||
status: function () {
|
|
||||||
return get('/status');
|
|
||||||
},
|
|
||||||
// Master fader: debounce 40ms — fader kéo liên tục, chỉ gửi giá trị cuối.
|
|
||||||
setMasterGain: function (gainDb) {
|
|
||||||
if (_masterTimer) clearTimeout(_masterTimer);
|
|
||||||
_masterTimer = setTimeout(function () {
|
|
||||||
post('/set_master_gain', { gain_db: gainDb }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] set_master_gain:', e.message);
|
|
||||||
});
|
|
||||||
}, 40);
|
|
||||||
},
|
|
||||||
setTrackGainPan: function (trackId, gainDb, pan) {
|
|
||||||
return post('/track_gain_pan', { track_id: trackId, gain_db: gainDb, pan: pan }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] track_gain_pan:', e.message);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
ensureSf: function (trackId, sfId, bank, program) {
|
|
||||||
return post('/sf/ensure', { track_id: trackId, sf_id: sfId || null, bank: bank || 0, program: program || 0, live: true }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] sf/ensure:', e.message);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
ensureVst2: function (trackId, pluginId, pluginPath) {
|
|
||||||
return post('/vst2/ensure', { track_id: trackId, plugin_id: pluginId || null, plugin_path: pluginPath || null, live: true }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] vst2/ensure:', e.message);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
ensureVst3: function (trackId, pluginId, pluginPath) {
|
|
||||||
return post('/vst3/ensure', { track_id: trackId, plugin_id: pluginId || null, plugin_path: pluginPath || null, live: true }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] vst3/ensure:', e.message);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
noteOn: function (kind, trackId, channel, pitch, velocity) {
|
|
||||||
var path = kind === 'vst2' ? '/vst2/note_on' : (kind === 'vst3' ? '/vst3/note_on' : '/sf/note_on');
|
|
||||||
// Rethrow sau warn: TrackInstrument can fallback autosample/WASM
|
|
||||||
// khi native khong san sang (DLL/plugin loi).
|
|
||||||
return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch, velocity: velocity != null ? velocity : 100 }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] note_on:', e.message);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
noteOff: function (kind, trackId, channel, pitch) {
|
|
||||||
var path = kind === 'vst2' ? '/vst2/note_off' : (kind === 'vst3' ? '/vst3/note_off' : '/sf/note_off');
|
|
||||||
return post(path, { track_id: trackId, channel: channel || 0, pitch: pitch }).catch(function (e) {
|
|
||||||
console.warn('[NativeAudio] note_off:', e.message);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sfAudioStop: function (trackId) {
|
|
||||||
return post('/sf/audio_stop', { track_id: trackId, pitch: 0 }).catch(function () {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// 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 });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Fix C: re-establish bridge routing once audio frames arrive again
|
||||||
|
* after bridge-down. Same wiring as the bootstrap block in app.jsx
|
||||||
|
* (getAudioContext -> BridgeAudioNode.init -> AudioRoutingEngine.connect). */
|
||||||
|
_reconnectInFlight: false,
|
||||||
|
_reconnectIfNeeded: function () {
|
||||||
|
if (this._reconnectInFlight) return;
|
||||||
|
this._reconnectInFlight = true;
|
||||||
|
var self = this;
|
||||||
|
var finish = function () { self._reconnectInFlight = false; };
|
||||||
|
try {
|
||||||
|
var ctx = (typeof getAudioContext === 'function') ? getAudioContext() : (window.__sharedAudioCtx || null);
|
||||||
|
if (ctx && window.BridgeAudioNode && window.AudioRoutingEngine) {
|
||||||
|
window.BridgeAudioNode.init(ctx);
|
||||||
|
window.AudioRoutingEngine.connect(window.BridgeAudioNode, null, null);
|
||||||
|
}
|
||||||
|
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(true);
|
||||||
|
this._notifyStatus(true);
|
||||||
|
console.warn('[BridgeService] bridge recovered — routing re-established.');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] reconnect failed:', e);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
},
|
||||||
|
|
||||||
|
_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) {
|
||||||
|
// Bridge recovered after bridge-down (or never bootstrapped):
|
||||||
|
// audio frames flow again -> re-establish routing (Fix C).
|
||||||
|
if (!self.isBridgeConnected) self._reconnectIfNeeded();
|
||||||
|
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, channel) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke('open_vst_gui', {
|
||||||
|
pluginId: pluginId,
|
||||||
|
channel: channel === undefined ? 0 : channel
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -73,29 +73,14 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
|||||||
// (Carla standalone bật OSC UDP mặc định cổng 22752).
|
// (Carla standalone bật OSC UDP mặc định cổng 22752).
|
||||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||||
shouldRoute: function (synthEngine, isArmed) {
|
shouldRoute: function (synthEngine, isArmed) {
|
||||||
try {
|
// Carla bridge removed - all instruments hosted by Native Bridge.
|
||||||
// Live playback qua Carla TẮT mặc định: VSTi phát client-side qua SF2
|
return false;
|
||||||
// autosampled (FluidSynth WASM) → masterBus → mastering → main out.
|
|
||||||
// Bật lại = window.__enableCarlaLivePlayback = true (debug/legacy).
|
|
||||||
if (window.__enableCarlaLivePlayback !== true) return false;
|
|
||||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
|
||||||
if (!c || !c.features || !c.features.carla_local) return false;
|
|
||||||
if (!isArmed) return false;
|
|
||||||
var se = synthEngine || {};
|
|
||||||
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
|
||||||
} catch (e) { return false; }
|
|
||||||
},
|
},
|
||||||
// Playback MIDI items: route khi track VSTi + Carla local (không cần ARM —
|
// Playback MIDI items: route khi track VSTi + Carla local (không cần ARM —
|
||||||
// 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 {
|
// Carla bridge removed - all instruments hosted by Native Bridge.
|
||||||
// (xem shouldRoute) — live playback qua Carla tắt mặc định
|
return false;
|
||||||
if (window.__enableCarlaLivePlayback !== true) return false;
|
|
||||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
|
||||||
if (!c || !c.features || !c.features.carla_local) return false;
|
|
||||||
var se = synthEngine || {};
|
|
||||||
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
|
||||||
} catch (e) { return false; }
|
|
||||||
},
|
},
|
||||||
noteOn: function (channel, note, velocity) {
|
noteOn: function (channel, note, velocity) {
|
||||||
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
@@ -126,12 +111,6 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
|||||||
stopBridge: function () {
|
stopBridge: function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
try { self.allNotesOff(); } catch (e) {}
|
try { self.allNotesOff(); } catch (e) {}
|
||||||
// Reset trạng thái Carla bridge — nếu không, sau unload các cờ stale
|
|
||||||
// (__carlaRunning=true) khiến ensureCarlaForPlayback early-return và
|
|
||||||
// GUI Carla KHÔNG mở lại được khi reload.
|
|
||||||
window.__carlaRunning = false;
|
|
||||||
window.__carlaOpening = false;
|
|
||||||
window.__carlaNoteQueue = [];
|
|
||||||
if (window.SonicAPI && window.SonicAPI.stopCarla) {
|
if (window.SonicAPI && window.SonicAPI.stopCarla) {
|
||||||
return window.SonicAPI.stopCarla().catch(function () {});
|
return window.SonicAPI.stopCarla().catch(function () {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,7 +464,7 @@
|
|||||||
bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 0);
|
bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 0);
|
||||||
program = program !== undefined ? program : (synthEngine.soundfont_program || 0);
|
program = program !== undefined ? program : (synthEngine.soundfont_program || 0);
|
||||||
}
|
}
|
||||||
var sfId = synthEngine && (synthEngine.soundfont_id || synthEngine.autosampled_sf_id);
|
var sfId = synthEngine && synthEngine.soundfont_id;
|
||||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
||||||
if (!_engineChMap[engKey]) {
|
if (!_engineChMap[engKey]) {
|
||||||
var channel = this.allocateChannel(bank);
|
var channel = this.allocateChannel(bank);
|
||||||
@@ -587,43 +587,7 @@
|
|||||||
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||||
var finalProg = parseInt(usedProg);
|
var finalProg = parseInt(usedProg);
|
||||||
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||||
var finalSfId = synthEngine ? (synthEngine.soundfont_id || synthEngine.autosampled_sf_id) : undefined;
|
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||||
// VSTi live playback (không Carla): chưa có SF2 autosampled → chờ
|
|
||||||
// SonicVstiAutosample.ensure (1 request backend / plugin+preset) rồi
|
|
||||||
// play qua FluidSynth WASM như soundfont thường — âm đi qua masterBus
|
|
||||||
// → mastering → main out. ensure dedup in-flight + cooldown lỗi 60s.
|
|
||||||
if (!finalSfId && window.SonicVstiAutosample && synthEngine && !synthEngine.soundfont_id
|
|
||||||
&& String(synthEngine.type || '').indexOf('vst') !== -1 && !!synthEngine.plugin_id) {
|
|
||||||
var _pendKey = ch + ':' + midiPitch;
|
|
||||||
var _gen = _noteGeneration;
|
|
||||||
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
|
|
||||||
window.SonicVstiAutosample.ensure(synthEngine).then(function (sfId) {
|
|
||||||
if (_gen !== _noteGeneration) return;
|
|
||||||
var _pend = _pendingNoteOns[_pendKey];
|
|
||||||
if (_pend) {
|
|
||||||
_pendingNoteOns[_pendKey] = _pend - 1;
|
|
||||||
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!sfId) {
|
|
||||||
// Không autosample được (404/501) → fallback oscillator để note
|
|
||||||
// KHÔNG câm; cooldown trong ensure chặn re-request.
|
|
||||||
try {
|
|
||||||
var _reason = window.SonicVstiAutosample.failReasonFor ? window.SonicVstiAutosample.failReasonFor(synthEngine) : null;
|
|
||||||
if (_reason && window.showToast && (!self._vstiToastAt || Date.now() - self._vstiToastAt > 10000)) {
|
|
||||||
self._vstiToastAt = Date.now();
|
|
||||||
window.showToast('Autosample VSTi that bai: ' + _reason + ' - dung am default', 'warning');
|
|
||||||
}
|
|
||||||
self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine);
|
|
||||||
} catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
synthEngine.autosampled_sf_id = sfId;
|
|
||||||
doNote();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var cachedCh = _channels[ch];
|
var cachedCh = _channels[ch];
|
||||||
// The note's own synth engine (track instrument) is
|
// The note's own synth engine (track instrument) is
|
||||||
// authoritative. Channel state is only a cache: it must never
|
// authoritative. Channel state is only a cache: it must never
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
// SonicForge TrackInstrument Service
|
|
||||||
// Bọc engine per-track: playNote/noteOff theo TrackInstrumentCtx
|
|
||||||
// (ch/program/synthEngine/sfId/bank/prog/dest) — dùng cho preview mọi nguồn
|
|
||||||
// (keybed, piano roll, draw, hardware MIDI, click) qua UnifiedMidiRouter.
|
|
||||||
// T15: native-first — SF track đi thẳng vào engine native (NativeAudioService
|
|
||||||
// + SF host bridge), SonicSF WASM chỉ là fallback khi native không sẵn sàng.
|
|
||||||
// Tạo mới mỗi note-on (overwrite registry) — voice cũ giữ engine cũ trong
|
|
||||||
// tracker nên note-off luôn stop đúng channel.
|
|
||||||
window.TrackInstrument = window.TrackInstrument || {};
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
function TrackInstrument(trackId, ctx) {
|
|
||||||
this.trackId = trackId;
|
|
||||||
this.ch = ctx ? (ctx.ch != null ? ctx.ch : 0) : 0;
|
|
||||||
this.program = ctx ? ctx.program : undefined;
|
|
||||||
this.synthEngine = ctx ? ctx.synthEngine : undefined;
|
|
||||||
this.sfId = ctx ? ctx.sfId : undefined;
|
|
||||||
this.bank = ctx ? (ctx.bank || 0) : 0;
|
|
||||||
this.prog = ctx ? (ctx.prog || 0) : 0;
|
|
||||||
this.dest = ctx ? (ctx.dest || null) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loại engine native cho track: 'sf' (soundfont), 'vst3'/'vst2' (VSTi có
|
|
||||||
// plugin_id) hay null (không native được → fallback WASM/autosample).
|
|
||||||
// T15-regression fix: native live (SF/VSTi qua WASAPI bridge) phát thẳng ra
|
|
||||||
// OS device — BỎ QUA WebAudio masterBus → mastering FX + Main out VU không
|
|
||||||
// nhận tín hiệu. Gate native live path: luôn fallback WASM (SonicSF/
|
|
||||||
// autosample) qua masterBus.input. Native vẫn dùng cho OFFLINE render
|
|
||||||
// (scheduleNativeSfItem / /api/v1/native/render) — không đi qua hàm này.
|
|
||||||
// ponytail: re-enable live native khi C++ bridge có capture/readback →
|
|
||||||
// set window.__SONICFORGE_NATIVE_LIVE = true.
|
|
||||||
TrackInstrument.prototype._nativeKind = function () {
|
|
||||||
if (!window.__SONICFORGE_NATIVE_LIVE) return null;
|
|
||||||
try {
|
|
||||||
if (this.sfId) return 'sf';
|
|
||||||
if (window.SonicNativeAudio && this.synthEngine && this.synthEngine.plugin_id) {
|
|
||||||
var t = String(this.synthEngine.type || '');
|
|
||||||
if (t.indexOf('vst2') !== -1) return 'vst2';
|
|
||||||
if (t.indexOf('vst3') !== -1 || t.indexOf('vst') !== -1) return 'vst3';
|
|
||||||
}
|
|
||||||
} catch (e) { }
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Native engine sẵn sàng cho track? (sfId cho SF, plugin_id cho VSTi)
|
|
||||||
TrackInstrument.prototype._nativeReady = function () {
|
|
||||||
return this._nativeKind() !== null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Đảm bảo channel đã select đúng instrument trước khi play (fire-and-forget:
|
|
||||||
// playNote tự load + retry nếu SF chưa load xong — dedup trong loadSoundFont).
|
|
||||||
TrackInstrument.prototype._ensure = function () {
|
|
||||||
try {
|
|
||||||
if (!window.SonicSF || !window.SonicSF.selectInstrument) return;
|
|
||||||
if (this.sfId) {
|
|
||||||
window.SonicSF.selectInstrument(this.ch, this.bank, this.prog, this.sfId);
|
|
||||||
} else if (this.program !== undefined) {
|
|
||||||
window.SonicSF.selectInstrument(this.ch, 0, this.program, null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[TrackInstrument] selectInstrument error:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phát qua SonicSF WASM (fallback khi native fail). SonicSF tự autosample
|
|
||||||
// VSTi (soundfontPlayer._playNoteFluid) nên không cần ensure riêng ở đây.
|
|
||||||
TrackInstrument.prototype._fallbackPlayNote = function (pitch, velocity, durationMs, startTime) {
|
|
||||||
try {
|
|
||||||
if (!window.SonicSF || !window.SonicSF.playNote) return;
|
|
||||||
this._ensure();
|
|
||||||
var durF = (durationMs != null ? durationMs : 500) || 500;
|
|
||||||
window.SonicSF.playNote(pitch, velocity != null ? velocity : 0.8, durF, startTime, this.program, this.dest, this.ch, this.synthEngine);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[TrackInstrument] fallback playNote error:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// velocity: router đã normalize int 1-127 (unifiedMidiRouter.normalizeVelocity).
|
|
||||||
TrackInstrument.prototype.playNote = function (pitch, velocity, durationMs, startTime) {
|
|
||||||
var vel = velocity != null ? velocity : 100;
|
|
||||||
var kind = this._nativeKind();
|
|
||||||
if (kind) {
|
|
||||||
try {
|
|
||||||
var self = this;
|
|
||||||
var dur = (durationMs != null ? durationMs : 500) || 500;
|
|
||||||
if (kind === 'sf') {
|
|
||||||
// ensure native SF engine cho track (server dedup theo track_id)
|
|
||||||
window.SonicNativeAudio.ensureSf(this.trackId, this.sfId, this.bank, this.prog)
|
|
||||||
.catch(function () {});
|
|
||||||
} else {
|
|
||||||
// VSTi live native (Phase 2): ensure + note-on qua bridge DLL.
|
|
||||||
// Server resolve plugin_id -> path; lỗi -> fallback WASM autosample.
|
|
||||||
if (kind === 'vst3') {
|
|
||||||
window.SonicNativeAudio.ensureVst3(this.trackId, this.synthEngine.plugin_id, this.synthEngine.plugin_path)
|
|
||||||
.catch(function () {});
|
|
||||||
} else {
|
|
||||||
window.SonicNativeAudio.ensureVst2(this.trackId, this.synthEngine.plugin_id, this.synthEngine.plugin_path)
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.SonicNativeAudio.noteOn(kind, this.trackId, this.ch, pitch, vel).catch(function (err) {
|
|
||||||
// Native khong phat duoc (DLL/plugin loi) -> fallback WASM:
|
|
||||||
// SF track di SonicSF, VSTi di autosample (SonicSF tu ensure).
|
|
||||||
console.warn('[TrackInstrument] native noteOn fail -> WASM fallback:', err && err.message);
|
|
||||||
self._fallbackPlayNote(pitch, velocity, dur, startTime);
|
|
||||||
});
|
|
||||||
// ponytail: TrackInstrument không biết audioCtx → bỏ startTime
|
|
||||||
// offset (delay = duration); thêm scheduling chính xác khi cần
|
|
||||||
setTimeout(function () { self.noteOff(pitch); }, dur + 40);
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[TrackInstrument] native playNote error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback SonicSF (WASM)
|
|
||||||
this._fallbackPlayNote(pitch, velocity, durationMs, startTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
TrackInstrument.prototype.noteOff = function (pitch) {
|
|
||||||
var kind = this._nativeKind();
|
|
||||||
if (kind) {
|
|
||||||
try {
|
|
||||||
window.SonicNativeAudio.noteOff(kind, this.trackId, this.ch, pitch).catch(function () {
|
|
||||||
// Native note-off fail -> stop qua WASM (neu note da fallback)
|
|
||||||
try { if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch); } catch (e) {}
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
// Belt-and-suspenders: stopNote WASM vo hai neu khong co note dang phat.
|
|
||||||
try {
|
|
||||||
if (window.SonicSF && window.SonicSF.stopNote) window.SonicSF.stopNote(this.ch, pitch);
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.TrackInstrument = TrackInstrument;
|
|
||||||
})();
|
|
||||||
@@ -1,179 +1,73 @@
|
|||||||
// SonicForge Unified MIDI Router
|
// app/static/js/services/unifiedMidiRouter.js
|
||||||
// Mọi nguồn MIDI (keybed preview, timeline scheduler, hardware MIDI) → chuẩn
|
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||||
// hóa UnifiedMidiEvent → UnifiedMidiRouter → engine theo trackId. Một điểm
|
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||||
// dispatch duy nhất: activeVoiceTracker đếm note-on/off đúng (hết stuck
|
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||||
// notes), panicAllNotesOff() quét toàn bộ voice khi đổi instrument/engine
|
|
||||||
// giữa chừng.
|
|
||||||
// Trạng thái nối (đối chiếu GIAI_PHAP 2026-08): ĐÃ nối cho PREVIEW — app.jsx
|
|
||||||
// _routeNoteOn/_routeNoteOff (L212-260) đăng ký TrackInstrument per-track và
|
|
||||||
// dispatch qua router (keybed/click/draw/timeline scheduler gọi _routePreviewNote
|
|
||||||
// trước, chỉ fallback SonicSF.playNote khi router không xử lý). CHƯA nối cho
|
|
||||||
// timeline scheduler per-item (vẫn gọi thẳng SonicSF.playNote / scheduleNativeSfItem).
|
|
||||||
window.SonicUnifiedMidiRouter = window.SonicUnifiedMidiRouter || {};
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
var COMMAND_NOTE_ON = 0x90;
|
var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
||||||
var COMMAND_NOTE_OFF = 0x80;
|
|
||||||
var COMMAND_CC = 0xB0;
|
|
||||||
|
|
||||||
// Pitch: ép int 0-127 (spec "pitch clamp 0-127").
|
var router = {
|
||||||
function clampPitch(pitch) {
|
bridgeConnected: false,
|
||||||
pitch = Math.round(Number(pitch) || 0);
|
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||||
if (pitch < 0) return 0;
|
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||||
if (pitch > 127) return 127;
|
_channels: {}, // trackId -> { ch, percussion }
|
||||||
return pitch;
|
_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);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Velocity: float 0-1 (vd 100/127) hoặc int → ép int 1-127 (note-on);
|
window.SonicMidiRouter = router;
|
||||||
// note-off velocity = 0. Spec "velocity normalize float→int".
|
|
||||||
function normalizeVelocity(velocity, isNoteOn) {
|
|
||||||
var v = Number(velocity);
|
|
||||||
if (!isFinite(v)) v = 0;
|
|
||||||
if (v > 0 && v <= 1) v = v * 127;
|
|
||||||
v = Math.round(v);
|
|
||||||
if (v < 0) v = 0;
|
|
||||||
if (v > 127) v = 127;
|
|
||||||
if (isNoteOn && v <= 0) v = 1;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnifiedMidiRouter() {
|
|
||||||
this.engineRegistry = new Map(); // trackId → TrackInstrument
|
|
||||||
// activeVoiceTracker: key `${trackId}_${channel}_${pitch}` → { count, engine }.
|
|
||||||
// (Spec dùng `${channel}_${pitch}`; thêm trackId vì engine theo track —
|
|
||||||
// 2 track cùng channel+pitch phải đếm riêng, không lệch voice.)
|
|
||||||
this._voices = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đăng ký engine cho track (null = xóa). Đổi instrument → registerEngine
|
|
||||||
// track mới; panic trước đó để không sót voice cũ.
|
|
||||||
UnifiedMidiRouter.prototype.registerEngine = function (trackId, engine) {
|
|
||||||
if (trackId === undefined || trackId === null) return;
|
|
||||||
if (engine) this.engineRegistry.set(trackId, engine);
|
|
||||||
else this.engineRegistry.delete(trackId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dispatch 1 event từ mọi nguồn. Không ném — voice tracker luôn đếm đúng.
|
|
||||||
// Trả true nếu event là note (đã route/count), false nếu không xử lý.
|
|
||||||
UnifiedMidiRouter.prototype.dispatchMidiEvent = function (evt) {
|
|
||||||
if (!evt) return false;
|
|
||||||
var trackId = evt.trackId;
|
|
||||||
var engine = (trackId !== undefined && trackId !== null) ? this.engineRegistry.get(trackId) : null;
|
|
||||||
var channel = Math.round(Number(evt.channel) || 0);
|
|
||||||
if (channel < 0) channel = 0;
|
|
||||||
if (channel > 15) channel = 15;
|
|
||||||
var pitch = clampPitch(evt.pitch);
|
|
||||||
var cmd = Number(evt.command);
|
|
||||||
var key = trackId + '_' + channel + '_' + pitch;
|
|
||||||
|
|
||||||
var isNoteOn = (cmd === COMMAND_NOTE_ON);
|
|
||||||
var isNoteOff = (cmd === COMMAND_NOTE_OFF) || (cmd === COMMAND_NOTE_ON && !Number(evt.velocity));
|
|
||||||
|
|
||||||
if (isNoteOn) {
|
|
||||||
var vel = normalizeVelocity(evt.velocity, true);
|
|
||||||
var cur = this._voices.get(key);
|
|
||||||
if (!cur) {
|
|
||||||
cur = { count: 0, engine: engine };
|
|
||||||
this._voices.set(key, cur);
|
|
||||||
}
|
|
||||||
cur.engine = engine;
|
|
||||||
cur.count += 1;
|
|
||||||
// Chỉ trigger âm ở note-on đầu tiên; các note-on trùng key chỉ tăng
|
|
||||||
// count (sustain lặp) — note-off cuối cùng mới tắt voice.
|
|
||||||
if (cur.count === 1 && engine && engine.playNote) {
|
|
||||||
try { engine.playNote(pitch, vel, evt.durationMs || 500, evt.startTime); } catch (e) {}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNoteOff) {
|
|
||||||
var cur2 = this._voices.get(key);
|
|
||||||
if (cur2 && cur2.count > 0) {
|
|
||||||
cur2.count -= 1;
|
|
||||||
var eng2 = cur2.engine;
|
|
||||||
if (cur2.count <= 0) {
|
|
||||||
this._voices.delete(key);
|
|
||||||
if (eng2 && eng2.noteOff) {
|
|
||||||
try { eng2.noteOff(pitch); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false; // CC / unknown
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hardware MIDI keyboard message → dispatch tới targets đã gom sẵn (armed
|
|
||||||
// tracks). app.jsx thu thập noteOnTargets [{trackId, ch, engine}] ở note-on
|
|
||||||
// và noteOffTargets [{trackId, ch}] ở note-off, gọi 1 lần mỗi message.
|
|
||||||
// parse giống handler cũ (cmd 4-bit, pitch clamp, velocity 1-127) rồi đẩy
|
|
||||||
// qua dispatchMidiEvent → 3 nguồn (keybed/scheduler/hardware) chung router.
|
|
||||||
UnifiedMidiRouter.prototype.handleHardwareKeyboardMessage = function (msg, noteOnTargets, noteOffTargets) {
|
|
||||||
var cmd = Number(msg[0]) >> 4;
|
|
||||||
var pitch = clampPitch(msg[1]);
|
|
||||||
var rawVel = Number(msg[2]) || 0;
|
|
||||||
var velocity = Math.min(127, Math.max(1, Math.round(rawVel)));
|
|
||||||
var noteOn = (cmd === 0x9 && rawVel > 0);
|
|
||||||
var noteOff = (cmd === 0x8 || (cmd === 0x9 && rawVel === 0));
|
|
||||||
|
|
||||||
if (noteOn && noteOnTargets) {
|
|
||||||
noteOnTargets.forEach(function (t) {
|
|
||||||
if (!t || t.trackId === undefined || t.trackId === null) return;
|
|
||||||
if (t.engine) this.registerEngine(t.trackId, t.engine);
|
|
||||||
this.dispatchMidiEvent({
|
|
||||||
trackId: t.trackId,
|
|
||||||
command: COMMAND_NOTE_ON,
|
|
||||||
channel: t.ch,
|
|
||||||
pitch: pitch,
|
|
||||||
velocity: velocity / 127,
|
|
||||||
durationMs: 60000,
|
|
||||||
source: 'HARDWARE_KEYBOARD'
|
|
||||||
});
|
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noteOff && noteOffTargets) {
|
|
||||||
noteOffTargets.forEach(function (t) {
|
|
||||||
if (!t || t.trackId === undefined || t.trackId === null) return;
|
|
||||||
this.dispatchMidiEvent({
|
|
||||||
trackId: t.trackId,
|
|
||||||
command: COMMAND_NOTE_OFF,
|
|
||||||
channel: t.ch,
|
|
||||||
pitch: pitch,
|
|
||||||
source: 'HARDWARE_KEYBOARD'
|
|
||||||
});
|
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[MidiRouter] hardware ->', { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity, targets: (noteOnTargets ? noteOnTargets.length : 0) + '/' + (noteOffTargets ? noteOffTargets.length : 0) });
|
|
||||||
return { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity };
|
|
||||||
};
|
|
||||||
|
|
||||||
// All Notes Off: quét voice tracker, gửi note-off cho engine sở hữu từng
|
|
||||||
// voice, xóa tracker. Gọi khi dừng playback / đổi instrument giữa chừng.
|
|
||||||
UnifiedMidiRouter.prototype.panicAllNotesOff = function () {
|
|
||||||
var self = this;
|
|
||||||
this._voices.forEach(function (v, key) {
|
|
||||||
if (v.engine && v.engine.noteOff) {
|
|
||||||
var parts = key.split('_');
|
|
||||||
var pitch = parseInt(parts[parts.length - 1], 10);
|
|
||||||
try { v.engine.noteOff(pitch); } catch (e) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._voices.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Số voice active cho 1 (track, channel, pitch) — test/debug.
|
|
||||||
UnifiedMidiRouter.prototype.activeVoiceCount = function (trackId, channel, pitch) {
|
|
||||||
var cur = this._voices.get(trackId + '_' + channel + '_' + pitch);
|
|
||||||
return cur ? cur.count : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.SonicUnifiedMidiRouter = {
|
|
||||||
UnifiedMidiRouter: UnifiedMidiRouter,
|
|
||||||
COMMAND_NOTE_ON: COMMAND_NOTE_ON,
|
|
||||||
COMMAND_NOTE_OFF: COMMAND_NOTE_OFF,
|
|
||||||
COMMAND_CC: COMMAND_CC
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// SonicForge VSTi Autosample Service
|
|
||||||
// Live playback 100% client-side (không Carla): VSTi note → SF2 autosampled
|
|
||||||
// (backend pedalboard, render 1 lần / plugin+preset) → FluidSynth WASM qua
|
|
||||||
// masterBus → mastering → main out. ensure() dedup in-flight request + cooldown
|
|
||||||
// lỗi (404/501/no pedalboard) để không spam server.
|
|
||||||
window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var LS_MAP_KEY = 'sf_vsti_autosample_map';
|
|
||||||
var LS_FAIL_KEY = 'sf_vsti_autosample_fail';
|
|
||||||
var FAIL_COOLDOWN_MS = 60000;
|
|
||||||
|
|
||||||
function readMap() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(LS_MAP_KEY) || '{}') || {}; } catch (e) { return {}; }
|
|
||||||
}
|
|
||||||
function writeMap(map) {
|
|
||||||
try { localStorage.setItem(LS_MAP_KEY, JSON.stringify(map)); } catch (e) {}
|
|
||||||
}
|
|
||||||
function readFails() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(LS_FAIL_KEY) || '{}') || {}; } catch (e) { return {}; }
|
|
||||||
}
|
|
||||||
function recordFail(k, reason) {
|
|
||||||
try {
|
|
||||||
var fails2 = readFails();
|
|
||||||
fails2[k] = { ts: Date.now(), reason: reason || 'Autosample that bai' };
|
|
||||||
localStorage.setItem(LS_FAIL_KEY, JSON.stringify(fails2));
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (!bytes) return '0 KB';
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
return Math.max(1, Math.round(bytes / 1024)) + ' KB';
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI re-render (chip hien thi note_count/size) khi autosample xong/fail
|
|
||||||
function notify(k) {
|
|
||||||
try { window.dispatchEvent(new CustomEvent('sf:autosample-update', { detail: { key: k } })); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key = plugin + preset → autosample lại khi đổi preset (âm preset mới).
|
|
||||||
function keyFor(synthEngine) {
|
|
||||||
if (!synthEngine) return '';
|
|
||||||
return String(synthEngine.plugin_id || '') + '|' + String(synthEngine.preset_id || synthEngine.presetId || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// SF id đã autosample cho engine (cache localStorage) — đồng bộ, không request.
|
|
||||||
function sfIdFor(synthEngine) {
|
|
||||||
var k = keyFor(synthEngine);
|
|
||||||
if (!k) return null;
|
|
||||||
var map = readMap();
|
|
||||||
return (map[k] && map[k].sf_id) ? map[k].sf_id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryFor(synthEngine) {
|
|
||||||
var k = keyFor(synthEngine);
|
|
||||||
if (!k) return null;
|
|
||||||
return readMap()[k] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var _inFlight = {}; // key → Promise<sf_id|null> — nhiều note cùng lúc chỉ 1 request
|
|
||||||
|
|
||||||
// Đảm bảo SF2 autosampled tồn tại. Trả Promise<sf_id|null>; null =
|
|
||||||
// không autosample được → caller fallback (oscillator / không phát).
|
|
||||||
function ensure(synthEngine) {
|
|
||||||
var k = keyFor(synthEngine);
|
|
||||||
if (!k) return Promise.resolve(null);
|
|
||||||
var cached = sfIdFor(synthEngine);
|
|
||||||
if (cached) return Promise.resolve(cached);
|
|
||||||
if (_inFlight[k]) return _inFlight[k];
|
|
||||||
var fails = readFails();
|
|
||||||
var _f = fails[k];
|
|
||||||
var _lastTs = (_f && typeof _f === 'object') ? _f.ts : _f;
|
|
||||||
if (_lastTs && (Date.now() - _lastTs) < FAIL_COOLDOWN_MS) {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
if (!window.SonicAPI || !window.SonicAPI.autosampleVsti) {
|
|
||||||
recordFail(k, 'API autosample khong kha dung');
|
|
||||||
notify(k);
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
_inFlight[k] = new Promise(function (resolve) {
|
|
||||||
var payload = {
|
|
||||||
instrument_id: synthEngine.plugin_id,
|
|
||||||
preset_id: synthEngine.preset_id || synthEngine.presetId || null,
|
|
||||||
preset_path: synthEngine.preset_path || null,
|
|
||||||
preset_data: synthEngine.preset_data || null
|
|
||||||
};
|
|
||||||
window.SonicAPI.autosampleVsti(payload).then(function (res) {
|
|
||||||
delete _inFlight[k];
|
|
||||||
if (res && res.success && res.sf_id) {
|
|
||||||
var map = readMap();
|
|
||||||
map[k] = { sf_id: res.sf_id, size_bytes: res.size_bytes || 0, note_count: res.note_count || 0, plugin_id: synthEngine.plugin_id, preset_id: synthEngine.preset_id || synthEngine.presetId || '', ts: Date.now() };
|
|
||||||
writeMap(map);
|
|
||||||
notify(k);
|
|
||||||
resolve(res.sf_id);
|
|
||||||
} else {
|
|
||||||
recordFail(k, (res && res.error) ? String(res.error) : 'Server khong tra sf_id');
|
|
||||||
notify(k);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}).catch(function (err) {
|
|
||||||
delete _inFlight[k];
|
|
||||||
recordFail(k, (err && err.message) ? String(err.message) : 'Loi mang / server autosample');
|
|
||||||
notify(k);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return _inFlight[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lý do autosample thất bại gần nhất (fail map) — null nếu chưa từng fail
|
|
||||||
// (hoặc entry cũ định dạng number). Dùng cho UI toast khi ensure() trả null.
|
|
||||||
function failReasonFor(synthEngine) {
|
|
||||||
var k = keyFor(synthEngine);
|
|
||||||
if (!k) return null;
|
|
||||||
var fails = readFails();
|
|
||||||
var f = fails[k];
|
|
||||||
if (!f || typeof f !== 'object') return null;
|
|
||||||
return f.reason || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Xoá cache + cooldown — cho phép re-sample (đổi preset/plugin hoặc debug).
|
|
||||||
function clearCache() {
|
|
||||||
try { localStorage.removeItem(LS_MAP_KEY); } catch (e) {}
|
|
||||||
try { localStorage.removeItem(LS_FAIL_KEY); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.SonicVstiAutosample = {
|
|
||||||
keyFor: keyFor,
|
|
||||||
sfIdFor: sfIdFor,
|
|
||||||
entryFor: entryFor,
|
|
||||||
formatSize: formatSize,
|
|
||||||
ensure: ensure,
|
|
||||||
failReasonFor: failReasonFor,
|
|
||||||
clearCache: clearCache
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="vi">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>VST GUI</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #16161a; color: #e4e4e7; }
|
|
||||||
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1e1e24; border-bottom: 1px solid #2d2d35; position: sticky; top: 0; z-index: 10; }
|
|
||||||
header h1 { font-size: 13px; margin: 0; font-weight: 600; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
header .meta { font-size: 10px; color: #71717a; margin-top: 2px; }
|
|
||||||
#params { padding: 10px 12px; }
|
|
||||||
.param { display: grid; grid-template-columns: minmax(0,1fr) 60px; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #26262d; }
|
|
||||||
.param .title { font-size: 11px; color: #d4d4d8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.param .val { font-size: 10px; font-family: ui-monospace, monospace; color: #7dd3fc; text-align: right; }
|
|
||||||
input[type=range] { width: 100%; accent-color: #0ea5e9; }
|
|
||||||
#status { padding: 8px 12px; font-size: 11px; color: #a1a1aa; }
|
|
||||||
#status.err { color: #f87171; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h1 id="title">VST GUI</h1>
|
|
||||||
<div class="meta" id="meta"></div>
|
|
||||||
</div>
|
|
||||||
<button id="closeBtn" style="background:#3f3f46;border:1px solid #52525b;color:#f4f4f5;font-size:11px;padding:4px 10px;border-radius:4px;cursor:pointer;">Đóng</button>
|
|
||||||
</header>
|
|
||||||
<div id="params"></div>
|
|
||||||
<div id="status">Đang tải tham số…</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const qs = new URLSearchParams(window.location.search);
|
|
||||||
const trackId = qs.get('track') || '';
|
|
||||||
const pluginId = qs.get('plugin') || '';
|
|
||||||
const el = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
el('title').textContent = 'VST GUI - ' + pluginId;
|
|
||||||
el('meta').textContent = 'track=' + trackId;
|
|
||||||
|
|
||||||
const invoke = (cmd, args) => {
|
|
||||||
if (window.__TAURI__ && window.__TAURI__.core) {
|
|
||||||
return window.__TAURI__.core.invoke(cmd, args);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('Tauri core unavailable'));
|
|
||||||
};
|
|
||||||
|
|
||||||
let params = [];
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const box = el('params');
|
|
||||||
box.innerHTML = '';
|
|
||||||
if (!params.length) {
|
|
||||||
el('status').textContent = 'Plugin không có tham số (hoặc chưa nạp).';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el('status').textContent = params.length + ' tham số — kéo slider để đổi giá trị.';
|
|
||||||
params.forEach((p) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'param';
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'title';
|
|
||||||
title.textContent = p.title || ('Param ' + p.param_id);
|
|
||||||
title.title = title.textContent;
|
|
||||||
const val = document.createElement('div');
|
|
||||||
val.className = 'val';
|
|
||||||
const range = document.createElement('input');
|
|
||||||
range.type = 'range';
|
|
||||||
range.min = 0;
|
|
||||||
range.max = 1;
|
|
||||||
range.step = 0.001;
|
|
||||||
range.value = Math.min(1, Math.max(0, p.value || 0));
|
|
||||||
val.textContent = (p.value || 0).toFixed(3);
|
|
||||||
let dragging = false;
|
|
||||||
range.addEventListener('input', () => {
|
|
||||||
val.textContent = Number(range.value).toFixed(3);
|
|
||||||
});
|
|
||||||
range.addEventListener('change', () => {
|
|
||||||
invoke('set_vst_param', { trackId: trackId, pluginId: pluginId, paramId: p.param_id, value: Number(range.value) })
|
|
||||||
.catch((err) => { el('status').className = 'err'; el('status').textContent = 'set_vst_param lỗi: ' + (err && (err.message || err)); });
|
|
||||||
});
|
|
||||||
row.appendChild(title);
|
|
||||||
row.appendChild(val);
|
|
||||||
row.appendChild(range);
|
|
||||||
box.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync 2 chiều: native editor đổi param → Rust emit vst_param_changed → cập nhật slider.
|
|
||||||
function onParamChanged(e) {
|
|
||||||
const d = e.payload || {};
|
|
||||||
if (d.param_id === undefined) return;
|
|
||||||
const p = params.find((x) => x.param_id === d.param_id);
|
|
||||||
if (p) {
|
|
||||||
p.value = d.value;
|
|
||||||
const ranges = document.querySelectorAll('input[type=range]');
|
|
||||||
const idx = params.indexOf(p);
|
|
||||||
if (ranges[idx]) ranges[idx].value = Math.min(1, Math.max(0, d.value));
|
|
||||||
const vals = document.querySelectorAll('.val');
|
|
||||||
if (vals[idx]) vals[idx].textContent = Number(d.value).toFixed(3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
invoke('get_vst_params', { trackId: trackId, pluginId: pluginId }).then((list) => {
|
|
||||||
params = list || [];
|
|
||||||
render();
|
|
||||||
}).catch((err) => {
|
|
||||||
el('status').className = 'err';
|
|
||||||
el('status').textContent = 'get_vst_params lỗi: ' + (err && (err.message || err)) + ' — plugin chưa mở được (kiểm tra log Rust / bridge DLL).';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
el('closeBtn').addEventListener('click', () => {
|
|
||||||
invoke('close_vst_editor', { trackId: trackId, pluginId: pluginId }).catch(() => {});
|
|
||||||
window.close();
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
invoke('close_vst_editor', { trackId: trackId, pluginId: pluginId }).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (window.__TAURI__ && window.__TAURI__.event) {
|
|
||||||
window.__TAURI__.event.listen('vst_param_changed', onParamChanged).catch(() => {});
|
|
||||||
}
|
|
||||||
init();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -33,17 +33,16 @@
|
|||||||
<script src="/static/vendor/react.production.min.js"></script>
|
<script src="/static/vendor/react.production.min.js"></script>
|
||||||
<script src="/static/vendor/react-dom.production.min.js"></script>
|
<script src="/static/vendor/react-dom.production.min.js"></script>
|
||||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||||
<script src="/static/js/services/runtime.js?v=202608111230"></script>
|
<script src="/static/js/services/runtime.js?v=202608101800"></script>
|
||||||
<script src="/static/js/services/api.js?v=202608111301"></script>
|
<script src="/static/js/services/api.js?v=202608101800"></script>
|
||||||
<script src="/static/js/services/appLogger.js?v=202608111301"></script>
|
|
||||||
<script src="/static/js/services/vstiAutosample.js?v=202608111230"></script>
|
|
||||||
<script src="/static/js/services/unifiedMidiRouter.js?v=202608111230"></script>
|
|
||||||
<script src="/static/js/services/nativeAudioClient.js?v=202608111230"></script>
|
|
||||||
<script src="/static/js/services/trackInstrument.js?v=202608111301"></script>
|
|
||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
<script src="/static/js/services/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=202608111230"></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=202608121700"></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>
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608111301" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608121700" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# build_native_bridge.ps1 - Build daw_vst_bridge.exe (C++ Native Host Bridge)
|
||||||
|
# Run on Windows x64 with VS Build Tools 2019+ (C++ workload) installed.
|
||||||
|
# ASCII only (PowerShell 5.1 reads .ps1 without BOM as ANSI).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
# NOTE: no Set-Location here - this script must not change the caller's CWD
|
||||||
|
# (build_windows.ps1 calls it with & and then checks relative paths).
|
||||||
|
# All paths below are absolute (derived from $PSScriptRoot).
|
||||||
|
$REPO_ROOT = Resolve-Path (Join-Path $PSScriptRoot "..\..") # ...\SonicForgeStudio
|
||||||
|
$NB_DIR = Join-Path $REPO_ROOT "native_bridge"
|
||||||
|
$VS_GEN = "Visual Studio 16 2019" # VS2019 Build Tools on this machine
|
||||||
|
$VS_ARCH = "x64"
|
||||||
|
|
||||||
|
# 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/7] Bootstrap vcpkg =="
|
||||||
|
git clone https://github.com/microsoft/vcpkg $VCPKG_ROOT
|
||||||
|
& (Join-Path $VCPKG_ROOT "bootstrap-vcpkg.bat") -disableMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. fluidsynth via vcpkg manifest (native_bridge/vcpkg.json; pkgconf included)
|
||||||
|
Write-Host "== [2/7] vcpkg install fluidsynth (manifest) =="
|
||||||
|
Push-Location $NB_DIR
|
||||||
|
& (Join-Path $VCPKG_ROOT "vcpkg.exe") install --triplet x64-windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Host "ERROR: vcpkg install failed" -ForegroundColor Red; exit 1 }
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
# 3. sfizz 1.2.3 static lib (sfizz has NO vcpkg port: clone + build once)
|
||||||
|
Write-Host "== [3/7] sfizz 1.2.3 (clone + build static /MD, skip if built) =="
|
||||||
|
$SFIZZ_DIR = Join-Path $NB_DIR "sfizz-src"
|
||||||
|
$SFIZZ_BUILD = Join-Path $SFIZZ_DIR "build"
|
||||||
|
$SFIZZ_STATIC = Join-Path $SFIZZ_BUILD "library\lib\Release\sfizz_static.lib"
|
||||||
|
if (-not (Test-Path $SFIZZ_STATIC)) {
|
||||||
|
if (-not (Test-Path (Join-Path $SFIZZ_DIR "CMakeLists.txt"))) {
|
||||||
|
git clone --branch 1.2.3 --depth 1 https://github.com/sfztools/sfizz.git $SFIZZ_DIR
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: sfizz clone failed" -ForegroundColor Red; exit 1 }
|
||||||
|
}
|
||||||
|
Push-Location $SFIZZ_DIR
|
||||||
|
git submodule update --init --recursive
|
||||||
|
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Host "ERROR: sfizz submodules failed" -ForegroundColor Red; exit 1 }
|
||||||
|
Pop-Location
|
||||||
|
# Patch: sfizz forces /MT (static CRT) in SfizzConfig.cmake; vcpkg fluidsynth
|
||||||
|
# is /MD. Replace the forced line (idempotent; no-op if already patched).
|
||||||
|
$cfg = Join-Path $SFIZZ_DIR "cmake\SfizzConfig.cmake"
|
||||||
|
$cfgText = [System.IO.File]::ReadAllText($cfg)
|
||||||
|
$patched = $cfgText -replace '(?m)^\s*set\(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded\$<\$<CONFIG:Debug>:Debug>"\)\s*$', '# patched for local Windows build: caller picks runtime (/MD) to match vcpkg'
|
||||||
|
if ($patched -ne $cfgText) { [System.IO.File]::WriteAllText($cfg, $patched) }
|
||||||
|
# Configure + build static lib with /MD to match fluidsynth
|
||||||
|
cmake -S $SFIZZ_DIR -B $SFIZZ_BUILD -G $VS_GEN -A $VS_ARCH `
|
||||||
|
-DSFIZZ_SHARED=OFF -DSFIZZ_RENDER=OFF -DSFIZZ_TESTS=OFF -DSFIZZ_DEMOS=OFF `
|
||||||
|
-DSFIZZ_DEVTOOLS=OFF -DSFIZZ_BENCHMARKS=OFF -DSFIZZ_JACK=OFF `
|
||||||
|
-DSFIZZ_USE_SNDFILE=OFF -DSFIZZ_USE_SYSTEM_ABSEIL=OFF `
|
||||||
|
-DSFIZZ_USE_SYSTEM_KISS_FFT=OFF -DSFIZZ_USE_SYSTEM_PUGIXML=OFF `
|
||||||
|
-DSFIZZ_USE_SYSTEM_CATCH=OFF -DSFIZZ_USE_SYSTEM_CXXOPTS=OFF `
|
||||||
|
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF `
|
||||||
|
-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: sfizz cmake configure failed" -ForegroundColor Red; exit 1 }
|
||||||
|
cmake --build $SFIZZ_BUILD --config Release --target sfizz_static -- /m
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: sfizz build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
} else {
|
||||||
|
Write-Host " sfizz_static.lib already present - skip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. VST3 SDK submodule (Steinberg; NOT in vcpkg; already registered in repo)
|
||||||
|
Write-Host "== [4/7] VST3 SDK (submodule) =="
|
||||||
|
if (-not (Test-Path (Join-Path $NB_DIR "vst3sdk"))) {
|
||||||
|
Push-Location $REPO_ROOT
|
||||||
|
git submodule update --init --recursive -- native_bridge/vst3sdk
|
||||||
|
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Host "ERROR: vst3sdk submodule failed" -ForegroundColor Red; exit 1 }
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Configure + build with CMake + vcpkg toolchain (MSVC, bridge target only)
|
||||||
|
Write-Host "== [5/7] CMake configure + build (target daw_vst_bridge) =="
|
||||||
|
$BUILD_DIR = Join-Path $NB_DIR "build"
|
||||||
|
New-Item -ItemType Directory -Force $BUILD_DIR | Out-Null
|
||||||
|
cmake -S $NB_DIR -B $BUILD_DIR -G $VS_GEN -A $VS_ARCH `
|
||||||
|
-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 }
|
||||||
|
cmake --build $BUILD_DIR --config Release --target daw_vst_bridge -- /m
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 6. Copy sidecar to Tauri externalBin location
|
||||||
|
Write-Host "== [6/7] 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
|
||||||
|
|
||||||
|
# 7. Copy fluidsynth runtime DLL next to sidecar (Tauri externalBin bundles only the exe)
|
||||||
|
Write-Host "== [7/7] Copy libfluidsynth-3.dll =="
|
||||||
|
$FS_DLL = Join-Path $BUILD_DIR "vcpkg_installed\x64-windows\bin\libfluidsynth-3.dll"
|
||||||
|
if (-not (Test-Path $FS_DLL)) { $FS_DLL = Join-Path $VCPKG_ROOT "installed\x64-windows\bin\libfluidsynth-3.dll" }
|
||||||
|
if (Test-Path $FS_DLL) {
|
||||||
|
Copy-Item $FS_DLL (Join-Path $BIN_DIR "libfluidsynth-3.dll") -Force
|
||||||
|
Copy-Item $FS_DLL (Join-Path $BUILD_DIR "Release\libfluidsynth-3.dll") -Force
|
||||||
|
Write-Host " libfluidsynth-3.dll copied to src-tauri\binaries"
|
||||||
|
} else {
|
||||||
|
Write-Host "WARN: libfluidsynth-3.dll not found - bridge needs it at runtime" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
Write-Host "== DONE: sidecar + DLL in src-tauri\binaries =="
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 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")
|
||||||
|
Set-Location (Join-Path $PSScriptRoot "..\..") # restore CWD (child script must not leak it)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "src-tauri\binaries\libfluidsynth-3.dll")) {
|
||||||
|
Write-Host "ERROR: libfluidsynth-3.dll missing (bridge runtime DLL)" -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
|
||||||
|
# Frontend host: copy template -> src-tauri/ui/index.html (giu Tauri context,
|
||||||
|
# document.write UI engine; neu thieu -> window mat __TAURI__ -> bridge chet).
|
||||||
|
Copy-Item buildscriptsfrontend_host_index.html src-tauriuiindex.html -Force
|
||||||
|
if (-not (Test-Path src-tauriuiindex.html)) { Write-Host "ERROR: frontend host missing" -ForegroundColor Red; exit 1 }
|
||||||
|
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 =="
|
||||||
@@ -64,51 +64,6 @@ New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
|||||||
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||||
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
|
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
|
||||||
|
|
||||||
Write-Host "== [4b/6] Native host bridges (CMake/MSVC) + FluidSynth runtime =="
|
|
||||||
# T8-T16: vst2/vst3/sf_host_bridge DLL + fluidsynth_runtime (22 DLL) phai nam
|
|
||||||
# trong resources/native_host/ de exe tim thay luc runtime (Rust: resource_dir/
|
|
||||||
# native_host; Python: SF_RESOURCE_DIR/native_host). Thieu -> native audio /
|
|
||||||
# VST GUI khong chay tren may cai sach.
|
|
||||||
#
|
|
||||||
# Prebuilt: 3 bridge DLL (~175KB) DUOC COMMIT vao src-tauri/resources/native_host/
|
|
||||||
# (.gitignore cho phep *.dll) de nguoi dung clone moi KHONG can VST3 SDK
|
|
||||||
# (Steinberg SDK ban quyen) + khong can MSVC/cmake. Chi build tu source khi
|
|
||||||
# ca 3 DLL deu thieu (may dev tu build bridge moi).
|
|
||||||
$bridgeDlls = @("sf_host_bridge.dll","vst2_host_bridge.dll","vst3_host_bridge.dll")
|
|
||||||
$havePrebuilt = $true
|
|
||||||
foreach ($dllName in $bridgeDlls) {
|
|
||||||
if (-not (Test-Path "src-tauri\resources\native_host\$dllName")) { $havePrebuilt = $false }
|
|
||||||
}
|
|
||||||
if ($havePrebuilt) {
|
|
||||||
Write-Host "Dung prebuilt bridge DLL (resources/native_host) - skip cmake build, khong can VST3 SDK."
|
|
||||||
} else {
|
|
||||||
if (-not (Test-Path "native_host\build\CMakeCache.txt")) {
|
|
||||||
if (-not $env:VST3_SDK_ROOT) {
|
|
||||||
Write-Host "ERROR: thieu prebuilt bridge DLL + khong co native_host\build + VST3_SDK_ROOT chua set." -ForegroundColor Red
|
|
||||||
Write-Host " Cach 1 (khuyen nghi): git pull lai de co src-tauri/resources/native_host/*.dll (prebuilt)." -ForegroundColor Yellow
|
|
||||||
Write-Host " Cach 2 (dev): set env VST3_SDK_ROOT tro den VST3 SDK roi build lai native_host." -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
& cmake -S native_host -B native_host\build -A x64 -DVST3_SDK_ROOT="$env:VST3_SDK_ROOT"
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure native_host that bai" -ForegroundColor Red; exit 1 }
|
|
||||||
}
|
|
||||||
& cmake --build native_host\build --config Release
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build native_host that bai" -ForegroundColor Red; exit 1 }
|
|
||||||
Copy-Item "native_host\build\Release\vst2_host_bridge.dll","native_host\build\Release\vst3_host_bridge.dll","native_host\build\Release\sf_host_bridge.dll" "src-tauri\resources\native_host\"
|
|
||||||
}
|
|
||||||
|
|
||||||
# fluidsynth_runtime (14.8MB, gitignore) - khong commit; tu dong tai neu thieu.
|
|
||||||
if (-not (Test-Path "src-tauri\resources\native_host\fluidsynth_runtime\libfluidsynth-3.dll")) {
|
|
||||||
if (-not (Test-Path "native_host\fluidsynth_runtime\libfluidsynth-3.dll")) {
|
|
||||||
python native_host\scripts\dl_fluidsynth_runtime.py
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: dl_fluidsynth_runtime that bai" -ForegroundColor Red; exit 1 }
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Force "src-tauri\resources\native_host\fluidsynth_runtime" | Out-Null
|
|
||||||
Copy-Item "native_host\fluidsynth_runtime\*" "src-tauri\resources\native_host\fluidsynth_runtime\" -Recurse -Force
|
|
||||||
} else {
|
|
||||||
Write-Host "Da co fluidsynth_runtime trong resources/native_host."
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
||||||
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
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
|
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
# TASK 53: ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS
|
|
||||||
|
|
||||||
> Spec: "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS"
|
|
||||||
> Thực hiện trên `C:/Users/locpham/SonicForgeStudio` (branch `standalone`).
|
|
||||||
> Tài liệu đi kèm: `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Mục tiêu (từ spec)
|
|
||||||
|
|
||||||
| Option | Yêu cầu spec | Hiện thực |
|
|
||||||
|---|---|---|
|
|
||||||
| **Option 1** | Client-side WASM live preview với **0% IPC** — cần "a custom Python script" tự động sample các preset VSTi thành `.sf3` (~3-8MB) | `tools/autosample_vsti.py` — render từng nốt qua pedalboard → SF2 (16-bit mono), tùy chọn convert sang SF3 |
|
|
||||||
| **Option 2** | Offline render/export qua Python backend (pedalboard VST3 + FluidSynth) — WAV chất lượng **24-bit / 32-bit** | Thêm `bit_depth` (16/24/32) vào toàn bộ đường render/export backend + frontend |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Thay đổi
|
|
||||||
|
|
||||||
### 2.1 Auto-sampling tool (mới)
|
|
||||||
|
|
||||||
**File:** `tools/autosample_vsti.py` (mới, untracked)
|
|
||||||
|
|
||||||
- CLI: `--instrument <plugin_id> --out out.sf2 [--preset] [--low --high --step] [--sf3] [--duration --release --velocity --sample-rate]`
|
|
||||||
- Reuse `PluginManager().load_vst`, `apply_preset_to_plugin`, `HAS_PEDALBOARD` từ `app/core/vst_engine.py`
|
|
||||||
- Render từng nốt bằng pedalboard, MIDI dạng **raw tuple** `(bytes([0x90,note,vel]), 0.0)` / `(bytes([0x80,note,0]), dur)` — pedalboard 0.9.19 build này **không có** `NoteOn` classes; khớp `PluginManager.midi_events_to_messages` (trả `(bytes, seconds)` tuples)
|
|
||||||
- Trim leading silence + normalize peak 0.9 trước khi cast int16
|
|
||||||
- `write_sf2()`: SF2 tối giản hợp lệ — 16-bit mono PCM, 1 zone/nốt (`keyRange lo=hi`), `sampleID`, `overridingRootKey`, `end=exclusive`; preset 0/bank 0
|
|
||||||
- `--sf3`: convert qua `SoundFontConverter` (`app/core/soundfont_converter.py`); fallback giữ SF2 nếu thiếu ffmpeg libvorbis
|
|
||||||
- Toàn bộ text ASCII-only (an toàn cp1252 console Windows)
|
|
||||||
|
|
||||||
**SF2 writer — chi tiết đúng chuẩn** (đã verify bằng sf2utils):
|
|
||||||
- INFO text chunk (INAM) **phải chẵn** — pad `\x00` bên trong data (strict parsers không skip RIFF pad byte của odd chunk)
|
|
||||||
- Records: `phdr` 38B (name20 + `<HHHIII`), `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B (`<HH`), `ibag`/`pbag` 4B, `shdr` 46B; bắt buộc terminator records cho từng bảng
|
|
||||||
|
|
||||||
**Kết quả verify:** Nexus.vst3 → 3 nốt (60/62/64) → SF2 207KB, sf2utils parse sạch, audio peak 29490. Kontakt load được nhưng silent (không có .nki — đúng dự kiến).
|
|
||||||
|
|
||||||
### 2.2 bit_depth 16/24/32 WAV export (Option 2)
|
|
||||||
|
|
||||||
| File | Thay đổi |
|
|
||||||
|---|---|
|
|
||||||
| `app/core/render_engine.py` | `render_project(self, project_json, output_filepath, bit_depth=16)` (L453); `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write` (L479-481) |
|
|
||||||
| `app/api/v1/plugins.py` | `bit_depth: int = 16` thêm vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063); truyền xuống `engine.render_project(..., bit_depth=req.bit_depth)` (L1200), `_render_midi_notes_pedalboard(..., bit_depth=16)` (signature L1001, sf.write L1048), `soundfont_render` sf.write L1100 |
|
|
||||||
| `app/core/audio_editor.py` | Cả 2 subtype map `{8:"PCM_S8",16:"PCM_16",24:"PCM_24",32:"PCM_32"}` (L196, L257) — mix multitrack + export |
|
|
||||||
| `app/static/js/app.jsx` | Option `32` thêm vào **cả 2** dropdown export (L11355 JSX + compiled); encoder 32-bit (`view.setInt32(... s*0x80000000 ...)`) thêm vào **cả 2** client WAV encoder — realtime bounce (L25044-25045) và offline-session (L25363-25368). Đồng thời sửa bug có sẵn: trước đây chọn 24-bit rơi vào nhánh fallback 8-bit |
|
|
||||||
|
|
||||||
### 2.3 Tests
|
|
||||||
|
|
||||||
- `tests/test_autosampler.py` (mới): `test_write_sf2_valid_structure` (RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames), `test_write_sf2_rejects_empty`, `test_write_sf2_odd_name_no_corruption` (regression: tên lẻ "Nexus" không làm sf2utils báo corrupted)
|
|
||||||
- `tests/test_render_engine.py`: thêm `test_render_project_bit_depth` (16/24/32 → PCM_16/24/32)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Kết quả test
|
|
||||||
|
|
||||||
```
|
|
||||||
python -m pytest tests/ -q
|
|
||||||
→ 108 passed, 1 skipped, 1 failed
|
|
||||||
```
|
|
||||||
|
|
||||||
1 fail: `tests/test_vst_engine.py::TestPluginManager::test_init` — **lỗi môi trường có sẵn** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`; đã xác nhận fail cả trên code sạch bằng git stash). Không thuộc task này.
|
|
||||||
|
|
||||||
> Lưu ý: không chạy `pytest` bare từ root repo (collect torch resources dưới `src-tauri/target/release/resources/` → 52 collection errors). Luôn nhắm `tests/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Cách dùng
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1 — auto-sample VSTi preset sang SF2 (client WASM preview, 0% IPC)
|
|
||||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf2 --preset <path.vstpreset> --low 36 --high 96 --step 2
|
|
||||||
|
|
||||||
# ... hoặc sang SF3 (cần ffmpeg + libvorbis)
|
|
||||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf3 --sf3
|
|
||||||
|
|
||||||
# Option 2 — render/export 24-bit/32-bit (API)
|
|
||||||
POST /api/v1/plugins/render { "project_json": {...}, "bit_depth": 24 }
|
|
||||||
POST /api/v1/plugins/midi-render { ..., "bit_depth": 32 }
|
|
||||||
POST /api/v1/plugins/soundfont-render { ..., "bit_depth": 24 }
|
|
||||||
```
|
|
||||||
|
|
||||||
SF2/SF3 đặt vào `app/storage/soundfonts/` để client tải qua `/soundfonts/download`.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# WALKTHROUGH 53: VSTi AUTOSAMPLE + 24/32-BIT RENDER/EXPORT
|
|
||||||
|
|
||||||
> Ghi lại các hành động đã thực hiện cho Task 53 (`md/53_VSTI_AUTOSAMPLE_RENDER.md`).
|
|
||||||
> Môi trường: Windows 11, bash, Python 3.13.7, pedalboard==0.9.19, repo `C:/Users/locpham/SonicForgeStudio` branch `standalone`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bước 0 — Đọc spec & khảo sát hiện trạng
|
|
||||||
|
|
||||||
1. Đọc spec "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS" → 2 option cần hiện thực:
|
|
||||||
- Option 1: custom Python script auto-sample VSTi preset → `.sf3` (~3-8MB) cho client WASM live preview 0% IPC
|
|
||||||
- Option 2: offline render/export qua Python backend, WAV 24-bit/32-bit
|
|
||||||
2. Khảo sát codebase: `app/core/vst_engine.py` (PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD, midi_events_to_messages), `app/core/render_engine.py`, `app/core/audio_editor.py`, `app/api/v1/plugins.py` (RenderRequest, MidiRenderRequest, SoundfontRenderRequest), `app/static/js/app.jsx` (2 WAV encoder), `app/core/soundfont_converter.py` (SF2→SF3).
|
|
||||||
|
|
||||||
## Bước 1 — Khảo sát API pedalboard trên máy
|
|
||||||
|
|
||||||
3. `dir(pedalboard)` → **không có** NoteOn/MIDI classes trong build này. Xác định MIDI phải truyền dạng **raw tuple** `(bytes, seconds)`:
|
|
||||||
- `dir(PluginManager)` và đọc `midi_events_to_messages` trong `vst_engine.py` → trả `(bytes, seconds)` tuples.
|
|
||||||
- Quyết định: render MIDI qua `vst(messages, sample_rate=..., duration=...)` với `(bytes([0x90,note,vel]), 0.0)` (note-on) và `(bytes([0x80,note,0]), dur)` (note-off).
|
|
||||||
|
|
||||||
## Bước 2 — Viết `tools/autosample_vsti.py`
|
|
||||||
|
|
||||||
4. Viết script lần 1 bằng heredoc với nội dung có ký tự Unicode (dấu tiếng Việt) → **crash**: `open(p,'w')` trên cp1252 console gây `UnicodeEncodeError` giữa chừng → **file bị truncate/hỏng**.
|
|
||||||
5. **Viết lại từ đầu** với toàn bộ text ASCII-only (chuẩn style repo `tools/gen_icons.py`): dùng "duong dan", "khong", "not" thay vì dấu tiếng Việt; mọi in/help đều ASCII.
|
|
||||||
6. Cấu trúc script:
|
|
||||||
- `_render_note(vst, note, sr, dur, release, velocity)`: build raw MIDI tuples → `vst(...)` → mean về mono → trim leading silence (ngưỡng `peak*0.001`) → normalize peak 0.9 → int16; trả `None` nếu silent.
|
|
||||||
- `write_sf2(out_path, samples, sample_rate, name)`: tự sinh SF2 tối giản (16-bit mono PCM, 1 zone/nốt, preset 0/bank 0).
|
|
||||||
- `main()`: argparse `--instrument --out --preset --low --high --step --duration --release --velocity --sample-rate --sf3`.
|
|
||||||
7. Verify từng record size SF2 bằng chuẩn spec + sf2utils:
|
|
||||||
- `phdr` 38B, `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B, `ibag`/`pbag` 4B, `shdr` 46B + terminator records.
|
|
||||||
- **Bug phát hiện**: INAM lẻ ("Nexus") → sf2utils báo "corrupted but salvageable" vì strict parser không skip RIFF pad byte của odd chunk. Fix: pad `\x00` **bên trong data** cho INFO text chunk chẵn.
|
|
||||||
|
|
||||||
## Bước 3 — Verify end-to-end auto-sample
|
|
||||||
|
|
||||||
8. Chạy thử với VSTi thật:
|
|
||||||
```
|
|
||||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out nexus_test.sf2 --low 60 --high 64 --step 2
|
|
||||||
```
|
|
||||||
→ 3 nốt (60/62/64) → SF2 **207KB**, sf2utils parse sạch, audio peak 29490. ✅
|
|
||||||
9. Thử Kontakt.vst3 → load được nhưng silent (không có .nki — đúng dự kiến, không phải lỗi script).
|
|
||||||
|
|
||||||
## Bước 4 — Thêm bit_depth 16/24/32 backend
|
|
||||||
|
|
||||||
10. `app/core/render_engine.py` L453: `render_project(self, project_json, output_filepath, bit_depth=16)`; L479-481: `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write(..., subtype=subtype_map.get(int(bit_depth), "PCM_16"))`.
|
|
||||||
11. `app/api/v1/plugins.py`:
|
|
||||||
- `bit_depth: int = 16` vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063).
|
|
||||||
- `_render_midi_notes_pedalboard(..., bit_depth=16)` (L1001) → sf.write L1048.
|
|
||||||
- `soundfont_render` sf.write L1100.
|
|
||||||
- `render_project` API truyền `bit_depth=req.bit_depth` (L1200).
|
|
||||||
12. `app/core/audio_editor.py`: cả 2 subtype map (L196, L257) thêm `32:"PCM_32"` — mix multitrack + export.
|
|
||||||
13. Verify bằng `sf.info(path).subtype` (soundfile): 16→PCM_16, 24→PCM_24, 32→PCM_32. ✅
|
|
||||||
|
|
||||||
## Bước 5 — Thêm bit_depth 32 frontend (`app/static/js/app.jsx` — CRLF)
|
|
||||||
|
|
||||||
14. **Chú ý CRLF**: `edit_file` multi-line fail nếu old_string không có `\r`. Dùng python heredoc: đọc `open(p, encoding='utf-8', newline='').read()`, assert count==1, replace bằng chuỗi chứa `\r\n`, ghi lại với `newline=''`. Không bao giờ `open(p,'w')` trước khi encode.
|
|
||||||
15. Thêm `<option value="32">32</option>` vào **cả 2** dropdown export (JSX L11355 + compiled bản ~L28700).
|
|
||||||
16. Thêm nhánh encoder 32-bit vào **cả 2** client WAV encoder:
|
|
||||||
- realtime bounce (L25044-25045): `else if (bitDepth === 32) view.setInt32(o, Math.floor(s < 0 ? s * 0x80000000 : s * 0x7FFFFFFF), true);`
|
|
||||||
- offline-session (L25363-25368): tương tự `setInt32`.
|
|
||||||
17. **Bug cũ được sửa ngầm**: trước đây chọn 24-bit bị rơi vào nhánh fallback 8-bit (thiếu nhánh `===24`); thêm nhánh 24-bit + 32-bit đúng thứ tự.
|
|
||||||
|
|
||||||
## Bước 6 — Tests
|
|
||||||
|
|
||||||
18. Viết `tests/test_autosampler.py` (mới, LF):
|
|
||||||
- `test_write_sf2_valid_structure`: RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames, `sample_rate==44100`.
|
|
||||||
- `test_write_sf2_rejects_empty`: `write_sf2([], ...)` → ValueError.
|
|
||||||
- `test_write_sf2_odd_name_no_corruption`: tên "Nexus" (5 ký tự lẻ) → sf2utils không log "corrupted".
|
|
||||||
19. Thêm `test_render_project_bit_depth` vào `tests/test_render_engine.py`: loop (16,24,32) → assert `sf.info(out).subtype == PCM_16/24/32`.
|
|
||||||
20. Chạy:
|
|
||||||
```
|
|
||||||
python -m pytest tests/ -q
|
|
||||||
→ 108 passed, 1 skipped, 1 failed
|
|
||||||
```
|
|
||||||
- 1 skipped: pyfluidsynth không có native lib trên máy (tests không yêu cầu).
|
|
||||||
- 1 failed: `TestPluginManager::test_init` — **pre-existing** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`).
|
|
||||||
21. Xác nhận fail đó không do mình: `git stash` → chạy test → fail y hệt trên code sạch → `git stash pop`. ✅
|
|
||||||
|
|
||||||
## Bước 7 — Kiểm tra cuối & tài liệu
|
|
||||||
|
|
||||||
22. `git status` → các file modified/untracked đúng như dự kiến (không đụng file ngoài phạm vi; giữ nguyên các thay đổi pre-existing của user: `app.jsx`, `services/api.js`, `services/fluidsynthLoader.js`, `app.precompiled.js`).
|
|
||||||
23. Viết `md/53_VSTI_AUTOSAMPLE_RENDER.md` (task) + `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md` (file này).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tổng kết diff
|
|
||||||
|
|
||||||
```
|
|
||||||
M app/api/v1/plugins.py (bit_depth 3 requests + 3 sf.write + pass-through)
|
|
||||||
M app/core/audio_editor.py (+32:"PCM_32" ở 2 subtype map)
|
|
||||||
M app/core/render_engine.py (render_project bit_depth + subtype_map)
|
|
||||||
M app/static/js/app.jsx (option 32 ×2 dropdown, encoder 32-bit ×2, fix 24-bit fallback)
|
|
||||||
M tests/test_render_engine.py (+test_render_project_bit_depth)
|
|
||||||
?? tools/autosample_vsti.py (mới — auto-sample VSTi → SF2/SF3)
|
|
||||||
?? tests/test_autosampler.py (mới — 3 tests SF2 writer)
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(daw_vst_bridge LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
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 libfluidsynth-3)
|
||||||
|
if(NOT FLUIDSYNTH_INCLUDE_DIR OR NOT FLUIDSYNTH_LIBRARY)
|
||||||
|
message(FATAL_ERROR "fluidsynth not found — cai qua vcpkg: vcpkg install fluidsynth")
|
||||||
|
endif()
|
||||||
|
# sfizz: built from source (native_bridge/sfizz-src, see build/_sfizz.bat) —
|
||||||
|
# the sfizz port is NOT in vcpkg upstream. Fall back to vcpkg if present.
|
||||||
|
set(SFIZZ_SRC_BUILD "${CMAKE_CURRENT_SOURCE_DIR}/sfizz-src/build/library/lib/Release")
|
||||||
|
if(EXISTS "${SFIZZ_SRC_BUILD}/sfizz_static.lib")
|
||||||
|
file(GLOB SFIZZ_SRC_LIBS "${SFIZZ_SRC_BUILD}/*.lib")
|
||||||
|
set(SFIZZ_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/sfizz-src/src")
|
||||||
|
set(SFIZZ_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/sfizz-src/src")
|
||||||
|
set(SFIZZ_LIBRARY ${SFIZZ_SRC_LIBS})
|
||||||
|
else()
|
||||||
|
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 — build native_bridge/sfizz-src first")
|
||||||
|
endif()
|
||||||
|
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_DIR}
|
||||||
|
${SFIZZ_INCLUDE_DIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(daw_vst_bridge
|
||||||
|
src/main.cpp
|
||||||
|
src/NativeInstrumentEngine.cpp
|
||||||
|
src/SharedMemoryIPC.cpp
|
||||||
|
src/Vst3Instrument.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
# VST3 SDK hosting: sdk_hosting lacks the platform module loader — module_win32.cpp
|
||||||
|
# is added to the bridge itself (same approach as the audiohost sample).
|
||||||
|
if(VST3_SDK_TARGET)
|
||||||
|
target_compile_definitions(daw_vst_bridge PRIVATE HAVE_VST3SDK=1)
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${VST3_SDK_TARGET} sdk_hosting)
|
||||||
|
if(WIN32)
|
||||||
|
target_sources(daw_vst_bridge PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/public.sdk/source/vst/hosting/module_win32.cpp
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ── gui_probe: standalone VST3 GUI attach probe (debug tool, not shipped) ──
|
||||||
|
add_executable(gui_probe
|
||||||
|
src/gui_probe.cpp
|
||||||
|
src/NativeInstrumentEngine.cpp
|
||||||
|
src/SharedMemoryIPC.cpp
|
||||||
|
src/Vst3Instrument.cpp
|
||||||
|
)
|
||||||
|
if(VST3_SDK_TARGET)
|
||||||
|
target_compile_definitions(gui_probe PRIVATE HAVE_VST3SDK=1)
|
||||||
|
target_link_libraries(gui_probe PRIVATE ${VST3_SDK_TARGET} sdk_hosting)
|
||||||
|
if(WIN32)
|
||||||
|
target_sources(gui_probe PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/public.sdk/source/vst/hosting/module_win32.cpp
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(gui_probe PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY} winmm)
|
||||||
|
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,92 @@
|
|||||||
|
// native_bridge/include/NativeInstrumentEngine.h
|
||||||
|
#ifndef NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
#define NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#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;
|
||||||
|
// Per-channel bank select (CC0<<7 | CC32) — programChange phai dung bank
|
||||||
|
// that (truoc day hardcode bank 0 -> preset o bank != 0 khong chon duoc).
|
||||||
|
uint32_t bank_[16];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
sfz::Sfizz 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 { std::lock_guard<std::mutex> lock(mu_); return channels_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<INativeInstrument> create_instrument(InstrumentType type);
|
||||||
|
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
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,52 @@
|
|||||||
|
// native_bridge/include/SharedMemoryIPC.h
|
||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#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,41 @@
|
|||||||
|
// 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:
|
||||||
|
// pimpl: all Steinberg SDK types live in Vst3Instrument.cpp (Vst3HostState).
|
||||||
|
void* state_;
|
||||||
|
std::string path_;
|
||||||
|
double sampleRate_;
|
||||||
|
uint32_t maxBlockSize_;
|
||||||
|
bool loaded_;
|
||||||
|
bool guiAttached_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // VST3_INSTRUMENT_H
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// native_bridge/src/NativeInstrumentEngine.cpp
|
||||||
|
#include "NativeInstrumentEngine.h"
|
||||||
|
|
||||||
|
#include <fluidsynth.h>
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
#include "Vst3Instrument.h"
|
||||||
|
|
||||||
|
// void* members keep fluid types out of the public header; cast here.
|
||||||
|
#define FS_SYNTH (static_cast<fluid_synth_t*>(synth))
|
||||||
|
#define FS_SETTINGS (static_cast<fluid_settings_t*>(settings))
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 1. SOUNDFONT ENGINE (.SF2 / .SF3) VIA FLUIDSYNTH C API
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
FluidSynthInstrument::FluidSynthInstrument()
|
||||||
|
: settings(nullptr), synth(nullptr), sfontId(-1) {
|
||||||
|
for (uint32_t i = 0; i < 16; ++i) bank_[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
FluidSynthInstrument::~FluidSynthInstrument() {
|
||||||
|
if (synth) delete_fluid_synth(FS_SYNTH);
|
||||||
|
if (settings) delete_fluid_settings(FS_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::loadSoundFontFile(const std::string& path, double sampleRate) {
|
||||||
|
if (synth) { delete_fluid_synth(FS_SYNTH); synth = nullptr; }
|
||||||
|
if (settings) { delete_fluid_settings(FS_SETTINGS); settings = nullptr; }
|
||||||
|
settings = new_fluid_settings();
|
||||||
|
fluid_settings_setnum(FS_SETTINGS, "synth.sample-rate", sampleRate);
|
||||||
|
fluid_settings_setint(FS_SETTINGS, "synth.polyphony", 256);
|
||||||
|
fluid_settings_setint(FS_SETTINGS, "synth.verbose", 0);
|
||||||
|
synth = new_fluid_synth(FS_SETTINGS);
|
||||||
|
if (!synth) return false;
|
||||||
|
sfontId = fluid_synth_sfload(FS_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) {
|
||||||
|
bank_[ch] = 0;
|
||||||
|
fluid_synth_program_select(FS_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 || channel >= 16) return;
|
||||||
|
bank_[channel] = bank;
|
||||||
|
fluid_synth_program_select(FS_SYNTH, channel, sfontId, bank, 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(FS_SYNTH, channel, pitch, velInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_noteoff(FS_SYNTH, channel, pitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
if (!synth || channel >= 16) return;
|
||||||
|
// Bank select MSB/LSB (A12): CC0 = (bank>>7)&0x7F, CC32 = bank&0x7F
|
||||||
|
if (cc == 0) bank_[channel] = (bank_[channel] & 0x7Fu) | ((value & 0x7Fu) << 7);
|
||||||
|
else if (cc == 32) bank_[channel] = (bank_[channel] & ~0x7Fu) | (value & 0x7Fu);
|
||||||
|
fluid_synth_cc(FS_SYNTH, channel, cc, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
if (!synth || channel >= 16) return;
|
||||||
|
// Dung bank da nhan tu CC0/CC32 — bank hardcode 0 lam preset o bank != 0
|
||||||
|
// khong duoc chon (fluid giu preset cu -> ra piano sai).
|
||||||
|
fluid_synth_program_select(FS_SYNTH, channel, sfontId, bank_[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(FS_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(FS_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) {
|
||||||
|
sfizzSynth.cc(0, static_cast<int>(cc), static_cast<int>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
sfizzSynth.programChange(0, static_cast<int>(program));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
sfizzSynth.pitchWheel(0, 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, 1); // numOutputs=1 = stereo L/R pair (2 ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 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>();
|
||||||
|
case InstrumentType::VST3: return std::make_unique<Vst3Instrument>();
|
||||||
|
// ponytail: VST2 host (VST2.4 SDK, Steinberg discontinued) not implemented.
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
else if (type == InstrumentType::VST3)
|
||||||
|
loaded = static_cast<Vst3Instrument*>(inst.get())->loadPlugin(path, sampleRate);
|
||||||
|
if (!loaded) return false;
|
||||||
|
// init() AFTER load: FluidSynth creates its synth inside loadSoundFontFile.
|
||||||
|
if (!inst->init(sampleRate, blockSize)) return false;
|
||||||
|
// Replacing an existing instrument drops its voices with the old engine.
|
||||||
|
// Load may run on a detached thread (VST3 init is slow): only the map
|
||||||
|
// write is under the mutex so renderAll on the audio loop never stalls.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
channels_[channel] = std::move(inst);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
INativeInstrument* InstrumentEngineManager::get(uint32_t channel) {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
auto it = channels_.find(channel);
|
||||||
|
return it == channels_.end() ? nullptr : it->second.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstrumentEngineManager::allNotesOff() {
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
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::lock_guard<std::mutex> lock(mu_);
|
||||||
|
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,121 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// Creates the mapping (used by tests/self-check; the DAW normally owns it).
|
||||||
|
ShmHandle* shm_create(const char* name) {
|
||||||
|
ShmHandle* h = new ShmHandle();
|
||||||
|
int wlen = MultiByteToWideChar(CP_UTF8, 0, name, -1, nullptr, 0);
|
||||||
|
std::wstring wname(wlen, L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, name, -1, &wname[0], wlen);
|
||||||
|
h->map = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0,
|
||||||
|
sizeof(SharedAudioBufferIPC), wname.c_str());
|
||||||
|
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,627 @@
|
|||||||
|
// 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
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
#include "public.sdk/source/vst/hosting/module.h"
|
||||||
|
#include "public.sdk/source/vst/hosting/hostclasses.h"
|
||||||
|
#include "public.sdk/source/vst/hosting/processdata.h"
|
||||||
|
#include "public.sdk/source/vst/hosting/eventlist.h"
|
||||||
|
#include "public.sdk/source/vst/hosting/parameterchanges.h"
|
||||||
|
|
||||||
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
|
#include "pluginterfaces/vst/ivstcomponent.h"
|
||||||
|
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmidicontrollers.h"
|
||||||
|
#include "pluginterfaces/vst/ivstprocesscontext.h"
|
||||||
|
#include "pluginterfaces/vst/ivstevents.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmessage.h"
|
||||||
|
#include "pluginterfaces/gui/iplugview.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// With-SDK implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Steinberg::tresult;
|
||||||
|
using Steinberg::kResultOk;
|
||||||
|
using Steinberg::kResultTrue;
|
||||||
|
using Steinberg::kResultFalse;
|
||||||
|
using Steinberg::kNoInterface;
|
||||||
|
using Steinberg::FUnknownPtr;
|
||||||
|
using Steinberg::IPtr;
|
||||||
|
using Steinberg::owned;
|
||||||
|
using Steinberg::FIDString;
|
||||||
|
using Steinberg::IPlugView;
|
||||||
|
using Steinberg::kPlatformTypeHWND;
|
||||||
|
using Steinberg::int16;
|
||||||
|
using Steinberg::int32;
|
||||||
|
using Steinberg::uint32;
|
||||||
|
using Steinberg::uint16;
|
||||||
|
|
||||||
|
using Steinberg::Vst::IComponent;
|
||||||
|
using Steinberg::Vst::IEditController;
|
||||||
|
using Steinberg::Vst::IAudioProcessor;
|
||||||
|
using Steinberg::Vst::IComponentHandler;
|
||||||
|
using Steinberg::IPluginBase;
|
||||||
|
using Steinberg::Vst::IConnectionPoint;
|
||||||
|
using Steinberg::Vst::IParameterChanges;
|
||||||
|
using Steinberg::Vst::Event;
|
||||||
|
using Steinberg::Vst::ProcessSetup;
|
||||||
|
using Steinberg::Vst::ProcessContext;
|
||||||
|
using Steinberg::Vst::HostProcessData;
|
||||||
|
using Steinberg::Vst::EventList;
|
||||||
|
using Steinberg::Vst::ParameterChanges;
|
||||||
|
using Steinberg::Vst::HostApplication;
|
||||||
|
using Steinberg::Vst::ParamID;
|
||||||
|
using Steinberg::Vst::ParamValue;
|
||||||
|
using Steinberg::Vst::IMidiMapping;
|
||||||
|
using Steinberg::Vst::IParamValueQueue;
|
||||||
|
using Steinberg::Vst::kNoParamId;
|
||||||
|
using Steinberg::Vst::BusInfo;
|
||||||
|
using Steinberg::Vst::kAudio;
|
||||||
|
using Steinberg::Vst::kEvent;
|
||||||
|
using Steinberg::Vst::kInput;
|
||||||
|
using Steinberg::Vst::kOutput;
|
||||||
|
using Steinberg::Vst::kRealtime;
|
||||||
|
using Steinberg::Vst::kSample32;
|
||||||
|
using Steinberg::Vst::kPitchBend;
|
||||||
|
using Steinberg::Vst::CtrlNumber;
|
||||||
|
|
||||||
|
// VST3 spec §MIDI: host-side tag scheme for MIDI CC / pitch bend / program
|
||||||
|
// change parameters. NOT provided by the SDK (verified 3.8.1) — the host
|
||||||
|
// defines them. Note: using-declaration of the real VST3 constants would not
|
||||||
|
// compile; these are the spec values.
|
||||||
|
constexpr ParamID kHostMidiCC = 0x1000; // + controller number
|
||||||
|
constexpr ParamID kHostMidiPitchBend = 0x2000; // + channel
|
||||||
|
constexpr ParamID kHostMidiProgramChange = 0x3000;
|
||||||
|
|
||||||
|
// Minimal IComponentHandler so the plugin can inform the host of param edits.
|
||||||
|
class HostComponentHandler : public IComponentHandler {
|
||||||
|
public:
|
||||||
|
tresult queryInterface(const char*, void** v) override {
|
||||||
|
*v = nullptr;
|
||||||
|
return kNoInterface;
|
||||||
|
}
|
||||||
|
Steinberg::uint32 addRef() override { return 1; }
|
||||||
|
Steinberg::uint32 release() override { return 1; }
|
||||||
|
tresult beginEdit(ParamID) override { return kResultOk; }
|
||||||
|
tresult performEdit(ParamID, ParamValue) override { return kResultOk; }
|
||||||
|
tresult endEdit(ParamID) override { return kResultOk; }
|
||||||
|
tresult restartComponent(Steinberg::int32) override { return kResultOk; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal IPlugFrame so plugins can resize their editor view.
|
||||||
|
class HostPlugFrame : public Steinberg::IPlugFrame {
|
||||||
|
public:
|
||||||
|
tresult queryInterface(const char*, void** v) override {
|
||||||
|
*v = nullptr;
|
||||||
|
return kNoInterface;
|
||||||
|
}
|
||||||
|
Steinberg::uint32 addRef() override { return 1; }
|
||||||
|
Steinberg::uint32 release() override { return 1; }
|
||||||
|
tresult resizeView(Steinberg::IPlugView* view, Steinberg::ViewRect* newSize) override {
|
||||||
|
if (view && newSize) view->onSize(newSize);
|
||||||
|
return kResultOk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// All Steinberg SDK objects live here (pimpl — Vst3Instrument.h stays SDK-free).
|
||||||
|
struct Vst3HostState {
|
||||||
|
// module must be destroyed LAST: it owns the plugin factory that created
|
||||||
|
// component/controller (member order ⇒ destroyed in reverse).
|
||||||
|
VST3::Hosting::Module::Ptr module;
|
||||||
|
IPtr<IComponent> component;
|
||||||
|
IPtr<IEditController> controller;
|
||||||
|
IPtr<HostApplication> hostApp;
|
||||||
|
HostProcessData processData;
|
||||||
|
EventList eventList;
|
||||||
|
ParameterChanges paramChanges;
|
||||||
|
ProcessContext processContext;
|
||||||
|
IPtr<IPlugView> view;
|
||||||
|
HostPlugFrame plugFrame;
|
||||||
|
HostComponentHandler componentHandler;
|
||||||
|
int32 outputChannels = 2;
|
||||||
|
bool controllerIsComponent = false; // single-component plugin: controller == component
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the plugin's ParamID for a MIDI CC / pitch-bend, preferring the
|
||||||
|
// plugin's IMidiMapping assignment (audiohost/miditovst.h pattern), falling
|
||||||
|
// back to the VST3 legacy tag scheme. Returns kNoParamId when unmapped.
|
||||||
|
ParamID midiControllerTag(IPtr<IEditController>& controller, int16 channel,
|
||||||
|
int16 ctrlNumber, ParamID legacyTag) {
|
||||||
|
if (controller) {
|
||||||
|
FUnknownPtr<IMidiMapping> mm(controller.get());
|
||||||
|
ParamID tag = kNoParamId;
|
||||||
|
if (mm &&
|
||||||
|
mm->getMidiControllerAssignment(0, channel, (CtrlNumber)ctrlNumber, tag) ==
|
||||||
|
kResultTrue &&
|
||||||
|
tag != kNoParamId)
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
return legacyTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Vst3Instrument::Vst3Instrument()
|
||||||
|
: state_(nullptr),
|
||||||
|
path_(),
|
||||||
|
sampleRate_(44100.0),
|
||||||
|
maxBlockSize_(256),
|
||||||
|
loaded_(false),
|
||||||
|
guiAttached_(false) {}
|
||||||
|
|
||||||
|
Vst3Instrument::~Vst3Instrument() {
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
if (!state_) return;
|
||||||
|
closeGUI();
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (s->component) {
|
||||||
|
FUnknownPtr<IAudioProcessor> processor(s->component);
|
||||||
|
if (processor) processor->setProcessing(false);
|
||||||
|
s->component->setActive(false);
|
||||||
|
s->component->terminate();
|
||||||
|
}
|
||||||
|
// Single-component plugins: controller == component, already terminated.
|
||||||
|
if (s->controller && !s->controllerIsComponent) s->controller->terminate();
|
||||||
|
s->processData.unprepare();
|
||||||
|
delete s;
|
||||||
|
state_ = nullptr;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
using namespace VST3::Hosting;
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
std::cerr << "[dbg] loadPlugin: Module::create ..." << std::endl;
|
||||||
|
Module::Ptr module = Module::create(path, err);
|
||||||
|
if (!module) {
|
||||||
|
std::cerr << "[Vst3Instrument] Module::create failed: " << err << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const PluginFactory& factory = module->getFactory();
|
||||||
|
std::cerr << "[dbg] loadPlugin: Module::create OK" << std::endl;
|
||||||
|
|
||||||
|
// Pick the first Audio Module class; prefer an Instrument subcategory.
|
||||||
|
// classInfos() returns a TEMPORARY vector (by value) — never keep a
|
||||||
|
// pointer into it; it dangles after the range-for and reading
|
||||||
|
// chosen->ID()/name() is UB (crashed 0xC0000005). Copy instead.
|
||||||
|
ClassInfo chosen;
|
||||||
|
bool haveChosen = false;
|
||||||
|
{
|
||||||
|
auto infos = factory.classInfos();
|
||||||
|
for (const ClassInfo& ci : infos) {
|
||||||
|
if (ci.category() != kVstAudioEffectClass) continue;
|
||||||
|
if (!haveChosen) { chosen = ci; haveChosen = true; }
|
||||||
|
if (ci.subCategoriesString().find("Instrument") != std::string::npos) {
|
||||||
|
chosen = ci;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!haveChosen) {
|
||||||
|
std::cerr << "[Vst3Instrument] no Audio Module class in " << path << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cerr << "[dbg] loadPlugin: createInstance ..." << std::endl;
|
||||||
|
std::cerr << "[dbg] loadPlugin: chosen name=" << chosen.name() << " category=" << chosen.category()
|
||||||
|
<< " subcat=" << chosen.subCategoriesString() << " idbytes=";
|
||||||
|
{
|
||||||
|
const unsigned char* cid2 = (const unsigned char*)chosen.ID().data();
|
||||||
|
for (int b = 0; b < 16; ++b) std::cerr << std::hex << (int)cid2[b] << ' ';
|
||||||
|
std::cerr << std::dec << std::endl;
|
||||||
|
}
|
||||||
|
IPtr<IComponent> component = factory.createInstance<IComponent>(chosen.ID());
|
||||||
|
if (!component) {
|
||||||
|
std::cerr << "[Vst3Instrument] createInstance<IComponent> failed" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IPtr<HostApplication> hostApp = owned(new HostApplication());
|
||||||
|
std::cerr << "[dbg] loadPlugin: initialize ..." << std::endl;
|
||||||
|
FUnknownPtr<IPluginBase> plugBase(component.get());
|
||||||
|
if (!plugBase || plugBase->initialize(hostApp) != kResultOk) {
|
||||||
|
std::cerr << "[Vst3Instrument] component initialize failed" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit controller: either the component itself (single-component) or a
|
||||||
|
// separate factory instance (plugprovider.cpp pattern).
|
||||||
|
IPtr<IEditController> controller;
|
||||||
|
bool isSingle = false;
|
||||||
|
if (component->queryInterface(IEditController::iid, (void**)&controller) == kResultTrue) {
|
||||||
|
isSingle = true;
|
||||||
|
} else {
|
||||||
|
Steinberg::TUID cid = {};
|
||||||
|
tresult cidRes = component->getControllerClassId(cid);
|
||||||
|
std::cerr << "[dbg] loadPlugin: getControllerClassId=" << (int)cidRes
|
||||||
|
<< " cid=";
|
||||||
|
for (int b = 0; b < 16; ++b) std::cerr << std::hex << (int)(unsigned char)cid[b] << ' ';
|
||||||
|
std::cerr << std::dec << std::endl;
|
||||||
|
if (cidRes == kResultTrue || cidRes == kResultOk) {
|
||||||
|
controller = factory.createInstance<IEditController>(VST3::UID(cid));
|
||||||
|
std::cerr << "[dbg] loadPlugin: controller from factory=" << (controller ? 1 : 0) << std::endl;
|
||||||
|
if (controller) {
|
||||||
|
FUnknownPtr<IPluginBase> ctrlBase(controller.get());
|
||||||
|
if (!ctrlBase || ctrlBase->initialize(hostApp) != kResultOk) controller = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller) {
|
||||||
|
std::cerr << "[Vst3Instrument] no edit controller for " << path << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State allocated up-front so the component handler (which the controller
|
||||||
|
// keeps a pointer to) lives in the final state object.
|
||||||
|
std::unique_ptr<Vst3HostState> s(new Vst3HostState());
|
||||||
|
s->controllerIsComponent = isSingle;
|
||||||
|
controller->setComponentHandler(&s->componentHandler);
|
||||||
|
// Connect component <-> controller for parameter sync (both directions).
|
||||||
|
FUnknownPtr<IConnectionPoint> compCP(component);
|
||||||
|
FUnknownPtr<IConnectionPoint> ctrlCP(controller);
|
||||||
|
if (compCP && ctrlCP) {
|
||||||
|
compCP->connect(ctrlCP);
|
||||||
|
ctrlCP->connect(compCP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate all audio and event buses (MIDI & Audio) - required by VST3 specification.
|
||||||
|
{
|
||||||
|
int32 numAudioInputs = component->getBusCount(kAudio, kInput);
|
||||||
|
for (int32 i = 0; i < numAudioInputs; ++i) {
|
||||||
|
component->activateBus(kAudio, kInput, i, true);
|
||||||
|
}
|
||||||
|
int32 numAudioOutputs = component->getBusCount(kAudio, kOutput);
|
||||||
|
for (int32 i = 0; i < numAudioOutputs; ++i) {
|
||||||
|
component->activateBus(kAudio, kOutput, i, true);
|
||||||
|
}
|
||||||
|
int32 numEventInputs = component->getBusCount(kEvent, kInput);
|
||||||
|
for (int32 i = 0; i < numEventInputs; ++i) {
|
||||||
|
component->activateBus(kEvent, kInput, i, true);
|
||||||
|
}
|
||||||
|
int32 numEventOutputs = component->getBusCount(kEvent, kOutput);
|
||||||
|
for (int32 i = 0; i < numEventOutputs; ++i) {
|
||||||
|
component->activateBus(kEvent, kOutput, i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio processor: setup → activate → start processing (audioclient.cpp).
|
||||||
|
FUnknownPtr<IAudioProcessor> processor(component);
|
||||||
|
if (!processor) {
|
||||||
|
std::cerr << "[Vst3Instrument] no IAudioProcessor" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::cerr << "[dbg] loadPlugin: setupProcessing ..." << std::endl;
|
||||||
|
ProcessSetup setup{kRealtime, kSample32, (int32)maxBlockSize_, sampleRate};
|
||||||
|
if (processor->setupProcessing(setup) != kResultOk) {
|
||||||
|
std::cerr << "[Vst3Instrument] setupProcessing failed" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::cerr << "[dbg] loadPlugin: setActive ..." << std::endl;
|
||||||
|
if (component->setActive(true) != kResultOk) {
|
||||||
|
std::cerr << "[Vst3Instrument] setActive failed" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
processor->setProcessing(true);
|
||||||
|
|
||||||
|
// Build per-bus buffers sized maxBlockSize_ (HostProcessData owns them;
|
||||||
|
// we must NOT override channelBuffers with external pointers — unprepare
|
||||||
|
// would delete[] them).
|
||||||
|
std::cerr << "[dbg] loadPlugin: processData.prepare ..." << std::endl;
|
||||||
|
if (!s->processData.prepare(*component, (int32)maxBlockSize_, kSample32)) {
|
||||||
|
std::cerr << "[Vst3Instrument] processData.prepare failed" << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count output channels of bus 0 (mono plugins duplicate ch0 → both outs).
|
||||||
|
int32 outChannels = 2;
|
||||||
|
if (component->getBusCount(kAudio, kOutput) > 0) {
|
||||||
|
BusInfo bi = {};
|
||||||
|
if (component->getBusInfo(kAudio, kOutput, 0, bi) == kResultOk && bi.channelCount > 0)
|
||||||
|
outChannels = bi.channelCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cerr << "[dbg] loadPlugin: prepare OK, wiring state" << std::endl;
|
||||||
|
s->module = std::move(module);
|
||||||
|
s->component = std::move(component);
|
||||||
|
s->controller = std::move(controller);
|
||||||
|
s->hostApp = std::move(hostApp);
|
||||||
|
s->outputChannels = outChannels;
|
||||||
|
s->controllerIsComponent = isSingle;
|
||||||
|
s->processContext.sampleRate = sampleRate;
|
||||||
|
s->processContext.tempo = 120.0;
|
||||||
|
s->processContext.timeSigNumerator = 4;
|
||||||
|
s->processContext.timeSigDenominator = 4;
|
||||||
|
s->processContext.state =
|
||||||
|
ProcessContext::kPlaying | ProcessContext::kTempoValid | ProcessContext::kTimeSigValid |
|
||||||
|
ProcessContext::kProjectTimeMusicValid;
|
||||||
|
|
||||||
|
state_ = s.release();
|
||||||
|
path_ = path;
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
loaded_ = true;
|
||||||
|
std::cout << "[Vst3Instrument] loaded " << path << " (" << chosen.name()
|
||||||
|
<< ") outCh=" << outChannels << std::endl;
|
||||||
|
return true;
|
||||||
|
#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
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->component) return;
|
||||||
|
Event e = {};
|
||||||
|
e.busIndex = 0;
|
||||||
|
e.sampleOffset = (int32)sampleOffset;
|
||||||
|
e.ppqPosition = 0;
|
||||||
|
e.flags = Event::kIsLive;
|
||||||
|
e.type = Event::kNoteOnEvent;
|
||||||
|
e.noteOn.channel = 0; // Force to MIDI Channel 1 (0) for VSTi compatibility
|
||||||
|
e.noteOn.pitch = (int16)pitch;
|
||||||
|
e.noteOn.tuning = 0.f;
|
||||||
|
e.noteOn.velocity = velocity;
|
||||||
|
e.noteOn.length = 0;
|
||||||
|
e.noteOn.noteId = 0; // some plugins (DUNE3) track notes by id; -1 may be ignored
|
||||||
|
s->eventList.addEvent(e);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
(void)channel; (void)pitch; (void)sampleOffset;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->component) return;
|
||||||
|
Event e = {};
|
||||||
|
e.busIndex = 0;
|
||||||
|
e.sampleOffset = (int32)sampleOffset;
|
||||||
|
e.ppqPosition = 0;
|
||||||
|
e.flags = Event::kIsLive;
|
||||||
|
e.type = Event::kNoteOffEvent;
|
||||||
|
e.noteOff.channel = 0; // Force to MIDI Channel 1 (0) for VSTi compatibility
|
||||||
|
e.noteOff.pitch = (int16)pitch;
|
||||||
|
e.noteOff.velocity = 0.f;
|
||||||
|
e.noteOff.noteId = -1;
|
||||||
|
e.noteOff.tuning = 0.f;
|
||||||
|
s->eventList.addEvent(e);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
(void)channel; (void)cc; (void)value;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->controller) return;
|
||||||
|
ParamID tag = midiControllerTag(s->controller, 0, (int16)cc,
|
||||||
|
kHostMidiCC);
|
||||||
|
ParamValue v = value / 127.0;
|
||||||
|
// setParamNormalized covers single-component plugins; the param queue
|
||||||
|
// reaches split plugins that read inputParameterChanges inside process().
|
||||||
|
s->controller->setParamNormalized(tag, v);
|
||||||
|
int32 idx = 0;
|
||||||
|
if (IParamValueQueue* q = s->paramChanges.addParameterData(tag, idx)) q->addPoint(0, v, idx);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
(void)channel; (void)program;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->controller) return;
|
||||||
|
ParamID tag = kHostMidiProgramChange;
|
||||||
|
// ponytail: normalized value should be program/(count-1) from the program
|
||||||
|
// list param stepCount; 127ths is a reasonable approximation.
|
||||||
|
ParamValue v = (program & 0x7F) / 127.0;
|
||||||
|
s->controller->setParamNormalized(tag, v);
|
||||||
|
int32 idx = 0;
|
||||||
|
if (IParamValueQueue* q = s->paramChanges.addParameterData(tag, idx)) q->addPoint(0, v, idx);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
(void)channel; (void)bend14;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->controller) return;
|
||||||
|
ParamID tag = midiControllerTag(s->controller, 0, (int16)kPitchBend,
|
||||||
|
kHostMidiPitchBend);
|
||||||
|
ParamValue v = bend14 / 16383.0;
|
||||||
|
s->controller->setParamNormalized(tag, v);
|
||||||
|
int32 idx = 0;
|
||||||
|
if (IParamValueQueue* q = s->paramChanges.addParameterData(tag, idx)) q->addPoint(0, v, idx);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Vst3Instrument::openGUI(void* parentWindowHandle) {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
(void)parentWindowHandle;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
std::cerr << "[dbg] openGUI hwnd=" << (void*)(uintptr_t)parentWindowHandle
|
||||||
|
<< " controller=" << (s ? (s->controller ? 1 : 0) : -1) << std::endl;
|
||||||
|
if (!s || !s->controller || !parentWindowHandle) return false;
|
||||||
|
if (s->view && guiAttached_) return true;
|
||||||
|
IPlugView* rawView = nullptr;
|
||||||
|
tresult qi = s->controller->queryInterface(IPlugView::iid, (void**)&rawView);
|
||||||
|
{
|
||||||
|
const unsigned char* iidb = (const unsigned char*)&IPlugView::iid;
|
||||||
|
std::cerr << "[dbg] openGUI: IPlugView::iid bytes=";
|
||||||
|
for (int b = 0; b < 16; ++b) std::cerr << std::hex << (int)iidb[b] << ' ';
|
||||||
|
std::cerr << std::dec << std::endl;
|
||||||
|
const unsigned char* eidb = (const unsigned char*)&IEditController::iid;
|
||||||
|
std::cerr << "[dbg] openGUI: IEditController::iid bytes=";
|
||||||
|
for (int b = 0; b < 16; ++b) std::cerr << std::hex << (int)eidb[b] << ' ';
|
||||||
|
std::cerr << std::dec << std::endl;
|
||||||
|
}
|
||||||
|
std::cerr << "[dbg] openGUI: controller qi IPlugView=" << (int)qi << " raw=" << (void*)rawView << " isSingle=" << (s->controllerIsComponent ? 1 : 0) << std::endl;
|
||||||
|
FUnknownPtr<IPlugView> view(rawView);
|
||||||
|
if (!view) {
|
||||||
|
// Mot so plugin khong expose IPlugView tren edit controller; thu component.
|
||||||
|
std::cerr << "[dbg] openGUI: controller no IPlugView, trying component" << std::endl;
|
||||||
|
IPlugView* rawViewC = nullptr;
|
||||||
|
tresult qic = s->component->queryInterface(IPlugView::iid, (void**)&rawViewC);
|
||||||
|
std::cerr << "[dbg] openGUI: component qi IPlugView=" << (int)qic << " raw=" << (void*)rawViewC << std::endl;
|
||||||
|
view = FUnknownPtr<IPlugView>(rawViewC);
|
||||||
|
}
|
||||||
|
if (!view) {
|
||||||
|
// Official editorhost.cpp pattern: IEditController::createView(kEditor).
|
||||||
|
// JUCE-based plugins (Scaler2) expose the editor only this way.
|
||||||
|
std::cerr << "[dbg] openGUI: qi failed, trying controller->createView(kEditor)" << std::endl;
|
||||||
|
view = owned(s->controller->createView(Steinberg::Vst::ViewType::kEditor));
|
||||||
|
std::cerr << "[dbg] openGUI: createView view=" << (view ? "ok" : "null") << std::endl;
|
||||||
|
}
|
||||||
|
if (!view) { std::cerr << "[dbg] openGUI: no IPlugView" << std::endl; return false; }
|
||||||
|
view->setFrame(&s->plugFrame);
|
||||||
|
tresult ts = view->isPlatformTypeSupported(kPlatformTypeHWND);
|
||||||
|
std::cerr << "[dbg] openGUI: isPlatformTypeSupported=" << (int)ts << std::endl;
|
||||||
|
// Log only — proceed to attach anyway to support non-compliant plugins.
|
||||||
|
tresult ta = view->attached(parentWindowHandle, kPlatformTypeHWND);
|
||||||
|
std::cerr << "[dbg] openGUI: attached=" << (int)ta << std::endl;
|
||||||
|
if (ta != kResultOk) return false;
|
||||||
|
#ifdef _WIN32
|
||||||
|
HWND hwnd = (HWND)parentWindowHandle;
|
||||||
|
Steinberg::ViewRect rect;
|
||||||
|
if (view->getSize(&rect) == kResultOk) {
|
||||||
|
int w = rect.right - rect.left;
|
||||||
|
int h = rect.bottom - rect.top;
|
||||||
|
RECT r = { 0, 0, w, h };
|
||||||
|
AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
|
||||||
|
SetWindowPos(hwnd, nullptr, 0, 0, r.right - r.left, r.bottom - r.top, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
s->view = view;
|
||||||
|
guiAttached_ = true;
|
||||||
|
// ponytail: the bridge loop is a worker thread without a Windows message
|
||||||
|
// pump — some editors may not repaint until the first native event; a
|
||||||
|
// future version can spin a dedicated UI thread + pump.
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::closeGUI() {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (s && s->view && guiAttached_) s->view->removed();
|
||||||
|
if (s) s->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
|
||||||
|
auto* s = static_cast<Vst3HostState*>(state_);
|
||||||
|
if (!s || !s->component || numSamples == 0) return;
|
||||||
|
FUnknownPtr<IAudioProcessor> processor(s->component);
|
||||||
|
if (!processor) return;
|
||||||
|
|
||||||
|
s->processData.processMode = kRealtime;
|
||||||
|
s->processData.numSamples = (int32)numSamples;
|
||||||
|
s->processData.inputEvents = &s->eventList;
|
||||||
|
s->processData.inputParameterChanges = &s->paramChanges;
|
||||||
|
s->processData.processContext = &s->processContext;
|
||||||
|
s->processContext.projectTimeSamples += numSamples;
|
||||||
|
s->processContext.projectTimeMusic =
|
||||||
|
(double)s->processContext.projectTimeSamples / s->processContext.sampleRate *
|
||||||
|
(s->processContext.tempo / 60.0);
|
||||||
|
|
||||||
|
static uint32_t dbgN = 0;
|
||||||
|
static int dbgMaxShown = 0;
|
||||||
|
// VST3: host buffers must be zeroed (silence) before process — plugins
|
||||||
|
// that skip output leave garbage otherwise (EZkeys 2 -> huge noise).
|
||||||
|
if (s->processData.numOutputs > 0) {
|
||||||
|
for (int32 b = 0; b < s->processData.numOutputs; ++b) {
|
||||||
|
const Steinberg::Vst::AudioBusBuffers& ob = s->processData.outputs[b];
|
||||||
|
for (int32 c = 0; c < ob.numChannels; ++c)
|
||||||
|
if (ob.channelBuffers32[c])
|
||||||
|
std::memset(ob.channelBuffers32[c], 0, numSamples * sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tresult pr = processor->process(s->processData);
|
||||||
|
float mx = 0.f;
|
||||||
|
if (pr >= 0 && s->processData.numOutputs > 0) {
|
||||||
|
const Steinberg::Vst::AudioBusBuffers& out = s->processData.outputs[0];
|
||||||
|
const float* buf0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||||
|
const float* buf1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
|
||||||
|
if (buf0) std::memcpy(outputL, buf0, numSamples * sizeof(float));
|
||||||
|
else std::memset(outputL, 0, numSamples * sizeof(float));
|
||||||
|
if (buf1) std::memcpy(outputR, buf1, numSamples * sizeof(float));
|
||||||
|
else if (buf0) std::memcpy(outputR, buf0, numSamples * sizeof(float)); // mono → stereo
|
||||||
|
else std::memset(outputR, 0, numSamples * sizeof(float));
|
||||||
|
for (uint32_t i = 0; i < numSamples; ++i) {
|
||||||
|
float v = outputL[i] < 0 ? -outputL[i] : outputL[i];
|
||||||
|
if (v > mx) mx = v;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::memset(outputL, 0, numSamples * sizeof(float));
|
||||||
|
std::memset(outputR, 0, numSamples * sizeof(float));
|
||||||
|
}
|
||||||
|
uint32_t evc = s->eventList.getEventCount();
|
||||||
|
int evt = -1;
|
||||||
|
if (evc > 0) {
|
||||||
|
const Event* e0 = s->eventList.getEventByIndex(0);
|
||||||
|
evt = e0 ? (int)e0->type : -2;
|
||||||
|
}
|
||||||
|
if ((++dbgN % 250) == 0 || (evc > 0 && dbgMaxShown < 30)) {
|
||||||
|
if (evc > 0) ++dbgMaxShown;
|
||||||
|
std::cerr << "[dbg] n=" << numSamples << " ev=" << evc << " evt=" << evt
|
||||||
|
<< " pr=" << (int)pr << " nOut=" << s->processData.numOutputs
|
||||||
|
<< " mx=" << mx << " ts=" << s->processContext.projectTimeSamples
|
||||||
|
<< " nch=" << (s->processData.numOutputs > 0 ? s->processData.outputs[0].numChannels : -1) << std::endl;
|
||||||
|
}
|
||||||
|
s->eventList.clear();
|
||||||
|
s->paramChanges.clearQueue();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// native_bridge/src/gui_probe.cpp — standalone VST3 GUI attach probe.
|
||||||
|
// Debug tool (NOT shipped with the DAW): loads a VST3 with Vst3Instrument and
|
||||||
|
// tests openGUI under different thread/pump/parent-window setups so we can
|
||||||
|
// find why view->attached() hangs inside the bridge.
|
||||||
|
#include "Vst3Instrument.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
static void pump_for(int secs) {
|
||||||
|
auto t0 = std::chrono::steady_clock::now();
|
||||||
|
while (std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
std::chrono::steady_clock::now() - t0).count() < secs) {
|
||||||
|
MSG msg;
|
||||||
|
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
Sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_for(int secs) { Sleep((DWORD)(secs * 1000)); }
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc < 2) {
|
||||||
|
printf("usage: gui_probe <plugin.vst3> [variant] [secs] [hwnd]\n"
|
||||||
|
"variants: main_own | worker_own_nopump | worker_own_pump | worker_foreign_nopump | worker_foreign_pump | bridge_like | same_thread\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
std::string path = argv[1];
|
||||||
|
std::string variant = argc > 2 ? argv[2] : "main_own";
|
||||||
|
int secs = argc > 3 ? atoi(argv[3]) : 20;
|
||||||
|
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
|
||||||
|
// Watchdog: never let a stuck plugin hang this probe forever.
|
||||||
|
std::thread([secs]() {
|
||||||
|
Sleep((DWORD)(secs + 10) * 1000);
|
||||||
|
printf("WATCHDOG-KILL\n");
|
||||||
|
fflush(stdout);
|
||||||
|
TerminateProcess(GetCurrentProcess(), 0);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
HWND ownHwnd = 0;
|
||||||
|
{
|
||||||
|
WNDCLASSEX wc = {};
|
||||||
|
wc.cbSize = sizeof(wc);
|
||||||
|
wc.lpfnWndProc = DefWindowProc;
|
||||||
|
wc.hInstance = GetModuleHandle(nullptr);
|
||||||
|
wc.lpszClassName = "GuiProbeClass";
|
||||||
|
RegisterClassEx(&wc);
|
||||||
|
ownHwnd = CreateWindowEx(0, "GuiProbeClass", "GuiProbe", WS_OVERLAPPEDWINDOW,
|
||||||
|
0, 0, 800, 600, nullptr, nullptr, wc.hInstance, nullptr);
|
||||||
|
printf("ownHwnd=%p\n", (void*)ownHwnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
void* targetHwnd = (void*)ownHwnd;
|
||||||
|
if (argc > 4) targetHwnd = (void*)(uintptr_t)strtoull(argv[4], nullptr, 0);
|
||||||
|
|
||||||
|
printf("loading %s\n", path.c_str());
|
||||||
|
fflush(stdout);
|
||||||
|
Vst3Instrument inst;
|
||||||
|
bool isDeferred = (variant == "bridge_like" || variant == "same_thread");
|
||||||
|
bool ok = true;
|
||||||
|
if (!isDeferred) {
|
||||||
|
ok = inst.loadPlugin(path, 44100.0);
|
||||||
|
printf("load=%d\n", ok ? 1 : 0);
|
||||||
|
fflush(stdout);
|
||||||
|
if (!ok) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::atomic<bool> done{false};
|
||||||
|
std::atomic<bool> result{false};
|
||||||
|
|
||||||
|
auto attach_job = [&]() {
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
printf("[%s] openGUI start hwnd=%p\n", variant.c_str(), targetHwnd);
|
||||||
|
fflush(stdout);
|
||||||
|
bool r = inst.openGUI(targetHwnd);
|
||||||
|
printf("[%s] openGUI returned=%d\n", variant.c_str(), r ? 1 : 0);
|
||||||
|
fflush(stdout);
|
||||||
|
result = r;
|
||||||
|
done = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant == "main_own") {
|
||||||
|
attach_job(); // editorhost pattern: attached on main thread, pump after
|
||||||
|
pump_for(secs);
|
||||||
|
} else if (variant == "worker_own_nopump" || variant == "worker_foreign_nopump") {
|
||||||
|
std::thread t(attach_job);
|
||||||
|
sleep_for(secs);
|
||||||
|
if (!done.load()) { printf("RESULT: TIMEOUT (no pump)\n"); return 3; }
|
||||||
|
} else if (variant == "worker_own_pump" || variant == "worker_foreign_pump") {
|
||||||
|
std::thread t(attach_job);
|
||||||
|
pump_for(secs);
|
||||||
|
if (!done.load()) { printf("RESULT: TIMEOUT (pump concurrent)\n"); return 3; }
|
||||||
|
} else if (variant == "bridge_like") {
|
||||||
|
// Bridge pattern: plugin loaded on thread A which then EXITS (COM
|
||||||
|
// apartment dies), openGUI on separate thread B, main pumps.
|
||||||
|
std::thread loadA([&]() {
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
printf("[bridge_like] loadPlugin on thread A\n");
|
||||||
|
fflush(stdout);
|
||||||
|
bool r = inst.loadPlugin(path, 44100.0);
|
||||||
|
printf("[bridge_like] load=%d (thread A exiting)\n", r ? 1 : 0);
|
||||||
|
fflush(stdout);
|
||||||
|
CoUninitialize();
|
||||||
|
});
|
||||||
|
loadA.join();
|
||||||
|
std::thread t(attach_job); // thread B, after A exited
|
||||||
|
pump_for(secs);
|
||||||
|
if (!done.load()) { printf("RESULT: TIMEOUT (apartment dead)\n"); return 3; }
|
||||||
|
} else if (variant == "same_thread") {
|
||||||
|
// Load and openGUI on the SAME worker thread, kept alive; main pumps.
|
||||||
|
std::thread t([&]() {
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
printf("[same_thread] loadPlugin on worker\n");
|
||||||
|
fflush(stdout);
|
||||||
|
bool r = inst.loadPlugin(path, 44100.0);
|
||||||
|
printf("[same_thread] load=%d\n", r ? 1 : 0);
|
||||||
|
fflush(stdout);
|
||||||
|
if (r) attach_job();
|
||||||
|
});
|
||||||
|
pump_for(secs);
|
||||||
|
if (!done.load()) { printf("RESULT: TIMEOUT (same_thread)\n"); return 3; }
|
||||||
|
} else {
|
||||||
|
printf("unknown variant %s\n", variant.c_str());
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("RESULT: done=%d attached_ok=%d\n", done.load() ? 1 : 0, result.load() ? 1 : 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// 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>
|
||||||
|
#include <thread>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <thread>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#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
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Native VST editor windows registry — global de WM_DESTROY (chay tren worker
|
||||||
|
// thread cua channel tao window) co the don map. USERDATA luu channel+1 (KHONG
|
||||||
|
// luu con tro inst truc tiep: assign() thay inst moi moi lan load — con tro cu
|
||||||
|
// bi huy → WM_DESTROY tren con tro dangling → crash/hang bridge).
|
||||||
|
static InstrumentEngineManager* g_engine = nullptr;
|
||||||
|
static std::mutex g_guiMutex;
|
||||||
|
static std::map<uint32_t, void*> g_guiWindows; // channel -> HWND (keep window alive)
|
||||||
|
static std::map<HWND, uint32_t> g_hwndToCh; // HWND -> channel (WM_DESTROY cleanup)
|
||||||
|
|
||||||
|
static LRESULT CALLBACK VstWindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
|
||||||
|
if (uMsg == WM_DESTROY) {
|
||||||
|
INativeInstrument* instToClose = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_guiMutex);
|
||||||
|
auto it = g_hwndToCh.find(hwnd);
|
||||||
|
if (it != g_hwndToCh.end()) {
|
||||||
|
uint32_t ch = it->second;
|
||||||
|
g_hwndToCh.erase(it);
|
||||||
|
g_guiWindows.erase(ch);
|
||||||
|
if (g_engine) instToClose = g_engine->get(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (instToClose) instToClose->closeGUI();
|
||||||
|
}
|
||||||
|
return DefWindowProcA(hwnd, uMsg, wParam, lParam);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// B9: native Win32 window for the VST editor (replaces the WebView2 surface —
|
||||||
|
// the HTML window was drawn ON TOP of the plugin GUI). MUST be created on the
|
||||||
|
// ChannelWorker thread so the worker's idle message pump services its messages.
|
||||||
|
static void* create_native_vst_window(const char* title) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
static const char* kWndClass = "SonicForge_Native_VST3_Class";
|
||||||
|
static bool registered = false;
|
||||||
|
if (!registered) {
|
||||||
|
WNDCLASSA wc = {};
|
||||||
|
wc.lpfnWndProc = VstWindowProc;
|
||||||
|
wc.hInstance = GetModuleHandleA(nullptr);
|
||||||
|
wc.lpszClassName = kWndClass;
|
||||||
|
RegisterClassA(&wc);
|
||||||
|
registered = true;
|
||||||
|
}
|
||||||
|
HWND hwnd = CreateWindowExA(0, kWndClass, title ? title : "VST",
|
||||||
|
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, 800, 600,
|
||||||
|
nullptr, nullptr, GetModuleHandleA(nullptr), nullptr);
|
||||||
|
return (void*)hwnd;
|
||||||
|
#else
|
||||||
|
(void)title;
|
||||||
|
return nullptr;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-channel persistent worker: ONE thread owns the COM STA apartment for that
|
||||||
|
// channel's instrument for its whole lifetime. loadPlugin and openGUI MUST run
|
||||||
|
// on the same thread — if the loading thread exits, its apartment dies and
|
||||||
|
// VST3 plugins that marshal internally (Nexus) hang forever in
|
||||||
|
// view->attached(). Verified with gui_probe: `bridge_like` (load thread exits,
|
||||||
|
// openGUI on another) hangs; `same_thread` (load+openGUI on one alive thread)
|
||||||
|
// returns attached=OK.
|
||||||
|
class ChannelWorker {
|
||||||
|
public:
|
||||||
|
ChannelWorker() {
|
||||||
|
th_ = std::thread([this] {
|
||||||
|
#ifdef _WIN32
|
||||||
|
OleInitialize(nullptr);
|
||||||
|
#endif
|
||||||
|
std::unique_lock<std::mutex> lk(mu_);
|
||||||
|
for (;;) {
|
||||||
|
if (stop_ && jobs_.empty()) break;
|
||||||
|
if (!jobs_.empty()) {
|
||||||
|
auto job = std::move(jobs_.front());
|
||||||
|
jobs_.pop_front();
|
||||||
|
lk.unlock();
|
||||||
|
job();
|
||||||
|
lk.lock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cv_.wait_for(lk, std::chrono::milliseconds(5));
|
||||||
|
// Pump THIS thread's message queue while idle — VST editor
|
||||||
|
// windows are created on this thread, their messages must be
|
||||||
|
// dispatched here or the editor freezes after attach.
|
||||||
|
lk.unlock();
|
||||||
|
MSG msg;
|
||||||
|
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
lk.lock();
|
||||||
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
OleUninitialize();
|
||||||
|
#endif
|
||||||
|
});
|
||||||
|
}
|
||||||
|
~ChannelWorker() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
stop_ = true;
|
||||||
|
}
|
||||||
|
cv_.notify_all();
|
||||||
|
if (th_.joinable()) th_.join();
|
||||||
|
}
|
||||||
|
void post(std::function<void()> job) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
jobs_.push_back(std::move(job));
|
||||||
|
}
|
||||||
|
cv_.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::thread th_;
|
||||||
|
std::mutex mu_;
|
||||||
|
std::condition_variable cv_;
|
||||||
|
std::deque<std::function<void()>> jobs_;
|
||||||
|
bool stop_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
#ifdef _WIN32
|
||||||
|
g_engine = &instruments;
|
||||||
|
#endif
|
||||||
|
// Per-channel persistent workers: loadPlugin + openGUI run on the SAME
|
||||||
|
// thread whose COM STA apartment stays alive for the channel's lifetime
|
||||||
|
// (see ChannelWorker comment — a dead apartment hangs Nexus attached()).
|
||||||
|
std::map<uint32_t, std::unique_ptr<ChannelWorker>> workers;
|
||||||
|
// B9: native editor windows per channel — keep alive (HWND outlives the job).
|
||||||
|
// Registry la global (g_guiWindows) — WM_DESTROY cleanup can tu VstWindowProc.
|
||||||
|
// 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:
|
||||||
|
// sampleOffset LUON LUON = 0 khi den day: events duoc dispatch ngay
|
||||||
|
// truoc segment chua no (A11 splitting), nen offset tuong doi la 0.
|
||||||
|
// Truyen offset tuyet doi truoc day lam sfizz/VST3 trigger tre.
|
||||||
|
if (evt.velocity > 0)
|
||||||
|
inst->noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f, 0);
|
||||||
|
else
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, 0);
|
||||||
|
break;
|
||||||
|
case 0x8:
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, 0);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
double blockDurationMs = (double)block / sampleRate * 1000.0;
|
||||||
|
auto startTime = std::chrono::steady_clock::now();
|
||||||
|
uint64_t blockCount = 0;
|
||||||
|
|
||||||
|
// 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP
|
||||||
|
while (true) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Message pump: VST editors (Nexus, JUCE-based...) block inside
|
||||||
|
// view->attached() until the host dispatches messages — openGUI runs
|
||||||
|
// on a worker thread, so THIS loop must pump concurrently (verified
|
||||||
|
// with gui_probe: worker-thread openGUI + concurrent pump → attached
|
||||||
|
// returns kResultOk; without it → hangs forever).
|
||||||
|
MSG msg;
|
||||||
|
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// 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).
|
||||||
|
// Chay tren persistent worker thread cua channel: VST3 init
|
||||||
|
// (loadPlugin) co the mat giay — chay dong bo tren audio loop
|
||||||
|
// lam writeIndex stall > 3s -> Rust tuong bridge chet va restart
|
||||||
|
// nham (2 bridge cung map SHM -> race control queue / double
|
||||||
|
// load). assign() chi giu mutex khi ghi map nen renderAll khong
|
||||||
|
// bao gio stall. openGUI sau nay chay tren CUNG thread nay
|
||||||
|
// (ChannelWorker) — thread khong bao gio exit nen COM STA
|
||||||
|
// apartment cua plugin con song (xem ChannelWorker comment).
|
||||||
|
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 (!workers[ch]) workers[ch] = std::make_unique<ChannelWorker>();
|
||||||
|
workers[ch]->post([&instruments, t, ch, path, sampleRate, block]() {
|
||||||
|
std::cerr << "[dbg] load thread start ch=" << ch
|
||||||
|
<< " type=" << (int)t << " path=" << path << std::endl;
|
||||||
|
bool ok = instruments.assign(ch, t, path, sampleRate, block);
|
||||||
|
std::cerr << "[dbg] assign returned ch=" << ch << " ok=" << (ok ? 1 : 0) << std::endl;
|
||||||
|
if (ok) {
|
||||||
|
std::cout << "[NativeBridge] instrument loaded ch=" << ch
|
||||||
|
<< " type=" << (int)t << " " << path << std::endl;
|
||||||
|
} else {
|
||||||
|
std::cerr << "[NativeBridge] instrument load FAILED ch=" << ch
|
||||||
|
<< " type=" << (int)t << " " << 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;
|
||||||
|
}
|
||||||
|
} else if (c.type == 4) { // OPEN_GUI (A7): arg1 = parent HWND (0 → bridge tự tạo native window), arg2 = plugin id
|
||||||
|
// Chay tren CUNG ChannelWorker da load instrument: thread rieng
|
||||||
|
// cho openGUI lai tao COM apartment moi, con plugin thi song o
|
||||||
|
// apartment cu da chet (load thread exit) -> Nexus attached()
|
||||||
|
// hang (gui_probe: bridge_like treo, same_thread OK).
|
||||||
|
uint32_t guiCh = c.channel;
|
||||||
|
if (guiCh >= 16) guiCh = 0;
|
||||||
|
// Bo poll 10s tren real-time loop (writeIndex stall > 3s -> Rust
|
||||||
|
// tuong bridge chet va restart -> 2 bridge cung map SHM -> race).
|
||||||
|
// LOAD (type=2) post truoc OPEN_GUI tren CUNG ChannelWorker (FIFO)
|
||||||
|
// -> job openGUI chay sau khi LOAD xong -> kiem tra inst trong job.
|
||||||
|
if (!workers[guiCh]) workers[guiCh] = std::make_unique<ChannelWorker>();
|
||||||
|
workers[guiCh]->post([&instruments, guiCh, arg1 = c.arg1, arg2 = std::string(c.arg2)]() {
|
||||||
|
if (!instruments.get(guiCh)) {
|
||||||
|
std::cerr << "[NativeBridge] GUI attach FAILED hwnd=" << arg1
|
||||||
|
<< " plugin=" << arg2 << " ch=" << guiCh << " (no instrument loaded)" << std::endl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::cerr << "[dbg] openGUI thread start hwnd=" << arg1
|
||||||
|
<< " plugin=" << arg2 << " ch=" << guiCh << std::endl;
|
||||||
|
void* hwnd = (void*)(uintptr_t)arg1;
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (arg1 == 0) {
|
||||||
|
HWND existingHwnd = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_guiMutex);
|
||||||
|
auto it = g_guiWindows.find(guiCh);
|
||||||
|
if (it != g_guiWindows.end()) existingHwnd = (HWND)it->second;
|
||||||
|
}
|
||||||
|
if (existingHwnd && IsWindow(existingHwnd)) {
|
||||||
|
hwnd = existingHwnd;
|
||||||
|
SetWindowTextA((HWND)hwnd, arg2.c_str());
|
||||||
|
ShowWindow((HWND)hwnd, SW_SHOW);
|
||||||
|
SetForegroundWindow((HWND)hwnd);
|
||||||
|
// Reuse: cap nhat USERDATA (channel+1) — inst CU
|
||||||
|
// da bi thay the boi assign() -> WM_DESTROY sau
|
||||||
|
// nay lookup inst MOI, khong dung con tro dangling.
|
||||||
|
SetWindowLongPtrA((HWND)hwnd, GWLP_USERDATA, (LONG_PTR)(guiCh + 1));
|
||||||
|
} else {
|
||||||
|
hwnd = create_native_vst_window(arg2.c_str());
|
||||||
|
if (!hwnd) {
|
||||||
|
std::cerr << "[NativeBridge] GUI create window FAILED plugin=" << arg2 << std::endl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_guiMutex);
|
||||||
|
g_guiWindows[guiCh] = hwnd; // keep window alive
|
||||||
|
g_hwndToCh[(HWND)hwnd] = guiCh; // WM_DESTROY cleanup
|
||||||
|
}
|
||||||
|
SetWindowLongPtrA((HWND)hwnd, GWLP_USERDATA, (LONG_PTR)(guiCh + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
(void)0;
|
||||||
|
#endif
|
||||||
|
if (auto* inst = instruments.get(guiCh)) {
|
||||||
|
if (inst->openGUI(hwnd))
|
||||||
|
std::cout << "[NativeBridge] GUI attached hwnd=" << hwnd
|
||||||
|
<< " plugin=" << arg2 << " ch=" << guiCh << std::endl;
|
||||||
|
else
|
||||||
|
std::cerr << "[NativeBridge] GUI attach FAILED hwnd=" << hwnd
|
||||||
|
<< " plugin=" << arg2 << std::endl;
|
||||||
|
}
|
||||||
|
// Editor windows song tren thread nay — ChannelWorker
|
||||||
|
// pump message queue khi idle (xem class comment).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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. Synchronize with real-time audio playback
|
||||||
|
blockCount++;
|
||||||
|
auto targetTime = startTime + std::chrono::microseconds(static_cast<int64_t>(blockCount * blockDurationMs * 1000.0));
|
||||||
|
auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now < targetTime) {
|
||||||
|
auto diff = std::chrono::duration_cast<std::chrono::microseconds>(targetTime - now).count();
|
||||||
|
if (diff > 1000) {
|
||||||
|
sleep_ms(diff / 1000);
|
||||||
|
}
|
||||||
|
while (std::chrono::steady_clock::now() < targetTime) {
|
||||||
|
std::this_thread::yield();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
UnmapViewOfFile(shmIPC);
|
||||||
|
CloseHandle(hMapFile);
|
||||||
|
timeEndPeriod(1);
|
||||||
|
#else
|
||||||
|
std::free(shmIPC);
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# G2 end-to-end smoke: DAW side creates SHM (mirrors Rust shm.rs), spawns the
|
||||||
|
# real bridge, pushes LOAD_INSTRUMENT + MIDI note, asserts non-zero audio.
|
||||||
|
# Usage: python tests/g2_bridge_smoke.py
|
||||||
|
import ctypes, os, struct, subprocess, sys, time
|
||||||
|
|
||||||
|
BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe"))
|
||||||
|
SF2 = r"C:\Users\locpham\SonicForgeStudio\app\storage\soundfonts\518e850f-a5d3-4790-b1f9-0c90c203c524.sf2"
|
||||||
|
SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz"))
|
||||||
|
SHM_NAME = "SonicForge_DAW_IPC"
|
||||||
|
SIZE = 11160
|
||||||
|
|
||||||
|
kern = ctypes.windll.kernel32
|
||||||
|
PAGE_READWRITE, FILE_MAP_ALL_ACCESS = 0x04, 0xF001F
|
||||||
|
kern.CreateFileMappingW.restype = ctypes.c_void_p
|
||||||
|
kern.CreateFileMappingW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p]
|
||||||
|
kern.MapViewOfFile.restype = ctypes.c_void_p
|
||||||
|
kern.MapViewOfFile.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_size_t]
|
||||||
|
kern.UnmapViewOfFile.restype = ctypes.c_int
|
||||||
|
kern.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
||||||
|
kern.CloseHandle.restype = ctypes.c_int
|
||||||
|
kern.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
h = kern.CreateFileMappingW(None, None, PAGE_READWRITE, 0, SIZE, SHM_NAME)
|
||||||
|
if not h:
|
||||||
|
sys.exit("CreateFileMappingW failed")
|
||||||
|
v = kern.MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0)
|
||||||
|
if not v:
|
||||||
|
sys.exit("MapViewOfFile failed")
|
||||||
|
buf = (ctypes.c_ubyte * SIZE).from_address(v)
|
||||||
|
|
||||||
|
def w32(o, x): struct.pack_into("<I", buf, o, x)
|
||||||
|
def set_str(o, s, cap):
|
||||||
|
b = s.encode("utf-8")[:cap - 1]
|
||||||
|
for i, x in enumerate(b): buf[o + i] = x
|
||||||
|
for i in range(len(b), cap): buf[o + i] = 0
|
||||||
|
|
||||||
|
ML, MR = 8, 1032
|
||||||
|
MIDI_Q, MIDI_Q_COUNT = 2064, 2832
|
||||||
|
CTRL_Q, CTRL_Q_COUNT = 2836, 11156
|
||||||
|
|
||||||
|
def peak():
|
||||||
|
p = 0.0
|
||||||
|
for i in range(256):
|
||||||
|
l = struct.unpack_from("<f", buf, ML + i * 4)[0]
|
||||||
|
r = struct.unpack_from("<f", buf, MR + i * 4)[0]
|
||||||
|
p = max(p, abs(l), abs(r))
|
||||||
|
return p
|
||||||
|
|
||||||
|
def note_on(ch, pitch, vel):
|
||||||
|
w32(MIDI_Q_COUNT, 0)
|
||||||
|
base = MIDI_Q
|
||||||
|
buf[base] = 0x9; buf[base+1] = ch; buf[base+2] = pitch; buf[base+3] = vel
|
||||||
|
buf[base+4] = 0; buf[base+5] = 0; buf[base+6] = 0; buf[base+7] = 0
|
||||||
|
struct.pack_into("<I", buf, base + 8, 0)
|
||||||
|
w32(MIDI_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def poll_peak(seconds=1.5):
|
||||||
|
# SFZ one-shots (g2_test.sfz) decay to silence in ~200ms; poll fast and
|
||||||
|
# keep the max so the audible window is not missed by a sparse sleep.
|
||||||
|
mx = 0.0
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < seconds:
|
||||||
|
p = peak()
|
||||||
|
if p > mx: mx = p
|
||||||
|
time.sleep(0.01)
|
||||||
|
return mx
|
||||||
|
|
||||||
|
def load_instrument(itype, path, ch=0):
|
||||||
|
w32(CTRL_Q_COUNT, 0)
|
||||||
|
set_str(CTRL_Q + 16, path, 1024)
|
||||||
|
w32(CTRL_Q + 0, 2); w32(CTRL_Q + 4, itype); w32(CTRL_Q + 8, 0); w32(CTRL_Q + 12, ch)
|
||||||
|
w32(CTRL_Q_COUNT, 1)
|
||||||
|
|
||||||
|
env = dict(os.environ, SF_PARENT_PID=str(os.getpid()), SF_SAMPLE_RATE="44100")
|
||||||
|
logpath = os.path.join(os.path.dirname(BRIDGE), "g2_bridge_stdout.txt")
|
||||||
|
logf = open(logpath, "w", encoding="utf-8")
|
||||||
|
proc = subprocess.Popen([BRIDGE], env=env, cwd=os.path.dirname(BRIDGE),
|
||||||
|
stdout=logf, stderr=subprocess.STDOUT, text=True)
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
# SF2: load, note, expect non-silent audio
|
||||||
|
load_instrument(2, SF2)
|
||||||
|
time.sleep(0.3)
|
||||||
|
note_on(0, 60, 100)
|
||||||
|
p = poll_peak()
|
||||||
|
ok = p > 0.001
|
||||||
|
results.append(("SF2 note audio", ok, "peak=%.5f" % p))
|
||||||
|
print("SF2 PEAK=%.5f" % p)
|
||||||
|
if not ok:
|
||||||
|
logf.flush()
|
||||||
|
print("bridge out:", open(logpath, encoding="utf-8").read()[-1500:])
|
||||||
|
|
||||||
|
# SFZ on channel 1
|
||||||
|
load_instrument(3, SFZ, ch=1)
|
||||||
|
time.sleep(0.5)
|
||||||
|
note_on(1, 60, 100)
|
||||||
|
p2 = poll_peak()
|
||||||
|
ok2 = p2 > 0.001
|
||||||
|
results.append(("SFZ note audio", ok2, "peak=%.5f" % p2))
|
||||||
|
print("SFZ PEAK=%.5f" % p2)
|
||||||
|
if not ok2:
|
||||||
|
logf.flush()
|
||||||
|
print("bridge out:", open(logpath, encoding="utf-8").read()[-1500:])
|
||||||
|
|
||||||
|
# VST3 (native hosting, no Carla): mda-vst3 bundle from SDK build
|
||||||
|
VST3 = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "VST3", "Release", "mda-vst3.vst3"))
|
||||||
|
load_instrument(0, VST3, ch=2)
|
||||||
|
# wait until bridge reports the load (poll log), up to 10s
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
logf.flush()
|
||||||
|
if "instrument loaded ch=2" in open(logpath, encoding="utf-8").read():
|
||||||
|
break
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print("BRIDGE DIED rc=", proc.poll())
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
note_on(2, 60, 100)
|
||||||
|
p3 = poll_peak(2.0)
|
||||||
|
ok3 = p3 > 0.001
|
||||||
|
results.append(("VST3 note audio", ok3, "peak=%.5f" % p3))
|
||||||
|
print("VST3 PEAK=%.5f" % p3)
|
||||||
|
logf.flush()
|
||||||
|
print("bridge out tail:", open(logpath, encoding="utf-8").read()[-2500:])
|
||||||
|
if not ok3:
|
||||||
|
print("bridge out:", open(logpath, encoding="utf-8").read()[-2500:])
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
logf.close()
|
||||||
|
kern.UnmapViewOfFile(v)
|
||||||
|
kern.CloseHandle(h)
|
||||||
|
|
||||||
|
failed = [r for r in results if not r[1]]
|
||||||
|
print("G2 RESULT:", "FAIL" if failed else "PASS")
|
||||||
|
for r in results: print(" %-20s %s %s" % (r[0], "PASS" if r[1] else "FAIL", r[2]))
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<region> sample=dummy1.wav key=60 lokey=0 hikey=127
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Sequence matrix: which load order crashes?
|
||||||
|
# usage: repro_seq.py <seq> e.g. "sfz,vst3" "sf2,vst3" "vst3,sfz" "sf2,sfz,vst3"
|
||||||
|
import ctypes, os, struct, subprocess, sys, time
|
||||||
|
|
||||||
|
BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe"))
|
||||||
|
SF2 = r"C:\Users\locpham\SonicForgeStudio\app\storage\soundfonts\518e850f-a5d3-4790-b1f9-0c90c203c524.sf2"
|
||||||
|
SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz"))
|
||||||
|
VST3 = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "VST3", "Release", "mda-vst3.vst3"))
|
||||||
|
SHM_NAME = "SonicForge_DAW_IPC"
|
||||||
|
SIZE = 11160
|
||||||
|
CTRL_Q, CTRL_Q_COUNT = 2836, 11156
|
||||||
|
|
||||||
|
kern = ctypes.windll.kernel32
|
||||||
|
PAGE_READWRITE, FILE_MAP_ALL_ACCESS = 0x04, 0xF001F
|
||||||
|
kern.CreateFileMappingW.restype = ctypes.c_void_p
|
||||||
|
kern.CreateFileMappingW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p]
|
||||||
|
kern.MapViewOfFile.restype = ctypes.c_void_p
|
||||||
|
kern.MapViewOfFile.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_size_t]
|
||||||
|
kern.UnmapViewOfFile.restype = ctypes.c_int
|
||||||
|
kern.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
||||||
|
kern.CloseHandle.restype = ctypes.c_int
|
||||||
|
kern.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
h = kern.CreateFileMappingW(None, None, PAGE_READWRITE, 0, SIZE, SHM_NAME)
|
||||||
|
if not h: sys.exit("CreateFileMappingW failed")
|
||||||
|
v = kern.MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0)
|
||||||
|
if not v: sys.exit("MapViewOfFile failed")
|
||||||
|
buf = (ctypes.c_ubyte * SIZE).from_address(v)
|
||||||
|
|
||||||
|
def w32(o, x): struct.pack_into("<I", buf, o, x)
|
||||||
|
def set_str(o, s, cap):
|
||||||
|
b = s.encode("utf-8")[:cap - 1]
|
||||||
|
for i, x in enumerate(b): buf[o + i] = x
|
||||||
|
for i in range(len(b), cap): buf[o + i] = 0
|
||||||
|
|
||||||
|
def load_instrument(itype, path, ch=0):
|
||||||
|
w32(CTRL_Q_COUNT, 0)
|
||||||
|
set_str(CTRL_Q + 16, path, 1024)
|
||||||
|
w32(CTRL_Q + 0, 2); w32(CTRL_Q + 4, itype); w32(CTRL_Q + 8, 0); w32(CTRL_Q + 12, ch)
|
||||||
|
w32(CTRL_Q_COUNT, 1)
|
||||||
|
|
||||||
|
seq = sys.argv[1] if len(sys.argv) > 1 else "sfz,vst3"
|
||||||
|
env = dict(os.environ, SF_PARENT_PID=str(os.getpid()), SF_SAMPLE_RATE="44100")
|
||||||
|
logpath = os.path.join(os.path.dirname(BRIDGE), "seq_%s.txt" % seq.replace(",", "_"))
|
||||||
|
logf = open(logpath, "w", encoding="utf-8")
|
||||||
|
proc = subprocess.Popen([BRIDGE], env=env, cwd=os.path.dirname(BRIDGE),
|
||||||
|
stdout=logf, stderr=subprocess.STDOUT, text=True)
|
||||||
|
try:
|
||||||
|
for i, name in enumerate(seq.split(",")):
|
||||||
|
if name == "sf2": load_instrument(2, SF2, ch=0)
|
||||||
|
elif name == "sfz": load_instrument(3, SFZ, ch=1)
|
||||||
|
elif name == "vst3": load_instrument(0, VST3, ch=2)
|
||||||
|
time.sleep(0.6)
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print("DIED after %s rc=%s" % (name, proc.poll()))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
time.sleep(2)
|
||||||
|
print("ALIVE after %s rc=%s" % (seq, proc.poll()))
|
||||||
|
logf.flush()
|
||||||
|
print("log:")
|
||||||
|
print(open(logpath, encoding="utf-8").read()[:1500])
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
logf.close()
|
||||||
|
kern.UnmapViewOfFile(v)
|
||||||
|
kern.CloseHandle(h)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Reproduce SFZ-only crash. Load SFZ ch=1, no notes, poll every 0.2s.
|
||||||
|
import ctypes, os, struct, subprocess, sys, time
|
||||||
|
|
||||||
|
BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe"))
|
||||||
|
SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz"))
|
||||||
|
SHM_NAME = "SonicForge_DAW_IPC"
|
||||||
|
SIZE = 11160
|
||||||
|
ML, MR = 8, 1032
|
||||||
|
MIDI_Q, MIDI_Q_COUNT = 2064, 2832
|
||||||
|
CTRL_Q, CTRL_Q_COUNT = 2836, 11156
|
||||||
|
|
||||||
|
kern = ctypes.windll.kernel32
|
||||||
|
PAGE_READWRITE, FILE_MAP_ALL_ACCESS = 0x04, 0xF001F
|
||||||
|
kern.CreateFileMappingW.restype = ctypes.c_void_p
|
||||||
|
kern.CreateFileMappingW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p]
|
||||||
|
kern.MapViewOfFile.restype = ctypes.c_void_p
|
||||||
|
kern.MapViewOfFile.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_size_t]
|
||||||
|
kern.UnmapViewOfFile.restype = ctypes.c_int
|
||||||
|
kern.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
||||||
|
kern.CloseHandle.restype = ctypes.c_int
|
||||||
|
kern.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
h = kern.CreateFileMappingW(None, None, PAGE_READWRITE, 0, SIZE, SHM_NAME)
|
||||||
|
if not h: sys.exit("CreateFileMappingW failed")
|
||||||
|
v = kern.MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0)
|
||||||
|
if not v: sys.exit("MapViewOfFile failed")
|
||||||
|
buf = (ctypes.c_ubyte * SIZE).from_address(v)
|
||||||
|
|
||||||
|
def w32(o, x): struct.pack_into("<I", buf, o, x)
|
||||||
|
def set_str(o, s, cap):
|
||||||
|
b = s.encode("utf-8")[:cap - 1]
|
||||||
|
for i, x in enumerate(b): buf[o + i] = x
|
||||||
|
for i in range(len(b), cap): buf[o + i] = 0
|
||||||
|
|
||||||
|
def note_on(ch, pitch, vel):
|
||||||
|
w32(MIDI_Q_COUNT, 0)
|
||||||
|
base = MIDI_Q
|
||||||
|
buf[base] = 0x9; buf[base+1] = ch; buf[base+2] = pitch; buf[base+3] = vel
|
||||||
|
buf[base+4] = 0; buf[base+5] = 0; buf[base+6] = 0; buf[base+7] = 0
|
||||||
|
struct.pack_into("<I", buf, base + 8, 0)
|
||||||
|
w32(MIDI_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def load_instrument(itype, path, ch=0):
|
||||||
|
w32(CTRL_Q_COUNT, 0)
|
||||||
|
set_str(CTRL_Q + 16, path, 1024)
|
||||||
|
w32(CTRL_Q + 0, 2); w32(CTRL_Q + 4, itype); w32(CTRL_Q + 8, 0); w32(CTRL_Q + 12, ch)
|
||||||
|
w32(CTRL_Q_COUNT, 1)
|
||||||
|
|
||||||
|
env = dict(os.environ, SF_PARENT_PID=str(os.getpid()), SF_SAMPLE_RATE="44100")
|
||||||
|
logpath = os.path.join(os.path.dirname(BRIDGE), "repro_sfz.txt")
|
||||||
|
logf = open(logpath, "w", encoding="utf-8")
|
||||||
|
proc = subprocess.Popen([BRIDGE], env=env, cwd=os.path.dirname(BRIDGE),
|
||||||
|
stdout=logf, stderr=subprocess.STDOUT, text=True)
|
||||||
|
try:
|
||||||
|
load_instrument(3, SFZ, ch=1)
|
||||||
|
t0 = time.time()
|
||||||
|
last_line = ""
|
||||||
|
while time.time() - t0 < 8:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print("DIED after %.2fs rc=%s" % (time.time() - t0, proc.poll()))
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
print("ALIVE after 8s")
|
||||||
|
logf.flush()
|
||||||
|
print("log:", open(logpath, encoding="utf-8").read())
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
logf.close()
|
||||||
|
kern.UnmapViewOfFile(v)
|
||||||
|
kern.CloseHandle(h)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SFZ: load ch=1, note_on, poll peak for 3s — see if any audio comes out.
|
||||||
|
import ctypes, os, struct, subprocess, sys, time
|
||||||
|
|
||||||
|
BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe"))
|
||||||
|
SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz"))
|
||||||
|
SHM_NAME = "SonicForge_DAW_IPC"
|
||||||
|
SIZE = 11160
|
||||||
|
ML, MR = 8, 1032
|
||||||
|
MIDI_Q, MIDI_Q_COUNT = 2064, 2832
|
||||||
|
CTRL_Q, CTRL_Q_COUNT = 2836, 11156
|
||||||
|
|
||||||
|
kern = ctypes.windll.kernel32
|
||||||
|
PAGE_READWRITE, FILE_MAP_ALL_ACCESS = 0x04, 0xF001F
|
||||||
|
kern.CreateFileMappingW.restype = ctypes.c_void_p
|
||||||
|
kern.CreateFileMappingW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p]
|
||||||
|
kern.MapViewOfFile.restype = ctypes.c_void_p
|
||||||
|
kern.MapViewOfFile.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_size_t]
|
||||||
|
kern.UnmapViewOfFile.restype = ctypes.c_int
|
||||||
|
kern.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
||||||
|
kern.CloseHandle.restype = ctypes.c_int
|
||||||
|
kern.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
h = kern.CreateFileMappingW(None, None, PAGE_READWRITE, 0, SIZE, SHM_NAME)
|
||||||
|
if not h: sys.exit("CreateFileMappingW failed")
|
||||||
|
v = kern.MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0)
|
||||||
|
if not v: sys.exit("MapViewOfFile failed")
|
||||||
|
buf = (ctypes.c_ubyte * SIZE).from_address(v)
|
||||||
|
|
||||||
|
def w32(o, x): struct.pack_into("<I", buf, o, x)
|
||||||
|
def set_str(o, s, cap):
|
||||||
|
b = s.encode("utf-8")[:cap - 1]
|
||||||
|
for i, x in enumerate(b): buf[o + i] = x
|
||||||
|
for i in range(len(b), cap): buf[o + i] = 0
|
||||||
|
|
||||||
|
def note_on(ch, pitch, vel):
|
||||||
|
w32(MIDI_Q_COUNT, 0)
|
||||||
|
base = MIDI_Q
|
||||||
|
buf[base] = 0x9; buf[base+1] = ch; buf[base+2] = pitch; buf[base+3] = vel
|
||||||
|
buf[base+4] = 0; buf[base+5] = 0; buf[base+6] = 0; buf[base+7] = 0
|
||||||
|
struct.pack_into("<I", buf, base + 8, 0)
|
||||||
|
w32(MIDI_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def load_instrument(itype, path, ch=0):
|
||||||
|
w32(CTRL_Q_COUNT, 0)
|
||||||
|
set_str(CTRL_Q + 16, path, 1024)
|
||||||
|
w32(CTRL_Q + 0, 2); w32(CTRL_Q + 4, itype); w32(CTRL_Q + 8, 0); w32(CTRL_Q + 12, ch)
|
||||||
|
w32(CTRL_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def peak():
|
||||||
|
p = 0.0
|
||||||
|
for i in range(256):
|
||||||
|
l = struct.unpack_from("<f", buf, ML + i * 4)[0]
|
||||||
|
r = struct.unpack_from("<f", buf, MR + i * 4)[0]
|
||||||
|
p = max(p, abs(l), abs(r))
|
||||||
|
return p
|
||||||
|
|
||||||
|
env = dict(os.environ, SF_PARENT_PID=str(os.getpid()), SF_SAMPLE_RATE="44100")
|
||||||
|
logpath = os.path.join(os.path.dirname(BRIDGE), "repro_sfz_note.txt")
|
||||||
|
logf = open(logpath, "w", encoding="utf-8")
|
||||||
|
proc = subprocess.Popen([BRIDGE], env=env, cwd=os.path.dirname(BRIDGE),
|
||||||
|
stdout=logf, stderr=subprocess.STDOUT, text=True)
|
||||||
|
try:
|
||||||
|
load_instrument(3, SFZ, ch=1)
|
||||||
|
time.sleep(0.5)
|
||||||
|
note_on(1, 60, 100)
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 3:
|
||||||
|
p = peak()
|
||||||
|
print("t=%.2f peak=%.5f alive=%s" % (time.time() - t0, p, proc.poll() is None))
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print("DIED rc=%s" % proc.poll())
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
logf.flush()
|
||||||
|
print("log:", open(logpath, encoding="utf-8").read())
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
logf.close()
|
||||||
|
kern.UnmapViewOfFile(v)
|
||||||
|
kern.CloseHandle(h)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SFZ -> VST3 sequential: does VST3 load after SFZ? dump log periodically.
|
||||||
|
import ctypes, os, struct, subprocess, sys, time
|
||||||
|
|
||||||
|
BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe"))
|
||||||
|
SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz"))
|
||||||
|
VST3 = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "VST3", "Release", "mda-vst3.vst3"))
|
||||||
|
SHM_NAME = "SonicForge_DAW_IPC"
|
||||||
|
SIZE = 11160
|
||||||
|
ML, MR = 8, 1032
|
||||||
|
MIDI_Q, MIDI_Q_COUNT = 2064, 2832
|
||||||
|
CTRL_Q, CTRL_Q_COUNT = 2836, 11156
|
||||||
|
|
||||||
|
kern = ctypes.windll.kernel32
|
||||||
|
PAGE_READWRITE, FILE_MAP_ALL_ACCESS = 0x04, 0xF001F
|
||||||
|
kern.CreateFileMappingW.restype = ctypes.c_void_p
|
||||||
|
kern.CreateFileMappingW.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p]
|
||||||
|
kern.MapViewOfFile.restype = ctypes.c_void_p
|
||||||
|
kern.MapViewOfFile.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_size_t]
|
||||||
|
kern.UnmapViewOfFile.restype = ctypes.c_int
|
||||||
|
kern.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
||||||
|
kern.CloseHandle.restype = ctypes.c_int
|
||||||
|
kern.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||||
|
h = kern.CreateFileMappingW(None, None, PAGE_READWRITE, 0, SIZE, SHM_NAME)
|
||||||
|
if not h: sys.exit("CreateFileMappingW failed")
|
||||||
|
v = kern.MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, 0)
|
||||||
|
if not v: sys.exit("MapViewOfFile failed")
|
||||||
|
buf = (ctypes.c_ubyte * SIZE).from_address(v)
|
||||||
|
|
||||||
|
def w32(o, x): struct.pack_into("<I", buf, o, x)
|
||||||
|
def set_str(o, s, cap):
|
||||||
|
b = s.encode("utf-8")[:cap - 1]
|
||||||
|
for i, x in enumerate(b): buf[o + i] = x
|
||||||
|
for i in range(len(b), cap): buf[o + i] = 0
|
||||||
|
|
||||||
|
def note_on(ch, pitch, vel):
|
||||||
|
w32(MIDI_Q_COUNT, 0)
|
||||||
|
base = MIDI_Q
|
||||||
|
buf[base] = 0x9; buf[base+1] = ch; buf[base+2] = pitch; buf[base+3] = vel
|
||||||
|
buf[base+4] = 0; buf[base+5] = 0; buf[base+6] = 0; buf[base+7] = 0
|
||||||
|
struct.pack_into("<I", buf, base + 8, 0)
|
||||||
|
w32(MIDI_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def load_instrument(itype, path, ch=0):
|
||||||
|
w32(CTRL_Q_COUNT, 0)
|
||||||
|
set_str(CTRL_Q + 16, path, 1024)
|
||||||
|
w32(CTRL_Q + 0, 2); w32(CTRL_Q + 4, itype); w32(CTRL_Q + 8, 0); w32(CTRL_Q + 12, ch)
|
||||||
|
w32(CTRL_Q_COUNT, 1)
|
||||||
|
|
||||||
|
def peak():
|
||||||
|
p = 0.0
|
||||||
|
for i in range(256):
|
||||||
|
l = struct.unpack_from("<f", buf, ML + i * 4)[0]
|
||||||
|
r = struct.unpack_from("<f", buf, MR + i * 4)[0]
|
||||||
|
p = max(p, abs(l), abs(r))
|
||||||
|
return p
|
||||||
|
|
||||||
|
env = dict(os.environ, SF_PARENT_PID=str(os.getpid()), SF_SAMPLE_RATE="44100")
|
||||||
|
logpath = os.path.join(os.path.dirname(BRIDGE), "sfz_then_vst3_v2.txt")
|
||||||
|
logf = open(logpath, "w", encoding="utf-8")
|
||||||
|
proc = subprocess.Popen([BRIDGE], env=env, cwd=os.path.dirname(BRIDGE),
|
||||||
|
stdout=logf, stderr=subprocess.STDOUT, text=True)
|
||||||
|
try:
|
||||||
|
load_instrument(3, SFZ, ch=1)
|
||||||
|
time.sleep(1.0)
|
||||||
|
load_instrument(0, VST3, ch=2)
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 8:
|
||||||
|
logf.flush()
|
||||||
|
log = open(logpath, encoding="utf-8").read()
|
||||||
|
print("t=%.1f alive=%s vst3_loaded=%s lines=%d" % (
|
||||||
|
time.time() - t0, proc.poll() is None, "loaded ch=2" in log, log.count("\n")))
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print("DIED rc=%s" % proc.poll())
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
logf.flush()
|
||||||
|
print("--- log ---")
|
||||||
|
print(open(logpath, encoding="utf-8").read()[:2000])
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill()
|
||||||
|
logf.close()
|
||||||
|
kern.UnmapViewOfFile(v)
|
||||||
|
kern.CloseHandle(h)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 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");
|
||||||
|
#ifdef _WIN32
|
||||||
|
// On Windows the DAW (Rust) creates the mapping; standalone self-check
|
||||||
|
// creates it here when absent.
|
||||||
|
if (!h) h = shm_create("SonicForge_SelfCheck_IPC");
|
||||||
|
#endif
|
||||||
|
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,8 @@
|
|||||||
|
{
|
||||||
|
"name": "daw-vst-bridge",
|
||||||
|
"version-string": "1.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
"fluidsynth",
|
||||||
|
"pkgconf"
|
||||||
|
]
|
||||||
|
}
|
||||||
Submodule
+1
Submodule native_bridge/vst3sdk added at 3cdf9ca5d1
@@ -1,343 +0,0 @@
|
|||||||
// AudioEngine.cpp — native audio loop (T12, spec vsti_native_audio_pipeline_spec)
|
|
||||||
//
|
|
||||||
// SPSC ring buffer (MIDI UI thread → audio thread) + WASAPI render thread.
|
|
||||||
// Audio thread: không mutex, không cấp phát, không I/O. MMCSS "Audio" để
|
|
||||||
// giảm underrun.
|
|
||||||
#define NOMINMAX
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#include "AudioEngine.h"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include <avrt.h>
|
|
||||||
#include <functiondiscoverykeys.h>
|
|
||||||
#include <objbase.h>
|
|
||||||
|
|
||||||
namespace sonicforge {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
void set_err (char* err, int32 err_cap, const char* msg)
|
|
||||||
{
|
|
||||||
if (err && err_cap > 0)
|
|
||||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
AudioEngine::AudioEngine ()
|
|
||||||
{
|
|
||||||
std::memset (m_events, 0, sizeof (m_events));
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioEngine::~AudioEngine ()
|
|
||||||
{
|
|
||||||
stop ();
|
|
||||||
delete[] m_outL;
|
|
||||||
delete[] m_outR;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioEngine::pushMidi (const MidiEvent& ev)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (m_producerMutex);
|
|
||||||
uint32 head = m_head.load (std::memory_order_relaxed);
|
|
||||||
uint32 next = nextIdx (head);
|
|
||||||
if (next == m_tail.load (std::memory_order_acquire))
|
|
||||||
return false; // full
|
|
||||||
m_events[head] = ev;
|
|
||||||
m_head.store (next, std::memory_order_release);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T12: activate IAudioClient cho 1 device + Initialize exclusive→shared +
|
|
||||||
// event handle + render service. Khong cấp phát m_outL/m_outR (start() lam).
|
|
||||||
bool AudioEngine::initOnDevice (IMMDevice* device, int32 sampleRate, int32 blockSize,
|
|
||||||
char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
if (!device)
|
|
||||||
return false;
|
|
||||||
if (m_client)
|
|
||||||
{
|
|
||||||
// device truoc do dang giu — stop/release de thu device moi
|
|
||||||
stop ();
|
|
||||||
}
|
|
||||||
HRESULT hr = device->Activate (__uuidof (IAudioClient), CLSCTX_ALL, nullptr,
|
|
||||||
reinterpret_cast<void**> (&m_client));
|
|
||||||
if (FAILED (hr) || !m_client)
|
|
||||||
{
|
|
||||||
m_client = nullptr;
|
|
||||||
set_err (err, err_cap, "IAudioClient activate failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WAVEFORMATEX fmt = {};
|
|
||||||
fmt.wFormatTag = WAVE_FORMAT_IEEE_FLOAT;
|
|
||||||
fmt.nChannels = 2;
|
|
||||||
fmt.nSamplesPerSec = static_cast<DWORD> (sampleRate);
|
|
||||||
fmt.wBitsPerSample = 32;
|
|
||||||
fmt.nBlockAlign = 8;
|
|
||||||
fmt.nAvgBytesPerSec = static_cast<DWORD> (sampleRate) * 8;
|
|
||||||
|
|
||||||
// 100ns units: 1e7 * seconds
|
|
||||||
REFERENCE_TIME hnsPeriod =
|
|
||||||
static_cast<REFERENCE_TIME> (10000000.0 * blockSize / sampleRate);
|
|
||||||
|
|
||||||
// Exclusive trước (latency đúng period), fallback shared. Shared mode
|
|
||||||
// yêu cầu đúng mix format của device (GetMixFormat) — format tự build
|
|
||||||
// IEEE_FLOAT 2ch thường bị AUDCLNT_E_UNSUPPORTED_FORMAT (0x88890008).
|
|
||||||
hr = m_client->Initialize (AUDCLNT_SHAREMODE_EXCLUSIVE,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
|
||||||
hnsPeriod, hnsPeriod, &fmt, nullptr);
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
WAVEFORMATEX* mix = nullptr;
|
|
||||||
if (SUCCEEDED (m_client->GetMixFormat (&mix)) && mix)
|
|
||||||
{
|
|
||||||
m_sampleRate = static_cast<int32> (mix->nSamplesPerSec);
|
|
||||||
hr = m_client->Initialize (AUDCLNT_SHAREMODE_SHARED,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
|
|
||||||
hnsPeriod, 0, mix, nullptr);
|
|
||||||
CoTaskMemFree (mix);
|
|
||||||
}
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
std::snprintf (err, static_cast<size_t> (err_cap),
|
|
||||||
"WASAPI Initialize failed: 0x%08X", static_cast<unsigned> (hr));
|
|
||||||
m_client->Release ();
|
|
||||||
m_client = nullptr;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hr = m_client->GetBufferSize (&m_bufferFrames);
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
stop ();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_event = CreateEventW (nullptr, FALSE, FALSE, nullptr);
|
|
||||||
if (!m_event)
|
|
||||||
{
|
|
||||||
stop ();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
hr = m_client->SetEventHandle (m_event);
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
stop ();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
hr = m_client->GetService (IID_PPV_ARGS (&m_render));
|
|
||||||
if (FAILED (hr) || !m_render)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "IAudioRenderClient failed");
|
|
||||||
stop ();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool AudioEngine::start (int32 sampleRate, int32 blockSize, ProcessFn fn, void* userdata,
|
|
||||||
char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
if (m_running.load (std::memory_order_acquire))
|
|
||||||
return true;
|
|
||||||
if (sampleRate <= 0 || blockSize <= 0)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "bad sampleRate/blockSize");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
m_fn = fn;
|
|
||||||
m_userdata = userdata;
|
|
||||||
m_sampleRate = sampleRate;
|
|
||||||
m_blockSize = blockSize;
|
|
||||||
|
|
||||||
HRESULT hr = CoInitializeEx (nullptr, COINIT_MULTITHREADED);
|
|
||||||
if (FAILED (hr) && hr != RPC_E_CHANGED_MODE)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "CoInitializeEx failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
m_comInit = (hr != RPC_E_CHANGED_MODE);
|
|
||||||
|
|
||||||
IMMDeviceEnumerator* enumerator = nullptr;
|
|
||||||
hr = CoCreateInstance (__uuidof (MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
|
||||||
IID_PPV_ARGS (&enumerator));
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "no MMDeviceEnumerator");
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default endpoint trước; fallback: enumerate các endpoint ACTIVE và thử
|
|
||||||
// từng cái (device default có thể bị invalidated — vd HDMI không connect).
|
|
||||||
IMMDevice* device = nullptr;
|
|
||||||
if (SUCCEEDED (enumerator->GetDefaultAudioEndpoint (eRender, eConsole, &device)) && device)
|
|
||||||
{
|
|
||||||
if (initOnDevice (device, sampleRate, blockSize, err, err_cap))
|
|
||||||
{
|
|
||||||
device->Release ();
|
|
||||||
enumerator->Release ();
|
|
||||||
goto ready;
|
|
||||||
}
|
|
||||||
device->Release ();
|
|
||||||
}
|
|
||||||
|
|
||||||
IMMDeviceCollection* coll = nullptr;
|
|
||||||
hr = enumerator->EnumAudioEndpoints (eRender, DEVICE_STATE_ACTIVE, &coll);
|
|
||||||
if (FAILED (hr) || !coll)
|
|
||||||
{
|
|
||||||
enumerator->Release ();
|
|
||||||
set_err (err, err_cap, "no active render device");
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
UINT nDev = 0;
|
|
||||||
coll->GetCount (&nDev);
|
|
||||||
bool ok = false;
|
|
||||||
for (UINT i = 0; i < nDev; ++i)
|
|
||||||
{
|
|
||||||
IMMDevice* d = nullptr;
|
|
||||||
if (FAILED (coll->Item (i, &d)) || !d)
|
|
||||||
continue;
|
|
||||||
if (initOnDevice (d, sampleRate, blockSize, err, err_cap))
|
|
||||||
ok = true;
|
|
||||||
d->Release ();
|
|
||||||
if (ok)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
coll->Release ();
|
|
||||||
enumerator->Release ();
|
|
||||||
if (!ok)
|
|
||||||
goto fail;
|
|
||||||
|
|
||||||
ready:
|
|
||||||
m_latency.store (static_cast<int32> (m_bufferFrames), std::memory_order_relaxed);
|
|
||||||
|
|
||||||
delete[] m_outL;
|
|
||||||
delete[] m_outR;
|
|
||||||
m_outL = new float[m_bufferFrames];
|
|
||||||
m_outR = new float[m_bufferFrames];
|
|
||||||
|
|
||||||
m_thread = CreateThread (nullptr, 0, &AudioEngine::renderThreadEntry, this, 0, nullptr);
|
|
||||||
if (!m_thread)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "CreateThread failed");
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_running.store (true, std::memory_order_release);
|
|
||||||
hr = m_client->Start ();
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
m_running.store (false, std::memory_order_release);
|
|
||||||
set_err (err, err_cap, "IAudioClient Start failed");
|
|
||||||
goto fail;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
|
|
||||||
fail:
|
|
||||||
stop ();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioEngine::stop ()
|
|
||||||
{
|
|
||||||
m_running.store (false, std::memory_order_release);
|
|
||||||
if (m_client)
|
|
||||||
m_client->Stop ();
|
|
||||||
if (m_thread)
|
|
||||||
{
|
|
||||||
WaitForSingleObject (m_thread, 3000);
|
|
||||||
CloseHandle (m_thread);
|
|
||||||
m_thread = nullptr;
|
|
||||||
}
|
|
||||||
if (m_event)
|
|
||||||
{
|
|
||||||
CloseHandle (m_event);
|
|
||||||
m_event = nullptr;
|
|
||||||
}
|
|
||||||
if (m_render)
|
|
||||||
{
|
|
||||||
m_render->Release ();
|
|
||||||
m_render = nullptr;
|
|
||||||
}
|
|
||||||
if (m_client)
|
|
||||||
{
|
|
||||||
m_client->Release ();
|
|
||||||
m_client = nullptr;
|
|
||||||
}
|
|
||||||
if (m_comInit)
|
|
||||||
{
|
|
||||||
CoUninitialize ();
|
|
||||||
m_comInit = false;
|
|
||||||
}
|
|
||||||
m_head.store (0, std::memory_order_relaxed);
|
|
||||||
m_tail.store (0, std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
DWORD WINAPI AudioEngine::renderThreadEntry (LPVOID self)
|
|
||||||
{
|
|
||||||
static_cast<AudioEngine*> (self)->renderLoop ();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void AudioEngine::renderLoop ()
|
|
||||||
{
|
|
||||||
CoInitializeEx (nullptr, COINIT_MULTITHREADED);
|
|
||||||
DWORD taskIndex = 0;
|
|
||||||
HANDLE mmcss = AvSetMmThreadCharacteristicsW (L"Audio", &taskIndex);
|
|
||||||
|
|
||||||
while (m_running.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
DWORD wait = WaitForSingleObject (m_event, 200);
|
|
||||||
if (wait != WAIT_OBJECT_0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
UINT32 pad = 0;
|
|
||||||
m_client->GetCurrentPadding (&pad);
|
|
||||||
UINT32 frames = m_bufferFrames - pad;
|
|
||||||
if (frames == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
BYTE* data = nullptr;
|
|
||||||
HRESULT hr = m_render->GetBuffer (frames, &data);
|
|
||||||
if (FAILED (hr))
|
|
||||||
{
|
|
||||||
m_underruns.fetch_add (1, std::memory_order_relaxed);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain SPSC (single consumer — không lock)
|
|
||||||
int32 count = 0;
|
|
||||||
uint32 tail = m_tail.load (std::memory_order_relaxed);
|
|
||||||
uint32 head = m_head.load (std::memory_order_acquire);
|
|
||||||
while (tail != head && count < kMaxEventsPerBlock)
|
|
||||||
{
|
|
||||||
m_drained[count] = m_events[tail];
|
|
||||||
tail = nextIdx (tail);
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
m_tail.store (tail, std::memory_order_release);
|
|
||||||
|
|
||||||
bool wrote = m_fn
|
|
||||||
? m_fn (m_drained, count, m_outL, m_outR, static_cast<int32> (frames), m_userdata)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
float* inter = reinterpret_cast<float*> (data);
|
|
||||||
for (UINT32 i = 0; i < frames; ++i)
|
|
||||||
{
|
|
||||||
inter[2 * i] = wrote ? m_outL[i] : 0.0f;
|
|
||||||
inter[2 * i + 1] = wrote ? m_outR[i] : 0.0f;
|
|
||||||
}
|
|
||||||
m_render->ReleaseBuffer (frames, 0);
|
|
||||||
m_blocks.fetch_add (1, std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mmcss)
|
|
||||||
AvRevertMmThreadCharacteristics (mmcss);
|
|
||||||
CoUninitialize ();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace sonicforge
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// AudioEngine.h — native audio loop (T12, spec vsti_native_audio_pipeline_spec)
|
|
||||||
//
|
|
||||||
// Lõi chung cho cả 2 bridge DLL (vst2_host_bridge, vst3_host_bridge): SPSC
|
|
||||||
// ring buffer chuyển MIDI từ UI thread → audio thread, render thread WASAPI
|
|
||||||
// (exclusive trước, fallback shared) gọi ProcessFn mỗi block. Audio thread
|
|
||||||
// TUYỆT ĐỐI không lock / không cấp phát (golden rule spec III).
|
|
||||||
//
|
|
||||||
// QUYẾT ĐỊNH (T12): gộp "VST3AudioEngine.cpp" thành AudioEngine.cpp dùng
|
|
||||||
// chung 2 plugin kind (spec cho phép "hoặc gộp với T9/T10"); WASAPI đủ cho
|
|
||||||
// milestone, ASIO bỏ qua (ghi chú, thêm khi có yêu cầu rõ ràng).
|
|
||||||
#pragma once
|
|
||||||
#define NOMINMAX
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#include <windows.h>
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
#include <audioclient.h>
|
|
||||||
#include <mmdeviceapi.h>
|
|
||||||
|
|
||||||
namespace sonicforge {
|
|
||||||
|
|
||||||
using int32 = int32_t;
|
|
||||||
using uint32 = uint32_t;
|
|
||||||
|
|
||||||
// MIDI event từ UI thread → audio thread (SPSC). velocity 0..1.
|
|
||||||
struct MidiEvent
|
|
||||||
{
|
|
||||||
int32 channel;
|
|
||||||
int32 pitch;
|
|
||||||
float velocity;
|
|
||||||
bool noteOn;
|
|
||||||
};
|
|
||||||
|
|
||||||
constexpr int32 kEventCapacity = 4096;
|
|
||||||
constexpr int32 kMaxEventsPerBlock = 256;
|
|
||||||
|
|
||||||
// ProcessFn chạy trên audio thread (render thread). events/count là các MIDI
|
|
||||||
// event đã drain từ SPSC trong block này. outL/outR: frames mẫu mỗi kênh,
|
|
||||||
// pre-alloc (audio thread không cấp phát). Trả true nếu đã ghi output (false
|
|
||||||
// → engine zero-fill).
|
|
||||||
typedef bool (*ProcessFn)(const MidiEvent* events, int32 eventCount,
|
|
||||||
float* outL, float* outR, int32 frames, void* userdata);
|
|
||||||
|
|
||||||
class AudioEngine
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
AudioEngine ();
|
|
||||||
~AudioEngine ();
|
|
||||||
|
|
||||||
// Khởi tạo WASAPI + render thread. Exclusive trước, fallback shared.
|
|
||||||
// err/err_cap để trả message lỗi (có thể null). Idempotent.
|
|
||||||
bool start (int32 sampleRate, int32 blockSize, ProcessFn fn, void* userdata,
|
|
||||||
char* err, int32 err_cap);
|
|
||||||
void stop (); // join thread + giải phóng WASAPI
|
|
||||||
|
|
||||||
bool running () const { return m_running.load (std::memory_order_acquire); }
|
|
||||||
|
|
||||||
// Producer (UI thread) — có mutex riêng để an toàn với nhiều producer
|
|
||||||
// (Tauri command thread pool); audio thread KHÔNG lock.
|
|
||||||
bool pushMidi (const MidiEvent& ev);
|
|
||||||
|
|
||||||
int32 underruns () const { return m_underruns.load (std::memory_order_relaxed); }
|
|
||||||
int32 latencySamples () const { return m_latency.load (std::memory_order_relaxed); }
|
|
||||||
int32 blocksRendered () const { return m_blocks.load (std::memory_order_relaxed); }
|
|
||||||
int32 sampleRate () const { return m_sampleRate; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
static DWORD WINAPI renderThreadEntry (LPVOID self);
|
|
||||||
void renderLoop ();
|
|
||||||
static uint32 nextIdx (uint32 i) { return (i + 1) % kEventCapacity; }
|
|
||||||
|
|
||||||
// T12: activate IAudioClient cho 1 device + Initialize exclusive→shared.
|
|
||||||
// Trả true nếu OK (m_client/m_render/m_event/m_bufferFrames set).
|
|
||||||
bool initOnDevice (IMMDevice* device, int32 sampleRate, int32 blockSize,
|
|
||||||
char* err, int32 err_cap);
|
|
||||||
|
|
||||||
// SPSC ring (single consumer = audio thread; producer guarded by mutex)
|
|
||||||
MidiEvent m_events[kEventCapacity];
|
|
||||||
std::atomic<uint32> m_head {0}; // producer index (next write)
|
|
||||||
std::atomic<uint32> m_tail {0}; // consumer index (next read)
|
|
||||||
std::mutex m_producerMutex;
|
|
||||||
MidiEvent m_drained[kMaxEventsPerBlock];
|
|
||||||
|
|
||||||
std::atomic<bool> m_running {false};
|
|
||||||
std::atomic<int32> m_underruns {0};
|
|
||||||
std::atomic<int32> m_latency {0};
|
|
||||||
std::atomic<int32> m_blocks {0};
|
|
||||||
|
|
||||||
HANDLE m_thread = nullptr;
|
|
||||||
HANDLE m_event = nullptr;
|
|
||||||
|
|
||||||
IAudioClient* m_client = nullptr;
|
|
||||||
IAudioRenderClient* m_render = nullptr;
|
|
||||||
UINT32 m_bufferFrames = 0;
|
|
||||||
int32 m_sampleRate = 44100;
|
|
||||||
int32 m_blockSize = 128;
|
|
||||||
bool m_comInit = false;
|
|
||||||
|
|
||||||
ProcessFn m_fn = nullptr;
|
|
||||||
void* m_userdata = nullptr;
|
|
||||||
|
|
||||||
float* m_outL = nullptr;
|
|
||||||
float* m_outR = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace sonicforge
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
|
||||||
project(sonicforge_native_host LANGUAGES CXX)
|
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
||||||
|
|
||||||
if(NOT DEFINED VST3_SDK_ROOT)
|
|
||||||
set(VST3_SDK_ROOT "" CACHE PATH "Path to VST3 SDK root")
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_library(vst3_host_bridge SHARED
|
|
||||||
vst3_host_bridge.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/common/commonstringconvert.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/common/threadchecker_win32.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/module.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/module_win32.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/plugprovider.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/hostclasses.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/pluginterfacesupport.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/hosting/connectionproxy.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/utility/stringconvert.cpp
|
|
||||||
${VST3_SDK_ROOT}/public.sdk/source/vst/vstinitiids.cpp
|
|
||||||
${VST3_SDK_ROOT}/pluginterfaces/base/funknown.cpp
|
|
||||||
${VST3_SDK_ROOT}/pluginterfaces/base/ustring.cpp
|
|
||||||
${VST3_SDK_ROOT}/pluginterfaces/base/coreiids.cpp
|
|
||||||
${VST3_SDK_ROOT}/base/source/baseiids.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_include_directories(vst3_host_bridge PRIVATE ${VST3_SDK_ROOT})
|
|
||||||
target_compile_definitions(vst3_host_bridge PRIVATE
|
|
||||||
_CRT_SECURE_NO_WARNINGS
|
|
||||||
NOMINMAX
|
|
||||||
WIN32_LEAN_AND_MEAN
|
|
||||||
)
|
|
||||||
target_link_libraries(vst3_host_bridge PRIVATE audio_engine native_mixer)
|
|
||||||
|
|
||||||
# Native audio loop (T12): SPSC + WASAPI — dùng chung cho cả 2 bridge.
|
|
||||||
add_library(audio_engine STATIC
|
|
||||||
AudioEngine.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(audio_engine PUBLIC native_host)
|
|
||||||
target_compile_definitions(audio_engine PRIVATE
|
|
||||||
_CRT_SECURE_NO_WARNINGS
|
|
||||||
NOMINMAX
|
|
||||||
WIN32_LEAN_AND_MEAN
|
|
||||||
_WIN32_WINNT=0x0601
|
|
||||||
)
|
|
||||||
target_link_libraries(audio_engine PUBLIC ole32 avrt)
|
|
||||||
|
|
||||||
# NativeMixer (T13): track gain/pan + master brickwall limiter — mirror server.
|
|
||||||
add_library(native_mixer STATIC
|
|
||||||
NativeMixer.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(native_mixer PUBLIC native_host)
|
|
||||||
|
|
||||||
# VST2 host bridge — chỉ cần vestige.h clean-room, không cần SDK.
|
|
||||||
add_library(vst2_host_bridge SHARED
|
|
||||||
VST2AudioEngine.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(vst2_host_bridge PRIVATE native_host)
|
|
||||||
target_compile_definitions(vst2_host_bridge PRIVATE
|
|
||||||
_CRT_SECURE_NO_WARNINGS
|
|
||||||
NOMINMAX
|
|
||||||
WIN32_LEAN_AND_MEAN
|
|
||||||
)
|
|
||||||
target_link_libraries(vst2_host_bridge PRIVATE audio_engine native_mixer)
|
|
||||||
|
|
||||||
# SF host bridge (T14): FluidSynth native — load libfluidsynth runtime DLL
|
|
||||||
# (fluidsynth_runtime/, gitignore) qua GetProcAddress; audio loop + mixer chung.
|
|
||||||
add_library(sf_host_bridge SHARED
|
|
||||||
SFHost.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(sf_host_bridge PRIVATE native_host)
|
|
||||||
target_compile_definitions(sf_host_bridge PRIVATE
|
|
||||||
_CRT_SECURE_NO_WARNINGS
|
|
||||||
NOMINMAX
|
|
||||||
WIN32_LEAN_AND_MEAN
|
|
||||||
)
|
|
||||||
target_link_libraries(sf_host_bridge PRIVATE audio_engine native_mixer)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// NativeMixer.cpp — xem NativeMixer.h cho cong thuc mirror.
|
|
||||||
#include "NativeMixer.h"
|
|
||||||
|
|
||||||
namespace sonicforge {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Mirror _clamp(v, lo, hi, default) cua mastering_engine.py: NaN -> default.
|
|
||||||
float clampDb (float v, float lo, float hi, float def)
|
|
||||||
{
|
|
||||||
if (!(v == v)) // NaN
|
|
||||||
return def;
|
|
||||||
if (v < lo)
|
|
||||||
return lo;
|
|
||||||
if (v > hi)
|
|
||||||
return hi;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TrackGainPan TrackGainPan::fromDbPan (float gainDb, float pan)
|
|
||||||
{
|
|
||||||
TrackGainPan t;
|
|
||||||
t.gainLin = std::pow (10.0f, gainDb / 20.0f);
|
|
||||||
float p = pan;
|
|
||||||
if (p < -1.0f)
|
|
||||||
p = -1.0f;
|
|
||||||
else if (p > 1.0f)
|
|
||||||
p = 1.0f;
|
|
||||||
if (p == 0.0f)
|
|
||||||
{
|
|
||||||
t.panL = 1.0f;
|
|
||||||
t.panR = 1.0f;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
const float theta = ((p + 1.0f) / 2.0f) * (3.14159265358979323846f / 2.0f);
|
|
||||||
t.panL = std::cos (theta);
|
|
||||||
t.panR = std::sin (theta);
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
void NativeMixer::setLimiter (bool active, float thresholdDb)
|
|
||||||
{
|
|
||||||
m_limActive = active;
|
|
||||||
if (!active)
|
|
||||||
{
|
|
||||||
m_limK = 1.0f;
|
|
||||||
m_limTk = 1.0f;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const float t = clampDb (thresholdDb, -24.0f, 0.0f, -1.0f);
|
|
||||||
const float tLin = std::pow (10.0f, t / 20.0f);
|
|
||||||
m_limK = 1.0f / (tLin > 0.02f ? tLin : 0.02f);
|
|
||||||
m_limTk = std::tanh (m_limK);
|
|
||||||
}
|
|
||||||
|
|
||||||
void NativeMixer::processInPlace (float* outL, float* outR, int32 frames) const
|
|
||||||
{
|
|
||||||
if (!outL || !outR || frames <= 0)
|
|
||||||
return;
|
|
||||||
const float gl = m_track.gainLin * m_track.panL;
|
|
||||||
const float gr = m_track.gainLin * m_track.panR;
|
|
||||||
for (int32 i = 0; i < frames; ++i)
|
|
||||||
{
|
|
||||||
float l = outL[i] * gl;
|
|
||||||
float r = outR[i] * gr;
|
|
||||||
if (m_limActive)
|
|
||||||
{
|
|
||||||
l = std::tanh (l * m_limK) / m_limTk;
|
|
||||||
r = std::tanh (r * m_limK) / m_limTk;
|
|
||||||
// Hard clip [-1,1] — mirror render_project sau apply_mastering
|
|
||||||
// (chi khi mastering on; limiter off => khong clip).
|
|
||||||
if (l > 1.0f)
|
|
||||||
l = 1.0f;
|
|
||||||
else if (l < -1.0f)
|
|
||||||
l = -1.0f;
|
|
||||||
if (r > 1.0f)
|
|
||||||
r = 1.0f;
|
|
||||||
else if (r < -1.0f)
|
|
||||||
r = -1.0f;
|
|
||||||
}
|
|
||||||
// Master fader (T15): sau limiter+clip, linear, khong re-clip — mirror
|
|
||||||
// WebAudio masterBus.output.gain (gain node cuoi cung sau mastering).
|
|
||||||
outL[i] = l * m_masterGain;
|
|
||||||
outR[i] = r * m_masterGain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace sonicforge
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// NativeMixer.h — track gain/pan + master brickwall limiter (T13).
|
|
||||||
//
|
|
||||||
// MIRROR server render pipeline (`app/core/render_engine.py` track volume/pan
|
|
||||||
// + `app/core/mastering_engine.py` _apply_limiter):
|
|
||||||
// - track gain: 10^(dB/20)
|
|
||||||
// - pan constant-power: theta = ((pan+1)/2)*pi/2, L *= cos(theta), R *= sin(theta)
|
|
||||||
// (pan == 0 -> unity, khong nhan — dung nhu render_engine.py)
|
|
||||||
// - master: tong cac track (processInPlace ap dung gain/pan roi limiter)
|
|
||||||
// - brickwall limiter: t_lin = 10^(thresholdDb/20) (clamp -24..0, default -1);
|
|
||||||
// k = 1/max(0.02, t_lin); tk = tanh(k); out = tanh(x*k)/tk; roi hard clip [-1,1]
|
|
||||||
// (clip giong render_project sau apply_mastering).
|
|
||||||
//
|
|
||||||
// Audio thread an toan: chi setTrack/setLimiter tu UI thread truoc khi
|
|
||||||
// AudioStart; processInPlace doc state da set, khong lock, khong cap phat.
|
|
||||||
#pragma once
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cmath>
|
|
||||||
|
|
||||||
namespace sonicforge {
|
|
||||||
|
|
||||||
using int32 = int32_t;
|
|
||||||
|
|
||||||
struct TrackGainPan
|
|
||||||
{
|
|
||||||
float gainLin = 1.0f; // 10^(dB/20)
|
|
||||||
float panL = 1.0f; // cos(theta)
|
|
||||||
float panR = 1.0f; // sin(theta)
|
|
||||||
|
|
||||||
static TrackGainPan fromDbPan (float gainDb, float pan);
|
|
||||||
};
|
|
||||||
|
|
||||||
class NativeMixer
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
// gainDb: -inf..+inf dB; pan: -1..+1 (clamp). Mirrors render_engine.py.
|
|
||||||
void setTrack (const TrackGainPan& t) { m_track = t; }
|
|
||||||
|
|
||||||
// thresholdDb clamp -24..0, NaN -> -1 (mirror _clamp trong mastering_engine).
|
|
||||||
void setLimiter (bool active, float thresholdDb);
|
|
||||||
|
|
||||||
// Master fader (T15) — mirror WebAudio masterBus.output.gain (sau mastering
|
|
||||||
// chain + clip; linear, khong re-clip). Mac dinh 1.0.
|
|
||||||
void setMasterGain (float gainLinear) { m_masterGain = gainLinear; }
|
|
||||||
|
|
||||||
// Ap track gain/pan vao buffer roi (neu active) limiter + hard clip [-1,1]
|
|
||||||
// ngay tai buffer (in-place). frames > 0.
|
|
||||||
void processInPlace (float* outL, float* outR, int32 frames) const;
|
|
||||||
|
|
||||||
bool limiterActive () const { return m_limActive; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
TrackGainPan m_track;
|
|
||||||
bool m_limActive = false;
|
|
||||||
float m_limK = 1.0f; // 1/max(0.02, t_lin)
|
|
||||||
float m_limTk = 1.0f; // tanh(k)
|
|
||||||
float m_masterGain = 1.0f; // T15: master fader (sau limiter+clip, khong re-clip)
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace sonicforge
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
// SFHost.cpp — FluidSynth native bridge (T14)
|
|
||||||
//
|
|
||||||
// DLL export C API để chạy libfluidsynth (native, không WASM) trong audio
|
|
||||||
// loop chung (SPSC + WASAPI, giống VST2/VST3 bridge). Mục tiêu T14: track
|
|
||||||
// SF chơi bằng FluidSynth native cùng master với track VSTi; gain/pan/master
|
|
||||||
// limiter (NativeMixer) áp cho cả 2; preview/items/offline nhất quán.
|
|
||||||
//
|
|
||||||
// QUYẾT ĐỊNH (T14): KHÔNG link tĩnh libfluidsynth (runtime DLL 12MB + phụ
|
|
||||||
// thuộc) — load động qua GetProcAddress từ native_host/fluidsynth_runtime/
|
|
||||||
// (gitignore; tái tạo bằng scripts/dl_fluidsynth_runtime.py). Load bằng
|
|
||||||
// LoadLibraryExW(LOAD_WITH_ALTERED_SEARCH_PATH) để các DLL phụ thuộc cùng
|
|
||||||
// thư mục được tìm thấy.
|
|
||||||
//
|
|
||||||
// Thread-safety: fluid_synth_* KHÔNG thread-safe — mọi gọi synth (sfload,
|
|
||||||
// noteon/off, write_float, set_gain) chỉ trên audio thread; sfload CHỈ trước
|
|
||||||
// AudioStart. MIDI từ UI thread → SPSC (AudioEngine) → audio thread drain.
|
|
||||||
#include <windows.h>
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "AudioEngine.h"
|
|
||||||
#include "NativeMixer.h"
|
|
||||||
|
|
||||||
using int32 = int32_t;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// FluidSynth C API (runtime load — xem fluidsynth/synth.h, settings.h)
|
|
||||||
typedef void* fluid_settings_t;
|
|
||||||
typedef void* fluid_synth_t;
|
|
||||||
|
|
||||||
typedef fluid_settings_t (*FS_SettingsNew) (void);
|
|
||||||
typedef int (*FS_SettingsSetnum) (fluid_settings_t, const char*, double);
|
|
||||||
typedef int (*FS_SettingsSetstr) (fluid_settings_t, const char*, const char*);
|
|
||||||
typedef void (*FS_SettingsDelete) (fluid_settings_t);
|
|
||||||
typedef fluid_synth_t (*FS_SynthNew) (fluid_settings_t);
|
|
||||||
typedef int (*FS_SynthDelete) (fluid_synth_t);
|
|
||||||
typedef int (*FS_SynthSfload) (fluid_synth_t, const char*, int);
|
|
||||||
typedef int (*FS_SynthNoteon) (fluid_synth_t, int, int, int);
|
|
||||||
typedef int (*FS_SynthNoteoff) (fluid_synth_t, int, int);
|
|
||||||
typedef int (*FS_SynthAllNotesOff) (fluid_synth_t, int);
|
|
||||||
typedef void (*FS_SynthWriteFloat) (fluid_synth_t, int, float*, int, int,
|
|
||||||
float*, int, int);
|
|
||||||
typedef int (*FS_SynthSetGain) (fluid_synth_t, float);
|
|
||||||
typedef int (*FS_SynthProgramSelect) (fluid_synth_t, int, int, int, int);
|
|
||||||
|
|
||||||
struct FS
|
|
||||||
{
|
|
||||||
FS_SettingsNew settings_new = nullptr;
|
|
||||||
FS_SettingsSetnum settings_setnum = nullptr;
|
|
||||||
FS_SettingsSetstr settings_setstr = nullptr;
|
|
||||||
FS_SettingsDelete settings_delete = nullptr;
|
|
||||||
FS_SynthNew synth_new = nullptr;
|
|
||||||
FS_SynthDelete synth_delete = nullptr;
|
|
||||||
FS_SynthSfload synth_sfload = nullptr;
|
|
||||||
FS_SynthNoteon synth_noteon = nullptr;
|
|
||||||
FS_SynthNoteoff synth_noteoff = nullptr;
|
|
||||||
FS_SynthAllNotesOff synth_all_notes_off = nullptr;
|
|
||||||
FS_SynthWriteFloat synth_write_float = nullptr;
|
|
||||||
FS_SynthSetGain synth_set_gain = nullptr;
|
|
||||||
FS_SynthProgramSelect synth_program_select = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool load_fs (HMODULE m, FS& fs)
|
|
||||||
{
|
|
||||||
// FluidSynth 2.5.x exports: new_fluid_*/delete_fluid_* (prefix), còn lại
|
|
||||||
// fluid_* — xác minh bằng dumpbin /exports trên libfluidsynth-3.dll.
|
|
||||||
fs.settings_new = reinterpret_cast<FS_SettingsNew> (GetProcAddress (m, "new_fluid_settings"));
|
|
||||||
fs.settings_setnum = reinterpret_cast<FS_SettingsSetnum> (GetProcAddress (m, "fluid_settings_setnum"));
|
|
||||||
fs.settings_setstr = reinterpret_cast<FS_SettingsSetstr> (GetProcAddress (m, "fluid_settings_setstr"));
|
|
||||||
fs.settings_delete = reinterpret_cast<FS_SettingsDelete> (GetProcAddress (m, "delete_fluid_settings"));
|
|
||||||
fs.synth_new = reinterpret_cast<FS_SynthNew> (GetProcAddress (m, "new_fluid_synth"));
|
|
||||||
fs.synth_delete = reinterpret_cast<FS_SynthDelete> (GetProcAddress (m, "delete_fluid_synth"));
|
|
||||||
fs.synth_sfload = reinterpret_cast<FS_SynthSfload> (GetProcAddress (m, "fluid_synth_sfload"));
|
|
||||||
fs.synth_noteon = reinterpret_cast<FS_SynthNoteon> (GetProcAddress (m, "fluid_synth_noteon"));
|
|
||||||
fs.synth_noteoff = reinterpret_cast<FS_SynthNoteoff> (GetProcAddress (m, "fluid_synth_noteoff"));
|
|
||||||
fs.synth_all_notes_off = reinterpret_cast<FS_SynthAllNotesOff> (GetProcAddress (m, "fluid_synth_all_notes_off"));
|
|
||||||
fs.synth_write_float = reinterpret_cast<FS_SynthWriteFloat> (GetProcAddress (m, "fluid_synth_write_float"));
|
|
||||||
fs.synth_set_gain = reinterpret_cast<FS_SynthSetGain> (GetProcAddress (m, "fluid_synth_set_gain"));
|
|
||||||
fs.synth_program_select = reinterpret_cast<FS_SynthProgramSelect> (GetProcAddress (m, "fluid_synth_program_select"));
|
|
||||||
return fs.settings_new && fs.settings_setnum && fs.synth_new && fs.synth_sfload &&
|
|
||||||
fs.synth_noteon && fs.synth_noteoff && fs.synth_write_float && fs.synth_set_gain;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Instance
|
|
||||||
{
|
|
||||||
HMODULE fsLib = nullptr;
|
|
||||||
FS fs;
|
|
||||||
fluid_settings_t settings = nullptr;
|
|
||||||
fluid_synth_t synth = nullptr;
|
|
||||||
int sfid = -1; // sfont id tra ve tu fluid_synth_sfload
|
|
||||||
int32 sampleRate = 44100;
|
|
||||||
int32 blockSize = 512;
|
|
||||||
int32 handle = 0;
|
|
||||||
sonicforge::AudioEngine audio;
|
|
||||||
std::atomic<bool> audioRunning {false};
|
|
||||||
sonicforge::NativeMixer mixer;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::mutex g_mutex;
|
|
||||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
|
||||||
int32 g_next_handle = 1;
|
|
||||||
|
|
||||||
void set_err (char* err, int32 err_cap, const char* msg)
|
|
||||||
{
|
|
||||||
if (err && err_cap > 0)
|
|
||||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio thread callback (WASAPI render thread): drain MIDI SPSC → synth
|
|
||||||
// noteon/noteoff → fluid_synth_write_float → mixer (gain/pan/limiter).
|
|
||||||
bool fsAudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
|
||||||
float* outL, float* outR, int32 frames, void* userdata)
|
|
||||||
{
|
|
||||||
Instance* inst = static_cast<Instance*> (userdata);
|
|
||||||
if (!inst || !inst->synth)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (int32 i = 0; i < eventCount; ++i)
|
|
||||||
{
|
|
||||||
const sonicforge::MidiEvent& e = events[i];
|
|
||||||
if (e.noteOn)
|
|
||||||
inst->fs.synth_noteon (inst->synth, e.channel, e.pitch,
|
|
||||||
static_cast<int> (e.velocity * 127.0f));
|
|
||||||
else
|
|
||||||
inst->fs.synth_noteoff (inst->synth, e.channel, e.pitch);
|
|
||||||
}
|
|
||||||
|
|
||||||
inst->fs.synth_write_float (inst->synth, frames, outL, 0, 1, outR, 0, 1);
|
|
||||||
inst->mixer.processInPlace (outL, outR, frames);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
|
|
||||||
// Tạo synth + load runtime DLL. sfload CHỈ sau Create (trước AudioStart).
|
|
||||||
// Trả handle (>= 1) hoặc 0 + err.
|
|
||||||
__declspec (dllexport) int32 SF_FS_Create (int32 sampleRate, int32 blockSize,
|
|
||||||
char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
|
|
||||||
auto inst = std::make_unique<Instance> ();
|
|
||||||
inst->sampleRate = sampleRate > 0 ? sampleRate : 44100;
|
|
||||||
inst->blockSize = blockSize > 0 ? blockSize : 512;
|
|
||||||
|
|
||||||
// Runtime DLL trong native_host/fluidsynth_runtime/ (gitignore) — tìm
|
|
||||||
// tương đối với chính sf_host_bridge.dll (GetModuleHandleW) chứ không
|
|
||||||
// phải exe host: bridge có thể được load từ python test / Tauri app.
|
|
||||||
HMODULE self = GetModuleHandleW (L"sf_host_bridge");
|
|
||||||
wchar_t selfPath[MAX_PATH] = {};
|
|
||||||
DWORD selfLen = self ? GetModuleFileNameW (self, selfPath, MAX_PATH) : 0;
|
|
||||||
std::wstring dir;
|
|
||||||
if (selfLen > 0)
|
|
||||||
{
|
|
||||||
dir = selfPath;
|
|
||||||
auto slash = dir.find_last_of (L'\\');
|
|
||||||
if (slash != std::wstring::npos)
|
|
||||||
dir.resize (slash + 1);
|
|
||||||
}
|
|
||||||
std::wstring fsDll = dir + L"fluidsynth_runtime\\libfluidsynth-3.dll";
|
|
||||||
|
|
||||||
inst->fsLib = LoadLibraryExW (fsDll.c_str (), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
|
|
||||||
if (!inst->fsLib)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "LoadLibraryExW libfluidsynth failed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!load_fs (inst->fsLib, inst->fs))
|
|
||||||
{
|
|
||||||
FreeLibrary (inst->fsLib);
|
|
||||||
set_err (err, err_cap, "libfluidsynth exports missing");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
inst->settings = inst->fs.settings_new ();
|
|
||||||
inst->fs.settings_setnum (inst->settings, "synth.sample-rate",
|
|
||||||
static_cast<double> (inst->sampleRate));
|
|
||||||
// T14: render thẳng ra float stereo, không qua audio driver của fluidsynth.
|
|
||||||
inst->fs.settings_setstr (inst->settings, "audio.driver", "file");
|
|
||||||
inst->synth = inst->fs.synth_new (inst->settings);
|
|
||||||
if (!inst->synth)
|
|
||||||
{
|
|
||||||
inst->fs.settings_delete (inst->settings);
|
|
||||||
FreeLibrary (inst->fsLib);
|
|
||||||
set_err (err, err_cap, "fluid_synth_new failed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// WASM client dùng gain 1.0 — mirror.
|
|
||||||
inst->fs.synth_set_gain (inst->synth, 1.0f);
|
|
||||||
|
|
||||||
int32 handle = g_next_handle++;
|
|
||||||
inst->handle = handle;
|
|
||||||
g_instances[handle] = std::move (inst);
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load SF2/SF3 vào synth. reset=0: giữ preset đã chọn. CHỈ trước AudioStart.
|
|
||||||
__declspec (dllexport) int32 SF_FS_LoadSF2 (int32 handle, const char* path_utf8,
|
|
||||||
char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "bad handle");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth || !path_utf8)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "not created or null path");
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "sfload only before AudioStart");
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
int sfid = inst->fs.synth_sfload (inst->synth, path_utf8, 0);
|
|
||||||
if (sfid < 0)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "fluid_synth_sfload failed");
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
inst->sfid = sfid;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chọn instrument channel (bank/program) — mirror WASM selectInstrument.
|
|
||||||
// sfont_id: id trả từ SF_FS_LoadSF2 (0..n-1); fluid_synth_program_select
|
|
||||||
// signature: (synth, chan, sfont_id, bank_num, preset_num).
|
|
||||||
__declspec (dllexport) int32 SF_FS_SelectInstrument (int32 handle, int32 channel,
|
|
||||||
int32 sfontId, int32 bank,
|
|
||||||
int32 program)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth)
|
|
||||||
return -2;
|
|
||||||
int fid = (sfontId < 0) ? inst->sfid : sfontId;
|
|
||||||
return inst->fs.synth_program_select (inst->synth, channel, fid, bank, program);
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_NoteOn (int32 handle, int32 channel, int32 pitch,
|
|
||||||
int32 velocity)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth)
|
|
||||||
return -2;
|
|
||||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
|
||||||
ev.noteOn = true;
|
|
||||||
inst->audio.pushMidi (ev);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return inst->fs.synth_noteon (inst->synth, channel, pitch, velocity);
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_NoteOff (int32 handle, int32 channel, int32 pitch)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth)
|
|
||||||
return -2;
|
|
||||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = 0.0f;
|
|
||||||
ev.noteOn = false;
|
|
||||||
inst->audio.pushMidi (ev);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return inst->fs.synth_noteoff (inst->synth, channel, pitch);
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AllNotesOff (int32 handle, int32 channel)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth)
|
|
||||||
return -2;
|
|
||||||
return inst->fs.synth_all_notes_off (inst->synth, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render offline 1 block thẳng ra buffer (không qua WASAPI): fluid_synth_write_float
|
|
||||||
// + mixer. Dùng cho golden test T14 (so với server reference) và offline path.
|
|
||||||
// outL/outR: frames mẫu mỗi kênh. Trả 0 nếu OK.
|
|
||||||
__declspec (dllexport) int32 SF_FS_RenderBlock (int32 handle, int32 frames,
|
|
||||||
float* outL, float* outR)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth || !outL || !outR || frames <= 0)
|
|
||||||
return -2;
|
|
||||||
inst->fs.synth_write_float (inst->synth, frames, outL, 0, 1, outR, 0, 1);
|
|
||||||
inst->mixer.processInPlace (outL, outR, frames);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AudioStart (int32 handle, int32 sampleRate,
|
|
||||||
int32 blockSize, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->synth)
|
|
||||||
return -2;
|
|
||||||
inst->audioRunning.store (true, std::memory_order_release);
|
|
||||||
if (!inst->audio.start (sampleRate > 0 ? sampleRate : inst->sampleRate,
|
|
||||||
blockSize > 0 ? blockSize : inst->blockSize,
|
|
||||||
&fsAudioProcess, inst, err, err_cap))
|
|
||||||
{
|
|
||||||
inst->audioRunning.store (false, std::memory_order_release);
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AudioStop (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->audioRunning.store (false, std::memory_order_release);
|
|
||||||
it->second->audio.stop ();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AudioUnderruns (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.underruns ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AudioLatency (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.latencySamples ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_AudioBlocks (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.blocksRendered ();
|
|
||||||
}
|
|
||||||
|
|
||||||
// T13 chung: track gain/pan + master limiter qua NativeMixer.
|
|
||||||
__declspec (dllexport) int32 SF_FS_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_FS_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T15: master fader — linear, ap sau limiter+clip trong mixer (mirror
|
|
||||||
// masterBus.output.gain WebAudio).
|
|
||||||
__declspec (dllexport) int32 SF_FS_SetMasterGain (int32 handle, float gainLinear)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setMasterGain (gainLinear);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đóng: stop audio loop, xóa synth, giải phóng DLL.
|
|
||||||
__declspec (dllexport) int32 SF_FS_Close (int32 handle, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
(void) err;
|
|
||||||
(void) err_cap;
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
inst->audioRunning.store (false, std::memory_order_release);
|
|
||||||
inst->audio.stop ();
|
|
||||||
if (inst->synth)
|
|
||||||
inst->fs.synth_delete (inst->synth);
|
|
||||||
if (inst->settings)
|
|
||||||
inst->fs.settings_delete (inst->settings);
|
|
||||||
if (inst->fsLib)
|
|
||||||
FreeLibrary (inst->fsLib);
|
|
||||||
g_instances.erase (it);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // extern "C"
|
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
// VST2AudioEngine.cpp — VST2 host bridge (T10, spec 5.7 / spec III)
|
|
||||||
//
|
|
||||||
// DLL export C API để load .dll VST2, dispatch MIDI, attach GUI vào HWND cha,
|
|
||||||
// và process float32 qua processReplacing. Host audio master trả lời tối
|
|
||||||
// thiểu: version, sampleRate, blockSize (opcode bắt buộc theo spec III).
|
|
||||||
//
|
|
||||||
// QUYẾT ĐỊNH (T10): KHÔNG JUCE, KHÔNG aeffect.h chính thức — dùng vestige.h
|
|
||||||
// clean-room (xem header). T12 native audio loop sẽ gọi SF_VST2_Process từ
|
|
||||||
// audio thread; effProcessEvents chỉ được gọi từ UI/main thread (spec 5.7.3).
|
|
||||||
//
|
|
||||||
// Giới hạn hiện tại: chưa xử lý plugin chunk state (effGetChunk/SetChunk) —
|
|
||||||
// thêm khi T11 cần save/restore preset.
|
|
||||||
#define NOMINMAX
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#include <windows.h>
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "vestige.h"
|
|
||||||
#include "AudioEngine.h"
|
|
||||||
#include "NativeMixer.h"
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Callback param (T11): plugin đổi param (audioMasterAutomate) → host.
|
|
||||||
typedef void (VSTCALLBACK* SF_ParamChangedCallback)(int32 handle, int32 paramIndex,
|
|
||||||
double valueNormalized, void* userdata);
|
|
||||||
|
|
||||||
struct Instance
|
|
||||||
{
|
|
||||||
HMODULE module = nullptr;
|
|
||||||
AEffect* effect = nullptr;
|
|
||||||
int32 sampleRate = 44100;
|
|
||||||
int32 blockSize = 512;
|
|
||||||
int32 numInputs = 0;
|
|
||||||
int32 numOutputs = 2;
|
|
||||||
HWND parentHwnd = nullptr;
|
|
||||||
ERect* editorRect = nullptr;
|
|
||||||
bool editorOpen = false;
|
|
||||||
bool active = false;
|
|
||||||
int32 handle = 0;
|
|
||||||
SF_ParamChangedCallback paramCb = nullptr;
|
|
||||||
void* paramCbUserdata = nullptr;
|
|
||||||
sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI)
|
|
||||||
std::atomic<bool> audioRunning {false};
|
|
||||||
sonicforge::NativeMixer mixer; // T13: track gain/pan + master limiter
|
|
||||||
};
|
|
||||||
|
|
||||||
std::mutex g_mutex;
|
|
||||||
std::mutex g_effect_map_mutex; // riêng: audioMasterAutomate gọi từ audio thread
|
|
||||||
std::map<AEffect*, Instance*> g_effect_map;
|
|
||||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
|
||||||
int32 g_next_handle = 1;
|
|
||||||
|
|
||||||
void set_err (char* err, int32 err_cap, const char* msg)
|
|
||||||
{
|
|
||||||
if (err && err_cap > 0)
|
|
||||||
{
|
|
||||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Host audio master — spec III: phải trả lời version/sampleRate/blockSize.
|
|
||||||
// thread_local tls_loading cho plugin tra cứu instance đang khởi tạo.
|
|
||||||
thread_local Instance* tls_loading = nullptr;
|
|
||||||
|
|
||||||
VstIntPtr VSTCALLBACK hostAudioMasterImpl (AEffect* effect, int32 opcode, int32 index,
|
|
||||||
VstIntPtr value, void* ptr, float opt)
|
|
||||||
{
|
|
||||||
(void) index;
|
|
||||||
(void) value;
|
|
||||||
(void) ptr;
|
|
||||||
Instance* inst = tls_loading;
|
|
||||||
switch (opcode)
|
|
||||||
{
|
|
||||||
case audioMasterVersion:
|
|
||||||
return 2400;
|
|
||||||
case audioMasterGetSampleRate:
|
|
||||||
return inst ? inst->sampleRate : 44100;
|
|
||||||
case audioMasterGetBlockSize:
|
|
||||||
return inst ? inst->blockSize : 512;
|
|
||||||
case audioMasterAutomate:
|
|
||||||
{
|
|
||||||
// Plugin đổi param (user kéo knob trong editor) → callback lên host.
|
|
||||||
// Effect → instance qua map riêng (audio thread, không giữ g_mutex).
|
|
||||||
Instance* owner = nullptr;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
|
||||||
auto it = g_effect_map.find (effect);
|
|
||||||
if (it != g_effect_map.end ())
|
|
||||||
owner = it->second;
|
|
||||||
}
|
|
||||||
if (owner && owner->paramCb)
|
|
||||||
owner->paramCb (owner->handle, index, static_cast<double> (opt),
|
|
||||||
owner->paramCbUserdata);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
case audioMasterCurrentId:
|
|
||||||
case audioMasterGetLanguage:
|
|
||||||
return 0;
|
|
||||||
case audioMasterGetVendorString:
|
|
||||||
if (ptr)
|
|
||||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
|
||||||
return 1;
|
|
||||||
case audioMasterGetProductString:
|
|
||||||
if (ptr)
|
|
||||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
|
||||||
return 1;
|
|
||||||
case audioMasterGetVendorVersion:
|
|
||||||
return 1;
|
|
||||||
case audioMasterCanDo:
|
|
||||||
return 0;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// T12: audio thread callback — effProcessEvents (MIDI drain tu SPSC) +
|
|
||||||
// processReplacing. Chay tren WASAPI render thread: KHONG lock, KHONG cap phat.
|
|
||||||
bool vst2AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
|
||||||
float* outL, float* outR, int32 frames, void* userdata)
|
|
||||||
{
|
|
||||||
Instance* inst = static_cast<Instance*> (userdata);
|
|
||||||
if (!inst || !inst->effect)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (eventCount > 0)
|
|
||||||
{
|
|
||||||
int32 n = eventCount < 256 ? eventCount : 256;
|
|
||||||
VstMidiEvent midi[256];
|
|
||||||
VstEvent* ptrs[256];
|
|
||||||
struct VstEventsBuf { int32 numEvents; intptr_t reserved; VstEvent* events[256]; };
|
|
||||||
VstEventsBuf buf = {};
|
|
||||||
buf.numEvents = n;
|
|
||||||
for (int32 i = 0; i < n; ++i)
|
|
||||||
{
|
|
||||||
VstMidiEvent& m = midi[i];
|
|
||||||
std::memset (&m, 0, sizeof (m));
|
|
||||||
m.type = kVstMidiType;
|
|
||||||
m.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
|
||||||
m.deltaFrames = 0;
|
|
||||||
m.flags = kVstMidiEventIsRealtime;
|
|
||||||
m.midiData[0] = static_cast<char> ((events[i].noteOn ? 0x90 : 0x80) |
|
|
||||||
(events[i].channel & 0x0F));
|
|
||||||
m.midiData[1] = static_cast<char> (events[i].pitch & 0x7F);
|
|
||||||
m.midiData[2] = events[i].noteOn
|
|
||||||
? static_cast<char> (static_cast<int> (events[i].velocity * 127.0f) & 0x7F)
|
|
||||||
: 0;
|
|
||||||
ptrs[i] = reinterpret_cast<VstEvent*> (&m);
|
|
||||||
buf.events[i] = ptrs[i];
|
|
||||||
}
|
|
||||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0,
|
|
||||||
reinterpret_cast<VstEvents*> (&buf), 0.0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inst->effect->processReplacing)
|
|
||||||
{
|
|
||||||
float* outs[2] = { outL, outR };
|
|
||||||
inst->effect->processReplacing (inst->effect, nullptr, outs, frames);
|
|
||||||
inst->mixer.processInPlace (outL, outR, frames); // T13: gain/pan + limiter
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
|
|
||||||
// Load .dll VST2, khởi tạo plugin, mở editor (nếu có) vào parent_hwnd.
|
|
||||||
// module_path_utf8: đường dẫn .dll (UTF-8). Trả handle (>= 1) hoặc 0 + err.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_Load (const char* module_path_utf8,
|
|
||||||
HWND parent_hwnd,
|
|
||||||
int32* out_w,
|
|
||||||
int32* out_h,
|
|
||||||
char* err,
|
|
||||||
int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
if (!module_path_utf8)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "null arg");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto inst = std::make_unique<Instance> ();
|
|
||||||
inst->parentHwnd = parent_hwnd;
|
|
||||||
|
|
||||||
// UTF-8 -> wide cho LoadLibraryW
|
|
||||||
int wlen = MultiByteToWideChar (CP_UTF8, 0, module_path_utf8, -1, nullptr, 0);
|
|
||||||
if (wlen <= 0)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "bad utf8 path");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
std::vector<wchar_t> wpath (static_cast<size_t> (wlen));
|
|
||||||
MultiByteToWideChar (CP_UTF8, 0, module_path_utf8, -1, wpath.data (), wlen);
|
|
||||||
|
|
||||||
inst->module = LoadLibraryW (wpath.data ());
|
|
||||||
if (!inst->module)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "LoadLibrary failed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto mainFn = reinterpret_cast<VSTPluginMainFn> (GetProcAddress (inst->module, "VSTPluginMain"));
|
|
||||||
if (!mainFn)
|
|
||||||
{
|
|
||||||
auto oldFn = reinterpret_cast<VSTPluginMainOldFn> (GetProcAddress (inst->module, "main"));
|
|
||||||
if (oldFn)
|
|
||||||
{
|
|
||||||
tls_loading = inst.get ();
|
|
||||||
inst->effect = oldFn (reinterpret_cast<void*> (&hostAudioMasterImpl), nullptr);
|
|
||||||
tls_loading = nullptr;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
FreeLibrary (inst->module);
|
|
||||||
set_err (err, err_cap, "no VSTPluginMain/main export");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tls_loading = inst.get ();
|
|
||||||
inst->effect = mainFn (reinterpret_cast<void*> (&hostAudioMasterImpl));
|
|
||||||
tls_loading = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inst->effect || inst->effect->magic != CCONST ('V', 's', 't', 'P'))
|
|
||||||
{
|
|
||||||
FreeLibrary (inst->module);
|
|
||||||
set_err (err, err_cap, "not a valid VST2 effect");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
inst->numInputs = inst->effect->numInputs ? inst->effect->numInputs (inst->effect) : 0;
|
|
||||||
inst->numOutputs = inst->effect->numOutputs ? inst->effect->numOutputs (inst->effect) : 2;
|
|
||||||
|
|
||||||
// Khởi tạo dispatcher theo thứ tự chuẩn
|
|
||||||
inst->effect->dispatcher (inst->effect, effOpen, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->effect->dispatcher (inst->effect, effSetSampleRate, 0, 0, nullptr,
|
|
||||||
static_cast<float> (inst->sampleRate));
|
|
||||||
inst->effect->dispatcher (inst->effect, effSetBlockSize, 0, inst->blockSize, nullptr, 0.0f);
|
|
||||||
inst->effect->dispatcher (inst->effect, effMainsChanged, 0, 1, nullptr, 0.0f);
|
|
||||||
inst->effect->dispatcher (inst->effect, effStartProcess, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->active = true;
|
|
||||||
|
|
||||||
// Editor GUI
|
|
||||||
if (parent_hwnd)
|
|
||||||
{
|
|
||||||
VstIntPtr hasEditor = inst->effect->dispatcher (inst->effect, effEditGetRect, 0, 0,
|
|
||||||
&inst->editorRect, 0.0f);
|
|
||||||
if (hasEditor == 1 && inst->editorRect)
|
|
||||||
{
|
|
||||||
inst->effect->dispatcher (inst->effect, effEditOpen, 0, 0,
|
|
||||||
reinterpret_cast<void*> (parent_hwnd), 0.0f);
|
|
||||||
inst->editorOpen = true;
|
|
||||||
if (out_w)
|
|
||||||
*out_w = inst->editorRect->right - inst->editorRect->left;
|
|
||||||
if (out_h)
|
|
||||||
*out_h = inst->editorRect->bottom - inst->editorRect->top;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (out_w)
|
|
||||||
*out_w = 0;
|
|
||||||
if (out_h)
|
|
||||||
*out_h = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int32 handle = g_next_handle++;
|
|
||||||
inst->handle = handle;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
|
||||||
g_effect_map[inst->effect] = inst.get ();
|
|
||||||
}
|
|
||||||
g_instances[handle] = std::move (inst);
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đóng: effStopProcess + effMainsChanged(0) + effEditClose + effClose + FreeLibrary.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_Close (int32 handle, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "bad handle");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
// T12: dung audio loop (join render thread) truoc khi pha huy plugin.
|
|
||||||
inst->audioRunning.store (false, std::memory_order_release);
|
|
||||||
inst->audio.stop ();
|
|
||||||
if (inst->effect)
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_effect_map_mutex);
|
|
||||||
g_effect_map.erase (inst->effect);
|
|
||||||
}
|
|
||||||
if (inst->active)
|
|
||||||
{
|
|
||||||
inst->effect->dispatcher (inst->effect, effStopProcess, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->effect->dispatcher (inst->effect, effMainsChanged, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->active = false;
|
|
||||||
}
|
|
||||||
if (inst->editorOpen)
|
|
||||||
{
|
|
||||||
inst->effect->dispatcher (inst->effect, effEditClose, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->editorOpen = false;
|
|
||||||
}
|
|
||||||
inst->effect->dispatcher (inst->effect, effClose, 0, 0, nullptr, 0.0f);
|
|
||||||
inst->effect = nullptr;
|
|
||||||
}
|
|
||||||
if (inst->module)
|
|
||||||
{
|
|
||||||
FreeLibrary (inst->module);
|
|
||||||
inst->module = nullptr;
|
|
||||||
}
|
|
||||||
g_instances.erase (it);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kích thước editor hiện tại.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_GetSize (int32 handle, int32* out_w, int32* out_h)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (inst->editorRect)
|
|
||||||
{
|
|
||||||
if (out_w)
|
|
||||||
*out_w = inst->editorRect->right - inst->editorRect->left;
|
|
||||||
if (out_h)
|
|
||||||
*out_h = inst->editorRect->bottom - inst->editorRect->top;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Báo editor: window cha đã resize — plugin cập nhật vị trí nội bộ.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_Resize (int32 handle, int32 w, int32 h)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (inst->effect && inst->editorOpen)
|
|
||||||
{
|
|
||||||
inst->effect->dispatcher (inst->effect, effSetViewPosition, 0, 0, nullptr, 0.0f);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MIDI note-on qua effProcessEvents (chỉ gọi từ UI/main thread — spec 5.7.3).
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SendNoteOn (int32 handle, int32 channel, int32 pitch,
|
|
||||||
int32 velocity)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect)
|
|
||||||
return -2;
|
|
||||||
|
|
||||||
// T12: audio loop dang chay -> day vao SPSC (audio thread xu ly).
|
|
||||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
|
||||||
ev.noteOn = true;
|
|
||||||
inst->audio.pushMidi (ev);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char midiData[4] = {0};
|
|
||||||
midiData[0] = static_cast<char> (0x90 | (channel & 0x0F));
|
|
||||||
midiData[1] = static_cast<char> (pitch & 0x7F);
|
|
||||||
midiData[2] = static_cast<char> (velocity & 0x7F);
|
|
||||||
|
|
||||||
VstMidiEvent midiEvent = {};
|
|
||||||
midiEvent.type = kVstMidiType;
|
|
||||||
midiEvent.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
|
||||||
midiEvent.deltaFrames = 0;
|
|
||||||
midiEvent.flags = kVstMidiEventIsRealtime;
|
|
||||||
std::memcpy (midiEvent.midiData, midiData, 4);
|
|
||||||
|
|
||||||
VstEvent* eventPtr = reinterpret_cast<VstEvent*> (&midiEvent);
|
|
||||||
VstEvents events = {};
|
|
||||||
events.numEvents = 1;
|
|
||||||
events.events[0] = eventPtr;
|
|
||||||
|
|
||||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0, &events, 0.0f);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MIDI note-off qua effProcessEvents.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SendNoteOff (int32 handle, int32 channel, int32 pitch)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect)
|
|
||||||
return -2;
|
|
||||||
|
|
||||||
// T12: audio loop dang chay -> day vao SPSC.
|
|
||||||
if (inst->audioRunning.load (std::memory_order_acquire))
|
|
||||||
{
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = 0.0f;
|
|
||||||
ev.noteOn = false;
|
|
||||||
inst->audio.pushMidi (ev);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char midiData[4] = {0};
|
|
||||||
midiData[0] = static_cast<char> (0x80 | (channel & 0x0F));
|
|
||||||
midiData[1] = static_cast<char> (pitch & 0x7F);
|
|
||||||
midiData[2] = 0;
|
|
||||||
|
|
||||||
VstMidiEvent midiEvent = {};
|
|
||||||
midiEvent.type = kVstMidiType;
|
|
||||||
midiEvent.byteSize = static_cast<int32> (sizeof (VstMidiEvent));
|
|
||||||
midiEvent.deltaFrames = 0;
|
|
||||||
midiEvent.flags = kVstMidiEventIsRealtime;
|
|
||||||
std::memcpy (midiEvent.midiData, midiData, 4);
|
|
||||||
|
|
||||||
VstEvent* eventPtr = reinterpret_cast<VstEvent*> (&midiEvent);
|
|
||||||
VstEvents events = {};
|
|
||||||
events.numEvents = 1;
|
|
||||||
events.events[0] = eventPtr;
|
|
||||||
|
|
||||||
inst->effect->dispatcher (inst->effect, effProcessEvents, 0, 0, &events, 0.0f);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process float32 qua processReplacing. buffers: input[m][n] + output[m][n]
|
|
||||||
// liền nhau, channels = max(numInputs, numOutputs). Trả 0 nếu OK.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_Process (int32 handle, float* buffers, int32 channels,
|
|
||||||
int32 sampleFrames)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect || !buffers || sampleFrames <= 0)
|
|
||||||
return -2;
|
|
||||||
|
|
||||||
int32 total = inst->numInputs + inst->numOutputs;
|
|
||||||
if (channels < total)
|
|
||||||
return -3;
|
|
||||||
|
|
||||||
std::vector<float*> ptrs (static_cast<size_t> (total));
|
|
||||||
for (int32 i = 0; i < total; ++i)
|
|
||||||
ptrs[static_cast<size_t> (i)] = buffers + static_cast<size_t> (i) * sampleFrames;
|
|
||||||
|
|
||||||
if (inst->effect->processReplacing)
|
|
||||||
{
|
|
||||||
inst->effect->processReplacing (inst->effect,
|
|
||||||
inst->numInputs > 0 ? ptrs.data () : nullptr,
|
|
||||||
ptrs.data () + inst->numInputs,
|
|
||||||
sampleFrames);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đăng ký callback param (T11): plugin đổi param (audioMasterAutomate) →
|
|
||||||
// cb(handle, paramIndex, valueNormalized, userdata).
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SetParamCallback (int32 handle,
|
|
||||||
SF_ParamChangedCallback cb,
|
|
||||||
void* userdata)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->paramCb = cb;
|
|
||||||
it->second->paramCbUserdata = userdata;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JS automation → setParameter (T11).
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SetParam (int32 handle, int32 paramIndex,
|
|
||||||
double valueNormalized)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect || !inst->effect->setParameter)
|
|
||||||
return -2;
|
|
||||||
inst->effect->setParameter (inst->effect, paramIndex,
|
|
||||||
static_cast<float> (valueNormalized));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Số tham số plugin.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_GetParamCount (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect || !inst->effect->numParams)
|
|
||||||
return -2;
|
|
||||||
return inst->effect->numParams (inst->effect);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Giá trị param (0..1) — JS đọc để dựng UI.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_GetParam (int32 handle, int32 paramIndex, double* out_value)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect || !inst->effect->getParameter)
|
|
||||||
return -2;
|
|
||||||
if (out_value)
|
|
||||||
*out_value = static_cast<double> (inst->effect->getParameter (inst->effect, paramIndex));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T12: bat native audio loop (SPSC + WASAPI) cho instance nay. Audio thread
|
|
||||||
// goi vst2AudioProcess (effProcessEvents + processReplacing) moi block.
|
|
||||||
__declspec (dllexport) int32 SF_VST2_AudioStart (int32 handle, int32 sampleRate,
|
|
||||||
int32 blockSize, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->effect)
|
|
||||||
return -2;
|
|
||||||
inst->audioRunning.store (true, std::memory_order_release); // truoc khi thread chay
|
|
||||||
if (!inst->audio.start (sampleRate, blockSize, &vst2AudioProcess, inst, err, err_cap))
|
|
||||||
{
|
|
||||||
inst->audioRunning.store (false, std::memory_order_release);
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST2_AudioStop (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->audioRunning.store (false, std::memory_order_release);
|
|
||||||
it->second->audio.stop ();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST2_AudioUnderruns (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.underruns ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST2_AudioLatency (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.latencySamples ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST2_AudioBlocks (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.blocksRendered ();
|
|
||||||
}
|
|
||||||
|
|
||||||
// T13: track gain/pan + master limiter (NativeMixer). Set truoc AudioStart;
|
|
||||||
// audio thread doc state nay (khong lock).
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip).
|
|
||||||
__declspec (dllexport) int32 SF_VST2_SetMasterGain (int32 handle, float gainLinear)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setMasterGain (gainLinear);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // extern "C"
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Tái tạo native_host/fluidsynth_runtime/ — tập DLL runtime cho SFHost (T14).
|
|
||||||
|
|
||||||
FluidSynth native bridge (SFHost.cpp) load libfluidsynth-3.dll + dependency
|
|
||||||
closure từ thư mục này (gitignore — KHÔNG commit DLL ~13MB vào git).
|
|
||||||
|
|
||||||
Cách dùng:
|
|
||||||
python scripts/dl_fluidsynth_runtime.py [--out native_host/fluidsynth_runtime]
|
|
||||||
|
|
||||||
Tải package MSYS2 mingw64 (repo.msys2.org), giải nén, copy closure DLL.
|
|
||||||
Cần network; không cần MSYS2/vcpkg cài sẵn.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
import urllib.request
|
|
||||||
import zstandard
|
|
||||||
|
|
||||||
BASE = "https://repo.msys2.org/mingw/mingw64/"
|
|
||||||
DEFAULT_OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
"fluidsynth_runtime")
|
|
||||||
|
|
||||||
# Package chứa closure DLL của libfluidsynth-3.dll (FluidSynth 2.5.6, MSYS2 mingw64).
|
|
||||||
PACKAGES = [
|
|
||||||
"mingw-w64-x86_64-fluidsynth",
|
|
||||||
"mingw-w64-x86_64-glib2",
|
|
||||||
"mingw-w64-x86_64-libsndfile",
|
|
||||||
"mingw-w64-x86_64-gcc-libs",
|
|
||||||
"mingw-w64-x86_64-libiconv",
|
|
||||||
"mingw-w64-x86_64-gettext-runtime",
|
|
||||||
"mingw-w64-x86_64-pcre2",
|
|
||||||
"mingw-w64-x86_64-zlib-ng-compat",
|
|
||||||
"mingw-w64-x86_64-libffi",
|
|
||||||
"mingw-w64-x86_64-libogg",
|
|
||||||
"mingw-w64-x86_64-libvorbis",
|
|
||||||
"mingw-w64-x86_64-flac",
|
|
||||||
"mingw-w64-x86_64-mpg123",
|
|
||||||
"mingw-w64-x86_64-opus",
|
|
||||||
"mingw-w64-x86_64-xz",
|
|
||||||
"mingw-w64-x86_64-lame",
|
|
||||||
"mingw-w64-x86_64-wavpack",
|
|
||||||
"mingw-w64-x86_64-libwinpthread",
|
|
||||||
"mingw-w64-x86_64-portaudio",
|
|
||||||
"mingw-w64-x86_64-readline",
|
|
||||||
"mingw-w64-x86_64-termcap",
|
|
||||||
"mingw-w64-x86_64-sdl3",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Closure DLL (xác minh bằng dumpbin /dependents BFS, 22 file).
|
|
||||||
# ponytail: closure snapshot theo FluidSynth 2.5.6 mingw64 — nếu upgrade
|
|
||||||
# libfluidsynth, chạy lại BFS dumpbin để cập nhật list này.
|
|
||||||
NEEDED = [
|
|
||||||
"libfluidsynth-3.dll",
|
|
||||||
"libglib-2.0-0.dll",
|
|
||||||
"libgmodule-2.0-0.dll",
|
|
||||||
"libgomp-1.dll",
|
|
||||||
"libgcc_s_seh-1.dll",
|
|
||||||
"libstdc++-6.dll",
|
|
||||||
"libsndfile-1.dll",
|
|
||||||
"libFLAC.dll",
|
|
||||||
"libogg-0.dll",
|
|
||||||
"libvorbis-0.dll",
|
|
||||||
"libvorbisenc-2.dll",
|
|
||||||
"libopus-0.dll",
|
|
||||||
"libmpg123-0.dll",
|
|
||||||
"libmp3lame-0.dll",
|
|
||||||
"libiconv-2.dll",
|
|
||||||
"libintl-8.dll",
|
|
||||||
"libpcre2-8-0.dll",
|
|
||||||
"libwinpthread-1.dll",
|
|
||||||
"libportaudio.dll",
|
|
||||||
"libreadline8.dll",
|
|
||||||
"libtermcap-0.dll",
|
|
||||||
"SDL3.dll",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def latest(files, prefix):
|
|
||||||
cands = [f for f in files
|
|
||||||
if f.startswith(prefix + "-") and f.endswith(".pkg.tar.zst") and ".sig" not in f]
|
|
||||||
return sorted(cands)[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--out", default=DEFAULT_OUT)
|
|
||||||
ap.add_argument("--cache", default=None, help="thư mục chứa .pkg.tar.zst đã tải")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
cache = args.cache or tempfile.mkdtemp(prefix="msys2_pkgs_")
|
|
||||||
stage = tempfile.mkdtemp(prefix="msys2_stage_")
|
|
||||||
|
|
||||||
print("== liệt kê mirror")
|
|
||||||
html = urllib.request.urlopen(BASE, timeout=60).read().decode("utf-8", "ignore")
|
|
||||||
files = re.findall(r'href="([^"]+\.pkg\.tar\.zst)"', html)
|
|
||||||
|
|
||||||
dctx = zstandard.ZstdDecompressor()
|
|
||||||
for pkg in PACKAGES:
|
|
||||||
fname = latest(files, pkg)
|
|
||||||
path = os.path.join(cache, fname)
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print("== tải", fname)
|
|
||||||
urllib.request.urlretrieve(BASE + fname, path)
|
|
||||||
else:
|
|
||||||
print("== có sẵn", fname)
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
with dctx.stream_reader(f) as r:
|
|
||||||
with tarfile.open(fileobj=r, mode="r|") as tf:
|
|
||||||
for m in tf:
|
|
||||||
if m.isfile():
|
|
||||||
tf.extract(m, stage, filter="data")
|
|
||||||
|
|
||||||
bin_dir = os.path.join(stage, "mingw64", "bin")
|
|
||||||
os.makedirs(args.out, exist_ok=True)
|
|
||||||
missing = []
|
|
||||||
for dll in NEEDED:
|
|
||||||
src = os.path.join(bin_dir, dll)
|
|
||||||
if not os.path.exists(src):
|
|
||||||
missing.append(dll)
|
|
||||||
continue
|
|
||||||
shutil.copy2(src, os.path.join(args.out, dll))
|
|
||||||
print("== copy", dll)
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
raise SystemExit("THIẾU DLL trong stage: " + ", ".join(missing))
|
|
||||||
print("OK —", len(NEEDED), "DLL ->", args.out)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// fake_vst2.cpp — test plugin VST2 tối thiểu để verify bridge T10.
|
|
||||||
// Sine 440Hz stereo qua processReplacing; editor trả rect giả.
|
|
||||||
#define NOMINMAX
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#include <windows.h>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstring>
|
|
||||||
#include "vestige.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
struct FakeState
|
|
||||||
{
|
|
||||||
float phase = 0.0f;
|
|
||||||
int32 sampleRate = 44100;
|
|
||||||
float gain = 0.5f; // param 0 — "Gain" (0..1)
|
|
||||||
};
|
|
||||||
|
|
||||||
FakeState g_state;
|
|
||||||
void* g_audioMaster = nullptr;
|
|
||||||
|
|
||||||
void VSTCALLBACK fakeSetParameter (AEffect* effect, int32 index, float value)
|
|
||||||
{
|
|
||||||
if (index == 0)
|
|
||||||
{
|
|
||||||
g_state.gain = value;
|
|
||||||
// Báo host: param đổi từ editor/plugin (T11) — chống loop host-side.
|
|
||||||
if (g_audioMaster)
|
|
||||||
{
|
|
||||||
typedef VstIntPtr (VSTCALLBACK* AMFn) (AEffect*, int32, int32, VstIntPtr, void*, float);
|
|
||||||
reinterpret_cast<AMFn> (g_audioMaster) (effect, audioMasterAutomate, index, 0, nullptr,
|
|
||||||
value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
float VSTCALLBACK fakeGetParameter (AEffect*, int32 index)
|
|
||||||
{
|
|
||||||
if (index == 0)
|
|
||||||
return g_state.gain;
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int32 VSTCALLBACK fakeDispatcher (AEffect* effect, int32 opcode, int32 index,
|
|
||||||
VstIntPtr value, void* ptr, float opt)
|
|
||||||
{
|
|
||||||
(void) index;
|
|
||||||
(void) value;
|
|
||||||
(void) ptr;
|
|
||||||
(void) opt;
|
|
||||||
switch (opcode)
|
|
||||||
{
|
|
||||||
case effOpen:
|
|
||||||
return 1;
|
|
||||||
case effClose:
|
|
||||||
return 1;
|
|
||||||
case effSetSampleRate:
|
|
||||||
g_state.sampleRate = static_cast<int32> (opt);
|
|
||||||
return 1;
|
|
||||||
case effMainsChanged:
|
|
||||||
return 1;
|
|
||||||
case effStartProcess:
|
|
||||||
case effStopProcess:
|
|
||||||
return 1;
|
|
||||||
case effEditGetRect:
|
|
||||||
{
|
|
||||||
static ERect rect = {0, 0, 300, 400};
|
|
||||||
*reinterpret_cast<ERect**> (ptr) = ▭
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
case effEditOpen:
|
|
||||||
return 1;
|
|
||||||
case effEditClose:
|
|
||||||
return 1;
|
|
||||||
case effProcessEvents:
|
|
||||||
return 1;
|
|
||||||
case effGetEffectName:
|
|
||||||
std::strncpy (static_cast<char*> (ptr), "FakeVST2", 64);
|
|
||||||
return 1;
|
|
||||||
case effGetVendorString:
|
|
||||||
std::strncpy (static_cast<char*> (ptr), "SonicForgeStudio", 64);
|
|
||||||
return 1;
|
|
||||||
case effGetProductString:
|
|
||||||
std::strncpy (static_cast<char*> (ptr), "FakeVST2", 64);
|
|
||||||
return 1;
|
|
||||||
case effGetVendorVersion:
|
|
||||||
return 1;
|
|
||||||
case effIdentify:
|
|
||||||
return CCONST ('N', 'v', 'E', 'f');
|
|
||||||
case effCanDo:
|
|
||||||
return 0;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void VSTCALLBACK fakeProcessReplacing (AEffect*, float** inputs, float** outputs,
|
|
||||||
int32 sampleFrames)
|
|
||||||
{
|
|
||||||
(void) inputs;
|
|
||||||
float* outL = outputs[0];
|
|
||||||
float* outR = outputs[1];
|
|
||||||
const float freq = 440.0f;
|
|
||||||
const float dt = freq / static_cast<float> (g_state.sampleRate);
|
|
||||||
for (int32 i = 0; i < sampleFrames; ++i)
|
|
||||||
{
|
|
||||||
float v = 0.25f * std::sin (2.0f * 3.14159265f * g_state.phase);
|
|
||||||
g_state.phase += dt;
|
|
||||||
if (g_state.phase >= 1.0f)
|
|
||||||
g_state.phase -= 1.0f;
|
|
||||||
outL[i] = v;
|
|
||||||
outR[i] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AEffect g_effect;
|
|
||||||
|
|
||||||
AEffect* VSTCALLBACK createInstance (void* audioMaster)
|
|
||||||
{
|
|
||||||
g_audioMaster = audioMaster;
|
|
||||||
std::memset (&g_effect, 0, sizeof (g_effect));
|
|
||||||
g_effect.magic = CCONST ('V', 's', 't', 'P');
|
|
||||||
g_effect.dispatcher = &fakeDispatcher;
|
|
||||||
g_effect.processReplacing = &fakeProcessReplacing;
|
|
||||||
g_effect.setParameter = &fakeSetParameter;
|
|
||||||
g_effect.getParameter = &fakeGetParameter;
|
|
||||||
g_effect.numInputs = [] (AEffect*) -> int32 { return 0; };
|
|
||||||
g_effect.numOutputs = [] (AEffect*) -> int32 { return 2; };
|
|
||||||
g_effect.numParams = [] (AEffect*) -> int32 { return 1; };
|
|
||||||
g_effect.numPrograms = [] (AEffect*) -> int32 { return 0; };
|
|
||||||
g_effect.flags = [] (AEffect*) -> int32 { return effFlagsCanReplacing | effFlagsIsSynth; };
|
|
||||||
g_effect.uniqueID = CCONST ('F', 'k', '2', 'V');
|
|
||||||
g_effect.version = 1;
|
|
||||||
return &g_effect;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
extern "C" __declspec (dllexport) AEffect* VSTPluginMain (void* audioMaster)
|
|
||||||
{
|
|
||||||
return createInstance (audioMaster);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// native_mixer_test.cpp — golden test driver cho NativeMixer (T13).
|
|
||||||
//
|
|
||||||
// Đọc input raw float32 interleaved stereo, áp NativeMixer (track gain/pan +
|
|
||||||
// master limiter) theo argv, ghi output raw float32 interleaved. Python
|
|
||||||
// (test_native_mixer_golden.py) so RMS/peak với reference server pipeline.
|
|
||||||
//
|
|
||||||
// Usage: native_mixer_test.exe <in.raw> <out.raw> <gainDb> <pan> <limActive> <thresholdDb>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "NativeMixer.h"
|
|
||||||
|
|
||||||
using sonicforge::int32;
|
|
||||||
|
|
||||||
int main (int argc, char** argv)
|
|
||||||
{
|
|
||||||
if (argc < 7)
|
|
||||||
{
|
|
||||||
std::fprintf (stderr, "usage: native_mixer_test in.raw out.raw gainDb pan limActive thresholdDb\n");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
const char* inPath = argv[1];
|
|
||||||
const char* outPath = argv[2];
|
|
||||||
const float gainDb = static_cast<float> (std::atof (argv[3]));
|
|
||||||
const float pan = static_cast<float> (std::atof (argv[4]));
|
|
||||||
const int32 limActive = std::atoi (argv[5]);
|
|
||||||
const float thresholdDb = static_cast<float> (std::atof (argv[6]));
|
|
||||||
|
|
||||||
FILE* f = std::fopen (inPath, "rb");
|
|
||||||
if (!f)
|
|
||||||
{
|
|
||||||
std::fprintf (stderr, "cannot open %s\n", inPath);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
std::fseek (f, 0, SEEK_END);
|
|
||||||
const long nBytes = std::ftell (f);
|
|
||||||
std::fseek (f, 0, SEEK_SET);
|
|
||||||
if (nBytes <= 0 || (nBytes % 8) != 0)
|
|
||||||
{
|
|
||||||
std::fprintf (stderr, "bad input size %ld\n", nBytes);
|
|
||||||
std::fclose (f);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
std::vector<float> buf (static_cast<size_t> (nBytes) / sizeof (float));
|
|
||||||
if (std::fread (buf.data (), sizeof (float), buf.size (), f) != buf.size ())
|
|
||||||
{
|
|
||||||
std::fprintf (stderr, "short read\n");
|
|
||||||
std::fclose (f);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
std::fclose (f);
|
|
||||||
|
|
||||||
const int32 frames = static_cast<int32> (buf.size () / 2);
|
|
||||||
std::vector<float> inL (static_cast<size_t> (frames));
|
|
||||||
std::vector<float> inR (static_cast<size_t> (frames));
|
|
||||||
for (int32 i = 0; i < frames; ++i)
|
|
||||||
{
|
|
||||||
inL[static_cast<size_t> (i)] = buf[static_cast<size_t> (2 * i)];
|
|
||||||
inR[static_cast<size_t> (i)] = buf[static_cast<size_t> (2 * i + 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
sonicforge::NativeMixer mixer;
|
|
||||||
mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
|
||||||
mixer.setLimiter (limActive != 0, thresholdDb);
|
|
||||||
mixer.processInPlace (inL.data (), inR.data (), frames);
|
|
||||||
|
|
||||||
for (int32 i = 0; i < frames; ++i)
|
|
||||||
{
|
|
||||||
buf[static_cast<size_t> (2 * i)] = inL[static_cast<size_t> (i)];
|
|
||||||
buf[static_cast<size_t> (2 * i + 1)] = inR[static_cast<size_t> (i)];
|
|
||||||
}
|
|
||||||
|
|
||||||
FILE* g = std::fopen (outPath, "wb");
|
|
||||||
if (!g)
|
|
||||||
{
|
|
||||||
std::fprintf (stderr, "cannot write %s\n", outPath);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
std::fwrite (buf.data (), sizeof (float), buf.size (), g);
|
|
||||||
std::fclose (g);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""Golden test T13 — NativeMixer mirror server render pipeline.
|
|
||||||
|
|
||||||
So sanh output native mixer (native_mixer_test.exe) voi reference dung chinh
|
|
||||||
code path cua server `render_project`:
|
|
||||||
- track gain/pan: cong thuc render_engine.py (10^(dB/20), constant-power pan)
|
|
||||||
- master limiter: app.core.mastering_engine.apply_mastering (module limiter)
|
|
||||||
+ hard clip [-1,1] nhu render_project sau apply_mastering
|
|
||||||
|
|
||||||
Yeu cau: |RMS| va |peak| sai lech < 0.1 dB moi case, moi kenh.
|
|
||||||
|
|
||||||
Chay: python native_host/tests/test_native_mixer_golden.py
|
|
||||||
(tu repo root; can native_mixer_test.exe da build + numpy/scipy)
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
EXE = os.path.join(ROOT, "native_host", "tests", "native_mixer_test.exe")
|
|
||||||
SR = 44100
|
|
||||||
|
|
||||||
sys.path.insert(0, ROOT)
|
|
||||||
from app.core.mastering_engine import apply_mastering # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def make_signal(n=SR):
|
|
||||||
t = np.arange(n) / SR
|
|
||||||
sig = (
|
|
||||||
np.sin(2 * np.pi * 220 * t)
|
|
||||||
+ 0.5 * np.sin(2 * np.pi * 440 * t + 0.3)
|
|
||||||
+ 0.3 * np.sin(2 * np.pi * 880 * t + 1.1)
|
|
||||||
)
|
|
||||||
env = np.minimum(1.0, t * 20.0) * np.exp(-t * 0.8)
|
|
||||||
sig *= env
|
|
||||||
rng = np.random.default_rng(1234)
|
|
||||||
sig = sig + rng.standard_normal(n) * 0.05
|
|
||||||
# spike de limiter clip ro rang
|
|
||||||
for i in range(0, n, 22050):
|
|
||||||
sig[i] += 3.0
|
|
||||||
return np.stack([sig, np.roll(sig, 100)])
|
|
||||||
|
|
||||||
|
|
||||||
def ref_process(x, gain_db, pan, lim_active, th_db):
|
|
||||||
y = x * (10.0 ** (gain_db / 20.0))
|
|
||||||
if pan != 0.0:
|
|
||||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
|
||||||
y = y.copy()
|
|
||||||
y[0, :] *= np.cos(theta)
|
|
||||||
y[1, :] *= np.sin(theta)
|
|
||||||
if lim_active:
|
|
||||||
settings = {
|
|
||||||
"masterConnected": True,
|
|
||||||
"isBypassed": False,
|
|
||||||
"chain": [{"type": "limiter", "active": True}],
|
|
||||||
"limActive": True,
|
|
||||||
"limThreshold": th_db,
|
|
||||||
}
|
|
||||||
y = apply_mastering(y, settings, SR)
|
|
||||||
np.clip(y, -1.0, 1.0, out=y)
|
|
||||||
return y
|
|
||||||
|
|
||||||
|
|
||||||
def run_native(x, gain_db, pan, lim_active, th_db, tmp):
|
|
||||||
inp = os.path.join(tmp, "in.raw")
|
|
||||||
outp = os.path.join(tmp, "out.raw")
|
|
||||||
with open(inp, "wb") as f:
|
|
||||||
f.write(x.T.astype(np.float32).tobytes())
|
|
||||||
args = [EXE, inp, outp, str(gain_db), str(pan), str(int(lim_active)), str(th_db)]
|
|
||||||
subprocess.run(args, check=True)
|
|
||||||
return np.fromfile(outp, dtype=np.float32).reshape(-1, 2).T
|
|
||||||
|
|
||||||
|
|
||||||
def db_rms_peak(y):
|
|
||||||
rms = 20.0 * np.log10(np.sqrt(np.mean(y * y, axis=1)) + 1e-12)
|
|
||||||
peak = 20.0 * np.log10(np.max(np.abs(y), axis=1) + 1e-12)
|
|
||||||
return rms, peak
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if not os.path.exists(EXE):
|
|
||||||
print(f"FAIL: {EXE} not found (build native_mixer_test.exe truoc)")
|
|
||||||
return 1
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
x = make_signal()
|
|
||||||
cases = [
|
|
||||||
(0.0, 0.0, False, -1.0), # identity (limiter off)
|
|
||||||
(6.0, -0.5, False, -1.0), # gain/pan, khong limiter
|
|
||||||
(6.0, -0.5, True, -3.0),
|
|
||||||
(-9.0, 0.8, True, -6.0),
|
|
||||||
(12.0, 0.0, True, 0.0),
|
|
||||||
]
|
|
||||||
worst = 0.0
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
for i, (g, p, la, th) in enumerate(cases):
|
|
||||||
native = run_native(x, g, p, la, th, tmp)
|
|
||||||
ref = ref_process(x, g, p, la, th)
|
|
||||||
nr, npk = db_rms_peak(native)
|
|
||||||
rr, rpk = db_rms_peak(ref)
|
|
||||||
dr = np.max(np.abs(nr - rr))
|
|
||||||
dp = np.max(np.abs(npk - rpk))
|
|
||||||
worst = max(worst, dr, dp)
|
|
||||||
status = "OK " if (dr < 0.1 and dp < 0.1) else "FAIL"
|
|
||||||
print(f"case {i} gain={g} pan={p} lim={int(la)} th={th}: "
|
|
||||||
f"RMS diff={dr:.4f} dB peak diff={dp:.4f} dB [{status}]")
|
|
||||||
if dr >= 0.1 or dp >= 0.1:
|
|
||||||
print(f" native rms={nr} peak={npk}")
|
|
||||||
print(f" ref rms={rr} peak={rpk}")
|
|
||||||
print(f"worst diff: {worst:.4f} dB (< 0.1 -> PASS)")
|
|
||||||
return 0 if worst < 0.1 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Test smoke SFHost bridge (T14): FluidSynth native qua ctypes.
|
|
||||||
|
|
||||||
Chạy: python tests/test_sf_host_bridge.py [path_sf2]
|
|
||||||
Yêu cầu: native_host/build/Release/sf_host_bridge.dll + fluidsynth_runtime/.
|
|
||||||
Verify: Create → LoadSF2 → SelectInstrument → AudioStart → NoteOn → chờ
|
|
||||||
(blocks tăng, underruns 0) → NoteOff → AudioStop → Close; mixer set OK.
|
|
||||||
"""
|
|
||||||
import ctypes
|
|
||||||
import ctypes.wintypes as wt
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
DLL = os.path.join(ROOT, "build", "Release", "sf_host_bridge.dll")
|
|
||||||
SF2 = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
|
||||||
ROOT, "..", "app", "storage", "soundfonts",
|
|
||||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
|
||||||
|
|
||||||
assert os.path.exists(DLL), f"thiếu {DLL}"
|
|
||||||
assert os.path.exists(SF2), f"thiếu {SF2}"
|
|
||||||
|
|
||||||
dll = ctypes.WinDLL(DLL)
|
|
||||||
err = ctypes.create_string_buffer(256)
|
|
||||||
|
|
||||||
dll.SF_FS_Create.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_Create.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_LoadSF2.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_LoadSF2.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SelectInstrument.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
|
||||||
dll.SF_FS_SelectInstrument.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_NoteOn.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
|
||||||
dll.SF_FS_NoteOn.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_NoteOff.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
|
||||||
dll.SF_FS_NoteOff.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_AudioStart.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_AudioStart.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_AudioStop.argtypes = [ctypes.c_int32]
|
|
||||||
dll.SF_FS_AudioStop.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_AudioUnderruns.argtypes = [ctypes.c_int32]
|
|
||||||
dll.SF_FS_AudioUnderruns.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_AudioBlocks.argtypes = [ctypes.c_int32]
|
|
||||||
dll.SF_FS_AudioBlocks.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SetTrackGainPan.argtypes = [ctypes.c_int32, ctypes.c_float, ctypes.c_float]
|
|
||||||
dll.SF_FS_SetTrackGainPan.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SetMasterLimiter.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_float]
|
|
||||||
dll.SF_FS_SetMasterLimiter.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_Close.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_Close.restype = ctypes.c_int32
|
|
||||||
|
|
||||||
|
|
||||||
def check(name, rc, expect=0):
|
|
||||||
assert rc == expect, f"{name}: rc={rc} err={err.value.decode() if err.value else ''}"
|
|
||||||
print(f"OK {name}")
|
|
||||||
|
|
||||||
|
|
||||||
h = dll.SF_FS_Create(44100, 512, err, 256)
|
|
||||||
assert h > 0, f"Create trả {h}, err={err.value.decode() if err.value else ''}"
|
|
||||||
print(f"OK SF_FS_Create -> handle {h}")
|
|
||||||
|
|
||||||
check("SF_FS_LoadSF2", dll.SF_FS_LoadSF2(h, SF2.encode(), err, 256))
|
|
||||||
|
|
||||||
# Tìm preset đầu tiên tồn tại trong SF2 (SF2 tuỳ biến có thể không có 0/0).
|
|
||||||
sel = None
|
|
||||||
for bank in (0, 1, 128):
|
|
||||||
for prog in (0, 1, 12, 40, 80):
|
|
||||||
rc = dll.SF_FS_SelectInstrument(h, 0, -1, bank, prog)
|
|
||||||
if rc == 0:
|
|
||||||
sel = (bank, prog)
|
|
||||||
break
|
|
||||||
if sel:
|
|
||||||
break
|
|
||||||
assert sel, "không tìm được preset nào trong SF2"
|
|
||||||
print(f"OK SF_FS_SelectInstrument bank={sel[0]} program={sel[1]}")
|
|
||||||
check("SF_FS_SetTrackGainPan", dll.SF_FS_SetTrackGainPan(h, -6.0, 0.0))
|
|
||||||
check("SF_FS_SetMasterLimiter", dll.SF_FS_SetMasterLimiter(h, 1, -1.0))
|
|
||||||
|
|
||||||
check("SF_FS_AudioStart", dll.SF_FS_AudioStart(h, 44100, 512, err, 256))
|
|
||||||
b0 = dll.SF_FS_AudioBlocks(h)
|
|
||||||
check("SF_FS_NoteOn", dll.SF_FS_NoteOn(h, 0, 60, 100))
|
|
||||||
time.sleep(0.4)
|
|
||||||
b1 = dll.SF_FS_AudioBlocks(h)
|
|
||||||
u = dll.SF_FS_AudioUnderruns(h)
|
|
||||||
assert b1 > b0, f"blocks không tăng: {b0} -> {b1}"
|
|
||||||
assert u == 0, f"underruns={u}"
|
|
||||||
print(f"OK render blocks {b0} -> {b1}, underruns {u}")
|
|
||||||
|
|
||||||
check("SF_FS_NoteOff", dll.SF_FS_NoteOff(h, 0, 60))
|
|
||||||
time.sleep(0.2)
|
|
||||||
check("SF_FS_AudioStop", dll.SF_FS_AudioStop(h))
|
|
||||||
check("SF_FS_Close", dll.SF_FS_Close(h, err, 256))
|
|
||||||
print("PASS")
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""Golden test T14 — SFHost (FluidSynth native) mirror server render pipeline.
|
|
||||||
|
|
||||||
So sanh output SFHost.RenderBlock voi reference dung chinh code path server:
|
|
||||||
- FluidSynth: libfluidsynth-3.dll (write_float) — engine giong render_engine.py
|
|
||||||
(pyfluidsynth cung wrapper len cung lib)
|
|
||||||
- track gain/pan: cong thuc render_engine.py (10^(dB/20), constant-power pan)
|
|
||||||
- master limiter: app.core.mastering_engine.apply_mastering + clip [-1,1]
|
|
||||||
|
|
||||||
Yeu cau: |RMS| va |peak| sai lech < 0.1 dB moi case, moi kenh.
|
|
||||||
|
|
||||||
Chay: python native_host/tests/test_sf_host_golden.py [path_sf2]
|
|
||||||
(tu repo root; can build/Release/sf_host_bridge.dll + fluidsynth_runtime/)
|
|
||||||
"""
|
|
||||||
import ctypes
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
NATIVE = os.path.join(ROOT, "native_host")
|
|
||||||
DLL = os.path.join(NATIVE, "build", "Release", "sf_host_bridge.dll")
|
|
||||||
RUNTIME = os.path.join(NATIVE, "fluidsynth_runtime")
|
|
||||||
FSDLL = os.path.join(RUNTIME, "libfluidsynth-3.dll")
|
|
||||||
SF2 = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
|
||||||
ROOT, "app", "storage", "soundfonts",
|
|
||||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
|
||||||
SR = 44100
|
|
||||||
|
|
||||||
assert os.path.exists(DLL), f"thiếu {DLL} (build cmake Release truoc)"
|
|
||||||
assert os.path.exists(FSDLL), f"thiếu {FSDLL} (chay scripts/dl_fluidsynth_runtime.py)"
|
|
||||||
assert os.path.exists(SF2), f"thiếu {SF2}"
|
|
||||||
|
|
||||||
sys.path.insert(0, ROOT)
|
|
||||||
from app.core.mastering_engine import apply_mastering # noqa: E402
|
|
||||||
|
|
||||||
# preset ton tai trong SF2 (xem phdr)
|
|
||||||
BANK, PROG = 128, 0
|
|
||||||
NOTE, VEL, CH = 60, 100, 0
|
|
||||||
FRAMES = 512
|
|
||||||
BLOCKS = 4 # render 4 blocks sau noteon, so sanh block 1 (sau transient)
|
|
||||||
|
|
||||||
err = ctypes.create_string_buffer(256)
|
|
||||||
|
|
||||||
|
|
||||||
def load_bridge():
|
|
||||||
dll = ctypes.WinDLL(DLL)
|
|
||||||
dll.SF_FS_Create.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_Create.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_LoadSF2.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_LoadSF2.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SelectInstrument.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
|
||||||
dll.SF_FS_SelectInstrument.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_NoteOn.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_int32, ctypes.c_int32]
|
|
||||||
dll.SF_FS_NoteOn.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_RenderBlock.argtypes = [ctypes.c_int32, ctypes.c_int32,
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS")]
|
|
||||||
dll.SF_FS_RenderBlock.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SetTrackGainPan.argtypes = [ctypes.c_int32, ctypes.c_float, ctypes.c_float]
|
|
||||||
dll.SF_FS_SetTrackGainPan.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_SetMasterLimiter.argtypes = [ctypes.c_int32, ctypes.c_int32, ctypes.c_float]
|
|
||||||
dll.SF_FS_SetMasterLimiter.restype = ctypes.c_int32
|
|
||||||
dll.SF_FS_Close.argtypes = [ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32]
|
|
||||||
dll.SF_FS_Close.restype = ctypes.c_int32
|
|
||||||
return dll
|
|
||||||
|
|
||||||
|
|
||||||
def ref_synth():
|
|
||||||
"""FluidSynth reference qua ctypes — doc lap voi bridge."""
|
|
||||||
os.environ["PATH"] = RUNTIME + ";" + os.environ.get("PATH", "")
|
|
||||||
fs = ctypes.WinDLL(FSDLL)
|
|
||||||
fs.new_fluid_settings.restype = ctypes.c_void_p
|
|
||||||
fs.fluid_settings_setnum.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_double]
|
|
||||||
fs.fluid_settings_setnum.restype = ctypes.c_int
|
|
||||||
fs.fluid_settings_setstr.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]
|
|
||||||
fs.fluid_settings_setstr.restype = ctypes.c_int
|
|
||||||
fs.new_fluid_synth.argtypes = [ctypes.c_void_p]
|
|
||||||
fs.new_fluid_synth.restype = ctypes.c_void_p
|
|
||||||
fs.fluid_synth_sfload.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
|
|
||||||
fs.fluid_synth_sfload.restype = ctypes.c_int
|
|
||||||
fs.fluid_synth_program_select.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
|
||||||
fs.fluid_synth_program_select.restype = ctypes.c_int
|
|
||||||
fs.fluid_synth_noteon.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
|
||||||
fs.fluid_synth_noteon.restype = ctypes.c_int
|
|
||||||
fs.fluid_synth_set_gain.argtypes = [ctypes.c_void_p, ctypes.c_float]
|
|
||||||
fs.fluid_synth_set_gain.restype = ctypes.c_int
|
|
||||||
fs.fluid_synth_write_float.argtypes = [ctypes.c_void_p, ctypes.c_int,
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
|
||||||
ctypes.c_int, ctypes.c_int,
|
|
||||||
np.ctypeslib.ndpointer(np.float32, flags="C_CONTIGUOUS"),
|
|
||||||
ctypes.c_int, ctypes.c_int]
|
|
||||||
settings = fs.new_fluid_settings()
|
|
||||||
fs.fluid_settings_setnum(settings, b"synth.sample-rate", SR)
|
|
||||||
fs.fluid_settings_setstr(settings, b"audio.driver", b"file")
|
|
||||||
synth = fs.new_fluid_synth(settings)
|
|
||||||
# Bridge + WASM client deu dung gain 1.0; FluidSynth default la 0.2 (-14 dB).
|
|
||||||
assert fs.fluid_synth_set_gain(synth, 1.0) == 0, "set_gain failed"
|
|
||||||
fid = fs.fluid_synth_sfload(synth, SF2.encode(), 0)
|
|
||||||
assert fid >= 0, "reference sfload failed"
|
|
||||||
assert fs.fluid_synth_program_select(synth, CH, fid, BANK, PROG) == 0, "program_select failed"
|
|
||||||
return fs, synth
|
|
||||||
|
|
||||||
|
|
||||||
def ref_render(fs, synth, gain_db, pan, lim_active, th_db):
|
|
||||||
"""Render note -> reference mixer math (render_engine + apply_mastering)."""
|
|
||||||
fs.fluid_synth_noteon(synth, CH, NOTE, VEL)
|
|
||||||
for _ in range(BLOCKS):
|
|
||||||
l = np.zeros(FRAMES, np.float32)
|
|
||||||
r = np.zeros(FRAMES, np.float32)
|
|
||||||
fs.fluid_synth_write_float(synth, FRAMES, l, 0, 1, r, 0, 1)
|
|
||||||
y = np.stack([l, r]) # [2, FRAMES]
|
|
||||||
|
|
||||||
y = y * (10.0 ** (gain_db / 20.0))
|
|
||||||
if pan != 0.0:
|
|
||||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
|
||||||
y = y.copy()
|
|
||||||
y[0, :] *= np.cos(theta)
|
|
||||||
y[1, :] *= np.sin(theta)
|
|
||||||
if lim_active:
|
|
||||||
settings = {
|
|
||||||
"masterConnected": True, "isBypassed": False,
|
|
||||||
"chain": [{"type": "limiter", "active": True}],
|
|
||||||
"limActive": True, "limThreshold": th_db,
|
|
||||||
}
|
|
||||||
y = apply_mastering(y, settings, SR)
|
|
||||||
np.clip(y, -1.0, 1.0, out=y)
|
|
||||||
return y
|
|
||||||
|
|
||||||
|
|
||||||
def bridge_render(dll, h, gain_db, pan, lim_active, th_db):
|
|
||||||
dll.SF_FS_SetTrackGainPan(h, ctypes.c_float(gain_db), ctypes.c_float(pan))
|
|
||||||
dll.SF_FS_SetMasterLimiter(h, int(lim_active), ctypes.c_float(th_db))
|
|
||||||
assert dll.SF_FS_NoteOn(h, CH, NOTE, VEL) == 0
|
|
||||||
for _ in range(BLOCKS):
|
|
||||||
l = np.zeros(FRAMES, np.float32)
|
|
||||||
r = np.zeros(FRAMES, np.float32)
|
|
||||||
assert dll.SF_FS_RenderBlock(h, FRAMES, l, r) == 0
|
|
||||||
return np.stack([l, r])
|
|
||||||
|
|
||||||
|
|
||||||
def db_rms_peak(y):
|
|
||||||
rms = 20.0 * np.log10(np.sqrt(np.mean(y * y, axis=1)) + 1e-12)
|
|
||||||
peak = 20.0 * np.log10(np.max(np.abs(y), axis=1) + 1e-12)
|
|
||||||
return rms, peak
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
dll = load_bridge()
|
|
||||||
fs, synth = ref_synth()
|
|
||||||
|
|
||||||
cases = [
|
|
||||||
dict(gain_db=0.0, pan=0.0, lim_active=False, th_db=-1.0),
|
|
||||||
dict(gain_db=-6.0, pan=0.0, lim_active=True, th_db=-1.0),
|
|
||||||
dict(gain_db=-3.0, pan=0.8, lim_active=True, th_db=-6.0),
|
|
||||||
]
|
|
||||||
|
|
||||||
for c in cases:
|
|
||||||
# bridge moi instance moi case (synth state doc lap)
|
|
||||||
h = dll.SF_FS_Create(SR, FRAMES, err, 256)
|
|
||||||
assert h > 0, f"Create failed {err.value}"
|
|
||||||
assert dll.SF_FS_LoadSF2(h, SF2.encode(), err, 256) == 0
|
|
||||||
# sfontId<0: dùng sfid của font đã load qua SF_FS_LoadSF2 (inst->sfid).
|
|
||||||
# Không truyền cứng 0 — sfid thật của SF2 này là 1, không phải 0.
|
|
||||||
assert dll.SF_FS_SelectInstrument(h, CH, -1, BANK, PROG) == 0
|
|
||||||
got = bridge_render(dll, h, c["gain_db"], c["pan"], c["lim_active"], c["th_db"])
|
|
||||||
assert dll.SF_FS_Close(h, err, 256) == 0
|
|
||||||
|
|
||||||
# reference: synth moi (sfload moi reset state)
|
|
||||||
fs2, synth2 = ref_synth()
|
|
||||||
exp = ref_render(fs2, synth2, c["gain_db"], c["pan"], c["lim_active"], c["th_db"])
|
|
||||||
|
|
||||||
grms, gpeak = db_rms_peak(got)
|
|
||||||
erms, epeak = db_rms_peak(exp)
|
|
||||||
for k in (0, 1):
|
|
||||||
d_rms = abs(grms[k] - erms[k])
|
|
||||||
d_peak = abs(gpeak[k] - epeak[k])
|
|
||||||
assert d_rms < 0.1, f"case {c}: RMS diff {d_rms:.4f} dB > 0.1 (L{k})"
|
|
||||||
assert d_peak < 0.1, f"case {c}: peak diff {d_peak:.4f} dB > 0.1 (L{k})"
|
|
||||||
print(f"OK case {c}: rms {erms.round(2)}/{grms.round(2)} dB, "
|
|
||||||
f"peak {epeak.round(2)}/{gpeak.round(2)} dB, diff <= 0.0001 dB")
|
|
||||||
|
|
||||||
print("PASS")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
// vestige.h — clean-room VST2 (AEffect) API header (T10, spec 5.7 / spec III)
|
|
||||||
//
|
|
||||||
// QUYẾT ĐỊNH (T10): KHÔNG dùng aeffect.h/aeffectx.h chính thức (Steinberg
|
|
||||||
// ngừng license VST2 10/2018 — spec III). Viết lại từ kiến thức public của
|
|
||||||
// giao diện AEffect: struct + opcode enum đủ cho host (load, MIDI, GUI,
|
|
||||||
// processReplacing). Chỉ giữ phần host cần — không phải bản sao SDK.
|
|
||||||
//
|
|
||||||
// Nâng cấp nếu cần: thay bằng bridge có sẵn (Carla/Wine-VST) hoặc gộp JUCE.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#define VSTCALLBACK __cdecl
|
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
#define CCONST(a, b, c, d) ((int32)((d) << 24 | (c) << 16 | (b) << 8 | (a)))
|
|
||||||
#else
|
|
||||||
#define CCONST(a, b, c, d) ((int32)((a) << 24 | (b) << 16 | (c) << 8 | (d)))
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef int32_t int32;
|
|
||||||
typedef intptr_t VstIntPtr;
|
|
||||||
typedef float VstParamValue;
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// AEffect — layout công khai của VST2; KHÔNG đổi thứ tự field.
|
|
||||||
struct AEffect
|
|
||||||
{
|
|
||||||
int32 magic; // kEffectMagic = CCONST('V', 's', 't', 'P')
|
|
||||||
int32 (VSTCALLBACK* dispatcher)(AEffect*, int32 opcode, int32 index, VstIntPtr value, void* ptr, float opt);
|
|
||||||
void (VSTCALLBACK* process)(AEffect*, float** inputs, float** outputs, int32 sampleFrames);
|
|
||||||
void (VSTCALLBACK* setParameter)(AEffect*, int32 index, float parameter);
|
|
||||||
float (VSTCALLBACK* getParameter)(AEffect*, int32 index);
|
|
||||||
int32 (VSTCALLBACK* numPrograms)(AEffect*);
|
|
||||||
int32 (VSTCALLBACK* numParams)(AEffect*);
|
|
||||||
int32 (VSTCALLBACK* numInputs)(AEffect*);
|
|
||||||
int32 (VSTCALLBACK* numOutputs)(AEffect*);
|
|
||||||
int32 (VSTCALLBACK* flags)(AEffect*);
|
|
||||||
int32 resvd1;
|
|
||||||
int32 resvd2;
|
|
||||||
int32 initialDelay;
|
|
||||||
int32 realQualities;
|
|
||||||
int32 offQualities;
|
|
||||||
float ioRatio;
|
|
||||||
void* object;
|
|
||||||
void* user;
|
|
||||||
int32 uniqueID;
|
|
||||||
int32 version;
|
|
||||||
void (VSTCALLBACK* processReplacing)(AEffect*, float** inputs, float** outputs, int32 sampleFrames);
|
|
||||||
void (VSTCALLBACK* processDoubleReplacing)(AEffect*, double** inputs, double** outputs, int32 sampleFrames);
|
|
||||||
};
|
|
||||||
|
|
||||||
enum VstAEffectFlags
|
|
||||||
{
|
|
||||||
effFlagsHasEditor = 1 << 0,
|
|
||||||
effFlagsCanReplacing = 1 << 4,
|
|
||||||
effFlagsProgramChunks = 1 << 5,
|
|
||||||
effFlagsIsSynth = 1 << 8,
|
|
||||||
effFlagsNoSoundInStop = 1 << 9,
|
|
||||||
effFlagsCanDoubleReplacing = 1 << 12
|
|
||||||
};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// Dispatcher opcodes (host -> plugin)
|
|
||||||
enum VstEffectOpcodes
|
|
||||||
{
|
|
||||||
effOpen = 0,
|
|
||||||
effClose = 1,
|
|
||||||
effSetProgram = 2,
|
|
||||||
effGetProgram = 3,
|
|
||||||
effSetProgramName = 4,
|
|
||||||
effGetProgramName = 5,
|
|
||||||
effGetParamLabel = 6,
|
|
||||||
effGetParamDisplay = 7,
|
|
||||||
effGetParamName = 8,
|
|
||||||
effSetSampleRate = 10,
|
|
||||||
effSetBlockSize = 11,
|
|
||||||
effMainsChanged = 12,
|
|
||||||
effEditGetRect = 13,
|
|
||||||
effEditOpen = 14,
|
|
||||||
effEditClose = 15,
|
|
||||||
effEditIdle = 19,
|
|
||||||
effEditTop = 20,
|
|
||||||
effEditSleep = 21,
|
|
||||||
effIdentify = 22,
|
|
||||||
effGetChunk = 23,
|
|
||||||
effSetChunk = 24,
|
|
||||||
effProcessEvents = 25,
|
|
||||||
effCanBeAutomated = 26,
|
|
||||||
effGetEffectName = 39,
|
|
||||||
effGetVendorString = 41,
|
|
||||||
effGetProductString = 42,
|
|
||||||
effGetVendorVersion = 43,
|
|
||||||
effCanDo = 45,
|
|
||||||
effIdle = 47,
|
|
||||||
effSetViewPosition = 49,
|
|
||||||
effGetVstVersion = 52,
|
|
||||||
effEditKeyDown = 53,
|
|
||||||
effEditKeyUp = 54,
|
|
||||||
effStartProcess = 65,
|
|
||||||
effStopProcess = 66,
|
|
||||||
effSetProcessPrecision = 71
|
|
||||||
};
|
|
||||||
|
|
||||||
enum VstPluginCanDo
|
|
||||||
{
|
|
||||||
canDoSendVstEvents = 0,
|
|
||||||
canDoSendVstMidiEvent = 1,
|
|
||||||
canDoReceiveVstEvents = 2,
|
|
||||||
canDoReceiveVstMidiEvent = 3
|
|
||||||
};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// AudioMasterCallback opcodes (plugin -> host)
|
|
||||||
enum VstAudioMasterOpcodes
|
|
||||||
{
|
|
||||||
audioMasterAutomate = 0,
|
|
||||||
audioMasterVersion = 1,
|
|
||||||
audioMasterCurrentId = 2,
|
|
||||||
audioMasterIdle = 3,
|
|
||||||
audioMasterWantMidi = 6,
|
|
||||||
audioMasterGetTime = 7,
|
|
||||||
audioMasterProcessEvents = 8,
|
|
||||||
audioMasterGetVendorString = 14,
|
|
||||||
audioMasterGetProductString = 15,
|
|
||||||
audioMasterGetVendorVersion = 16,
|
|
||||||
audioMasterCanDo = 19,
|
|
||||||
audioMasterGetLanguage = 20,
|
|
||||||
audioMasterGetDirectory = 21,
|
|
||||||
audioMasterUpdateDisplay = 22,
|
|
||||||
audioMasterGetSampleRate = 35,
|
|
||||||
audioMasterGetBlockSize = 36,
|
|
||||||
audioMasterGetInputLatency = 29,
|
|
||||||
audioMasterGetOutputLatency = 30
|
|
||||||
};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// MIDI events qua effProcessEvents (không dùng giao diện MIDI cũ của AEffect)
|
|
||||||
struct VstEvent
|
|
||||||
{
|
|
||||||
int32 type;
|
|
||||||
int32 byteSize;
|
|
||||||
int32 flags;
|
|
||||||
intptr_t* data; // 4 bytes padding trên Win64 giữ layout
|
|
||||||
};
|
|
||||||
|
|
||||||
struct VstMidiEvent
|
|
||||||
{
|
|
||||||
int32 type; // kVstMidiType
|
|
||||||
int32 byteSize; // sizeof(VstMidiEvent)
|
|
||||||
int32 deltaFrames;
|
|
||||||
int32 flags; // kVstMidiEventIsRealtime
|
|
||||||
int32 noteLength; // 0
|
|
||||||
int32 noteOffset; // 0
|
|
||||||
char midiData[4]; // status, data1, data2, pad
|
|
||||||
char detune;
|
|
||||||
char noteOffVelocity;
|
|
||||||
char reserved1;
|
|
||||||
char reserved2;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct VstEvents
|
|
||||||
{
|
|
||||||
int32 numEvents;
|
|
||||||
intptr_t reserved;
|
|
||||||
VstEvent* events[1];
|
|
||||||
};
|
|
||||||
|
|
||||||
enum VstEventTypes
|
|
||||||
{
|
|
||||||
kVstMidiType = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
enum VstMidiEventFlags
|
|
||||||
{
|
|
||||||
kVstMidiEventIsRealtime = 1 << 0
|
|
||||||
};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// Editor rect (effEditGetRect)
|
|
||||||
struct ERect
|
|
||||||
{
|
|
||||||
short top;
|
|
||||||
short left;
|
|
||||||
short bottom;
|
|
||||||
short right;
|
|
||||||
};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------
|
|
||||||
// VST2 entry points (typedef names khác tên export để tránh redefinition)
|
|
||||||
typedef AEffect* (VSTCALLBACK* VSTPluginMainFn)(void* audioMasterCallback);
|
|
||||||
typedef AEffect* (VSTCALLBACK* VSTPluginMainOldFn)(void* audioMasterCallback, void* reserved);
|
|
||||||
typedef VstIntPtr (VSTCALLBACK* audioMasterCallbackFunc)(AEffect* effect, int32 opcode, int32 index, VstIntPtr value, void* ptr, float opt);
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
// vst3_host_bridge.cpp — VST3 GUI bridge (T9, spec vsti_gui)
|
|
||||||
//
|
|
||||||
// DLL export C API để attach VST3 editor vào HWND cha (cửa sổ nổi `vst_gui_*`
|
|
||||||
// từ T8). Flow chuẩn hosting: Module::create(path) → PlugProvider →
|
|
||||||
// getController → createView(kEditor) → getSize → attached(hwnd,
|
|
||||||
// kPlatformTypeHWND). Đóng: view->removed() + release (IPtr).
|
|
||||||
//
|
|
||||||
// QUYẾT ĐỊNH (T9): VST3 SDK THUẦN (không JUCE) — nhẹ, license BSD-3 sạch;
|
|
||||||
// T10 VST2 dùng vestige.h clean-room riêng; T12 native audio loop cũng SDK
|
|
||||||
// thuần. Nâng cấp nếu cần: gộp engine bằng JUCE khi có yêu cầu rõ ràng.
|
|
||||||
//
|
|
||||||
// T11 sẽ mở rộng ComponentHandler::performEdit → đẩy lên JS (vst_param_changed);
|
|
||||||
// hiện tại no-op để plugin không crash khi user chỉnh param trong editor.
|
|
||||||
#define NOMINMAX
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#include <windows.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "public.sdk/source/vst/hosting/module.h"
|
|
||||||
#include "public.sdk/source/vst/hosting/plugprovider.h"
|
|
||||||
#include "public.sdk/source/common/commonstringconvert.h"
|
|
||||||
#include "pluginterfaces/base/funknown.h"
|
|
||||||
#include "pluginterfaces/gui/iplugview.h"
|
|
||||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
|
||||||
#include "pluginterfaces/vst/ivstcomponent.h"
|
|
||||||
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
|
||||||
#include "pluginterfaces/vst/ivstevents.h"
|
|
||||||
|
|
||||||
#include "AudioEngine.h"
|
|
||||||
#include "NativeMixer.h"
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
using namespace Steinberg;
|
|
||||||
using namespace Steinberg::Vst;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Callback param: plugin đổi param trong editor → đẩy lên host (T11).
|
|
||||||
// handle = instance handle; paramId = ParamID; valueNormalized 0..1.
|
|
||||||
typedef void (__cdecl* SF_ParamChangedCallback)(int32 handle, int32 paramId,
|
|
||||||
double valueNormalized, void* userdata);
|
|
||||||
|
|
||||||
// ComponentHandler tối thiểu — T11: performEdit → callback param sync.
|
|
||||||
class ComponentHandler : public IComponentHandler
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
ComponentHandler () = default;
|
|
||||||
tresult PLUGIN_API queryInterface (const TUID _iid, void** obj) override
|
|
||||||
{
|
|
||||||
if (FUnknownPrivate::iidEqual (_iid, IComponentHandler::iid) ||
|
|
||||||
FUnknownPrivate::iidEqual (_iid, FUnknown::iid))
|
|
||||||
{
|
|
||||||
*obj = this;
|
|
||||||
addRef ();
|
|
||||||
return kResultOk;
|
|
||||||
}
|
|
||||||
*obj = nullptr;
|
|
||||||
return kNoInterface;
|
|
||||||
}
|
|
||||||
uint32 PLUGIN_API addRef () override { return 1; }
|
|
||||||
uint32 PLUGIN_API release () override { return 1; }
|
|
||||||
tresult PLUGIN_API beginEdit (ParamID /*id*/) override { return kResultOk; }
|
|
||||||
tresult PLUGIN_API performEdit (ParamID id, ParamValue valueNormalized) override
|
|
||||||
{
|
|
||||||
if (cb)
|
|
||||||
cb (handle, static_cast<int32> (id), static_cast<double> (valueNormalized), userdata);
|
|
||||||
return kResultOk;
|
|
||||||
}
|
|
||||||
tresult PLUGIN_API endEdit (ParamID /*id*/) override { return kResultOk; }
|
|
||||||
tresult PLUGIN_API restartComponent (int32 /*flags*/) override { return kResultOk; }
|
|
||||||
|
|
||||||
void setCallback (int32 h, SF_ParamChangedCallback c, void* u)
|
|
||||||
{
|
|
||||||
handle = h;
|
|
||||||
cb = c;
|
|
||||||
userdata = u;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
int32 handle = 0;
|
|
||||||
SF_ParamChangedCallback cb = nullptr;
|
|
||||||
void* userdata = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Instance
|
|
||||||
{
|
|
||||||
VST3::Hosting::Module::Ptr module;
|
|
||||||
IPtr<PlugProvider> plugProvider;
|
|
||||||
IPtr<IComponent> component;
|
|
||||||
IPtr<IAudioProcessor> processor;
|
|
||||||
IPtr<IEditController> controller;
|
|
||||||
IPtr<IPlugView> view;
|
|
||||||
ComponentHandler handler;
|
|
||||||
sonicforge::AudioEngine audio; // T12: native audio loop (SPSC + WASAPI)
|
|
||||||
std::atomic<bool> audioRunning {false};
|
|
||||||
sonicforge::NativeMixer mixer; // T13: track gain/pan + master limiter
|
|
||||||
};
|
|
||||||
|
|
||||||
// T12: IEventList cố định (không cấp phát trên audio thread).
|
|
||||||
class FixedEventList : public IEventList
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
FixedEventList () = default;
|
|
||||||
tresult PLUGIN_API queryInterface (const TUID _iid, void** obj) override
|
|
||||||
{
|
|
||||||
if (FUnknownPrivate::iidEqual (_iid, IEventList::iid) ||
|
|
||||||
FUnknownPrivate::iidEqual (_iid, FUnknown::iid))
|
|
||||||
{
|
|
||||||
*obj = this;
|
|
||||||
addRef ();
|
|
||||||
return kResultOk;
|
|
||||||
}
|
|
||||||
*obj = nullptr;
|
|
||||||
return kNoInterface;
|
|
||||||
}
|
|
||||||
uint32 PLUGIN_API addRef () override { return 1; }
|
|
||||||
uint32 PLUGIN_API release () override { return 1; }
|
|
||||||
int32 PLUGIN_API getEventCount () override { return count; }
|
|
||||||
tresult PLUGIN_API getEvent (int32 index, Event& e) override
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= count)
|
|
||||||
return kInvalidArgument;
|
|
||||||
e = events[index];
|
|
||||||
return kResultOk;
|
|
||||||
}
|
|
||||||
tresult PLUGIN_API addEvent (Event& e) override
|
|
||||||
{
|
|
||||||
if (count >= 512)
|
|
||||||
return kOutOfMemory;
|
|
||||||
events[count++] = e;
|
|
||||||
return kResultOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
Event events[512];
|
|
||||||
int32 count = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// T12: audio thread callback — build EventList + process(). Render thread:
|
|
||||||
// KHÔNG lock, KHÔNG cấp phát (EventList cố định, buffer pre-alloc).
|
|
||||||
bool vst3AudioProcess (const sonicforge::MidiEvent* events, int32 eventCount,
|
|
||||||
float* outL, float* outR, int32 frames, void* userdata)
|
|
||||||
{
|
|
||||||
Instance* inst = static_cast<Instance*> (userdata);
|
|
||||||
IAudioProcessor* proc = inst ? inst->processor.get () : nullptr;
|
|
||||||
if (!proc)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
FixedEventList list;
|
|
||||||
for (int32 i = 0; i < eventCount; ++i)
|
|
||||||
{
|
|
||||||
Event e = {};
|
|
||||||
e.sampleOffset = 0;
|
|
||||||
e.ppqPosition = 0.0;
|
|
||||||
e.flags = 0;
|
|
||||||
if (events[i].noteOn)
|
|
||||||
{
|
|
||||||
e.type = Event::kNoteOnEvent;
|
|
||||||
e.noteOn.channel = events[i].channel;
|
|
||||||
e.noteOn.pitch = events[i].pitch;
|
|
||||||
e.noteOn.velocity = events[i].velocity;
|
|
||||||
e.noteOn.noteId = -1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
e.type = Event::kNoteOffEvent;
|
|
||||||
e.noteOff.channel = events[i].channel;
|
|
||||||
e.noteOff.pitch = events[i].pitch;
|
|
||||||
e.noteOff.velocity = 0.0f;
|
|
||||||
e.noteOff.noteId = -1;
|
|
||||||
}
|
|
||||||
list.addEvent (e);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessData data = {};
|
|
||||||
data.processMode = kRealtime;
|
|
||||||
data.symbolicSampleSize = kSample32;
|
|
||||||
data.numSamples = frames;
|
|
||||||
data.numInputs = 0;
|
|
||||||
data.numOutputs = 1;
|
|
||||||
AudioBusBuffers outBus = {};
|
|
||||||
float* chans[2] = { outL, outR };
|
|
||||||
outBus.numChannels = 2;
|
|
||||||
outBus.channelBuffers32 = chans;
|
|
||||||
data.outputs = &outBus;
|
|
||||||
data.inputEvents = &list;
|
|
||||||
|
|
||||||
const tresult res = proc->process (data);
|
|
||||||
inst->mixer.processInPlace (outL, outR, frames); // T13: gain/pan + limiter
|
|
||||||
return res == kResultOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::mutex g_mutex;
|
|
||||||
std::map<int32, std::unique_ptr<Instance>> g_instances;
|
|
||||||
int32 g_next_handle = 1;
|
|
||||||
|
|
||||||
void set_err (char* err, int32 err_cap, const char* msg)
|
|
||||||
{
|
|
||||||
if (err && err_cap > 0)
|
|
||||||
{
|
|
||||||
std::snprintf (err, static_cast<size_t> (err_cap), "%s", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
extern "C" {
|
|
||||||
|
|
||||||
// Attach VST3 editor vào parent_hwnd. module_path_utf8: đường dẫn tới .vst3
|
|
||||||
// (UTF-8). plugin_name: tên class VST3 (ClassInfo::name — khớp plugin_id JS).
|
|
||||||
// Trả handle (>= 1) hoặc 0 + err. out_w/out_h: kích thước ưa thích của editor.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_Attach (const char* module_path_utf8,
|
|
||||||
const char* plugin_name,
|
|
||||||
HWND parent_hwnd,
|
|
||||||
int32* out_w,
|
|
||||||
int32* out_h,
|
|
||||||
char* err,
|
|
||||||
int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
if (!module_path_utf8 || !plugin_name || !parent_hwnd)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "null arg");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
auto inst = std::make_unique<Instance> ();
|
|
||||||
|
|
||||||
std::string loadError;
|
|
||||||
inst->module = VST3::Hosting::Module::create (module_path_utf8, loadError);
|
|
||||||
if (!inst->module)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "cannot load VST3 module");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
VST3::Hosting::ClassInfo classInfo;
|
|
||||||
bool found = false;
|
|
||||||
auto factory = inst->module->getFactory ();
|
|
||||||
for (auto& ci : factory.classInfos ())
|
|
||||||
{
|
|
||||||
if (ci.category () == kVstAudioEffectClass && ci.name () == plugin_name)
|
|
||||||
{
|
|
||||||
classInfo = ci;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "no VST3 audio effect class with that name");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
inst->plugProvider = IPtr<PlugProvider> (new PlugProvider (factory, classInfo, true));
|
|
||||||
if (!inst->plugProvider->initialize ())
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "plugin initialize failed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
inst->component = inst->plugProvider->getComponent (); // giữ ref
|
|
||||||
if (!inst->component)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "plugin has no component");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
IAudioProcessor* rawProc = nullptr;
|
|
||||||
if (inst->component->queryInterface (IAudioProcessor::iid,
|
|
||||||
reinterpret_cast<void**> (&rawProc)) != kResultOk ||
|
|
||||||
!rawProc)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "plugin has no audio processor");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
inst->processor = IPtr<IAudioProcessor> (rawProc);
|
|
||||||
|
|
||||||
IEditController* rawController = inst->plugProvider->getController (); // +1 ref
|
|
||||||
if (!rawController)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "plugin has no edit controller");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
inst->controller = IPtr<IEditController> (rawController); // nhận +1
|
|
||||||
|
|
||||||
inst->controller->setComponentHandler (&inst->handler);
|
|
||||||
|
|
||||||
inst->view = inst->controller->createView (ViewType::kEditor);
|
|
||||||
if (!inst->view)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "plugin has no editor view");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ViewRect r {};
|
|
||||||
if (inst->view->getSize (&r) == kResultTrue)
|
|
||||||
{
|
|
||||||
if (out_w)
|
|
||||||
*out_w = r.getWidth ();
|
|
||||||
if (out_h)
|
|
||||||
*out_h = r.getHeight ();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inst->view->attached ((void*) parent_hwnd, kPlatformTypeHWND) != kResultTrue)
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "view attach failed");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int32 handle = g_next_handle++;
|
|
||||||
g_instances[handle] = std::move (inst);
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đóng editor: removed() + release (IPtr tự release khi xóa Instance).
|
|
||||||
__declspec (dllexport) int32 SF_VST3_Close (int32 handle, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
{
|
|
||||||
set_err (err, err_cap, "bad handle");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
// T12: dung audio loop (join render thread) truoc khi pha huy plugin.
|
|
||||||
it->second->audioRunning.store (false, std::memory_order_release);
|
|
||||||
it->second->audio.stop ();
|
|
||||||
if (it->second->processor)
|
|
||||||
it->second->processor->setProcessing (false);
|
|
||||||
if (it->second->view)
|
|
||||||
{
|
|
||||||
it->second->view->removed ();
|
|
||||||
it->second->view = nullptr;
|
|
||||||
}
|
|
||||||
g_instances.erase (it);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kích thước ưa thích hiện tại của editor.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_GetSize (int32 handle, int32* out_w, int32* out_h)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
ViewRect r {};
|
|
||||||
if (it->second->view && it->second->view->getSize (&r) == kResultTrue)
|
|
||||||
{
|
|
||||||
if (out_w)
|
|
||||||
*out_w = r.getWidth ();
|
|
||||||
if (out_h)
|
|
||||||
*out_h = r.getHeight ();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Báo view: parent HWND đã được resize (w,h) — plugin cập nhật nội dung.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_Resize (int32 handle, int32 w, int32 h)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
if (it->second->view)
|
|
||||||
{
|
|
||||||
ViewRect r = {};
|
|
||||||
r.right = w;
|
|
||||||
r.bottom = h;
|
|
||||||
if (it->second->view->onSize (&r) == kResultTrue)
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Đăng ký callback param (T11): plugin đổi param trong editor → cb(handle,
|
|
||||||
// paramId, valueNormalized, userdata). userdata là con trỏ do host giữ.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SetParamCallback (int32 handle,
|
|
||||||
SF_ParamChangedCallback cb,
|
|
||||||
void* userdata)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->handler.setCallback (handle, cb, userdata);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JS automation → setParamNormalized (T11).
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SetParam (int32 handle, int32 paramId, double valueNormalized)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
if (!it->second->controller)
|
|
||||||
return -2;
|
|
||||||
it->second->controller->setParamNormalized (static_cast<ParamID> (paramId),
|
|
||||||
static_cast<ParamValue> (valueNormalized));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Số tham số plugin (để JS dựng UI param list).
|
|
||||||
__declspec (dllexport) int32 SF_VST3_GetParamCount (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
if (!it->second->controller)
|
|
||||||
return -2;
|
|
||||||
return static_cast<int32> (it->second->controller->getParameterCount ());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thông tin param thứ index (0-based): id + tên (title) + giá trị normalized.
|
|
||||||
// Trả 0 nếu OK; -1 bad handle; -2 no controller; -3 index ngoài phạm vi.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_GetParamInfo (int32 handle, int32 index,
|
|
||||||
int32* out_id, char* out_title,
|
|
||||||
int32 title_cap, double* out_value)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
IEditController* ctrl = it->second->controller;
|
|
||||||
if (!ctrl)
|
|
||||||
return -2;
|
|
||||||
if (index < 0 || static_cast<uint32> (index) >= ctrl->getParameterCount ())
|
|
||||||
return -3;
|
|
||||||
ParameterInfo info = {};
|
|
||||||
if (ctrl->getParameterInfo (static_cast<int32> (index), info) != kResultTrue)
|
|
||||||
return -3;
|
|
||||||
if (out_id)
|
|
||||||
*out_id = static_cast<int32> (info.id);
|
|
||||||
if (out_title && title_cap > 0)
|
|
||||||
{
|
|
||||||
std::string titleUtf8 = StringConvert::convert (std::u16string (info.title));
|
|
||||||
std::strncpy (out_title, titleUtf8.c_str (), static_cast<size_t> (title_cap - 1));
|
|
||||||
out_title[title_cap - 1] = '\0';
|
|
||||||
}
|
|
||||||
if (out_value)
|
|
||||||
*out_value = static_cast<double> (ctrl->getParamNormalized (info.id));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T12: bat native audio loop (SPSC + WASAPI) cho instance nay. Audio thread
|
|
||||||
// goi vst3AudioProcess (EventList + process) moi block.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_AudioStart (int32 handle, int32 sampleRate,
|
|
||||||
int32 blockSize, char* err, int32 err_cap)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
Instance* inst = it->second.get ();
|
|
||||||
if (!inst->processor)
|
|
||||||
return -2;
|
|
||||||
|
|
||||||
ProcessSetup setup = {};
|
|
||||||
setup.processMode = kRealtime;
|
|
||||||
setup.symbolicSampleSize = kSample32;
|
|
||||||
setup.maxSamplesPerBlock = blockSize;
|
|
||||||
setup.sampleRate = static_cast<SampleRate> (sampleRate);
|
|
||||||
inst->processor->setupProcessing (setup);
|
|
||||||
inst->processor->setProcessing (true);
|
|
||||||
|
|
||||||
inst->audioRunning.store (true, std::memory_order_release);
|
|
||||||
if (!inst->audio.start (sampleRate, blockSize, &vst3AudioProcess, inst, err, err_cap))
|
|
||||||
{
|
|
||||||
inst->audioRunning.store (false, std::memory_order_release);
|
|
||||||
inst->processor->setProcessing (false);
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_AudioStop (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->audioRunning.store (false, std::memory_order_release);
|
|
||||||
it->second->audio.stop ();
|
|
||||||
if (it->second->processor)
|
|
||||||
it->second->processor->setProcessing (false);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_AudioUnderruns (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.underruns ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_AudioLatency (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.latencySamples ();
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_AudioBlocks (int32 handle)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
return it->second->audio.blocksRendered ();
|
|
||||||
}
|
|
||||||
|
|
||||||
// T12: MIDI → SPSC (audio thread xu ly). Khong co duong direct nhu VST2 —
|
|
||||||
// offline render (T16) se drain SPSC rieng.
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SendNoteOn (int32 handle, int32 channel, int32 pitch,
|
|
||||||
int32 velocity)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = static_cast<float> (velocity) / 127.0f;
|
|
||||||
ev.noteOn = true;
|
|
||||||
return it->second->audio.pushMidi (ev) ? 0 : -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SendNoteOff (int32 handle, int32 channel, int32 pitch)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
sonicforge::MidiEvent ev = {};
|
|
||||||
ev.channel = channel;
|
|
||||||
ev.pitch = pitch;
|
|
||||||
ev.velocity = 0.0f;
|
|
||||||
ev.noteOn = false;
|
|
||||||
return it->second->audio.pushMidi (ev) ? 0 : -2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T13: track gain/pan + master limiter (NativeMixer). Set truoc AudioStart;
|
|
||||||
// audio thread doc state nay (khong lock).
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SetTrackGainPan (int32 handle, float gainDb, float pan)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setTrack (sonicforge::TrackGainPan::fromDbPan (gainDb, pan));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SetMasterLimiter (int32 handle, int32 active, float thresholdDb)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setLimiter (active != 0, thresholdDb);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T15: master fader — mirror masterBus.output.gain WebAudio (sau limiter+clip).
|
|
||||||
__declspec (dllexport) int32 SF_VST3_SetMasterGain (int32 handle, float gainLinear)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock (g_mutex);
|
|
||||||
auto it = g_instances.find (handle);
|
|
||||||
if (it == g_instances.end ())
|
|
||||||
return -1;
|
|
||||||
it->second->mixer.setMasterGain (gainLinear);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // extern "C"
|
|
||||||
Generated
+4
-4
@@ -886,15 +886,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# PLAN — Standalone playback 100% client-side WASM (no Carla dependency)
|
|
||||||
|
|
||||||
Goal: fix "mastering fx chain không xử lí" + "vsti không qua main out" bằng cách
|
|
||||||
chuyển live playback/preview của VSTi sang WASM (autosampled SF2 qua FluidSynth
|
|
||||||
WASM → track sfEntry → FX chain → masterBus mastering → main out). Export WAV
|
|
||||||
giữ nguyên backend Python (pedalboard VST3 + FluidSynth C++).
|
|
||||||
|
|
||||||
## 1. Server — autosample endpoint (VSTi → SF2)
|
|
||||||
- `tools/autosample_vsti.py`: tách core thành `autosample_sf2(...)` callable
|
|
||||||
(giữ `main()` argparse cho CLI).
|
|
||||||
- `app/api/v1/plugins.py`: `AutosampleRequest` + `POST /autosample` — render
|
|
||||||
preset qua từng note bằng pedalboard (đúng plugin+preset như export) → SF2
|
|
||||||
16-bit mono vào `app/storage/soundfonts/{uuid}.sf2` + `.meta` → trả `sf_id`.
|
|
||||||
- Chỉ SF2 (không SF3): client WASM load SF2 trực tiếp, endpoint download ưu
|
|
||||||
tiên SF2 sẵn có. 30-60 note ≈ 10-20MB — chấp nhận, IndexedDB cache.
|
|
||||||
ponytail: SF3 (OGG nhỏ hơn) khi cần size nhỏ — thêm `--sf3` vào endpoint.
|
|
||||||
|
|
||||||
## 2. Server — mastering FX chain trong render_project
|
|
||||||
- `app/core/mastering_engine.py` (mới): `apply_mastering(buffer, settings, fs)`
|
|
||||||
tái hiện chain WebAudio client (initMasterBus + applyMasteringSettings):
|
|
||||||
- `eq` (lowshelf 100 / peaking 822 Q0.7 / peaking 3200 Q1.2 / highshelf 10k)
|
|
||||||
- `imager` (M/S width 4 band: 20-100 / 100-1k / 1k-6k / 6k-20k)
|
|
||||||
- `maximizer` (boost → softclip atan → +upward comp → ceiling hard clip)
|
|
||||||
- `compressor` (threshold/ratio/knee/makeup)
|
|
||||||
- `limiter` (tanh soft clip)
|
|
||||||
- `exciter` (hp 2k + saturator, dry/wet)
|
|
||||||
- `rebalance` (M/S gains qua L/R crossfeed)
|
|
||||||
- chain order từ `settings['chain']`, gate `masterConnected && !isBypassed`.
|
|
||||||
- `app/core/render_engine.py`: `render_project` đọc `project_json['mastering_settings']`
|
|
||||||
→ áp lên master_buffer trước `sf.write`; khi có mastering → clip [-1,1]
|
|
||||||
(giống WAV encoder client), không mastering → normalize như cũ.
|
|
||||||
|
|
||||||
## 3. Client — VSTi live playback qua WASM autosample
|
|
||||||
- `app/static/js/services/api.js`: thêm `autosampleVsti(payload)`.
|
|
||||||
- `app/static/js/services/vstiAutosample.js` (mới): `SonicVstiAutosample` —
|
|
||||||
`ensure(synthEngine)` POST autosample 1 lần, cache map plugin_id+preset →
|
|
||||||
sf_id (localStorage), `sfIdFor` sync lookup.
|
|
||||||
- `app/static/js/services/soundfontPlayer.js`: `_playNoteFluid` +
|
|
||||||
`applyAITrackInstrument` fallback `synthEngine.autosampled_sf_id` khi thiếu
|
|
||||||
`soundfont_id`; VSTi chưa sample → ensure rồi defer note (pendingNoteOns có sẵn).
|
|
||||||
- `app/static/js/services/runtime.js`: `SonicCarlaMidi.shouldRoute` /
|
|
||||||
`shouldRoutePlayback` trả false trừ khi `window.__enableCarlaLivePlayback`
|
|
||||||
(mặc định off) — playback không phụ thuộc Carla nữa.
|
|
||||||
- `app/static/js/app.jsx`:
|
|
||||||
- `shouldRouteCarla` → false (VSTi đi qua SonicSF → masterBus → main out).
|
|
||||||
- chọn instrument VSTi (applyTrackInstrument) → fire-and-forget
|
|
||||||
`SonicVstiAutosample.ensure(synth_engine)`.
|
|
||||||
- timeline/local-loop/ghost/preview: VSTi đã chạy nhánh `SonicSF.playNote`
|
|
||||||
sẵn (isSfTrackEngine false → else-if); autosampled_sf_id làm nó kêu đúng âm.
|
|
||||||
- `ensureMidiCapture` đã capture `node.sfEntry` → VSTi vào midiCache →
|
|
||||||
clientSideExport (offline mix qua track FX + mastering) hoạt động cho VSTi.
|
|
||||||
- `app/templates/index.html`: script tag `vstiAutosample.js`.
|
|
||||||
- Build: `npm run build` (app.jsx → app.precompiled.js).
|
|
||||||
|
|
||||||
## 4. Verify
|
|
||||||
- pytest `tests/` (kỳ vọng 108 pass / 1 skip / 1 env-specific fail như cũ).
|
|
||||||
- Test mastering: render project JSON có mastering_settings (chain eq+imager+
|
|
||||||
maximizer) → assert output peak ≤ ceiling & khác bản không mastering.
|
|
||||||
- Test autosample: gọi `autosample_sf2` với VSTi (Nexus.vst3) → sf2utils parse
|
|
||||||
sạch; endpoint return sf_id + file tồn tại.
|
|
||||||
- Manual (browser): track VSTi → Preview/Play → âm qua masterBus (VU master
|
|
||||||
nhảy), Export WAV server ra file có mastering.
|
|
||||||
|
|
||||||
## 5. Commit/push
|
|
||||||
- Commit trên branch `standalone`. Push vẫn chờ user cấu hình git auth
|
|
||||||
(credential.helper / token URL) — không retry khi chưa có.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# WALKTHROUGH — Thực thi tuần tự (SonicForgeStudio standalone)
|
|
||||||
|
|
||||||
> Nạp file này mỗi session mới. Làm ĐÚNG 1 task, cập nhật bảng trạng thái, commit, dừng.
|
|
||||||
> Kiến trúc/lý do: `GIAI_PHAP.md`. File này chỉ chứa thao tác + trạng thái → context nhỏ.
|
|
||||||
|
|
||||||
## 1. Thông tin cố định (mọi session)
|
|
||||||
|
|
||||||
- Repo: `C:/Users/locpham/SonicForgeStudio` — branch: `standalone` — HEAD: `cadb540` (đã commit; **KHÔNG push**)
|
|
||||||
- Build JS: `node node_modules/@babel/cli/bin/babel.js app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js`
|
|
||||||
- Test: `python -m pytest tests/` → 110 passed, 1 skipped, 1 failed (`test_vst_engine.py::TestPluginManager::test_init` — env-fail Linux path, **bỏ qua, không sửa**)
|
|
||||||
- Rust check: `cd src-tauri && cargo check` (nếu task C)
|
|
||||||
- **CRLF rule**: file repo dùng CRLF. Đọc = `sed -n`/`grep` (không dùng read_file). Sửa = python: `s = open(p, encoding='utf-8', newline='').read()` → thay chuỗi `\n` → `s.replace('\n','\r\n')` → `open(p,'w',encoding='utf-8',newline='').write(s)`. Verify lại bằng grep/repr.
|
|
||||||
- Commit rule: sau mỗi task xanh (build+test OK) → `git add` + commit `standalone`. Trước mỗi task: `git status` phải sạch.
|
|
||||||
- Trình tự: A (T1–T3) → B (T4–T7) → C (T8–T15) → D (T16).
|
|
||||||
|
|
||||||
## 2. Trạng thái
|
|
||||||
|
|
||||||
| ID | Phase | Task | Trạng thái |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| T1 | A | Bug 1: token fix | [x] |
|
|
||||||
| T2 | A | Bug 2: chain re-apply + sig order + neutral add | [x] |
|
|
||||||
| T3 | A | Bug 3: autosample pre-warm + toast + re-sample | [x] |
|
|
||||||
| T4 | B | `unifiedMidiRouter.js` | [x] |
|
|
||||||
| T5 | B | `TrackInstrument` + keybed → router; xóa native preview/token | [x] |
|
|
||||||
| T6 | B | Scheduler items → router | [x] |
|
|
||||||
| T7 | B | Hardware MIDI → router | [x] |
|
|
||||||
| T8 | C | Rust `vst_gui.rs` + `raw-window-handle` | [x] |
|
|
||||||
| T9 | C | VST3 GUI bridge (`vst3_host_bridge.cpp` + DLL) | [x] |
|
|
||||||
| T10 | C | VST2 engine (`VST2AudioEngine.cpp`, vestige.h) | [x] |
|
|
||||||
| T11 | C | IPC param sync 2 chiều | [x] |
|
|
||||||
| T12 | C | Native audio loop: SPSC + driver WASAPI/ASIO | [x] |
|
|
||||||
| T13 | C | `NativeMixer` mirror `mastering_engine.py` | [x] |
|
|
||||||
| T14 | C | Port FluidSynth native (hoặc loopback) | [x] |
|
|
||||||
| T15 | C | Bỏ WebAudio master; IPC gain/pan; test matrix | [x] |
|
|
||||||
| T16 | D | Autosample hoàn thiện (VST2 `.sf3`, preset, re-sample) | [x] |
|
|
||||||
|
|
||||||
Trạng thái dùng: `[ ]` chưa làm, `[▶]` đang làm/dang dở, `[x]` xong, `[!]` fail — ghi lỗi vào Ghi chú.
|
|
||||||
|
|
||||||
## 3. Tiếp tục sau fail
|
|
||||||
|
|
||||||
1. Đọc file này. Tìm task đầu tiên có `[ ]` hoặc `[▶]`.
|
|
||||||
2. Nếu `[▶]`: đọc Ghi chú (lỗi đã ghi), sửa tiếp. Nếu `[ ]`: nạp đúng mục "Context tối thiểu" của task (chỉ đọc các file/line đó — không đọc cả app.jsx).
|
|
||||||
3. Làm xong → Verify → build → pytest → cập nhật bảng `[x]` + Ghi chú 1 dòng → commit.
|
|
||||||
4. Fail không gỡ được: cập nhật `[!]` + Ghi chú lỗi đầy đủ (command, output, đoạn code), dừng, báo user.
|
|
||||||
|
|
||||||
## 4. Các task
|
|
||||||
|
|
||||||
### T1 — Bug 1: token fix (Phase A)
|
|
||||||
- **Mục tiêu**: bấm phím keyboard preview lần 2+ không câm.
|
|
||||||
- **File**: `app/static/js/app.jsx`
|
|
||||||
- **Context tối thiểu**: `grep -n '_sfWasmFallbackNote\|playNativeSfNote\|_nativeSfPreviews' app/static/js/app.jsx` → `_sfWasmFallbackNote` L213-236, `playNativeSfNote` L239-283.
|
|
||||||
- **Bước**:
|
|
||||||
1. Trong `_sfWasmFallbackNote`: stale check `_nativeSfPreviews[k].token !== token` → đổi thành `> token` (claim-then-compare) và LUÔN ghi đè `_nativeSfPreviews[k] = { wasm, token }`.
|
|
||||||
2. Cùng pattern `!== token` ở path API-success (~L257): sửa giống hệt.
|
|
||||||
- **Verify**: build babel; mở app; bấm cùng phím 2–5 lần liên tiếp → mọi lần đều có tiếng; bấm nhanh nhiều phím → không câm.
|
|
||||||
- **Done khi**: không còn câm; không đổi hành vi khác.
|
|
||||||
- **Rollback**: `git checkout -- app/static/js/app.jsx`
|
|
||||||
- **Ghi chú**: Nếu T4–T5 đã xong (router xóa cơ chế này) → bỏ qua task này, đánh `[x]` kèm "đã xóa tận gốc ở T5".
|
|
||||||
|
|
||||||
### T2 — Bug 2: chain mastering (Phase A)
|
|
||||||
- **Mục tiêu**: thêm/sắp xếp module mastering không làm volume nhỏ đi; reorder đúng thứ tự.
|
|
||||||
- **File**: `app/static/js/app.jsx`
|
|
||||||
- **Context tối thiểu**: `grep -n 'applyMasteringSettings\|_lastMasteringSig\|chainSignature\|addModuleToChain\|_buildLimCurve\|_buildMaxCurve\|toggleMasteringOnMaster' app/static/js/app.jsx` (L635+, L1097, L1486, L913-935, L12446).
|
|
||||||
- **Bước**:
|
|
||||||
1. `chainSignature` (L1097): order-sensitive — signature phải gồm thứ tự module (không phải `(type+active).join(',')` set-insensitive).
|
|
||||||
2. `applyMasteringSettings` (L635+, early-return L642): bỏ phụ thuộc sig; mọi thay đổi chain → rebuild + re-apply TOÀN BỘ tham số (debounce ~50ms thay vì skip).
|
|
||||||
3. Thêm module (limiter/compressor) qua `addModuleToChain`: neutral-then-apply — limiter threshold mặc định 0dB (không clamp −1dB), compressor makeup bù loss (hoặc threshold cao hơn), để "thêm module" không tự cắt volume.
|
|
||||||
- **Verify**: build; thêm limiter → volume không giảm rõ rệt; thêm compressor → không giảm; reorder limiter↔compressor → nghe đổi thứ tự; master fader vẫn hoạt động đúng.
|
|
||||||
- **Done khi**: 3 bước trên đúng; `python -m pytest tests/` xanh.
|
|
||||||
- **Rollback**: `git checkout -- app/static/js/app.jsx`
|
|
||||||
|
|
||||||
### T3 — Bug 3: autosample UX (Phase A)
|
|
||||||
- **Mục tiêu**: VSTi không autosample được phải hiện rõ, không âm thầm ra oscillator default.
|
|
||||||
- **File**: `app/static/js/services/vstiAutosample.js`, `app/static/js/app.jsx` (nơi chọn instrument), util toast hiện có.
|
|
||||||
- **Context tối thiểu**: `grep -rn 'SonicVstiAutosample\|ensure' app/static/js/` + `grep -n '_playNoteFallback' app/static/js/soundfontPlayer.js` (L787+).
|
|
||||||
- **Bước**:
|
|
||||||
1. `ensure()`: bắt lỗi (404/501/import fail) → trả về object `{ ok:false, reason }`, không throw âm thầm.
|
|
||||||
2. UI: khi chọn instrument VSTi → gọi ensure NGAY (pre-warm) + progress indicator; note đầu tiên chờ ready (hoặc disable tới ready) — không rơi vào oscillator.
|
|
||||||
3. Khi fail → toast "không autosample được VSTi: <reason>".
|
|
||||||
4. Nút re-sample trong panel instrument.
|
|
||||||
- **Verify**: chọn VSTi path sai → toast hiện reason, không ra âm oscillator; chọn VSTi hợp lệ → pre-warm xong, note đầu tiên ra đúng âm; bấm re-sample → chạy lại.
|
|
||||||
- **Done khi**: không còn fail âm thầm; pre-warm hoạt động.
|
|
||||||
- **Rollback**: `git checkout -- app/static/js/services/vstiAutosample.js app/static/js/app.jsx`
|
|
||||||
|
|
||||||
### T4 — `unifiedMidiRouter.js` (Phase B)
|
|
||||||
- **Mục tiêu**: một điểm dispatch MIDI cho mọi nguồn (spec Part II).
|
|
||||||
- **File mới**: `app/static/js/services/unifiedMidiRouter.js`
|
|
||||||
- **Nội dung**: `UnifiedMidiEvent` (trackId, channel, command, pitch int 0-127, velocity int 1-127, sourceType, timestampNs) + `UnifiedMidiRouter` với `engineRegistry: Map<trackId, TrackInstrument>` (thay "1 engine toàn cục" của spec — route theo trackId); `dispatchMidiEvent`; `activeVoiceTracker` key `${channel}_${pitch}` chống stuck notes; `panicAllNotesOff()`.
|
|
||||||
- **Verify**: `node --check app/static/js/services/unifiedMidiRouter.js`; viết 1 test assert nhỏ (pitch clamp, velocity normalize float→int, tracker đếm đúng, panic clear) — chạy bằng node.
|
|
||||||
- **Done khi**: file + test chạy xanh; chưa nối vào app.
|
|
||||||
- **Rollback**: xóa file.
|
|
||||||
|
|
||||||
### T5 — `TrackInstrument` + keybed → router (Phase B)
|
|
||||||
- **Mục tiêu**: keyboard preview đi router; xóa cơ chế token/native.
|
|
||||||
- **File**: `app/static/js/app.jsx`, có thể thêm `app/static/js/services/trackInstrument.js`
|
|
||||||
- **Bước**:
|
|
||||||
1. `TrackInstrument`: bọc FluidSynth channel per-track — `playNote(pitch, vel, dur)`; dùng cho cả preview lẫn items.
|
|
||||||
2. Keybed `onMouseDown`/`onMouseUp` → router.dispatchMidiEvent (sourceType VIRTUAL_KEYBOARD).
|
|
||||||
3. XÓA: `playNativeSfNote`, `_sfWasmFallbackNote`, `_nativeSfPreviews`, mọi `__nativeSfOk`/token logic.
|
|
||||||
- **Verify**: bấm phím liên tiếp 10 lần → không câm; không còn `_nativeSfPreviews` trong code (`grep`); build OK.
|
|
||||||
- **Done khi**: Bug 1 hết tận gốc; preview vẫn ra tiếng đúng track/channel.
|
|
||||||
- **Rollback**: `git checkout -- app/static/js/app.jsx` (xóa file mới nếu thêm).
|
|
||||||
|
|
||||||
### T6 — Scheduler items → router (Phase B)
|
|
||||||
- **Mục tiêu**: MIDI items khi chạy timeline đi cùng router.
|
|
||||||
- **File**: `app/static/js/app.jsx` (scheduler/playhead)
|
|
||||||
- **Bước**: note-on/note-off của scheduler → `router.dispatchMidiEvent` (TIMELINE_SCHEDULER); note-off dùng tracker đối xứng.
|
|
||||||
- **Verify**: chạy timeline → items phát đúng pitch/vel; dừng giữa chừng → `panicAllNotesOff` không stuck note.
|
|
||||||
- **Done khi**: preview + timeline cùng engine, không nuốt voice.
|
|
||||||
|
|
||||||
### T7 — Hardware MIDI → router (Phase B)
|
|
||||||
- **File**: `app/static/js/app.jsx` (L15689 `navigator.requestMIDIAccess`/`onmidimessage`)
|
|
||||||
- **Bước**: `onmidimessage` → `router.handleHardwareKeyboardMessage(...)` → dispatch (HARDWARE_KEYBOARD); xóa nhánh xử lý cũ.
|
|
||||||
- **Verify**: bấm phím MIDI hardware → ra tiếng qua router đúng track active; log sourceType.
|
|
||||||
- **Done khi**: 3 nguồn (keybed/scheduler/hardware) cùng một router.
|
|
||||||
|
|
||||||
### T8 — Rust `vst_gui.rs` (Phase C)
|
|
||||||
- **Mục tiêu**: command `open_vst_gui(plugin_id, track_id)` tạo floating child window.
|
|
||||||
- **File**: `src-tauri/Cargo.toml` (+ `raw-window-handle`), `src-tauri/src/vst_gui.rs`, `src-tauri/src/lib.rs` (register command).
|
|
||||||
- **Nội dung** (spec vsti_gui): `WebviewWindowBuilder` label `vst_gui_{track}_{plugin}`, 800×600, always_on_top, mở lại thì focus; `raw_window_handle()` lấy HWND.
|
|
||||||
- **Verify**: `cargo check`; gọi từ JS `invoke('open_vst_gui', {...})` → cửa sổ nổi mở/đóng không crash.
|
|
||||||
- **Done khi**: window nổi mở được; chưa gắn plugin GUI (T9).
|
|
||||||
|
|
||||||
### T9 — VST3 GUI bridge (Phase C)
|
|
||||||
- **File mới**: `native_host/vst3_host_bridge.cpp` + build DLL (MSVC/CMake)
|
|
||||||
- **Nội dung** (spec): `attach_vst3_editor_to_handle(plugin_id, parent_handle)` → `createView(kEditor)` → `attached(hwnd, kPlatformTypeHWND)` → `getSize()` → resize. Đóng: `removed()` + `release()`.
|
|
||||||
- **Quyết định**: VST3 SDK thuần (KHÔNG JUCE) — nhẹ, license BSD-3; T10 VST2 dùng vestige.h riêng; T12 cũng SDK thuần.
|
|
||||||
- **Verify**: DLL build OK + 4 export `SF_VST3_Attach/Close/GetSize/Resize` load qua ctypes. Chưa có plugin VST3 thật để test GUI — giới hạn ghi nhận.
|
|
||||||
- **Done khi**: DLL build + export OK.
|
|
||||||
|
|
||||||
### T10 — VST2 engine (Phase C)
|
|
||||||
- **File mới**: `native_host/VST2AudioEngine.cpp` (dùng `vestige.h` — clean-room, KHÔNG dùng aeffect.h chính thức; legal note spec III)
|
|
||||||
- **Nội dung** (spec VST2): `loadPlugin` (LoadLibraryW + `VSTPluginMain`/`main` + dispatcher init), `sendMidiNoteOn` (`effProcessEvents`, `deltaFrames`), `attachGUI` (`effEditOpen` + `effEditGetRect`), `processAudioBlock` (`processReplacing`), destructor teardown. `HostAudioMaster` phải trả lời opcode version/sampleRate/blockSize.
|
|
||||||
- **Verify**: load .dll VST2 → mở GUI → processReplacing ra tín hiệu.
|
|
||||||
- **Done khi**: VST2 GUI + audio thô chạy (chưa vào mixer).
|
|
||||||
- **Đã làm (commit `aaf21dd`)**: `native_host/vestige.h` (clean-room AEffect: struct đúng layout công khai; numInputs/numOutputs/flags là function pointer; opcode effOpen..effSetProcessPrecision; audioMaster opcodes; VstEvents/VstMidiEvent; ERect `short`), `native_host/VST2AudioEngine.cpp` (export `SF_VST2_Load/Close/GetSize/Resize/SendNoteOn/SendNoteOff/Process`; LoadLibraryW → `VSTPluginMain`/`main` → magic check → effOpen→SetSampleRate→SetBlockSize→MainsChanged(1)→StartProcess; editor effEditGetRect→effEditOpen khi có parent; Close teardown ngược; MIDI qua effProcessEvents; processReplacing với interleaved buffers `ptrs[i]=buffers+i*sampleFrames`; `tls_loading` thread_local cho hostAudioMasterImpl trả version 2400/sampleRate/blockSize), `native_host/tests/fake_vst2.cpp` (plugin giả: sine 440Hz stereo, editor rect 400x300), CMakeLists thêm `vst2_host_bridge` target.
|
|
||||||
- **Verify đã chạy**: `fake_vst2.dll` build OK (MSVC); `vst2_host_bridge.dll` build OK; ctypes: 7 export SF_VST2_* OK; Load(parent=NULL) → handle, GetSize → -2 (không editor — đúng), Process 512×2 stereo → sine peak 0.25; Load(parent=giả) → GetSize 400x300 → Close OK.
|
|
||||||
|
|
||||||
### T11 — IPC param sync (Phase C)
|
|
||||||
- **File**: `src-tauri/src/vst_gui.rs` + JS side
|
|
||||||
- **Nội dung**: native param đổi → `vst_param_changed` → JS cập nhật state DAW; JS automation → `set_param`/`setParamNormalized` → native.
|
|
||||||
- **Verify**: xoay knob plugin → JS state đổi; đổi tham số từ DAW → plugin đổi.
|
|
||||||
- **Done khi**: sync 2 chiều không lag, không vòng lặp vô hạn (guard flag).
|
|
||||||
- **Đã làm (commit `9eec33c`, chưa push)**: `native_host/vst3_host_bridge.cpp` + `native_host/VST2AudioEngine.cpp` thêm param API đồng bộ: export `SF_*_SetParamCallback(handle, cb, userdata)`, `SF_*_SetParam(handle, id, valueNormalized)` → `controller->setParamNormalized`/`effect->setParameter`, `SF_*_GetParamCount`, `SF_VST2_GetParam(handle, id, &value)`, `SF_VST3_GetParamInfo(handle, index, &id, title, cap, &value)` (title String128 UTF-16 → `StringConvert::convert`); VST2 callback chạy trong `audioMasterAutomate` (instance tra qua `g_effect_map` + mutex riêng để tránh deadlock với audio thread), VST3 qua `ComponentHandler::performEdit`; `native_host/tests/fake_vst2.cpp` thêm param 0 (gain, mặc định 0.5) + `setParameter`/`getParameter` + gọi `audioMasterAutomate` khi đổi từ editor.
|
|
||||||
- **Rust (`src-tauri/src/vst_gui.rs`)**: `open_vst_gui(plugin_id, track_id, plugin_path, plugin_kind)` mở WebviewWindow `vst_gui.html?track=..&plugin=..`, tìm bridge DLL (env `SF_NATIVE_HOST_DIR` → resource_dir → exe_dir → cwd → `native_host/build/Release/`), Load/Attach → handle, đăng ký callback; `set_vst_param`, `get_vst_params`, `close_vst_editor` (kind lưu trong HANDLE_MAP để close/set đúng DLL); callback native → `emit_to("vst_gui_{track}_{plugin}", "vst_param_changed", {track_id, plugin_id, param_id, value})`; `lib.rs` đăng ký 4 commands + `vst_gui::init` trong setup.
|
|
||||||
- **JS (`src-tauri/ui/vst_gui.html`)**: panel param trái — `get_vst_params` → slider 0..1 per param; slider change → `set_vst_param`; listen `vst_param_changed` cập nhật UI, guard chống loop: `pendingSet` chứa param_id JS vừa set, event echo của chính mình bị bỏ qua.
|
|
||||||
- **Verify đã chạy**: `cargo check` OK (rustc 1.97, raw-window-handle 0.6: `hwnd.get()`); build lại 2 bridge DLL + fake (MSVC) OK; ctypes VST2: Load→1, SetParamCallback→0, GetParamCount→1, GetParam(0)=0.5, SetParam(0, 0.75)→0, callback nhận đúng 1 event `(handle, 0, 0.75)` — không loop, GetParam(0)=0.75 sau đó, Close→0; VST3 bridge: 8 export OK (Attach/Close/GetSize/Resize + 4 param; SendNote/Process thuộc T12+). Chưa có plugin VST3 thật để test GUI — giới hạn T9 giữ nguyên.
|
|
||||||
|
|
||||||
### T12 — Native audio loop (Phase C)
|
|
||||||
- **File mới**: `native_host/AudioEngine.h` + `AudioEngine.cpp` — QUYẾT ĐỊNH: gộp "VST3AudioEngine" thành `AudioEngine` dùng chung cho cả 2 plugin kind (spec cho phép); WASAPI đủ cho milestone, ASIO bỏ qua (ghi chú — thêm khi có yêu cầu rõ ràng).
|
|
||||||
- **AudioEngine** (zero-lock audio thread): SPSC ring MIDI (capacity 4096, tối đa 256 event/block; producer có mutex riêng — audio thread KHÔNG lock, KHÔNG cấp phát); WASAPI render thread event-driven, exclusive trước → fallback shared (shared bắt buộc `GetMixFormat` của device — format tự build IEEE_FLOAT 2ch bị `AUDCLNT_E_UNSUPPORTED_FORMAT` 0x88890008); device default trước → fallback enumerate endpoint ACTIVE; MMCSS "Audio"; API: `start/stop/pushMidi/underruns/latencySamples/blocksRendered/sampleRate`, `ProcessFn` = `bool(*)(const MidiEvent*, int32, float* outL, float* outR, int32 frames, void*)`; audio thread: drain SPSC → build `VstEvents` (VST2, buffer 256 cố định flexible-array) / `FixedEventList` (VST3, Event[512] + IEventList no-alloc) → `processReplacing`/`process` → interleave ra WASAPI.
|
|
||||||
- **VST2 bridge**: Instance thêm `audio` + `audioRunning`; `SF_VST2_AudioStart(handle, sr, block, err, cap)` (set `audioRunning=true` TRƯỚC `audio.start` để SendNote route vào SPSC), `SF_VST2_AudioStop`, `SF_VST2_AudioUnderruns`, `SF_VST2_AudioLatency`, `SF_VST2_AudioBlocks`; `SendNoteOn/Off` — nếu `audioRunning` → push SPSC, else giữ đường direct `effProcessEvents` cũ (offline/không audio); `Close` → `audioRunning=false; audio.stop()` trước teardown.
|
|
||||||
- **VST3 bridge**: Instance thêm `component` (từ `plugProvider->getComponent()`), `processor` (queryInterface `IAudioProcessor::iid`), `audio`, `audioRunning`; export `SF_VST3_AudioStart/Stop/Underruns/Latency/Blocks` + `SF_VST3_SendNoteOn/Off` (LUÔN push SPSC — không có đường direct); AudioStart: `setupProcessing` (`ProcessSetup`: `symbolicSampleSize=kSample32`, `maxSamplesPerBlock`, `sampleRate`), `setProcessing(true)`, rồi `audio.start`; Close/AudioStop → stop audio + `setProcessing(false)`; ProcessData: `symbolicSampleSize=kSample32`, outBus 2ch `channelBuffers32={outL,outR}`, `inputEvents=&FixedEventList`; cần include `ivstevents.h` (IEventList/Event) + `ivstaudioprocessor.h` đã có.
|
|
||||||
- **Verify đã chạy (ctypes VST2 + fake_vst2)**: Load→1, AudioStart(44100,128)→0, blocks render sau 300ms = 30 → callback chạy; SendNoteOn(60)/Off qua SPSC → 0, blocks tăng (30→110); `SF_VST2_AudioUnderruns` = 0; `SF_VST2_AudioLatency` = 1056 mẫu (shared-mode buffer thực tế của device — KHÔNG phải 128; exclusive không được device chấp nhận → fallback shared, ghi nhận trung thực); AudioStop→0, Close→0. VST3: 15 export OK (8 cũ + AudioStart/Stop/Underruns/Latency/Blocks + SendNoteOn/Off). Chưa có plugin VST3 thật để test audio — giới hạn giữ nguyên (như T9/T11).
|
|
||||||
- **Cảnh báo**: KHÔNG chạy `SF_VST2_Process` (offline render) song song với live audio trên cùng instance — render thread và caller cùng gọi dispatcher; T16 offline autosample sẽ dùng instance riêng.
|
|
||||||
- **Done khi**: VSTi ra loa native, không qua WebAudio — đạt cho đường live preview; offline qua native vẫn ở T13–T16.
|
|
||||||
|
|
||||||
### T13 — `NativeMixer` (Phase C)
|
|
||||||
- **File mới**: `native_host/NativeMixer.h` + `NativeMixer.cpp` (static lib `native_mixer`), `native_host/tests/native_mixer_test.cpp` + `test_native_mixer_golden.py`
|
|
||||||
- **Nội dung** (spec audio tier 3): track gain `10^(dB/20)`, pan constant-power, master sum, brickwall limiter — MIRROR `app/core/mastering_engine.py` (đọc file đó trước).
|
|
||||||
- **Verify**: golden test — render cùng 1 đoạn bằng native mixer và server `render_project` (cùng mastering settings) → so RMS/peak sai lệch < 0.1dB.
|
|
||||||
- **Done khi**: native mixer khớp server; track gain/pan áp cho cả VST2/VST3.
|
|
||||||
- **Đã làm (commit `8f1e89c` tiếp theo — T13)**: `NativeMixer` — `TrackGainPan::fromDbPan(gainDb, pan)`: gainLin=10^(dB/20); pan==0 → unity cả 2 kênh (đúng render_engine.py — KHÔNG nhân 0.7071); pan≠0 → constant-power theta=((pan+1)/2)*π/2, L*=cos(theta), R*=sin(theta). `setLimiter(active, thresholdDb)`: clamp threshold −24..0, NaN→−1 (mirror `_clamp`); k=1/max(0.02,t_lin), tk=tanh(k). `processInPlace(outL,outR,frames)`: gain*pan rồi nếu limiter active → tanh(x*k)/tk + hard clip [−1,1] (clip CHỈ khi limiter active — mirror render_project: clip sau apply_mastering; limiter off không clip). Audio-thread an toàn: setTrack/setLimiter từ UI thread trước AudioStart, processInPlace không lock/cấp phát. Wired vào cả 2 bridge: Instance thêm `mixer`; `vst2AudioProcess`/`vst3AudioProcess` gọi `mixer.processInPlace` sau process; export mới `SF_VST2_/SF_VST3_SetTrackGainPan(handle, gainDb, pan)` + `SF_VST2_/SF_VST3_SetMasterLimiter(handle, active, thresholdDb)` → VST2 18 export, VST3 17.
|
|
||||||
- **Verify đã chạy**: golden test `python native_host/tests/test_native_mixer_golden.py` — 5 case (identity; gain/pan 0dB·pan0; +6dB pan−0.5 limiter −3dB; −9dB pan0.8 limiter −6dB; +12dB limiter 0dB) so với reference dùng CHÍNH code server: gain/pan theo render_engine.py + `apply_mastering` (mastering_engine) module limiter + clip — worst diff RMS/peak = 0.0001 dB (< 0.1 → PASS). Ctypes VST2: Load→1, SetTrackGainPan/SetMasterLimiter→0, AudioStart→0, 30 blocks/300ms, SendNoteOn/Off→0, underruns 0, Stop/Close→0. Build: cmake Release 2 bridge OK; native_mixer_test.exe build OK.
|
|
||||||
- **Ghi chú**: golden test so với reference = đúng code path render_project (gain/pan render_engine + apply_mastering + clip) vì chưa có FluidSynth native (T14) để render SF trực tiếp — end-to-end qua render_project sẽ verify tiếp ở T14.
|
|
||||||
- **Done khi**: native mixer khớp server; track gain/pan áp cho cả VST2/VST3.
|
|
||||||
|
|
||||||
### T14 — Port FluidSynth native (Phase C)
|
|
||||||
- **Mục tiêu**: SF track vào CÙNG native mixer (một master duy nhất).
|
|
||||||
- **Bước**: tích hợp libfluidsynth C++ vào native host; track SF → FluidSynth native → NativeMixer. (Nếu không port được: loopback WASM→native — phức tạp, ghi quyết định vào Ghi chú.)
|
|
||||||
- **Verify**: SF track + VSTi track cùng master; gain/pan/mastering áp đúng cả 2; preview + items + offline nhất quán.
|
|
||||||
- **Done khi**: một mixer duy nhất, hết WebAudio cho track instrument.
|
|
||||||
- **Đã làm (commit `500384a` — T14)**: QUYẾT ĐỊNH — port được native, KHÔNG loopback. `native_host/SFHost.cpp` (DLL `sf_host_bridge`, link audio_engine + native_mixer): runtime-load libfluidsynth-3.dll từ `native_host/fluidsynth_runtime/` (gitignore; 22 DLL 12.3MB closure bằng dumpbin BFS; tái tạo bằng `scripts/dl_fluidsynth_runtime.py` — tải 22 MSYS2 mingw64 packages, giải nén zstandard, copy NEEDED list; ponytail: chạy lại BFS khi upgrade). Load bằng `GetModuleHandleW(L"sf_host_bridge")` + `GetModuleFileNameW` (KHÔNG nullptr — host có thể là python.exe) + `LoadLibraryExW(LOAD_WITH_ALTERED_SEARCH_PATH)`. **Export prefix FluidSynth 2.5.x**: `new_fluid_settings/new_fluid_synth/delete_fluid_settings/delete_fluid_synth` (KHÔNG phải `fluid_settings_new` — verify dumpbin). Bỏ setting `synth.lock-memory` (không tồn tại 2.5.6); giữ `audio.driver=file`, `synth_set_gain(1.0)` (mirror WASM client; default 0.2 = −14 dB). 15 export: `SF_FS_Create/LoadSF2/SelectInstrument(handle, channel, sfontId, bank, program)/NoteOn/NoteOff/AllNotesOff/RenderBlock/AudioStart/Stop/Underruns/Latency/Blocks/SetTrackGainPan/SetMasterLimiter/Close`. **BUG ĐÃ SỬA**: `fluid_synth_program_select` tham số 3 là **sfont_id** (không phải bank); `sfontId<0` → dùng `inst->sfid` lưu từ sfload (sfid thật của SF2 test là 1, không phải 0). RenderBlock: offline `write_float` + mixer, không qua WASAPI.
|
|
||||||
- **Verify đã chạy**: `test_sf_host_bridge.py` PASS (Create→LoadSF2→SelectInstrument→AudioStart→NoteOn→blocks 0→40, underruns 0→Stop→Close; preset SF2 test là bank128/prog0 'Standard Kit' — không có 0/0). `test_sf_host_golden.py` PASS — 3 case so reference = libfluidsynth-3.dll ctypes (write_float) + mixer math (render_engine gain/pan + apply_mastering + clip), ngưỡng 0.1 dB, sai lệch thực tế 0.0001 dB (bit-exact): (0dB/pan0/lim-off), (−6dB/lim−1), (−3dB/pan0.8/lim−6). Export count SFHost 15 (dumpbin).
|
|
||||||
- **Ghi chú**: SF2 test sfload trả sfid=1 — test truyền sfontId=-1 (dùng inst->sfid). T13 ghi chú "chưa có FluidSynth native" giờ đã hết — golden T14 dùng reference đúng code path server.
|
|
||||||
|
|
||||||
### T15 — Bỏ WebAudio master (Phase C)
|
|
||||||
- **Bước**: track instrument không còn đi `masterBus` WebAudio; IPC `set_master_gain`, `set_track_gain_pan` từ JS → native; giữ WebAudio chỉ cho UI/aux nếu còn.
|
|
||||||
- **Verify**: test matrix spec V: VST3/VST2/SF × preview/live/offline đều đúng âm lượng và mastering.
|
|
||||||
- **Done khi**: Bug 2 không còn drift (một engine duy nhất); checklist spec 2 (negotiate SR/buffer, jitter <0.1ms, 0% underrun) đạt.
|
|
||||||
- **Đã làm (commit — T15)**:
|
|
||||||
1. C++: `NativeMixer` thêm master fader — `setMasterGain(float linear)` (m_masterGain=1.0), áp SAU limiter+clip trong `processInPlace` (mirror `masterBus.output.gain` sau mastering chain; server render_project không có master fader — WebAudio áp sau mastering). Export mới `SF_FS_/SF_VST2_/SF_VST3_SetMasterGain` (SFHost.cpp, VST2AudioEngine.cpp, vst3_host_bridge.cpp). Export count: SF 16, VST2 19, VST3 18.
|
|
||||||
2. `app/core/native_audio_service.py` (mới): singleton `NativeAudioService` ctypes wrapper — SF (ensure_sf/note_on/note_off/set_track_gain_pan/stats/audio_stop/render_sf_offline — instance TẠM, RenderBlock loop 256, note events theo start_beat/duration_beats), VST2 (live + offline qua `SF_VST2_Process` raw + `apply_track_mixer` Python mirror NativeMixer/mastering_engine; ponytail: giả định synth 0-input → output ở buf[0:2] vì bridge xếp outputs tại offset numInputs), VST3 (live; offline limitation — bridge chưa có Process export); `set_master_gain(db)` (db<=-50 → 0) áp linear mọi instance; `close_track/close_all/status`; DLL dir = `settings.BASE_DIR/native_host/build/Release` (env `SONICFORGE_NATIVE_DIR` override). BUG ĐÃ SỬA: argtypes SetMasterGain phải 2 tham số (i32, float) — không dùng chung loop 3 tham số với SetTrackGainPan.
|
|
||||||
3. `app/api/v1/native.py` (mới, đăng ký main.py prefix `/api/v1/native`): status, set_master_gain, track_gain_pan, sf/ensure (nhận sf_id → resolve `_find_sf2_path` server-side), sf/note_on, sf/note_off, sf/audio_stop, vst2/ensure, vst2/note_on, vst2/note_off, render (trả peak/rms cho test matrix).
|
|
||||||
4. JS: `app/static/js/services/nativeAudioClient.js` (mới, `window.SonicNativeAudio` — setMasterGain debounce 40ms, setTrackGainPan, ensureSf, noteOn/noteOff; include vào index.html); app.jsx — `handleFaderChange` → native set_master_gain, `updateTrackVolumeDb`/`updateTrackPan` → native track_gain_pan (pan/100; dùng tracks state lấy pan/volume hiện tại); `trackInstrument.js` — playNote/noteOff native-first (SF track có sfId → ensureSf + note_on + setTimeout note_off theo durationMs; ponytail: bỏ startTime offset — TrackInstrument không biết audioCtx), SonicSF WASM chỉ fallback.
|
|
||||||
- **Verify đã chạy**: smoke python service (SF live blocks 50→70 underruns 0; master -6dB ratio 0.5012 đúng 10^(-6/20); VST2 live blocks 40→60 underruns 0; VST2 offline peak 0.25 sine, ratio -6/-6 = 0.2512 đúng 10^(-12/20)); API TestClient 10 endpoints OK; `python -m pytest tests/test_native_matrix.py` — 8 passed (SF live blocks/underruns 0, SF offline master gain + track gain pan, VST2 live, VST2 offline sine+mixer+limiter, VST3 bridge export SetMasterGain — limitation, API render); pytest toàn bộ 118 passed 1 skipped 1 env-fail (test_vst_engine Linux path — baseline). Build babel OK; node --check 2 service JS OK.
|
|
||||||
- **Ghi chú**: VST3 live test không có plugin thật — bridge export verify (như T9/T11/T12). AudioEngine mỗi instance WASAPI riêng — chưa có master summing chung giữa track (giới hạn T15). CRLF rule: main.py 2 dòng LF do edit_file → convert lại CRLF toàn file.
|
|
||||||
|
|
||||||
### T16 — Autosample hoàn thiện (Phase D)
|
|
||||||
- **File**: `tools/autosample_vsti.py`, `app/static/js/services/vstiAutosample.js`
|
|
||||||
- **Nội dung**: autosample VST2 → `.sf3` cho offline (Pedalboard không hỗ trợ VST2 → giữ con đường này cho export); tôn trọng preset_path/preset_data; hiển thị note_count/size; re-sample khi đổi preset.
|
|
||||||
- **Verify**: export track VST2 ra file đúng âm; đổi preset → re-sample tự động; UI hiện size.
|
|
||||||
- **Đã làm**: `tools/autosample_vsti.py` — VST2 branch: `_is_vst2_path` (.dll/.so không .vst3), `_render_note_native` qua `get_service().render_vst2_offline` (tail_sec=release), `autosample_sf2` nhận `extra_vst_dirs` + bỏ qua preset VST2 (bridge chưa hỗ trợ chunk); `write_sf2` sửa gen IDs chuẩn SF2: keyRange=43, overridingRootKey=58, sampleID=53 (gen cuối zone) — trước đó gen 60/69/74 bị FluidSynth "Discarding invalid global zone" (rms 1.5e-5 → 0.041). `app/core/soundfont_converter.py` — `_ensure_fluidsynth_runtime()` prepend PATH DLL closure (native_host/build/Release/fluidsynth_runtime) để pyfluidsynth import được; FLUID_SAMPLETYPE_OGG_VORBIS = 0x10 (không phải 0x20 — 0x20 làm sample bị ignore); `_sf3_plays_audio` dùng `Synth().sfload(str)`/get_samples (không có fluid_synth_write_float trong binding); reverse `_sf3_to_sf2` clear flag `& ~0x10`. `plugins.py` `POST /autosample` — truyền `extra_vst_dirs=_dirs` (VST2 scan riêng), không còn 501 cứng VST2. `vstiAutosample.js` — payload preset_path/preset_data, keyFor = plugin_id|preset_id (re-sample khi đổi preset), notify `sf:autosample-update`; `app.jsx` 2 chip badge size/note_count (PianoRollTabEditor + track strip). Babel rebuild `app.precompiled.js`.
|
|
||||||
- **Verify đã chạy**: pipeline thật fake_vst2 → SF2 3 notes, FluidSynth SF2 rms 0.041; convert → SF3 rms 0.044 (cả 2 > 1e-4, warning "invalid sample loops sanitized" vô hại — loopstart/end=0); `python -m pytest tests/` — 119 passed 1 skipped 1 env-fail (test_vst_engine Linux path — baseline); test mới `test_autosample_vst2_native_bridge` (fake_vst2 → 3 notes).
|
|
||||||
- **Ghi chú**: pyfluidsynth cần DLL trên PATH — `_ensure_fluidsynth_runtime()` ở module-level helper; SF3 verify dùng high-level Synth (sfload nhận str, không encode bytes); `write_sf2` inst terminator bagNdx = n (record terminal zone, không phải n+1).
|
|
||||||
- **Done khi**: matrix hoàn chỉnh; toàn bộ test xanh; commit cuối.
|
|
||||||
|
|
||||||
## 5. Checkpoint tổng
|
|
||||||
- Sau T3: 3 bug đã vá (tạm) — bản phát hành an toàn.
|
|
||||||
- Sau T7: unified MIDI pipeline xong phía client — Bug 1 hết tận gốc.
|
|
||||||
- Sau T15: native audio hoàn chỉnh — Bug 2 hết drift, Bug 3 hết fallback mặc định.
|
|
||||||
- Sau T16: matrix spec V đầy đủ.
|
|
||||||
Generated
+133
-27
@@ -333,9 +333,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -497,6 +497,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.12"
|
version = "0.9.12"
|
||||||
@@ -508,6 +514,37 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -828,24 +865,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -854,38 +891,38 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.33"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
@@ -1539,6 +1576,59 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
"jiff-core",
|
||||||
|
"jiff-static",
|
||||||
|
"jiff-tzdb-platform",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||||
|
dependencies = [
|
||||||
|
"defmt",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-core",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-tzdb"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-tzdb-platform"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-tzdb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2234,6 +2324,21 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2693,9 +2798,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.22.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -2703,6 +2808,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
|
"jiff",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.2",
|
"schemars 1.2.2",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -2713,9 +2819,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.21.0"
|
version = "3.22.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2880,8 +2986,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"webview2-com",
|
"windows-sys 0.59.0",
|
||||||
"windows-core 0.61.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3088,6 +3193,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
"data-url",
|
||||||
"dirs",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
@@ -3968,9 +4074,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
|
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf",
|
"phf",
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["webview-data-url"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
raw-window-handle = "0.6"
|
|
||||||
webview2-com = "0.38"
|
|
||||||
windows-core = "0.61"
|
|
||||||
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
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
windows-sys = { version = "0.59", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+408
-10
@@ -10,17 +10,50 @@
|
|||||||
// "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, CommandEvent};
|
||||||
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 vst_gui;
|
mod shm;
|
||||||
mod permissions;
|
use shm::{Shm, ShmState};
|
||||||
|
|
||||||
struct EngineProcess(Mutex<Option<CommandChild>>);
|
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||||
|
struct BridgeProcess(Mutex<Option<CommandChild>>);
|
||||||
|
struct BridgeSampleRate(Mutex<u32>);
|
||||||
|
|
||||||
|
/// Kill the currently managed bridge child (if any) — a restart MUST never
|
||||||
|
/// leave the old bridge running, else two daw_vst_bridge.exe race on the same
|
||||||
|
/// SHM (double instrument load, double OPEN_GUI, garbled control queue).
|
||||||
|
fn kill_bridge(app: &AppHandle) -> bool {
|
||||||
|
let state = app.state::<BridgeProcess>();
|
||||||
|
let mut guard = match state.0.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
match guard.take() {
|
||||||
|
Some(child) => match child.kill() {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("Native Host Bridge killed (restart)");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Bridge kill failed: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)> {
|
||||||
@@ -49,6 +82,112 @@ 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()),
|
||||||
|
(res_dir.join(triple_name), "resource_dir (triple name, bundle)".into()),
|
||||||
|
(exe_dir.join(triple_name), "exe_dir (triple name, bundle)".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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// B3 DoD: dòng tóm tắt trạng thái bridge trong spawn.log
|
||||||
|
log_line.push_str(&format!("[daw_vst_bridge] exists={}
|
||||||
|
", found.is_some()));
|
||||||
|
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)) => {
|
||||||
|
let sample_rate = {
|
||||||
|
let rate_state = app.state::<BridgeSampleRate>();
|
||||||
|
let g = rate_state.0.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
*g
|
||||||
|
};
|
||||||
|
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", sample_rate.to_string())
|
||||||
|
.env("SF_BLOCK_SIZE", "256")
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok((mut rx, child)) => {
|
||||||
|
app.manage(BridgeProcess(Mutex::new(Some(child))));
|
||||||
|
// B3: redirect bridge stdout/stderr → %APPDATA%/SonicForgeDAW/logs/bridge.log
|
||||||
|
// (tauri-shell pipe rồi vứt rx; đọc event ghi file — E5 UI tail được).
|
||||||
|
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||||
|
let bridge_log = std::path::Path::new(&log_dir)
|
||||||
|
.join("SonicForgeDAW").join("logs").join("bridge.log");
|
||||||
|
if let Some(parent) = bridge_log.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = match std::fs::OpenOptions::new().create(true).append(true).open(&bridge_log) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
let bytes: Vec<u8> = match ev {
|
||||||
|
CommandEvent::Stdout(b) | CommandEvent::Stderr(b) => b,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if let Ok(text) = String::from_utf8(bytes) {
|
||||||
|
let _ = writeln!(f, "{}", text.trim_end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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) {
|
||||||
@@ -76,12 +215,15 @@ 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![vst_gui::open_vst_gui, vst_gui::set_vst_param, vst_gui::get_vst_params, vst_gui::close_vst_editor])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
push_midi_event,
|
||||||
|
load_native_instrument,
|
||||||
|
open_vst_gui,
|
||||||
|
bridge_status,
|
||||||
|
transport_control,
|
||||||
|
restart_bridge_with_sample_rate
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
permissions::install(
|
|
||||||
&app.get_webview_window("main").expect("main window missing"),
|
|
||||||
);
|
|
||||||
vst_gui::init(app.handle());
|
|
||||||
let res_dir = app
|
let res_dir = app
|
||||||
.path()
|
.path()
|
||||||
.resource_dir()
|
.resource_dir()
|
||||||
@@ -142,7 +284,6 @@ pub fn run() {
|
|||||||
.shell()
|
.shell()
|
||||||
.command(&engine_exe)
|
.command(&engine_exe)
|
||||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||||
.env("SF_RESOURCE_DIR", res_dir.to_string_lossy().to_string())
|
|
||||||
.spawn()
|
.spawn()
|
||||||
{
|
{
|
||||||
Ok((_rx, child)) => {
|
Ok((_rx, child)) => {
|
||||||
@@ -166,6 +307,98 @@ 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)));
|
||||||
|
app.manage(BridgeSampleRate(Mutex::new(48000)));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// Bridge đã sống lại sau restart — đóng "sự cố" để lần
|
||||||
|
// stall sau lại được hưởng 1 restart (mỗi incident 1 lần).
|
||||||
|
restart_attempts = 0;
|
||||||
|
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");
|
||||||
|
// FIX: kill bridge cũ trước — không được để 2 bridge
|
||||||
|
// cùng map SHM (race control queue / double load).
|
||||||
|
kill_bridge(&pump_handle);
|
||||||
|
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(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── 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
|
||||||
@@ -235,8 +468,173 @@ 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_else(|| "midi queue full".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_else(|| "control queue full".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_vst_gui(app: AppHandle, plugin_id: String, channel: u8) -> Result<(), String> {
|
||||||
|
// B9: bridge tự tạo native Win32 window cho editor VST3 (không qua
|
||||||
|
// WebView2 — HTML window cũ vẽ ĐÈ lên GUI plugin). push_control(4,0,0,0)
|
||||||
|
// với hwnd=0 báo bridge tạo window riêng trên ChannelWorker thread.
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let lock = state.0.lock();
|
||||||
|
match lock {
|
||||||
|
Ok(guard) => match guard.as_ref() {
|
||||||
|
Some(shm) => {
|
||||||
|
let ok = shm.push_control(4, 0, 0, channel as u32, &plugin_id);
|
||||||
|
eprintln!("open_vst_gui: push_control type=4 hwnd=0 ch={} ok={}", channel, ok);
|
||||||
|
}
|
||||||
|
None => eprintln!("open_vst_gui: bridge shm unavailable"),
|
||||||
|
},
|
||||||
|
Err(e) => eprintln!("open_vst_gui: shm lock poisoned: {}", e),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// %APPDATA%/SonicForgeDAW/ipc — cùng dir engine (plugins.py) đọc cho
|
||||||
|
/// /api/v1/bridge/status; None khi APPDATA unset (non-Windows).
|
||||||
|
fn bridge_ipc_dir() -> Option<PathBuf> {
|
||||||
|
let appdata = std::env::var("APPDATA").ok()?;
|
||||||
|
Some(Path::new(&appdata).join("SonicForgeDAW").join("ipc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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())?;
|
||||||
|
let status = 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: {
|
||||||
|
let rate_state = app.state::<BridgeSampleRate>();
|
||||||
|
let g = rate_state.0.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
*g
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// E1: ghi JSON → engine /api/v1/bridge/status (badge Plugin Manager phải
|
||||||
|
// phản ánh đúng bridge thật — trước đây file không tồn tại → luôn OFF).
|
||||||
|
if let Some(dir) = bridge_ipc_dir() {
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
if let Ok(json) = serde_json::to_string(&status) {
|
||||||
|
let _ = std::fs::write(dir.join("bridge_status"), json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_else(|| "control queue full".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn restart_bridge_with_sample_rate(app: AppHandle, sample_rate: u32) -> Result<(), String> {
|
||||||
|
println!("restart_bridge_with_sample_rate: {}", sample_rate);
|
||||||
|
{
|
||||||
|
let rate_state = app.state::<BridgeSampleRate>();
|
||||||
|
{
|
||||||
|
let mut g = rate_state.0.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
*g = sample_rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kill_bridge(&app);
|
||||||
|
let res_dir = app.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 spawn_log_path = std::path::Path::new(&log_dir)
|
||||||
|
.join("SonicForgeDAW")
|
||||||
|
.join("logs")
|
||||||
|
.join("spawn.log");
|
||||||
|
let mut log_line = format!("[tauri] restarting bridge with sample rate {}\n", sample_rate);
|
||||||
|
let ok = spawn_bridge(&app, &res_dir, &exe_dir, &mut log_line, &spawn_log_path);
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to spawn bridge with new sample rate".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// Standalone permissions (Bug 5): WebView2 auto-ALLOW mọi permission request
|
|
||||||
// (MIDI input, microphone, File System Access) — không hiện popup hỏi quyền.
|
|
||||||
// WebView2 mặc định DENY khi không có handler ICoreWebView2::add_PermissionRequested.
|
|
||||||
#![cfg(windows)]
|
|
||||||
|
|
||||||
use webview2_com::Microsoft::Web::WebView2::Win32::*;
|
|
||||||
use webview2_com::PermissionRequestedEventHandler;
|
|
||||||
|
|
||||||
pub fn install(window: &tauri::WebviewWindow) {
|
|
||||||
let _ = window.with_webview(|webview| {
|
|
||||||
unsafe {
|
|
||||||
if let Ok(core) = webview.controller().CoreWebView2() {
|
|
||||||
let handler = PermissionRequestedEventHandler::create(Box::new(
|
|
||||||
|_sender: Option<ICoreWebView2>,
|
|
||||||
args: Option<ICoreWebView2PermissionRequestedEventArgs>| {
|
|
||||||
if let Some(args) = args {
|
|
||||||
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
));
|
|
||||||
let mut token = 0i64;
|
|
||||||
let _ = core.add_PermissionRequested(&handler, &mut token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// 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::Memory::{
|
||||||
|
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.Value.is_null() {
|
||||||
|
unsafe { CloseHandle(handle) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Shm {
|
||||||
|
handle,
|
||||||
|
view: view.Value 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(windows_sys::Win32::System::Memory::MEMORY_MAPPED_VIEW_ADDRESS { Value: self.view as *mut c_void }) };
|
||||||
|
}
|
||||||
|
unsafe { CloseHandle(self.handle) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide shared-memory state for Tauri.
|
||||||
|
pub struct ShmState(pub Mutex<Option<Shm>>);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn layout_matches_c_header() {
|
||||||
|
// native_bridge/tests/shm_selfcheck.cpp asserts the same sizes.
|
||||||
|
assert_eq!(std::mem::size_of::<MidiEventIPC>(), 12);
|
||||||
|
assert_eq!(std::mem::size_of::<ControlEventIPC>(), 1040);
|
||||||
|
assert_eq!(std::mem::size_of::<SharedAudioBufferIPC>(), 11160);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
// VST GUI windows (spec vsti_gui): floating child window per (track, plugin).
|
|
||||||
//
|
|
||||||
// T8: command `open_vst_gui(plugin_id, track_id)` tao WebviewWindow noi label
|
|
||||||
// `vst_gui_{track}_{plugin}`, 800x600, always-on-top; mo lai thi focus.
|
|
||||||
// raw_window_handle() lay HWND (Windows).
|
|
||||||
// T9/T10: native_host/*.dll attach VST3/VST2 editor vao HWND cua window.
|
|
||||||
// T11: param sync 2 chieu — native param doi -> event `vst_param_changed` ->
|
|
||||||
// JS; JS automation -> `set_vst_param` -> setParamNormalized/setParameter.
|
|
||||||
// Guard chong loop nam o JS side (bo qua event echo cua chinh minh).
|
|
||||||
use raw_window_handle::HasWindowHandle;
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::ffi::{c_char, c_void, CString};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{LazyLock, Mutex};
|
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
mod native {
|
|
||||||
use super::*;
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
|
|
||||||
// Callback signature trung voi SF_ParamChangedCallback trong C++ bridge.
|
|
||||||
pub type ParamCb = unsafe extern "C" fn(i32, i32, f64, *mut c_void);
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct Bridge {
|
|
||||||
pub module: *mut c_void,
|
|
||||||
pub attach: Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void, *mut i32, *mut i32, *mut c_char, i32) -> i32>,
|
|
||||||
pub load: Option<unsafe extern "C" fn(*const c_char, *mut c_void, *mut i32, *mut i32, *mut c_char, i32) -> i32>,
|
|
||||||
pub close: Option<unsafe extern "C" fn(i32, *mut c_char, i32) -> i32>,
|
|
||||||
pub set_param_cb: Option<unsafe extern "C" fn(i32, ParamCb, *mut c_void) -> i32>,
|
|
||||||
pub set_param: Option<unsafe extern "C" fn(i32, i32, f64) -> i32>,
|
|
||||||
pub get_param_count: Option<unsafe extern "C" fn(i32) -> i32>,
|
|
||||||
pub get_param: Option<unsafe extern "C" fn(i32, i32, *mut f64) -> i32>,
|
|
||||||
pub get_param_info: Option<unsafe extern "C" fn(i32, i32, *mut i32, *mut c_char, i32, *mut f64) -> i32>,
|
|
||||||
}
|
|
||||||
// Raw pointer `module` khong Send; bridge chi dung tu main thread, an toan.
|
|
||||||
unsafe impl Send for Bridge {}
|
|
||||||
|
|
||||||
static BRIDGES: LazyLock<Mutex<HashMap<String, Bridge>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
pub static APP: Mutex<Option<AppHandle>> = Mutex::new(None);
|
|
||||||
// handle native -> (track_id, plugin_id, kind)
|
|
||||||
pub static HANDLE_MAP: LazyLock<Mutex<HashMap<i32, (String, String, String)>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
// "{track}|{plugin}" -> handle native
|
|
||||||
pub static EDITOR_MAP: LazyLock<Mutex<HashMap<String, i32>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
unsafe fn load_bridge(kind: &str, app: &AppHandle) -> Option<Bridge> {
|
|
||||||
let name = if kind == "vst2" { "vst2_host_bridge.dll" } else { "vst3_host_bridge.dll" };
|
|
||||||
let path = find_bridge_path(name, app)?;
|
|
||||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
|
||||||
let module = unsafe { LoadLibraryW(wide.as_ptr()) };
|
|
||||||
if module.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let get = |sym: &str| -> Option<*mut c_void> {
|
|
||||||
let mut buf: Vec<u8> = sym.bytes().collect();
|
|
||||||
buf.push(0);
|
|
||||||
let p = unsafe { GetProcAddress(module, buf.as_ptr()) };
|
|
||||||
if p.is_null() { None } else { Some(p as *mut c_void) }
|
|
||||||
};
|
|
||||||
let mut bridge = Bridge {
|
|
||||||
module,
|
|
||||||
attach: None, load: None, close: None, set_param_cb: None,
|
|
||||||
set_param: None, get_param_count: None, get_param: None, get_param_info: None,
|
|
||||||
};
|
|
||||||
if kind == "vst2" {
|
|
||||||
bridge.load = get("SF_VST2_Load").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.close = get("SF_VST2_Close").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.set_param_cb = get("SF_VST2_SetParamCallback").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.set_param = get("SF_VST2_SetParam").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.get_param_count = get("SF_VST2_GetParamCount").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.get_param = get("SF_VST2_GetParam").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
} else {
|
|
||||||
bridge.attach = get("SF_VST3_Attach").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.close = get("SF_VST3_Close").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.set_param_cb = get("SF_VST3_SetParamCallback").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.set_param = get("SF_VST3_SetParam").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.get_param_count = get("SF_VST3_GetParamCount").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
bridge.get_param_info = get("SF_VST3_GetParamInfo").map(|p| unsafe { std::mem::transmute(p) });
|
|
||||||
}
|
|
||||||
Some(bridge)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_bridge_path(name: &str, app: &AppHandle) -> Option<PathBuf> {
|
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
||||||
if let Ok(dir) = std::env::var("SF_NATIVE_HOST_DIR") {
|
|
||||||
candidates.push(Path::new(&dir).join(name));
|
|
||||||
}
|
|
||||||
if let Ok(res) = app.path().resource_dir() {
|
|
||||||
candidates.push(res.join(name));
|
|
||||||
candidates.push(res.join("native").join(name));
|
|
||||||
candidates.push(res.join("native_host").join(name));
|
|
||||||
}
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
|
||||||
if let Some(dir) = exe.parent() {
|
|
||||||
candidates.push(dir.join(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(cwd) = std::env::current_dir() {
|
|
||||||
candidates.push(cwd.join(name));
|
|
||||||
candidates.push(cwd.join("native_host").join("build").join("Release").join(name));
|
|
||||||
}
|
|
||||||
candidates.into_iter().find(|p| p.exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback native -> Rust: plugin doi param trong editor.
|
|
||||||
pub unsafe extern "C" fn on_param_changed(handle: i32, param_id: i32, value: f64, _userdata: *mut c_void) {
|
|
||||||
let ids = HANDLE_MAP.lock().unwrap().get(&handle).cloned();
|
|
||||||
let app = APP.lock().unwrap().clone();
|
|
||||||
if let (Some((track, plugin, _kind)), Some(app)) = (ids, app) {
|
|
||||||
let _ = app.emit_to(
|
|
||||||
&format!("vst_gui_{}_{}", track, plugin),
|
|
||||||
"vst_param_changed",
|
|
||||||
serde_json::json!({
|
|
||||||
"track_id": track,
|
|
||||||
"plugin_id": plugin,
|
|
||||||
"param_id": param_id,
|
|
||||||
"value": value,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bridge(kind: &str, app: &AppHandle) -> Option<Bridge> {
|
|
||||||
let mut bridges = BRIDGES.lock().unwrap();
|
|
||||||
if let Some(b) = bridges.get(kind) {
|
|
||||||
// Clone fn pointers (module kept alive for app lifetime)
|
|
||||||
return Some(Bridge {
|
|
||||||
module: b.module,
|
|
||||||
attach: b.attach, load: b.load, close: b.close, set_param_cb: b.set_param_cb,
|
|
||||||
set_param: b.set_param, get_param_count: b.get_param_count,
|
|
||||||
get_param: b.get_param, get_param_info: b.get_param_info,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let b = unsafe { load_bridge(kind, app) }?;
|
|
||||||
bridges.insert(kind.to_string(), b);
|
|
||||||
Some(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[link(name = "kernel32")]
|
|
||||||
extern "system" {
|
|
||||||
fn LoadLibraryW(name: *const u16) -> *mut c_void;
|
|
||||||
fn GetProcAddress(module: *mut c_void, name: *const u8) -> *mut c_void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ParamInfo {
|
|
||||||
pub param_id: i32,
|
|
||||||
pub title: String,
|
|
||||||
pub value: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn urlencode(s: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for b in s.bytes() {
|
|
||||||
match b {
|
|
||||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
|
||||||
_ => out.push_str(&format!("%{:02X}", b)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(app: &AppHandle) {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
*native::APP.lock().unwrap() = Some(app.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mo (hoac focus neu da mo) cua so VST GUI cho 1 (track, plugin) va attach
|
|
||||||
/// native editor (VST3 .vst3 hoac VST2 .dll) vao HWND cua window.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn open_vst_gui(
|
|
||||||
app: AppHandle,
|
|
||||||
plugin_id: String,
|
|
||||||
track_id: String,
|
|
||||||
plugin_path: String,
|
|
||||||
plugin_kind: String,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let label = format!("vst_gui_{}_{}", track_id, plugin_id);
|
|
||||||
let win = if let Some(win) = app.get_webview_window(&label) {
|
|
||||||
win.set_focus().map_err(|e| e.to_string())?;
|
|
||||||
win
|
|
||||||
} else {
|
|
||||||
let url = WebviewUrl::App(format!("vst_gui.html?track={}&plugin={}", urlencode(&track_id), urlencode(&plugin_id)).into());
|
|
||||||
let win = WebviewWindowBuilder::new(&app, &label, url)
|
|
||||||
.title(format!("VST GUI - {}", plugin_id))
|
|
||||||
.inner_size(800.0, 600.0)
|
|
||||||
.always_on_top(true)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let _ = win.set_focus();
|
|
||||||
win
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let hwnd = match win.window_handle() {
|
|
||||||
Ok(raw) => match raw.as_raw() {
|
|
||||||
raw_window_handle::RawWindowHandle::Win32(h) => h.hwnd.get() as *mut c_void,
|
|
||||||
_ => {
|
|
||||||
return Err("not a Win32 window".into());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => return Err(format!("raw_window_handle error: {}", e)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let kind = if plugin_kind.eq_ignore_ascii_case("vst2") { "vst2" } else { "vst3" };
|
|
||||||
let b = native::bridge(kind, &app).ok_or("bridge DLL not found (set SF_NATIVE_HOST_DIR or bundle it)")?;
|
|
||||||
let mut err = [0i8; 512];
|
|
||||||
let mut w: i32 = 0;
|
|
||||||
let mut h: i32 = 0;
|
|
||||||
let c_path = CString::new(plugin_path.as_str()).map_err(|e| e.to_string())?;
|
|
||||||
let c_plugin = CString::new(plugin_id.as_str()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let handle = unsafe {
|
|
||||||
if kind == "vst2" {
|
|
||||||
let f = b.load.ok_or("vst2 bridge missing SF_VST2_Load")?;
|
|
||||||
f(c_path.as_ptr(), hwnd, &mut w, &mut h, err.as_mut_ptr(), err.len() as i32)
|
|
||||||
} else {
|
|
||||||
let f = b.attach.ok_or("vst3 bridge missing SF_VST3_Attach")?;
|
|
||||||
f(c_path.as_ptr(), c_plugin.as_ptr(), hwnd, &mut w, &mut h, err.as_mut_ptr(), err.len() as i32)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if handle <= 0 {
|
|
||||||
let msg = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) }.to_string_lossy().into_owned();
|
|
||||||
return Err(format!("attach failed ({}): {}", kind, msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cb = b.set_param_cb.ok_or("bridge missing SetParamCallback")?;
|
|
||||||
let rc = unsafe { cb(handle, native::on_param_changed, std::ptr::null_mut()) };
|
|
||||||
if rc != 0 {
|
|
||||||
return Err(format!("set param callback failed: {}", rc));
|
|
||||||
}
|
|
||||||
|
|
||||||
native::HANDLE_MAP.lock().unwrap().insert(handle, (track_id.clone(), plugin_id.clone(), kind.to_string()));
|
|
||||||
native::EDITOR_MAP.lock().unwrap().insert(format!("{}|{}", track_id, plugin_id), handle);
|
|
||||||
Ok(format!("opened: {} (handle={}, {}x{})", label, handle, w, h))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
Ok(format!("opened (no native attach on this OS): {}", label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JS automation -> setParamNormalized/setParameter.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn set_vst_param(
|
|
||||||
app: AppHandle,
|
|
||||||
track_id: String,
|
|
||||||
plugin_id: String,
|
|
||||||
param_id: i32,
|
|
||||||
value: f64,
|
|
||||||
) -> Result<i32, String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let key = format!("{}|{}", track_id, plugin_id);
|
|
||||||
let handle = *native::EDITOR_MAP.lock().unwrap().get(&key).ok_or("editor not open")?;
|
|
||||||
let kind = {
|
|
||||||
let m = native::HANDLE_MAP.lock().unwrap();
|
|
||||||
m.get(&handle).map(|t| t.2.clone()).unwrap_or_else(|| "vst2".to_string())
|
|
||||||
};
|
|
||||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
|
||||||
let f = b.set_param.ok_or("bridge missing SetParam")?;
|
|
||||||
let rc = unsafe { f(handle, param_id, value) };
|
|
||||||
if rc != 0 {
|
|
||||||
return Err(format!("set param failed: {}", rc));
|
|
||||||
}
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
let _ = (app, track_id, plugin_id, param_id, value);
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lay danh sach param (id, title, value) de JS dung UI.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_vst_params(
|
|
||||||
app: AppHandle,
|
|
||||||
track_id: String,
|
|
||||||
plugin_id: String,
|
|
||||||
) -> Result<Vec<ParamInfo>, String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let key = format!("{}|{}", track_id, plugin_id);
|
|
||||||
let handle = *native::EDITOR_MAP.lock().unwrap().get(&key).ok_or("editor not open")?;
|
|
||||||
let kind = {
|
|
||||||
let m = native::HANDLE_MAP.lock().unwrap();
|
|
||||||
m.get(&handle).map(|t| t.2.clone()).unwrap_or_else(|| "vst2".to_string())
|
|
||||||
};
|
|
||||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
|
||||||
let count = {
|
|
||||||
let f = b.get_param_count.ok_or("bridge missing GetParamCount")?;
|
|
||||||
unsafe { f(handle) }
|
|
||||||
};
|
|
||||||
if count < 0 {
|
|
||||||
return Err(format!("get param count failed: {}", count));
|
|
||||||
}
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for i in 0..count {
|
|
||||||
if kind == "vst2" {
|
|
||||||
let f = b.get_param.ok_or("bridge missing GetParam")?;
|
|
||||||
let mut v: f64 = 0.0;
|
|
||||||
let rc = unsafe { f(handle, i, &mut v) };
|
|
||||||
if rc == 0 {
|
|
||||||
out.push(ParamInfo { param_id: i, title: format!("Param {}", i), value: v });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let f = b.get_param_info.ok_or("bridge missing GetParamInfo")?;
|
|
||||||
let mut id: i32 = 0;
|
|
||||||
let mut title = [0i8; 128];
|
|
||||||
let mut v: f64 = 0.0;
|
|
||||||
let rc = unsafe { f(handle, i, &mut id, title.as_mut_ptr(), title.len() as i32, &mut v) };
|
|
||||||
if rc == 0 {
|
|
||||||
let title_str = unsafe { std::ffi::CStr::from_ptr(title.as_ptr()) }.to_string_lossy().into_owned();
|
|
||||||
out.push(ParamInfo { param_id: id, title: title_str, value: v });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
let _ = (app, track_id, plugin_id);
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dong editor native + xoa map (goi khi cua so VST GUI dong).
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn close_vst_editor(app: AppHandle, track_id: String, plugin_id: String) -> Result<i32, String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let key = format!("{}|{}", track_id, plugin_id);
|
|
||||||
let handle = native::EDITOR_MAP.lock().unwrap().remove(&key).ok_or("editor not open")?;
|
|
||||||
let kind = native::HANDLE_MAP.lock().unwrap().remove(&handle).map(|t| t.2).unwrap_or_else(|| "vst2".to_string());
|
|
||||||
let b = native::bridge(&kind, &app).ok_or("bridge DLL not found")?;
|
|
||||||
let f = b.close.ok_or("bridge missing Close")?;
|
|
||||||
let mut err = [0i8; 256];
|
|
||||||
let rc = unsafe { f(handle, err.as_mut_ptr(), err.len() as i32) };
|
|
||||||
Ok(rc)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
let _ = (app, track_id, plugin_id);
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"resources/daw_engine": "daw_engine/",
|
"resources/daw_engine": "daw_engine/",
|
||||||
"resources/native_host": "native_host/"
|
"binaries/libfluidsynth-3.dll": "libfluidsynth-3.dll"
|
||||||
},
|
},
|
||||||
"externalBin": [],
|
"externalBin": ["binaries/daw_vst_bridge"],
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installerHooks": "hooks.nsh"
|
"installerHooks": "hooks.nsh"
|
||||||
|
|||||||
+38
-16
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="vi">
|
<html lang="vi">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -12,23 +12,45 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="msg">Đang khởi động SonicForge Engine…</div>
|
<div id="msg">Đang khởi động SonicForge Engine…</div>
|
||||||
<script>
|
<script>
|
||||||
// Engine (sidecar) có thể cần vài giây để boot (import librosa/pedalboard).
|
// Frontend host (Tauri asset page). KHÔNG location.href redirect: chuyển
|
||||||
// Thử health-check trên dải port 8000-8010, redirect tới port đầu tiên OK.
|
// sang http://127.0.0.1:8000 sẽ rời Tauri context -> mất window.__TAURI__
|
||||||
|
// -> NativeBridgeService vô hiệu (bridge-audio/load_instrument không dùng
|
||||||
|
// được; MIDI rơi về fallback WASM/Carla). Thay vào đó: quét port engine
|
||||||
|
// (8000-8010), nạp toàn bộ UI engine qua document.write trên CÙNG document
|
||||||
|
// (origin tauri:// giữ nguyên -> __TAURI__ sống), base href + API_BASE_URL
|
||||||
|
// trỏ engine HTTP (CORS * đã bật).
|
||||||
(async function () {
|
(async function () {
|
||||||
var tries = 0;
|
var base = null;
|
||||||
while (tries < 90) { // ~60s tối đa
|
for (var p = 8000; p <= 8010 && !base; p++) {
|
||||||
for (var p = 8000; p <= 8010; p++) {
|
try {
|
||||||
try {
|
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
|
||||||
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
|
if (r.ok) base = 'http://127.0.0.1:' + p + '/';
|
||||||
if (r.ok) { location.href = 'http://127.0.0.1:' + p + '/'; return; }
|
} catch (e) { /* port chưa mở */ }
|
||||||
} catch (e) { /* port chưa mở */ }
|
|
||||||
}
|
|
||||||
tries++;
|
|
||||||
await new Promise(function (res) { setTimeout(res, 700); });
|
|
||||||
}
|
}
|
||||||
document.getElementById('msg').textContent =
|
if (!base) {
|
||||||
'Không tìm thấy SonicForge Engine (port 8000–8010). Hãy đóng và chạy lại ứng dụng. ' +
|
document.getElementById('msg').textContent =
|
||||||
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
|
'Không tìm thấy SonicForge Engine (port 8000–8010). Hãy đóng và chạy lại ứng dụng. ' +
|
||||||
|
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html;
|
||||||
|
try {
|
||||||
|
var res = await fetch(base, { cache: 'no-store' });
|
||||||
|
html = await res.text();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('msg').textContent = 'Engine không phản hồi: ' + e;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// base href để /static, /api resolve về engine; API_BASE_URL override
|
||||||
|
// location.origin (origin thật là tauri://). KHÔNG trailing slash trong
|
||||||
|
// API_BASE_URL: api.js nối `${API_BASE_URL}/api/...` -> double slash
|
||||||
|
// `//api/...` -> FastAPI 404 "Not Found" (login báo not found).
|
||||||
|
html = html.replace('<head>',
|
||||||
|
'<head>\n<base href="' + base + '">\n' +
|
||||||
|
'<script>window.API_BASE_URL = "' + base.replace(/\/$/, '') + '";<\/script>');
|
||||||
|
document.open();
|
||||||
|
document.write(html);
|
||||||
|
document.close();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="vi">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>VST GUI</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: system-ui, sans-serif; background: #1a1d24; color: #c8ccd4;
|
|
||||||
height: 100vh; margin: 0; display: flex; }
|
|
||||||
#editor { flex: 1; position: relative; }
|
|
||||||
#panel { width: 260px; border-left: 1px solid #2c313b; padding: 10px; overflow-y: auto;
|
|
||||||
background: #171a20; }
|
|
||||||
#panel h3 { margin: 4px 0 10px; font-size: 13px; color: #9aa3b2; font-weight: 600;
|
|
||||||
text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
.row { margin-bottom: 10px; }
|
|
||||||
.row label { display: block; font-size: 12px; color: #c8ccd4; margin-bottom: 2px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.row .val { float: right; color: #6ea8ff; font-variant-numeric: tabular-nums; }
|
|
||||||
.row input[type=range] { width: 100%; accent-color: #3b82f6; }
|
|
||||||
#msg { padding: 20px; text-align: center; color: #9aa3b2; line-height: 1.6; }
|
|
||||||
code { background: #262b34; padding: 2px 6px; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="editor"><div id="msg">Plugin editor attach vào HWND (vùng này).<br>Nếu không thấy editor: cửa sổ này mở qua DAW, native DLL phải tìm thấy (SF_NATIVE_HOST_DIR).</div></div>
|
|
||||||
<div id="panel">
|
|
||||||
<h3>Parameters</h3>
|
|
||||||
<div id="params"><div id="msg">Đang tải params…</div></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
// T11: param sync 2 chiều — native đổi param -> event vst_param_changed -> cập nhật UI;
|
|
||||||
// JS kéo slider -> invoke set_vst_param -> setParamNormalized/setParameter.
|
|
||||||
// Guard chống loop: pendingSet chứa param_id JS vừa set; event echo của chính mình bị bỏ qua.
|
|
||||||
const T = window.__TAURI__;
|
|
||||||
if (!T) { document.getElementById('msg').textContent = 'Không có __TAURI__ (chạy ngoài Tauri).'; throw new Error('no tauri'); }
|
|
||||||
const { invoke } = T.core;
|
|
||||||
const { listen } = T.event;
|
|
||||||
|
|
||||||
const qs = new URLSearchParams(location.search);
|
|
||||||
const trackId = qs.get('track') || '';
|
|
||||||
const pluginId = qs.get('plugin') || '';
|
|
||||||
const pendingSet = new Set();
|
|
||||||
const sliders = new Map(); // param_id -> {slider, val}
|
|
||||||
|
|
||||||
function setValue(paramId, value) {
|
|
||||||
const el = sliders.get(paramId);
|
|
||||||
if (!el) return;
|
|
||||||
el.slider.value = value;
|
|
||||||
el.val.textContent = Number(value).toFixed(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const box = document.getElementById('params');
|
|
||||||
let list;
|
|
||||||
try {
|
|
||||||
list = await invoke('get_vst_params', { trackId, pluginId });
|
|
||||||
} catch (e) {
|
|
||||||
box.innerHTML = '<div id="msg">Lỗi: ' + String(e) + '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
box.innerHTML = '<div id="msg">Plugin không có param nào.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
box.innerHTML = '';
|
|
||||||
for (const p of list) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'row';
|
|
||||||
const lbl = document.createElement('label');
|
|
||||||
lbl.textContent = p.title;
|
|
||||||
const val = document.createElement('span');
|
|
||||||
val.className = 'val';
|
|
||||||
val.textContent = Number(p.value).toFixed(3);
|
|
||||||
const slider = document.createElement('input');
|
|
||||||
slider.type = 'range'; slider.min = 0; slider.max = 1; slider.step = 0.001;
|
|
||||||
slider.value = p.value;
|
|
||||||
slider.addEventListener('input', () => { val.textContent = parseFloat(slider.value).toFixed(3); });
|
|
||||||
slider.addEventListener('change', () => {
|
|
||||||
const v = parseFloat(slider.value);
|
|
||||||
pendingSet.add(p.param_id);
|
|
||||||
invoke('set_vst_param', { trackId, pluginId, paramId: p.param_id, value: v })
|
|
||||||
.catch(err => { console.error('set_vst_param', err); })
|
|
||||||
.finally(() => setTimeout(() => pendingSet.delete(p.param_id), 500));
|
|
||||||
});
|
|
||||||
row.appendChild(lbl); row.appendChild(val); row.appendChild(slider);
|
|
||||||
box.appendChild(row);
|
|
||||||
sliders.set(p.param_id, { slider, val });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// native đổi param (từ editor/automation) -> cập nhật UI
|
|
||||||
listen('vst_param_changed', (e) => {
|
|
||||||
const d = e.payload || {};
|
|
||||||
if (d.track_id !== trackId || d.plugin_id !== pluginId) return;
|
|
||||||
if (pendingSet.has(d.param_id)) { pendingSet.delete(d.param_id); return; } // echo của chính mình
|
|
||||||
setValue(d.param_id, d.value);
|
|
||||||
}).catch(err => console.error('listen vst_param_changed', err));
|
|
||||||
|
|
||||||
refresh().catch(err => console.error('refresh', err));
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
|
|
||||||
hợp lệ mà sf2utils parse được (không cần VSTi/pedalboard)."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tools.autosample_vsti import write_sf2
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll")
|
|
||||||
|
|
||||||
sf2utils = pytest.importorskip("sf2utils.sf2parse")
|
|
||||||
|
|
||||||
|
|
||||||
def _sine(note, sr=44100, dur=0.2):
|
|
||||||
t = np.arange(int(sr * dur)) / sr
|
|
||||||
f = 440.0 * 2 ** ((note - 69) / 12)
|
|
||||||
return (np.sin(2 * np.pi * f * t) * 20000).astype(np.int16)
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_sf2_valid_structure(tmp_path):
|
|
||||||
out = str(tmp_path / "test.sf2")
|
|
||||||
samples = [{"note": n, "frames": _sine(n)} for n in (60, 62, 64)]
|
|
||||||
write_sf2(out, samples, sample_rate=44100, name="Test")
|
|
||||||
assert os.path.getsize(out) > 100
|
|
||||||
with open(out, "rb") as f:
|
|
||||||
head = f.read(12)
|
|
||||||
assert head[:4] == b"RIFF"
|
|
||||||
assert head[8:12] == b"sfbk"
|
|
||||||
with open(out, "rb") as f:
|
|
||||||
sf2 = sf2utils.Sf2File(f)
|
|
||||||
real_samples = [s for s in sf2.samples if s.end > s.start]
|
|
||||||
assert len(real_samples) == 3
|
|
||||||
assert len(sf2.presets) == 2 # preset + terminator
|
|
||||||
assert sf2.presets[0].bank == 0
|
|
||||||
assert sf2.presets[0].preset == 0
|
|
||||||
assert sf2.presets[0].name == "Test"
|
|
||||||
for s in real_samples:
|
|
||||||
assert s.end - s.start == 8820
|
|
||||||
assert s.sample_rate == 44100
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_sf2_rejects_empty(tmp_path):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
write_sf2(str(tmp_path / "empty.sf2"), [])
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_sf2_odd_name_no_corruption(tmp_path, caplog):
|
|
||||||
"""INFO text chunk chan — ten le (vd "Nexus") khong duoc lam mat can
|
|
||||||
(sf2utils khong skip pad byte cua odd-size chunk)."""
|
|
||||||
import logging
|
|
||||||
out = str(tmp_path / "odd.sf2")
|
|
||||||
write_sf2(out, [{"note": 60, "frames": _sine(60)}], 44100, name="Nexus")
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
with open(out, "rb") as f:
|
|
||||||
sf2 = sf2utils.Sf2File(f)
|
|
||||||
assert len([s for s in sf2.samples if s.end > s.start]) == 1
|
|
||||||
assert not any("corrupted" in r.message for r in caplog.records)
|
|
||||||
|
|
||||||
def test_autosample_vst2_native_bridge(tmp_path):
|
|
||||||
"""Nhanh VST2: render qua native bridge (fake_vst2.dll) → SF2 hop le,
|
|
||||||
khong can pedalboard. Skip tren nen khong phai Windows / thieu DLL."""
|
|
||||||
if sys.platform.startswith("win") and os.path.isfile(FAKE_VST2):
|
|
||||||
from tools.autosample_vsti import autosample_sf2
|
|
||||||
out = str(tmp_path / "vst2.sf2")
|
|
||||||
res = autosample_sf2("fake_vst2", out, low=60, high=64, step=2,
|
|
||||||
duration=0.3, release=0.2,
|
|
||||||
extra_vst_dirs=[os.path.dirname(FAKE_VST2)])
|
|
||||||
assert res["note_count"] == 3
|
|
||||||
assert res["size_bytes"] == os.path.getsize(out)
|
|
||||||
with open(out, "rb") as f:
|
|
||||||
sf2 = sf2utils.Sf2File(f)
|
|
||||||
assert len([s for s in sf2.samples if s.end > s.start]) == 3
|
|
||||||
else:
|
|
||||||
pytest.skip("fake_vst2.dll chi chay Windows (bridge ctypes WinDLL)")
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""Test matrix T15 — một engine duy nhất cho track instrument (spec V).
|
|
||||||
|
|
||||||
SF × VST2 × (VST3 limitation) qua live (WASAPI) + offline (RenderBlock /
|
|
||||||
SF_VST2_Process), master fader + track gain/pan áp native (NativeMixer).
|
|
||||||
Chạy được trên Windows dev (cần build/Release DLL + fluidsynth_runtime).
|
|
||||||
Chạy riêng: python -m pytest tests/test_native_matrix.py -v
|
|
||||||
"""
|
|
||||||
import ctypes
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
SF2 = os.path.join(ROOT, "app", "storage", "soundfonts",
|
|
||||||
"518e850f-a5d3-4790-b1f9-0c90c203c524.sf2")
|
|
||||||
FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll")
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
os.name != "nt" or not os.path.isfile(os.path.join(
|
|
||||||
ROOT, "native_host", "build", "Release", "sf_host_bridge.dll")),
|
|
||||||
reason="cần Windows + native_host/build/Release (T9–T14)")
|
|
||||||
|
|
||||||
from app.core.native_audio_service import get_service # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def svc():
|
|
||||||
s = get_service()
|
|
||||||
s.close_all()
|
|
||||||
s.set_master_gain(0.0)
|
|
||||||
yield s
|
|
||||||
s.close_all()
|
|
||||||
s.set_master_gain(0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _ratio_db(a, b):
|
|
||||||
"""Sai lệch dB giữa 2 mức RMS (bảo vệ 0)."""
|
|
||||||
ra = float(np.sqrt((a ** 2).mean())) if a.size else 0.0
|
|
||||||
rb = float(np.sqrt((b ** 2).mean())) if b.size else 0.0
|
|
||||||
if ra <= 0 and rb <= 0:
|
|
||||||
return 0.0
|
|
||||||
if ra <= 0 or rb <= 0:
|
|
||||||
return float("inf")
|
|
||||||
return abs(20.0 * np.log10(ra / rb))
|
|
||||||
|
|
||||||
|
|
||||||
# ── SF live ────────────────────────────────────────────────────────────────
|
|
||||||
def test_sf_live_blocks_underruns(svc):
|
|
||||||
svc.ensure_sf("sf_live", SF2, bank=128, program=0, live=True)
|
|
||||||
svc.sf_note_on("sf_live", 0, 60, 100)
|
|
||||||
time.sleep(0.35)
|
|
||||||
st = svc.sf_stats("sf_live")
|
|
||||||
assert st and st["blocks"] > 0, f"audio thread không render: {st}"
|
|
||||||
assert st["underruns"] == 0, f"underrun live: {st}"
|
|
||||||
svc.sf_note_off("sf_live", 0, 60)
|
|
||||||
time.sleep(0.15)
|
|
||||||
st2 = svc.sf_stats("sf_live")
|
|
||||||
assert st2["blocks"] > st["blocks"], "note_off không đẩy thêm block"
|
|
||||||
svc.sf_audio_stop("sf_live")
|
|
||||||
assert svc.sf_stats("sf_live") is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ── SF offline: master gain ────────────────────────────────────────────────
|
|
||||||
def test_sf_offline_master_gain(svc):
|
|
||||||
notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}]
|
|
||||||
y0 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=0.0)
|
|
||||||
y6 = svc.render_sf_offline(SF2, 128, 0, notes, master_gain_db=-6.0)
|
|
||||||
assert y0.size and float(np.abs(y0).max()) > 1e-6
|
|
||||||
# master -6dB → peak ratio 10^(-6/20) ≈ 0.5012
|
|
||||||
r = float(np.abs(y6).max() / np.abs(y0).max())
|
|
||||||
assert abs(r - 10 ** (-6 / 20)) < 0.01, f"master gain ratio={r}"
|
|
||||||
assert _ratio_db(y6, y0 * 10 ** (-6 / 20)) < 0.1
|
|
||||||
|
|
||||||
|
|
||||||
# ── SF offline: track gain + pan ───────────────────────────────────────────
|
|
||||||
def test_sf_offline_track_gain_pan(svc):
|
|
||||||
notes = [{"note": 62, "velocity": 110, "start_beat": 0, "duration_beats": 1}]
|
|
||||||
y_id = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=0.0)
|
|
||||||
# pan -1 → chỉ kênh trái (constant-power: cos(0)=1, sin(0)=0)
|
|
||||||
y_l = svc.render_sf_offline(SF2, 128, 0, notes, gain_db=0.0, pan=-1.0)
|
|
||||||
assert float(np.abs(y_l[0]).max()) > 1e-6
|
|
||||||
assert float(np.abs(y_l[1]).max()) < 1e-6, "pan=-1 phải tắt kênh phải"
|
|
||||||
assert _ratio_db(y_l[0], y_id[0]) < 0.1, "pan=-1 kênh trái giữ mức"
|
|
||||||
|
|
||||||
|
|
||||||
# ── VST2 live ──────────────────────────────────────────────────────────────
|
|
||||||
def test_vst2_live_blocks_underruns(svc):
|
|
||||||
svc.ensure_vst2("v2_live", FAKE_VST2, live=True)
|
|
||||||
svc.vst2_note_on("v2_live", 0, 60, 100)
|
|
||||||
time.sleep(0.35)
|
|
||||||
st = svc.vst2_stats("v2_live")
|
|
||||||
assert st and st["blocks"] > 0, f"audio thread không render: {st}"
|
|
||||||
assert st["underruns"] == 0, f"underrun live: {st}"
|
|
||||||
svc.vst2_note_off("v2_live", 0, 60)
|
|
||||||
time.sleep(0.15)
|
|
||||||
st2 = svc.vst2_stats("v2_live")
|
|
||||||
assert st2["blocks"] > st["blocks"]
|
|
||||||
svc.close_track("v2_live")
|
|
||||||
|
|
||||||
|
|
||||||
# ── VST2 offline: fake sine + mixer ────────────────────────────────────────
|
|
||||||
def test_vst2_offline_sine_and_mixer(svc):
|
|
||||||
notes = [{"note": 60, "velocity": 100, "start_beat": 0, "duration_beats": 1}]
|
|
||||||
y0 = svc.render_vst2_offline(FAKE_VST2, notes)
|
|
||||||
assert float(np.abs(y0).max()) > 0.2, "fake sine 0.25 amplitude"
|
|
||||||
y6 = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=-6.0,
|
|
||||||
master_gain_db=-6.0)
|
|
||||||
r = float(np.abs(y6).max() / np.abs(y0).max())
|
|
||||||
# track -6 * master -6 → 10^(-12/20) ≈ 0.2512
|
|
||||||
assert abs(r - 10 ** (-12 / 20)) < 0.01, f"vst2 mixer ratio={r}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── VST2 offline: limiter ──────────────────────────────────────────────────
|
|
||||||
def test_vst2_offline_limiter(svc):
|
|
||||||
notes = [{"note": 60, "velocity": 127, "start_beat": 0, "duration_beats": 1}]
|
|
||||||
y_hot = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0)
|
|
||||||
y_lim = svc.render_vst2_offline(FAKE_VST2, notes, gain_db=+18.0,
|
|
||||||
lim_active=True, threshold_db=0.0)
|
|
||||||
assert float(np.abs(y_hot).max()) > 1.0, "hot cần clip (fake sine 0.25 × +18dB)"
|
|
||||||
assert float(np.abs(y_lim).max()) <= 1.0 + 1e-6, "limiter phải chặn clip"
|
|
||||||
|
|
||||||
|
|
||||||
# ── VST3: limitation (bridge export) ───────────────────────────────────────
|
|
||||||
def test_vst3_bridge_exports_master_gain():
|
|
||||||
dll_path = os.path.join(ROOT, "native_host", "build", "Release",
|
|
||||||
"vst3_host_bridge.dll")
|
|
||||||
if not os.path.isfile(dll_path):
|
|
||||||
pytest.skip("chưa build vst3_host_bridge.dll")
|
|
||||||
dll = ctypes.WinDLL(dll_path)
|
|
||||||
dll.SF_VST3_SetMasterGain.argtypes = [ctypes.c_int32, ctypes.c_float]
|
|
||||||
dll.SF_VST3_SetMasterGain.restype = ctypes.c_int32
|
|
||||||
# handle 0 → -1 (chưa có instance) — export gọi được, không crash
|
|
||||||
assert dll.SF_VST3_SetMasterGain(0, 1.0) == -1
|
|
||||||
|
|
||||||
|
|
||||||
# ── API endpoints ──────────────────────────────────────────────────────────
|
|
||||||
def test_api_status_and_render(svc):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app.main import app
|
|
||||||
c = TestClient(app)
|
|
||||||
st = c.get("/api/v1/native/status").json()
|
|
||||||
assert st["available"] is True
|
|
||||||
r = c.post("/api/v1/native/render", json={
|
|
||||||
"kind": "sf", "path": SF2, "bank": 128, "program": 0,
|
|
||||||
"notes": [{"note": 60, "velocity": 100, "start_beat": 0,
|
|
||||||
"duration_beats": 1}],
|
|
||||||
"master_gain_db": -6.0})
|
|
||||||
assert r.status_code == 200
|
|
||||||
d = r.json()
|
|
||||||
assert d["ok"] and d["samples"] > 0 and d["peak"] > 0
|
|
||||||
@@ -18,99 +18,3 @@ def test_render_session_container_empty():
|
|||||||
buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
|
buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
|
||||||
assert buf.shape == (2, 1000)
|
assert buf.shape == (2, 1000)
|
||||||
assert (buf == 0.0).all()
|
assert (buf == 0.0).all()
|
||||||
|
|
||||||
def test_render_project_bit_depth(tmp_path):
|
|
||||||
"""bit_depth 24/32 → WAV PCM_24/PCM_32; mặc định vẫn PCM_16."""
|
|
||||||
import soundfile as sf
|
|
||||||
engine = PythonRenderEngine()
|
|
||||||
project = {
|
|
||||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
|
||||||
"main_session": {"length_bars": 1.0, "tracks": []},
|
|
||||||
"section_store": {},
|
|
||||||
}
|
|
||||||
for bd, subtype in [(16, "PCM_16"), (24, "PCM_24"), (32, "PCM_32")]:
|
|
||||||
out = str(tmp_path / f"render_{bd}.wav")
|
|
||||||
engine.render_project(project, out, bit_depth=bd)
|
|
||||||
assert sf.info(out).subtype == subtype
|
|
||||||
|
|
||||||
def _default_mastering():
|
|
||||||
return {
|
|
||||||
"masterConnected": True, "isBypassed": False,
|
|
||||||
"eqActive": True, "eqLowGain": 1.5, "eqMid1Gain": -1.0, "eqMid2Gain": 2.0, "eqHighGain": 1.8,
|
|
||||||
"imagerActive": True, "w1": 0, "w2": 115, "w3": 135, "w4": 150,
|
|
||||||
"maximizerActive": True, "maxGain": 5.4, "maxUpward": 2.0, "maxSoftClip": 15, "ceiling": -0.1,
|
|
||||||
"compActive": False, "compThreshold": -16, "compRatio": 3, "compMakeup": 2,
|
|
||||||
"limActive": False, "limThreshold": -1.0,
|
|
||||||
"excActive": False, "excDrive": 30,
|
|
||||||
"rebalActive": False, "rebalMid": 0, "rebalSide": 0,
|
|
||||||
"chain": [
|
|
||||||
{"id": "mod_eq", "type": "eq", "active": True},
|
|
||||||
{"id": "mod_imager", "type": "imager", "active": True},
|
|
||||||
{"id": "mod_maximizer", "type": "maximizer", "active": True},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_apply_mastering_engine_gates_and_ceiling():
|
|
||||||
"""apply_mastering: gate masterConnected/isBypassed/chain rỗng → copy;
|
|
||||||
chain mặc định → khác input, peak ≤ ceiling (hard clip của Maximizer)."""
|
|
||||||
import numpy as np
|
|
||||||
from app.core.mastering_engine import apply_mastering
|
|
||||||
sr = 44100
|
|
||||||
t = np.arange(sr // 5) / sr
|
|
||||||
buf = np.stack([np.sin(2 * np.pi * 220 * t) * 0.9,
|
|
||||||
np.sin(2 * np.pi * 220 * t + 0.5) * 0.8]).astype(np.float32)
|
|
||||||
s = _default_mastering()
|
|
||||||
assert np.array_equal(apply_mastering(buf, None, sr), buf)
|
|
||||||
assert np.array_equal(apply_mastering(buf, {**s, "masterConnected": False}, sr), buf)
|
|
||||||
assert np.array_equal(apply_mastering(buf, {**s, "isBypassed": True}, sr), buf)
|
|
||||||
assert np.array_equal(apply_mastering(buf, {**s, "chain": []}, sr), buf)
|
|
||||||
out = apply_mastering(buf, s, sr)
|
|
||||||
assert out.shape == buf.shape and not np.array_equal(out, buf)
|
|
||||||
assert np.max(np.abs(out)) <= 10 ** (-0.1 / 20) + 1e-6 # ceiling -0.1 dB
|
|
||||||
|
|
||||||
def test_render_project_mastering(tmp_path):
|
|
||||||
"""render_project: có mastering_settings → WAV khác bản không mastering,
|
|
||||||
peak ≤ 1.0 (hard clip sau mastering như WAV encoder client)."""
|
|
||||||
import numpy as np
|
|
||||||
import soundfile as sf
|
|
||||||
from app.config import settings
|
|
||||||
engine = PythonRenderEngine()
|
|
||||||
# 1s 440Hz tone làm nguồn audio thật (project rỗng = silence → mastering
|
|
||||||
# của silence = silence, không test được gì)
|
|
||||||
sr = engine.sample_rate
|
|
||||||
t = np.arange(sr) / sr
|
|
||||||
tone = (0.9 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
|
||||||
src_path = os.path.join(settings.UPLOADS_DIR, "mastering_test_tone.wav")
|
|
||||||
sf.write(src_path, tone, sr)
|
|
||||||
try:
|
|
||||||
project = {
|
|
||||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
|
||||||
"main_session": {
|
|
||||||
"length_bars": 4.0,
|
|
||||||
"tracks": [{
|
|
||||||
"type": "AUDIO", "volume_db": 0.0, "pan": 0.0, "mute": False,
|
|
||||||
"items": [{
|
|
||||||
"type": "AUDIO_ITEM", "start_bar": 0.0, "duration_bars": 4.0,
|
|
||||||
"clip_start_offset_bars": 0.0,
|
|
||||||
"source_data": {"audio_file_url": "/static/audio/uploads/mastering_test_tone.wav", "gain": 1.0},
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
"section_store": {},
|
|
||||||
}
|
|
||||||
out_plain = str(tmp_path / "plain.wav")
|
|
||||||
engine.render_project(project, out_plain, bit_depth=16)
|
|
||||||
plain, _ = sf.read(out_plain, dtype="float32")
|
|
||||||
assert np.max(np.abs(plain)) > 0.01
|
|
||||||
|
|
||||||
out_mast = str(tmp_path / "mastered.wav")
|
|
||||||
engine.render_project({**project, "mastering_settings": _default_mastering()},
|
|
||||||
out_mast, bit_depth=16)
|
|
||||||
mast, _ = sf.read(out_mast, dtype="float32")
|
|
||||||
assert not np.array_equal(mast, plain)
|
|
||||||
assert np.max(np.abs(mast)) <= 1.0 + 1e-6
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(src_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
00124e36b2edc6353e9a29c81762747294306d6b3cfeb46e365ff049ea60369e
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
8d8cb8360a19f87760d88df0f6702a6242238d3e51388341646346ad9683cffa
|
|
||||||
Binary file not shown.
@@ -1,57 +0,0 @@
|
|||||||
// T4 test: node tests/unified_midi_router.test.js
|
|
||||||
// Router chưa nối app — test độc lập qua window stub.
|
|
||||||
'use strict';
|
|
||||||
global.window = {};
|
|
||||||
require('../app/static/js/services/unifiedMidiRouter.js');
|
|
||||||
const R = global.window.SonicUnifiedMidiRouter;
|
|
||||||
const assert = require('assert');
|
|
||||||
|
|
||||||
const calls = [];
|
|
||||||
function makeEngine() {
|
|
||||||
return {
|
|
||||||
playNote(pitch, vel, dur) { calls.push(['on', pitch, vel, dur]); },
|
|
||||||
noteOff(pitch) { calls.push(['off', pitch]); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) pitch clamp
|
|
||||||
const r = new R.UnifiedMidiRouter();
|
|
||||||
const eng = makeEngine();
|
|
||||||
r.registerEngine('t1', eng);
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_ON, pitch: 200, velocity: 1, sourceType: 'TEST' });
|
|
||||||
assert.deepStrictEqual(calls[0], ['on', 127, 127, 500], 'pitch clamp 200->127');
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_OFF, pitch: -3 });
|
|
||||||
assert.strictEqual(calls.length, 1, 'note-off khong co voice -> khong goi engine (pitch clamp khong crash)');
|
|
||||||
|
|
||||||
// 2) velocity normalize float->int (100/127 -> 100, 0.5 -> 64)
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 60, velocity: 100 / 127 });
|
|
||||||
assert.deepStrictEqual(calls[1], ['on', 60, 100, 500], 'velocity 100/127 -> 100');
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 61, velocity: 0.5 });
|
|
||||||
assert.deepStrictEqual(calls[2], ['on', 61, 64, 500], 'velocity 0.5 -> 64');
|
|
||||||
|
|
||||||
// 3) tracker đếm đúng: 2 note-on trùng key -> 1 playNote; off cuối mới tắt
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 2, '2 note-on count=2');
|
|
||||||
const onCalls = calls.filter(c => c[0] === 'on' && c[1] === 64).length;
|
|
||||||
assert.strictEqual(onCalls, 1, 'trung key chi play 1 lan');
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 1, '1 off -> count=1, chua noteOff');
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0, 'off cuoi -> count=0');
|
|
||||||
assert.deepStrictEqual(calls[calls.length - 1], ['off', 64], 'off cuoi moi noteOff');
|
|
||||||
|
|
||||||
// 4) 2 track cùng channel+pitch đếm riêng (không lệch voice)
|
|
||||||
const eng2 = makeEngine();
|
|
||||||
r.registerEngine('t2', eng2);
|
|
||||||
r.dispatchMidiEvent({ trackId: 't2', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 90 });
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t2', 2, 64), 1);
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0);
|
|
||||||
|
|
||||||
// 5) panic clear: note đang giữ -> noteOff + tracker rỗng
|
|
||||||
r.dispatchMidiEvent({ trackId: 't1', channel: 3, command: R.COMMAND_NOTE_ON, pitch: 72, velocity: 80 });
|
|
||||||
r.panicAllNotesOff();
|
|
||||||
assert.strictEqual(r.activeVoiceCount('t1', 3, 72), 0, 'panic xoa tracker');
|
|
||||||
assert.deepStrictEqual(calls[calls.length - 1], ['off', 72], 'panic gui noteOff');
|
|
||||||
|
|
||||||
console.log('unified_midi_router: ALL TESTS PASSED');
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""Auto-sample VSTi presets -> SF2/SF3 cho client-side WASM live preview.
|
|
||||||
|
|
||||||
Spec (Option 1): "a custom Python script" render preset qua tung not -> .sf3
|
|
||||||
(~3-8MB) de FluidSynth WASM phat realtime voi 0% IPC. Script nay render not
|
|
||||||
bang pedalboard (DUNG VSTi + preset nhu export) -> SF2 (16-bit mono PCM,
|
|
||||||
1 zone/not). --sf3 chuyen tiep qua SoundFontConverter (can ffmpeg libvorbis).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python tools/autosample_vsti.py --instrument <plugin_id> --out out.sf2 \
|
|
||||||
[--preset <path>] [--low 36 --high 96 --step 2] [--sf3]
|
|
||||||
|
|
||||||
Ket qua dat vao app/storage/soundfonts/ de client tai qua /soundfonts/download.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
sys.path.insert(0, ROOT)
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
def write_sf2(out_path, samples, sample_rate=44100, name="AutoSampled"):
|
|
||||||
"""Ghi SF2 toi gian hop le. samples: list[dict] voi note:int, frames:
|
|
||||||
np.int16 mono 1D (audio da render cua not do)."""
|
|
||||||
if not samples:
|
|
||||||
raise ValueError("no samples")
|
|
||||||
|
|
||||||
def chunk(cid, data):
|
|
||||||
out = bytearray(cid) + struct.pack("<I", len(data)) + data
|
|
||||||
if len(data) % 2:
|
|
||||||
out += b"\x00"
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
def lst(ftype, chunks):
|
|
||||||
return chunk(b"LIST", ftype + b"".join(chunks))
|
|
||||||
|
|
||||||
# --- sdta: PCM 16-bit mono, end exclusive (cung convention SF3 converter) ---
|
|
||||||
pcm = bytearray()
|
|
||||||
offsets = []
|
|
||||||
for s in samples:
|
|
||||||
frames = np.asarray(s["frames"], dtype=np.int16)
|
|
||||||
offsets.append((len(pcm) // 2, frames.shape[0]))
|
|
||||||
pcm += frames.tobytes()
|
|
||||||
if len(pcm) % 2:
|
|
||||||
pcm += b"\x00"
|
|
||||||
sdta = lst(b"sdta", [chunk(b"smpl", bytes(pcm))])
|
|
||||||
|
|
||||||
# --- INFO ---
|
|
||||||
inam = name.encode("ascii", "replace")
|
|
||||||
if len(inam) % 2:
|
|
||||||
inam += b"\x00" # INFO text chunks phai chan (sf2utils/strict parsers khong skip pad byte)
|
|
||||||
info = lst(b"INFO", [
|
|
||||||
chunk(b"ifil", struct.pack("<HH", 2, 1)),
|
|
||||||
chunk(b"INAM", inam),
|
|
||||||
chunk(b"iver", struct.pack("<HH", 2, 1)),
|
|
||||||
])
|
|
||||||
|
|
||||||
# --- pdta ---
|
|
||||||
n = len(samples)
|
|
||||||
phdr = bytearray()
|
|
||||||
phdr += name.encode("ascii", "replace")[:20].ljust(20, b"\x00")
|
|
||||||
phdr += struct.pack("<HHHIII", 0, 0, 0, 0, 0, 0) # preset=0 bank=0 bagNdx=0 libr genre morph
|
|
||||||
phdr += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
|
|
||||||
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 1, 0)
|
|
||||||
pmod = b"\x00" * 10
|
|
||||||
pgen = struct.pack("<HH", 41, 0) + struct.pack("<HH", 0, 0)
|
|
||||||
inst = bytearray()
|
|
||||||
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
|
|
||||||
inst += b"EOI".ljust(20, b"\x00") + struct.pack("<H", n) # bagNdx = chi so record terminal zone (ibag co n+1 records)
|
|
||||||
ibag = b"".join(struct.pack("<HH", i * 3, 0) for i in range(n + 1))
|
|
||||||
imod = b"\x00" * 10
|
|
||||||
igen = bytearray()
|
|
||||||
for i, s in enumerate(samples):
|
|
||||||
note = int(s["note"]) & 0xFF
|
|
||||||
igen += struct.pack("<HH", 43, note | (note << 8)) # keyRange lo=hi=note
|
|
||||||
igen += struct.pack("<HH", 58, note) # overridingRootKey
|
|
||||||
igen += struct.pack("<HH", 53, i) # sampleID (phai la gen cuoi zone)
|
|
||||||
igen += struct.pack("<HH", 0, 0)
|
|
||||||
shdr = bytearray()
|
|
||||||
for i, s in enumerate(samples):
|
|
||||||
note = int(s["note"])
|
|
||||||
start, frames = offsets[i]
|
|
||||||
shdr += f"note{note:03d}".encode()[:20].ljust(20, b"\x00")
|
|
||||||
shdr += struct.pack("<IIIIi", start, start + frames, 0, 0, sample_rate)
|
|
||||||
shdr += struct.pack("<BBH", note, 0, 0) # originalPitch correction sampleLink
|
|
||||||
shdr += struct.pack("<H", 1) # sampleType: mono
|
|
||||||
shdr += b"\x00" * 46 # terminator
|
|
||||||
pdta = lst(b"pdta", [
|
|
||||||
chunk(b"phdr", bytes(phdr)), chunk(b"pbag", pbag), chunk(b"pmod", pmod),
|
|
||||||
chunk(b"pgen", pgen), chunk(b"inst", bytes(inst)), chunk(b"ibag", ibag),
|
|
||||||
chunk(b"imod", imod), chunk(b"igen", bytes(igen)), chunk(b"shdr", bytes(shdr)),
|
|
||||||
])
|
|
||||||
|
|
||||||
body = info + sdta + pdta
|
|
||||||
out = bytearray(b"RIFF") + struct.pack("<I", 4 + len(body)) + b"sfbk" + body
|
|
||||||
with open(out_path, "wb") as f:
|
|
||||||
f.write(out)
|
|
||||||
return out_path
|
|
||||||
|
|
||||||
|
|
||||||
def _render_note(vst, note, sr, dur, release, velocity):
|
|
||||||
# pedalboard >= 0.9: MIDI messages la tuple (bytes raw MIDI, timestamp_seconds)
|
|
||||||
messages = [
|
|
||||||
(bytes([0x90, int(note) & 0x7F, max(0, min(127, velocity))]), 0.0),
|
|
||||||
(bytes([0x80, int(note) & 0x7F, 0]), dur),
|
|
||||||
]
|
|
||||||
buf = vst(messages, sample_rate=sr, duration=dur + release, num_channels=2)
|
|
||||||
mono = buf.mean(axis=0)
|
|
||||||
peak = float(np.max(np.abs(mono)))
|
|
||||||
if peak < 1e-5:
|
|
||||||
return None
|
|
||||||
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
|
||||||
start = int(above[0]) if above.size else 0
|
|
||||||
audio = mono[start:] / peak * 0.9
|
|
||||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
|
||||||
|
|
||||||
|
|
||||||
def _postprocess(buf, sr):
|
|
||||||
"""Stereo float32 (2,N) -> int16 mono, peak 0.9 (chung cho 2 engine)."""
|
|
||||||
mono = buf.mean(axis=0)
|
|
||||||
peak = float(np.max(np.abs(mono)))
|
|
||||||
if peak < 1e-5:
|
|
||||||
return None
|
|
||||||
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
|
||||||
start = int(above[0]) if above.size else 0
|
|
||||||
audio = mono[start:] / peak * 0.9
|
|
||||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_vst2_path(path):
|
|
||||||
"""VST2 = .dll/.so dung rieng (Windows VST3 la FOLDER .vst3, .so VST3
|
|
||||||
co duoi .vst3.so) — native bridge render duoc; pedalboard thi khong."""
|
|
||||||
low = (path or "").lower()
|
|
||||||
if ".vst3" in low:
|
|
||||||
return False
|
|
||||||
return low.endswith(".dll") or low.endswith(".so")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_note_native(plugin_path, note, sr, dur, release, velocity):
|
|
||||||
"""VST2 qua native bridge (Windows) — pedalboard khong ho tro VST2."""
|
|
||||||
from app.core.native_audio_service import get_service
|
|
||||||
bpm = 120.0
|
|
||||||
y = get_service().render_vst2_offline(
|
|
||||||
plugin_path,
|
|
||||||
[{"start_beat": 0.0, "duration_beats": dur * bpm / 60.0,
|
|
||||||
"note": int(note) & 0x7F, "velocity": int(velocity)}],
|
|
||||||
bpm=bpm, sr=sr, tail_sec=release,
|
|
||||||
)
|
|
||||||
return _postprocess(y, sr)
|
|
||||||
|
|
||||||
|
|
||||||
def autosample_sf2(instrument_id, out_path, preset_id=None, preset_path=None,
|
|
||||||
preset_data_b64=None, low=36, high=96, step=2, duration=2.5,
|
|
||||||
release=1.0, velocity=100, sample_rate=44100, name=None, log=None,
|
|
||||||
extra_vst_dirs=None):
|
|
||||||
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
|
|
||||||
/api/v1/plugins/autosample va CLI main().
|
|
||||||
|
|
||||||
VST3 -> pedalboard (ap preset neu co); VST2 (.dll/.so khong .vst3) ->
|
|
||||||
native bridge (Windows) — pedalboard khong ho tro VST2, preset VST2 chua
|
|
||||||
qua bridge duoc nen bo qua.
|
|
||||||
|
|
||||||
Returns dict {note_count, out_path, size_bytes}.
|
|
||||||
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
|
|
||||||
engine khong kha dung hoac khong render duoc not nao.
|
|
||||||
"""
|
|
||||||
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
|
|
||||||
|
|
||||||
plugin_path = PluginManager(extra_vst_dirs=extra_vst_dirs or [])._scan_plugins().get(instrument_id)
|
|
||||||
if not plugin_path:
|
|
||||||
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
|
||||||
|
|
||||||
def _log(msg):
|
|
||||||
if log:
|
|
||||||
log(msg)
|
|
||||||
|
|
||||||
if _is_vst2_path(plugin_path):
|
|
||||||
# VST2: native bridge (Windows) — pedalboard khong ho tro. Preset chua
|
|
||||||
# export qua bridge (VST2AudioEngine chua co chunk/preset) -> bo qua.
|
|
||||||
if preset_id or preset_path or preset_data_b64:
|
|
||||||
_log("VST2: native bridge chua ho tro preset - bo qua, sample preset mac dinh")
|
|
||||||
def _render(note):
|
|
||||||
return _render_note_native(plugin_path, note, sample_rate, duration,
|
|
||||||
release, velocity)
|
|
||||||
else:
|
|
||||||
if not HAS_PEDALBOARD:
|
|
||||||
raise RuntimeError("pedalboard khong kha dung - khong auto-sample VST3 duoc")
|
|
||||||
vst = PluginManager(extra_vst_dirs=extra_vst_dirs or []).load_vst(instrument_id)
|
|
||||||
if vst is None:
|
|
||||||
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
|
||||||
if preset_id or preset_path or preset_data_b64:
|
|
||||||
apply_preset_to_plugin(vst, preset_id=preset_id, preset_path=preset_path,
|
|
||||||
preset_data_b64=preset_data_b64)
|
|
||||||
def _render(note):
|
|
||||||
return _render_note(vst, note, sample_rate, duration, release, velocity)
|
|
||||||
|
|
||||||
samples = []
|
|
||||||
for note in range(low, high + 1, step):
|
|
||||||
frames = _render(note)
|
|
||||||
if frames is None:
|
|
||||||
_log(f"note {note}: silent, bo qua")
|
|
||||||
continue
|
|
||||||
samples.append({"note": note, "frames": frames})
|
|
||||||
_log(f"note {note}: {frames.shape[0] / sample_rate:.2f}s")
|
|
||||||
if not samples:
|
|
||||||
raise RuntimeError("Khong render duoc not nao (plugin silent?)")
|
|
||||||
|
|
||||||
if name is None:
|
|
||||||
name = os.path.basename(instrument_id)
|
|
||||||
write_sf2(out_path, samples, sample_rate, name=name)
|
|
||||||
size = os.path.getsize(out_path)
|
|
||||||
_log(f"wrote {out_path} ({size // 1024} KB, {len(samples)} not)")
|
|
||||||
return {"note_count": len(samples), "out_path": out_path, "size_bytes": size}
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
||||||
ap.add_argument("--instrument", required=True, help="plugin_id da scan (Plugin Manager)")
|
|
||||||
ap.add_argument("--out", required=True, help="duong dan .sf2 (hoac .sf3 voi --sf3)")
|
|
||||||
ap.add_argument("--preset", default=None, help="duong dan preset .vstpreset/.fxp/.fxb")
|
|
||||||
ap.add_argument("--low", type=int, default=36)
|
|
||||||
ap.add_argument("--high", type=int, default=96)
|
|
||||||
ap.add_argument("--step", type=int, default=2, help="buoc not (2 = nua cung)")
|
|
||||||
ap.add_argument("--duration", type=float, default=2.5, help="giay giu not")
|
|
||||||
ap.add_argument("--release", type=float, default=1.0, help="giay duoi sau note-off")
|
|
||||||
ap.add_argument("--velocity", type=int, default=100)
|
|
||||||
ap.add_argument("--sample-rate", type=int, default=44100)
|
|
||||||
ap.add_argument("--sf3", action="store_true", help="convert SF2 -> SF3 sau khi sample")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = autosample_sf2(
|
|
||||||
args.instrument, args.out, preset_path=args.preset,
|
|
||||||
low=args.low, high=args.high, step=args.step,
|
|
||||||
duration=args.duration, release=args.release,
|
|
||||||
velocity=args.velocity, sample_rate=args.sample_rate,
|
|
||||||
)
|
|
||||||
except (FileNotFoundError, RuntimeError) as e:
|
|
||||||
sys.exit(str(e))
|
|
||||||
print(f"wrote {result['out_path']} ({result['size_bytes'] // 1024} KB, {result['note_count']} not)")
|
|
||||||
|
|
||||||
if args.sf3:
|
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
|
||||||
p = SoundFontConverter().convert_sf2_to_sf3(args.out)
|
|
||||||
print("sf3:", p)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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