Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59cf77bcbc | |||
| e3c9d6e4ab | |||
| 255e8586bc | |||
| 916ce1334b | |||
| 94dc7dcf12 | |||
| 371b1ca665 | |||
| 0af834a8fa | |||
| 500384a226 | |||
| b9817e1aed | |||
| 8f1e89ce50 | |||
| 9eec33cc79 | |||
| aaf21ddf89 | |||
| 6bc197b909 | |||
| a12c133ba6 | |||
| 9fc5498999 | |||
| 9dcc37e037 | |||
| 2001d1c601 | |||
| ee30ca097e | |||
| fa2f96dbe3 | |||
| 6da0c5b68d | |||
| aa06d40b98 | |||
| cadb5402a3 | |||
| 7293d7ac7e | |||
| f8cb2c6a5e |
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
+10
-5
@@ -33,13 +33,18 @@ 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/
|
||||||
|
|
||||||
# build-time vendor trees & accidental artifacts (never commit)
|
# Native host build artifacts
|
||||||
native_bridge/vcpkg_installed/
|
native_host/tests/*.dll
|
||||||
native_bridge/sfizz-src/
|
native_host/tests/*.exp
|
||||||
app/static/js/_diag.txt
|
native_host/tests/*.lib
|
||||||
src-tauri/resources/daw_engine" && cp -r*
|
native_host/tests/*.obj
|
||||||
|
native_host/tests/*.exe
|
||||||
|
native_host/fluidsynth_runtime/
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "native_bridge/vst3sdk"]
|
|
||||||
path = native_bridge/vst3sdk
|
|
||||||
url = https://github.com/steinbergmedia/vst3sdk.git
|
|
||||||
@@ -302,31 +302,3 @@ 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,40 +112,6 @@ 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).
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
# 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__`.
|
|
||||||
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# 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()))}
|
||||||
+131
-135
@@ -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, Query
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
|
||||||
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,6 +490,7 @@ 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):
|
||||||
@@ -573,7 +574,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(("127.0.0.1", port))
|
s.bind(("0.0.0.0", port))
|
||||||
s.close()
|
s.close()
|
||||||
return False
|
return False
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -749,12 +750,20 @@ 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
|
# 2. Terminate tiến trình Carla đã spawn — Windows PHẢI giết cả process
|
||||||
|
# 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:
|
||||||
proc.terminate()
|
if os.name == "nt" and proc.pid:
|
||||||
|
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
|
||||||
@@ -774,6 +783,15 @@ 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,
|
||||||
@@ -916,6 +934,7 @@ 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")
|
||||||
@@ -943,6 +962,7 @@ 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,
|
||||||
@@ -978,7 +998,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) -> tuple:
|
soundfont_program=None, bit_depth: int = 16) -> 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."""
|
||||||
@@ -1024,7 +1044,8 @@ 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")
|
||||||
sf.write(out_path, buf.T, sample_rate)
|
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1039,6 +1060,7 @@ 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")
|
||||||
@@ -1074,7 +1096,8 @@ 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,
|
||||||
)
|
)
|
||||||
sf.write(out_path, audio.T, req.sample_rate)
|
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||||
|
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),
|
||||||
@@ -1095,6 +1118,106 @@ 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
|
||||||
@@ -1174,134 +1297,7 @@ 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)
|
result_path = engine.render_project(req.project_json, output_path, bit_depth=req.bit_depth)
|
||||||
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"}
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||||
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"}
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
"""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):
|
def render_project(self, project_json: dict, output_filepath: str, bit_depth: int = 16):
|
||||||
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,11 +470,28 @@ class PythonRenderEngine:
|
|||||||
_cache={},
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalization to prevent clipping
|
# Mastering chain — mirror client WebAudio (app.jsx applyMasteringSettings /
|
||||||
max_peak = np.max(np.abs(master_buffer))
|
# rebuildMasteringGraph). Truoc day mastering_settings bi BO QUA hoan toan:
|
||||||
if max_peak > 1.0:
|
# export WAV khong qua EQ/Imager/Maximizer -> file khac am nghe live.
|
||||||
master_buffer /= max_peak
|
# Gio ap dung dung chain (thu tu settings['chain']) khi mastering bat.
|
||||||
|
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
|
# Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
|
||||||
sf.write(output_filepath, master_buffer.T, self.sample_rate)
|
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||||
|
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,6 +5,31 @@ 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",
|
||||||
@@ -193,8 +218,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 = 0x20)
|
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
|
||||||
new_stype = sampletype | 0x20
|
new_stype = sampletype | 0x10
|
||||||
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
|
||||||
@@ -282,35 +307,28 @@ 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 low-level CFFI binding (new_fluid_synth / write_float) — the
|
Uses the high-level Synth() binding (sfload/get_samples).
|
||||||
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
|
||||||
_settings = _fs.new_fluid_settings()
|
_synth = _fs.Synth()
|
||||||
_fl = _fs.new_fluid_synth(_settings)
|
|
||||||
try:
|
try:
|
||||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
fid = _synth.sfload(path)
|
||||||
if h < 0:
|
if fid == -1:
|
||||||
return False
|
return False
|
||||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
_synth.program_select(0, fid, 0, 0)
|
||||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
_synth.noteon(0, 60, 100)
|
||||||
frames = 8820 # 0.2s
|
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
|
||||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
_synth.noteoff(0, 60)
|
||||||
_fs.fluid_synth_write_float(
|
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
|
||||||
_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:
|
||||||
_fs.delete_fluid_synth(_fl)
|
_synth.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -453,7 +471,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 & ~0x20
|
new_stype = sampletype & ~0x10
|
||||||
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,36 +551,6 @@ 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,6 +20,8 @@ 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
|
||||||
|
|
||||||
@@ -112,6 +114,8 @@ 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")
|
||||||
@@ -132,6 +136,15 @@ 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")
|
||||||
|
|||||||
+633
-519
File diff suppressed because it is too large
Load Diff
+142
-150
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.API_BASE_URL || window.location.origin).replace(/\/+$/, '');
|
const origin = window.location.origin;
|
||||||
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,21 +341,7 @@ ${rules.join('\n')}` },
|
|||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ct = (response.headers.get('content-type') || '').toLowerCase();
|
return await response.json();
|
||||||
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).replace(/\/+$/, '');
|
window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
@@ -91,6 +91,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
|||||||
// để 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) }),
|
||||||
@@ -113,11 +115,6 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
|||||||
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' }),
|
||||||
@@ -146,6 +143,12 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
|||||||
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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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); }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// 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;
|
|
||||||
})();
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// 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 () {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// 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,14 +73,29 @@ 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) {
|
||||||
// Carla bridge removed - all instruments hosted by Native Bridge.
|
try {
|
||||||
return false;
|
// Live playback qua Carla TẮT mặc định: VSTi phát client-side qua SF2
|
||||||
|
// 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) {
|
||||||
// Carla bridge removed - all instruments hosted by Native Bridge.
|
try {
|
||||||
return false;
|
// (xem shouldRoute) — live playback qua Carla tắt mặc định
|
||||||
|
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;
|
||||||
@@ -111,6 +126,12 @@ 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;
|
var sfId = synthEngine && (synthEngine.soundfont_id || synthEngine.autosampled_sf_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,7 +587,43 @@
|
|||||||
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 : undefined;
|
var finalSfId = synthEngine ? (synthEngine.soundfont_id || synthEngine.autosampled_sf_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
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// 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,73 +1,179 @@
|
|||||||
// app/static/js/services/unifiedMidiRouter.js
|
// SonicForge Unified MIDI Router
|
||||||
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
// Mọi nguồn MIDI (keybed preview, timeline scheduler, hardware MIDI) → chuẩn
|
||||||
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
// hóa UnifiedMidiEvent → UnifiedMidiRouter → engine theo trackId. Một điểm
|
||||||
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
// dispatch duy nhất: activeVoiceTracker đếm note-on/off đúng (hết stuck
|
||||||
|
// 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 MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
var COMMAND_NOTE_ON = 0x90;
|
||||||
|
var COMMAND_NOTE_OFF = 0x80;
|
||||||
|
var COMMAND_CC = 0xB0;
|
||||||
|
|
||||||
var router = {
|
// Pitch: ép int 0-127 (spec "pitch clamp 0-127").
|
||||||
bridgeConnected: false,
|
function clampPitch(pitch) {
|
||||||
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
pitch = Math.round(Number(pitch) || 0);
|
||||||
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
if (pitch < 0) return 0;
|
||||||
_channels: {}, // trackId -> { ch, percussion }
|
if (pitch > 127) return 127;
|
||||||
_nextMelodicIdx: 0,
|
return pitch;
|
||||||
|
|
||||||
setBridgeConnected: function (flag) {
|
|
||||||
this.bridgeConnected = !!flag;
|
|
||||||
},
|
|
||||||
|
|
||||||
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
|
||||||
|
|
||||||
isBridgeActive: function () {
|
|
||||||
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
|
||||||
allocateChannel: function (trackId, isPercussion) {
|
|
||||||
if (this._channels[trackId]) return this._channels[trackId].ch;
|
|
||||||
var ch;
|
|
||||||
if (isPercussion) {
|
|
||||||
ch = 9;
|
|
||||||
} else {
|
|
||||||
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
|
||||||
this._nextMelodicIdx++;
|
|
||||||
}
|
|
||||||
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
|
||||||
return ch;
|
|
||||||
},
|
|
||||||
|
|
||||||
resetChannels: function () {
|
|
||||||
this._channels = {};
|
|
||||||
this._nextMelodicIdx = 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
|
||||||
* channel: MIDI channel (or trackId -> allocateChannel first)
|
|
||||||
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
|
||||||
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
|
||||||
*/
|
|
||||||
pushEvent: function (opts) {
|
|
||||||
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
|
||||||
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
|
||||||
var sampleOffset = opts.sampleOffset || 0;
|
|
||||||
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
|
||||||
if (ch === undefined || ch === null) ch = 0;
|
|
||||||
if (this.isBridgeActive()) {
|
|
||||||
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
|
||||||
panic: function () {
|
|
||||||
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
|
||||||
window.NativeBridgeService.transport('panic');
|
|
||||||
}
|
|
||||||
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
window.SonicMidiRouter = router;
|
// Velocity: float 0-1 (vd 100/127) hoặc int → ép int 1-127 (note-on);
|
||||||
|
// 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
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!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,16 +33,17 @@
|
|||||||
<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=202608101800"></script>
|
<script src="/static/js/services/runtime.js?v=202608111230"></script>
|
||||||
<script src="/static/js/services/api.js?v=202608101800"></script>
|
<script src="/static/js/services/api.js?v=202608111301"></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=202608101800"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608111230"></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>
|
||||||
@@ -50,7 +51,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=202608121700" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608111301" 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 {
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
# 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 =="
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# 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,6 +64,51 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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`.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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)
|
||||||
|
```
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// 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
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// 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
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// 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;
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
// 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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<region> sample=dummy1.wav key=60 lokey=0 hikey=127
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "daw-vst-bridge",
|
|
||||||
"version-string": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"fluidsynth",
|
|
||||||
"pkgconf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Submodule native_bridge/vst3sdk deleted from 3cdf9ca5d1
@@ -0,0 +1,343 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// 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"
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
// 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"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""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())
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/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")
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// 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.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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ó.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 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
+27
-133
@@ -333,9 +333,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -497,12 +497,6 @@ 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"
|
||||||
@@ -514,37 +508,6 @@ 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"
|
||||||
@@ -865,24 +828,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -891,38 +854,38 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.34"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
@@ -1576,59 +1539,6 @@ 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"
|
||||||
@@ -2324,21 +2234,6 @@ 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"
|
||||||
@@ -2798,9 +2693,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.22.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -2808,7 +2703,6 @@ 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",
|
||||||
@@ -2819,9 +2713,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.22.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2986,7 +2880,8 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"windows-sys 0.59.0",
|
"webview2-com",
|
||||||
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3193,7 +3088,6 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"data-url",
|
|
||||||
"dirs",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
@@ -4074,9 +3968,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.6"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf",
|
"phf",
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
|
|||||||
@@ -12,18 +12,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["webview-data-url"] }
|
tauri = { version = "2", features = [] }
|
||||||
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.
+10
-408
@@ -10,50 +10,17 @@
|
|||||||
// "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::{AppHandle, Emitter, Manager};
|
use tauri::Manager;
|
||||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
mod shm;
|
mod vst_gui;
|
||||||
use shm::{Shm, ShmState};
|
mod permissions;
|
||||||
|
|
||||||
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)> {
|
||||||
@@ -82,112 +49,6 @@ 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) {
|
||||||
@@ -215,15 +76,12 @@ 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![
|
.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])
|
||||||
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()
|
||||||
@@ -284,6 +142,7 @@ 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)) => {
|
||||||
@@ -307,98 +166,6 @@ 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
|
||||||
@@ -468,173 +235,8 @@ 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
// 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/",
|
||||||
"binaries/libfluidsynth-3.dll": "libfluidsynth-3.dll"
|
"resources/native_host": "native_host/"
|
||||||
},
|
},
|
||||||
"externalBin": ["binaries/daw_vst_bridge"],
|
"externalBin": [],
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installerHooks": "hooks.nsh"
|
"installerHooks": "hooks.nsh"
|
||||||
|
|||||||
+16
-38
@@ -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,45 +12,23 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="msg">Đang khởi động SonicForge Engine…</div>
|
<div id="msg">Đang khởi động SonicForge Engine…</div>
|
||||||
<script>
|
<script>
|
||||||
// Frontend host (Tauri asset page). KHÔNG location.href redirect: chuyển
|
// Engine (sidecar) có thể cần vài giây để boot (import librosa/pedalboard).
|
||||||
// sang http://127.0.0.1:8000 sẽ rời Tauri context -> mất window.__TAURI__
|
// Thử health-check trên dải port 8000-8010, redirect tới port đầu tiên OK.
|
||||||
// -> 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 base = null;
|
var tries = 0;
|
||||||
for (var p = 8000; p <= 8010 && !base; p++) {
|
while (tries < 90) { // ~60s tối đa
|
||||||
try {
|
for (var p = 8000; p <= 8010; p++) {
|
||||||
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
|
try {
|
||||||
if (r.ok) base = 'http://127.0.0.1:' + p + '/';
|
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
|
||||||
} catch (e) { /* port chưa mở */ }
|
if (r.ok) { location.href = 'http://127.0.0.1:' + p + '/'; return; }
|
||||||
|
} catch (e) { /* port chưa mở */ }
|
||||||
|
}
|
||||||
|
tries++;
|
||||||
|
await new Promise(function (res) { setTimeout(res, 700); });
|
||||||
}
|
}
|
||||||
if (!base) {
|
document.getElementById('msg').textContent =
|
||||||
document.getElementById('msg').textContent =
|
'Không tìm thấy SonicForge Engine (port 8000–8010). Hãy đóng và chạy lại ứng dụng. ' +
|
||||||
'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';
|
||||||
'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>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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)")
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""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,3 +18,99 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
00124e36b2edc6353e9a29c81762747294306d6b3cfeb46e365ff049ea60369e
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
8d8cb8360a19f87760d88df0f6702a6242238d3e51388341646346ad9683cffa
|
||||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
// 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');
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""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,11 +14,6 @@ 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:
|
||||||
@@ -36,7 +31,6 @@ 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:
|
||||||
@@ -60,19 +54,6 @@ 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