Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3bd0989031 | |||
| dccf4f45ef | |||
| 9099529403 | |||
| 3795ea9d78 | |||
| fa24c61bbf | |||
| 83427fe503 | |||
| cef2d6666b | |||
| bfe9e47db1 | |||
| bcc91db4a7 | |||
| 1f7a8c6f1b | |||
| 8b0551b18f | |||
| eaca051191 | |||
| b78a629429 | |||
| 366219a269 | |||
| 3618e3c591 | |||
| b490aa9951 | |||
| be93f9f55a | |||
| 7dda81b1d3 | |||
| f7b0172fdc | |||
| 9bebbe8e7a | |||
| 4e4391b315 | |||
| e978abf0e9 | |||
| bb699631bf | |||
| 0a1f3efd64 | |||
| 9ef622f29a | |||
| 8e3c652551 | |||
| 393df8fd7b | |||
| 688a7a57e2 | |||
| 652e745d0a | |||
| 45ab31d42e | |||
| c3182f6baa | |||
| 1d9b426d15 | |||
| 4f34491f3e | |||
| 9e269d3b79 | |||
| 58c6ec794d | |||
| 173a1f227b | |||
| c1aa1eb4d4 | |||
| 6c16bf8410 | |||
| 4981785913 | |||
| bca661bffd | |||
| 4c820bfa77 | |||
| 8cdf2942b9 | |||
| 23675dab83 | |||
| 71b9231b57 | |||
| 93dc13b17a | |||
| f1e914fa62 | |||
| 4dfab954d8 | |||
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca | |||
| 289746f187 | |||
| b3ced7a7b3 | |||
| 4233c1eeda | |||
| 35f7c3822f | |||
| d37f4e7557 | |||
| 8a9d6b3c27 | |||
| 55d3464b1e | |||
| cfc114b9d7 | |||
| 856a8183b6 | |||
| 0272912cff | |||
| c047934fc4 | |||
| fc663921ff | |||
| fe5e8cb58e | |||
| 01fcf51fea | |||
| 1af2119444 | |||
| e5b4321a55 | |||
| ec5fedec33 | |||
| 8c1a8ead56 | |||
| ed91e4534c | |||
| 8fc1c2641b | |||
| a9da813cb1 | |||
| 6f55d36085 |
@@ -0,0 +1,44 @@
|
|||||||
|
# ── Docker build context — loại bỏ mã nguồn/bí mật không cần vào image ──
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Mã nguồn client (JSX) — image CHỈ chứa bản precompiled (minified)
|
||||||
|
app/static/js/app.jsx
|
||||||
|
app/static/js/app.jsx.new
|
||||||
|
*.jsx
|
||||||
|
|
||||||
|
# Bí mật
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies / build cache
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.cache
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
|
# Tài liệu / kế hoạch / backup nội bộ
|
||||||
|
wiki.md
|
||||||
|
PLAN.md
|
||||||
|
PLAN*
|
||||||
|
plans/
|
||||||
|
md/
|
||||||
|
*.patch
|
||||||
|
*.recovered
|
||||||
|
index.html.recovered
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Dữ liệu runtime (mount volume riêng — KHÔNG chép vào image)
|
||||||
|
app/storage/uploads
|
||||||
|
app/storage/processed
|
||||||
|
app/storage/sonicforge.db
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# Test
|
||||||
|
tests/
|
||||||
|
tools/
|
||||||
|
samples/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ── SonicForge Studio — môi trường (SAO CHÉP sang .env — KHÔNG commit .env) ──
|
||||||
|
|
||||||
|
# Redis / Celery
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
|
||||||
|
# LLM (server-side proxy — client không thấy key)
|
||||||
|
OPENAI_API_BASE=http://localhost:11434/v1
|
||||||
|
OPENAI_API_KEY=ollama
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
SECRET_KEY=thay-bang-chuoi-ngau-nhien-dai
|
||||||
|
DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
|
||||||
|
|
||||||
|
# Storage (đường dẫn trong container)
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
@@ -29,3 +30,7 @@ samples/
|
|||||||
*.log
|
*.log
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
node_modules
|
node_modules
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
|
src-tauri/resources/daw_engine/
|
||||||
|
src-tauri/vc_redist.x64.exeapp/storage/plugin_dirs.json
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# DESKTOP INSTALL PLAN — SonicForge Studio (bản cài trực tiếp trên OS)
|
||||||
|
|
||||||
|
Mô hình: **server chạy nền trên máy người dùng (localhost), client mở bằng browser**.
|
||||||
|
Không cần Electron — tận dụng stack hiện có (FastAPI + static JS precompiled).
|
||||||
|
Soundfonts do **người dùng tự tải vào thư mục riêng** và **khai báo** cho ứng dụng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. KIẾN TRÚC
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────── NGƯỜI DÙNG (1 máy) ───────────────────────────┐
|
||||||
|
│ Browser ──http://127.0.0.1:8000──▶ FastAPI (uvicorn, chạy NỀN) │
|
||||||
|
│ │ │
|
||||||
|
│ ~/SonicForgeStudio/ ├─ data/ (DB, uploads, cache) │
|
||||||
|
│ ├─ data/sonicforge.db ├─ soundfonts/ (user tự bỏ .sf2) │
|
||||||
|
│ ├─ soundfonts/*.sf2/.sf3 └─ quét nền 30s (scanner có sẵn) │
|
||||||
|
│ └─ config.json ← khai báo folder soundfont │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. ĐÓNG GÓI CÀI ĐẶT (per-OS) — server nền + browser client
|
||||||
|
|
||||||
|
### Thành phần đóng gói
|
||||||
|
| Thành phần | Vai trò |
|
||||||
|
|---|---|
|
||||||
|
| Python backend (`app/`, `requirements.txt`) | Server — **PyInstaller `--onedir`** → binary (không cần Python trên máy user; bảo vệ source tốt hơn so với chạy .py) |
|
||||||
|
| `app/static/` (precompiled.js + css + vendor) | Client — đóng vào package, server phục vụ |
|
||||||
|
| VST3 plugins (`vst_plugins/`) | Copy theo platform (win .dll / mac .vst3 / linux .so) |
|
||||||
|
| Service wrapper | Tự khởi động server nền + mở browser khi login |
|
||||||
|
|
||||||
|
### Cài đặt + service nền theo OS
|
||||||
|
| OS | Installer | Service nền | Auto-open browser |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Windows** | Inno Setup (`.exe`) | Windows Service qua `NSSM`/`WinSW` hoặc Task Scheduler (logon) | `start http://127.0.0.1:8000` |
|
||||||
|
| **macOS** | `.dmg` + `.app` (PyInstaller) | `launchd` LaunchAgent (`~/Library/LaunchAgents`) | `open http://127.0.0.1:8000` |
|
||||||
|
| **Linux** | `.deb` / `.AppImage` | systemd **user** service (`~/.config/systemd/user/`) | `xdg-open http://127.0.0.1:8000` |
|
||||||
|
|
||||||
|
- Server bind **`127.0.0.1`** (không lộ mạng), port mặc định 8000 (config được).
|
||||||
|
- App gồm 2 tiến trình nhỏ: `web` (uvicorn) + `worker` (celery) — hoặc gộp worker vào web ở chế độ desktop (đơn giản: `--pool=solo`, chạy celery trong tiến trình riêng nếu cần render nặng).
|
||||||
|
|
||||||
|
## 3. THƯ MỤC DỮ LIỆU NGƯỜI DÙNG
|
||||||
|
```
|
||||||
|
~/SonicForgeStudio/
|
||||||
|
├── data/ # DB, uploads, processed (thay app/storage khi chạy desktop)
|
||||||
|
├── soundfonts/ # USER tự tải .sf2/.sf3 vào đây (mặc định được quét)
|
||||||
|
└── config.json # cấu hình: soundfont_dirs, port, autostart...
|
||||||
|
```
|
||||||
|
- `config.py`: thêm `DATA_DIR` (env `SFDATA_DIR`, mặc định `~/SonicForgeStudio/data`), `SOUNDFONT_DIRS`.
|
||||||
|
|
||||||
|
## 4. SOUNDFONT DO NGƯỜI DÙNG QUẢN LÝ (tải + khai báo)
|
||||||
|
**Cơ chế hiện có (tận dụng):** `app/core/soundfont_scanner.py` — `SoundFontAutoScanner` quét nền 30s
|
||||||
|
`/opt/daw_engine/soundfonts` + `storage/soundfonts` → catalog → API list qua `app/api/v1/plugins.py`.
|
||||||
|
|
||||||
|
**Việc cần làm (mở rộng):**
|
||||||
|
1. **Scanner nhận folder người dùng:** constructor nhận thêm `user_dirs` (từ `SOUNDFONT_DIRS` env + `config.json`) — merge vào catalog (ưu tiên: user > system).
|
||||||
|
2. **Khai báo folder — 2 cách:**
|
||||||
|
- **UI** (chính): Settings → "Soundfonts" → nút **Add folder…** (chọn thư mục chứa .sf2) → lưu vào `config.json` → gọi scanner rescan.
|
||||||
|
- **config.json** (thủ công): `{ "soundfont_dirs": ["D:/SF", "/Users/me/sf"] }`.
|
||||||
|
3. **API:** thêm endpoint `POST /api/v1/plugins/soundfonts/dirs` (đăng ký folder) + `GET .../dirs` (liệt kê) — scanner reload.
|
||||||
|
4. **UI danh sách:** hiển thị catalog (tên SF, kích thước, folder nguồn) — chọn = load vào FluidSynth (luồng có sẵn qua `SonicSF.selectInstrument`).
|
||||||
|
5. **Số hóa:** không upload file — server đọc trực tiếp từ đường dẫn user khai báo (không nhân đôi dữ liệu).
|
||||||
|
|
||||||
|
## 5. BUILD + PHÁT HÀNH
|
||||||
|
```
|
||||||
|
code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đóng installer theo OS
|
||||||
|
→ ký số (tùy chọn) → phát hành (GitHub Releases / trang riêng)
|
||||||
|
```
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. CẬP NHẬT
|
||||||
|
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
||||||
|
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
||||||
|
- `?v=` cache-bust JS mỗi bản (cơ chế đã có) — browser không dính cache cũ.
|
||||||
|
|
||||||
|
## 7. CHECKLIST CODE CẦN LÀM
|
||||||
|
- [ ] `config.py`: `DATA_DIR`, `SOUNDFONT_DIRS` (env + config.json)
|
||||||
|
- [ ] `soundfont_scanner.py`: nhận `user_dirs`, merge catalog, ưu tiên user
|
||||||
|
- [ ] API: `POST/GET .../soundfonts/dirs` (đăng ký/liệt kê folder) + rescan
|
||||||
|
- [ ] UI Settings → Soundfonts (Add folder, Browse, list, load)
|
||||||
|
- [ ] `soundfontStorage.js`: chuyển hướng sang catalog folder-scan (giữ upload path cho dự án cũ)
|
||||||
|
- [ ] PyInstaller spec: bundle static/ + vendor (libfluidsynth wasm, vst), bind 127.0.0.1
|
||||||
|
- [ ] Service wrappers: Windows (NSSM/WinSW), macOS (launchd), Linux (systemd user) + auto-open browser
|
||||||
|
- [ ] Installer scripts: Inno Setup (.exe), dmg (macOS), deb/AppImage (Linux)
|
||||||
|
- [ ] CI matrix build 3 OS + release artifacts
|
||||||
|
- [ ] `version.json` + in-app update check
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# DISTRIBUTION PLAN — SonicForge Studio (Alpha → Beta → Production)
|
||||||
|
|
||||||
|
Mục tiêu: phân phối ứng dụng cho người dùng **không chia sẻ mã nguồn**, có pipeline
|
||||||
|
cập nhật liên tục. Áp dụng cho backend Python/FastAPI + frontend JS (Babel precompiled).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TÁCH BÍ MẬT — FILE .env
|
||||||
|
|
||||||
|
| Biến | Dùng cho | Bắt buộc |
|
||||||
|
|---|---|---|
|
||||||
|
| `REDIS_URL` | Redis/Celery broker | ✓ |
|
||||||
|
| `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` | Celery | ✓ |
|
||||||
|
| `OPENAI_API_BASE` / `OPENAI_API_KEY` | LLM server-side (client KHÔNG thấy key — `aiGateway.js` chỉ gọi proxy server) | ✓ |
|
||||||
|
| `SECRET_KEY` | Auth token (app/core/auth.py) | ✓ (mặc định trống → phải set) |
|
||||||
|
| `DEFAULT_ADMIN_PASSWORD` | Admin mặc định | thay đổi ở production |
|
||||||
|
|
||||||
|
- File `.env.example` đã tạo (commit được). `.env` thật KHÔNG commit (đã vào `.dockerignore`).
|
||||||
|
- **Nguyên tắc:** client JS không bao giờ chứa secret — mọi API key nằm server (env) hoặc proxy.
|
||||||
|
|
||||||
|
## 2. BUILD IMAGE — KHÔNG CHIA SẺ MÃ NGUỒN
|
||||||
|
|
||||||
|
### 2.1 Chặn source khỏi image (`.dockerignore` — đã tạo)
|
||||||
|
Loại khỏi build context: `.git`, `app/static/js/app.jsx` (source JSX), `wiki.md`, `plans/`, `*.patch`, `.env`, `tests/`, `node_modules`, backup...
|
||||||
|
|
||||||
|
### 2.2 Frontend — chỉ ship bản precompiled
|
||||||
|
- Build JS: `node build.mjs` (Babel → `app.precompiled.js` minified) — **CHỈ bản minified vào image**, source `.jsx` bị `.dockerignore` chặn.
|
||||||
|
- Bump `?v=` (cache-bust) mỗi bản phát hành — user cập nhật không dính cache cũ.
|
||||||
|
|
||||||
|
### 2.3 Backend Python — giới hạn đọc source
|
||||||
|
- Python không biên dịch native mặc định → `.py` vẫn đọc được trong image. 3 lớp bảo vệ:
|
||||||
|
1. **Registry riêng tư** (GHCR / Docker Hub private / Harbor) — image KHÔNG public — người dùng chỉ nhận qua pull có auth.
|
||||||
|
2. **Secret chỉ qua env** — không hardcode gì trong image.
|
||||||
|
3. *(Tùy chọn, giai đoạn sau)* compile Python bằng **Nuitka** → `.so` cho `app/core`, `app/api` (giữ `main.py`/`config.py` đọc được — không chứa bí mật).
|
||||||
|
|
||||||
|
### 2.4 Cách build + push
|
||||||
|
```bash
|
||||||
|
# 1. Precompile JS + bump ?v=
|
||||||
|
node build.mjs # hoặc: NODE=... babel ... (xem wiki)
|
||||||
|
# 2. Build image (tag theo version)
|
||||||
|
docker build -t ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1 .
|
||||||
|
docker push ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1
|
||||||
|
# 3. Production chạy image đã push (KHÔNG mount source):
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
- Compose production (`docker-compose.prod.yml` — đã tạo): image từ registry, `env_file: .env`,
|
||||||
|
volumes bền (uploads/processed/db), asset dirs qua env (`VST3_DIR`, `SOUNDFONTS_DIR`, `PIANOBOOK_DIR`),
|
||||||
|
KHÔNG có `.:/app` (bỏ mount dev của compose cũ).
|
||||||
|
|
||||||
|
## 3. KẾ HOẠCH PHÁT HÀNH
|
||||||
|
|
||||||
|
### Giai đoạn 1 — ALPHA (nội bộ dev/QA)
|
||||||
|
- Tag: `v0.x.x-alpha.N` — chạy `docker compose up --build` (dev mount OK).
|
||||||
|
- Log verbose; chưa quan tâm bảo mật; test tính năng + thu hồi nhanh.
|
||||||
|
- Mỗi thay đổi: bump `?v=` → build → tag → ghi wiki.md.
|
||||||
|
|
||||||
|
### Giai đoạn 2 — BETA (nhóm người dùng mời)
|
||||||
|
- Tag: `v0.x.x-beta.N` — image push **registry riêng tư**.
|
||||||
|
- Người dùng: `docker compose -f docker-compose.prod.yml pull && up -d` (chỉ cần `.env` + image).
|
||||||
|
- Thu log lỗi: thêm endpoint `/health` + (tùy chọn) Sentry/self-host error tracking.
|
||||||
|
- **Feature flags** (biến env `FEATURE_*`) để tắt tính năng rủi ro từ xa.
|
||||||
|
|
||||||
|
### Giai đoạn 3 — PRODUCTION
|
||||||
|
- Tag: `vX.Y.Z` (semver) + `latest`.
|
||||||
|
- **Trước khi release:** backup volumes (`docker run --rm -v sf_db:/data -v $PWD:/backup alpine tar czf /backup/db-<ver>.tgz /data`), migrate dữ liệu nếu schema đổi.
|
||||||
|
- Triển khai: pull image mới → recreate (rolling — web trước, worker sau) → healthcheck.
|
||||||
|
- `SECRET_KEY`, `DEFAULT_ADMIN_PASSWORD`, API keys: quản lý qua secret manager (Docker secrets / vault) — không ở compose file.
|
||||||
|
|
||||||
|
## 4. PIPELINE CẬP NHẬT (lặp lại mỗi release)
|
||||||
|
```
|
||||||
|
code mới → build.mjs (precompiled + ?v=) → docker build (tag mới)
|
||||||
|
→ push registry → [beta/prod] pull + recreate → backup trước nếu prod → kiểm tra /health
|
||||||
|
```
|
||||||
|
- Phiên bản: `git tag vX.Y.Z` — build tag tự động từ git (`git describe --tags`).
|
||||||
|
- Rollback: giữ tag cũ — `docker compose -f docker-compose.prod.yml up -d` với `IMAGE=...:<tag cũ>`.
|
||||||
|
|
||||||
|
## 5. VIỆC CẦN LÀM (checklist)
|
||||||
|
- [x] `.env.example` — liệt kê đủ 7 biến
|
||||||
|
- [x] `.dockerignore` — chặn source/secret/docs
|
||||||
|
- [x] `docker-compose.prod.yml` — production (registry + env_file + volumes, bỏ mount source)
|
||||||
|
- [ ] Kiểm tra endpoint `/health` (tạo nếu chưa có — healthcheck compose đang trỏ tới)
|
||||||
|
- [ ] Chọn registry (GHCR/Docker Hub private/Harbor) + tạo token CI
|
||||||
|
- [ ] Nuitka compile `app/core`+`app/api` (tùy chọn — nếu cần bảo vệ backend chặt hơn)
|
||||||
|
- [ ] CI (GitHub Actions): test → build → push tag → deploy beta tự động
|
||||||
|
- [ ] Backup script volumes trước mỗi production release
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 SonicForge Studio contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Third-party components
|
||||||
|
|
||||||
|
Code viết riêng của dự án (app/static/js/services/*, components/*, app.jsx)
|
||||||
|
thuộc license MIT trên.
|
||||||
|
|
||||||
|
Các thành phần bên thứ ba giữ license gốc:
|
||||||
|
|
||||||
|
- **React / react-dom** — MIT
|
||||||
|
- **Babel** (@babel/cli, core, preset-react) — MIT
|
||||||
|
- **jsdom** — MIT
|
||||||
|
- **Tailwind CSS** (app/static/css/tailwind.min.css) — MIT
|
||||||
|
- **Lucide icons** — ISC
|
||||||
|
- **FluidSynth 2.3.0** (app/static/js/vendor/libfluidsynth-2.3.0-sf3.js/.wasm) —
|
||||||
|
GNU LGPL v2.1+ — xem https://www.gnu.org/licenses/lgpl-2.1.html
|
||||||
|
|
||||||
|
Chi tiết + attribution từng thành phần: xem THIRD_PARTY_LICENSES.md.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# TEST & DEBUG PLAN — SonicForge DAW Standalone (Windows .exe / .msi)
|
||||||
|
|
||||||
|
Áp dụng cho bản đóng gói Tauri v2 + PyInstaller (xem `DESKTOP_INSTALL_PLAN.md`,
|
||||||
|
`build_windows.ps1`, `engine.spec`, `src-tauri/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Cổng kiểm soát TRƯỚC khi build (pre-build gates — chạy trên máy dev)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Backend tests (repo đã có 86 tests pytest)
|
||||||
|
python -m pytest tests/ -x -q
|
||||||
|
|
||||||
|
# 2. Frontend syntax + bundle
|
||||||
|
node --check app/static/js/app.precompiled.js
|
||||||
|
node build.mjs # rebuild app.precompiled.js từ app.jsx
|
||||||
|
|
||||||
|
# 3. Python syntax của entry + spec
|
||||||
|
python -m py_compile desktop_engine.py engine.spec app/config.py app/tasks/worker.py
|
||||||
|
|
||||||
|
# 4. Chạy thử engine độc lập (chưa cần Tauri)
|
||||||
|
python desktop_engine.py # mở browser: http://127.0.0.1:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate: tất cả PASS mới chạy `build_windows.ps1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TEST PLAN — theo 6 kịch bản của tài liệu gốc
|
||||||
|
|
||||||
|
| # | Kịch bản | Cách kiểm thử | Kết quả mong đợi |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Lifecycle & Process Spawn** | Mở app → Task Manager tìm `daw_engine.exe` → đóng cửa sổ → kiểm tra lại | Engine xuất hiện lúc launch; BIẾN MẤT hoàn toàn khi đóng (không orphan). Engine còn có watchdog: nếu cha chết đột ngột, tự thoát ≤1s |
|
||||||
|
| 2 | **Port 8000 & Fallback** | Mở app → browser truy cập `http://localhost:8000/docs`; hoặc chiếm sẵn port 8000 (vd `net stop` dịch vụ khác / chạy server khác) rồi mở app | Swagger UI hiển thị. Khi 8000 bị chiếm → engine tự chuyển 8001…8010, loader page trong app tự redirect tới port đúng |
|
||||||
|
| 3 | **VST3 Local Scan (Windows)** | Plugin Manager → set path `C:\Program Files\Common Files\VST3` → **[Scan]** | Phát hiện đúng VST3 đã cài (Vital, Surge XT, FabFilter…). Scan state lưu ở `%APPDATA%\SonicForgeDAW\storage\sf_scan_state.json` |
|
||||||
|
| 4 | **Isolation & Crash Protection** | Load 1 VST3 không ổn định / giả lập crash | UI hiện thông báo lỗi, cửa sổ DAW vẫn phản hồi (tiến trình engine riêng — shell không chết theo) |
|
||||||
|
| 5 | **Storage Persistence (`%APPDATA%`)** | Mở Explorer tới `%APPDATA%\SonicForgeDAW\storage\` | Có `sonicforge.db`, `sf_scan_state.json`, `uploads/`, `processed/`, `soundfonts/` — **không** nằm trong thư mục cài đặt (onefile giải nén tạm sẽ bị xóa khi thoát) |
|
||||||
|
| 6 | **Audio Export / Bouncing** | Click **Export WAV** ở chế độ Standalone | File `.wav` render đúng (VST3 + SoundFont qua Pedalboard/FluidSynth) |
|
||||||
|
|
||||||
|
### 1.1 Kiểm thử cài đặt (installer)
|
||||||
|
- **NSIS**: cài trên máy sạch (không có Python, không có VC++ redist) → app chạy được, `hooks.nsh` tự cài VC redist. Gỡ cài bằng Control Panel → không còn process, không còn shortcut.
|
||||||
|
- **MSI**: cài qua `msiexec /i SonicForgeDAW_1.0.0_x64_en-US.msi` (hoặc double-click) → tương đương; kiểm tra repair/uninstall.
|
||||||
|
- **Nâng cấp**: cài bản mới đè bản cũ → `%APPDATA%\SonicForgeDAW\storage` GIỮ NGUYÊN (dữ liệu người dùng không mất).
|
||||||
|
- **SmartScreen/AV**: lần đầu chạy có thể bị chặn ("More info → Run anyway"); bản production phải ký số (signtool + certificate DigiCert/Sectigo).
|
||||||
|
|
||||||
|
### 1.2 Kiểm thử hồi quy (regression — sau mỗi thay đổi code)
|
||||||
|
- `pytest tests/` — 86 test hiện có (conftest fixture dùng test client).
|
||||||
|
- Smoke UI thủ công: play/pause, piano roll, FX rack, save/load project, soundfont scan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DEBUG WORKFLOWS
|
||||||
|
|
||||||
|
### 2.1 Engine (Python sidecar) không start / crash âm thầm
|
||||||
|
1. Mở log: `%APPDATA%\SonicForgeDAW\logs\engine.log` (desktop_engine.py ghi log ra file vì `console=False`).
|
||||||
|
2. Chạy thủ công engine có console: sửa `console=True` trong `engine.spec` → rebuild → chạy `dist\daw_engine.exe` từ cmd → xem traceback trực tiếp.
|
||||||
|
3. Test độc lập không cần Tauri: `python desktop_engine.py` (dev) — đúng code path, có stdout.
|
||||||
|
|
||||||
|
### 2.2 UI (WebView2)
|
||||||
|
- **F12 / Ctrl+Shift+I** trong cửa sổ app: mở DevTools (đã bật `"devtools": true` trong `tauri.conf.json`) — Console, Network, Web Audio context.
|
||||||
|
- `devUrl: "http://localhost:8000"` — bản dev có thể chạy `tauri dev` với engine chạy tay.
|
||||||
|
|
||||||
|
### 2.3 Sidecar không spawn / không thấy engine
|
||||||
|
- Kiểm tra `src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe` có tồn tại (build script copy tự động).
|
||||||
|
- Chạy `npx tauri build` lần đầu: nếu thiếu sidecar → lib.rs `expect("sidecar daw_engine not found")` fail rõ ràng.
|
||||||
|
- Kiểm tra port bị chiếm: `netstat -ano | findstr :8000`.
|
||||||
|
|
||||||
|
### 2.4 Windows build lỗi (máy dev)
|
||||||
|
- Rust toolchain: `rustup default stable-msvc` (bắt buộc MSVC toolchain cho Tauri Windows).
|
||||||
|
- WebView2 runtime: Win10/11 có sẵn; máy cũ cài [WebView2 Evergreen](https://developer.microsoft.com/microsoft-edge/webview2/).
|
||||||
|
- Nếu `tauri build` lỗi NSIS/MSI riêng: build riêng từng target `npx tauri build --bundles nsis` / `--bundles msi`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Known Gaps (đã xử lý / chấp nhận)
|
||||||
|
|
||||||
|
| Vấn đề | Trạng thái |
|
||||||
|
|---|---|
|
||||||
|
| Celery cần Redis broker | **Đã xử lý**: `SF_DESKTOP=1` → `task_always_eager=True` (chạy đồng bộ trong tiến trình, `memory://` broker + `cache+memory://` backend). Chế độ docker/cloud không đổi |
|
||||||
|
| Storage khi frozen (onefile giải nén tạm) | **Đã xử lý**: `config.py` freeze-aware → writable data về `%APPDATA%\SonicForgeDAW\storage`, assets read-only trong `_MEIPASS` |
|
||||||
|
| Port 8000 bị chiếm | **Đã xử lý**: probe 8000–8010 + loader page redirect tự động |
|
||||||
|
| `engine.spec` console=False → mù log | **Đã xử lý**: log ra file `%APPDATA%\SonicForgeDAW\logs\engine.log` |
|
||||||
|
| Soundfont scanner path `/opt/daw_engine/soundfonts` (Linux-only) | **Chấp nhận**: cần xác minh scanner bỏ qua path không tồn tại trên Windows (test scenario #5) |
|
||||||
|
| `child.kill()` = TerminateProcess (không graceful 100%) | **Giảm thiểu**: watchdog parent-PID trong `desktop_engine.py` → engine tự shutdown sạch khi cha chết |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Checklist phát hành (release)
|
||||||
|
|
||||||
|
- [ ] Pre-build gates PASS (mục 0)
|
||||||
|
- [ ] `build_windows.ps1` chạy sạch, đủ 6 bước
|
||||||
|
- [ ] 6 kịch bản test (mục 1) PASS trên máy sạch
|
||||||
|
- [ ] Installer NSIS + MSI cài/gỡ/nâng cấp đúng
|
||||||
|
- [ ] Ký số (production) — SmartScreen hết cảnh báo
|
||||||
|
- [ ] Commit artifacts: `engine.spec`, `desktop_engine.py`, `src-tauri/`, `build_windows.ps1`, `tools/gen_icons.py`
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# THIRD-PARTY LICENSES — SonicForgeStudio
|
||||||
|
|
||||||
|
Kiểm tra ngày 2026-08-07. Danh sách thành phần bên thứ ba + license.
|
||||||
|
|
||||||
|
## 1. Dependencies (npm — package.json)
|
||||||
|
| Thành phần | License | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| react / react-dom 19.x | MIT | OK |
|
||||||
|
| @babel/cli, @babel/core, @babel/preset-react | MIT | Chỉ build-time |
|
||||||
|
| jsdom | MIT | Chỉ build-time/test |
|
||||||
|
|
||||||
|
## 2. Thư viện nhúng (app/static)
|
||||||
|
| File | Nguồn | License | Trạng thái |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `css/tailwind.min.css` | Tailwind CSS | MIT | OK — nên giữ attribution |
|
||||||
|
| `js/services/spessasynth_processor.min.js` | SpessaSynth (KHÔNG còn dùng — không được include trong index.html — chỉ FluidSynth) | MIT | ✅ KHÔNG có code nào gọi (chỉ còn comment cũ app.jsx:14276 + file chết) — có thể xóa file |
|
||||||
|
| `js/vendor/libfluidsynth-2.3.0-sf3.js` + `.wasm` | FluidSynth 2.3.0 (Emscripten) | **LGPL-2.1+** | ⚠️ Bắt buộc giữ license notice + attribution + (nếu phân phối bản build) cung cấp link/tài liệu LGPL |
|
||||||
|
| `js/services/fluidsynthLoader.js`, `worklets/fluidsynth-bridge.js` | Bản tự viết (bọc FluidSynth) | Dự án | OK |
|
||||||
|
| Lucide icons (inline `data-lucide`) | Lucide | ISC (MIT-compatible) | OK |
|
||||||
|
|
||||||
|
## 3. Nội dung âm thanh (app/storage)
|
||||||
|
| File | Nguồn | License | Trạng thái |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `soundfonts/518e850f-...sf2` | "General MIDI SoundFont v3.0" — © 2006-2010 Rich "Weeds" Nagel — "Some rights reserved" | Không có text đầy đủ trong file (chỉ ICOP ngắn). Tuyên bố: "created from various **commercial**, custom, and freeware soundfonts and samples" | ⚠️ RỦI RO: (a) điều kiện "some rights reserved" không rõ ràng (thường là CC-BY — cần ghi attribution; có thể hạn chế thương mại); (b) samples gốc có nguồn commercial — quyền tái phân phối phụ thuộc tuyên bố tác giả. **Khuyến nghị: thay bằng SF2 license rõ (FluidR3_GM — MIT/GPL-2; GeneralUser GS — CC-BY-SA; Arachno — public domain) HOẶC giữ + ghi attribution đầy đủ.** |
|
||||||
|
| `uploads/user_anonymous_*.mp3` | File người dùng upload | Thuộc người dùng | OK — không phải thành phần phân phối |
|
||||||
|
| SGM-V2.01 (xuất hiện trong log `sfId: SGM-V2.01`) | SGM-V2.01 | Freeware — tác giả cho phép dùng nhưng **hạn chế redistribution** (cần permission) | ⚠️ Nếu vẫn dùng để phân phối bản build — cần permission từ tác giả; không nhúng vào sản phẩm |
|
||||||
|
|
||||||
|
## 4. Assets
|
||||||
|
- `templates/favicon.svg`, `images/SonicForgeUI.png` — nội bộ (tự tạo) — OK.
|
||||||
|
- Code trong `app/static/js/services/*`, `app/static/js/components/*`, `app.jsx` — tự viết — thuộc dự án.
|
||||||
|
|
||||||
|
## KẾT LUẬN
|
||||||
|
- **Không phát hiện vi phạm bản quyền rõ ràng** (không có code GPL bị nhúng vào project MIT; LGPL của FluidSynth tương thích nếu giữ notice).
|
||||||
|
- **3 điểm cần xử lý trước khi phân phối công khai:**
|
||||||
|
1. Bổ sung `LICENSE` text (MIT + LGPL-2.1) + attribution cho SpessaSynth (MIT notice) và Tailwind.
|
||||||
|
2. Ghi attribution SF2 Rich Nagel ("General MIDI SoundFont v3.0 — © 2006-2010 Rich 'Weeds' Nagel — Some rights reserved") hoặc thay soundfont khác license rõ.
|
||||||
|
3. Không nhúng SGM-V2.01 vào bản phân phối (hạn chế redistribution).
|
||||||
+112
-10
@@ -1,7 +1,16 @@
|
|||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any, Dict, List
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from app.api.v1.auth import get_current_user
|
||||||
|
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
|||||||
headers: Dict[str, str] = {}
|
headers: Dict[str, str] = {}
|
||||||
body: Dict[str, Any] = {}
|
body: Dict[str, Any] = {}
|
||||||
|
|
||||||
import json
|
|
||||||
|
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||||
|
_BLOCKED_NETWORKS = [
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||||
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
|
]
|
||||||
|
# Private ranges: only reachable when the target host is one the user has
|
||||||
|
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||||
|
_PRIVATE_NETWORKS = [
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"), # ULA
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_ai_hosts(user_id: str) -> set:
|
||||||
|
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||||
|
hosts = set()
|
||||||
|
configs = _load_ai_configs()
|
||||||
|
providers = configs.get(user_id) or _get_default_providers()
|
||||||
|
for p in providers:
|
||||||
|
base = (p.get("api_base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
host = urlparse(base).hostname
|
||||||
|
if host:
|
||||||
|
hosts.add(host.lower())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_host_ips(hostname: str):
|
||||||
|
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||||
|
ips = []
|
||||||
|
for info in infos:
|
||||||
|
try:
|
||||||
|
ips.append(ipaddress.ip_address(info[4][0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ips
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_target_url(url: str, user_id: str):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||||
|
|
||||||
|
allowed_hosts = _configured_ai_hosts(user_id)
|
||||||
|
|
||||||
|
# Hostname-level fast path for loopback hosts
|
||||||
|
if hostname in _LOOPBACK_HOSTS:
|
||||||
|
if hostname in allowed_hosts:
|
||||||
|
return
|
||||||
|
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:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
ips = [ip]
|
||||||
|
except ValueError:
|
||||||
|
ips = await _resolve_host_ips(hostname)
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||||
|
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||||
|
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||||
|
if hostname in allowed_hosts:
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxy")
|
@router.post("/proxy")
|
||||||
async def proxy_llm(req: ProxyRequest):
|
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
await _validate_target_url(req.url, current_user["user_id"])
|
||||||
|
# Never forward the app's own auth token upstream.
|
||||||
|
headers = {
|
||||||
|
k: v for k, v in req.headers.items()
|
||||||
|
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||||
req.url,
|
|
||||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
|
||||||
json=req.body
|
|
||||||
)
|
|
||||||
raw = resp.text
|
raw = resp.text
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
|||||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||||
raise HTTPException(status_code=502, detail=msg)
|
raise HTTPException(status_code=502, detail=msg)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.auth import get_current_user
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
from app.api.v1.projects import get_optional_user
|
from app.api.v1.projects import get_optional_user
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
def _safe_file_id(file_id: str) -> str:
|
||||||
|
"""Strip any path components from a client-supplied file id."""
|
||||||
|
if not file_id:
|
||||||
|
return ""
|
||||||
|
return os.path.basename(file_id.replace("\\", "/"))
|
||||||
|
|
||||||
|
def _resolve_storage_path(file_id: str) -> str:
|
||||||
|
"""Return the existing file path (processed first, then uploads) for a
|
||||||
|
sanitized file id, or '' when not found."""
|
||||||
|
fid = _safe_file_id(file_id)
|
||||||
|
if not fid:
|
||||||
|
return ""
|
||||||
|
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||||
|
p = os.path.join(d, fid)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
return ""
|
||||||
|
|
||||||
class EditRequest(BaseModel):
|
class EditRequest(BaseModel):
|
||||||
file_id: str
|
file_id: str
|
||||||
cut_start_ms: Optional[float] = None
|
cut_start_ms: Optional[float] = None
|
||||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
if 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)[1]
|
ext = os.path.splitext(file.filename or "")[1]
|
||||||
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}"
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
|
|
||||||
|
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||||
|
# WAV into RAM and bounds disk usage).
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
content = await file.read()
|
size = 0
|
||||||
f.write(content)
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||||
|
f.close()
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Save original filename as sidecar metadata
|
# Save original filename as sidecar metadata
|
||||||
import json
|
import json
|
||||||
@@ -90,15 +126,12 @@ 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):
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
|
|
||||||
# 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 os.path.exists(upload_path) and not os.path.exists(processed_path):
|
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")
|
||||||
|
|
||||||
from app.tasks.worker import edit_audio_task
|
from app.tasks.worker import edit_audio_task
|
||||||
task = edit_audio_task.delay(req.dict())
|
task = edit_audio_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id
|
"task_id": task.id
|
||||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
|||||||
|
|
||||||
@router.get("/download/{file_id}")
|
@router.get("/download/{file_id}")
|
||||||
async def download_audio(file_id: str):
|
async def download_audio(file_id: str):
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
path = _resolve_storage_path(file_id)
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if os.path.exists(processed_path):
|
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
@router.get("/waveform/{file_id}")
|
@router.get("/waveform/{file_id}")
|
||||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||||
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
|||||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_peak_waveform
|
from app.core.dsp_utils import generate_peak_waveform
|
||||||
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
|||||||
"""
|
"""
|
||||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_rms_waveform
|
from app.core.dsp_utils import generate_rms_waveform
|
||||||
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
|||||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
file_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import analyze_ai_task
|
from app.tasks.worker import analyze_ai_task
|
||||||
task = analyze_ai_task.delay(
|
task = analyze_ai_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
api_base_url=req.api_base_url,
|
api_base_url=req.api_base_url,
|
||||||
model=req.model
|
model=req.model
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/export")
|
@router.post("/export")
|
||||||
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
|
|||||||
"""
|
"""
|
||||||
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).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
source_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not source_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
source_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
source_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import export_audio_task
|
from app.tasks.worker import export_audio_task
|
||||||
task = export_audio_task.delay(
|
task = export_audio_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
format=req.format,
|
format=req.format,
|
||||||
sample_rate=req.sample_rate,
|
sample_rate=req.sample_rate,
|
||||||
bit_depth=req.bit_depth
|
bit_depth=req.bit_depth
|
||||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/ai-scan")
|
@router.post("/ai-scan")
|
||||||
async def ai_scan_audio(req: AIScanRequest):
|
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
|||||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.python_tools_engine import PythonToolsEngine
|
from app.core.python_tools_engine import PythonToolsEngine
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|||||||
+107
-13
@@ -1,10 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
import threading
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
from app.core.auth import (
|
||||||
|
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||||
|
COOKIE_NAME, X_AUTH_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||||
|
_LOGIN_LOCK = threading.Lock()
|
||||||
|
MAX_LOGIN_ATTEMPTS = 10
|
||||||
|
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||||
|
LOGIN_BLOCK_SEC = 900
|
||||||
|
|
||||||
|
def _check_login_ratelimit(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||||
|
_LOGIN_FAILURES[ip] = stamps
|
||||||
|
|
||||||
|
def _record_login_failure(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||||
|
stamps.append(now)
|
||||||
|
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
|
||||||
|
def _record_login_success(ip: str):
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
_LOGIN_FAILURES.pop(ip, None)
|
||||||
|
|
||||||
|
def _set_auth_cookie(response: Response, token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME, token,
|
||||||
|
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||||
|
# path="/" (default); secure flag set by proxy when behind TLS
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||||
|
token = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
elif x_auth_token:
|
||||||
|
token = x_auth_token
|
||||||
|
elif request.cookies.get(COOKIE_NAME):
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||||
token = authorization.split(" ")[1]
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(req: LoginRequest):
|
async def login(req: LoginRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_login_ratelimit(client_ip)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not user or not user["is_active"]:
|
if not user or not user["is_active"]:
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
if not verify_password(password, user["hashed_password"]):
|
if not verify_password(password, user["hashed_password"]):
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||||
|
_record_login_success(client_ip)
|
||||||
|
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
|||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"must_change_password": bool(user["must_change_password"])
|
"must_change_password": bool(user["must_change_password"])
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _validate_password_strength(password: str):
|
||||||
|
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||||
|
if len(password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||||
|
lowered = password.lower()
|
||||||
|
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register(req: RegisterRequest):
|
async def register(req: RegisterRequest, request: Request):
|
||||||
username = req.username.strip()
|
username = req.username.strip()
|
||||||
email = req.email.strip()
|
email = req.email.strip()
|
||||||
password = req.password.strip()
|
password = req.password.strip()
|
||||||
|
_validate_password_strength(password)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
token = create_token(user_id, username, "standard", False)
|
token = create_token(user_id, username, "standard", False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
|||||||
"role": "standard",
|
"role": "standard",
|
||||||
"must_change_password": False
|
"must_change_password": False
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||||
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"message": "Đổi mật khẩu thành công!",
|
"message": "Đổi mật khẩu thành công!",
|
||||||
"access_token": new_token
|
"access_token": new_token
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, new_token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||||
@@ -194,3 +259,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
|
|||||||
"max_tracks": row["max_tracks"] or 16
|
"max_tracks": row["max_tracks"] or 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/first-time")
|
||||||
|
async def auth_first_time():
|
||||||
|
# "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH
|
||||||
|
# (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa
|
||||||
|
# gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu:
|
||||||
|
# admin123, nút Điền nhanh).
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, hashed_password, must_change_password FROM users "
|
||||||
|
"WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return {"first_time": True}
|
||||||
|
still_default = False
|
||||||
|
try:
|
||||||
|
still_default = verify_password("admin123", row["hashed_password"])
|
||||||
|
except Exception:
|
||||||
|
still_default = False
|
||||||
|
return {"first_time": bool(row["must_change_password"]) and still_default}
|
||||||
|
except Exception:
|
||||||
|
return {"first_time": True}
|
||||||
|
|||||||
+6
-4
@@ -2,9 +2,11 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.v1.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
MEDIA_EXTS = {
|
MEDIA_EXTS = {
|
||||||
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/computer")
|
@router.get("/computer")
|
||||||
async def list_computer_roots():
|
async def list_computer_roots(current_user: dict = Depends(get_current_user)):
|
||||||
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
|
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
roots = []
|
roots = []
|
||||||
@@ -97,7 +99,7 @@ async def list_computer_roots():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@router.get("/browse")
|
||||||
async def browse_directory(path: str = Query(...)):
|
async def browse_directory(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||||
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
|
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
if not os.path.isdir(resolved):
|
if not os.path.isdir(resolved):
|
||||||
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/file")
|
@router.get("/file")
|
||||||
async def serve_local_file(path: str = Query(...)):
|
async def serve_local_file(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||||
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
if not os.path.isfile(resolved):
|
if not os.path.isfile(resolved):
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
|||||||
|
|
||||||
# Gửi task xuống Celery Worker
|
# Gửi task xuống Celery Worker
|
||||||
from app.tasks.worker import mix_multitrack_task
|
from app.tasks.worker import mix_multitrack_task
|
||||||
task = mix_multitrack_task.delay(req.dict())
|
task = mix_multitrack_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
|
|||||||
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.
|
||||||
"""
|
"""
|
||||||
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.dict())
|
task = process_multitrack_session_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
|
|||||||
+737
-14
@@ -1,22 +1,145 @@
|
|||||||
import os, uuid, json, tempfile
|
import os, sys, uuid, json, tempfile, subprocess, time as _time
|
||||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
|
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin
|
||||||
from app.core.render_engine import PythonRenderEngine
|
from app.core.render_engine import PythonRenderEngine
|
||||||
from app.core.soundfont_inspector import SoundFontInspector
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
from app.core.soundfont_converter import SoundFontConverter
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
from app.api.v1.auth import get_current_user
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||||
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
|
||||||
|
|
||||||
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
|
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
||||||
|
SYSTEM_VST_DIR = settings.VST_DIR
|
||||||
|
|
||||||
|
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
|
||||||
|
# Plugins Manager). File global (không per-user): desktop app 1 user.
|
||||||
|
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
|
||||||
|
|
||||||
|
def _load_plugin_dirs() -> dict:
|
||||||
|
if os.path.exists(PLUGIN_DIRS_FILE):
|
||||||
|
try:
|
||||||
|
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_plugin_dirs(dirs: dict):
|
||||||
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
|
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(dirs, f, indent=2)
|
||||||
|
|
||||||
|
def _effective_dirs() -> dict:
|
||||||
|
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
|
||||||
|
|
||||||
|
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
|
||||||
|
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
|
||||||
|
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
|
||||||
|
"""
|
||||||
|
user = _load_plugin_dirs()
|
||||||
|
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
|
||||||
|
vst_dir = settings.VST_DIR
|
||||||
|
soundfont_dir = settings.SOUNDFONT_DIR
|
||||||
|
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
|
||||||
|
if not plugin_dirs:
|
||||||
|
if user.get("vst_dir"):
|
||||||
|
plugin_dirs.append(user["vst_dir"])
|
||||||
|
if user.get("soundfont_dir"):
|
||||||
|
plugin_dirs.append(user["soundfont_dir"])
|
||||||
|
if not plugin_dirs:
|
||||||
|
plugin_dirs = [vst_dir, soundfont_dir]
|
||||||
|
return {
|
||||||
|
"plugin_dirs": plugin_dirs,
|
||||||
|
"vst_dir": vst_dir,
|
||||||
|
"soundfont_dir": soundfont_dir,
|
||||||
|
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
|
||||||
|
}
|
||||||
|
|
||||||
|
class DirsRequest(BaseModel):
|
||||||
|
vst_dir: Optional[str] = None
|
||||||
|
soundfont_dir: Optional[str] = None
|
||||||
|
plugin_dirs: Optional[list] = None
|
||||||
|
|
||||||
|
@router.get("/dirs")
|
||||||
|
async def get_plugin_dirs():
|
||||||
|
return {"success": True, **(_effective_dirs())}
|
||||||
|
|
||||||
|
@router.post("/dirs")
|
||||||
|
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
user = _load_plugin_dirs()
|
||||||
|
if req.plugin_dirs is not None:
|
||||||
|
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
|
||||||
|
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
|
||||||
|
user.pop("vst_dir", None)
|
||||||
|
user.pop("soundfont_dir", None)
|
||||||
|
else:
|
||||||
|
if req.vst_dir is not None:
|
||||||
|
user["vst_dir"] = req.vst_dir.strip()
|
||||||
|
if req.soundfont_dir is not None:
|
||||||
|
user["soundfont_dir"] = req.soundfont_dir.strip()
|
||||||
|
_save_plugin_dirs(user)
|
||||||
|
return {"success": True, **(_effective_dirs())}
|
||||||
|
|
||||||
|
@router.post("/scan")
|
||||||
|
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
|
||||||
|
current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
|
||||||
|
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
|
||||||
|
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
dirs = _effective_dirs()
|
||||||
|
plugin_dirs = dirs["plugin_dirs"]
|
||||||
|
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
|
||||||
|
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
|
||||||
|
if background_tasks:
|
||||||
|
background_tasks.add_task(scanner.scan_once)
|
||||||
|
else:
|
||||||
|
scanner.scan_once()
|
||||||
|
catalog = scanner.get_catalog()
|
||||||
|
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
|
||||||
|
vst_found = []
|
||||||
|
sf_found = []
|
||||||
|
for d in plugin_dirs:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for root, dirs, files in os.walk(d):
|
||||||
|
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
|
||||||
|
for sub in list(dirs):
|
||||||
|
if sub.lower().endswith(".vst3"):
|
||||||
|
vst_found.append({"name": os.path.splitext(sub)[0],
|
||||||
|
"path": os.path.join(root, sub),
|
||||||
|
"dir": d,
|
||||||
|
"type": "VST3"})
|
||||||
|
for f in files:
|
||||||
|
low = f.lower()
|
||||||
|
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
|
||||||
|
vst_found.append({"name": os.path.splitext(f)[0],
|
||||||
|
"path": os.path.join(root, f),
|
||||||
|
"dir": d,
|
||||||
|
"type": "VST3" if low.endswith(".vst3") else "VST2"})
|
||||||
|
elif low.endswith(".sf2") or low.endswith(".sf3"):
|
||||||
|
sf_found.append({"name": os.path.splitext(f)[0],
|
||||||
|
"path": os.path.join(root, f),
|
||||||
|
"dir": d})
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"plugin_dirs": plugin_dirs,
|
||||||
|
"vst_found": vst_found,
|
||||||
|
"vst_count": len(vst_found),
|
||||||
|
"soundfonts": sf_found,
|
||||||
|
"soundfont_count": len(sf_found),
|
||||||
|
}
|
||||||
|
|
||||||
_inspector = None
|
_inspector = None
|
||||||
_scanner = None
|
_scanner = None
|
||||||
@@ -24,21 +147,199 @@ _scanner = None
|
|||||||
def get_inspector():
|
def get_inspector():
|
||||||
global _inspector
|
global _inspector
|
||||||
if _inspector is None:
|
if _inspector is None:
|
||||||
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
|
d = _effective_dirs()
|
||||||
|
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
|
||||||
|
upload_sf_dir=UPLOAD_SF_DIR)
|
||||||
return _inspector
|
return _inspector
|
||||||
|
|
||||||
def get_scanner():
|
def get_scanner():
|
||||||
global _scanner
|
global _scanner
|
||||||
if _scanner is None:
|
if _scanner is None:
|
||||||
_scanner = SoundFontAutoScanner(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
|
d = _effective_dirs()
|
||||||
|
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
|
||||||
_scanner.scan_once()
|
_scanner.scan_once()
|
||||||
return _scanner
|
return _scanner
|
||||||
|
|
||||||
|
|
||||||
@router.get("/available")
|
@router.get("/available")
|
||||||
async def list_plugins(current_user: dict = Depends(get_current_user)):
|
async def list_plugins(current_user: dict = Depends(get_current_user)):
|
||||||
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
|
d = _effective_dirs()
|
||||||
return pm.list_available()
|
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
|
||||||
|
avail = pm.list_available()
|
||||||
|
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
|
||||||
|
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
|
||||||
|
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
|
||||||
|
extra = _scan_vst_in_dirs(d["plugin_dirs"])
|
||||||
|
by_id = {v["id"]: v for v in avail["vst_instruments"]}
|
||||||
|
for v in extra:
|
||||||
|
by_id.setdefault(v["id"], v)
|
||||||
|
avail["vst_instruments"] = list(by_id.values())
|
||||||
|
# Tương tự cho SOUNDFONT: gộp .sf2/.sf3 từ plugin_dirs user — nếu không,
|
||||||
|
# nút Synth không liệt kê instrument của soundfont trong thư mục user thêm.
|
||||||
|
sf_by_id = {s["id"]: s for s in avail["soundfonts"]}
|
||||||
|
for s in _scan_soundfonts_in_dirs(d["plugin_dirs"]):
|
||||||
|
sf_by_id.setdefault(s["id"], s)
|
||||||
|
avail["soundfonts"] = list(sf_by_id.values())
|
||||||
|
return avail
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_soundfonts_in_dirs(dirs: list) -> list:
|
||||||
|
"""Walk các thư mục (plugin_dirs user) → danh sách soundfont .sf2/.sf3
|
||||||
|
(id = tên file; name từ .meta nếu có — cùng format PluginManager)."""
|
||||||
|
found = {}
|
||||||
|
for d in dirs:
|
||||||
|
if not d or not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for root, dirs2, files in os.walk(d):
|
||||||
|
for f in files:
|
||||||
|
low = f.lower()
|
||||||
|
if not (low.endswith(".sf2") or low.endswith(".sf3")):
|
||||||
|
continue
|
||||||
|
base_id = os.path.splitext(f)[0]
|
||||||
|
if base_id in found:
|
||||||
|
continue
|
||||||
|
meta = {}
|
||||||
|
mp = os.path.join(root, os.path.splitext(f)[0] + ".meta")
|
||||||
|
if os.path.isfile(mp):
|
||||||
|
try:
|
||||||
|
with open(mp, "r", encoding="utf-8") as mf:
|
||||||
|
meta = json.load(mf)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
name = meta.get("original_name", f) if meta else f
|
||||||
|
found[base_id] = {
|
||||||
|
"id": base_id,
|
||||||
|
"name": name,
|
||||||
|
"file": f,
|
||||||
|
"display": os.path.splitext(name)[0][:40],
|
||||||
|
"source": "user",
|
||||||
|
}
|
||||||
|
# Không walk sâu thêm (soundfont thường đặt ngay trong thư mục khai báo)
|
||||||
|
dirs2[:] = []
|
||||||
|
return list(found.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_vst_in_dirs(dirs: list) -> list:
|
||||||
|
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
|
||||||
|
file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa
|
||||||
|
X.vst3.dll bên trong)."""
|
||||||
|
found = {}
|
||||||
|
for d in dirs:
|
||||||
|
if not d or not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for root, dirs, files in os.walk(d):
|
||||||
|
# Windows: VST3 là FOLDER tên X.vst3
|
||||||
|
for sub in list(dirs):
|
||||||
|
if sub.lower().endswith(".vst3"):
|
||||||
|
name = os.path.splitext(sub)[0]
|
||||||
|
if name not in found:
|
||||||
|
found[name] = {
|
||||||
|
"id": name, "name": name, "type": "VST3",
|
||||||
|
"path": os.path.join(root, sub),
|
||||||
|
}
|
||||||
|
for f in files:
|
||||||
|
low = f.lower()
|
||||||
|
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
|
||||||
|
name = os.path.splitext(f)[0]
|
||||||
|
if name not in found:
|
||||||
|
found[name] = {
|
||||||
|
"id": name, "name": name,
|
||||||
|
"type": "VST3" if low.endswith(".vst3") else "VST2",
|
||||||
|
"path": os.path.join(root, f),
|
||||||
|
}
|
||||||
|
return list(found.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
|
||||||
|
# nhập tay) ─────────────────────────────────────────────────────────────
|
||||||
|
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_dir_via_tauri_bridge() -> Optional[str]:
|
||||||
|
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
|
||||||
|
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
|
||||||
|
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
|
||||||
|
if not os.path.isdir(ipc):
|
||||||
|
return None
|
||||||
|
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
|
||||||
|
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
|
||||||
|
return None
|
||||||
|
req = os.path.join(ipc, "pick_dir.request")
|
||||||
|
resp = os.path.join(ipc, "pick_dir.response")
|
||||||
|
try:
|
||||||
|
for f in (req, resp):
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
with open(req, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("1")
|
||||||
|
deadline = _time.time() + PICK_DIR_TIMEOUT
|
||||||
|
while _time.time() < deadline:
|
||||||
|
if os.path.exists(resp):
|
||||||
|
try:
|
||||||
|
with open(resp, "r", encoding="utf-8") as fh:
|
||||||
|
val = fh.read().strip()
|
||||||
|
finally:
|
||||||
|
os.remove(resp)
|
||||||
|
return val or None
|
||||||
|
_time.sleep(0.1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_dir_native_engine() -> Optional[str]:
|
||||||
|
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
|
||||||
|
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
|
||||||
|
if os.name == "nt":
|
||||||
|
ps = (
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms; "
|
||||||
|
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
|
||||||
|
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
|
||||||
|
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-STA", "-Command", ps],
|
||||||
|
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
|
||||||
|
)
|
||||||
|
return r.stdout.strip() or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["osascript", "-e",
|
||||||
|
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
|
||||||
|
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
|
||||||
|
)
|
||||||
|
return r.stdout.strip() or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for cmd in (["zenity", "--file-selection", "--directory"],
|
||||||
|
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
|
||||||
|
if r.returncode == 0:
|
||||||
|
p = r.stdout.strip()
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pick-dir")
|
||||||
|
def pick_plugin_directory():
|
||||||
|
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
|
||||||
|
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
|
||||||
|
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
|
||||||
|
trong lúc user chọn thư mục (có thể mất phút)."""
|
||||||
|
path = _pick_dir_via_tauri_bridge()
|
||||||
|
if path is None:
|
||||||
|
path = _pick_dir_native_engine()
|
||||||
|
return {"path": path}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/default-soundfonts")
|
@router.get("/default-soundfonts")
|
||||||
@@ -68,7 +369,9 @@ async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
|
|||||||
|
|
||||||
@router.get("/soundfont-instruments/{sf_id}")
|
@router.get("/soundfont-instruments/{sf_id}")
|
||||||
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
|
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
|
d = _effective_dirs()
|
||||||
|
# extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó
|
||||||
|
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
|
||||||
presets = pm.list_soundfont_instruments(sf_id)
|
presets = pm.list_soundfont_instruments(sf_id)
|
||||||
return {"presets": presets, "count": len(presets)}
|
return {"presets": presets, "count": len(presets)}
|
||||||
|
|
||||||
@@ -79,11 +382,23 @@ async def upload_soundfont(
|
|||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
||||||
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||||
|
|
||||||
contents = await file.read()
|
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||||
if not PluginManager.validate_sf2_header(contents):
|
# 500MB; reading the whole body into RAM would OOM the server).
|
||||||
|
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
||||||
|
contents = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
contents.extend(chunk)
|
||||||
|
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
||||||
|
|
||||||
|
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
||||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||||
|
|
||||||
file_ext = os.path.splitext(file.filename)[1]
|
file_ext = os.path.splitext(file.filename)[1]
|
||||||
@@ -139,7 +454,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
|||||||
@router.get("/soundfonts/download/{sf_id}")
|
@router.get("/soundfonts/download/{sf_id}")
|
||||||
async def download_soundfont_asset(sf_id: str):
|
async def download_soundfont_asset(sf_id: str):
|
||||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||||
|
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||||
|
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||||
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||||
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||||
if not os.path.isdir(base_dir):
|
if not os.path.isdir(base_dir):
|
||||||
continue
|
continue
|
||||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||||
@@ -173,13 +492,417 @@ class RenderRequest(BaseModel):
|
|||||||
output_filename: Optional[str] = "render_output.wav"
|
output_filename: Optional[str] = "render_output.wav"
|
||||||
|
|
||||||
|
|
||||||
|
class OpenInCarlaRequest(BaseModel):
|
||||||
|
"""Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop
|
||||||
|
và Carla được cài trên cùng máy — tự phát hiện qua runtime profile)."""
|
||||||
|
plugin_name: Optional[str] = None
|
||||||
|
plugin_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(BaseModel):
|
||||||
|
"""Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin +
|
||||||
|
preset (cùng code path với export) → trả wav để browser phát.
|
||||||
|
Âm preview = âm export (khác Preview Synth WASM hiện tại)."""
|
||||||
|
instrument_id: str
|
||||||
|
notes: list = []
|
||||||
|
bpm: float = 120.0
|
||||||
|
sample_rate: int = 44100
|
||||||
|
soundfont_bank: Optional[int] = 0
|
||||||
|
soundfont_program: Optional[int] = 0
|
||||||
|
preset_id: Optional[str] = None
|
||||||
|
preset_path: Optional[str] = None
|
||||||
|
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
||||||
|
|
||||||
|
|
||||||
|
class CarlaMidiRequest(BaseModel):
|
||||||
|
"""Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off).
|
||||||
|
|
||||||
|
Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc,
|
||||||
|
CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của
|
||||||
|
Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app).
|
||||||
|
Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0."""
|
||||||
|
event: str # "note_on" | "note_off"
|
||||||
|
note: int
|
||||||
|
velocity: Optional[int] = 100
|
||||||
|
channel: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/carla-midi")
|
||||||
|
async def carla_midi(req: CarlaMidiRequest):
|
||||||
|
"""MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime."""
|
||||||
|
if req.event not in ("note_on", "note_off"):
|
||||||
|
raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off")
|
||||||
|
ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; "
|
||||||
|
"nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)",
|
||||||
|
)
|
||||||
|
return {"success": True, "event": req.event, "note": req.note, "channel": req.channel}
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_osc_port() -> int:
|
||||||
|
"""Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong
|
||||||
|
storage/carla_path.json → mặc định 22752 (CarlaEngineData)."""
|
||||||
|
try:
|
||||||
|
p = os.environ.get("SF_CARLA_OSC_PORT")
|
||||||
|
if p:
|
||||||
|
return int(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from app.core.runtime import _carla_config_path
|
||||||
|
with open(_carla_config_path(), "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
p = data.get("osc_port")
|
||||||
|
if p:
|
||||||
|
return int(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 22752
|
||||||
|
|
||||||
|
|
||||||
|
def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool:
|
||||||
|
"""Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load).
|
||||||
|
|
||||||
|
OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4.
|
||||||
|
Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và
|
||||||
|
tên client mặc định của app standalone là "Carla" (carla_host.py
|
||||||
|
fClientName = CARLA_CLIENT_NAME or "Carla")."""
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
port = _carla_osc_port()
|
||||||
|
path = f"/Carla/0/{event}".encode("utf-8")
|
||||||
|
typetag = b",iii" if event == "note_on" else b",ii"
|
||||||
|
vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)]
|
||||||
|
|
||||||
|
def _pad(b: bytes) -> bytes:
|
||||||
|
rem = len(b) % 4
|
||||||
|
return b + b"\x00" * (4 - rem) if rem else b
|
||||||
|
|
||||||
|
msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals)
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(0.5)
|
||||||
|
s.sendto(msg, ("127.0.0.1", port))
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/open-in-carla")
|
||||||
|
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard).
|
||||||
|
|
||||||
|
Cơ chế: 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>...</Binary></Info> → Carla mở lên là plugin đã load sẵn,
|
||||||
|
kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime.
|
||||||
|
|
||||||
|
Carla là app ngoài do user tự giải nén (GPL-2.0+ → không bundle/nhúng);
|
||||||
|
app chỉ spawn tiến trình + trao đổi file preset."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
from app.core.runtime import find_carla
|
||||||
|
carla = find_carla()
|
||||||
|
if not carla:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla "
|
||||||
|
"portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, "
|
||||||
|
"rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn "
|
||||||
|
"thư mục chứa carla.exe (bản portable không dùng PATH).",
|
||||||
|
)
|
||||||
|
plugin_path = req.plugin_path or ""
|
||||||
|
if not plugin_path and req.plugin_name:
|
||||||
|
try:
|
||||||
|
pm = PluginManager()
|
||||||
|
plugins = pm._scan_plugins()
|
||||||
|
if req.plugin_name in plugins:
|
||||||
|
plugin_path = plugins[req.plugin_name]
|
||||||
|
except Exception:
|
||||||
|
plugin_path = ""
|
||||||
|
carxs_path = ""
|
||||||
|
if plugin_path:
|
||||||
|
carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path)
|
||||||
|
cmd = [carla]
|
||||||
|
if carxs_path:
|
||||||
|
cmd.append(carxs_path)
|
||||||
|
try:
|
||||||
|
cwd = os.path.dirname(carla) or None
|
||||||
|
proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
|
||||||
|
_register_carla_process(proc)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"started": True,
|
||||||
|
"carla_path": carla,
|
||||||
|
"plugin_path": plugin_path,
|
||||||
|
"project_file": carxs_path,
|
||||||
|
"cmd": cmd,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Carla bridge lifecycle ─────────────────────────────────────────────────
|
||||||
|
# App spawn Carla (open_in_carla) và PHẢI có khả năng dừng nó khi track chuyển
|
||||||
|
# sang instrument KHÔNG phải VST (soundfont/GM) — nếu không, Carla vẫn chạy và
|
||||||
|
# MIDI vẫn vào VSTi cũ → âm sai instrument + âm kẹt không dừng được.
|
||||||
|
_CARLA_PROCESSES = [] # list[subprocess.Popen]
|
||||||
|
|
||||||
|
|
||||||
|
def _register_carla_process(proc):
|
||||||
|
"""Lưu handle tiến trình Carla do app spawn (để carla-stop terminate được)."""
|
||||||
|
global _CARLA_PROCESSES
|
||||||
|
_prune_carla_processes()
|
||||||
|
_CARLA_PROCESSES.append(proc)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_carla_processes():
|
||||||
|
"""Bỏ các handle đã thoát (poll() trả code) — tránh list rác."""
|
||||||
|
global _CARLA_PROCESSES
|
||||||
|
_CARLA_PROCESSES = [p for p in _CARLA_PROCESSES if p is not None and p.poll() is None]
|
||||||
|
|
||||||
|
|
||||||
|
def _send_carla_all_notes_off() -> bool:
|
||||||
|
"""Gửi note_off TẤT CẢ pitch (0-127) trên mọi channel (0-15) tới Carla.
|
||||||
|
|
||||||
|
Dừng mọi âm đang ngân trong Carla (kể cả sustain) — dùng ngay trước khi
|
||||||
|
terminate để không còn tiếng kẹt khi bridge bị unload."""
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
for ch in range(16):
|
||||||
|
for note in range(128):
|
||||||
|
if _send_carla_osc("note_off", note, 0, ch):
|
||||||
|
ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/carla-status")
|
||||||
|
async def carla_status():
|
||||||
|
"""Kiểm tra Carla bridge còn sống không (do app spawn) + cổng OSC đang dùng.
|
||||||
|
|
||||||
|
Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi
|
||||||
|
Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn
|
||||||
|
phần khi Carla bị đóng."""
|
||||||
|
_prune_carla_processes()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"running": len(_CARLA_PROCESSES) > 0,
|
||||||
|
"osc_port": _carla_osc_port(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/carla-stop")
|
||||||
|
async def carla_stop(authorization: Optional[str] = Header(None)):
|
||||||
|
"""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 khác (soundfont)
|
||||||
|
để âm KHÔNG còn play qua Carla bridge."""
|
||||||
|
# Auth là optional (desktop app có thể chưa login) — chỉ cần decode nếu có
|
||||||
|
try:
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
from app.core.auth import decode_token
|
||||||
|
decode_token(authorization.split(" ")[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 1. Tắt hết âm đang ngân trong Carla (trước khi giết tiến trình)
|
||||||
|
try:
|
||||||
|
_send_carla_all_notes_off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 2. Terminate tiến trình Carla đã spawn
|
||||||
|
killed = 0
|
||||||
|
_prune_carla_processes()
|
||||||
|
for proc in list(_CARLA_PROCESSES):
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
|
||||||
|
try:
|
||||||
|
import time as _t
|
||||||
|
deadline = _t.time() + 2.0
|
||||||
|
for proc in list(_CARLA_PROCESSES):
|
||||||
|
while proc.poll() is None and _t.time() < deadline:
|
||||||
|
_t.sleep(0.05)
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if proc.poll() is not None:
|
||||||
|
killed += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_CARLA_PROCESSES.clear()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stopped": True,
|
||||||
|
"killed": killed,
|
||||||
|
"all_notes_off": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _write_carla_project(plugin_name: str, plugin_path: str) -> str:
|
||||||
|
"""Sinh file project .carxs cho Carla load sẵn VSTi (chỉ VST3 — VST2 cần
|
||||||
|
uniqueID không đoán được → mở Carla trống để user tự Add Plugin).
|
||||||
|
|
||||||
|
Định dạng theo source Carla (CarlaEngine::saveProjectInternal +
|
||||||
|
CarlaStateSave::dumpToMemoryStream): root <CARLA-PROJECT VERSION='2.5'>,
|
||||||
|
<Plugin><Info><Type>VST3</Type><Binary>path</Binary><Label>..</Label>
|
||||||
|
</Info><Data><Active>Yes</Active><ControlChannel>1</ControlChannel>
|
||||||
|
<Options>0x0</Options></Data></Plugin>."""
|
||||||
|
if not plugin_path or not os.path.exists(plugin_path):
|
||||||
|
return ""
|
||||||
|
low = plugin_path.lower()
|
||||||
|
is_vst3 = low.endswith(".vst3") or low.endswith(".vst3/") or "\\" in plugin_path and plugin_path.rstrip("\\/").lower().endswith(".vst3")
|
||||||
|
if not is_vst3:
|
||||||
|
# VST2 (.dll/.so ngoài .vst3): không auto-load được tin cậy → Carla trống
|
||||||
|
return ""
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
name = escape(plugin_name or os.path.splitext(os.path.basename(plugin_path))[0])
|
||||||
|
binary = escape(plugin_path)
|
||||||
|
# Patchbay: kết nối TỰ ĐỘNG MIDI input + audio output → default speaker.
|
||||||
|
# Mặc định Carla KHÔNG connect khi load project (chỉ restore connection có
|
||||||
|
# trong file) → lần đầu mở không có âm, không nhận MIDI (phải unload/load
|
||||||
|
# lại mới auto-connect như thêm plugin thủ công). Connection nào trỏ tới
|
||||||
|
# port không tồn tại sẽ bị Carla bỏ qua im lặng → thêm nhiều biến thể tên.
|
||||||
|
pb_name = name
|
||||||
|
patchbay = (
|
||||||
|
" <Patchbay>\n"
|
||||||
|
# Audio out plugin → loa mặc định (system:playback_1/2)
|
||||||
|
f" <Connection><Source>{pb_name}:audio_out1</Source><Target>system:playback_1</Target></Connection>\n"
|
||||||
|
f" <Connection><Source>{pb_name}:audio_out2</Source><Target>system:playback_2</Target></Connection>\n"
|
||||||
|
# MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
|
||||||
|
f" <Connection><Source>Carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
||||||
|
f" <Connection><Source>carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
||||||
|
f" <Connection><Source>system:midi_capture_1</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
||||||
|
" </Patchbay>\n"
|
||||||
|
)
|
||||||
|
xml = (
|
||||||
|
"<?xml version='1.0' encoding='UTF-8'?>\n"
|
||||||
|
"<!DOCTYPE CARLA-PROJECT>\n"
|
||||||
|
f"<CARLA-PROJECT VERSION='2.5'>\n"
|
||||||
|
" <EngineSettings>\n"
|
||||||
|
" <ForceStereo>false</ForceStereo>\n"
|
||||||
|
" <PreferPluginBridges>false</PreferPluginBridges>\n"
|
||||||
|
" <PreferUiBridges>false</PreferUiBridges>\n"
|
||||||
|
" <UIsAlwaysOnTop>false</UIsAlwaysOnTop>\n"
|
||||||
|
" <MaxParameters>100</MaxParameters>\n"
|
||||||
|
" <UIBridgesTimeout>10000</UIBridgesTimeout>\n"
|
||||||
|
" </EngineSettings>\n"
|
||||||
|
" <Plugin>\n"
|
||||||
|
" <Info>\n"
|
||||||
|
f" <Type>VST3</Type>\n"
|
||||||
|
f" <Name>{name}</Name>\n"
|
||||||
|
f" <Binary>{binary}</Binary>\n"
|
||||||
|
f" <Label>{name}</Label>\n"
|
||||||
|
" </Info>\n"
|
||||||
|
" <Data>\n"
|
||||||
|
" <Active>Yes</Active>\n"
|
||||||
|
" <ControlChannel>1</ControlChannel>\n"
|
||||||
|
" <Options>0x0</Options>\n"
|
||||||
|
" </Data>\n"
|
||||||
|
" </Plugin>\n"
|
||||||
|
+ patchbay +
|
||||||
|
"</CARLA-PROJECT>\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
proj_dir = os.path.join(settings.STORAGE_DIR, "carla_projects")
|
||||||
|
os.makedirs(proj_dir, exist_ok=True)
|
||||||
|
# Dọn project cũ (quá 1 ngày) — tránh rác
|
||||||
|
try:
|
||||||
|
now = _time.time()
|
||||||
|
for f in os.listdir(proj_dir):
|
||||||
|
fp = os.path.join(proj_dir, f)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(fp) and now - os.path.getmtime(fp) > 86400:
|
||||||
|
os.remove(fp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
safe = "".join(c for c in plugin_name if c.isalnum() or c in " _-")[:40].strip() or "plugin"
|
||||||
|
carxs = os.path.join(proj_dir, f"{safe}_{uuid.uuid4().hex[:8]}.carxs")
|
||||||
|
with open(carxs, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(xml)
|
||||||
|
return carxs
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
async def preview_instrument(req: PreviewRequest):
|
||||||
|
"""Quick-render preview VSTi (âm thật, cùng code path với export)."""
|
||||||
|
if not HAS_PEDALBOARD:
|
||||||
|
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
|
||||||
|
if not req.notes:
|
||||||
|
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
|
||||||
|
try:
|
||||||
|
pm = PluginManager()
|
||||||
|
vst = pm.load_vst(req.instrument_id)
|
||||||
|
if vst is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
|
||||||
|
apply_preset_to_plugin(
|
||||||
|
vst,
|
||||||
|
preset_id=req.preset_id,
|
||||||
|
preset_path=req.preset_path,
|
||||||
|
preset_data_b64=req.preset_data,
|
||||||
|
)
|
||||||
|
from pedalboard import Pedalboard
|
||||||
|
midi_events = []
|
||||||
|
for n in req.notes:
|
||||||
|
midi_events.append({
|
||||||
|
"note": int(n.get("pitch", 60)),
|
||||||
|
"start_beat": float(n.get("start_beat", 0)),
|
||||||
|
"duration_beats": float(n.get("duration_beats", 1)),
|
||||||
|
"velocity": int(float(n.get("velocity", 0.8)) * 127),
|
||||||
|
})
|
||||||
|
midi_messages = PluginManager.midi_events_to_messages(
|
||||||
|
midi_events, req.bpm, req.sample_rate,
|
||||||
|
bank=req.soundfont_bank, program=req.soundfont_program,
|
||||||
|
)
|
||||||
|
total_needed = 0
|
||||||
|
beat_sec = 60.0 / max(30.0, req.bpm)
|
||||||
|
for ev in midi_events:
|
||||||
|
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
|
||||||
|
if int(end_sec * req.sample_rate) > total_needed:
|
||||||
|
total_needed = int(end_sec * req.sample_rate)
|
||||||
|
total_needed = max(total_needed, 1024)
|
||||||
|
silent = np.zeros((2, total_needed), dtype=np.float32)
|
||||||
|
board = Pedalboard([vst])
|
||||||
|
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
|
||||||
|
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
|
||||||
|
out_path = os.path.join(settings.PROCESSED_DIR, fname)
|
||||||
|
sf.write(out_path, buf.T, req.sample_rate)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": f"/static/audio/processed/{fname}",
|
||||||
|
"path": out_path,
|
||||||
|
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/render")
|
@router.post("/render")
|
||||||
async def render_project(
|
async def render_project(
|
||||||
req: RenderRequest,
|
req: RenderRequest,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
engine = PythonRenderEngine()
|
engine = PythonRenderEngine()
|
||||||
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
|
# Prevent path traversal: strip any directory components and force .wav.
|
||||||
|
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
||||||
|
if not safe_name.lower().endswith(".wav"):
|
||||||
|
safe_name += ".wav"
|
||||||
|
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||||
try:
|
try:
|
||||||
result_path = engine.render_project(req.project_json, output_path)
|
result_path = engine.render_project(req.project_json, output_path)
|
||||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||||
|
|||||||
@@ -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}
|
||||||
+110
-13
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
return project_data
|
return project_data
|
||||||
|
|
||||||
tracks = project_data.get("tracks", [])
|
tracks = project_data.get("tracks", [])
|
||||||
|
# Legacy format stores item start times in SECONDS; convert using the real
|
||||||
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||||
|
# position for any tempo other than the one where 1 bar = 4s).
|
||||||
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||||
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||||
upgraded_tracks = []
|
upgraded_tracks = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track_id = str(t.get("id", ""))
|
track_id = str(t.get("id", ""))
|
||||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"name": c.get("name", "Audio Clip"),
|
"name": c.get("name", "Audio Clip"),
|
||||||
"type": "AUDIO_ITEM",
|
"type": "AUDIO_ITEM",
|
||||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": 4.0,
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||||
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": m.get("id"),
|
"id": m.get("id"),
|
||||||
"name": m.get("name", "MIDI Item"),
|
"name": m.get("name", "MIDI Item"),
|
||||||
"type": "MIDI_ITEM",
|
"type": "MIDI_ITEM",
|
||||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": m.get("duration", 4.0),
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"total_buffer_bars": m.get("duration", 8.0),
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||||
"notes": m.get("notes", [])
|
"notes": m.get("notes", [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -157,6 +162,18 @@ 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)
|
||||||
|
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")
|
||||||
@@ -170,15 +187,78 @@ 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 {
|
||||||
|
"has_temp": True,
|
||||||
|
"data_json": row["data_json"],
|
||||||
|
"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}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
"has_temp": True,
|
"success": True,
|
||||||
"data_json": row["data_json"],
|
"name": req.name or "Dự án mới",
|
||||||
"updated_at": row["updated_at"]
|
"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)):
|
||||||
validated_data_json = validate_project_data(req.data_json)
|
validated_data_json = validate_project_data(req.data_json)
|
||||||
@@ -283,13 +363,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||||
exists = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
if not exists:
|
if not existing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||||
|
|
||||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||||
|
|
||||||
|
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||||
|
# update bypassed the quota entirely).
|
||||||
|
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||||
|
quota_row = cursor.fetchone()
|
||||||
|
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||||
|
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||||
|
used_row = cursor.fetchone()
|
||||||
|
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||||
|
max_bytes = storage_limit_mb * 1024 * 1024
|
||||||
|
if used_bytes + new_size_bytes > max_bytes:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||||
|
)
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|
||||||
+10
-3
@@ -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,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
|||||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||||
uid = _get_user_id(authorization)
|
uid = _get_user_id(authorization)
|
||||||
configs = _load_ai_configs()
|
configs = _load_ai_configs()
|
||||||
configs[uid] = [p.dict() for p in req.providers]
|
configs[uid] = [p.model_dump() for p in req.providers]
|
||||||
_save_all(ai_configs=configs)
|
_save_all(ai_configs=configs)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+52
-3
@@ -1,16 +1,65 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _app_dir():
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# PyInstaller onefile: assets read-only nằm trong thư mục giải nén tạm
|
||||||
|
return os.path.join(sys._MEIPASS, "app")
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_dir():
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# 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("~")
|
||||||
|
return os.path.join(root, "SonicForgeDAW", "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")
|
||||||
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
||||||
|
|
||||||
# __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root
|
APP_DIR: str = _app_dir()
|
||||||
APP_DIR: str = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
BASE_DIR: str = os.path.dirname(APP_DIR)
|
BASE_DIR: str = os.path.dirname(APP_DIR)
|
||||||
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
|
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
|
||||||
STORAGE_DIR: str = os.path.join(APP_DIR, "storage")
|
STORAGE_DIR: str = _storage_dir()
|
||||||
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
|
||||||
|
|||||||
+21
-12
@@ -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)
|
||||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
|||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
COOKIE_NAME = "sf_token"
|
||||||
|
X_AUTH_HEADER = "X-Auth-Token"
|
||||||
|
|
||||||
|
def _load_or_create_secret_key() -> str:
|
||||||
|
"""Persistent random SECRET_KEY.
|
||||||
|
|
||||||
|
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
|
||||||
|
first run). Never falls back to a hardcoded value: a known secret lets
|
||||||
|
anyone forge admin tokens.
|
||||||
|
"""
|
||||||
|
env_key = os.getenv("SECRET_KEY", "").strip()
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
|
||||||
|
try:
|
||||||
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
|
if os.path.exists(key_file):
|
||||||
|
with open(key_file, "r") as f:
|
||||||
|
key = f.read().strip()
|
||||||
|
if len(key) >= 32:
|
||||||
|
return key
|
||||||
|
key = secrets.token_hex(32)
|
||||||
|
with open(key_file, "w") as f:
|
||||||
|
f.write(key)
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
# Last resort: ephemeral random key (all tokens invalid on restart).
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
SECRET_KEY = _load_or_create_secret_key()
|
||||||
|
|
||||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+78
-23
@@ -1,7 +1,8 @@
|
|||||||
import os, logging
|
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):
|
||||||
@@ -87,12 +104,18 @@ class PythonRenderEngine:
|
|||||||
return url_or_id
|
return url_or_id
|
||||||
return url_or_id
|
return url_or_id
|
||||||
|
|
||||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
|
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||||
|
tracks = session.get("tracks", [])
|
||||||
|
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||||
|
|
||||||
_channel_counter = 0
|
_channel_counter = 0
|
||||||
|
|
||||||
for track in session.get("tracks", []):
|
for track in tracks:
|
||||||
|
if solo_ids and track.get("id") not in solo_ids:
|
||||||
|
continue
|
||||||
track_type = track.get("type", "AUDIO")
|
track_type = track.get("type", "AUDIO")
|
||||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
@@ -123,8 +146,17 @@ class PythonRenderEngine:
|
|||||||
try:
|
try:
|
||||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||||
if sr != self.sample_rate:
|
if sr != self.sample_rate:
|
||||||
# Resampling fallback if simple, otherwise skip
|
# Proper resampling: previously a silent no-op that
|
||||||
pass
|
# played 48kHz audio at the wrong speed/pitch.
|
||||||
|
from scipy.signal import resample_poly
|
||||||
|
g = math.gcd(sr, self.sample_rate)
|
||||||
|
audio_data = resample_poly(
|
||||||
|
audio_data,
|
||||||
|
up=self.sample_rate // g,
|
||||||
|
down=sr // g,
|
||||||
|
axis=-1,
|
||||||
|
)
|
||||||
|
sr = self.sample_rate
|
||||||
|
|
||||||
# Handle channel mapping (Mono/Stereo)
|
# Handle channel mapping (Mono/Stereo)
|
||||||
if len(audio_data.shape) == 1:
|
if len(audio_data.shape) == 1:
|
||||||
@@ -148,7 +180,7 @@ class PythonRenderEngine:
|
|||||||
if actual_len > 0:
|
if actual_len > 0:
|
||||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||||
|
|
||||||
elif item_type == "MIDI_ITEM":
|
elif item_type == "MIDI_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
@@ -207,6 +239,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
|
||||||
@@ -284,21 +331,27 @@ class PythonRenderEngine:
|
|||||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||||
|
|
||||||
elif item_type == "SECTION_ITEM":
|
elif item_type == "SECTION_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
sec_id = source_data.get("referenced_section_id", "")
|
sec_id = source_data.get("referenced_section_id", "")
|
||||||
if sec_id and sec_id in section_store:
|
if sec_id and sec_id in section_store:
|
||||||
# Render nested section recursively
|
# Render nested section recursively, cached per section id
|
||||||
sec_container = section_store[sec_id]
|
# so repeated section instances don't re-render every time.
|
||||||
sec_buffer = self.render_session_container(
|
cache = _cache if _cache is not None else {}
|
||||||
session=sec_container,
|
if sec_id in cache:
|
||||||
section_store=section_store,
|
sec_buffer = cache[sec_id]
|
||||||
bpm=bpm,
|
else:
|
||||||
time_sig_num=time_sig_num,
|
sec_buffer = self.render_session_container(
|
||||||
total_samples=total_samples
|
session=section_store[sec_id],
|
||||||
)
|
section_store=section_store,
|
||||||
|
bpm=bpm,
|
||||||
|
time_sig_num=time_sig_num,
|
||||||
|
total_samples=total_samples,
|
||||||
|
_cache=cache,
|
||||||
|
)
|
||||||
|
cache[sec_id] = sec_buffer
|
||||||
|
|
||||||
# Apply non-destructive crop/slicing on section buffer
|
# Apply non-destructive crop/slicing on section buffer
|
||||||
if offset_sample < total_samples:
|
if offset_sample < total_samples:
|
||||||
@@ -327,7 +380,7 @@ class PythonRenderEngine:
|
|||||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||||
try:
|
try:
|
||||||
@@ -341,14 +394,14 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = track_buffer[ch, indices]
|
wet[ch, :] = track_buffer[ch, indices]
|
||||||
track_buffer = dry + wet * 0.5
|
track_buffer = dry + wet * 0.5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||||
elif fx_type == "reverb":
|
elif fx_type == "reverb":
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback reverb using exponentially decaying noise room impulse response
|
# Fallback reverb using exponentially decaying noise room impulse response
|
||||||
try:
|
try:
|
||||||
@@ -364,11 +417,12 @@ 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:
|
||||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||||
|
|
||||||
# Process track volume
|
# Process track volume
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
@@ -410,7 +464,8 @@ class PythonRenderEngine:
|
|||||||
section_store=section_store,
|
section_store=section_store,
|
||||||
bpm=bpm,
|
bpm=bpm,
|
||||||
time_sig_num=time_sig_num,
|
time_sig_num=time_sig_num,
|
||||||
total_samples=total_samples
|
total_samples=total_samples,
|
||||||
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalization to prevent clipping
|
# Normalization to prevent clipping
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# 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 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")]
|
||||||
|
|
||||||
|
|
||||||
|
@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 ""
|
||||||
|
return {
|
||||||
|
"platform": platform,
|
||||||
|
"runtime": runtime, # "desktop" | "headless"
|
||||||
|
"docker": _in_docker(),
|
||||||
|
"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 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"],
|
||||||
|
"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))
|
||||||
@@ -280,31 +280,37 @@ class SoundFontConverter:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _sf3_plays_audio(path: str) -> bool:
|
def _sf3_plays_audio(path: str) -> bool:
|
||||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||||
against shipping malformed SF3 files that silently play nothing)."""
|
against shipping malformed SF3 files that silently play nothing).
|
||||||
|
|
||||||
|
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||||
|
high-level Synth() class does not exist in this binding, so it is never
|
||||||
|
used here.
|
||||||
|
"""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
import numpy as np
|
import numpy as np
|
||||||
fl = fluidsynth.Synth()
|
_settings = _fs.new_fluid_settings()
|
||||||
|
_fl = _fs.new_fluid_synth(_settings)
|
||||||
try:
|
try:
|
||||||
h = fl.sfload(path)
|
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||||
if h < 0:
|
if h < 0:
|
||||||
return False
|
return False
|
||||||
fl.program_select(0, h, 0, 0)
|
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||||
fl.noteon(0, 60, 100)
|
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||||
frames = 8820 # 0.2s
|
frames = 8820 # 0.2s
|
||||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||||
fluidsynth._fl.fluid_synth_write_float(
|
_fs.fluid_synth_write_float(
|
||||||
fl.synth, frames, buf.ctypes.data, 0, 1,
|
_fl, frames, buf.ctypes.data, 0, 1,
|
||||||
buf.ctypes.data + frames * 4, 0, 1
|
buf.ctypes.data + frames * 4, 0, 1
|
||||||
)
|
)
|
||||||
fl.noteoff(0, 60)
|
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||||
rms = float(np.sqrt(np.mean(buf ** 2)))
|
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||||
return rms > 1e-4
|
return rms > 1e-4
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
_fs.delete_fluid_synth(_fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+235
-81
@@ -1,8 +1,9 @@
|
|||||||
# 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_int, c_char_p, c_void_p
|
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))
|
||||||
@@ -49,27 +50,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()
|
||||||
@@ -99,18 +102,47 @@ _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):
|
||||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||||
|
|
||||||
|
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||||
|
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||||
|
do not exist in this binding, so they are never used here.
|
||||||
|
"""
|
||||||
global _FLUID_CACHE
|
global _FLUID_CACHE
|
||||||
if not HAS_PYFLUIDSYNTH:
|
if not HAS_PYFLUIDSYNTH:
|
||||||
return None
|
return None
|
||||||
@@ -119,10 +151,15 @@ def load_soundfont_cached(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||||
return fl
|
return fl
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
_settings = _fs.new_fluid_settings()
|
||||||
font_id = fl.sfload(path)
|
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||||
fl.program_select(0, font_id, 0, 0)
|
fl = _fs.new_fluid_synth(_settings)
|
||||||
|
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||||
|
if font_id < 0:
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
|
return None
|
||||||
|
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||||
_FLUID_CACHE[path] = (fl, 1)
|
_FLUID_CACHE[path] = (fl, 1)
|
||||||
return fl
|
return fl
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -136,7 +173,8 @@ def release_soundfont(path: str):
|
|||||||
fl, ref = _FLUID_CACHE[path]
|
fl, ref = _FLUID_CACHE[path]
|
||||||
if ref <= 1:
|
if ref <= 1:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
import fluidsynth as _fs
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
del _FLUID_CACHE[path]
|
del _FLUID_CACHE[path]
|
||||||
@@ -144,22 +182,34 @@ 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 file in files:
|
for root, dirs, files in os.walk(scan_dir):
|
||||||
if file.endswith(".vst3") or file.endswith(".so"):
|
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
|
||||||
plugin_path = os.path.join(root, file)
|
for d in list(dirs):
|
||||||
plugin_name = os.path.splitext(file)[0]
|
if d.lower().endswith(".vst3"):
|
||||||
plugins[plugin_name] = plugin_path
|
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
|
||||||
|
for file in files:
|
||||||
|
low = file.lower()
|
||||||
|
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
|
||||||
|
plugin_path = os.path.join(root, file)
|
||||||
|
plugin_name = os.path.splitext(file)[0]
|
||||||
|
if plugin_name not in plugins:
|
||||||
|
plugins[plugin_name] = plugin_path
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
def _scan_soundfonts(self) -> list:
|
def _scan_soundfonts(self) -> list:
|
||||||
@@ -228,8 +278,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 = []
|
||||||
@@ -237,50 +285,89 @@ 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)
|
||||||
for d in search_dirs:
|
# Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn
|
||||||
for f in os.listdir(d):
|
# SoundFont): nếu không có, instrument của soundfont trong thư mục user
|
||||||
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
# không liệt kê được (bug "nhấn tên SF không thấy instrument").
|
||||||
continue
|
for d in (self.extra_vst_dirs or []):
|
||||||
base = os.path.splitext(f)[0]
|
if d and os.path.isdir(d) and d not in search_dirs:
|
||||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
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:
|
||||||
|
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 and base != sf_id.replace("sf_", ""):
|
||||||
|
continue
|
||||||
path = os.path.join(d, f)
|
path = os.path.join(d, f)
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.Synth()
|
# Low-level CFFI API (same as render_engine); never use
|
||||||
fid = fl.sfload(path)
|
# the high-level Synth() class that this binding lacks.
|
||||||
if fid < 0:
|
_settings = _fs.new_fluid_settings()
|
||||||
fl.delete()
|
_synth = _fs.new_fluid_synth(_settings)
|
||||||
continue
|
try:
|
||||||
presets = []
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||||
_fl = fluidsynth._fl
|
if fid >= 0:
|
||||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||||
_fl.fluid_preset_get_name.restype = c_char_p
|
if sfont:
|
||||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
for bank in range(0, 2):
|
||||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
for prog_num in range(0, 128):
|
||||||
if sfont_ptr:
|
try:
|
||||||
for bank in range(0, 2):
|
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||||
for prog_num in range(0, 128):
|
except Exception:
|
||||||
try:
|
break
|
||||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
if preset:
|
||||||
except Exception:
|
try:
|
||||||
break
|
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||||
if preset:
|
if name_ptr:
|
||||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
if hasattr(_fs, "ffi"):
|
||||||
if name_ptr:
|
raw = _fs.ffi.string(name_ptr)
|
||||||
name_val = c_char_p(name_ptr).value
|
else:
|
||||||
if name_val:
|
raw = c_char_p(name_ptr).value
|
||||||
presets.append({
|
if raw:
|
||||||
"bank": bank,
|
presets.append({
|
||||||
"program": prog_num,
|
"bank": bank,
|
||||||
"name": name_val.decode("utf-8", errors="replace")
|
"program": prog_num,
|
||||||
})
|
"name": raw.decode("utf-8", errors="replace"),
|
||||||
fl.delete()
|
})
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
except Exception:
|
||||||
return presets[:256]
|
continue
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fs.delete_fluid_synth(_synth)
|
||||||
|
except Exception:
|
||||||
|
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 {
|
||||||
@@ -349,3 +436,70 @@ 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 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
|
||||||
|
|
||||||
|
|||||||
+58
-23
@@ -1,8 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse
|
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
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.audio import router as audio_router
|
from app.api.v1.audio import router as audio_router
|
||||||
from app.api.v1.tasks import router as tasks_router
|
from app.api.v1.tasks import router as tasks_router
|
||||||
@@ -15,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
|
||||||
|
|
||||||
@@ -22,16 +27,32 @@ from app.core.soundfont_scanner import SoundFontAutoScanner
|
|||||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||||
|
|
||||||
app = FastAPI(title="SonicForge API Engine")
|
_SF_SCANNER_STOP = None
|
||||||
|
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
seed_admin()
|
||||||
|
scanner = SoundFontAutoScanner()
|
||||||
|
global _SF_SCANNER_STOP
|
||||||
|
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
if _SF_SCANNER_STOP is not None:
|
||||||
|
_SF_SCANNER_STOP.set()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
|
|
||||||
|
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||||
|
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -39,7 +60,27 @@ 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")
|
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
||||||
# Mount app static files (js, css)
|
# Mount app static files (js, css)
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
# 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...
|
||||||
|
# nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý).
|
||||||
|
STATIC_DIR = os.path.join(settings.APP_DIR, "static")
|
||||||
|
# ⚠️ Fallback an toàn: nếu vì lý do nào đó static không nằm đúng chỗ (vd
|
||||||
|
# bundle thiếu file, chạy từ nơi khác), thử các vị trí khác; nếu vẫn không
|
||||||
|
# có → TỰ TẠO thư mục rỗng để app KHÔNG crash khi khởi động (lỗi "Directory
|
||||||
|
# does not exist" từ StaticFiles làm engine chết ngay lúc import — đã gặp).
|
||||||
|
if not os.path.isdir(STATIC_DIR):
|
||||||
|
for cand in [
|
||||||
|
os.path.join(getattr(sys, "_MEIPASS", ""), "app", "static"),
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static"),
|
||||||
|
]:
|
||||||
|
if cand and os.path.isdir(cand):
|
||||||
|
STATIC_DIR = cand
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
@@ -54,23 +95,13 @@ 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"])
|
||||||
|
|
||||||
# Seed admin user on startup
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_seed_admin():
|
|
||||||
seed_admin()
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.get("/health")
|
||||||
async def startup_convert_soundfonts():
|
async def health():
|
||||||
# SF2 -> SF3 conversion is disabled: the client FluidSynth WASM cannot decode
|
return {"status": "ok"}
|
||||||
# Ogg Vorbis (SF3) samples, so converted SF3s would play silence. The download
|
|
||||||
# endpoint serves SF2 when available and converts SF3 -> SF2 on demand instead.
|
|
||||||
pass
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_sf_scanner():
|
|
||||||
scanner = SoundFontAutoScanner()
|
|
||||||
scanner.start_background(interval=30)
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def get_index():
|
async def get_index():
|
||||||
@@ -78,14 +109,18 @@ async def get_index():
|
|||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||||
|
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||||
|
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def get_favicon():
|
async def get_favicon():
|
||||||
import os
|
|
||||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||||
if os.path.exists(favicon_path):
|
if os.path.exists(favicon_path):
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||||
return HTMLResponse(content="", status_code=404)
|
return HTMLResponse(content="", status_code=404)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"id": { "type": "string" },
|
"id": { "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||||
|
"color": { "type": ["string", "null"], "default": null },
|
||||||
"volume_db": { "type": "number", "default": 0.0 },
|
"volume_db": { "type": "number", "default": 0.0 },
|
||||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||||
"mute": { "type": "boolean", "default": false },
|
"mute": { "type": "boolean", "default": false },
|
||||||
|
|||||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||||
|
# dev database is never touched by the test suite.
|
||||||
|
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||||
|
# cleanup consistent when users are deleted.
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -89,6 +95,16 @@ def init_db():
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Placeholder user for anonymous autosave: projects are saved with
|
||||||
|
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||||
|
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
import secrets as _secrets
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||||
|
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||||
|
""", (_secrets.token_hex(32), time.time()))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+7437
-788
File diff suppressed because it is too large
Load Diff
+1296
-159
File diff suppressed because one or more lines are too long
@@ -121,6 +121,57 @@ const AIGateway = (function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ai_midi_rearrange_specification.md §3 — Function Tool Schema hỗ trợ 2 mode:
|
||||||
|
// SIMILAR_VARIATION (biến tấu cùng độ dài) / EXTEND_CONTINUATION (viết tiếp
|
||||||
|
// các bar sau). AI trả gói dữ liệu có vị trí target trên Timeline.
|
||||||
|
const REARRANGE_EXTEND_TOOL_SPEC = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rearrange_or_extend_midi_melody',
|
||||||
|
description: 'Analyzes source MIDI melody data and returns either a variation (Variation) or continuation (Extend) based on user instructions.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['SIMILAR_VARIATION', 'EXTEND_CONTINUATION'],
|
||||||
|
description: "Mode: 'SIMILAR_VARIATION' (new arrangement of equal length) or 'EXTEND_CONTINUATION' (writes subsequent bars)."
|
||||||
|
},
|
||||||
|
composition_title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Short title describing the new melody style (e.g., Jazz Swing Variation, Epic Extension Part 2)'
|
||||||
|
},
|
||||||
|
target_start_bar: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Starting bar number for the generated notes on the Timeline'
|
||||||
|
},
|
||||||
|
target_duration_bars: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Total bar duration covered by the generated sequence'
|
||||||
|
},
|
||||||
|
soundfont_id: { type: 'string', default: 'generaluser_gs' },
|
||||||
|
soundfont_bank: { type: 'integer', default: 0 },
|
||||||
|
soundfont_program: { type: 'integer', default: 0 },
|
||||||
|
generated_notes: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of AI-generated MIDI notes.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pitch: { type: 'integer', minimum: 0, maximum: 127 },
|
||||||
|
start_beat: { type: 'number', description: 'Starting beat position relative to beat 0.0 of the generated item' },
|
||||||
|
duration_beats: { type: 'number', minimum: 0.1 },
|
||||||
|
velocity: { type: 'number', minimum: 0.0, maximum: 1.0 }
|
||||||
|
},
|
||||||
|
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['mode', 'composition_title', 'target_start_bar', 'target_duration_bars', 'generated_notes']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const REARRANGE_SCENARIOS = [
|
const REARRANGE_SCENARIOS = [
|
||||||
{
|
{
|
||||||
id: 'arpeggio',
|
id: 'arpeggio',
|
||||||
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
|
|||||||
} else {
|
} else {
|
||||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({ url, headers, body })
|
body: JSON.stringify({ url, headers, body })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
|||||||
return {
|
return {
|
||||||
DEFAULT_TOOLS,
|
DEFAULT_TOOLS,
|
||||||
REARRANGE_TOOL_SPEC,
|
REARRANGE_TOOL_SPEC,
|
||||||
|
REARRANGE_EXTEND_TOOL_SPEC,
|
||||||
REARRANGE_SCENARIOS,
|
REARRANGE_SCENARIOS,
|
||||||
detectRearrangeScenario,
|
detectRearrangeScenario,
|
||||||
buildRearrangeMessage,
|
buildRearrangeMessage,
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -64,6 +66,42 @@ 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) }),
|
||||||
|
// 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' }),
|
||||||
@@ -77,6 +115,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,122 @@
|
|||||||
|
// 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;
|
||||||
|
var html = document.documentElement;
|
||||||
|
html.dataset.runtime = c.runtime || 'unknown';
|
||||||
|
html.dataset.platform = c.platform || '';
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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,17 +21,38 @@
|
|||||||
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 _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
|
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||||
|
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||||
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) {
|
||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return _audioCtx;
|
return _audioCtx;
|
||||||
}
|
}
|
||||||
@@ -40,7 +61,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = ctx.createGain();
|
_gainNode = ctx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -53,7 +75,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = window.__sharedAudioCtx.createGain();
|
_gainNode = window.__sharedAudioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return window.__sharedAudioCtx;
|
return window.__sharedAudioCtx;
|
||||||
};
|
};
|
||||||
@@ -61,6 +84,41 @@
|
|||||||
const SonicSF = {
|
const SonicSF = {
|
||||||
loadedFonts: _loadedFonts,
|
loadedFonts: _loadedFonts,
|
||||||
|
|
||||||
|
// Route the shared FluidSynth output through a per-track node (e.g. the
|
||||||
|
// track's gainNode) so the track's FX chain / fader / pan affect the
|
||||||
|
// soundfont instrument. Pass null to restore the default master-bus route.
|
||||||
|
setOutputDestination: function (node) {
|
||||||
|
try {
|
||||||
|
if (_gainNode) {
|
||||||
|
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||||
|
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||||
|
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||||
|
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||||
|
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||||
|
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||||
|
// ngay sau setOutputDestination lần 2).
|
||||||
|
if (dest === _outputDestination) return;
|
||||||
|
_gainNode.disconnect();
|
||||||
|
_gainNode.connect(dest);
|
||||||
|
// Reconnect VU analyser tap (app.jsx VU tick gắn __vuAnalyser
|
||||||
|
// trên _gainNode): disconnect() không đối số ngắt MỌI kết
|
||||||
|
// nối kể cả analyser → sfAudioPeak = 0 mãi → track VU MIDI
|
||||||
|
// không nhảy khi có âm (user bug 06:35).
|
||||||
|
if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);
|
||||||
|
_outputDestination = dest;
|
||||||
|
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||||
|
} else {
|
||||||
|
_pendingOutputDestination = node || null;
|
||||||
|
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SonicSF] setOutputDestination error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getOutputNode: function () {
|
||||||
|
return _gainNode;
|
||||||
|
},
|
||||||
|
|
||||||
init: async function (audioContext) {
|
init: async function (audioContext) {
|
||||||
if (_initialized && _fluidModule) return;
|
if (_initialized && _fluidModule) return;
|
||||||
if (_initPromise) return _initPromise;
|
if (_initPromise) return _initPromise;
|
||||||
@@ -81,7 +139,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||||
@@ -117,7 +176,7 @@
|
|||||||
// expected notice when a soundfont simply has no
|
// expected notice when a soundfont simply has no
|
||||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||||
// font) — the note is just silent, not an error.
|
// font) — the note is just silent, not an error.
|
||||||
if (msg && msg.indexOf('No preset found on channel') !== -1) return;
|
if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
|
||||||
console.warn('[FluidSynth:err]', msg);
|
console.warn('[FluidSynth:err]', msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -168,17 +227,31 @@
|
|||||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||||
|
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||||
|
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||||
|
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||||
|
// buffer + re-malloc khi đổi.
|
||||||
|
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
spn.onaudioprocess = function (e) {
|
spn.onaudioprocess = function (e) {
|
||||||
var left = e.outputBuffer.getChannelData(0);
|
var left = e.outputBuffer.getChannelData(0);
|
||||||
var right = e.outputBuffer.getChannelData(1);
|
var right = e.outputBuffer.getChannelData(1);
|
||||||
var sz = left.length;
|
var sz = left.length;
|
||||||
try {
|
try {
|
||||||
|
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||||
|
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||||
|
lp = _fluidModule._malloc(sz * 4);
|
||||||
|
rp = _fluidModule._malloc(sz * 4);
|
||||||
|
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
|
}
|
||||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||||
var hf = _fluidModule.HEAPF32;
|
var hf = _fluidModule.HEAPF32;
|
||||||
var lpb = lp >> 2, rpb = rp >> 2;
|
var lpb = lp >> 2, rpb = rp >> 2;
|
||||||
for (var si = 0; si < sz; si++) {
|
for (var si = 0; si < sz; si++) {
|
||||||
left[si] = hf[lpb + si];
|
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||||
right[si] = hf[rpb + si];
|
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||||
|
var L = hf[lpb + si], R = hf[rpb + si];
|
||||||
|
left[si] = isFinite(L) ? L : 0;
|
||||||
|
right[si] = isFinite(R) ? R : 0;
|
||||||
}
|
}
|
||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
};
|
};
|
||||||
@@ -249,6 +322,12 @@
|
|||||||
|
|
||||||
_doLoadSoundFont: async function (sfId) {
|
_doLoadSoundFont: async function (sfId) {
|
||||||
try {
|
try {
|
||||||
|
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
|
||||||
|
// thành rác trong khi channel state vẫn trỏ tới → program_select
|
||||||
|
// bị skip (progAlreadySet) → noteon trên handle đã unload →
|
||||||
|
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
|
||||||
|
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
|
||||||
|
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
|
||||||
var cache = window.SonicSFStorage;
|
var cache = window.SonicSFStorage;
|
||||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||||
if (buf) {
|
if (buf) {
|
||||||
@@ -258,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;
|
||||||
}
|
}
|
||||||
@@ -269,8 +349,15 @@
|
|||||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||||
var resp = await fetch(url);
|
var resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||||
return false;
|
// serve qua /soundfonts/{f} — catalog default-soundfonts).
|
||||||
|
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
|
||||||
|
var resp2 = await fetch(url2);
|
||||||
|
if (!resp2.ok) {
|
||||||
|
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resp = resp2;
|
||||||
}
|
}
|
||||||
buf = await resp.arrayBuffer();
|
buf = await resp.arrayBuffer();
|
||||||
if (cache) await cache.saveBuffer(sfId, buf);
|
if (cache) await cache.saveBuffer(sfId, buf);
|
||||||
@@ -286,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) {
|
||||||
@@ -315,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) {}
|
||||||
@@ -327,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) {
|
||||||
@@ -398,8 +495,28 @@
|
|||||||
|
|
||||||
stopNote: function (channel, pitch) {
|
stopNote: function (channel, pitch) {
|
||||||
if (channel < 0 || channel > 15) return;
|
if (channel < 0 || channel > 15) return;
|
||||||
|
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) {
|
if (_initialized && _fluidModule) {
|
||||||
var key = channel + ':' + pitch;
|
|
||||||
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++) {
|
||||||
@@ -429,10 +546,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
var parsedPitch = parseInt(note);
|
||||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
|
||||||
)));
|
if (isNaN(rawVel)) rawVel = 100;
|
||||||
|
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
|
||||||
var _origChannel = channel;
|
var _origChannel = channel;
|
||||||
var usedBank = 0, usedProg = 0;
|
var usedBank = 0, usedProg = 0;
|
||||||
if (synthEngine) {
|
if (synthEngine) {
|
||||||
@@ -465,8 +583,10 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var doNote = function () {
|
var doNote = function () {
|
||||||
try {
|
try {
|
||||||
var finalBank = usedBank;
|
var finalBank = parseInt(usedBank);
|
||||||
var finalProg = usedProg;
|
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||||
|
var finalProg = parseInt(usedProg);
|
||||||
|
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||||
var cachedCh = _channels[ch];
|
var cachedCh = _channels[ch];
|
||||||
// The note's own synth engine (track instrument) is
|
// The note's own synth engine (track instrument) is
|
||||||
@@ -475,16 +595,49 @@
|
|||||||
// re-picked instrument plays the wrong soundfont. Without an
|
// re-picked instrument plays the wrong soundfont. Without an
|
||||||
// engine, fall back to the soundfont configured on the channel.
|
// engine, fall back to the soundfont configured on the channel.
|
||||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||||
finalBank = cachedCh.bank;
|
finalBank = parseInt(cachedCh.bank) || 0;
|
||||||
finalProg = cachedCh.program;
|
finalProg = parseInt(cachedCh.program) || 0;
|
||||||
finalSfId = cachedCh.sfId;
|
finalSfId = cachedCh.sfId;
|
||||||
}
|
}
|
||||||
// Ensure the soundfont is actually loaded before the note plays.
|
// Ensure the soundfont is actually loaded before the note plays.
|
||||||
// Quick instrument pick on a track does not pre-load it, so load
|
// Quick instrument pick on a track does not pre-load it, so load
|
||||||
// lazily here and retry the note once the font is ready.
|
// lazily here and retry the note once the font is ready.
|
||||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||||
|
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
|
||||||
|
// — note chạy thẳng fallback để CÓ ÂM.
|
||||||
|
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
|
||||||
|
if (Date.now() - _lastFail < 10000) {
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
if (ok) doNote();
|
// 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);
|
||||||
|
if (ok) {
|
||||||
|
doNote();
|
||||||
|
} else {
|
||||||
|
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||||
|
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||||
|
_sfLoadFailAt[finalSfId] = Date.now();
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -492,12 +645,49 @@
|
|||||||
// instrument for each item regardless of processing order.
|
// instrument for each item regardless of processing order.
|
||||||
// Skip if the channel already has this exact instrument (avoids
|
// Skip if the channel already has this exact instrument (avoids
|
||||||
// per-note soundfont reloads that cause audible crackle/glitches).
|
// per-note soundfont reloads that cause audible crackle/glitches).
|
||||||
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
|
// ⚠️ 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).
|
||||||
|
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
|
||||||
|
&& cachedCh._epoch === _instrumentEpoch
|
||||||
|
&& (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;
|
||||||
|
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||||
|
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||||
|
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||||
|
// định → font không có → cache channel = (128,0)
|
||||||
|
// INVALID → note sau skip re-select (progAlreadySet)
|
||||||
|
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||||
|
if (finalBank === 128) {
|
||||||
|
var _vKey = finalSfId || ('h' + sfHandle);
|
||||||
|
if (_validPercCache[_vKey] === undefined) {
|
||||||
|
var _found = null;
|
||||||
|
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||||
|
var _bk = _b === 0 ? 128 : 0;
|
||||||
|
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||||
|
try {
|
||||||
|
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||||
|
_found = [_bk, _p];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validPercCache[_vKey] = _found;
|
||||||
|
}
|
||||||
|
if (_validPercCache[_vKey]) {
|
||||||
|
finalBank = _validPercCache[_vKey][0];
|
||||||
|
finalProg = _validPercCache[_vKey][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {}
|
||||||
@@ -507,7 +697,9 @@
|
|||||||
_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);
|
||||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||||
@@ -538,6 +730,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
|
||||||
|
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
|
||||||
|
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
|
||||||
|
// tự động → ngân vô hạn → VU master nhảy dù không play).
|
||||||
|
panic: function () {
|
||||||
|
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||||
|
_scheduledNotes = [];
|
||||||
|
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
|
||||||
|
_noteGeneration++;
|
||||||
|
_pendingNoteOns = {};
|
||||||
|
if (_initialized && _fluidModule) {
|
||||||
|
// 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ó
|
||||||
|
// thể không có trong WASM exports (catch nuốt → notes kẹt).
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
||||||
|
} 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 = {};
|
||||||
|
},
|
||||||
|
|
||||||
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||||
var ctx = getCtx();
|
var ctx = getCtx();
|
||||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||||
@@ -554,7 +785,11 @@
|
|||||||
var oscType = 'triangle';
|
var oscType = 'triangle';
|
||||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||||
var prog = program !== undefined ? parseInt(program) : 0;
|
var prog = program !== undefined ? parseInt(program) : 0;
|
||||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||||
|
// truyền — trước đây override program của track bằng cache channel
|
||||||
|
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||||
|
// nhạc cụ của track TRƯỚC).
|
||||||
|
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||||
prog = _channels[channel].program || prog;
|
prog = _channels[channel].program || prog;
|
||||||
}
|
}
|
||||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||||
@@ -572,7 +807,8 @@
|
|||||||
osc.frequency.setValueAtTime(freq, 0);
|
osc.frequency.setValueAtTime(freq, 0);
|
||||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||||
var durSec = durationMs / 1000;
|
var durSec = durationMs / 1000;
|
||||||
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||||
|
if (isNaN(vel)) vel = 0.8;
|
||||||
var targetGain = vel * volFactor;
|
var targetGain = vel * volFactor;
|
||||||
noteGain.gain.setValueAtTime(0, startAt);
|
noteGain.gain.setValueAtTime(0, startAt);
|
||||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||||
@@ -587,12 +823,38 @@
|
|||||||
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) —
|
||||||
|
// phòng all_notes_off không có trong WASM exports.
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
for (var ch = 0; ch < 16; ch++) {
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -611,6 +873,8 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeOscillatorsByKey = {};
|
||||||
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||||
|
|||||||
@@ -60,11 +60,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
|
let lastGetProjectStateCallback = null;
|
||||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||||
|
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||||
autoSaveTimer = setTimeout(async () => {
|
autoSaveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const state = getProjectStateCallback();
|
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);
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
@@ -76,10 +78,26 @@
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
|
||||||
|
async function flushTempAutoSave() {
|
||||||
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
||||||
|
try {
|
||||||
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
|
const dataJson = JSON.stringify(state);
|
||||||
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
|
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Flush temp project warning:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.SonicStorage = {
|
window.SonicStorage = {
|
||||||
exportProjectToSFS,
|
exportProjectToSFS,
|
||||||
importProjectFromSFSFile,
|
importProjectFromSFSFile,
|
||||||
scheduleTempAutoSave
|
scheduleTempAutoSave,
|
||||||
|
flushTempAutoSave
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||||
+121
-28
@@ -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,30 +12,124 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
celery_app = Celery(
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
"audio_tasks",
|
# Task layer 2 che do:
|
||||||
broker=settings.CELERY_BROKER_URL,
|
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
|
||||||
backend=settings.CELERY_RESULT_BACKEND
|
# - 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
|
||||||
|
|
||||||
celery_app.conf.update(
|
if HAS_CELERY:
|
||||||
task_serializer="json",
|
celery_app = Celery(
|
||||||
accept_content=["json"],
|
"audio_tasks",
|
||||||
result_serializer="json",
|
broker=settings.CELERY_BROKER_URL,
|
||||||
timezone="UTC",
|
backend=settings.CELERY_RESULT_BACKEND
|
||||||
enable_utc=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
celery_app.conf.update(
|
||||||
celery_app.conf.beat_schedule = {
|
task_serializer="json",
|
||||||
"cleanup-expired-files-every-hour": {
|
accept_content=["json"],
|
||||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
result_serializer="json",
|
||||||
"schedule": 3600.0, # Chạy mỗi giờ
|
timezone="UTC",
|
||||||
},
|
enable_utc=True,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
||||||
|
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
|
||||||
|
if os.getenv("SF_DESKTOP") == "1":
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_always_eager=True,
|
||||||
|
task_eager_propagates=True,
|
||||||
|
broker_url="memory://",
|
||||||
|
result_backend="cache+memory://",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Lich trinh tu dong don dep file het han (Week 5) ──
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"cleanup-expired-files-every-hour": {
|
||||||
|
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||||
|
"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):
|
||||||
@@ -44,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"):
|
||||||
"""
|
"""
|
||||||
@@ -68,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")
|
||||||
|
|
||||||
@@ -88,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):
|
||||||
"""
|
"""
|
||||||
@@ -121,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).
|
||||||
@@ -174,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.
|
||||||
@@ -270,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).
|
||||||
@@ -305,7 +398,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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.
|
||||||
|
|||||||
@@ -3,8 +3,27 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>SonicForge Studio - Professional DAW Editor</title>
|
<title>SonicForge Studio - Professional DAW Editor</title>
|
||||||
|
<script>
|
||||||
|
// ── Chặn BROWSER ZOOM toàn trang ──
|
||||||
|
// UI dùng px cố định cho item/button/label — browser zoom (Ctrl+wheel,
|
||||||
|
// Ctrl+plus/minus/0, pinch) scale TOÀN BỘ làm control phóng to theo.
|
||||||
|
// Chặn ở capture phase bằng preventDefault() (KHÔNG stopPropagation —
|
||||||
|
// các vùng zoom chuyên dụng: timeline, piano roll, canvas, EQ vẫn nhận
|
||||||
|
// event và tự xử lý zoom nội dung của chúng).
|
||||||
|
document.addEventListener('wheel', function (e) {
|
||||||
|
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
||||||
|
}, { capture: true, passive: false });
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '_', '0'].indexOf(e.key) !== -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
// Pinch zoom (Safari/WebKit gesture events)
|
||||||
|
document.addEventListener('gesturestart', function (e) { e.preventDefault(); }, { passive: false });
|
||||||
|
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
|
||||||
|
</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>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
@@ -12,19 +31,20 @@
|
|||||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/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=202607271016"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></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>
|
||||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||||
<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=202608031415" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608101800" 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 {
|
||||||
@@ -33,8 +53,43 @@
|
|||||||
--top-bar-height: 80px;
|
--top-bar-height: 80px;
|
||||||
--status-bar-height: 25px;
|
--status-bar-height: 25px;
|
||||||
--panel-border-color: #2a2a2a;
|
--panel-border-color: #2a2a2a;
|
||||||
|
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
|
||||||
|
--sf-bg: #1e1e1e;
|
||||||
|
--sf-panel: #262626;
|
||||||
|
--sf-header: #2e2e2e;
|
||||||
|
--sf-border: #181818;
|
||||||
|
--sf-accent: #00ffcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── THEME presets (Tools → Preferences) ── */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
|
||||||
|
}
|
||||||
|
html[data-theme="midnight"] {
|
||||||
|
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
|
||||||
|
}
|
||||||
|
html[data-theme="forest"] {
|
||||||
|
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
|
||||||
|
}
|
||||||
|
html[data-theme="violet"] {
|
||||||
|
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
|
||||||
|
}
|
||||||
|
html[data-theme="graphite"] {
|
||||||
|
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Áp theme lên các vùng chính của app shell */
|
||||||
|
.daw-app-shell { background-color: var(--sf-bg) !important; }
|
||||||
|
.daw-panel { background-color: var(--sf-panel) !important; }
|
||||||
|
.daw-header { background-color: var(--sf-header) !important; }
|
||||||
|
body { background-color: var(--sf-bg) !important; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
|
||||||
|
|
||||||
|
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
|
||||||
|
html[data-btnfont="sm"] button { font-size: 10px !important; }
|
||||||
|
html[data-btnfont="md"] button { font-size: 12px !important; }
|
||||||
|
html[data-btnfont="lg"] button { font-size: 14px !important; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
color: #c0c0c0;
|
color: #c0c0c0;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fluidsynth
|
||||||
|
print("fluidsynth import successful.")
|
||||||
|
|
||||||
|
fl = fluidsynth.Synth()
|
||||||
|
# Try to load a pre-existing system SF3
|
||||||
|
sf3_path = "/opt/daw_engine/soundfonts/Equinox_Grand_Pianos.sf3"
|
||||||
|
print(f"Checking if {sf3_path} exists: {os.path.exists(sf3_path)}")
|
||||||
|
if os.path.exists(sf3_path):
|
||||||
|
h = fl.sfload(sf3_path)
|
||||||
|
print(f"Loaded {sf3_path}, handle: {h}")
|
||||||
|
else:
|
||||||
|
print("Equinox_Grand_Pianos.sf3 not found.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed: {e}")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Ensure app is in path
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("test_sf_convert")
|
||||||
|
|
||||||
|
from app.core.soundfont_converter import SoundFontConverter
|
||||||
|
|
||||||
|
def test():
|
||||||
|
sf2_dir = "/app/app/storage/soundfonts"
|
||||||
|
sf2_files = [os.path.join(sf2_dir, f) for f in os.listdir(sf2_dir) if f.endswith(".sf2") and "_decomp" not in f]
|
||||||
|
if not sf2_files:
|
||||||
|
logger.error("No SF2 files found in /app/app/storage/soundfonts")
|
||||||
|
return
|
||||||
|
|
||||||
|
sf2_path = sf2_files[0]
|
||||||
|
logger.info(f"Testing with SF2 file: {sf2_path}")
|
||||||
|
|
||||||
|
converter = SoundFontConverter()
|
||||||
|
|
||||||
|
# Check ffmpeg encoder support
|
||||||
|
has_ogg = converter._check_ffmpeg_ogg()
|
||||||
|
logger.info(f"ffmpeg with libvorbis available: {has_ogg}")
|
||||||
|
|
||||||
|
# Convert SF2 -> SF3
|
||||||
|
sf3_path = sf2_path.replace(".sf2", ".sf3")
|
||||||
|
if os.path.exists(sf3_path):
|
||||||
|
os.remove(sf3_path)
|
||||||
|
|
||||||
|
logger.info("Converting SF2 -> SF3...")
|
||||||
|
result_path = converter.convert_sf2_to_sf3(sf2_path)
|
||||||
|
logger.info(f"Result path from convert_sf2_to_sf3: {result_path}")
|
||||||
|
|
||||||
|
if result_path.endswith(".sf3"):
|
||||||
|
logger.info(f"SF3 file exists: {os.path.exists(sf3_path)}")
|
||||||
|
if os.path.exists(sf3_path):
|
||||||
|
logger.info(f"SF3 size: {os.path.getsize(sf3_path)} bytes")
|
||||||
|
# Verify if it plays audio
|
||||||
|
plays = converter._sf3_plays_audio(sf3_path)
|
||||||
|
logger.info(f"SF3 plays audio (pyfluidsynth verify): {plays}")
|
||||||
|
|
||||||
|
# Now test decompression back to SF2
|
||||||
|
decomp_sf2 = sf3_path.replace(".sf3", "_decomp.sf2")
|
||||||
|
if os.path.exists(decomp_sf2):
|
||||||
|
os.remove(decomp_sf2)
|
||||||
|
|
||||||
|
logger.info("Decompressing SF3 -> SF2...")
|
||||||
|
try:
|
||||||
|
decomp_result = converter.sf3_to_sf2(sf3_path, decomp_sf2)
|
||||||
|
logger.info(f"Decompress result path: {decomp_result}")
|
||||||
|
if os.path.exists(decomp_sf2):
|
||||||
|
logger.info(f"Decompressed SF2 size: {os.path.getsize(decomp_sf2)} bytes")
|
||||||
|
# Check if it plays
|
||||||
|
decomp_plays = converter._sf3_plays_audio(decomp_sf2)
|
||||||
|
logger.info(f"Decompressed SF2 plays audio: {decomp_plays}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Decompression failed: {e}", exc_info=True)
|
||||||
|
else:
|
||||||
|
logger.warning("Conversion did not produce an SF3 path.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Rebuild app.precompiled.js from app.jsx using @babel/standalone (avoids the
|
||||||
|
// Babel 8 ESM-only CLI conflict). Mirrors package.json's build script:
|
||||||
|
// babel app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js
|
||||||
|
import * as Babel from '@babel/standalone';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
const src = readFileSync('app/static/js/app.jsx', 'utf8');
|
||||||
|
const out = Babel.transform(src, {
|
||||||
|
presets: ['react'],
|
||||||
|
filename: 'app.jsx',
|
||||||
|
sourceType: 'script',
|
||||||
|
}).code;
|
||||||
|
writeFileSync('app/static/js/app.precompiled.js', out);
|
||||||
|
console.log('BUILD OK', out.length, 'bytes');
|
||||||
Executable
+52
@@ -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"
|
||||||
Executable
+50
@@ -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 ...)"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI)
|
||||||
|
# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# LUU Y: file nay chi dung ky tu ASCII (khong dau, khong em-dash) - PowerShell 5.1
|
||||||
|
# doc .ps1 khong BOM theo ANSI, ky tu Unicode bi hong -> "String is missing terminator".
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
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 =="
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||||
|
|
||||||
|
Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install
|
||||||
|
# Dam bao @babel/standalone co (build.mjs import truc tiep - da gap
|
||||||
|
# ERR_MODULE_NOT_FOUND tren may Windows khi package.json cu thieu dep).
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) {
|
||||||
|
Write-Host "Thieu @babel/standalone - dang cai them..."
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) {
|
||||||
|
Write-Host "ERROR: Khong cai duoc @babel/standalone. Kiem tra ket noi npm!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
node build.mjs # @babel/standalone; thay cho 'npm run build' (Babel 8 ESM-only CLI conflict)
|
||||||
|
|
||||||
|
Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) =="
|
||||||
|
pyinstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
Write-Host "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
|
||||||
|
# Bat loi bundle NGAY tai build (da gap 2 lan: chay pyinstaller tu noi khac
|
||||||
|
# -> static/templates thieu -> exe crash 'Directory ...\app\static does not exist').
|
||||||
|
# Dung tools/verify_bundle.py (parse TOC bang ast, chap nhan ca / va \) thay
|
||||||
|
# vi regex thong thuong (TOC Windows co the dung backslash -> false positive).
|
||||||
|
# Truoc tien kiem tra engine.spec phai la ban moi (collect_data_files).
|
||||||
|
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($specContent -notmatch "collect_data_files\('app'") {
|
||||||
|
Write-Host "ERROR: engine.spec CU (thieu collect_data_files('app')). Pull code moi truoc khi build!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python tools\verify_bundle.py
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERROR: Bundle thieu asset - dung build, kiem tra engine.spec datas!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
|
||||||
|
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
|
||||||
|
# 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 =="
|
||||||
|
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 "== [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
|
||||||
|
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 ""
|
||||||
|
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"
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""SonicForge DAW — Desktop engine launcher (PyInstaller entry point).
|
||||||
|
|
||||||
|
Chay FastAPI backend duoi dang tien trinh nen (sidecar) cho ban cai Standalone
|
||||||
|
Windows (Tauri v2 shell). Tu chon port 8000-8010 (fallback neu bi chiem), tu
|
||||||
|
thoat khi tien trinh cha (Tauri) ket thuc — Graceful Shutdown.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
PORT_RANGE = (8000, 8010)
|
||||||
|
APP_DATA_DIR_NAME = "SonicForgeDAW"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_port():
|
||||||
|
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
# 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).
|
||||||
|
try:
|
||||||
|
s.bind(("127.0.0.1", port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return PORT_RANGE[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_alive(pid):
|
||||||
|
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
|
||||||
|
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
|
||||||
|
# => watchdog se GIET LUON tien trinh cha (Tauri) sau 1s dau tien.
|
||||||
|
# Dung OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION): tra ve NULL khi
|
||||||
|
# pid khong con ton tai.
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||||
|
if not h:
|
||||||
|
return False # tien trinh da chet
|
||||||
|
ctypes.windll.kernel32.CloseHandle(h)
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
|
||||||
|
log_dir = os.path.join(
|
||||||
|
os.environ.get("APPDATA") or os.path.expanduser("~"),
|
||||||
|
APP_DATA_DIR_NAME, "logs",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
log_dir = os.path.expanduser("~")
|
||||||
|
|
||||||
|
# Windowed app (engine.spec console=False): Windows khong tao console ->
|
||||||
|
# sys.stdout/sys.stderr = None -> uvicorn logging crash
|
||||||
|
# ('NoneType' object has no attribute 'isatty'). Tro stdout/stderr vao
|
||||||
|
# FILE (KHONG phai devnull) — neu devnull thi moi exception bi nuot im
|
||||||
|
# lang (trieu chung 'stuck khong logs, khong loi').
|
||||||
|
if sys.stdout is None:
|
||||||
|
sys.stdout = open(os.path.join(log_dir, "stdout.log"), "w", encoding="utf-8")
|
||||||
|
if sys.stderr is None:
|
||||||
|
sys.stderr = open(os.path.join(log_dir, "stderr.log"), "w", encoding="utf-8")
|
||||||
|
|
||||||
|
port = _pick_port()
|
||||||
|
os.environ["SF_PORT"] = str(port)
|
||||||
|
|
||||||
|
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
|
||||||
|
try:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
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,
|
||||||
|
log_level="info", access_log=False)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
parent_pid = os.environ.get("SF_PARENT_PID")
|
||||||
|
if parent_pid:
|
||||||
|
def _watchdog():
|
||||||
|
pid = int(parent_pid)
|
||||||
|
while True:
|
||||||
|
time.sleep(1.0)
|
||||||
|
if not _parent_alive(pid):
|
||||||
|
logging.getLogger("desktop_engine").info(
|
||||||
|
"Parent process exited — shutting down engine")
|
||||||
|
server.should_exit = True
|
||||||
|
return
|
||||||
|
threading.Thread(target=_watchdog, daemon=True).start()
|
||||||
|
|
||||||
|
logging.getLogger("desktop_engine").info(
|
||||||
|
"SonicForge engine listening on 127.0.0.1:%d", port)
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ── Production compose — KHÔNG mount source, image từ registry, bí mật qua .env ──
|
||||||
|
# Dùng: docker compose -f docker-compose.prod.yml up -d
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8000}:8000"
|
||||||
|
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:
|
||||||
|
- sf_uploads:/app/app/storage/uploads
|
||||||
|
- sf_processed:/app/app/storage/processed
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
|
||||||
|
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
|
||||||
|
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- sf_uploads:/app/app/storage/uploads
|
||||||
|
- sf_processed:/app/app/storage/processed
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
|
||||||
|
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
|
||||||
|
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
beat:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sf_uploads:
|
||||||
|
sf_processed:
|
||||||
|
sf_db:
|
||||||
+15
-9
@@ -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
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
|
||||||
|
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
|
||||||
|
|
||||||
|
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
|
||||||
|
binaries = collect_dynamic_libs('pedalboard')
|
||||||
|
binaries += collect_dynamic_libs('soundfile')
|
||||||
|
|
||||||
|
# Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
|
||||||
|
# package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
|
||||||
|
# duoc bundle qua collect_data_files('app').
|
||||||
|
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
||||||
|
|
||||||
|
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
|
||||||
|
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
||||||
|
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
||||||
|
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
||||||
|
# bundle thieu static/templates -> exe crash luc chay. Fix: chen _SPEC_ROOT
|
||||||
|
# vao sys.path TRUOC khi collect de import 'app' luon hoạt dong.
|
||||||
|
import sys as _sys
|
||||||
|
if _SPEC_ROOT not in _sys.path:
|
||||||
|
_sys.path.insert(0, _SPEC_ROOT)
|
||||||
|
|
||||||
|
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
||||||
|
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
|
||||||
|
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
||||||
|
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
||||||
|
# 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.
|
||||||
|
if not datas:
|
||||||
|
print("WARN: collect_data_files('app') tra ve rong - dung datas tinh absolute")
|
||||||
|
datas = [
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'static'), 'app/static'), # js/css/processors
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
|
||||||
|
]
|
||||||
|
datas += [
|
||||||
|
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
|
||||||
|
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
|
||||||
|
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
|
||||||
|
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
|
||||||
|
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
|
||||||
|
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
|
||||||
|
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
|
||||||
|
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
|
||||||
|
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
|
||||||
|
# render_engine, audio_features).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
import importlib.util as _ilu
|
||||||
|
import glob as _glob
|
||||||
|
|
||||||
|
def _scan_pkg_modules(pkg_name: str):
|
||||||
|
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
|
||||||
|
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
|
||||||
|
_spec = _ilu.find_spec(pkg_name)
|
||||||
|
if _spec is None or _spec.origin is None:
|
||||||
|
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.loops',
|
||||||
|
'uvicorn.loops.auto',
|
||||||
|
'uvicorn.protocols',
|
||||||
|
'uvicorn.protocols.http',
|
||||||
|
'uvicorn.protocols.http.auto',
|
||||||
|
'uvicorn.protocols.websockets',
|
||||||
|
'uvicorn.protocols.websockets.auto',
|
||||||
|
'pedalboard',
|
||||||
|
'soundfile',
|
||||||
|
'sf2utils',
|
||||||
|
'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=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=_excludes,
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=None,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='daw_engine',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False, # True khi debug (xem log truc tiep), False cho production
|
||||||
|
# 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).
|
||||||
Generated
+10
@@ -9,6 +9,7 @@
|
|||||||
"@babel/cli": "^8.0.4",
|
"@babel/cli": "^8.0.4",
|
||||||
"@babel/core": "^8.0.1",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/preset-react": "^8.0.1",
|
"@babel/preset-react": "^8.0.1",
|
||||||
|
"@babel/standalone": "^7.29.8",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
@@ -355,6 +356,15 @@
|
|||||||
"@babel/core": "^8.0.0"
|
"@babel/core": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/standalone": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-XgbPNz+u6JzB7cKGnPDoS1U24J5td8yp3HFWbT/6f4/ASZ0PqaQGIvts1o5v5AI+f6i3jdVB/L/o2CYLCv101A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"@babel/cli": "^8.0.4",
|
"@babel/cli": "^8.0.4",
|
||||||
"@babel/core": "^8.0.1",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/preset-react": "^8.0.1",
|
"@babel/preset-react": "^8.0.1",
|
||||||
|
"@babel/standalone": "^7.29.8",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "sonicforge-daw"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "SonicForge DAW desktop shell (Tauri v2 + Python sidecar)"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "sonicforge_daw_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "dialog:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
; NSIS installer hooks — cai Microsoft Visual C++ Redistributable neu thieu
|
||||||
|
; (MSVCP140.dll missing). Dat vc_redist.x64.exe CANH tauri.conf.json
|
||||||
|
; (build_windows.ps1 tu tai ve neu chua co).
|
||||||
|
!macro customInstall
|
||||||
|
File "/oname=$PLUGINSDIR\vc_redist.x64.exe" "vc_redist.x64.exe"
|
||||||
|
ExecWait '"$PLUGINSDIR\vc_redist.x64.exe" /install /quiet /norestart'
|
||||||
|
!macroend
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// SonicForge DAW — desktop shell: spawn/terminate daw_engine sidecar.
|
||||||
|
//
|
||||||
|
// 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::Manager;
|
||||||
|
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||||
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
struct EngineProcess(Mutex<Option<CommandChild>>);
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let res_dir = 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()
|
||||||
|
.command(&engine_exe)
|
||||||
|
.env("SF_PARENT_PID", std::process::id().to_string())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok((_rx, child)) => {
|
||||||
|
app.manage(EngineProcess(Mutex::new(Some(child))));
|
||||||
|
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 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(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
// Terminate sidecar khi DAW window dong — tranh orphan process
|
||||||
|
if let tauri::WindowEvent::Destroyed = event {
|
||||||
|
let child = window
|
||||||
|
.state::<EngineProcess>()
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut lock| lock.take());
|
||||||
|
if let Some(child) = child {
|
||||||
|
let _ = child.kill();
|
||||||
|
println!("daw_engine sidecar terminated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
sonicforge_daw_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "SonicForgeDAW",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"identifier": "com.sonicforge.daw",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "ui",
|
||||||
|
"devUrl": "http://localhost:8000"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Sonic Forge DAW - Professional Desktop Studio",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"devtools": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis", "msi"],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/favicon.ico"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"resources/daw_engine": "daw_engine/"
|
||||||
|
},
|
||||||
|
"externalBin": [],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerHooks": "hooks.nsh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SonicForge DAW</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; background: #12141a; color: #e8eaf0;
|
||||||
|
display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||||
|
#msg { text-align: center; max-width: 480px; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="msg">Đang khởi động SonicForge Engine…</div>
|
||||||
|
<script>
|
||||||
|
// Engine (sidecar) có thể cần vài giây để boot (import librosa/pedalboard).
|
||||||
|
// Thử health-check trên dải port 8000-8010, redirect tới port đầu tiên OK.
|
||||||
|
(async function () {
|
||||||
|
var tries = 0;
|
||||||
|
while (tries < 90) { // ~60s tối đa
|
||||||
|
for (var p = 8000; p <= 8010; p++) {
|
||||||
|
try {
|
||||||
|
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
|
||||||
|
if (r.ok) { location.href = 'http://127.0.0.1:' + p + '/'; return; }
|
||||||
|
} catch (e) { /* port chưa mở */ }
|
||||||
|
}
|
||||||
|
tries++;
|
||||||
|
await new Promise(function (res) { setTimeout(res, 700); });
|
||||||
|
}
|
||||||
|
document.getElementById('msg').textContent =
|
||||||
|
'Không tìm thấy SonicForge Engine (port 8000–8010). Hãy đóng và chạy lại ứng dụng. ' +
|
||||||
|
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Pytest bootstrap: isolate the test suite from the development database.
|
||||||
|
|
||||||
|
Must be imported before any app module (pytest imports conftest.py first), so
|
||||||
|
app.models.user picks up the test DB path instead of the dev DB. Without this,
|
||||||
|
tests that seed/rotate the admin password (test_auth_and_quota) permanently
|
||||||
|
mutate the developer's sonicforge.db.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"SONICFORGE_DB_PATH",
|
||||||
|
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
||||||
|
)
|
||||||
@@ -13,7 +13,17 @@ client = TestClient(app)
|
|||||||
def get_admin_token():
|
def get_admin_token():
|
||||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
|
# Admin is seeded with must_change_password=1; the app blocks music
|
||||||
|
# processing until the first password change. Complete that flow here
|
||||||
|
# (keeping the same password) so feature tests run unblocked.
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": "admin123", "new_password": "admin123"})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,3 +93,55 @@ 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]
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regression tests for legacy project upgrade (items must stay on their tracks
|
||||||
|
with correct bar positions)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.api.v1.projects import upgrade_project_json_if_needed
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_project():
|
||||||
|
return {
|
||||||
|
"id": "legacy_1",
|
||||||
|
"name": "Legacy",
|
||||||
|
"bpm": 120.0, # 1 bar = 2.0s
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 1",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": "abc.wav",
|
||||||
|
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
|
||||||
|
"midiItems": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 2",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": None,
|
||||||
|
"clips": [],
|
||||||
|
"midiItems": [
|
||||||
|
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
|
||||||
|
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_keeps_items_on_their_tracks():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
assert len(tracks) == 2
|
||||||
|
t1_items = tracks[0]["items"]
|
||||||
|
t2_items = tracks[1]["items"]
|
||||||
|
# Items must NOT be merged into the first track
|
||||||
|
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
|
||||||
|
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
|
||||||
|
assert t1_items[0]["id"] == "c1"
|
||||||
|
assert t2_items[0]["id"] == "m1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_uses_bpm_based_seconds_per_bar():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
|
||||||
|
assert tracks[0]["items"][0]["start_bar"] == 1.0
|
||||||
|
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
|
||||||
|
assert tracks[1]["items"][0]["start_bar"] == 2.0
|
||||||
|
assert tracks[1]["items"][0]["duration_bars"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_skips_new_format():
|
||||||
|
data = {"main_session": {"tracks": []}}
|
||||||
|
assert upgrade_project_json_if_needed(data) is data
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Regression tests for security hardening.
|
||||||
|
|
||||||
|
Covers the vulnerabilities found during the 2026-08 audit:
|
||||||
|
- SSRF / open proxy on /api/v1/ai/proxy
|
||||||
|
- path traversal on render output and audio file ids
|
||||||
|
- unauthenticated filesystem access via /api/v1/media/*
|
||||||
|
- hardcoded SECRET_KEY
|
||||||
|
- quota bypass on project update
|
||||||
|
- audio resampling correctness in the render engine
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.config import settings
|
||||||
|
from app.core import auth as core_auth
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_token():
|
||||||
|
# test_auth_and_quota.py may have rotated the admin password; try both.
|
||||||
|
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"]
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": pwd, "new_password": pwd})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers():
|
||||||
|
return {"Authorization": f"Bearer {get_admin_token()}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. SSRF / open proxy ──
|
||||||
|
|
||||||
|
class TestAIProxySSRF:
|
||||||
|
def test_proxy_requires_auth(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", json={"url": "https://api.openai.com/v1", "body": {}})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_proxy_blocks_cloud_metadata(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://169.254.169.254/latest/meta-data/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_blocks_private_ip_not_configured(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://10.0.0.5/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_rejects_non_http_scheme(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "file:///etc/passwd", "body": {}})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_proxy_allows_configured_localhost_provider(self):
|
||||||
|
# localhost:11434 is in the default AI provider list; it must pass the
|
||||||
|
# SSRF check (and then fail to connect in this environment -> 502).
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Path traversal ──
|
||||||
|
|
||||||
|
class TestPathTraversal:
|
||||||
|
def test_render_output_filename_sanitized(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
project = {
|
||||||
|
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||||
|
"main_session": {"length_bars": 1, "tracks": []},
|
||||||
|
"section_store": {},
|
||||||
|
}
|
||||||
|
resp = client.post("/api/v1/plugins/render", headers=auth_headers(),
|
||||||
|
json={"project_json": project, "output_filename": "/tmp/evil_traversal.wav"})
|
||||||
|
# Absolute paths must be reduced to a basename inside PROCESSED_DIR.
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out_path = resp.json()["path"]
|
||||||
|
assert os.path.dirname(out_path) == settings.PROCESSED_DIR
|
||||||
|
assert os.path.basename(out_path) == "evil_traversal.wav"
|
||||||
|
assert os.path.isfile(out_path)
|
||||||
|
|
||||||
|
def test_audio_download_rejects_traversal(self):
|
||||||
|
resp = client.get("/api/v1/audio/download/..%2F..%2Fapp%2Fconfig.py")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_ai_scan_rejects_traversal_file_id(self):
|
||||||
|
resp = client.post("/api/v1/audio/ai-scan",
|
||||||
|
json={"track_id": "1", "file_id": "../../app/config.py"})
|
||||||
|
# Traversal must NOT read the file: falls through to the demo branch.
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Filesystem exposure via media endpoints ──
|
||||||
|
|
||||||
|
class TestMediaAuth:
|
||||||
|
# Use a fresh client (no cookies from earlier logins) to prove 401.
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_client(self):
|
||||||
|
self.fresh = TestClient(app)
|
||||||
|
yield
|
||||||
|
self.fresh.close()
|
||||||
|
|
||||||
|
def test_media_computer_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/computer")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_browse_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/browse", params={"path": "/etc"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_file_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/file", params={"path": "/etc/passwd"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Secret key ──
|
||||||
|
|
||||||
|
class TestSecretKey:
|
||||||
|
def test_secret_key_not_hardcoded_default(self):
|
||||||
|
assert core_auth.SECRET_KEY != "sonicforge_secret_key_super_secure_2026"
|
||||||
|
assert len(core_auth.SECRET_KEY) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Quota enforcement on update ──
|
||||||
|
|
||||||
|
class TestQuotaUpdate:
|
||||||
|
def test_update_cloud_project_enforces_quota(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
# Register a fresh user with a small quota (unique name per run so the
|
||||||
|
# test is re-runnable against a persistent DB).
|
||||||
|
import uuid as _uuid
|
||||||
|
uname = f"quota_user_{_uuid.uuid4().hex[:8]}"
|
||||||
|
resp = client.post("/api/v1/auth/register", json={
|
||||||
|
"username": uname, "email": f"{uname}@studio.com", "password": "quota_pass_123"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
user_token = resp.json()["access_token"]
|
||||||
|
user_headers = {"Authorization": f"Bearer {user_token}"}
|
||||||
|
|
||||||
|
# Shrink quota to 1 MB via admin API.
|
||||||
|
uid = resp.json()["user"]["id"]
|
||||||
|
r = client.put(f"/api/v1/admin/quotas/{uid}", headers=auth_headers(),
|
||||||
|
json={"storage_limit_mb": 1, "max_tracks": 16})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Save a small project.
|
||||||
|
small = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "small", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True, "tracks": []},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.post("/api/v1/projects/cloud", headers=user_headers,
|
||||||
|
json={"name": "small", "data_json": small})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pid = r.json()["project_id"]
|
||||||
|
|
||||||
|
# Updating with a payload over the quota must be rejected (was a bypass).
|
||||||
|
items = [{
|
||||||
|
"type": "AUDIO_ITEM", "id": f"it_{i}", "name": "n",
|
||||||
|
"start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "", "gain": 1.0},
|
||||||
|
} for i in range(20000)]
|
||||||
|
huge = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "huge", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True,
|
||||||
|
"tracks": [{"id": "t", "name": "x", "type": "AUDIO", "items": items}]},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.put(f"/api/v1/projects/cloud/{pid}", headers=user_headers,
|
||||||
|
json={"name": "huge", "data_json": huge})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Quota" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. Render engine: resampling correctness ──
|
||||||
|
|
||||||
|
class TestRenderResample:
|
||||||
|
def test_audio_item_resampled_to_engine_rate(self, tmp_path):
|
||||||
|
from app.core.render_engine import PythonRenderEngine
|
||||||
|
# 44.1kHz source, engine at 22.05kHz -> exactly 2x downsampling.
|
||||||
|
sr_src = 44100
|
||||||
|
t = np.arange(sr_src) / sr_src
|
||||||
|
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||||
|
src_path = os.path.join(settings.UPLOADS_DIR, "resample_test_tone.wav")
|
||||||
|
sf.write(src_path, tone, sr_src)
|
||||||
|
|
||||||
|
engine = PythonRenderEngine(sample_rate=22050)
|
||||||
|
session = {
|
||||||
|
"tracks": [{
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0.0, "pan": 0.0, "mute": False,
|
||||||
|
"items": [{
|
||||||
|
"type": "AUDIO_ITEM",
|
||||||
|
"start_bar": 0.0, "duration_bars": 4.0,
|
||||||
|
"clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "/static/audio/uploads/resample_test_tone.wav", "gain": 1.0},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
buf = engine.render_session_container(session, {}, bpm=120.0, time_sig_num=4,
|
||||||
|
total_samples=engine.sample_rate * 2)
|
||||||
|
# A 1s 440Hz tone must actually render energy (previously the SR
|
||||||
|
# mismatch silently skipped the audio).
|
||||||
|
assert np.max(np.abs(buf)) > 0.01
|
||||||
|
# Duration should be ~1 second at the engine rate, not 2.
|
||||||
|
nonzero = np.where(np.abs(buf[0]) > 1e-4)[0]
|
||||||
|
assert len(nonzero) > 0
|
||||||
|
assert (nonzero[-1] - nonzero[0]) < int(engine.sample_rate * 1.3)
|
||||||
|
try:
|
||||||
|
os.remove(src_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bca1bb5f-b656-48a8-b113-8fff900188ab",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784710097790",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784710143.4476569
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d627f532-b632-4ef0-a21f-80dcc2ac8396",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719724178",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719770.23144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a17fe50b-c51d-408d-b45c-c09645777e68",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719775936",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719821.9856758
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "38a0f344-05dc-47e3-a769-07426c57f5fe",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720540893",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Cartoon Capers Loop.mp3",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720586.9746954
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"name": "Rose",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720555735",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720601.8155787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "525bdab5-e594-4a0c-a3e6-102a0abef816",
|
||||||
|
"name": "Rose (autosave 03/08)",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose (autosave 03/08)",
|
||||||
|
"bpm": 128,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": "sf_DSK_Asian_DreamZ",
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057918746",
|
||||||
|
"name": "MIDI Item",
|
||||||
|
"type": "MIDI_ITEM",
|
||||||
|
"start_bar": 0,
|
||||||
|
"duration_bars": 4,
|
||||||
|
"clip_start_offset_bars": 0,
|
||||||
|
"source_data": {
|
||||||
|
"total_buffer_bars": 4,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": null,
|
||||||
|
"instrument_program": null,
|
||||||
|
"instrument_name": null,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sub_tabs": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057919362",
|
||||||
|
"label": "Piano Roll: MIDI Item",
|
||||||
|
"type": "PIANO_ROLL",
|
||||||
|
"track_id": "1",
|
||||||
|
"target_id": "midi_1785057918746",
|
||||||
|
"parent_tab_id": null,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"duration": 7.5,
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"current_time": 0,
|
||||||
|
"color": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1785058087.45
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Sinh bo icon cho Tauri v2 + PyInstaller (chay 1 lan, co the tai chay).
|
||||||
|
|
||||||
|
Usage: python tools/gen_icons.py
|
||||||
|
Output: src-tauri/icons/{32x32,128x128,128x128@2x}.png, icon.png, icon.ico
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ICONS = os.path.join(ROOT, "src-tauri", "icons")
|
||||||
|
BG = (18, 20, 26, 255) # #12141a — trùng màu nền loader
|
||||||
|
FG = (77, 182, 255, 255) # xanh dương sáng
|
||||||
|
RING = (46, 52, 66, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def _font(size):
|
||||||
|
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"C:/Windows/Fonts/segoeuib.ttf"):
|
||||||
|
if os.path.exists(p):
|
||||||
|
return ImageFont.truetype(p, size)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw(size):
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
# nền bo góc + viền
|
||||||
|
r = size // 8
|
||||||
|
d.rounded_rectangle([0, 0, size - 1, size - 1], radius=r, fill=BG)
|
||||||
|
d.rounded_rectangle([size // 32, size // 32, size - 1 - size // 32, size - 1 - size // 32],
|
||||||
|
radius=max(1, r - size // 32), outline=RING, width=max(2, size // 64))
|
||||||
|
# chữ "SF"
|
||||||
|
font = _font(int(size * 0.42))
|
||||||
|
text = "SF"
|
||||||
|
bbox = d.textbbox((0, 0), text, font=font)
|
||||||
|
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
d.text(((size - w) / 2 - bbox[0], (size - h) / 2 - bbox[1]), text,
|
||||||
|
font=font, fill=FG)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(ICONS, exist_ok=True)
|
||||||
|
sizes = {"32x32.png": 32, "128x128.png": 128, "128x128@2x.png": 256, "icon.png": 512}
|
||||||
|
imgs = []
|
||||||
|
for name, size in sizes.items():
|
||||||
|
img = _draw(size)
|
||||||
|
img.save(os.path.join(ICONS, name))
|
||||||
|
imgs.append((img, size))
|
||||||
|
print("generated", name)
|
||||||
|
# icon.ico: multi-size
|
||||||
|
base = _draw(256)
|
||||||
|
base.save(os.path.join(ICONS, "icon.ico"),
|
||||||
|
sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
|
||||||
|
print("generated icon.ico")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Khôi phục các dự án cloud cũ (từ git history) vào bất kỳ sonicforge.db nào.
|
||||||
|
|
||||||
|
Cách dùng trên máy deployment thật (host game):
|
||||||
|
python3 tools/restore_cloud_projects.py /path/to/sonicforge.db
|
||||||
|
|
||||||
|
Script đọc backup projects (JSON) đã xuất từ git history và chèn vào DB chỉ định,
|
||||||
|
gán tất cả cho user_id được yêu cầu (mặc định: admin đầu tiên tìm thấy).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECTS_BACKUP = os.path.join(os.path.dirname(__file__), "cloud_projects_backup.json")
|
||||||
|
|
||||||
|
|
||||||
|
def main(db_path: str, user_id: str = None):
|
||||||
|
if not os.path.exists(PROJECTS_BACKUP):
|
||||||
|
print(f"Không tìm thấy {PROJECTS_BACKUP}")
|
||||||
|
return 1
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Không tìm thấy DB: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(PROJECTS_BACKUP) as f:
|
||||||
|
projects = json.load(f)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1").fetchone()
|
||||||
|
if not row:
|
||||||
|
print("Không có user admin nào trong DB")
|
||||||
|
conn.close()
|
||||||
|
return 1
|
||||||
|
user_id = row["id"]
|
||||||
|
print(f"Gán tất cả cho admin: {user_id}")
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
for p in projects:
|
||||||
|
if conn.execute("SELECT id FROM projects WHERE id=?", (p["id"],)).fetchone():
|
||||||
|
print(f" bỏ qua (đã tồn tại): {p['name']}")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||||
|
(p["id"], user_id, p["name"], json.dumps(p["data_json"], ensure_ascii=False),
|
||||||
|
len(json.dumps(p["data_json"]).encode("utf-8")), p["updated_at"]))
|
||||||
|
restored += 1
|
||||||
|
print(f" đã khôi phục: {p['name']}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"\nHoàn tất: {restored} dự án đã khôi phục cho user {user_id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 restore_cloud_projects.py <path/to/sonicforge.db> [user_id]")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify PyInstaller bundle TOC chua du app assets (app/static, app/templates).
|
||||||
|
|
||||||
|
Dung boi build_windows.ps1 (buoc 3.5/6) de bat loi bundle NGAY tai build:
|
||||||
|
python tools/verify_bundle.py
|
||||||
|
Exit code 0 = OK, 1 = thieu asset.
|
||||||
|
|
||||||
|
Ly do ton tai: da gap 2 lan exe crash 'Directory ...\\app\\static does not exist'
|
||||||
|
vi chay pyinstaller tu noi khac -> datas relative khong resolve -> bo qua am tham.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REQUIRED = ["app/static", "app/templates"]
|
||||||
|
|
||||||
|
|
||||||
|
def find_toc(build_dir: str) -> str:
|
||||||
|
# PyInstaller 6.x: build/engine/Analysis-00.toc (hoac *-00.toc khac)
|
||||||
|
for pattern in (
|
||||||
|
os.path.join(build_dir, "engine", "Analysis-00.toc"),
|
||||||
|
os.path.join(build_dir, "engine", "*-00.toc"),
|
||||||
|
os.path.join(build_dir, "**", "Analysis-00.toc"),
|
||||||
|
os.path.join(build_dir, "**", "*-00.toc"),
|
||||||
|
):
|
||||||
|
hits = glob.glob(pattern, recursive=True)
|
||||||
|
if hits:
|
||||||
|
return hits[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
build_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build"))
|
||||||
|
toc = find_toc(build_dir)
|
||||||
|
if not toc:
|
||||||
|
print(f"WARN: khong tim thay TOC trong {build_dir} - bo qua verify (tiep tuc build)")
|
||||||
|
return 0
|
||||||
|
print(f"Verify TOC: {toc}")
|
||||||
|
with open(toc, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
raw = f.read()
|
||||||
|
# TOC Windows co the dung backslash (app\\static) — normalize het ve forward
|
||||||
|
# slash de kiem tra khong bi false positive. TOC la Python literal nen
|
||||||
|
# backslash bi DOUBLE-escape ('app\\\\static') — thay 2 lan (\\\\ truoc,
|
||||||
|
# roi \\) de ca 2 dang deu ve '/'.
|
||||||
|
text = raw.replace("\\\\", "/").replace("\\", "/")
|
||||||
|
missing = []
|
||||||
|
for req in REQUIRED:
|
||||||
|
# Tim theo prefix thuc su trong TOC (vd 'app/static/js/...' hoac
|
||||||
|
# string repr 'app/static/...' trong tuple DATA entry).
|
||||||
|
if not re.search(re.escape(req) + r"(?=[/'\"]|$)", text):
|
||||||
|
missing.append(req)
|
||||||
|
if missing:
|
||||||
|
print(f"ERROR: Bundle thieu: {', '.join(missing)}. Kiem tra engine.spec datas!")
|
||||||
|
return 1
|
||||||
|
print("OK: app/static + app/templates co trong bundle.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user