56 Commits

Author SHA1 Message Date
3dtours fbaac1f673 chore: bo test data plugin_dirs.json khoi repo + ignore 2026-08-09 13:52:49 +00:00
3dtours 438ee607fc fix: nut Synth tren TCP khong hien danh sach VSTi da scan (ban 1.1.5)
- Nut Synth TCP goi openInstrumentSelector (modal 'Select Instrument')
  nhung modal chi co category SoundFont + GM - khong co muc VST (muc VST
  chi ton tai o dropdown MixerStrip). Them 'VST Instruments' vao sidebar
  trai modal: list instrumentSelectorData.vst_instruments (listPlugins),
  click -> setTrackInstrumentWithUndo(trackId, v.id, v.name) + dong modal.
- _scan_vst_in_dirs + /scan: phat hien ca FOLDER ten X.vst3 (Windows VST3
  = folder chua X.vst3.dll) - truoc day chi scan file.
- Ghi chu: pedalboard (engine VST) la headless - KHONG mo duoc GUI goc cua
  plugin; danh sach + chon la co che chon nhac cu kha thi.

Verify engine frozen: /plugins/available tra ['PadMachine','UltraSynth']
(ca file va folder .vst3); /scan dong bo; test_plugin_api 3 passed.
2026-08-09 13:52:43 +00:00
3dtours 9f7af1e1b6 fix: build loi E0505 - khong move handle vao closure khi dang borrow
run_on_main_thread(&self, FnOnce) borrow handle; closure move can ban clone
rieng (handle_for_closure) de goi dialog() ben trong.
2026-08-09 13:20:17 +00:00
3dtours cacd072f3e fix: build loi E0308 - blocking_pick_folder tra Option<FilePath> khong phai PathBuf
tauri-plugin-dialog v2: blocking_pick_folder() -> Option<FilePath> (enum
Path(PathBuf) | Url(Url) — xac minh tu source plugins-workspace v2:
plugins/dialog/src/lib.rs:728 + plugins/fs/src/file_path.rs:20).
Match FilePath::Path -> to_string_lossy, FilePath::Url -> to_string.
2026-08-09 13:15:46 +00:00
3dtours cc8b286f6c feat: native folder picker (Explorer) + Synth liet ke VSTi da scan + fix VU leak (ban 1.1.4)
1) Folder picker dung WINDOW EXPLORER (khong prompt nhap tay):
   - src-tauri/src/lib.rs: IPC bridge — thread watcher ipc/pick_dir.request
     -> run_on_main_thread -> dialog().file().blocking_pick_folder()
     (tauri-plugin-dialog = IFileDialog/Explorer) -> ghi pick_dir.response;
     ghi marker tauri_bridge_ready luc setup.
   - app/api/v1/plugins.py: POST /pick-dir (def sync -> threadpool, khong
     auth) — uu tien Tauri bridge, fallback PowerShell FolderBrowserDialog
     (Win) / osascript (macOS) / zenity-kdialog (Linux).
   - app.jsx pickPluginFolder: 1) pickPluginDir (native) -> 2) __TAURI__
     invoke -> 3) in-app browser -> 4) prompt (cuoi cung).

2) Nut Synth liet ke VSTi da scan (truoc day rong):
   - Root: list_available() chi quet settings.VST_DIR (mac dinh
     /opt/daw_engine/vst3) trong khi Plugin Manager scan plugin_dirs user.
   - plugins.py list_plugins: gop _scan_vst_in_dirs(plugin_dirs) (file
     .vst3/.dll/.so + folder X.vst3 Windows).
   - vst_engine.py: PluginManager.extra_vst_dirs + _scan_plugins quet them
     (ca folder .vst3) + get_plugin_manager doc plugin_dirs.json -> load_vst
     tim thay plugin user scan khi render.

3) VU meter leak: section-tab play -> sang MAIN SESSION -> track MAIN van
   animate theo am section.
   - Root: VU tick fallback _sub_ (sub-node section co analyser) chay cho
     ca canvas MAIN; startSubTabPlayback ghi node o key track MAIN.
   - Fix: fallback _sub_ chi ap dung cho canvas SECTION (isSessVu); 2 trigger
     piano-roll them prefix _sess_ theo st.parent_tab_id.

Verify: 86 tests pass; engine frozen STARTUP 1.35s, 1 engine, 0 spawn '-c';
pick-dir IPC mock tra dung path; scan VST user dirs OK.
2026-08-09 13:05:53 +00:00
3dtours 5ae4fd6149 fix: Plugin Manager '+ Add Directory' khong hien folder picker (ban 1.1.3)
Nguyen nhan: UI that chay tren http://127.0.0.1:8000 (engine) nen
window.__TAURI__ KHONG ton tai (Tauri chi inject vao trang no serve -
src-tauri/ui/index.html chi la loader redirect). Fallback window.prompt
KHONG hoat dong trong WebView2 (no-op) -> khong co picker nao xuat hien.

Fix: in-app folder browser qua backend (co san /media/computer +
/media/browse trong media.py, auth qua apiRequest):
- services/api.js: them browseComputer() + browseDir(path)
- app.jsx PluginManagerModal: state pmPicker + overlay UI (roots/drives
  -> dirs -> 'Len' (parent) -> 'Chon thu muc nay' -> setPmDirs)
- Thu tu uu tien: __TAURI__.core.invoke('plugin:dialog|open') -> in-app
  browser -> prompt (fallback cuoi, browser thuan khong phai WebView2)
- Bump ?v= cache-bust index.html (api.js + app.precompiled.js)

Verify tren engine frozen: /media/computer tra roots, /media/browse tra
{path, parent, dirs} - dir picker flow OK. Skill da ghi lai:
~/.hermes/skills/software-development/sonicforge-daw-engine-packaging
2026-08-09 11:55:16 +00:00
3dtours 24a39869c8 fix: dau_engine de quy spawn vo han + load rat cham (ban 1.1.2)
Trieu chung tu user: Task Manager day daw_engine process, port 8000->8005
leo thang, engine.log co engine moi moi ~2.5-3.3s trong nhieu phut.

Goc re: app/core/vst_engine.py module-level:
  HAS_PEDALBOARD = check_pedalboard_safe()
  -> subprocess.run([sys.executable, '-c', 'import pedalboard'], timeout=2.0)
Khi frozen, sys.executable = daw_engine.exe. PyInstaller bootloader bo qua
'-c' va chay script nhung (desktop_engine.py = CA ENGINE) -> moi lan check
thu vien lai khoi dong mot engine moi (timeout 2s moi check, block startup
~4s) -> engine con import app.main -> lai chay check -> spawn tiep
=> de quy vo han, hang chuc engine, may nghen, load 6-7.5s.

Fix:
- vst_engine: check_pedalboard_safe/check_pyfluidsynth_safe dung
  importlib.util.find_spec (micro-giay, khong spawn, hoat dong frozen).
- desktop_engine.main(): guard phong thu - neu bi goi voi argv[1]=='-c'
  (dang bi dung nhu python interpreter) thi thoat ngay khong chay server.

Verify tren Linux (engine frozen):
- STARTUP: 6-7.5s -> 1.24-1.33s (3 lan lien tiep)
- sau 15s: dung 1 engine, 0 process '-c import' (truoc: hang chuc)
- 86 tests pass; test suite 34s -> 14s (may het bi spam).
2026-08-09 11:23:36 +00:00
3dtours 0c0aadb8ee fix: daw_engine khong chay tren Windows - bundle resources sai layout
Spawn.log cho thay: resource_dir()/daw_engine/daw_engine.exe exists=false
tren ca ban cai lan target/release - engine KHONG duoc bundle dung cho.

Goc re (doc source tauri-utils/src/resources.rs + tauri-cli/src/interface/rust.rs):
- bundle.resources dang ARRAY ['resources/daw_engine']: ResourcePaths::new
  (Slice) -> target = resource_relpath(path) GIU TIEN TO 'resources/'
  -> engine nam o /resources/daw_engine/...
- lib.rs cu tim o /daw_engine/... -> exists=false.

Fix:
- tauri.conf.json: resources dang MAP {'resources/daw_engine': 'daw_engine/'}
  -> resources_map -> Walk mode, dest.join(strip_prefix) -> giu nguyen cay
  _internal, dich chuan /daw_engine/ (xac nhan tauri-cli
  BundleResources::Map -> settings.resources_map, dong 1467-1469).
- lib.rs: do them 3 vi tri fallback (legacy resources/ prefix, portable
  exe_dir, dev exe_dir/../resources) + log diagnostic day du vao spawn.log
  (liet ke noi dung resource_dir/exe_dir khi khong tim thay).
- build_windows.ps1: them huong dan xac minh spawn.log sau khi cai dat.
2026-08-09 10:48:09 +00:00
3dtours 29ebbfc1c0 perf: slim daw_engine bundle 409MB->170MB + fix engine khong chay tren Windows
- Thay librosa bang app/core/audio_features.py (numpy/scipy/soundfile):
  load, beat_track, frames_to_time, spectral_centroid, rms, zero_crossing_rate,
  time_stretch, pitch_shift, chroma_stft. A/B ngang librosa (BPM <1% sai lech,
  pitch_shift chuan toi Hz). Loai bo llvmlite 171MB + scikit-learn + numba.
- Task layer 2 che do (app/tasks/worker.py): server giu celery; desktop slim
  chay in-process thread + registry, giu nguyen API contract (.delay/.id/status
  /tasks/{id}) nen frontend khong doi.
- engine.spec: excludes librosa/numba/llvmlite/sklearn/celery/redis/kombu/
  billiard/amqp/msgpack/yaml/PIL/cairosvg/zstandard/...; scan scipy gioi han
  scipy.signal; giu click (uvicorn.main import click).
- render_engine: scipy.signal thanh lazy import (giam cold start).
- tauri.conf.json: targets [nsis, msi] - NSIS tro lai (bundle nho) de
  hooks.nsh cai VC++ Redistributable - sua bug daw_engine.exe khong chay
  tren Windows (truoc day MSI-only khong chay hooks).
