Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3368d0a91 | |||
| aae0b05473 | |||
| aa1cd8d5de | |||
| d31e226f00 | |||
| a0d8725541 | |||
| e89a594b7c | |||
| 61cb4b846f | |||
| 67ad2b8e4b | |||
| a1dc074e35 | |||
| 75eba27dcf | |||
| f492104869 | |||
| 75795653c5 | |||
| 025e0f03a2 | |||
| c08233a163 | |||
| d20932a68b | |||
| 1cf5b18492 | |||
| 1db892bb99 | |||
| 85ac80bb4f | |||
| 250b94cd88 | |||
| aa32272f36 | |||
| fbaac1f673 | |||
| 438ee607fc | |||
| 9f7af1e1b6 | |||
| cacd072f3e | |||
| cc8b286f6c | |||
| 5ae4fd6149 | |||
| 24a39869c8 | |||
| 0c0aadb8ee | |||
| 29ebbfc1c0 | |||
| 1191e46ee5 | |||
| 63af8ec414 | |||
| 6e962dc1b2 | |||
| 6eef16edc0 | |||
| cd2547ef6a | |||
| c538cab745 | |||
| 90493c73f3 | |||
| a3359aa0ed | |||
| a7489f41e6 | |||
| 194c9b52b2 | |||
| 77505fbc86 | |||
| a61f9abd6a | |||
| a7163efaf1 | |||
| 2462bbc1a8 | |||
| 1f1017d78a | |||
| f17bed4e5f | |||
| bc6bc71d0e | |||
| bc8431fe81 | |||
| 466bf25a0b | |||
| 52e1dc6eda | |||
| 30a40b2bca | |||
| 8dd00cc2ea |
@@ -15,3 +15,20 @@ DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
|
|||||||
|
|
||||||
# Storage (đường dẫn trong container)
|
# Storage (đường dẫn trong container)
|
||||||
STORAGE_DIR=/app/app/storage
|
STORAGE_DIR=/app/app/storage
|
||||||
|
|
||||||
|
# ── Plugin directories ──
|
||||||
|
# Đường dẫn HOST tới thư mục chứa VST / SoundFont / Pianobook — dùng trong
|
||||||
|
# docker-compose.yml để mount vào container (đổi theo máy chạy Docker).
|
||||||
|
# Mặc định: /home/locpham/daw_assets/...
|
||||||
|
VST_DIR=/home/locpham/daw_assets/vst3
|
||||||
|
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
|
||||||
|
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
|
||||||
|
|
||||||
|
# ── Runtime (tự phát hiện môi trường) ──
|
||||||
|
# auto (mặc định): Windows/macOS → desktop; Linux không DISPLAY hoặc docker → headless.
|
||||||
|
# desktop: server + client cùng 1 máy (bật nút "Mở trong Carla" nếu có Carla local)
|
||||||
|
# headless: server docker + browser UI (soundfont + VSTi mở được từ storage mount;
|
||||||
|
# preset chỉnh trên máy khác → upload .vstpreset qua web UI)
|
||||||
|
SF_RUNTIME=auto
|
||||||
|
# Ép nhận diện Docker (thường tự detect qua /.dockerenv; đặt =1 nếu cần)
|
||||||
|
SF_DOCKER=1
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: build-windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install Rust (stable)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install MSVC + CMake deps
|
||||||
|
uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
|
||||||
|
- name: Build one-command pipeline (engine + bridge + tauri)
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
powershell -ExecutionPolicy Bypass -File build/scripts/build_windows.ps1
|
||||||
|
|
||||||
|
- name: Upload NSIS installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SonicForgeDAW-setup
|
||||||
|
path: |
|
||||||
|
src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
src-tauri/target/release/bundle/msi/*.msi
|
||||||
@@ -19,6 +19,7 @@ app/storage/processed/*
|
|||||||
!app/storage/processed/.gitkeep
|
!app/storage/processed/.gitkeep
|
||||||
app/storage/*.db
|
app/storage/*.db
|
||||||
app/storage/sf_scan_state.json
|
app/storage/sf_scan_state.json
|
||||||
|
app/storage/temp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# VST3 and sample library directories (proprietary binaries)
|
# VST3 and sample library directories (proprietary binaries)
|
||||||
@@ -31,4 +32,8 @@ celerybeat-schedule
|
|||||||
node_modules
|
node_modules
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/binaries/
|
src-tauri/binaries/
|
||||||
|
src-tauri/resources/daw_engine/
|
||||||
src-tauri/vc_redist.x64.exe
|
src-tauri/vc_redist.x64.exe
|
||||||
|
app/storage/plugin_dirs.json
|
||||||
|
app/storage/sf_scan_state.json.bak-root
|
||||||
|
app/storage/soundfonts/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "native_bridge/vst3sdk"]
|
||||||
|
path = native_bridge/vst3sdk
|
||||||
|
url = https://github.com/steinbergmedia/vst3sdk.git
|
||||||
@@ -302,3 +302,31 @@ Tất cả 7 task trong kế hoạch đã hoàn thành:
|
|||||||
7. ✅ Kiểm thử hệ thống
|
7. ✅ Kiểm thử hệ thống
|
||||||
|
|
||||||
**Hệ thống SonicForge Studio đã sẵn sàng sử dụng! 🎉**
|
**Hệ thống SonicForge Studio đã sẵn sàng sử dụng! 🎉**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 Bản 1.2 — Bản Desktop Windows (Tauri v2 + 2 sidecar)
|
||||||
|
|
||||||
|
> Report trên là môi trường **Docker/server** (dev). Từ bản 1.2, bản phát hành
|
||||||
|
> desktop là **Tauri v2 shell + 2 sidecar** — xem `DESKTOP_INSTALL_PLAN.md` §5.2.
|
||||||
|
|
||||||
|
- **Sidecar 1** `daw_engine.exe` (FastAPI/PyInstaller onedir, port 8000-8010):
|
||||||
|
bundle.resources → `src-tauri/resources/daw_engine/`; `SF_PARENT_PID` để
|
||||||
|
watchdog tự thoát khi Tauri đóng.
|
||||||
|
- **Sidecar 2** `daw_vst_bridge.exe` (C++ Native Host Bridge): vcpkg
|
||||||
|
(fluidsynth/sfizz) + vst3sdk submodule; `bundle.externalBin` →
|
||||||
|
`src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe`; trao đổi qua
|
||||||
|
shared memory `SonicForge_DAW_IPC` (struct 11160 bytes — sync
|
||||||
|
SharedMemoryIPC.h ↔ src-tauri/src/shm.rs ↔ tests/shm_selfcheck.cpp).
|
||||||
|
- **IPC control**: LOAD/TRANSPORT(panic|play|set_position)/OPEN_GUI(HWND) qua
|
||||||
|
SHM control queue; MIDI 0x9/0x8/0xB/0xC/0xE + sampleOffset.
|
||||||
|
- **API mới**: `GET /api/v1/bridge/status`, `POST /api/v1/bridge/load`,
|
||||||
|
`GET /api/v1/bridge/log`.
|
||||||
|
- **Build 1 lệnh**: `build/scripts/build_windows.ps1` (7 bước: kill → pip →
|
||||||
|
frontend → engine → copy → bridge → vc_redist → tauri build NSIS+MSI);
|
||||||
|
verify: `tools/verify_bundle.py --check-bridge`.
|
||||||
|
- **YC runtime**: VC++ 2015-2022 Redistributable cho cả 2 sidecar (NSIS hook
|
||||||
|
tự cài; MSI không chạy hooks → cài thủ công).
|
||||||
|
- **Trạng thái**: code + selfcheck SHM pass trên Linux; chưa build trên
|
||||||
|
Windows (thiếu rustc/CMake-MSVC tại máy dev) — chạy `test_standalone.ps1`
|
||||||
|
trên máy Windows sau khi cài.
|
||||||
|
|||||||
@@ -69,6 +69,83 @@ code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đón
|
|||||||
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
|
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
|
||||||
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
|
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
|
||||||
|
|
||||||
|
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
|
||||||
|
|
||||||
|
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
|
||||||
|
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
|
||||||
|
|
||||||
|
Đã xử lý:
|
||||||
|
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
|
||||||
|
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
|
||||||
|
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
|
||||||
|
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
|
||||||
|
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
|
||||||
|
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
|
||||||
|
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
|
||||||
|
desktop slim chạy task in-process (thread + registry), giữ nguyên API
|
||||||
|
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
|
||||||
|
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
|
||||||
|
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
|
||||||
|
còn `scipy.signal` (goi duy nhất app còn dùng).
|
||||||
|
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
|
||||||
|
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
|
||||||
|
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
|
||||||
|
"build xong không chạy daw_engine" trên Windows).
|
||||||
|
- **Fix layout resources (bản 1.1.1 — `exists=false` trong spawn.log)**:
|
||||||
|
`bundle.resources` dạng ARRAY `["resources/daw_engine"]` copy engine tới
|
||||||
|
`$RESOURCE_DIR/resources/daw_engine/...` (giữ tiền tố `resources/` — đọc
|
||||||
|
source `tauri-utils/src/resources.rs`) trong khi lib.rs tìm ở
|
||||||
|
`$RESOURCE_DIR/daw_engine/...` → `exists=false`. Đổi sang dạng MAP
|
||||||
|
`{"resources/daw_engine": "daw_engine/"}` (Walk mode, giữ nguyên cây
|
||||||
|
`_internal`, đích chuẩn `daw_engine/`). `src-tauri/src/lib.rs` đồng thời dò
|
||||||
|
thêm 3 vị trí fallback (legacy/portable/dev) + ghi diagnostic đầy đủ vào
|
||||||
|
`%APPDATA%/SonicForgeDAW/logs/spawn.log` (liệt kê nội dung resource_dir khi
|
||||||
|
không tìm thấy).
|
||||||
|
|
||||||
|
Lệnh build 1 lệnh mỗi OS:
|
||||||
|
```bash
|
||||||
|
# Windows (PowerShell, ASCII-only)
|
||||||
|
powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# Linux (cần binutils: sudo apt-get install -y binutils)
|
||||||
|
bash build_linux.sh
|
||||||
|
# macOS (cần codesign/notarize khi phát hành)
|
||||||
|
bash build_macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 KIẾN TRÚC 2 SIDECAR (Native Host Bridge — bản 1.2)
|
||||||
|
|
||||||
|
Từ bản 1.2, bản desktop chạy **Tauri v2** shell + **2 sidecar** (không còn browser):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────── SonicForgeDAW (Tauri v2) ─────────────────────────┐
|
||||||
|
│ WebView2 (UI HTML5/JS — app.precompiled.js) │
|
||||||
|
│ ▲ invoke ▲ bridge-audio (event) │
|
||||||
|
│ Rust shell (src-tauri) │
|
||||||
|
│ ├── spawn+watchdog ──► daw_engine.exe (FastAPI sidecar, port 8000) │
|
||||||
|
│ └── spawn+health ────► daw_vst_bridge.exe (C++ Native Host Bridge) │
|
||||||
|
│ │ SharedMemory "SonicForge_DAW_IPC" │
|
||||||
|
│ │ (MidiEventIPC / ControlEventIPC / audio) │
|
||||||
|
│ └─ FluidSynth / sfizz / VST3 host │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **MIDI → âm**: Web MIDI/timeline → `push_midi_event` (Rust ghi SHM) →
|
||||||
|
bridge render block 256 (FluidSynth/sfizz/VST3) → Rust audio pump emit
|
||||||
|
`bridge-audio` → WebView2 ScriptProcessor → track FX → master → loa.
|
||||||
|
- **Fallback**: bridge chết → `SonicSF` (FluidSynth WASM) tự bật, app không crash.
|
||||||
|
- **YC runtime**: cả `daw_engine.exe` lẫn `daw_vst_bridge.exe` cần VC++ 2015-2022
|
||||||
|
Redistributable — `hooks.nsh` (NSIS) cài `vc_redist.x64.exe`; MSI không chạy
|
||||||
|
hooks → máy sạch nên cài NSIS hoặc tự cài redist.
|
||||||
|
- **Binaries**: engine = `src-tauri/resources/daw_engine/` (bundle.resources),
|
||||||
|
bridge = `src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe`
|
||||||
|
(`bundle.externalBin`).
|
||||||
|
- **Build bridge**: `build/scripts/build_native_bridge.ps1` (vcpkg fluidsynth/
|
||||||
|
sfizz + submodule vst3sdk + CMake MSVC) → copy exe vào `src-tauri/binaries/`.
|
||||||
|
- **API mới**: `GET /api/v1/bridge/status`, `POST /api/v1/bridge/load`,
|
||||||
|
`GET /api/v1/bridge/log` (engine giữ trung gian IPC file
|
||||||
|
`%APPDATA%/SonicForgeDAW/ipc/`).
|
||||||
|
- **Logs**: `%APPDATA%/SonicForgeDAW/logs/spawn.log` (engine+bridge), `bridge.log`.
|
||||||
|
|
||||||
## 6. CẬP NHẬT
|
## 6. CẬP NHẬT
|
||||||
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
||||||
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# TASKS — TRIỂN KHAI NATIVE HOST BRIDGE THEO PLAN
|
||||||
|
|
||||||
|
Nguồn: `PLAN.md` + spec. Mỗi task: file đích, hành động cụ thể, điều kiện hoàn thành (DoD) để verify.
|
||||||
|
Ký hiệu: **[MỚI]** tạo mới, **[THAY]** thay thế/đổi luồng hiện có, **[BỔ SUNG]** thêm vào code hiện có.
|
||||||
|
|
||||||
|
> **TRẠNG THÁI 2026-08-11:** ✅ = đã làm xong. Task ✅ liên quan Rust/Windows (B1-B4, C2, C5, C7)
|
||||||
|
> mới verify ở mức code (node --check JS / review struct) — **cần `cargo check` + chạy thật trên máy
|
||||||
|
> Windows** trước khi coi là hoàn tất; máy này (Linux) không có rustc/CMake-MSVC.
|
||||||
|
> **D2/D9:** đã đổi timeline → `scheduleMidiNoteDispatch` (bridge active) + transport play/stop +
|
||||||
|
> `bridgePlayheadSampleRef`; sampleOffset/set_position chỉ gửi khi C++ A11/A13 sẵn sàng (nay gửi
|
||||||
|
> sampleOffset=0 qua setTimeout). **E1-E3:** syntax OK (ast.parse); máy này SIGILL khi import
|
||||||
|
> `app.core.vst_engine` (numpy/pedalboard pre-existing) → chưa chạy thử API tại đây.
|
||||||
|
> **BATCH A10-A13 + D5 (2026-08-11):** C++ multi-instance (InstrumentEngineManager), CC/program/pitch
|
||||||
|
> bend (MidiEventIPC + data2/data3), TRANSPORT (type=3 STOP flush/PLAY/SET_POSITION + playheadSamples),
|
||||||
|
> sample-accurate segment render, JS dispatch MỞ RỘNG (CC/PROGRAM/PITCH_BEND), load theo channel,
|
||||||
|
> transport(playhead). **Struct SHM ĐỔI: 10872 → 11160 bytes** — đã sync C (selfcheck pass Linux) ↔ Rust
|
||||||
|
> shm.rs ↔ selfcheck; **bắt buộc `cargo check` + chạy shm_selfcheck trên Windows**. Sửa bug pre-existing:
|
||||||
|
> Rust push_control không set pathlen → C++ load giờ dùng strnlen(arg2) thay vì tin arg1. C++ chưa compile
|
||||||
|
> ở máy này (thiếu fluidsynth/sfizz headers) — verify bằng shm_selfcheck (không cần engine) + review.
|
||||||
|
> **BATCH B8-B10 (cùng ngày):** spawn bridge kèm SF_SAMPLE_RATE/SF_BLOCK_SIZE (C++ main đọc env, block cố
|
||||||
|
> định 256), open_vst_gui tạo child WebviewWindow + HWND raw_window_handle → control type=4, health monitor
|
||||||
|
> stall 3s → respawn 1 lần → bridge-down (đếm vào bridge.log). Rust chưa compile được (rustup không có
|
||||||
|
> toolchain) — review tay; `cargo check` trên Windows là điều kiện hoàn tất.
|
||||||
|
> **BATCH D7/D10/E4/E5/F1-F7/B6/B7/A7 (2026-08-11, cuối):** D7 UI Plugin Manager (badge Bridge ON/OFF +
|
||||||
|
> nút "Load Bridge" trên VST/SoundFont → `/api/v1/bridge/load` → loadInstrument); D10 header indicator
|
||||||
|
> Bridge/WASM + nút "Ép WASM" (setForceWasm) + service `onStatusChange`; E4 watchdog verify (không đụng
|
||||||
|
> bridge); E5 `GET /api/v1/bridge/log`; F1-F3 3 ps1 copy vào repo build/scripts/; F4 verify_bundle
|
||||||
|
> `--check-bridge`; F5 hooks.nsh OK; F6 docs 2 sidecar; F7 CI yml; B6 externalBin; B7 capabilities OK;
|
||||||
|
> A7 tách Vst3Instrument.h/.cpp (guard HAVE_VST3SDK, stub no-op khi thiếu submodule — A6 host thật vẫn
|
||||||
|
> TODO chờ vst3sdk trên Windows). Bundle rebuild OK; selfcheck 11160 pass Linux.
|
||||||
|
> **BATCH CUỐI (2026-08-11):** B5 ✅ (pump thread tồn tại trong lib.rs ~263 — review tay, cần cargo check
|
||||||
|
> Windows); F5 ✅ (hooks.nsh `customInstall` ExecWait vc_redist toàn máy — đủ cho cả 2 exe); F2 🟡
|
||||||
|
> (ps1 đã copy, chạy thật cần Windows); D9 bổ sung `transport('set_position', playhead)` ~mỗi giây trong
|
||||||
|
> `updatePlayhead` (JS là nguồn playhead duy nhất — C++ A13 không tự biết). Bundle rebuilt OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. NATIVE BRIDGE C++ (`native_bridge/`) — [MỚI]
|
||||||
|
|
||||||
|
Code skeleton đã chuẩn bị sẵn trong workspace `native_bridge/`; copy vào repo `/home/locpham/SonicForgeStudio/native_bridge/` rồi hoàn thiện từng task.
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ A1 | `native_bridge/` | Copy toàn bộ skeleton từ workspace vào repo | `native_bridge/CMakeLists.txt` + `include/*.h` + `src/*.cpp` tồn tại |
|
||||||
|
| ✅ A2 | `native_bridge/vst3sdk/` | `git submodule add https://github.com/steinbergmedia/vst3sdk.git` | thư mục `vst3sdk/CMakeLists.txt` tồn tại |
|
||||||
|
| ✅ A3 | `native_bridge/vcpkg.json` | Tạo manifest: `{"dependencies":["fluidsynth","sfizz","pkgconf"]}` | `vcpkg install` chạy từ manifest không lỗi |
|
||||||
|
| ✅ A4 | `native_bridge/include/SharedMemoryIPC.h` | Giữ struct spec + controlQueue (đã có); **bổ sung** field `uint64_t blockTimestamp` (đo latency IPC) | struct khớp layout Rust ở B3 |
|
||||||
|
| ✅ A5 | `native_bridge/src/main.cpp` | Hoàn thiện: đọc control LOAD (đã có), **thêm** xử lý `type=3 TRANSPORT_PLAY/STOP` (flush note-off khi Stop), log mỗi lần load instrument ra stdout | Stop timeline → bridge gửi note-off toàn pitch |
|
||||||
|
| A6 | `native_bridge/src/NativeInstrumentEngine.cpp` | Giữ FluidSynth + sfizz (đã có); **hoàn thiện Vst3Instrument**: host qua vst3sdk (`IAudioProcessor::process`, `IPlugView::attached` với HWND con), factory `make_instrument` trả về instance thật | load `.vst3` → noteon → PCM khác 0 |
|
||||||
|
| 🟡 A7 | `native_bridge/include/Vst3Instrument.h` + `src/Vst3Instrument.cpp` | Tách class VST3 riêng (hiện đang stub trong main.cpp); xử lý `openGUI(void* hwnd)` | compile OK khi có vst3sdk |
|
||||||
|
| A8 | `native_bridge/src/Vst2Instrument.cpp` | (Tùy chọn, GĐ sau) Host VST2 qua vestige | skip nếu không đủ thời gian; ghi TODO |
|
||||||
|
| ✅ A9 | `native_bridge/src/SharedMemoryIPC.cpp` | Bỏ no-op; thêm helper `open_shm(name)`, `write_midi`, `write_control` (tái dùng bởi test) | unit test nhỏ gọi helper pass |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. RUST / TAURI SHELL (`src-tauri/`) — [MỚI] + [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ B1 | `src-tauri/Cargo.toml` | **BỔ SUNG** dependency `windows-sys` (features `Win32_System_MemoryManagement`, `Win32_Foundation`) để CreateFileMapping/MapViewOfFile | `cargo check` pass |
|
||||||
|
| ✅ B2 | `src-tauri/src/shm.rs` | **[MỚI]** Module SHM: `create_shm(name) -> ShmHandle` tạo `SonicForge_DAW_IPC` (CreateFileMapping + MapViewOfFile, size = `sizeof(SharedAudioBufferIPC)`), expose `write_midi_event(evt)`, `read_audio_block()`, `reset_queue()` | `cargo check` pass; struct layout khớp A4 |
|
||||||
|
| ✅ B3 | `src-tauri/src/lib.rs` | **BỔ SUNG** `EngineProcess` thứ hai cho bridge: `bridge_candidates()` dò `binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe` (resource dir + exe dir), spawn với env `SF_SHM_NAME=SonicForge_DAW_IPC` + `SF_PARENT_PID`, redirect stdout→`%APPDATA%/SonicForgeDAW/logs/bridge.log`, kill khi window destroyed | spawn.log có dòng `[daw_vst_bridge] exists=True` |
|
||||||
|
| ✅ B4 | `src-tauri/src/lib.rs` | **BỔ SUNG** `invoke_handler` với commands: `push_midi_event(cmd,ch,pitch,vel,sampleOffset)`, `load_native_instrument(path,type)`, `open_vst_gui(pluginId)`, `bridge_status()`, `transport_control(kind)` | `window.__TAURI__.core.invoke(...)` trả về OK |
|
||||||
|
| ✅ B5 | `src-tauri/src/lib.rs` | **[MỚI]** Audio pump thread: mỗi ~5.8ms đọc SHM (`bridgeWriteIndex` tăng) → `app.emit("bridge-audio", {L: Float32Array, R: Float32Array})`; nếu index không tăng 1s → emit `bridge-down` | UI nhận event `bridge-audio` |
|
||||||
|
| B6 | `src-tauri/tauri.conf.json` | **BỔ SUNG** `bundle.externalBin: ["binaries/daw_vst_bridge"]` (giữ resources engine) | `npx tauri build` bundle chứa `daw_vst_bridge-x86_64-pc-windows-msvc.exe` |
|
||||||
|
| B7 | `src-tauri/capabilities/default.json` | Xác nhận không cần permission mới (commands app-level) — nếu dùng plugin window cho GUI VST thì thêm `core:window:allow-create` | `tauri dev` không cảnh báo permission |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. FRONTEND SERVICES MỚI (`app/static/js/services/`) — [MỚI]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ C1 | `unifiedMidiRouter.js` | **[MỚI]** Class `UnifiedMidiRouter`: `pushEvent({cmd,ch,pitch,vel,sampleOffset})` normalize `UnifiedMidiEvent`; `subscribe(source)` cho Web MIDI + Timeline; khi `bridgeConnected` → gọi `nativeBridgeService.dispatchMidiEvent`, ngược lại → gọi `SonicSF.playNote/stopNote` (fallback); `panic()` → `transport_control('panic')` | 1 router dùng chung 2 nguồn |
|
||||||
|
| ✅ C2 | `nativeBridgeService.js` | **[MỚI]** Như spec NHƯNG `dispatchMidiEvent` = `window.__TAURI__.core.invoke('push_midi_event', evt)` (không có `__TAURI_IPC_SHM__`); `loadInstrument(filePath,type)` invoke `load_native_instrument`; `openNativeGUI(pluginId)` invoke `open_vst_gui`; lắng nghe `bridge-audio`/`bridge-down`, expose `onAudio(cb)` | MIDI keyboard → note chạy qua bridge |
|
||||||
|
| ✅ C3 | `audioRoutingEngine.js` | **[MỚI]** `connectNativeBridgeToTrackFX(bridgeNode, trackId)` theo spec §VI: nối node vào `trackContext.sfEntry` → FX chain → `masterBus.input`; `disconnect()` khi bridge down | âm bridge qua EQ track + master maximizer |
|
||||||
|
| ✅ C4 | `app/templates/index.html` | **BỔ SUNG** 3 script tag sau `soundfontPlayer.js` (dòng 41): `nativeBridgeService.js`, `unifiedMidiRouter.js`, `audioRoutingEngine.js` (kèm `?v=` mới) | reload page → `window.SonicMidiRouter` tồn tại |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. WIRING `app/static/js/app.jsx` — [THAY]
|
||||||
|
|
||||||
|
| ID | Vị trí (dòng hiện tại) | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ D1 | `onmidimessage` ~15587 | **[THAY]** Nhánh NOTE_ON/OFF gọi `SonicSF.playNote`/`SonicCarlaMidi.noteOn` → gọi `UnifiedMidiRouter.pushEvent` trước; giữ fallback khi `!bridgeConnected` | bấm keyboard → router log + âm qua bridge |
|
||||||
|
| ✅ D2 | `updatePlayhead` ~20771+ (nhánh dispatch midiItems) | **[THAY]** Dispatch note timeline → `pushEvent` kèm `sampleOffset` tính từ playhead (bỏ `setTimeout` drift); Stop/Play → `transport_control` | play piano roll đúng nhịp, Stop hết âm ngân |
|
||||||
|
| ✅ D3 | `playNativeSfNote` / `SonicSF.playNote` call sites (track armed, keybed, sub-tab) | **[THAY]** Đổi đích tới router khi bridge active (cờ `SF_BRIDGE_AVAILABLE`) | mọi đường note đi qua 1 cổng |
|
||||||
|
| ✅ D4 | `SonicCarlaMidi` (runtime.js ~74) | **[THAY]** `shouldRouteCarla` trả false khi bridge connected (VSTi do bridge host); giữ Carla khi không có bridge | không kép âm VST (bridge + Carla) |
|
||||||
|
| ✅ D5 | `updateSfRouting` ~21255 + `setOutputDestination` | **[BỔ SUNG]** Nhánh mới: khi bridge active, `audioRoutingEngine.connectNativeBridgeToTrackFX(node, trackId)` thay vì `SonicSF.setOutputDestination` | âm bridge vào đúng track FX chain |
|
||||||
|
| D6 | `panic`/`stopAll` (soundfontPlayer.js) + Stop handler app.jsx | **[BỔ SUNG]** Stop/panic → `transport_control('panic')` tới bridge + vẫn gọi fallback cục bộ | không note kẹt sau Stop |
|
||||||
|
| D7 | UI Plugin Manager / Synth dropdown | **[BỔ SUNG]** Hiển thị trạng thái bridge (`bridge_status`); nút "Load vào Native Bridge" gọi `C2.loadInstrument` với path resolve từ API plugins (Bổ sung E1) | chọn VSTi/SF2 → bridge load, GUI VST mở được |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. BACKEND PYTHON (`app/`) — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ E1 | `app/api/v1/plugins.py` | **BỔ SUNG** endpoint `GET /api/v1/bridge/status` (đọc file `%APPDATA%/SonicForgeDAW/ipc/bridge_status` do Rust ghi, hoặc probe bridge.log) | `curl /api/v1/bridge/status` trả JSON có `connected` |
|
||||||
|
| ✅ E2 | `app/api/v1/plugins.py` | **BỔ SUNG** endpoint `POST /api/v1/bridge/load` — resolve path thật (sf2 trong storage/soundfonts, .vst3 trong plugin_dirs) rồi ghi file IPC `ipc/bridge_load.request` cho Rust đọc (giống cơ chế pick_dir hiện có) | Rust nhận request → bridge load → UI nhận `bridge-audio` |
|
||||||
|
| ✅ E3 | `app/core/vst_engine.py` | Giữ nguyên cho offline render; **thêm** comment + hàm `resolve_plugin_path(name)` tái dùng bởi E2 | không phá luồng offline hiện có |
|
||||||
|
| E4 | `desktop_engine.py` | Không đổi logic; kiểm tra watchdog không giết bridge (bridge do Rust spawn, engine chỉ quản lý chính nó) | chạy standalone vẫn OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. BUILD & ĐÓNG GÓI WINDOWS — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | `build/scripts/build_native_bridge.ps1` | Copy từ workspace (đã sẵn): vcpkg bootstrap + install fluidsynth/sfizz + submodule vst3sdk + cmake MSVC + copy exe | chạy trên máy Windows → exe trong `src-tauri/binaries/` |
|
||||||
|
| 🟡 F2 | `build/scripts/build_windows.ps1` | Copy từ workspace: pipeline 7 bước (0. kill engine+bridge, 1. pip, 2. frontend, 3. pyinstaller, 4. copy engine, 5. build bridge, 6. vc_redist, 7. tauri build) | build 1 lệnh ra NSIS+MSI |
|
||||||
|
| F3 | `build/scripts/test_standalone.ps1` | Copy từ workspace: 8 bước test (process, spawn.log, SHM, health, bridge/status, load SF2, checklist tay) | pass #1-#5 |
|
||||||
|
| F4 | `tools/verify_bundle.py` | **BỔ SUNG** kiểm tra thêm: `src-tauri/binaries/daw_vst_bridge-x86_64-pc-windows-msvc.exe` tồn tại trước tauri build | build fail sớm nếu thiếu bridge |
|
||||||
|
| ✅ F5 | `src-tauri/hooks.nsh` | Xác nhận `vc_redist.x64.exe` được cài cho cả `daw_engine.exe` + `daw_vst_bridge.exe` (bridge cần VC++ runtime) | máy sạch cài app → bridge chạy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. TEST — [BỔ SUNG]
|
||||||
|
|
||||||
|
| ID | Nội dung | DoD |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | Unit test C++ (A9 helper) | assert-based check trong `native_bridge/tests/` chạy pass |
|
||||||
|
| G2 | Test SHM: Rust tạo → bridge mở → ghi MIDI → đọc audio | `bridgeWriteIndex` tăng; PCM khác 0 khi noteon |
|
||||||
|
| G3 | Test load SF2/SF3 (SGM-V2.01) + SFZ (SalamanderPiano) + VST3 (Vital) | spec VII mục 2 pass |
|
||||||
|
| G4 | Test live MIDI + timeline cùng lúc (Nektar SE49) | spec VII mục 3 pass, latency <10ms |
|
||||||
|
| G5 | Test Track FX + Master FX tác động âm bridge | spec VII mục 4 pass |
|
||||||
|
| G6 | Test fallback: kill bridge → SonicSF WASM tiếp tục, không crash | spec VII mục 6 pass |
|
||||||
|
| G7 | Chạy `test_standalone.ps1` trên máy cài standalone | 5/5 bước tự động pass |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. BỔ SUNG LIỀN MẠCH (MẠCH DỮ LIỆU HOÀN CHỈNH)
|
||||||
|
|
||||||
|
Các task nhóm H lấp những khâu còn hở giữa A-G để pipeline chạy end-to-end không đứt đoạn.
|
||||||
|
Đánh số tiếp theo nhóm tương ứng.
|
||||||
|
|
||||||
|
### H-A. Bridge C++ — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ A10 | `native_bridge/src/main.cpp` + `NativeInstrumentEngine.h` | **[MỚI]** Thay `currentInstrument` (1 instance) bằng `InstrumentEngineManager`: `std::map<uint32_t, std::unique_ptr<INativeInstrument>>` keyed theo MIDI channel; `assign_instrument(channel, path, type)`; MIDI event → instance theo `evt.channel`; channel chưa gán → silent (không crash) | 2 track (ch 0 + ch 2) 2 instrument phát đồng thời |
|
||||||
|
| ✅ A11 | `native_bridge/src/main.cpp` | **[BỔ SUNG]** Sample-accurate: không xử lý MIDI ngay; buffer event theo `sampleOffset`, rẽ block 256 thành các đoạn theo offset, gọi `noteOn/Off` đúng vị trí trước khi render đoạn đó | note timeline không lệch nhịp (>±1 sample test) |
|
||||||
|
| ✅ A12 | `native_bridge/include/SharedMemoryIPC.h` + engine | **[BỔ SUNG]** Mở rộng `MidiEventIPC`: field `data2` (+`data3` PB MSB); hỗ trợ `0xB CC` (FluidSynth `fluid_synth_cc`), `0xC program change`, `0xE pitch bend` (`fluid_synth_pitch_bend`); VST3 → tham số tương ứng | sustain/CC/pitchbend qua bridge hoạt động |
|
||||||
|
| ✅ A13 | `native_bridge/src/main.cpp` | **[BỔ SUNG]** Control `type=3 TRANSPORT`: `arg0=0 STOP` → flush note-off toàn channel + `fluid_synth_all_notes_off`; `arg0=1 PLAY` kèm `arg1=playheadSamples` → đồng bộ timeline | Stop → hết âm ngân tức thì |
|
||||||
|
|
||||||
|
### H-B. Rust — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ B8 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** Spawn bridge kèm env `SF_SAMPLE_RATE` + `SF_BLOCK_SIZE` (đọc từ `bridge_status`/cấu hình, mặc định 44100/256); nếu `AudioContext.sampleRate` khác → JS resampler (C5) — ghi chú trong code | SR khác 44100 vẫn nghe đúng cao độ |
|
||||||
|
| ✅ B9 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** `open_vst_gui(pluginId)`: tạo Tauri child window (WebviewWindowBuilder, title = plugin, childOf main) → lấy HWND (windows-sys) → ghi control `type=4 OPEN_GUI` + hwnd vào SHM → bridge `openGUI(hwnd)`; window đóng → `close_gui` | VST GUI hiện trong cửa sổ con, đóng sạch |
|
||||||
|
| ✅ B10 | `src-tauri/src/lib.rs` | **[BỔ SUNG]** Health monitor: nếu `bridgeWriteIndex` đứng >3s và process chết → spawn lại 1 lần, vẫn fail → `emit("bridge-down")`; đếm số lần restart vào bridge.log | app tự phục hồi bridge 1 lần; UI fallback sau đó |
|
||||||
|
|
||||||
|
### H-C. Frontend — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✅ C5 | `app/static/js/services/bridgeAudioNode.js` | **[MỚI]** Sink phát âm: nhận `bridge-audio` (Float32Array L/R) → ring buffer depth 8 block (~46ms) chống underrun → ScriptProcessor/Worklet đẩy ra `AudioNode` (output chuẩn stereo); nút này là "bridgeNode" mà C3 nối; **VU analyser tap trên node** (`__vuAnalyser` như `_gainNode` cũ) để track VU nhảy; resampler tuyến tính nếu SR khác bridge | âm nghe được từ SHM; VU track hoạt động |
|
||||||
|
| ✅ C6 | `app/static/js/app.jsx` (useEffect khởi tạo ~gần `initMasterBus`) | **[BỔ SUNG]** Bootstrap bridge: gọi `bridge_status()` → set `SF_BRIDGE_AVAILABLE`; `nativeBridgeService.onAudio` nối vào `bridgeAudioNode`; subscribe `bridge-down` → ngắt node + quay về SonicSF | cờ đúng trước note đầu tiên; bridge chết → fallback tự động |
|
||||||
|
| ✅ C7 | `app/static/js/services/nativeBridgeService.js` | **[BỔ SUNG]** Dev-mode mock: không có `window.__TAURI__` (Linux dev / browser) → mọi invoke thành log + `onAudio` không emit → app chạy bằng SonicSF như cũ | dev trên Linux không crash |
|
||||||
|
|
||||||
|
### H-D. app.jsx — bổ sung
|
||||||
|
|
||||||
|
| ID | Vị trí | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D8 | router (C1) | **[BỔ SUNG]** Channel allocation: bảng `trackId → channel 0-15` thay `_engineChMap`/`allocateChannel`; percussion (bank 128) → ch 9; gửi `CC0/CC32/program change` qua bridge khi đổi instrument (A12) | 2 track 2 instrument không trùng channel |
|
||||||
|
| ✅ D9 | `updatePlayhead` | **[BỔ SUNG]** Playhead sample counter: `playheadSample += block*(dt*sr/block)`; `sampleOffset = playheadSample - lastBlockStartSamples`; gửi `transport_control('set_position', playheadSample)` mỗi bar | timeline chính xác sample |
|
||||||
|
| D10 | UI header/status | **[BỔ SUNG]** Indicator bridge (connected/down) + nút "Ép dùng WASM" (debug): gọi `SonicMidiRouter.setForceWasm(true)` | user biết engine nào đang phát |
|
||||||
|
|
||||||
|
### H-E. Backend — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E5 | `app/api/v1/plugins.py` | **[BỔ SUNG]** `GET /api/v1/bridge/log?lines=100` — trả tail `bridge.log` cho UI debug | UI xem được log bridge |
|
||||||
|
|
||||||
|
### H-F. Build — bổ sung
|
||||||
|
|
||||||
|
| ID | File | Hành động | DoD |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F6 | `DESKTOP_INSTALL_PLAN.md` / `DEPLOYMENT_REPORT.md` | **[BỔ SUNG]** Cập nhật tài liệu: kiến trúc 2 sidecar (daw_engine + daw_vst_bridge), yêu cầu VC++ runtime, port/API mới | tài liệu khớp thực tế |
|
||||||
|
| F7 | `ci/build-windows.yml` | **[MỚI]** (Tùy chọn) GitHub Actions: runner windows-latest → build bridge + engine + tauri → artifact installer | pipeline CI ra NSIS setup |
|
||||||
|
|
||||||
|
### H-G. Test — bổ sung
|
||||||
|
|
||||||
|
| ID | Nội dung | DoD |
|
||||||
|
|---|---|---|
|
||||||
|
| G8 | Đo latency end-to-end: đọc `blockTimestamp` (A4) qua `bridge_status` + round-trip MIDI→loa (loopback/mic); so ngưỡng | IPC ≤0.5ms; end-to-end <10ms |
|
||||||
|
| G9 | Test multi-instance: track 1 SF2 (piano) + track 2 SFZ (string) chạy cùng lúc, đổi instrument live | không choke, đúng âm từng track |
|
||||||
|
| G10 | Test CC/sustain/pitchbend qua bridge (A12) | sustain pedal + pitch wheel + CC volume hoạt động |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SƠ ĐỒ MẠCH DỮ LIỆU HOÀN CHỈNH (SAU KHI ĐỦ TASKS)
|
||||||
|
|
||||||
|
```
|
||||||
|
Web MIDI keyboard ──┐
|
||||||
|
Timeline playhead ──┴─► UnifiedMidiRouter (C1) ─ invoke push_midi_event (B4/C2)
|
||||||
|
(D8: track→channel) │ bridge down → SonicSF fallback (C1/C6/C7)
|
||||||
|
▼
|
||||||
|
Rust shm.rs write_midi_event (B2) ──► SHM "SonicForge_DAW_IPC"
|
||||||
|
▼
|
||||||
|
bridge main.cpp (A11 sampleOffset, A12 CC/PC/PB, A13 transport)
|
||||||
|
→ InstrumentEngineManager (A10) → FluidSynth / sfizz / VST3 (A6-A8)
|
||||||
|
▼
|
||||||
|
SHM masterLeft/Right + bridgeWriteIndex + blockTimestamp (A4)
|
||||||
|
▼
|
||||||
|
Rust audio pump (B5/B10 health) ─ emit 'bridge-audio' ─► bridgeAudioNode (C5)
|
||||||
|
▼
|
||||||
|
audioRoutingEngine (C3) → track sfEntry → track FX → masterBus → speakers
|
||||||
|
▼
|
||||||
|
VU analyser tap (C5) → track VU nhảy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHỤ THUỘC (thứ tự thực hiện — đã cập nhật)
|
||||||
|
|
||||||
|
1. **A1-A4 → B1-B4 → C1-C2 + C5-C7** (skeleton + IPC + router + audio sink) — nền tảng.
|
||||||
|
2. **A10-A13 → B5, B8 → C3, C6 → D1-D2, D8-D9** (multi-instance + sample-accurate + audio pump + routing + wiring).
|
||||||
|
3. **A5-A7, A12 → B9-B10 → D3-D7, D10 → E1-E5** (engine thật + VST GUI + gỡ Carla + UI + backend).
|
||||||
|
4. **F1-F7 → G1-G10** (build pipeline + test) — cần máy Windows.
|
||||||
|
|
||||||
|
## CÒN THIẾU SO VỚI SPEC (đã bổ sung vào tasks — bản đầy đủ)
|
||||||
|
|
||||||
|
- SHM tạo bởi Rust (B2) — spec chỉ có bridge mở.
|
||||||
|
- Đường MIDI JS→bridge (B4/C2) — spec dùng `__TAURI_IPC_SHM__` không tồn tại.
|
||||||
|
- Audio pump SHM→WebView (B5/C5) — spec nói zero-copy nhưng WebView không zero-copy được.
|
||||||
|
- Transport/panic + playhead position (A5/A13/B4/C1/D6/D9) — spec thiếu, cần cho timeline Stop/đồng bộ.
|
||||||
|
- Nhiều instrument/track (A10/D8) — spec chỉ 1 `currentInstrument`.
|
||||||
|
- VST3 GUI child window (B9/A7) — spec có `openGUI` nhưng thiếu cơ chế HWND con từ Tauri.
|
||||||
|
- CC/program/pitchbend (A12) — spec chỉ note on/off; app hiện có sustain/pitch wheel.
|
||||||
|
- Sample-accurate scheduling (A11/D9) — spec xử lý MIDI ngay lập tức, bỏ qua sampleOffset.
|
||||||
|
- Resampler khi SR AudioContext ≠ bridge (B8/C5).
|
||||||
|
- Ring buffer chống underrun + VU tap (C5) — kế thừa hành vi `_gainNode.__vuAnalyser` cũ.
|
||||||
|
- Fallback SonicSF (C1/C6/C7/G6) — spec không đề cập; giữ để app không chết khi bridge lỗi.
|
||||||
|
- Dev-mode mock (C7) — chạy được trên Linux/browser không có `__TAURI__`.
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# TEST NOTES — NATIVE BRIDGE (test trên máy native/Windows)
|
||||||
|
|
||||||
|
Máy phát triển hiện tại (Linux, không rustc/CMake-MSVC, Python SIGILL khi import
|
||||||
|
`app.core.vst_engine`) KHÔNG chạy được pipeline. Danh sách này là "bộ nhớ" —
|
||||||
|
chạy từng mục trên máy Windows (hoặc OS native có đủ toolchain), đánh dấu ✅ khi pass.
|
||||||
|
|
||||||
|
## A. C++ bridge (`native_bridge/`)
|
||||||
|
- [x] A5+A13: TRANSPORT type=3 (STOP flush / PLAY / SET_POSITION) + log mỗi load — code OK, chưa chạy thật
|
||||||
|
- [x] A10: InstrumentEngineManager multi-channel (std::map channel→instrument) — code OK, chưa compile Windows
|
||||||
|
- [x] A11: sample-accurate segment render theo sampleOffset — code OK; JS hiện gửi sampleOffset=0 (best-effort)
|
||||||
|
- [x] A12: CC/program/pitch bend (MidiEventIPC data2+data3) — code OK
|
||||||
|
- [ ] A6: Vst3Instrument host thật qua vst3sdk — load .vst3 → noteon → PCM ≠ 0
|
||||||
|
- [ ] A7: Vst3Instrument tách class + openGUI(hwnd)
|
||||||
|
- [ ] **Windows verify sfizz API names**: `SfizzInstrument::controlChange` dùng `sfizzSynth.cc(cc,val)`, `pitchBend` dùng `sfizzSynth.pitchWheel(bend14)` — nếu bản sfizz vcpkg chỉ có `hdCC`/`hdPitchWheel` thì đổi; `programChange` đang no-op (TODO)
|
||||||
|
- [ ] **Windows verify**: `cargo check` + chạy `shm_selfcheck.exe` (sizeof struct = **11160 bytes**)
|
||||||
|
|
||||||
|
## B. Rust / Tauri (`src-tauri/`)
|
||||||
|
- [ ] B1-B4: `cargo check` pass trên Windows (windows-sys, shm.rs, lib.rs commands)
|
||||||
|
- [ ] B2: SHM struct layout khớp C header (**11160 bytes** — đã verify C bằng selfcheck Linux; cần Rust)
|
||||||
|
- [ ] B2: signature commands mới — `push_midi_event(..., data2, data3, sample_offset)`, `load_native_instrument(path, type, channel)`, `transport_control(kind, playhead: Option<u32>)` — JS `nativeBridgeService.js`/`unifiedMidiRouter.js` đã sync
|
||||||
|
- [ ] B5: audio pump thread emit `bridge-audio` ~5ms — ✅ code: pump thread trong lib.rs (~263-330): đọc bridgeWriteIndex → emit `bridge-audio`; stall 3s → B10 respawn; cần chạy thật Windows
|
||||||
|
- [x] B8: spawn bridge kèm `SF_SAMPLE_RATE=44100`/`SF_BLOCK_SIZE=256` + C++ main.cpp đọc env (block cố định 256, SR áp cho init engine) — code OK
|
||||||
|
- [x] B9: `open_vst_gui` tạo child WebviewWindow + HWND qua `raw_window_handle` → control type=4 arg1 — code OK; **close_gui khi window đóng chưa làm (chờ A7)**
|
||||||
|
- [x] B10: health monitor trong audio pump — stall 3s → respawn 1 lần → `bridge-down`; đếm restart vào bridge.log — code OK
|
||||||
|
- [ ] B6: tauri.conf.json externalBin chứa daw_vst_bridge — ✅ code: `["binaries/daw_vst_bridge"]` đã thêm (chưa `tauri build` verify)
|
||||||
|
- [ ] B7: capabilities — ✅ review: window VST tạo từ Rust (`WebviewWindowBuilder`), commands app-level, event listen nằm trong `core:default` — không cần permission mới
|
||||||
|
- [ ] B9 thật trên Windows: child window hiện + HWND hợp lệ (kiểm tra log bridge nhận type=4)
|
||||||
|
|
||||||
|
## C. Frontend services
|
||||||
|
- [x] C1-C7: node --check OK + bundle build OK (Linux)
|
||||||
|
- [ ] C5: ring buffer 8 block chống underrun thật trên Windows (âm nghe được, VU nhảy)
|
||||||
|
|
||||||
|
## D. app.jsx wiring
|
||||||
|
- [x] D1: onmidimessage NOTE_ON/OFF → router (code + bundle OK)
|
||||||
|
- [x] D2: timeline → scheduleMidiNoteDispatch + transport play/stop (code + bundle OK)
|
||||||
|
- [x] D9: bridgePlayheadSampleRef counter + `transport('set_position', playhead)` ~mỗi giây trong updatePlayhead (JS là nguồn playhead duy nhất; C++ A13 cập nhật anchor) — code + bundle OK
|
||||||
|
- [ ] D1-D2 thật: bấm keyboard + play piano roll → âm qua bridge, Stop hết âm ngân
|
||||||
|
- [x] D4: Carla không route khi bridge active (code, cần verify thật)
|
||||||
|
- [ ] D5: audio routing vào track FX chain
|
||||||
|
- [x] D6: panic/Stop → `SonicMidiRouter.panic()` (bridge transport panic + fallback SonicSF.stopAll) — 4 call site app.jsx đã đổi
|
||||||
|
- [x] D8: track→channel map + gửi CC0/CC32/PROGRAM trước NOTE_ON khi đổi instrument (scheduleMidiNoteDispatch)
|
||||||
|
- [x] D7: UI Plugin Manager — badge `● Bridge ON/OFF` (query `GET /api/v1/bridge/status`), nút "Load Bridge" trên mỗi VST + SoundFont → `POST /api/v1/bridge/load` (E2 resolve path) → `NativeBridgeService.loadInstrument(path, type, 0)`; code + bundle OK — chưa test thật trên Windows
|
||||||
|
- [x] D10: header indicator Bridge/WASM + nút "Ép WASM" (toggle `SonicMidiRouter.setForceWasm`); service thêm `onStatusChange(cb)` — code + bundle OK
|
||||||
|
|
||||||
|
## E. Backend Python
|
||||||
|
- [x] E1-E3: syntax OK (ast.parse)
|
||||||
|
- [x] E4: desktop_engine.py watchdog — verify: chỉ theo dõi `SF_PARENT_PID` (Tauri) để tự thoát; không spawn/kill gì khác → không đụng bridge (bridge do Rust spawn). Không cần sửa.
|
||||||
|
- [x] E5: `GET /api/v1/bridge/log?lines=N` tail bridge.log — thêm Query import; ast.parse OK
|
||||||
|
- [ ] `curl /api/v1/bridge/status` trả `connected` đúng — cần chạy trên máy không SIGILL
|
||||||
|
- [ ] `POST /api/v1/bridge/load` resolve sf2/vst3 → bridge load → UI nhận `bridge-audio`
|
||||||
|
- [ ] SIGILL khi import `app.core.vst_engine` trên máy dev (numpy/pedalboard pre-existing) — không phải do code mới
|
||||||
|
|
||||||
|
## F. Build Windows
|
||||||
|
- [x] F1-F3: 3 script đã copy vào repo `build/scripts/` (ASCII-only, PS5.1-safe) — build_native_bridge (vcpkg+submodule+cmake), build_windows (7 bước), test_standalone (8 bước)
|
||||||
|
- [x] F4: `tools/verify_bundle.py --check-bridge` — verify TOC + sidecar tồn tại; fail đúng cách trên Linux (chưa có exe) — test exit 1 OK
|
||||||
|
- [x] F5: hooks.nsh — vc_redist cài toàn máy (ExecWait /quiet) → đủ runtime cho cả daw_engine.exe + daw_vst_bridge.exe; MSI không chạy hooks → cài thủ công
|
||||||
|
- [x] F6: DESKTOP_INSTALL_PLAN.md §5.2 (kiến trúc 2 sidecar) + DEPLOYMENT_REPORT.md bản 1.2
|
||||||
|
- [x] F7: `.github/workflows/build-windows.yml` — windows-latest: MSVC + Rust + node + python → build_windows.ps1 → upload NSIS/MSI
|
||||||
|
- [ ] **Chạy thật trên Windows**: build_windows.ps1 trọn pipeline ra NSIS+MSI
|
||||||
|
|
||||||
|
## G. Test end-to-end
|
||||||
|
- [ ] G1: shm_selfcheck pass (đã pass trên Linux: "SHM self-check OK (**11160 bytes**)" — chạy lại trên Windows)
|
||||||
|
- [ ] G2: SHM Rust tạo → bridge mở → MIDI → audio
|
||||||
|
- [ ] G3: load SGM-V2.01 (SF2) + SalamanderPiano (SFZ) + Vital (VST3)
|
||||||
|
- [ ] G4: live MIDI + timeline cùng lúc, latency <10ms
|
||||||
|
- [ ] G5: Track FX + Master FX tác động âm bridge
|
||||||
|
- [ ] G6: kill bridge → SonicSF fallback, không crash
|
||||||
|
- [ ] G7: test_standalone.ps1 5/5
|
||||||
|
- [ ] G8: đo latency blockTimestamp IPC ≤0.5ms, end-to-end <10ms
|
||||||
|
- [ ] G9: multi-instance 2 track đồng thời (A10 — 2 SF2/SFZ khác nhau, đổi live)
|
||||||
|
- [ ] G10: sustain/CC/pitchbend qua bridge (A12 — CC64 sustain + pitch wheel + CC7 volume)
|
||||||
|
|
||||||
|
## Ghi chú batch 2026-08-11 (A10-A13 + D5)
|
||||||
|
- Struct SHM đổi 10872 → 11160: MidiEventIPC +data2/data3(+reserved) = 12B; ControlEventIPC +channel = 1040B.
|
||||||
|
Sync 3 nơi: `SharedMemoryIPC.h`, `shm.rs`, `shm_selfcheck.cpp` — đã khớp (selfcheck pass Linux).
|
||||||
|
- Bug pre-existing tìm thấy: `shm.rs push_control` không ghi pathlen vào arg1 → bridge cũ đọc path rỗng.
|
||||||
|
Sửa: C++ `main.cpp` load path dùng strnlen(arg2); helper C `shm_write_control` chỉ set arg1=pathlen khi type==2.
|
||||||
|
- `transport_control` Rust giờ nhận `playhead: Option<u32>`; JS `NativeBridgeService.transport(kind, playhead)`.
|
||||||
|
- `loadInstrument(filePath, type, channel)`; `dispatchMidiEvent(cmd,...,data2,data3)` hỗ trợ CC/PROGRAM/PITCH_BEND.
|
||||||
|
- Khi D8 (track→channel map) chưa xong: JS loadInstrument mặc định channel=0 — multi-instance chưa kích hoạt từ UI.
|
||||||
@@ -74,6 +74,8 @@ async def _resolve_host_ips(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _validate_target_url(url: str, user_id: str):
|
async def _validate_target_url(url: str, user_id: str):
|
||||||
|
"""Validate target. Trả IP đã validate (str) để proxy connect thẳng vào đó
|
||||||
|
chống DNS rebinding TOCTOU; None khi giữ hostname (loopback / https)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||||
@@ -88,7 +90,7 @@ async def _validate_target_url(url: str, user_id: str):
|
|||||||
# Hostname-level fast path for loopback hosts
|
# Hostname-level fast path for loopback hosts
|
||||||
if hostname in _LOOPBACK_HOSTS:
|
if hostname in _LOOPBACK_HOSTS:
|
||||||
if hostname in allowed_hosts:
|
if hostname in allowed_hosts:
|
||||||
return
|
return None
|
||||||
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||||
|
|
||||||
# Try direct IP parse (hostname may itself be an IP)
|
# Try direct IP parse (hostname may itself be an IP)
|
||||||
@@ -109,18 +111,34 @@ async def _validate_target_url(url: str, user_id: str):
|
|||||||
continue
|
continue
|
||||||
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||||
|
|
||||||
|
# Trả IP đầu tiên đã validate — http sẽ connect thẳng vào IP này (bind),
|
||||||
|
# không cho httpx re-resolve hostname (fix DNS rebinding TOCTOU).
|
||||||
|
return str(ips[0])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxy")
|
@router.post("/proxy")
|
||||||
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||||
await _validate_target_url(req.url, current_user["user_id"])
|
target_ip = await _validate_target_url(req.url, current_user["user_id"])
|
||||||
# Never forward the app's own auth token upstream.
|
# Never forward the app's own auth token upstream.
|
||||||
headers = {
|
headers = {
|
||||||
k: v for k, v in req.headers.items()
|
k: v for k, v in req.headers.items()
|
||||||
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||||
}
|
}
|
||||||
|
url = req.url
|
||||||
|
# Bug #6: http + hostname (không phải IP literal) → connect thẳng IP đã
|
||||||
|
# validate, giữ Host gốc. https giữ hostname (SNI + cert validation chống
|
||||||
|
# rebinding sẵn). IPv6 skip (netloc bracket phức tạp, hiếm gặp).
|
||||||
|
if target_ip and ":" not in target_ip:
|
||||||
|
parsed = urlparse(req.url)
|
||||||
|
if parsed.scheme == "http":
|
||||||
|
host_header = parsed.netloc
|
||||||
|
new_netloc = parsed.netloc.replace(parsed.hostname, target_ip)
|
||||||
|
from urllib.parse import urlunsplit
|
||||||
|
url = urlunsplit((parsed.scheme, new_netloc, parsed.path, parsed.query, parsed.fragment))
|
||||||
|
headers["Host"] = host_header
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||||
resp = await client.post(req.url, headers=headers, json=req.body)
|
resp = await client.post(url, headers=headers, json=req.body)
|
||||||
raw = resp.text
|
raw = resp.text
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ router = APIRouter()
|
|||||||
|
|
||||||
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
# Bug #4: whitelist extension upload — trước đây nhận mọi đuôi (`.exe`) rồi
|
||||||
|
# serve qua static. Chỉ chấp nhận định dạng audio phổ biến.
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {
|
||||||
|
"wav", "mp3", "ogg", "flac", "aiff", "aif", "m4a", "aac", "opus", "webm",
|
||||||
|
}
|
||||||
|
|
||||||
def _safe_file_id(file_id: str) -> str:
|
def _safe_file_id(file_id: str) -> str:
|
||||||
"""Strip any path components from a client-supplied file id."""
|
"""Strip any path components from a client-supplied file id."""
|
||||||
if not file_id:
|
if not file_id:
|
||||||
@@ -81,7 +87,10 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
|||||||
if current_user:
|
if current_user:
|
||||||
enforce_password_changed(current_user)
|
enforce_password_changed(current_user)
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
ext = os.path.splitext(file.filename or "")[1]
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
# Bug #4: reject non-audio extensions before saving
|
||||||
|
if ext and ext.lstrip(".") not in ALLOWED_AUDIO_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Định dạng file không được hỗ trợ: {ext}")
|
||||||
if not ext:
|
if not ext:
|
||||||
ext = ".wav"
|
ext = ".wav"
|
||||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||||
@@ -125,7 +134,9 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/edit")
|
@router.post("/edit")
|
||||||
async def edit_audio(req: EditRequest):
|
async def edit_audio(req: EditRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||||
if not _resolve_storage_path(req.file_id):
|
if not _resolve_storage_path(req.file_id):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
@@ -192,10 +203,12 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/export")
|
@router.post("/export")
|
||||||
async def export_audio(req: ExportRequest):
|
async def export_audio(req: ExportRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
source_path = _resolve_storage_path(req.file_id)
|
source_path = _resolve_storage_path(req.file_id)
|
||||||
if not source_path:
|
if not source_path:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
@@ -392,7 +405,11 @@ async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_
|
|||||||
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
|
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
user_id = current_user["user_id"]
|
user_id = current_user["user_id"]
|
||||||
prefix = f"user_{user_id}_"
|
prefix = f"user_{user_id}_"
|
||||||
|
# Bug #2: sanitize TRƯỚC khi check prefix — trước đây chỉ check startswith
|
||||||
|
# nên `user_<id>_../../tmp/x` pass guard → os.remove xóa file ngoài storage.
|
||||||
|
file_id = _safe_file_id(file_id)
|
||||||
|
if not file_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Không tìm thấy tệp trên server")
|
||||||
# Guard: only own files can be deleted
|
# Guard: only own files can be deleted
|
||||||
if not file_id.startswith(prefix):
|
if not file_id.startswith(prefix):
|
||||||
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
|
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
|||||||
user_id = current_user["user_id"]
|
user_id = current_user["user_id"]
|
||||||
old_pwd = req.old_password.strip()
|
old_pwd = req.old_password.strip()
|
||||||
new_pwd = req.new_password.strip()
|
new_pwd = req.new_password.strip()
|
||||||
|
# Bug #7: register đã validate độ mạnh, change-password thì KHÔNG — admin bị
|
||||||
|
# ép đổi mật khẩu có thể đặt `a` (1 ký tự). Áp cùng policy.
|
||||||
|
_validate_password_strength(new_pwd)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.api.v1.auth import enforce_password_changed
|
||||||
|
from app.api.v1.projects import get_optional_user
|
||||||
|
from app.api.v1.audio import _safe_file_id
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,13 +36,19 @@ class MultitrackSessionRequest(BaseModel):
|
|||||||
tracks: List[TrackConfig]
|
tracks: List[TrackConfig]
|
||||||
|
|
||||||
@router.post("/mix")
|
@router.post("/mix")
|
||||||
async def mix_multitrack_session(req: MultitrackSessionRequest):
|
async def mix_multitrack_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
|
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||||
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
|
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
|
||||||
"""
|
"""
|
||||||
# Kiểm tra xem các file nguồn có tồn tại không
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
# Bug #9: guard auth-optional + sanitize file_id (chống traversal vào worker)
|
||||||
for track in req.tracks:
|
for track in req.tracks:
|
||||||
|
track.file_id = _safe_file_id(track.file_id)
|
||||||
|
if not track.file_id:
|
||||||
|
raise HTTPException(status_code=400, detail=f"file_id không hợp lệ cho track {track.track_id}")
|
||||||
|
# Kiểm tra xem các file nguồn có tồn tại không
|
||||||
if track.muted:
|
if track.muted:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -63,11 +72,15 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/process-session")
|
@router.post("/process-session")
|
||||||
async def process_session(req: MultitrackSessionRequest):
|
async def process_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
|
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
|
||||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
for track in req.tracks:
|
||||||
|
track.file_id = _safe_file_id(track.file_id)
|
||||||
from app.tasks.worker import process_multitrack_session_task
|
from app.tasks.worker import process_multitrack_session_task
|
||||||
task = process_multitrack_session_task.delay(req.model_dump())
|
task = process_multitrack_session_task.delay(req.model_dump())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# SonicForge Preset Library API — thư viện preset VST3 (.vstpreset) nằm trong
|
||||||
|
# storage/presets (mount qua volume trong docker; thư mục storage trên Windows).
|
||||||
|
#
|
||||||
|
# Vai trò: cầu nối Carla → pedalboard. User chỉnh preset trong Carla (native
|
||||||
|
# GUI) → xuất .vstpreset → upload vào thư viện → gán vào track (preset_id trong
|
||||||
|
# synth_engine) → render_engine tải qua load_preset → âm render = âm đã chỉnh.
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.vst_engine import preset_library_dir, PRESET_EXTENSIONS
|
||||||
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_preset_path(preset_id: str) -> str:
|
||||||
|
"""Chống path traversal: chỉ cho phép tên file (không chứa separator)."""
|
||||||
|
if not preset_id or os.path.basename(preset_id) != preset_id:
|
||||||
|
return ""
|
||||||
|
d = preset_library_dir()
|
||||||
|
p = os.path.join(d, preset_id)
|
||||||
|
if os.path.isfile(p) and os.path.dirname(os.path.abspath(p)) == os.path.abspath(d):
|
||||||
|
return p
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_presets():
|
||||||
|
"""Danh sách preset trong thư viện (public — frontend cần trước login)."""
|
||||||
|
d = preset_library_dir()
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(d))
|
||||||
|
except Exception:
|
||||||
|
names = []
|
||||||
|
for f in names:
|
||||||
|
low = f.lower()
|
||||||
|
if not low.endswith(PRESET_EXTENSIONS):
|
||||||
|
continue
|
||||||
|
meta = {}
|
||||||
|
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
|
||||||
|
if os.path.isfile(meta_path):
|
||||||
|
try:
|
||||||
|
with open(meta_path, "r", encoding="utf-8") as mf:
|
||||||
|
meta = json.load(mf)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(os.path.join(d, f))
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
items.append({
|
||||||
|
"id": f,
|
||||||
|
"name": meta.get("original_name", f),
|
||||||
|
"plugin_hint": meta.get("plugin_hint", ""),
|
||||||
|
"size_bytes": size,
|
||||||
|
"created_at": meta.get("created_at", ""),
|
||||||
|
})
|
||||||
|
return {"success": True, "presets": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_preset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
plugin_hint: Optional[str] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload preset (.vstpreset / .fxp / .fxb / .dspreset) vào thư viện."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
filename = (file.filename or "preset.vstpreset").replace("\\", "/").split("/")[-1]
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext not in PRESET_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Định dạng preset không hỗ trợ: {ext or '(không có đuôi)'} — hỗ trợ: {', '.join(PRESET_EXTENSIONS)}",
|
||||||
|
)
|
||||||
|
contents = await file.read()
|
||||||
|
if not contents:
|
||||||
|
raise HTTPException(status_code=400, detail="File rỗng")
|
||||||
|
d = preset_library_dir()
|
||||||
|
preset_id = uuid.uuid4().hex + ext
|
||||||
|
dest = os.path.join(d, preset_id)
|
||||||
|
try:
|
||||||
|
with open(dest, "wb") as fh:
|
||||||
|
fh.write(contents)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Không lưu được preset: {e}")
|
||||||
|
meta = {
|
||||||
|
"original_name": filename,
|
||||||
|
"plugin_hint": plugin_hint or "",
|
||||||
|
"size_bytes": len(contents),
|
||||||
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, os.path.splitext(preset_id)[0] + ".meta"), "w", encoding="utf-8") as mf:
|
||||||
|
json.dump(meta, mf, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"success": True, "preset_id": preset_id, **meta}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{preset_id}/download")
|
||||||
|
async def download_preset(preset_id: str):
|
||||||
|
path = _safe_preset_path(preset_id)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset không tồn tại")
|
||||||
|
return FileResponse(path, filename=preset_id, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{preset_id}")
|
||||||
|
async def delete_preset(preset_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
path = _safe_preset_path(preset_id)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset không tồn tại")
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
mp = os.path.join(preset_library_dir(), os.path.splitext(preset_id)[0] + ".meta")
|
||||||
|
if os.path.isfile(mp):
|
||||||
|
os.remove(mp)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Không xóa được preset: {e}")
|
||||||
|
return {"success": True, "preset_id": preset_id}
|
||||||
@@ -136,6 +136,7 @@ class SaveProjectRequest(BaseModel):
|
|||||||
|
|
||||||
class SaveTempProjectRequest(BaseModel):
|
class SaveTempProjectRequest(BaseModel):
|
||||||
data_json: str
|
data_json: str
|
||||||
|
client_id: Optional[str] = None # client ghi bản này (LAN browser / Tauri UI) — chống ping-pong sync
|
||||||
|
|
||||||
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
||||||
if authorization and authorization.startswith("Bearer "):
|
if authorization and authorization.startswith("Bearer "):
|
||||||
@@ -162,6 +163,22 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
# ── Ghi file autosave vào thư mục temp CỦA ỨNG DỤNG (storage/temp) ──
|
||||||
|
# Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp trên thư mục temp của ứng
|
||||||
|
# dụng để khi load lại thì tải lại dự án đang làm dở." Ngoài row trong DB,
|
||||||
|
# ghi thẳng file JSON để luôn có bản sao thật trên ổ đĩa OS.
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
temp_dir = os.path.join(_st.STORAGE_DIR, "temp")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
|
||||||
|
# Revision sidecar (nhỏ) — realtime sync LAN/Tauri đọc updated_at +
|
||||||
|
# client_id KHÔNG cần parse data_json (có thể MB).
|
||||||
|
with open(os.path.join(temp_dir, f"revision_{user_id}.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"client_id": req.client_id or "", "updated_at": now}, f, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
||||||
|
|
||||||
@router.get("/temp")
|
@router.get("/temp")
|
||||||
@@ -175,14 +192,108 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
|
|||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not row:
|
if row:
|
||||||
return {"has_temp": False}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"has_temp": True,
|
"has_temp": True,
|
||||||
"data_json": row["data_json"],
|
"data_json": row["data_json"],
|
||||||
"updated_at": row["updated_at"]
|
"updated_at": row["updated_at"]
|
||||||
}
|
}
|
||||||
|
# Fallback: file autosave.json trong thư mục temp của ứng dụng (khi lưu lúc
|
||||||
|
# đóng app qua sendBeacon — user anonymous) — tải lại dự án đang làm dở.
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
autosave_path = os.path.join(_st.STORAGE_DIR, "temp", "autosave.json")
|
||||||
|
if os.path.isfile(autosave_path):
|
||||||
|
with open(autosave_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if data.get("data_json"):
|
||||||
|
return {
|
||||||
|
"has_temp": True,
|
||||||
|
"data_json": data["data_json"],
|
||||||
|
"updated_at": data.get("updated_at", 0),
|
||||||
|
"source": "file",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"has_temp": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/temp/revision")
|
||||||
|
async def temp_project_revision(current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
"""Revision nhẹ của bản temp: updated_at + client_id — frontend poll 2-3s
|
||||||
|
để realtime sync giữa LAN browser và Tauri standalone (không tải full
|
||||||
|
data_json mỗi lần). client_id = client ghi bản cuối → client khác bỏ qua
|
||||||
|
bản do CHÍNH NÓ ghi (chống ping-pong)."""
|
||||||
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
client_id = ""
|
||||||
|
updated_at = 0.0
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
rev_path = os.path.join(_st.STORAGE_DIR, "temp", f"revision_{user_id}.json")
|
||||||
|
if os.path.isfile(rev_path):
|
||||||
|
with open(rev_path, "r", encoding="utf-8") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
client_id = meta.get("client_id", "") or ""
|
||||||
|
updated_at = float(meta.get("updated_at", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not updated_at:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT updated_at FROM projects WHERE id = ? AND is_temp = 1", (f"temp_{user_id}",))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
updated_at = float(row["updated_at"] or 0)
|
||||||
|
return {"updated_at": updated_at, "client_id": client_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _os_projects_dir() -> str:
|
||||||
|
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
|
||||||
|
Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
|
||||||
|
Linux/macOS → ~/SonicForgeDAW/Projects. Luôn tồn tại (tự tạo)."""
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
docs = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), "Documents")
|
||||||
|
base = docs if os.path.isdir(docs) else (os.environ.get("USERPROFILE") or os.path.expanduser("~"))
|
||||||
|
else:
|
||||||
|
base = os.path.expanduser("~")
|
||||||
|
d = os.path.join(base, "SonicForgeDAW", "Projects")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
except Exception:
|
||||||
|
return os.path.join(os.path.expanduser("~"), "SonicForgeDAW", "Projects")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save-to-disk")
|
||||||
|
async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[str] = Header(None)):
|
||||||
|
"""Ctrl-S trên desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
||||||
|
(Documents/SonicForgeDAW/Projects — bản desktop). Docker/headless KHÔNG
|
||||||
|
dùng endpoint này (frontend lưu Cloud). Auth optional — desktop có thể
|
||||||
|
chưa login."""
|
||||||
|
try:
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
decode_token(authorization.split(" ")[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
validated_data_json = validate_project_data(req.data_json)
|
||||||
|
safe_name = "".join(c for c in (req.name or "Dự án mới") if c.isalnum() or c in " _-.").strip() or "Du-an-moi"
|
||||||
|
if len(safe_name) > 80:
|
||||||
|
safe_name = safe_name[:80].strip()
|
||||||
|
safe_name = safe_name.replace(".", "_") if safe_name.endswith(".") else safe_name
|
||||||
|
fname = safe_name + ".sonicforge.json"
|
||||||
|
out_dir = _os_projects_dir()
|
||||||
|
out_path = os.path.join(out_dir, fname)
|
||||||
|
# Không ghi đè file đang mở ở nơi khác? Ghi đè OK (Ctrl-S = save).
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(validated_data_json)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"name": req.name or "Dự án mới",
|
||||||
|
"path": out_path,
|
||||||
|
"filename": fname,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cloud")
|
@router.post("/cloud")
|
||||||
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
|
||||||
|
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from app.core.runtime import capabilities, save_carla_path
|
||||||
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capabilities")
|
||||||
|
async def get_capabilities():
|
||||||
|
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
|
||||||
|
|
||||||
|
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
|
||||||
|
"headless" (docker server + browser UI)
|
||||||
|
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
|
||||||
|
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
|
||||||
|
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
|
||||||
|
âm thật giống export) | "wasm" (Preview Synth trong browser)
|
||||||
|
"""
|
||||||
|
return capabilities()
|
||||||
|
|
||||||
|
|
||||||
|
class CarlaPathRequest(BaseModel):
|
||||||
|
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
|
||||||
|
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
|
||||||
|
carla_path: str
|
||||||
|
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/carla-path")
|
||||||
|
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
|
||||||
|
|
||||||
|
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
|
||||||
|
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
target = (req.carla_path or req.carla_dir or "").strip()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
|
||||||
|
exe = save_carla_path(target)
|
||||||
|
if not exe:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
|
||||||
|
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
|
||||||
|
)
|
||||||
|
return {"success": True, "carla_path": exe, **capabilities()}
|
||||||
|
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import os
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from celery.result import AsyncResult
|
|
||||||
from app.tasks.worker import celery_app
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Task status endpoint dung chung cho ca 2 che do:
|
||||||
|
# - Server/Docker: celery (AsyncResult, broker Redis).
|
||||||
|
# - Desktop slim (PyInstaller khong bundle celery): in-process registry
|
||||||
|
# (app/tasks/worker._SimpleAsyncResult) — API contract giong het nhau.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
async def get_task_status(task_id: str):
|
async def get_task_status(task_id: str):
|
||||||
res = AsyncResult(task_id, app=celery_app)
|
from app.tasks.worker import get_task_result
|
||||||
|
|
||||||
|
res = get_task_result(task_id)
|
||||||
response_data = {
|
response_data = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": res.status,
|
"status": res.status,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ def _app_dir():
|
|||||||
|
|
||||||
|
|
||||||
def _storage_dir():
|
def _storage_dir():
|
||||||
|
# Test/dev isolation: env override thắng (pytest set để không đụng storage
|
||||||
|
# thật — app production có thể đang chạy và ghi file state).
|
||||||
|
env_dir = os.getenv("SONICFORGE_STORAGE_DIR")
|
||||||
|
if env_dir:
|
||||||
|
return env_dir
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
# Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm
|
# Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm
|
||||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
@@ -17,6 +22,28 @@ def _storage_dir():
|
|||||||
return os.path.join(_app_dir(), "storage")
|
return os.path.join(_app_dir(), "storage")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_vst_dir():
|
||||||
|
"""Thư mục VST mặc định theo platform (env VST_DIR vẫn thắng).
|
||||||
|
|
||||||
|
Windows: thư mục VST3 chuẩn của hệ thống — user có thể thêm thư mục khác
|
||||||
|
qua Plugins Manager (plugin_dirs.json). Docker/Linux: mount qua compose."""
|
||||||
|
if os.name == "nt":
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
return os.path.join(pf, "Common Files", "VST3")
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "/Library/Audio/Plug-Ins/VST3"
|
||||||
|
return "/opt/daw_engine/vst3"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_soundfont_dir():
|
||||||
|
if os.name == "nt":
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.join(root, "SonicForgeDAW", "soundfonts")
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")
|
||||||
|
return "/opt/daw_engine/soundfonts"
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
@@ -29,4 +56,15 @@ class Settings:
|
|||||||
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
||||||
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
||||||
|
|
||||||
|
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
|
||||||
|
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user). Default theo
|
||||||
|
# platform (Windows: thư mục VST3 chuẩn; Linux: mount compose).
|
||||||
|
VST_DIR: str = os.getenv("VST_DIR", _default_vst_dir())
|
||||||
|
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", _default_soundfont_dir())
|
||||||
|
|
||||||
|
# Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard.
|
||||||
|
# Nằm trong storage nên tự động nằm trong volume mount (docker) / thư mục
|
||||||
|
# storage (Windows desktop).
|
||||||
|
PRESET_DIR: str = os.path.join(STORAGE_DIR, "presets")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -75,13 +75,16 @@ class AIDSPEngine:
|
|||||||
t_end = min(total_duration, 4.0)
|
t_end = min(total_duration, 4.0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import librosa
|
from app.core.audio_features import chroma_stft as _chroma_stft
|
||||||
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform
|
# 1. Compute harmonic structural properties via Chroma (STFT-based,
|
||||||
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr)
|
# thay chroma_cqt de lo bo librosa/numba/llvmlite ~171MB)
|
||||||
|
chroma = _chroma_stft(y=y_mono, sr=sr)
|
||||||
|
|
||||||
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
# thay sklearn.metrics.pairwise.cosine_similarity bang numpy
|
||||||
ssm = cosine_similarity(chroma.T, chroma.T)
|
c = chroma.T # (n_frames, 12)
|
||||||
|
norms = np.linalg.norm(c, axis=1, keepdims=True)
|
||||||
|
ssm = (c @ c.T) / (norms @ norms.T + 1e-9)
|
||||||
|
|
||||||
num_frames = ssm.shape[0]
|
num_frames = ssm.shape[0]
|
||||||
hop_length = 512
|
hop_length = 512
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import librosa
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Thay librosa bang shim nhe (numpy/scipy/soundfile) — khong keo numba/llvmlite
|
||||||
|
from app.core.audio_features import (
|
||||||
|
load as _load,
|
||||||
|
beat_track as _beat_track,
|
||||||
|
frames_to_time as _frames_to_time,
|
||||||
|
spectral_centroid as _spectral_centroid,
|
||||||
|
rms as _rms,
|
||||||
|
zero_crossing_rate as _zcr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def analyze_audio(file_path: str) -> dict:
|
def analyze_audio(file_path: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
||||||
"""
|
"""
|
||||||
# Load audio
|
# Load audio
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
|
|
||||||
# Track beats
|
# Track beats
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
# Handle tempo which might be scalar or numpy array in different librosa versions
|
# Handle tempo which might be scalar or numpy array in different librosa versions
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
|
|||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
# Convert frames to time (seconds)
|
# Convert frames to time (seconds)
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
|
|
||||||
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
@@ -43,31 +52,31 @@ def analyze_audio_advanced(file_path: str) -> dict:
|
|||||||
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
||||||
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
duration = float(len(y)) / sr
|
duration = float(len(y)) / sr
|
||||||
|
|
||||||
# Beat tracking
|
# Beat tracking
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
||||||
else:
|
else:
|
||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
|
|
||||||
# Spectral centroid (brightness)
|
# Spectral centroid (brightness)
|
||||||
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
spectral_centroids = _spectral_centroid(y=y, sr=sr)[0]
|
||||||
avg_brightness = float(np.mean(spectral_centroids))
|
avg_brightness = float(np.mean(spectral_centroids))
|
||||||
|
|
||||||
# RMS energy
|
# RMS energy
|
||||||
rms = librosa.feature.rms(y=y)[0]
|
rms_vals = _rms(y=y)[0]
|
||||||
avg_energy = float(np.mean(rms))
|
avg_energy = float(np.mean(rms_vals))
|
||||||
|
|
||||||
# Zero crossing rate
|
# Zero crossing rate
|
||||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
zcr_vals = _zcr(y)[0]
|
||||||
avg_zcr = float(np.mean(zcr))
|
avg_zcr = float(np.mean(zcr_vals))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"bpm": round(bpm, 2),
|
"bpm": round(bpm, 2),
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only).
|
||||||
|
|
||||||
|
Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung
|
||||||
|
ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB).
|
||||||
|
|
||||||
|
Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site:
|
||||||
|
- load() ~ librosa.load (sr=None, mono=True)
|
||||||
|
- frames_to_time() ~ librosa.frames_to_time
|
||||||
|
- beat_track() ~ librosa.beat.beat_track (onset spectral flux
|
||||||
|
+ autocorrelation tempo + adaptive peak picking)
|
||||||
|
- spectral_centroid() ~ librosa.feature.spectral_centroid
|
||||||
|
- rms() ~ librosa.feature.rms
|
||||||
|
- zero_crossing_rate() ~ librosa.feature.zero_crossing_rate
|
||||||
|
- time_stretch() ~ librosa.effects.time_stretch (phase vocoder)
|
||||||
|
- pitch_shift() ~ librosa.effects.pitch_shift
|
||||||
|
- chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based,
|
||||||
|
dung cho fingerprint/similarity, KHONG dung cho
|
||||||
|
hien thi pitch chinh xac)
|
||||||
|
|
||||||
|
Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from scipy import signal as _signal
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load", "frames_to_time", "beat_track",
|
||||||
|
"spectral_centroid", "rms", "zero_crossing_rate",
|
||||||
|
"time_stretch", "pitch_shift", "chroma_stft",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048)
|
||||||
|
HOP_LENGTH = 512
|
||||||
|
N_FFT = 2048
|
||||||
|
WIN_LENGTH = 2048
|
||||||
|
|
||||||
|
|
||||||
|
# ── Load / time ──────────────────────────────────────────────────────────────
|
||||||
|
def load(path, sr=None, mono=True, offset=0.0, duration=None):
|
||||||
|
"""Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel.
|
||||||
|
|
||||||
|
sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None).
|
||||||
|
Neu truyen sr -> resample bang scipy.signal.resample_poly.
|
||||||
|
"""
|
||||||
|
if offset or duration:
|
||||||
|
info = sf.info(path)
|
||||||
|
start = int(offset * info.samplerate) if offset else 0
|
||||||
|
n_frames = int(duration * info.samplerate) if duration else -1
|
||||||
|
data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames)
|
||||||
|
else:
|
||||||
|
data, file_sr = sf.read(path, dtype="float32")
|
||||||
|
|
||||||
|
if data.ndim > 1:
|
||||||
|
if mono:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
else:
|
||||||
|
data = data.T # (channels, samples) giong librosa
|
||||||
|
|
||||||
|
if sr is not None and sr != file_sr:
|
||||||
|
from fractions import Fraction
|
||||||
|
ratio = Fraction(int(sr), int(file_sr))
|
||||||
|
up, down = ratio.numerator, ratio.denominator
|
||||||
|
data = _signal.resample_poly(data, up, down).astype(np.float32)
|
||||||
|
file_sr = sr
|
||||||
|
|
||||||
|
return data, file_sr
|
||||||
|
|
||||||
|
|
||||||
|
def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None):
|
||||||
|
"""Chuyen frame index sang giay: frames * hop_length / sr (giong librosa)."""
|
||||||
|
return np.asanyarray(frames) * float(hop_length) / float(sr)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Framing / STFT (center=True, reflect pad, giong librosa) ────────────────
|
||||||
|
def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True)."""
|
||||||
|
pad = frame_length // 2
|
||||||
|
yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect")
|
||||||
|
n_frames = 1 + (len(yp) - frame_length) // hop_length
|
||||||
|
if n_frames < 1:
|
||||||
|
n_frames = 1
|
||||||
|
idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :]
|
||||||
|
return yp[idx]
|
||||||
|
|
||||||
|
|
||||||
|
def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH):
|
||||||
|
"""STFT mot phia (rfft) voi cua so hann periodic, reflect pad."""
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
f, _t, Zxx = _signal.stft(
|
||||||
|
y, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
boundary="even", padded=True,
|
||||||
|
)
|
||||||
|
return Zxx
|
||||||
|
|
||||||
|
|
||||||
|
def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
||||||
|
length=None):
|
||||||
|
"""ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity).
|
||||||
|
|
||||||
|
boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True.
|
||||||
|
"""
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
_t, y = _signal.istft(
|
||||||
|
Zxx, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
input_onesided=True, boundary=True,
|
||||||
|
)
|
||||||
|
if length is not None and len(y) > length:
|
||||||
|
y = y[:length]
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
# ── Features ─────────────────────────────────────────────────────────────────
|
||||||
|
def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
||||||
|
S=None):
|
||||||
|
"""Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram."""
|
||||||
|
if S is None:
|
||||||
|
S = np.abs(_stft(y, n_fft, hop_length)) ** 2
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
mag = np.abs(S)
|
||||||
|
denom = mag.sum(axis=0)
|
||||||
|
cent = np.divide(
|
||||||
|
np.sum(freqs[:, None] * mag, axis=0), denom,
|
||||||
|
out=np.zeros_like(denom), where=denom > 1e-10,
|
||||||
|
)
|
||||||
|
return cent[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None):
|
||||||
|
"""RMS nang luong moi frame - (1, n_frames)."""
|
||||||
|
if S is not None:
|
||||||
|
frames = S # caller truyen power spectrogram
|
||||||
|
else:
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
return np.sqrt(np.mean(frames ** 2, axis=0))[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Ti le zero-crossing moi frame - (1, n_frames)."""
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
signs = np.signbit(frames).astype(np.int8)
|
||||||
|
zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0)
|
||||||
|
return zcr[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH):
|
||||||
|
"""Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding).
|
||||||
|
|
||||||
|
Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi
|
||||||
|
cosine_similarity trong ai_dsp_engine.
|
||||||
|
"""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
# Chi giu bin <= 5kHz (tranh nhieu alias o high freq)
|
||||||
|
keep = freqs <= 5000.0
|
||||||
|
freqs = freqs[keep]
|
||||||
|
mag = mag[keep]
|
||||||
|
# note number -> pitch class
|
||||||
|
note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0
|
||||||
|
pc = np.mod(np.round(note).astype(int), 12)
|
||||||
|
chroma = np.zeros((12, mag.shape[1]), dtype=np.float64)
|
||||||
|
np.add.at(chroma, pc, mag)
|
||||||
|
# L2 normalize tung frame (giong librosa)
|
||||||
|
norms = np.linalg.norm(chroma, axis=0)
|
||||||
|
chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10)
|
||||||
|
return chroma
|
||||||
|
|
||||||
|
|
||||||
|
# ── Onset / tempo / beat (thay librosa.beat) ─────────────────────────────────
|
||||||
|
def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT):
|
||||||
|
"""Onset envelope: spectral flux (log-magnitude diff, chi chieu duong)."""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
logmag = np.log1p(1000.0 * mag)
|
||||||
|
flux = np.diff(logmag, axis=1)
|
||||||
|
onset = np.maximum(flux, 0.0).sum(axis=0)
|
||||||
|
if onset.size == 0:
|
||||||
|
return onset
|
||||||
|
# Tru moving-average ~1s de loai trend (giong librosa detrend)
|
||||||
|
win = max(1, int(round(1.0 * sr / hop_length)))
|
||||||
|
if len(onset) >= win:
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
onset = np.maximum(onset - ma, 0.0)
|
||||||
|
return onset
|
||||||
|
|
||||||
|
|
||||||
|
def _autocorr(x):
|
||||||
|
"""Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 2:
|
||||||
|
return np.ones(n)
|
||||||
|
x = x - x.mean()
|
||||||
|
nfft = 2 ** int(np.ceil(np.log2(2 * n)))
|
||||||
|
X = np.fft.rfft(x, nfft)
|
||||||
|
r = np.fft.irfft(X * np.conj(X), nfft)[:n]
|
||||||
|
denom = np.maximum(n - np.arange(n), 1)
|
||||||
|
r = r / denom
|
||||||
|
r0 = r[0] if r[0] != 0 else 1.0
|
||||||
|
return r / r0
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0),
|
||||||
|
start_bpm=120.0):
|
||||||
|
"""Uoc luong BPM bang autocorrelation cua onset envelope.
|
||||||
|
|
||||||
|
Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de
|
||||||
|
chon dung octave (tranh roi vao nua/double tempo khi autocorrelation
|
||||||
|
bi mo ho giua cac harmonic).
|
||||||
|
"""
|
||||||
|
if len(onset) < 4:
|
||||||
|
return float(start_bpm)
|
||||||
|
min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length)))
|
||||||
|
max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length)))
|
||||||
|
if max_lag <= min_lag or max_lag >= len(onset):
|
||||||
|
return float(start_bpm)
|
||||||
|
ac = _autocorr(onset)
|
||||||
|
lags = np.arange(min_lag, max_lag + 1)
|
||||||
|
tempi = 60.0 * sr / (hop_length * lags)
|
||||||
|
# prior rong ~0.7 octave quanh start_bpm (log2 scale)
|
||||||
|
prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2)
|
||||||
|
seg = ac[lags] * prior
|
||||||
|
best = lags[int(np.argmax(seg))]
|
||||||
|
tempo = 60.0 * sr / (hop_length * best)
|
||||||
|
# Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi
|
||||||
|
if tempo > 200.0 and best * 2 <= max_lag:
|
||||||
|
tempo = 60.0 * sr / (hop_length * best * 2)
|
||||||
|
return float(tempo)
|
||||||
|
|
||||||
|
|
||||||
|
def _localmax(x):
|
||||||
|
"""Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can)."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 3:
|
||||||
|
return np.zeros(n, dtype=bool)
|
||||||
|
out = np.zeros(n, dtype=bool)
|
||||||
|
out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0):
|
||||||
|
"""Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid."""
|
||||||
|
n = len(onset)
|
||||||
|
if n == 0:
|
||||||
|
return np.array([], dtype=int)
|
||||||
|
period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat
|
||||||
|
win = max(1, int(round(period)))
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
thresh = 1.25 * ma + 1e-9
|
||||||
|
|
||||||
|
cand = np.where(_localmax(onset) & (onset >= thresh))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.where(_localmax(onset))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.arange(0, n, max(1, int(round(period))))
|
||||||
|
|
||||||
|
beats = [int(cand[0])]
|
||||||
|
while True:
|
||||||
|
expected = beats[-1] + period
|
||||||
|
if expected >= n:
|
||||||
|
break
|
||||||
|
lo, hi = expected - 0.45 * period, expected + 0.45 * period
|
||||||
|
in_win = cand[(cand >= lo) & (cand <= hi)]
|
||||||
|
if in_win.size == 0:
|
||||||
|
nxt = int(round(expected))
|
||||||
|
if nxt >= n:
|
||||||
|
break
|
||||||
|
beats.append(nxt)
|
||||||
|
else:
|
||||||
|
beats.append(int(in_win[np.argmin(np.abs(in_win - expected))]))
|
||||||
|
# Chong beat kep (khoang cach < 0.5 period)
|
||||||
|
if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period:
|
||||||
|
beats.pop()
|
||||||
|
continue
|
||||||
|
if len(beats) > 2000:
|
||||||
|
break
|
||||||
|
return np.array(beats, dtype=int)
|
||||||
|
|
||||||
|
|
||||||
|
def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0,
|
||||||
|
tightness=100):
|
||||||
|
"""Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int).
|
||||||
|
|
||||||
|
Tempo bang autocorrelation onset; beats bang peak-picking thich nghi.
|
||||||
|
Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site
|
||||||
|
(analyzer xu ly ca scalar lan ndarray).
|
||||||
|
"""
|
||||||
|
onset = _onset_strength(y, sr, hop_length)
|
||||||
|
tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm)
|
||||||
|
beats = _beat_frames(onset, sr, hop_length, tempo)
|
||||||
|
return tempo, beats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Effects (thay librosa.effects) ───────────────────────────────────────────
|
||||||
|
def _phase_vocoder(D, rate, hop_length=HOP_LENGTH):
|
||||||
|
"""Phase vocoder time-stretch kinh dien (DAFX/Puckette).
|
||||||
|
|
||||||
|
D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon).
|
||||||
|
Tra ve STFT da stretch voi so frame ~ n_frames / rate.
|
||||||
|
"""
|
||||||
|
n_freq, n_frames = D.shape
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return D
|
||||||
|
time_steps = np.arange(0, n_frames, rate, dtype=float)
|
||||||
|
n_out = len(time_steps)
|
||||||
|
if n_out == 0:
|
||||||
|
return D[:, :0]
|
||||||
|
out = np.zeros((n_freq, n_out), dtype=np.complex128)
|
||||||
|
# Phase advance moi hop cua tung bin tan so
|
||||||
|
phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq)
|
||||||
|
mag = np.abs(D)
|
||||||
|
phase_acc = np.angle(D[:, 0])
|
||||||
|
for t, step in enumerate(time_steps):
|
||||||
|
idx = int(step)
|
||||||
|
if idx >= n_frames:
|
||||||
|
break
|
||||||
|
if idx + 1 >= n_frames:
|
||||||
|
out[:, t] = mag[:, idx] * np.exp(1j * phase_acc)
|
||||||
|
break
|
||||||
|
# Phase difference that giua 2 frame lien tiep (true frequency)
|
||||||
|
dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv
|
||||||
|
dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi))
|
||||||
|
phase_acc = phase_acc + phase_adv + dphase
|
||||||
|
out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def time_stretch(y, rate, **kwargs):
|
||||||
|
"""Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon."""
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
D = _stft(y)
|
||||||
|
D_stretch = _phase_vocoder(D, rate)
|
||||||
|
y_out = _istft(D_stretch)
|
||||||
|
# Cat ve dung do dai ky vong: len(y) / rate
|
||||||
|
target = int(round(len(y) / rate))
|
||||||
|
if len(y_out) > target:
|
||||||
|
y_out = y_out[:target]
|
||||||
|
return y_out.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_shift(y, sr=22050, n_steps=1, **kwargs):
|
||||||
|
"""Dich pitch n semitone (positive = cao hon), giu nguyen duration.
|
||||||
|
|
||||||
|
Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample
|
||||||
|
nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen.
|
||||||
|
"""
|
||||||
|
if n_steps == 0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
rate = 2.0 ** (-float(n_steps) / 12.0)
|
||||||
|
y_shift = time_stretch(y, rate)
|
||||||
|
# Resample (FFT) ve dung do dai goc: factor = rate
|
||||||
|
target = int(round(len(y_shift) * rate))
|
||||||
|
if target != len(y_shift) and target > 0:
|
||||||
|
y_shift = _signal.resample(y_shift, target)
|
||||||
|
return np.asarray(y_shift, dtype=np.float32)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import librosa
|
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
|
from app.core.audio_features import load as _load
|
||||||
|
|
||||||
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load mono audio for zero crossing analysis
|
# Load mono audio for zero crossing analysis
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error finding zero crossing: {e}")
|
print(f"Error finding zero crossing: {e}")
|
||||||
@@ -136,7 +136,7 @@ def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
|||||||
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
# Load mono audio
|
# Load mono audio
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os, logging, math
|
import os, logging, math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import scipy.signal as signal
|
# scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
|
||||||
|
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.vst_engine import (
|
from app.core.vst_engine import (
|
||||||
render_midi_events_to_audio,
|
render_midi_events_to_audio,
|
||||||
@@ -9,6 +10,7 @@ from app.core.vst_engine import (
|
|||||||
DecentSamplerManager,
|
DecentSamplerManager,
|
||||||
HAS_PEDALBOARD,
|
HAS_PEDALBOARD,
|
||||||
HAS_PYFLUIDSYNTH,
|
HAS_PYFLUIDSYNTH,
|
||||||
|
apply_preset_to_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -33,6 +35,21 @@ def _find_sf2_path(sf_id: str) -> str:
|
|||||||
fbase, fext = os.path.splitext(fname)
|
fbase, fext = os.path.splitext(fname)
|
||||||
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
||||||
return os.path.join(base_dir, fname)
|
return os.path.join(base_dir, fname)
|
||||||
|
# Thư mục user thêm qua Plugin Manager (plugin_dirs — Add Directory):
|
||||||
|
# soundfont trong thư mục user phải render được (Windows thường dùng cách này)
|
||||||
|
try:
|
||||||
|
from app.core.vst_engine import _load_user_plugin_dirs
|
||||||
|
for base_dir in _load_user_plugin_dirs():
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for root, dirs, files in os.walk(base_dir):
|
||||||
|
for fname in files:
|
||||||
|
fbase, fext = os.path.splitext(fname)
|
||||||
|
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
||||||
|
return os.path.join(root, fname)
|
||||||
|
dirs[:] = [] # không walk sâu
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
|
static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
|
||||||
if os.path.isdir(static_dir):
|
if os.path.isdir(static_dir):
|
||||||
for fname in os.listdir(static_dir):
|
for fname in os.listdir(static_dir):
|
||||||
@@ -209,8 +226,9 @@ class PythonRenderEngine:
|
|||||||
total_needed = dur_samples
|
total_needed = dur_samples
|
||||||
total_needed = max(total_needed, 1024)
|
total_needed = max(total_needed, 1024)
|
||||||
silent = np.zeros((2, total_needed), dtype=np.float32)
|
silent = np.zeros((2, total_needed), dtype=np.float32)
|
||||||
board = Pedalboard([vst])
|
# pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
|
||||||
synth_buffer = board(silent, sample_rate=self.sample_rate, midi_messages=midi_messages)
|
synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
|
||||||
|
duration=total_needed / float(self.sample_rate), num_channels=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[RenderEngine] DecentSampler/Pianobook error: {e}")
|
logger.warning(f"[RenderEngine] DecentSampler/Pianobook error: {e}")
|
||||||
synth_buffer = render_midi_events_to_audio(
|
synth_buffer = render_midi_events_to_audio(
|
||||||
@@ -222,6 +240,21 @@ class PythonRenderEngine:
|
|||||||
)
|
)
|
||||||
elif vst and HAS_PEDALBOARD:
|
elif vst and HAS_PEDALBOARD:
|
||||||
from pedalboard import Pedalboard
|
from pedalboard import Pedalboard
|
||||||
|
# Preset bridge Carla → pedalboard: preset_id
|
||||||
|
# (thư viện storage/presets), preset_path (file)
|
||||||
|
# hoặc preset_data (base64 .vstpreset nhúng trong
|
||||||
|
# project). Cùng sample rate → âm render = âm đã
|
||||||
|
# chỉnh trong Carla.
|
||||||
|
try:
|
||||||
|
se2 = track.get("synth_engine", {}) or {}
|
||||||
|
apply_preset_to_plugin(
|
||||||
|
vst,
|
||||||
|
preset_id=se2.get("preset_id") or track.get("preset_id"),
|
||||||
|
preset_path=se2.get("preset_path") or track.get("preset_path"),
|
||||||
|
preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[RenderEngine] Preset apply failed: %s", e)
|
||||||
midi_messages = PluginManager.midi_events_to_messages(
|
midi_messages = PluginManager.midi_events_to_messages(
|
||||||
midi_events, bpm, self.sample_rate,
|
midi_events, bpm, self.sample_rate,
|
||||||
bank=soundfont_bank, program=soundfont_program
|
bank=soundfont_bank, program=soundfont_program
|
||||||
@@ -234,9 +267,10 @@ class PythonRenderEngine:
|
|||||||
total_needed = dur_samples
|
total_needed = dur_samples
|
||||||
total_needed = max(total_needed, 1024)
|
total_needed = max(total_needed, 1024)
|
||||||
silent = np.zeros((2, total_needed), dtype=np.float32)
|
silent = np.zeros((2, total_needed), dtype=np.float32)
|
||||||
board = Pedalboard([vst])
|
# pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
|
||||||
synth_buffer = board(silent, sample_rate=self.sample_rate, midi_messages=midi_messages)
|
synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
|
||||||
elif instrument_id and (instrument_id.startswith("sf_") or soundfont_id):
|
duration=total_needed / float(self.sample_rate), num_channels=2)
|
||||||
|
elif soundfont_id or (instrument_id and instrument_id.startswith("sf_")):
|
||||||
sf_path = _find_sf2_path(soundfont_id or instrument_id)
|
sf_path = _find_sf2_path(soundfont_id or instrument_id)
|
||||||
# 3-level fallback: selected SF → default SF → oscillator synth
|
# 3-level fallback: selected SF → default SF → oscillator synth
|
||||||
if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH:
|
if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH:
|
||||||
@@ -385,7 +419,8 @@ class PythonRenderEngine:
|
|||||||
for ch in range(2):
|
for ch in range(2):
|
||||||
ir = ir_l if ch == 0 else ir_r
|
ir = ir_l if ch == 0 else ir_r
|
||||||
# Convolve
|
# Convolve
|
||||||
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
from scipy.signal import convolve
|
||||||
|
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
||||||
wet[ch, :] = conv
|
wet[ch, :] = conv
|
||||||
track_buffer = dry + wet * 0.4
|
track_buffer = dry + wet * 0.4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# SonicForge Runtime Profile — phát hiện môi trường chạy (desktop Windows /
|
||||||
|
# docker headless) và khả năng của máy, để app TỰ CHỌN cách xử lý:
|
||||||
|
# - Windows desktop (server + client cùng 1 máy): Carla local để preview +
|
||||||
|
# chỉnh preset → pedalboard render (Hướng A)
|
||||||
|
# - Docker / Linux headless (server + browser UI): soundfont + VSTi mở được
|
||||||
|
# từ storage mount; preset upload qua browser; không có GUI local.
|
||||||
|
#
|
||||||
|
# Ưu tiên: SF_RUNTIME env override > heuristic (platform + display + docker).
|
||||||
|
# Module CHỈ dùng stdlib + app.config (không kéo pedalboard/numpy) để import
|
||||||
|
# nhẹ và hoạt động trong mọi tiến trình (web/worker/celery).
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
SF_RUNTIME_ENV = "SF_RUNTIME" # auto | desktop | headless
|
||||||
|
SF_DOCKER_ENV = "SF_DOCKER" # 1 = chạy trong container Docker
|
||||||
|
CARLA_CONFIG_FILE = "carla_path.json" # storage/carla_path.json — user định vị
|
||||||
|
# Bản Carla portable (zip) có thể giải nén ở BẤT KỲ ĐÂU — PATH/Program Files
|
||||||
|
# không đủ. User tự chọn thư mục chứa carla.exe qua Plugin Manager → lưu file
|
||||||
|
# này (ưu tiên cao nhất khi detect), kèm tìm kiếm nông Downloads/Desktop.
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows() -> bool:
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_macos() -> bool:
|
||||||
|
return sys.platform == "darwin"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_linux() -> bool:
|
||||||
|
return not _is_windows() and not _is_macos()
|
||||||
|
|
||||||
|
|
||||||
|
def _in_docker() -> bool:
|
||||||
|
if os.environ.get(SF_DOCKER_ENV) == "1":
|
||||||
|
return True
|
||||||
|
# Marker chuẩn của Docker (không tồn tại trên máy host thường)
|
||||||
|
try:
|
||||||
|
return os.path.exists("/.dockerenv")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_display() -> bool:
|
||||||
|
"""Có môi trường GUI hiển thị được hay không.
|
||||||
|
|
||||||
|
Windows/macOS luôn có (desktop). Linux cần biến DISPLAY (X11) — nếu chạy
|
||||||
|
Docker không có DISPLAY → headless."""
|
||||||
|
if _is_windows() or _is_macos():
|
||||||
|
return True
|
||||||
|
return bool(os.environ.get("DISPLAY"))
|
||||||
|
|
||||||
|
|
||||||
|
def _tauri_bridge_ready() -> bool:
|
||||||
|
"""App desktop Tauri viết marker file lúc setup (xem plugins.py
|
||||||
|
_pick_dir_via_tauri_bridge) — dùng để nhận diện bản desktop app."""
|
||||||
|
try:
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.exists(os.path.join(root, "SonicForgeDAW", "ipc", "tauri_bridge_ready"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_config_path() -> str:
|
||||||
|
return os.path.join(settings.STORAGE_DIR, CARLA_CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_configured_carla() -> str:
|
||||||
|
"""Đường dẫn carla.exe do USER tự định vị (lưu trong storage/carla_path.json).
|
||||||
|
|
||||||
|
File lưu đường dẫn tới carla.exe (đã resolve) hoặc thư mục chứa — nếu là
|
||||||
|
thư mục, tìm carla.exe bên trong (độ sâu ≤ 2). Đây là cách bắt buộc có cho
|
||||||
|
bản Carla portable (zip) giải nén ở vị trí bất kỳ, không cài đặt/PATH."""
|
||||||
|
try:
|
||||||
|
with open(_carla_config_path(), "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
p = (data.get("carla_path") or "").strip()
|
||||||
|
if not p:
|
||||||
|
return ""
|
||||||
|
if os.path.isfile(p) and os.path.basename(p).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||||
|
return p
|
||||||
|
if os.path.isdir(p):
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(p):
|
||||||
|
depth = root[len(p):].count(os.sep)
|
||||||
|
if depth >= 2:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
return os.path.join(root, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_carla_path(path: str) -> str:
|
||||||
|
"""Lưu vị trí Carla do user chọn (Plugin Manager → Định vị Carla...).
|
||||||
|
|
||||||
|
Chấp nhận: đường dẫn tới carla.exe, hoặc thư mục chứa carla.exe (portable
|
||||||
|
zip). Resolve về đường dẫn exe hợp lệ → ghi config → xóa cache detect.
|
||||||
|
Trả đường dẫn exe, hoặc '' nếu không hợp lệ."""
|
||||||
|
path = (path or "").strip().strip('"').strip()
|
||||||
|
exe = ""
|
||||||
|
if os.path.isfile(path) and os.path.basename(path).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||||
|
exe = path
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
depth = root[len(path):].count(os.sep)
|
||||||
|
if depth >= 2:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
exe = os.path.join(root, f)
|
||||||
|
break
|
||||||
|
if exe:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not exe:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
|
with open(_carla_config_path(), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"carla_path": exe, "carla_exe": os.path.basename(exe)}, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
# Xóa cache detect/find_carla để capabilities phản ánh ngay
|
||||||
|
invalidate_carla_cache()
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_from_registry() -> str:
|
||||||
|
"""Windows: Carla cài qua installer có thể ghi registry (best-effort)."""
|
||||||
|
if not _is_windows():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
for hive, key in (
|
||||||
|
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Carla"),
|
||||||
|
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Carla"),
|
||||||
|
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Carla"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, key) as k:
|
||||||
|
val, _ = winreg.QueryValueEx(k, "InstallPath")
|
||||||
|
p = os.path.join(str(val), "carla.exe")
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_carla_exe_bounded(base: str, max_depth: int = 3) -> str:
|
||||||
|
"""Quét nông tìm carla.exe (bản portable giải nén thường nằm Downloads/
|
||||||
|
Desktop/Documents). Giới hạn độ sâu + bỏ qua thư mục lớn — KHÔNG quét
|
||||||
|
toàn ổ đĩa."""
|
||||||
|
if not base or not os.path.isdir(base):
|
||||||
|
return ""
|
||||||
|
skip = ("node_modules", ".cache", "AppData", ".git", "venv", ".venv",
|
||||||
|
"__pycache__", "$RECYCLE.BIN", "Windows", "Program Files",
|
||||||
|
"Program Files (x86)")
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(base):
|
||||||
|
depth = root[len(base):].count(os.sep) if root != base else 0
|
||||||
|
if depth >= max_depth:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
return os.path.join(root, f)
|
||||||
|
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_from_shallow_search() -> str:
|
||||||
|
"""Windows: tìm carla.exe trong Downloads/Desktop/Documents/Home."""
|
||||||
|
if not _is_windows():
|
||||||
|
return ""
|
||||||
|
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
||||||
|
bases = []
|
||||||
|
for sub in ("Downloads", "Desktop", "Documents"):
|
||||||
|
bases.append(os.path.join(home, sub))
|
||||||
|
bases.append(home)
|
||||||
|
for base in bases:
|
||||||
|
hit = _find_carla_exe_bounded(base)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_candidates() -> list:
|
||||||
|
"""Các vị trí Carla — theo thứ tự ưu tiên:
|
||||||
|
1) user định vị (config file — bản portable bắt buộc dùng cách này)
|
||||||
|
2) PATH (shutil.which)
|
||||||
|
3) registry (Windows, nếu cài qua installer)
|
||||||
|
4) thư mục cài đặt chuẩn (Program Files...)
|
||||||
|
5) quét nông Downloads/Desktop/Documents (Windows) / AppImage (Linux)"""
|
||||||
|
cands = []
|
||||||
|
configured = get_configured_carla()
|
||||||
|
if configured:
|
||||||
|
cands.append(configured)
|
||||||
|
try:
|
||||||
|
which = shutil.which("carla")
|
||||||
|
if which:
|
||||||
|
cands.append(which)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
reg = _carla_from_registry()
|
||||||
|
if reg:
|
||||||
|
cands.append(reg)
|
||||||
|
try:
|
||||||
|
if _is_windows():
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
lpf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||||
|
la = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
for base in (pf, lpf, la):
|
||||||
|
for rel in ("Carla\\carla.exe", "Carla\\bin\\carla.exe", "Programs\\Carla\\carla.exe"):
|
||||||
|
p = os.path.join(base, rel) if base else ""
|
||||||
|
if p and os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
elif _is_linux():
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
for p in ("/usr/bin/carla", "/usr/local/bin/carla", "/opt/carla/carla"):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
try:
|
||||||
|
for f in os.listdir(home):
|
||||||
|
if f.lower().startswith("carla") and f.lower().endswith(".appimage"):
|
||||||
|
cands.append(os.path.join(home, f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif _is_macos():
|
||||||
|
for p in ("/Applications/Carla.app/Contents/MacOS/Carla", "/Applications/Carla.app/Contents/MacOS/carla"):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
shallow = _carla_from_shallow_search()
|
||||||
|
if shallow:
|
||||||
|
cands.append(shallow)
|
||||||
|
# Dedup giữ thứ tự
|
||||||
|
seen, out = set(), []
|
||||||
|
for c in cands:
|
||||||
|
if c not in seen:
|
||||||
|
seen.add(c)
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def find_carla() -> str:
|
||||||
|
"""Đường dẫn Carla đầu tiên tìm thấy, hoặc '' nếu chưa có.
|
||||||
|
|
||||||
|
Lưu ý license: Carla GPL-2.0+ — app KHÔNG bundle/nhúng, chỉ spawn tiến
|
||||||
|
trình ngoài (user tự cài/giải nén) → không dính copyleft."""
|
||||||
|
for c in _carla_candidates():
|
||||||
|
return c
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_carla_cache():
|
||||||
|
"""Xóa cache detect/find_carla — gọi sau khi user định vị Carla mới."""
|
||||||
|
find_carla.cache_clear()
|
||||||
|
detect.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_platform() -> str:
|
||||||
|
if _is_windows():
|
||||||
|
return "windows"
|
||||||
|
if _is_macos():
|
||||||
|
return "darwin"
|
||||||
|
return "linux"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_runtime() -> str:
|
||||||
|
"""auto → desktop nếu có GUI (Windows/macOS luôn; Linux cần DISPLAY và
|
||||||
|
không phải docker), ngược lại headless. SF_RUNTIME override thắng."""
|
||||||
|
override = (os.environ.get(SF_RUNTIME_ENV) or "auto").strip().lower()
|
||||||
|
if override in ("desktop", "headless"):
|
||||||
|
return override
|
||||||
|
if _is_windows() or _is_macos():
|
||||||
|
return "desktop"
|
||||||
|
if _in_docker() or not _has_display():
|
||||||
|
return "headless"
|
||||||
|
return "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def default_vst_dirs() -> list:
|
||||||
|
"""Thư mục VST mặc định theo platform (vẫn ưu tiên env VST_DIR).
|
||||||
|
|
||||||
|
Windows: thư mục chuẩn VST3 của hệ thống; user thường khai báo thêm qua
|
||||||
|
Plugins Manager (plugin_dirs.json)."""
|
||||||
|
if settings.VST_DIR and settings.VST_DIR != "/opt/daw_engine/vst3":
|
||||||
|
return [settings.VST_DIR]
|
||||||
|
if _is_windows():
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
return [os.path.join(pf, "Common Files", "VST3"), os.path.join(pf, "VSTPlugins")]
|
||||||
|
if _is_macos():
|
||||||
|
return ["/Library/Audio/Plug-Ins/VST3", os.path.expanduser("~/Library/Audio/Plug-Ins/VST3")]
|
||||||
|
return ["/opt/daw_engine/vst3", os.path.expanduser("~/.vst3")]
|
||||||
|
|
||||||
|
|
||||||
|
def default_soundfont_dirs() -> list:
|
||||||
|
if settings.SOUNDFONT_DIR and settings.SOUNDFONT_DIR != "/opt/daw_engine/soundfonts":
|
||||||
|
return [settings.SOUNDFONT_DIR]
|
||||||
|
if _is_windows():
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return [os.path.join(root, "SonicForgeDAW", "soundfonts")]
|
||||||
|
if _is_macos():
|
||||||
|
return [os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")]
|
||||||
|
return ["/opt/daw_engine/soundfonts", os.path.expanduser("~/.sf2")]
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_ips() -> list:
|
||||||
|
"""IPv4 của máy trên mạng LAN (bỏ loopback) — để client browser ở máy
|
||||||
|
khác mở app qua địa chỉ này. Stdlib only (không kéo thư viện ngoài)."""
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
# IP ra mạng theo default route — không gửi gói tin thật
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Enumerate thêm các interface khác (multi-NIC)
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ips
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def detect() -> dict:
|
||||||
|
"""Detect 1 lần (cache toàn cục) — kết quả bất biến trong 1 tiến trình."""
|
||||||
|
platform = _detect_platform()
|
||||||
|
runtime = _detect_runtime()
|
||||||
|
carla = find_carla() if runtime == "desktop" else ""
|
||||||
|
docker = _in_docker()
|
||||||
|
return {
|
||||||
|
"platform": platform,
|
||||||
|
"runtime": runtime, # "desktop" | "headless"
|
||||||
|
"docker": docker,
|
||||||
|
"environment": "docker" if docker else "standalone", # luồng âm instrument: docker→FluidSynthWASM, standalone→native OS
|
||||||
|
"has_display": _has_display(),
|
||||||
|
"tauri_bridge": _tauri_bridge_ready(),
|
||||||
|
"carla_path": carla,
|
||||||
|
"carla_local": bool(carla), # chỉ có ý nghĩa khi runtime=desktop
|
||||||
|
"vst_render": _vst_render_available(),
|
||||||
|
"default_vst_dirs": default_vst_dirs(),
|
||||||
|
"default_soundfont_dirs": default_soundfont_dirs(),
|
||||||
|
"preset_dir": os.path.join(settings.STORAGE_DIR, "presets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _vst_render_available() -> bool:
|
||||||
|
"""pedalboard có sẵn không (dùng find_spec — KHÔNG import pedalboard để
|
||||||
|
tránh kéo JUCE lib; giống pattern _module_available trong vst_engine)."""
|
||||||
|
import importlib.util
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec("pedalboard") is not None
|
||||||
|
except (ImportError, AttributeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_info() -> dict:
|
||||||
|
"""Địa chỉ + port để client LAN mở app. Port: SF_PORT (desktop engine chọn
|
||||||
|
trước, 8000-8010) hoặc 8000 mặc định."""
|
||||||
|
try:
|
||||||
|
port = int(os.environ.get("SF_PORT") or "8000")
|
||||||
|
except ValueError:
|
||||||
|
port = 8000
|
||||||
|
ips = _lan_ips()
|
||||||
|
return {
|
||||||
|
"ips": ips,
|
||||||
|
"port": port,
|
||||||
|
"urls": [f"http://{ip}:{port}" for ip in ips],
|
||||||
|
}
|
||||||
|
|
||||||
|
def capabilities() -> dict:
|
||||||
|
"""Capabilities API — frontend gọi 1 lần lúc boot để bật/tắt tính năng."""
|
||||||
|
d = detect()
|
||||||
|
features = {
|
||||||
|
"carla_local": d["carla_local"],
|
||||||
|
"carla_path": d["carla_path"],
|
||||||
|
"preset_upload": True, # cả 2 mode đều upload preset được
|
||||||
|
"vst_render": d["vst_render"],
|
||||||
|
"tauri_bridge": d["tauri_bridge"],
|
||||||
|
# preview VSTi: quick_render (pedalboard render clip ngắn — âm thật,
|
||||||
|
# giống export) là chuẩn cho cả 2 mode; không có realtime trong web UI.
|
||||||
|
"preview_mode": "quick_render" if d["vst_render"] else "wasm",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"runtime": d["runtime"],
|
||||||
|
"platform": d["platform"],
|
||||||
|
"docker": d["docker"],
|
||||||
|
"environment": d["environment"],
|
||||||
|
# LAN access (chỉ standalone): URL để client browser ở máy khác trên
|
||||||
|
# mạng mở app. Docker (container) → bỏ qua (IP container không dùng được).
|
||||||
|
**({"lan": _lan_info()} if not d["docker"] else {}),
|
||||||
|
"features": features,
|
||||||
|
"default_dirs": {
|
||||||
|
"vst": d["default_vst_dirs"],
|
||||||
|
"soundfont": d["default_soundfont_dirs"],
|
||||||
|
"preset": d["preset_dir"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_desktop() -> bool:
|
||||||
|
return detect()["runtime"] == "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def is_headless() -> bool:
|
||||||
|
return detect()["runtime"] == "headless"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# python -m app.core.runtime → in capabilities để kiểm tra detect
|
||||||
|
import json
|
||||||
|
print(json.dumps(capabilities(), ensure_ascii=False, indent=2))
|
||||||
@@ -8,7 +8,7 @@ from app.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
|
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
|
||||||
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
|
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
||||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +19,11 @@ def _file_sig(path: str) -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
class SoundFontAutoScanner:
|
class SoundFontAutoScanner:
|
||||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
|
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR,
|
||||||
self.system_sf_dir = system_sf_dir
|
system_sf_dirs=None):
|
||||||
|
# system_sf_dirs (list) — nhiều thư mục user khai báo trong Plugins
|
||||||
|
# Manager. Fallback system_sf_dir (env/.env) nếu list rỗng.
|
||||||
|
self.system_sf_dirs = [d for d in (system_sf_dirs or []) if d] or [system_sf_dir]
|
||||||
self.upload_sf_dir = upload_sf_dir
|
self.upload_sf_dir = upload_sf_dir
|
||||||
self._catalog = {}
|
self._catalog = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -50,6 +53,15 @@ class SoundFontAutoScanner:
|
|||||||
out.append((fname, os.path.join(directory, fname)))
|
out.append((fname, os.path.join(directory, fname)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _all_sf_files(self) -> list:
|
||||||
|
"""Gộp file .sf2/.sf3 từ TẤT CẢ thư mục hiệu lực (user dirs + upload)."""
|
||||||
|
out = []
|
||||||
|
for d in self.system_sf_dirs:
|
||||||
|
out.extend(self._sf_files(d))
|
||||||
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||||
|
out.extend(self._sf_files(self.upload_sf_dir))
|
||||||
|
return out
|
||||||
|
|
||||||
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
|
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
|
||||||
if fname.lower().endswith(".sf2"):
|
if fname.lower().endswith(".sf2"):
|
||||||
sf_info = inspector.inspect_sf2_file(full) or {}
|
sf_info = inspector.inspect_sf2_file(full) or {}
|
||||||
@@ -68,9 +80,9 @@ class SoundFontAutoScanner:
|
|||||||
|
|
||||||
def scan_once(self) -> bool:
|
def scan_once(self) -> bool:
|
||||||
from app.core.soundfont_inspector import SoundFontInspector
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir)
|
inspector = SoundFontInspector(self.system_sf_dirs[0], self.upload_sf_dir)
|
||||||
found_new = False
|
found_new = False
|
||||||
dirs = [(self.system_sf_dir, "system")]
|
dirs = [(d, "system") for d in self.system_sf_dirs]
|
||||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||||
dirs.append((self.upload_sf_dir, "upload"))
|
dirs.append((self.upload_sf_dir, "upload"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import scipy.signal as signal
|
import scipy.signal as signal
|
||||||
import librosa
|
from app.core.audio_features import time_stretch as _time_stretch, pitch_shift as _pitch_shift
|
||||||
|
|
||||||
class SubTabDSPEngine:
|
class SubTabDSPEngine:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
|
|||||||
return y
|
return y
|
||||||
|
|
||||||
if preserve_pitch:
|
if preserve_pitch:
|
||||||
return librosa.effects.time_stretch(y, rate=speed_ratio)
|
return _time_stretch(y, rate=speed_ratio)
|
||||||
else:
|
else:
|
||||||
num_samples_new = int(len(y) / speed_ratio)
|
num_samples_new = int(len(y) / speed_ratio)
|
||||||
return signal.resample(y, num_samples_new)
|
return signal.resample(y, num_samples_new)
|
||||||
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
|
|||||||
"""
|
"""
|
||||||
if n_steps == 0:
|
if n_steps == 0:
|
||||||
return y
|
return y
|
||||||
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
|
return _pitch_shift(y, sr=sr, n_steps=n_steps)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def merge_back_to_parent(
|
def merge_back_to_parent(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# SonicForge Studio VST / VSTi Engine Service
|
# SonicForge Studio VST / VSTi Engine Service
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import functools
|
import functools
|
||||||
from ctypes import c_char_p
|
from ctypes import c_char_p
|
||||||
@@ -7,6 +8,74 @@ from ctypes import c_char_p
|
|||||||
def midi_note_to_freq(note_number: int) -> float:
|
def midi_note_to_freq(note_number: int) -> float:
|
||||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = 0,
|
||||||
|
program: int = 0, sr: int = 44100, bpm: float = 120.0) -> np.ndarray:
|
||||||
|
"""Native FluidSynth (pyfluidsynth) — render MIDI events bằng soundfont THẬT.
|
||||||
|
|
||||||
|
Dùng cho môi trường standalone: âm instrument xử lí trực tiếp trên OS
|
||||||
|
(backend pyfluidsynth), không phải FluidSynthWASM trong browser.
|
||||||
|
Xử lí đúng note CHỒNG nhau + đuôi release (event-stream noteon/noteoff).
|
||||||
|
Trả stereo float32 (2, N). Raise RuntimeError nếu pyfluidsynth thiếu / SF
|
||||||
|
không load được."""
|
||||||
|
import fluidsynth as _fs
|
||||||
|
settings = _fs.new_fluid_settings()
|
||||||
|
_fs.fluid_settings_setnum(settings, b'synth.sample-rate', float(sr))
|
||||||
|
synth = _fs.new_fluid_synth(settings)
|
||||||
|
try:
|
||||||
|
fid = _fs.fluid_synth_sfload(synth, os.fspath(sf_path).encode('utf-8'), 1)
|
||||||
|
if fid == -1:
|
||||||
|
raise RuntimeError(f"SoundFont load failed: {sf_path}")
|
||||||
|
_fs.fluid_synth_program_select(synth, 0, fid, int(bank), int(program))
|
||||||
|
beat_sec = 60.0 / max(30.0, bpm)
|
||||||
|
events = []
|
||||||
|
total_sec = 0.0
|
||||||
|
for ev in midi_events:
|
||||||
|
start_s = int(float(ev.get('start_beat', 0)) * beat_sec * sr)
|
||||||
|
dur_s = int(float(ev.get('duration_beats', 1)) * beat_sec * sr)
|
||||||
|
note = int(ev.get('note', 60))
|
||||||
|
vel = min(127, max(1, int(float(ev.get('velocity', 100)))))
|
||||||
|
events.append((start_s, 'on', note, vel))
|
||||||
|
events.append((start_s + dur_s, 'off', note, 0))
|
||||||
|
end_sec = (start_s + dur_s) / float(sr)
|
||||||
|
if end_sec > total_sec:
|
||||||
|
total_sec = end_sec
|
||||||
|
events.sort(key=lambda e: e[0])
|
||||||
|
total_sec = max(total_sec, 0.25) + 0.5 # đuôi reverb/release
|
||||||
|
n = int(total_sec * sr)
|
||||||
|
out = np.zeros((2, n), dtype=np.float32)
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
def _render_until(target: int):
|
||||||
|
"""Render từ pos tới target, CỘNG vào out (giữ âm đang ngân)."""
|
||||||
|
nonlocal pos
|
||||||
|
target = min(target, n)
|
||||||
|
while pos < target:
|
||||||
|
chunk = min(target - pos, 44100)
|
||||||
|
block = _fs.fluid_synth_write_s16_stereo(synth, chunk)
|
||||||
|
b = block.astype(np.float32).reshape(-1, 2).T / 32768.0
|
||||||
|
take = min(chunk, b.shape[1], target - pos)
|
||||||
|
if take > 0:
|
||||||
|
out[:, pos:pos + take] += b[:, :take]
|
||||||
|
pos += take
|
||||||
|
|
||||||
|
for t, kind, note, vel in events:
|
||||||
|
if t > pos:
|
||||||
|
_render_until(t)
|
||||||
|
if pos >= n:
|
||||||
|
break
|
||||||
|
if kind == 'on':
|
||||||
|
_fs.fluid_synth_noteon(synth, 0, note, vel)
|
||||||
|
else:
|
||||||
|
_fs.fluid_synth_noteoff(synth, 0, note)
|
||||||
|
_render_until(n) # đuôi release của notes cuối
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fs.delete_fluid_synth(synth)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
||||||
beat_duration_sec = 60.0 / max(30.0, bpm)
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
||||||
max_duration_sec = 2.0
|
max_duration_sec = 2.0
|
||||||
@@ -49,27 +118,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
|
|||||||
out_r /= max_peak
|
out_r /= max_peak
|
||||||
return np.vstack([out_l, out_r])
|
return np.vstack([out_l, out_r])
|
||||||
|
|
||||||
def check_pedalboard_safe():
|
def _module_available(name: str) -> bool:
|
||||||
import subprocess, sys
|
"""Kiem tra module co san khong — KHONG spawn subprocess.
|
||||||
|
|
||||||
|
Truoc day dung subprocess.run([sys.executable, '-c', 'import X']) —
|
||||||
|
khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe
|
||||||
|
-> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine)
|
||||||
|
-> moi lan check lai sinh ra engine moi -> de quy spawn vo han
|
||||||
|
(Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham).
|
||||||
|
find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
return importlib.util.find_spec(name) is not None
|
||||||
[sys.executable, "-c", "import pedalboard"],
|
except (ImportError, AttributeError, ValueError):
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
||||||
)
|
|
||||||
return res.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_pedalboard_safe():
|
||||||
|
return _module_available("pedalboard")
|
||||||
|
|
||||||
|
|
||||||
def check_pyfluidsynth_safe():
|
def check_pyfluidsynth_safe():
|
||||||
import subprocess, sys
|
return _module_available("fluidsynth")
|
||||||
try:
|
|
||||||
res = subprocess.run(
|
|
||||||
[sys.executable, "-c", "import fluidsynth"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
||||||
)
|
|
||||||
return res.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
HAS_PEDALBOARD = check_pedalboard_safe()
|
HAS_PEDALBOARD = check_pedalboard_safe()
|
||||||
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
||||||
@@ -82,7 +153,7 @@ def ensure_pyfluidsynth():
|
|||||||
|
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage
|
from pedalboard import VST3Plugin, Pedalboard, Gain
|
||||||
except Exception:
|
except Exception:
|
||||||
HAS_PEDALBOARD = False
|
HAS_PEDALBOARD = False
|
||||||
|
|
||||||
@@ -99,14 +170,38 @@ _PLUGIN_MANAGER_INSTANCE = None
|
|||||||
_PLUGIN_MANAGER_ARGS = None
|
_PLUGIN_MANAGER_ARGS = None
|
||||||
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
||||||
|
|
||||||
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
|
def _load_user_plugin_dirs() -> list:
|
||||||
"""Singleton: reuse PluginManager when args match, else create new."""
|
"""Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
|
||||||
|
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
|
||||||
|
tránh vòng import (plugins.py import vst_engine)."""
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [d for d in (data.get("plugin_dirs") or []) if d]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
|
||||||
|
"""Singleton: reuse PluginManager when args match, else create new.
|
||||||
|
Default dirs từ settings (env/.env/docker-compose hoặc user override).
|
||||||
|
VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt
|
||||||
|
kê được VSTi đã scan và load_vst phải tìm thấy chúng khi render."""
|
||||||
|
from app.config import settings as _st
|
||||||
|
vst_dir = vst_dir or _st.VST_DIR
|
||||||
|
sf_dir = sf_dir or _st.SOUNDFONT_DIR
|
||||||
|
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
|
||||||
|
extra = _load_user_plugin_dirs()
|
||||||
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
||||||
args = (vst_dir, sf_dir, upload_sf_dir)
|
args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
|
||||||
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
||||||
return _PLUGIN_MANAGER_INSTANCE
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
_PLUGIN_MANAGER_ARGS = args
|
_PLUGIN_MANAGER_ARGS = args
|
||||||
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
|
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
|
||||||
return _PLUGIN_MANAGER_INSTANCE
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
|
|
||||||
def load_soundfont_cached(path: str):
|
def load_soundfont_cached(path: str):
|
||||||
@@ -155,21 +250,33 @@ def release_soundfont(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref - 1)
|
_FLUID_CACHE[path] = (fl, ref - 1)
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
|
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None):
|
||||||
self.vst_dir = vst_dir
|
from app.config import settings as _st
|
||||||
self.sf_dir = sf_dir
|
self.vst_dir = vst_dir or _st.VST_DIR
|
||||||
|
self.sf_dir = sf_dir or _st.SOUNDFONT_DIR
|
||||||
self.upload_sf_dir = upload_sf_dir
|
self.upload_sf_dir = upload_sf_dir
|
||||||
|
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
|
||||||
|
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
|
||||||
|
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
|
||||||
|
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
|
||||||
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
||||||
|
|
||||||
def _scan_plugins(self) -> dict:
|
def _scan_plugins(self) -> dict:
|
||||||
plugins = {}
|
plugins = {}
|
||||||
if not os.path.isdir(self.vst_dir):
|
for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
|
||||||
return plugins
|
if not scan_dir or not os.path.isdir(scan_dir):
|
||||||
for root, dirs, files in os.walk(self.vst_dir):
|
continue
|
||||||
|
for root, dirs, files in os.walk(scan_dir):
|
||||||
|
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
|
||||||
|
for d in list(dirs):
|
||||||
|
if d.lower().endswith(".vst3"):
|
||||||
|
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.endswith(".vst3") or file.endswith(".so"):
|
low = file.lower()
|
||||||
|
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
|
||||||
plugin_path = os.path.join(root, file)
|
plugin_path = os.path.join(root, file)
|
||||||
plugin_name = os.path.splitext(file)[0]
|
plugin_name = os.path.splitext(file)[0]
|
||||||
|
if plugin_name not in plugins:
|
||||||
plugins[plugin_name] = plugin_path
|
plugins[plugin_name] = plugin_path
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
@@ -239,8 +346,6 @@ class PluginManager:
|
|||||||
return load_soundfont_cached(path)
|
return load_soundfont_cached(path)
|
||||||
|
|
||||||
def list_soundfont_instruments(self, sf_id: str):
|
def list_soundfont_instruments(self, sf_id: str):
|
||||||
if not ensure_pyfluidsynth():
|
|
||||||
return []
|
|
||||||
if sf_id in _SF_INSTRUMENTS_CACHE:
|
if sf_id in _SF_INSTRUMENTS_CACHE:
|
||||||
return _SF_INSTRUMENTS_CACHE[sf_id]
|
return _SF_INSTRUMENTS_CACHE[sf_id]
|
||||||
search_dirs = []
|
search_dirs = []
|
||||||
@@ -248,12 +353,24 @@ class PluginManager:
|
|||||||
search_dirs.append(self.sf_dir)
|
search_dirs.append(self.sf_dir)
|
||||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
|
||||||
search_dirs.append(self.upload_sf_dir)
|
search_dirs.append(self.upload_sf_dir)
|
||||||
|
# Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn
|
||||||
|
# SoundFont): nếu không có, instrument của soundfont trong thư mục user
|
||||||
|
# không liệt kê được (bug "nhấn tên SF không thấy instrument").
|
||||||
|
for d in (self.extra_vst_dirs or []):
|
||||||
|
if d and os.path.isdir(d) and d not in search_dirs:
|
||||||
|
search_dirs.append(d)
|
||||||
|
presets = []
|
||||||
|
# ── 1) FluidSynth (nếu có lib) — đọc bank/program/name từ engine ──
|
||||||
|
if ensure_pyfluidsynth():
|
||||||
for d in search_dirs:
|
for d in search_dirs:
|
||||||
|
if presets:
|
||||||
|
break
|
||||||
for f in os.listdir(d):
|
for f in os.listdir(d):
|
||||||
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
||||||
continue
|
continue
|
||||||
base = os.path.splitext(f)[0]
|
base = os.path.splitext(f)[0]
|
||||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
if base != sf_id and base != sf_id.replace("sf_", ""):
|
||||||
|
continue
|
||||||
path = os.path.join(d, f)
|
path = os.path.join(d, f)
|
||||||
try:
|
try:
|
||||||
import fluidsynth as _fs
|
import fluidsynth as _fs
|
||||||
@@ -263,10 +380,8 @@ class PluginManager:
|
|||||||
_synth = _fs.new_fluid_synth(_settings)
|
_synth = _fs.new_fluid_synth(_settings)
|
||||||
try:
|
try:
|
||||||
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||||
if fid < 0:
|
if fid >= 0:
|
||||||
continue
|
|
||||||
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||||
presets = []
|
|
||||||
if sfont:
|
if sfont:
|
||||||
for bank in range(0, 2):
|
for bank in range(0, 2):
|
||||||
for prog_num in range(0, 128):
|
for prog_num in range(0, 128):
|
||||||
@@ -286,12 +401,10 @@ class PluginManager:
|
|||||||
presets.append({
|
presets.append({
|
||||||
"bank": bank,
|
"bank": bank,
|
||||||
"program": prog_num,
|
"program": prog_num,
|
||||||
"name": raw.decode("utf-8", errors="replace")
|
"name": raw.decode("utf-8", errors="replace"),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
|
||||||
return presets[:256]
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
_fs.delete_fluid_synth(_synth)
|
_fs.delete_fluid_synth(_synth)
|
||||||
@@ -299,8 +412,30 @@ class PluginManager:
|
|||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback; traceback.print_exc()
|
import traceback; traceback.print_exc()
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
break # đã xử lý file khớp
|
||||||
return []
|
# ── 2) Fallback: sf2utils đọc TRỰC TIẾP file (thuần Python) ──
|
||||||
|
# Không cần libfluidsynth — quan trọng trên Windows khi thiếu DLL.
|
||||||
|
# Đọc preset header (pdta/phdr) → bank/program/name như nhau.
|
||||||
|
if not presets:
|
||||||
|
try:
|
||||||
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
|
insp = SoundFontInspector(system_sf_dir=self.sf_dir, upload_sf_dir=self.upload_sf_dir)
|
||||||
|
for d in search_dirs:
|
||||||
|
if presets:
|
||||||
|
break
|
||||||
|
for f in os.listdir(d):
|
||||||
|
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
||||||
|
continue
|
||||||
|
base = os.path.splitext(f)[0]
|
||||||
|
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||||
|
info = insp.inspect_sf2_file(os.path.join(d, f))
|
||||||
|
if info and info.get("instruments"):
|
||||||
|
presets = info["instruments"]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
presets = []
|
||||||
|
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||||
|
return presets[:256]
|
||||||
|
|
||||||
def list_available(self) -> dict:
|
def list_available(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -316,22 +451,23 @@ class PluginManager:
|
|||||||
if not HAS_PEDALBOARD:
|
if not HAS_PEDALBOARD:
|
||||||
return []
|
return []
|
||||||
beat_duration_sec = 60.0 / max(30.0, bpm)
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
||||||
|
# pedalboard >= 0.9: MIDI messages là tuple (bytes, timestamp_seconds)
|
||||||
|
# (MidiMessage class đã bị bỏ). bytes là raw MIDI event:
|
||||||
|
# 0x9n note_on, 0x8n note_off, 0xB0 control_change, 0xC0 program_change
|
||||||
messages = []
|
messages = []
|
||||||
if bank is not None:
|
if bank is not None:
|
||||||
messages.append(MidiMessage(control_change=0, value=bank, sample_offset=0))
|
messages.append((bytes([0xB0, 0, int(bank) & 0x7F]), 0.0))
|
||||||
if program is not None:
|
if program is not None:
|
||||||
messages.append(MidiMessage(program_change=program, sample_offset=0))
|
messages.append((bytes([0xC0, int(program) & 0x7F]), 0.0))
|
||||||
for ev in midi_events:
|
for ev in midi_events:
|
||||||
note = ev.get("note", 60)
|
note = int(ev.get("note", 60)) & 0x7F
|
||||||
velocity = ev.get("velocity", 100)
|
velocity = max(0, min(127, int(ev.get("velocity", 100))))
|
||||||
start_beat = ev.get("start_beat", 0.0)
|
start_beat = float(ev.get("start_beat", 0.0))
|
||||||
dur_beats = ev.get("duration_beats", 1.0)
|
dur_beats = float(ev.get("duration_beats", 1.0))
|
||||||
start_sec = start_beat * beat_duration_sec
|
start_sec = start_beat * beat_duration_sec
|
||||||
dur_sec = dur_beats * beat_duration_sec
|
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
||||||
sample_offset = int(start_sec * sr)
|
messages.append((bytes([0x90, note, velocity]), start_sec))
|
||||||
end_sample_offset = int((start_sec + dur_sec) * sr)
|
messages.append((bytes([0x80, note, 0]), end_sec))
|
||||||
messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset))
|
|
||||||
messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset))
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -369,3 +505,100 @@ class DecentSamplerManager:
|
|||||||
os.chdir(cwd_before)
|
os.chdir(cwd_before)
|
||||||
|
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preset bridge (Carla ↔ pedalboard) ─────────────────────────────────────
|
||||||
|
# Carla (chạy ngoài, user tự cài — GPL-2.0+ nên app KHÔNG bundle/nhúng) dùng
|
||||||
|
# để mở native GUI VSTi + xuất file preset (.vstpreset từ nút Save của plugin).
|
||||||
|
# File preset nằm trong thư viện storage/presets (upload qua web UI hoặc picker
|
||||||
|
# local trên Windows) → render_engine tải qua apply_preset_to_plugin() khi render.
|
||||||
|
PRESET_EXTENSIONS = (".vstpreset", ".fxp", ".fxb", ".dspreset")
|
||||||
|
|
||||||
|
|
||||||
|
def preset_library_dir() -> str:
|
||||||
|
from app.config import settings as _st
|
||||||
|
d = os.path.join(_st.STORAGE_DIR, "presets")
|
||||||
|
try:
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_preset_path(preset_id_or_path: str) -> str:
|
||||||
|
"""preset_id (tên file trong thư viện) hoặc đường dẫn tuyệt đối → path.
|
||||||
|
|
||||||
|
Trả '' nếu không tìm thấy. Chống path traversal: chỉ chấp nhận tên file
|
||||||
|
(không chứa separator) hoặc đường dẫn tuyệt đối tồn tại."""
|
||||||
|
if not preset_id_or_path:
|
||||||
|
return ""
|
||||||
|
p = preset_id_or_path
|
||||||
|
# Đường dẫn tuyệt đối / tương đối tồn tại → dùng thẳng
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
# id dạng tên file trong thư viện (uuid + ext)
|
||||||
|
if os.path.basename(p) == p:
|
||||||
|
cand = os.path.join(preset_library_dir(), p)
|
||||||
|
if os.path.isfile(cand):
|
||||||
|
return cand
|
||||||
|
# Không có ext → quét theo prefix (uuid.idx → uuid.vstpreset)
|
||||||
|
try:
|
||||||
|
for f in os.listdir(preset_library_dir()):
|
||||||
|
if f.lower().endswith(PRESET_EXTENSIONS) and f.startswith(p + "."):
|
||||||
|
return os.path.join(preset_library_dir(), f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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
|
||||||
|
áp dụng được; KHÔNG raise (render engine chỉ log warning)."""
|
||||||
|
if plugin is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if preset_data_b64:
|
||||||
|
import base64
|
||||||
|
raw = base64.b64decode(preset_data_b64)
|
||||||
|
if hasattr(plugin, "preset_data"):
|
||||||
|
plugin.preset_data = raw # bytes dạng .vstpreset (VST3)
|
||||||
|
return True
|
||||||
|
p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "")
|
||||||
|
if p and hasattr(plugin, "load_preset"):
|
||||||
|
plugin.load_preset(p) # .vstpreset (VST3) / .dspreset (DecentSampler)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -18,6 +18,8 @@ from app.api.v1.ai_proxy import router as ai_proxy_router
|
|||||||
from app.api.v1.ai_presets import router as ai_presets_router
|
from app.api.v1.ai_presets import router as ai_presets_router
|
||||||
from app.api.v1.plugins import router as plugins_router
|
from app.api.v1.plugins import router as plugins_router
|
||||||
from app.api.v1.media import router as media_router
|
from app.api.v1.media import router as media_router
|
||||||
|
from app.api.v1.system import router as system_router
|
||||||
|
from app.api.v1.presets import router as presets_router
|
||||||
from app.core.auth import seed_admin
|
from app.core.auth import seed_admin
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
|
|
||||||
@@ -56,7 +58,22 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mount storage directory (must come before general /static mount)
|
# Mount storage directory (must come before general /static mount)
|
||||||
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
# Bug #1: trước đây mount TOÀN BỘ STORAGE_DIR tại /static/audio — StaticFiles
|
||||||
|
# serve cả dotfile → attacker đọc .secret_key (forge admin token) + sonicforge.db
|
||||||
|
# (mọi user/hash) + temp/autosave.json mà KHÔNG cần auth. Fix: mount riêng từng
|
||||||
|
# thư mục con + chặn dotfile bằng SafeStaticFiles.
|
||||||
|
class SafeStaticFiles(StaticFiles):
|
||||||
|
"""StaticFiles chặn mọi đường dẫn chứa dotfile/.. (404 thay vì serve)."""
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope):
|
||||||
|
parts = path.split("/")
|
||||||
|
if any(p.startswith(".") or p in ("", "..") for p in parts):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
return await super().get_response(path, scope)
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static/audio/uploads", SafeStaticFiles(directory=settings.UPLOADS_DIR), name="uploads")
|
||||||
|
app.mount("/static/audio/processed", SafeStaticFiles(directory=settings.PROCESSED_DIR), name="processed")
|
||||||
# Mount app static files (js, css)
|
# Mount app static files (js, css)
|
||||||
# Dùng settings.APP_DIR (freeze-aware) thay vì os.path.dirname(__file__):
|
# Dùng settings.APP_DIR (freeze-aware) thay vì os.path.dirname(__file__):
|
||||||
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
|
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
|
||||||
@@ -93,6 +110,8 @@ app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
|||||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||||
|
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
||||||
|
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
async function apiRequest(endpoint, options = {}) {
|
async function apiRequest(endpoint, options = {}) {
|
||||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||||
|
// FormData: browser tự đặt Content-Type kèm boundary — không được ép JSON
|
||||||
|
if (options.body instanceof FormData) delete headers['Content-Type'];
|
||||||
const response = await fetch(url, { ...options, headers });
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -43,8 +45,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
|
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
|
||||||
createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }),
|
createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }),
|
||||||
|
|
||||||
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }),
|
saveTempProject: (dataJson, clientId) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson, client_id: clientId || '' }) }),
|
||||||
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
||||||
|
getTempRevision: () => apiRequest('/api/v1/projects/temp/revision', { method: 'GET' }),
|
||||||
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
||||||
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
|
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
|
||||||
@@ -64,9 +67,57 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
|
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
|
||||||
|
|
||||||
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
|
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
|
||||||
|
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
|
||||||
|
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
|
||||||
|
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
|
||||||
|
// Runtime capabilities — frontend gọi lúc boot để biết môi trường
|
||||||
|
// (desktop Windows / docker headless) và bật/tắt tính năng
|
||||||
|
getCapabilities: () => apiRequest('/api/v1/system/capabilities', { method: 'GET' }),
|
||||||
|
// Định vị Carla.exe (bản portable zip không cài đặt/PATH) — lưu config
|
||||||
|
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
|
||||||
|
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
|
||||||
|
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
|
||||||
|
// Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
|
||||||
|
// do app spawn (gọi khi track chuyển từ VSTi sang instrument soundfont)
|
||||||
|
stopCarla: () => apiRequest('/api/v1/plugins/carla-stop', { method: 'POST', body: JSON.stringify({}) }),
|
||||||
|
// Kiểm tra Carla còn sống không (app-spawn) + cổng OSC — quyết định
|
||||||
|
// route MIDI item exclusive qua Carla hay fallback FluidSynth
|
||||||
|
carlaStatus: () => apiRequest('/api/v1/plugins/carla-status', { method: 'GET' }),
|
||||||
|
// Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime
|
||||||
|
carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Quick-render preview VSTi (âm thật = âm export, cùng code path)
|
||||||
|
previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Render MIDI notes → WAV bằng VSTi (pedalboard, âm thật) — trả file_id
|
||||||
|
// để 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) }),
|
||||||
|
// 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) }),
|
||||||
|
// Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard
|
||||||
|
listPresets: () => apiRequest('/api/v1/presets', { method: 'GET' }),
|
||||||
|
uploadPreset: (file, pluginHint) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (pluginHint) fd.append('plugin_hint', pluginHint);
|
||||||
|
return apiRequest('/api/v1/presets/upload', { method: 'POST', body: fd });
|
||||||
|
},
|
||||||
|
deletePreset: (presetId) => apiRequest(`/api/v1/presets/${presetId}`, { method: 'DELETE' }),
|
||||||
|
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
|
||||||
|
// user yêu cầu dùng window explorer, không nhập tay
|
||||||
|
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
|
||||||
|
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
|
||||||
|
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
|
||||||
|
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
|
||||||
|
browseDir: (path) => apiRequest(`/api/v1/media/browse?path=${encodeURIComponent(path)}`, { method: 'GET' }),
|
||||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||||
|
// Native Host Bridge (daw_vst_bridge C++): trạng thái + load asset +
|
||||||
|
// tail bridge.log (E1/E2/E5).
|
||||||
|
bridgeStatus: () => apiRequest('/api/v1/bridge/status', { method: 'GET' }),
|
||||||
|
bridgeLoad: (payload) => apiRequest('/api/v1/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
bridgeLog: (lines = 100) => apiRequest(`/api/v1/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||||
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||||
@@ -77,6 +128,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
|
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
|
||||||
|
|
||||||
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
|
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
|
||||||
|
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
||||||
|
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
|
||||||
|
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
||||||
uploadSoundFont: async (file) => {
|
uploadSoundFont: async (file) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// app/static/js/services/audioRoutingEngine.js
|
||||||
|
// Routes the native bridge audio node into the DAW graph:
|
||||||
|
// bridge node -> track.sfEntry (FX rack chain) -> fader/pan -> masterBus
|
||||||
|
// Falls back to masterBus.input when no per-track sfEntry exists.
|
||||||
|
(function () {
|
||||||
|
var engine = {
|
||||||
|
_connected: false,
|
||||||
|
_trackId: null,
|
||||||
|
|
||||||
|
isConnected: function () { return this._connected; },
|
||||||
|
|
||||||
|
/** bridgeNode = window.BridgeAudioNode; trackCtx = track node ({ sfEntry, gainNode }). */
|
||||||
|
connect: function (bridgeNode, trackCtx, trackId) {
|
||||||
|
if (!bridgeNode || !bridgeNode.getOutputNode) return false;
|
||||||
|
this.disconnect();
|
||||||
|
var dest = null;
|
||||||
|
if (trackCtx && trackCtx.sfEntry) dest = trackCtx.sfEntry;
|
||||||
|
else if (trackCtx && trackCtx.gainNode) dest = trackCtx.gainNode;
|
||||||
|
else if (window.masterBus && window.masterBus.input) dest = window.masterBus.input;
|
||||||
|
if (!dest) {
|
||||||
|
console.warn('[AudioRoutingEngine] no destination (no trackCtx / masterBus)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||||
|
bridgeNode.connect(dest);
|
||||||
|
this._connected = true;
|
||||||
|
this._trackId = trackId || null;
|
||||||
|
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: function () {
|
||||||
|
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||||
|
this._connected = false;
|
||||||
|
this._trackId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.AudioRoutingEngine = engine;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// app/static/js/services/bridgeAudioNode.js
|
||||||
|
// Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them
|
||||||
|
// through a ScriptProcessorNode into the WebAudio graph (track FX / master bus).
|
||||||
|
(function () {
|
||||||
|
var RING_DEPTH = 8; // 8 blocks * 256 samples ~= 46ms anti-underrun
|
||||||
|
var SP_BUFFER = 4096; // ScriptProcessor chunk (16 bridge blocks)
|
||||||
|
var _queue = [];
|
||||||
|
var _spn = null;
|
||||||
|
var _gainNode = null;
|
||||||
|
var _ctx = null;
|
||||||
|
var _initialized = false;
|
||||||
|
|
||||||
|
function _getCtx() {
|
||||||
|
if (_ctx) return _ctx;
|
||||||
|
if (typeof getAudioContext === 'function') _ctx = getAudioContext();
|
||||||
|
else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx;
|
||||||
|
else _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
return _ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(audioCtx) {
|
||||||
|
if (_initialized) return;
|
||||||
|
_ctx = audioCtx || _getCtx();
|
||||||
|
_gainNode = _ctx.createGain();
|
||||||
|
_gainNode.gain.value = 1.0;
|
||||||
|
_spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2);
|
||||||
|
_spn.onaudioprocess = function (e) {
|
||||||
|
var L = e.outputBuffer.getChannelData(0);
|
||||||
|
var R = e.outputBuffer.getChannelData(1);
|
||||||
|
L.fill(0); R.fill(0);
|
||||||
|
if (!_queue.length) return;
|
||||||
|
var f = _queue.shift();
|
||||||
|
L.set(f.l); R.set(f.r);
|
||||||
|
};
|
||||||
|
_spn.connect(_gainNode);
|
||||||
|
_initialized = true;
|
||||||
|
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudio(l, r) {
|
||||||
|
if (!_initialized) init();
|
||||||
|
_queue.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||||
|
if (_queue.length > RING_DEPTH) _queue.shift(); // drop oldest on overrun
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() { _queue = []; }
|
||||||
|
|
||||||
|
function getOutputNode() { return _gainNode; }
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
flush();
|
||||||
|
if (_spn && _gainNode) {
|
||||||
|
try { _spn.disconnect(); } catch (e) {}
|
||||||
|
try { _gainNode.disconnect(); } catch (e) {}
|
||||||
|
}
|
||||||
|
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||||
|
_initialized = false;
|
||||||
|
_spn = null;
|
||||||
|
_gainNode = null;
|
||||||
|
_ctx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.BridgeAudioNode = {
|
||||||
|
init: init,
|
||||||
|
onAudio: onAudio,
|
||||||
|
flush: flush,
|
||||||
|
getOutputNode: getOutputNode,
|
||||||
|
connect: function (dest) { if (_gainNode) _gainNode.connect(dest); },
|
||||||
|
disconnect: disconnect,
|
||||||
|
isReady: function () { return _initialized; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// app/static/js/services/nativeBridgeService.js
|
||||||
|
// JS client for the C++ Native Host Bridge (daw_vst_bridge.exe).
|
||||||
|
// All IPC goes through Tauri commands (Rust writes the shared memory) —
|
||||||
|
// WebView2 JS cannot map Windows shared memory directly.
|
||||||
|
(function () {
|
||||||
|
// Must match native_bridge/include/INativeInstrument.h enum InstrumentType
|
||||||
|
var INSTRUMENT_TYPE = { VST3: 0, VST2: 1, SF2: 2, SF3: 2, SFZ: 3 };
|
||||||
|
|
||||||
|
var service = {
|
||||||
|
isBridgeConnected: false,
|
||||||
|
activeInstrumentType: 'VST3',
|
||||||
|
_audioCb: null,
|
||||||
|
_statusCb: null,
|
||||||
|
_tauri: function () { return window.__TAURI__; },
|
||||||
|
|
||||||
|
/** D10: UI subscribes to bridge connection changes. cb({connected}) or null to clear. */
|
||||||
|
onStatusChange: function (cb) { this._statusCb = cb; },
|
||||||
|
_notifyStatus: function (connected) {
|
||||||
|
this.isBridgeConnected = !!connected;
|
||||||
|
if (this._statusCb) this._statusCb({ connected: !!connected });
|
||||||
|
},
|
||||||
|
|
||||||
|
_init: function () {
|
||||||
|
if (!this._tauri()) {
|
||||||
|
// Dev mode (Linux / plain browser): no Tauri -> log-only, app uses SonicSF.
|
||||||
|
console.log('[BridgeService] Dev mode: no __TAURI__, native bridge disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var self = this;
|
||||||
|
try {
|
||||||
|
window.__TAURI__.event.listen('bridge-audio', function (e) {
|
||||||
|
if (self._audioCb) self._audioCb(e.payload.l, e.payload.r);
|
||||||
|
});
|
||||||
|
window.__TAURI__.event.listen('bridge-down', function () {
|
||||||
|
self._notifyStatus(false);
|
||||||
|
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(false);
|
||||||
|
if (window.AudioRoutingEngine) window.AudioRoutingEngine.disconnect();
|
||||||
|
console.warn('[BridgeService] bridge-down event received.');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] event listen failed:', e);
|
||||||
|
}
|
||||||
|
this.queryStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
queryStatus: async function () {
|
||||||
|
if (!this._tauri()) return { connected: false };
|
||||||
|
try {
|
||||||
|
var s = await window.__TAURI__.core.invoke('bridge_status');
|
||||||
|
this._notifyStatus(!!s.connected);
|
||||||
|
return s;
|
||||||
|
} catch (e) {
|
||||||
|
this._notifyStatus(false);
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. LOAD NEW INSTRUMENT INTO BRIDGE (VST3 / VST2 / SF2 / SF3 / SFZ)
|
||||||
|
* instrumentType: 'VST3' | 'VST2' | 'SF2' | 'SF3' | 'SFZ'
|
||||||
|
* channel: MIDI channel to assign this instrument to (A10 multi-instance).
|
||||||
|
*/
|
||||||
|
loadInstrument: async function (filePath, instrumentType, channel) {
|
||||||
|
this.activeInstrumentType = instrumentType;
|
||||||
|
console.log('[BridgeService] Loading ' + instrumentType + ' asset: ' + filePath + ' ch=' + channel);
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke('load_native_instrument', {
|
||||||
|
path: filePath,
|
||||||
|
instrumentType: INSTRUMENT_TYPE[instrumentType] !== undefined ? INSTRUMENT_TYPE[instrumentType] : 0,
|
||||||
|
channel: channel === undefined ? 0 : channel
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] loadInstrument failed:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. UNIFIED MIDI EVENT DISPATCH
|
||||||
|
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||||
|
* velocity 0..1; sampleOffset in samples within the current audio block.
|
||||||
|
* data2/data3 (A12): CC value / program / PB LSB|MSB.
|
||||||
|
*/
|
||||||
|
dispatchMidiEvent: function (cmd, channel, pitch, velocity, sampleOffset, data2, data3) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
var safeVelocity = Math.floor(Math.min(1.0, Math.max(0.0, velocity)) * 127);
|
||||||
|
var byteCmd;
|
||||||
|
switch (cmd) {
|
||||||
|
case 'CC': byteCmd = 0xB; break;
|
||||||
|
case 'PROGRAM': byteCmd = 0xC; break;
|
||||||
|
case 'PITCH_BEND': byteCmd = 0xE; break;
|
||||||
|
default: byteCmd = cmd === 'NOTE_ON' ? 0x9 : 0x8; break;
|
||||||
|
}
|
||||||
|
window.__TAURI__.core.invoke('push_midi_event', {
|
||||||
|
command: byteCmd,
|
||||||
|
channel: channel,
|
||||||
|
pitch: pitch,
|
||||||
|
velocity: safeVelocity,
|
||||||
|
data2: data2 === undefined ? 0 : data2,
|
||||||
|
data3: data3 === undefined ? 0 : data3,
|
||||||
|
sampleOffset: sampleOffset || 0
|
||||||
|
}).catch(function (e) { console.warn('[BridgeService] push_midi_event:', e); });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. OPEN FLOATING CHILD WINDOW NATIVE GUI
|
||||||
|
*/
|
||||||
|
openNativeGUI: async function (pluginId) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
try {
|
||||||
|
await window.__TAURI__.core.invoke('open_vst_gui', { pluginId: pluginId });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[BridgeService] open_vst_gui:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. TRANSPORT CONTROL ('play' | 'stop' | 'panic' | 'set_position')
|
||||||
|
* playhead: sample position (A13) — used by 'play' and 'set_position'.
|
||||||
|
*/
|
||||||
|
transport: function (kind, playhead) {
|
||||||
|
if (!this._tauri()) return false;
|
||||||
|
var args = { kind: kind };
|
||||||
|
if (playhead !== undefined) args.playhead = playhead;
|
||||||
|
window.__TAURI__.core.invoke('transport_control', args)
|
||||||
|
.catch(function (e) { console.warn('[BridgeService] transport_control:', e); });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. AUDIO SINK: register consumer of PCM frames from the bridge.
|
||||||
|
* cb(l: Float32Array, r: Float32Array)
|
||||||
|
*/
|
||||||
|
onAudio: function (cb) {
|
||||||
|
this._audioCb = cb;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.NativeBridgeService = service;
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { service._init(); });
|
||||||
|
} else {
|
||||||
|
service._init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// SonicForge Runtime service — phát hiện môi trường chạy (desktop Windows /
|
||||||
|
// docker headless) qua /api/v1/system/capabilities, bật/tắt tính năng theo đó.
|
||||||
|
// - data-runtime trên <html>: "desktop" | "headless"
|
||||||
|
// - data-carla="1": có Carla local (hiện nút "Mở trong Carla")
|
||||||
|
// - Phần tử có thuộc tính data-carla-only sẽ bị ẩn khi không có Carla local.
|
||||||
|
// - Thư viện preset (.vstpreset) cache trong SonicRuntime.presets — dùng cho
|
||||||
|
// dropdown gán preset vào track (Carla → pedalboard bridge).
|
||||||
|
window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null, presets: null };
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
function getHeaders() {
|
||||||
|
const token = localStorage.getItem('sonic_token') || '';
|
||||||
|
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
return fetch(window.API_BASE_URL + '/api/v1/system/capabilities')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var c = data && data.success ? data : { features: {} };
|
||||||
|
window.SonicRuntime.capabilities = c;
|
||||||
|
window.SonicRuntime.loaded = true;
|
||||||
|
// environment: "docker" | "standalone" — quyết định luồng âm
|
||||||
|
// instrument: docker → FluidSynthWASM (client), standalone →
|
||||||
|
// native (backend pyfluidsynth / Carla). Không phải docker =
|
||||||
|
// standalone (Windows/Linux/macOS chạy trực tiếp trên OS).
|
||||||
|
window.SonicRuntime.environment = c.environment || (c.docker ? 'docker' : 'standalone');
|
||||||
|
var html = document.documentElement;
|
||||||
|
html.dataset.runtime = c.runtime || 'unknown';
|
||||||
|
html.dataset.platform = c.platform || '';
|
||||||
|
html.dataset.environment = window.SonicRuntime.environment;
|
||||||
|
html.dataset.carla = (c.features && c.features.carla_local) ? '1' : '0';
|
||||||
|
if (c.features && c.features.carla_local === false) {
|
||||||
|
document.querySelectorAll('[data-carla-only]').forEach(function (el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cache sẵn danh sách preset (static, ít thay đổi)
|
||||||
|
listPresets().catch(function () {});
|
||||||
|
return c;
|
||||||
|
})
|
||||||
|
.catch(function () { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPresets() {
|
||||||
|
if (window.SonicRuntime.presets) return Promise.resolve(window.SonicRuntime.presets);
|
||||||
|
return fetch(window.API_BASE_URL + '/api/v1/presets', { headers: getHeaders() })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
window.SonicRuntime.presets = (d && d.presets) || [];
|
||||||
|
return window.SonicRuntime.presets;
|
||||||
|
})
|
||||||
|
.catch(function () { return []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SonicRuntime.load = load;
|
||||||
|
window.SonicRuntime.listPresets = listPresets;
|
||||||
|
window.SonicRuntime.refreshPresets = function () {
|
||||||
|
window.SonicRuntime.presets = null;
|
||||||
|
return window.SonicRuntime.listPresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', load);
|
||||||
|
} else {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── SonicCarlaMidi: cầu nối MIDI từ track ARM → Carla (OSC /Carla/0/note_*) ──
|
||||||
|
// Khi track dùng VSTi (synth_engine.type chứa 'vst') + được ARM + máy có Carla
|
||||||
|
// local → phím bấm trên piano roll / keybed được gửi tới Carla để phát realtime
|
||||||
|
// (Carla standalone bật OSC UDP mặc định cổng 22752).
|
||||||
|
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;
|
||||||
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
|
if (!isArmed) return false;
|
||||||
|
var se = synthEngine || {};
|
||||||
|
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
},
|
||||||
|
// Playback MIDI items: route khi track VSTi + Carla local (không cần ARM —
|
||||||
|
// 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;
|
||||||
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
|
var se = synthEngine || {};
|
||||||
|
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
},
|
||||||
|
noteOn: function (channel, note, velocity) {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_on', note: note, velocity: velocity || 100, channel: channel || 0 }).catch(function () {});
|
||||||
|
},
|
||||||
|
noteOff: function (channel, note) {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_off', note: note, channel: channel || 0 }).catch(function () {});
|
||||||
|
},
|
||||||
|
// Bật note rồi tự tắt sau durMs (preview ngắn)
|
||||||
|
playNote: function (channel, note, velocity, durMs) {
|
||||||
|
this.noteOn(channel, note, velocity);
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50);
|
||||||
|
},
|
||||||
|
// Tắt mọi note đang ngân trong Carla (note_off toàn pitch — dùng khi stop)
|
||||||
|
allNotesOff: function () {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
|
for (var n = 0; n < 128; n++) {
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_off', note: n, channel: ch }).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Unload Carla bridge: tắt âm + terminate tiến trình Carla (app spawn).
|
||||||
|
// Gọi khi track chuyển từ VSTi → instrument soundfont để âm KHÔNG còn
|
||||||
|
// play qua Carla bridge nữa.
|
||||||
|
stopBridge: function () {
|
||||||
|
var self = this;
|
||||||
|
try { self.allNotesOff(); } catch (e) {}
|
||||||
|
if (window.SonicAPI && window.SonicAPI.stopCarla) {
|
||||||
|
return window.SonicAPI.stopCarla().catch(function () {});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,6 +21,15 @@
|
|||||||
let _pendingNoteTimers = [];
|
let _pendingNoteTimers = [];
|
||||||
let _loadedFonts = {};
|
let _loadedFonts = {};
|
||||||
let _activeOscillators = {};
|
let _activeOscillators = {};
|
||||||
|
// Fallback oscillator theo 'channel:pitch' — để stopNote/stopAll dừng được
|
||||||
|
// note khi thả phím (trước đây stopNote không dừng oscillator → âm kêu
|
||||||
|
// liên tục dù đã thả phím khi FluidSynth chưa sẵn sàng / font load fail).
|
||||||
|
let _activeOscillatorsByKey = {};
|
||||||
|
// Epoch chống cache channel STALE: tăng mỗi khi selectInstrument/load
|
||||||
|
// soundfont — channel cache chỉ được tin khi khớp epoch hiện tại, nếu
|
||||||
|
// không → note KẾ TIẾP luôn program_select lại (hết "chọn Synth String
|
||||||
|
// nghe Piano" do cache cũ skip program_select).
|
||||||
|
let _instrumentEpoch = 0;
|
||||||
let _gainNode = null;
|
let _gainNode = null;
|
||||||
let _pendingOutputDestination = null;
|
let _pendingOutputDestination = null;
|
||||||
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
@@ -29,6 +38,13 @@
|
|||||||
let _scheduledNotes = [];
|
let _scheduledNotes = [];
|
||||||
let _loadPromises = {};
|
let _loadPromises = {};
|
||||||
let _sfloadSeq = 0;
|
let _sfloadSeq = 0;
|
||||||
|
// Note đang CHỜ load soundfont (chưa noteon) — 'ch:pitch' → số lần chờ.
|
||||||
|
// Đăng ký TRƯỚC khi load để stopNote/stopAll/panic hủy được note này;
|
||||||
|
// trước đây note deferred bắn TRỄ sau khi thả phím / sau Stop → âm loop
|
||||||
|
// không dừng (keybed preview âm soundfont).
|
||||||
|
let _pendingNoteOns = {};
|
||||||
|
// Tăng mỗi lần stopAll/panic — deferred doNote của generation cũ thành no-op.
|
||||||
|
let _noteGeneration = 0;
|
||||||
|
|
||||||
const getCtx = function () {
|
const getCtx = function () {
|
||||||
if (_audioCtx) {
|
if (_audioCtx) {
|
||||||
@@ -321,6 +337,7 @@
|
|||||||
_sfHandleMap.set(sfId, cachedOk);
|
_sfHandleMap.set(sfId, cachedOk);
|
||||||
_currentSfId = sfId;
|
_currentSfId = sfId;
|
||||||
_loadedFonts[sfId] = true;
|
_loadedFonts[sfId] = true;
|
||||||
|
_instrumentEpoch++;
|
||||||
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
|
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -356,6 +373,7 @@
|
|||||||
_sfHandleMap.set(sfId, sfHandle);
|
_sfHandleMap.set(sfId, sfHandle);
|
||||||
_currentSfId = sfId;
|
_currentSfId = sfId;
|
||||||
_loadedFonts[sfId] = true;
|
_loadedFonts[sfId] = true;
|
||||||
|
_instrumentEpoch++;
|
||||||
console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle);
|
console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -385,9 +403,17 @@
|
|||||||
_engineChMap[engKey] = channel;
|
_engineChMap[engKey] = channel;
|
||||||
}
|
}
|
||||||
var sfHandle = _sfHandleMap.get(sfId);
|
var sfHandle = _sfHandleMap.get(sfId);
|
||||||
|
// ⚠️ FIX âm sai instrument: tăng epoch → channel cache cũ thành
|
||||||
|
// stale → note kế tiếp LUÔN program_select lại (hết "chọn Synth
|
||||||
|
// String nghe Piano" do cache cũ skip).
|
||||||
|
_instrumentEpoch++;
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program);
|
var _sr = _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program);
|
||||||
|
if (_sr !== 0) {
|
||||||
|
// Preset không tồn tại ở bank/program này → thử bank 0
|
||||||
|
try { _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, 0, program); } catch (e2) {}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {}
|
||||||
@@ -397,6 +423,7 @@
|
|||||||
_channels[channel].program = program;
|
_channels[channel].program = program;
|
||||||
_channels[channel].isPercussion = (bank === 128);
|
_channels[channel].isPercussion = (bank === 128);
|
||||||
_channels[channel].sfId = sfId;
|
_channels[channel].sfId = sfId;
|
||||||
|
_channels[channel]._epoch = _instrumentEpoch;
|
||||||
},
|
},
|
||||||
|
|
||||||
controllerChange: function (channel, controller, value) {
|
controllerChange: function (channel, controller, value) {
|
||||||
@@ -468,8 +495,28 @@
|
|||||||
|
|
||||||
stopNote: function (channel, pitch) {
|
stopNote: function (channel, pitch) {
|
||||||
if (channel < 0 || channel > 15) return;
|
if (channel < 0 || channel > 15) return;
|
||||||
if (_initialized && _fluidModule) {
|
|
||||||
var key = channel + ':' + pitch;
|
var key = channel + ':' + pitch;
|
||||||
|
// Hủy note đang CHỜ LOAD soundfont (chưa noteon) — trước đây note
|
||||||
|
// này vẫn bắn TRỄ sau khi thả phím → âm loop không dừng được.
|
||||||
|
if (_pendingNoteOns[key]) delete _pendingNoteOns[key];
|
||||||
|
// ⚠️ FIX: dừng fallback oscillator theo 'channel:pitch' — trước đây
|
||||||
|
// stopNote chỉ noteoff FluidSynth, KHÔNG dừng oscillator (khi WASM
|
||||||
|
// chưa sẵn sàng / font load fail → _playNoteFallback) → âm kêu liên
|
||||||
|
// tục mặc dù đã thả phím (không xử lí noteoff).
|
||||||
|
var _oscIds = _activeOscillatorsByKey[key];
|
||||||
|
if (_oscIds && _oscIds.length) {
|
||||||
|
var _nowT = getCtx().currentTime;
|
||||||
|
_oscIds.slice().forEach(function (oid) {
|
||||||
|
var entry = _activeOscillators[oid];
|
||||||
|
if (entry) {
|
||||||
|
try { entry.gain.gain.cancelScheduledValues(_nowT); entry.gain.gain.setValueAtTime(0, _nowT); } catch (e) {}
|
||||||
|
try { entry.osc.stop(_nowT); } catch (e) {}
|
||||||
|
delete _activeOscillators[oid];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
delete _activeOscillatorsByKey[key];
|
||||||
|
}
|
||||||
|
if (_initialized && _fluidModule) {
|
||||||
var mappedChs = _activeNotes[key];
|
var mappedChs = _activeNotes[key];
|
||||||
if (mappedChs === undefined) mappedChs = [channel];
|
if (mappedChs === undefined) mappedChs = [channel];
|
||||||
for (var i = 0; i < mappedChs.length; i++) {
|
for (var i = 0; i < mappedChs.length; i++) {
|
||||||
@@ -564,7 +611,24 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
||||||
|
// Đăng ký note CHỜ LOAD trước khi load — để stopNote (thả
|
||||||
|
// phím) / stopAll / panic hủy được note này. Trước đây
|
||||||
|
// note deferred vẫn bắn TRỄ sau khi thả phím hoặc sau
|
||||||
|
// Stop → âm loop không dừng với preview MIDI Keyboard.
|
||||||
|
var _pendKey = ch + ':' + midiPitch;
|
||||||
|
var _gen = _noteGeneration;
|
||||||
|
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
|
||||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||||
|
// stopAll/panic chạy trong lúc load → generation đổi → bỏ
|
||||||
|
if (_gen !== _noteGeneration) return;
|
||||||
|
// stopNote (thả phím) đã hủy → KHÔNG bắn note trễ nữa
|
||||||
|
var _pend = _pendingNoteOns[_pendKey];
|
||||||
|
if (_pend) {
|
||||||
|
_pendingNoteOns[_pendKey] = _pend - 1;
|
||||||
|
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
doNote();
|
doNote();
|
||||||
@@ -584,6 +648,7 @@
|
|||||||
// ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
|
// ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
|
||||||
// không → vẫn program_select lại (tránh dùng handle đã unload).
|
// không → vẫn program_select lại (tránh dùng handle đã unload).
|
||||||
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
|
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
|
||||||
|
&& cachedCh._epoch === _instrumentEpoch
|
||||||
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
|
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
|
||||||
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
||||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||||
@@ -618,6 +683,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||||
|
if (_selRet !== 0 && finalBank !== 0) {
|
||||||
|
// Preset không tồn tại ở bank này → thử bank 0
|
||||||
|
// (hết "noteon preset rỗng = âm sai/không đúng")
|
||||||
|
try { _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, 0, finalProg); } catch (e2) {}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||||
@@ -627,6 +697,7 @@
|
|||||||
_channels[ch].bank = finalBank;
|
_channels[ch].bank = finalBank;
|
||||||
_channels[ch].program = finalProg;
|
_channels[ch].program = finalProg;
|
||||||
_channels[ch].sfId = finalSfId;
|
_channels[ch].sfId = finalSfId;
|
||||||
|
_channels[ch]._epoch = _instrumentEpoch;
|
||||||
}
|
}
|
||||||
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
||||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||||
@@ -666,6 +737,9 @@
|
|||||||
panic: function () {
|
panic: function () {
|
||||||
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||||
_scheduledNotes = [];
|
_scheduledNotes = [];
|
||||||
|
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
|
||||||
|
_noteGeneration++;
|
||||||
|
_pendingNoteOns = {};
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
|
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
|
||||||
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
|
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
|
||||||
@@ -681,6 +755,17 @@
|
|||||||
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
// Dừng fallback oscillator (FluidSynth chưa sẵn sàng / font fail)
|
||||||
|
try {
|
||||||
|
var _pctx = getCtx();
|
||||||
|
var _pnow = _pctx.currentTime;
|
||||||
|
Object.values(_activeOscillators).forEach(function (entry) {
|
||||||
|
try { if (entry.gain) { entry.gain.gain.cancelScheduledValues(_pnow); entry.gain.gain.setValueAtTime(0, _pnow); } } catch (e) {}
|
||||||
|
try { if (entry.osc) entry.osc.stop(_pnow); } catch (e) {}
|
||||||
|
});
|
||||||
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeOscillatorsByKey = {};
|
||||||
|
} catch (e) {}
|
||||||
_activeNotes = {};
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -738,11 +823,28 @@
|
|||||||
osc.stop(stopAt);
|
osc.stop(stopAt);
|
||||||
var oscId = note + '_' + Date.now() + '_' + Math.random();
|
var oscId = note + '_' + Date.now() + '_' + Math.random();
|
||||||
_activeOscillators[oscId] = { osc: osc, gain: noteGain };
|
_activeOscillators[oscId] = { osc: osc, gain: noteGain };
|
||||||
setTimeout(function () { delete _activeOscillators[oscId]; }, (stopAt - ctx.currentTime) * 1000 + 100);
|
// Đăng ký theo 'channel:pitch' để stopNote dừng được khi thả phím
|
||||||
|
var oscKey = (channel !== undefined && channel >= 0 && channel < 16) ? (channel + ':' + note) : null;
|
||||||
|
if (oscKey) {
|
||||||
|
if (!_activeOscillatorsByKey[oscKey]) _activeOscillatorsByKey[oscKey] = [];
|
||||||
|
_activeOscillatorsByKey[oscKey].push(oscId);
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
delete _activeOscillators[oscId];
|
||||||
|
if (oscKey && _activeOscillatorsByKey[oscKey]) {
|
||||||
|
var _oi = _activeOscillatorsByKey[oscKey].indexOf(oscId);
|
||||||
|
if (_oi >= 0) _activeOscillatorsByKey[oscKey].splice(_oi, 1);
|
||||||
|
if (_activeOscillatorsByKey[oscKey].length === 0) delete _activeOscillatorsByKey[oscKey];
|
||||||
|
}
|
||||||
|
}, (stopAt - ctx.currentTime) * 1000 + 100);
|
||||||
return osc;
|
return osc;
|
||||||
},
|
},
|
||||||
|
|
||||||
stopAll: function () {
|
stopAll: function () {
|
||||||
|
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành
|
||||||
|
// no-op sau Stop (âm loop không dừng khi preview MIDI Keyboard).
|
||||||
|
_noteGeneration++;
|
||||||
|
_pendingNoteOns = {};
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
||||||
// phòng all_notes_off không có trong WASM exports.
|
// phòng all_notes_off không có trong WASM exports.
|
||||||
@@ -771,6 +873,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeOscillatorsByKey = {};
|
||||||
_activeNotes = {};
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,21 @@
|
|||||||
|
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
let lastGetProjectStateCallback = null;
|
let lastGetProjectStateCallback = null;
|
||||||
|
// ⚠️ FIX (Bug 3): hash-skip — nếu state serialize GIỐNG HỆT bản đã lưu
|
||||||
|
// thì KHÔNG ghi lại (server + localStorage). Chặn ping-pong realtime sync:
|
||||||
|
// client B apply state của A rồi autosave lại → server bump updated_at →
|
||||||
|
// A tưởng mới → apply → ... vô hạn. Bản giống hệt → bỏ qua → hội tụ.
|
||||||
|
let lastSavedJson = '';
|
||||||
|
function getClientId() {
|
||||||
|
try {
|
||||||
|
let c = localStorage.getItem('sonic_client_id');
|
||||||
|
if (!c) {
|
||||||
|
c = 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
|
||||||
|
localStorage.setItem('sonic_client_id', c);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||||
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||||
@@ -69,9 +84,11 @@
|
|||||||
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
if (!state || (!state.tracks && !state.main_session)) return;
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
const dataJson = JSON.stringify(state);
|
const dataJson = JSON.stringify(state);
|
||||||
|
if (dataJson === lastSavedJson) return; // không đổi → không ghi
|
||||||
|
lastSavedJson = dataJson;
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Auto-save temp project warning:", e);
|
console.warn("Auto-save temp project warning:", e);
|
||||||
@@ -85,9 +102,11 @@
|
|||||||
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
if (!state || (!state.tracks && !state.main_session)) return;
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
const dataJson = JSON.stringify(state);
|
const dataJson = JSON.stringify(state);
|
||||||
|
if (dataJson === lastSavedJson) return; // không đổi → không ghi
|
||||||
|
lastSavedJson = dataJson;
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Flush temp project warning:", e);
|
console.warn("Flush temp project warning:", e);
|
||||||
@@ -98,6 +117,7 @@
|
|||||||
exportProjectToSFS,
|
exportProjectToSFS,
|
||||||
importProjectFromSFSFile,
|
importProjectFromSFSFile,
|
||||||
scheduleTempAutoSave,
|
scheduleTempAutoSave,
|
||||||
flushTempAutoSave
|
flushTempAutoSave,
|
||||||
|
getClientId
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// app/static/js/services/unifiedMidiRouter.js
|
||||||
|
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||||
|
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||||
|
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||||
|
(function () {
|
||||||
|
var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
||||||
|
|
||||||
|
var router = {
|
||||||
|
bridgeConnected: false,
|
||||||
|
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||||
|
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||||
|
_channels: {}, // trackId -> { ch, percussion }
|
||||||
|
_nextMelodicIdx: 0,
|
||||||
|
|
||||||
|
setBridgeConnected: function (flag) {
|
||||||
|
this.bridgeConnected = !!flag;
|
||||||
|
},
|
||||||
|
|
||||||
|
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
||||||
|
|
||||||
|
isBridgeActive: function () {
|
||||||
|
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
||||||
|
allocateChannel: function (trackId, isPercussion) {
|
||||||
|
if (this._channels[trackId]) return this._channels[trackId].ch;
|
||||||
|
var ch;
|
||||||
|
if (isPercussion) {
|
||||||
|
ch = 9;
|
||||||
|
} else {
|
||||||
|
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
||||||
|
this._nextMelodicIdx++;
|
||||||
|
}
|
||||||
|
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
||||||
|
return ch;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetChannels: function () {
|
||||||
|
this._channels = {};
|
||||||
|
this._nextMelodicIdx = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||||
|
* channel: MIDI channel (or trackId -> allocateChannel first)
|
||||||
|
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
||||||
|
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
||||||
|
*/
|
||||||
|
pushEvent: function (opts) {
|
||||||
|
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
||||||
|
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
||||||
|
var sampleOffset = opts.sampleOffset || 0;
|
||||||
|
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
||||||
|
if (ch === undefined || ch === null) ch = 0;
|
||||||
|
if (this.isBridgeActive()) {
|
||||||
|
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
||||||
|
panic: function () {
|
||||||
|
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
||||||
|
window.NativeBridgeService.transport('panic');
|
||||||
|
}
|
||||||
|
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.SonicMidiRouter = router;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
(function(){/*
|
||||||
|
Modernizr 3.0.0pre (Custom Build) | MIT
|
||||||
|
*/
|
||||||
|
'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;c<arguments.length;c++)b+="&args[]="+encodeURIComponent(arguments[c]);return"Minified React error #"+a+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}
|
||||||
|
function mb(a,b){Ab(a,b);Ab(a+"Capture",b)}function Ab(a,b){$b[a]=b;for(a=0;a<b.length;a++)cg.add(b[a])}function bj(a){if(Zd.call(dg,a))return!0;if(Zd.call(eg,a))return!1;if(cj.test(a))return dg[a]=!0;eg[a]=!0;return!1}function dj(a,b,c,d){if(null!==c&&0===c.type)return!1;switch(typeof b){case "function":case "symbol":return!0;case "boolean":if(d)return!1;if(null!==c)return!c.acceptsBooleans;a=a.toLowerCase().slice(0,5);return"data-"!==a&&"aria-"!==a;default:return!1}}function ej(a,b,c,d){if(null===
|
||||||
|
b||"undefined"===typeof b||dj(a,b,c,d))return!0;if(d)return!1;if(null!==c)switch(c.type){case 3:return!b;case 4:return!1===b;case 5:return isNaN(b);case 6:return isNaN(b)||1>b}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2<b.length)||"o"!==
|
||||||
|
b[0]&&"O"!==b[0]||"n"!==b[1]&&"N"!==b[1])ej(b,c,e,d)&&(c=null),d||null===e?bj(b)&&(null===c?a.removeAttribute(b):a.setAttribute(b,""+c)):e.mustUseProperty?a[e.propertyName]=null===c?3===e.type?!1:"":c:(b=e.attributeName,d=e.attributeNamespace,null===c?a.removeAttribute(b):(e=e.type,c=3===e||4===e&&!0===c?"":""+c,d?a.setAttributeNS(d,b,c):a.setAttribute(b,c)))}function ac(a){if(null===a||"object"!==typeof a)return null;a=fg&&a[fg]||a["@@iterator"];return"function"===typeof a?a:null}function bc(a,b,
|
||||||
|
c){if(void 0===ae)try{throw Error();}catch(d){ae=(b=d.stack.trim().match(/\n( *(at )?)/))&&b[1]||""}return"\n"+ae+a}function be(a,b){if(!a||ce)return"";ce=!0;var c=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(b)if(b=function(){throw Error();},Object.defineProperty(b.prototype,"props",{set:function(){throw Error();}}),"object"===typeof Reflect&&Reflect.construct){try{Reflect.construct(b,[])}catch(n){var d=n}Reflect.construct(a,[],b)}else{try{b.call()}catch(n){d=n}a.call(b.prototype)}else{try{throw Error();
|
||||||
|
}catch(n){d=n}a()}}catch(n){if(n&&d&&"string"===typeof n.stack){for(var e=n.stack.split("\n"),f=d.stack.split("\n"),g=e.length-1,h=f.length-1;1<=g&&0<=h&&e[g]!==f[h];)h--;for(;1<=g&&0<=h;g--,h--)if(e[g]!==f[h]){if(1!==g||1!==h){do if(g--,h--,0>h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("<anonymous>")&&(k=k.replace("<anonymous>",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a):
|
||||||
|
""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode";
|
||||||
|
case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type;
|
||||||
|
switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";
|
||||||
|
case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===
|
||||||
|
b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker=
|
||||||
|
null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0,
|
||||||
|
value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=
|
||||||
|
c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;
|
||||||
|
c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e<c.length;e++)b["$"+c[e]]=!0;for(c=0;c<a.length;c++)e=b.hasOwnProperty("$"+a[c].value),a[c].selected!==e&&(a[c].selected=e),e&&d&&(a[c].defaultSelected=
|
||||||
|
!0)}else{c=""+Ua(c);b=null;for(e=0;e<a.length;e++){if(a[e].value===c){a[e].selected=!0;d&&(a[e].defaultSelected=!0);return}null!==b||a[e].disabled||(b=a[e])}null!==b&&(b.selected=!0)}}function ne(a,b){if(null!=b.dangerouslySetInnerHTML)throw Error(m(91));return E({},b,{value:void 0,defaultValue:void 0,children:""+a._wrapperState.initialValue})}function ng(a,b){var c=b.value;if(null==c){c=b.children;b=b.defaultValue;if(null!=c){if(null!=b)throw Error(m(92));if(cc(c)){if(1<c.length)throw Error(m(93));
|
||||||
|
c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:Ua(c)}}function og(a,b){var c=Ua(b.value),d=Ua(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function pg(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b)}function qg(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}
|
||||||
|
function oe(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?qg(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function rg(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||dc.hasOwnProperty(a)&&dc[a]?(""+b).trim():b+"px"}function sg(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rg(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}function pe(a,b){if(b){if(ij[a]&&
|
||||||
|
(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(m(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(m(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(m(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(m(62));}}function qe(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;
|
||||||
|
default:return!0}}function re(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}function tg(a){if(a=ec(a)){if("function"!==typeof se)throw Error(m(280));var b=a.stateNode;b&&(b=Rc(b),se(a.stateNode,a.type,b))}}function ug(a){Eb?Fb?Fb.push(a):Fb=[a]:Eb=a}function vg(){if(Eb){var a=Eb,b=Fb;Fb=Eb=null;tg(a);if(b)for(a=0;a<b.length;a++)tg(b[a])}}function wg(a,b,c){if(te)return a(b,c);te=!0;try{return xg(a,b,c)}finally{if(te=
|
||||||
|
!1,null!==Eb||null!==Fb)yg(),vg()}}function fc(a,b){var c=a.stateNode;if(null===c)return null;var d=Rc(c);if(null===d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;
|
||||||
|
if(c&&"function"!==typeof c)throw Error(m(231,b,typeof c));return c}function jj(a,b,c,d,e,f,g,h,k){gc=!1;Sc=null;kj.apply(lj,arguments)}function mj(a,b,c,d,e,f,g,h,k){jj.apply(this,arguments);if(gc){if(gc){var n=Sc;gc=!1;Sc=null}else throw Error(m(198));Tc||(Tc=!0,ue=n)}}function nb(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.flags&4098)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function zg(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,
|
||||||
|
null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Ag(a){if(nb(a)!==a)throw Error(m(188));}function nj(a){var b=a.alternate;if(!b){b=nb(a);if(null===b)throw Error(m(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Ag(e),a;if(f===d)return Ag(e),b;f=f.sibling}throw Error(m(188));}if(c.return!==d.return)c=e,d=f;
|
||||||
|
else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h=f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(m(189));}}if(c.alternate!==d)throw Error(m(190));}if(3!==c.tag)throw Error(m(188));return c.stateNode.current===c?a:b}function Bg(a){a=nj(a);return null!==a?Cg(a):null}function Cg(a){if(5===a.tag||6===a.tag)return a;for(a=a.child;null!==a;){var b=Cg(a);if(null!==b)return b;a=a.sibling}return null}
|
||||||
|
function oj(a,b){if(Ca&&"function"===typeof Ca.onCommitFiberRoot)try{Ca.onCommitFiberRoot(Uc,a,void 0,128===(a.current.flags&128))}catch(c){}}function pj(a){a>>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&
|
||||||
|
4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&
|
||||||
|
(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0<b;)c=31-ta(b),e=1<<c,d|=a[c],b&=~e;return d}function sj(a,b){switch(a){case 1:case 2:case 4:return b+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return b+5E3;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return-1;
|
||||||
|
case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tj(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes;0<f;){var g=31-ta(f),h=1<<g,k=e[g];if(-1===k){if(0===(h&c)||0!==(h&d))e[g]=sj(h,b)}else k<=b&&(a.expiredLanes|=h);f&=~h}}function ve(a){a=a.pendingLanes&-1073741825;return 0!==a?a:a&1073741824?1073741824:0}function Dg(){var a=Wc;Wc<<=1;0===(Wc&4194240)&&(Wc=64);return a}function we(a){for(var b=[],c=0;31>c;c++)b.push(a);
|
||||||
|
return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0<c;){var e=31-ta(c),f=1<<e;b[e]=0;d[e]=-1;a[e]=-1;c&=~f}}function xe(a,b){var c=a.entangledLanes|=b;for(a=a.entanglements;c;){var d=31-ta(c),e=1<<d;e&b|a[d]&
|
||||||
|
b&&(a[d]|=b);c&=~e}}function Eg(a){a&=-a;return 1<a?4<a?0!==(a&268435455)?16:536870912:4:1}function Fg(a,b){switch(a){case "focusin":case "focusout":Va=null;break;case "dragenter":case "dragleave":Wa=null;break;case "mouseover":case "mouseout":Xa=null;break;case "pointerover":case "pointerout":jc.delete(b.pointerId);break;case "gotpointercapture":case "lostpointercapture":kc.delete(b.pointerId)}}function lc(a,b,c,d,e,f){if(null===a||a.nativeEvent!==f)return a={blockedOn:b,domEventName:c,eventSystemFlags:d,
|
||||||
|
nativeEvent:f,targetContainers:[e]},null!==b&&(b=ec(b),null!==b&&Gg(b)),a;a.eventSystemFlags|=d;b=a.targetContainers;null!==e&&-1===b.indexOf(e)&&b.push(e);return a}function vj(a,b,c,d,e){switch(b){case "focusin":return Va=lc(Va,a,b,c,d,e),!0;case "dragenter":return Wa=lc(Wa,a,b,c,d,e),!0;case "mouseover":return Xa=lc(Xa,a,b,c,d,e),!0;case "pointerover":var f=e.pointerId;jc.set(f,lc(jc.get(f)||null,a,b,c,d,e));return!0;case "gotpointercapture":return f=e.pointerId,kc.set(f,lc(kc.get(f)||null,a,b,
|
||||||
|
c,d,e)),!0}return!1}function Hg(a){var b=ob(a.target);if(null!==b){var c=nb(b);if(null!==c)if(b=c.tag,13===b){if(b=zg(c),null!==b){a.blockedOn=b;wj(a.priority,function(){xj(c)});return}}else if(3===b&&c.stateNode.current.memoizedState.isDehydrated){a.blockedOn=3===c.tag?c.stateNode.containerInfo:null;return}}a.blockedOn=null}function Xc(a){if(null!==a.blockedOn)return!1;for(var b=a.targetContainers;0<b.length;){var c=ye(a.domEventName,a.eventSystemFlags,b[0],a.nativeEvent);if(null===c){c=a.nativeEvent;
|
||||||
|
var d=new c.constructor(c.type,c);ze=d;c.target.dispatchEvent(d);ze=null}else return b=ec(c),null!==b&&Gg(b),a.blockedOn=c,!1;b.shift()}return!0}function Ig(a,b,c){Xc(a)&&c.delete(b)}function yj(){Ae=!1;null!==Va&&Xc(Va)&&(Va=null);null!==Wa&&Xc(Wa)&&(Wa=null);null!==Xa&&Xc(Xa)&&(Xa=null);jc.forEach(Ig);kc.forEach(Ig)}function mc(a,b){a.blockedOn===b&&(a.blockedOn=null,Ae||(Ae=!0,Jg(Kg,yj)))}function nc(a){if(0<Yc.length){mc(Yc[0],a);for(var b=1;b<Yc.length;b++){var c=Yc[b];c.blockedOn===a&&(c.blockedOn=
|
||||||
|
null)}}null!==Va&&mc(Va,a);null!==Wa&&mc(Wa,a);null!==Xa&&mc(Xa,a);b=function(b){return mc(b,a)};jc.forEach(b);kc.forEach(b);for(b=0;b<Ya.length;b++)c=Ya[b],c.blockedOn===a&&(c.blockedOn=null);for(;0<Ya.length&&(b=Ya[0],null===b.blockedOn);)Hg(b),null===b.blockedOn&&Ya.shift()}function zj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=1,Be(a,b,c,d)}finally{z=e,Gb.transition=f}}function Aj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=4,Be(a,b,c,d)}finally{z=e,Gb.transition=
|
||||||
|
f}}function Be(a,b,c,d){if(Zc){var e=ye(a,b,c,d);if(null===e)Ce(a,b,d,$c,c),Fg(a,d);else if(vj(e,a,b,c,d))d.stopPropagation();else if(Fg(a,d),b&4&&-1<Bj.indexOf(a)){for(;null!==e;){var f=ec(e);null!==f&&Cj(f);f=ye(a,b,c,d);null===f&&Ce(a,b,d,$c,c);if(f===e)break;e=f}null!==e&&d.stopPropagation()}else Ce(a,b,d,null,c)}}function ye(a,b,c,d){$c=null;a=re(d);a=ob(a);if(null!==a)if(b=nb(a),null===b)a=null;else if(c=b.tag,13===c){a=zg(b);if(null!==a)return a;a=null}else if(3===c){if(b.stateNode.current.memoizedState.isDehydrated)return 3===
|
||||||
|
b.tag?b.stateNode.containerInfo:null;a=null}else b!==a&&(a=null);$c=a;return null}function Lg(a){switch(a){case "cancel":case "click":case "close":case "contextmenu":case "copy":case "cut":case "auxclick":case "dblclick":case "dragend":case "dragstart":case "drop":case "focusin":case "focusout":case "input":case "invalid":case "keydown":case "keypress":case "keyup":case "mousedown":case "mouseup":case "paste":case "pause":case "play":case "pointercancel":case "pointerdown":case "pointerup":case "ratechange":case "reset":case "resize":case "seeked":case "submit":case "touchcancel":case "touchend":case "touchstart":case "volumechange":case "change":case "selectionchange":case "textInput":case "compositionstart":case "compositionend":case "compositionupdate":case "beforeblur":case "afterblur":case "beforeinput":case "blur":case "fullscreenchange":case "focus":case "hashchange":case "popstate":case "select":case "selectstart":return 1;
|
||||||
|
case "drag":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "mousemove":case "mouseout":case "mouseover":case "pointermove":case "pointerout":case "pointerover":case "scroll":case "toggle":case "touchmove":case "wheel":case "mouseenter":case "mouseleave":case "pointerenter":case "pointerleave":return 4;case "message":switch(Dj()){case De:return 1;case Mg:return 4;case ad:case Ej:return 16;case Ng:return 536870912;default:return 16}default:return 16}}function Og(){if(bd)return bd;
|
||||||
|
var a,b=Ee,c=b.length,d,e="value"in Za?Za.value:Za.textContent,f=e.length;for(a=0;a<c&&b[a]===e[a];a++);var g=c-a;for(d=1;d<=g&&b[c-d]===e[f-d];d++);return bd=e.slice(a,1<d?1-d:void 0)}function cd(a){var b=a.keyCode;"charCode"in a?(a=a.charCode,0===a&&13===b&&(a=13)):a=b;10===a&&(a=13);return 32<=a||13===a?a:0}function dd(){return!0}function Pg(){return!1}function ka(a){function b(b,d,e,f,g){this._reactName=b;this._targetInst=e;this.type=d;this.nativeEvent=f;this.target=g;this.currentTarget=null;
|
||||||
|
for(var c in a)a.hasOwnProperty(c)&&(b=a[c],this[c]=b?b(f):f[c]);this.isDefaultPrevented=(null!=f.defaultPrevented?f.defaultPrevented:!1===f.returnValue)?dd:Pg;this.isPropagationStopped=Pg;return this}E(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var a=this.nativeEvent;a&&(a.preventDefault?a.preventDefault():"unknown"!==typeof a.returnValue&&(a.returnValue=!1),this.isDefaultPrevented=dd)},stopPropagation:function(){var a=this.nativeEvent;a&&(a.stopPropagation?a.stopPropagation():
|
||||||
|
"unknown"!==typeof a.cancelBubble&&(a.cancelBubble=!0),this.isPropagationStopped=dd)},persist:function(){},isPersistent:dd});return b}function Fj(a){var b=this.nativeEvent;return b.getModifierState?b.getModifierState(a):(a=Gj[a])?!!b[a]:!1}function Fe(a){return Fj}function Qg(a,b){switch(a){case "keyup":return-1!==Hj.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function Rg(a){a=a.detail;return"object"===typeof a&&
|
||||||
|
"data"in a?a.data:null}function Ij(a,b){switch(a){case "compositionend":return Rg(b);case "keypress":if(32!==b.which)return null;Sg=!0;return Tg;case "textInput":return a=b.data,a===Tg&&Sg?null:a;default:return null}}function Jj(a,b){if(Hb)return"compositionend"===a||!Ge&&Qg(a,b)?(a=Og(),bd=Ee=Za=null,Hb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1<b.char.length)return b.char;if(b.which)return String.fromCharCode(b.which)}return null;
|
||||||
|
case "compositionend":return Ug&&"ko"!==b.locale?null:b.data;default:return null}}function Vg(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return"input"===b?!!Kj[a.type]:"textarea"===b?!0:!1}function Lj(a){if(!Ia)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Wg(a,b,c,d){ug(d);b=ed(b,"onChange");0<b.length&&(c=new He("onChange","change",null,c,d),a.push({event:c,listeners:b}))}function Mj(a){Xg(a,
|
||||||
|
0)}function fd(a){var b=Ib(a);if(jg(b))return a}function Nj(a,b){if("change"===a)return b}function Yg(){oc&&(oc.detachEvent("onpropertychange",Zg),pc=oc=null)}function Zg(a){if("value"===a.propertyName&&fd(pc)){var b=[];Wg(b,pc,a,re(a));wg(Mj,b)}}function Oj(a,b,c){"focusin"===a?(Yg(),oc=b,pc=c,oc.attachEvent("onpropertychange",Zg)):"focusout"===a&&Yg()}function Pj(a,b){if("selectionchange"===a||"keyup"===a||"keydown"===a)return fd(pc)}function Qj(a,b){if("click"===a)return fd(b)}function Rj(a,b){if("input"===
|
||||||
|
a||"change"===a)return fd(b)}function Sj(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}function qc(a,b){if(ua(a,b))return!0;if("object"!==typeof a||null===a||"object"!==typeof b||null===b)return!1;var c=Object.keys(a),d=Object.keys(b);if(c.length!==d.length)return!1;for(d=0;d<c.length;d++){var e=c[d];if(!Zd.call(b,e)||!ua(a[e],b[e]))return!1}return!0}function $g(a){for(;a&&a.firstChild;)a=a.firstChild;return a}function ah(a,b){var c=$g(a);a=0;for(var d;c;){if(3===c.nodeType){d=a+c.textContent.length;
|
||||||
|
if(a<=b&&d>=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;
|
||||||
|
b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);
|
||||||
|
else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),
|
||||||
|
a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c<b.length;c++)a=b[c],a.element.scrollLeft=a.left,a.element.scrollTop=a.top}}function dh(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Je||null==Jb||Jb!==Qc(d)||(d=Jb,"selectionStart"in d&&Ie(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d=
|
||||||
|
{anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),rc&&qc(rc,d)||(rc=d,d=ed(Ke,"onSelect"),0<d.length&&(b=new He("onSelect","select",null,b,c),a.push({event:b,listeners:d}),b.target=Jb)))}function gd(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function hd(a){if(Le[a])return Le[a];if(!Kb[a])return a;var b=Kb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in eh)return Le[a]=b[c];return a}function $a(a,
|
||||||
|
b){fh.set(a,b);mb(b,[a])}function gh(a,b,c){var d=a.type||"unknown-event";a.currentTarget=c;mj(d,b,void 0,a);a.currentTarget=null}function Xg(a,b){b=0!==(b&4);for(var c=0;c<a.length;c++){var d=a[c],e=d.event;d=d.listeners;a:{var f=void 0;if(b)for(var g=d.length-1;0<=g;g--){var h=d[g],k=h.instance,n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;gh(e,h,n);f=k}else for(g=0;g<d.length;g++){h=d[g];k=h.instance;n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;
|
||||||
|
gh(e,h,n);f=k}}}if(Tc)throw a=ue,Tc=!1,ue=null,a;}function B(a,b){var c=b[Me];void 0===c&&(c=b[Me]=new Set);var d=a+"__bubble";c.has(d)||(hh(b,a,2,!1),c.add(d))}function Ne(a,b,c){var d=0;b&&(d|=4);hh(c,a,d,b)}function sc(a){if(!a[id]){a[id]=!0;cg.forEach(function(b){"selectionchange"!==b&&(Uj.has(b)||Ne(b,!1,a),Ne(b,!0,a))});var b=9===a.nodeType?a:a.ownerDocument;null===b||b[id]||(b[id]=!0,Ne("selectionchange",!1,b))}}function hh(a,b,c,d,e){switch(Lg(b)){case 1:e=zj;break;case 4:e=Aj;break;default:e=
|
||||||
|
Be}c=e.bind(null,b,c,a);e=void 0;!Oe||"touchstart"!==b&&"touchmove"!==b&&"wheel"!==b||(e=!0);d?void 0!==e?a.addEventListener(b,c,{capture:!0,passive:e}):a.addEventListener(b,c,!0):void 0!==e?a.addEventListener(b,c,{passive:e}):a.addEventListener(b,c,!1)}function Ce(a,b,c,d,e){var f=d;if(0===(b&1)&&0===(b&2)&&null!==d)a:for(;;){if(null===d)return;var g=d.tag;if(3===g||4===g){var h=d.stateNode.containerInfo;if(h===e||8===h.nodeType&&h.parentNode===e)break;if(4===g)for(g=d.return;null!==g;){var k=g.tag;
|
||||||
|
if(3===k||4===k)if(k=g.stateNode.containerInfo,k===e||8===k.nodeType&&k.parentNode===e)return;g=g.return}for(;null!==h;){g=ob(h);if(null===g)return;k=g.tag;if(5===k||6===k){d=f=g;continue a}h=h.parentNode}}d=d.return}wg(function(){var d=f,e=re(c),g=[];a:{var h=fh.get(a);if(void 0!==h){var k=He,m=a;switch(a){case "keypress":if(0===cd(c))break a;case "keydown":case "keyup":k=Vj;break;case "focusin":m="focus";k=Pe;break;case "focusout":m="blur";k=Pe;break;case "beforeblur":case "afterblur":k=Pe;break;
|
||||||
|
case "click":if(2===c.button)break a;case "auxclick":case "dblclick":case "mousedown":case "mousemove":case "mouseup":case "mouseout":case "mouseover":case "contextmenu":k=ih;break;case "drag":case "dragend":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "dragstart":case "drop":k=Wj;break;case "touchcancel":case "touchend":case "touchmove":case "touchstart":k=Xj;break;case jh:case kh:case lh:k=Yj;break;case mh:k=Zj;break;case "scroll":k=ak;break;case "wheel":k=bk;break;case "copy":case "cut":case "paste":k=
|
||||||
|
ck;break;case "gotpointercapture":case "lostpointercapture":case "pointercancel":case "pointerdown":case "pointermove":case "pointerout":case "pointerover":case "pointerup":k=nh}var l=0!==(b&4),p=!l&&"scroll"===a,w=l?null!==h?h+"Capture":null:h;l=[];for(var A=d,t;null!==A;){t=A;var M=t.stateNode;5===t.tag&&null!==M&&(t=M,null!==w&&(M=fc(A,w),null!=M&&l.push(tc(A,M,t))));if(p)break;A=A.return}0<l.length&&(h=new k(h,m,null,c,e),g.push({event:h,listeners:l}))}}if(0===(b&7)){a:{h="mouseover"===a||"pointerover"===
|
||||||
|
a;k="mouseout"===a||"pointerout"===a;if(h&&c!==ze&&(m=c.relatedTarget||c.fromElement)&&(ob(m)||m[Ja]))break a;if(k||h){h=e.window===e?e:(h=e.ownerDocument)?h.defaultView||h.parentWindow:window;if(k){if(m=c.relatedTarget||c.toElement,k=d,m=m?ob(m):null,null!==m&&(p=nb(m),m!==p||5!==m.tag&&6!==m.tag))m=null}else k=null,m=d;if(k!==m){l=ih;M="onMouseLeave";w="onMouseEnter";A="mouse";if("pointerout"===a||"pointerover"===a)l=nh,M="onPointerLeave",w="onPointerEnter",A="pointer";p=null==k?h:Ib(k);t=null==
|
||||||
|
m?h:Ib(m);h=new l(M,A+"leave",k,c,e);h.target=p;h.relatedTarget=t;M=null;ob(e)===d&&(l=new l(w,A+"enter",m,c,e),l.target=t,l.relatedTarget=p,M=l);p=M;if(k&&m)b:{l=k;w=m;A=0;for(t=l;t;t=Lb(t))A++;t=0;for(M=w;M;M=Lb(M))t++;for(;0<A-t;)l=Lb(l),A--;for(;0<t-A;)w=Lb(w),t--;for(;A--;){if(l===w||null!==w&&l===w.alternate)break b;l=Lb(l);w=Lb(w)}l=null}else l=null;null!==k&&oh(g,h,k,l,!1);null!==m&&null!==p&&oh(g,p,m,l,!0)}}}a:{h=d?Ib(d):window;k=h.nodeName&&h.nodeName.toLowerCase();if("select"===k||"input"===
|
||||||
|
k&&"file"===h.type)var ma=Nj;else if(Vg(h))if(ph)ma=Rj;else{ma=Pj;var va=Oj}else(k=h.nodeName)&&"input"===k.toLowerCase()&&("checkbox"===h.type||"radio"===h.type)&&(ma=Qj);if(ma&&(ma=ma(a,d))){Wg(g,ma,c,e);break a}va&&va(a,h,d);"focusout"===a&&(va=h._wrapperState)&&va.controlled&&"number"===h.type&&me(h,"number",h.value)}va=d?Ib(d):window;switch(a){case "focusin":if(Vg(va)||"true"===va.contentEditable)Jb=va,Ke=d,rc=null;break;case "focusout":rc=Ke=Jb=null;break;case "mousedown":Je=!0;break;case "contextmenu":case "mouseup":case "dragend":Je=
|
||||||
|
!1;dh(g,c,e);break;case "selectionchange":if(dk)break;case "keydown":case "keyup":dh(g,c,e)}var ab;if(Ge)b:{switch(a){case "compositionstart":var da="onCompositionStart";break b;case "compositionend":da="onCompositionEnd";break b;case "compositionupdate":da="onCompositionUpdate";break b}da=void 0}else Hb?Qg(a,c)&&(da="onCompositionEnd"):"keydown"===a&&229===c.keyCode&&(da="onCompositionStart");da&&(Ug&&"ko"!==c.locale&&(Hb||"onCompositionStart"!==da?"onCompositionEnd"===da&&Hb&&(ab=Og()):(Za=e,Ee=
|
||||||
|
"value"in Za?Za.value:Za.textContent,Hb=!0)),va=ed(d,da),0<va.length&&(da=new qh(da,a,null,c,e),g.push({event:da,listeners:va}),ab?da.data=ab:(ab=Rg(c),null!==ab&&(da.data=ab))));if(ab=ek?Ij(a,c):Jj(a,c))d=ed(d,"onBeforeInput"),0<d.length&&(e=new fk("onBeforeInput","beforeinput",null,c,e),g.push({event:e,listeners:d}),e.data=ab)}Xg(g,b)})}function tc(a,b,c){return{instance:a,listener:b,currentTarget:c}}function ed(a,b){for(var c=b+"Capture",d=[];null!==a;){var e=a,f=e.stateNode;5===e.tag&&null!==
|
||||||
|
f&&(e=f,f=fc(a,c),null!=f&&d.unshift(tc(a,f,e)),f=fc(a,b),null!=f&&d.push(tc(a,f,e)));a=a.return}return d}function Lb(a){if(null===a)return null;do a=a.return;while(a&&5!==a.tag);return a?a:null}function oh(a,b,c,d,e){for(var f=b._reactName,g=[];null!==c&&c!==d;){var h=c,k=h.alternate,n=h.stateNode;if(null!==k&&k===d)break;5===h.tag&&null!==n&&(h=n,e?(k=fc(c,f),null!=k&&g.unshift(tc(c,k,h))):e||(k=fc(c,f),null!=k&&g.push(tc(c,k,h))));c=c.return}0!==g.length&&a.push({event:b,listeners:g})}function rh(a){return("string"===
|
||||||
|
typeof a?a:""+a).replace(gk,"\n").replace(hk,"")}function jd(a,b,c,d){b=rh(b);if(rh(a)!==b&&c)throw Error(m(425));}function kd(){}function Qe(a,b){return"textarea"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function ik(a){setTimeout(function(){throw a;})}function Re(a,b){var c=b,d=0;do{var e=c.nextSibling;a.removeChild(c);if(e&&8===e.nodeType)if(c=
|
||||||
|
e.data,"/$"===c){if(0===d){a.removeChild(e);nc(b);return}d--}else"$"!==c&&"$?"!==c&&"$!"!==c||d++;c=e}while(c);nc(b)}function Ka(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break;if(8===b){b=a.data;if("$"===b||"$!"===b||"$?"===b)break;if("/$"===b)return null}}return a}function sh(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if("$"===c||"$!"===c||"$?"===c){if(0===b)return a;b--}else"/$"===c&&b++}a=a.previousSibling}return null}function ob(a){var b=a[Da];
|
||||||
|
if(b)return b;for(var c=a.parentNode;c;){if(b=c[Ja]||c[Da]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=sh(a);null!==a;){if(c=a[Da])return c;a=sh(a)}return b}a=c;c=a.parentNode}return null}function ec(a){a=a[Da]||a[Ja];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Ib(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(m(33));}function Rc(a){return a[uc]||null}function bb(a){return{current:a}}function v(a,b){0>Mb||(a.current=Se[Mb],Se[Mb]=null,Mb--)}
|
||||||
|
function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168));
|
||||||
|
y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S);
|
||||||
|
y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a<c.length;a++){var d=c[a];do d=d(!0);while(null!==d)}La=null;md=!1}catch(e){throw null!==La&&(La=La.slice(a+1)),xh(De,db),e;}finally{z=b,Te=!1}}return null}function qb(a,b){Ob[Pb++]=nd;Ob[Pb++]=od;od=a;nd=b}function yh(a,b,c){na[oa++]=Ma;na[oa++]=Na;na[oa++]=rb;rb=a;var d=Ma;a=Na;var e=32-ta(d)-1;d&=~(1<<e);c+=1;var f=32-ta(b)+e;if(30<f){var g=e-e%5;
|
||||||
|
f=(d&(1<<g)-1).toString(32);d>>=g;e-=g;Ma=1<<32-ta(b)+e|c<<e|d;Na=f+a}else Ma=1<<f|c<<e|d,Na=a}function Ue(a){null!==a.return&&(qb(a,1),yh(a,1,0))}function Ve(a){for(;a===od;)od=Ob[--Pb],Ob[Pb]=null,nd=Ob[--Pb],Ob[Pb]=null;for(;a===rb;)rb=na[--oa],na[oa]=null,Na=na[--oa],na[oa]=null,Ma=na[--oa],na[oa]=null}function zh(a,b){var c=pa(5,null,null,0);c.elementType="DELETED";c.stateNode=b;c.return=a;b=a.deletions;null===b?(a.deletions=[c],a.flags|=16):b.push(c)}function Ah(a,b){switch(a.tag){case 5:var c=
|
||||||
|
a.type;b=1!==b.nodeType||c.toLowerCase()!==b.nodeName.toLowerCase()?null:b;return null!==b?(a.stateNode=b,la=a,fa=Ka(b.firstChild),!0):!1;case 6:return b=""===a.pendingProps||3!==b.nodeType?null:b,null!==b?(a.stateNode=b,la=a,fa=null,!0):!1;case 13:return b=8!==b.nodeType?null:b,null!==b?(c=null!==rb?{id:Ma,overflow:Na}:null,a.memoizedState={dehydrated:b,treeContext:c,retryLane:1073741824},c=pa(18,null,null,0),c.stateNode=b,c.return=a,a.child=c,la=a,fa=null,!0):!1;default:return!1}}function We(a){return 0!==
|
||||||
|
(a.mode&1)&&0===(a.flags&128)}function Xe(a){if(D){var b=fa;if(b){var c=b;if(!Ah(a,b)){if(We(a))throw Error(m(418));b=Ka(c.nextSibling);var d=la;b&&Ah(a,b)?zh(d,c):(a.flags=a.flags&-4097|2,D=!1,la=a)}}else{if(We(a))throw Error(m(418));a.flags=a.flags&-4097|2;D=!1;la=a}}}function Bh(a){for(a=a.return;null!==a&&5!==a.tag&&3!==a.tag&&13!==a.tag;)a=a.return;la=a}function pd(a){if(a!==la)return!1;if(!D)return Bh(a),D=!0,!1;var b;(b=3!==a.tag)&&!(b=5!==a.tag)&&(b=a.type,b="head"!==b&&"body"!==b&&!Qe(a.type,
|
||||||
|
a.memoizedProps));if(b&&(b=fa)){if(We(a)){for(a=fa;a;)a=Ka(a.nextSibling);throw Error(m(418));}for(;b;)zh(a,b),b=Ka(b.nextSibling)}Bh(a);if(13===a.tag){a=a.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(m(317));a:{a=a.nextSibling;for(b=0;a;){if(8===a.nodeType){var c=a.data;if("/$"===c){if(0===b){fa=Ka(a.nextSibling);break a}b--}else"$"!==c&&"$!"!==c&&"$?"!==c||b++}a=a.nextSibling}fa=null}}else fa=la?Ka(a.stateNode.nextSibling):null;return!0}function Qb(){fa=la=null;D=!1}function Ye(a){null===
|
||||||
|
wa?wa=[a]:wa.push(a)}function vc(a,b,c){a=c.ref;if(null!==a&&"function"!==typeof a&&"object"!==typeof a){if(c._owner){c=c._owner;if(c){if(1!==c.tag)throw Error(m(309));var d=c.stateNode}if(!d)throw Error(m(147,a));var e=d,f=""+a;if(null!==b&&null!==b.ref&&"function"===typeof b.ref&&b.ref._stringRef===f)return b.ref;b=function(a){var b=e.refs;null===a?delete b[f]:b[f]=a};b._stringRef=f;return b}if("string"!==typeof a)throw Error(m(284));if(!c._owner)throw Error(m(290,a));}return a}function qd(a,b){a=
|
||||||
|
Object.prototype.toString.call(b);throw Error(m(31,"[object Object]"===a?"object with keys {"+Object.keys(b).join(", ")+"}":a));}function Ch(a){var b=a._init;return b(a._payload)}function Dh(a){function b(b,c){if(a){var d=b.deletions;null===d?(b.deletions=[c],b.flags|=16):d.push(c)}}function c(c,d){if(!a)return null;for(;null!==d;)b(c,d),d=d.sibling;return null}function d(a,b){for(a=new Map;null!==b;)null!==b.key?a.set(b.key,b):a.set(b.index,b),b=b.sibling;return a}function e(a,b){a=eb(a,b);a.index=
|
||||||
|
0;a.sibling=null;return a}function f(b,c,d){b.index=d;if(!a)return b.flags|=1048576,c;d=b.alternate;if(null!==d)return d=d.index,d<c?(b.flags|=2,c):d;b.flags|=2;return c}function g(b){a&&null===b.alternate&&(b.flags|=2);return b}function h(a,b,c,d){if(null===b||6!==b.tag)return b=Ze(c,a.mode,d),b.return=a,b;b=e(b,c);b.return=a;return b}function k(a,b,c,d){var f=c.type;if(f===Bb)return l(a,b,c.props.children,d,c.key);if(null!==b&&(b.elementType===f||"object"===typeof f&&null!==f&&f.$$typeof===Ta&&
|
||||||
|
Ch(f)===b.type))return d=e(b,c.props),d.ref=vc(a,b,c),d.return=a,d;d=rd(c.type,c.key,c.props,null,a.mode,d);d.ref=vc(a,b,c);d.return=a;return d}function n(a,b,c,d){if(null===b||4!==b.tag||b.stateNode.containerInfo!==c.containerInfo||b.stateNode.implementation!==c.implementation)return b=$e(c,a.mode,d),b.return=a,b;b=e(b,c.children||[]);b.return=a;return b}function l(a,b,c,d,f){if(null===b||7!==b.tag)return b=sb(c,a.mode,d,f),b.return=a,b;b=e(b,c);b.return=a;return b}function u(a,b,c){if("string"===
|
||||||
|
typeof b&&""!==b||"number"===typeof b)return b=Ze(""+b,a.mode,c),b.return=a,b;if("object"===typeof b&&null!==b){switch(b.$$typeof){case sd:return c=rd(b.type,b.key,b.props,null,a.mode,c),c.ref=vc(a,null,b),c.return=a,c;case Cb:return b=$e(b,a.mode,c),b.return=a,b;case Ta:var d=b._init;return u(a,d(b._payload),c)}if(cc(b)||ac(b))return b=sb(b,a.mode,c,null),b.return=a,b;qd(a,b)}return null}function r(a,b,c,d){var e=null!==b?b.key:null;if("string"===typeof c&&""!==c||"number"===typeof c)return null!==
|
||||||
|
e?null:h(a,b,""+c,d);if("object"===typeof c&&null!==c){switch(c.$$typeof){case sd:return c.key===e?k(a,b,c,d):null;case Cb:return c.key===e?n(a,b,c,d):null;case Ta:return e=c._init,r(a,b,e(c._payload),d)}if(cc(c)||ac(c))return null!==e?null:l(a,b,c,d,null);qd(a,c)}return null}function p(a,b,c,d,e){if("string"===typeof d&&""!==d||"number"===typeof d)return a=a.get(c)||null,h(b,a,""+d,e);if("object"===typeof d&&null!==d){switch(d.$$typeof){case sd:return a=a.get(null===d.key?c:d.key)||null,k(b,a,d,
|
||||||
|
e);case Cb:return a=a.get(null===d.key?c:d.key)||null,n(b,a,d,e);case Ta:var f=d._init;return p(a,b,c,f(d._payload),e)}if(cc(d)||ac(d))return a=a.get(c)||null,l(b,a,d,e,null);qd(b,d)}return null}function x(e,g,h,k){for(var n=null,m=null,l=g,t=g=0,q=null;null!==l&&t<h.length;t++){l.index>t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t<
|
||||||
|
h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);t<h.length;t++)q=p(l,e,t,h[t],k),null!==q&&(a&&null!==q.alternate&&l.delete(null===q.key?t:q.key),g=f(q,g,t),null===m?n=q:m.sibling=q,m=q);a&&l.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function I(e,g,h,k){var n=ac(h);if("function"!==typeof n)throw Error(m(150));h=n.call(h);if(null==h)throw Error(m(151));for(var l=n=null,q=g,t=g=0,A=null,w=h.next();null!==q&&!w.done;t++,w=
|
||||||
|
h.next()){q.index>t?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling=
|
||||||
|
w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a,
|
||||||
|
n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a;
|
||||||
|
a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=
|
||||||
|
b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved;
|
||||||
|
null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue=
|
||||||
|
{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b=
|
||||||
|
b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,
|
||||||
|
shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l=
|
||||||
|
n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=
|
||||||
|
[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a,
|
||||||
|
b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;b<a.length;b++){var d=a[b],e=d.callback;if(null!==e){d.callback=null;d=c;if("function"!==typeof e)throw Error(m(191,e));e.call(d)}}}function ub(a){if(a===wc)throw Error(m(174));return a}function gf(a,b){y(xc,b);y(yc,a);y(Ea,wc);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:oe(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=oe(b,a)}v(Ea);y(Ea,b)}function Tb(a){v(Ea);v(yc);v(xc)}function Ih(a){ub(xc.current);
|
||||||
|
var b=ub(Ea.current);var c=oe(b,a.type);b!==c&&(y(yc,a),y(Ea,c))}function hf(a){yc.current===a&&(v(Ea),v(yc))}function xd(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=
|
||||||
|
b.return;b=b.sibling}return null}function jf(){for(var a=0;a<kf.length;a++)kf[a]._workInProgressVersionPrimary=null;kf.length=0}function V(){throw Error(m(321));}function lf(a,b){if(null===b)return!1;for(var c=0;c<b.length&&c<a.length;c++)if(!ua(a[c],b[c]))return!1;return!0}function mf(a,b,c,d,e,f){vb=f;C=b;b.memoizedState=null;b.updateQueue=null;b.lanes=0;yd.current=null===a||null===a.memoizedState?lk:mk;a=c(d,e);if(zc){f=0;do{zc=!1;Ac=0;if(25<=f)throw Error(m(301));f+=1;N=K=null;b.updateQueue=null;
|
||||||
|
yd.current=nk;a=c(d,e)}while(zc)}yd.current=zd;b=null!==K&&null!==K.next;vb=0;N=K=C=null;Ad=!1;if(b)throw Error(m(300));return a}function nf(){var a=0!==Ac;Ac=0;return a}function Fa(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===N?C.memoizedState=N=a:N=N.next=a;return N}function sa(){if(null===K){var a=C.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===N?C.memoizedState:N.next;if(null!==b)N=b,K=a;else{if(null===a)throw Error(m(310));K=a;
|
||||||
|
a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===N?C.memoizedState=N=a:N=N.next=a}return N}function Bc(a,b){return"function"===typeof b?b(a):b}function of(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){f=e.next;d=d.baseState;var h=g=null,k=null,n=f;do{var l=n.lane;if((vb&
|
||||||
|
l)===l)null!==k&&(k=k.next={lane:0,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null}),d=n.hasEagerState?n.eagerState:a(d,n.action);else{var u={lane:l,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null};null===k?(h=k=u,g=d):k=k.next=u;C.lanes|=l;ra|=l}n=n.next}while(null!==n&&n!==f);null===k?g=d:k.next=h;ua(d,b.memoizedState)||(ha=!0);b.memoizedState=d;b.baseState=g;b.baseQueue=k;c.lastRenderedState=d}a=c.interleaved;if(null!==a){e=a;do f=
|
||||||
|
e.lane,C.lanes|=f,ra|=f,e=e.next;while(e!==a)}else null===e&&(c.lanes=0);return[b.memoizedState,c.dispatch]}function pf(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);ua(f,b.memoizedState)||(ha=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function Jh(a,b,c){}function Kh(a,b,c){c=C;var d=sa(),
|
||||||
|
e=b(),f=!ua(d.memoizedState,e);f&&(d.memoizedState=e,ha=!0);d=d.queue;qf(Lh.bind(null,c,d,a),[a]);if(d.getSnapshot!==b||f||null!==N&&N.memoizedState.tag&1){c.flags|=2048;Cc(9,Mh.bind(null,c,d,e,b),void 0,null);if(null===O)throw Error(m(349));0!==(vb&30)||Nh(c,b,e)}return e}function Nh(a,b,c){a.flags|=16384;a={getSnapshot:b,value:c};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.stores=[a]):(c=b.stores,null===c?b.stores=[a]:c.push(a))}function Mh(a,b,c,d){b.value=c;b.getSnapshot=
|
||||||
|
d;Oh(b)&&Ph(a)}function Lh(a,b,c){return c(function(){Oh(b)&&Ph(a)})}function Oh(a){var b=a.getSnapshot;a=a.value;try{var c=b();return!ua(a,c)}catch(d){return!0}}function Ph(a){var b=Oa(a,1);null!==b&&xa(b,a,1,-1)}function Qh(a){var b=Fa();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Bc,lastRenderedState:a};b.queue=a;a=a.dispatch=ok.bind(null,C,a);return[b.memoizedState,a]}function Cc(a,b,c,d){a={tag:a,create:b,
|
||||||
|
destroy:c,deps:d,next:null};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function Rh(a){return sa().memoizedState}function Bd(a,b,c,d){var e=Fa();C.flags|=a;e.memoizedState=Cc(1|b,c,void 0,void 0===d?null:d)}function Cd(a,b,c,d){var e=sa();d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&lf(d,g.deps)){e.memoizedState=
|
||||||
|
Cc(b,c,f,d);return}}C.flags|=a;e.memoizedState=Cc(1|b,c,f,d)}function Sh(a,b){return Bd(8390656,8,a,b)}function qf(a,b){return Cd(2048,8,a,b)}function Th(a,b){return Cd(4,2,a,b)}function Uh(a,b){return Cd(4,4,a,b)}function Vh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function Wh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Cd(4,4,Vh.bind(null,b,a),c)}function rf(a,b){}function Xh(a,b){var c=
|
||||||
|
sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function Yh(a,b){var c=sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Zh(a,b,c){if(0===(vb&21))return a.baseState&&(a.baseState=!1,ha=!0),a.memoizedState=c;ua(c,b)||(c=Dg(),C.lanes|=c,ra|=c,a.baseState=!0);return b}function pk(a,b,c){c=z;z=0!==c&&4>c?c:4;a(!0);var d=sf.transition;sf.transition=
|
||||||
|
{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,
|
||||||
|
h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&&
|
||||||
|
a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&&
|
||||||
|
null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&&
|
||||||
|
b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!==
|
||||||
|
typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+
|
||||||
|
"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)};
|
||||||
|
c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b;
|
||||||
|
if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f,
|
||||||
|
e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref===
|
||||||
|
b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c;
|
||||||
|
else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a,
|
||||||
|
b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b,
|
||||||
|
n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount||
|
||||||
|
("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props=
|
||||||
|
n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState),
|
||||||
|
(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=
|
||||||
|
4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1),
|
||||||
|
Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a,
|
||||||
|
cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!==
|
||||||
|
f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f=
|
||||||
|
sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d}
|
||||||
|
function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return=
|
||||||
|
b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e=
|
||||||
|
32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate;
|
||||||
|
null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&
|
||||||
|
vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c=
|
||||||
|
null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!==
|
||||||
|
b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current&
|
||||||
|
1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!==
|
||||||
|
b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,
|
||||||
|
d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&
|
||||||
|
256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break;
|
||||||
|
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],d);break;case "source":B("error",d);break;case "img":case "image":case "link":B("error",d);B("load",d);break;case "details":B("toggle",d);break;case "input":kg(d,f);B("invalid",d);break;case "select":d._wrapperState={wasMultiple:!!f.multiple};B("invalid",d);break;case "textarea":ng(d,f),B("invalid",d)}pe(c,f);e=null;for(var g in f)if(f.hasOwnProperty(g)){var h=f[g];"children"===g?"string"===typeof h?d.textContent!==h&&(!0!==f.suppressHydrationWarning&&
|
||||||
|
jd(d.textContent,h,a),e=["children",h]):"number"===typeof h&&d.textContent!==""+h&&(!0!==f.suppressHydrationWarning&&jd(d.textContent,h,a),e=["children",""+h]):$b.hasOwnProperty(g)&&null!=h&&"onScroll"===g&&B("scroll",d)}switch(c){case "input":Pc(d);mg(d,f,!0);break;case "textarea":Pc(d);pg(d);break;case "select":case "option":break;default:"function"===typeof f.onClick&&(d.onclick=kd)}d=e;b.updateQueue=d;null!==d&&(b.flags|=4)}else{g=9===e.nodeType?e:e.ownerDocument;"http://www.w3.org/1999/xhtml"===
|
||||||
|
a&&(a=qg(c));"http://www.w3.org/1999/xhtml"===a?"script"===c?(a=g.createElement("div"),a.innerHTML="<script>\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break;
|
||||||
|
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],a);e=d;break;case "source":B("error",a);e=d;break;case "img":case "image":case "link":B("error",a);B("load",a);e=d;break;case "details":B("toggle",a);e=d;break;case "input":kg(a,d);e=ke(a,d);B("invalid",a);break;case "option":e=d;break;case "select":a._wrapperState={wasMultiple:!!d.multiple};e=E({},d,{value:void 0});B("invalid",a);break;case "textarea":ng(a,d);e=ne(a,d);B("invalid",a);break;default:e=d}pe(c,e);h=e;for(f in h)if(h.hasOwnProperty(f)){var k=
|
||||||
|
h[f];"style"===f?sg(a,k):"dangerouslySetInnerHTML"===f?(k=k?k.__html:void 0,null!=k&&yi(a,k)):"children"===f?"string"===typeof k?("textarea"!==c||""!==k)&&Fc(a,k):"number"===typeof k&&Fc(a,""+k):"suppressContentEditableWarning"!==f&&"suppressHydrationWarning"!==f&&"autoFocus"!==f&&($b.hasOwnProperty(f)?null!=k&&"onScroll"===f&&B("scroll",a):null!=k&&$d(a,f,k,g))}switch(c){case "input":Pc(a);mg(a,d,!1);break;case "textarea":Pc(a);pg(a);break;case "option":null!=d.value&&a.setAttribute("value",""+Ua(d.value));
|
||||||
|
break;case "select":a.multiple=!!d.multiple;f=d.value;null!=f?Db(a,!!d.multiple,f,!1):null!=d.defaultValue&&Db(a,!!d.multiple,d.defaultValue,!0);break;default:"function"===typeof e.onClick&&(a.onclick=kd)}switch(c){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}}d&&(b.flags|=4)}null!==b.ref&&(b.flags|=512,b.flags|=2097152)}W(b);return null;case 6:if(a&&null!=b.stateNode)Ak(a,b,a.memoizedProps,d);else{if("string"!==typeof d&&null===
|
||||||
|
b.stateNode)throw Error(m(166));c=ub(xc.current);ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.memoizedProps;d[Da]=b;if(f=d.nodeValue!==c)if(a=la,null!==a)switch(a.tag){case 3:jd(d.nodeValue,c,0!==(a.mode&1));break;case 5:!0!==a.memoizedProps.suppressHydrationWarning&&jd(d.nodeValue,c,0!==(a.mode&1))}f&&(b.flags|=4)}else d=(9===c.nodeType?c:c.ownerDocument).createTextNode(d),d[Da]=b,b.stateNode=d}W(b);return null;case 13:v(F);d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){if(D&&
|
||||||
|
null!==fa&&0!==(b.mode&1)&&0===(b.flags&128)){for(f=fa;f;)f=Ka(f.nextSibling);Qb();b.flags|=98560;f=!1}else if(f=pd(b),null!==d&&null!==d.dehydrated){if(null===a){if(!f)throw Error(m(318));f=b.memoizedState;f=null!==f?f.dehydrated:null;if(!f)throw Error(m(317));f[Da]=b}else Qb(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;W(b);f=!1}else null!==wa&&(Gf(wa),wa=null),f=!0;if(!f)return b.flags&65536?b:null}if(0!==(b.flags&128))return b.lanes=c,b;d=null!==d;d!==(null!==a&&null!==a.memoizedState)&&
|
||||||
|
d&&(b.child.flags|=8192,0!==(b.mode&1)&&(null===a||0!==(F.current&1)?0===L&&(L=3):Ef()));null!==b.updateQueue&&(b.flags|=4);W(b);return null;case 4:return Tb(),xi(a,b),null===a&&sc(b.stateNode.containerInfo),W(b),null;case 10:return cf(b.type._context),W(b),null;case 17:return ea(b.type)&&(v(S),v(J)),W(b),null;case 19:v(F);f=b.memoizedState;if(null===f)return W(b),null;d=0!==(b.flags&128);g=f.rendering;if(null===g)if(d)Dc(f,!1);else{if(0!==L||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){g=
|
||||||
|
xd(a);if(null!==g){b.flags|=128;Dc(f,!1);d=g.updateQueue;null!==d&&(b.updateQueue=d,b.flags|=4);b.subtreeFlags=0;d=c;for(c=b.child;null!==c;)f=c,a=d,f.flags&=14680066,g=f.alternate,null===g?(f.childLanes=0,f.lanes=a,f.child=null,f.subtreeFlags=0,f.memoizedProps=null,f.memoizedState=null,f.updateQueue=null,f.dependencies=null,f.stateNode=null):(f.childLanes=g.childLanes,f.lanes=g.lanes,f.child=g.child,f.subtreeFlags=0,f.deletions=null,f.memoizedProps=g.memoizedProps,f.memoizedState=g.memoizedState,
|
||||||
|
f.updateQueue=g.updateQueue,f.type=g.type,a=g.dependencies,f.dependencies=null===a?null:{lanes:a.lanes,firstContext:a.firstContext}),c=c.sibling;y(F,F.current&1|2);return b.child}a=a.sibling}null!==f.tail&&P()>Hf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|=
|
||||||
|
128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null;
|
||||||
|
case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(),
|
||||||
|
null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();
|
||||||
|
if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null:
|
||||||
|
{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t=
|
||||||
|
b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}}
|
||||||
|
function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk],
|
||||||
|
delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&
|
||||||
|
2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);
|
||||||
|
else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za?
|
||||||
|
(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=
|
||||||
|
c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;d<c.length;d++){var e=
|
||||||
|
c[d];try{var f=a,g=b,h=g;a:for(;null!==h;){switch(h.tag){case 5:T=h.stateNode;za=!1;break a;case 3:T=h.stateNode.containerInfo;za=!0;break a;case 4:T=h.stateNode.containerInfo;za=!0;break a}h=h.return}if(null===T)throw Error(m(160));Di(f,g,e);T=null;za=!1;var k=e.alternate;null!==k&&(k.return=null);e.return=null}catch(n){G(e,b,n)}}if(b.subtreeFlags&12854)for(b=b.child;null!==b;)Fi(b,a),b=b.sibling}function Fi(a,b,c){var d=a.alternate;c=a.flags;switch(a.tag){case 0:case 11:case 14:case 15:Aa(b,a);
|
||||||
|
Ha(a);if(c&4){try{Gc(3,a,a.return),Id(3,a)}catch(I){G(a,a.return,I)}try{Gc(5,a,a.return)}catch(I){G(a,a.return,I)}}break;case 1:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);break;case 5:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);if(a.flags&32){var e=a.stateNode;try{Fc(e,"")}catch(I){G(a,a.return,I)}}if(c&4&&(e=a.stateNode,null!=e)){var f=a.memoizedProps,g=null!==d?d.memoizedProps:f,h=a.type,k=a.updateQueue;a.updateQueue=null;if(null!==k)try{"input"===h&&"radio"===f.type&&null!=f.name&&lg(e,f);
|
||||||
|
qe(h,g);var n=qe(h,f);for(g=0;g<k.length;g+=2){var q=k[g],u=k[g+1];"style"===q?sg(e,u):"dangerouslySetInnerHTML"===q?yi(e,u):"children"===q?Fc(e,u):$d(e,q,u,n)}switch(h){case "input":le(e,f);break;case "textarea":og(e,f);break;case "select":var r=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!f.multiple;var p=f.value;null!=p?Db(e,!!f.multiple,p,!1):r!==!!f.multiple&&(null!=f.defaultValue?Db(e,!!f.multiple,f.defaultValue,!0):Db(e,!!f.multiple,f.multiple?[]:"",!1))}e[uc]=f}catch(I){G(a,a.return,
|
||||||
|
I)}}break;case 6:Aa(b,a);Ha(a);if(c&4){if(null===a.stateNode)throw Error(m(162));e=a.stateNode;f=a.memoizedProps;try{e.nodeValue=f}catch(I){G(a,a.return,I)}}break;case 3:Aa(b,a);Ha(a);if(c&4&&null!==d&&d.memoizedState.isDehydrated)try{nc(b.containerInfo)}catch(I){G(a,a.return,I)}break;case 4:Aa(b,a);Ha(a);break;case 13:Aa(b,a);Ha(a);e=a.child;e.flags&8192&&(f=null!==e.memoizedState,e.stateNode.isHidden=f,!f||null!==e.alternate&&null!==e.alternate.memoizedState||(Of=P()));c&4&&Ei(a);break;case 22:q=
|
||||||
|
null!==d&&null!==d.memoizedState;a.mode&1?(X=(n=X)||q,Aa(b,a),X=n):Aa(b,a);Ha(a);if(c&8192){n=null!==a.memoizedState;if((a.stateNode.isHidden=n)&&!q&&0!==(a.mode&1))for(l=a,q=a.child;null!==q;){for(u=l=q;null!==l;){r=l;p=r.child;switch(r.tag){case 0:case 11:case 14:case 15:Gc(4,r,r.return);break;case 1:Wb(r,r.return);var x=r.stateNode;if("function"===typeof x.componentWillUnmount){c=r;b=r.return;try{d=c,x.props=d.memoizedProps,x.state=d.memoizedState,x.componentWillUnmount()}catch(I){G(c,b,I)}}break;
|
||||||
|
case 5:Wb(r,r.return);break;case 22:if(null!==r.memoizedState){Gi(u);continue}}null!==p?(p.return=r,l=p):Gi(u)}q=q.sibling}a:for(q=null,u=a;;){if(5===u.tag){if(null===q){q=u;try{e=u.stateNode,n?(f=e.style,"function"===typeof f.setProperty?f.setProperty("display","none","important"):f.display="none"):(h=u.stateNode,k=u.memoizedProps.style,g=void 0!==k&&null!==k&&k.hasOwnProperty("display")?k.display:null,h.style.display=rg("display",g))}catch(I){G(a,a.return,I)}}}else if(6===u.tag){if(null===q)try{u.stateNode.nodeValue=
|
||||||
|
n?"":u.memoizedProps}catch(I){G(a,a.return,I)}}else if((22!==u.tag&&23!==u.tag||null===u.memoizedState||u===a)&&null!==u.child){u.child.return=u;u=u.child;continue}if(u===a)break a;for(;null===u.sibling;){if(null===u.return||u.return===a)break a;q===u&&(q=null);u=u.return}q===u&&(q=null);u.sibling.return=u.return;u=u.sibling}}break;case 19:Aa(b,a);Ha(a);c&4&&Ei(a);break;case 21:break;default:Aa(b,a),Ha(a)}}function Ha(a){var b=a.flags;if(b&2){try{a:{for(var c=a.return;null!==c;){if(Bi(c)){var d=c;
|
||||||
|
break a}c=c.return}throw Error(m(160));}switch(d.tag){case 5:var e=d.stateNode;d.flags&32&&(Fc(e,""),d.flags&=-33);var f=Ci(a);Nf(a,f,e);break;case 3:case 4:var g=d.stateNode.containerInfo,h=Ci(a);Mf(a,h,g);break;default:throw Error(m(161));}}catch(k){G(a,a.return,k)}a.flags&=-3}b&4096&&(a.flags&=-4097)}function Hk(a,b,c){l=a;Hi(a,b,c)}function Hi(a,b,c){for(var d=0!==(a.mode&1);null!==l;){var e=l,f=e.child;if(22===e.tag&&d){var g=null!==e.memoizedState||Jd;if(!g){var h=e.alternate,k=null!==h&&null!==
|
||||||
|
h.memoizedState||X;h=Jd;var n=X;Jd=g;if((X=k)&&!n)for(l=e;null!==l;)g=l,k=g.child,22===g.tag&&null!==g.memoizedState?Ii(e):null!==k?(k.return=g,l=k):Ii(e);for(;null!==f;)l=f,Hi(f,b,c),f=f.sibling;l=e;Jd=h;X=n}Ji(a,b,c)}else 0!==(e.subtreeFlags&8772)&&null!==f?(f.return=e,l=f):Ji(a,b,c)}}function Ji(a,b,c){for(;null!==l;){b=l;if(0!==(b.flags&8772)){c=b.alternate;try{if(0!==(b.flags&8772))switch(b.tag){case 0:case 11:case 15:X||Id(5,b);break;case 1:var d=b.stateNode;if(b.flags&4&&!X)if(null===c)d.componentDidMount();
|
||||||
|
else{var e=b.elementType===b.type?c.memoizedProps:ya(b.type,c.memoizedProps);d.componentDidUpdate(e,c.memoizedState,d.__reactInternalSnapshotBeforeUpdate)}var f=b.updateQueue;null!==f&&Hh(b,f,d);break;case 3:var g=b.updateQueue;if(null!==g){c=null;if(null!==b.child)switch(b.child.tag){case 5:c=b.child.stateNode;break;case 1:c=b.child.stateNode}Hh(b,g,c)}break;case 5:var h=b.stateNode;if(null===c&&b.flags&4){c=h;var k=b.memoizedProps;switch(b.type){case "button":case "input":case "select":case "textarea":k.autoFocus&&
|
||||||
|
c.focus();break;case "img":k.src&&(c.src=k.src)}}break;case 6:break;case 4:break;case 12:break;case 13:if(null===b.memoizedState){var n=b.alternate;if(null!==n){var q=n.memoizedState;if(null!==q){var p=q.dehydrated;null!==p&&nc(p)}}}break;case 19:case 17:case 21:case 22:case 23:case 25:break;default:throw Error(m(163));}X||b.flags&512&&Lf(b)}catch(r){G(b,b.return,r)}}if(b===a){l=null;break}c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Gi(a){for(;null!==l;){var b=l;if(b===
|
||||||
|
a){l=null;break}var c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Ii(a){for(;null!==l;){var b=l;try{switch(b.tag){case 0:case 11:case 15:var c=b.return;try{Id(4,b)}catch(k){G(b,c,k)}break;case 1:var d=b.stateNode;if("function"===typeof d.componentDidMount){var e=b.return;try{d.componentDidMount()}catch(k){G(b,e,k)}}var f=b.return;try{Lf(b)}catch(k){G(b,f,k)}break;case 5:var g=b.return;try{Lf(b)}catch(k){G(b,g,k)}}}catch(k){G(b,b.return,k)}if(b===a){l=null;break}var h=b.sibling;
|
||||||
|
if(null!==h){h.return=b.return;l=h;break}l=b.return}}function Hc(){Hf=P()+500}function Z(){return 0!==(p&6)?P():-1!==Kd?Kd:Kd=P()}function hb(a){if(0===(a.mode&1))return 1;if(0!==(p&2)&&0!==U)return U&-U;if(null!==Ik.transition)return 0===Ld&&(Ld=Dg()),Ld;a=z;if(0!==a)return a;a=window.event;a=void 0===a?16:Lg(a.type);return a}function xa(a,b,c,d){if(50<Ic)throw Ic=0,Pf=null,Error(m(185));ic(a,c,d);if(0===(p&2)||a!==O)a===O&&(0===(p&2)&&(Md|=c),4===L&&kb(a,U)),ia(a,d),1===c&&0===p&&0===(b.mode&1)&&
|
||||||
|
(Hc(),md&&db())}function ia(a,b){var c=a.callbackNode;tj(a,b);var d=Vc(a,a===O?U:0);if(0===d)null!==c&&Ki(c),a.callbackNode=null,a.callbackPriority=0;else if(b=d&-d,a.callbackPriority!==b){null!=c&&Ki(c);if(1===b)0===a.tag?jk(Li.bind(null,a)):wh(Li.bind(null,a)),Jk(function(){0===(p&6)&&db()}),c=null;else{switch(Eg(d)){case 1:c=De;break;case 4:c=Mg;break;case 16:c=ad;break;case 536870912:c=Ng;break;default:c=ad}c=Mi(c,Ni.bind(null,a))}a.callbackPriority=b;a.callbackNode=c}}function Ni(a,b){Kd=-1;
|
||||||
|
Ld=0;if(0!==(p&6))throw Error(m(327));var c=a.callbackNode;if(Xb()&&a.callbackNode!==c)return null;var d=Vc(a,a===O?U:0);if(0===d)return null;if(0!==(d&30)||0!==(d&a.expiredLanes)||b)b=Nd(a,d);else{b=d;var e=p;p|=2;var f=Oi();if(O!==a||U!==b)Ra=null,Hc(),wb(a,b);do try{Kk();break}catch(h){Pi(a,h)}while(1);af();Od.current=f;p=e;null!==H?b=0:(O=null,U=0,b=L)}if(0!==b){2===b&&(e=ve(a),0!==e&&(d=e,b=Qf(a,e)));if(1===b)throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;if(6===b)kb(a,d);else{e=a.current.alternate;
|
||||||
|
if(0===(d&30)&&!Lk(e)&&(b=Nd(a,d),2===b&&(f=ve(a),0!==f&&(d=f,b=Qf(a,f))),1===b))throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;a.finishedWork=e;a.finishedLanes=d;switch(b){case 0:case 1:throw Error(m(345));case 2:xb(a,ja,Ra);break;case 3:kb(a,d);if((d&130023424)===d&&(b=Of+500-P(),10<b)){if(0!==Vc(a,0))break;e=a.suspendedLanes;if((e&d)!==d){Z();a.pingedLanes|=a.suspendedLanes&e;break}a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),b);break}xb(a,ja,Ra);break;case 4:kb(a,d);if((d&4194240)===d)break;b=a.eventTimes;
|
||||||
|
for(e=-1;0<d;){var g=31-ta(d);f=1<<g;g=b[g];g>e&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10<d){a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),d);break}xb(a,ja,Ra);break;case 5:xb(a,ja,Ra);break;default:throw Error(m(329));}}}ia(a,P());return a.callbackNode===c?Ni.bind(null,a):null}function Qf(a,b){var c=Kc;a.current.memoizedState.isDehydrated&&(wb(a,b).flags|=256);a=Nd(a,b);2!==a&&(b=ja,ja=c,null!==b&&Gf(b));return a}function Gf(a){null===
|
||||||
|
ja?ja=a:ja.push.apply(ja,a)}function Lk(a){for(var b=a;;){if(b.flags&16384){var c=b.updateQueue;if(null!==c&&(c=c.stores,null!==c))for(var d=0;d<c.length;d++){var e=c[d],f=e.getSnapshot;e=e.value;try{if(!ua(f(),e))return!1}catch(g){return!1}}}c=b.child;if(b.subtreeFlags&16384&&null!==c)c.return=b,b=c;else{if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return!0;b=b.return}b.sibling.return=b.return;b=b.sibling}}return!0}function kb(a,b){b&=~Sf;b&=~Md;a.suspendedLanes|=b;a.pingedLanes&=
|
||||||
|
~b;for(a=a.expirationTimes;0<b;){var c=31-ta(b),d=1<<c;a[c]=-1;b&=~d}}function Li(a){if(0!==(p&6))throw Error(m(327));Xb();var b=Vc(a,0);if(0===(b&1))return ia(a,P()),null;var c=Nd(a,b);if(0!==a.tag&&2===c){var d=ve(a);0!==d&&(b=d,c=Qf(a,d))}if(1===c)throw c=Jc,wb(a,0),kb(a,b),ia(a,P()),c;if(6===c)throw Error(m(345));a.finishedWork=a.current.alternate;a.finishedLanes=b;xb(a,ja,Ra);ia(a,P());return null}function Tf(a,b){var c=p;p|=1;try{return a(b)}finally{p=c,0===p&&(Hc(),md&&db())}}function yb(a){null!==
|
||||||
|
lb&&0===lb.tag&&0===(p&6)&&Xb();var b=p;p|=1;var c=ca.transition,d=z;try{if(ca.transition=null,z=1,a)return a()}finally{z=d,ca.transition=c,p=b,0===(p&6)&&db()}}function wb(a,b){a.finishedWork=null;a.finishedLanes=0;var c=a.timeoutHandle;-1!==c&&(a.timeoutHandle=-1,Nk(c));if(null!==H)for(c=H.return;null!==c;){var d=c;Ve(d);switch(d.tag){case 1:d=d.type.childContextTypes;null!==d&&void 0!==d&&(v(S),v(J));break;case 3:Tb();v(S);v(J);jf();break;case 5:hf(d);break;case 4:Tb();break;case 13:v(F);break;
|
||||||
|
case 19:v(F);break;case 10:cf(d.type._context);break;case 22:case 23:ba=Ga.current,v(Ga)}c=c.return}O=a;H=a=eb(a.current,null);U=ba=b;L=0;Jc=null;Sf=Md=ra=0;ja=Kc=null;if(null!==tb){for(b=0;b<tb.length;b++)if(c=tb[b],d=c.interleaved,null!==d){c.interleaved=null;var e=d.next,f=c.pending;if(null!==f){var g=f.next;f.next=e;d.next=g}c.pending=d}tb=null}return a}function Pi(a,b){do{var c=H;try{af();yd.current=zd;if(Ad){for(var d=C.memoizedState;null!==d;){var e=d.queue;null!==e&&(e.pending=null);d=d.next}Ad=
|
||||||
|
!1}vb=0;N=K=C=null;zc=!1;Ac=0;Uf.current=null;if(null===c||null===c.return){L=1;Jc=b;H=null;break}a:{var f=a,g=c.return,h=c,k=b;b=U;h.flags|=32768;if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var n=k,l=h,p=l.tag;if(0===(l.mode&1)&&(0===p||11===p||15===p)){var r=l.alternate;r?(l.updateQueue=r.updateQueue,l.memoizedState=r.memoizedState,l.lanes=r.lanes):(l.updateQueue=null,l.memoizedState=null)}var v=ji(g);if(null!==v){v.flags&=-257;ki(v,g,h,f,b);v.mode&1&&ii(f,n,b);b=v;k=n;var x=b.updateQueue;
|
||||||
|
if(null===x){var z=new Set;z.add(k);b.updateQueue=z}else x.add(k);break a}else{if(0===(b&1)){ii(f,n,b);Ef();break a}k=Error(m(426))}}else if(D&&h.mode&1){var y=ji(g);if(null!==y){0===(y.flags&65536)&&(y.flags|=256);ki(y,g,h,f,b);Ye(Ub(k,h));break a}}f=k=Ub(k,h);4!==L&&(L=2);null===Kc?Kc=[f]:Kc.push(f);f=g;do{switch(f.tag){case 3:f.flags|=65536;b&=-b;f.lanes|=b;var w=gi(f,k,b);Gh(f,w);break a;case 1:h=k;var A=f.type,t=f.stateNode;if(0===(f.flags&128)&&("function"===typeof A.getDerivedStateFromError||
|
||||||
|
null!==t&&"function"===typeof t.componentDidCatch&&(null===ib||!ib.has(t)))){f.flags|=65536;b&=-b;f.lanes|=b;var B=hi(f,h,b);Gh(f,B);break a}}f=f.return}while(null!==f)}Qi(c)}catch(ma){b=ma;H===c&&null!==c&&(H=c=c.return);continue}break}while(1)}function Oi(){var a=Od.current;Od.current=zd;return null===a?zd:a}function Ef(){if(0===L||3===L||2===L)L=4;null===O||0===(ra&268435455)&&0===(Md&268435455)||kb(O,U)}function Nd(a,b){var c=p;p|=2;var d=Oi();if(O!==a||U!==b)Ra=null,wb(a,b);do try{Ok();break}catch(e){Pi(a,
|
||||||
|
e)}while(1);af();p=c;Od.current=d;if(null!==H)throw Error(m(261));O=null;U=0;return L}function Ok(){for(;null!==H;)Ri(H)}function Kk(){for(;null!==H&&!Pk();)Ri(H)}function Ri(a){var b=Qk(a.alternate,a,ba);a.memoizedProps=a.pendingProps;null===b?Qi(a):H=b;Uf.current=null}function Qi(a){var b=a;do{var c=b.alternate;a=b.return;if(0===(b.flags&32768)){if(c=xk(c,b,ba),null!==c){H=c;return}}else{c=Bk(c,b);if(null!==c){c.flags&=32767;H=c;return}if(null!==a)a.flags|=32768,a.subtreeFlags=0,a.deletions=null;
|
||||||
|
else{L=6;H=null;return}}b=b.sibling;if(null!==b){H=b;return}H=b=a}while(null!==b);0===L&&(L=5)}function xb(a,b,c){var d=z,e=ca.transition;try{ca.transition=null,z=1,Rk(a,b,c,d)}finally{ca.transition=e,z=d}return null}function Rk(a,b,c,d){do Xb();while(null!==lb);if(0!==(p&6))throw Error(m(327));c=a.finishedWork;var e=a.finishedLanes;if(null===c)return null;a.finishedWork=null;a.finishedLanes=0;if(c===a.current)throw Error(m(177));a.callbackNode=null;a.callbackPriority=0;var f=c.lanes|c.childLanes;
|
||||||
|
uj(a,f);a===O&&(H=O=null,U=0);0===(c.subtreeFlags&2064)&&0===(c.flags&2064)||Pd||(Pd=!0,Mi(ad,function(){Xb();return null}));f=0!==(c.flags&15990);if(0!==(c.subtreeFlags&15990)||f){f=ca.transition;ca.transition=null;var g=z;z=1;var h=p;p|=4;Uf.current=null;Ck(a,c);Fi(c,a);Tj(Kf);Zc=!!Jf;Kf=Jf=null;a.current=c;Hk(c,a,e);Sk();p=h;z=g;ca.transition=f}else a.current=c;Pd&&(Pd=!1,lb=a,Qd=e);f=a.pendingLanes;0===f&&(ib=null);oj(c.stateNode,d);ia(a,P());if(null!==b)for(d=a.onRecoverableError,c=0;c<b.length;c++)e=
|
||||||
|
b[c],d(e.value,{componentStack:e.stack,digest:e.digest});if(Ed)throw Ed=!1,a=xf,xf=null,a;0!==(Qd&1)&&0!==a.tag&&Xb();f=a.pendingLanes;0!==(f&1)?a===Pf?Ic++:(Ic=0,Pf=a):Ic=0;db();return null}function Xb(){if(null!==lb){var a=Eg(Qd),b=ca.transition,c=z;try{ca.transition=null;z=16>a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;k<h.length;k++){var n=
|
||||||
|
h[k];for(l=n;null!==l;){var q=l;switch(q.tag){case 0:case 11:case 15:Gc(8,q,f)}var u=q.child;if(null!==u)u.return=q,l=u;else for(;null!==l;){q=l;var r=q.sibling,v=q.return;Ai(q);if(q===n){l=null;break}if(null!==r){r.return=v;l=r;break}l=v}}}var x=f.alternate;if(null!==x){var y=x.child;if(null!==y){x.child=null;do{var C=y.sibling;y.sibling=null;y=C}while(null!==y)}}l=f}}if(0!==(f.subtreeFlags&2064)&&null!==g)g.return=f,l=g;else b:for(;null!==l;){f=l;if(0!==(f.flags&2048))switch(f.tag){case 0:case 11:case 15:Gc(9,
|
||||||
|
f,f.return)}var w=f.sibling;if(null!==w){w.return=f.return;l=w;break b}l=f.return}}var A=a.current;for(l=A;null!==l;){g=l;var t=g.child;if(0!==(g.subtreeFlags&2064)&&null!==t)t.return=g,l=t;else b:for(g=A;null!==l;){h=l;if(0!==(h.flags&2048))try{switch(h.tag){case 0:case 11:case 15:Id(9,h)}}catch(ma){G(h,h.return,ma)}if(h===g){l=null;break b}var B=h.sibling;if(null!==B){B.return=h.return;l=B;break b}l=h.return}}p=e;db();if(Ca&&"function"===typeof Ca.onPostCommitFiberRoot)try{Ca.onPostCommitFiberRoot(Uc,
|
||||||
|
a)}catch(ma){}d=!0}return d}finally{z=c,ca.transition=b}}return!1}function Si(a,b,c){b=Ub(c,b);b=gi(a,b,1);a=fb(a,b,1);b=Z();null!==a&&(ic(a,1,b),ia(a,b))}function G(a,b,c){if(3===a.tag)Si(a,a,c);else for(;null!==b;){if(3===b.tag){Si(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError||"function"===typeof d.componentDidCatch&&(null===ib||!ib.has(d))){a=Ub(c,a);a=hi(b,a,1);b=fb(b,a,1);a=Z();null!==b&&(ic(b,1,a),ia(b,a));break}}b=b.return}}function sk(a,
|
||||||
|
b,c){var d=a.pingCache;null!==d&&d.delete(b);b=Z();a.pingedLanes|=a.suspendedLanes&c;O===a&&(U&c)===c&&(4===L||3===L&&(U&130023424)===U&&500>P()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);
|
||||||
|
break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a=
|
||||||
|
a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=
|
||||||
|
a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a=
|
||||||
|
pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType=
|
||||||
|
Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=
|
||||||
|
0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,
|
||||||
|
pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Cb,key:null==d?null:""+d,children:a,containerInfo:b,implementation:c}}function Vi(a){if(!a)return cb;a=a._reactInternals;a:{if(nb(a)!==a||1!==a.tag)throw Error(m(170));var b=a;do{switch(b.tag){case 3:b=b.stateNode.context;break a;case 1:if(ea(b.type)){b=b.stateNode.__reactInternalMemoizedMergedChildContext;break a}}b=b.return}while(null!==b);throw Error(m(171));
|
||||||
|
}if(1===a.tag){var c=a.type;if(ea(c))return uh(a,c,b)}return b}function Wi(a,b,c,d,e,f,g,h,k,l){a=Vf(c,d,!0,a,e,f,g,h,k);a.context=Vi(null);c=a.current;d=Z();e=hb(c);f=Pa(d,e);f.callback=void 0!==b&&null!==b?b:null;fb(c,f,e);a.current.lanes=e;ic(a,e,d);ia(a,d);return a}function Sd(a,b,c,d){var e=b.current,f=Z(),g=hb(e);c=Vi(c);null===b.context?b.context=c:b.pendingContext=c;b=Pa(f,g);b.payload={element:a};d=void 0===d?null:d;null!==d&&(b.callback=d);a=fb(e,b,g);null!==a&&(xa(a,e,g,f),vd(a,e,g));return g}
|
||||||
|
function Td(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Xi(a,b){a=a.memoizedState;if(null!==a&&null!==a.dehydrated){var c=a.retryLane;a.retryLane=0!==c&&c<b?c:b}}function Wf(a,b){Xi(a,b);(a=a.alternate)&&Xi(a,b)}function Xk(a){a=Bg(a);return null===a?null:a.stateNode}function Yk(a){return null}function Xf(a){this._internalRoot=a}function Ud(a){this._internalRoot=a}function Yf(a){return!(!a||1!==a.nodeType&&9!==
|
||||||
|
a.nodeType&&11!==a.nodeType)}function Vd(a){return!(!a||1!==a.nodeType&&9!==a.nodeType&&11!==a.nodeType&&(8!==a.nodeType||" react-mount-point-unstable "!==a.nodeValue))}function Yi(){}function Zk(a,b,c,d,e){if(e){if("function"===typeof d){var f=d;d=function(){var a=Td(g);f.call(a)}}var g=Wi(b,d,a,0,null,!1,!1,"",Yi);a._reactRootContainer=g;a[Ja]=g.current;sc(8===a.nodeType?a.parentNode:a);yb();return g}for(;e=a.lastChild;)a.removeChild(e);if("function"===typeof d){var h=d;d=function(){var a=Td(k);
|
||||||
|
h.call(a)}}var k=Vf(a,0,!1,null,null,!1,!1,"",Yi);a._reactRootContainer=k;a[Ja]=k.current;sc(8===a.nodeType?a.parentNode:a);yb(function(){Sd(b,k,c,d)});return k}function Wd(a,b,c,d,e){var f=c._reactRootContainer;if(f){var g=f;if("function"===typeof e){var h=e;e=function(){var a=Td(g);h.call(a)}}Sd(b,g,a,e)}else g=Zk(c,b,a,e,d);return Td(g)}var cg=new Set,$b={},Ia=!("undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement),Zd=Object.prototype.hasOwnProperty,
|
||||||
|
cj=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eg={},dg={},R={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){R[a]=
|
||||||
|
new Y(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];R[b]=new Y(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){R[a]=new Y(a,2,!1,a.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){R[a]=new Y(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){R[a]=
|
||||||
|
new Y(a,3,!1,a.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(a){R[a]=new Y(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){R[a]=new Y(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){R[a]=new Y(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){R[a]=new Y(a,5,!1,a.toLowerCase(),null,!1,!1)});var Zf=/[\-:]([a-z])/g,$f=function(a){return a[1].toUpperCase()};"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=
|
||||||
|
a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!1,!1)});R.xlinkHref=new Y("xlinkHref",
|
||||||
|
1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!0,!0)});var Sa=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,sd=Symbol.for("react.element"),Cb=Symbol.for("react.portal"),Bb=Symbol.for("react.fragment"),fe=Symbol.for("react.strict_mode"),ee=Symbol.for("react.profiler"),hg=Symbol.for("react.provider"),gg=Symbol.for("react.context"),ie=Symbol.for("react.forward_ref"),ge=Symbol.for("react.suspense"),
|
||||||
|
he=Symbol.for("react.suspense_list"),je=Symbol.for("react.memo"),Ta=Symbol.for("react.lazy");Symbol.for("react.scope");Symbol.for("react.debug_trace_mode");var Ui=Symbol.for("react.offscreen");Symbol.for("react.legacy_hidden");Symbol.for("react.cache");Symbol.for("react.tracing_marker");var fg=Symbol.iterator,E=Object.assign,ae,ce=!1,cc=Array.isArray,Xd,yi=function(a){return"undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,
|
||||||
|
c,d,e)})}:a}(function(a,b){if("http://www.w3.org/2000/svg"!==a.namespaceURI||"innerHTML"in a)a.innerHTML=b;else{Xd=Xd||document.createElement("div");Xd.innerHTML="<svg>"+b.valueOf().toString()+"</svg>";for(b=Xd.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}),Fc=function(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b},dc={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,
|
||||||
|
borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,
|
||||||
|
strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$k=["Webkit","ms","Moz","O"];Object.keys(dc).forEach(function(a){$k.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);dc[b]=dc[a]})});var ij=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),ze=null,se=null,Eb=null,Fb=null,xg=function(a,b){return a(b)},yg=function(){},te=!1,Oe=!1;if(Ia)try{var Lc={};Object.defineProperty(Lc,
|
||||||
|
"passive",{get:function(){Oe=!0}});window.addEventListener("test",Lc,Lc);window.removeEventListener("test",Lc,Lc)}catch(a){Oe=!1}var kj=function(a,b,c,d,e,f,g,h,k){var l=Array.prototype.slice.call(arguments,3);try{b.apply(c,l)}catch(q){this.onError(q)}},gc=!1,Sc=null,Tc=!1,ue=null,lj={onError:function(a){gc=!0;Sc=a}},Ba=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler,Jg=Ba.unstable_scheduleCallback,Kg=Ba.unstable_NormalPriority,xh=Jg,Ki=Ba.unstable_cancelCallback,Pk=Ba.unstable_shouldYield,
|
||||||
|
Sk=Ba.unstable_requestPaint,P=Ba.unstable_now,Dj=Ba.unstable_getCurrentPriorityLevel,De=Ba.unstable_ImmediatePriority,Mg=Ba.unstable_UserBlockingPriority,ad=Kg,Ej=Ba.unstable_LowPriority,Ng=Ba.unstable_IdlePriority,Uc=null,Ca=null,ta=Math.clz32?Math.clz32:pj,qj=Math.log,rj=Math.LN2,Wc=64,Rd=4194304,z=0,Ae=!1,Yc=[],Va=null,Wa=null,Xa=null,jc=new Map,kc=new Map,Ya=[],Bj="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "),
|
||||||
|
Gb=Sa.ReactCurrentBatchConfig,Zc=!0,$c=null,Za=null,Ee=null,bd=null,Yb={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(a){return a.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},He=ka(Yb),Mc=E({},Yb,{view:0,detail:0}),ak=ka(Mc),ag,bg,Nc,Yd=E({},Mc,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Fe,button:0,buttons:0,relatedTarget:function(a){return void 0===a.relatedTarget?a.fromElement===a.srcElement?a.toElement:a.fromElement:
|
||||||
|
a.relatedTarget},movementX:function(a){if("movementX"in a)return a.movementX;a!==Nc&&(Nc&&"mousemove"===a.type?(ag=a.screenX-Nc.screenX,bg=a.screenY-Nc.screenY):bg=ag=0,Nc=a);return ag},movementY:function(a){return"movementY"in a?a.movementY:bg}}),ih=ka(Yd),al=E({},Yd,{dataTransfer:0}),Wj=ka(al),bl=E({},Mc,{relatedTarget:0}),Pe=ka(bl),cl=E({},Yb,{animationName:0,elapsedTime:0,pseudoElement:0}),Yj=ka(cl),dl=E({},Yb,{clipboardData:function(a){return"clipboardData"in a?a.clipboardData:window.clipboardData}}),
|
||||||
|
ck=ka(dl),el=E({},Yb,{data:0}),qh=ka(el),fk=qh,fl={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},gl={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",
|
||||||
|
112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},Gj={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"},hl=E({},Mc,{key:function(a){if(a.key){var b=fl[a.key]||a.key;if("Unidentified"!==b)return b}return"keypress"===a.type?(a=cd(a),13===a?"Enter":String.fromCharCode(a)):"keydown"===a.type||"keyup"===a.type?gl[a.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,
|
||||||
|
metaKey:0,repeat:0,locale:0,getModifierState:Fe,charCode:function(a){return"keypress"===a.type?cd(a):0},keyCode:function(a){return"keydown"===a.type||"keyup"===a.type?a.keyCode:0},which:function(a){return"keypress"===a.type?cd(a):"keydown"===a.type||"keyup"===a.type?a.keyCode:0}}),Vj=ka(hl),il=E({},Yd,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),nh=ka(il),jl=E({},Mc,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,
|
||||||
|
ctrlKey:0,shiftKey:0,getModifierState:Fe}),Xj=ka(jl),kl=E({},Yb,{propertyName:0,elapsedTime:0,pseudoElement:0}),Zj=ka(kl),ll=E({},Yd,{deltaX:function(a){return"deltaX"in a?a.deltaX:"wheelDeltaX"in a?-a.wheelDeltaX:0},deltaY:function(a){return"deltaY"in a?a.deltaY:"wheelDeltaY"in a?-a.wheelDeltaY:"wheelDelta"in a?-a.wheelDelta:0},deltaZ:0,deltaMode:0}),bk=ka(ll),Hj=[9,13,27,32],Ge=Ia&&"CompositionEvent"in window,Oc=null;Ia&&"documentMode"in document&&(Oc=document.documentMode);var ek=Ia&&"TextEvent"in
|
||||||
|
window&&!Oc,Ug=Ia&&(!Ge||Oc&&8<Oc&&11>=Oc),Tg=String.fromCharCode(32),Sg=!1,Hb=!1,Kj={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},oc=null,pc=null,ph=!1;Ia&&(ph=Lj("input")&&(!document.documentMode||9<document.documentMode));var ua="function"===typeof Object.is?Object.is:Sj,dk=Ia&&"documentMode"in document&&11>=document.documentMode,Jb=null,Ke=null,rc=null,Je=!1,Kb={animationend:gd("Animation","AnimationEnd"),
|
||||||
|
animationiteration:gd("Animation","AnimationIteration"),animationstart:gd("Animation","AnimationStart"),transitionend:gd("Transition","TransitionEnd")},Le={},eh={};Ia&&(eh=document.createElement("div").style,"AnimationEvent"in window||(delete Kb.animationend.animation,delete Kb.animationiteration.animation,delete Kb.animationstart.animation),"TransitionEvent"in window||delete Kb.transitionend.transition);var jh=hd("animationend"),kh=hd("animationiteration"),lh=hd("animationstart"),mh=hd("transitionend"),
|
||||||
|
fh=new Map,Zi="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");
|
||||||
|
(function(){for(var a=0;a<Zi.length;a++){var b=Zi[a],c=b.toLowerCase();b=b[0].toUpperCase()+b.slice(1);$a(c,"on"+b)}$a(jh,"onAnimationEnd");$a(kh,"onAnimationIteration");$a(lh,"onAnimationStart");$a("dblclick","onDoubleClick");$a("focusin","onFocus");$a("focusout","onBlur");$a(mh,"onTransitionEnd")})();Ab("onMouseEnter",["mouseout","mouseover"]);Ab("onMouseLeave",["mouseout","mouseover"]);Ab("onPointerEnter",["pointerout","pointerover"]);Ab("onPointerLeave",["pointerout","pointerover"]);mb("onChange",
|
||||||
|
"change click focusin focusout input keydown keyup selectionchange".split(" "));mb("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" "));mb("onBeforeInput",["compositionend","keypress","textInput","paste"]);mb("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));
|
||||||
|
var Ec="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Uj=new Set("cancel close invalid load scroll toggle".split(" ").concat(Ec)),id="_reactListening"+Math.random().toString(36).slice(2),gk=/\r\n?/g,hk=/\u0000|\uFFFD/g,Jf=null,Kf=null,Rf="function"===typeof setTimeout?setTimeout:void 0,Nk="function"===typeof clearTimeout?
|
||||||
|
clearTimeout:void 0,$i="function"===typeof Promise?Promise:void 0,Jk="function"===typeof queueMicrotask?queueMicrotask:"undefined"!==typeof $i?function(a){return $i.resolve(null).then(a).catch(ik)}:Rf,Zb=Math.random().toString(36).slice(2),Da="__reactFiber$"+Zb,uc="__reactProps$"+Zb,Ja="__reactContainer$"+Zb,Me="__reactEvents$"+Zb,Dk="__reactListeners$"+Zb,Ek="__reactHandles$"+Zb,Se=[],Mb=-1,cb={},J=bb(cb),S=bb(!1),pb=cb,La=null,md=!1,Te=!1,Ob=[],Pb=0,od=null,nd=0,na=[],oa=0,rb=null,Ma=1,Na="",la=
|
||||||
|
null,fa=null,D=!1,wa=null,Ik=Sa.ReactCurrentBatchConfig,Vb=Dh(!0),li=Dh(!1),ud=bb(null),td=null,Rb=null,bf=null,tb=null,kk=Oa,gb=!1,wc={},Ea=bb(wc),yc=bb(wc),xc=bb(wc),F=bb(0),kf=[],yd=Sa.ReactCurrentDispatcher,sf=Sa.ReactCurrentBatchConfig,vb=0,C=null,K=null,N=null,Ad=!1,zc=!1,Ac=0,ml=0,zd={readContext:qa,useCallback:V,useContext:V,useEffect:V,useImperativeHandle:V,useInsertionEffect:V,useLayoutEffect:V,useMemo:V,useReducer:V,useRef:V,useState:V,useDebugValue:V,useDeferredValue:V,useTransition:V,
|
||||||
|
useMutableSource:V,useSyncExternalStore:V,useId:V,unstable_isNewReconciler:!1},lk={readContext:qa,useCallback:function(a,b){Fa().memoizedState=[a,void 0===b?null:b];return a},useContext:qa,useEffect:Sh,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Bd(4194308,4,Vh.bind(null,b,a),c)},useLayoutEffect:function(a,b){return Bd(4194308,4,a,b)},useInsertionEffect:function(a,b){return Bd(4,2,a,b)},useMemo:function(a,b){var c=Fa();b=void 0===b?null:b;a=a();c.memoizedState=
|
||||||
|
[a,b];return a},useReducer:function(a,b,c){var d=Fa();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=qk.bind(null,C,a);return[d.memoizedState,a]},useRef:function(a){var b=Fa();a={current:a};return b.memoizedState=a},useState:Qh,useDebugValue:rf,useDeferredValue:function(a){return Fa().memoizedState=a},useTransition:function(){var a=Qh(!1),b=a[0];a=pk.bind(null,a[1]);Fa().memoizedState=
|
||||||
|
a;return[b,a]},useMutableSource:function(a,b,c){},useSyncExternalStore:function(a,b,c){var d=C,e=Fa();if(D){if(void 0===c)throw Error(m(407));c=c()}else{c=b();if(null===O)throw Error(m(349));0!==(vb&30)||Nh(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;Sh(Lh.bind(null,d,f,a),[a]);d.flags|=2048;Cc(9,Mh.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Fa(),b=O.identifierPrefix;if(D){var c=Na;var d=Ma;c=(d&~(1<<32-ta(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Ac++;0<c&&
|
||||||
|
(b+="H"+c.toString(32));b+=":"}else c=ml++,b=":"+b+"r"+c.toString(32)+":";return a.memoizedState=b},unstable_isNewReconciler:!1},mk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:of,useRef:Rh,useState:function(a){return of(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return Zh(b,K.memoizedState,a)},useTransition:function(){var a=of(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,
|
||||||
|
useSyncExternalStore:Kh,useId:$h,unstable_isNewReconciler:!1},nk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:pf,useRef:Rh,useState:function(a){return pf(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return null===K?b.memoizedState=a:Zh(b,K.memoizedState,a)},useTransition:function(){var a=pf(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,useSyncExternalStore:Kh,useId:$h,
|
||||||
|
unstable_isNewReconciler:!1},Dd={isMounted:function(a){return(a=a._reactInternals)?nb(a)===a:!1},enqueueSetState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueReplaceState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.tag=1;f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueForceUpdate:function(a,b){a=a._reactInternals;
|
||||||
|
var c=Z(),d=hb(a),e=Pa(c,d);e.tag=2;void 0!==b&&null!==b&&(e.callback=b);b=fb(a,e,d);null!==b&&(xa(b,a,d,c),vd(b,a,d))}},rk="function"===typeof WeakMap?WeakMap:Map,tk=Sa.ReactCurrentOwner,ha=!1,Cf={dehydrated:null,treeContext:null,retryLane:0};var zk=function(a,b,c,d){for(c=b.child;null!==c;){if(5===c.tag||6===c.tag)a.appendChild(c.stateNode);else if(4!==c.tag&&null!==c.child){c.child.return=c;c=c.child;continue}if(c===b)break;for(;null===c.sibling;){if(null===c.return||c.return===b)return;c=c.return}c.sibling.return=
|
||||||
|
c.return;c=c.sibling}};var xi=function(a,b){};var yk=function(a,b,c,d,e){var f=a.memoizedProps;if(f!==d){a=b.stateNode;ub(Ea.current);e=null;switch(c){case "input":f=ke(a,f);d=ke(a,d);e=[];break;case "select":f=E({},f,{value:void 0});d=E({},d,{value:void 0});e=[];break;case "textarea":f=ne(a,f);d=ne(a,d);e=[];break;default:"function"!==typeof f.onClick&&"function"===typeof d.onClick&&(a.onclick=kd)}pe(c,d);var g;c=null;for(l in f)if(!d.hasOwnProperty(l)&&f.hasOwnProperty(l)&&null!=f[l])if("style"===
|
||||||
|
l){var h=f[l];for(g in h)h.hasOwnProperty(g)&&(c||(c={}),c[g]="")}else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&($b.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in d){var k=d[l];h=null!=f?f[l]:void 0;if(d.hasOwnProperty(l)&&k!==h&&(null!=k||null!=h))if("style"===l)if(h){for(g in h)!h.hasOwnProperty(g)||k&&k.hasOwnProperty(g)||(c||(c={}),c[g]="");for(g in k)k.hasOwnProperty(g)&&h[g]!==k[g]&&(c||
|
||||||
|
(c={}),c[g]=k[g])}else c||(e||(e=[]),e.push(l,c)),c=k;else"dangerouslySetInnerHTML"===l?(k=k?k.__html:void 0,h=h?h.__html:void 0,null!=k&&h!==k&&(e=e||[]).push(l,k)):"children"===l?"string"!==typeof k&&"number"!==typeof k||(e=e||[]).push(l,""+k):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&($b.hasOwnProperty(l)?(null!=k&&"onScroll"===l&&B("scroll",a),e||h===k||(e=[])):(e=e||[]).push(l,k))}c&&(e=e||[]).push("style",c);var l=e;if(b.updateQueue=l)b.flags|=4}};var Ak=function(a,
|
||||||
|
b,c,d){c!==d&&(b.flags|=4)};var Jd=!1,X=!1,Fk="function"===typeof WeakSet?WeakSet:Set,l=null,zi=!1,T=null,za=!1,Mk=Math.ceil,Od=Sa.ReactCurrentDispatcher,Uf=Sa.ReactCurrentOwner,ca=Sa.ReactCurrentBatchConfig,p=0,O=null,H=null,U=0,ba=0,Ga=bb(0),L=0,Jc=null,ra=0,Md=0,Sf=0,Kc=null,ja=null,Of=0,Hf=Infinity,Ra=null,Ed=!1,xf=null,ib=null,Pd=!1,lb=null,Qd=0,Ic=0,Pf=null,Kd=-1,Ld=0;var Qk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||S.current)ha=!0;else{if(0===(a.lanes&c)&&0===(b.flags&
|
||||||
|
128))return ha=!1,wk(a,b,c);ha=0!==(a.flags&131072)?!0:!1}else ha=!1,D&&0!==(b.flags&1048576)&&yh(b,nd,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;Fd(a,b);a=b.pendingProps;var e=Nb(b,J.current);Sb(b,c);e=mf(null,b,d,a,e,c);var f=nf();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=null,ea(d)?(f=!0,ld(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ff(b),e.updater=Dd,b.stateNode=
|
||||||
|
e,e._reactInternals=b,uf(b,d,a,c),b=Af(null,b,d,!0,f,c)):(b.tag=0,D&&f&&Ue(b),aa(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{Fd(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Uk(d);a=ya(d,a);switch(e){case 0:b=zf(null,b,d,a,c);break a;case 1:b=ri(null,b,d,a,c);break a;case 11:b=mi(null,b,d,a,c);break a;case 14:b=ni(null,b,d,ya(d.type,a),c);break a}throw Error(m(306,d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),zf(a,b,d,e,c);
|
||||||
|
case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),ri(a,b,d,e,c);case 3:a:{si(b);if(null===a)throw Error(m(387));d=b.pendingProps;f=b.memoizedState;e=f.element;Fh(a,b);wd(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){e=Ub(Error(m(423)),b);b=ti(a,b,d,c,e);break a}else if(d!==e){e=
|
||||||
|
Ub(Error(m(424)),b);b=ti(a,b,d,c,e);break a}else for(fa=Ka(b.stateNode.containerInfo.firstChild),la=b,D=!0,wa=null,c=li(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Qb();if(d===e){b=Qa(a,b,c);break a}aa(a,b,d,c)}b=b.child}return b;case 5:return Ih(b),null===a&&Xe(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Qe(d,e)?g=null:null!==f&&Qe(d,f)&&(b.flags|=32),qi(a,b),aa(a,b,g,c),b.child;case 6:return null===a&&Xe(b),null;case 13:return ui(a,b,c);case 4:return gf(b,
|
||||||
|
b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Vb(b,null,d,c):aa(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),mi(a,b,d,e,c);case 7:return aa(a,b,b.pendingProps,c),b.child;case 8:return aa(a,b,b.pendingProps.children,c),b.child;case 12:return aa(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;g=e.value;y(ud,d._currentValue);d._currentValue=g;if(null!==f)if(ua(f.value,g)){if(f.children===
|
||||||
|
e.children&&!S.current){b=Qa(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=Pa(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var p=l.pending;null===p?k.next=k:(k.next=p.next,p.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);df(f.return,c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===
|
||||||
|
f.tag){g=f.return;if(null===g)throw Error(m(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);df(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}aa(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Sb(b,c),e=qa(e),d=d(e),b.flags|=1,aa(a,b,d,c),b.child;case 14:return d=b.type,e=ya(d,b.pendingProps),e=ya(d.type,e),ni(a,b,d,e,c);case 15:return oi(a,
|
||||||
|
b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),Fd(a,b),b.tag=1,ea(d)?(a=!0,ld(b)):a=!1,Sb(b,c),ei(b,d,e),uf(b,d,e,c),Af(null,b,d,!0,a,c);case 19:return wi(a,b,c);case 22:return pi(a,b,c)}throw Error(m(156,b.tag));};var pa=function(a,b,c,d){return new Tk(a,b,c,d)},aj="function"===typeof reportError?reportError:function(a){console.error(a)};Ud.prototype.render=Xf.prototype.render=function(a){var b=this._internalRoot;if(null===b)throw Error(m(409));
|
||||||
|
Sd(a,b,null,null)};Ud.prototype.unmount=Xf.prototype.unmount=function(){var a=this._internalRoot;if(null!==a){this._internalRoot=null;var b=a.containerInfo;yb(function(){Sd(null,a,null,null)});b[Ja]=null}};Ud.prototype.unstable_scheduleHydration=function(a){if(a){var b=nl();a={blockedOn:null,target:a,priority:b};for(var c=0;c<Ya.length&&0!==b&&b<Ya[c].priority;c++);Ya.splice(c,0,a);0===c&&Hg(a)}};var Cj=function(a){switch(a.tag){case 3:var b=a.stateNode;if(b.current.memoizedState.isDehydrated){var c=
|
||||||
|
hc(b.pendingLanes);0!==c&&(xe(b,c|1),ia(b,P()),0===(p&6)&&(Hc(),db()))}break;case 13:yb(function(){var b=Oa(a,1);if(null!==b){var c=Z();xa(b,a,1,c)}}),Wf(a,1)}};var Gg=function(a){if(13===a.tag){var b=Oa(a,134217728);if(null!==b){var c=Z();xa(b,a,134217728,c)}Wf(a,134217728)}};var xj=function(a){if(13===a.tag){var b=hb(a),c=Oa(a,b);if(null!==c){var d=Z();xa(c,a,b,d)}Wf(a,b)}};var nl=function(){return z};var wj=function(a,b){var c=z;try{return z=a,b()}finally{z=c}};se=function(a,b,c){switch(b){case "input":le(a,
|
||||||
|
c);b=c.name;if("radio"===c.type&&null!=b){for(c=a;c.parentNode;)c=c.parentNode;c=c.querySelectorAll("input[name="+JSON.stringify(""+b)+'][type="radio"]');for(b=0;b<c.length;b++){var d=c[b];if(d!==a&&d.form===a.form){var e=Rc(d);if(!e)throw Error(m(90));jg(d);le(d,e)}}}break;case "textarea":og(a,c);break;case "select":b=c.value,null!=b&&Db(a,!!c.multiple,b,!1)}};(function(a,b,c){xg=a;yg=c})(Tf,function(a,b,c,d,e){var f=z,g=ca.transition;try{return ca.transition=null,z=1,a(b,c,d,e)}finally{z=f,ca.transition=
|
||||||
|
g,0===p&&Hc()}},yb);var ol={usingClientEntryPoint:!1,Events:[ec,Ib,Rc,ug,vg,Tf]};(function(a){a={bundleType:a.bundleType,version:a.version,rendererPackageName:a.rendererPackageName,rendererConfig:a.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:Sa.ReactCurrentDispatcher,findHostInstanceByFiber:Xk,
|
||||||
|
findFiberByHostInstance:a.findFiberByHostInstance||Yk,findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:"18.3.1"};if("undefined"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)a=!1;else{var b=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(b.isDisabled||!b.supportsFiber)a=!0;else{try{Uc=b.inject(a),Ca=b}catch(c){}a=b.checkDCE?!0:!1}}return a})({findFiberByHostInstance:ob,bundleType:0,version:"18.3.1-next-f1338f8080-20240426",
|
||||||
|
rendererPackageName:"react-dom"});Q.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=ol;Q.createPortal=function(a,b){var c=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Yf(b))throw Error(m(200));return Wk(a,b,null,c)};Q.createRoot=function(a,b){if(!Yf(a))throw Error(m(299));var c=!1,d="",e=aj;null!==b&&void 0!==b&&(!0===b.unstable_strictMode&&(c=!0),void 0!==b.identifierPrefix&&(d=b.identifierPrefix),void 0!==b.onRecoverableError&&(e=b.onRecoverableError));b=Vf(a,1,!1,null,null,
|
||||||
|
c,!1,d,e);a[Ja]=b.current;sc(8===a.nodeType?a.parentNode:a);return new Xf(b)};Q.findDOMNode=function(a){if(null==a)return null;if(1===a.nodeType)return a;var b=a._reactInternals;if(void 0===b){if("function"===typeof a.render)throw Error(m(188));a=Object.keys(a).join(",");throw Error(m(268,a));}a=Bg(b);a=null===a?null:a.stateNode;return a};Q.flushSync=function(a){return yb(a)};Q.hydrate=function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!0,c)};Q.hydrateRoot=function(a,b,c){if(!Yf(a))throw Error(m(405));
|
||||||
|
var d=null!=c&&c.hydratedSources||null,e=!1,f="",g=aj;null!==c&&void 0!==c&&(!0===c.unstable_strictMode&&(e=!0),void 0!==c.identifierPrefix&&(f=c.identifierPrefix),void 0!==c.onRecoverableError&&(g=c.onRecoverableError));b=Wi(b,null,a,1,null!=c?c:null,e,!1,f,g);a[Ja]=b.current;sc(a);if(d)for(a=0;a<d.length;a++)c=d[a],e=c._getVersion,e=e(c._source),null==b.mutableSourceEagerHydrationData?b.mutableSourceEagerHydrationData=[c,e]:b.mutableSourceEagerHydrationData.push(c,e);return new Ud(b)};Q.render=
|
||||||
|
function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!1,c)};Q.unmountComponentAtNode=function(a){if(!Vd(a))throw Error(m(40));return a._reactRootContainer?(yb(function(){Wd(null,null,a,!1,function(){a._reactRootContainer=null;a[Ja]=null})}),!0):!1};Q.unstable_batchedUpdates=Tf;Q.unstable_renderSubtreeIntoContainer=function(a,b,c,d){if(!Vd(c))throw Error(m(200));if(null==a||void 0===a._reactInternals)throw Error(m(38));return Wd(a,b,c,!1,d)};Q.version="18.3.1-next-f1338f8080-20240426"});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
(function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
|
||||||
|
e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
|
||||||
|
function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
|
||||||
|
N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
|
||||||
|
("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
|
||||||
|
0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
|
||||||
|
function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
|
||||||
|
k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
|
||||||
|
ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
|
||||||
|
b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
|
||||||
|
ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
|
||||||
|
void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
|
||||||
|
ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
|
||||||
|
b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
|
||||||
|
k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
|
||||||
|
ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
|
||||||
|
t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
|
||||||
|
Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
|
||||||
|
render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
|
||||||
|
c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
|
||||||
|
c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
|
||||||
|
})();
|
||||||
@@ -3,7 +3,6 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
from celery import Celery
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
||||||
from app.core.audio_editor import (
|
from app.core.audio_editor import (
|
||||||
@@ -13,23 +12,38 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
celery_app = Celery(
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Task layer 2 che do:
|
||||||
|
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
|
||||||
|
# - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
|
||||||
|
# (thread nen + registry dict), API contract GIONG het (.delay() tra
|
||||||
|
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from celery import Celery
|
||||||
|
HAS_CELERY = True
|
||||||
|
except Exception: # pragma: no cover - frozen desktop slim build
|
||||||
|
Celery = None
|
||||||
|
HAS_CELERY = False
|
||||||
|
|
||||||
|
if HAS_CELERY:
|
||||||
|
celery_app = Celery(
|
||||||
"audio_tasks",
|
"audio_tasks",
|
||||||
broker=settings.CELERY_BROKER_URL,
|
broker=settings.CELERY_BROKER_URL,
|
||||||
backend=settings.CELERY_RESULT_BACKEND
|
backend=settings.CELERY_RESULT_BACKEND
|
||||||
)
|
)
|
||||||
|
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
task_serializer="json",
|
task_serializer="json",
|
||||||
accept_content=["json"],
|
accept_content=["json"],
|
||||||
result_serializer="json",
|
result_serializer="json",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
||||||
# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker.
|
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
|
||||||
if os.getenv("SF_DESKTOP") == "1":
|
if os.getenv("SF_DESKTOP") == "1":
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
task_always_eager=True,
|
task_always_eager=True,
|
||||||
task_eager_propagates=True,
|
task_eager_propagates=True,
|
||||||
@@ -37,16 +51,85 @@ if os.getenv("SF_DESKTOP") == "1":
|
|||||||
result_backend="cache+memory://",
|
result_backend="cache+memory://",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
# ── Lich trinh tu dong don dep file het han (Week 5) ──
|
||||||
celery_app.conf.beat_schedule = {
|
celery_app.conf.beat_schedule = {
|
||||||
"cleanup-expired-files-every-hour": {
|
"cleanup-expired-files-every-hour": {
|
||||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||||
"schedule": 3600.0, # Chạy mỗi giờ
|
"schedule": 3600.0, # Chay moi gio
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
celery_app = None
|
||||||
|
# Registry in-process cho desktop slim: task_id -> {"status", "result"/"error"}
|
||||||
|
_results = {}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
def _task(fn):
|
||||||
|
"""Wrapper: celery task (server) hoac in-process task (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
return celery_app.task(fn)
|
||||||
|
return _InProcessTask(fn)
|
||||||
|
|
||||||
|
|
||||||
|
class _InProcessTask:
|
||||||
|
"""Task chay tren thread nen, ket qua luu vao registry dict — dung cho
|
||||||
|
bundle desktop khong kem celery (tiet kiem ~40MB)."""
|
||||||
|
|
||||||
|
def __init__(self, fn):
|
||||||
|
self._fn = fn
|
||||||
|
|
||||||
|
def delay(self, *args, **kwargs):
|
||||||
|
import threading
|
||||||
|
tid = uuid.uuid4().hex
|
||||||
|
_results[tid] = {"status": "PENDING"}
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
result = self._fn(*args, **kwargs)
|
||||||
|
_results[tid] = {"status": "SUCCESS", "result": result}
|
||||||
|
except Exception as e: # noqa: BLE001 - bao loi day du cho UI
|
||||||
|
logger.exception("In-process task %s failed", tid)
|
||||||
|
_results[tid] = {"status": "FAILURE", "error": str(e)}
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"task-{tid[:8]}").start()
|
||||||
|
return _SimpleAsyncResult(tid)
|
||||||
|
|
||||||
|
|
||||||
|
class _SimpleAsyncResult:
|
||||||
|
"""Giong celery.result.AsyncResult ve mat API cho desktop slim."""
|
||||||
|
|
||||||
|
def __init__(self, task_id):
|
||||||
|
self.task_id = task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
"""Giong celery.result.AsyncResult.id — audio.py dung task.id."""
|
||||||
|
return self.task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status", "PENDING")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self):
|
||||||
|
return _results.get(self.task_id, {}).get("result")
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status") in ("SUCCESS", "FAILURE")
|
||||||
|
|
||||||
|
def successful(self):
|
||||||
|
return self.status == "SUCCESS"
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_result(task_id):
|
||||||
|
"""Tra AsyncResult (celery) hoac _SimpleAsyncResult (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
return AsyncResult(task_id, app=celery_app)
|
||||||
|
return _SimpleAsyncResult(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@_task
|
||||||
def analyze_audio_task(file_id: str):
|
def analyze_audio_task(file_id: str):
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -54,7 +137,7 @@ def analyze_audio_task(file_id: str):
|
|||||||
return analyze_audio(file_path)
|
return analyze_audio(file_path)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
||||||
model: str = "deepseek-chat"):
|
model: str = "deepseek-chat"):
|
||||||
"""
|
"""
|
||||||
@@ -78,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def edit_audio_task(config: dict):
|
def edit_audio_task(config: dict):
|
||||||
file_id = config.get("file_id")
|
file_id = config.get("file_id")
|
||||||
|
|
||||||
@@ -98,7 +181,7 @@ def edit_audio_task(config: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def export_audio_task(file_id: str, format: str = "wav",
|
def export_audio_task(file_id: str, format: str = "wav",
|
||||||
sample_rate: int = 44100, bit_depth: int = 16):
|
sample_rate: int = 44100, bit_depth: int = 16):
|
||||||
"""
|
"""
|
||||||
@@ -131,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def mix_multitrack_task(session_config: dict):
|
def mix_multitrack_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||||
@@ -184,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def process_multitrack_session_task(session_config: dict):
|
def process_multitrack_session_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý toàn bộ session với nhiều tracks và clips.
|
Task xử lý toàn bộ session với nhiều tracks và clips.
|
||||||
@@ -280,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def cleanup_expired_files_task(max_age_hours: int = 24):
|
def cleanup_expired_files_task(max_age_hours: int = 24):
|
||||||
"""
|
"""
|
||||||
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
||||||
@@ -289,6 +372,34 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
max_age_seconds = max_age_hours * 3600
|
max_age_seconds = max_age_hours * 3600
|
||||||
|
|
||||||
|
# Bug #3: KHÔNG xóa file đang được project tham chiếu (tracks[].serverFileId)
|
||||||
|
# — trước đây xóa mọi file > 24h → mất audio của project đã lưu (JSON còn,
|
||||||
|
# file mất, track câm). Quét DB giống list_user_files.
|
||||||
|
referenced = set()
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
from app.models.user import get_db_connection
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT data_json FROM projects")
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
try:
|
||||||
|
proj = _json.loads(row["data_json"])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for track in proj.get("tracks", []):
|
||||||
|
fid = track.get("serverFileId")
|
||||||
|
if fid:
|
||||||
|
referenced.add(os.path.basename(fid.replace("\\", "/")))
|
||||||
|
# Clip audio (kể cả clip sinh từ MIDI render) cũng giữ file
|
||||||
|
for clip in track.get("clips", []):
|
||||||
|
cfid = clip.get("serverFileId")
|
||||||
|
if cfid:
|
||||||
|
referenced.add(os.path.basename(cfid.replace("\\", "/")))
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("cleanup: không đọc được projects DB, bỏ qua tham chiếu: %s", e)
|
||||||
|
|
||||||
cleaned_count = 0
|
cleaned_count = 0
|
||||||
cleaned_size = 0
|
cleaned_size = 0
|
||||||
|
|
||||||
@@ -298,6 +409,8 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
|
|
||||||
for filepath in glob.glob(os.path.join(directory, "*")):
|
for filepath in glob.glob(os.path.join(directory, "*")):
|
||||||
if os.path.isfile(filepath):
|
if os.path.isfile(filepath):
|
||||||
|
if os.path.basename(filepath) in referenced:
|
||||||
|
continue
|
||||||
file_age = now - os.path.getmtime(filepath)
|
file_age = now - os.path.getmtime(filepath)
|
||||||
if file_age > max_age_seconds:
|
if file_age > max_age_seconds:
|
||||||
file_size = os.path.getsize(filepath)
|
file_size = os.path.getsize(filepath)
|
||||||
@@ -311,11 +424,12 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
return {
|
return {
|
||||||
"cleaned_files": cleaned_count,
|
"cleaned_files": cleaned_count,
|
||||||
"cleaned_size_mb": round(cleaned_size / (1024 * 1024), 2),
|
"cleaned_size_mb": round(cleaned_size / (1024 * 1024), 2),
|
||||||
"max_age_hours": max_age_hours
|
"max_age_hours": max_age_hours,
|
||||||
|
"referenced_files_kept": len(referenced),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
||||||
"""
|
"""
|
||||||
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
||||||
|
|||||||
@@ -25,17 +25,24 @@
|
|||||||
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
|
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Bug #11: vendor local — trước đây load CDN (unpkg/cdn.tailwindcss/cdnjs) →
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
desktop Tauri offline trắng màn hình. Các file này nằm /static/vendor/. -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<script src="/static/vendor/tailwindcdn.js"></script>
|
||||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
<script src="/static/vendor/lucide.min.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
<link rel="stylesheet" href="/static/vendor/fontawesome.min.css">
|
||||||
|
<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/fluidsynthLoader.js?v=202607271245"></script>
|
||||||
<script src="/static/js/services/api.js?v=202607271016"></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/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||||
|
<script src="/static/js/services/nativeBridgeService.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/bridgeAudioNode.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/unifiedMidiRouter.js?v=202608112200"></script>
|
||||||
|
<script src="/static/js/services/audioRoutingEngine.js?v=202608112200"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
@@ -43,7 +50,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608081600" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608102202" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# build_native_bridge.ps1 - Build daw_vst_bridge.exe (C++ Native Host Bridge)
|
||||||
|
# Run on Windows x64 with VS Build Tools 2022 (C++ workload) installed.
|
||||||
|
# ASCII only (PowerShell 5.1 reads .ps1 without BOM as ANSI).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
$REPO_ROOT = Resolve-Path (Join-Path $PSScriptRoot "..\..") # ...\SonicForgeStudio
|
||||||
|
$NB_DIR = Join-Path $REPO_ROOT "native_bridge"
|
||||||
|
|
||||||
|
# 1. vcpkg (bootstrap once; skip if already present)
|
||||||
|
$VCPKG_ROOT = Join-Path $env:USERPROFILE "vcpkg"
|
||||||
|
if (-not (Test-Path (Join-Path $VCPKG_ROOT "vcpkg.exe"))) {
|
||||||
|
Write-Host "== [1/6] Bootstrap vcpkg =="
|
||||||
|
git clone https://github.com/microsoft/vcpkg $VCPKG_ROOT
|
||||||
|
& (Join-Path $VCPKG_ROOT "bootstrap-vcpkg.bat") -disableMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. vcpkg deps: fluidsynth + sfizz (+ pkgconf so pkg_check_modules may work)
|
||||||
|
Write-Host "== [2/6] vcpkg install fluidsynth sfizz =="
|
||||||
|
& (Join-Path $VCPKG_ROOT "vcpkg.exe") install fluidsynth sfizz --triplet x64-windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: vcpkg install failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 3. VST3 SDK submodule (Steinberg; NOT in vcpkg)
|
||||||
|
Write-Host "== [3/6] VST3 SDK (submodule) =="
|
||||||
|
if (-not (Test-Path (Join-Path $NB_DIR "vst3sdk"))) {
|
||||||
|
Push-Location $NB_DIR
|
||||||
|
git submodule add https://github.com/steinbergmedia/vst3sdk.git vst3sdk
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Configure with CMake + vcpkg toolchain (MSVC)
|
||||||
|
Write-Host "== [4/6] CMake configure =="
|
||||||
|
$BUILD_DIR = Join-Path $NB_DIR "build"
|
||||||
|
New-Item -ItemType Directory -Force $BUILD_DIR | Out-Null
|
||||||
|
cmake -S $NB_DIR -B $BUILD_DIR `
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE=(Join-Path $VCPKG_ROOT "scripts\buildsystems\vcpkg.cmake") `
|
||||||
|
-DCMAKE_BUILD_TYPE=Release `
|
||||||
|
-DVCPKG_TARGET_TRIPLET=x64-windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake configure failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 5. Build Release
|
||||||
|
Write-Host "== [5/6] CMake build =="
|
||||||
|
cmake --build $BUILD_DIR --config Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: cmake build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
# 6. Copy sidecar to Tauri externalBin location
|
||||||
|
Write-Host "== [6/6] Copy to src-tauri\binaries =="
|
||||||
|
$BIN_DIR = Join-Path $REPO_ROOT "src-tauri\binaries"
|
||||||
|
New-Item -ItemType Directory -Force $BIN_DIR | Out-Null
|
||||||
|
$EXE = Join-Path $BUILD_DIR "Release\daw_vst_bridge.exe"
|
||||||
|
if (-not (Test-Path $EXE)) { Write-Host "ERROR: $EXE not found" -ForegroundColor Red; exit 1 }
|
||||||
|
Copy-Item $EXE (Join-Path $BIN_DIR "daw_vst_bridge-x86_64-pc-windows-msvc.exe") -Force
|
||||||
|
Write-Host "== DONE: daw_vst_bridge.exe copied to src-tauri\binaries =="
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe + daw_vst_bridge.exe + Tauri (NSIS+MSI)
|
||||||
|
# Run on Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# ASCII only (PowerShell 5.1 ANSI limitation).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location (Join-Path $PSScriptRoot "..\..") # repo root
|
||||||
|
|
||||||
|
Write-Host "== [0/7] Kill old sidecars (daw_engine, daw_vst_bridge) =="
|
||||||
|
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-Process -Name "daw_vst_bridge" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
Write-Host "== [1/7] Python dependencies =="
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||||
|
|
||||||
|
Write-Host "== [2/7] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) { npm install @babel/standalone --no-audit --no-fund }
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) { Write-Host "ERROR: @babel/standalone missing" -ForegroundColor Red; exit 1 }
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
Write-Host "== [3/7] Build daw_engine.exe (PyInstaller ONEDIR) =="
|
||||||
|
pyinstaller engine.spec --clean --noconfirm
|
||||||
|
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($specContent -notmatch "collect_data_files\('app'") { Write-Host "ERROR: engine.spec too old" -ForegroundColor Red; exit 1 }
|
||||||
|
python tools\verify_bundle.py
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: bundle missing assets" -ForegroundColor Red; exit 1 }
|
||||||
|
|
||||||
|
Write-Host "== [4/7] Copy engine onedir -> src-tauri\resources\daw_engine =="
|
||||||
|
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) { Write-Host "ERROR: onedir engine missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (Test-Path "src-tauri\resources\daw_engine") { Remove-Item -Recurse -Force "src-tauri\resources\daw_engine" }
|
||||||
|
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
||||||
|
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||||
|
|
||||||
|
Write-Host "== [5/7] Build native bridge (daw_vst_bridge.exe) =="
|
||||||
|
& (Join-Path $PSScriptRoot "build_native_bridge.ps1")
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: native bridge build failed" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) {
|
||||||
|
Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [6/7] VC++ Redistributable for hooks.nsh =="
|
||||||
|
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||||
|
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [7/7] Tauri build (NSIS + MSI) =="
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) { Write-Host "ERROR: engine resource missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) { Write-Host "ERROR: engine _internal missing" -ForegroundColor Red; exit 1 }
|
||||||
|
if (-not (Test-Path "src-tauri\binaries\daw_vst_bridge-x86_64-pc-windows-msvc.exe")) { Write-Host "ERROR: bridge externalBin missing" -ForegroundColor Red; exit 1 }
|
||||||
|
python tools\verify_bundle.py --check-bridge
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: bridge sidecar missing" -ForegroundColor Red; exit 1 }
|
||||||
|
npm install -D @tauri-apps/cli
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== DONE =="
|
||||||
|
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
|
||||||
|
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
|
||||||
|
Write-Host " Verify after install: %APPDATA%\SonicForgeDAW\logs\spawn.log exists=True for daw_engine AND daw_vst_bridge"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# test_standalone.ps1 - Post-install verification for Native Host Bridge
|
||||||
|
# Run on the Windows machine AFTER installing the NSIS setup (or from dev tree).
|
||||||
|
# ASCII only (PowerShell 5.1 ANSI limitation).
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
$APPDATA = Join-Path $env:APPDATA "SonicForgeDAW"
|
||||||
|
$LOG_DIR = Join-Path $APPDATA "logs"
|
||||||
|
$BRIDGE_LOG = Join-Path $LOG_DIR "bridge.log"
|
||||||
|
|
||||||
|
Write-Host "== [1/8] daw_vst_bridge.exe process =="
|
||||||
|
$proc = Get-Process -Name "daw_vst_bridge" -ErrorAction SilentlyContinue
|
||||||
|
if ($proc) { Write-Host " OK: running (PID $($proc.Id))" } else { Write-Host " FAIL: not running" }
|
||||||
|
|
||||||
|
Write-Host "== [2/8] daw_engine.exe process =="
|
||||||
|
$eng = Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue
|
||||||
|
if ($eng) { Write-Host " OK: running (PID $($eng.Id))" } else { Write-Host " FAIL: not running" }
|
||||||
|
|
||||||
|
Write-Host "== [3/8] spawn.log (engine + bridge lines) =="
|
||||||
|
$spawn = Join-Path $LOG_DIR "spawn.log"
|
||||||
|
if (Test-Path $spawn) {
|
||||||
|
$content = Get-Content $spawn -Raw
|
||||||
|
if ($content -match "daw_vst_bridge.*exists=True") { Write-Host " OK: bridge found in spawn.log" }
|
||||||
|
else { Write-Host " WARN: no daw_vst_bridge line in spawn.log (old build?)" }
|
||||||
|
if ($content -match "daw_engine.*exists=True") { Write-Host " OK: engine found in spawn.log" }
|
||||||
|
else { Write-Host " FAIL: engine not found in spawn.log" }
|
||||||
|
} else { Write-Host " FAIL: spawn.log missing" }
|
||||||
|
|
||||||
|
Write-Host "== [4/8] Shared Memory mapping SonicForge_DAW_IPC =="
|
||||||
|
# Requires admin? Usually not for read. Use a tiny C# helper to OpenFileMapping.
|
||||||
|
$shmCheck = @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class ShmCheck {
|
||||||
|
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||||
|
static extern IntPtr OpenFileMapping(uint dwDesiredAccess, bool bInheritHandle, string lpName);
|
||||||
|
public static int Check() {
|
||||||
|
IntPtr h = OpenFileMapping(0x6, false, "SonicForge_DAW_IPC"); // FILE_MAP_READ|FILE_MAP_WRITE
|
||||||
|
if (h == IntPtr.Zero) return Marshal.GetLastWin32Error();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
try {
|
||||||
|
Add-Type -TypeDefinition $shmCheck -Language CSharp -ErrorAction Stop
|
||||||
|
$r = [ShmCheck]::Check()
|
||||||
|
if ($r -eq 0) { Write-Host " OK: SonicForge_DAW_IPC exists" } else { Write-Host " FAIL: shared memory not found (win32 err $r)" }
|
||||||
|
} catch { Write-Host " SKIP: cannot check SHM (compile error: $($_.Exception.Message))" }
|
||||||
|
|
||||||
|
Write-Host "== [5/8] Engine HTTP health =="
|
||||||
|
$health = $null
|
||||||
|
for ($p = 8000; $p -le 8010 -and -not $health; $p++) {
|
||||||
|
try { $health = Invoke-RestMethod -Uri "http://127.0.0.1:$p/health" -TimeoutSec 2 } catch {}
|
||||||
|
}
|
||||||
|
if ($health) { Write-Host " OK: engine health on port $p -> $($health | ConvertTo-Json -Compress)" }
|
||||||
|
else { Write-Host " FAIL: no engine on 8000-8010" }
|
||||||
|
|
||||||
|
Write-Host "== [6/8] Bridge status via engine API =="
|
||||||
|
try {
|
||||||
|
$st = Invoke-RestMethod -Uri "http://127.0.0.1:$p/api/v1/bridge/status" -TimeoutSec 3
|
||||||
|
Write-Host " bridge_status: $($st | ConvertTo-Json -Compress)"
|
||||||
|
} catch { Write-Host " WARN: /api/v1/bridge/status not available yet (endpoint added in Giai doan 6)" }
|
||||||
|
|
||||||
|
Write-Host "== [7/8] Audio smoke test: load SF2 + note -> PCM in SHM =="
|
||||||
|
# Requires a test soundfont; adjust path to a real .sf2 on this machine.
|
||||||
|
$sf = "C:\SonicForgeDAW\soundfonts\SGM-V2.01.sf2"
|
||||||
|
if (Test-Path $sf) {
|
||||||
|
try {
|
||||||
|
$body = @{ path = $sf; type = "SF2" } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "http://127.0.0.1:$p/api/v1/bridge/load" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 30 | Out-Null
|
||||||
|
Write-Host " OK: bridge load SF2 requested. Press keys on MIDI keyboard and watch VU meters."
|
||||||
|
} catch { Write-Host " WARN: bridge/load failed ($($_.Exception.Message))" }
|
||||||
|
} else { Write-Host " SKIP: $sf not found - set a real .sf2 path" }
|
||||||
|
|
||||||
|
Write-Host "== [8/8] Manual checklist (spec VII) =="
|
||||||
|
Write-Host " [ ] Load VST3 (Vital.vst3) -> open floating GUI -> tweak knobs"
|
||||||
|
Write-Host " [ ] Load SF2 (SGM-V2.01.sf2) bank 0 prog 0 -> piano"
|
||||||
|
Write-Host " [ ] Load SFZ (SalamanderPiano.sfz) -> samples play"
|
||||||
|
Write-Host " [ ] MIDI keyboard live + Timeline play together -> no choke/dropout"
|
||||||
|
Write-Host " [ ] Track EQ cutoff + Master maximizer affect bridge audio instantly"
|
||||||
|
Write-Host " [ ] Kill daw_vst_bridge.exe -> app falls back to SonicSF WASM (no crash)"
|
||||||
|
Write-Host "== DONE =="
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_linux.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (deb + AppImage)
|
||||||
|
# Chay tren Linux: bash build_linux.sh
|
||||||
|
# Yeu cau: python3, pip, node/npm, rust/cargo, webkit2gtk-4.1, libappindicator,
|
||||||
|
# librsvg (xem README / DISTRIBUTION_PLAN.md)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
# PyInstaller tren Linux can objdump (binutils). May build that phai co:
|
||||||
|
# sudo apt-get install -y binutils
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
echo "Thieu @babel/standalone - dang cai them..."
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources truoc khi tauri build =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (deb + AppImage) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " deb : src-tauri/target/release/bundle/deb/sonicforge-daw_1.0.0_amd64.deb"
|
||||||
|
echo " AppImage: src-tauri/target/release/bundle/appimage/SonicForgeDAW_1.0.0_amd64.AppImage"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_macos.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (.app + .dmg)
|
||||||
|
# Chay tren macOS: bash build_macos.sh
|
||||||
|
# LUU Y: macOS yeu cau codesign + notarize truoc khi phat hanh ra ngoai
|
||||||
|
# (Gatekeeper). Xem DISTRIBUTION_PLAN.md.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (dmg) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " dmg: src-tauri/target/release/bundle/dmg/SonicForgeDAW_1.0.0_x64.dmg"
|
||||||
|
echo " (Codesign/notarize: codesign --deep -s \"Developer ID Application: ...\" "
|
||||||
|
echo " src-tauri/target/release/bundle/macos/SonicForgeDAW.app ; xcrun notarytool submit ...)"
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Set-Location $PSScriptRoot
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
|
||||||
|
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
|
||||||
|
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
|
||||||
|
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
|
||||||
|
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
Write-Host "== [1/6] Python dependencies =="
|
Write-Host "== [1/6] Python dependencies =="
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install -r requirements.txt pyinstaller pywin32
|
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||||
@@ -43,9 +50,19 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "== [4/6] Sidecar binary -> src-tauri/binaries (tauri triple naming) =="
|
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
|
||||||
New-Item -ItemType Directory -Force src-tauri\binaries | Out-Null
|
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
|
||||||
Copy-Item dist\daw_engine.exe src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe -Force
|
# Tauri bundle resources -> resource_dir()/daw_engine/daw_engine.exe luc runtime.
|
||||||
|
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) {
|
||||||
|
Write-Host "ERROR: dist\daw_engine\daw_engine.exe khong ton tai (onedir build loi?)" -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 "Copied onedir engine -> src-tauri\resources\daw_engine"
|
||||||
|
|
||||||
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
||||||
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||||
@@ -53,6 +70,16 @@ if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
|
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
|
||||||
|
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
|
||||||
|
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
|
||||||
|
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
|
||||||
|
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
npm install -D @tauri-apps/cli
|
npm install -D @tauri-apps/cli
|
||||||
npx tauri build
|
npx tauri build
|
||||||
|
|
||||||
@@ -60,3 +87,9 @@ Write-Host ""
|
|||||||
Write-Host "== DONE =="
|
Write-Host "== DONE =="
|
||||||
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
|
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 " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== XAC MINH SAU KHI CAI DAT (quan trong) =="
|
||||||
|
Write-Host " Mo %APPDATA%\SonicForgeDAW\logs\spawn.log - phai thay:"
|
||||||
|
Write-Host " [resource_dir/daw_engine (map layout)] ...exists=True"
|
||||||
|
Write-Host " Neu exists=False: bundle resources KHONG vao installer (chay lai buoc [4/6])."
|
||||||
|
Write-Host " Engine phai nam o: <thu muc cai dat>\daw_engine\daw_engine.exe"
|
||||||
|
|||||||
@@ -18,15 +18,41 @@ APP_DATA_DIR_NAME = "SonicForgeDAW"
|
|||||||
def _pick_port():
|
def _pick_port():
|
||||||
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
|
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
# KHONG dung SO_REUSEADDR: tren Windows no CHO PHEP bind trung port
|
||||||
|
# (2 engine cung 8000 -> request roi vao engine ngau nhien -> UI loi).
|
||||||
|
# Listen socket khong can SO_REUSEADDR (TIME_WAIT chi ap dung cho
|
||||||
|
# connection socket, khong phai listen socket).
|
||||||
|
# Bind 0.0.0.0 (GIONG uvicorn) — kiem tra dung port ma server se
|
||||||
|
# dung, truong hop port bi chiem tren interface khac khong sot.
|
||||||
try:
|
try:
|
||||||
s.bind(("127.0.0.1", port))
|
s.bind(("0.0.0.0", port))
|
||||||
return port
|
return port
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
return PORT_RANGE[0]
|
return PORT_RANGE[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_ips():
|
||||||
|
"""IPv4 cua may tren LAN (bo loopback) — client browser o may khac mo app."""
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
def _parent_alive(pid):
|
def _parent_alive(pid):
|
||||||
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
|
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
|
||||||
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
|
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
|
||||||
@@ -49,6 +75,13 @@ def _parent_alive(pid):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
# PHONG THU (fix ban 1.1.2): neu exe bi goi nhu 'python -c ...' — vd code
|
||||||
|
# cu kiem tra thu vien bang subprocess.run([sys.executable, '-c', 'import X'])
|
||||||
|
# thi frozen sys.executable = daw_engine.exe -> se chay CA ENGINE o day.
|
||||||
|
# Thoat ngay, KHONG khoi dong server, tranh de quy spawn vo han.
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "-c":
|
||||||
|
return
|
||||||
|
|
||||||
os.environ.setdefault("SF_DESKTOP", "1")
|
os.environ.setdefault("SF_DESKTOP", "1")
|
||||||
|
|
||||||
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
|
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
|
||||||
@@ -90,7 +123,10 @@ def main():
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
|
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
|
||||||
|
|
||||||
config = uvicorn.Config(app.main.app, host="127.0.0.1", port=port,
|
# Bind 0.0.0.0: client browser o may khac tren LAN mo app qua dia chi IP
|
||||||
|
# (http://<LAN-IP>:port) — lam viec realtime voi may chay standalone.
|
||||||
|
# Cua so Tauri (shell) van mo http://127.0.0.1:port nhu cu.
|
||||||
|
config = uvicorn.Config(app.main.app, host="0.0.0.0", port=port,
|
||||||
log_level="info", access_log=False)
|
log_level="info", access_log=False)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
@@ -107,8 +143,14 @@ def main():
|
|||||||
return
|
return
|
||||||
threading.Thread(target=_watchdog, daemon=True).start()
|
threading.Thread(target=_watchdog, daemon=True).start()
|
||||||
|
|
||||||
logging.getLogger("desktop_engine").info(
|
logger = logging.getLogger("desktop_engine")
|
||||||
"SonicForge engine listening on 127.0.0.1:%d", port)
|
logger.info("SonicForge engine listening on 0.0.0.0:%d", port)
|
||||||
|
lan_ips = _lan_ips()
|
||||||
|
if lan_ips:
|
||||||
|
logger.info("LAN access — mo browser o may khac: %s",
|
||||||
|
" | ".join("http://%s:%d" % (ip, port) for ip in lan_ips))
|
||||||
|
else:
|
||||||
|
logger.warning("Khong phat hien dia chi LAN — client may khac khong truy cap duoc")
|
||||||
server.run()
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-8000}:8000"
|
- "${WEB_PORT:-8000}:8000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Ép nhận diện headless (server docker + browser UI) — soundfont + VSTi
|
||||||
|
# mở được lấy từ storage mount bên dưới; preset VST3 (.vstpreset) upload
|
||||||
|
# qua web UI vào storage/presets (nằm trong volume sf_db).
|
||||||
|
- SF_DOCKER=1
|
||||||
volumes:
|
volumes:
|
||||||
- sf_uploads:/app/app/storage/uploads
|
- sf_uploads:/app/app/storage/uploads
|
||||||
- sf_processed:/app/app/storage/processed
|
- sf_processed:/app/app/storage/processed
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
@@ -25,13 +27,15 @@ services:
|
|||||||
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
@@ -40,12 +44,14 @@ services:
|
|||||||
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python)
|
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
|
||||||
# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows)
|
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
|
||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules, collect_data_files
|
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
|
||||||
|
|
||||||
# Native DLL cho pedalboard va soundfile
|
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
|
||||||
binaries = collect_dynamic_libs('pedalboard')
|
binaries = collect_dynamic_libs('pedalboard')
|
||||||
binaries += collect_dynamic_libs('soundfile')
|
binaries += collect_dynamic_libs('soundfile')
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ binaries += collect_dynamic_libs('soundfile')
|
|||||||
# duoc bundle qua collect_data_files('app').
|
# duoc bundle qua collect_data_files('app').
|
||||||
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
||||||
|
|
||||||
# ⚠️ GOC ROOT CUA MOI LOI 'app\static does not exist' (gap 3 lan):
|
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
|
||||||
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
||||||
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
||||||
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
||||||
@@ -25,8 +25,8 @@ if _SPEC_ROOT not in _sys.path:
|
|||||||
_sys.path.insert(0, _SPEC_ROOT)
|
_sys.path.insert(0, _SPEC_ROOT)
|
||||||
|
|
||||||
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
||||||
# Loai tru storage (57MB soundfonts/uploads — vo ich trong onefile, config.py
|
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
|
||||||
# da chuyen storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
||||||
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
||||||
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
|
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
|
||||||
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
|
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
|
||||||
@@ -41,49 +41,46 @@ datas += [
|
|||||||
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
||||||
]
|
]
|
||||||
|
|
||||||
# librosa 0.11 dùng lazy_loader.attach_stub -> lúc RUNTIME cần file .pyi
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
# ton tai tren disk ('Cannot load imports from non-existent stub ...librosa\__init__.pyi').
|
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
|
||||||
# PyInstaller mac dinh KHONG bundle .pyi -> phai collect explicit.
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
datas += collect_data_files('librosa', includes=['**/*.pyi'])
|
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
|
||||||
|
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
|
||||||
# scipy >= 1.18 tach scipy.stats thanh nhieu module con (vd
|
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
|
||||||
# _ansari_swilk_statistics) import lazy ben trong ham -> hook scipy cua
|
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
|
||||||
# PyInstaller miss -> ModuleNotFoundError luc runtime. Giai phap TRIET DE:
|
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
|
||||||
# scan FILESYSTEM toan bo site-packages/scipy (khong import, khong walk —
|
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
|
||||||
# pkgutil.walk_packages BO QUA AM THAM subpackage import loi luc build,
|
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
|
||||||
# da gap: may user mat ca cay scipy.sparse.csgraph._shortest_path).
|
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
|
||||||
# Bat moi module .py + C-extension .pyd/.so -> hiddenimports day du.
|
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
|
||||||
|
# render_engine, audio_features).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
import importlib.util as _ilu
|
import importlib.util as _ilu
|
||||||
import glob as _glob
|
import glob as _glob
|
||||||
_scipy_spec = _ilu.find_spec('scipy')
|
|
||||||
_scipy_dir = os.path.dirname(os.path.abspath(_scipy_spec.origin))
|
|
||||||
_scipy_hidden = []
|
|
||||||
for _ext in ('*.py', '*.pyd', '*.so'):
|
|
||||||
for _f in _glob.glob(os.path.join(_scipy_dir, '**', _ext), recursive=True):
|
|
||||||
_rel = os.path.relpath(_f, _scipy_dir)
|
|
||||||
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
|
|
||||||
_pkg = os.path.dirname(_rel).replace(os.sep, '.')
|
|
||||||
_mod = ('scipy.' + _pkg + '.' + _base) if _pkg else ('scipy.' + _base)
|
|
||||||
if _mod not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append(_mod)
|
|
||||||
# Cung co bang hiddenimport TINH: _morestats import module nay o top-level
|
|
||||||
# (scipy 1.18+); collect_submodules du phong nhung neu miss (version khac
|
|
||||||
# tren may user) thi dong nay van dam bao bundle co.
|
|
||||||
if 'scipy.stats._ansari_swilk_statistics' not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append('scipy.stats._ansari_swilk_statistics')
|
|
||||||
# scipy.sparse.csgraph cung lazy-import C-extension tu ben trong ham (vd
|
|
||||||
# _shortest_path, _traversal, _matching) — hiddenimport tinh phong walk miss.
|
|
||||||
for _m in ('scipy.sparse.csgraph._shortest_path', 'scipy.sparse.csgraph._traversal',
|
|
||||||
'scipy.sparse.csgraph._matching', 'scipy.sparse.csgraph._min_spanning_tree'):
|
|
||||||
if _m not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append(_m)
|
|
||||||
|
|
||||||
a = Analysis(
|
def _scan_pkg_modules(pkg_name: str):
|
||||||
['desktop_engine.py'],
|
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
|
||||||
pathex=[_SPEC_ROOT],
|
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
|
||||||
binaries=binaries,
|
_spec = _ilu.find_spec(pkg_name)
|
||||||
datas=datas,
|
if _spec is None or _spec.origin is None:
|
||||||
hiddenimports=collect_submodules('celery.fixups') + _scipy_hidden + [
|
print(f"WARN: khong tim thay package '{pkg_name}' - bo qua scan")
|
||||||
|
return []
|
||||||
|
_pkg_dir = os.path.dirname(os.path.abspath(_spec.origin))
|
||||||
|
_out = []
|
||||||
|
for _ext in ('*.py', '*.pyd', '*.so'):
|
||||||
|
for _f in _glob.glob(os.path.join(_pkg_dir, '**', _ext), recursive=True):
|
||||||
|
_rel = os.path.relpath(_f, _pkg_dir)
|
||||||
|
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
|
||||||
|
_sub = os.path.dirname(_rel).replace(os.sep, '.')
|
||||||
|
_mod = (pkg_name + '.' + _sub + '.' + _base) if _sub else (pkg_name + '.' + _base)
|
||||||
|
if _mod not in _out:
|
||||||
|
_out.append(_mod)
|
||||||
|
return _out
|
||||||
|
|
||||||
|
_scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
|
||||||
|
|
||||||
|
# Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
|
||||||
|
_hidden = [
|
||||||
'uvicorn.logging',
|
'uvicorn.logging',
|
||||||
'uvicorn.loops',
|
'uvicorn.loops',
|
||||||
'uvicorn.loops.auto',
|
'uvicorn.loops.auto',
|
||||||
@@ -96,11 +93,35 @@ a = Analysis(
|
|||||||
'soundfile',
|
'soundfile',
|
||||||
'sf2utils',
|
'sf2utils',
|
||||||
'mido.backends.rtmidi',
|
'mido.backends.rtmidi',
|
||||||
],
|
] + _scipy_signal_hidden
|
||||||
|
|
||||||
|
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
|
||||||
|
_excludes = [
|
||||||
|
'tkinter',
|
||||||
|
# libs da thay the (audio_features.py)
|
||||||
|
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
|
||||||
|
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
|
||||||
|
# celery/redis chi dung cho server (Docker), khong cho desktop
|
||||||
|
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
|
||||||
|
'click_didyoumean', 'click_plugins', 'click_repl',
|
||||||
|
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
|
||||||
|
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
|
||||||
|
# khong dung trong desktop
|
||||||
|
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
|
||||||
|
# keo vao nham boi hooks_contrib (app khong import bao gio)
|
||||||
|
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['desktop_engine.py'],
|
||||||
|
pathex=[_SPEC_ROOT],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=_hidden,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=['tkinter'],
|
excludes=_excludes,
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=None,
|
cipher=None,
|
||||||
@@ -112,10 +133,8 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
|||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
name='daw_engine',
|
name='daw_engine',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
@@ -124,5 +143,18 @@ exe = EXE(
|
|||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False, # True khi debug (xem log truc tiep), False cho production
|
console=False, # True khi debug (xem log truc tiep), False cho production
|
||||||
icon='src-tauri/icons/icon.ico',
|
# Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
|
||||||
|
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
|
||||||
|
icon='src-tauri/icons/favicon.ico',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
|
||||||
|
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
|
||||||
|
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
name='daw_engine',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# CARLA BRIDGE — NATIVE GUI VSTi (Windows) + DUAL-MODE RUNTIME (Desktop / Docker Headless)
|
||||||
|
|
||||||
|
> Tài liệu kỹ thuật & vận hành: tích hợp Carla làm host native GUI cho VSTi trên
|
||||||
|
> Windows, tự phát hiện môi trường (desktop / headless), thư viện preset
|
||||||
|
> (.vstpreset) làm cầu nối Carla → pedalboard, và Plugin Manager quản lý
|
||||||
|
> soundfont + instrument.
|
||||||
|
>
|
||||||
|
> Phạm vi code: `app/core/runtime.py`, `app/api/v1/system.py`,
|
||||||
|
> `app/api/v1/presets.py`, `app/api/v1/plugins.py`, `app/core/vst_engine.py`,
|
||||||
|
> `app/core/render_engine.py`, `app/static/js/services/runtime.js`, `app.jsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bối cảnh & quyết định kiến trúc
|
||||||
|
|
||||||
|
| Vấn đề | Quyết định |
|
||||||
|
|---|---|
|
||||||
|
| `pedalboard` **cố tình headless** — không mở được native GUI VSTi | Không thay thế pedalboard; dùng **Carla làm host GUI ngoài** (preview + chỉnh preset), pedalboard giữ nguyên làm **render engine** (offline, cùng code path cho preview & export) |
|
||||||
|
| Browser không chạy được binary `.vst3` (Windows/Linux) | Preview realtime thật chỉ có trong **cửa sổ Carla**; trong web UI dùng **quick-render preview** (pedalboard render clip ngắn → wav — **âm thật = âm export**) |
|
||||||
|
| App chạy 2 môi trường: **Windows desktop** (server+client 1 máy) và **Docker headless** (server + browser UI) | **Runtime profile tự phát hiện** → bật/tắt tính năng theo môi trường (nút Carla Bridge chỉ hiện trên desktop có Carla) |
|
||||||
|
| Carla Windows là **bộ zip portable** — không installer, không PATH | **User tự định vị** thư mục chứa `carla.exe` (Plugin Manager → Định vị Carla...) + tìm kiếm dự phòng (registry, Program Files, quét nông Downloads/Desktop) |
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────── Windows Desktop (1 máy) ──────────────────────────┐
|
||||||
|
│ Browser/Tauri UI ──► FastAPI (localhost:8000) ──► pedalboard (render VST3) │
|
||||||
|
│ │ │ ▲ │
|
||||||
|
│ ▼ ▼ │ load_preset │
|
||||||
|
│ Nút "Carla Bridge" ──► spawn carla.exe (native GUI) │ │
|
||||||
|
│ │ chọn VSTi, chỉnh âm, Save .vstpreset │
|
||||||
|
│ └──────────────► Upload preset ──► storage/presets ─┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌────────────────────────── Docker Headless (server) ─────────────────────────┐
|
||||||
|
│ Browser UI ──► FastAPI (container :8000) ──► pedalboard (VSTi Linux) │
|
||||||
|
│ Soundfont + VSTi mở được: mount volumes (docker-compose.prod.yml) │
|
||||||
|
│ Preset .vstpreset: user chỉnh ở máy desktop → upload qua web UI │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Runtime tự phát hiện môi trường (`app/core/runtime.py`)
|
||||||
|
|
||||||
|
### 2.1 Nguyên tắc
|
||||||
|
|
||||||
|
- **Heuristic + override**: `SF_RUNTIME=auto|desktop|headless` **thắng tuyệt đối**
|
||||||
|
(WSL / docker-in-docker / remote desktop có thể làm heuristic sai).
|
||||||
|
- `SF_DOCKER=1` hoặc tồn tại `/.dockerenv` → nhận diện container.
|
||||||
|
- Linux: không có `DISPLAY` hoặc trong docker → `headless`; ngược lại `desktop`.
|
||||||
|
- Windows/macOS → luôn `desktop`.
|
||||||
|
|
||||||
|
### 2.2 Capabilities API (frontend gọi 1 lần lúc boot)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/system/capabilities (public — cần trước khi đăng nhập)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"runtime": "desktop", // "desktop" | "headless"
|
||||||
|
"platform": "windows", // "windows" | "linux" | "darwin"
|
||||||
|
"docker": false,
|
||||||
|
"features": {
|
||||||
|
"carla_local": true, // có Carla trên máy → hiện nút "Carla Bridge"
|
||||||
|
"carla_path": "D:/Tools/Carla/carla.exe",
|
||||||
|
"preset_upload": true, // luôn true (upload .vstpreset qua web UI)
|
||||||
|
"vst_render": true, // pedalboard khả dụng
|
||||||
|
"tauri_bridge": false,
|
||||||
|
"preview_mode": "quick_render" // "quick_render" | "wasm"
|
||||||
|
},
|
||||||
|
"default_dirs": { "vst": [...], "soundfont": [...], "preset": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Bảng hành vi theo môi trường
|
||||||
|
|
||||||
|
| Tính năng | Windows desktop | Docker headless |
|
||||||
|
|---|---|---|
|
||||||
|
| Render VSTi | pedalboard `VST3Plugin` + `load_preset` | giống hệt (VSTi **bản Linux**) |
|
||||||
|
| Scan VST/SF | thư mục chuẩn Windows + `plugin_dirs.json` | mount volumes + `plugin_dirs.json` |
|
||||||
|
| Preview VSTi | quick-render (âm thật); realtime trong Carla | quick-render |
|
||||||
|
| Nút "Carla Bridge" (nút Synth) | **Hiện** → spawn `carla.exe` | **Ẩn** (không có GUI local) |
|
||||||
|
| Preset | picker local / upload → thư viện | upload → thư viện |
|
||||||
|
| SoundFont preview | FluidSynth WASM (browser) | giống hệt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cài đặt Carla trên Windows (bản portable zip)
|
||||||
|
|
||||||
|
Carla phát hành dạng **zip** (`Carla-2.5.x-win64.zip`) — **không có installer,
|
||||||
|
không ghi PATH**. Không bundle Carla vào installer của app (license GPL-2.0+,
|
||||||
|
xem §8).
|
||||||
|
|
||||||
|
1. Tải: <https://github.com/falkTX/Carla/releases> (bản `win64`).
|
||||||
|
2. Giải nén ra bất kỳ đâu (VD `D:\Tools\Carla\`, chứa `carla.exe`).
|
||||||
|
3. Khai báo 1 lần (2 cách, tùy chọn 1):
|
||||||
|
- **Nút bấm**: Plugin Manager → section **"Carla Bridge (VSTi native GUI)"** →
|
||||||
|
**"Định vị Carla..."** → chọn thư mục chứa `carla.exe` (hoặc chính file).
|
||||||
|
- **Nhập tay**: ô text trong section đó → nhập `D:/Tools/Carla` → **Lưu**.
|
||||||
|
4. App lưu vào `storage/carla_path.json` → cache detect bị xóa → nút
|
||||||
|
**"Carla Bridge"** hiện trong dropdown nút Synth + **tự động mở Carla**
|
||||||
|
khi chọn VSTi.
|
||||||
|
|
||||||
|
### 3.1 Thứ tự phát hiện `carla_local`
|
||||||
|
|
||||||
|
| Ưu tiên | Nguồn |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Config user** (`storage/carla_path.json`) — kênh chính cho bản portable |
|
||||||
|
| 2 | PATH (`shutil.which`) |
|
||||||
|
| 3 | Registry (`HKLM/HKCU\SOFTWARE\Carla\InstallPath`) — nếu cài qua installer |
|
||||||
|
| 4 | Thư mục chuẩn (`Program Files\Carla`, `%LOCALAPPDATA%\Programs\Carla`) |
|
||||||
|
| 5 | **Quét nông** Downloads/Desktop/Documents (độ sâu ≤ 3, bỏ qua `node_modules`, `AppData`, `Windows`...) — không bao giờ quét toàn ổ đĩa |
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/system/carla-path (auth) — lưu vị trí Carla
|
||||||
|
body: { "carla_path": "D:/Tools/Carla" } (thư mục HOẶC file exe)
|
||||||
|
→ trả { success, carla_path, ...capabilities }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Luồng sử dụng end-to-end (Windows)
|
||||||
|
|
||||||
|
1. **Nút Synth** → chọn **VSTi** trong danh sách → app **TỰ ĐỘNG gọi Carla**:
|
||||||
|
sinh file project `.carxs` (định dạng XML chính thức của Carla —
|
||||||
|
`carla.exe [FILE]` nhận project file) chứa node
|
||||||
|
`<Plugin><Info><Type>VST3</Type><Binary>path</Binary>...</Info></Plugin>`
|
||||||
|
→ `carla.exe <project.carxs>` → Carla mở lên **plugin đã load sẵn** kèm
|
||||||
|
**on-screen MIDI keyboard** (`PixmapKeyboard`).
|
||||||
|
- VST3 (file `.vst3` hoặc folder `X.vst3` Windows): auto-load qua `.carxs`.
|
||||||
|
- VST2 (`.dll`/`.so` ngoài `.vst3`): không auto-load tin cậy (cần uniqueID)
|
||||||
|
→ mở Carla trống để user **Add Plugin** thủ công.
|
||||||
|
- Project `.carxs` nằm `{STORAGE_DIR}/carla_projects/`, tự dọn sau 1 ngày.
|
||||||
|
2. Trong Carla: native GUI của VSTi hiện ra → **chọn instrument/preset** của
|
||||||
|
plugin → **bật ARM** trên track trong app (tùy chọn) → **bấm phím trên
|
||||||
|
keyboard ảo của Carla** để preview realtime (âm thật qua audio device).
|
||||||
|
3. **Save preset** bằng nút của CHÍNH plugin (không dùng project save của Carla)
|
||||||
|
→ file `.vstpreset`.
|
||||||
|
4. Trong app: dropdown Synth → **"⬆ Upload preset (từ Carla...)"** → chọn file
|
||||||
|
`.vstpreset` → nằm trong **thư viện preset** → chọn preset trong danh sách
|
||||||
|
**"VST Presets"** → gán vào track (`synth_engine.preset_id`).
|
||||||
|
5. **Render**: `render_engine.py` nạp plugin qua `load_vst()` rồi
|
||||||
|
`apply_preset_to_plugin()` → **âm render = âm đã chỉnh trong Carla**
|
||||||
|
(điều kiện: cùng sample rate — xem §7).
|
||||||
|
|
||||||
|
Lưu ý: danh sách VSTi trong dropdown vẫn cần thiết — dùng để **map tên →
|
||||||
|
đường dẫn khi pedalboard nạp plugin lúc render** (không phải để mở GUI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Thư viện preset — cầu nối Carla ↔ pedalboard
|
||||||
|
|
||||||
|
- Thư mục: `{STORAGE_DIR}/presets` (Windows: `%APPDATA%\SonicForgeDAW\storage\presets`;
|
||||||
|
Docker: nằm trong volume `sf_db` → `/app/app/storage/presets`).
|
||||||
|
- Định dạng hỗ trợ: `.vstpreset` (VST3 — chuẩn), `.fxp`/`.fxb` (VST2, chỉ
|
||||||
|
preview trong Carla), `.dspreset` (DecentSampler/Pianobook).
|
||||||
|
- `synth_engine` của track có thể chứa 1 trong 3 dạng (ưu tiên giảm dần):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "vst3", "plugin_id": "Kontakt 7",
|
||||||
|
"preset_data": "<base64 bytes .vstpreset nhúng — project tự chứa state, portable>",
|
||||||
|
"preset_id": "a1b2c3....vstpreset", // thư viện storage/presets
|
||||||
|
"preset_path": "D:/presets/Piano.vstpreset" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/presets (public) — danh sách
|
||||||
|
POST /api/v1/presets/upload (auth) — multipart file + plugin_hint
|
||||||
|
GET /api/v1/presets/{id}/download
|
||||||
|
DELETE /api/v1/presets/{id} (auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick-render preview (âm thật = âm export)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/plugins/preview
|
||||||
|
body: { instrument_id, notes:[{pitch,start_beat,duration_beats,velocity}],
|
||||||
|
bpm, sample_rate, preset_id?, preset_path?, preset_data? }
|
||||||
|
→ { success, url: "/static/audio/processed/preview_xxx.wav", duration_sec }
|
||||||
|
```
|
||||||
|
|
||||||
|
Cùng code path với export (pedalboard + `load_preset`) → **preview nghe đúng
|
||||||
|
plugin/preset** (khác "Preview Synth WASM" cũ — âm giả).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Plugin Manager — SoundFont: Add Directory → Scan → Instrument → Synth
|
||||||
|
|
||||||
|
1. **Add Directory** (folder picker native / in-app browser) → thư mục vào
|
||||||
|
danh sách (lưu `plugin_dirs.json`).
|
||||||
|
2. **Scan** → quét soundfont trong các thư mục (catalog qua
|
||||||
|
`SoundFontAutoScanner`) + liệt kê VST.
|
||||||
|
3. Bấm vào **một soundfont** (▸) → expand danh sách **instrument bên trong**
|
||||||
|
(Bank/Program/Tên) qua `GET /api/v1/plugins/soundfont-instruments/{sf_id}`.
|
||||||
|
4. Nút **"Chèn vào Synth"** → gán instrument (bank/program) vào **track đang
|
||||||
|
chọn** (`setTrackInstrumentWithProgram`) và đóng Plugin Manager.
|
||||||
|
|
||||||
|
> SF3: instrument đọc sau khi chuyển đổi SF3→SF2 (endpoint download tự chuyển
|
||||||
|
> đổi khi cần). Nếu thiếu libfluidsynth, danh sách trả `[]` (graceful).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lưu ý kỹ thuật & hạn chế (đã xác minh)
|
||||||
|
|
||||||
|
1. **Sample rate**: Carla chạy theo audio device (thường 48 kHz), pedalboard
|
||||||
|
render mặc định 44.1 kHz → plugin phụ thuộc SR (delay/chorus/oversampling)
|
||||||
|
nghe khác. **Render đúng SR của thiết bị Carla** để preview = export.
|
||||||
|
2. **VST2**: pedalboard 0.10+ **đã gỡ hỗ trợ VST2** (chỉ còn VST3) → chỉ preset
|
||||||
|
VST3 (`.vstpreset`) round-trip được. Plugin VST2 cũ: preview được trong
|
||||||
|
Carla nhưng **không render** được qua pedalboard.
|
||||||
|
3. **VSTi trên Docker**: chỉ chạy được plugin có bản **Linux** (`.vst3`/`.so`);
|
||||||
|
plugin Windows-only (`.dll`) không chạy trên server Linux (không khuyến nghị
|
||||||
|
Wine bridge trong container).
|
||||||
|
4. **License Carla GPL-2.0+**: app **không bundle/nhúng** Carla — chỉ spawn
|
||||||
|
tiến trình ngoài + trao đổi file preset (không link code) → không dính
|
||||||
|
copyleft. User tự tải zip.
|
||||||
|
5. **Preview WASM ≠ âm thật**: Preview Synth trong browser không phải plugin
|
||||||
|
thật — dùng quick-render (`/plugins/preview`) nếu cần nghe đúng âm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Checklist QA
|
||||||
|
|
||||||
|
- [ ] Windows: cài Carla zip → Định vị Carla → nút "Carla Bridge" hiện ở nút Synth
|
||||||
|
- [ ] Bấm Carla Bridge → `carla.exe` chạy, mở được VSTi + native GUI
|
||||||
|
- [ ] Save `.vstpreset` từ GUI plugin → Upload trong app → gán vào track → render ra âm đúng preset
|
||||||
|
- [ ] Render cùng SR với Carla → preview (Carla) nghe = âm export
|
||||||
|
- [ ] Plugin Manager: Add Directory → Scan → expand soundfont → "Chèn vào Synth" gán đúng bank/program vào track đang chọn
|
||||||
|
- [ ] Docker (`SF_DOCKER=1`): capabilities `runtime=headless`, không hiện Carla Bridge, preset upload hoạt động, render VSTi Linux + soundfont từ mount OK
|
||||||
|
- [ ] `SF_RUNTIME=desktop` trên Linux có DISPLAY → chạy như desktop
|
||||||
|
- [ ] pytest: `86 passed, 7 skipped`
|
||||||
|
|
||||||
|
## 9. Flow MIDI Keyboard hardware (Web MIDI) — route đầy đủ
|
||||||
|
|
||||||
|
Hardware MIDI keyboard (Web MIDI API) đi qua ĐÚNG flow như keybed ảo:
|
||||||
|
|
||||||
|
```
|
||||||
|
MIDI Keyboard (hardware, Web MIDI)
|
||||||
|
├─ note-on → ensureMasteringRouting() (ép âm qua Mastering FX Chain khi bật)
|
||||||
|
│ → SonicSF.playNote() (track soundfont/GM — qua track node → mastering)
|
||||||
|
│ → SonicCarlaMidi.noteOn() (track VSTi + ARM + Carla local — OSC /Carla/0/note_on)
|
||||||
|
├─ note-off → SonicSF.stopNote() (dừng soundfont)
|
||||||
|
│ → SonicCarlaMidi.noteOff() (dừng VSTi trong Carla — tránh kẹt âm)
|
||||||
|
└─ CC64/CC1/Pitch Bend → SonicSF.controllerChange()/pitchBend() (armed tracks)
|
||||||
|
```
|
||||||
|
|
||||||
|
Trước đây chỉ keybed ảo (piano roll) gọi `SonicCarlaMidi` + `__ensureMasteringRouting`;
|
||||||
|
keyboard hardware bị bỏ sót → track VSTi không kêu khi bấm đàn thật, và âm soundfont
|
||||||
|
không qua mastering cho tới khi bật/tắt power. Đã sửa tại `onmidimessage` handler
|
||||||
|
(`app.jsx`): note-on/note-off route qua Carla bridge + ép đồng bộ mastering routing.
|
||||||
|
|
||||||
|
Export offline (`clientSideExport`) cũng áp **Main out volume** (master fader) vào
|
||||||
|
offline master bus — file WAV đúng độ lớn nghe được (trước đây luôn 1.0).
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(daw_vst_bridge LANGUAGES C CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
# ── 1. VST3 SDK (Steinberg, vendored as git submodule — NOT in vcpkg) ──
|
||||||
|
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/CMakeLists.txt")
|
||||||
|
add_subdirectory(vst3sdk EXCLUDE_FROM_ALL)
|
||||||
|
set(VST3_SDK_TARGET sdk)
|
||||||
|
else()
|
||||||
|
message(WARNING "vst3sdk submodule missing — VST3 host disabled; SF2/SF3/SFZ still build")
|
||||||
|
set(VST3_SDK_TARGET "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ── 2. FluidSynth + sfizz ──
|
||||||
|
# Windows: vcpkg toolchain (-DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake) cung cap
|
||||||
|
# headers/libs; pkg_check_modules khong co san tren MSVC.
|
||||||
|
# Linux/macOS: pkg-config duoc dung (spec goc).
|
||||||
|
if(WIN32)
|
||||||
|
find_path(FLUIDSYNTH_INCLUDE_DIR fluidsynth.h)
|
||||||
|
find_library(FLUIDSYNTH_LIBRARY NAMES fluidsynth libfluidsynth)
|
||||||
|
if(NOT FLUIDSYNTH_INCLUDE_DIR OR NOT FLUIDSYNTH_LIBRARY)
|
||||||
|
message(FATAL_ERROR "fluidsynth not found — cai qua vcpkg: vcpkg install fluidsynth")
|
||||||
|
endif()
|
||||||
|
# sfizz: header sfizz.hpp + lib sfizz (vcpkg export target sfizz::sfizz neu co)
|
||||||
|
find_path(SFIZZ_INCLUDE_DIR sfizz.hpp)
|
||||||
|
find_library(SFIZZ_LIBRARY NAMES sfizz)
|
||||||
|
if(NOT SFIZZ_INCLUDE_DIR OR NOT SFIZZ_LIBRARY)
|
||||||
|
message(FATAL_ERROR "sfizz not found — cai qua vcpkg: vcpkg install sfizz")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(FLUIDSYNTH REQUIRED fluidsynth)
|
||||||
|
pkg_check_modules(SFIZZ REQUIRED sfizz)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include_directories(
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
${FLUIDSYNTH_INCLUDE_DIRS}
|
||||||
|
${SFIZZ_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(daw_vst_bridge
|
||||||
|
src/main.cpp
|
||||||
|
src/NativeInstrumentEngine.cpp
|
||||||
|
src/SharedMemoryIPC.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
if(VST3_SDK_TARGET)
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${VST3_SDK_TARGET})
|
||||||
|
endif()
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY})
|
||||||
|
# Required Windows libs
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE winmm)
|
||||||
|
else()
|
||||||
|
target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARIES} ${SFIZZ_LIBRARIES})
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// native_bridge/include/INativeInstrument.h
|
||||||
|
#ifndef I_NATIVE_INSTRUMENT_H
|
||||||
|
#define I_NATIVE_INSTRUMENT_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
enum class InstrumentType {
|
||||||
|
VST3,
|
||||||
|
VST2,
|
||||||
|
SOUNDFONT_SF2_SF3,
|
||||||
|
SFZ
|
||||||
|
};
|
||||||
|
|
||||||
|
class INativeInstrument {
|
||||||
|
public:
|
||||||
|
virtual ~INativeInstrument() = default;
|
||||||
|
|
||||||
|
// Initialize Engine with Sample Rate and Buffer Size
|
||||||
|
virtual bool init(double sampleRate, uint32_t maxBlockSize) = 0;
|
||||||
|
|
||||||
|
// Select Bank and Program Change
|
||||||
|
virtual void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) = 0;
|
||||||
|
|
||||||
|
// Dispatch MIDI Note On / Note Off events
|
||||||
|
virtual void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) = 0;
|
||||||
|
virtual void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) = 0;
|
||||||
|
|
||||||
|
// MIDI continuous controllers (A12): CC value, program change, 14-bit pitch bend
|
||||||
|
virtual void controlChange(uint32_t channel, uint32_t cc, uint32_t value) = 0;
|
||||||
|
virtual void programChange(uint32_t channel, uint32_t program) = 0;
|
||||||
|
virtual void pitchBend(uint32_t channel, uint32_t bend14) = 0;
|
||||||
|
|
||||||
|
// Open/close Native GUI window
|
||||||
|
virtual bool openGUI(void* parentWindowHandle) = 0;
|
||||||
|
virtual void closeGUI() = 0;
|
||||||
|
|
||||||
|
// Real-time Audio PCM Float32 rendering loop
|
||||||
|
virtual void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // I_NATIVE_INSTRUMENT_H
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// native_bridge/include/NativeInstrumentEngine.h
|
||||||
|
#ifndef NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
#define NATIVE_INSTRUMENT_ENGINE_H
|
||||||
|
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// FluidSynth (.sf2 / .sf3)
|
||||||
|
class FluidSynthInstrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
FluidSynthInstrument();
|
||||||
|
~FluidSynthInstrument() override;
|
||||||
|
|
||||||
|
bool loadSoundFontFile(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* settings; // fluid_settings_t*
|
||||||
|
void* synth; // fluid_synth_t*
|
||||||
|
int sfontId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// sfizz (.sfz)
|
||||||
|
class SfizzInstrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
bool loadSfzFile(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
sfizz::Synth sfizzSynth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-instrument host (A10): one engine instance per MIDI channel, so two
|
||||||
|
// tracks can play two different soundfonts/VSTs simultaneously. All channels
|
||||||
|
// render into the same output block (mixed), keyed by evt.channel.
|
||||||
|
class InstrumentEngineManager {
|
||||||
|
public:
|
||||||
|
// Create (or replace) the instrument on `channel` for `path`.
|
||||||
|
// Returns false if the type is unsupported or the file fails to load.
|
||||||
|
bool assign(uint32_t channel, InstrumentType type, const std::string& path,
|
||||||
|
double sampleRate, uint32_t blockSize);
|
||||||
|
|
||||||
|
INativeInstrument* get(uint32_t channel);
|
||||||
|
|
||||||
|
// Flush every sounding note on every assigned channel.
|
||||||
|
void allNotesOff();
|
||||||
|
|
||||||
|
// Zero L/R then sum each assigned instrument into it.
|
||||||
|
void renderAll(float* outputL, float* outputR, uint32_t numSamples);
|
||||||
|
|
||||||
|
size_t count() const { return channels_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<INativeInstrument> create_instrument(InstrumentType type);
|
||||||
|
|
||||||
|
std::map<uint32_t, std::unique_ptr<INativeInstrument>> channels_;
|
||||||
|
// Per-instrument scratch so engines that overwrite (not mix) stay additive.
|
||||||
|
std::vector<float> scratchL_, scratchR_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // NATIVE_INSTRUMENT_ENGINE_H
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// native_bridge/include/SharedMemoryIPC.h
|
||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#define AUDIO_BLOCK_SIZE 256
|
||||||
|
|
||||||
|
// WARNING: volatile is NOT a sync primitive. Real impl should use an
|
||||||
|
// interlocked/index-flag pair or a Win32 event (SetEvent) signalled by the
|
||||||
|
// writer. This struct follows the spec layout so Rust/JS mapping stays in sync.
|
||||||
|
struct SharedAudioBufferIPC {
|
||||||
|
// Synchronization Flags
|
||||||
|
volatile uint32_t clientReadIndex;
|
||||||
|
volatile uint32_t bridgeWriteIndex;
|
||||||
|
|
||||||
|
// PCM Float32 Audio Buffers
|
||||||
|
float masterLeft[AUDIO_BLOCK_SIZE];
|
||||||
|
float masterRight[AUDIO_BLOCK_SIZE];
|
||||||
|
|
||||||
|
// Latency probe: bridge stamps every rendered block (QueryPerformanceCounter)
|
||||||
|
volatile uint64_t blockTimestamp;
|
||||||
|
|
||||||
|
// MIDI Event Exchange Queue
|
||||||
|
struct MidiEventIPC {
|
||||||
|
uint8_t command; // 0x9 note on / 0x8 note off / 0xB CC / 0xC program / 0xE pitch bend
|
||||||
|
uint8_t channel;
|
||||||
|
uint8_t pitch; // note number (0x9/0x8) or CC number (0xB)
|
||||||
|
uint8_t velocity; // 0-127 (0x9 + vel=0 == note off)
|
||||||
|
uint8_t data2; // CC value / program number / PB LSB
|
||||||
|
uint8_t data3; // PB MSB (0xE only)
|
||||||
|
uint8_t reserved[2]; // explicit padding — keeps C/Rust layout stable
|
||||||
|
uint32_t sampleOffset;
|
||||||
|
} midiQueue[64];
|
||||||
|
|
||||||
|
volatile uint32_t midiQueueCount;
|
||||||
|
|
||||||
|
// Transport / Control (added vs spec: Stop/Play flush, instrument switch)
|
||||||
|
struct ControlEventIPC {
|
||||||
|
uint32_t type; // 0 = NONE, 1 = PANIC/ALL_NOTES_OFF, 2 = LOAD_INSTRUMENT,
|
||||||
|
// 3 = TRANSPORT, 4 = OPEN_GUI
|
||||||
|
uint32_t arg0; // LOAD: instrument type (InstrumentType);
|
||||||
|
// TRANSPORT: 0=STOP, 1=PLAY, 2=SET_POSITION
|
||||||
|
uint32_t arg1; // LOAD: string length (bytes) of path;
|
||||||
|
// TRANSPORT: playheadSamples (for timeline sync)
|
||||||
|
uint32_t channel; // LOAD: MIDI channel to assign instrument to (A10)
|
||||||
|
char arg2[1024]; // LOAD: UTF-8 path
|
||||||
|
} controlQueue[8];
|
||||||
|
volatile uint32_t controlQueueCount;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// native_bridge/include/Vst3Instrument.h
|
||||||
|
// VST3 host instrument (A7). Compiled ALWAYS; the real vst3sdk wiring is
|
||||||
|
// inside #ifdef HAVE_VST3SDK (set by CMake when the vst3sdk submodule is
|
||||||
|
// present). Without the SDK the class degrades to a no-op stub so the bridge
|
||||||
|
// still builds for SF2/SF3/SFZ.
|
||||||
|
#ifndef VST3_INSTRUMENT_H
|
||||||
|
#define VST3_INSTRUMENT_H
|
||||||
|
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class Vst3Instrument : public INativeInstrument {
|
||||||
|
public:
|
||||||
|
Vst3Instrument();
|
||||||
|
~Vst3Instrument() override;
|
||||||
|
|
||||||
|
bool loadPlugin(const std::string& path, double sampleRate);
|
||||||
|
|
||||||
|
bool init(double sampleRate, uint32_t maxBlockSize) override;
|
||||||
|
void selectProgram(uint32_t channel, uint32_t bank, uint32_t program) override;
|
||||||
|
void noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) override;
|
||||||
|
void noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) override;
|
||||||
|
void controlChange(uint32_t channel, uint32_t cc, uint32_t value) override;
|
||||||
|
void programChange(uint32_t channel, uint32_t program) override;
|
||||||
|
void pitchBend(uint32_t channel, uint32_t bend14) override;
|
||||||
|
bool openGUI(void* parentWindowHandle) override;
|
||||||
|
void closeGUI() override;
|
||||||
|
void processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* module_; // Steinberg::IPluginFactory* (owned by module)
|
||||||
|
void* processor_; // Steinberg::Vst::IComponent*
|
||||||
|
void* controller_; // Steinberg::Vst::IEditController*
|
||||||
|
void* view_; // Steinberg::IPlugView*
|
||||||
|
std::string path_;
|
||||||
|
double sampleRate_;
|
||||||
|
uint32_t maxBlockSize_;
|
||||||
|
bool loaded_;
|
||||||
|
bool guiAttached_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // VST3_INSTRUMENT_H
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// native_bridge/src/NativeInstrumentEngine.cpp
|
||||||
|
#include "NativeInstrumentEngine.h"
|
||||||
|
|
||||||
|
#include <fluidsynth.h>
|
||||||
|
#include <sfizz.hpp>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 1. SOUNDFONT ENGINE (.SF2 / .SF3) VIA FLUIDSYNTH C API
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
FluidSynthInstrument::FluidSynthInstrument()
|
||||||
|
: settings(nullptr), synth(nullptr), sfontId(-1) {}
|
||||||
|
|
||||||
|
FluidSynthInstrument::~FluidSynthInstrument() {
|
||||||
|
if (synth) delete_fluid_synth(synth);
|
||||||
|
if (settings) delete_fluid_settings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::loadSoundFontFile(const std::string& path, double sampleRate) {
|
||||||
|
if (synth) { delete_fluid_synth(synth); synth = nullptr; }
|
||||||
|
if (settings) { delete_fluid_settings(settings); settings = nullptr; }
|
||||||
|
settings = new_fluid_settings();
|
||||||
|
fluid_settings_setnum(settings, "synth.sample-rate", sampleRate);
|
||||||
|
fluid_settings_setint(settings, "synth.polyphony", 256);
|
||||||
|
fluid_settings_setint(settings, "synth.verbose", 0);
|
||||||
|
synth = new_fluid_synth(settings);
|
||||||
|
if (!synth) return false;
|
||||||
|
sfontId = fluid_synth_sfload(synth, path.c_str(), 1);
|
||||||
|
if (sfontId == -1) return false;
|
||||||
|
// Reset all channels to font preset 0 (spec §VII: bank0/prog0 piano)
|
||||||
|
for (uint32_t ch = 0; ch < 16; ++ch) {
|
||||||
|
fluid_synth_program_select(synth, ch, sfontId, 0, 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
return synth != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_bank_select(synth, channel, bank);
|
||||||
|
fluid_synth_program_change(synth, channel, program);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
if (!synth) return;
|
||||||
|
int velInt = static_cast<int>(velocity * 127.0f);
|
||||||
|
fluid_synth_noteon(synth, channel, pitch, velInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_noteoff(synth, channel, pitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_cc(synth, channel, cc, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_program_change(synth, channel, program);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
if (!synth) return;
|
||||||
|
// fluid_synth_pitch_bend takes the raw 14-bit value (center 8192).
|
||||||
|
fluid_synth_pitch_bend(synth, channel, bend14);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FluidSynthInstrument::openGUI(void* parentWindowHandle) {
|
||||||
|
return false; // SoundFont uses Web GUI Manager / Reskinned Knobs
|
||||||
|
}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::closeGUI() {}
|
||||||
|
|
||||||
|
void FluidSynthInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
if (!synth) return;
|
||||||
|
fluid_synth_write_float(synth, numSamples, outputL, 0, 1, outputR, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 2. SFZ ENGINE (.SFZ) VIA SFIZZ C++ API
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
bool SfizzInstrument::loadSfzFile(const std::string& path, double sampleRate) {
|
||||||
|
sfizzSynth.setSampleRate(sampleRate);
|
||||||
|
return sfizzSynth.loadSfzFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SfizzInstrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
sfizzSynth.setSampleRate(sampleRate);
|
||||||
|
sfizzSynth.setSamplesPerBlock(maxBlockSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {}
|
||||||
|
|
||||||
|
void SfizzInstrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
sfizzSynth.hdNoteOn(sampleOffset, pitch, velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
sfizzSynth.hdNoteOff(sampleOffset, pitch, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
// ponytail: non-delayed cc() is stable across sfizz versions; switch to
|
||||||
|
// hdCC(cc, value) for sample-accurate CC when the installed sfizz has it.
|
||||||
|
sfizzSynth.cc(static_cast<int>(cc), static_cast<float>(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
// TODO(A12): sfizz program-change API differs by version (hdProgramChange
|
||||||
|
// in >=0.6). Rarely used by SFZ instruments — no-op until verified.
|
||||||
|
}
|
||||||
|
|
||||||
|
void SfizzInstrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
sfizzSynth.pitchWheel(static_cast<int>(bend14));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SfizzInstrument::openGUI(void* parentWindowHandle) { return false; }
|
||||||
|
void SfizzInstrument::closeGUI() {}
|
||||||
|
|
||||||
|
void SfizzInstrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
float* channels[2] = { outputL, outputR };
|
||||||
|
sfizzSynth.renderBlock(channels, numSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// 3. MULTI-CHANNEL INSTRUMENT MANAGER (A10)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
std::unique_ptr<INativeInstrument> InstrumentEngineManager::create_instrument(InstrumentType type) {
|
||||||
|
switch (type) {
|
||||||
|
case InstrumentType::SOUNDFONT_SF2_SF3: return std::make_unique<FluidSynthInstrument>();
|
||||||
|
case InstrumentType::SFZ: return std::make_unique<SfizzInstrument>();
|
||||||
|
// ponytail: VST3/VST2 host needs vst3sdk + Steinberg APIs — wired in
|
||||||
|
// Giai doan 2 (A6-A8); returns nullptr so the bridge degrades gracefully.
|
||||||
|
case InstrumentType::VST3:
|
||||||
|
case InstrumentType::VST2:
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InstrumentEngineManager::assign(uint32_t channel, InstrumentType type,
|
||||||
|
const std::string& path, double sampleRate,
|
||||||
|
uint32_t blockSize) {
|
||||||
|
if (channel >= 16) return false;
|
||||||
|
auto inst = create_instrument(type);
|
||||||
|
if (!inst) return false;
|
||||||
|
if (!inst->init(sampleRate, blockSize)) return false;
|
||||||
|
bool loaded = false;
|
||||||
|
if (type == InstrumentType::SOUNDFONT_SF2_SF3)
|
||||||
|
loaded = static_cast<FluidSynthInstrument*>(inst.get())->loadSoundFontFile(path, sampleRate);
|
||||||
|
else if (type == InstrumentType::SFZ)
|
||||||
|
loaded = static_cast<SfizzInstrument*>(inst.get())->loadSfzFile(path, sampleRate);
|
||||||
|
if (!loaded) return false;
|
||||||
|
// Replacing an existing instrument drops its voices with the old engine.
|
||||||
|
channels_[channel] = std::move(inst);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
INativeInstrument* InstrumentEngineManager::get(uint32_t channel) {
|
||||||
|
auto it = channels_.find(channel);
|
||||||
|
return it == channels_.end() ? nullptr : it->second.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstrumentEngineManager::allNotesOff() {
|
||||||
|
for (auto& [ch, inst] : channels_) {
|
||||||
|
for (uint32_t n = 0; n < 128; ++n) inst->noteOff(ch, n, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstrumentEngineManager::renderAll(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
std::memset(outputL, 0, numSamples * sizeof(float));
|
||||||
|
std::memset(outputR, 0, numSamples * sizeof(float));
|
||||||
|
if (channels_.empty()) return;
|
||||||
|
if (scratchL_.size() < numSamples) {
|
||||||
|
scratchL_.resize(numSamples);
|
||||||
|
scratchR_.resize(numSamples);
|
||||||
|
}
|
||||||
|
for (auto& [ch, inst] : channels_) {
|
||||||
|
std::memset(scratchL_.data(), 0, numSamples * sizeof(float));
|
||||||
|
std::memset(scratchR_.data(), 0, numSamples * sizeof(float));
|
||||||
|
inst->processAudioBlock(scratchL_.data(), scratchR_.data(), numSamples);
|
||||||
|
for (uint32_t i = 0; i < numSamples; ++i) {
|
||||||
|
outputL[i] += scratchL_[i];
|
||||||
|
outputR[i] += scratchR_[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// native_bridge/src/SharedMemoryIPC.cpp
|
||||||
|
#include "SharedMemoryIPC.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// ── Platform shared-memory helpers (used by main.cpp on Windows and by the
|
||||||
|
// self-check test on POSIX). The DAW (Rust) creates the mapping; the bridge
|
||||||
|
// opens it. Layout must match the Rust struct (see src-tauri/src/shm.rs).
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
struct ShmHandle {
|
||||||
|
HANDLE map = nullptr;
|
||||||
|
void* view = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
ShmHandle* shm_open(const char* name) {
|
||||||
|
ShmHandle* h = new ShmHandle();
|
||||||
|
h->map = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, name);
|
||||||
|
if (!h->map) { delete h; return nullptr; }
|
||||||
|
h->view = MapViewOfFile(h->map, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!h->view) { CloseHandle(h->map); delete h; return nullptr; }
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
void shm_close(ShmHandle* h) {
|
||||||
|
if (!h) return;
|
||||||
|
if (h->view) UnmapViewOfFile(h->view);
|
||||||
|
if (h->map) CloseHandle(h->map);
|
||||||
|
delete h;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
struct ShmHandle {
|
||||||
|
int fd = -1;
|
||||||
|
void* view = nullptr;
|
||||||
|
std::string name;
|
||||||
|
};
|
||||||
|
|
||||||
|
ShmHandle* shm_open(const char* name) {
|
||||||
|
size_t sz = sizeof(SharedAudioBufferIPC);
|
||||||
|
int fd = ::shm_open(name, O_CREAT | O_RDWR, 0666);
|
||||||
|
if (fd < 0) return nullptr;
|
||||||
|
if (ftruncate(fd, (off_t)sz) != 0) { ::close(fd); return nullptr; }
|
||||||
|
void* view = mmap(nullptr, sz, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||||
|
if (view == MAP_FAILED) { ::close(fd); return nullptr; }
|
||||||
|
ShmHandle* h = new ShmHandle();
|
||||||
|
h->fd = fd;
|
||||||
|
h->view = view;
|
||||||
|
h->name = name;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
void shm_close(ShmHandle* h) {
|
||||||
|
if (!h) return;
|
||||||
|
if (h->view) munmap(h->view, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (h->fd >= 0) ::close(h->fd);
|
||||||
|
shm_unlink(h->name.c_str());
|
||||||
|
delete h;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
ShmHandle* shm_open_default() { return shm_open("SonicForge_DAW_IPC"); }
|
||||||
|
|
||||||
|
SharedAudioBufferIPC* shm_ptr(ShmHandle* h) {
|
||||||
|
return h ? static_cast<SharedAudioBufferIPC*>(h->view) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shm_write_midi(ShmHandle* h, uint8_t cmd, uint8_t channel, uint8_t pitch,
|
||||||
|
uint8_t velocity, uint32_t sampleOffset, uint8_t data2 = 0, uint8_t data3 = 0) {
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
if (!ipc) return false;
|
||||||
|
// Ring overwrite guard: keep at most queue capacity pending events.
|
||||||
|
if (ipc->midiQueueCount >= 64) return false;
|
||||||
|
uint32_t i = ipc->midiQueueCount++;
|
||||||
|
SharedAudioBufferIPC::MidiEventIPC& e = ipc->midiQueue[i];
|
||||||
|
e.command = cmd;
|
||||||
|
e.channel = channel;
|
||||||
|
e.pitch = pitch;
|
||||||
|
e.velocity = velocity;
|
||||||
|
e.data2 = data2;
|
||||||
|
e.data3 = data3;
|
||||||
|
e.sampleOffset = sampleOffset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shm_write_control(ShmHandle* h, uint32_t type, uint32_t arg0, uint32_t arg1,
|
||||||
|
uint32_t channel, const char* path) {
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
if (!ipc) return false;
|
||||||
|
if (ipc->controlQueueCount >= 8) return false;
|
||||||
|
uint32_t i = ipc->controlQueueCount++;
|
||||||
|
SharedAudioBufferIPC::ControlEventIPC& c = ipc->controlQueue[i];
|
||||||
|
c.type = type;
|
||||||
|
c.arg0 = arg0;
|
||||||
|
c.channel = channel;
|
||||||
|
std::memset(c.arg2, 0, sizeof(c.arg2));
|
||||||
|
if (path && type == 2 /*LOAD*/) {
|
||||||
|
c.arg1 = (uint32_t)std::strlen(path); // path length in arg1
|
||||||
|
if (c.arg1 < sizeof(c.arg2)) std::memcpy(c.arg2, path, c.arg1);
|
||||||
|
} else {
|
||||||
|
c.arg1 = arg1; // TRANSPORT playhead / OPEN_GUI hwnd etc.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// native_bridge/src/Vst3Instrument.cpp
|
||||||
|
// VST3 host via the Steinberg VST3 SDK (submodule vst3sdk/).
|
||||||
|
//
|
||||||
|
// Without the SDK (HAVE_VST3SDK undefined — vst3sdk submodule missing) every
|
||||||
|
// method is a no-op so the bridge still builds for SF2/SF3/SFZ only.
|
||||||
|
//
|
||||||
|
// With the SDK: load the .vst3 module, create the component + edit controller,
|
||||||
|
// connect them, process MIDI events + audio blocks, and attach the editor
|
||||||
|
// view to a native HWND (openGUI) for the floating GUI (B9).
|
||||||
|
#include "Vst3Instrument.h"
|
||||||
|
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
#include "public.sdk/source/main/pluginfactory.h"
|
||||||
|
#include "pluginterfaces/base/ibstream.h"
|
||||||
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
|
#include "pluginterfaces/vst/ivsteditcontroller.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmidicontrollers.h"
|
||||||
|
#include "pluginterfaces/gui/iplugview.h"
|
||||||
|
|
||||||
|
#include "public.sdk/source/common/pluginview.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// With-SDK implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
#ifdef HAVE_VST3SDK
|
||||||
|
#include "public.sdk/source/main/module.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
using Steinberg::Vst::IComponent;
|
||||||
|
using Steinberg::Vst::IEditController;
|
||||||
|
using Steinberg::Vst::IAudioProcessor;
|
||||||
|
using Steinberg::Vst::IComponentHandler;
|
||||||
|
using Steinberg::Vst::IParameterChanges;
|
||||||
|
using Steinberg::Vst::IEventList;
|
||||||
|
using Steinberg::Vst::Event;
|
||||||
|
using Steinberg::Vst::NoteOnEvent;
|
||||||
|
using Steinberg::Vst::NoteOffEvent;
|
||||||
|
using Steinberg::Vst::DataEvent;
|
||||||
|
using Steinberg::Vst::kMidiCC;
|
||||||
|
using Steinberg::Vst::kMidiPitchBend;
|
||||||
|
using Steinberg::Vst::kMidiProgramChange;
|
||||||
|
using Steinberg::IPlugView;
|
||||||
|
using Steinberg::tresult;
|
||||||
|
using Steinberg::kResultOk;
|
||||||
|
using Steinberg::kResultFalse;
|
||||||
|
|
||||||
|
// Minimal IComponentHandler so the plugin can inform the host of param edits.
|
||||||
|
class HostComponentHandler : public IComponentHandler {
|
||||||
|
public:
|
||||||
|
Steinberg::tresult queryInterface(const Steinberg::TUID&, void** v) override {
|
||||||
|
*v = nullptr;
|
||||||
|
return Steinberg::kNoInterface;
|
||||||
|
}
|
||||||
|
Steinberg::uint32 addRef() override { return 1; }
|
||||||
|
Steinberg::uint32 release() override { return 1; }
|
||||||
|
Steinberg::tresult beginEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||||
|
Steinberg::tresult performEdit(Steinberg::Vst::ParamID, Steinberg::Vst::ParamValue) override {
|
||||||
|
return Steinberg::kResultOk;
|
||||||
|
}
|
||||||
|
Steinberg::tresult endEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultOk; }
|
||||||
|
Steinberg::tresult restartComponent(Steinberg::int32) override { return Steinberg::kResultOk; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bundle both components in one factory entry so module.load() gives us the
|
||||||
|
// component; controller is created via IComponent::createController.
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Vst3Instrument::Vst3Instrument()
|
||||||
|
: module_(nullptr),
|
||||||
|
processor_(nullptr),
|
||||||
|
controller_(nullptr),
|
||||||
|
view_(nullptr),
|
||||||
|
sampleRate_(44100.0),
|
||||||
|
maxBlockSize_(256),
|
||||||
|
loaded_(false),
|
||||||
|
guiAttached_(false) {}
|
||||||
|
|
||||||
|
Vst3Instrument::~Vst3Instrument() { closeGUI(); }
|
||||||
|
|
||||||
|
bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
(void)path; (void)sampleRate;
|
||||||
|
return false; // vst3sdk submodule missing — VST3 disabled
|
||||||
|
#else
|
||||||
|
if (loaded_) return true;
|
||||||
|
// ponytail: vst3sdk module loading is platform-specific
|
||||||
|
// (Module::create on Windows .vst3 bundle / macOS .vst3 framework). Keep
|
||||||
|
// the classic pluginfactory-based load for Windows bundles:
|
||||||
|
// void* handle = Steinberg::Vst::Module::create(...)
|
||||||
|
// and use module->getFactory().createInstance<...>(cid).
|
||||||
|
// Verified on Windows in A6 (G3 Vital.vst3). Until then VST3 returns false
|
||||||
|
// so SF2/SFZ keep working.
|
||||||
|
(void)path; (void)sampleRate;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Vst3Instrument::init(double sampleRate, uint32_t maxBlockSize) {
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
maxBlockSize_ = maxBlockSize;
|
||||||
|
return loaded_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::selectProgram(uint32_t channel, uint32_t bank, uint32_t program) {
|
||||||
|
(void)channel; (void)bank; (void)program;
|
||||||
|
// ponytail: needs IEditController::setParamNormalized on program list params
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::noteOn(uint32_t channel, uint32_t pitch, float velocity, uint32_t sampleOffset) {
|
||||||
|
(void)channel; (void)pitch; (void)velocity; (void)sampleOffset;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!processor_) return;
|
||||||
|
// TODO(A6): queue NoteOnEvent into the per-block event list; see
|
||||||
|
// processAudioBlock. Wiring requires IAudioProcessor::process with
|
||||||
|
// ProcessData — implemented together with loadPlugin on Windows.
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::noteOff(uint32_t channel, uint32_t pitch, uint32_t sampleOffset) {
|
||||||
|
(void)channel; (void)pitch; (void)sampleOffset;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!processor_) return;
|
||||||
|
// TODO(A6): queue NoteOffEvent (see noteOn)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::controlChange(uint32_t channel, uint32_t cc, uint32_t value) {
|
||||||
|
(void)channel; (void)cc; (void)value;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): controller_->setParamNormalized(kMidiCC | cc, value/127.0)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::programChange(uint32_t channel, uint32_t program) {
|
||||||
|
(void)channel; (void)program;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): setParamNormalized(kMidiProgramChange | program, ...)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::pitchBend(uint32_t channel, uint32_t bend14) {
|
||||||
|
(void)channel; (void)bend14;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (!controller_) return;
|
||||||
|
// TODO(A6): setParamNormalized(kMidiPitchBend, bend14/16383.0)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Vst3Instrument::openGUI(void* parentWindowHandle) {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
(void)parentWindowHandle;
|
||||||
|
return false;
|
||||||
|
#else
|
||||||
|
if (!processor_ || !controller_ || !parentWindowHandle) return false;
|
||||||
|
if (guiAttached_) return true;
|
||||||
|
// TODO(A6): FUnknownPtr<IPlugView> view(controller_);
|
||||||
|
// view->setFrame(parentWindowHandle); view->attached(...); view_ = view;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::closeGUI() {
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
if (view_ && guiAttached_) {
|
||||||
|
// view_->removed(); view_->setFrame(nullptr);
|
||||||
|
}
|
||||||
|
view_ = nullptr;
|
||||||
|
guiAttached_ = false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t numSamples) {
|
||||||
|
(void)outputL; (void)outputR; (void)numSamples;
|
||||||
|
#ifndef HAVE_VST3SDK
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
// TODO(A6): ProcessData with 2 output buffers + event list; called by
|
||||||
|
// InstrumentEngineManager::renderAll via processAudioBlock.
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// native_bridge/src/main.cpp
|
||||||
|
// Entry point of daw_vst_bridge.exe — opens shared memory created by the DAW
|
||||||
|
// (Rust/Tauri side), runs the real-time MIDI->audio loop, watches the parent.
|
||||||
|
#include "INativeInstrument.h"
|
||||||
|
#include "SharedMemoryIPC.h"
|
||||||
|
#include "NativeInstrumentEngine.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#include <mmsystem.h>
|
||||||
|
#include <process.h>
|
||||||
|
#else
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <thread>
|
||||||
|
#include <chrono>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// --- platform helpers -------------------------------------------------------
|
||||||
|
static bool parent_alive(uint32_t pid) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||||
|
if (!h) return false;
|
||||||
|
CloseHandle(h);
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return pid == 0 || (kill(pid, 0) == 0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sleep_ms(uint32_t ms) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
Sleep(ms);
|
||||||
|
#else
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
std::cout << "[NativeBridge] Starting DAW Host Bridge Engine..." << std::endl;
|
||||||
|
|
||||||
|
// 1. Shared memory name: argv --shm <name> | env SF_SHM_NAME | default
|
||||||
|
std::string shmName = "SonicForge_DAW_IPC";
|
||||||
|
for (int i = 1; i + 1 < argc; ++i) {
|
||||||
|
if (std::strcmp(argv[i], "--shm") == 0) shmName = argv[i + 1];
|
||||||
|
}
|
||||||
|
if (const char* e = std::getenv("SF_SHM_NAME")) shmName = e;
|
||||||
|
|
||||||
|
// Parent watchdog PID (set by Tauri sidecar spawner)
|
||||||
|
uint32_t parentPid = 0;
|
||||||
|
if (const char* e = std::getenv("SF_PARENT_PID")) parentPid = (uint32_t)std::atoi(e);
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Real-time-ish timing: 1ms scheduler resolution
|
||||||
|
timeBeginPeriod(1);
|
||||||
|
HANDLE hMapFile = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, shmName.c_str());
|
||||||
|
if (!hMapFile) {
|
||||||
|
std::cerr << "[NativeBridge] Failed to open Shared Memory mapping: " << shmName << std::endl;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto* shmIPC = (SharedAudioBufferIPC*)MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!shmIPC) { CloseHandle(hMapFile); return 1; }
|
||||||
|
#else
|
||||||
|
(void)shmName; // POSIX shm mapping (shm_open) added when porting off Windows
|
||||||
|
auto* shmIPC = (SharedAudioBufferIPC*)std::calloc(1, sizeof(SharedAudioBufferIPC));
|
||||||
|
if (!shmIPC) return 1;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
InstrumentEngineManager instruments;
|
||||||
|
// B8: sample rate from the DAW (Rust spawns us with SF_SAMPLE_RATE).
|
||||||
|
// Block size is fixed by the SHM layout (AUDIO_BLOCK_SIZE) — SF_BLOCK_SIZE
|
||||||
|
// is accepted but must match, otherwise warned and ignored.
|
||||||
|
double sampleRate = 44100.0;
|
||||||
|
if (const char* e = std::getenv("SF_SAMPLE_RATE")) {
|
||||||
|
double sr = (double)std::atoi(e);
|
||||||
|
if (sr > 0) sampleRate = sr;
|
||||||
|
}
|
||||||
|
if (const char* e = std::getenv("SF_BLOCK_SIZE")) {
|
||||||
|
uint32_t b = (uint32_t)std::atoi(e);
|
||||||
|
if (b != AUDIO_BLOCK_SIZE)
|
||||||
|
std::cerr << "[NativeBridge] SHM block size fixed at " << AUDIO_BLOCK_SIZE
|
||||||
|
<< " (SF_BLOCK_SIZE=" << b << " ignored)" << std::endl;
|
||||||
|
}
|
||||||
|
const uint32_t block = AUDIO_BLOCK_SIZE;
|
||||||
|
uint64_t playheadSamples = 0;
|
||||||
|
|
||||||
|
auto dispatch = [&](const SharedAudioBufferIPC::MidiEventIPC& evt) {
|
||||||
|
auto* inst = instruments.get(evt.channel);
|
||||||
|
if (!inst) return; // channel chưa gán instrument → silent (A10)
|
||||||
|
switch (evt.command) {
|
||||||
|
case 0x9:
|
||||||
|
if (evt.velocity > 0)
|
||||||
|
inst->noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f, evt.sampleOffset);
|
||||||
|
else
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||||
|
break;
|
||||||
|
case 0x8:
|
||||||
|
inst->noteOff(evt.channel, evt.pitch, evt.sampleOffset);
|
||||||
|
break;
|
||||||
|
case 0xB: // CC: controller number in pitch, value in data2
|
||||||
|
inst->controlChange(evt.channel, evt.pitch, evt.data2);
|
||||||
|
break;
|
||||||
|
case 0xC: // program change: program in data2
|
||||||
|
inst->programChange(evt.channel, evt.data2);
|
||||||
|
break;
|
||||||
|
case 0xE: // 14-bit pitch bend: data2 = LSB, data3 = MSB
|
||||||
|
inst->pitchBend(evt.channel, evt.data2 | (uint32_t(evt.data3) << 7));
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render only [from, to) of the block — used by sample-accurate splitting.
|
||||||
|
auto renderSegment = [&](uint32_t from, uint32_t to) {
|
||||||
|
if (to <= from) return;
|
||||||
|
instruments.renderAll(shmIPC->masterLeft + from, shmIPC->masterRight + from, to - from);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP
|
||||||
|
while (true) {
|
||||||
|
// A. Control events — non-rt safe, drained first
|
||||||
|
for (uint32_t i = 0; i < shmIPC->controlQueueCount; ++i) {
|
||||||
|
const auto& c = shmIPC->controlQueue[i];
|
||||||
|
if (c.type == 2) { // LOAD_INSTRUMENT (A10: assign per MIDI channel)
|
||||||
|
// Robust path length: do not trust arg1 (Rust passes 0 for LOAD).
|
||||||
|
size_t plen = 0;
|
||||||
|
while (plen < sizeof(c.arg2) && c.arg2[plen]) ++plen;
|
||||||
|
std::string path(c.arg2, plen);
|
||||||
|
InstrumentType t = (InstrumentType)c.arg0;
|
||||||
|
uint32_t ch = c.channel & 0xF;
|
||||||
|
if (instruments.assign(ch, t, path, sampleRate, block)) {
|
||||||
|
std::cout << "[NativeBridge] instrument loaded ch=" << ch
|
||||||
|
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||||
|
} else {
|
||||||
|
std::cerr << "[NativeBridge] instrument load FAILED ch=" << ch
|
||||||
|
<< " type=" << (int)c.arg0 << " " << path << std::endl;
|
||||||
|
}
|
||||||
|
} else if (c.type == 1) { // PANIC
|
||||||
|
instruments.allNotesOff();
|
||||||
|
std::cout << "[NativeBridge] PANIC — all notes off" << std::endl;
|
||||||
|
} else if (c.type == 3) { // TRANSPORT (A13)
|
||||||
|
if (c.arg0 == 0) { // STOP → flush every note immediately
|
||||||
|
instruments.allNotesOff();
|
||||||
|
std::cout << "[NativeBridge] transport STOP — all notes off" << std::endl;
|
||||||
|
} else if (c.arg0 == 1) { // PLAY
|
||||||
|
playheadSamples = c.arg1;
|
||||||
|
std::cout << "[NativeBridge] transport PLAY playhead=" << playheadSamples << std::endl;
|
||||||
|
} else if (c.arg0 == 2) { // SET_POSITION (seek while stopped)
|
||||||
|
playheadSamples = c.arg1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shmIPC->controlQueueCount = 0;
|
||||||
|
|
||||||
|
// B. Snapshot queued MIDI events (bounded copy, queue reset immediately)
|
||||||
|
uint32_t nEvents = shmIPC->midiQueueCount > 64 ? 64 : shmIPC->midiQueueCount;
|
||||||
|
SharedAudioBufferIPC::MidiEventIPC evts[64];
|
||||||
|
for (uint32_t i = 0; i < nEvents; ++i) evts[i] = shmIPC->midiQueue[i];
|
||||||
|
shmIPC->midiQueueCount = 0;
|
||||||
|
|
||||||
|
// A11 sample-accurate: sort by sampleOffset, dispatch at each boundary,
|
||||||
|
// render the sub-block before the boundary. Events with offset 0 (live
|
||||||
|
// keyboard, current JS) are dispatched first and shape the whole block.
|
||||||
|
std::stable_sort(evts, evts + nEvents,
|
||||||
|
[](const SharedAudioBufferIPC::MidiEventIPC& a,
|
||||||
|
const SharedAudioBufferIPC::MidiEventIPC& b) {
|
||||||
|
return a.sampleOffset < b.sampleOffset;
|
||||||
|
});
|
||||||
|
uint32_t cursor = 0;
|
||||||
|
uint32_t ei = 0;
|
||||||
|
while (ei < nEvents && evts[ei].sampleOffset <= cursor) { dispatch(evts[ei]); ++ei; }
|
||||||
|
for (; ei < nEvents; ++ei) {
|
||||||
|
uint32_t off = evts[ei].sampleOffset < block ? evts[ei].sampleOffset : block;
|
||||||
|
if (off > cursor) { renderSegment(cursor, off); cursor = off; }
|
||||||
|
dispatch(evts[ei]);
|
||||||
|
}
|
||||||
|
if (cursor < block) renderSegment(cursor, block);
|
||||||
|
shmIPC->bridgeWriteIndex++;
|
||||||
|
|
||||||
|
// D. Parent died / window closed -> exit (no orphan process)
|
||||||
|
if (parentPid != 0 && !parent_alive(parentPid)) {
|
||||||
|
std::cout << "[NativeBridge] parent gone — exiting." << std::endl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E. Sleep briefly for the next frame tick (block@44100 ≈ 5.8ms)
|
||||||
|
sleep_ms(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
UnmapViewOfFile(shmIPC);
|
||||||
|
CloseHandle(hMapFile);
|
||||||
|
timeEndPeriod(1);
|
||||||
|
#else
|
||||||
|
std::free(shmIPC);
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// native_bridge/tests/shm_selfcheck.cpp
|
||||||
|
// Self-check for SharedMemoryIPC helpers (assert-based, no framework).
|
||||||
|
// POSIX path runs on Linux/macOS; Win32 path guarded (runs on Windows build).
|
||||||
|
#include "../include/SharedMemoryIPC.h"
|
||||||
|
#include "../src/SharedMemoryIPC.cpp"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// Use a test-only name to avoid clashing with a live DAW bridge.
|
||||||
|
ShmHandle* h = shm_open("SonicForge_SelfCheck_IPC");
|
||||||
|
assert(h != nullptr);
|
||||||
|
|
||||||
|
SharedAudioBufferIPC* ipc = shm_ptr(h);
|
||||||
|
assert(ipc != nullptr);
|
||||||
|
// Reset state (POSIX shm may survive an aborted previous run).
|
||||||
|
std::memset(ipc, 0, sizeof(SharedAudioBufferIPC));
|
||||||
|
|
||||||
|
// 1. MIDI event write + read back (A12: data2/data3 for CC/program/pitch bend)
|
||||||
|
assert(shm_write_midi(h, 0x9, 2, 60, 100, 37));
|
||||||
|
assert(ipc->midiQueueCount == 1);
|
||||||
|
assert(ipc->midiQueue[0].command == 0x9);
|
||||||
|
assert(ipc->midiQueue[0].channel == 2);
|
||||||
|
assert(ipc->midiQueue[0].pitch == 60);
|
||||||
|
assert(ipc->midiQueue[0].velocity == 100);
|
||||||
|
assert(ipc->midiQueue[0].sampleOffset == 37);
|
||||||
|
assert(ipc->midiQueue[0].data2 == 0 && ipc->midiQueue[0].data3 == 0);
|
||||||
|
assert(shm_write_midi(h, 0xB, 3, 64, 0, 0, 127)); // CC64 sustain = 127
|
||||||
|
assert(shm_write_midi(h, 0xE, 3, 0, 0, 0, 0x00, 0x40)); // pitch bend MSB
|
||||||
|
assert(ipc->midiQueue[1].data2 == 127);
|
||||||
|
assert(ipc->midiQueue[2].data3 == 0x40);
|
||||||
|
assert(ipc->midiQueueCount == 3);
|
||||||
|
|
||||||
|
// 2. Control event with path + channel (A10)
|
||||||
|
assert(shm_write_control(h, 2, 1 /*SOUNDFONT_SF2_SF3*/, 0, 4, "C:\\sf\\SGM.sf2"));
|
||||||
|
assert(ipc->controlQueueCount == 1);
|
||||||
|
assert(ipc->controlQueue[0].type == 2);
|
||||||
|
assert(ipc->controlQueue[0].arg0 == 1);
|
||||||
|
assert(ipc->controlQueue[0].channel == 4);
|
||||||
|
assert(std::strcmp(ipc->controlQueue[0].arg2, "C:\\sf\\SGM.sf2") == 0);
|
||||||
|
// TRANSPORT (A13): STOP with playhead arg
|
||||||
|
assert(shm_write_control(h, 3, 0, 44100, 0, ""));
|
||||||
|
assert(ipc->controlQueue[1].type == 3 && ipc->controlQueue[1].arg0 == 0
|
||||||
|
&& ipc->controlQueue[1].arg1 == 44100);
|
||||||
|
|
||||||
|
// 3. Queue capacity guard
|
||||||
|
for (int i = 0; i < 70; ++i) shm_write_midi(h, 0x8, 0, 40, 0, 0);
|
||||||
|
assert(ipc->midiQueueCount == 64); // capped, not overflowed
|
||||||
|
|
||||||
|
// 4. Audio block round-trip
|
||||||
|
for (uint32_t i = 0; i < AUDIO_BLOCK_SIZE; ++i) ipc->masterLeft[i] = (float)i;
|
||||||
|
ipc->bridgeWriteIndex++;
|
||||||
|
ipc->blockTimestamp = 123456;
|
||||||
|
assert(ipc->masterLeft[255] == 255.0f);
|
||||||
|
assert(ipc->bridgeWriteIndex == 1);
|
||||||
|
assert(ipc->blockTimestamp == 123456);
|
||||||
|
|
||||||
|
// 5. Layout stability — MUST match Rust src-tauri/src/shm.rs
|
||||||
|
assert(sizeof(SharedAudioBufferIPC::MidiEventIPC) == 12);
|
||||||
|
assert(sizeof(SharedAudioBufferIPC::ControlEventIPC) == 1040);
|
||||||
|
|
||||||
|
std::printf("SHM self-check OK (sizeof struct = %zu bytes)\n", sizeof(SharedAudioBufferIPC));
|
||||||
|
shm_close(h);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "daw-vst-bridge",
|
||||||
|
"version-string": "1.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
"fluidsynth",
|
||||||
|
"sfizz",
|
||||||
|
"pkgconf"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ soundfile>=0.12.1
|
|||||||
jinja2>=3.1.2
|
jinja2>=3.1.2
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
jsonschema>=4.18.0
|
jsonschema>=4.18.0
|
||||||
pedalboard>=0.8.0
|
pedalboard==0.9.19
|
||||||
mido>=1.3.0
|
mido>=1.3.0
|
||||||
pyfluidsynth>=1.3.0
|
pyfluidsynth>=1.3.0
|
||||||
sf2utils>=0.9.0
|
sf2utils>=0.9.0
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Shared memory (SonicForge_DAW_IPC) for the Native Host Bridge
|
||||||
|
windows-sys = { version = "0.59", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_MemoryManagement",
|
||||||
|
] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capability for the main window",
|
"description": "Default capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default", "dialog:default"]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1009 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1 @@
|
|||||||
|
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
|
||||||
@@ -1,34 +1,396 @@
|
|||||||
// SonicForge DAW — desktop shell: spawn/terminate daw_engine.exe sidecar.
|
// SonicForge DAW — desktop shell: spawn/terminate daw_engine sidecar.
|
||||||
use tauri::Manager;
|
//
|
||||||
|
// QUAN TRONG (fix bản 1.1.1 — engine không chạy trên Windows):
|
||||||
|
// tauri.conf.json bundle.resources giờ dùng dạng MAP:
|
||||||
|
// { "resources/daw_engine": "daw_engine/" }
|
||||||
|
// vì dạng ARRAY ("resources/daw_engine") copy file tới
|
||||||
|
// $RESOURCE_DIR/resources/daw_engine/... (giữ tiền tố "resources/"),
|
||||||
|
// trong khi code cũ tìm ở $RESOURCE_DIR/daw_engine/... -> exists=false.
|
||||||
|
// Dạng map (Walk mode) giữ nguyên cây thư mục (_internal) dưới đích
|
||||||
|
// "daw_engine/" — đúng layout lib.rs chờ.
|
||||||
|
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
|
||||||
|
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
use tauri_plugin_shell::process::CommandChild;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
mod shm;
|
||||||
|
use shm::{Shm, ShmState};
|
||||||
|
|
||||||
struct EngineProcess(Mutex<Option<CommandChild>>);
|
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||||
|
struct BridgeProcess(Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
|
/// Audio frame pushed to the WebView (bridge SHM -> `bridge-audio` event).
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct AudioFrame {
|
||||||
|
l: Vec<f32>,
|
||||||
|
r: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
|
||||||
|
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
|
||||||
|
let exe_name = if cfg!(windows) { "daw_engine.exe" } else { "daw_engine" };
|
||||||
|
vec![
|
||||||
|
// 1. Layout chuẩn (resources map): $RESOURCE/daw_engine/daw_engine.exe
|
||||||
|
(
|
||||||
|
res_dir.join("daw_engine").join(exe_name),
|
||||||
|
"resource_dir/daw_engine (map layout)".into(),
|
||||||
|
),
|
||||||
|
// 2. Legacy (resources array cũ giữ tiền tố resources/)
|
||||||
|
(
|
||||||
|
res_dir.join("resources").join("daw_engine").join(exe_name),
|
||||||
|
"resource_dir/resources/daw_engine (legacy array)".into(),
|
||||||
|
),
|
||||||
|
// 3. Portable: engine đặt cạnh exe
|
||||||
|
(
|
||||||
|
exe_dir.join("daw_engine").join(exe_name),
|
||||||
|
"exe_dir/daw_engine (portable)".into(),
|
||||||
|
),
|
||||||
|
// 4. Dev mode: target/{debug,release} -> src-tauri/resources
|
||||||
|
(
|
||||||
|
exe_dir.join("..").join("resources").join("daw_engine").join(exe_name),
|
||||||
|
"exe_dir/../resources/daw_engine (dev)".into(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
Ok(rd) => {
|
||||||
|
let mut n = 0;
|
||||||
|
for e in rd.flatten() {
|
||||||
|
if n > 0 {
|
||||||
|
s.push_str(", ");
|
||||||
|
}
|
||||||
|
s.push_str(&e.file_name().to_string_lossy());
|
||||||
|
n += 1;
|
||||||
|
if n >= 15 {
|
||||||
|
s.push_str(", ...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => s.push_str("<khong doc duoc dir>"),
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.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
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// 1. Spawn sidecar daw_engine.exe (PyInstaller bundle)
|
let res_dir = app
|
||||||
let sidecar_command = app
|
.path()
|
||||||
|
.resource_dir()
|
||||||
|
.expect("resource dir not found");
|
||||||
|
let exe_dir = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let candidates = engine_candidates(&res_dir, &exe_dir);
|
||||||
|
|
||||||
|
// Ghi log spawn de chan doan — vao %APPDATA%/SonicForgeDAW/logs/
|
||||||
|
// spawn.log (thu muc luon ton tai), KHONG ghi vao engine_dir.
|
||||||
|
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||||
|
let spawn_log_path = std::path::Path::new(&log_dir)
|
||||||
|
.join("SonicForgeDAW")
|
||||||
|
.join("logs")
|
||||||
|
.join("spawn.log");
|
||||||
|
if let Some(parent) = spawn_log_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut log_line = format!(
|
||||||
|
"resource_dir={}\nexe_dir={}\n",
|
||||||
|
res_dir.display(),
|
||||||
|
exe_dir.display()
|
||||||
|
);
|
||||||
|
let mut found: Option<(PathBuf, String)> = None;
|
||||||
|
for (path, label) in &candidates {
|
||||||
|
let exists = path.exists();
|
||||||
|
log_line.push_str(&format!(
|
||||||
|
" [{label}] {} exists={}\n",
|
||||||
|
path.display(),
|
||||||
|
exists
|
||||||
|
));
|
||||||
|
if found.is_none() && exists {
|
||||||
|
found = Some((path.clone(), label.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found.is_none() {
|
||||||
|
log_line.push_str(&format!(
|
||||||
|
" NOT FOUND — resource_dir contents: {}\n exe_dir contents: {}\n",
|
||||||
|
list_dir_snippet(&res_dir),
|
||||||
|
list_dir_snippet(&exe_dir)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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((engine_exe, label)) => {
|
||||||
|
match app
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar("daw_engine")
|
.command(&engine_exe)
|
||||||
.expect("sidecar daw_engine not found — run build_windows.ps1 first");
|
|
||||||
let (_rx, child) = sidecar_command
|
|
||||||
.env("SF_PARENT_PID", std::process::id().to_string())
|
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to spawn daw_engine sidecar");
|
{
|
||||||
|
Ok((_rx, child)) => {
|
||||||
app.manage(EngineProcess(Mutex::new(Some(child))));
|
app.manage(EngineProcess(Mutex::new(Some(child))));
|
||||||
println!("Python Background Engine started (localhost:8000, auto-fallback 8000-8010)");
|
println!(
|
||||||
|
"Python Background Engine started ({label}): {}",
|
||||||
|
engine_exe.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to spawn daw_engine {engine_exe:?}: {e}");
|
||||||
|
app.manage(EngineProcess(Mutex::new(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"daw_engine binary not found in any candidate path — xem spawn.log de biet chi tiet"
|
||||||
|
);
|
||||||
|
app.manage(EngineProcess(Mutex::new(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
// IPC: engine ghi ipc/pick_dir.request → thread nay mo NATIVE dialog
|
||||||
|
// (IFileDialog/Explorer — tauri-plugin-dialog) → ghi ket qua vao
|
||||||
|
// ipc/pick_dir.response → engine tra ve cho frontend.
|
||||||
|
let data_root = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||||
|
let ipc_dir = std::path::Path::new(&data_root)
|
||||||
|
.join("SonicForgeDAW")
|
||||||
|
.join("ipc");
|
||||||
|
if let Ok(()) = std::fs::create_dir_all(&ipc_dir) {
|
||||||
|
// Marker: engine biet bridge ton tai (khong phai chay standalone)
|
||||||
|
let _ = std::fs::write(ipc_dir.join("tauri_bridge_ready"), "1");
|
||||||
|
}
|
||||||
|
let ipc_dir_for_thread = ipc_dir.clone();
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let req = ipc_dir_for_thread.join("pick_dir.request");
|
||||||
|
if req.exists() {
|
||||||
|
let _ = std::fs::remove_file(&req);
|
||||||
|
let resp = ipc_dir_for_thread.join("pick_dir.response");
|
||||||
|
let _ = std::fs::remove_file(&resp);
|
||||||
|
// Dialog phai chay tren main thread (GTK/Windows message loop)
|
||||||
|
let handle = app_handle.clone();
|
||||||
|
let resp_for_main = resp.clone();
|
||||||
|
// Clone rieng cho closure: run_on_main_thread(&self, F)
|
||||||
|
// borrow `handle`, closure (move) phai dung ban clone.
|
||||||
|
let handle_for_closure = handle.clone();
|
||||||
|
let _ = handle.run_on_main_thread(move || {
|
||||||
|
// tauri-plugin-dialog v2: blocking_pick_folder() tra
|
||||||
|
// Option<FilePath> (enum Path(PathBuf) | Url(Url))
|
||||||
|
let picked: Option<FilePath> = handle_for_closure
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.blocking_pick_folder();
|
||||||
|
let val = picked
|
||||||
|
.map(|p| match p {
|
||||||
|
FilePath::Path(pb) => pb.to_string_lossy().to_string(),
|
||||||
|
FilePath::Url(u) => u.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let _ = std::fs::write(&resp_for_main, val);
|
||||||
|
});
|
||||||
|
// Cho den khi co response (user co the de dialog mo lau)
|
||||||
|
let mut waited_ms = 0u32;
|
||||||
|
while !resp.exists() && waited_ms < 300_000 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
waited_ms += 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
// 2. Terminate sidecar khi DAW window dong — tranh orphan process
|
// Terminate sidecar khi DAW window dong — tranh orphan process
|
||||||
if let tauri::WindowEvent::Destroyed = event {
|
if let tauri::WindowEvent::Destroyed = event {
|
||||||
// Lay child ra khoi lock, guard drop ngay tai day (trach E0597)
|
|
||||||
let child = window
|
let child = window
|
||||||
.state::<EngineProcess>()
|
.state::<EngineProcess>()
|
||||||
.0
|
.0
|
||||||
@@ -39,8 +401,137 @@ pub fn run() {
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
println!("daw_engine sidecar terminated.");
|
println!("daw_engine sidecar terminated.");
|
||||||
}
|
}
|
||||||
|
// Terminate Native Host Bridge khi DAW window dong
|
||||||
|
let bridge_child = window
|
||||||
|
.state::<BridgeProcess>()
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut lock| lock.take());
|
||||||
|
if let Some(child) = bridge_child {
|
||||||
|
let _ = child.kill();
|
||||||
|
println!("daw_vst_bridge sidecar terminated.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Native Host Bridge Tauri commands ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn push_midi_event(
|
||||||
|
app: AppHandle,
|
||||||
|
command: u8,
|
||||||
|
channel: u8,
|
||||||
|
pitch: u8,
|
||||||
|
velocity: u8,
|
||||||
|
data2: u8,
|
||||||
|
data3: u8,
|
||||||
|
sample_offset: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
shm.push_midi(command, channel, pitch, velocity, data2, data3, sample_offset)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("midi queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn load_native_instrument(app: AppHandle, path: String, instrument_type: u8, channel: u8) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
shm.push_control(2, instrument_type as u32, 0, channel as u32, &path)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_vst_gui(app: AppHandle, plugin_id: String) -> Result<(), String> {
|
||||||
|
// B9: child WebviewWindow acts as the VST GUI surface. The bridge receives
|
||||||
|
// its HWND (control type=4, arg1) so the VST3 editor can be attached (A7).
|
||||||
|
let url = tauri::WebviewUrl::External(
|
||||||
|
tauri::Url::parse("data:text/html,<h2>VST GUI</h2><p>Editor attaches from daw_vst_bridge (A7).</p>")
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
let win = tauri::WebviewWindowBuilder::new(&app, format!("vst-{}", plugin_id), url)
|
||||||
|
.title(format!("VST — {}", plugin_id))
|
||||||
|
.inner_size(800.0, 600.0)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
// ponytail: HWND truncated to u32 (HWNDs fit in practice); window-close →
|
||||||
|
// close_gui wiring lands together with A7 on Windows.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let hwnd = {
|
||||||
|
use tauri::raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
match win.window_handle().map(|h| h.as_raw()) {
|
||||||
|
Ok(RawWindowHandle::Win32(w)) => w.hwnd as usize as u32,
|
||||||
|
_ => 0u32,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let hwnd = 0u32;
|
||||||
|
shm.push_control(4, 0, hwnd, 0, &plugin_id)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct BridgeStatus {
|
||||||
|
connected: bool,
|
||||||
|
shm_name: &'static str,
|
||||||
|
write_index: u32,
|
||||||
|
block_timestamp: u64,
|
||||||
|
shm_size_bytes: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
block_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(match guard.as_ref() {
|
||||||
|
Some(shm) => BridgeStatus {
|
||||||
|
connected: true,
|
||||||
|
shm_name: shm::SHM_NAME,
|
||||||
|
write_index: shm.write_index(),
|
||||||
|
block_timestamp: shm.block_timestamp(),
|
||||||
|
shm_size_bytes: std::mem::size_of::<shm::SharedAudioBufferIPC>(),
|
||||||
|
sample_rate: 44100,
|
||||||
|
block_size: shm::AUDIO_BLOCK_SIZE as u32,
|
||||||
|
},
|
||||||
|
None => BridgeStatus {
|
||||||
|
connected: false,
|
||||||
|
shm_name: shm::SHM_NAME,
|
||||||
|
write_index: 0,
|
||||||
|
block_timestamp: 0,
|
||||||
|
shm_size_bytes: 0,
|
||||||
|
sample_rate: 0,
|
||||||
|
block_size: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn transport_control(app: AppHandle, kind: String, playhead: Option<u32>) -> Result<(), String> {
|
||||||
|
let state = app.state::<ShmState>();
|
||||||
|
let guard = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
|
||||||
|
// arg0: 0=STOP (flush), 1=PLAY, 2=SET_POSITION (seek, no flush)
|
||||||
|
let (arg0, arg1) = match kind.as_str() {
|
||||||
|
"play" => (1, playhead.unwrap_or(0)),
|
||||||
|
"set_position" => (2, playhead.unwrap_or(0)),
|
||||||
|
_ => (0, playhead.unwrap_or(0)), // stop / panic
|
||||||
|
};
|
||||||
|
shm.push_control(3, arg0, arg1, 0, "")
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("control queue full")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// src-tauri/src/shm.rs
|
||||||
|
// Shared memory `SonicForge_DAW_IPC` — created by the DAW, opened by the
|
||||||
|
// C++ bridge (native_bridge). Layout MUST match native_bridge/include/
|
||||||
|
// SharedMemoryIPC.h (verified by native_bridge/tests/shm_selfcheck.cpp).
|
||||||
|
#![cfg_attr(not(windows), allow(dead_code))]
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
use windows_sys::Win32::System::MemoryManagement::{
|
||||||
|
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, PAGE_READWRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SHM_NAME: &str = "SonicForge_DAW_IPC";
|
||||||
|
pub const AUDIO_BLOCK_SIZE: usize = 256;
|
||||||
|
pub const MIDI_QUEUE_CAP: usize = 64;
|
||||||
|
pub const CONTROL_QUEUE_CAP: usize = 8;
|
||||||
|
pub const CONTROL_PATH_MAX: usize = 1024;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct MidiEventIPC {
|
||||||
|
pub command: u8,
|
||||||
|
pub channel: u8,
|
||||||
|
pub pitch: u8,
|
||||||
|
pub velocity: u8,
|
||||||
|
pub data2: u8, // CC value / program / PB LSB
|
||||||
|
pub data3: u8, // PB MSB (0xE only)
|
||||||
|
pub reserved: [u8; 2],
|
||||||
|
pub sample_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ControlEventIPC {
|
||||||
|
pub ctype: u32,
|
||||||
|
pub arg0: u32,
|
||||||
|
pub arg1: u32,
|
||||||
|
pub channel: u32, // LOAD: MIDI channel to assign (A10)
|
||||||
|
pub arg2: [u8; CONTROL_PATH_MAX],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct SharedAudioBufferIPC {
|
||||||
|
pub client_read_index: u32,
|
||||||
|
pub bridge_write_index: u32,
|
||||||
|
pub master_left: [f32; AUDIO_BLOCK_SIZE],
|
||||||
|
pub master_right: [f32; AUDIO_BLOCK_SIZE],
|
||||||
|
pub block_timestamp: u64,
|
||||||
|
pub midi_queue: [MidiEventIPC; MIDI_QUEUE_CAP],
|
||||||
|
pub midi_queue_count: u32,
|
||||||
|
pub control_queue: [ControlEventIPC; CONTROL_QUEUE_CAP],
|
||||||
|
pub control_queue_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Shm {
|
||||||
|
handle: HANDLE,
|
||||||
|
view: *mut SharedAudioBufferIPC,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for Shm {}
|
||||||
|
|
||||||
|
impl Shm {
|
||||||
|
/// Create (or open) the mapping. Returns None on non-Windows (dev fallback).
|
||||||
|
pub fn create() -> Option<Self> {
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name_wide: Vec<u16> = SHM_NAME.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let size = std::mem::size_of::<SharedAudioBufferIPC>() as u64;
|
||||||
|
let handle = unsafe {
|
||||||
|
CreateFileMappingW(
|
||||||
|
HANDLE::default(),
|
||||||
|
ptr::null(),
|
||||||
|
PAGE_READWRITE,
|
||||||
|
(size >> 32) as u32,
|
||||||
|
size as u32,
|
||||||
|
name_wide.as_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if handle == HANDLE::default() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, 0) };
|
||||||
|
if view.is_null() {
|
||||||
|
unsafe { CloseHandle(handle) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Shm {
|
||||||
|
handle,
|
||||||
|
view: view as *mut SharedAudioBufferIPC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipc(&self) -> &mut SharedAudioBufferIPC {
|
||||||
|
unsafe { &mut *self.view }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_midi(&self, cmd: u8, channel: u8, pitch: u8, velocity: u8, data2: u8, data3: u8, sample_offset: u32) -> bool {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
if ipc.midi_queue_count as usize >= MIDI_QUEUE_CAP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let i = ipc.midi_queue_count as usize;
|
||||||
|
ipc.midi_queue[i] = MidiEventIPC {
|
||||||
|
command: cmd,
|
||||||
|
channel,
|
||||||
|
pitch,
|
||||||
|
velocity,
|
||||||
|
data2,
|
||||||
|
data3,
|
||||||
|
reserved: [0u8; 2],
|
||||||
|
sample_offset,
|
||||||
|
};
|
||||||
|
ipc.midi_queue_count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_control(&self, ctype: u32, arg0: u32, arg1: u32, channel: u32, path: &str) -> bool {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
if ipc.control_queue_count as usize >= CONTROL_QUEUE_CAP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut ev = ControlEventIPC {
|
||||||
|
ctype,
|
||||||
|
arg0,
|
||||||
|
arg1,
|
||||||
|
channel,
|
||||||
|
arg2: [0u8; CONTROL_PATH_MAX],
|
||||||
|
};
|
||||||
|
let bytes = path.as_bytes();
|
||||||
|
let n = bytes.len().min(CONTROL_PATH_MAX - 1);
|
||||||
|
ev.arg2[..n].copy_from_slice(&bytes[..n]);
|
||||||
|
// LOAD relies on C++ side strnlen() — arg1 is free for TRANSPORT playhead.
|
||||||
|
let i = ipc.control_queue_count as usize;
|
||||||
|
ipc.control_queue[i] = ev;
|
||||||
|
ipc.control_queue_count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_audio(&self) -> ([f32; AUDIO_BLOCK_SIZE], [f32; AUDIO_BLOCK_SIZE]) {
|
||||||
|
let ipc = self.ipc();
|
||||||
|
(ipc.master_left, ipc.master_right)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_index(&self) -> u32 {
|
||||||
|
self.ipc().bridge_write_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block_timestamp(&self) -> u64 {
|
||||||
|
self.ipc().block_timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Shm {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.view.is_null() {
|
||||||
|
unsafe { UnmapViewOfFile(self.view as *const c_void) };
|
||||||
|
}
|
||||||
|
unsafe { CloseHandle(self.handle) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide shared-memory state for Tauri.
|
||||||
|
pub struct ShmState(pub Mutex<Option<Shm>>);
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"devUrl": "http://localhost:8000"
|
"devUrl": "http://localhost:8000"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Sonic Forge DAW - Professional Desktop Studio",
|
"title": "Sonic Forge DAW - Professional Desktop Studio",
|
||||||
@@ -24,16 +25,17 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["msi", "nsis"],
|
"targets": ["nsis", "msi"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.ico"
|
"icons/favicon.ico"
|
||||||
],
|
|
||||||
"externalBin": [
|
|
||||||
"binaries/daw_engine"
|
|
||||||
],
|
],
|
||||||
|
"resources": {
|
||||||
|
"resources/daw_engine": "daw_engine/"
|
||||||
|
},
|
||||||
|
"externalBin": ["binaries/daw_vst_bridge"],
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installerHooks": "hooks.nsh"
|
"installerHooks": "hooks.nsh"
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ os.environ.setdefault(
|
|||||||
"SONICFORGE_DB_PATH",
|
"SONICFORGE_DB_PATH",
|
||||||
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
||||||
)
|
)
|
||||||
|
# Cô lập storage khỏi app production (đang chạy root, ghi sf_scan_state.json
|
||||||
|
# vào storage thật → pytest gặp PermissionError khi file root-owned).
|
||||||
|
os.environ.setdefault(
|
||||||
|
"SONICFORGE_STORAGE_DIR",
|
||||||
|
os.path.join(tempfile.gettempdir(), "sonicforge_test_storage"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ client = TestClient(app)
|
|||||||
|
|
||||||
|
|
||||||
def get_admin_token():
|
def get_admin_token():
|
||||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
# test_auth_and_quota.py có thể đã rotate password; thử cả 2.
|
||||||
if resp.status_code == 200:
|
for pwd in ("admin123", "admin_new_password_2026"):
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
token = resp.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
# Admin is seeded with must_change_password=1; the app blocks music
|
# Admin is seeded with must_change_password=1; the app blocks music
|
||||||
# processing until the first password change. Complete that flow here
|
# processing until the first password change. Complete that flow here
|
||||||
@@ -20,7 +23,7 @@ def get_admin_token():
|
|||||||
user = resp.json()["user"]
|
user = resp.json()["user"]
|
||||||
if user.get("must_change_password"):
|
if user.get("must_change_password"):
|
||||||
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
json={"old_password": "admin123", "new_password": "admin123"})
|
json={"old_password": pwd, "new_password": pwd})
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
token = r.json()["access_token"]
|
token = r.json()["access_token"]
|
||||||
return token
|
return token
|
||||||
@@ -93,3 +96,119 @@ class TestPluginAPI:
|
|||||||
assert data["size_bytes"] == len(valid_content)
|
assert data["size_bytes"] == len(valid_content)
|
||||||
elif resp.status_code == 403:
|
elif resp.status_code == 403:
|
||||||
pytest.skip("Permission denied for admin user")
|
pytest.skip("Permission denied for admin user")
|
||||||
|
|
||||||
|
def test_plugin_dirs_save_list(self):
|
||||||
|
"""plugin_dirs (list) — save/get/effective + scan phân loại riêng rẽ."""
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
# 2 dir giả: 1 chứa VST, 1 chứa SoundFont
|
||||||
|
vst_dir = os.path.join(td, "vsts")
|
||||||
|
sf_dir = os.path.join(td, "sfs")
|
||||||
|
os.makedirs(vst_dir)
|
||||||
|
os.makedirs(sf_dir)
|
||||||
|
open(os.path.join(vst_dir, "Synth1.vst3"), "w").write("x")
|
||||||
|
open(os.path.join(sf_dir, "piano.sf2"), "w").write("x")
|
||||||
|
# Save list
|
||||||
|
r = client.post("/api/v1/plugins/dirs", headers=h,
|
||||||
|
json={"plugin_dirs": [vst_dir, sf_dir]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["plugin_dirs"] == [vst_dir, sf_dir]
|
||||||
|
# Get lại
|
||||||
|
g = client.get("/api/v1/plugins/dirs", headers=h)
|
||||||
|
assert g.json()["plugin_dirs"] == [vst_dir, sf_dir]
|
||||||
|
# Scan → phân loại riêng rẽ
|
||||||
|
s = client.post("/api/v1/plugins/scan", headers=h)
|
||||||
|
assert s.status_code == 200
|
||||||
|
data = s.json()
|
||||||
|
assert any(v["name"] == "Synth1" for v in data["vst_found"])
|
||||||
|
assert any(x["name"] == "piano" for x in data["soundfonts"])
|
||||||
|
assert data["vst_count"] == 1
|
||||||
|
assert data["soundfont_count"] == 1
|
||||||
|
# Mỗi entry có dir gốc
|
||||||
|
assert data["vst_found"][0]["dir"] == vst_dir
|
||||||
|
assert data["soundfonts"][0]["dir"] == sf_dir
|
||||||
|
|
||||||
|
def test_plugin_dirs_remove(self):
|
||||||
|
"""Xóa 1 dir khỏi list → save lại → không còn trong effective."""
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
h = {"Authorization": f"Bearer {token}"}
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d1 = os.path.join(td, "d1")
|
||||||
|
d2 = os.path.join(td, "d2")
|
||||||
|
os.makedirs(d1)
|
||||||
|
os.makedirs(d2)
|
||||||
|
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1, d2]})
|
||||||
|
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
|
||||||
|
g = client.get("/api/v1/plugins/dirs", headers=h)
|
||||||
|
assert g.json()["plugin_dirs"] == [d1]
|
||||||
|
|
||||||
|
class TestMidiRenderVSTi:
|
||||||
|
"""Preview/export MIDI notes với âm VSTi — feature Carla bridge → pedalboard."""
|
||||||
|
|
||||||
|
def test_midi_render_requires_auth(self):
|
||||||
|
client.cookies.clear() # TestClient giữ cookie login từ test trước
|
||||||
|
resp = client.post("/api/v1/plugins/midi-render",
|
||||||
|
json={"instrument_id": "x", "notes": [{"pitch": 60}]})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_midi_render_no_notes(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"instrument_id": "x", "notes": []})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_midi_render_unknown_instrument(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"instrument_id": "NoSuchPluginXYZ",
|
||||||
|
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1, "velocity": 0.8}]})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "VSTi" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_midi_render_requires_pedalboard(self):
|
||||||
|
# Nếu pedalboard thiếu → 501 (không crash)
|
||||||
|
import app.api.v1.plugins as plugins_mod
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
if not plugins_mod.HAS_PEDALBOARD:
|
||||||
|
resp = client.post("/api/v1/plugins/midi-render", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"instrument_id": "x",
|
||||||
|
"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}]})
|
||||||
|
assert resp.status_code == 501
|
||||||
|
else:
|
||||||
|
pytest.skip("pedalboard present — render path covered by unknown-instrument test")
|
||||||
|
|
||||||
|
def test_carla_play_notes_requires_auth(self):
|
||||||
|
client.cookies.clear() # TestClient giữ cookie login từ test trước
|
||||||
|
resp = client.post("/api/v1/plugins/carla-play-notes",
|
||||||
|
json={"notes": [{"pitch": 60}], "bpm": 120})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_carla_play_notes_no_notes(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"notes": [], "bpm": 120})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_carla_play_notes_no_carla(self):
|
||||||
|
# Máy test không có Carla local → 409 hướng dẫn định vị/mở Carla.
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
resp = client.post("/api/v1/plugins/carla-play-notes", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"notes": [{"pitch": 60, "start_beat": 0, "duration_beats": 1}], "bpm": 120})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ Covers the vulnerabilities found during the 2026-08 audit:
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
@@ -68,9 +70,32 @@ class TestAIProxySSRF:
|
|||||||
|
|
||||||
def test_proxy_allows_configured_localhost_provider(self):
|
def test_proxy_allows_configured_localhost_provider(self):
|
||||||
# localhost:11434 is in the default AI provider list; it must pass the
|
# localhost:11434 is in the default AI provider list; it must pass the
|
||||||
# SSRF check (and then fail to connect in this environment -> 502).
|
# SSRF check (and then fail to connect -> 502). Bug #5: máy có Ollama
|
||||||
|
# chạy ở localhost:11434 trả 200 → test fail giả. Mock connection fail
|
||||||
|
# để test deterministic, không phụ thuộc service ngoài.
|
||||||
|
import app.api.v1.ai_proxy as ai_proxy
|
||||||
|
|
||||||
|
class _FakeConnectError(httpx.ConnectError):
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
super().__init__("mock connection refused", request=httpx.Request("POST", "http://localhost:11434/"))
|
||||||
|
|
||||||
|
class _FakeAsyncClient(httpx.AsyncClient):
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, *a, **k):
|
||||||
|
raise _FakeConnectError()
|
||||||
|
|
||||||
|
monkeypatch = pytest.MonkeyPatch()
|
||||||
|
monkeypatch.setattr(ai_proxy.httpx, "AsyncClient", _FakeAsyncClient)
|
||||||
|
try:
|
||||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||||
|
finally:
|
||||||
|
monkeypatch.undo()
|
||||||
assert resp.status_code == 502
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
@@ -232,3 +257,101 @@ class TestRenderResample:
|
|||||||
os.remove(src_path)
|
os.remove(src_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── 7. Bug fixes 2026-08-10 audit round 2 ──
|
||||||
|
|
||||||
|
class TestBugFixes:
|
||||||
|
def test_static_mount_blocks_dotfiles(self):
|
||||||
|
# Bug #1: /static/audio/.secret_key, sonicforge.db, temp/autosave.json
|
||||||
|
# từng serve 200 không cần auth — giờ phải 404.
|
||||||
|
assert client.get("/static/audio/.secret_key").status_code == 404
|
||||||
|
assert client.get("/static/audio/sonicforge.db").status_code == 404
|
||||||
|
assert client.get("/static/audio/temp/autosave.json").status_code == 404
|
||||||
|
assert client.get("/static/audio/../.secret_key").status_code in (404, 400)
|
||||||
|
|
||||||
|
def test_static_uploads_still_served(self):
|
||||||
|
# File audio hợp lệ trong uploads vẫn phải serve được (không vỡ UI).
|
||||||
|
fid = f"user_test_{os.urandom(4).hex()}.wav"
|
||||||
|
p = os.path.join(settings.UPLOADS_DIR, fid)
|
||||||
|
with open(p, "wb") as f:
|
||||||
|
f.write(b"RIFFxxxxWAVE")
|
||||||
|
try:
|
||||||
|
assert client.get(f"/static/audio/uploads/{fid}").status_code == 200
|
||||||
|
finally:
|
||||||
|
os.remove(p)
|
||||||
|
|
||||||
|
def test_delete_user_file_blocks_traversal(self):
|
||||||
|
# Bug #2: `user_<id>_../../x` không được xóa file ngoài storage.
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
uid = prof.json()["id"]
|
||||||
|
marker = os.path.join(settings.STORAGE_DIR, "pwn_marker_%s.txt" % os.urandom(4).hex())
|
||||||
|
with open(marker, "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
try:
|
||||||
|
resp = client.delete(f"/api/v1/audio/my-files/user_{uid}_../../{os.path.basename(marker)}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code in (403, 404)
|
||||||
|
assert os.path.exists(marker), "traversal delete phải bị chặn"
|
||||||
|
finally:
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.remove(marker)
|
||||||
|
|
||||||
|
def test_upload_rejects_non_audio_extension(self):
|
||||||
|
# Bug #4: upload .exe bị từ chối.
|
||||||
|
resp = client.post("/api/v1/audio/upload",
|
||||||
|
files={"file": ("evil.exe", b"MZ\x90\x00", "application/octet-stream")})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_change_password_rejects_weak(self):
|
||||||
|
# Bug #7: change-password áp cùng policy độ mạnh như register.
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
resp = client.post("/api/v1/auth/change-password",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": "admin123", "new_password": "a"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_cleanup_keeps_referenced_files(self):
|
||||||
|
# Bug #3: cleanup_expired_files_task không xóa file được project tham chiếu.
|
||||||
|
from app.tasks.worker import cleanup_expired_files_task
|
||||||
|
from app.models.user import get_db_connection
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
prof = client.get("/api/v1/auth/profile", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
uid = prof.json()["id"]
|
||||||
|
ref_id = f"user_{uid}_ref_{os.urandom(4).hex()}.wav"
|
||||||
|
stray_id = f"user_{uid}_stray_{os.urandom(4).hex()}.wav"
|
||||||
|
ref_path = os.path.join(settings.PROCESSED_DIR, ref_id)
|
||||||
|
stray_path = os.path.join(settings.PROCESSED_DIR, stray_id)
|
||||||
|
old = time.time() - 999999
|
||||||
|
for p in (ref_path, stray_path):
|
||||||
|
with open(p, "wb") as f:
|
||||||
|
f.write(b"RIFFxxxxWAVE")
|
||||||
|
os.utime(p, (old, old))
|
||||||
|
pid = f"cleanup_test_{os.urandom(4).hex()}"
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
proj = {"tracks": [{"serverFileId": ref_id}]}
|
||||||
|
cursor.execute("INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||||
|
(pid, uid, "cleanup test", json.dumps(proj), 0, old))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
result = cleanup_expired_files_task(max_age_hours=0)
|
||||||
|
assert os.path.exists(ref_path), "file được project tham chiếu phải giữ"
|
||||||
|
assert not os.path.exists(stray_path), "file không tham chiếu phải bị dọn"
|
||||||
|
assert result["referenced_files_kept"] >= 1
|
||||||
|
finally:
|
||||||
|
for p in (ref_path, stray_path):
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM projects WHERE id = ?", (pid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -21,14 +21,9 @@ class TestPluginManager:
|
|||||||
sr = 44100
|
sr = 44100
|
||||||
msgs = PluginManager.midi_events_to_messages(events, bpm, sr)
|
msgs = PluginManager.midi_events_to_messages(events, bpm, sr)
|
||||||
beat_sec = 60.0 / 120
|
beat_sec = 60.0 / 120
|
||||||
expected_note_on_offset = 0
|
# pedalboard >= 0.9: message = (bytes raw MIDI, timestamp_seconds)
|
||||||
expected_note_off_offset = int(beat_sec * sr)
|
assert msgs[0] == (bytes([0x90, 60, 100]), 0.0)
|
||||||
# Check note_on message
|
assert msgs[1] == (bytes([0x80, 60, 0]), beat_sec)
|
||||||
assert msgs[0].sample_offset == expected_note_on_offset
|
|
||||||
assert msgs[0].note == 60
|
|
||||||
# Check note_off message
|
|
||||||
assert msgs[1].sample_offset == expected_note_off_offset
|
|
||||||
assert msgs[1].note == 60
|
|
||||||
|
|
||||||
def test_list_available_empty(self):
|
def test_list_available_empty(self):
|
||||||
pm = PluginManager(vst_dir="/tmp/nonexistent_vst_dir_xyz", sf_dir="/tmp/nonexistent_sf_dir_xyz")
|
pm = PluginManager(vst_dir="/tmp/nonexistent_vst_dir_xyz", sf_dir="/tmp/nonexistent_sf_dir_xyz")
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Sinh src-tauri/icons/favicon.ico tu app/templates/favicon.svg (icon exe).
|
||||||
|
// Dung: node tools/gen_favicon_ico.js
|
||||||
|
// (Can @resvg/resvg-js — npm install @resvg/resvg-js trong thu muc lam viec,
|
||||||
|
// hoac chay trong thu muc da cai. Output: src-tauri/icons/favicon.ico.)
|
||||||
|
// ICO chua cac size 16/24/32/48/64/128 — Windows dung cho exe icon.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SVG = path.join(ROOT, 'app', 'templates', 'favicon.svg');
|
||||||
|
const OUT = path.join(ROOT, 'src-tauri', 'icons', 'favicon.ico');
|
||||||
|
|
||||||
|
let Resvg;
|
||||||
|
try {
|
||||||
|
({ Resvg } = require('@resvg/resvg-js'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Thieu @resvg/resvg-js. Chay: npm install @resvg/resvg-js');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = fs.readFileSync(SVG, 'utf8');
|
||||||
|
const SIZES = [16, 24, 32, 48, 64, 128];
|
||||||
|
|
||||||
|
function buildIco(images) {
|
||||||
|
const header = Buffer.alloc(6);
|
||||||
|
header.writeUInt16LE(0, 0);
|
||||||
|
header.writeUInt16LE(1, 2);
|
||||||
|
header.writeUInt16LE(images.length, 4);
|
||||||
|
const entries = [];
|
||||||
|
const datas = [];
|
||||||
|
let offset = 6 + 16 * images.length;
|
||||||
|
for (const { size, data } of images) {
|
||||||
|
const entry = Buffer.alloc(16);
|
||||||
|
const dim = size >= 256 ? 0 : size;
|
||||||
|
entry.writeUInt8(dim, 0);
|
||||||
|
entry.writeUInt8(dim, 1);
|
||||||
|
entry.writeUInt8(0, 2);
|
||||||
|
entry.writeUInt8(0, 3);
|
||||||
|
entry.writeUInt16LE(1, 4);
|
||||||
|
entry.writeUInt16LE(32, 6);
|
||||||
|
entry.writeUInt32LE(data.length, 8);
|
||||||
|
entry.writeUInt32LE(offset, 12);
|
||||||
|
entries.push(entry);
|
||||||
|
datas.push(data);
|
||||||
|
offset += data.length;
|
||||||
|
}
|
||||||
|
return Buffer.concat([header, ...entries, ...datas]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngs = SIZES.map(s => ({
|
||||||
|
size: s,
|
||||||
|
data: new Resvg(svg, { fitTo: { mode: 'width', value: s }, background: 'rgba(0,0,0,0)' }).render().asPng(),
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(OUT, buildIco(pngs));
|
||||||
|
console.log('favicon.ico ->', OUT, fs.statSync(OUT).size, 'bytes');
|
||||||
@@ -14,6 +14,11 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
REQUIRED = ["app/static", "app/templates"]
|
REQUIRED = ["app/static", "app/templates"]
|
||||||
|
# F4: bridge sidecar phai co truoc khi tauri build (externalBin). Kiem tra tai
|
||||||
|
# day de build fail SOM nhat (build_windows.ps1 goi verify nay truoc tauri build).
|
||||||
|
BRIDGE_SIDECAR = os.path.join(
|
||||||
|
"src-tauri", "binaries", "daw_vst_bridge-x86_64-pc-windows-msvc.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def find_toc(build_dir: str) -> str:
|
def find_toc(build_dir: str) -> str:
|
||||||
@@ -31,6 +36,7 @@ def find_toc(build_dir: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
check_bridge = "--check-bridge" in sys.argv
|
||||||
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
||||||
toc = find_toc(build_dir)
|
toc = find_toc(build_dir)
|
||||||
if not toc:
|
if not toc:
|
||||||
@@ -54,6 +60,19 @@ def main() -> int:
|
|||||||
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
||||||
return 1
|
return 1
|
||||||
print("OK: app/static + app/templates co trong bundle.")
|
print("OK: app/static + app/templates co trong bundle.")
|
||||||
|
# F4: bridge sidecar (externalBin) — kiem tra o repo root (khong phai trong
|
||||||
|
# PyInstaller bundle): thieu -> tauri build se im lang bo qua bridge.
|
||||||
|
# Chi chay khi --check-bridge (goi o buoc [7/7] build_windows.ps1 — sau
|
||||||
|
# khi da build bridge o buoc [5/7]).
|
||||||
|
if check_bridge:
|
||||||
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
sidecar = os.path.join(repo_root, BRIDGE_SIDECAR)
|
||||||
|
if not os.path.exists(sidecar):
|
||||||
|
print(f"ERROR: Thieu bridge sidecar: {sidecar}")
|
||||||
|
print(" Chay build/scripts/build_native_bridge.ps1 truoc, hoac")
|
||||||
|
print(" build_windows.ps1 buoc [5/7] se build tu dong.")
|
||||||
|
return 1
|
||||||
|
print(f"OK: bridge sidecar co mat ({BRIDGE_SIDECAR}).")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,36 @@
|
|||||||
|
### [2026-08-10] Task: Thực thi fix (phiên có tool) — mastering ON mặc định, âm sai instrument, Carla alive check
|
||||||
|
- **Tóm tắt thay đổi:**
|
||||||
|
(1) **Mastering FX bật ON mặc định**: `masteringSettings` khởi tạo `masterConnected: true` — MIDI item/preview được xử lí qua mastering FX ngay khi chạy app (không cần bật/tắt power). Kèm `ensureMasteringRouting()` + `refreshCarlaStatus()` gọi ở đầu `startTrackPlayback`/`startLocalTrackPlayback`/`schedulePianoRollMidi`.
|
||||||
|
(2) **Âm sai instrument (chọn Synth String nghe Piano)**: `soundfontPlayer.js` thêm `_instrumentEpoch` — tăng khi `selectInstrument`/load soundfont thành công; `progAlreadySet` chỉ skip program_select khi cache khớp EPOCH hiện tại → mọi đổi instrument đều program_select lại đúng preset (hết cache stale); `program_select` fail → thử lại bank 0; cache channel lưu `_epoch`.
|
||||||
|
(3) **Carla không play MIDI note / câm toàn phần**: backend thêm `GET /api/v1/plugins/carla-status` (running + osc_port); `api.js` thêm `carlaStatus()`; frontend `refreshCarlaStatus()` + `ensureCarlaForPlayback()` (auto-mở Carla với VSTi khi chưa chạy); `routeToCarla` chỉ EXCLUSIVE khi `window.__carlaRunning !== false` — Carla chết → fallback FluidSynth (luôn có âm).
|
||||||
|
(4) **Keybed giữ note theo thời gian bấm**: durationMs 500/200 → 5000 (auto-off phòng hờ); mouseup/mouseleave dừng NGAY qua stopNote.
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+/carla-status), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js` (epoch + fallback), `app/static/js/services/api.js` (+carlaStatus), `app/templates/index.html` (?v= 202608101700), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke jsdom EVAL OK. Test noteoff 6/6. curl `/carla-status` → {"running":false,"osc_port":22752}. ⚠️ Rebuild PyInstaller exe trên Windows để nhận backend `/carla-status` (PYZ); static/templates đã sync dist.
|
||||||
|
|
||||||
|
### [2026-08-10] Task: Fix (KHÔNG bỏ qua) — MIDI keyboard preview không qua mastering + noteoff không xử lí + MIDI item không play qua Carla bridge
|
||||||
|
- **Tóm tắt thay đổi:**
|
||||||
|
(1) **MIDI item PHẢI play qua Carla bridge khi VSTi loaded** (`app.jsx`): trước đây các hàm playback gọi `SonicSF.playNote` (FluidSynth GM — âm SAI) SONG SONG với route Carla → âm nghe được là GM, không phải VSTi. Giờ: mọi đường playback (startTrackPlayback main, MIDI section subTrack, startLocalTrackPlayback, schedulePianoRollMidi + ghost notes) tính `routeToCarla = shouldRoutePlayback(synth_engine)` — nếu TRUE → CHỈ route Carla (bỏ hẳn FluidSynth); FALSE → FluidSynth như cũ.
|
||||||
|
(2) **MIDI keyboard preview không route qua mastering FX chain** (`app.jsx`): `updateSfRouting` nhánh gainNode (piano roll + midiAudible) THIẾU sync route gains → node giữ bypass cũ (tạo khi mastering OFF / audioBypass true) → preview đi dry cho tới khi bật/tắt power. Fix: sync `route.routeGain/dryGain` theo `effMidiBypass` NGAY tại thời điểm routing; `ensureMasteringRouting` giờ force-sync MỌI node (sfRouteGain/sfDryGain + routeGain/dryGain) theo trạng thái hiện tại.
|
||||||
|
(3) **Âm kêu liên tục dù đã thả phím (không xử lí noteoff)** (`soundfontPlayer.js`): khi FluidSynth WASM chưa sẵn sàng / font load fail → `_playNoteFallback` tạo oscillator — `stopNote` trước đây CHỈ noteoff FluidSynth, KHÔNG dừng oscillator → âm kêu tới khi auto-stop 2s. Fix: `_activeOscillatorsByKey` (key 'channel:pitch'); `stopNote` dừng oscillator đúng key ngay (osc.stop + gain 0); `panic`/`stopAll` dừng toàn bộ + clear map.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` (?v= 202608101600), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Test jsdom noteoff: 6/6 pass (stopNote gọi osc.stop ngay t=1.0 + gain 0; đúng channel:pitch — không đụng note/osc channel khác; panic dừng tất cả). Bundle: routeToCarla ở cả 4 đường + ghosts. ⚠️ Rebuild PyInstaller exe trên Windows để nhận backend fix trước đó (plugins.py/projects.py — PYZ).
|
||||||
|
|
||||||
|
### [2026-08-10] Task: Preview soundfont + MIDI keyboard không đi qua mastering FX main out (phải bật/tắt power mới có tác dụng)
|
||||||
|
- **Tóm tắt thay đổi:** Routing âm thanh SF preview (keybed/draw/MIDI file) chỉ được đồng bộ khi masteringSettings ĐỔI (toggle power) — bắt đầu preview KHÔNG chạy lại `updateSfRouting` + đồng bộ route gains → âm preview không tự động qua mastering chain cho tới khi bật/tắt nút power. Fix: (1) thêm `ensureMasteringRouting()` trong App (toggleMasteringOnMaster theo trạng thái hiện tại — early-return nếu đúng; applyMasteringSettings; updateSfRouting) — gọi tại: keybed onMouseDown, playDrawPreview, playMidiPreview (Media Explorer), mở piano roll tab (setTimeout 60ms sau setActiveTab); (2) thêm `masterConnected`/`isBypassed` vào sig của `applyMasteringSettings` — toggle power luôn re-apply tham số module (hết blind-spot early-return); (3) expose `window.__ensureMasteringRouting`. Đã kiểm chứng bằng test jsdom (AudioContext mock theo dõi connect): khi mastering ON, path SF_ROUTE → masterBus.input → inputAnalyser → eqLowFilter (mastering chain) → destination là ĐÚNG — vấn đề nằm ở chỗ không re-sync routing tại thời điểm preview.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/templates/index.html` (?v= bump 202608101400), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke test jsdom: bundle eval OK; bundle chứa __ensureMasteringRouting (5 chỗ) + sig masterConnected. Test graph jsdom: SF → EQ module đạt khi mastering ON (cả từ đầu lẫn sau toggle) — routing wiring đúng, fix tập trung vào re-sync khi preview.
|
||||||
|
|
||||||
|
### [2026-08-10] Task: Fix 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain
|
||||||
|
- **Tóm tắt thay đổi:**
|
||||||
|
(1) **Soundfont preview âm loop không dừng**: `soundfontPlayer.js` — note đang CHỜ load soundfont giờ được đăng ký trong `_pendingNoteOns` + `_noteGeneration` trước khi load → `stopNote` (thả phím MIDI Keyboard) / `stopAll` / `panic` hủy được note deferred (trước đây note bắn TRỄ sau khi thả phím/Stop → âm loop không dừng).
|
||||||
|
(2) **Unload Carla bridge khi chọn lại instrument soundfont**: backend `plugins.py` thêm registry `_CARLA_PROCESSES` (open_in_carla lưu Popen) + endpoint `POST /api/v1/plugins/carla-stop` (OSC note_off toàn pitch mọi channel → terminate/kill tiến trình Carla). Frontend `setTrackInstrumentWithProgram` gọi `SonicCarlaMidi.stopBridge()` khi track chuyển từ VSTi → non-VST; `runtime.js` thêm `allNotesOff()` + `stopBridge()`; `api.js` thêm `stopCarla()`.
|
||||||
|
(3) **MIDI item play qua Carla bridge khi VSTi loaded**: `app.jsx` thêm helper `scheduleCarlaNote()` và route vào CẢ 3 đường playback: `startTrackPlayback` (main timeline), `startLocalTrackPlayback` (local loop), ghost notes trong `schedulePianoRollMidi` (trước đây chỉ piano roll chính route).
|
||||||
|
(4) **Tự động lưu temp khi tắt app**: `projects.py` POST /temp giờ ghi file `storage/temp/autosave.json` (thư mục temp của ứng dụng) + DB; GET /temp fallback đọc file. `app.jsx` thêm beforeunload/pagehide → sendBeacon POST /projects/temp + autosave 30s + khôi phục dự án temp khi mount (chỉ khi project trống).
|
||||||
|
(5) **Ctrl-S (Save)**: `app.jsx` thêm global Ctrl/Cmd+S handler — desktop → `POST /api/v1/projects/save-to-disk` (ghi vào Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux); docker/headless → Cloud (đã login) hoặc local .sfs.
|
||||||
|
(6) **FX Chain (Mastering + FX Rack) load Carla bridge**: thêm module type `carla` vào `TRACK_FX_META`/`TRACK_FX_DEFAULTS` + `MODULE_META` (mastering) — UI dropdown VST đã scan → Load Carla Bridge (openInCarla) / Stop-Unload (carla-stop); `createTrackFxModule` + `rebuildMasteringGraph` xử lý carla là pass-through (không thêm DSP WebAudio).
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/api/v1/projects.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/runtime.js`, `app/static/js/services/api.js`, `app/templates/index.html` (?v= bump 202608101200), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke test jsdom: bundle eval OK, các symbol mới (scheduleCarlaNote, TRACK_FX_META.carla, stopBridge, stopCarla, saveProjectToDisk) đều tồn tại. Test curl: `/carla-stop` 200, `/save-to-disk` ghi file OS, `/temp` ghi storage/temp/autosave.json + GET trả has_temp. ⚠️ Backend .py nằm trong PyInstaller PYZ → CẦN rebuild exe trên Windows để nhận fix backend (static/templates đã sync dist).
|
||||||
|
|
||||||
### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static')
|
### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static')
|
||||||
- **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True.
|
- **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True.
|
||||||
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize)
|
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize)
|
||||||
@@ -3031,3 +3064,33 @@
|
|||||||
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
|
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
|
||||||
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.
|
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.
|
||||||
|
|
||||||
|
### [2026-08-09] Task: Carla Bridge (native GUI VSTi) + dual-mode runtime + preset library
|
||||||
|
- **Tóm tắt thay đổi:** (1) Runtime tự phát hiện môi trường `app/core/runtime.py` (desktop Windows / docker headless; override `SF_RUNTIME`/`SF_DOCKER`) + `GET /api/v1/system/capabilities` (public) — frontend bật/tắt tính năng theo môi trường. (2) Carla Bridge: mục "🎛 Carla Bridge (mở Carla.exe)" trong dropdown nút Synth (chỉ hiện khi desktop + có Carla local) → spawn `carla.exe` qua `POST /api/v1/plugins/open-in-carla`; vì bản Windows là zip portable (không installer, không PATH) → Plugin Manager thêm section "Carla Bridge" + nút "Định vị Carla..." (`POST /api/v1/system/carla-path`, lưu `storage/carla_path.json` ưu tiên cao nhất; kèm registry + quét nông Downloads/Desktop/Documents giới hạn độ sâu). (3) Thư viện preset `app/api/v1/presets.py` (storage/presets: list/upload/download/delete, chống path traversal) + `apply_preset_to_plugin()` trong `vst_engine.py` (preset_data base64 → preset_id → preset_path) → render_engine nạp preset trước khi render VST3 → âm render = âm đã chỉnh trong Carla. (4) `POST /api/v1/plugins/preview` — quick-render preview (cùng code path pedalboard với export → âm thật). (5) Plugin Manager: bấm soundfont expand → liệt kê instrument (bank/program/name) + nút "Chèn vào Synth" gán vào track đang chọn. (6) `config.py` default VST_DIR/SOUNDFONT_DIR theo platform + `PRESET_DIR`; docker-compose `SF_DOCKER=1`; service `runtime.js` (capabilities lúc boot, cache preset) + api.js methods mới (getCapabilities/setCarlaPath/openInCarla/previewInstrument/listPresets/uploadPreset/deletePreset).
|
||||||
|
- **Các file ảnh hưởng:** `app/core/runtime.py` (mới), `app/api/v1/system.py` (mới), `app/api/v1/presets.py` (mới), `app/api/v1/plugins.py`, `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/config.py`, `app/main.py`, `app/static/js/services/runtime.js` (mới), `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html`, `.env.example`, `docker-compose.prod.yml`, `md/52_CARLA_BRIDGE.md` (mới), `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test API: capabilities 200; preset CRUD + chặn path traversal; carla-path (thư mục/exe → resolve exe, invalid → 400); open-in-carla → 409 kèm hướng dẫn khi chưa có Carla; preview → 501 khi thiếu pedalboard. Rebuild bundle BUILD OK. Lưu ý: pedalboard 0.10+ đã bỏ VST2 (chỉ preset VST3 `.vstpreset` round-trip); render cùng sample rate với Carla để preview = export; Carla GPL-2.0+ → không bundle/nhúng, chỉ spawn tiến trình ngoài.
|
||||||
|
|
||||||
|
### [2026-08-09] Task: Carla Bridge — auto-load VSTi qua .carxs + khai báo thư mục Carla
|
||||||
|
- **Tóm tắt thay đổi:** (1) `POST /api/v1/plugins/open-in-carla` viết lại: sinh file project `.carxs` (định dạng XML chính thức từ source Carla — `carla.exe [FILE]` nhận project file) chứa `<Plugin><Info><Type>VST3</Type><Binary>path</Binary>...` → Carla mở lên **plugin đã load sẵn** kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime. VST3 (file `.vst3`/folder `X.vst3` Windows) auto-load; VST2 (`.dll`/`.so` ngoài `.vst3`) → Carla trống để Add Plugin thủ công (cần uniqueID). Project lưu `{STORAGE_DIR}/carla_projects/`, tự dọn sau 1 ngày. (2) Frontend: chọn VSTi trong dropdown Synth → **tự động** `openInCarla(v.id)` (chỉ khi desktop + carla_local) — nhấn nút Synth, thêm VSTi là Carla tự gọi và load luôn VSTi đó. (3) Plugin Manager section "Carla Bridge": thêm **ô nhập tay thư mục chứa carla.exe + nút Lưu** (bên cạnh "Định vị Carla..." dùng picker) — khai báo 1 lần là xong.
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+ `_write_carla_project`), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `md/52_CARLA_BRIDGE.md`, `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test `_write_carla_project`: VST3 file → carxs hợp lệ (CARLA-PROJECT VERSION='2.5', Type VST3, Binary, Active Yes, ControlChannel 1); Windows VST3 folder → OK; VST2 .dll → '' (không sinh); XML escape tên đặc biệt (A&B <C>) parse OK. End-to-end endpoint với Carla giả: 200, project_file sinh + chứa Binary, cmd = [carla.exe, carxs]. Rebuild bundle BUILD OK (node qua PATH nvm v24).
|
||||||
|
|
||||||
|
### [2026-08-09] Task: Fix — Carla không được gọi + Soundfont không scan được trên Windows
|
||||||
|
- **Tóm tắt thay đổi:** (1) Bug backend: `GET /api/v1/plugins/available` chỉ gộp VST từ `plugin_dirs` user, KHÔNG gộp soundfont → nút Synth rỗng khi soundfont nằm trong thư mục user thêm. Fix: thêm `_scan_soundfonts_in_dirs()` (walk .sf2/.sf3 + meta) và merge vào `avail["soundfonts"]`. (2) Auto-launch Carla giờ phủ TẤT CẢ bề mặt click VSTi: modal "Select Instrument" (vst_instruments), dropdown track strip (đã có), Plugin Manager tab VST + kết quả Scan (thêm nút 🎛 mỗi dòng, scan dùng path trực tiếp). (3) Plugin Manager: Add Directory / remove dir → **tự động lưu + scan** (debounce 600ms) — không cần bấm Scan tay. (4) Sync file static mới (app.precompiled.js, api.js, runtime.js, app.jsx, index.html) vào `dist/daw_engine/_internal/app/` để bản packaged nhận fix frontend.
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `dist/daw_engine/_internal/app/templates/index.html`, `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** Test /available với thư mục sf2 user → soundfont xuất hiện (total 2). pytest: 86 passed, 7 skipped. Rebuild bundle BUILD OK. ⚠️ Backend fix (plugins.py) cần **rebuild PyInstaller trên Windows** (dist backend nằm trong exe); frontend đã patch sẵn trong dist.
|
||||||
|
|
||||||
|
### [2026-08-09] Task: MIDI ARM → Carla (OSC bridge) + fix instrument soundfont từ thư mục user
|
||||||
|
- **Tóm tắt thay đổi:** (1) **MIDI keyboard → Carla realtime**: `POST /api/v1/plugins/carla-midi` (note_on/note_off) gửi OSC UDP tới `/Carla/0/note_on|note_off` (pluginId 0 = plugin auto-load qua .carxs; cổng mặc định 22752 — đã xác minh từ source Carla: CarlaEngineOsc handleMsgNoteOn/NoteOff nhận `iii`/`ii`, CarlaEngineData oscPortUDP=22752, tên client standalone "Carla" từ carla_host.py; override qua env SF_CARLA_OSC_PORT hoặc `osc_port` trong carla_path.json). Frontend: `window.SonicCarlaMidi` (runtime.js) + hook vào **keybed piano roll** (onMouseDown/onMouseEnter) và **playDrawPreview** (vẽ/click note) — chỉ route khi track VSTi + ARM + carla_local. (2) **Soundfont từ thư mục user**: endpoint `/soundfont-instruments/{sf_id}` giờ truyền plugin_dirs → `PluginManager.list_soundfont_instruments` tìm cả thư mục user (bug "nhấn tên SF không thấy instrument"); `/available` truyền extra dirs; `render_engine._find_sf2_path` tìm thêm thư mục user (trước chỉ UPLOAD + /opt/daw_engine/soundfonts + static → soundfont user render câm).
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+carla-midi, _send_carla_osc, _carla_osc_port), `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/static/js/services/runtime.js` (+SonicCarlaMidi), `app/static/js/services/api.js` (+carlaMidi), `app/static/js/app.jsx` (keybed + playDrawPreview), `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. OSC: hexdump 32B = `/Carla/0/note_on` + `,iii` + [0,60,100] (big-endian int32) — khớp expected; note_off = `,ii` + [ch,note]. Rebuild bundle BUILD OK. ⚠️ Cần rebuild PyInstaller trên Windows để nhận backend fix (dist backend nằm trong exe). Lưu ý: Carla phải ĐANG MỞ để nhận OSC; nếu đổi cổng OSC trong Carla → đặt SF_CARLA_OSC_PORT.
|
||||||
|
|
||||||
|
### [2026-08-09] Task: MIDI items play qua Carla + list instrument SF không cần libfluidsynth
|
||||||
|
- **Tóm tắt thay đổi:** (1) **Playback MIDI items → Carla**: `schedulePianoRollMidi` (app.jsx ~21133) giờ route note_on/note_off tới Carla qua `SonicCarlaMidi.shouldRoutePlayback()` (track VSTi + carla_local, KHÔNG cần ARM — user chủ động bấm Play) — schedule bằng setTimeout theo audio clock (preview, timing gần đúng). Keybed/draw preview vẫn yêu cầu ARM (shouldRoute). (2) **List instrument soundfont hoạt động không cần libfluidsynth**: `PluginManager.list_soundfont_instruments` bỏ early-return `if not ensure_pyfluidsynth(): return []`; thêm fallback đọc TRỰC TIẾP file SF2 qua `SoundFontInspector.inspect_sf2_file` (sf2utils — thuần Python, đọc preset header pdta/phdr → bank/program/name) — quan trọng trên Windows khi thiếu fluidsynth DLL. Giữ đường FluidSynth khi có lib.
|
||||||
|
- **Các file ảnh hưởng:** `app/core/vst_engine.py`, `app/static/js/services/runtime.js` (+shouldRoutePlayback), `app/static/js/app.jsx` (schedulePianoRollMidi), `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. Test fallback trên máy KHÔNG có libfluidsynth (đúng kịch bản Windows): file sf2 thật → 137 presets (first: {bank:128, program:48, name:'Orchestra Kit', is_percussion:True}). Rebuild bundle BUILD OK; dist synced. ⚠️ Cần rebuild PyInstaller trên Windows để nhận backend fix (dist backend trong exe).
|
||||||
|
|
||||||
|
### [2026-08-09] Task: Carla auto-connect (Patchbay) + MIDI item play + fix note kẹt keybed
|
||||||
|
- **Tóm tắt thay đổi:** (1) **Carla auto-connect lần đầu**: `_write_carla_project` thêm section `<Patchbay>` vào .carxs — audio out plugin → `system:playback_1/2` (default speaker) + MIDI in → plugin `midi_in` (biến thể tên client Carla/carla/system:midi_capture_1; connection trỏ port không tồn tại bị Carla bỏ qua im lặng). Trước đây Carla load project KHÔNG connect gì → lần đầu không có âm/không nhận MIDI (phải unload/load lại). (2) **MIDI item play qua Carla**: nguyên nhân chính là (a) thiếu audio connection (đã fix ở trên) + (b) browser cache bundle cũ — bump `?v=` stamps trong index.html (runtime.js/api.js/app.precompiled.js → 202608091200) để chắc chắn load bundle mới có `SonicCarlaMidi.shouldRoutePlayback`. (3) **Keybed note kẹt**: onMouseUp + onMouseLeave (mới) giờ gọi `SonicSF.stopNote(kbCtx.ch, pitch)` (dừng âm soundfont khi thả/rời phím) + clear timer Carla + `noteOff` tới Carla. Không đụng MIDI item playback của soundfont (vẫn chạy tốt).
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (_write_carla_project +Patchbay), `app/static/js/app.jsx` (keybed onMouseUp/onMouseLeave), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (?v= bump), `dist/daw_engine/_internal/app/**`, `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. Test .carxs: XML parse OK, 5 Connection đúng (audio_out1/2 → system:playback_1/2; Carla/carla/system:midi_capture_1 → plugin:midi_in). Rebuild bundle BUILD OK; dist synced (gồm index.html mới ?v=). ⚠️ Rebuild PyInstaller trên Windows để nhận backend fix (.carxs mới).
|
||||||
|
|||||||