47 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
41 changed files with 2509 additions and 233 deletions
+8
View File
@@ -15,3 +15,11 @@ DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
# Storage (đường dẫn trong container) # Storage (đường dẫn trong container)
STORAGE_DIR=/app/app/storage STORAGE_DIR=/app/app/storage
# ── Plugin directories ──
# Đường dẫn HOST tới thư mục chứa VST / SoundFont / Pianobook — dùng trong
# docker-compose.yml để mount vào container (đổi theo máy chạy Docker).
# Mặc định: /home/locpham/daw_assets/...
VST_DIR=/home/locpham/daw_assets/vst3
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
+2 -1
View File
@@ -31,4 +31,5 @@ celerybeat-schedule
node_modules node_modules
src-tauri/target/ src-tauri/target/
src-tauri/binaries/ src-tauri/binaries/
src-tauri/vc_redist.x64.exe src-tauri/resources/daw_engine/
src-tauri/vc_redist.x64.exeapp/storage/plugin_dirs.json
+43
View File
@@ -69,6 +69,49 @@ code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đón
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact. - **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu. - Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
Đã xử lý:
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
desktop slim chạy task in-process (thread + registry), giữ nguyên API
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
còn `scipy.signal` (goi duy nhất app còn dùng).
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
"build xong không chạy daw_engine" trên Windows).
- **Fix layout resources (bản 1.1.1 — `exists=false` trong spawn.log)**:
`bundle.resources` dạng ARRAY `["resources/daw_engine"]` copy engine tới
`$RESOURCE_DIR/resources/daw_engine/...` (giữ tiền tố `resources/` — đọc
source `tauri-utils/src/resources.rs`) trong khi lib.rs tìm ở
`$RESOURCE_DIR/daw_engine/...` → `exists=false`. Đổi sang dạng MAP
`{"resources/daw_engine": "daw_engine/"}` (Walk mode, giữ nguyên cây
`_internal`, đích chuẩn `daw_engine/`). `src-tauri/src/lib.rs` đồng thời dò
thêm 3 vị trí fallback (legacy/portable/dev) + ghi diagnostic đầy đủ vào
`%APPDATA%/SonicForgeDAW/logs/spawn.log` (liệt kê nội dung resource_dir khi
không tìm thấy).
Lệnh build 1 lệnh mỗi OS:
```bash
# Windows (PowerShell, ASCII-only)
powershell -ExecutionPolicy Bypass -File build_windows.ps1
# Linux (cần binutils: sudo apt-get install -y binutils)
bash build_linux.sh
# macOS (cần codesign/notarize khi phát hành)
bash build_macos.sh
```
## 6. CẬP NHẬT ## 6. CẬP NHẬT
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer. - **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng). - Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
+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 import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -16,7 +16,128 @@ router = APIRouter()
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts") UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True) os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts" SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
SYSTEM_VST_DIR = settings.VST_DIR
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
# Plugins Manager). File global (không per-user): desktop app 1 user.
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
def _load_plugin_dirs() -> dict:
if os.path.exists(PLUGIN_DIRS_FILE):
try:
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_plugin_dirs(dirs: dict):
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
json.dump(dirs, f, indent=2)
def _effective_dirs() -> dict:
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
"""
user = _load_plugin_dirs()
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
vst_dir = settings.VST_DIR
soundfont_dir = settings.SOUNDFONT_DIR
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
if not plugin_dirs:
if user.get("vst_dir"):
plugin_dirs.append(user["vst_dir"])
if user.get("soundfont_dir"):
plugin_dirs.append(user["soundfont_dir"])
if not plugin_dirs:
plugin_dirs = [vst_dir, soundfont_dir]
return {
"plugin_dirs": plugin_dirs,
"vst_dir": vst_dir,
"soundfont_dir": soundfont_dir,
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
}
class DirsRequest(BaseModel):
vst_dir: Optional[str] = None
soundfont_dir: Optional[str] = None
plugin_dirs: Optional[list] = None
@router.get("/dirs")
async def get_plugin_dirs():
return {"success": True, **(_effective_dirs())}
@router.post("/dirs")
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
user = _load_plugin_dirs()
if req.plugin_dirs is not None:
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
user.pop("vst_dir", None)
user.pop("soundfont_dir", None)
else:
if req.vst_dir is not None:
user["vst_dir"] = req.vst_dir.strip()
if req.soundfont_dir is not None:
user["soundfont_dir"] = req.soundfont_dir.strip()
_save_plugin_dirs(user)
return {"success": True, **(_effective_dirs())}
@router.post("/scan")
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)):
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
enforce_password_changed(current_user)
dirs = _effective_dirs()
plugin_dirs = dirs["plugin_dirs"]
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
if background_tasks:
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
catalog = scanner.get_catalog()
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
vst_found = []
sf_found = []
for d in plugin_dirs:
if not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
vst_found.append({"name": os.path.splitext(sub)[0],
"path": os.path.join(root, sub),
"dir": d,
"type": "VST3"})
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
vst_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d,
"type": "VST3" if low.endswith(".vst3") else "VST2"})
elif low.endswith(".sf2") or low.endswith(".sf3"):
sf_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d})
return {
"success": True,
"plugin_dirs": plugin_dirs,
"vst_found": vst_found,
"vst_count": len(vst_found),
"soundfonts": sf_found,
"soundfont_count": len(sf_found),
}
_inspector = None _inspector = None
_scanner = None _scanner = None
@@ -24,21 +145,157 @@ _scanner = None
def get_inspector(): def get_inspector():
global _inspector global _inspector
if _inspector is None: if _inspector is None:
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
upload_sf_dir=UPLOAD_SF_DIR)
return _inspector return _inspector
def get_scanner(): def get_scanner():
global _scanner global _scanner
if _scanner is None: if _scanner is None:
_scanner = SoundFontAutoScanner(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
_scanner.scan_once() _scanner.scan_once()
return _scanner return _scanner
@router.get("/available") @router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)): async def list_plugins(current_user: dict = Depends(get_current_user)):
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
return pm.list_available() pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
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") @router.get("/default-soundfonts")
+10 -3
View File
@@ -1,12 +1,19 @@
import os
from fastapi import APIRouter from fastapi import APIRouter
from celery.result import AsyncResult
from app.tasks.worker import celery_app
router = APIRouter() router = APIRouter()
# Task status endpoint dung chung cho ca 2 che do:
# - Server/Docker: celery (AsyncResult, broker Redis).
# - Desktop slim (PyInstaller khong bundle celery): in-process registry
# (app/tasks/worker._SimpleAsyncResult) — API contract giong het nhau.
@router.get("/tasks/{task_id}") @router.get("/tasks/{task_id}")
async def get_task_status(task_id: str): async def get_task_status(task_id: str):
res = AsyncResult(task_id, app=celery_app) from app.tasks.worker import get_task_result
res = get_task_result(task_id)
response_data = { response_data = {
"task_id": task_id, "task_id": task_id,
"status": res.status, "status": res.status,
+5
View File
@@ -29,4 +29,9 @@ class Settings:
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads") UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed") PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user).
VST_DIR: str = os.getenv("VST_DIR", "/opt/daw_engine/vst3")
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", "/opt/daw_engine/soundfonts")
settings = Settings() settings = Settings()
+8 -5
View File
@@ -75,13 +75,16 @@ class AIDSPEngine:
t_end = min(total_duration, 4.0) t_end = min(total_duration, 4.0)
try: try:
import librosa from app.core.audio_features import chroma_stft as _chroma_stft
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform # 1. Compute harmonic structural properties via Chroma (STFT-based,
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr) # thay chroma_cqt de lo bo librosa/numba/llvmlite ~171MB)
chroma = _chroma_stft(y=y_mono, sr=sr)
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot) # 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
from sklearn.metrics.pairwise import cosine_similarity # thay sklearn.metrics.pairwise.cosine_similarity bang numpy
ssm = cosine_similarity(chroma.T, chroma.T) c = chroma.T # (n_frames, 12)
norms = np.linalg.norm(c, axis=1, keepdims=True)
ssm = (c @ c.T) / (norms @ norms.T + 1e-9)
num_frames = ssm.shape[0] num_frames = ssm.shape[0]
hop_length = 512 hop_length = 512
+21 -12
View File
@@ -1,19 +1,28 @@
import os import os
import json import json
import librosa
import numpy as np import numpy as np
from typing import Optional from typing import Optional
# Thay librosa bang shim nhe (numpy/scipy/soundfile) — khong keo numba/llvmlite
from app.core.audio_features import (
load as _load,
beat_track as _beat_track,
frames_to_time as _frames_to_time,
spectral_centroid as _spectral_centroid,
rms as _rms,
zero_crossing_rate as _zcr,
)
def analyze_audio(file_path: str) -> dict: def analyze_audio(file_path: str) -> dict:
""" """
Phân tích âm thanh: BPM, beat tracking, ước lượng bars. Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
""" """
# Load audio # Load audio
y, sr = librosa.load(file_path, sr=None) y, sr = _load(file_path, sr=None)
# Track beats # Track beats
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr) tempo, beat_frames = _beat_track(y=y, sr=sr)
# Handle tempo which might be scalar or numpy array in different librosa versions # Handle tempo which might be scalar or numpy array in different librosa versions
if isinstance(tempo, np.ndarray): if isinstance(tempo, np.ndarray):
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
bpm = float(tempo) bpm = float(tempo)
# Convert frames to time (seconds) # Convert frames to time (seconds)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist() beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
# Estimate bars (assume 4/4 time signature - grouping every 4 beats) # Estimate bars (assume 4/4 time signature - grouping every 4 beats)
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)] bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
@@ -43,31 +52,31 @@ def analyze_audio_advanced(file_path: str) -> dict:
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features. Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết. Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
""" """
y, sr = librosa.load(file_path, sr=None) y, sr = _load(file_path, sr=None)
duration = float(len(y)) / sr duration = float(len(y)) / sr
# Beat tracking # Beat tracking
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr) tempo, beat_frames = _beat_track(y=y, sr=sr)
if isinstance(tempo, np.ndarray): if isinstance(tempo, np.ndarray):
bpm = float(tempo[0]) if tempo.size > 0 else 120.0 bpm = float(tempo[0]) if tempo.size > 0 else 120.0
else: else:
bpm = float(tempo) bpm = float(tempo)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist() beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)] bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
# Spectral centroid (brightness) # Spectral centroid (brightness)
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0] spectral_centroids = _spectral_centroid(y=y, sr=sr)[0]
avg_brightness = float(np.mean(spectral_centroids)) avg_brightness = float(np.mean(spectral_centroids))
# RMS energy # RMS energy
rms = librosa.feature.rms(y=y)[0] rms_vals = _rms(y=y)[0]
avg_energy = float(np.mean(rms)) avg_energy = float(np.mean(rms_vals))
# Zero crossing rate # Zero crossing rate
zcr = librosa.feature.zero_crossing_rate(y)[0] zcr_vals = _zcr(y)[0]
avg_zcr = float(np.mean(zcr)) avg_zcr = float(np.mean(zcr_vals))
return { return {
"bpm": round(bpm, 2), "bpm": round(bpm, 2),
+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 numpy as np
import librosa
from pydub import AudioSegment from pydub import AudioSegment
from app.core.audio_features import load as _load
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float: def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
""" """
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
""" """
try: try:
# Load mono audio for zero crossing analysis # Load mono audio for zero crossing analysis
y, sr = librosa.load(file_path, sr=None, mono=True) y, sr = _load(file_path, sr=None, mono=True)
return find_zero_crossing(y, sr, target_time_sec, search_window_sec) return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
except Exception as e: except Exception as e:
print(f"Error finding zero crossing: {e}") print(f"Error finding zero crossing: {e}")
@@ -136,7 +136,7 @@ def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
dict: {"peaks": [...], "duration": float, "sample_rate": int} dict: {"peaks": [...], "duration": float, "sample_rate": int}
""" """
# Load mono audio # Load mono audio
y, sr = librosa.load(file_path, sr=None, mono=True) y, sr = _load(file_path, sr=None, mono=True)
total_samples = len(y) total_samples = len(y)
duration = float(total_samples) / sr duration = float(total_samples) / sr
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
Returns: Returns:
dict: {"rms": [...], "duration": float, "sample_rate": int} dict: {"rms": [...], "duration": float, "sample_rate": int}
""" """
y, sr = librosa.load(file_path, sr=None, mono=True) y, sr = _load(file_path, sr=None, mono=True)
total_samples = len(y) total_samples = len(y)
duration = float(total_samples) / sr duration = float(total_samples) / sr
+4 -2
View File
@@ -1,7 +1,8 @@
import os, logging, math import os, logging, math
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
import scipy.signal as signal # scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
from app.config import settings from app.config import settings
from app.core.vst_engine import ( from app.core.vst_engine import (
render_midi_events_to_audio, render_midi_events_to_audio,
@@ -385,7 +386,8 @@ class PythonRenderEngine:
for ch in range(2): for ch in range(2):
ir = ir_l if ch == 0 else ir_r ir = ir_l if ch == 0 else ir_r
# Convolve # Convolve
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples] from scipy.signal import convolve
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
wet[ch, :] = conv wet[ch, :] = conv
track_buffer = dry + wet * 0.4 track_buffer = dry + wet * 0.4
except Exception as e: except Exception as e:
+17 -5
View File
@@ -8,7 +8,7 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json") TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts" SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts") UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
@@ -19,8 +19,11 @@ def _file_sig(path: str) -> tuple:
class SoundFontAutoScanner: class SoundFontAutoScanner:
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR): def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR,
self.system_sf_dir = system_sf_dir system_sf_dirs=None):
# system_sf_dirs (list) — nhiều thư mục user khai báo trong Plugins
# Manager. Fallback system_sf_dir (env/.env) nếu list rỗng.
self.system_sf_dirs = [d for d in (system_sf_dirs or []) if d] or [system_sf_dir]
self.upload_sf_dir = upload_sf_dir self.upload_sf_dir = upload_sf_dir
self._catalog = {} self._catalog = {}
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -50,6 +53,15 @@ class SoundFontAutoScanner:
out.append((fname, os.path.join(directory, fname))) out.append((fname, os.path.join(directory, fname)))
return out return out
def _all_sf_files(self) -> list:
"""Gộp file .sf2/.sf3 từ TẤT CẢ thư mục hiệu lực (user dirs + upload)."""
out = []
for d in self.system_sf_dirs:
out.extend(self._sf_files(d))
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
out.extend(self._sf_files(self.upload_sf_dir))
return out
def _inspect_single(self, fname: str, full: str, inspector) -> dict: def _inspect_single(self, fname: str, full: str, inspector) -> dict:
if fname.lower().endswith(".sf2"): if fname.lower().endswith(".sf2"):
sf_info = inspector.inspect_sf2_file(full) or {} sf_info = inspector.inspect_sf2_file(full) or {}
@@ -68,9 +80,9 @@ class SoundFontAutoScanner:
def scan_once(self) -> bool: def scan_once(self) -> bool:
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir) inspector = SoundFontInspector(self.system_sf_dirs[0], self.upload_sf_dir)
found_new = False found_new = False
dirs = [(self.system_sf_dir, "system")] dirs = [(d, "system") for d in self.system_sf_dirs]
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir): if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
dirs.append((self.upload_sf_dir, "upload")) dirs.append((self.upload_sf_dir, "upload"))
+3 -3
View File
@@ -1,6 +1,6 @@
import numpy as np import numpy as np
import scipy.signal as signal import scipy.signal as signal
import librosa from app.core.audio_features import time_stretch as _time_stretch, pitch_shift as _pitch_shift
class SubTabDSPEngine: class SubTabDSPEngine:
@staticmethod @staticmethod
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
return y return y
if preserve_pitch: if preserve_pitch:
return librosa.effects.time_stretch(y, rate=speed_ratio) return _time_stretch(y, rate=speed_ratio)
else: else:
num_samples_new = int(len(y) / speed_ratio) num_samples_new = int(len(y) / speed_ratio)
return signal.resample(y, num_samples_new) return signal.resample(y, num_samples_new)
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
""" """
if n_steps == 0: if n_steps == 0:
return y return y
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps) return _pitch_shift(y, sr=sr, n_steps=n_steps)
@staticmethod @staticmethod
def merge_back_to_parent( def merge_back_to_parent(
+67 -28
View File
@@ -1,5 +1,6 @@
# SonicForge Studio VST / VSTi Engine Service # SonicForge Studio VST / VSTi Engine Service
import os import os
import json
import numpy as np import numpy as np
import functools import functools
from ctypes import c_char_p from ctypes import c_char_p
@@ -49,27 +50,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
out_r /= max_peak out_r /= max_peak
return np.vstack([out_l, out_r]) return np.vstack([out_l, out_r])
def check_pedalboard_safe(): def _module_available(name: str) -> bool:
import subprocess, sys """Kiem tra module co san khong — KHONG spawn subprocess.
Truoc day dung subprocess.run([sys.executable, '-c', 'import X']) —
khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe
-> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine)
-> moi lan check lai sinh ra engine moi -> de quy spawn vo han
(Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham).
find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen.
"""
import importlib.util
try: try:
res = subprocess.run( return importlib.util.find_spec(name) is not None
[sys.executable, "-c", "import pedalboard"], except (ImportError, AttributeError, ValueError):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False return False
def check_pedalboard_safe():
return _module_available("pedalboard")
def check_pyfluidsynth_safe(): def check_pyfluidsynth_safe():
import subprocess, sys return _module_available("fluidsynth")
try:
res = subprocess.run(
[sys.executable, "-c", "import fluidsynth"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False
HAS_PEDALBOARD = check_pedalboard_safe() HAS_PEDALBOARD = check_pedalboard_safe()
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe() HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
@@ -99,14 +102,38 @@ _PLUGIN_MANAGER_INSTANCE = None
_PLUGIN_MANAGER_ARGS = None _PLUGIN_MANAGER_ARGS = None
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets] _SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager": def _load_user_plugin_dirs() -> list:
"""Singleton: reuse PluginManager when args match, else create new.""" """Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
tránh vòng import (plugins.py import vst_engine)."""
try:
from app.config import settings as _st
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return [d for d in (data.get("plugin_dirs") or []) if d]
except Exception:
pass
return []
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
"""Singleton: reuse PluginManager when args match, else create new.
Default dirs từ settings (env/.env/docker-compose hoặc user override).
VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt
kê được VSTi đã scan và load_vst phải tìm thấy chúng khi render."""
from app.config import settings as _st
vst_dir = vst_dir or _st.VST_DIR
sf_dir = sf_dir or _st.SOUNDFONT_DIR
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
extra = _load_user_plugin_dirs()
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
args = (vst_dir, sf_dir, upload_sf_dir) args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args: if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
return _PLUGIN_MANAGER_INSTANCE return _PLUGIN_MANAGER_INSTANCE
_PLUGIN_MANAGER_ARGS = args _PLUGIN_MANAGER_ARGS = args
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir) _PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
return _PLUGIN_MANAGER_INSTANCE return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str): def load_soundfont_cached(path: str):
@@ -155,21 +182,33 @@ def release_soundfont(path: str):
_FLUID_CACHE[path] = (fl, ref - 1) _FLUID_CACHE[path] = (fl, ref - 1)
class PluginManager: class PluginManager:
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None): def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None):
self.vst_dir = vst_dir from app.config import settings as _st
self.sf_dir = sf_dir self.vst_dir = vst_dir or _st.VST_DIR
self.sf_dir = sf_dir or _st.SOUNDFONT_DIR
self.upload_sf_dir = upload_sf_dir self.upload_sf_dir = upload_sf_dir
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
self._sf_scan_cache = None # cache for _scan_soundfonts() self._sf_scan_cache = None # cache for _scan_soundfonts()
def _scan_plugins(self) -> dict: def _scan_plugins(self) -> dict:
plugins = {} plugins = {}
if not os.path.isdir(self.vst_dir): for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
return plugins if not scan_dir or not os.path.isdir(scan_dir):
for root, dirs, files in os.walk(self.vst_dir): continue
for root, dirs, files in os.walk(scan_dir):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for d in list(dirs):
if d.lower().endswith(".vst3"):
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
for file in files: for file in files:
if file.endswith(".vst3") or file.endswith(".so"): low = file.lower()
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
plugin_path = os.path.join(root, file) plugin_path = os.path.join(root, file)
plugin_name = os.path.splitext(file)[0] plugin_name = os.path.splitext(file)[0]
if plugin_name not in plugins:
plugins[plugin_name] = plugin_path plugins[plugin_name] = plugin_path
return plugins return plugins
+17
View File
@@ -62,6 +62,23 @@ app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="au
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI... # khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
# nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý). # nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý).
STATIC_DIR = os.path.join(settings.APP_DIR, "static") STATIC_DIR = os.path.join(settings.APP_DIR, "static")
# ⚠️ Fallback an toàn: nếu vì lý do nào đó static không nằm đúng chỗ (vd
# bundle thiếu file, chạy từ nơi khác), thử các vị trí khác; nếu vẫn không
# có → TỰ TẠO thư mục rỗng để app KHÔNG crash khi khởi động (lỗi "Directory
# does not exist" từ StaticFiles làm engine chết ngay lúc import — đã gặp).
if not os.path.isdir(STATIC_DIR):
for cand in [
os.path.join(getattr(sys, "_MEIPASS", ""), "app", "static"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static"),
]:
if cand and os.path.isdir(cand):
STATIC_DIR = cand
break
else:
try:
os.makedirs(STATIC_DIR, exist_ok=True)
except OSError:
pass
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Include routers # Include routers
+585 -13
View File
@@ -5284,14 +5284,111 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
const [localData, setLocalData] = React.useState(pluginsData); const [localData, setLocalData] = React.useState(pluginsData);
const [sfUploadStatus, setSfUploadStatus] = React.useState(''); const [sfUploadStatus, setSfUploadStatus] = React.useState('');
const [sfToDelete, setSfToDelete] = React.useState(null); 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(() => { React.useEffect(() => {
if (isOpen) { if (isOpen) {
window.SonicAPI.listPlugins() window.SonicAPI.listPlugins()
.then(data => setLocalData(data)) .then(data => setLocalData(data))
.catch(() => setLocalData({ vst_instruments: [], soundfonts: [] })); .catch(() => setLocalData({ vst_instruments: [], soundfonts: [] }));
window.SonicAPI.getPluginDirs()
.then(d => setPmDirs(d.plugin_dirs || []))
.catch(() => {});
setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50); setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50);
} }
}, [isOpen]); }, [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 handleUploadSF = async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; 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', className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',
onClick: onClose onClick: onClose
}, React.createElement('div', { }, 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' }, style: { maxHeight: '80vh' },
onClick: e => e.stopPropagation() 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 // Header
React.createElement('div', { React.createElement('div', {
className: 'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]' 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) // Upload section (bottom of right panel)
React.createElement('div', { React.createElement('div', {
className: 'pt-4 mt-4 border-t border-[#383838]' className: 'pt-4 mt-4 border-t border-[#383838]'
@@ -6469,6 +6707,155 @@ const SystemManagerModal = ({
}, "Xóa"))))))))); }, "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 }) => { const AIPresetModal = ({ isOpen, onClose }) => {
if (!isOpen) return null; if (!isOpen) return null;
@@ -8517,7 +8904,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
keybedMouseDownRef.current = true; keybedMouseDownRef.current = true;
try { try {
if (window.triggerMidiVuActivity) { 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) { if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); 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) { if (keybedMouseDownRef.current) {
try { try {
if (window.triggerMidiVuActivity) { 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) { if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
@@ -14500,6 +14887,11 @@ const App = () => {
if (window.triggerMidiVuActivity) { if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(as.trackId, scaledVel); 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); window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe);
}); });
} }
@@ -14512,6 +14904,10 @@ const App = () => {
if (window.triggerMidiVuActivity) { if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(at.id, scaledVel); 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); 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; if (!st.synth_engine && st.midiChannel === undefined) return;
var stCh = assignTrackMidiChannel(st, stopTracks); var stCh = assignTrackMidiChannel(st, stopTracks);
window.SonicSF.stopNote(stCh, pitch); 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]); }, [isPlaying]);
const midiVuActivityRef = useRef({}); 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) => { const triggerMidiVuActivity = (trackId, velocity) => {
if (!trackId) return; if (!trackId) return;
const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
@@ -15538,6 +15949,51 @@ const App = () => {
const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false); const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false);
const [aiPresetVersion, setAiPresetVersion] = useState(0); const [aiPresetVersion, setAiPresetVersion] = useState(0);
const [showMasteringModal, setShowMasteringModal] = useState(false); 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 // Unified FX Rack target context (unified_fx_rack_panel.md): { trackId, trackName } | null
const [fxRackTarget, setFxRackTarget] = useState(null); const [fxRackTarget, setFxRackTarget] = useState(null);
window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName }); window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName });
@@ -17782,6 +18238,33 @@ const App = () => {
for (let i = 0; i < endSample - startSample; i++) { for (let i = 0; i < endSample - startSample; i++) {
resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; 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 // Update subTab buffer state
@@ -17794,7 +18277,11 @@ const App = () => {
selectionEnd: null 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 exportSubTabBuffer = async tabId => {
const subTab = subTabs.find(s => s.id === 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 // â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). // items phi tt theo, không "din" tiếp).
try { midiVuActivityRef.current = {}; } catch (e) { } 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(); stopMidiCapture();
if (window.SonicSF) { if (window.SonicSF) {
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); } try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
@@ -25834,7 +26324,10 @@ STRICT CONSTRAINTS:
// Section item trên MAIN track: VU theo sub-nodes ca track này (key // 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 // <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. // 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) { for (var sk in trackNodes) {
if (sk.indexOf(trackId + '_sub_') === 0) { if (sk.indexOf(trackId + '_sub_') === 0) {
const sn = trackNodes[sk]; const sn = trackNodes[sk];
@@ -25856,14 +26349,20 @@ STRICT CONSTRAINTS:
// velocity thp (âm SF nh < 0.03) VU không nhy (user bug 07:10). // velocity thp (âm SF nh < 0.03) VU không nhy (user bug 07:10).
let midiPeak = isAudible ? (midiVuActivityRef.current[vuKey] || 0) : 0; let midiPeak = isAudible ? (midiVuActivityRef.current[vuKey] || 0) : 0;
if (midiPeak > 0) { if (midiPeak > 0) {
// Decay 0.75 (~0.5s) cân bng: note đơn/velocity thp hin th rõ // ARM + MIDI keyboard: còn note đang GI gi nguyên peak, KHÔNG
// NHƯNG tt nhanh sau hết note (decay 0.85 ~1s quá lâu user: // decay VU animate đúng trưng đ âm thanh (user bug 08:08: âm còn
// "vn din animation" sau khi âm hết bug 07:15). // 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; midiVuActivityRef.current[vuKey] = midiPeak * 0.75;
if (midiVuActivityRef.current[vuKey] < 0.01) { if (midiVuActivityRef.current[vuKey] < 0.01) {
midiVuActivityRef.current[vuKey] = 0; midiVuActivityRef.current[vuKey] = 0;
} }
} }
}
// KHÔNG gp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mi // 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). // 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). // audioPeak ch theo âm TRACK tht (vNode/sub-node analyser).
@@ -26239,7 +26738,17 @@ STRICT CONSTRAINTS:
}, { }, {
label: 'DSP Tools Panel', label: 'DSP Tools Panel',
icon: 'wrench', 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 sep: true
}, { }, {
@@ -26249,13 +26758,25 @@ STRICT CONSTRAINTS:
setPluginManagerModalOpen(true); setPluginManagerModalOpen(true);
window.SonicAPI.listPlugins().then(data => setPluginsData(data)).catch(() => {}); window.SonicAPI.listPlugins().then(data => setPluginsData(data)).catch(() => {});
} }
}, {
sep: true
}, {
label: 'Preferences...',
icon: 'settings-2',
action: () => setPreferencesModalOpen(true)
}] }]
}, { }, {
label: 'Help', label: 'Help',
items: [{ 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', icon: 'info',
action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info') action: () => setAboutModalOpen(true)
}] }]
}].map(menu => /*#__PURE__*/React.createElement("div", { }].map(menu => /*#__PURE__*/React.createElement("div", {
key: menu.label, key: menu.label,
@@ -28301,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", 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" title: "Loop count"
}))), /*#__PURE__*/React.createElement("div", { }))), /*#__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" className: "flex gap-1 justify-between my-2.5"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
@@ -28860,7 +29411,9 @@ STRICT CONSTRAINTS:
className: "w-3 h-3 text-zinc-600" className: "w-3 h-3 text-zinc-600"
})), prHint ? /*#__PURE__*/React.createElement("span", { })), prHint ? /*#__PURE__*/React.createElement("span", {
className: "text-cyan-400" 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", className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",
style: { style: {
left: Math.min(contextMenu.x, window.innerWidth - 260), left: Math.min(contextMenu.x, window.innerWidth - 260),
@@ -29231,6 +29784,18 @@ STRICT CONSTRAINTS:
const active = providers.find(p => p.is_active) || providers[0]; const active = providers.find(p => p.is_active) || providers[0];
if (active) setSelectedProviderId(active.id); 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, { }), /*#__PURE__*/React.createElement(SystemManagerModal, {
isOpen: systemManagerModalOpen, isOpen: systemManagerModalOpen,
onClose: () => setSystemManagerModalOpen(false) onClose: () => setSystemManagerModalOpen(false)
@@ -29311,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") 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); }, 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("div", { className: "flex-1 overflow-y-auto space-y-0.5" },
/*#__PURE__*/React.createElement("button", { /*#__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 }) }), savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }), listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
// user yêu cầu dùng window explorer, không nhập tay
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
browseDir: (path) => apiRequest(`/api/v1/media/browse?path=${encodeURIComponent(path)}`, { method: 'GET' }),
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }), getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }), listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }), listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
+95 -12
View File
@@ -3,7 +3,6 @@ import uuid
import time import time
import glob import glob
import logging import logging
from celery import Celery
from app.config import settings from app.config import settings
from app.core.analyzer import analyze_audio, analyze_structure_with_ai from app.core.analyzer import analyze_audio, analyze_structure_with_ai
from app.core.audio_editor import ( from app.core.audio_editor import (
@@ -13,6 +12,21 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────
# 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( celery_app = Celery(
"audio_tasks", "audio_tasks",
broker=settings.CELERY_BROKER_URL, broker=settings.CELERY_BROKER_URL,
@@ -28,7 +42,7 @@ celery_app.conf.update(
) )
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo # Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker. # trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
if os.getenv("SF_DESKTOP") == "1": if os.getenv("SF_DESKTOP") == "1":
celery_app.conf.update( celery_app.conf.update(
task_always_eager=True, task_always_eager=True,
@@ -37,16 +51,85 @@ if os.getenv("SF_DESKTOP") == "1":
result_backend="cache+memory://", result_backend="cache+memory://",
) )
# ── Lch trình tự động dn dp file hết hn (Week 5) ── # ── Lich trinh tu dong don dep file het han (Week 5) ──
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
"cleanup-expired-files-every-hour": { "cleanup-expired-files-every-hour": {
"task": "app.tasks.worker.cleanup_expired_files_task", "task": "app.tasks.worker.cleanup_expired_files_task",
"schedule": 3600.0, # 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): def analyze_audio_task(file_id: str):
file_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = os.path.join(settings.UPLOADS_DIR, file_id)
if not os.path.exists(file_path): if not os.path.exists(file_path):
@@ -54,7 +137,7 @@ def analyze_audio_task(file_id: str):
return analyze_audio(file_path) return analyze_audio(file_path)
@celery_app.task @_task
def analyze_ai_task(file_id: str, api_base_url: str = None, def analyze_ai_task(file_id: str, api_base_url: str = None,
model: str = "deepseek-chat"): model: str = "deepseek-chat"):
""" """
@@ -78,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
) )
@celery_app.task @_task
def edit_audio_task(config: dict): def edit_audio_task(config: dict):
file_id = config.get("file_id") file_id = config.get("file_id")
@@ -98,7 +181,7 @@ def edit_audio_task(config: dict):
} }
@celery_app.task @_task
def export_audio_task(file_id: str, format: str = "wav", def export_audio_task(file_id: str, format: str = "wav",
sample_rate: int = 44100, bit_depth: int = 16): sample_rate: int = 44100, bit_depth: int = 16):
""" """
@@ -131,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
return result return result
@celery_app.task @_task
def mix_multitrack_task(session_config: dict): def mix_multitrack_task(session_config: dict):
""" """
Task xử lý hòa âm đa kênh (Multitrack Mixdown). Task xử lý hòa âm đa kênh (Multitrack Mixdown).
@@ -184,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
return result return result
@celery_app.task @_task
def process_multitrack_session_task(session_config: dict): def process_multitrack_session_task(session_config: dict):
""" """
Task xử lý toàn bộ session với nhiều tracks và clips. Task xử lý toàn bộ session với nhiều tracks và clips.
@@ -280,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
return result return result
@celery_app.task @_task
def cleanup_expired_files_task(max_age_hours: int = 24): def cleanup_expired_files_task(max_age_hours: int = 24):
""" """
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5). Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
@@ -315,7 +398,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
} }
@celery_app.task @_task
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100): def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
""" """
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md. Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
+57 -3
View File
@@ -3,8 +3,27 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>SonicForge Studio - Professional DAW Editor</title> <title>SonicForge Studio - Professional DAW Editor</title>
<script>
// ── Chặn BROWSER ZOOM toàn trang ──
// UI dùng px cố định cho item/button/label — browser zoom (Ctrl+wheel,
// Ctrl+plus/minus/0, pinch) scale TOÀN BỘ làm control phóng to theo.
// Chặn ở capture phase bằng preventDefault() (KHÔNG stopPropagation —
// các vùng zoom chuyên dụng: timeline, piano roll, canvas, EQ vẫn nhận
// event và tự xử lý zoom nội dung của chúng).
document.addEventListener('wheel', function (e) {
if (e.ctrlKey || e.metaKey) e.preventDefault();
}, { capture: true, passive: false });
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '_', '0'].indexOf(e.key) !== -1) {
e.preventDefault();
}
}, { capture: true });
// Pinch zoom (Safari/WebKit gesture events)
document.addEventListener('gesturestart', function (e) { e.preventDefault(); }, { passive: false });
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
@@ -12,7 +31,7 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script> <script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/api.js?v=202607271016"></script> <script src="/static/js/services/api.js?v=202608091400"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script> <script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script> <script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script> <script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
@@ -24,7 +43,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608081200" defer></script> <script src="/static/js/app.precompiled.js?v=202608091400" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
@@ -33,8 +52,43 @@
--top-bar-height: 80px; --top-bar-height: 80px;
--status-bar-height: 25px; --status-bar-height: 25px;
--panel-border-color: #2a2a2a; --panel-border-color: #2a2a2a;
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
--sf-bg: #1e1e1e;
--sf-panel: #262626;
--sf-header: #2e2e2e;
--sf-border: #181818;
--sf-accent: #00ffcc;
} }
/* ── THEME presets (Tools → Preferences) ── */
html[data-theme="dark"] {
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
}
html[data-theme="midnight"] {
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
}
html[data-theme="forest"] {
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
}
html[data-theme="violet"] {
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
}
html[data-theme="graphite"] {
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
}
/* Áp theme lên các vùng chính của app shell */
.daw-app-shell { background-color: var(--sf-bg) !important; }
.daw-panel { background-color: var(--sf-panel) !important; }
.daw-header { background-color: var(--sf-header) !important; }
body { background-color: var(--sf-bg) !important; }
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
html[data-btnfont="sm"] button { font-size: 10px !important; }
html[data-btnfont="md"] button { font-size: 12px !important; }
html[data-btnfont="lg"] button { font-size: 14px !important; }
body { body {
background-color: #1a1a1a; background-color: #1a1a1a;
color: #c0c0c0; color: #c0c0c0;
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 ...)"
+66 -4
View File
@@ -1,22 +1,68 @@
# build_windows.ps1 ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI) # build_windows.ps1 - ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI)
# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1 # 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" $ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot Set-Location $PSScriptRoot
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
Write-Host "== [1/6] Python dependencies ==" Write-Host "== [1/6] Python dependencies =="
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install -r requirements.txt pyinstaller pywin32 python -m pip install -r requirements.txt pyinstaller pywin32
Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) ==" Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
npm install 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) 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) ==" Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) =="
pyinstaller engine.spec --clean --noconfirm pyinstaller engine.spec --clean --noconfirm
Write-Host "== [4/6] Sidecar binary -> src-tauri/binaries (tauri triple naming) ==" Write-Host "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
New-Item -ItemType Directory -Force src-tauri\binaries | Out-Null # Bat loi bundle NGAY tai build (da gap 2 lan: chay pyinstaller tu noi khac
Copy-Item dist\daw_engine.exe src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe -Force # -> 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 ==" Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) { if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
@@ -24,6 +70,16 @@ if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
} }
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) ==" Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
exit 1
}
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
exit 1
}
npm install -D @tauri-apps/cli npm install -D @tauri-apps/cli
npx tauri build npx tauri build
@@ -31,3 +87,9 @@ Write-Host ""
Write-Host "== DONE ==" Write-Host "== DONE =="
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe" Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi" Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
Write-Host ""
Write-Host "== XAC MINH SAU KHI CAI DAT (quan trong) =="
Write-Host " Mo %APPDATA%\SonicForgeDAW\logs\spawn.log - phai thay:"
Write-Host " [resource_dir/daw_engine (map layout)] ...exists=True"
Write-Host " Neu exists=False: bundle resources KHONG vao installer (chay lai buoc [4/6])."
Write-Host " Engine phai nam o: <thu muc cai dat>\daw_engine\daw_engine.exe"
+45 -6
View File
@@ -18,7 +18,10 @@ APP_DATA_DIR_NAME = "SonicForgeDAW"
def _pick_port(): def _pick_port():
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1): for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # KHONG dung SO_REUSEADDR: tren Windows no CHO PHEP bind trung port
# (2 engine cung 8000 -> request roi vao engine ngau nhien -> UI loi).
# Listen socket khong can SO_REUSEADDR (TIME_WAIT chi ap dung cho
# connection socket, khong phai listen socket).
try: try:
s.bind(("127.0.0.1", port)) s.bind(("127.0.0.1", port))
return port return port
@@ -28,6 +31,19 @@ def _pick_port():
def _parent_alive(pid): 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: try:
os.kill(pid, 0) os.kill(pid, 0)
return True return True
@@ -36,17 +52,40 @@ def _parent_alive(pid):
def main(): def main():
# PHONG THU (fix ban 1.1.2): neu exe bi goi nhu 'python -c ...' — vd code
# cu kiem tra thu vien bang subprocess.run([sys.executable, '-c', 'import X'])
# thi frozen sys.executable = daw_engine.exe -> se chay CA ENGINE o day.
# Thoat ngay, KHONG khoi dong server, tranh de quy spawn vo han.
if len(sys.argv) > 1 and sys.argv[1] == "-c":
return
os.environ.setdefault("SF_DESKTOP", "1") os.environ.setdefault("SF_DESKTOP", "1")
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
log_dir = 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() port = _pick_port()
os.environ["SF_PORT"] = str(port) os.environ["SF_PORT"] = str(port)
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc. # Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
try: try:
log_dir = os.path.join(
os.environ.get("APPDATA") or os.path.expanduser("~"),
APP_DATA_DIR_NAME, "logs",
)
os.makedirs(log_dir, exist_ok=True)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+15 -9
View File
@@ -10,13 +10,15 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- .:/app - .:/app
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3 - ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts - ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook - ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
environment: environment:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
- VST_DIR=/opt/daw_engine/vst3
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
depends_on: depends_on:
- redis - redis
@@ -25,13 +27,15 @@ services:
command: celery -A app.tasks.worker.celery_app worker --loglevel=info command: celery -A app.tasks.worker.celery_app worker --loglevel=info
volumes: volumes:
- .:/app - .:/app
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3 - ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts - ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook - ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
environment: environment:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
- VST_DIR=/opt/daw_engine/vst3
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
depends_on: depends_on:
- redis - redis
@@ -40,12 +44,14 @@ services:
command: celery -A app.tasks.worker.celery_app beat --loglevel=info command: celery -A app.tasks.worker.celery_app beat --loglevel=info
volumes: volumes:
- .:/app - .:/app
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3 - ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts - ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook - ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
environment: environment:
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
- VST_DIR=/opt/daw_engine/vst3
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
depends_on: depends_on:
- redis - redis
+107 -33
View File
@@ -1,47 +1,86 @@
# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python) # engine.spec — PyInstaller config cho daw_engine (sidecar Python)
# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows) # Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os import os
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules, collect_data_files from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
# Native DLL cho pedalboard va soundfile # Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
binaries = collect_dynamic_libs('pedalboard') binaries = collect_dynamic_libs('pedalboard')
binaries += collect_dynamic_libs('soundfile') binaries += collect_dynamic_libs('soundfile')
# Root tuyet doi cua thu muc chua spec — datas PHẢI absolute: PyInstaller # Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
# resolve duong dan relative theo CWD luc chay lenh (KHONG theo spec file), # package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
# chay tu noi khac -> khong tim thay -> bo qua am tham -> app/static thieu # duoc bundle qua collect_data_files('app').
# trong bundle -> RuntimeError 'app\static does not exist' luc chay (da gap).
_SPEC_ROOT = os.path.abspath(SPECPATH) _SPEC_ROOT = os.path.abspath(SPECPATH)
# Assets doc (read-only) + thu muc storage (khoi tao rong; khi frozen, # ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
# config.py chuyen storage sang %APPDATA%\SonicForgeDAW\storage) # 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 = [ datas = [
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg (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', 'static'), 'app/static'), # js/css/processors
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py) (os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
(os.path.join(_SPEC_ROOT, 'app', 'storage'), 'app/storage'), ]
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator datas += [
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
] ]
# librosa 0.11 dùng lazy_loader.attach_stub -> lúc RUNTIME cần file .pyi # ══════════════════════════════════════════════════════════════════════════
# ton tai tren disk ('Cannot load imports from non-existent stub ...librosa\__init__.pyi'). # TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
# PyInstaller mac dinh KHONG bundle .pyi -> phai collect explicit. # ──────────────────────────────────────────────────────────────────────────
datas += collect_data_files('librosa', includes=['**/*.pyi']) # 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
# 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
# scipy >= 1.18 tach scipy.stats thanh nhieu module con (vd def _scan_pkg_modules(pkg_name: str):
# _ansari_swilk_statistics) import lazy ben trong ham -> hook scipy cua """Scan filesystem cua 1 package con (khong import, khong walk) ->
# PyInstaller miss -> ModuleNotFoundError luc runtime. Collect toan bo bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
# submodules cua scipy de khong sot module nao (stats/signal/ndimage...). _spec = _ilu.find_spec(pkg_name)
_scipy_hidden = collect_submodules('scipy') 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
a = Analysis( _scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
['desktop_engine.py'],
pathex=[_SPEC_ROOT], # Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
binaries=binaries, _hidden = [
datas=datas,
hiddenimports=collect_submodules('celery.fixups') + _scipy_hidden + [
'uvicorn.logging', 'uvicorn.logging',
'uvicorn.loops', 'uvicorn.loops',
'uvicorn.loops.auto', 'uvicorn.loops.auto',
@@ -54,11 +93,35 @@ a = Analysis(
'soundfile', 'soundfile',
'sf2utils', 'sf2utils',
'mido.backends.rtmidi', 'mido.backends.rtmidi',
], ] + _scipy_signal_hidden
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
_excludes = [
'tkinter',
# libs da thay the (audio_features.py)
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
# celery/redis chi dung cho server (Docker), khong cho desktop
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
'click_didyoumean', 'click_plugins', 'click_repl',
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
# khong dung trong desktop
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
# keo vao nham boi hooks_contrib (app khong import bao gio)
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
]
a = Analysis(
['desktop_engine.py'],
pathex=[_SPEC_ROOT],
binaries=binaries,
datas=datas,
hiddenimports=_hidden,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=['tkinter'], excludes=_excludes,
win_no_prefer_redirects=False, win_no_prefer_redirects=False,
win_private_assemblies=False, win_private_assemblies=False,
cipher=None, cipher=None,
@@ -70,10 +133,8 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[], [],
exclude_binaries=True,
name='daw_engine', name='daw_engine',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
@@ -82,5 +143,18 @@ exe = EXE(
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, # True khi debug (xem log truc tiep), False cho production console=False, # True khi debug (xem log truc tiep), False cho production
icon='src-tauri/icons/icon.ico', # Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
icon='src-tauri/icons/favicon.ico',
)
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
name='daw_engine',
) )
+10
View File
@@ -9,6 +9,7 @@
"@babel/cli": "^8.0.4", "@babel/cli": "^8.0.4",
"@babel/core": "^8.0.1", "@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1", "@babel/preset-react": "^8.0.1",
"@babel/standalone": "^7.29.8",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
@@ -355,6 +356,15 @@
"@babel/core": "^8.0.0" "@babel/core": "^8.0.0"
} }
}, },
"node_modules/@babel/standalone": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.8.tgz",
"integrity": "sha512-XgbPNz+u6JzB7cKGnPDoS1U24J5td8yp3HFWbT/6f4/ASZ0PqaQGIvts1o5v5AI+f6i3jdVB/L/o2CYLCv101A==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
+1
View File
@@ -8,6 +8,7 @@
"@babel/cli": "^8.0.4", "@babel/cli": "^8.0.4",
"@babel/core": "^8.0.1", "@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1", "@babel/preset-react": "^8.0.1",
"@babel/standalone": "^7.29.8",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
+1
View File
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Default capability for the main window", "description": "Default capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "dialog:default"]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1009 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1 @@
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
+198 -11
View File
@@ -1,34 +1,221 @@
// SonicForge DAW — desktop shell: spawn/terminate daw_engine.exe sidecar. // 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::Manager;
use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri_plugin_shell::process::CommandChild; use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
struct EngineProcess(Mutex<Option<CommandChild>>); 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| { .setup(|app| {
// 1. Spawn sidecar daw_engine.exe (PyInstaller bundle) let res_dir = app
let sidecar_command = app .path()
.resource_dir()
.expect("resource dir not found");
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_default();
let candidates = engine_candidates(&res_dir, &exe_dir);
// Ghi log spawn de chan doan — vao %APPDATA%/SonicForgeDAW/logs/
// spawn.log (thu muc luon ton tai), KHONG ghi vao engine_dir.
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let spawn_log_path = std::path::Path::new(&log_dir)
.join("SonicForgeDAW")
.join("logs")
.join("spawn.log");
if let Some(parent) = spawn_log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut log_line = format!(
"resource_dir={}\nexe_dir={}\n",
res_dir.display(),
exe_dir.display()
);
let mut found: Option<(PathBuf, String)> = None;
for (path, label) in &candidates {
let exists = path.exists();
log_line.push_str(&format!(
" [{label}] {} exists={}\n",
path.display(),
exists
));
if found.is_none() && exists {
found = Some((path.clone(), label.clone()));
}
}
if found.is_none() {
log_line.push_str(&format!(
" NOT FOUND — resource_dir contents: {}\n exe_dir contents: {}\n",
list_dir_snippet(&res_dir),
list_dir_snippet(&exe_dir)
));
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&spawn_log_path)
{
use std::io::Write;
let _ = f.write_all(log_line.as_bytes());
}
match found {
Some((engine_exe, label)) => {
match app
.shell() .shell()
.sidecar("daw_engine") .command(&engine_exe)
.expect("sidecar daw_engine not found — run build_windows.ps1 first");
let (_rx, child) = sidecar_command
.env("SF_PARENT_PID", std::process::id().to_string()) .env("SF_PARENT_PID", std::process::id().to_string())
.spawn() .spawn()
.expect("Failed to spawn daw_engine sidecar"); {
Ok((_rx, child)) => {
app.manage(EngineProcess(Mutex::new(Some(child)))); app.manage(EngineProcess(Mutex::new(Some(child))));
println!("Python Background Engine started (localhost:8000, auto-fallback 8000-8010)"); println!(
"Python Background Engine started ({label}): {}",
engine_exe.display()
);
}
Err(e) => {
println!("Failed to spawn daw_engine {engine_exe:?}: {e}");
app.manage(EngineProcess(Mutex::new(None)));
}
}
}
None => {
println!(
"daw_engine binary not found in any candidate path — xem spawn.log de biet chi tiet"
);
app.manage(EngineProcess(Mutex::new(None)));
}
}
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
// IPC: engine ghi ipc/pick_dir.request → thread nay mo NATIVE dialog
// (IFileDialog/Explorer — tauri-plugin-dialog) → ghi ket qua vao
// ipc/pick_dir.response → engine tra ve cho frontend.
let data_root = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let ipc_dir = std::path::Path::new(&data_root)
.join("SonicForgeDAW")
.join("ipc");
if let Ok(()) = std::fs::create_dir_all(&ipc_dir) {
// Marker: engine biet bridge ton tai (khong phai chay standalone)
let _ = std::fs::write(ipc_dir.join("tauri_bridge_ready"), "1");
}
let ipc_dir_for_thread = ipc_dir.clone();
let app_handle = app.handle().clone();
std::thread::spawn(move || {
loop {
let req = ipc_dir_for_thread.join("pick_dir.request");
if req.exists() {
let _ = std::fs::remove_file(&req);
let resp = ipc_dir_for_thread.join("pick_dir.response");
let _ = std::fs::remove_file(&resp);
// Dialog phai chay tren main thread (GTK/Windows message loop)
let handle = app_handle.clone();
let resp_for_main = resp.clone();
// Clone rieng cho closure: run_on_main_thread(&self, F)
// borrow `handle`, closure (move) phai dung ban clone.
let handle_for_closure = handle.clone();
let _ = handle.run_on_main_thread(move || {
// tauri-plugin-dialog v2: blocking_pick_folder() tra
// Option<FilePath> (enum Path(PathBuf) | Url(Url))
let picked: Option<FilePath> = handle_for_closure
.dialog()
.file()
.blocking_pick_folder();
let val = picked
.map(|p| match p {
FilePath::Path(pb) => pb.to_string_lossy().to_string(),
FilePath::Url(u) => u.to_string(),
})
.unwrap_or_default();
let _ = std::fs::write(&resp_for_main, val);
});
// Cho den khi co response (user co the de dialog mo lau)
let mut waited_ms = 0u32;
while !resp.exists() && waited_ms < 300_000 {
std::thread::sleep(std::time::Duration::from_millis(100));
waited_ms += 100;
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
});
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
// 2. Terminate sidecar khi DAW window dong — tranh orphan process // Terminate sidecar khi DAW window dong — tranh orphan process
if let tauri::WindowEvent::Destroyed = event { if let tauri::WindowEvent::Destroyed = event {
// Lay child ra khoi lock, guard drop ngay tai day (trach E0597)
let child = window let child = window
.state::<EngineProcess>() .state::<EngineProcess>()
.0 .0
+7 -5
View File
@@ -8,6 +8,7 @@
"devUrl": "http://localhost:8000" "devUrl": "http://localhost:8000"
}, },
"app": { "app": {
"withGlobalTauri": true,
"windows": [ "windows": [
{ {
"title": "Sonic Forge DAW - Professional Desktop Studio", "title": "Sonic Forge DAW - Professional Desktop Studio",
@@ -24,16 +25,17 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["msi", "nsis"], "targets": ["nsis", "msi"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.ico" "icons/favicon.ico"
],
"externalBin": [
"binaries/daw_engine"
], ],
"resources": {
"resources/daw_engine": "daw_engine/"
},
"externalBin": [],
"windows": { "windows": {
"nsis": { "nsis": {
"installerHooks": "hooks.nsh" "installerHooks": "hooks.nsh"
+52
View File
@@ -93,3 +93,55 @@ class TestPluginAPI:
assert data["size_bytes"] == len(valid_content) assert data["size_bytes"] == len(valid_content)
elif resp.status_code == 403: elif resp.status_code == 403:
pytest.skip("Permission denied for admin user") pytest.skip("Permission denied for admin user")
def test_plugin_dirs_save_list(self):
"""plugin_dirs (list) — save/get/effective + scan phân loại riêng rẽ."""
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
h = {"Authorization": f"Bearer {token}"}
import tempfile
with tempfile.TemporaryDirectory() as td:
# 2 dir giả: 1 chứa VST, 1 chứa SoundFont
vst_dir = os.path.join(td, "vsts")
sf_dir = os.path.join(td, "sfs")
os.makedirs(vst_dir)
os.makedirs(sf_dir)
open(os.path.join(vst_dir, "Synth1.vst3"), "w").write("x")
open(os.path.join(sf_dir, "piano.sf2"), "w").write("x")
# Save list
r = client.post("/api/v1/plugins/dirs", headers=h,
json={"plugin_dirs": [vst_dir, sf_dir]})
assert r.status_code == 200
assert r.json()["plugin_dirs"] == [vst_dir, sf_dir]
# Get lại
g = client.get("/api/v1/plugins/dirs", headers=h)
assert g.json()["plugin_dirs"] == [vst_dir, sf_dir]
# Scan → phân loại riêng rẽ
s = client.post("/api/v1/plugins/scan", headers=h)
assert s.status_code == 200
data = s.json()
assert any(v["name"] == "Synth1" for v in data["vst_found"])
assert any(x["name"] == "piano" for x in data["soundfonts"])
assert data["vst_count"] == 1
assert data["soundfont_count"] == 1
# Mỗi entry có dir gốc
assert data["vst_found"][0]["dir"] == vst_dir
assert data["soundfonts"][0]["dir"] == sf_dir
def test_plugin_dirs_remove(self):
"""Xóa 1 dir khỏi list → save lại → không còn trong effective."""
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
h = {"Authorization": f"Bearer {token}"}
import tempfile
with tempfile.TemporaryDirectory() as td:
d1 = os.path.join(td, "d1")
d2 = os.path.join(td, "d2")
os.makedirs(d1)
os.makedirs(d2)
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1, d2]})
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
g = client.get("/api/v1/plugins/dirs", headers=h)
assert g.json()["plugin_dirs"] == [d1]
+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');
+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())
+90
View File
@@ -1,3 +1,93 @@
### [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) ### [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): - **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). 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).