82 Commits

Author SHA1 Message Date
3dtours 1191e46ee5 fix: spawn.log ghi vao %APPDATA%/SonicForgeDAW/logs (thu muc luon ton tai) — engine_dir co the khong duoc bundle vao installer 2026-08-09 09:15:01 +00:00
3dtours 63af8ec414 fix: khong panic khi spawn engine fail (UI khong thoat) + spawn.log chan doan (path + exists) + giu SF_PARENT_PID 2026-08-09 08:51:21 +00:00
3dtours 6e962dc1b2 FIX: _pick_port bo SO_REUSEADDR — Windows cho phep bind trung port -> 2 engine cung 8000 -> UI loi ngau nhien 2026-08-09 08:50:08 +00:00
3dtours 6eef16edc0 fix: targets = ['msi'] — makensis 32-bit loi mmapping datablock voi bundle engine lon (RAM 32GB van fail); MSI (WiX 64-bit) build thanh cong 2026-08-09 08:28:15 +00:00
3dtours cd2547ef6a revert: compression zstd khong co trong schema nsis cua Tauri version hien tai 2026-08-09 05:08:53 +00:00
3dtours c538cab745 fix: NSIS compression zstd thay LZMA — LZMA solid can nhieu RAM/pagefile -> makensis 32-bit loi mmapping datablock (#12345) voi bundle engine lon 2026-08-09 05:03:54 +00:00
3dtours 90493c73f3 fix: spawn engine qua app.shell().command() (ShellExt) — process::Command::new la private trong tauri-plugin-shell v2 2026-08-09 04:39:15 +00:00
3dtours a3359aa0ed fix: resources dung directory path thuan 'resources/daw_engine' (Tauri v2 copy recursive, glob ** khong dang tin — da fail 3 lan) 2026-08-09 04:34:01 +00:00
3dtours a7489f41e6 fix: resources glob rong resources/daw_engine/** — match ca 2 cau truc onedir (PyInstaller 6.x co _internal, 5.x khong) 2026-08-09 04:26:22 +00:00
3dtours 194c9b52b2 fix: resources glob liet ke file cu the (exe + README.txt luon co) + _internal/** — khong phu thuoc glob rong 2026-08-09 04:24:11 +00:00
3dtours 77505fbc86 fix: build_windows.ps1 ASCII-only lan 2 (em-dash) 2026-08-09 04:17:40 +00:00
3dtours a61f9abd6a fix: build_windows.ps1 ASCII-only (PS 5.1) 2026-08-09 04:17:22 +00:00
3dtours a7163efaf1 fix: build_windows.ps1 guard resources/daw_engine (exe + _internal) truoc tauri build — glob fail neu thieu 2026-08-09 04:17:09 +00:00
3dtours 2462bbc1a8 fix: tauri resources glob tach rieng — resources/daw_engine/* (file 1 level) + _internal/** (subdirs) thay ** rong (co the khong match file truc tiep) 2026-08-09 04:16:49 +00:00
3dtours 1f1017d78a fix: thay .gitkeep bang README.txt (glob ** co the bo qua dotfile) trong resources/daw_engine 2026-08-09 03:46:08 +00:00
3dtours f17bed4e5f fix: placeholder .gitkeep trong resources/daw_engine de tauri.conf glob resources/daw_engine/** khong loi 'path not found' khi chua chay buoc copy engine 2026-08-09 03:44:50 +00:00
3dtours bc6bc71d0e chore: gitignore src-tauri/resources/daw_engine (build script tao) 2026-08-09 03:31:24 +00:00
3dtours bc8431fe81 FIX 3 van de Windows:
1. LOAD CHAM: PyInstaller onefile 723MB giai nen toan bo vao %TEMP% moi lan chay -> chuyen ONEDIR (engine.spec COLLECT) + bundle qua Tauri resources (resource_dir/daw_engine/daw_engine.exe) thay externalBin — khoi dong tuc thi khong giai nen
2. WINDOW PICKER: Tauri v2 khong co window.__TAURI__.dialog — goi invoke('plugin:dialog|open', {options:{directory:true}}) qua __TAURI__.core.invoke
3. ICON exe: bundle.icon chuyen icon.ico -> favicon.ico + thay 3 PNG bang favicon render
- build_windows.ps1 buoc 4: copy onedir dist/daw_engine -> src-tauri/resources/daw_engine
2026-08-09 03:30:57 +00:00
3dtours 466bf25a0b FIX: build_windows.ps1 buoc [0/6] kill daw_engine.exe cu dang chay — file exe bi khoa -> Tauri build PermissionDenied externalBin 2026-08-09 03:12:00 +00:00
3dtours 52e1dc6eda FEAT: icon exe = favicon cua app — favicon.svg -> PNG -> ICO (6 sizes 16-128)
- engine.spec: icon='src-tauri/icons/favicon.ico' (cung nguon voi icon hien thi trong app, truoc day dung icon.ico mac dinh khac favicon)
- tools/gen_favicon_ico.js: script tai sinh ICO tu app/templates/favicon.svg (resvg-js WASM, khong can libcairo)
- Verify: ICO 6 sizes (16/24/32/48/64/128), 571 mau, binary chay OK
2026-08-09 02:59:11 +00:00
3dtours 30a40b2bca FEAT: Plugins Manager - danh sach thu muc da muc (Add Directory + x xoa), scan phan loai rieng re VST/SoundFont theo dir
- Backend: plugin_dirs (list) thay vst_dir/soundfont_dir rieng — luu plugin_dirs.json, backward compat gop field cu; scan walk tung dir phan loai .vst3/.dll/.so -> vst_found, .sf2/.sf3 -> soundfonts (kem dir goc)
- SoundFontAutoScanner: system_sf_dirs (list) — scan tat ca thu muc user khai bao
- Frontend: PluginManagerModal — nut Add Directory (Tauri dialog / prompt fallback), moi dir co nut x xoa, nut Scan -> hien danh sach rieng re VST Instruments + SoundFonts (kem duong dan thu muc)
- Tests: +2 (save/get/scan phan loai, xoa dir)
2026-08-09 02:52:10 +00:00
3dtours 8dd00cc2ea FEAT: Plugin Manager folder picker + scan dirs (Windows/macOS), docker .env paths, status bar adaptive tips, DSP Tool vao sub-tab audioclip
- Plugins Manager (Tools menu): Browse folder (Tauri dialog + fallback paste path), Save & Scan VST/SoundFont dirs -> plugin_dirs.json (user override, env la base)
- Docker: VST_DIR/SOUNDFONT_DIR/PIANOBK_DIR tu .env/docker-compose mount vao container + env cho engine/celery
- Status bar: bo label 'Scroll: Zoom' -> Adaptive tips (prHint + fallback text)
- DSP Tool: move vao SUB-TAB editor audioclip (Phase Inv / Swap L/R / Reverse + apply vung chon/ca clip)
- Tauri: them tauri-plugin-dialog + dialog:default permission cho folder picker
2026-08-09 02:44:29 +00:00
3dtours 3bd0989031 FIX: windowed stderr -> file log (khong devnull) de khong nuot im loi; watchdog Windows dung OpenProcess thay os.kill(pid,0) (TerminateProcess giet cha) 2026-08-09 01:40:20 +00:00
3dtours dccf4f45ef FIX: windowed app (console=False) sys.stderr=None -> uvicorn logging crash isatty — thay bang devnull 2026-08-09 00:58:22 +00:00
3dtours 9099529403 FIX: engine.spec dung os thay _os (NameError luc build) 2026-08-08 15:56:34 +00:00
3dtours 3795ea9d78 FIX: scipy hiddenimports scan FILESYSTEM (glob .py/.pyd/.so) thay collect_submodules - walk_packages bo qua am tham subpackage import loi luc build (mat ca cay scipy.sparse.csgraph._shortest_path tren may user) 2026-08-08 15:55:08 +00:00
3dtours fa24c61bbf FIX: hiddenimport tinh scipy.stats._ansari_swilk_statistics - phong collect_submodules miss (scipy 1.18+ _morestats import top-level) 2026-08-08 15:07:37 +00:00
3dtours 83427fe503 FIX: verify_bundle.py false-positive Windows - TOC Python literal double-escape backslash (app\\static -> app//static), normalize 2 buoc 2026-08-08 14:50:07 +00:00
3dtours cef2d6666b FIX (lan 3 - goc root): chen _SPEC_ROOT vao sys.path truoc collect_data_files('app') — 'pyinstaller' entry point khong them CWD vao sys.path 2026-08-08 14:37:38 +00:00
3dtours bfe9e47db1 FIX (lan 2): build_windows.ps1 tu cai @babel/standalone neu thieu (ERR_MODULE_NOT_FOUND) 2026-08-08 14:19:03 +00:00
3dtours bcc91db4a7 FIX: them @babel/standalone vao package.json (build.mjs ERR_MODULE_NOT_FOUND tren Windows) 2026-08-08 14:17:15 +00:00
3dtours 1f7a8c6f1b FIX: verify bundle dung tools/verify_bundle.py (normalize backslash, false-positive 'thieu static') + check engine.spec phien ban moi 2026-08-08 14:15:14 +00:00
3dtours 8b0551b18f FIX: build_windows.ps1 parse error PS5.1 — ASCII-only + UTF-8 BOM (unicode em-dash lam hong string terminator) 2026-08-08 14:01:40 +00:00
3dtours eaca051191 FIX (lan 2): bundle app assets qua collect_data_files (import-system) + fallback static dir trong main.py + verify TOC trong build_windows.ps1 2026-08-08 13:59:35 +00:00
3dtours b78a629429 IMPROVE: About modal dùng logo favicon.svg của app thay cho div chữ SF 2026-08-08 13:37:03 +00:00
3dtours 366219a269 FEAT: menu Help (About modal + hướng dẫn sử dụng) + Tools Preferences (theme/language/button font size) 2026-08-08 13:23:23 +00:00
3dtours 3618e3c591 FIX: VU meter giữ animation đúng trường độ âm khi ARM + giữ phím MIDI keyboard (heldMidiNotesRef, không decay khi note còn giữ) 2026-08-08 13:12:49 +00:00
3dtours b490aa9951 FIX: chặn browser zoom toàn trang — item/button/label giữ nguyên kích thước khi phóng to UI (capture preventDefault, giữ zoom chuyên dụng) 2026-08-08 13:08:25 +00:00
3dtours be93f9f55a FIX: bundle scipy submodules (ansari_swilk_stats) + librosa .pyi stubs (lazy_loader) trong engine.spec 2026-08-08 12:52:38 +00:00
3dtours 7dda81b1d3 FIX: Windows build crash 'app\static does not exist' (datas absolute + APP_DIR freeze-aware) + VU meter ARM/MIDI keyboard 2026-08-08 11:51:15 +00:00
3dtours f7b0172fdc FIX: engine.spec — collect_submodules('celery.fixups') thay vi liet ke tung module 2026-08-08 10:40:21 +00:00
3dtours 9bebbe8e7a FIX: engine.spec — hiddenimport celery.fixups (celery import dong, PyInstaller khong thay) 2026-08-08 10:24:35 +00:00
3dtours 4e4391b315 FIX: lib.rs — E0597 borrow, take child ra khoi lock truoc khi kill 2026-08-08 10:12:21 +00:00
3dtours e978abf0e9 FIX: lib.rs — import tauri::Manager (can manage/state) 2026-08-08 10:04:58 +00:00
3dtours bb699631bf FIX: tauri.conf.json v2 — nsis config phai nam trong bundle.windows 2026-08-08 09:55:27 +00:00
3dtours 0a1f3efd64 FEAT: chuẩn bị thư viện để tạo file exe trên window 2026-08-08 09:44:37 +00:00
3dtours 9ef622f29a IMPROVE: thêm tập tin LICENSE của dự án 2026-08-08 07:20:09 +07:00
3dtours 8e3c652551 IMPROVE: PIANO ROLL TAB khi play thì playhead luôn nằm chính giữa piano roll 2026-08-07 22:10:37 +07:00
3dtours 393df8fd7b FIX: cho phép shift+click để chọn notes, drag để di chuyển các notes, ctrl+drag để copy nhanh các notes 2026-08-07 21:48:14 +07:00
3dtours 688a7a57e2 FIX: cho phép copy/cut các notes được chọn trong PIANO ROLL TAB 2026-08-07 21:39:18 +07:00
3dtours 652e745d0a IMPROVE: thay đổi giao diện của PIANO ROLL TAB 2026-08-07 21:22:13 +07:00
3dtours 45ab31d42e IMPROVE: thêm các tính năng cho MIDI trong PIANO ROLL TAB 2026-08-07 20:46:47 +07:00
3dtours c3182f6baa FIX: sửa lỗi không hiển thị đúng nội dung của SECTION-TAB trong section item 2026-08-07 18:58:29 +07:00
3dtours 1d9b426d15 FIX: sửa lỗi copy/cut paste không đúng item 2026-08-07 17:06:05 +07:00
3dtours 4f34491f3e FIX: sửa lỗi hiển thị animation của VU meter khi play MIDI 2026-08-07 15:57:49 +07:00
3dtours 9e269d3b79 FIX: sửa lỗi hiển thị animation của VU meter 2026-08-07 15:40:15 +07:00
3dtours 58c6ec794d FIX: sửa lỗi hiển thị số bar bị sai 2026-08-07 15:07:06 +07:00
3dtours 173a1f227b FIX: sửa lỗi lưu thay đổi trong SECTION-TAB thì cũng tạo duration trên MAIN SESSION 2026-08-07 11:08:39 +07:00
3dtours c1aa1eb4d4 FIX: sửa lỗi phát hiện đã không còn play midi note để PIANO ROLL TAB quay về vị trí 0 2026-08-07 10:41:45 +07:00
3dtours 6c16bf8410 FIX: sửa lỗi đồng bộ playhead vị trí play giữa MAIN SESSION/SECTION-TAB/PIANO ROLL TAB 2026-08-07 10:37:34 +07:00
3dtours 4981785913 FIX: sửa lỗi playhead đồng bộ giữa các tab, nhấn space có thể bind với phím play 2026-08-07 10:27:21 +07:00
3dtours bca661bffd FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION/SECTION-TAB không đồng bộ playhead 2026-08-07 10:12:18 +07:00
3dtours 4c820bfa77 FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION và SECTION-TAB thì bị câm 2026-08-07 09:35:40 +07:00
3dtours 8cdf2942b9 FIX: sửa lỗi chuyển từ tab MAIN SESSION sang SECTION-TAB và PIANO ROLL TAB thì bị bypass 2026-08-07 09:13:03 +07:00
3dtours 23675dab83 FIX: Đã fix preview Media Explorer (v202608062345) 2026-08-06 20:45:56 +07:00
3dtours 71b9231b57 FIX: Đã fix block lồng + mất MIDI (v202608062330) 2026-08-06 20:42:27 +07:00
3dtours 93dc13b17a FIX: lỗi bypass của SECTION-TAB 2026-08-06 18:19:04 +07:00
3dtours f1e914fa62 FIX: lỗi: CC7 (channel volume) chỉ được set 100/0 (mute/unmute) — volume track KHÔNG áp qua channel → track MIDI dùng chung channel: CC7 của track set cuối thắng → volume của track kia vô tác dụng 2026-08-06 14:10:57 +07:00
3dtours 4dfab954d8 FIX: reset tất cả về mặc định khi tạo new project 2026-08-06 11:47:10 +07:00
3dtours e99e54773f FIX: Đã sắp xếp lại (v202608061030) 2026-08-06 08:29:23 +07:00
3dtours 0ba31c57bf FIX: Đã đổi tên (v202608060900) 2026-08-05 22:23:31 +07:00
3dtours 454dd91f96 FIX: Đã fix cross-machine (v202608060700) 2026-08-05 22:02:43 +07:00
3dtours e9f29e09ca FIX: Đã fix cross-machine (v202608060500) 2026-08-05 17:40:35 +07:00
3dtours 289746f187 FIX: Đã fix (v202608060430) — tab deactive → stop âm tab đó 2026-08-05 17:29:52 +07:00
3dtours b3ced7a7b3 FIX: Clone AI Var kế thừa midiChannel của track gốc → 2 track DÙNG CHUNG channel 2026-08-05 15:49:44 +07:00
3dtours 4233c1eeda FIX: Piano roll track 4 phát âm thanh instrument track 3 2026-08-05 15:17:13 +07:00
3dtours 35f7c3822f FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 15:05:44 +07:00
3dtours d37f4e7557 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 12:30:42 +07:00
3dtours 8a9d6b3c27 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery nhưng âm thanh rất nhỏ 2026-08-05 12:16:00 +07:00
3dtours 55d3464b1e FIX: Piano roll tab không xuất âm thanh qua mastering chain 2026-08-05 12:10:50 +07:00
3dtours cfc114b9d7 FIX: khi ở MAIN SESSION đang play mà mở Piano roll tab thì âm thanh bị tắt 2026-08-04 22:12:50 +07:00
3dtours 856a8183b6 FIX: lỗi mở MIDI item ở trong PIANO ROLL TAB là bị lỗi âm thanh của main session 2026-08-04 22:07:48 +07:00
47 changed files with 6764 additions and 647 deletions
+44
View File
@@ -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/
+25
View File
@@ -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
+4
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
# 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****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.
## 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
+85
View File
@@ -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
+40
View File
@@ -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.
+95
View File
@@ -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 80008010 + 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`
+37
View File
@@ -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).
+29
View File
@@ -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
View File
@@ -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)
+24 -3
View File
@@ -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()
+17 -5
View File
@@ -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"))
+11 -5
View File
@@ -99,8 +99,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 +160,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
View File
@@ -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")
+2934 -386
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3
View File
@@ -64,6 +64,9 @@ 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' }),
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' }),
+162 -21
View File
@@ -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,9 +332,16 @@
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url);
if (!resp.ok) {
// 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);
var sfHandle = this._tryLoadSFL(buf, '.sf3');
@@ -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) {
+10
View File
@@ -27,6 +27,16 @@ celery_app.conf.update(
enable_utc=True,
)
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker.
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://",
)
# ── 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": {
+57 -3
View File
@@ -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>
@@ -16,7 +35,7 @@
<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=202608081600" 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;
+89
View File
@@ -0,0 +1,89 @@
# 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"
+119
View File
@@ -0,0 +1,119 @@
"""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():
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()
+58
View File
@@ -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
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python)
# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows)
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules, collect_data_files
# Native DLL cho pedalboard va soundfile
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 trong onefile, 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)
]
# librosa 0.11 dùng lazy_loader.attach_stub -> lúc RUNTIME cần file .pyi
# ton tai tren disk ('Cannot load imports from non-existent stub ...librosa\__init__.pyi').
# PyInstaller mac dinh KHONG bundle .pyi -> phai collect explicit.
datas += collect_data_files('librosa', includes=['**/*.pyi'])
# scipy >= 1.18 tach scipy.stats thanh nhieu module con (vd
# _ansari_swilk_statistics) import lazy ben trong ham -> hook scipy cua
# PyInstaller miss -> ModuleNotFoundError luc runtime. Giai phap TRIET DE:
# scan FILESYSTEM toan bo site-packages/scipy (khong import, khong walk —
# pkgutil.walk_packages BO QUA AM THAM subpackage import loi luc build,
# da gap: may user mat ca cay scipy.sparse.csgraph._shortest_path).
# Bat moi module .py + C-extension .pyd/.so -> hiddenimports day du.
import importlib.util as _ilu
import glob as _glob
_scipy_spec = _ilu.find_spec('scipy')
_scipy_dir = os.path.dirname(os.path.abspath(_scipy_spec.origin))
_scipy_hidden = []
for _ext in ('*.py', '*.pyd', '*.so'):
for _f in _glob.glob(os.path.join(_scipy_dir, '**', _ext), recursive=True):
_rel = os.path.relpath(_f, _scipy_dir)
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
_pkg = os.path.dirname(_rel).replace(os.sep, '.')
_mod = ('scipy.' + _pkg + '.' + _base) if _pkg else ('scipy.' + _base)
if _mod not in _scipy_hidden:
_scipy_hidden.append(_mod)
# Cung co bang hiddenimport TINH: _morestats import module nay o top-level
# (scipy 1.18+); collect_submodules du phong nhung neu miss (version khac
# tren may user) thi dong nay van dam bao bundle co.
if 'scipy.stats._ansari_swilk_statistics' not in _scipy_hidden:
_scipy_hidden.append('scipy.stats._ansari_swilk_statistics')
# scipy.sparse.csgraph cung lazy-import C-extension tu ben trong ham (vd
# _shortest_path, _traversal, _matching) — hiddenimport tinh phong walk miss.
for _m in ('scipy.sparse.csgraph._shortest_path', 'scipy.sparse.csgraph._traversal',
'scipy.sparse.csgraph._matching', 'scipy.sparse.csgraph._min_spanning_tree'):
if _m not in _scipy_hidden:
_scipy_hidden.append(_m)
a = Analysis(
['desktop_engine.py'],
pathex=[_SPEC_ROOT],
binaries=binaries,
datas=datas,
hiddenimports=collect_submodules('celery.fixups') + _scipy_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',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter'],
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',
)
+10
View File
@@ -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",
+1
View File
@@ -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"
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -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"]
}
+7
View File
@@ -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)
+88
View File
@@ -0,0 +1,88 @@
// SonicForge DAW — desktop shell: spawn/terminate daw_engine.exe sidecar.
use tauri::Manager;
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::sync::Mutex;
struct EngineProcess(Mutex<Option<CommandChild>>);
#[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| {
// 1. Spawn daw_engine (PyInstaller ONEDIR bundle — khong giai nen
// moi lan chay nhu onefile, khoi dong nhanh). Bundle qua Tauri
// resources: resource_dir()/daw_engine/daw_engine(.exe)
let res_dir = app
.path()
.resource_dir()
.expect("resource dir not found");
let engine_dir = res_dir.join("daw_engine");
let engine_exe = if cfg!(windows) {
engine_dir.join("daw_engine.exe")
} else {
engine_dir.join("daw_engine")
};
// Ghi log spawn de chan doan (UI khong duoc panic/thoat — chi log).
// Ghi vao %APPDATA%/SonicForgeDAW/logs/spawn.log (thu muc luon ton
// tai) — KHONG ghi vao engine_dir vi thu muc do co the khong duoc
// bundle vao installer (resources loi) -> ghi am tham that bai.
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 log_line = format!(
"spawn daw_engine: {} exists={}\nresource_dir={}\n",
engine_exe.display(),
engine_exe.exists(),
res_dir.display()
);
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 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 (localhost:8000, auto-fallback 8000-8010)");
}
Err(e) => {
// Khong panic: UI van mo, engine loi se hien trong UI loader
// ("Khong tim thay SonicForge Engine"). Log ro de chan doan.
println!("Failed to spawn daw_engine: {e}");
app.manage(EngineProcess(Mutex::new(None)));
}
}
Ok(())
})
.on_window_event(|window, event| {
// 2. Terminate sidecar khi DAW window dong — tranh orphan process
if let tauri::WindowEvent::Destroyed = event {
// Lay child ra khoi lock, guard drop ngay tai day (trach E0597)
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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
sonicforge_daw_lib::run()
}
+45
View File
@@ -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": ["msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/favicon.ico"
],
"resources": [
"resources/daw_engine"
],
"externalBin": [],
"windows": {
"nsis": {
"installerHooks": "hooks.nsh"
}
}
}
}
+35
View File
@@ -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 80008010). 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>
+52
View File
@@ -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]
+55
View File
@@ -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');
+59
View File
@@ -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()
+61
View File
@@ -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())
+1317 -1
View File
File diff suppressed because it is too large Load Diff