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
|
||||
+11
@@ -33,7 +33,18 @@ node_modules
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
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
|
||||
app/storage/plugin_dirs.json
|
||||
app/storage/sf_scan_state.json.bak-root
|
||||
app/storage/soundfonts/
|
||||
|
||||
# Native host build artifacts
|
||||
native_host/tests/*.dll
|
||||
native_host/tests/*.exp
|
||||
native_host/tests/*.lib
|
||||
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
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
## 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
|
||||
- **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).
|
||||
|
||||
@@ -1,237 +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.
|
||||
|
||||
---
|
||||
|
||||
## 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,80 +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)
|
||||
- [ ] C5: ring buffer 8 block chống underrun thật trên Windows (âm nghe được, VU nhảy)
|
||||
|
||||
## D. app.jsx wiring
|
||||
- [x] D1: onmidimessage NOTE_ON/OFF → router (code + bundle OK)
|
||||
- [x] D2: timeline → scheduleMidiNoteDispatch + transport play/stop (code + bundle OK)
|
||||
- [x] D9: bridgePlayheadSampleRef counter + `transport('set_position', playhead)` ~mỗi giây trong updatePlayhead (JS là nguồn playhead duy nhất; C++ A13 cập nhật anchor) — code + bundle OK
|
||||
- [ ] D1-D2 thật: bấm keyboard + play piano roll → âm qua bridge, Stop hết âm ngân
|
||||
- [x] D4: Carla không route khi bridge active (code, cần verify thật)
|
||||
- [ ] D5: audio routing vào track FX chain
|
||||
- [x] D6: panic/Stop → `SonicMidiRouter.panic()` (bridge transport panic + fallback SonicSF.stopAll) — 4 call site app.jsx đã đổi
|
||||
- [x] D8: track→channel map + gửi CC0/CC32/PROGRAM trước NOTE_ON khi đổi instrument (scheduleMidiNoteDispatch)
|
||||
- [x] D7: UI Plugin Manager — badge `● Bridge ON/OFF` (query `GET /api/v1/bridge/status`), nút "Load Bridge" trên mỗi VST + SoundFont → `POST /api/v1/bridge/load` (E2 resolve path) → `NativeBridgeService.loadInstrument(path, type, 0)`; code + bundle OK — chưa test thật trên Windows
|
||||
- [x] D10: header indicator Bridge/WASM + nút "Ép WASM" (toggle `SonicMidiRouter.setForceWasm`); service thêm `onStatusChange(cb)` — code + bundle OK
|
||||
|
||||
## E. Backend Python
|
||||
- [x] E1-E3: syntax OK (ast.parse)
|
||||
- [x] E4: desktop_engine.py watchdog — verify: chỉ theo dõi `SF_PARENT_PID` (Tauri) để tự thoát; không spawn/kill gì khác → không đụng bridge (bridge do Rust spawn). Không cần sửa.
|
||||
- [x] E5: `GET /api/v1/bridge/log?lines=N` tail bridge.log — thêm Query import; ast.parse OK
|
||||
- [ ] `curl /api/v1/bridge/status` trả `connected` đúng — cần chạy trên máy không SIGILL
|
||||
- [ ] `POST /api/v1/bridge/load` resolve sf2/vst3 → bridge load → UI nhận `bridge-audio`
|
||||
- [ ] SIGILL khi import `app.core.vst_engine` trên máy dev (numpy/pedalboard pre-existing) — không phải do code mới
|
||||
|
||||
## F. Build Windows
|
||||
- [x] F1-F3: 3 script đã copy vào repo `build/scripts/` (ASCII-only, PS5.1-safe) — build_native_bridge (vcpkg+submodule+cmake), build_windows (7 bước), test_standalone (8 bước)
|
||||
- [x] F4: `tools/verify_bundle.py --check-bridge` — verify TOC + sidecar tồn tại; fail đúng cách trên Linux (chưa có exe) — test exit 1 OK
|
||||
- [x] F5: hooks.nsh — vc_redist cài toàn máy (ExecWait /quiet) → đủ runtime cho cả daw_engine.exe + daw_vst_bridge.exe; MSI không chạy hooks → cài thủ công
|
||||
- [x] F6: DESKTOP_INSTALL_PLAN.md §5.2 (kiến trúc 2 sidecar) + DEPLOYMENT_REPORT.md bản 1.2
|
||||
- [x] F7: `.github/workflows/build-windows.yml` — windows-latest: MSVC + Rust + node + python → build_windows.ps1 → upload NSIS/MSI
|
||||
- [ ] **Chạy thật trên Windows**: build_windows.ps1 trọn pipeline ra NSIS+MSI
|
||||
|
||||
## G. Test end-to-end
|
||||
- [ ] G1: shm_selfcheck pass (đã pass trên Linux: "SHM self-check OK (**11160 bytes**)" — chạy lại trên Windows)
|
||||
- [ ] G2: SHM Rust tạo → bridge mở → MIDI → audio
|
||||
- [ ] G3: load SGM-V2.01 (SF2) + SalamanderPiano (SFZ) + Vital (VST3)
|
||||
- [ ] G4: live MIDI + timeline cùng lúc, latency <10ms
|
||||
- [ ] G5: Track FX + Master FX tác động âm bridge
|
||||
- [ ] G6: kill bridge → SonicSF fallback, không crash
|
||||
- [ ] G7: test_standalone.ps1 5/5
|
||||
- [ ] G8: đo latency blockTimestamp IPC ≤0.5ms, end-to-end <10ms
|
||||
- [ ] G9: multi-instance 2 track đồng thời (A10 — 2 SF2/SFZ khác nhau, đổi live)
|
||||
- [ ] G10: sustain/CC/pitchbend qua bridge (A12 — CC64 sustain + pitch wheel + CC7 volume)
|
||||
|
||||
## Ghi chú batch 2026-08-11 (A10-A13 + D5)
|
||||
- Struct SHM đổi 10872 → 11160: MidiEventIPC +data2/data3(+reserved) = 12B; ControlEventIPC +channel = 1040B.
|
||||
Sync 3 nơi: `SharedMemoryIPC.h`, `shm.rs`, `shm_selfcheck.cpp` — đã khớp (selfcheck pass Linux).
|
||||
- Bug pre-existing tìm thấy: `shm.rs push_control` không ghi pathlen vào arg1 → bridge cũ đọc path rỗng.
|
||||
Sửa: C++ `main.cpp` load path dùng strnlen(arg2); helper C `shm_write_control` chỉ set arg1=pathlen khi type==2.
|
||||
- `transport_control` Rust giờ nhận `playhead: Option<u32>`; JS `NativeBridgeService.transport(kind, playhead)`.
|
||||
- `loadInstrument(filePath, type, channel)`; `dispatchMidiEvent(cmd,...,data2,data3)` hỗ trợ CC/PROGRAM/PITCH_BEND.
|
||||
- Khi D8 (track→channel map) chưa xong: JS loadInstrument mặc định channel=0 — multi-instance chưa kích hoạt từ UI.
|
||||
@@ -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
-125
@@ -1,7 +1,7 @@
|
||||
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
|
||||
import numpy as np
|
||||
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 pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
@@ -490,6 +490,7 @@ async def download_soundfont_asset(sf_id: str):
|
||||
class RenderRequest(BaseModel):
|
||||
project_json: dict
|
||||
output_filename: Optional[str] = "render_output.wav"
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
class OpenInCarlaRequest(BaseModel):
|
||||
@@ -573,7 +574,7 @@ def _carla_osc_ready() -> bool:
|
||||
import socket
|
||||
port = _carla_osc_port()
|
||||
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()
|
||||
return False
|
||||
except OSError:
|
||||
@@ -749,12 +750,20 @@ async def carla_stop(authorization: Optional[str] = Header(None)):
|
||||
_send_carla_all_notes_off()
|
||||
except Exception:
|
||||
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
|
||||
_prune_carla_processes()
|
||||
for proc in list(_CARLA_PROCESSES):
|
||||
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:
|
||||
pass
|
||||
# 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:
|
||||
pass
|
||||
_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 {
|
||||
"success": True,
|
||||
"stopped": True,
|
||||
@@ -916,6 +934,7 @@ class MidiRenderRequest(BaseModel):
|
||||
preset_id: Optional[str] = None
|
||||
preset_path: Optional[str] = None
|
||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@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_path=req.preset_path,
|
||||
preset_data_b64=req.preset_data,
|
||||
bit_depth=req.bit_depth,
|
||||
)
|
||||
return {
|
||||
"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,
|
||||
sample_rate: int, preset_id=None, preset_path=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.
|
||||
|
||||
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,
|
||||
duration=total_needed / float(sample_rate), num_channels=2)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1039,6 +1060,7 @@ class SoundfontRenderRequest(BaseModel):
|
||||
notes: list = []
|
||||
bpm: float = 120.0
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@router.post("/soundfont-render")
|
||||
@@ -1074,7 +1096,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
||||
bank=req.bank, program=req.program,
|
||||
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 {
|
||||
"success": True,
|
||||
"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}")
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
@@ -1174,124 +1297,7 @@ async def render_project(
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
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}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
||||
|
||||
# ── E1/E2: NATIVE HOST BRIDGE (daw_vst_bridge) ──────────────────────────────
|
||||
# IPC dir giống _pick_dir_via_tauri_bridge: %APPDATA%/SonicForgeDAW/ipc — Rust
|
||||
# (src-tauri) tạo, engine chỉ đọc/ghi file request/response.
|
||||
BRIDGE_IPC_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "SonicForgeDAW", "ipc")
|
||||
|
||||
def _bridge_ipc_dir() -> Optional[str]:
|
||||
if os.path.isdir(BRIDGE_IPC_DIR):
|
||||
return BRIDGE_IPC_DIR
|
||||
return None
|
||||
|
||||
@router.get("/bridge/status")
|
||||
async def bridge_status(current_user: dict = Depends(get_current_user)):
|
||||
"""E1: trạng thái native bridge. Rust ghi bridge_status (JSON) khi spawn/
|
||||
health-check; nếu chưa có → probe tail bridge.log (dòng started/exists)."""
|
||||
ipc = _bridge_ipc_dir()
|
||||
if not ipc:
|
||||
return {"connected": False, "reason": "no_ipc_dir"}
|
||||
st = os.path.join(ipc, "bridge_status")
|
||||
if os.path.exists(st):
|
||||
try:
|
||||
with open(st, "r", encoding="utf-8") as fh:
|
||||
raw = fh.read() or "{}"
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = {}
|
||||
data.setdefault("connected", True)
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
log = os.path.normpath(os.path.join(ipc, "..", "logs", "bridge.log"))
|
||||
if os.path.exists(log):
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
tail = fh.read().splitlines()[-5:]
|
||||
joined = "\n".join(tail).lower()
|
||||
return {"connected": ("started" in joined or "exists=true" in joined), "log_tail": tail}
|
||||
except Exception:
|
||||
pass
|
||||
return {"connected": False, "reason": "no_status_no_log"}
|
||||
|
||||
class BridgeLoadRequest(BaseModel):
|
||||
name: str # tên asset hiển thị (sf_xxx / Vital.vst3 / ...)
|
||||
path: Optional[str] = None # path tuyệt đối nếu đã biết
|
||||
instrumentType: str = "SF2" # 'VST3'|'VST2'|'SF2'|'SF3'|'SFZ'
|
||||
channel: Optional[int] = None
|
||||
|
||||
def _resolve_bridge_asset(name: str, instrument_type: str, path: Optional[str]) -> Optional[str]:
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
base = os.path.basename((path or name).replace("\\", "/"))
|
||||
base_noext = os.path.splitext(base)[0].lower()
|
||||
# sf2/sf3/sfz: storage/soundfonts + system + user plugin_dirs
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for fname in os.listdir(base_dir):
|
||||
cand = os.path.join(base_dir, fname)
|
||||
if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
|
||||
(fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
|
||||
return cand
|
||||
ext = (".vst3" if instrument_type.upper() == "VST3" else
|
||||
".vst2" if instrument_type.upper() == "VST2" else
|
||||
".sfz" if instrument_type.upper() == "SFZ" else None)
|
||||
if ext:
|
||||
try:
|
||||
from app.core.vst_engine import _load_user_plugin_dirs
|
||||
dirs = _load_user_plugin_dirs() or []
|
||||
except Exception:
|
||||
dirs = []
|
||||
if not dirs:
|
||||
dirs = [settings.VST_DIR, settings.SOUNDFONT_DIR]
|
||||
for base_dir in dirs:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for fn in files:
|
||||
if fn.lower().endswith(ext) and \
|
||||
(fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext):
|
||||
return os.path.join(root, fn)
|
||||
return None
|
||||
|
||||
@router.post("/bridge/load")
|
||||
async def bridge_load(req: BridgeLoadRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""E2: resolve path thật của asset rồi (a) ghi bridge_load.request cho Rust
|
||||
watcher (nếu có) và (b) trả path để JS gọi invoke load_native_instrument
|
||||
trực tiếp — pipeline hoạt động ngay, không chờ watcher."""
|
||||
resolved = _resolve_bridge_asset(req.name, req.instrumentType, req.path)
|
||||
if not resolved:
|
||||
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
|
||||
wrote_request = False
|
||||
ipc = _bridge_ipc_dir()
|
||||
if ipc:
|
||||
try:
|
||||
req_file = os.path.join(ipc, "bridge_load.request")
|
||||
if os.path.exists(req_file):
|
||||
os.remove(req_file)
|
||||
with open(req_file, "w", encoding="utf-8") as fh:
|
||||
json.dump({"path": resolved, "type": req.instrumentType, "channel": req.channel}, fh)
|
||||
wrote_request = True
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True, "path": resolved, "type": req.instrumentType, "ipc_request_written": wrote_request}
|
||||
|
||||
@router.get("/bridge/log")
|
||||
async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict = Depends(get_current_user)):
|
||||
"""E5: tail bridge.log cho UI debug. Log Rust ghi tại %APPDATA%/SonicForgeDAW/logs/bridge.log."""
|
||||
log = os.path.normpath(os.path.join(BRIDGE_IPC_DIR, "..", "logs", "bridge.log"))
|
||||
if not os.path.exists(log):
|
||||
return {"lines": [], "path": log, "exists": False}
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
all_lines = fh.read().splitlines()
|
||||
return {"lines": all_lines[-lines:], "path": log, "exists": True}
|
||||
except Exception as e:
|
||||
return {"lines": [], "path": log, "exists": False, "error": str(e)}
|
||||
|
||||
@@ -193,7 +193,7 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int
|
||||
y, sr_read = sf.read(temp_wav)
|
||||
|
||||
# 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")
|
||||
|
||||
# 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")
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
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"]
|
||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||
main_session = project_json["main_session"]
|
||||
@@ -470,11 +470,28 @@ class PythonRenderEngine:
|
||||
_cache={},
|
||||
)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
max_peak = np.max(np.abs(master_buffer))
|
||||
if max_peak > 1.0:
|
||||
master_buffer /= max_peak
|
||||
# Mastering chain — mirror client WebAudio (app.jsx applyMasteringSettings /
|
||||
# rebuildMasteringGraph). Truoc day mastering_settings bi BO QUA hoan toan:
|
||||
# export WAV khong qua EQ/Imager/Maximizer -> file khac am nghe live.
|
||||
# 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
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate)
|
||||
# Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
|
||||
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
|
||||
|
||||
@@ -5,6 +5,31 @@ import logging
|
||||
import wave
|
||||
|
||||
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 = [
|
||||
"/opt/daw_engine/soundfonts",
|
||||
@@ -193,8 +218,8 @@ class SoundFontConverter:
|
||||
# OGG loop pointers are relative to the individual decompressed sample
|
||||
new_sloop = (startloop - start) if (startloop > start and startloop <= 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)
|
||||
new_stype = sampletype | 0x20
|
||||
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
|
||||
new_stype = sampletype | 0x10
|
||||
new_shdr += data[base:base + 20] # sample name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
||||
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
|
||||
against shipping malformed SF3 files that silently play nothing).
|
||||
|
||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||
high-level Synth() class does not exist in this binding, so it is never
|
||||
used here.
|
||||
Uses the high-level Synth() binding (sfload/get_samples).
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
_ensure_fluidsynth_runtime()
|
||||
try:
|
||||
import fluidsynth as _fs
|
||||
import numpy as np
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fl = _fs.new_fluid_synth(_settings)
|
||||
_synth = _fs.Synth()
|
||||
try:
|
||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||
if h < 0:
|
||||
fid = _synth.sfload(path)
|
||||
if fid == -1:
|
||||
return False
|
||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||
frames = 8820 # 0.2s
|
||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||
_fs.fluid_synth_write_float(
|
||||
_fl, frames, buf.ctypes.data, 0, 1,
|
||||
buf.ctypes.data + frames * 4, 0, 1
|
||||
)
|
||||
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||
_synth.program_select(0, fid, 0, 0)
|
||||
_synth.noteon(0, 60, 100)
|
||||
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
|
||||
_synth.noteoff(0, 60)
|
||||
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
|
||||
return rms > 1e-4
|
||||
finally:
|
||||
try:
|
||||
_fs.delete_fluid_synth(_fl)
|
||||
_synth.delete()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -453,7 +471,7 @@ class SoundFontConverter:
|
||||
new_loopstart = loopstart + 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
|
||||
new_stype = sampletype & ~0x20
|
||||
new_stype = sampletype & ~0x10
|
||||
new_shdr += name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
||||
new_shdr += data[base + 40:base + 44]
|
||||
|
||||
@@ -551,36 +551,6 @@ def resolve_preset_path(preset_id_or_path: str) -> str:
|
||||
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:
|
||||
"""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
|
||||
|
||||
+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.system import router as system_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.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(system_router, prefix="/api/v1/system", tags=["system"])
|
||||
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")
|
||||
@@ -132,6 +136,15 @@ async def get_index():
|
||||
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")
|
||||
async def get_favicon():
|
||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||
|
||||
+527
-409
File diff suppressed because it is too large
Load Diff
+128
-123
File diff suppressed because one or more lines are too long
@@ -91,6 +91,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
// để UI preview / gán clip vào track / download
|
||||
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) }),
|
||||
// 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
|
||||
// pedalboard không render được plugin (VD VST2)
|
||||
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;
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
// Native Host Bridge (daw_vst_bridge C++): trạng thái + load asset +
|
||||
// tail bridge.log (E1/E2/E5).
|
||||
bridgeStatus: () => apiRequest('/api/v1/bridge/status', { method: 'GET' }),
|
||||
bridgeLoad: (payload) => apiRequest('/api/v1/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
bridgeLog: (lines = 100) => apiRequest(`/api/v1/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||
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;
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
}
|
||||
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,40 +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,
|
||||
|
||||
isConnected: function () { return this._connected; },
|
||||
|
||||
/** bridgeNode = window.BridgeAudioNode; trackCtx = track node ({ sfEntry, gainNode }). */
|
||||
connect: function (bridgeNode, trackCtx, trackId) {
|
||||
if (!bridgeNode || !bridgeNode.getOutputNode) return false;
|
||||
this.disconnect();
|
||||
var dest = null;
|
||||
if (trackCtx && trackCtx.sfEntry) dest = trackCtx.sfEntry;
|
||||
else if (trackCtx && trackCtx.gainNode) dest = trackCtx.gainNode;
|
||||
else if (window.masterBus && window.masterBus.input) dest = window.masterBus.input;
|
||||
if (!dest) {
|
||||
console.warn('[AudioRoutingEngine] no destination (no trackCtx / masterBus)');
|
||||
return false;
|
||||
}
|
||||
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||
bridgeNode.connect(dest);
|
||||
this._connected = true;
|
||||
this._trackId = trackId || null;
|
||||
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||
return true;
|
||||
},
|
||||
|
||||
disconnect: function () {
|
||||
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||
this._connected = false;
|
||||
this._trackId = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.AudioRoutingEngine = engine;
|
||||
})();
|
||||
@@ -1,72 +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 () {
|
||||
var RING_DEPTH = 8; // 8 blocks * 256 samples ~= 46ms anti-underrun
|
||||
var SP_BUFFER = 4096; // ScriptProcessor chunk (16 bridge blocks)
|
||||
var _queue = [];
|
||||
var _spn = null;
|
||||
var _gainNode = null;
|
||||
var _ctx = null;
|
||||
var _initialized = false;
|
||||
|
||||
function _getCtx() {
|
||||
if (_ctx) return _ctx;
|
||||
if (typeof getAudioContext === 'function') _ctx = getAudioContext();
|
||||
else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx;
|
||||
else _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
function init(audioCtx) {
|
||||
if (_initialized) return;
|
||||
_ctx = audioCtx || _getCtx();
|
||||
_gainNode = _ctx.createGain();
|
||||
_gainNode.gain.value = 1.0;
|
||||
_spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2);
|
||||
_spn.onaudioprocess = function (e) {
|
||||
var L = e.outputBuffer.getChannelData(0);
|
||||
var R = e.outputBuffer.getChannelData(1);
|
||||
L.fill(0); R.fill(0);
|
||||
if (!_queue.length) return;
|
||||
var f = _queue.shift();
|
||||
L.set(f.l); R.set(f.r);
|
||||
};
|
||||
_spn.connect(_gainNode);
|
||||
_initialized = true;
|
||||
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||
}
|
||||
|
||||
function onAudio(l, r) {
|
||||
if (!_initialized) init();
|
||||
_queue.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||
if (_queue.length > RING_DEPTH) _queue.shift(); // drop oldest on overrun
|
||||
}
|
||||
|
||||
function flush() { _queue = []; }
|
||||
|
||||
function getOutputNode() { return _gainNode; }
|
||||
|
||||
function disconnect() {
|
||||
flush();
|
||||
if (_spn && _gainNode) {
|
||||
try { _spn.disconnect(); } catch (e) {}
|
||||
try { _gainNode.disconnect(); } catch (e) {}
|
||||
}
|
||||
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||
_initialized = false;
|
||||
_spn = null;
|
||||
_gainNode = null;
|
||||
_ctx = null;
|
||||
}
|
||||
|
||||
window.BridgeAudioNode = {
|
||||
init: init,
|
||||
onAudio: onAudio,
|
||||
flush: flush,
|
||||
getOutputNode: getOutputNode,
|
||||
connect: function (dest) { if (_gainNode) _gainNode.connect(dest); },
|
||||
disconnect: disconnect,
|
||||
isReady: function () { return _initialized; }
|
||||
};
|
||||
})();
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
if (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 {
|
||||
// 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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,150 +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 });
|
||||
},
|
||||
|
||||
_init: function () {
|
||||
if (!this._tauri()) {
|
||||
// Dev mode (Linux / plain browser): no Tauri -> log-only, app uses SonicSF.
|
||||
console.log('[BridgeService] Dev mode: no __TAURI__, native bridge disabled.');
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
try {
|
||||
window.__TAURI__.event.listen('bridge-audio', function (e) {
|
||||
if (self._audioCb) self._audioCb(e.payload.l, e.payload.r);
|
||||
});
|
||||
window.__TAURI__.event.listen('bridge-down', function () {
|
||||
self._notifyStatus(false);
|
||||
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(false);
|
||||
if (window.AudioRoutingEngine) window.AudioRoutingEngine.disconnect();
|
||||
console.warn('[BridgeService] bridge-down event received.');
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] event listen failed:', e);
|
||||
}
|
||||
this.queryStatus();
|
||||
},
|
||||
|
||||
queryStatus: async function () {
|
||||
if (!this._tauri()) return { connected: false };
|
||||
try {
|
||||
var s = await window.__TAURI__.core.invoke('bridge_status');
|
||||
this._notifyStatus(!!s.connected);
|
||||
return s;
|
||||
} catch (e) {
|
||||
this._notifyStatus(false);
|
||||
return { connected: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 1. LOAD NEW INSTRUMENT INTO BRIDGE (VST3 / VST2 / SF2 / SF3 / SFZ)
|
||||
* instrumentType: 'VST3' | 'VST2' | 'SF2' | 'SF3' | 'SFZ'
|
||||
* channel: MIDI channel to assign this instrument to (A10 multi-instance).
|
||||
*/
|
||||
loadInstrument: async function (filePath, instrumentType, channel) {
|
||||
this.activeInstrumentType = instrumentType;
|
||||
console.log('[BridgeService] Loading ' + instrumentType + ' asset: ' + filePath + ' ch=' + channel);
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('load_native_instrument', {
|
||||
path: filePath,
|
||||
instrumentType: INSTRUMENT_TYPE[instrumentType] !== undefined ? INSTRUMENT_TYPE[instrumentType] : 0,
|
||||
channel: channel === undefined ? 0 : channel
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] loadInstrument failed:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 2. UNIFIED MIDI EVENT DISPATCH
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* velocity 0..1; sampleOffset in samples within the current audio block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB.
|
||||
*/
|
||||
dispatchMidiEvent: function (cmd, channel, pitch, velocity, sampleOffset, data2, data3) {
|
||||
if (!this._tauri()) return false;
|
||||
var safeVelocity = Math.floor(Math.min(1.0, Math.max(0.0, velocity)) * 127);
|
||||
var byteCmd;
|
||||
switch (cmd) {
|
||||
case 'CC': byteCmd = 0xB; break;
|
||||
case 'PROGRAM': byteCmd = 0xC; break;
|
||||
case 'PITCH_BEND': byteCmd = 0xE; break;
|
||||
default: byteCmd = cmd === 'NOTE_ON' ? 0x9 : 0x8; break;
|
||||
}
|
||||
window.__TAURI__.core.invoke('push_midi_event', {
|
||||
command: byteCmd,
|
||||
channel: channel,
|
||||
pitch: pitch,
|
||||
velocity: safeVelocity,
|
||||
data2: data2 === undefined ? 0 : data2,
|
||||
data3: data3 === undefined ? 0 : data3,
|
||||
sampleOffset: sampleOffset || 0
|
||||
}).catch(function (e) { console.warn('[BridgeService] push_midi_event:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 3. OPEN FLOATING CHILD WINDOW NATIVE GUI
|
||||
*/
|
||||
openNativeGUI: async function (pluginId) {
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('open_vst_gui', { pluginId: pluginId });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] open_vst_gui:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 4. TRANSPORT CONTROL ('play' | 'stop' | 'panic' | 'set_position')
|
||||
* playhead: sample position (A13) — used by 'play' and 'set_position'.
|
||||
*/
|
||||
transport: function (kind, playhead) {
|
||||
if (!this._tauri()) return false;
|
||||
var args = { kind: kind };
|
||||
if (playhead !== undefined) args.playhead = playhead;
|
||||
window.__TAURI__.core.invoke('transport_control', args)
|
||||
.catch(function (e) { console.warn('[BridgeService] transport_control:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 5. AUDIO SINK: register consumer of PCM frames from the bridge.
|
||||
* cb(l: Float32Array, r: Float32Array)
|
||||
*/
|
||||
onAudio: function (cb) {
|
||||
this._audioCb = cb;
|
||||
}
|
||||
};
|
||||
|
||||
window.NativeBridgeService = service;
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { service._init(); });
|
||||
} else {
|
||||
service._init();
|
||||
}
|
||||
})();
|
||||
@@ -74,9 +74,10 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
shouldRoute: function (synthEngine, isArmed) {
|
||||
try {
|
||||
// D4: bridge active → VSTi do native bridge host, KHÔNG route Carla
|
||||
// (tránh kép âm).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||
// 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;
|
||||
@@ -88,8 +89,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
// user đã chủ động bấm Play trên item đó).
|
||||
shouldRoutePlayback: function (synthEngine) {
|
||||
try {
|
||||
// D4: bridge active → KHÔNG route Carla (bridge host VSTi).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) 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 || {};
|
||||
@@ -125,6 +126,12 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
stopBridge: function () {
|
||||
var self = this;
|
||||
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) {
|
||||
return window.SonicAPI.stopCarla().catch(function () {});
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
bank = bank !== undefined ? bank : (synthEngine.soundfont_bank || 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;
|
||||
if (!_engineChMap[engKey]) {
|
||||
var channel = this.allocateChannel(bank);
|
||||
@@ -587,7 +587,43 @@
|
||||
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||
var finalProg = parseInt(usedProg);
|
||||
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];
|
||||
// The note's own synth engine (track instrument) is
|
||||
// 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
|
||||
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||
// SonicForge Unified MIDI Router
|
||||
// Mọi nguồn MIDI (keybed preview, timeline scheduler, hardware MIDI) → chuẩn
|
||||
// hóa UnifiedMidiEvent → UnifiedMidiRouter → engine theo trackId. Một điểm
|
||||
// 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 () {
|
||||
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 = {
|
||||
bridgeConnected: false,
|
||||
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||
_channels: {}, // trackId -> { ch, percussion }
|
||||
_nextMelodicIdx: 0,
|
||||
|
||||
setBridgeConnected: function (flag) {
|
||||
this.bridgeConnected = !!flag;
|
||||
},
|
||||
|
||||
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
||||
|
||||
isBridgeActive: function () {
|
||||
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
||||
},
|
||||
|
||||
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
||||
allocateChannel: function (trackId, isPercussion) {
|
||||
if (this._channels[trackId]) return this._channels[trackId].ch;
|
||||
var ch;
|
||||
if (isPercussion) {
|
||||
ch = 9;
|
||||
} else {
|
||||
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
||||
this._nextMelodicIdx++;
|
||||
}
|
||||
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
||||
return ch;
|
||||
},
|
||||
|
||||
resetChannels: function () {
|
||||
this._channels = {};
|
||||
this._nextMelodicIdx = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* channel: MIDI channel (or trackId -> allocateChannel first)
|
||||
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
||||
*/
|
||||
pushEvent: function (opts) {
|
||||
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
||||
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
||||
var sampleOffset = opts.sampleOffset || 0;
|
||||
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
||||
if (ch === undefined || ch === null) ch = 0;
|
||||
if (this.isBridgeActive()) {
|
||||
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
||||
return;
|
||||
}
|
||||
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
||||
},
|
||||
|
||||
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
||||
panic: function () {
|
||||
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
||||
window.NativeBridgeService.transport('panic');
|
||||
}
|
||||
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
||||
// Pitch: ép int 0-127 (spec "pitch clamp 0-127").
|
||||
function clampPitch(pitch) {
|
||||
pitch = Math.round(Number(pitch) || 0);
|
||||
if (pitch < 0) return 0;
|
||||
if (pitch > 127) return 127;
|
||||
return pitch;
|
||||
}
|
||||
};
|
||||
|
||||
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-dom.production.min.js"></script>
|
||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/api.js?v=202608101800"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608111230"></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/storage.js?v=202608038200"></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/nativeBridgeService.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/bridgeAudioNode.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/unifiedMidiRouter.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/audioRoutingEngine.js?v=202608112200"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608111230"></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/pianoRollTabService.js?v=202607272044"></script>
|
||||
@@ -50,7 +51,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608102202" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608111301" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# build_native_bridge.ps1 - Build daw_vst_bridge.exe (C++ Native Host Bridge)
|
||||
# Run on Windows x64 with VS Build Tools 2022 (C++ workload) installed.
|
||||
# ASCII only (PowerShell 5.1 reads .ps1 without BOM as ANSI).
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location $PSScriptRoot
|
||||
$REPO_ROOT = Resolve-Path (Join-Path $PSScriptRoot "..\..") # ...\SonicForgeStudio
|
||||
$NB_DIR = Join-Path $REPO_ROOT "native_bridge"
|
||||
|
||||
# 1. vcpkg (bootstrap once; skip if already present)
|
||||
$VCPKG_ROOT = Join-Path $env:USERPROFILE "vcpkg"
|
||||
if (-not (Test-Path (Join-Path $VCPKG_ROOT "vcpkg.exe"))) {
|
||||
Write-Host "== [1/6] Bootstrap vcpkg =="
|
||||
git clone https://github.com/microsoft/vcpkg $VCPKG_ROOT
|
||||
& (Join-Path $VCPKG_ROOT "bootstrap-vcpkg.bat") -disableMetrics
|
||||
}
|
||||
|
||||
# 2. vcpkg deps: fluidsynth + sfizz (+ pkgconf so pkg_check_modules may work)
|
||||
Write-Host "== [2/6] vcpkg install fluidsynth sfizz =="
|
||||
& (Join-Path $VCPKG_ROOT "vcpkg.exe") install fluidsynth sfizz --triplet x64-windows
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: vcpkg install failed" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 3. VST3 SDK submodule (Steinberg; NOT in vcpkg)
|
||||
Write-Host "== [3/6] VST3 SDK (submodule) =="
|
||||
if (-not (Test-Path (Join-Path $NB_DIR "vst3sdk"))) {
|
||||
Push-Location $NB_DIR
|
||||
git submodule add https://github.com/steinbergmedia/vst3sdk.git vst3sdk
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# 4. Configure with CMake + vcpkg toolchain (MSVC)
|
||||
Write-Host "== [4/6] CMake configure =="
|
||||
$BUILD_DIR = Join-Path $NB_DIR "build"
|
||||
New-Item -ItemType Directory -Force $BUILD_DIR | Out-Null
|
||||
cmake -S $NB_DIR -B $BUILD_DIR `
|
||||
-DCMAKE_TOOLCHAIN_FILE=(Join-Path $VCPKG_ROOT "scripts\buildsystems\vcpkg.cmake") `
|
||||
-DCMAKE_BUILD_TYPE=Release `
|
||||
-DVCPKG_TARGET_TRIPLET=x64-windows
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure failed" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 5. Build Release
|
||||
Write-Host "== [5/6] CMake build =="
|
||||
cmake --build $BUILD_DIR --config Release
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build failed" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 6. Copy sidecar to Tauri externalBin location
|
||||
Write-Host "== [6/6] Copy to src-tauri\binaries =="
|
||||
$BIN_DIR = Join-Path $REPO_ROOT "src-tauri\binaries"
|
||||
New-Item -ItemType Directory -Force $BIN_DIR | Out-Null
|
||||
$EXE = Join-Path $BUILD_DIR "Release\daw_vst_bridge.exe"
|
||||
if (-not (Test-Path $EXE)) { Write-Host "ERROR: $EXE not found" -ForegroundColor Red; exit 1 }
|
||||
Copy-Item $EXE (Join-Path $BIN_DIR "daw_vst_bridge-x86_64-pc-windows-msvc.exe") -Force
|
||||
Write-Host "== DONE: daw_vst_bridge.exe copied to src-tauri\binaries =="
|
||||
@@ -1,60 +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")
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: native bridge build failed" -ForegroundColor Red; exit 1 }
|
||||
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) {
|
||||
Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1
|
||||
}
|
||||
|
||||
Write-Host "== [6/7] VC++ Redistributable for hooks.nsh =="
|
||||
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||
}
|
||||
|
||||
Write-Host "== [7/7] Tauri build (NSIS + MSI) =="
|
||||
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) { Write-Host "ERROR: engine resource missing" -ForegroundColor Red; exit 1 }
|
||||
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) { Write-Host "ERROR: engine _internal missing" -ForegroundColor Red; exit 1 }
|
||||
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) { Write-Host "ERROR: bridge externalBin missing" -ForegroundColor Red; exit 1 }
|
||||
python tools\verify_bundle.py --check-bridge
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1 }
|
||||
npm install -D @tauri-apps/cli
|
||||
npx tauri build
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "== DONE =="
|
||||
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
|
||||
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
|
||||
Write-Host " Verify after install: %APPDATA%\SonicForgeDAW\logs\spawn.log exists=True for daw_engine AND daw_vst_bridge"
|
||||
@@ -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
|
||||
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 =="
|
||||
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
|
||||
|
||||
@@ -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,59 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(daw_vst_bridge LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# ── 1. VST3 SDK (Steinberg, vendored as git submodule — NOT in vcpkg) ──
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/CMakeLists.txt")
|
||||
add_subdirectory(vst3sdk EXCLUDE_FROM_ALL)
|
||||
set(VST3_SDK_TARGET sdk)
|
||||
else()
|
||||
message(WARNING "vst3sdk submodule missing — VST3 host disabled; SF2/SF3/SFZ still build")
|
||||
set(VST3_SDK_TARGET "")
|
||||
endif()
|
||||
|
||||
# ── 2. FluidSynth + sfizz ──
|
||||
# Windows: vcpkg toolchain (-DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake) cung cap
|
||||
# headers/libs; pkg_check_modules khong co san tren MSVC.
|
||||
# Linux/macOS: pkg-config duoc dung (spec goc).
|
||||
if(WIN32)
|
||||
find_path(FLUIDSYNTH_INCLUDE_DIR fluidsynth.h)
|
||||
find_library(FLUIDSYNTH_LIBRARY NAMES fluidsynth libfluidsynth)
|
||||
if(NOT FLUIDSYNTH_INCLUDE_DIR OR NOT FLUIDSYNTH_LIBRARY)
|
||||
message(FATAL_ERROR "fluidsynth not found — cai qua vcpkg: vcpkg install fluidsynth")
|
||||
endif()
|
||||
# sfizz: header sfizz.hpp + lib sfizz (vcpkg export target sfizz::sfizz neu co)
|
||||
find_path(SFIZZ_INCLUDE_DIR sfizz.hpp)
|
||||
find_library(SFIZZ_LIBRARY NAMES sfizz)
|
||||
if(NOT SFIZZ_INCLUDE_DIR OR NOT SFIZZ_LIBRARY)
|
||||
message(FATAL_ERROR "sfizz not found — cai qua vcpkg: vcpkg install sfizz")
|
||||
endif()
|
||||
else()
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(FLUIDSYNTH REQUIRED fluidsynth)
|
||||
pkg_check_modules(SFIZZ REQUIRED sfizz)
|
||||
endif()
|
||||
|
||||
include_directories(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${FLUIDSYNTH_INCLUDE_DIRS}
|
||||
${SFIZZ_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
add_executable(daw_vst_bridge
|
||||
src/main.cpp
|
||||
src/NativeInstrumentEngine.cpp
|
||||
src/SharedMemoryIPC.cpp
|
||||
)
|
||||
|
||||
if(VST3_SDK_TARGET)
|
||||
target_link_libraries(daw_vst_bridge PRIVATE ${VST3_SDK_TARGET})
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY})
|
||||
# Required Windows libs
|
||||
target_link_libraries(daw_vst_bridge PRIVATE winmm)
|
||||
else()
|
||||
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARIES} ${SFIZZ_LIBRARIES})
|
||||
endif()
|
||||
@@ -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,87 +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 <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// FluidSynth (.sf2 / .sf3)
|
||||
class FluidSynthInstrument : public INativeInstrument {
|
||||
public:
|
||||
FluidSynthInstrument();
|
||||
~FluidSynthInstrument() override;
|
||||
|
||||
bool loadSoundFontFile(const std::string& path, double sampleRate);
|
||||
|
||||
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||
void programChange(uint32_t channel, uint32_t program) override;
|
||||
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||
bool openGUI(void* parentWindowHandle) override;
|
||||
void closeGUI() override;
|
||||
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||
|
||||
private:
|
||||
void* settings; // fluid_settings_t*
|
||||
void* synth; // fluid_synth_t*
|
||||
int sfontId;
|
||||
};
|
||||
|
||||
// sfizz (.sfz)
|
||||
class SfizzInstrument : public INativeInstrument {
|
||||
public:
|
||||
bool loadSfzFile(const std::string& path, double sampleRate);
|
||||
|
||||
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||
void programChange(uint32_t channel, uint32_t program) override;
|
||||
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||
bool openGUI(void* parentWindowHandle) override;
|
||||
void closeGUI() override;
|
||||
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||
|
||||
private:
|
||||
sfizz::Synth sfizzSynth;
|
||||
};
|
||||
|
||||
// Multi-instrument host (A10): one engine instance per MIDI channel, so two
|
||||
// tracks can play two different soundfonts/VSTs simultaneously. All channels
|
||||
// render into the same output block (mixed), keyed by evt.channel.
|
||||
class InstrumentEngineManager {
|
||||
public:
|
||||
// Create (or replace) the instrument on `channel` for `path`.
|
||||
// Returns false if the type is unsupported or the file fails to load.
|
||||
bool assign(uint32_t channel, InstrumentType type, const std::string& path,
|
||||
double sampleRate, uint32_t blockSize);
|
||||
|
||||
INativeInstrument* get(uint32_t channel);
|
||||
|
||||
// Flush every sounding note on every assigned channel.
|
||||
void allNotesOff();
|
||||
|
||||
// Zero L/R then sum each assigned instrument into it.
|
||||
void renderAll(float* outputL, float* outputR, uint32_t numSamples);
|
||||
|
||||
size_t count() const { return channels_.size(); }
|
||||
|
||||
private:
|
||||
static std::unique_ptr<INativeInstrument> create_instrument(InstrumentType type);
|
||||
|
||||
std::map<uint32_t, std::unique_ptr<INativeInstrument>> channels_;
|
||||
// Per-instrument scratch so engines that overwrite (not mix) stay additive.
|
||||
std::vector<float> scratchL_, scratchR_;
|
||||
};
|
||||
|
||||
#endif // NATIVE_INSTRUMENT_ENGINE_H
|
||||
@@ -1,48 +0,0 @@
|
||||
// native_bridge/include/SharedMemoryIPC.h
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
#define AUDIO_BLOCK_SIZE 256
|
||||
|
||||
// WARNING: volatile is NOT a sync primitive. Real impl should use an
|
||||
// interlocked/index-flag pair or a Win32 event (SetEvent) signalled by the
|
||||
// writer. This struct follows the spec layout so Rust/JS mapping stays in sync.
|
||||
struct SharedAudioBufferIPC {
|
||||
// Synchronization Flags
|
||||
volatile uint32_t clientReadIndex;
|
||||
volatile uint32_t bridgeWriteIndex;
|
||||
|
||||
// PCM Float32 Audio Buffers
|
||||
float masterLeft[AUDIO_BLOCK_SIZE];
|
||||
float masterRight[AUDIO_BLOCK_SIZE];
|
||||
|
||||
// Latency probe: bridge stamps every rendered block (QueryPerformanceCounter)
|
||||
volatile uint64_t blockTimestamp;
|
||||
|
||||
// MIDI Event Exchange Queue
|
||||
struct MidiEventIPC {
|
||||
uint8_t command; // 0x9 note on / 0x8 note off / 0xB CC / 0xC program / 0xE pitch bend
|
||||
uint8_t channel;
|
||||
uint8_t pitch; // note number (0x9/0x8) or CC number (0xB)
|
||||
uint8_t velocity; // 0-127 (0x9 + vel=0 == note off)
|
||||
uint8_t data2; // CC value / program number / PB LSB
|
||||
uint8_t data3; // PB MSB (0xE only)
|
||||
uint8_t reserved[2]; // explicit padding — keeps C/Rust layout stable
|
||||
uint32_t sampleOffset;
|
||||
} midiQueue[64];
|
||||
|
||||
volatile uint32_t midiQueueCount;
|
||||
|
||||
// Transport / Control (added vs spec: Stop/Play flush, instrument switch)
|
||||
struct ControlEventIPC {
|
||||
uint32_t type; // 0 = NONE, 1 = PANIC/ALL_NOTES_OFF, 2 = LOAD_INSTRUMENT,
|
||||
// 3 = TRANSPORT, 4 = OPEN_GUI
|
||||
uint32_t arg0; // LOAD: instrument type (InstrumentType);
|
||||
// TRANSPORT: 0=STOP, 1=PLAY, 2=SET_POSITION
|
||||
uint32_t arg1; // LOAD: string length (bytes) of path;
|
||||
// TRANSPORT: playheadSamples (for timeline sync)
|
||||
uint32_t channel; // LOAD: MIDI channel to assign instrument to (A10)
|
||||
char arg2[1024]; // LOAD: UTF-8 path
|
||||
} controlQueue[8];
|
||||
volatile uint32_t controlQueueCount;
|
||||
};
|
||||
@@ -1,43 +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:
|
||||
void* module_; // Steinberg::IPluginFactory* (owned by module)
|
||||
void* processor_; // Steinberg::Vst::IComponent*
|
||||
void* controller_; // Steinberg::Vst::IEditController*
|
||||
void* view_; // Steinberg::IPlugView*
|
||||
std::string path_;
|
||||
double sampleRate_;
|
||||
uint32_t maxBlockSize_;
|
||||
bool loaded_;
|
||||
bool guiAttached_;
|
||||
};
|
||||
|
||||
#endif // VST3_INSTRUMENT_H
|
||||
@@ -1,195 +0,0 @@
|
||||
// native_bridge/src/NativeInstrumentEngine.cpp
|
||||
#include "NativeInstrumentEngine.h"
|
||||
|
||||
#include <fluidsynth.h>
|
||||
#include <sfizz.hpp>
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. SOUNDFONT ENGINE (.SF2 / .SF3) VIA FLUIDSYNTH C API
|
||||
// -----------------------------------------------------------------
|
||||
FluidSynthInstrument::FluidSynthInstrument()
|
||||
: settings(nullptr), synth(nullptr), sfontId(-1) {}
|
||||
|
||||
FluidSynthInstrument::~FluidSynthInstrument() {
|
||||
if (synth) delete_fluid_synth(synth);
|
||||
if (settings) delete_fluid_settings(settings);
|
||||
}
|
||||
|
||||
bool FluidSynthInstrument::loadSoundFontFile(const std::string& path, double sampleRate) {
|
||||
if (synth) { delete_fluid_synth(synth); synth = nullptr; }
|
||||
if (settings) { delete_fluid_settings(settings); settings = nullptr; }
|
||||
settings = new_fluid_settings();
|
||||
fluid_settings_setnum(settings, "synth.sample-rate", sampleRate);
|
||||
fluid_settings_setint(settings, "synth.polyphony", 256);
|
||||
fluid_settings_setint(settings, "synth.verbose", 0);
|
||||
synth = new_fluid_synth(settings);
|
||||
if (!synth) return false;
|
||||
sfontId = fluid_synth_sfload(synth, path.c_str(), 1);
|
||||
if (sfontId == -1) return false;
|
||||
// Reset all channels to font preset 0 (spec §VII: bank0/prog0 piano)
|
||||
for (uint32_t ch = 0; ch < 16; ++ch) {
|
||||
fluid_synth_program_select(synth, ch, sfontId, 0, 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FluidSynthInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||
return synth != nullptr;
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||
if (!synth) return;
|
||||
fluid_synth_bank_select(synth, channel, bank);
|
||||
fluid_synth_program_change(synth, channel, program);
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||
if (!synth) return;
|
||||
int velInt = static_cast<int>(velocity * 127.0f);
|
||||
fluid_synth_noteon(synth, channel, pitch, velInt);
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||
if (!synth) return;
|
||||
fluid_synth_noteoff(synth, channel, pitch);
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||
if (!synth) return;
|
||||
fluid_synth_cc(synth, channel, cc, value);
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||
if (!synth) return;
|
||||
fluid_synth_program_change(synth, channel, program);
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||
if (!synth) return;
|
||||
// fluid_synth_pitch_bend takes the raw 14-bit value (center 8192).
|
||||
fluid_synth_pitch_bend(synth, channel, bend14);
|
||||
}
|
||||
|
||||
bool FluidSynthInstrument::openGUI(void* parentWindowHandle) {
|
||||
return false; // SoundFont uses Web GUI Manager / Reskinned Knobs
|
||||
}
|
||||
|
||||
void FluidSynthInstrument::closeGUI() {}
|
||||
|
||||
void FluidSynthInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||
if (!synth) return;
|
||||
fluid_synth_write_float(synth, numSamples, outputL, 0, 1, outputR, 0, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. SFZ ENGINE (.SFZ) VIA SFIZZ C++ API
|
||||
// -----------------------------------------------------------------
|
||||
bool SfizzInstrument::loadSfzFile(const std::string& path, double sampleRate) {
|
||||
sfizzSynth.setSampleRate(sampleRate);
|
||||
return sfizzSynth.loadSfzFile(path);
|
||||
}
|
||||
|
||||
bool SfizzInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||
sfizzSynth.setSampleRate(sampleRate);
|
||||
sfizzSynth.setSamplesPerBlock(maxBlockSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SfizzInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {}
|
||||
|
||||
void SfizzInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||
sfizzSynth.hdNoteOn(sampleOffset, pitch, velocity);
|
||||
}
|
||||
|
||||
void SfizzInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||
sfizzSynth.hdNoteOff(sampleOffset, pitch, 0.0f);
|
||||
}
|
||||
|
||||
void SfizzInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||
// ponytail: non-delayed cc() is stable across sfizz versions; switch to
|
||||
// hdCC(cc, value) for sample-accurate CC when the installed sfizz has it.
|
||||
sfizzSynth.cc(static_cast<int>(cc), static_cast<float>(value));
|
||||
}
|
||||
|
||||
void SfizzInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||
// TODO(A12): sfizz program-change API differs by version (hdProgramChange
|
||||
// in >=0.6). Rarely used by SFZ instruments — no-op until verified.
|
||||
}
|
||||
|
||||
void SfizzInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||
sfizzSynth.pitchWheel(static_cast<int>(bend14));
|
||||
}
|
||||
|
||||
bool SfizzInstrument::openGUI(void* parentWindowHandle) { return false; }
|
||||
void SfizzInstrument::closeGUI() {}
|
||||
|
||||
void SfizzInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||
float* channels[2] = { outputL, outputR };
|
||||
sfizzSynth.renderBlock(channels, numSamples);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. MULTI-CHANNEL INSTRUMENT MANAGER (A10)
|
||||
// -----------------------------------------------------------------
|
||||
std::unique_ptr<INativeInstrument> InstrumentEngineManager::create_instrument(InstrumentType type) {
|
||||
switch (type) {
|
||||
case InstrumentType::SOUNDFONT_SF2_SF3: return std::make_unique<FluidSynthInstrument>();
|
||||
case InstrumentType::SFZ: return std::make_unique<SfizzInstrument>();
|
||||
// ponytail: VST3/VST2 host needs vst3sdk + Steinberg APIs — wired in
|
||||
// Giai doan 2 (A6-A8); returns nullptr so the bridge degrades gracefully.
|
||||
case InstrumentType::VST3:
|
||||
case InstrumentType::VST2:
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type,
|
||||
const std::string& path, double sampleRate,
|
||||
uint32_t blockSize) {
|
||||
if (channel >= 16) return false;
|
||||
auto inst = create_instrument(type);
|
||||
if (!inst) return false;
|
||||
if (!inst->init(sampleRate, blockSize)) return false;
|
||||
bool loaded = false;
|
||||
if (type == InstrumentType::SOUNDFONT_SF2_SF3)
|
||||
loaded = static_cast<FluidSynthInstrument*>(inst.get())->loadSoundFontFile(path, sampleRate);
|
||||
else if (type == InstrumentType::SFZ)
|
||||
loaded = static_cast<SfizzInstrument*>(inst.get())->loadSfzFile(path, sampleRate);
|
||||
if (!loaded) return false;
|
||||
// Replacing an existing instrument drops its voices with the old engine.
|
||||
channels_[channel] = std::move(inst);
|
||||
return true;
|
||||
}
|
||||
|
||||
INativeInstrument* InstrumentEngineManager::get(uint32_t channel) {
|
||||
auto it = channels_.find(channel);
|
||||
return it == channels_.end() ? nullptr : it->second.get();
|
||||
}
|
||||
|
||||
void InstrumentEngineManager::allNotesOff() {
|
||||
for (auto& [ch, inst] : channels_) {
|
||||
for (uint32_t n = 0; n < 128; ++n) inst->noteOff(ch, n, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void InstrumentEngineManager::renderAll(float* outputL, float* outputR, uint32_t numSamples) {
|
||||
std::memset(outputL, 0, numSamples * sizeof(float));
|
||||
std::memset(outputR, 0, numSamples * sizeof(float));
|
||||
if (channels_.empty()) return;
|
||||
if (scratchL_.size() < numSamples) {
|
||||
scratchL_.resize(numSamples);
|
||||
scratchR_.resize(numSamples);
|
||||
}
|
||||
for (auto& [ch, inst] : channels_) {
|
||||
std::memset(scratchL_.data(), 0, numSamples * sizeof(float));
|
||||
std::memset(scratchR_.data(), 0, numSamples * sizeof(float));
|
||||
inst->processAudioBlock(scratchL_.data(), scratchR_.data(), numSamples);
|
||||
for (uint32_t i = 0; i < numSamples; ++i) {
|
||||
outputL[i] += scratchL_[i];
|
||||
outputR[i] += scratchR_[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +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;
|
||||
}
|
||||
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,199 +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
|
||||
#include "public.sdk/source/main/pluginfactory.h"
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||
#include "pluginterfaces/vst/ivstmidicontrollers.h"
|
||||
#include "pluginterfaces/gui/iplugview.h"
|
||||
|
||||
#include "public.sdk/source/common/pluginview.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// With-SDK implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
#ifdef HAVE_VST3SDK
|
||||
#include "public.sdk/source/main/module.h"
|
||||
|
||||
namespace {
|
||||
using Steinberg::Vst::IComponent;
|
||||
using Steinberg::Vst::IEditController;
|
||||
using Steinberg::Vst::IAudioProcessor;
|
||||
using Steinberg::Vst::IComponentHandler;
|
||||
using Steinberg::Vst::IParameterChanges;
|
||||
using Steinberg::Vst::IEventList;
|
||||
using Steinberg::Vst::Event;
|
||||
using Steinberg::Vst::NoteOnEvent;
|
||||
using Steinberg::Vst::NoteOffEvent;
|
||||
using Steinberg::Vst::DataEvent;
|
||||
using Steinberg::Vst::kMidiCC;
|
||||
using Steinberg::Vst::kMidiPitchBend;
|
||||
using Steinberg::Vst::kMidiProgramChange;
|
||||
using Steinberg::IPlugView;
|
||||
using Steinberg::tresult;
|
||||
using Steinberg::kResultOk;
|
||||
using Steinberg::kResultFalse;
|
||||
|
||||
// Minimal IComponentHandler so the plugin can inform the host of param edits.
|
||||
class HostComponentHandler : public IComponentHandler {
|
||||
public:
|
||||
Steinberg::tresult queryInterface(const Steinberg::TUID&, void** v) override {
|
||||
*v = nullptr;
|
||||
return Steinberg::kNoInterface;
|
||||
}
|
||||
Steinberg::uint32 addRef() override { return 1; }
|
||||
Steinberg::uint32 release() override { return 1; }
|
||||
Steinberg::tresult beginEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||
Steinberg::tresult performEdit(Steinberg::Vst::ParamID, Steinberg::Vst::ParamValue) override {
|
||||
return Steinberg::kResultOk;
|
||||
}
|
||||
Steinberg::tresult endEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||
Steinberg::tresult restartComponent(Steinberg::int32) override { return Steinberg::kResultOk; }
|
||||
};
|
||||
|
||||
// Bundle both components in one factory entry so module.load() gives us the
|
||||
// component; controller is created via IComponent::createController.
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
Vst3Instrument::Vst3Instrument()
|
||||
: module_(nullptr),
|
||||
processor_(nullptr),
|
||||
controller_(nullptr),
|
||||
view_(nullptr),
|
||||
sampleRate_(44100.0),
|
||||
maxBlockSize_(256),
|
||||
loaded_(false),
|
||||
guiAttached_(false) {}
|
||||
|
||||
Vst3Instrument::~Vst3Instrument() { closeGUI(); }
|
||||
|
||||
bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) {
|
||||
#ifndef HAVE_VST3SDK
|
||||
(void)path; (void)sampleRate;
|
||||
return false; // vst3sdk submodule missing — VST3 disabled
|
||||
#else
|
||||
if (loaded_) return true;
|
||||
// ponytail: vst3sdk module loading is platform-specific
|
||||
// (Module::create on Windows .vst3 bundle / macOS .vst3 framework). Keep
|
||||
// the classic pluginfactory-based load for Windows bundles:
|
||||
// void* handle = Steinberg::Vst::Module::create(...)
|
||||
// and use module->getFactory().createInstance<...>(cid).
|
||||
// Verified on Windows in A6 (G3 Vital.vst3). Until then VST3 returns false
|
||||
// so SF2/SFZ keep working.
|
||||
(void)path; (void)sampleRate;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Vst3Instrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||
sampleRate_ = sampleRate;
|
||||
maxBlockSize_ = maxBlockSize;
|
||||
return loaded_;
|
||||
}
|
||||
|
||||
void Vst3Instrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||
(void)channel; (void)bank; (void)program;
|
||||
// ponytail: needs IEditController::setParamNormalized on program list params
|
||||
}
|
||||
|
||||
void Vst3Instrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||
(void)channel; (void)pitch; (void)velocity; (void)sampleOffset;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (!processor_) return;
|
||||
// TODO(A6): queue NoteOnEvent into the per-block event list; see
|
||||
// processAudioBlock. Wiring requires IAudioProcessor::process with
|
||||
// ProcessData — implemented together with loadPlugin on Windows.
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||
(void)channel; (void)pitch; (void)sampleOffset;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (!processor_) return;
|
||||
// TODO(A6): queue NoteOffEvent (see noteOn)
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||
(void)channel; (void)cc; (void)value;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (!controller_) return;
|
||||
// TODO(A6): controller_->setParamNormalized(kMidiCC | cc, value/127.0)
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::programChange(uint32_t channel, uint32_t program) {
|
||||
(void)channel; (void)program;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (!controller_) return;
|
||||
// TODO(A6): setParamNormalized(kMidiProgramChange | program, ...)
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||
(void)channel; (void)bend14;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (!controller_) return;
|
||||
// TODO(A6): setParamNormalized(kMidiPitchBend, bend14/16383.0)
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Vst3Instrument::openGUI(void* parentWindowHandle) {
|
||||
#ifndef HAVE_VST3SDK
|
||||
(void)parentWindowHandle;
|
||||
return false;
|
||||
#else
|
||||
if (!processor_ || !controller_ || !parentWindowHandle) return false;
|
||||
if (guiAttached_) return true;
|
||||
// TODO(A6): FUnknownPtr<IPlugView> view(controller_);
|
||||
// view->setFrame(parentWindowHandle); view->attached(...); view_ = view;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::closeGUI() {
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
if (view_ && guiAttached_) {
|
||||
// view_->removed(); view_->setFrame(nullptr);
|
||||
}
|
||||
view_ = nullptr;
|
||||
guiAttached_ = false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||
(void)outputL; (void)outputR; (void)numSamples;
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
// TODO(A6): ProcessData with 2 output buffers + event list; called by
|
||||
// InstrumentEngineManager::renderAll via processAudioBlock.
|
||||
#endif
|
||||
}
|
||||
@@ -1,204 +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>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#include <cstdlib>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
// --- platform helpers -------------------------------------------------------
|
||||
static bool parent_alive(uint32_t pid) {
|
||||
#ifdef _WIN32
|
||||
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if (!h) return false;
|
||||
CloseHandle(h);
|
||||
return true;
|
||||
#else
|
||||
return pid == 0 || (kill(pid, 0) == 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void sleep_ms(uint32_t ms) {
|
||||
#ifdef _WIN32
|
||||
Sleep(ms);
|
||||
#else
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
||||
#endif
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::cout << "[NativeBridge] Starting DAW Host Bridge Engine..." << std::endl;
|
||||
|
||||
// 1. Shared memory name: argv --shm <name> | env SF_SHM_NAME | default
|
||||
std::string shmName = "SonicForge_DAW_IPC";
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--shm") == 0) shmName = argv[i + 1];
|
||||
}
|
||||
if (const char* e = std::getenv("SF_SHM_NAME")) shmName = e;
|
||||
|
||||
// Parent watchdog PID (set by Tauri sidecar spawner)
|
||||
uint32_t parentPid = 0;
|
||||
if (const char* e = std::getenv("SF_PARENT_PID")) parentPid = (uint32_t)std::atoi(e);
|
||||
|
||||
#ifdef _WIN32
|
||||
// Real-time-ish timing: 1ms scheduler resolution
|
||||
timeBeginPeriod(1);
|
||||
HANDLE hMapFile = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, shmName.c_str());
|
||||
if (!hMapFile) {
|
||||
std::cerr << "[NativeBridge] Failed to open Shared Memory mapping: " << shmName << std::endl;
|
||||
return 1;
|
||||
}
|
||||
auto* shmIPC = (SharedAudioBufferIPC*)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedAudioBufferIPC));
|
||||
if (!shmIPC) { CloseHandle(hMapFile); return 1; }
|
||||
#else
|
||||
(void)shmName; // POSIX shm mapping (shm_open) added when porting off Windows
|
||||
auto* shmIPC = (SharedAudioBufferIPC*)std::calloc(1, sizeof(SharedAudioBufferIPC));
|
||||
if (!shmIPC) return 1;
|
||||
#endif
|
||||
|
||||
InstrumentEngineManager instruments;
|
||||
// B8: sample rate from the DAW (Rust spawns us with SF_SAMPLE_RATE).
|
||||
// Block size is fixed by the SHM layout (AUDIO_BLOCK_SIZE) — SF_BLOCK_SIZE
|
||||
// is accepted but must match, otherwise warned and ignored.
|
||||
double sampleRate = 44100.0;
|
||||
if (const char* e = std::getenv("SF_SAMPLE_RATE")) {
|
||||
double sr = (double)std::atoi(e);
|
||||
if (sr > 0) sampleRate = sr;
|
||||
}
|
||||
if (const char* e = std::getenv("SF_BLOCK_SIZE")) {
|
||||
uint32_t b = (uint32_t)std::atoi(e);
|
||||
if (b != AUDIO_BLOCK_SIZE)
|
||||
std::cerr << "[NativeBridge] SHM block size fixed at " << AUDIO_BLOCK_SIZE
|
||||
<< " (SF_BLOCK_SIZE=" << b << " ignored)" << std::endl;
|
||||
}
|
||||
const uint32_t block = AUDIO_BLOCK_SIZE;
|
||||
uint64_t playheadSamples = 0;
|
||||
|
||||
auto dispatch = [&](const SharedAudioBufferIPC::MidiEventIPC& evt) {
|
||||
auto* inst = instruments.get(evt.channel);
|
||||
if (!inst) return; // channel chưa gán instrument → silent (A10)
|
||||
switch (evt.command) {
|
||||
case 0x9:
|
||||
if (evt.velocity > 0)
|
||||
inst->noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f, evt.sampleOffset);
|
||||
else
|
||||
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||
break;
|
||||
case 0x8:
|
||||
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||
break;
|
||||
case 0xB: // CC: controller number in pitch, value in data2
|
||||
inst->controlChange(evt.channel, evt.pitch, evt.data2);
|
||||
break;
|
||||
case 0xC: // program change: program in data2
|
||||
inst->programChange(evt.channel, evt.data2);
|
||||
break;
|
||||
case 0xE: // 14-bit pitch bend: data2 = LSB, data3 = MSB
|
||||
inst->pitchBend(evt.channel, evt.data2 | (uint32_t(evt.data3) << 7));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
|
||||
// Render only [from, to) of the block — used by sample-accurate splitting.
|
||||
auto renderSegment = [&](uint32_t from, uint32_t to) {
|
||||
if (to <= from) return;
|
||||
instruments.renderAll(shmIPC->masterLeft + from, shmIPC->masterRight + from, to - from);
|
||||
};
|
||||
|
||||
// 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP
|
||||
while (true) {
|
||||
// A. Control events — non-rt safe, drained first
|
||||
for (uint32_t i = 0; i < shmIPC->controlQueueCount; ++i) {
|
||||
const auto& c = shmIPC->controlQueue[i];
|
||||
if (c.type == 2) { // LOAD_INSTRUMENT (A10: assign per MIDI channel)
|
||||
// Robust path length: do not trust arg1 (Rust passes 0 for LOAD).
|
||||
size_t plen = 0;
|
||||
while (plen < sizeof(c.arg2) && c.arg2[plen]) ++plen;
|
||||
std::string path(c.arg2, plen);
|
||||
InstrumentType t = (InstrumentType)c.arg0;
|
||||
uint32_t ch = c.channel & 0xF;
|
||||
if (instruments.assign(ch, t, path, sampleRate, block)) {
|
||||
std::cout << "[NativeBridge] instrument loaded ch=" << ch
|
||||
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||
} else {
|
||||
std::cerr << "[NativeBridge] instrument load FAILED ch=" << ch
|
||||
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||
}
|
||||
} else if (c.type == 1) { // PANIC
|
||||
instruments.allNotesOff();
|
||||
std::cout << "[NativeBridge] PANIC — all notes off" << std::endl;
|
||||
} else if (c.type == 3) { // TRANSPORT (A13)
|
||||
if (c.arg0 == 0) { // STOP → flush every note immediately
|
||||
instruments.allNotesOff();
|
||||
std::cout << "[NativeBridge] transport STOP — all notes off" << std::endl;
|
||||
} else if (c.arg0 == 1) { // PLAY
|
||||
playheadSamples = c.arg1;
|
||||
std::cout << "[NativeBridge] transport PLAY playhead=" << playheadSamples << std::endl;
|
||||
} else if (c.arg0 == 2) { // SET_POSITION (seek while stopped)
|
||||
playheadSamples = c.arg1;
|
||||
}
|
||||
}
|
||||
}
|
||||
shmIPC->controlQueueCount = 0;
|
||||
|
||||
// B. Snapshot queued MIDI events (bounded copy, queue reset immediately)
|
||||
uint32_t nEvents = shmIPC->midiQueueCount > 64 ? 64 : shmIPC->midiQueueCount;
|
||||
SharedAudioBufferIPC::MidiEventIPC evts[64];
|
||||
for (uint32_t i = 0; i < nEvents; ++i) evts[i] = shmIPC->midiQueue[i];
|
||||
shmIPC->midiQueueCount = 0;
|
||||
|
||||
// A11 sample-accurate: sort by sampleOffset, dispatch at each boundary,
|
||||
// render the sub-block before the boundary. Events with offset 0 (live
|
||||
// keyboard, current JS) are dispatched first and shape the whole block.
|
||||
std::stable_sort(evts, evts + nEvents,
|
||||
[](const SharedAudioBufferIPC::MidiEventIPC& a,
|
||||
const SharedAudioBufferIPC::MidiEventIPC& b) {
|
||||
return a.sampleOffset < b.sampleOffset;
|
||||
});
|
||||
uint32_t cursor = 0;
|
||||
uint32_t ei = 0;
|
||||
while (ei < nEvents && evts[ei].sampleOffset <= cursor) { dispatch(evts[ei]); ++ei; }
|
||||
for (; ei < nEvents; ++ei) {
|
||||
uint32_t off = evts[ei].sampleOffset < block ? evts[ei].sampleOffset : block;
|
||||
if (off > cursor) { renderSegment(cursor, off); cursor = off; }
|
||||
dispatch(evts[ei]);
|
||||
}
|
||||
if (cursor < block) renderSegment(cursor, block);
|
||||
shmIPC->bridgeWriteIndex++;
|
||||
|
||||
// D. Parent died / window closed -> exit (no orphan process)
|
||||
if (parentPid != 0 && !parent_alive(parentPid)) {
|
||||
std::cout << "[NativeBridge] parent gone — exiting." << std::endl;
|
||||
break;
|
||||
}
|
||||
|
||||
// E. Sleep briefly for the next frame tick (block@44100 ≈ 5.8ms)
|
||||
sleep_ms(1);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
UnmapViewOfFile(shmIPC);
|
||||
CloseHandle(hMapFile);
|
||||
timeEndPeriod(1);
|
||||
#else
|
||||
std::free(shmIPC);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
@@ -1,67 +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");
|
||||
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,9 +0,0 @@
|
||||
{
|
||||
"name": "daw-vst-bridge",
|
||||
"version-string": "1.0.0",
|
||||
"dependencies": [
|
||||
"fluidsynth",
|
||||
"sfizz",
|
||||
"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
+239
-4
@@ -13,6 +13,9 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -608,6 +611,238 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/gensync": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz",
|
||||
@@ -651,15 +886,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+4678
File diff suppressed because it is too large
Load Diff
@@ -15,13 +15,11 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
raw-window-handle = "0.6"
|
||||
webview2-com = "0.38"
|
||||
windows-core = "0.61"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Shared memory (SonicForge_DAW_IPC) for the Native Host Bridge
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_MemoryManagement",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
-304
@@ -10,25 +10,17 @@
|
||||
// "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)
|
||||
// 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_shell::process::CommandChild;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
mod shm;
|
||||
use shm::{Shm, ShmState};
|
||||
mod vst_gui;
|
||||
mod permissions;
|
||||
|
||||
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||
struct BridgeProcess(Mutex<Option<CommandChild>>);
|
||||
|
||||
/// Audio frame pushed to the WebView (bridge SHM -> `bridge-audio` event).
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct AudioFrame {
|
||||
l: Vec<f32>,
|
||||
r: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
|
||||
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
||||
@@ -57,78 +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()),
|
||||
(exe_dir.join(exe_name), "exe_dir (portable)".into()),
|
||||
(exe_dir.join("..").join("resources").join("binaries").join(exe_name), "exe_dir/../resources/binaries (dev)".into()),
|
||||
(exe_dir.join("..").join("resources").join(exe_name), "exe_dir/../resources (dev)".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Spawn daw_vst_bridge.exe sidecar (B3/B8) with SHM + watchdog + audio env.
|
||||
/// Appends candidate diagnostics to spawn.log; manages `BridgeProcess`.
|
||||
/// Returns true when the child started.
|
||||
fn spawn_bridge(
|
||||
app: &AppHandle,
|
||||
res_dir: &Path,
|
||||
exe_dir: &Path,
|
||||
log_line: &mut String,
|
||||
spawn_log_path: &Path,
|
||||
) -> bool {
|
||||
let candidates = bridge_candidates(res_dir, exe_dir);
|
||||
let mut found: Option<(PathBuf, String)> = None;
|
||||
for (path, label) in &candidates {
|
||||
let exists = path.exists();
|
||||
log_line.push_str(&format!(" [bridge {label}] {} exists={}\n", path.display(), exists));
|
||||
if found.is_none() && exists {
|
||||
found = Some((path.clone(), label.clone()));
|
||||
}
|
||||
}
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(spawn_log_path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = f.write_all(log_line.as_bytes());
|
||||
}
|
||||
match found {
|
||||
Some((bridge_exe, label)) => {
|
||||
match app
|
||||
.shell()
|
||||
.command(&bridge_exe)
|
||||
.env("SF_SHM_NAME", shm::SHM_NAME)
|
||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||
.env("SF_SAMPLE_RATE", "44100") // B8: JS resampler handles mismatches (C5)
|
||||
.env("SF_BLOCK_SIZE", "256")
|
||||
.spawn()
|
||||
{
|
||||
Ok((_rx, child)) => {
|
||||
app.manage(BridgeProcess(Mutex::new(Some(child))));
|
||||
println!("Native Host Bridge started ({label}): {}", bridge_exe.display());
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to spawn daw_vst_bridge {bridge_exe:?}: {e}");
|
||||
app.manage(BridgeProcess(Mutex::new(None)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("daw_vst_bridge binary not found — app will use WASM fallback");
|
||||
app.manage(BridgeProcess(Mutex::new(None)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_dir_snippet(dir: &Path) -> String {
|
||||
let mut s = String::new();
|
||||
match std::fs::read_dir(dir) {
|
||||
@@ -156,14 +76,12 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
push_midi_event,
|
||||
load_native_instrument,
|
||||
open_vst_gui,
|
||||
bridge_status,
|
||||
transport_control
|
||||
])
|
||||
.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])
|
||||
.setup(|app| {
|
||||
permissions::install(
|
||||
&app.get_webview_window("main").expect("main window missing"),
|
||||
);
|
||||
vst_gui::init(app.handle());
|
||||
let res_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
@@ -224,6 +142,7 @@ pub fn run() {
|
||||
.shell()
|
||||
.command(&engine_exe)
|
||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||
.env("SF_RESOURCE_DIR", res_dir.to_string_lossy().to_string())
|
||||
.spawn()
|
||||
{
|
||||
Ok((_rx, child)) => {
|
||||
@@ -247,91 +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)));
|
||||
|
||||
// Append bridge diagnostics to spawn.log and spawn the sidecar (B3).
|
||||
let app_handle = app.handle().clone();
|
||||
let _ = spawn_bridge(&app_handle, &res_dir, &exe_dir, &mut log_line, &spawn_log_path);
|
||||
|
||||
// ── Audio pump: bridge SHM -> WebView `bridge-audio` events ──
|
||||
// B10 health: if bridgeWriteIndex stalls for 3s the bridge is dead —
|
||||
// respawn once, then emit `bridge-down` so the UI falls back to WASM.
|
||||
let pump_handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut last_index: u32 = 0;
|
||||
let mut last_change = std::time::Instant::now();
|
||||
let mut down_emitted = false;
|
||||
let mut restart_attempts = 0u32;
|
||||
loop {
|
||||
let state = pump_handle.state::<ShmState>();
|
||||
let guard = match state.0.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let advanced = guard.as_ref().map(|shm| {
|
||||
let idx = shm.write_index();
|
||||
if idx != last_index {
|
||||
last_index = idx;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if advanced == Some(true) {
|
||||
last_change = std::time::Instant::now();
|
||||
down_emitted = false;
|
||||
let (l, r) = guard.as_ref().map(|s| s.read_audio()).unwrap_or(([0f32; shm::AUDIO_BLOCK_SIZE], [0f32; shm::AUDIO_BLOCK_SIZE]));
|
||||
let _ = pump_handle.emit(
|
||||
"bridge-audio",
|
||||
AudioFrame { l: l.to_vec(), r: r.to_vec() },
|
||||
);
|
||||
} else if last_change.elapsed() >= std::time::Duration::from_secs(3) && !down_emitted {
|
||||
down_emitted = true;
|
||||
if restart_attempts == 0 {
|
||||
restart_attempts += 1;
|
||||
let res_dir = pump_handle.path().resource_dir().unwrap_or_default();
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||
.unwrap_or_default();
|
||||
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||
let log_path = std::path::Path::new(&log_dir)
|
||||
.join("SonicForgeDAW").join("logs").join("spawn.log");
|
||||
let mut line = String::from("[tauri] bridge stalled 3s — restart attempt #1\n");
|
||||
if spawn_bridge(&pump_handle, &res_dir, &exe_dir, &mut line, &log_path) {
|
||||
// count restarts into bridge.log (same file as stdout redirect)
|
||||
let bridge_log = std::path::Path::new(&log_dir)
|
||||
.join("SonicForgeDAW").join("logs").join("bridge.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true).append(true).open(&bridge_log)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = f.write_all(b"[tauri] bridge restarted (attempt 1)\n");
|
||||
}
|
||||
last_change = std::time::Instant::now();
|
||||
down_emitted = false;
|
||||
} else {
|
||||
let _ = pump_handle.emit("bridge-down", ());
|
||||
}
|
||||
} else {
|
||||
let _ = pump_handle.emit("bridge-down", ());
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
|
||||
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
|
||||
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
|
||||
@@ -401,137 +235,8 @@ pub fn run() {
|
||||
let _ = child.kill();
|
||||
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!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
// ── Native Host Bridge Tauri commands ──────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn push_midi_event(
|
||||
app: AppHandle,
|
||||
command: u8,
|
||||
channel: u8,
|
||||
pitch: u8,
|
||||
velocity: u8,
|
||||
data2: u8,
|
||||
data3: u8,
|
||||
sample_offset: u32,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<ShmState>();
|
||||
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||
shm.push_midi(command, channel, pitch, velocity, data2, data3, sample_offset)
|
||||
.then_some(())
|
||||
.ok_or("midi queue full")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_native_instrument(app: AppHandle, path: String, instrument_type: u8, channel: u8) -> Result<(), String> {
|
||||
let state = app.state::<ShmState>();
|
||||
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||
shm.push_control(2, instrument_type as u32, 0, channel as u32, &path)
|
||||
.then_some(())
|
||||
.ok_or("control queue full")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_vst_gui(app: AppHandle, plugin_id: String) -> Result<(), String> {
|
||||
// B9: child WebviewWindow acts as the VST GUI surface. The bridge receives
|
||||
// its HWND (control type=4, arg1) so the VST3 editor can be attached (A7).
|
||||
let url = tauri::WebviewUrl::External(
|
||||
tauri::Url::parse("data:text/html,<h2>VST GUI</h2><p>Editor attaches from daw_vst_bridge (A7).</p>")
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
let win = tauri::WebviewWindowBuilder::new(&app, format!("vst-{}", plugin_id), url)
|
||||
.title(format!("VST — {}", plugin_id))
|
||||
.inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let state = app.state::<ShmState>();
|
||||
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||
// ponytail: HWND truncated to u32 (HWNDs fit in practice); window-close →
|
||||
// close_gui wiring lands together with A7 on Windows.
|
||||
#[cfg(windows)]
|
||||
let hwnd = {
|
||||
use tauri::raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
match win.window_handle().map(|h| h.as_raw()) {
|
||||
Ok(RawWindowHandle::Win32(w)) => w.hwnd as usize as u32,
|
||||
_ => 0u32,
|
||||
}
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let hwnd = 0u32;
|
||||
shm.push_control(4, 0, hwnd, 0, &plugin_id)
|
||||
.then_some(())
|
||||
.ok_or("control queue full")
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct BridgeStatus {
|
||||
connected: bool,
|
||||
shm_name: &'static str,
|
||||
write_index: u32,
|
||||
block_timestamp: u64,
|
||||
shm_size_bytes: usize,
|
||||
sample_rate: u32,
|
||||
block_size: u32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
|
||||
let state = app.state::<ShmState>();
|
||||
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(match guard.as_ref() {
|
||||
Some(shm) => BridgeStatus {
|
||||
connected: true,
|
||||
shm_name: shm::SHM_NAME,
|
||||
write_index: shm.write_index(),
|
||||
block_timestamp: shm.block_timestamp(),
|
||||
shm_size_bytes: std::mem::size_of::<shm::SharedAudioBufferIPC>(),
|
||||
sample_rate: 44100,
|
||||
block_size: shm::AUDIO_BLOCK_SIZE as u32,
|
||||
},
|
||||
None => BridgeStatus {
|
||||
connected: false,
|
||||
shm_name: shm::SHM_NAME,
|
||||
write_index: 0,
|
||||
block_timestamp: 0,
|
||||
shm_size_bytes: 0,
|
||||
sample_rate: 0,
|
||||
block_size: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn transport_control(app: AppHandle, kind: String, playhead: Option<u32>) -> Result<(), String> {
|
||||
let state = app.state::<ShmState>();
|
||||
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||
// arg0: 0=STOP (flush), 1=PLAY, 2=SET_POSITION (seek, no flush)
|
||||
let (arg0, arg1) = match kind.as_str() {
|
||||
"play" => (1, playhead.unwrap_or(0)),
|
||||
"set_position" => (2, playhead.unwrap_or(0)),
|
||||
_ => (0, playhead.unwrap_or(0)), // stop / panic
|
||||
};
|
||||
shm.push_control(3, arg0, arg1, 0, "")
|
||||
.then_some(())
|
||||
.ok_or("control queue full")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,166 +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::MemoryManagement::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, PAGE_READWRITE,
|
||||
};
|
||||
|
||||
pub const SHM_NAME: &str = "SonicForge_DAW_IPC";
|
||||
pub const AUDIO_BLOCK_SIZE: usize = 256;
|
||||
pub const MIDI_QUEUE_CAP: usize = 64;
|
||||
pub const CONTROL_QUEUE_CAP: usize = 8;
|
||||
pub const CONTROL_PATH_MAX: usize = 1024;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MidiEventIPC {
|
||||
pub command: u8,
|
||||
pub channel: u8,
|
||||
pub pitch: u8,
|
||||
pub velocity: u8,
|
||||
pub data2: u8, // CC value / program / PB LSB
|
||||
pub data3: u8, // PB MSB (0xE only)
|
||||
pub reserved: [u8; 2],
|
||||
pub sample_offset: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ControlEventIPC {
|
||||
pub ctype: u32,
|
||||
pub arg0: u32,
|
||||
pub arg1: u32,
|
||||
pub channel: u32, // LOAD: MIDI channel to assign (A10)
|
||||
pub arg2: [u8; CONTROL_PATH_MAX],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct SharedAudioBufferIPC {
|
||||
pub client_read_index: u32,
|
||||
pub bridge_write_index: u32,
|
||||
pub master_left: [f32; AUDIO_BLOCK_SIZE],
|
||||
pub master_right: [f32; AUDIO_BLOCK_SIZE],
|
||||
pub block_timestamp: u64,
|
||||
pub midi_queue: [MidiEventIPC; MIDI_QUEUE_CAP],
|
||||
pub midi_queue_count: u32,
|
||||
pub control_queue: [ControlEventIPC; CONTROL_QUEUE_CAP],
|
||||
pub control_queue_count: u32,
|
||||
}
|
||||
|
||||
pub struct Shm {
|
||||
handle: HANDLE,
|
||||
view: *mut SharedAudioBufferIPC,
|
||||
}
|
||||
|
||||
unsafe impl Send for Shm {}
|
||||
|
||||
impl Shm {
|
||||
/// Create (or open) the mapping. Returns None on non-Windows (dev fallback).
|
||||
pub fn create() -> Option<Self> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let name_wide: Vec<u16> = SHM_NAME.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let size = std::mem::size_of::<SharedAudioBufferIPC>() as u64;
|
||||
let handle = unsafe {
|
||||
CreateFileMappingW(
|
||||
HANDLE::default(),
|
||||
ptr::null(),
|
||||
PAGE_READWRITE,
|
||||
(size >> 32) as u32,
|
||||
size as u32,
|
||||
name_wide.as_ptr(),
|
||||
)
|
||||
};
|
||||
if handle == HANDLE::default() {
|
||||
return None;
|
||||
}
|
||||
let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, 0) };
|
||||
if view.is_null() {
|
||||
unsafe { CloseHandle(handle) };
|
||||
return None;
|
||||
}
|
||||
Some(Shm {
|
||||
handle,
|
||||
view: view as *mut SharedAudioBufferIPC,
|
||||
})
|
||||
}
|
||||
|
||||
fn ipc(&self) -> &mut SharedAudioBufferIPC {
|
||||
unsafe { &mut *self.view }
|
||||
}
|
||||
|
||||
pub fn push_midi(&self, cmd: u8, channel: u8, pitch: u8, velocity: u8, data2: u8, data3: u8, sample_offset: u32) -> bool {
|
||||
let ipc = self.ipc();
|
||||
if ipc.midi_queue_count as usize >= MIDI_QUEUE_CAP {
|
||||
return false;
|
||||
}
|
||||
let i = ipc.midi_queue_count as usize;
|
||||
ipc.midi_queue[i] = MidiEventIPC {
|
||||
command: cmd,
|
||||
channel,
|
||||
pitch,
|
||||
velocity,
|
||||
data2,
|
||||
data3,
|
||||
reserved: [0u8; 2],
|
||||
sample_offset,
|
||||
};
|
||||
ipc.midi_queue_count += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn push_control(&self, ctype: u32, arg0: u32, arg1: u32, channel: u32, path: &str) -> bool {
|
||||
let ipc = self.ipc();
|
||||
if ipc.control_queue_count as usize >= CONTROL_QUEUE_CAP {
|
||||
return false;
|
||||
}
|
||||
let mut ev = ControlEventIPC {
|
||||
ctype,
|
||||
arg0,
|
||||
arg1,
|
||||
channel,
|
||||
arg2: [0u8; CONTROL_PATH_MAX],
|
||||
};
|
||||
let bytes = path.as_bytes();
|
||||
let n = bytes.len().min(CONTROL_PATH_MAX - 1);
|
||||
ev.arg2[..n].copy_from_slice(&bytes[..n]);
|
||||
// LOAD relies on C++ side strnlen() — arg1 is free for TRANSPORT playhead.
|
||||
let i = ipc.control_queue_count as usize;
|
||||
ipc.control_queue[i] = ev;
|
||||
ipc.control_queue_count += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn read_audio(&self) -> ([f32; AUDIO_BLOCK_SIZE], [f32; AUDIO_BLOCK_SIZE]) {
|
||||
let ipc = self.ipc();
|
||||
(ipc.master_left, ipc.master_right)
|
||||
}
|
||||
|
||||
pub fn write_index(&self) -> u32 {
|
||||
self.ipc().bridge_write_index
|
||||
}
|
||||
|
||||
pub fn block_timestamp(&self) -> u64 {
|
||||
self.ipc().block_timestamp
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
if !self.view.is_null() {
|
||||
unsafe { UnmapViewOfFile(self.view as *const c_void) };
|
||||
}
|
||||
unsafe { CloseHandle(self.handle) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide shared-memory state for Tauri.
|
||||
pub struct ShmState(pub Mutex<Option<Shm>>);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@
|
||||
"icons/favicon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"resources/daw_engine": "daw_engine/"
|
||||
"resources/daw_engine": "daw_engine/",
|
||||
"resources/native_host": "native_host/"
|
||||
},
|
||||
"externalBin": ["binaries/daw_vst_bridge"],
|
||||
"externalBin": [],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "hooks.nsh"
|
||||
|
||||
@@ -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)
|
||||
assert buf.shape == (2, 1000)
|
||||
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
|
||||
|
||||
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:
|
||||
@@ -36,7 +31,6 @@ def find_toc(build_dir: str) -> str:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check_bridge = "--check-bridge" in sys.argv
|
||||
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
||||
toc = find_toc(build_dir)
|
||||
if not toc:
|
||||
@@ -60,19 +54,6 @@ def main() -> int:
|
||||
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
||||
return 1
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user