Compare commits
86 Commits
0272912cff
...
standalone
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,25 @@
|
||||
# ── 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
|
||||
@@ -29,3 +29,7 @@ samples/
|
||||
*.log
|
||||
celerybeat-schedule
|
||||
node_modules
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
src-tauri/resources/daw_engine/
|
||||
src-tauri/vc_redist.x64.exe
|
||||
@@ -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).
|
||||
@@ -259,3 +259,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||
"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}
|
||||
|
||||
+127
-5
@@ -16,7 +16,121 @@ router = APIRouter()
|
||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||
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):
|
||||
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
|
||||
_scanner = None
|
||||
@@ -24,20 +138,24 @@ _scanner = None
|
||||
def get_inspector():
|
||||
global _inspector
|
||||
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
|
||||
|
||||
def get_scanner():
|
||||
global _scanner
|
||||
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()
|
||||
return _scanner
|
||||
|
||||
|
||||
@router.get("/available")
|
||||
async def list_plugins(current_user: dict = Depends(get_current_user)):
|
||||
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
|
||||
d = _effective_dirs()
|
||||
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
|
||||
return pm.list_available()
|
||||
|
||||
|
||||
@@ -151,7 +269,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
||||
@router.get("/soundfonts/download/{sf_id}")
|
||||
async def download_soundfont_asset(sf_id: str):
|
||||
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):
|
||||
continue
|
||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||
|
||||
+10
-3
@@ -1,12 +1,19 @@
|
||||
import os
|
||||
from fastapi import APIRouter
|
||||
from celery.result import AsyncResult
|
||||
from app.tasks.worker import celery_app
|
||||
|
||||
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}")
|
||||
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 = {
|
||||
"task_id": task_id,
|
||||
"status": res.status,
|
||||
|
||||
+24
-3
@@ -1,16 +1,37 @@
|
||||
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")
|
||||
|
||||
|
||||
class Settings:
|
||||
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_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 = os.path.dirname(os.path.abspath(__file__))
|
||||
APP_DIR: str = _app_dir()
|
||||
BASE_DIR: str = os.path.dirname(APP_DIR)
|
||||
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")
|
||||
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).
|
||||
VST_DIR: str = os.getenv("VST_DIR", "/opt/daw_engine/vst3")
|
||||
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", "/opt/daw_engine/soundfonts")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -75,13 +75,16 @@ class AIDSPEngine:
|
||||
t_end = min(total_duration, 4.0)
|
||||
|
||||
try:
|
||||
import librosa
|
||||
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform
|
||||
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr)
|
||||
from app.core.audio_features import chroma_stft as _chroma_stft
|
||||
# 1. Compute harmonic structural properties via Chroma (STFT-based,
|
||||
# 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)
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
ssm = cosine_similarity(chroma.T, chroma.T)
|
||||
# thay sklearn.metrics.pairwise.cosine_similarity bang numpy
|
||||
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]
|
||||
hop_length = 512
|
||||
|
||||
+21
-12
@@ -1,19 +1,28 @@
|
||||
import os
|
||||
import json
|
||||
import librosa
|
||||
import numpy as np
|
||||
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:
|
||||
"""
|
||||
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
||||
"""
|
||||
# Load audio
|
||||
y, sr = librosa.load(file_path, sr=None)
|
||||
y, sr = _load(file_path, sr=None)
|
||||
|
||||
# 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
|
||||
if isinstance(tempo, np.ndarray):
|
||||
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
|
||||
bpm = float(tempo)
|
||||
|
||||
# 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)
|
||||
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.
|
||||
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
|
||||
|
||||
# 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):
|
||||
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
||||
else:
|
||||
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)]
|
||||
|
||||
# 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))
|
||||
|
||||
# RMS energy
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
avg_energy = float(np.mean(rms))
|
||||
rms_vals = _rms(y=y)[0]
|
||||
avg_energy = float(np.mean(rms_vals))
|
||||
|
||||
# Zero crossing rate
|
||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
||||
avg_zcr = float(np.mean(zcr))
|
||||
zcr_vals = _zcr(y)[0]
|
||||
avg_zcr = float(np.mean(zcr_vals))
|
||||
|
||||
return {
|
||||
"bpm": round(bpm, 2),
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only).
|
||||
|
||||
Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung
|
||||
ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB).
|
||||
|
||||
Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site:
|
||||
- load() ~ librosa.load (sr=None, mono=True)
|
||||
- frames_to_time() ~ librosa.frames_to_time
|
||||
- beat_track() ~ librosa.beat.beat_track (onset spectral flux
|
||||
+ autocorrelation tempo + adaptive peak picking)
|
||||
- spectral_centroid() ~ librosa.feature.spectral_centroid
|
||||
- rms() ~ librosa.feature.rms
|
||||
- zero_crossing_rate() ~ librosa.feature.zero_crossing_rate
|
||||
- time_stretch() ~ librosa.effects.time_stretch (phase vocoder)
|
||||
- pitch_shift() ~ librosa.effects.pitch_shift
|
||||
- chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based,
|
||||
dung cho fingerprint/similarity, KHONG dung cho
|
||||
hien thi pitch chinh xac)
|
||||
|
||||
Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle.
|
||||
"""
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from scipy import signal as _signal
|
||||
|
||||
__all__ = [
|
||||
"load", "frames_to_time", "beat_track",
|
||||
"spectral_centroid", "rms", "zero_crossing_rate",
|
||||
"time_stretch", "pitch_shift", "chroma_stft",
|
||||
]
|
||||
|
||||
# Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048)
|
||||
HOP_LENGTH = 512
|
||||
N_FFT = 2048
|
||||
WIN_LENGTH = 2048
|
||||
|
||||
|
||||
# ── Load / time ──────────────────────────────────────────────────────────────
|
||||
def load(path, sr=None, mono=True, offset=0.0, duration=None):
|
||||
"""Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel.
|
||||
|
||||
sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None).
|
||||
Neu truyen sr -> resample bang scipy.signal.resample_poly.
|
||||
"""
|
||||
if offset or duration:
|
||||
info = sf.info(path)
|
||||
start = int(offset * info.samplerate) if offset else 0
|
||||
n_frames = int(duration * info.samplerate) if duration else -1
|
||||
data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames)
|
||||
else:
|
||||
data, file_sr = sf.read(path, dtype="float32")
|
||||
|
||||
if data.ndim > 1:
|
||||
if mono:
|
||||
data = data.mean(axis=1)
|
||||
else:
|
||||
data = data.T # (channels, samples) giong librosa
|
||||
|
||||
if sr is not None and sr != file_sr:
|
||||
from fractions import Fraction
|
||||
ratio = Fraction(int(sr), int(file_sr))
|
||||
up, down = ratio.numerator, ratio.denominator
|
||||
data = _signal.resample_poly(data, up, down).astype(np.float32)
|
||||
file_sr = sr
|
||||
|
||||
return data, file_sr
|
||||
|
||||
|
||||
def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None):
|
||||
"""Chuyen frame index sang giay: frames * hop_length / sr (giong librosa)."""
|
||||
return np.asanyarray(frames) * float(hop_length) / float(sr)
|
||||
|
||||
|
||||
# ── Framing / STFT (center=True, reflect pad, giong librosa) ────────────────
|
||||
def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||
"""Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True)."""
|
||||
pad = frame_length // 2
|
||||
yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect")
|
||||
n_frames = 1 + (len(yp) - frame_length) // hop_length
|
||||
if n_frames < 1:
|
||||
n_frames = 1
|
||||
idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :]
|
||||
return yp[idx]
|
||||
|
||||
|
||||
def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH):
|
||||
"""STFT mot phia (rfft) voi cua so hann periodic, reflect pad."""
|
||||
y = np.asarray(y, dtype=np.float64)
|
||||
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||
f, _t, Zxx = _signal.stft(
|
||||
y, fs=1.0, window=window, nperseg=win_length,
|
||||
noverlap=win_length - hop_length, nfft=n_fft,
|
||||
boundary="even", padded=True,
|
||||
)
|
||||
return Zxx
|
||||
|
||||
|
||||
def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
||||
length=None):
|
||||
"""ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity).
|
||||
|
||||
boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True.
|
||||
"""
|
||||
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||
_t, y = _signal.istft(
|
||||
Zxx, fs=1.0, window=window, nperseg=win_length,
|
||||
noverlap=win_length - hop_length, nfft=n_fft,
|
||||
input_onesided=True, boundary=True,
|
||||
)
|
||||
if length is not None and len(y) > length:
|
||||
y = y[:length]
|
||||
return y
|
||||
|
||||
|
||||
# ── Features ─────────────────────────────────────────────────────────────────
|
||||
def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
||||
S=None):
|
||||
"""Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram."""
|
||||
if S is None:
|
||||
S = np.abs(_stft(y, n_fft, hop_length)) ** 2
|
||||
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||
mag = np.abs(S)
|
||||
denom = mag.sum(axis=0)
|
||||
cent = np.divide(
|
||||
np.sum(freqs[:, None] * mag, axis=0), denom,
|
||||
out=np.zeros_like(denom), where=denom > 1e-10,
|
||||
)
|
||||
return cent[None, :]
|
||||
|
||||
|
||||
def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None):
|
||||
"""RMS nang luong moi frame - (1, n_frames)."""
|
||||
if S is not None:
|
||||
frames = S # caller truyen power spectrogram
|
||||
else:
|
||||
frames = _frame(y, frame_length, hop_length)
|
||||
return np.sqrt(np.mean(frames ** 2, axis=0))[None, :]
|
||||
|
||||
|
||||
def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||
"""Ti le zero-crossing moi frame - (1, n_frames)."""
|
||||
frames = _frame(y, frame_length, hop_length)
|
||||
signs = np.signbit(frames).astype(np.int8)
|
||||
zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0)
|
||||
return zcr[None, :]
|
||||
|
||||
|
||||
def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH):
|
||||
"""Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding).
|
||||
|
||||
Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi
|
||||
cosine_similarity trong ai_dsp_engine.
|
||||
"""
|
||||
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||
# Chi giu bin <= 5kHz (tranh nhieu alias o high freq)
|
||||
keep = freqs <= 5000.0
|
||||
freqs = freqs[keep]
|
||||
mag = mag[keep]
|
||||
# note number -> pitch class
|
||||
note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0
|
||||
pc = np.mod(np.round(note).astype(int), 12)
|
||||
chroma = np.zeros((12, mag.shape[1]), dtype=np.float64)
|
||||
np.add.at(chroma, pc, mag)
|
||||
# L2 normalize tung frame (giong librosa)
|
||||
norms = np.linalg.norm(chroma, axis=0)
|
||||
chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10)
|
||||
return chroma
|
||||
|
||||
|
||||
# ── Onset / tempo / beat (thay librosa.beat) ─────────────────────────────────
|
||||
def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT):
|
||||
"""Onset envelope: spectral flux (log-magnitude diff, chi chieu duong)."""
|
||||
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||
logmag = np.log1p(1000.0 * mag)
|
||||
flux = np.diff(logmag, axis=1)
|
||||
onset = np.maximum(flux, 0.0).sum(axis=0)
|
||||
if onset.size == 0:
|
||||
return onset
|
||||
# Tru moving-average ~1s de loai trend (giong librosa detrend)
|
||||
win = max(1, int(round(1.0 * sr / hop_length)))
|
||||
if len(onset) >= win:
|
||||
kernel = np.ones(win) / win
|
||||
ma = np.convolve(onset, kernel, mode="same")
|
||||
onset = np.maximum(onset - ma, 0.0)
|
||||
return onset
|
||||
|
||||
|
||||
def _autocorr(x):
|
||||
"""Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1."""
|
||||
n = len(x)
|
||||
if n < 2:
|
||||
return np.ones(n)
|
||||
x = x - x.mean()
|
||||
nfft = 2 ** int(np.ceil(np.log2(2 * n)))
|
||||
X = np.fft.rfft(x, nfft)
|
||||
r = np.fft.irfft(X * np.conj(X), nfft)[:n]
|
||||
denom = np.maximum(n - np.arange(n), 1)
|
||||
r = r / denom
|
||||
r0 = r[0] if r[0] != 0 else 1.0
|
||||
return r / r0
|
||||
|
||||
|
||||
def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0),
|
||||
start_bpm=120.0):
|
||||
"""Uoc luong BPM bang autocorrelation cua onset envelope.
|
||||
|
||||
Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de
|
||||
chon dung octave (tranh roi vao nua/double tempo khi autocorrelation
|
||||
bi mo ho giua cac harmonic).
|
||||
"""
|
||||
if len(onset) < 4:
|
||||
return float(start_bpm)
|
||||
min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length)))
|
||||
max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length)))
|
||||
if max_lag <= min_lag or max_lag >= len(onset):
|
||||
return float(start_bpm)
|
||||
ac = _autocorr(onset)
|
||||
lags = np.arange(min_lag, max_lag + 1)
|
||||
tempi = 60.0 * sr / (hop_length * lags)
|
||||
# prior rong ~0.7 octave quanh start_bpm (log2 scale)
|
||||
prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2)
|
||||
seg = ac[lags] * prior
|
||||
best = lags[int(np.argmax(seg))]
|
||||
tempo = 60.0 * sr / (hop_length * best)
|
||||
# Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi
|
||||
if tempo > 200.0 and best * 2 <= max_lag:
|
||||
tempo = 60.0 * sr / (hop_length * best * 2)
|
||||
return float(tempo)
|
||||
|
||||
|
||||
def _localmax(x):
|
||||
"""Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can)."""
|
||||
n = len(x)
|
||||
if n < 3:
|
||||
return np.zeros(n, dtype=bool)
|
||||
out = np.zeros(n, dtype=bool)
|
||||
out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:])
|
||||
return out
|
||||
|
||||
|
||||
def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0):
|
||||
"""Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid."""
|
||||
n = len(onset)
|
||||
if n == 0:
|
||||
return np.array([], dtype=int)
|
||||
period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat
|
||||
win = max(1, int(round(period)))
|
||||
kernel = np.ones(win) / win
|
||||
ma = np.convolve(onset, kernel, mode="same")
|
||||
thresh = 1.25 * ma + 1e-9
|
||||
|
||||
cand = np.where(_localmax(onset) & (onset >= thresh))[0]
|
||||
if cand.size == 0:
|
||||
cand = np.where(_localmax(onset))[0]
|
||||
if cand.size == 0:
|
||||
cand = np.arange(0, n, max(1, int(round(period))))
|
||||
|
||||
beats = [int(cand[0])]
|
||||
while True:
|
||||
expected = beats[-1] + period
|
||||
if expected >= n:
|
||||
break
|
||||
lo, hi = expected - 0.45 * period, expected + 0.45 * period
|
||||
in_win = cand[(cand >= lo) & (cand <= hi)]
|
||||
if in_win.size == 0:
|
||||
nxt = int(round(expected))
|
||||
if nxt >= n:
|
||||
break
|
||||
beats.append(nxt)
|
||||
else:
|
||||
beats.append(int(in_win[np.argmin(np.abs(in_win - expected))]))
|
||||
# Chong beat kep (khoang cach < 0.5 period)
|
||||
if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period:
|
||||
beats.pop()
|
||||
continue
|
||||
if len(beats) > 2000:
|
||||
break
|
||||
return np.array(beats, dtype=int)
|
||||
|
||||
|
||||
def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0,
|
||||
tightness=100):
|
||||
"""Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int).
|
||||
|
||||
Tempo bang autocorrelation onset; beats bang peak-picking thich nghi.
|
||||
Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site
|
||||
(analyzer xu ly ca scalar lan ndarray).
|
||||
"""
|
||||
onset = _onset_strength(y, sr, hop_length)
|
||||
tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm)
|
||||
beats = _beat_frames(onset, sr, hop_length, tempo)
|
||||
return tempo, beats
|
||||
|
||||
|
||||
# ── Effects (thay librosa.effects) ───────────────────────────────────────────
|
||||
def _phase_vocoder(D, rate, hop_length=HOP_LENGTH):
|
||||
"""Phase vocoder time-stretch kinh dien (DAFX/Puckette).
|
||||
|
||||
D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon).
|
||||
Tra ve STFT da stretch voi so frame ~ n_frames / rate.
|
||||
"""
|
||||
n_freq, n_frames = D.shape
|
||||
if rate <= 0:
|
||||
raise ValueError("rate phai > 0")
|
||||
if rate == 1.0:
|
||||
return D
|
||||
time_steps = np.arange(0, n_frames, rate, dtype=float)
|
||||
n_out = len(time_steps)
|
||||
if n_out == 0:
|
||||
return D[:, :0]
|
||||
out = np.zeros((n_freq, n_out), dtype=np.complex128)
|
||||
# Phase advance moi hop cua tung bin tan so
|
||||
phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq)
|
||||
mag = np.abs(D)
|
||||
phase_acc = np.angle(D[:, 0])
|
||||
for t, step in enumerate(time_steps):
|
||||
idx = int(step)
|
||||
if idx >= n_frames:
|
||||
break
|
||||
if idx + 1 >= n_frames:
|
||||
out[:, t] = mag[:, idx] * np.exp(1j * phase_acc)
|
||||
break
|
||||
# Phase difference that giua 2 frame lien tiep (true frequency)
|
||||
dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv
|
||||
dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi))
|
||||
phase_acc = phase_acc + phase_adv + dphase
|
||||
out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc)
|
||||
return out
|
||||
|
||||
|
||||
def time_stretch(y, rate, **kwargs):
|
||||
"""Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon."""
|
||||
if rate <= 0:
|
||||
raise ValueError("rate phai > 0")
|
||||
if rate == 1.0:
|
||||
return np.asarray(y, dtype=np.float32)
|
||||
y = np.asarray(y, dtype=np.float64)
|
||||
D = _stft(y)
|
||||
D_stretch = _phase_vocoder(D, rate)
|
||||
y_out = _istft(D_stretch)
|
||||
# Cat ve dung do dai ky vong: len(y) / rate
|
||||
target = int(round(len(y) / rate))
|
||||
if len(y_out) > target:
|
||||
y_out = y_out[:target]
|
||||
return y_out.astype(np.float32)
|
||||
|
||||
|
||||
def pitch_shift(y, sr=22050, n_steps=1, **kwargs):
|
||||
"""Dich pitch n semitone (positive = cao hon), giu nguyen duration.
|
||||
|
||||
Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample
|
||||
nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen.
|
||||
"""
|
||||
if n_steps == 0:
|
||||
return np.asarray(y, dtype=np.float32)
|
||||
rate = 2.0 ** (-float(n_steps) / 12.0)
|
||||
y_shift = time_stretch(y, rate)
|
||||
# Resample (FFT) ve dung do dai goc: factor = rate
|
||||
target = int(round(len(y_shift) * rate))
|
||||
if target != len(y_shift) and target > 0:
|
||||
y_shift = _signal.resample(y_shift, target)
|
||||
return np.asarray(y_shift, dtype=np.float32)
|
||||
@@ -1,6 +1,6 @@
|
||||
import numpy as np
|
||||
import librosa
|
||||
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:
|
||||
"""
|
||||
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
|
||||
"""
|
||||
try:
|
||||
# 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)
|
||||
except Exception as 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}
|
||||
"""
|
||||
# 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)
|
||||
duration = float(total_samples) / sr
|
||||
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
|
||||
Returns:
|
||||
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)
|
||||
duration = float(total_samples) / sr
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import os, logging, math
|
||||
import numpy as np
|
||||
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.core.vst_engine import (
|
||||
render_midi_events_to_audio,
|
||||
@@ -385,7 +386,8 @@ class PythonRenderEngine:
|
||||
for ch in range(2):
|
||||
ir = ir_l if ch == 0 else ir_r
|
||||
# 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
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ def _file_sig(path: str) -> tuple:
|
||||
|
||||
|
||||
class SoundFontAutoScanner:
|
||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
|
||||
self.system_sf_dir = system_sf_dir
|
||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_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._catalog = {}
|
||||
self._lock = threading.Lock()
|
||||
@@ -50,6 +53,15 @@ class SoundFontAutoScanner:
|
||||
out.append((fname, os.path.join(directory, fname)))
|
||||
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:
|
||||
if fname.lower().endswith(".sf2"):
|
||||
sf_info = inspector.inspect_sf2_file(full) or {}
|
||||
@@ -68,9 +80,9 @@ class SoundFontAutoScanner:
|
||||
|
||||
def scan_once(self) -> bool:
|
||||
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
|
||||
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):
|
||||
dirs.append((self.upload_sf_dir, "upload"))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import numpy as np
|
||||
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:
|
||||
@staticmethod
|
||||
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
|
||||
return y
|
||||
|
||||
if preserve_pitch:
|
||||
return librosa.effects.time_stretch(y, rate=speed_ratio)
|
||||
return _time_stretch(y, rate=speed_ratio)
|
||||
else:
|
||||
num_samples_new = int(len(y) / speed_ratio)
|
||||
return signal.resample(y, num_samples_new)
|
||||
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
|
||||
"""
|
||||
if n_steps == 0:
|
||||
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
|
||||
def merge_back_to_parent(
|
||||
|
||||
+30
-22
@@ -49,27 +49,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
|
||||
out_r /= max_peak
|
||||
return np.vstack([out_l, out_r])
|
||||
|
||||
def check_pedalboard_safe():
|
||||
import subprocess, sys
|
||||
def _module_available(name: str) -> bool:
|
||||
"""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:
|
||||
res = subprocess.run(
|
||||
[sys.executable, "-c", "import pedalboard"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
||||
)
|
||||
return res.returncode == 0
|
||||
except Exception:
|
||||
return importlib.util.find_spec(name) is not None
|
||||
except (ImportError, AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def check_pedalboard_safe():
|
||||
return _module_available("pedalboard")
|
||||
|
||||
|
||||
def check_pyfluidsynth_safe():
|
||||
import subprocess, sys
|
||||
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
|
||||
return _module_available("fluidsynth")
|
||||
|
||||
HAS_PEDALBOARD = check_pedalboard_safe()
|
||||
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
||||
@@ -99,8 +101,13 @@ _PLUGIN_MANAGER_INSTANCE = None
|
||||
_PLUGIN_MANAGER_ARGS = None
|
||||
_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":
|
||||
"""Singleton: reuse PluginManager when args match, else create new."""
|
||||
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)."""
|
||||
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"
|
||||
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
||||
args = (vst_dir, sf_dir, upload_sf_dir)
|
||||
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
||||
@@ -155,9 +162,10 @@ def release_soundfont(path: str):
|
||||
_FLUID_CACHE[path] = (fl, ref - 1)
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
|
||||
self.vst_dir = vst_dir
|
||||
self.sf_dir = sf_dir
|
||||
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None):
|
||||
from app.config import settings as _st
|
||||
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._sf_scan_cache = None # cache for _scan_soundfonts()
|
||||
|
||||
|
||||
+31
-2
@@ -58,7 +58,27 @@ app.add_middleware(
|
||||
# Mount storage directory (must come before general /static mount)
|
||||
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
||||
# 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")
|
||||
|
||||
# Include routers
|
||||
@@ -75,13 +95,22 @@ app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html")
|
||||
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)
|
||||
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")
|
||||
|
||||
+3092
-438
File diff suppressed because it is too large
Load Diff
+708
-154
File diff suppressed because one or more lines are too long
@@ -64,6 +64,13 @@ 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 }) }),
|
||||
|
||||
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' }),
|
||||
// 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' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
let _activeOscillators = {};
|
||||
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 _loadPromises = {};
|
||||
let _sfloadSeq = 0;
|
||||
@@ -32,7 +35,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination));
|
||||
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return _audioCtx;
|
||||
}
|
||||
@@ -41,7 +45,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = ctx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination));
|
||||
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -54,7 +59,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = window.__sharedAudioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(_pendingOutputDestination || window.__sharedAudioCtx.destination);
|
||||
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return window.__sharedAudioCtx;
|
||||
};
|
||||
@@ -68,10 +74,26 @@
|
||||
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(node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination)));
|
||||
_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);
|
||||
@@ -101,7 +123,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(_pendingOutputDestination || (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);
|
||||
@@ -137,7 +160,7 @@
|
||||
// expected notice when a soundfont simply has no
|
||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -188,17 +211,31 @@
|
||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||
var lp = _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) {
|
||||
var left = e.outputBuffer.getChannelData(0);
|
||||
var right = e.outputBuffer.getChannelData(1);
|
||||
var sz = left.length;
|
||||
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);
|
||||
var hf = _fluidModule.HEAPF32;
|
||||
var lpb = lp >> 2, rpb = rp >> 2;
|
||||
for (var si = 0; si < sz; si++) {
|
||||
left[si] = hf[lpb + si];
|
||||
right[si] = hf[rpb + si];
|
||||
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||
// 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) {}
|
||||
};
|
||||
@@ -269,6 +306,12 @@
|
||||
|
||||
_doLoadSoundFont: async function (sfId) {
|
||||
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 buf = cache ? await cache.getBuffer(sfId) : null;
|
||||
if (buf) {
|
||||
@@ -289,8 +332,15 @@
|
||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||
return false;
|
||||
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||
// 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();
|
||||
if (cache) await cache.saveBuffer(sfId, buf);
|
||||
@@ -449,10 +499,11 @@
|
||||
},
|
||||
|
||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
||||
)));
|
||||
var parsedPitch = parseInt(note);
|
||||
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||
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 usedBank = 0, usedProg = 0;
|
||||
if (synthEngine) {
|
||||
@@ -485,8 +536,10 @@
|
||||
var self = this;
|
||||
var doNote = function () {
|
||||
try {
|
||||
var finalBank = usedBank;
|
||||
var finalProg = usedProg;
|
||||
var finalBank = parseInt(usedBank);
|
||||
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 cachedCh = _channels[ch];
|
||||
// The note's own synth engine (track instrument) is
|
||||
@@ -495,16 +548,32 @@
|
||||
// re-picked instrument plays the wrong soundfont. Without an
|
||||
// engine, fall back to the soundfont configured on the channel.
|
||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||
finalBank = cachedCh.bank;
|
||||
finalProg = cachedCh.program;
|
||||
finalBank = parseInt(cachedCh.bank) || 0;
|
||||
finalProg = parseInt(cachedCh.program) || 0;
|
||||
finalSfId = cachedCh.sfId;
|
||||
}
|
||||
// Ensure the soundfont is actually loaded before the note plays.
|
||||
// Quick instrument pick on a track does not pre-load it, so load
|
||||
// lazily here and retry the note once the font is ready.
|
||||
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);
|
||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||
if (ok) doNote();
|
||||
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;
|
||||
}
|
||||
@@ -512,12 +581,43 @@
|
||||
// instrument for each item regardless of processing order.
|
||||
// Skip if the channel already has this exact instrument (avoids
|
||||
// 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
|
||||
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
|
||||
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||
if (sfHandle !== undefined) {
|
||||
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);
|
||||
} catch (e) {}
|
||||
} else {
|
||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||
@@ -528,6 +628,7 @@
|
||||
_channels[ch].program = finalProg;
|
||||
_channels[ch].sfId = finalSfId;
|
||||
}
|
||||
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||
@@ -558,6 +659,31 @@
|
||||
}
|
||||
},
|
||||
|
||||
// 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 = [];
|
||||
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) {}
|
||||
}
|
||||
_activeNotes = {};
|
||||
},
|
||||
|
||||
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||
var ctx = getCtx();
|
||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||
@@ -574,7 +700,11 @@
|
||||
var oscType = 'triangle';
|
||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||
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;
|
||||
}
|
||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||
@@ -592,7 +722,8 @@
|
||||
osc.frequency.setValueAtTime(freq, 0);
|
||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||
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;
|
||||
noteGain.gain.setValueAtTime(0, startAt);
|
||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||
@@ -613,6 +744,15 @@
|
||||
|
||||
stopAll: function () {
|
||||
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++) {
|
||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||
}
|
||||
@@ -631,6 +771,7 @@
|
||||
} catch (e) {}
|
||||
});
|
||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||
_activeNotes = {};
|
||||
},
|
||||
|
||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||
|
||||
+121
-28
@@ -3,7 +3,6 @@ import uuid
|
||||
import time
|
||||
import glob
|
||||
import logging
|
||||
from celery import Celery
|
||||
from app.config import settings
|
||||
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
||||
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__)
|
||||
|
||||
celery_app = Celery(
|
||||
"audio_tasks",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Task layer 2 che do:
|
||||
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
|
||||
# - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
|
||||
# (thread nen + registry dict), API contract GIONG het (.delay() tra
|
||||
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
from celery import Celery
|
||||
HAS_CELERY = True
|
||||
except Exception: # pragma: no cover - frozen desktop slim build
|
||||
Celery = None
|
||||
HAS_CELERY = False
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
)
|
||||
if HAS_CELERY:
|
||||
celery_app = Celery(
|
||||
"audio_tasks",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND
|
||||
)
|
||||
|
||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
||||
celery_app.conf.beat_schedule = {
|
||||
"cleanup-expired-files-every-hour": {
|
||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||
"schedule": 3600.0, # Chạy mỗi giờ
|
||||
},
|
||||
}
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
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):
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
if not os.path.exists(file_path):
|
||||
@@ -44,7 +137,7 @@ def analyze_audio_task(file_id: str):
|
||||
return analyze_audio(file_path)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
@_task
|
||||
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
||||
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):
|
||||
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",
|
||||
sample_rate: int = 44100, bit_depth: int = 16):
|
||||
"""
|
||||
@@ -121,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task
|
||||
@_task
|
||||
def mix_multitrack_task(session_config: dict):
|
||||
"""
|
||||
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||
@@ -174,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task
|
||||
@_task
|
||||
def process_multitrack_session_task(session_config: dict):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@celery_app.task
|
||||
@_task
|
||||
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).
|
||||
@@ -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):
|
||||
"""
|
||||
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>
|
||||
<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>
|
||||
<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">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
@@ -12,11 +31,11 @@
|
||||
<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="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/api.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/api.js?v=202608091200"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608031400"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
@@ -24,7 +43,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608038600" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608091200" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
@@ -33,8 +52,43 @@
|
||||
--top-bar-height: 80px;
|
||||
--status-bar-height: 25px;
|
||||
--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 {
|
||||
background-color: #1a1a1a;
|
||||
color: #c0c0c0;
|
||||
|
||||
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,58 @@
|
||||
# ── 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
|
||||
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"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=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:
|
||||
- redis
|
||||
|
||||
@@ -25,13 +27,15 @@ services:
|
||||
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=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:
|
||||
- redis
|
||||
|
||||
@@ -40,12 +44,14 @@ services:
|
||||
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
||||
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=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:
|
||||
- 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',
|
||||
)
|
||||
Generated
+10
@@ -9,6 +9,7 @@
|
||||
"@babel/cli": "^8.0.4",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/preset-react": "^8.0.1",
|
||||
"@babel/standalone": "^7.29.8",
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
@@ -355,6 +356,15 @@
|
||||
"@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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@babel/cli": "^8.0.4",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/preset-react": "^8.0.1",
|
||||
"@babel/standalone": "^7.29.8",
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^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,177 @@
|
||||
// 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_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)));
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -93,3 +93,55 @@ class TestPluginAPI:
|
||||
assert data["size_bytes"] == len(valid_content)
|
||||
elif resp.status_code == 403:
|
||||
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,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,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