- build_linux.sh / build_macos.sh: build 1 lenh moi OS.
- Doc: DESKTOP_INSTALL_PLAN.md muc 5.1.
- Verify: 86 tests pass, engine dong goi upload/analyze/waveform/export OK.
2026-08-09 10:17:10 +00:00
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
54 changed files with 3401 additions and 162 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.exeapp/storage/plugin_dirs.json
+130
View File
@@ -0,0 +1,130 @@
# DESKTOP INSTALL PLAN — SonicForge Studio (bản cài trực tiếp trên OS)
Mô hình: **server chạy nền trên máy người dùng (localhost), client mở bằng browser**.
Không cần Electron — tận dụng stack hiện có (FastAPI + static JS precompiled).
Soundfonts do **người dùng tự tải vào thư mục riêng****khai báo** cho ứng dụng.
---
## 1. KIẾN TRÚC
```
┌─────────────────────────── NGƯỜI DÙNG (1 máy) ───────────────────────────┐
│ Browser ──http://127.0.0.1:8000──▶ FastAPI (uvicorn, chạy NỀN) │
│ │ │
│ ~/SonicForgeStudio/ ├─ data/ (DB, uploads, cache) │
│ ├─ data/sonicforge.db ├─ soundfonts/ (user tự bỏ .sf2) │
│ ├─ soundfonts/*.sf2/.sf3 └─ quét nền 30s (scanner có sẵn) │
│ └─ config.json ← khai báo folder soundfont │
└──────────────────────────────────────────────────────────────────────────┘
```
## 2. ĐÓNG GÓI CÀI ĐẶT (per-OS) — server nền + browser client
### Thành phần đóng gói
| Thành phần | Vai trò |
|---|---|
| Python backend (`app/`, `requirements.txt`) | Server — **PyInstaller `--onedir`** → binary (không cần Python trên máy user; bảo vệ source tốt hơn so với chạy .py) |
| `app/static/` (precompiled.js + css + vendor) | Client — đóng vào package, server phục vụ |
| VST3 plugins (`vst_plugins/`) | Copy theo platform (win .dll / mac .vst3 / linux .so) |
| Service wrapper | Tự khởi động server nền + mở browser khi login |
### Cài đặt + service nền theo OS
| OS | Installer | Service nền | Auto-open browser |
|---|---|---|---|
| **Windows** | Inno Setup (`.exe`) | Windows Service qua `NSSM`/`WinSW` hoặc Task Scheduler (logon) | `start http://127.0.0.1:8000` |
| **macOS** | `.dmg` + `.app` (PyInstaller) | `launchd` LaunchAgent (`~/Library/LaunchAgents`) | `open http://127.0.0.1:8000` |
| **Linux** | `.deb` / `.AppImage` | systemd **user** service (`~/.config/systemd/user/`) | `xdg-open http://127.0.0.1:8000` |
- Server bind **`127.0.0.1`** (không lộ mạng), port mặc định 8000 (config được).
- App gồm 2 tiến trình nhỏ: `web` (uvicorn) + `worker` (celery) — hoặc gộp worker vào web ở chế độ desktop (đơn giản: `--pool=solo`, chạy celery trong tiến trình riêng nếu cần render nặng).
## 3. THƯ MỤC DỮ LIỆU NGƯỜI DÙNG
```
~/SonicForgeStudio/
├── data/ # DB, uploads, processed (thay app/storage khi chạy desktop)
├── soundfonts/ # USER tự tải .sf2/.sf3 vào đây (mặc định được quét)
└── config.json # cấu hình: soundfont_dirs, port, autostart...
```
- `config.py`: thêm `DATA_DIR` (env `SFDATA_DIR`, mặc định `~/SonicForgeStudio/data`), `SOUNDFONT_DIRS`.
## 4. SOUNDFONT DO NGƯỜI DÙNG QUẢN LÝ (tải + khai báo)
**Cơ chế hiện có (tận dụng):** `app/core/soundfont_scanner.py``SoundFontAutoScanner` quét nền 30s
`/opt/daw_engine/soundfonts` + `storage/soundfonts` → catalog → API list qua `app/api/v1/plugins.py`.
**Việc cần làm (mở rộng):**
1. **Scanner nhận folder người dùng:** constructor nhận thêm `user_dirs` (từ `SOUNDFONT_DIRS` env + `config.json`) — merge vào catalog (ưu tiên: user > system).
2. **Khai báo folder — 2 cách:**
- **UI** (chính): Settings → "Soundfonts" → nút **Add folder…** (chọn thư mục chứa .sf2) → lưu vào `config.json` → gọi scanner rescan.
- **config.json** (thủ công): `{ "soundfont_dirs": ["D:/SF", "/Users/me/sf"] }`.
3. **API:** thêm endpoint `POST /api/v1/plugins/soundfonts/dirs` (đăng ký folder) + `GET .../dirs` (liệt kê) — scanner reload.
4. **UI danh sách:** hiển thị catalog (tên SF, kích thước, folder nguồn) — chọn = load vào FluidSynth (luồng có sẵn qua `SonicSF.selectInstrument`).
5. **Số hóa:** không upload file — server đọc trực tiếp từ đường dẫn user khai báo (không nhân đôi dữ liệu).
## 5. BUILD + PHÁT HÀNH
```
code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đóng installer theo OS
→ ký số (tùy chọn) → phát hành (GitHub Releases / trang riêng)
```
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
Đã xử lý:
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
desktop slim chạy task in-process (thread + registry), giữ nguyên API
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
còn `scipy.signal` (goi duy nhất app còn dùng).
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
"build xong không chạy daw_engine" trên Windows).
- **Fix layout resources (bản 1.1.1 — `exists=false` trong spawn.log)**:
`bundle.resources` dạng ARRAY `["resources/daw_engine"]` copy engine tới
`$RESOURCE_DIR/resources/daw_engine/...` (giữ tiền tố `resources/` — đọc
source `tauri-utils/src/resources.rs`) trong khi lib.rs tìm ở
`$RESOURCE_DIR/daw_engine/...` → `exists=false`. Đổi sang dạng MAP
`{"resources/daw_engine": "daw_engine/"}` (Walk mode, giữ nguyên cây
`_internal`, đích chuẩn `daw_engine/`). `src-tauri/src/lib.rs` đồng thời dò
thêm 3 vị trí fallback (legacy/portable/dev) + ghi diagnostic đầy đủ vào
`%APPDATA%/SonicForgeDAW/logs/spawn.log` (liệt kê nội dung resource_dir khi
không tìm thấy).
Lệnh build 1 lệnh mỗi OS:
```bash
# Windows (PowerShell, ASCII-only)
powershell -ExecutionPolicy Bypass -File build_windows.ps1
# Linux (cần binutils: sudo apt-get install -y binutils)
bash build_linux.sh
# macOS (cần codesign/notarize khi phát hành)
bash build_macos.sh
```
## 6. CẬP NHẬT
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
- `?v=` cache-bust JS mỗi bản (cơ chế đã có) — browser không dính cache cũ.
## 7. CHECKLIST CODE CẦN LÀM
- [ ] `config.py`: `DATA_DIR`, `SOUNDFONT_DIRS` (env + config.json)
- [ ] `soundfont_scanner.py`: nhận `user_dirs`, merge catalog, ưu tiên user
- [ ] API: `POST/GET .../soundfonts/dirs` (đăng ký/liệt kê folder) + rescan
- [ ] UI Settings → Soundfonts (Add folder, Browse, list, load)
- [ ] `soundfontStorage.js`: chuyển hướng sang catalog folder-scan (giữ upload path cho dự án cũ)
- [ ] PyInstaller spec: bundle static/ + vendor (libfluidsynth wasm, vst), bind 127.0.0.1
- [ ] Service wrappers: Windows (NSSM/WinSW), macOS (launchd), Linux (systemd user) + auto-open browser
- [ ] Installer scripts: Inno Setup (.exe), dmg (macOS), deb/AppImage (Linux)
- [ ] CI matrix build 3 OS + release artifacts
- [ ] `version.json` + in-app update check
+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).
+263 -6
View File
@@ -1,4 +1,4 @@
import os, uuid, json, tempfile
import os, sys, uuid, json, tempfile, subprocess, time as _time
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -16,7 +16,128 @@ 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):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
vst_found.append({"name": os.path.splitext(sub)[0],
"path": os.path.join(root, sub),
"dir": d,
"type": "VST3"})
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
vst_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d,
"type": "VST3" if low.endswith(".vst3") else "VST2"})
elif low.endswith(".sf2") or low.endswith(".sf3"):
sf_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d})
return {
"success": True,
"plugin_dirs": plugin_dirs,
"vst_found": vst_found,
"vst_count": len(vst_found),
"soundfonts": sf_found,
"soundfont_count": len(sf_found),
}
_inspector = None
_scanner = None
@@ -24,21 +145,157 @@ _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)
return pm.list_available()
d = _effective_dirs()
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
avail = pm.list_available()
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
extra = _scan_vst_in_dirs(d["plugin_dirs"])
by_id = {v["id"]: v for v in avail["vst_instruments"]}
for v in extra:
by_id.setdefault(v["id"], v)
avail["vst_instruments"] = list(by_id.values())
return avail
def _scan_vst_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa
X.vst3.dll bên trong)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
name = os.path.splitext(sub)[0]
if name not in found:
found[name] = {
"id": name, "name": name, "type": "VST3",
"path": os.path.join(root, sub),
}
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
name = os.path.splitext(f)[0]
if name not in found:
found[name] = {
"id": name, "name": name,
"type": "VST3" if low.endswith(".vst3") else "VST2",
"path": os.path.join(root, f),
}
return list(found.values())
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
# nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
def _pick_dir_via_tauri_bridge() -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
return None
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
return None
req = os.path.join(ipc, "pick_dir.request")
resp = os.path.join(ipc, "pick_dir.response")
try:
for f in (req, resp):
if os.path.exists(f):
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = _time.time() + PICK_DIR_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
with open(resp, "r", encoding="utf-8") as fh:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
_time.sleep(0.1)
except Exception:
pass
return None
def _pick_dir_native_engine() -> Optional[str]:
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
if os.name == "nt":
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
if sys.platform == "darwin":
try:
r = subprocess.run(
["osascript", "-e",
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
for cmd in (["zenity", "--file-selection", "--directory"],
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
if r.returncode == 0:
p = r.stdout.strip()
if p:
return p
except Exception:
continue
return None
@router.post("/pick-dir")
def pick_plugin_directory():
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
trong lúc user chọn thư mục (có thể mất phút)."""
path = _pick_dir_via_tauri_bridge()
if path is None:
path = _pick_dir_native_engine()
return {"path": path}
@router.get("/default-soundfonts")
+10 -3
View File
@@ -1,12 +1,19 @@
import os
from fastapi import APIRouter
from celery.result import AsyncResult
from app.tasks.worker import celery_app
router = APIRouter()
# Task status endpoint dung chung cho ca 2 che do:
# - Server/Docker: celery (AsyncResult, broker Redis).
# - Desktop slim (PyInstaller khong bundle celery): in-process registry
# (app/tasks/worker._SimpleAsyncResult) — API contract giong het nhau.
@router.get("/tasks/{task_id}")
async def get_task_status(task_id: str):
res = AsyncResult(task_id, app=celery_app)
from app.tasks.worker import get_task_result
res = get_task_result(task_id)
response_data = {
"task_id": task_id,
"status": res.status,
+24 -3
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()
+8 -5
View File
@@ -75,13 +75,16 @@ class AIDSPEngine:
t_end = min(total_duration, 4.0)
try:
import librosa
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr)
from app.core.audio_features import chroma_stft as _chroma_stft
# 1. Compute harmonic structural properties via Chroma (STFT-based,
# thay chroma_cqt de lo bo librosa/numba/llvmlite ~171MB)
chroma = _chroma_stft(y=y_mono, sr=sr)
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
from sklearn.metrics.pairwise import cosine_similarity
ssm = cosine_similarity(chroma.T, chroma.T)
# thay sklearn.metrics.pairwise.cosine_similarity bang numpy
c = chroma.T # (n_frames, 12)
norms = np.linalg.norm(c, axis=1, keepdims=True)
ssm = (c @ c.T) / (norms @ norms.T + 1e-9)
num_frames = ssm.shape[0]
hop_length = 512
+21 -12
View File
@@ -1,19 +1,28 @@
import os
import json
import librosa
import numpy as np
from typing import Optional
# Thay librosa bang shim nhe (numpy/scipy/soundfile) — khong keo numba/llvmlite
from app.core.audio_features import (
load as _load,
beat_track as _beat_track,
frames_to_time as _frames_to_time,
spectral_centroid as _spectral_centroid,
rms as _rms,
zero_crossing_rate as _zcr,
)
def analyze_audio(file_path: str) -> dict:
"""
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
"""
# Load audio
y, sr = librosa.load(file_path, sr=None)
y, sr = _load(file_path, sr=None)
# Track beats
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
tempo, beat_frames = _beat_track(y=y, sr=sr)
# Handle tempo which might be scalar or numpy array in different librosa versions
if isinstance(tempo, np.ndarray):
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
bpm = float(tempo)
# Convert frames to time (seconds)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
@@ -43,31 +52,31 @@ def analyze_audio_advanced(file_path: str) -> dict:
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
"""
y, sr = librosa.load(file_path, sr=None)
y, sr = _load(file_path, sr=None)
duration = float(len(y)) / sr
# Beat tracking
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
tempo, beat_frames = _beat_track(y=y, sr=sr)
if isinstance(tempo, np.ndarray):
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
else:
bpm = float(tempo)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
# Spectral centroid (brightness)
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_centroids = _spectral_centroid(y=y, sr=sr)[0]
avg_brightness = float(np.mean(spectral_centroids))
# RMS energy
rms = librosa.feature.rms(y=y)[0]
avg_energy = float(np.mean(rms))
rms_vals = _rms(y=y)[0]
avg_energy = float(np.mean(rms_vals))
# Zero crossing rate
zcr = librosa.feature.zero_crossing_rate(y)[0]
avg_zcr = float(np.mean(zcr))
zcr_vals = _zcr(y)[0]
avg_zcr = float(np.mean(zcr_vals))
return {
"bpm": round(bpm, 2),
+363
View File
@@ -0,0 +1,363 @@
"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only).
Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung
ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB).
Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site:
- load() ~ librosa.load (sr=None, mono=True)
- frames_to_time() ~ librosa.frames_to_time
- beat_track() ~ librosa.beat.beat_track (onset spectral flux
+ autocorrelation tempo + adaptive peak picking)
- spectral_centroid() ~ librosa.feature.spectral_centroid
- rms() ~ librosa.feature.rms
- zero_crossing_rate() ~ librosa.feature.zero_crossing_rate
- time_stretch() ~ librosa.effects.time_stretch (phase vocoder)
- pitch_shift() ~ librosa.effects.pitch_shift
- chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based,
dung cho fingerprint/similarity, KHONG dung cho
hien thi pitch chinh xac)
Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle.
"""
import numpy as np
import soundfile as sf
from scipy import signal as _signal
__all__ = [
"load", "frames_to_time", "beat_track",
"spectral_centroid", "rms", "zero_crossing_rate",
"time_stretch", "pitch_shift", "chroma_stft",
]
# Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048)
HOP_LENGTH = 512
N_FFT = 2048
WIN_LENGTH = 2048
# ── Load / time ──────────────────────────────────────────────────────────────
def load(path, sr=None, mono=True, offset=0.0, duration=None):
"""Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel.
sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None).
Neu truyen sr -> resample bang scipy.signal.resample_poly.
"""
if offset or duration:
info = sf.info(path)
start = int(offset * info.samplerate) if offset else 0
n_frames = int(duration * info.samplerate) if duration else -1
data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames)
else:
data, file_sr = sf.read(path, dtype="float32")
if data.ndim > 1:
if mono:
data = data.mean(axis=1)
else:
data = data.T # (channels, samples) giong librosa
if sr is not None and sr != file_sr:
from fractions import Fraction
ratio = Fraction(int(sr), int(file_sr))
up, down = ratio.numerator, ratio.denominator
data = _signal.resample_poly(data, up, down).astype(np.float32)
file_sr = sr
return data, file_sr
def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None):
"""Chuyen frame index sang giay: frames * hop_length / sr (giong librosa)."""
return np.asanyarray(frames) * float(hop_length) / float(sr)
# ── Framing / STFT (center=True, reflect pad, giong librosa) ────────────────
def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
"""Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True)."""
pad = frame_length // 2
yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect")
n_frames = 1 + (len(yp) - frame_length) // hop_length
if n_frames < 1:
n_frames = 1
idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :]
return yp[idx]
def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH):
"""STFT mot phia (rfft) voi cua so hann periodic, reflect pad."""
y = np.asarray(y, dtype=np.float64)
window = _signal.get_window("hann", win_length, fftbins=False)
f, _t, Zxx = _signal.stft(
y, fs=1.0, window=window, nperseg=win_length,
noverlap=win_length - hop_length, nfft=n_fft,
boundary="even", padded=True,
)
return Zxx
def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
length=None):
"""ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity).
boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True.
"""
window = _signal.get_window("hann", win_length, fftbins=False)
_t, y = _signal.istft(
Zxx, fs=1.0, window=window, nperseg=win_length,
noverlap=win_length - hop_length, nfft=n_fft,
input_onesided=True, boundary=True,
)
if length is not None and len(y) > length:
y = y[:length]
return y
# ── Features ─────────────────────────────────────────────────────────────────
def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH,
S=None):
"""Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram."""
if S is None:
S = np.abs(_stft(y, n_fft, hop_length)) ** 2
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
mag = np.abs(S)
denom = mag.sum(axis=0)
cent = np.divide(
np.sum(freqs[:, None] * mag, axis=0), denom,
out=np.zeros_like(denom), where=denom > 1e-10,
)
return cent[None, :]
def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None):
"""RMS nang luong moi frame - (1, n_frames)."""
if S is not None:
frames = S # caller truyen power spectrogram
else:
frames = _frame(y, frame_length, hop_length)
return np.sqrt(np.mean(frames ** 2, axis=0))[None, :]
def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
"""Ti le zero-crossing moi frame - (1, n_frames)."""
frames = _frame(y, frame_length, hop_length)
signs = np.signbit(frames).astype(np.int8)
zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0)
return zcr[None, :]
def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH):
"""Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding).
Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi
cosine_similarity trong ai_dsp_engine.
"""
mag = np.abs(_stft(y, n_fft, hop_length))
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
# Chi giu bin <= 5kHz (tranh nhieu alias o high freq)
keep = freqs <= 5000.0
freqs = freqs[keep]
mag = mag[keep]
# note number -> pitch class
note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0
pc = np.mod(np.round(note).astype(int), 12)
chroma = np.zeros((12, mag.shape[1]), dtype=np.float64)
np.add.at(chroma, pc, mag)
# L2 normalize tung frame (giong librosa)
norms = np.linalg.norm(chroma, axis=0)
chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10)
return chroma
# ── Onset / tempo / beat (thay librosa.beat) ─────────────────────────────────
def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT):
"""Onset envelope: spectral flux (log-magnitude diff, chi chieu duong)."""
mag = np.abs(_stft(y, n_fft, hop_length))
logmag = np.log1p(1000.0 * mag)
flux = np.diff(logmag, axis=1)
onset = np.maximum(flux, 0.0).sum(axis=0)
if onset.size == 0:
return onset
# Tru moving-average ~1s de loai trend (giong librosa detrend)
win = max(1, int(round(1.0 * sr / hop_length)))
if len(onset) >= win:
kernel = np.ones(win) / win
ma = np.convolve(onset, kernel, mode="same")
onset = np.maximum(onset - ma, 0.0)
return onset
def _autocorr(x):
"""Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1."""
n = len(x)
if n < 2:
return np.ones(n)
x = x - x.mean()
nfft = 2 ** int(np.ceil(np.log2(2 * n)))
X = np.fft.rfft(x, nfft)
r = np.fft.irfft(X * np.conj(X), nfft)[:n]
denom = np.maximum(n - np.arange(n), 1)
r = r / denom
r0 = r[0] if r[0] != 0 else 1.0
return r / r0
def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0),
start_bpm=120.0):
"""Uoc luong BPM bang autocorrelation cua onset envelope.
Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de
chon dung octave (tranh roi vao nua/double tempo khi autocorrelation
bi mo ho giua cac harmonic).
"""
if len(onset) < 4:
return float(start_bpm)
min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length)))
max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length)))
if max_lag <= min_lag or max_lag >= len(onset):
return float(start_bpm)
ac = _autocorr(onset)
lags = np.arange(min_lag, max_lag + 1)
tempi = 60.0 * sr / (hop_length * lags)
# prior rong ~0.7 octave quanh start_bpm (log2 scale)
prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2)
seg = ac[lags] * prior
best = lags[int(np.argmax(seg))]
tempo = 60.0 * sr / (hop_length * best)
# Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi
if tempo > 200.0 and best * 2 <= max_lag:
tempo = 60.0 * sr / (hop_length * best * 2)
return float(tempo)
def _localmax(x):
"""Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can)."""
n = len(x)
if n < 3:
return np.zeros(n, dtype=bool)
out = np.zeros(n, dtype=bool)
out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:])
return out
def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0):
"""Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid."""
n = len(onset)
if n == 0:
return np.array([], dtype=int)
period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat
win = max(1, int(round(period)))
kernel = np.ones(win) / win
ma = np.convolve(onset, kernel, mode="same")
thresh = 1.25 * ma + 1e-9
cand = np.where(_localmax(onset) & (onset >= thresh))[0]
if cand.size == 0:
cand = np.where(_localmax(onset))[0]
if cand.size == 0:
cand = np.arange(0, n, max(1, int(round(period))))
beats = [int(cand[0])]
while True:
expected = beats[-1] + period
if expected >= n:
break
lo, hi = expected - 0.45 * period, expected + 0.45 * period
in_win = cand[(cand >= lo) & (cand <= hi)]
if in_win.size == 0:
nxt = int(round(expected))
if nxt >= n:
break
beats.append(nxt)
else:
beats.append(int(in_win[np.argmin(np.abs(in_win - expected))]))
# Chong beat kep (khoang cach < 0.5 period)
if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period:
beats.pop()
continue
if len(beats) > 2000:
break
return np.array(beats, dtype=int)
def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0,
tightness=100):
"""Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int).
Tempo bang autocorrelation onset; beats bang peak-picking thich nghi.
Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site
(analyzer xu ly ca scalar lan ndarray).
"""
onset = _onset_strength(y, sr, hop_length)
tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm)
beats = _beat_frames(onset, sr, hop_length, tempo)
return tempo, beats
# ── Effects (thay librosa.effects) ───────────────────────────────────────────
def _phase_vocoder(D, rate, hop_length=HOP_LENGTH):
"""Phase vocoder time-stretch kinh dien (DAFX/Puckette).
D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon).
Tra ve STFT da stretch voi so frame ~ n_frames / rate.
"""
n_freq, n_frames = D.shape
if rate <= 0:
raise ValueError("rate phai > 0")
if rate == 1.0:
return D
time_steps = np.arange(0, n_frames, rate, dtype=float)
n_out = len(time_steps)
if n_out == 0:
return D[:, :0]
out = np.zeros((n_freq, n_out), dtype=np.complex128)
# Phase advance moi hop cua tung bin tan so
phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq)
mag = np.abs(D)
phase_acc = np.angle(D[:, 0])
for t, step in enumerate(time_steps):
idx = int(step)
if idx >= n_frames:
break
if idx + 1 >= n_frames:
out[:, t] = mag[:, idx] * np.exp(1j * phase_acc)
break
# Phase difference that giua 2 frame lien tiep (true frequency)
dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv
dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi))
phase_acc = phase_acc + phase_adv + dphase
out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc)
return out
def time_stretch(y, rate, **kwargs):
"""Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon."""
if rate <= 0:
raise ValueError("rate phai > 0")
if rate == 1.0:
return np.asarray(y, dtype=np.float32)
y = np.asarray(y, dtype=np.float64)
D = _stft(y)
D_stretch = _phase_vocoder(D, rate)
y_out = _istft(D_stretch)
# Cat ve dung do dai ky vong: len(y) / rate
target = int(round(len(y) / rate))
if len(y_out) > target:
y_out = y_out[:target]
return y_out.astype(np.float32)
def pitch_shift(y, sr=22050, n_steps=1, **kwargs):
"""Dich pitch n semitone (positive = cao hon), giu nguyen duration.
Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample
nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen.
"""
if n_steps == 0:
return np.asarray(y, dtype=np.float32)
rate = 2.0 ** (-float(n_steps) / 12.0)
y_shift = time_stretch(y, rate)
# Resample (FFT) ve dung do dai goc: factor = rate
target = int(round(len(y_shift) * rate))
if target != len(y_shift) and target > 0:
y_shift = _signal.resample(y_shift, target)
return np.asarray(y_shift, dtype=np.float32)
+4 -4
View File
@@ -1,6 +1,6 @@
import numpy as np
import librosa
from pydub import AudioSegment
from app.core.audio_features import load as _load
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
"""
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
"""
try:
# Load mono audio for zero crossing analysis
y, sr = librosa.load(file_path, sr=None, mono=True)
y, sr = _load(file_path, sr=None, mono=True)
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
except Exception as e:
print(f"Error finding zero crossing: {e}")
@@ -136,7 +136,7 @@ def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
dict: {"peaks": [...], "duration": float, "sample_rate": int}
"""
# Load mono audio
y, sr = librosa.load(file_path, sr=None, mono=True)
y, sr = _load(file_path, sr=None, mono=True)
total_samples = len(y)
duration = float(total_samples) / sr
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
Returns:
dict: {"rms": [...], "duration": float, "sample_rate": int}
"""
y, sr = librosa.load(file_path, sr=None, mono=True)
y, sr = _load(file_path, sr=None, mono=True)
total_samples = len(y)
duration = float(total_samples) / sr
+4 -2
View File
@@ -1,7 +1,8 @@
import os, logging, math
import numpy as np
import soundfile as sf
import scipy.signal as signal
# scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
from app.config import settings
from app.core.vst_engine import (
render_midi_events_to_audio,
@@ -385,7 +386,8 @@ class PythonRenderEngine:
for ch in range(2):
ir = ir_l if ch == 0 else ir_r
# Convolve
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
from scipy.signal import convolve
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
wet[ch, :] = conv
track_buffer = dry + wet * 0.4
except Exception as e:
+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"))
+3 -3
View File
@@ -1,6 +1,6 @@
import numpy as np
import scipy.signal as signal
import librosa
from app.core.audio_features import time_stretch as _time_stretch, pitch_shift as _pitch_shift
class SubTabDSPEngine:
@staticmethod
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
return y
if preserve_pitch:
return librosa.effects.time_stretch(y, rate=speed_ratio)
return _time_stretch(y, rate=speed_ratio)
else:
num_samples_new = int(len(y) / speed_ratio)
return signal.resample(y, num_samples_new)
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
"""
if n_steps == 0:
return y
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
return _pitch_shift(y, sr=sr, n_steps=n_steps)
@staticmethod
def merge_back_to_parent(
+67 -28
View File
@@ -1,5 +1,6 @@
# SonicForge Studio VST / VSTi Engine Service
import os
import json
import numpy as np
import functools
from ctypes import c_char_p
@@ -49,27 +50,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
out_r /= max_peak
return np.vstack([out_l, out_r])
def check_pedalboard_safe():
import subprocess, sys
def _module_available(name: str) -> bool:
"""Kiem tra module co san khong — KHONG spawn subprocess.
Truoc day dung subprocess.run([sys.executable, '-c', 'import X'])
khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe
-> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine)
-> moi lan check lai sinh ra engine moi -> de quy spawn vo han
(Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham).
find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen.
"""
import importlib.util
try:
res = subprocess.run(
[sys.executable, "-c", "import pedalboard"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return importlib.util.find_spec(name) is not None
except (ImportError, AttributeError, ValueError):
return False
def check_pedalboard_safe():
return _module_available("pedalboard")
def check_pyfluidsynth_safe():
import subprocess, sys
try:
res = subprocess.run(
[sys.executable, "-c", "import fluidsynth"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False
return _module_available("fluidsynth")
HAS_PEDALBOARD = check_pedalboard_safe()
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
@@ -99,14 +102,38 @@ _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 _load_user_plugin_dirs() -> list:
"""Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
tránh vòng import (plugins.py import vst_engine)."""
try:
from app.config import settings as _st
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return [d for d in (data.get("plugin_dirs") or []) if d]
except Exception:
pass
return []
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
"""Singleton: reuse PluginManager when args match, else create new.
Default dirs từ settings (env/.env/docker-compose hoặc user override).
VST scan gộp thêm plugin_dirs user (Plugin Manager) nút Synth phải liệt
được VSTi đã scan load_vst phải tìm thấy chúng khi render."""
from app.config import settings as _st
vst_dir = vst_dir or _st.VST_DIR
sf_dir = sf_dir or _st.SOUNDFONT_DIR
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
extra = _load_user_plugin_dirs()
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
args = (vst_dir, sf_dir, upload_sf_dir)
args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
return _PLUGIN_MANAGER_INSTANCE
_PLUGIN_MANAGER_ARGS = args
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str):
@@ -155,21 +182,33 @@ 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, extra_vst_dirs=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
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
self._sf_scan_cache = None # cache for _scan_soundfonts()
def _scan_plugins(self) -> dict:
plugins = {}
if not os.path.isdir(self.vst_dir):
return plugins
for root, dirs, files in os.walk(self.vst_dir):
for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
if not scan_dir or not os.path.isdir(scan_dir):
continue
for root, dirs, files in os.walk(scan_dir):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for d in list(dirs):
if d.lower().endswith(".vst3"):
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
for file in files:
if file.endswith(".vst3") or file.endswith(".so"):
low = file.lower()
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
plugin_path = os.path.join(root, file)
plugin_name = os.path.splitext(file)[0]
if plugin_name not in plugins:
plugins[plugin_name] = plugin_path
return plugins
+25 -1
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,6 +95,10 @@ 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")
+597 -15
View File
@@ -5284,14 +5284,111 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
const [localData, setLocalData] = React.useState(pluginsData);
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
const [sfToDelete, setSfToDelete] = React.useState(null);
const [pmDirs, setPmDirs] = React.useState([]);
const [pmScanning, setPmScanning] = React.useState(false);
const [pmScanResult, setPmScanResult] = React.useState('');
const [pmScanData, setPmScanData] = React.useState(null); // { vst_found, soundfonts }
React.useEffect(() => {
if (isOpen) {
window.SonicAPI.listPlugins()
.then(data => setLocalData(data))
.catch(() => setLocalData({ vst_instruments: [], soundfonts: [] }));
window.SonicAPI.getPluginDirs()
.then(d => setPmDirs(d.plugin_dirs || []))
.catch(() => {});
setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50);
}
}, [isOpen]);
// Folder picker:
// 1) Neu page duoc Tauri serve (__TAURI__ co) -> dialog plugin invoke.
// 2) Binh thuong UI chay tren http://127.0.0.1:8000 (engine) -> __TAURI__
// KHONG co (Tauri chi inject vao trang no serve; window.prompt cung
// khong hoat dong trong WebView2) -> mo TRINH DUYET THU MUC in-app
// (backend /media/computer + /media/browse) hoat dong moi OS.
// 3) Cuoi cung: prompt nhap path (browser thuan).
const [pmPicker, setPmPicker] = React.useState(null); // {path, dirs, parent, roots, loading}
const openPluginPicker = async () => {
setPmPicker({ path: null, dirs: null, parent: null, roots: null, loading: true });
try {
const data = await window.SonicAPI.browseComputer();
setPmPicker({ path: null, dirs: null, parent: null, roots: data.roots || [], loading: false });
} catch (e) {
setPmPicker(null);
showToast('Không mở được trình duyệt thư mục: ' + (e.message || e), 'error');
}
};
const browsePluginDir = async (path) => {
setPmPicker(prev => ({ ...prev, loading: true }));
try {
const data = await window.SonicAPI.browseDir(path);
setPmPicker({ path: data.path, parent: data.parent, dirs: data.dirs || [], roots: null, loading: false });
} catch (e) {
setPmPicker(prev => ({ ...prev, loading: false }));
showToast('Không đọc được thư mục: ' + (e.message || e), 'error');
}
};
const confirmPluginDir = () => {
const p = pmPicker && pmPicker.path;
if (p && !pmDirs.includes(p)) setPmDirs(prev => [...prev, p]);
setPmPicker(null);
};
const pickPluginFolder = async () => {
const addDir = (p) => { if (p && !pmDirs.includes(p)) setPmDirs(prev => [...prev, p]); };
try {
// 1) NATIVE dialog qua engine (Tauri bridge IFileDialog/Explorer;
// fallback PowerShell/zenity/osascript) UI chy localhost:8000 nên
// __TAURI__ không có, window.prompt vô hiu trong WebView2.
try {
const d = await window.SonicAPI.pickPluginDir();
if (d && typeof d.path === 'string' && d.path) { addDir(d.path); return; }
} catch (e) { /* fallthrough */ }
// 2) Tauri dialog trc tiếp (ch khi page đưc Tauri serve)
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
const sel = await window.__TAURI__.core.invoke('plugin:dialog|open', {
options: { directory: true, multiple: false }
});
if (typeof sel === 'string' && sel) { addDir(sel); return; }
}
// 3) Trình duyt thư mc in-app (backend) fallback mi OS
await openPluginPicker();
return;
} catch (e) {
// 4) Cui cùng: prompt nhp tay (browser thun, không phi WebView2)
try {
const manual = window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');
if (manual && manual.trim()) addDir(manual.trim());
} catch (e2) {
showToast('Browse failed: ' + (e.message || e), 'error');
}
}
};
const removePluginDir = (dir) => {
setPmDirs(prev => prev.filter(d => d !== dir));
};
// Lưu dirs (user override) + scan c 2 thư mc refresh list + catalog.
const saveAndScanDirs = async () => {
setPmScanning(true);
setPmScanResult('');
setPmScanData(null);
try {
await window.SonicAPI.savePluginDirs({ plugin_dirs: pmDirs });
const scan = await window.SonicAPI.scanPluginDirs();
setPmScanData({ vst_found: scan.vst_found || [], soundfonts: scan.soundfonts || [] });
const data = await window.SonicAPI.listPlugins();
setLocalData(data);
try {
const cat = await window.SonicAPI.getSoundfontCatalog();
window.__soundfontCatalog = cat;
} catch (_) {}
setPmScanResult(`VST: ${scan.vst_count || 0} | SoundFonts: ${scan.soundfont_count || 0}`);
showToast(`Scan xong: ${scan.vst_count || 0} VST, ${scan.soundfont_count || 0} SoundFonts.`, 'success');
} catch (err) {
setPmScanResult('Scan failed: ' + (err.message || err));
showToast('Scan failed: ' + (err.message || err), 'error');
} finally {
setPmScanning(false);
}
};
const handleUploadSF = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -5315,10 +5412,78 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
onClick: onClose
}, React.createElement('div', {
className: 'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col',
className: 'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col relative',
style: { maxHeight: '80vh' },
onClick: e => e.stopPropagation()
},
// In-app folder picker (Plugin Directories)
pmPicker && React.createElement('div', {
className: 'absolute inset-0 z-10 bg-[#171717]/97 flex flex-col',
style: { padding: 16 }
},
React.createElement('div', { className: 'flex items-center justify-between mb-2' },
React.createElement('div', { className: 'text-xs font-bold text-violet-300 uppercase' }, 'Chọn thư mục plugin'),
React.createElement('button', {
onClick: () => setPmPicker(null),
className: 'text-zinc-500 hover:text-zinc-200 transition'
}, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' }))
),
React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
React.createElement('button', {
onClick: () => { if (pmPicker.parent) browsePluginDir(pmPicker.parent); },
disabled: !pmPicker.parent,
className: 'px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-[11px] text-zinc-300 disabled:opacity-30 shrink-0'
}, 'Lên'),
React.createElement('div', {
className: 'flex-1 text-[11px] text-zinc-400 font-mono truncate',
title: pmPicker.path || ''
}, pmPicker.path || (pmPicker.loading ? 'Đang tải...' : 'My Computer'))
),
pmPicker.loading ? React.createElement('div', { className: 'flex-1 flex items-center justify-center text-zinc-500 text-xs' }, 'Đang tải...') :
(pmPicker.dirs ? (
pmPicker.dirs.length === 0 ? React.createElement('div', { className: 'flex-1 flex items-center justify-center text-zinc-600 text-xs italic' }, 'Thư mục trống') :
React.createElement('div', { className: 'flex-1 overflow-y-auto space-y-1' },
pmPicker.dirs.map((d, i) =>
React.createElement('div', {
key: 'pd_' + i,
className: 'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer group',
onClick: () => browsePluginDir(d.path)
},
React.createElement('i', { 'data-lucide': 'folder', className: 'w-3.5 h-3.5 text-amber-500/80 shrink-0' }),
React.createElement('span', { className: 'flex-1 text-[11px] text-zinc-300 truncate' }, d.name),
React.createElement('i', { 'data-lucide': 'chevron-right', className: 'w-3 h-3 text-zinc-600 group-hover:text-violet-400 shrink-0' })
)
)
)
) : (
pmPicker.roots ? (
pmPicker.roots.length === 0 ? React.createElement('div', { className: 'flex-1 flex items-center justify-center text-zinc-600 text-xs' }, 'Không tìm thấy ổ đĩa') :
React.createElement('div', { className: 'flex-1 overflow-y-auto space-y-1' },
pmPicker.roots.map((r, i) =>
React.createElement('div', {
key: 'pr_' + i,
className: 'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer',
onClick: () => browsePluginDir(r.path)
},
React.createElement('i', { 'data-lucide': 'hard-drive', className: 'w-3.5 h-3.5 text-cyan-500/80 shrink-0' }),
React.createElement('span', { className: 'flex-1 text-[11px] text-zinc-300' }, r.name || r.path)
)
)
)
) : null
)),
React.createElement('div', { className: 'flex items-center justify-end gap-2 mt-2 pt-2 border-t border-[#383838]' },
React.createElement('button', {
onClick: () => setPmPicker(null),
className: 'px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-white text-xs rounded'
}, 'Hủy'),
React.createElement('button', {
onClick: confirmPluginDir,
disabled: !pmPicker.path,
className: 'px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white text-xs font-semibold rounded disabled:opacity-30'
}, 'Chọn thư mục này')
)
),
// Header
React.createElement('div', {
className: 'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]'
@@ -5403,6 +5568,79 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
)
))
),
// Plugin directories section (folder picker + save + scan)
React.createElement('div', {
className: 'pt-4 mt-4 border-t border-[#383838]'
},
React.createElement('div', { className: 'flex items-center justify-between mb-2' },
React.createElement('h4', { className: 'text-xs font-bold text-zinc-400 uppercase' },
'Plugin Directories'),
React.createElement('button', {
className: 'px-3 py-1.5 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0',
title: 'Add plugin directory (VST / SoundFont)',
onClick: pickPluginFolder
},
React.createElement('i', { 'data-lucide': 'plus', className: 'w-3 h-3' }), 'Add Directory')
),
pmDirs.length === 0 &&
React.createElement('p', { className: 'text-[10px] text-zinc-600 mb-2 italic' },
'Chưa có thư mục nào. Nhấn Add Directory để chọn thư mục chứa VST / SoundFont.'),
pmDirs.map((dir, idx) =>
React.createElement('div', {
key: 'pdir_' + idx,
className: 'flex items-center gap-2 mb-1.5 bg-zinc-800/70 border border-zinc-700 rounded px-2 py-1.5'
},
React.createElement('button', {
className: 'text-zinc-500 hover:text-red-400 transition shrink-0',
title: 'Remove directory',
onClick: () => removePluginDir(dir)
},
React.createElement('i', { 'data-lucide': 'x', className: 'w-3.5 h-3.5' })),
React.createElement('span', {
className: 'flex-1 text-[11px] text-zinc-300 font-mono truncate',
title: dir
}, dir),
React.createElement('i', {
'data-lucide': 'folder',
className: 'w-3 h-3 text-zinc-600 shrink-0'
})
)
),
React.createElement('div', { className: 'flex gap-2 items-center mt-2' },
React.createElement('button', {
className: 'px-4 py-2 bg-emerald-800 hover:bg-emerald-700 text-white text-xs font-semibold rounded transition flex items-center gap-1',
onClick: saveAndScanDirs
},
React.createElement('i', { 'data-lucide': 'search', className: 'w-3 h-3' }), 'Scan'),
pmScanning && React.createElement('span', { className: 'text-[10px] text-emerald-400' }, 'Scanning...'),
pmScanResult && React.createElement('span', { className: 'text-[10px] text-zinc-400' }, pmScanResult)
),
pmScanData && React.createElement('div', { className: 'mt-3 space-y-2 max-h-40 overflow-y-auto' },
React.createElement('div', { className: 'text-[10px] font-bold text-violet-300 uppercase flex items-center gap-1' },
React.createElement('i', { 'data-lucide': 'cpu', className: 'w-3 h-3' }),
'VST Instruments (' + pmScanData.vst_found.length + ')'),
pmScanData.vst_found.length === 0 ?
React.createElement('p', { className: 'text-[10px] text-zinc-600 italic' }, 'Không tìm thấy VST.') :
pmScanData.vst_found.map((v, i) =>
React.createElement('div', { key: 'sv_' + i, className: 'flex items-center gap-2 text-[11px] text-zinc-300' },
React.createElement('span', { className: 'w-16 shrink-0 text-zinc-500 font-mono text-[9px] truncate' }, v.type || 'VST'),
React.createElement('span', { className: 'truncate' }, v.name),
React.createElement('span', { className: 'text-[9px] text-zinc-600 font-mono truncate ml-auto' }, v.dir)
)
),
React.createElement('div', { className: 'text-[10px] font-bold text-amber-300 uppercase flex items-center gap-1 mt-2' },
React.createElement('i', { 'data-lucide': 'music', className: 'w-3 h-3' }),
'SoundFonts (' + pmScanData.soundfonts.length + ')'),
pmScanData.soundfonts.length === 0 ?
React.createElement('p', { className: 'text-[10px] text-zinc-600 italic' }, 'Không tìm thấy SoundFont.') :
pmScanData.soundfonts.map((s, i) =>
React.createElement('div', { key: 'ss_' + i, className: 'flex items-center gap-2 text-[11px] text-zinc-300' },
React.createElement('span', { className: 'truncate' }, s.name),
React.createElement('span', { className: 'text-[9px] text-zinc-600 font-mono truncate ml-auto' }, s.dir)
)
)
)
),
// Upload section (bottom of right panel)
React.createElement('div', {
className: 'pt-4 mt-4 border-t border-[#383838]'
@@ -6469,6 +6707,155 @@ const SystemManagerModal = ({
}, "Xóa")))))))));
};
/*
ABOUT / HELP / PREFERENCES MODALS (menu Help + Tools Preferences)
*/
const APP_VERSION = '1.0.0'; // khp src-tauri/tauri.conf.json
const AboutModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<img src="/favicon.svg" alt="SonicForge Studio" className="w-12 h-12 rounded-lg bg-zinc-900 border border-zinc-700 shadow-lg object-contain p-0.5" />
<div>
<h2 className="text-lg font-bold text-white">SonicForge Studio</h2>
<p className="text-xs text-zinc-400">Professional DAW v{APP_VERSION}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<p className="text-zinc-300 leading-relaxed">Phần mềm sản xuất âm nhạc (DAW) soạn nhạc, ghi âm, chỉnh sửa MIDI/Audio, SoundFont &amp; VST, trộn master.</p>
<div className="pt-2 border-t border-zinc-800 space-y-1 text-xs">
<div className="flex items-center gap-2"><span className="text-zinc-500 w-20 shrink-0">Developer</span><span className="text-cyan-400 font-semibold">Lộc Phạm</span></div>
<div className="flex items-center gap-2"><span className="text-zinc-500 w-20 shrink-0">Email</span><a href="mailto:tranloclqd@gmail.com" className="text-cyan-400 hover:underline">tranloclqd@gmail.com</a></div>
<div className="flex items-center gap-2"><span className="text-zinc-500 w-20 shrink-0">Version</span><span className="text-zinc-300">{APP_VERSION}</span></div>
<div className="flex items-center gap-2"><span className="text-zinc-500 w-20 shrink-0">Build</span><span className="text-zinc-300">Standalone (Tauri v2 + PyInstaller)</span></div>
</div>
</div>
<div className="mt-5 flex justify-end">
<button onClick={onClose} className="px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition">Đóng</button>
</div>
</div>
</div>
);
};
const HelpModal = ({ isOpen, onClose, lang }) => {
if (!isOpen) return null;
const vi = lang !== 'en';
const sections = vi ? [
{ title: '🚀 Bắt đầu nhanh', body: 'Nhấn phím Cách (Space) để Play/Pause. Nhấn phím / để dừng. Dùng nút Record (●) để ghi âm/MIDI. Tạo track mới từ menu Track hoặc nút "+ Add Track".' },
{ title: '🎹 MIDI & ARM', body: 'Bật nút ARM đỏ trên track để nhận phím từ MIDI keyboard. Vào menu Tools → MIDI Devices để chọn thiết bị. Khi ARM + bấm phím, âm preview phát và VU meter nhảy theo trường độ.' },
{ title: '🎼 Piano Roll', body: 'Nhấp đúp vào MIDI item để mở Piano Roll. Dùng Ctrl+scroll hoặc nút +/ để zoom. Nhấp để vẽ note, kéo để di chuyển, Ctrl+kéo để copy. Phím tắt: C (vẽ), E (tẩy), Space (nghe).' },
{ title: '🎛️ FX & Master', body: 'Chọn track → nút FX để mở FX Rack (thêm EQ, compressor...). Bấm nút PWR ở Master để mở Mastering Panel: EQ 4 băng, compressor, limiter. Mọi thay đổi áp dụng realtime.' },
{ title: '🧪 SoundFont & VST', body: 'Vào Tools → Plugin Manager để quét SoundFont (.sf2/.sf3) và VSTi. Track MIDI dùng SoundFont làm nhạc cụ — chọn instrument từ nút Synth trên track.' },
{ title: '🤖 AI', body: 'Tools → Config AI Providers để cấu hình API (OpenAI, Gemini, Ollama...). Dùng AI Prompt Generator (nút ✨) để sinh MIDI/ý tưởng; AI MIDI Preset Manager để lưu preset.' },
{ title: '💾 Lưu & Xuất', body: 'Ctrl+S lưu project (cloud/temp), Ctrl+Shift+S Save As. Export WAV qua nút Export — chọn vùng, format, bitrate rồi Render.' },
{ title: '⌨️ Phím tắt', body: 'Space: Play/Pause · /: Stop · Ctrl+Z/Y: Undo/Redo · Ctrl+C/X/V: Copy/Cut/Paste · Ctrl+S: Save · Ctrl+N: New · S: Split · Ctrl+E: Edit in new tab · Ctrl+wheel: zoom timeline' },
] : [
{ title: '🚀 Quick start', body: 'Press Space to Play/Pause. Press / to stop. Use Record (●) to capture audio/MIDI. Add tracks from the Track menu or the "+ Add Track" button.' },
{ title: '🎹 MIDI & ARM', body: 'Enable the red ARM button on a track to receive keys from a MIDI keyboard. Go to Tools → MIDI Devices to pick your device. When ARM + key press, preview audio plays and the VU meter animates for the note length.' },
{ title: '🎼 Piano Roll', body: 'Double-click a MIDI item to open the Piano Roll. Use Ctrl+scroll or +/ buttons to zoom. Click to draw notes, drag to move, Ctrl+drag to copy. Shortcuts: C (draw), E (erase), Space (listen).' },
{ title: '🎛️ FX & Master', body: 'Select a track → FX button to open the FX Rack (EQ, compressor...). Press the PWR button on the Master to open the Mastering Panel: 4-band EQ, compressor, limiter — all realtime.' },
{ title: '🧪 SoundFont & VST', body: 'Tools → Plugin Manager scans SoundFonts (.sf2/.sf3) and VSTi. MIDI tracks use SoundFonts as instruments — pick one from the Synth button on the track.' },
{ title: '🤖 AI', body: 'Tools → Config AI Providers to set up APIs (OpenAI, Gemini, Ollama...). Use the AI Prompt Generator (✨) to create MIDI/ideas; AI MIDI Preset Manager stores presets.' },
{ title: '💾 Save & Export', body: 'Ctrl+S saves the project (cloud/temp), Ctrl+Shift+S Save As. Export WAV via the Export button — pick range, format, bitrate, then Render.' },
{ title: '⌨️ Shortcuts', body: 'Space: Play/Pause · /: Stop · Ctrl+Z/Y: Undo/Redo · Ctrl+C/X/V: Copy/Cut/Paste · Ctrl+S: Save · Ctrl+N: New · S: Split · Ctrl+E: Edit in new tab · Ctrl+wheel: zoom timeline' },
];
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col text-slate-200" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
<h2 className="text-base font-bold text-white">{vi ? 'Hướng dẫn sử dụng SonicForge Studio' : 'SonicForge Studio User Guide'}</h2>
<button onClick={onClose} className="w-6 h-6 rounded hover:bg-zinc-700 text-zinc-400 hover:text-white text-sm"></button>
</div>
<div className="overflow-y-auto px-5 py-4 space-y-3">
{sections.map((s, i) => (
<div key={i} className="bg-zinc-900/60 border border-zinc-800 rounded-lg p-3">
<h3 className="text-sm font-bold text-cyan-400 mb-1">{s.title}</h3>
<p className="text-xs text-zinc-300 leading-relaxed">{s.body}</p>
</div>
))}
</div>
<div className="px-5 py-3 border-t border-zinc-800 flex justify-end shrink-0">
<button onClick={onClose} className="px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition">{vi ? 'Đóng' : 'Close'}</button>
</div>
</div>
</div>
);
};
const PreferencesModal = ({ isOpen, onClose, prefs, onPrefsChange }) => {
if (!isOpen) return null;
const vi = prefs.language !== 'en';
const themes = [
{ id: 'dark', name: vi ? 'Tối (mặc định)' : 'Dark (default)', swatch: 'from-zinc-700 to-zinc-900', ring: 'ring-cyan-400' },
{ id: 'midnight', name: vi ? 'Đêm xanh' : 'Midnight', swatch: 'from-sky-800 to-slate-950', ring: 'ring-sky-400' },
{ id: 'forest', name: vi ? 'Rừng xanh' : 'Forest', swatch: 'from-emerald-700 to-green-950', ring: 'ring-emerald-400' },
{ id: 'violet', name: vi ? 'Tím' : 'Violet', swatch: 'from-violet-700 to-purple-950', ring: 'ring-violet-400' },
{ id: 'graphite', name: vi ? 'Than chì' : 'Graphite', swatch: 'from-zinc-600 to-neutral-900', ring: 'ring-zinc-300' },
];
const set = (k, v) => onPrefsChange({ ...prefs, [k]: v });
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="bg-[#1e1e24] border border-zinc-700 rounded-xl shadow-2xl w-full max-w-md max-h-[85vh] overflow-y-auto p-5 text-slate-200" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-white">{vi ? 'Tùy chọn (Preferences)' : 'Preferences'}</h2>
<button onClick={onClose} className="w-6 h-6 rounded hover:bg-zinc-700 text-zinc-400 hover:text-white text-sm"></button>
</div>
{/* Theme */}
<div className="mb-5">
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2">{vi ? '🎨 Chủ đề màu' : '🎨 Theme'}</h3>
<div className="grid grid-cols-1 gap-1.5">
{themes.map(th => (
<button key={th.id} onClick={() => set('theme', th.id)}
className={`flex items-center gap-2.5 px-2.5 py-2 rounded-lg border text-xs text-left transition ${prefs.theme === th.id ? 'border-cyan-500 bg-zinc-800' : 'border-zinc-700 bg-zinc-900 hover:bg-zinc-800'}`}>
<span className={`w-6 h-6 rounded-md bg-gradient-to-br ${th.swatch} ring-1 ring-black/40 shrink-0 ${prefs.theme === th.id ? `ring-2 ${th.ring}` : ''}`}></span>
<span className="text-zinc-200">{th.name}</span>
{prefs.theme === th.id && <span className="ml-auto text-cyan-400 text-[10px] font-bold"></span>}
</button>
))}
</div>
</div>
{/* Language */}
<div className="mb-5">
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2">{vi ? '🌐 Ngôn ngữ' : '🌐 Language'}</h3>
<div className="flex gap-1.5">
{[{ id: 'vi', label: 'Tiếng Việt' }, { id: 'en', label: 'English' }].map(l => (
<button key={l.id} onClick={() => set('language', l.id)}
className={`flex-1 px-2 py-1.5 rounded-lg border text-xs font-semibold transition ${prefs.language === l.id ? 'border-cyan-500 bg-cyan-900/40 text-cyan-300' : 'border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800'}`}>
{l.label}
</button>
))}
</div>
<p className="text-[10px] text-zinc-500 mt-1">{vi ? 'Áp dụng ngay cho menu & hướng dẫn.' : 'Applies immediately to menus & guide.'}</p>
</div>
{/* Button font size */}
<div className="mb-5">
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2">{vi ? '🔠 Cỡ chữ nút bấm' : '🔠 Button font size'}</h3>
<div className="flex gap-1.5">
{[{ id: 'sm', label: vi ? 'Nhỏ' : 'Small' }, { id: 'md', label: vi ? 'Vừa' : 'Medium' }, { id: 'lg', label: vi ? 'Lớn' : 'Large' }].map(f => (
<button key={f.id} onClick={() => set('buttonFontSize', f.id)}
className={`flex-1 px-2 py-1.5 rounded-lg border text-xs font-semibold transition ${prefs.buttonFontSize === f.id ? 'border-cyan-500 bg-cyan-900/40 text-cyan-300' : 'border-zinc-700 bg-zinc-900 text-zinc-400 hover:bg-zinc-800'}`}>
{f.label}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-zinc-800">
<button onClick={onClose} className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 rounded text-xs font-bold text-white transition">{vi ? 'Hủy' : 'Cancel'}</button>
<button onClick={onClose} className="px-4 py-1.5 bg-cyan-700 hover:bg-cyan-600 rounded text-xs font-bold text-white transition">{vi ? 'Lưu & Đóng' : 'Save & Close'}</button>
</div>
</div>
</div>
);
};
const AIPresetModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
@@ -8517,7 +8904,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
keybedMouseDownRef.current = true;
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
@@ -8530,7 +8917,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (keybedMouseDownRef.current) {
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
window.triggerMidiVuActivity((st.parent_tab_id && st.parent_tab_id.startsWith('session_') ? '_sess_' : '') + st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
@@ -14273,7 +14660,7 @@ const App = () => {
setInstrumentDropdownTrackId(null);
setInstrumentDropdownBtnRect(null);
setSynthCategory(null);
// Trigger SpessaSynth load + program change when soundfont instrument selected
// Trigger FluidSynth load + program change when soundfont instrument selected
if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) {
const sfId = instrumentId.replace('sf_', '');
window.SonicSF.selectInstrument(mch, sfBank || 0, sfProg || 0, sfId);
@@ -14500,6 +14887,11 @@ const App = () => {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(as.trackId, scaledVel);
}
// Gi VU theo trưng đ: tăng counter note đang gi
// (tick gi peak cho ti khi note-off)
try {
heldMidiNotesRef.current[as.trackId] = (heldMidiNotesRef.current[as.trackId] || 0) + 1;
} catch (err) { }
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe);
});
}
@@ -14512,6 +14904,10 @@ const App = () => {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(at.id, scaledVel);
}
// Gi VU theo trưng đ: tăng counter note đang gi
try {
heldMidiNotesRef.current[at.id] = (heldMidiNotesRef.current[at.id] || 0) + 1;
} catch (err) { }
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, atProg, atDest, atCh, atSe);
});
}
@@ -14535,6 +14931,16 @@ const App = () => {
if (!st.synth_engine && st.midiChannel === undefined) return;
var stCh = assignTrackMidiChannel(st, stopTracks);
window.SonicSF.stopNote(stCh, pitch);
// Gim counter note đang gi hết note VU đưc phép decay/tt
// (tick s thy counter = 0 và không còn gi peak na)
try {
if (heldMidiNotesRef.current[st.id] !== undefined) {
heldMidiNotesRef.current[st.id] = Math.max(0, heldMidiNotesRef.current[st.id] - 1);
if (heldMidiNotesRef.current[st.id] === 0) {
delete heldMidiNotesRef.current[st.id];
}
}
} catch (err) { }
});
}
}
@@ -15494,6 +15900,11 @@ const App = () => {
}, [isPlaying]);
const midiVuActivityRef = useRef({});
// Đếm s note MIDI đang GI per-track (ARM + MIDI keyboard live input).
// VU tick dùng ref này: còn note gi gi peak (không decay) VU animate
// ĐÚNG trưng đ âm thanh (user bug: nhn phím gi âm còn kêu nhưng VU tt
// sau ~0.5s vì decay 0.75/frame). Hết note (note-off) mi decay/tt.
const heldMidiNotesRef = useRef({});
const triggerMidiVuActivity = (trackId, velocity) => {
if (!trackId) return;
const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
@@ -15538,6 +15949,51 @@ const App = () => {
const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false);
const [aiPresetVersion, setAiPresetVersion] = useState(0);
const [showMasteringModal, setShowMasteringModal] = useState(false);
// Help / About / Preferences (menu Help + Tools)
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [helpModalOpen, setHelpModalOpen] = useState(false);
const [preferencesModalOpen, setPreferencesModalOpen] = useState(false);
const [prefs, setPrefs] = useState(() => {
try {
const saved = localStorage.getItem('sf_prefs');
if (saved) return JSON.parse(saved);
} catch (e) { }
return { theme: 'dark', language: 'vi', buttonFontSize: 'md' };
});
// Áp dng theme + font size lên <html> ngay khi đi
useEffect(() => {
try {
document.documentElement.setAttribute('data-theme', prefs.theme || 'dark');
document.documentElement.setAttribute('data-btnfont', prefs.buttonFontSize || 'md');
localStorage.setItem('sf_prefs', JSON.stringify(prefs));
} catch (e) { }
}, [prefs]);
// Load preferences t server (nếu có tài khon) khi khi đng
useEffect(() => {
let cancelled = false;
if (window.SonicAPI && window.SonicAPI.getPreferences) {
window.SonicAPI.getPreferences().then(res => {
if (cancelled || !res || !res.success || !res.preferences) return;
const p = res.preferences;
if (p.theme || p.language || p.buttonFontSize) {
setPrefs(prev => ({
theme: p.theme || prev.theme,
language: p.language || prev.language,
buttonFontSize: p.buttonFontSize || prev.buttonFontSize
}));
}
}).catch(() => { });
}
return () => { cancelled = true; };
}, []);
// Lưu preferences lên server khi đi
const handlePrefsChange = (next) => {
setPrefs(next);
try { localStorage.setItem('sf_prefs', JSON.stringify(next)); } catch (e) { }
if (window.SonicAPI && window.SonicAPI.savePreferences) {
window.SonicAPI.savePreferences(next).catch(() => { });
}
};
// Unified FX Rack target context (unified_fx_rack_panel.md): { trackId, trackName } | null
const [fxRackTarget, setFxRackTarget] = useState(null);
window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName });
@@ -17782,6 +18238,33 @@ const App = () => {
for (let i = 0; i < endSample - startSample; i++) {
resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0;
}
} else if (effectType === 'invert_phase') {
// DSP: đo pha nhân -1 toàn b vùng chn/clip
for (let i = startSample; i < endSample; i++) {
resultData[i] = -resultData[i];
}
} else if (effectType === 'swap_channels') {
// DSP: đo kênh L/R buffer 2 kênh (nếu có), hoán đi d liu
const srcBuffer = subTab.buffer;
if (srcBuffer.numberOfChannels >= 2) {
const l = srcBuffer.getChannelData(0).slice();
const r = srcBuffer.getChannelData(1).slice();
const out = ctx.createBuffer(2, eff.length, sr);
out.getChannelData(0).set(r);
out.getChannelData(1).set(l);
resultBuffer = out;
resultData = resultBuffer.getChannelData(0);
} else {
// Mono không đi kênh đưc, gi nguyên
showToast('Buffer mono — không có kênh L/R để hoán đổi.', 'info');
return;
}
} else if (effectType === 'reverse') {
// DSP: đo ngưc thi gian vùng chn/clip
const seg = resultData.slice(startSample, endSample);
for (let i = 0; i < seg.length; i++) {
resultData[startSample + i] = seg[seg.length - 1 - i];
}
}
// Update subTab buffer state
@@ -17794,7 +18277,11 @@ const App = () => {
selectionEnd: null
};
}));
showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success');
const effectLabels = {
normalize: 'Normalize', gain: 'Gain', pitch: 'Pitch',
invert_phase: 'Phase Invert', swap_channels: 'Swap L/R', reverse: 'Reverse'
};
showToast(`Đã áp dụng ${effectLabels[effectType] || effectType} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success');
};
const exportSubTabBuffer = async tabId => {
const subTab = subTabs.find(s => s.id === tabId);
@@ -20595,6 +21082,9 @@ const App = () => {
// âm cũ user 06:45: tt âm MAIN items khi vào SECTION-TAB VU main
// items phi tt theo, không "din" tiếp).
try { midiVuActivityRef.current = {}; } catch (e) { }
// Clear luôn counter note đang gi nếu không, sau stop (âm đã dng)
// tick vn thy heldCnt > 0 gi peak VU dính mãi (user bug 08:08).
try { heldMidiNotesRef.current = {}; } catch (e) { }
stopMidiCapture();
if (window.SonicSF) {
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
@@ -25744,8 +26234,18 @@ STRICT CONSTRAINTS:
try {
// Khi STOP (không play, không recording): v VU RNG không đc analyser/
// activity hết animation dính + master không full sau khi dng play.
// NGOI L: nếu có MIDI activity live (bt ARM track + bm phím MIDI
// keyboard KHÔNG play/record) thì KHÔNG early-return track VU phi
// nhy theo midiVuActivityRef + decay (user bug: ARM + phím MIDI VU
// đng yên vì block này v rng và return trưc khi đc activity).
const isRecActive = activeAudioRecordersRef.current && Object.keys(activeAudioRecordersRef.current).length > 0;
if (!isPlaying && !isRecActive) {
let liveMidiVu = false;
try {
for (const _k in midiVuActivityRef.current) {
if (midiVuActivityRef.current[_k] > 0.001) { liveMidiVu = true; break; }
}
} catch (e) { }
if (!isPlaying && !isRecActive && !liveMidiVu) {
setMasterVU(0);
setMasterMeterPeak(0);
Object.keys(trackVuRefs.current).forEach(k => {
@@ -25824,7 +26324,10 @@ STRICT CONSTRAINTS:
// Section item trên MAIN track: VU theo sub-nodes ca track này (key
// <trackId>_sub_*) âm section đi thng masterBus.input (không qua
// node chính) node chính không có tín hiu VU track đng yên.
if (audioPeak <= 0.001) {
// CH fallback cho canvas SECTION (_sess_) KHÔNG cho canvas MAIN:
// section-tab đang play chuyn sang MAIN SESSION VU track MAIN
// không đưc animate theo âm section (user bug).
if (audioPeak <= 0.001 && isSessVu) {
for (var sk in trackNodes) {
if (sk.indexOf(trackId + '_sub_') === 0) {
const sn = trackNodes[sk];
@@ -25846,14 +26349,20 @@ STRICT CONSTRAINTS:
// velocity thp (âm SF nh < 0.03) VU không nhy (user bug 07:10).
let midiPeak = isAudible ? (midiVuActivityRef.current[vuKey] || 0) : 0;
if (midiPeak > 0) {
// Decay 0.75 (~0.5s) cân bng: note đơn/velocity thp hin th rõ
// NHƯNG tt nhanh sau hết note (decay 0.85 ~1s quá lâu user:
// "vn din animation" sau khi âm hết bug 07:15).
// ARM + MIDI keyboard: còn note đang GI gi nguyên peak, KHÔNG
// decay VU animate đúng trưng đ âm thanh (user bug 08:08: âm còn
// kêu nhưng VU tt sau ~0.5s vì decay 0.75/frame). Hết note decay
// 0.75 (~0.5s) tt nhanh như trưc.
const heldCnt = heldMidiNotesRef.current[vuKey] || 0;
if (heldCnt > 0) {
// Gi nguyên peak trong khi âm đang kêu (không decay)
} else {
midiVuActivityRef.current[vuKey] = midiPeak * 0.75;
if (midiVuActivityRef.current[vuKey] < 0.01) {
midiVuActivityRef.current[vuKey] = 0;
}
}
}
// KHÔNG gp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mi
// track MIDI gp làm track VU nhy CÙNG NHAU (user bug 07:00).
// audioPeak ch theo âm TRACK tht (vNode/sub-node analyser).
@@ -26229,7 +26738,17 @@ STRICT CONSTRAINTS:
}, {
label: 'DSP Tools Panel',
icon: 'wrench',
action: () => openPanel('python_tools')
action: () => {
// DSP Tool đã đưc move vào SUB-TAB editor (audioclip). M sub-tab
// edit cho track/clip đang chn nếu chưa có clip m panel cũ.
const t = activeTracks.find(x => x.id === selectedTrackId);
if (t && (t.buffer || (t.clips && t.clips.length))) {
const clipId = (t.clips && t.clips[0]) ? t.clips[0].id : 'default';
handleEditClipInSubTab(t.id, clipId);
} else {
openPanel('python_tools');
}
}
}, {
sep: true
}, {
@@ -26239,13 +26758,25 @@ STRICT CONSTRAINTS:
setPluginManagerModalOpen(true);
window.SonicAPI.listPlugins().then(data => setPluginsData(data)).catch(() => {});
}
}, {
sep: true
}, {
label: 'Preferences...',
icon: 'settings-2',
action: () => setPreferencesModalOpen(true)
}]
}, {
label: 'Help',
items: [{
label: 'About SonicForge',
label: 'Hướng dẫn sử dụng...',
icon: 'book-open',
action: () => setHelpModalOpen(true)
}, {
sep: true
}, {
label: 'About SonicForge Studio...',
icon: 'info',
action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info')
action: () => setAboutModalOpen(true)
}]
}].map(menu => /*#__PURE__*/React.createElement("div", {
key: menu.label,
@@ -28291,6 +28822,36 @@ STRICT CONSTRAINTS:
className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",
title: "Loop count"
}))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase mt-2"
}, /*#__PURE__*/React.createElement("span", null, "DSP"), /*#__PURE__*/React.createElement("span", {
className: "font-mono text-zinc-600 text-[11px] normal-case"
}, "áp dụng vùng chọn / cả clip")), /*#__PURE__*/React.createElement("div", {
className: "grid grid-cols-3 gap-1 mb-2"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => applySubTabEffect(st.id, 'invert_phase', 0),
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "arrow-down-up",
className: "w-3 h-3"
})), "Phase Inv"), /*#__PURE__*/React.createElement("button", {
onClick: () => applySubTabEffect(st.id, 'swap_channels', 0),
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "shuffle",
className: "w-3 h-3"
})), "Swap L/R"), /*#__PURE__*/React.createElement("button", {
onClick: () => applySubTabEffect(st.id, 'reverse', 0),
className: "py-1 bg-sky-900 hover:bg-sky-800 text-sky-200 font-bold rounded text-[14px] border border-sky-700 flex items-center justify-center gap-1"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "arrow-left-right",
className: "w-3 h-3"
})), "Reverse")), /*#__PURE__*/React.createElement("div", {
className: "flex gap-1 justify-between my-2.5"
}, /*#__PURE__*/React.createElement("div", {
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
@@ -28850,7 +29411,9 @@ STRICT CONSTRAINTS:
className: "w-3 h-3 text-zinc-600"
})), prHint ? /*#__PURE__*/React.createElement("span", {
className: "text-cyan-400"
}, prHint) : " Scroll: Zoom"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
}, prHint) : /*#__PURE__*/React.createElement("span", {
className: "text-zinc-600 italic"
}, "Adaptive tips: hover vào vùng làm việc để xem hướng dẫn")))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",
style: {
left: Math.min(contextMenu.x, window.innerWidth - 260),
@@ -29221,6 +29784,18 @@ STRICT CONSTRAINTS:
const active = providers.find(p => p.is_active) || providers[0];
if (active) setSelectedProviderId(active.id);
}
}), /*#__PURE__*/React.createElement(AboutModal, {
isOpen: aboutModalOpen,
onClose: () => setAboutModalOpen(false)
}), /*#__PURE__*/React.createElement(HelpModal, {
isOpen: helpModalOpen,
onClose: () => setHelpModalOpen(false),
lang: prefs.language
}), /*#__PURE__*/React.createElement(PreferencesModal, {
isOpen: preferencesModalOpen,
onClose: () => setPreferencesModalOpen(false),
prefs: prefs,
onPrefsChange: handlePrefsChange
}), /*#__PURE__*/React.createElement(SystemManagerModal, {
isOpen: systemManagerModalOpen,
onClose: () => setSystemManagerModalOpen(false)
@@ -29301,7 +29876,14 @@ STRICT CONSTRAINTS:
},
className: "w-full text-left px-3 py-2 text-sm rounded " + (selectedSoundFontId === sfId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300")
}, sfName);
})
}),
// VST Instruments (scanned bi Plugin Manager)
(instrumentSelectorData?.vst_instruments?.length > 0) && /*#__PURE__*/React.createElement("div", { className: "mt-3 text-[10px] text-zinc-500 uppercase font-bold px-1 flex items-center gap-1" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-3 h-3 text-violet-400" }), "VST Instruments"),
(instrumentSelectorData?.vst_instruments || []).map(v => /*#__PURE__*/React.createElement("button", {
key: 'vst_modal_' + (v.id || v.name),
onClick: () => { setTrackInstrumentWithUndo(instrumentSelectorTrackId, v.id, v.name || v.id); closeInstrumentSelector(); },
className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800 hover:bg-violet-800 text-zinc-300 flex items-center justify-between gap-2"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[9px] text-cyan-400 shrink-0" }, v.type || "VST")))
),
/*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto space-y-0.5" },
/*#__PURE__*/React.createElement("button", {
File diff suppressed because one or more lines are too long
+10
View File
@@ -64,6 +64,16 @@ 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' }),
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
// user yêu cầu dùng window explorer, không nhập tay
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
browseDir: (path) => apiRequest(`/api/v1/media/browse?path=${encodeURIComponent(path)}`, { method: 'GET' }),
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
+110 -17
View File
@@ -3,7 +3,6 @@ import uuid
import time
import glob
import logging
from celery import Celery
from app.config import settings
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
from app.core.audio_editor import (
@@ -13,30 +12,124 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
logger = logging.getLogger(__name__)
celery_app = Celery(
# ──────────────────────────────────────────────────────────────────────────
# Task layer 2 che do:
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
# - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
# (thread nen + registry dict), API contract GIONG het (.delay() tra
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
# ──────────────────────────────────────────────────────────────────────────
try:
from celery import Celery
HAS_CELERY = True
except Exception: # pragma: no cover - frozen desktop slim build
Celery = None
HAS_CELERY = False
if HAS_CELERY:
celery_app = Celery(
"audio_tasks",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND
)
)
celery_app.conf.update(
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
)
)
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
celery_app.conf.beat_schedule = {
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
if os.getenv("SF_DESKTOP") == "1":
celery_app.conf.update(
task_always_eager=True,
task_eager_propagates=True,
broker_url="memory://",
result_backend="cache+memory://",
)
# ── Lich trinh tu dong don dep file het han (Week 5) ──
celery_app.conf.beat_schedule = {
"cleanup-expired-files-every-hour": {
"task": "app.tasks.worker.cleanup_expired_files_task",
"schedule": 3600.0, # Chy mi gi
"schedule": 3600.0, # Chay moi gio
},
}
}
else:
celery_app = None
# Registry in-process cho desktop slim: task_id -> {"status", "result"/"error"}
_results = {}
@celery_app.task
def _task(fn):
"""Wrapper: celery task (server) hoac in-process task (desktop slim)."""
if HAS_CELERY:
return celery_app.task(fn)
return _InProcessTask(fn)
class _InProcessTask:
"""Task chay tren thread nen, ket qua luu vao registry dict — dung cho
bundle desktop khong kem celery (tiet kiem ~40MB)."""
def __init__(self, fn):
self._fn = fn
def delay(self, *args, **kwargs):
import threading
tid = uuid.uuid4().hex
_results[tid] = {"status": "PENDING"}
def _run():
try:
result = self._fn(*args, **kwargs)
_results[tid] = {"status": "SUCCESS", "result": result}
except Exception as e: # noqa: BLE001 - bao loi day du cho UI
logger.exception("In-process task %s failed", tid)
_results[tid] = {"status": "FAILURE", "error": str(e)}
threading.Thread(target=_run, daemon=True, name=f"task-{tid[:8]}").start()
return _SimpleAsyncResult(tid)
class _SimpleAsyncResult:
"""Giong celery.result.AsyncResult ve mat API cho desktop slim."""
def __init__(self, task_id):
self.task_id = task_id
@property
def id(self):
"""Giong celery.result.AsyncResult.id — audio.py dung task.id."""
return self.task_id
@property
def status(self):
return _results.get(self.task_id, {}).get("status", "PENDING")
@property
def result(self):
return _results.get(self.task_id, {}).get("result")
def ready(self):
return _results.get(self.task_id, {}).get("status") in ("SUCCESS", "FAILURE")
def successful(self):
return self.status == "SUCCESS"
def get_task_result(task_id):
"""Tra AsyncResult (celery) hoac _SimpleAsyncResult (desktop slim)."""
if HAS_CELERY:
from celery.result import AsyncResult
return AsyncResult(task_id, app=celery_app)
return _SimpleAsyncResult(task_id)
@_task
def analyze_audio_task(file_id: str):
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
if not os.path.exists(file_path):
@@ -44,7 +137,7 @@ def analyze_audio_task(file_id: str):
return analyze_audio(file_path)
@celery_app.task
@_task
def analyze_ai_task(file_id: str, api_base_url: str = None,
model: str = "deepseek-chat"):
"""
@@ -68,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
)
@celery_app.task
@_task
def edit_audio_task(config: dict):
file_id = config.get("file_id")
@@ -88,7 +181,7 @@ def edit_audio_task(config: dict):
}
@celery_app.task
@_task
def export_audio_task(file_id: str, format: str = "wav",
sample_rate: int = 44100, bit_depth: int = 16):
"""
@@ -121,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
return result
@celery_app.task
@_task
def mix_multitrack_task(session_config: dict):
"""
Task xử hòa âm đa kênh (Multitrack Mixdown).
@@ -174,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
return result
@celery_app.task
@_task
def process_multitrack_session_task(session_config: dict):
"""
Task xử toàn bộ session với nhiều tracks clips.
@@ -270,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
return result
@celery_app.task
@_task
def cleanup_expired_files_task(max_age_hours: int = 24):
"""
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
@@ -305,7 +398,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
}
@celery_app.task
@_task
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
"""
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
+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>
@@ -12,7 +31,7 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/api.js?v=202607271016"></script>
<script src="/static/js/services/api.js?v=202608091400"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
@@ -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=202608070950" defer></script>
<script src="/static/js/app.precompiled.js?v=202608091400" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
@@ -33,8 +52,43 @@
--top-bar-height: 80px;
--status-bar-height: 25px;
--panel-border-color: #2a2a2a;
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
--sf-bg: #1e1e1e;
--sf-panel: #262626;
--sf-header: #2e2e2e;
--sf-border: #181818;
--sf-accent: #00ffcc;
}
/* ── THEME presets (Tools → Preferences) ── */
html[data-theme="dark"] {
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
}
html[data-theme="midnight"] {
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
}
html[data-theme="forest"] {
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
}
html[data-theme="violet"] {
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
}
html[data-theme="graphite"] {
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
}
/* Áp theme lên các vùng chính của app shell */
.daw-app-shell { background-color: var(--sf-bg) !important; }
.daw-panel { background-color: var(--sf-panel) !important; }
.daw-header { background-color: var(--sf-header) !important; }
body { background-color: var(--sf-bg) !important; }
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
html[data-btnfont="sm"] button { font-size: 10px !important; }
html[data-btnfont="md"] button { font-size: 12px !important; }
html[data-btnfont="lg"] button { font-size: 14px !important; }
body {
background-color: #1a1a1a;
color: #c0c0c0;
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# build_linux.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (deb + AppImage)
# Chay tren Linux: bash build_linux.sh
# Yeu cau: python3, pip, node/npm, rust/cargo, webkit2gtk-4.1, libappindicator,
# librsvg (xem README / DISTRIBUTION_PLAN.md)
set -euo pipefail
cd "$(dirname "$0")"
echo "== [1/6] Python dependencies =="
# PyInstaller tren Linux can objdump (binutils). May build that phai co:
# sudo apt-get install -y binutils
python3 -m pip install --upgrade pip >/dev/null
python3 -m pip install -r requirements.txt pyinstaller
echo "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
npm install --no-audit --no-fund
if [ ! -d "node_modules/@babel/standalone" ]; then
echo "Thieu @babel/standalone - dang cai them..."
npm install @babel/standalone --no-audit --no-fund
fi
node build.mjs
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
python3 -m PyInstaller engine.spec --clean --noconfirm
echo "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
if [ ! -f "dist/daw_engine/daw_engine" ]; then
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
exit 1
fi
rm -rf src-tauri/resources/daw_engine
mkdir -p src-tauri/resources/daw_engine
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
echo "== [5/6] Kiem tra resources truoc khi tauri build =="
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
exit 1
fi
echo "== [6/6] Tauri build (deb + AppImage) =="
npm install -D @tauri-apps/cli --no-audit --no-fund
npx tauri build
echo ""
echo "== DONE =="
echo " deb : src-tauri/target/release/bundle/deb/sonicforge-daw_1.0.0_amd64.deb"
echo " AppImage: src-tauri/target/release/bundle/appimage/SonicForgeDAW_1.0.0_amd64.AppImage"
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# build_macos.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (.app + .dmg)
# Chay tren macOS: bash build_macos.sh
# LUU Y: macOS yeu cau codesign + notarize truoc khi phat hanh ra ngoai
# (Gatekeeper). Xem DISTRIBUTION_PLAN.md.
set -euo pipefail
cd "$(dirname "$0")"
echo "== [1/6] Python dependencies =="
python3 -m pip install --upgrade pip >/dev/null
python3 -m pip install -r requirements.txt pyinstaller
echo "== [2/6] Frontend bundle =="
npm install --no-audit --no-fund
if [ ! -d "node_modules/@babel/standalone" ]; then
npm install @babel/standalone --no-audit --no-fund
fi
node build.mjs
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
python3 -m PyInstaller engine.spec --clean --noconfirm
echo "== [3.5/6] Verify bundle contents =="
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
if [ ! -f "dist/daw_engine/daw_engine" ]; then
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
exit 1
fi
rm -rf src-tauri/resources/daw_engine
mkdir -p src-tauri/resources/daw_engine
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
echo "== [5/6] Kiem tra resources =="
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
exit 1
fi
echo "== [6/6] Tauri build (dmg) =="
npm install -D @tauri-apps/cli --no-audit --no-fund
npx tauri build
echo ""
echo "== DONE =="
echo " dmg: src-tauri/target/release/bundle/dmg/SonicForgeDAW_1.0.0_x64.dmg"
echo " (Codesign/notarize: codesign --deep -s \"Developer ID Application: ...\" "
echo " src-tauri/target/release/bundle/macos/SonicForgeDAW.app ; xcrun notarytool submit ...)"
+95
View File
@@ -0,0 +1,95 @@
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI)
# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
# LUU Y: file nay chi dung ky tu ASCII (khong dau, khong em-dash) - PowerShell 5.1
# doc .ps1 khong BOM theo ANSI, ky tu Unicode bi hong -> "String is missing terminator".
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
Write-Host "== [1/6] Python dependencies =="
python -m pip install --upgrade pip
python -m pip install -r requirements.txt pyinstaller pywin32
Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
npm install
# Dam bao @babel/standalone co (build.mjs import truc tiep - da gap
# ERR_MODULE_NOT_FOUND tren may Windows khi package.json cu thieu dep).
if (-not (Test-Path "node_modules\@babel\standalone")) {
Write-Host "Thieu @babel/standalone - dang cai them..."
npm install @babel/standalone --no-audit --no-fund
}
if (-not (Test-Path "node_modules\@babel\standalone")) {
Write-Host "ERROR: Khong cai duoc @babel/standalone. Kiem tra ket noi npm!" -ForegroundColor Red
exit 1
}
node build.mjs # @babel/standalone; thay cho 'npm run build' (Babel 8 ESM-only CLI conflict)
Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) =="
pyinstaller engine.spec --clean --noconfirm
Write-Host "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
# Bat loi bundle NGAY tai build (da gap 2 lan: chay pyinstaller tu noi khac
# -> static/templates thieu -> exe crash 'Directory ...\app\static does not exist').
# Dung tools/verify_bundle.py (parse TOC bang ast, chap nhan ca / va \) thay
# vi regex thong thuong (TOC Windows co the dung backslash -> false positive).
# Truoc tien kiem tra engine.spec phai la ban moi (collect_data_files).
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
if ($specContent -notmatch "collect_data_files\('app'") {
Write-Host "ERROR: engine.spec CU (thieu collect_data_files('app')). Pull code moi truoc khi build!" -ForegroundColor Red
exit 1
}
python tools\verify_bundle.py
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Bundle thieu asset - dung build, kiem tra engine.spec datas!" -ForegroundColor Red
exit 1
}
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
# Tauri bundle resources -> resource_dir()/daw_engine/daw_engine.exe luc runtime.
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) {
Write-Host "ERROR: dist\daw_engine\daw_engine.exe khong ton tai (onedir build loi?)" -ForegroundColor Red
exit 1
}
if (Test-Path "src-tauri\resources\daw_engine") {
Remove-Item -Recurse -Force "src-tauri\resources\daw_engine"
}
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
}
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
exit 1
}
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
exit 1
}
npm install -D @tauri-apps/cli
npx tauri build
Write-Host ""
Write-Host "== DONE =="
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
Write-Host ""
Write-Host "== XAC MINH SAU KHI CAI DAT (quan trong) =="
Write-Host " Mo %APPDATA%\SonicForgeDAW\logs\spawn.log - phai thay:"
Write-Host " [resource_dir/daw_engine (map layout)] ...exists=True"
Write-Host " Neu exists=False: bundle resources KHONG vao installer (chay lai buoc [4/6])."
Write-Host " Engine phai nam o: <thu muc cai dat>\daw_engine\daw_engine.exe"
+126
View File
@@ -0,0 +1,126 @@
"""SonicForge DAW — Desktop engine launcher (PyInstaller entry point).
Chay FastAPI backend duoi dang tien trinh nen (sidecar) cho ban cai Standalone
Windows (Tauri v2 shell). Tu chon port 8000-8010 (fallback neu bi chiem), tu
thoat khi tien trinh cha (Tauri) ket thuc Graceful Shutdown.
"""
import logging
import os
import socket
import sys
import threading
import time
PORT_RANGE = (8000, 8010)
APP_DATA_DIR_NAME = "SonicForgeDAW"
def _pick_port():
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# KHONG dung SO_REUSEADDR: tren Windows no CHO PHEP bind trung port
# (2 engine cung 8000 -> request roi vao engine ngau nhien -> UI loi).
# Listen socket khong can SO_REUSEADDR (TIME_WAIT chi ap dung cho
# connection socket, khong phai listen socket).
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return PORT_RANGE[0]
def _parent_alive(pid):
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
# => watchdog se GIET LUON tien trinh cha (Tauri) sau 1s dau tien.
# Dung OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION): tra ve NULL khi
# pid khong con ton tai.
if os.name == "nt":
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return False # tien trinh da chet
ctypes.windll.kernel32.CloseHandle(h)
return True
try:
os.kill(pid, 0)
return True
except OSError:
return False
def main():
# PHONG THU (fix ban 1.1.2): neu exe bi goi nhu 'python -c ...' — vd code
# cu kiem tra thu vien bang subprocess.run([sys.executable, '-c', 'import X'])
# thi frozen sys.executable = daw_engine.exe -> se chay CA ENGINE o day.
# Thoat ngay, KHONG khoi dong server, tranh de quy spawn vo han.
if len(sys.argv) > 1 and sys.argv[1] == "-c":
return
os.environ.setdefault("SF_DESKTOP", "1")
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
log_dir = os.path.join(
os.environ.get("APPDATA") or os.path.expanduser("~"),
APP_DATA_DIR_NAME, "logs",
)
try:
os.makedirs(log_dir, exist_ok=True)
except OSError:
log_dir = os.path.expanduser("~")
# Windowed app (engine.spec console=False): Windows khong tao console ->
# sys.stdout/sys.stderr = None -> uvicorn logging crash
# ('NoneType' object has no attribute 'isatty'). Tro stdout/stderr vao
# FILE (KHONG phai devnull) — neu devnull thi moi exception bi nuot im
# lang (trieu chung 'stuck khong logs, khong loi').
if sys.stdout is None:
sys.stdout = open(os.path.join(log_dir, "stdout.log"), "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.path.join(log_dir, "stderr.log"), "w", encoding="utf-8")
port = _pick_port()
os.environ["SF_PORT"] = str(port)
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
try:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"),
logging.StreamHandler(),
],
)
except Exception:
logging.basicConfig(level=logging.INFO)
import uvicorn
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
config = uvicorn.Config(app.main.app, host="127.0.0.1", port=port,
log_level="info", access_log=False)
server = uvicorn.Server(config)
parent_pid = os.environ.get("SF_PARENT_PID")
if parent_pid:
def _watchdog():
pid = int(parent_pid)
while True:
time.sleep(1.0)
if not _parent_alive(pid):
logging.getLogger("desktop_engine").info(
"Parent process exited — shutting down engine")
server.should_exit = True
return
threading.Thread(target=_watchdog, daemon=True).start()
logging.getLogger("desktop_engine").info(
"SonicForge engine listening on 127.0.0.1:%d", port)
server.run()
if __name__ == "__main__":
main()
+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
+160
View File
@@ -0,0 +1,160 @@
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
binaries = collect_dynamic_libs('pedalboard')
binaries += collect_dynamic_libs('soundfile')
# Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
# package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
# duoc bundle qua collect_data_files('app').
_SPEC_ROOT = os.path.abspath(SPECPATH)
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
# bundle thieu static/templates -> exe crash luc chay. Fix: chen _SPEC_ROOT
# vao sys.path TRUOC khi collect de import 'app' luon hoạt dong.
import sys as _sys
if _SPEC_ROOT not in _sys.path:
_sys.path.insert(0, _SPEC_ROOT)
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
if not datas:
print("WARN: collect_data_files('app') tra ve rong - dung datas tinh absolute")
datas = [
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg
(os.path.join(_SPEC_ROOT, 'app', 'static'), 'app/static'), # js/css/processors
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
]
datas += [
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
]
# ══════════════════════════════════════════════════════════════════════════
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
# ──────────────────────────────────────────────────────────────────────────
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
# render_engine, audio_features).
# ══════════════════════════════════════════════════════════════════════════
import importlib.util as _ilu
import glob as _glob
def _scan_pkg_modules(pkg_name: str):
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
_spec = _ilu.find_spec(pkg_name)
if _spec is None or _spec.origin is None:
print(f"WARN: khong tim thay package '{pkg_name}' - bo qua scan")
return []
_pkg_dir = os.path.dirname(os.path.abspath(_spec.origin))
_out = []
for _ext in ('*.py', '*.pyd', '*.so'):
for _f in _glob.glob(os.path.join(_pkg_dir, '**', _ext), recursive=True):
_rel = os.path.relpath(_f, _pkg_dir)
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
_sub = os.path.dirname(_rel).replace(os.sep, '.')
_mod = (pkg_name + '.' + _sub + '.' + _base) if _sub else (pkg_name + '.' + _base)
if _mod not in _out:
_out.append(_mod)
return _out
_scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
# Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
_hidden = [
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'pedalboard',
'soundfile',
'sf2utils',
'mido.backends.rtmidi',
] + _scipy_signal_hidden
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
_excludes = [
'tkinter',
# libs da thay the (audio_features.py)
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
# celery/redis chi dung cho server (Docker), khong cho desktop
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
'click_didyoumean', 'click_plugins', 'click_repl',
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
# khong dung trong desktop
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
# keo vao nham boi hooks_contrib (app khong import bao gio)
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
]
a = Analysis(
['desktop_engine.py'],
pathex=[_SPEC_ROOT],
binaries=binaries,
datas=datas,
hiddenimports=_hidden,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=_excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='daw_engine',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # True khi debug (xem log truc tiep), False cho production
# Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
icon='src-tauri/icons/favicon.ico',
)
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
name='daw_engine',
)
+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)
+233
View File
@@ -0,0 +1,233 @@
// SonicForge DAW — desktop shell: spawn/terminate daw_engine sidecar.
//
// QUAN TRONG (fix bản 1.1.1 — engine không chạy trên Windows):
// tauri.conf.json bundle.resources giờ dùng dạng MAP:
// { "resources/daw_engine": "daw_engine/" }
// vì dạng ARRAY ("resources/daw_engine") copy file tới
// $RESOURCE_DIR/resources/daw_engine/... (giữ tiền tố "resources/"),
// trong khi code cũ tìm ở $RESOURCE_DIR/daw_engine/... -> exists=false.
// Dạng map (Walk mode) giữ nguyên cây thư mục (_internal) dưới đích
// "daw_engine/" — đúng layout lib.rs chờ.
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
struct EngineProcess(Mutex<Option<CommandChild>>);
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
let exe_name = if cfg!(windows) { "daw_engine.exe" } else { "daw_engine" };
vec![
// 1. Layout chuẩn (resources map): $RESOURCE/daw_engine/daw_engine.exe
(
res_dir.join("daw_engine").join(exe_name),
"resource_dir/daw_engine (map layout)".into(),
),
// 2. Legacy (resources array cũ giữ tiền tố resources/)
(
res_dir.join("resources").join("daw_engine").join(exe_name),
"resource_dir/resources/daw_engine (legacy array)".into(),
),
// 3. Portable: engine đặt cạnh exe
(
exe_dir.join("daw_engine").join(exe_name),
"exe_dir/daw_engine (portable)".into(),
),
// 4. Dev mode: target/{debug,release} -> src-tauri/resources
(
exe_dir.join("..").join("resources").join("daw_engine").join(exe_name),
"exe_dir/../resources/daw_engine (dev)".into(),
),
]
}
fn list_dir_snippet(dir: &Path) -> String {
let mut s = String::new();
match std::fs::read_dir(dir) {
Ok(rd) => {
let mut n = 0;
for e in rd.flatten() {
if n > 0 {
s.push_str(", ");
}
s.push_str(&e.file_name().to_string_lossy());
n += 1;
if n >= 15 {
s.push_str(", ...");
break;
}
}
}
Err(_) => s.push_str("<khong doc duoc dir>"),
}
s
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let res_dir = app
.path()
.resource_dir()
.expect("resource dir not found");
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_default();
let candidates = engine_candidates(&res_dir, &exe_dir);
// Ghi log spawn de chan doan — vao %APPDATA%/SonicForgeDAW/logs/
// spawn.log (thu muc luon ton tai), KHONG ghi vao engine_dir.
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let spawn_log_path = std::path::Path::new(&log_dir)
.join("SonicForgeDAW")
.join("logs")
.join("spawn.log");
if let Some(parent) = spawn_log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut log_line = format!(
"resource_dir={}\nexe_dir={}\n",
res_dir.display(),
exe_dir.display()
);
let mut found: Option<(PathBuf, String)> = None;
for (path, label) in &candidates {
let exists = path.exists();
log_line.push_str(&format!(
" [{label}] {} exists={}\n",
path.display(),
exists
));
if found.is_none() && exists {
found = Some((path.clone(), label.clone()));
}
}
if found.is_none() {
log_line.push_str(&format!(
" NOT FOUND — resource_dir contents: {}\n exe_dir contents: {}\n",
list_dir_snippet(&res_dir),
list_dir_snippet(&exe_dir)
));
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&spawn_log_path)
{
use std::io::Write;
let _ = f.write_all(log_line.as_bytes());
}
match found {
Some((engine_exe, label)) => {
match app
.shell()
.command(&engine_exe)
.env("SF_PARENT_PID", std::process::id().to_string())
.spawn()
{
Ok((_rx, child)) => {
app.manage(EngineProcess(Mutex::new(Some(child))));
println!(
"Python Background Engine started ({label}): {}",
engine_exe.display()
);
}
Err(e) => {
println!("Failed to spawn daw_engine {engine_exe:?}: {e}");
app.manage(EngineProcess(Mutex::new(None)));
}
}
}
None => {
println!(
"daw_engine binary not found in any candidate path — xem spawn.log de biet chi tiet"
);
app.manage(EngineProcess(Mutex::new(None)));
}
}
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
// IPC: engine ghi ipc/pick_dir.request → thread nay mo NATIVE dialog
// (IFileDialog/Explorer — tauri-plugin-dialog) → ghi ket qua vao
// ipc/pick_dir.response → engine tra ve cho frontend.
let data_root = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let ipc_dir = std::path::Path::new(&data_root)
.join("SonicForgeDAW")
.join("ipc");
if let Ok(()) = std::fs::create_dir_all(&ipc_dir) {
// Marker: engine biet bridge ton tai (khong phai chay standalone)
let _ = std::fs::write(ipc_dir.join("tauri_bridge_ready"), "1");
}
let ipc_dir_for_thread = ipc_dir.clone();
let app_handle = app.handle().clone();
std::thread::spawn(move || {
loop {
let req = ipc_dir_for_thread.join("pick_dir.request");
if req.exists() {
let _ = std::fs::remove_file(&req);
let resp = ipc_dir_for_thread.join("pick_dir.response");
let _ = std::fs::remove_file(&resp);
// Dialog phai chay tren main thread (GTK/Windows message loop)
let handle = app_handle.clone();
let resp_for_main = resp.clone();
// Clone rieng cho closure: run_on_main_thread(&self, F)
// borrow `handle`, closure (move) phai dung ban clone.
let handle_for_closure = handle.clone();
let _ = handle.run_on_main_thread(move || {
// tauri-plugin-dialog v2: blocking_pick_folder() tra
// Option<FilePath> (enum Path(PathBuf) | Url(Url))
let picked: Option<FilePath> = handle_for_closure
.dialog()
.file()
.blocking_pick_folder();
let val = picked
.map(|p| match p {
FilePath::Path(pb) => pb.to_string_lossy().to_string(),
FilePath::Url(u) => u.to_string(),
})
.unwrap_or_default();
let _ = std::fs::write(&resp_for_main, val);
});
// Cho den khi co response (user co the de dialog mo lau)
let mut waited_ms = 0u32;
while !resp.exists() && waited_ms < 300_000 {
std::thread::sleep(std::time::Duration::from_millis(100));
waited_ms += 100;
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
});
Ok(())
})
.on_window_event(|window, event| {
// Terminate sidecar khi DAW window dong — tranh orphan process
if let tauri::WindowEvent::Destroyed = event {
let child = window
.state::<EngineProcess>()
.0
.lock()
.ok()
.and_then(|mut lock| lock.take());
if let Some(child) = child {
let _ = child.kill();
println!("daw_engine sidecar terminated.");
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+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": ["nsis", "msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/favicon.ico"
],
"resources": {
"resources/daw_engine": "daw_engine/"
},
"externalBin": [],
"windows": {
"nsis": {
"installerHooks": "hooks.nsh"
}
}
}
}
+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())
+106
View File
@@ -1,3 +1,109 @@
### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static')
- **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True.
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize)
- **Ghi chú/Test (nếu có):** Test TOC Windows repr `'app\\\\static\\\\js\\\\app.js'` → normalize ra `app/static/js/app.js` → regex match. User: pull code mới rồi chạy lại build_windows.ps1 (bước 3.5 sẽ hết báo thiếu nếu bundle thực sự đủ).
---
### [2026-08-08] FIX (LAN 3 - GOC ROOT): 'app\\static does not exist' — sys.path thieu CWD khi chay 'pyinstaller' (entry point)
- **Tóm tắt thay đổi:** Lỗi 'app\static does not exist' vẫn tái diễn dù đã 2 lần sửa datas (absolute path roi collect_data_files). Lần này tìm ra GOC ROOT THẬT SỰ: lệnh `pyinstaller engine.spec` (entry-point script cua PyInstaller) KHONG them CWD vao sys.path — chi `python -m PyInstaller` moi them. `collect_data_files('app')` import package 'app' qua sys.path -> khong thay -> tra ve [] AM THAM -> bundle thieu static/templates (dung canh: tren may dev chay `python -m PyInstaller` nen CWD co trong sys.path -> tuong da dung; may Windows chay `pyinstaller` -> CWD khong co -> collect rong -> TOC khong co app/static — dung voi loi verify cua user). Fix trong `engine.spec`:
1. `sys.path.insert(0, _SPEC_ROOT)` TRUOC khi goi collect_data_files — import 'app' luon hoạt dong bat ke CWD.
2. Fallback cuoi: neu collect_data_files van tra ve rong -> datas TINH absolute (app/templates, app/static, app/models) + in WARN de debug.
- **Cac file anh huong:** `engine.spec` (sys.path insert + fallback datas tinh)
- **Ghi chu/Test (neu co):** VERIFY DUNG DIEU KIEN THAT BAI: build tu /tmp (CWD khac project root — giong `pyinstaller` entry point khong co CWD trong sys.path) -> TOC co app/static (124) + app/templates (12), khong co WARN "tra ve rong"; tools/verify_bundle.py -> OK exit 0; chay binary frozen -> /health OK, index 200, static js 200. pytest 86 passed. User: pull engine.spec moi roi chay lai build_windows.ps1.
---
### [2026-08-08] FIX (lan 2): ERR_MODULE_NOT_FOUND '@babel/standalone' vẫn xảy ra — build_windows.ps1 TỰ cài dep nếu thiếu
- **Tóm tắt thay đổi:** User vẫn gặp ERR_MODULE_NOT_FOUND '@babel/standalone' dù đã thêm vào package.json — máy Windows chưa pull package.json mới hoặc npm install chưa cài. Fix: `build_windows.ps1` bước [2/6] — sau `npm install`, TỰ KIỂM TRA `node_modules\@babel\standalone`; thiếu → `npm install @babel/standalone --no-audit --no-fund`; vẫn thiếu → ERROR + exit 1. Không còn phụ thuộc package.json mới trên máy user.
- **Các file ảnh hưởng:** `build_windows.ps1` (bước 2 tự cài @babel/standalone)
- **Ghi chú/Test (nếu có):** File ASCII-only + UTF-8 BOM (không lặp lỗi PS5.1), brace depth 0. User: pull build_windows.ps1 mới (hoặc toàn bộ) rồi chạy lại.
---
### [2026-08-08] FIX: build.mjs ERR_MODULE_NOT_FOUND '@babel/standalone' trên Windows — package.json thiếu dependency
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 (bước 2/6 node build.mjs) nhận `Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@babel/standalone' imported from build.mjs`. Root cause: `build.mjs` import `@babel/standalone` nhưng **package.json KHÔNG khai báo** dependency này (máy dev có sẵn trong node_modules từ trước nên không lộ; máy Windows `npm install` chỉ cài theo package.json → thiếu). Fix: thêm `"@babel/standalone": "^7.29.8"` vào dependencies + chạy `npm install` cập nhật `package-lock.json`.
- **Các file ảnh hưởng:** `package.json` (+@babel/standalone), `package-lock.json` (npm install)
- **Ghi chú/Test (nếu có):** npm install OK (up to date), package-lock có node_modules/@babel/standalone, `node build.mjs` BUILD OK 1123403 bytes + node --check OK. User: pull code mới rồi chạy lại build_windows.ps1.
---
### [2026-08-08] FIX: verify bundle [3.5/6] false-positive 'app/static thieu' + check spec version — tools/verify_bundle.py
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 — bước verify [3.5/6] mới báo "ERROR: Bundle thieu: app/static, app/templates" nhưng thực tế bundle ĐỦ (collect_data_files('app') đã hoạt động — verify bằng build Linux). Root cause: script verify cũ dùng regex `-notmatch "app/static"` trên raw TOC — trên Windows TOC chứa `app\static` (BACKSLASH) → regex forward-slash không match → FALSE POSITIVE báo thiếu. Fix:
1. **tools/verify_bundle.py** (mới): verify TOC bằng Python — đọc text, `replace('\\','/')` normalize backslash→forward, regex theo prefix `app/static(?=[/'"]|$)`; exit 0 OK / 1 thiếu; không có TOC → WARN + exit 0 (không chặn build). Test 4 case: backslash OK, forward OK, thiếu static → ERROR, không TOC → WARN.
2. **build_windows.ps1 [3.5/6]**: trước tiên check `engine.spec` có chứa `collect_data_files('app')` — nếu spec CŨ (chưa pull code mới) → báo rõ "engine.spec CU... Pull code moi" + exit 1 (tránh nhầm lẫn nguyên nhân). Sau đó chạy `python tools\verify_bundle.py` + check `$LASTEXITCODE`.
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (mới), `build_windows.ps1` (bước 3.5 gọi script + check spec)
- **Ghi chú/Test (nếu có):** verify_bundle.py test 4 case đều đúng; chạy với TOC THẬT (build Linux PyInstaller 6.21) → "Thiếu: KHÔNG — OK". Binary frozen: /health OK, index 200, static js 200. pytest 86 passed. User: pull code mới (có tools/verify_bundle.py) rồi chạy lại build_windows.ps1.
---
### [2026-08-08] FIX: build_windows.ps1 lỗi parse PowerShell — "String is missing terminator" (file chứa ký tự Unicode, PS 5.1 đọc theo ANSI)
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 trên Windows nhận ParserError "String is missing terminator" ở dòng 57 + "Missing closing '}'" ở dòng 34. Root cause: bản mình thêm bước verify [3.5/6] có chứa ký tự Unicode (em-dash `—` trong comment + chuỗi tiếng Việt có dấu "thiếu/Dừng/kiểm tra"). PowerShell 5.1 đọc file .ps1 KHÔNG có BOM theo ANSI/Windows-1252 → byte UTF-8 của `—` (E2 80 94) giải mã thành `â€"` — dấu ngoặc kép giả chui vào giữa chuỗi → chuỗi "mất terminator" + block `{}` lệch. Fix:
1. Viết lại toàn bộ build_windows.ps1 **chỉ ASCII** (bỏ dấu tiếng Việt, bỏ em-dash, dùng `->`).
2. Thêm **UTF-8 BOM** (EF BB BF) vào đầu file — PowerShell đọc đúng encoding bất kể.
3. Giữ nguyên bước verify [3.5/6] (TOC check app/static + app/templates).
- **Các file ảnh hưởng:** `build_windows.ps1` (ASCII-only + BOM)
- **Ghi chú/Test (nếu có):** File giờ 0 ký tự non-ASCII, có BOM; brace depth = 0 (cân bằng), không có dòng Write-Host quote lẻ. Lưu ý chung: MỌI .ps1/.bat trong dự án phải ASCII-only + BOM (PS 5.1 ANSI bug) — tránh tiếng Việt có dấu/em-dash trong file script Windows.
---
### [2026-08-08] FIX (LẦN 2): Windows exe vẫn crash 'app\static does not exist' + stuck 'Đang khởi động Engine' — bundle qua collect_data_files + fallback + verify build
- **Tóm tắt thay đổi:** User build bản Windows vẫn gặp RuntimeError `Directory '..._MEIxxxx\app\static' does not exist` (lần 2 — fix trước dùng datas ABSOLUTE theo SPECPATH nhưng trên máy user vẫn thiếu static trong bundle) + UI stuck "Đang khởi động SonicForge Engine". 3 lớp phòng thủ:
1. **engine.spec — bundle qua IMPORT SYSTEM**: bỏ datas đường dẫn tĩnh, thay bằng `collect_data_files('app', excludes=['**/storage/**','**/__pycache__/**','**/*.pyc'])` — PyInstaller tự tìm assets (static/templates/models) qua package import, KHÔNG phụ thuộc CWD/SPECPATH lúc chạy lệnh (nguyên nhân gốc: chạy pyinstaller từ thư mục khác → relative path không resolve → bỏ qua âm thầm). Loại luôn `app/storage` (57MB soundfonts — vô ích trong onefile, config.py đã redirect %APPDATA%). Giữ `md/` (ngoài package) + librosa .pyi.
2. **app/main.py — fallback an toàn**: nếu STATIC_DIR không tồn tại → thử `sys._MEIPASS/app/static``dirname(__file__)/static`; vẫn thiếu → **tự os.makedirs** → StaticFiles không còn crash lúc import (engine không chết, app hiện lỗi rõ thay vì 6 hộp thoại + stuck loader).
3. **build_windows.ps1 — verify post-build [3.5/6]**: sau pyinstaller, đọc Analysis-00.toc kiểm tra `app/static` + `app/templates` có trong bundle — thiếu → in ERROR đỏ + `exit 1` (bắt lỗi NGAY lúc build, không đợi chạy app mới vỡ).
- **Các file ảnh hưởng:** `engine.spec` (collect_data_files('app') + bỏ storage), `app/main.py` (fallback static dir), `build_windows.ps1` (verify TOC post-build)
- **Ghi chú/Test (nếu có):** VERIFY THẬT trên Linux (PyInstaller 6.21, cùng spec): Analysis-00.toc chứa app/static (124), app/templates (12), app/models (5); exe 155MB (giảm 57MB); chạy binary frozen → /health OK, index 200, static js 200, favicon 200, không crash. pytest 86 passed. User cần chạy lại `build_windows.ps1` (bước 3.5 sẽ tự kiểm tra bundle).
---
### [2026-08-08] IMPROVE: About modal — logo dùng favicon của app (app/templates/favicon.svg, serve /favicon.svg)
- **Tóm tắt thay đổi:** User yêu cầu logo trong About modal phải là logo của web/app — favicon lưu trong hệ thống. Trước đây AboutModal dùng div gradient chữ "SF". Fix: thay bằng `<img src="/favicon.svg">` (route đã có sẵn trong app/main.py — serve từ TEMPLATES_DIR; file app/templates/favicon.svg, SVG 1254x1254). Hiển thị 48x48 object-contain, nền tối + border cho nổi trên modal.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (AboutModal img), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081600)
- **Ghi chú/Test (nếu có):** BUILD OK 1123403 bytes, node --check OK, pytest 86 passed. Verify: /favicon.svg 200 image/svg+xml, bundle chứa "favicon.svg".
---
### [2026-08-08] FEAT: Menu Help (About + Hướng dẫn sử dụng) + Tools → Preferences (Theme/Language/Button font size)
- **Tóm tắt thay đổi:** User yêu cầu 2 nhóm tính năng:
1. **Menu Help**:
- `About SonicForge Studio...`**AboutModal** mới: dev **Lộc Phạm**, email **tranloclqd@gmail.com**, version `1.0.0` (khớp tauri.conf.json), build Standalone (Tauri v2 + PyInstaller).
- `Hướng dẫn sử dụng...`**HelpModal** mới: 8 mục hướng dẫn song ngữ (vi/en theo language preference): Bắt đầu nhanh, MIDI & ARM, Piano Roll, FX & Master, SoundFont & VST, AI, Lưu & Xuất, Phím tắt.
2. **Tools → Preferences...****PreferencesModal** mới quản lý:
- **Theme** (5 preset): dark (mặc định), midnight, forest, violet, graphite — áp qua `data-theme` trên `<html>` + CSS variables `--sf-bg/--sf-panel/--sf-header/--sf-border/--sf-accent` (thêm trong index.html `<style>`, override .daw-app-shell/.daw-panel/.daw-header/body/scrollbar).
- **Language**: vi/en — áp ngay cho HelpModal + PreferencesModal (menu chính giữ nguyên — app vốn song ngữ lẫn lộn).
- **Button font size**: sm/md/lg — áp qua `data-btnfont` trên `<html>` + CSS `html[data-btnfont=...] button { font-size: ... !important }`.
3. **Lưu trữ**: localStorage `sf_prefs` + server `/api/v1/user/preferences` (window.SonicAPI.getPreferences/savePreferences — endpoint đã có sẵn). Load server prefs khi khởi động.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (AboutModal/HelpModal/PreferencesModal mới + state prefs + menu Tools/Help + render 3 modal), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (CSS theme + data-btnfont + bump v=202608081500)
- **Ghi chú/Test (nếu có):** BUILD OK 1123418 bytes, node --check OK, pytest 86 passed. Verify serve: index 200, precompiled 200, bundle chứa đủ "Hướng dẫn sử dụng", "tranloclqd@gmail.com", "data-btnfont", "Preferences". Lưu ý: button font size dùng !important nên override cả text-xs của Tailwind — đúng ý "đổi cỡ chữ button" nhưng có thể làm một số nút hơi to/nhỏ so với thiết kế gốc.
---
### [2026-08-08] FIX: VU meter giữ animation đúng TRƯỜNG ĐỘ âm thanh khi ARM + nhấn/giữ phím MIDI keyboard
- **Tóm tắt thay đổi:** User yêu cầu: bật ARM + nhấn phím MIDI keyboard preview — khi âm CÒN PLAY thì VU meter phải còn animate cho đúng trường độ âm thanh. Trước đây: note-on → `triggerMidiVuActivity` set peak → VU tick decay 0.75/frame → VU tắt sau ~0.5s DÙ âm còn kêu (note dài 60000ms, chỉ tắt khi note-off). Root cause: VU tick decay theo thời gian, không biết note đang giữ. Fix:
1. **`heldMidiNotesRef`** (mới): đếm số note MIDI đang GIỮ per-track (ARM + keyboard live). Note-on → tăng counter (cả nhánh armed sub-tab PIANO_ROLL lẫn armed main track); note-off → giảm, về 0 thì xóa.
2. **VU tick**: `heldCnt = heldMidiNotesRef.current[vuKey]` — còn note giữ (heldCnt > 0) → GIỮ NGUYÊN peak, KHÔNG decay → VU animate suốt trường độ; hết note (note-off) → decay 0.75 (~0.5s) tắt nhanh như cũ (giữ hành vi user bug 07:15 "hết âm → VU tắt ngay").
3. **stopAllPlayback**: clear luôn `heldMidiNotesRef.current = {}` cùng với `midiVuActivityRef` — tránh sau STOP (âm đã dừng) tick vẫn thấy counter > 0 → giữ peak → VU dính mãi.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (heldMidiNotesRef + note-on/note-off counters + VU tick giữ peak), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081400)
- **Ghi chú/Test (nếu có):** BUILD OK 1109174 bytes, node --check OK, pytest 86 passed + 5 skipped. Luồng: nhấn giữ phím → VU giữ peak; nhả phím (note-off) → VU decay ~0.5s rồi tắt; STOP → VU tắt ngay. Keybed click (mousedown duration 500ms) giữ nguyên hành vi cũ (không qua heldMidiNotesRef).
---
### [2026-08-08] FIX: Chặn browser zoom toàn trang — item/button/label giữ nguyên kích thước khi phóng to UI
- **Tóm tắt thay đổi:** User yêu cầu: khi phóng to giao diện, các item/button/label PHẢI GIỮ NGUYÊN kích thước hiện tại — chỉ các vùng flexible co giãn + cho phép user tự kéo resize (resizer TCP/sidebar/media-explorer/track-height ĐÃ có sẵn). Root cause: layout vốn dùng px cố định (không scale theo window), nhưng **browser zoom toàn trang** (Ctrl+wheel, Ctrl+plus/minus/0, pinch gesture) scale TOÀN BỘ UI → control phóng to theo. WebView2 (Tauri) mặc định bật zoom control. Fix trong `app/templates/index.html`:
1. Meta viewport thêm `maximum-scale=1.0, user-scalable=no`.
2. Inline script chặn zoom ở **capture phase** bằng `preventDefault()` — KHÔNG `stopPropagation()` nên các vùng zoom CHUYÊN DỤNG vẫn nhận event và hoạt động bình thường: timeline zoom (Ctrl+wheel, 18999), piano-roll grid zoom (7351), waveform canvas zoom (12189), EQ canvas wheel (10144).
3. Chặn keydown Ctrl+'+'/'-'/'='/'_'/'0' + gesturestart/gesturechange (pinch Safari/WebKit).
- **Các file ảnh hưởng:** `app/templates/index.html` (meta viewport + zoom-guard script inline)
- **Ghi chú/Test (nếu có):** index.html serve OK (uvicorn), script extract + node --check OK. Không đụng app.jsx/app.precompiled.js (index.html no-cache nên không cần bump stamp). Resizer panel giữ nguyên — user vẫn kéo được TCP width (280-600), sidebar (200-600), media-explorer (20-80%), track height (110-300).
---
### [2026-08-08] FIX: Windows runtime — ModuleNotFoundError scipy.stats._ansari_swilk_statistics + ValueError librosa stub (PyInstaller bundle)
- **Tóm tắt thay đổi:** User chạy bản Windows exe gặp 2 lỗi runtime (hộp thoại error):
1. `ModuleNotFoundError: No module named 'scipy.stats._ansari_swilk_statistics'` — scipy >= 1.18 tách `scipy.stats` thành nhiều module con import LAZY bên trong hàm (vd `_ansari_swilk_statistics`) → hook scipy của PyInstaller không thấy → thiếu trong bundle. Fix: `engine.spec` thêm `collect_submodules('scipy')` vào hiddenimports (collect toàn bộ stats/signal/ndimage/... — không sót module nào).
2. `ValueError: Cannot load imports from non-existent stub '..._MEIxxxx\librosa\__init__.pyi'` — librosa 0.11 dùng `lazy_loader.attach_stub` → lúc RUNTIME cần file `.pyi` tồn tại trên disk, nhưng PyInstaller mặc định KHÔNG bundle `.pyi` (4 file: `__init__.pyi`, `core/`, `feature/`, `util/`). Fix: `datas += collect_data_files('librosa', includes=['**/*.pyi'])`.
- **Các file ảnh hưởng:** `engine.spec` (import collect_data_files + _scipy_hidden + librosa .pyi datas)
- **Ghi chú/Test (nếu có):** VERIFY THẬT bằng build PyInstaller trên Linux (cùng engine.spec, cần objdump/objcopy từ NDK llvm) → chạy binary frozen → `/health` OK, `/` 200, `/static/js/app.precompiled.js` 200, không còn 2 lỗi trên. pytest 86 passed. Lưu ý: máy user cần build lại bằng `build_windows.ps1` để có exe mới.
---
### [2026-08-08] FIX: Windows build crash — 'app\static does not exist' (PyInstaller datas relative) + FIX: ARM track + MIDI keyboard → VU meter đứng yên
- **Tóm tắt thay đổi:** 2 bug trên bản Windows standalone:
1. **Build crash 6 hộp thoại error**: `RuntimeError: Directory '...\_MEIxxxx\app\static' does not exist` khi chạy daw_engine.exe. Root cause: `app/main.py` dùng `os.path.dirname(__file__)` để mount /static — khi frozen `__file__` trỏ vào thư mục giải nén tạm `_MEI...` KHÔNG phải `sys._MEIPASS`; đồng thời `engine.spec` datas dùng path RELATIVE (resolve theo CWD lúc chạy `pyinstaller`, chạy từ nơi khác → bỏ qua âm thầm → static/templates/models thiếu trong bundle). Fix: `main.py` dùng `settings.APP_DIR` (freeze-aware, config.py đã có); `engine.spec` đổi toàn bộ datas + pathex sang ABSOLUTE từ `SPECPATH` + thêm `app/models` (project_schema.json — projects.py dùng, trước đây thiếu khi frozen).
2. **ARM track + bấm phím MIDI keyboard → VU meter không animation**: vòng tick VU (`masterVUAnimRef` useEffect) có block early-return `if (!isPlaying && !isRecActive)` vẽ VU RỖNG và return — nên bật ARM + bấm phím MIDI (không play/record) thì `midiVuActivityRef` có giá trị nhưng không bao giờ được đọc → VU đứng yên. Fix: thêm điều kiện `liveMidiVu` (scan midiVuActivityRef > 0.001) — có MIDI activity live thì KHÔNG early-return, track VU nhảy theo activity + decay như bình thường.
- **Các file ảnh hưởng:** `app/main.py` (STATIC_DIR → settings.APP_DIR), `engine.spec` (datas/pathex absolute + app/models), `app/static/js/app.jsx` (VU tick liveMidiVu guard), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081200)
- **Ghi chú/Test (nếu có):** BUILD OK 1107797 bytes, node --check OK, pytest 86 passed + 5 skipped (test_sf_convert fail pre-existing — path `/app/...` docker-only). Backend import OK — STATIC_DIR resolve đúng cả dev lẫn frozen.
---
### [2026-08-04] Task: Mở PIANO ROLL TAB lúc main đang play KHÔNG được tắt âm — handleEditMidiInTab stopAllPlayback có điều kiện
- **Tóm tắt thay đổi:** User báo "đang play main session, dblclick mở Piano roll tab → âm bị tắt". Thủ phạm: `handleEditMidiInTab` (15983) gọi `stopAllPlayback()` VÔ ĐIỀU KIỆN ngay khi mở tab — dừng mọi nguồn main đang phát. Fix: `if (!isPlaying) stopAllPlayback()` — chỉ clean-stop khi KHÔNG play main (tránh stuck route khi đổi tab giữa sub-tab); main đang play → mở tab → âm main tiếp tục. Nhánh play sub-tab tự stopAllPlayback trước khi play nên không xung đột route.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (handleEditMidiInTab), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042300)