117 Commits

Author SHA1 Message Date
3dtours 67ad2b8e4b FIX: sửa lỗi MIDI item với fx mastering tồn tại và không qua Carla 2026-08-10 17:48:38 +07:00
3dtours a1dc074e35 FIX: MIDI item route qua Carla giống hệt keybed — bỏ gate __carlaRunning (Carla mở thủ công/cũ vẫn route; gate cũ làm 'keybed kêu, item câm') + log chẩn đoán [Carla] item note 2026-08-10 10:03:41 +00:00
3dtours 75eba27dcf FIX: (1) masteringConnected default true + ensureMasteringRouting/refreshCarlaStatus ở mọi đường play — MIDI item/preview qua mastering ngay khi chạy app; (2) _instrumentEpoch chống cache stale + retry bank 0 — hết chọn Synth String nghe Piano; (3) /carla-status + auto-open Carla + route exclusive chỉ khi Carla sống (fallback FluidSynth tránh câm); (4) keybed giữ note theo thời gian bấm (auto-off 5s phòng hờ, mouseup dừng ngay) 2026-08-10 09:51:58 +00:00
3dtours f492104869 FIX: (1) MIDI item route CHỈ qua Carla bridge khi VSTi loaded (bỏ FluidSynth GM sai âm) ở mọi đường playback + ghosts; (2) updateSfRouting sync route gains nhánh gainNode + ensureMasteringRouting force-sync mọi node — preview MIDI keyboard qua mastering không cần bật/tắt power; (3) stopNote/panic dừng fallback oscillator theo channel:pitch — hết âm kêu liên tục sau khi thả phím 2026-08-10 09:07:42 +00:00
3dtours 75795653c5 FIX: ensureMasteringRouting gọi getAudioContext() trước — preview đầu tiên cũng sync mastering routing 2026-08-10 06:04:32 +00:00
3dtours 025e0f03a2 FIX: preview soundfont (MIDI keyboard) không tự động qua mastering FX main out
- Thêm ensureMasteringRouting() đồng bộ graph mastering + routing SF + route gains NGAY trước khi preview (keybed onMouseDown, playDrawPreview, playMidiPreview, mở piano roll tab) — hết phải bật/tắt nút power mới có tác dụng
- applyMasteringSettings sig thêm masterConnected/isBypassed — toggle power luôn re-apply tham số module
2026-08-10 06:04:12 +00:00
3dtours c08233a163 FIX: 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain Carla bridge
- Soundfont preview: hủy note đang chờ load soundfont (stopNote/stopAll/panic) — hết âm loop không dừng với MIDI Keyboard; preview dùng channel riêng (applyAITrackInstrument) — hết sai instrument
- Carla bridge: endpoint /carla-stop (all-notes-off OSC + terminate process) + stopBridge() khi track chuyển VSTi -> soundfont — hết âm play qua Carla cũ
- MIDI items play qua Carla khi VSTi loaded: scheduleCarlaNote route vào startTrackPlayback + startLocalTrackPlayback + ghost notes
- Tự động lưu temp khi tắt app: beforeunload/pagehide sendBeacon + autosave 30s + ghi storage/temp/autosave.json + khôi phục khi load lại
- Ctrl-S: desktop -> save-to-disk (Documents/SonicForgeDAW/Projects); docker -> Cloud/local
- FX Chain (Mastering + FX Rack): module Carla Bridge (VST FX) — load/openInCarla + stop/unload, pass-through trong graph
2026-08-10 05:34:32 +00:00
3dtours d20932a68b FIX: sửa lỗi MIDI item không play được qua carla, loop không tắt với soundfont 2026-08-10 11:33:47 +07:00
3dtours 1cf5b18492 FIX: sửa lỗi không load soundfont trên window, MIDI item không play được qua carla 2026-08-10 10:50:02 +07:00
3dtours 1db892bb99 FIX: sửa lỗi không load instrument của soundfont trên window 2026-08-10 10:04:54 +07:00
3dtours 85ac80bb4f FIX: sửa lỗi không load soundfont trên window 2026-08-10 09:29:05 +07:00
3dtours 250b94cd88 FIX: Carla sử dụng bridge có midikeyboard 2026-08-10 08:53:05 +07:00
3dtours aa32272f36 FIX: Carla sử dụng bridge 2026-08-10 07:57:26 +07:00
3dtours fbaac1f673 chore: bo test data plugin_dirs.json khoi repo + ignore 2026-08-09 13:52:49 +00:00
3dtours 438ee607fc fix: nut Synth tren TCP khong hien danh sach VSTi da scan (ban 1.1.5)
- Nut Synth TCP goi openInstrumentSelector (modal 'Select Instrument')
  nhung modal chi co category SoundFont + GM - khong co muc VST (muc VST
  chi ton tai o dropdown MixerStrip). Them 'VST Instruments' vao sidebar
  trai modal: list instrumentSelectorData.vst_instruments (listPlugins),
  click -> setTrackInstrumentWithUndo(trackId, v.id, v.name) + dong modal.
- _scan_vst_in_dirs + /scan: phat hien ca FOLDER ten X.vst3 (Windows VST3
  = folder chua X.vst3.dll) - truoc day chi scan file.
- Ghi chu: pedalboard (engine VST) la headless - KHONG mo duoc GUI goc cua
  plugin; danh sach + chon la co che chon nhac cu kha thi.

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

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

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

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

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

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

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

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

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

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

Fix:
- tauri.conf.json: resources dang MAP {'resources/daw_engine': 'daw_engine/'}
  -> resources_map -> Walk mode, dest.join(strip_prefix) -> giu nguyen cay
  _internal, dich chuan /daw_engine/ (xac nhan tauri-cli
  BundleResources::Map -> settings.resources_map, dong 1467-1469).
- lib.rs: do them 3 vi tri fallback (legacy resources/ prefix, portable
  exe_dir, dev exe_dir/../resources) + log diagnostic day du vao spawn.log
  (liet ke noi dung resource_dir/exe_dir khi khong tim thay).
- build_windows.ps1: them huong dan xac minh spawn.log sau khi cai dat.
2026-08-09 10:48:09 +00:00
3dtours 29ebbfc1c0 perf: slim daw_engine bundle 409MB->170MB + fix engine khong chay tren Windows
- Thay librosa bang app/core/audio_features.py (numpy/scipy/soundfile):
  load, beat_track, frames_to_time, spectral_centroid, rms, zero_crossing_rate,
  time_stretch, pitch_shift, chroma_stft. A/B ngang librosa (BPM <1% sai lech,
  pitch_shift chuan toi Hz). Loai bo llvmlite 171MB + scikit-learn + numba.
- Task layer 2 che do (app/tasks/worker.py): server giu celery; desktop slim
  chay in-process thread + registry, giu nguyen API contract (.delay/.id/status
  /tasks/{id}) nen frontend khong doi.
- engine.spec: excludes librosa/numba/llvmlite/sklearn/celery/redis/kombu/
  billiard/amqp/msgpack/yaml/PIL/cairosvg/zstandard/...; scan scipy gioi han
  scipy.signal; giu click (uvicorn.main import click).
- render_engine: scipy.signal thanh lazy import (giam cold start).
- tauri.conf.json: targets [nsis, msi] - NSIS tro lai (bundle nho) de
  hooks.nsh cai VC++ Redistributable - sua bug daw_engine.exe khong chay
  tren Windows (truoc day MSI-only khong chay hooks).
- build_linux.sh / build_macos.sh: build 1 lenh moi OS.
- Doc: DESKTOP_INSTALL_PLAN.md muc 5.1.
- Verify: 86 tests pass, engine dong goi upload/analyze/waveform/export OK.
2026-08-09 10:17:10 +00:00
3dtours 1191e46ee5 fix: spawn.log ghi vao %APPDATA%/SonicForgeDAW/logs (thu muc luon ton tai) — engine_dir co the khong duoc bundle vao installer 2026-08-09 09:15:01 +00:00
3dtours 63af8ec414 fix: khong panic khi spawn engine fail (UI khong thoat) + spawn.log chan doan (path + exists) + giu SF_PARENT_PID 2026-08-09 08:51:21 +00:00
3dtours 6e962dc1b2 FIX: _pick_port bo SO_REUSEADDR — Windows cho phep bind trung port -> 2 engine cung 8000 -> UI loi ngau nhien 2026-08-09 08:50:08 +00:00
3dtours 6eef16edc0 fix: targets = ['msi'] — makensis 32-bit loi mmapping datablock voi bundle engine lon (RAM 32GB van fail); MSI (WiX 64-bit) build thanh cong 2026-08-09 08:28:15 +00:00
3dtours cd2547ef6a revert: compression zstd khong co trong schema nsis cua Tauri version hien tai 2026-08-09 05:08:53 +00:00
3dtours c538cab745 fix: NSIS compression zstd thay LZMA — LZMA solid can nhieu RAM/pagefile -> makensis 32-bit loi mmapping datablock (#12345) voi bundle engine lon 2026-08-09 05:03:54 +00:00
3dtours 90493c73f3 fix: spawn engine qua app.shell().command() (ShellExt) — process::Command::new la private trong tauri-plugin-shell v2 2026-08-09 04:39:15 +00:00
3dtours a3359aa0ed fix: resources dung directory path thuan 'resources/daw_engine' (Tauri v2 copy recursive, glob ** khong dang tin — da fail 3 lan) 2026-08-09 04:34:01 +00:00
3dtours a7489f41e6 fix: resources glob rong resources/daw_engine/** — match ca 2 cau truc onedir (PyInstaller 6.x co _internal, 5.x khong) 2026-08-09 04:26:22 +00:00
3dtours 194c9b52b2 fix: resources glob liet ke file cu the (exe + README.txt luon co) + _internal/** — khong phu thuoc glob rong 2026-08-09 04:24:11 +00:00
3dtours 77505fbc86 fix: build_windows.ps1 ASCII-only lan 2 (em-dash) 2026-08-09 04:17:40 +00:00
3dtours a61f9abd6a fix: build_windows.ps1 ASCII-only (PS 5.1) 2026-08-09 04:17:22 +00:00
3dtours a7163efaf1 fix: build_windows.ps1 guard resources/daw_engine (exe + _internal) truoc tauri build — glob fail neu thieu 2026-08-09 04:17:09 +00:00
3dtours 2462bbc1a8 fix: tauri resources glob tach rieng — resources/daw_engine/* (file 1 level) + _internal/** (subdirs) thay ** rong (co the khong match file truc tiep) 2026-08-09 04:16:49 +00:00
3dtours 1f1017d78a fix: thay .gitkeep bang README.txt (glob ** co the bo qua dotfile) trong resources/daw_engine 2026-08-09 03:46:08 +00:00
3dtours f17bed4e5f fix: placeholder .gitkeep trong resources/daw_engine de tauri.conf glob resources/daw_engine/** khong loi 'path not found' khi chua chay buoc copy engine 2026-08-09 03:44:50 +00:00
3dtours bc6bc71d0e chore: gitignore src-tauri/resources/daw_engine (build script tao) 2026-08-09 03:31:24 +00:00
3dtours bc8431fe81 FIX 3 van de Windows:
1. LOAD CHAM: PyInstaller onefile 723MB giai nen toan bo vao %TEMP% moi lan chay -> chuyen ONEDIR (engine.spec COLLECT) + bundle qua Tauri resources (resource_dir/daw_engine/daw_engine.exe) thay externalBin — khoi dong tuc thi khong giai nen
2. WINDOW PICKER: Tauri v2 khong co window.__TAURI__.dialog — goi invoke('plugin:dialog|open', {options:{directory:true}}) qua __TAURI__.core.invoke
3. ICON exe: bundle.icon chuyen icon.ico -> favicon.ico + thay 3 PNG bang favicon render
- build_windows.ps1 buoc 4: copy onedir dist/daw_engine -> src-tauri/resources/daw_engine
2026-08-09 03:30:57 +00:00
3dtours 466bf25a0b FIX: build_windows.ps1 buoc [0/6] kill daw_engine.exe cu dang chay — file exe bi khoa -> Tauri build PermissionDenied externalBin 2026-08-09 03:12:00 +00:00
3dtours 52e1dc6eda FEAT: icon exe = favicon cua app — favicon.svg -> PNG -> ICO (6 sizes 16-128)
- engine.spec: icon='src-tauri/icons/favicon.ico' (cung nguon voi icon hien thi trong app, truoc day dung icon.ico mac dinh khac favicon)
- tools/gen_favicon_ico.js: script tai sinh ICO tu app/templates/favicon.svg (resvg-js WASM, khong can libcairo)
- Verify: ICO 6 sizes (16/24/32/48/64/128), 571 mau, binary chay OK
2026-08-09 02:59:11 +00:00
3dtours 30a40b2bca FEAT: Plugins Manager - danh sach thu muc da muc (Add Directory + x xoa), scan phan loai rieng re VST/SoundFont theo dir
- Backend: plugin_dirs (list) thay vst_dir/soundfont_dir rieng — luu plugin_dirs.json, backward compat gop field cu; scan walk tung dir phan loai .vst3/.dll/.so -> vst_found, .sf2/.sf3 -> soundfonts (kem dir goc)
- SoundFontAutoScanner: system_sf_dirs (list) — scan tat ca thu muc user khai bao
- Frontend: PluginManagerModal — nut Add Directory (Tauri dialog / prompt fallback), moi dir co nut x xoa, nut Scan -> hien danh sach rieng re VST Instruments + SoundFonts (kem duong dan thu muc)
- Tests: +2 (save/get/scan phan loai, xoa dir)
2026-08-09 02:52:10 +00:00
3dtours 8dd00cc2ea FEAT: Plugin Manager folder picker + scan dirs (Windows/macOS), docker .env paths, status bar adaptive tips, DSP Tool vao sub-tab audioclip
- Plugins Manager (Tools menu): Browse folder (Tauri dialog + fallback paste path), Save & Scan VST/SoundFont dirs -> plugin_dirs.json (user override, env la base)
- Docker: VST_DIR/SOUNDFONT_DIR/PIANOBK_DIR tu .env/docker-compose mount vao container + env cho engine/celery
- Status bar: bo label 'Scroll: Zoom' -> Adaptive tips (prHint + fallback text)
- DSP Tool: move vao SUB-TAB editor audioclip (Phase Inv / Swap L/R / Reverse + apply vung chon/ca clip)
- Tauri: them tauri-plugin-dialog + dialog:default permission cho folder picker
2026-08-09 02:44:29 +00:00
3dtours 3bd0989031 FIX: windowed stderr -> file log (khong devnull) de khong nuot im loi; watchdog Windows dung OpenProcess thay os.kill(pid,0) (TerminateProcess giet cha) 2026-08-09 01:40:20 +00:00
3dtours dccf4f45ef FIX: windowed app (console=False) sys.stderr=None -> uvicorn logging crash isatty — thay bang devnull 2026-08-09 00:58:22 +00:00
3dtours 9099529403 FIX: engine.spec dung os thay _os (NameError luc build) 2026-08-08 15:56:34 +00:00
3dtours 3795ea9d78 FIX: scipy hiddenimports scan FILESYSTEM (glob .py/.pyd/.so) thay collect_submodules - walk_packages bo qua am tham subpackage import loi luc build (mat ca cay scipy.sparse.csgraph._shortest_path tren may user) 2026-08-08 15:55:08 +00:00
3dtours fa24c61bbf FIX: hiddenimport tinh scipy.stats._ansari_swilk_statistics - phong collect_submodules miss (scipy 1.18+ _morestats import top-level) 2026-08-08 15:07:37 +00:00
3dtours 83427fe503 FIX: verify_bundle.py false-positive Windows - TOC Python literal double-escape backslash (app\\static -> app//static), normalize 2 buoc 2026-08-08 14:50:07 +00:00
3dtours cef2d6666b FIX (lan 3 - goc root): chen _SPEC_ROOT vao sys.path truoc collect_data_files('app') — 'pyinstaller' entry point khong them CWD vao sys.path 2026-08-08 14:37:38 +00:00
3dtours bfe9e47db1 FIX (lan 2): build_windows.ps1 tu cai @babel/standalone neu thieu (ERR_MODULE_NOT_FOUND) 2026-08-08 14:19:03 +00:00
3dtours bcc91db4a7 FIX: them @babel/standalone vao package.json (build.mjs ERR_MODULE_NOT_FOUND tren Windows) 2026-08-08 14:17:15 +00:00
3dtours 1f7a8c6f1b FIX: verify bundle dung tools/verify_bundle.py (normalize backslash, false-positive 'thieu static') + check engine.spec phien ban moi 2026-08-08 14:15:14 +00:00
3dtours 8b0551b18f FIX: build_windows.ps1 parse error PS5.1 — ASCII-only + UTF-8 BOM (unicode em-dash lam hong string terminator) 2026-08-08 14:01:40 +00:00
3dtours eaca051191 FIX (lan 2): bundle app assets qua collect_data_files (import-system) + fallback static dir trong main.py + verify TOC trong build_windows.ps1 2026-08-08 13:59:35 +00:00
3dtours b78a629429 IMPROVE: About modal dùng logo favicon.svg của app thay cho div chữ SF 2026-08-08 13:37:03 +00:00
3dtours 366219a269 FEAT: menu Help (About modal + hướng dẫn sử dụng) + Tools Preferences (theme/language/button font size) 2026-08-08 13:23:23 +00:00
3dtours 3618e3c591 FIX: VU meter giữ animation đúng trường độ âm khi ARM + giữ phím MIDI keyboard (heldMidiNotesRef, không decay khi note còn giữ) 2026-08-08 13:12:49 +00:00
3dtours b490aa9951 FIX: chặn browser zoom toàn trang — item/button/label giữ nguyên kích thước khi phóng to UI (capture preventDefault, giữ zoom chuyên dụng) 2026-08-08 13:08:25 +00:00
3dtours be93f9f55a FIX: bundle scipy submodules (ansari_swilk_stats) + librosa .pyi stubs (lazy_loader) trong engine.spec 2026-08-08 12:52:38 +00:00
3dtours 7dda81b1d3 FIX: Windows build crash 'app\static does not exist' (datas absolute + APP_DIR freeze-aware) + VU meter ARM/MIDI keyboard 2026-08-08 11:51:15 +00:00
3dtours f7b0172fdc FIX: engine.spec — collect_submodules('celery.fixups') thay vi liet ke tung module 2026-08-08 10:40:21 +00:00
3dtours 9bebbe8e7a FIX: engine.spec — hiddenimport celery.fixups (celery import dong, PyInstaller khong thay) 2026-08-08 10:24:35 +00:00
3dtours 4e4391b315 FIX: lib.rs — E0597 borrow, take child ra khoi lock truoc khi kill 2026-08-08 10:12:21 +00:00
3dtours e978abf0e9 FIX: lib.rs — import tauri::Manager (can manage/state) 2026-08-08 10:04:58 +00:00
3dtours bb699631bf FIX: tauri.conf.json v2 — nsis config phai nam trong bundle.windows 2026-08-08 09:55:27 +00:00
3dtours 0a1f3efd64 FEAT: chuẩn bị thư viện để tạo file exe trên window 2026-08-08 09:44:37 +00:00
3dtours 9ef622f29a IMPROVE: thêm tập tin LICENSE của dự án 2026-08-08 07:20:09 +07:00
3dtours 8e3c652551 IMPROVE: PIANO ROLL TAB khi play thì playhead luôn nằm chính giữa piano roll 2026-08-07 22:10:37 +07:00
3dtours 393df8fd7b FIX: cho phép shift+click để chọn notes, drag để di chuyển các notes, ctrl+drag để copy nhanh các notes 2026-08-07 21:48:14 +07:00
3dtours 688a7a57e2 FIX: cho phép copy/cut các notes được chọn trong PIANO ROLL TAB 2026-08-07 21:39:18 +07:00
3dtours 652e745d0a IMPROVE: thay đổi giao diện của PIANO ROLL TAB 2026-08-07 21:22:13 +07:00
3dtours 45ab31d42e IMPROVE: thêm các tính năng cho MIDI trong PIANO ROLL TAB 2026-08-07 20:46:47 +07:00
3dtours c3182f6baa FIX: sửa lỗi không hiển thị đúng nội dung của SECTION-TAB trong section item 2026-08-07 18:58:29 +07:00
3dtours 1d9b426d15 FIX: sửa lỗi copy/cut paste không đúng item 2026-08-07 17:06:05 +07:00
3dtours 4f34491f3e FIX: sửa lỗi hiển thị animation của VU meter khi play MIDI 2026-08-07 15:57:49 +07:00
3dtours 9e269d3b79 FIX: sửa lỗi hiển thị animation của VU meter 2026-08-07 15:40:15 +07:00
3dtours 58c6ec794d FIX: sửa lỗi hiển thị số bar bị sai 2026-08-07 15:07:06 +07:00
3dtours 173a1f227b FIX: sửa lỗi lưu thay đổi trong SECTION-TAB thì cũng tạo duration trên MAIN SESSION 2026-08-07 11:08:39 +07:00
3dtours c1aa1eb4d4 FIX: sửa lỗi phát hiện đã không còn play midi note để PIANO ROLL TAB quay về vị trí 0 2026-08-07 10:41:45 +07:00
3dtours 6c16bf8410 FIX: sửa lỗi đồng bộ playhead vị trí play giữa MAIN SESSION/SECTION-TAB/PIANO ROLL TAB 2026-08-07 10:37:34 +07:00
3dtours 4981785913 FIX: sửa lỗi playhead đồng bộ giữa các tab, nhấn space có thể bind với phím play 2026-08-07 10:27:21 +07:00
3dtours bca661bffd FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION/SECTION-TAB không đồng bộ playhead 2026-08-07 10:12:18 +07:00
3dtours 4c820bfa77 FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION và SECTION-TAB thì bị câm 2026-08-07 09:35:40 +07:00
3dtours 8cdf2942b9 FIX: sửa lỗi chuyển từ tab MAIN SESSION sang SECTION-TAB và PIANO ROLL TAB thì bị bypass 2026-08-07 09:13:03 +07:00
3dtours 23675dab83 FIX: Đã fix preview Media Explorer (v202608062345) 2026-08-06 20:45:56 +07:00
3dtours 71b9231b57 FIX: Đã fix block lồng + mất MIDI (v202608062330) 2026-08-06 20:42:27 +07:00
3dtours 93dc13b17a FIX: lỗi bypass của SECTION-TAB 2026-08-06 18:19:04 +07:00
3dtours f1e914fa62 FIX: lỗi: CC7 (channel volume) chỉ được set 100/0 (mute/unmute) — volume track KHÔNG áp qua channel → track MIDI dùng chung channel: CC7 của track set cuối thắng → volume của track kia vô tác dụng 2026-08-06 14:10:57 +07:00
3dtours 4dfab954d8 FIX: reset tất cả về mặc định khi tạo new project 2026-08-06 11:47:10 +07:00
3dtours e99e54773f FIX: Đã sắp xếp lại (v202608061030) 2026-08-06 08:29:23 +07:00
3dtours 0ba31c57bf FIX: Đã đổi tên (v202608060900) 2026-08-05 22:23:31 +07:00
3dtours 454dd91f96 FIX: Đã fix cross-machine (v202608060700) 2026-08-05 22:02:43 +07:00
3dtours e9f29e09ca FIX: Đã fix cross-machine (v202608060500) 2026-08-05 17:40:35 +07:00
3dtours 289746f187 FIX: Đã fix (v202608060430) — tab deactive → stop âm tab đó 2026-08-05 17:29:52 +07:00
3dtours b3ced7a7b3 FIX: Clone AI Var kế thừa midiChannel của track gốc → 2 track DÙNG CHUNG channel 2026-08-05 15:49:44 +07:00
3dtours 4233c1eeda FIX: Piano roll track 4 phát âm thanh instrument track 3 2026-08-05 15:17:13 +07:00
3dtours 35f7c3822f FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 15:05:44 +07:00
3dtours d37f4e7557 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 12:30:42 +07:00
3dtours 8a9d6b3c27 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery nhưng âm thanh rất nhỏ 2026-08-05 12:16:00 +07:00
3dtours 55d3464b1e FIX: Piano roll tab không xuất âm thanh qua mastering chain 2026-08-05 12:10:50 +07:00
3dtours cfc114b9d7 FIX: khi ở MAIN SESSION đang play mà mở Piano roll tab thì âm thanh bị tắt 2026-08-04 22:12:50 +07:00
3dtours 856a8183b6 FIX: lỗi mở MIDI item ở trong PIANO ROLL TAB là bị lỗi âm thanh của main session 2026-08-04 22:07:48 +07:00
3dtours 0272912cff FIX: khi click vào track number thì bị mất âm thanh, cho phép lưu màu của track 2026-08-04 16:43:44 +07:00
3dtours c047934fc4 IMPROVE: cài đặt thêm tool để có thể phối lại cho đoạn midi item hoặc viết thêm cho nó 2026-08-04 15:23:11 +07:00
3dtours fc663921ff IMPROVE: khi zoom in thì giữ nguyên kích thước sau khi reload page, drag Audioclip item đi vị trí khác thì playhead vẫn play đúng vị trí âm thanh 2026-08-04 12:16:53 +07:00
3dtours fe5e8cb58e IMPROVE: gỡ bỏ các panel không cần thiết và tinh chỉnh phần export, thêm EQ Pro cho Mastering và FX Rack 2026-08-04 09:59:58 +07:00
3dtours 01fcf51fea FIX: thêm nút bypass cho midi items và audio items, Section item 2026-08-03 22:21:50 +07:00
3dtours 1af2119444 IMPROVE: thêm nút bypass cho midi items và audio items 2026-08-03 21:51:37 +07:00
3dtours e5b4321a55 FEAT: thêm FX RACK PANEL cho track, áp dụng cho midi item và audioclip item 2026-08-03 20:25:27 +07:00
3dtours ec5fedec33 FIX: sửa lỗi khi quét chọn duration để loop và khi bật nút loop thì tự động giãn ra bar 18 2026-08-03 19:41:42 +07:00
3dtours 8c1a8ead56 FIX: hiển thị MASTERING PANEL và các modules không bị lỗi 2026-08-03 18:29:34 +07:00
3dtours ed91e4534c IMPROVE: bổ sung thêm imager module ho MASTERING PANEL 2026-08-03 17:54:59 +07:00
3dtours 8fc1c2641b FIX: chỉnh sửa mute và unmute realtime, sửa lỗi save dự án và chèn chung các items vào một track 2026-08-03 17:22:19 +07:00
3dtours a9da813cb1 FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel 2026-08-03 15:47:10 +07:00
3dtours 6f55d36085 feat: Media Explorer - auto tempo theo MIDI file + gõ tempo tay (tempoText/commitTempo) + focus folder cha giữa tree + điều hướng tree bằng phím mũi tên 2026-08-03 12:46:34 +07:00
82 changed files with 17272 additions and 1330 deletions
+44
View File
@@ -0,0 +1,44 @@
# ── Docker build context — loại bỏ mã nguồn/bí mật không cần vào image ──
.git
.gitignore
.github
# Mã nguồn client (JSX) — image CHỈ chứa bản precompiled (minified)
app/static/js/app.jsx
app/static/js/app.jsx.new
*.jsx
# Bí mật
.env
.env.*
!.env.example
# Dependencies / build cache
node_modules
__pycache__
*.pyc
.cache
.pytest_cache
# Tài liệu / kế hoạch / backup nội bộ
wiki.md
PLAN.md
PLAN*
plans/
md/
*.patch
*.recovered
index.html.recovered
celerybeat-schedule
celerybeat.pid
# Dữ liệu runtime (mount volume riêng — KHÔNG chép vào image)
app/storage/uploads
app/storage/processed
app/storage/sonicforge.db
celerybeat-schedule
# Test
tests/
tools/
samples/
+34
View File
@@ -0,0 +1,34 @@
# ── SonicForge Studio — môi trường (SAO CHÉP sang .env — KHÔNG commit .env) ──
# Redis / Celery
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
# LLM (server-side proxy — client không thấy key)
OPENAI_API_BASE=http://localhost:11434/v1
OPENAI_API_KEY=ollama
# Auth
SECRET_KEY=thay-bang-chuoi-ngau-nhien-dai
DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
# Storage (đường dẫn trong container)
STORAGE_DIR=/app/app/storage
# ── Plugin directories ──
# Đường dẫn HOST tới thư mục chứa VST / SoundFont / Pianobook — dùng trong
# docker-compose.yml để mount vào container (đổi theo máy chạy Docker).
# Mặc định: /home/locpham/daw_assets/...
VST_DIR=/home/locpham/daw_assets/vst3
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
# ── Runtime (tự phát hiện môi trường) ──
# auto (mặc định): Windows/macOS → desktop; Linux không DISPLAY hoặc docker → headless.
# desktop: server + client cùng 1 máy (bật nút "Mở trong Carla" nếu có Carla local)
# headless: server docker + browser UI (soundfont + VSTi mở được từ storage mount;
# preset chỉnh trên máy khác → upload .vstpreset qua web UI)
SF_RUNTIME=auto
# Ép nhận diện Docker (thường tự detect qua /.dockerenv; đặt =1 nếu cần)
SF_DOCKER=1
+5
View File
@@ -19,6 +19,7 @@ app/storage/processed/*
!app/storage/processed/.gitkeep !app/storage/processed/.gitkeep
app/storage/*.db app/storage/*.db
app/storage/sf_scan_state.json app/storage/sf_scan_state.json
app/storage/temp/
.DS_Store .DS_Store
# VST3 and sample library directories (proprietary binaries) # VST3 and sample library directories (proprietary binaries)
@@ -29,3 +30,7 @@ samples/
*.log *.log
celerybeat-schedule celerybeat-schedule
node_modules node_modules
src-tauri/target/
src-tauri/binaries/
src-tauri/resources/daw_engine/
src-tauri/vc_redist.x64.exeapp/storage/plugin_dirs.json
+130
View File
@@ -0,0 +1,130 @@
# DESKTOP INSTALL PLAN — SonicForge Studio (bản cài trực tiếp trên OS)
Mô hình: **server chạy nền trên máy người dùng (localhost), client mở bằng browser**.
Không cần Electron — tận dụng stack hiện có (FastAPI + static JS precompiled).
Soundfonts do **người dùng tự tải vào thư mục riêng****khai báo** cho ứng dụng.
---
## 1. KIẾN TRÚC
```
┌─────────────────────────── NGƯỜI DÙNG (1 máy) ───────────────────────────┐
│ Browser ──http://127.0.0.1:8000──▶ FastAPI (uvicorn, chạy NỀN) │
│ │ │
│ ~/SonicForgeStudio/ ├─ data/ (DB, uploads, cache) │
│ ├─ data/sonicforge.db ├─ soundfonts/ (user tự bỏ .sf2) │
│ ├─ soundfonts/*.sf2/.sf3 └─ quét nền 30s (scanner có sẵn) │
│ └─ config.json ← khai báo folder soundfont │
└──────────────────────────────────────────────────────────────────────────┘
```
## 2. ĐÓNG GÓI CÀI ĐẶT (per-OS) — server nền + browser client
### Thành phần đóng gói
| Thành phần | Vai trò |
|---|---|
| Python backend (`app/`, `requirements.txt`) | Server — **PyInstaller `--onedir`** → binary (không cần Python trên máy user; bảo vệ source tốt hơn so với chạy .py) |
| `app/static/` (precompiled.js + css + vendor) | Client — đóng vào package, server phục vụ |
| VST3 plugins (`vst_plugins/`) | Copy theo platform (win .dll / mac .vst3 / linux .so) |
| Service wrapper | Tự khởi động server nền + mở browser khi login |
### Cài đặt + service nền theo OS
| OS | Installer | Service nền | Auto-open browser |
|---|---|---|---|
| **Windows** | Inno Setup (`.exe`) | Windows Service qua `NSSM`/`WinSW` hoặc Task Scheduler (logon) | `start http://127.0.0.1:8000` |
| **macOS** | `.dmg` + `.app` (PyInstaller) | `launchd` LaunchAgent (`~/Library/LaunchAgents`) | `open http://127.0.0.1:8000` |
| **Linux** | `.deb` / `.AppImage` | systemd **user** service (`~/.config/systemd/user/`) | `xdg-open http://127.0.0.1:8000` |
- Server bind **`127.0.0.1`** (không lộ mạng), port mặc định 8000 (config được).
- App gồm 2 tiến trình nhỏ: `web` (uvicorn) + `worker` (celery) — hoặc gộp worker vào web ở chế độ desktop (đơn giản: `--pool=solo`, chạy celery trong tiến trình riêng nếu cần render nặng).
## 3. THƯ MỤC DỮ LIỆU NGƯỜI DÙNG
```
~/SonicForgeStudio/
├── data/ # DB, uploads, processed (thay app/storage khi chạy desktop)
├── soundfonts/ # USER tự tải .sf2/.sf3 vào đây (mặc định được quét)
└── config.json # cấu hình: soundfont_dirs, port, autostart...
```
- `config.py`: thêm `DATA_DIR` (env `SFDATA_DIR`, mặc định `~/SonicForgeStudio/data`), `SOUNDFONT_DIRS`.
## 4. SOUNDFONT DO NGƯỜI DÙNG QUẢN LÝ (tải + khai báo)
**Cơ chế hiện có (tận dụng):** `app/core/soundfont_scanner.py``SoundFontAutoScanner` quét nền 30s
`/opt/daw_engine/soundfonts` + `storage/soundfonts` → catalog → API list qua `app/api/v1/plugins.py`.
**Việc cần làm (mở rộng):**
1. **Scanner nhận folder người dùng:** constructor nhận thêm `user_dirs` (từ `SOUNDFONT_DIRS` env + `config.json`) — merge vào catalog (ưu tiên: user > system).
2. **Khai báo folder — 2 cách:**
- **UI** (chính): Settings → "Soundfonts" → nút **Add folder…** (chọn thư mục chứa .sf2) → lưu vào `config.json` → gọi scanner rescan.
- **config.json** (thủ công): `{ "soundfont_dirs": ["D:/SF", "/Users/me/sf"] }`.
3. **API:** thêm endpoint `POST /api/v1/plugins/soundfonts/dirs` (đăng ký folder) + `GET .../dirs` (liệt kê) — scanner reload.
4. **UI danh sách:** hiển thị catalog (tên SF, kích thước, folder nguồn) — chọn = load vào FluidSynth (luồng có sẵn qua `SonicSF.selectInstrument`).
5. **Số hóa:** không upload file — server đọc trực tiếp từ đường dẫn user khai báo (không nhân đôi dữ liệu).
## 5. BUILD + PHÁT HÀNH
```
code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đóng installer theo OS
→ ký số (tùy chọn) → phát hành (GitHub Releases / trang riêng)
```
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
Đã xử lý:
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
desktop slim chạy task in-process (thread + registry), giữ nguyên API
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
còn `scipy.signal` (goi duy nhất app còn dùng).
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
"build xong không chạy daw_engine" trên Windows).
- **Fix layout resources (bản 1.1.1 — `exists=false` trong spawn.log)**:
`bundle.resources` dạng ARRAY `["resources/daw_engine"]` copy engine tới
`$RESOURCE_DIR/resources/daw_engine/...` (giữ tiền tố `resources/` — đọc
source `tauri-utils/src/resources.rs`) trong khi lib.rs tìm ở
`$RESOURCE_DIR/daw_engine/...` → `exists=false`. Đổi sang dạng MAP
`{"resources/daw_engine": "daw_engine/"}` (Walk mode, giữ nguyên cây
`_internal`, đích chuẩn `daw_engine/`). `src-tauri/src/lib.rs` đồng thời dò
thêm 3 vị trí fallback (legacy/portable/dev) + ghi diagnostic đầy đủ vào
`%APPDATA%/SonicForgeDAW/logs/spawn.log` (liệt kê nội dung resource_dir khi
không tìm thấy).
Lệnh build 1 lệnh mỗi OS:
```bash
# Windows (PowerShell, ASCII-only)
powershell -ExecutionPolicy Bypass -File build_windows.ps1
# Linux (cần binutils: sudo apt-get install -y binutils)
bash build_linux.sh
# macOS (cần codesign/notarize khi phát hành)
bash build_macos.sh
```
## 6. CẬP NHẬT
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
- `?v=` cache-bust JS mỗi bản (cơ chế đã có) — browser không dính cache cũ.
## 7. CHECKLIST CODE CẦN LÀM
- [ ] `config.py`: `DATA_DIR`, `SOUNDFONT_DIRS` (env + config.json)
- [ ] `soundfont_scanner.py`: nhận `user_dirs`, merge catalog, ưu tiên user
- [ ] API: `POST/GET .../soundfonts/dirs` (đăng ký/liệt kê folder) + rescan
- [ ] UI Settings → Soundfonts (Add folder, Browse, list, load)
- [ ] `soundfontStorage.js`: chuyển hướng sang catalog folder-scan (giữ upload path cho dự án cũ)
- [ ] PyInstaller spec: bundle static/ + vendor (libfluidsynth wasm, vst), bind 127.0.0.1
- [ ] Service wrappers: Windows (NSSM/WinSW), macOS (launchd), Linux (systemd user) + auto-open browser
- [ ] Installer scripts: Inno Setup (.exe), dmg (macOS), deb/AppImage (Linux)
- [ ] CI matrix build 3 OS + release artifacts
- [ ] `version.json` + in-app update check
+85
View File
@@ -0,0 +1,85 @@
# DISTRIBUTION PLAN — SonicForge Studio (Alpha → Beta → Production)
Mục tiêu: phân phối ứng dụng cho người dùng **không chia sẻ mã nguồn**, có pipeline
cập nhật liên tục. Áp dụng cho backend Python/FastAPI + frontend JS (Babel precompiled).
---
## 1. TÁCH BÍ MẬT — FILE .env
| Biến | Dùng cho | Bắt buộc |
|---|---|---|
| `REDIS_URL` | Redis/Celery broker | ✓ |
| `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` | Celery | ✓ |
| `OPENAI_API_BASE` / `OPENAI_API_KEY` | LLM server-side (client KHÔNG thấy key — `aiGateway.js` chỉ gọi proxy server) | ✓ |
| `SECRET_KEY` | Auth token (app/core/auth.py) | ✓ (mặc định trống → phải set) |
| `DEFAULT_ADMIN_PASSWORD` | Admin mặc định | thay đổi ở production |
- File `.env.example` đã tạo (commit được). `.env` thật KHÔNG commit (đã vào `.dockerignore`).
- **Nguyên tắc:** client JS không bao giờ chứa secret — mọi API key nằm server (env) hoặc proxy.
## 2. BUILD IMAGE — KHÔNG CHIA SẺ MÃ NGUỒN
### 2.1 Chặn source khỏi image (`.dockerignore` — đã tạo)
Loại khỏi build context: `.git`, `app/static/js/app.jsx` (source JSX), `wiki.md`, `plans/`, `*.patch`, `.env`, `tests/`, `node_modules`, backup...
### 2.2 Frontend — chỉ ship bản precompiled
- Build JS: `node build.mjs` (Babel → `app.precompiled.js` minified) — **CHỈ bản minified vào image**, source `.jsx` bị `.dockerignore` chặn.
- Bump `?v=` (cache-bust) mỗi bản phát hành — user cập nhật không dính cache cũ.
### 2.3 Backend Python — giới hạn đọc source
- Python không biên dịch native mặc định → `.py` vẫn đọc được trong image. 3 lớp bảo vệ:
1. **Registry riêng tư** (GHCR / Docker Hub private / Harbor) — image KHÔNG public — người dùng chỉ nhận qua pull có auth.
2. **Secret chỉ qua env** — không hardcode gì trong image.
3. *(Tùy chọn, giai đoạn sau)* compile Python bằng **Nuitka**`.so` cho `app/core`, `app/api` (giữ `main.py`/`config.py` đọc được — không chứa bí mật).
### 2.4 Cách build + push
```bash
# 1. Precompile JS + bump ?v=
node build.mjs # hoặc: NODE=... babel ... (xem wiki)
# 2. Build image (tag theo version)
docker build -t ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1 .
docker push ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1
# 3. Production chạy image đã push (KHÔNG mount source):
docker compose -f docker-compose.prod.yml up -d
```
- Compose production (`docker-compose.prod.yml` — đã tạo): image từ registry, `env_file: .env`,
volumes bền (uploads/processed/db), asset dirs qua env (`VST3_DIR`, `SOUNDFONTS_DIR`, `PIANOBOOK_DIR`),
KHÔNG có `.:/app` (bỏ mount dev của compose cũ).
## 3. KẾ HOẠCH PHÁT HÀNH
### Giai đoạn 1 — ALPHA (nội bộ dev/QA)
- Tag: `v0.x.x-alpha.N` — chạy `docker compose up --build` (dev mount OK).
- Log verbose; chưa quan tâm bảo mật; test tính năng + thu hồi nhanh.
- Mỗi thay đổi: bump `?v=` → build → tag → ghi wiki.md.
### Giai đoạn 2 — BETA (nhóm người dùng mời)
- Tag: `v0.x.x-beta.N` — image push **registry riêng tư**.
- Người dùng: `docker compose -f docker-compose.prod.yml pull && up -d` (chỉ cần `.env` + image).
- Thu log lỗi: thêm endpoint `/health` + (tùy chọn) Sentry/self-host error tracking.
- **Feature flags** (biến env `FEATURE_*`) để tắt tính năng rủi ro từ xa.
### Giai đoạn 3 — PRODUCTION
- Tag: `vX.Y.Z` (semver) + `latest`.
- **Trước khi release:** backup volumes (`docker run --rm -v sf_db:/data -v $PWD:/backup alpine tar czf /backup/db-<ver>.tgz /data`), migrate dữ liệu nếu schema đổi.
- Triển khai: pull image mới → recreate (rolling — web trước, worker sau) → healthcheck.
- `SECRET_KEY`, `DEFAULT_ADMIN_PASSWORD`, API keys: quản lý qua secret manager (Docker secrets / vault) — không ở compose file.
## 4. PIPELINE CẬP NHẬT (lặp lại mỗi release)
```
code mới → build.mjs (precompiled + ?v=) → docker build (tag mới)
→ push registry → [beta/prod] pull + recreate → backup trước nếu prod → kiểm tra /health
```
- Phiên bản: `git tag vX.Y.Z` — build tag tự động từ git (`git describe --tags`).
- Rollback: giữ tag cũ — `docker compose -f docker-compose.prod.yml up -d` với `IMAGE=...:<tag cũ>`.
## 5. VIỆC CẦN LÀM (checklist)
- [x] `.env.example` — liệt kê đủ 7 biến
- [x] `.dockerignore` — chặn source/secret/docs
- [x] `docker-compose.prod.yml` — production (registry + env_file + volumes, bỏ mount source)
- [ ] Kiểm tra endpoint `/health` (tạo nếu chưa có — healthcheck compose đang trỏ tới)
- [ ] Chọn registry (GHCR/Docker Hub private/Harbor) + tạo token CI
- [ ] Nuitka compile `app/core`+`app/api` (tùy chọn — nếu cần bảo vệ backend chặt hơn)
- [ ] CI (GitHub Actions): test → build → push tag → deploy beta tự động
- [ ] Backup script volumes trước mỗi production release
+40
View File
@@ -0,0 +1,40 @@
MIT License
Copyright (c) 2026 SonicForge Studio contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Third-party components
Code viết riêng của dự án (app/static/js/services/*, components/*, app.jsx)
thuộc license MIT trên.
Các thành phần bên thứ ba giữ license gốc:
- **React / react-dom** — MIT
- **Babel** (@babel/cli, core, preset-react) — MIT
- **jsdom** — MIT
- **Tailwind CSS** (app/static/css/tailwind.min.css) — MIT
- **Lucide icons** — ISC
- **FluidSynth 2.3.0** (app/static/js/vendor/libfluidsynth-2.3.0-sf3.js/.wasm) —
GNU LGPL v2.1+ — xem https://www.gnu.org/licenses/lgpl-2.1.html
Chi tiết + attribution từng thành phần: xem THIRD_PARTY_LICENSES.md.
+95
View File
@@ -0,0 +1,95 @@
# TEST & DEBUG PLAN — SonicForge DAW Standalone (Windows .exe / .msi)
Áp dụng cho bản đóng gói Tauri v2 + PyInstaller (xem `DESKTOP_INSTALL_PLAN.md`,
`build_windows.ps1`, `engine.spec`, `src-tauri/`).
---
## 0. Cổng kiểm soát TRƯỚC khi build (pre-build gates — chạy trên máy dev)
```powershell
# 1. Backend tests (repo đã có 86 tests pytest)
python -m pytest tests/ -x -q
# 2. Frontend syntax + bundle
node --check app/static/js/app.precompiled.js
node build.mjs # rebuild app.precompiled.js từ app.jsx
# 3. Python syntax của entry + spec
python -m py_compile desktop_engine.py engine.spec app/config.py app/tasks/worker.py
# 4. Chạy thử engine độc lập (chưa cần Tauri)
python desktop_engine.py # mở browser: http://127.0.0.1:8000/docs
```
Gate: tất cả PASS mới chạy `build_windows.ps1`.
---
## 1. TEST PLAN — theo 6 kịch bản của tài liệu gốc
| # | Kịch bản | Cách kiểm thử | Kết quả mong đợi |
|---|---|---|---|
| 1 | **Lifecycle & Process Spawn** | Mở app → Task Manager tìm `daw_engine.exe` → đóng cửa sổ → kiểm tra lại | Engine xuất hiện lúc launch; BIẾN MẤT hoàn toàn khi đóng (không orphan). Engine còn có watchdog: nếu cha chết đột ngột, tự thoát ≤1s |
| 2 | **Port 8000 & Fallback** | Mở app → browser truy cập `http://localhost:8000/docs`; hoặc chiếm sẵn port 8000 (vd `net stop` dịch vụ khác / chạy server khác) rồi mở app | Swagger UI hiển thị. Khi 8000 bị chiếm → engine tự chuyển 8001…8010, loader page trong app tự redirect tới port đúng |
| 3 | **VST3 Local Scan (Windows)** | Plugin Manager → set path `C:\Program Files\Common Files\VST3`**[Scan]** | Phát hiện đúng VST3 đã cài (Vital, Surge XT, FabFilter…). Scan state lưu ở `%APPDATA%\SonicForgeDAW\storage\sf_scan_state.json` |
| 4 | **Isolation & Crash Protection** | Load 1 VST3 không ổn định / giả lập crash | UI hiện thông báo lỗi, cửa sổ DAW vẫn phản hồi (tiến trình engine riêng — shell không chết theo) |
| 5 | **Storage Persistence (`%APPDATA%`)** | Mở Explorer tới `%APPDATA%\SonicForgeDAW\storage\` | Có `sonicforge.db`, `sf_scan_state.json`, `uploads/`, `processed/`, `soundfonts/`**không** nằm trong thư mục cài đặt (onefile giải nén tạm sẽ bị xóa khi thoát) |
| 6 | **Audio Export / Bouncing** | Click **Export WAV** ở chế độ Standalone | File `.wav` render đúng (VST3 + SoundFont qua Pedalboard/FluidSynth) |
### 1.1 Kiểm thử cài đặt (installer)
- **NSIS**: cài trên máy sạch (không có Python, không có VC++ redist) → app chạy được, `hooks.nsh` tự cài VC redist. Gỡ cài bằng Control Panel → không còn process, không còn shortcut.
- **MSI**: cài qua `msiexec /i SonicForgeDAW_1.0.0_x64_en-US.msi` (hoặc double-click) → tương đương; kiểm tra repair/uninstall.
- **Nâng cấp**: cài bản mới đè bản cũ → `%APPDATA%\SonicForgeDAW\storage` GIỮ NGUYÊN (dữ liệu người dùng không mất).
- **SmartScreen/AV**: lần đầu chạy có thể bị chặn ("More info → Run anyway"); bản production phải ký số (signtool + certificate DigiCert/Sectigo).
### 1.2 Kiểm thử hồi quy (regression — sau mỗi thay đổi code)
- `pytest tests/` — 86 test hiện có (conftest fixture dùng test client).
- Smoke UI thủ công: play/pause, piano roll, FX rack, save/load project, soundfont scan.
---
## 2. DEBUG WORKFLOWS
### 2.1 Engine (Python sidecar) không start / crash âm thầm
1. Mở log: `%APPDATA%\SonicForgeDAW\logs\engine.log` (desktop_engine.py ghi log ra file vì `console=False`).
2. Chạy thủ công engine có console: sửa `console=True` trong `engine.spec` → rebuild → chạy `dist\daw_engine.exe` từ cmd → xem traceback trực tiếp.
3. Test độc lập không cần Tauri: `python desktop_engine.py` (dev) — đúng code path, có stdout.
### 2.2 UI (WebView2)
- **F12 / Ctrl+Shift+I** trong cửa sổ app: mở DevTools (đã bật `"devtools": true` trong `tauri.conf.json`) — Console, Network, Web Audio context.
- `devUrl: "http://localhost:8000"` — bản dev có thể chạy `tauri dev` với engine chạy tay.
### 2.3 Sidecar không spawn / không thấy engine
- Kiểm tra `src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe` có tồn tại (build script copy tự động).
- Chạy `npx tauri build` lần đầu: nếu thiếu sidecar → lib.rs `expect("sidecar daw_engine not found")` fail rõ ràng.
- Kiểm tra port bị chiếm: `netstat -ano | findstr :8000`.
### 2.4 Windows build lỗi (máy dev)
- Rust toolchain: `rustup default stable-msvc` (bắt buộc MSVC toolchain cho Tauri Windows).
- WebView2 runtime: Win10/11 có sẵn; máy cũ cài [WebView2 Evergreen](https://developer.microsoft.com/microsoft-edge/webview2/).
- Nếu `tauri build` lỗi NSIS/MSI riêng: build riêng từng target `npx tauri build --bundles nsis` / `--bundles msi`.
---
## 3. Known Gaps (đã xử lý / chấp nhận)
| Vấn đề | Trạng thái |
|---|---|
| Celery cần Redis broker | **Đã xử lý**: `SF_DESKTOP=1``task_always_eager=True` (chạy đồng bộ trong tiến trình, `memory://` broker + `cache+memory://` backend). Chế độ docker/cloud không đổi |
| Storage khi frozen (onefile giải nén tạm) | **Đã xử lý**: `config.py` freeze-aware → writable data về `%APPDATA%\SonicForgeDAW\storage`, assets read-only trong `_MEIPASS` |
| Port 8000 bị chiếm | **Đã xử lý**: probe 80008010 + loader page redirect tự động |
| `engine.spec` console=False → mù log | **Đã xử lý**: log ra file `%APPDATA%\SonicForgeDAW\logs\engine.log` |
| Soundfont scanner path `/opt/daw_engine/soundfonts` (Linux-only) | **Chấp nhận**: cần xác minh scanner bỏ qua path không tồn tại trên Windows (test scenario #5) |
| `child.kill()` = TerminateProcess (không graceful 100%) | **Giảm thiểu**: watchdog parent-PID trong `desktop_engine.py` → engine tự shutdown sạch khi cha chết |
---
## 4. Checklist phát hành (release)
- [ ] Pre-build gates PASS (mục 0)
- [ ] `build_windows.ps1` chạy sạch, đủ 6 bước
- [ ] 6 kịch bản test (mục 1) PASS trên máy sạch
- [ ] Installer NSIS + MSI cài/gỡ/nâng cấp đúng
- [ ] Ký số (production) — SmartScreen hết cảnh báo
- [ ] Commit artifacts: `engine.spec`, `desktop_engine.py`, `src-tauri/`, `build_windows.ps1`, `tools/gen_icons.py`
+37
View File
@@ -0,0 +1,37 @@
# THIRD-PARTY LICENSES — SonicForgeStudio
Kiểm tra ngày 2026-08-07. Danh sách thành phần bên thứ ba + license.
## 1. Dependencies (npm — package.json)
| Thành phần | License | Ghi chú |
|---|---|---|
| react / react-dom 19.x | MIT | OK |
| @babel/cli, @babel/core, @babel/preset-react | MIT | Chỉ build-time |
| jsdom | MIT | Chỉ build-time/test |
## 2. Thư viện nhúng (app/static)
| File | Nguồn | License | Trạng thái |
|---|---|---|---|
| `css/tailwind.min.css` | Tailwind CSS | MIT | OK — nên giữ attribution |
| `js/services/spessasynth_processor.min.js` | SpessaSynth (KHÔNG còn dùng — không được include trong index.html — chỉ FluidSynth) | MIT | ✅ KHÔNG có code nào gọi (chỉ còn comment cũ app.jsx:14276 + file chết) — có thể xóa file |
| `js/vendor/libfluidsynth-2.3.0-sf3.js` + `.wasm` | FluidSynth 2.3.0 (Emscripten) | **LGPL-2.1+** | ⚠️ Bắt buộc giữ license notice + attribution + (nếu phân phối bản build) cung cấp link/tài liệu LGPL |
| `js/services/fluidsynthLoader.js`, `worklets/fluidsynth-bridge.js` | Bản tự viết (bọc FluidSynth) | Dự án | OK |
| Lucide icons (inline `data-lucide`) | Lucide | ISC (MIT-compatible) | OK |
## 3. Nội dung âm thanh (app/storage)
| File | Nguồn | License | Trạng thái |
|---|---|---|---|
| `soundfonts/518e850f-...sf2` | "General MIDI SoundFont v3.0" — © 2006-2010 Rich "Weeds" Nagel — "Some rights reserved" | Không có text đầy đủ trong file (chỉ ICOP ngắn). Tuyên bố: "created from various **commercial**, custom, and freeware soundfonts and samples" | ⚠️ RỦI RO: (a) điều kiện "some rights reserved" không rõ ràng (thường là CC-BY — cần ghi attribution; có thể hạn chế thương mại); (b) samples gốc có nguồn commercial — quyền tái phân phối phụ thuộc tuyên bố tác giả. **Khuyến nghị: thay bằng SF2 license rõ (FluidR3_GM — MIT/GPL-2; GeneralUser GS — CC-BY-SA; Arachno — public domain) HOẶC giữ + ghi attribution đầy đủ.** |
| `uploads/user_anonymous_*.mp3` | File người dùng upload | Thuộc người dùng | OK — không phải thành phần phân phối |
| SGM-V2.01 (xuất hiện trong log `sfId: SGM-V2.01`) | SGM-V2.01 | Freeware — tác giả cho phép dùng nhưng **hạn chế redistribution** (cần permission) | ⚠️ Nếu vẫn dùng để phân phối bản build — cần permission từ tác giả; không nhúng vào sản phẩm |
## 4. Assets
- `templates/favicon.svg`, `images/SonicForgeUI.png` — nội bộ (tự tạo) — OK.
- Code trong `app/static/js/services/*`, `app/static/js/components/*`, `app.jsx` — tự viết — thuộc dự án.
## KẾT LUẬN
- **Không phát hiện vi phạm bản quyền rõ ràng** (không có code GPL bị nhúng vào project MIT; LGPL của FluidSynth tương thích nếu giữ notice).
- **3 điểm cần xử lý trước khi phân phối công khai:**
1. Bổ sung `LICENSE` text (MIT + LGPL-2.1) + attribution cho SpessaSynth (MIT notice) và Tailwind.
2. Ghi attribution SF2 Rich Nagel ("General MIDI SoundFont v3.0 — © 2006-2010 Rich 'Weeds' Nagel — Some rights reserved") hoặc thay soundfont khác license rõ.
3. Không nhúng SGM-V2.01 vào bản phân phối (hạn chế redistribution).
+112 -10
View File
@@ -1,7 +1,16 @@
import asyncio
import ipaddress
import json
import socket
from urllib.parse import urlparse
import httpx import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any, Dict, List from typing import Any, Dict
from app.api.v1.auth import get_current_user
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
router = APIRouter() router = APIRouter()
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
headers: Dict[str, str] = {} headers: Dict[str, str] = {}
body: Dict[str, Any] = {} body: Dict[str, Any] = {}
import json
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
_BLOCKED_NETWORKS = [
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
ipaddress.ip_network("0.0.0.0/8"),
]
# Private ranges: only reachable when the target host is one the user has
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
_PRIVATE_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"), # ULA
]
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def _configured_ai_hosts(user_id: str) -> set:
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
hosts = set()
configs = _load_ai_configs()
providers = configs.get(user_id) or _get_default_providers()
for p in providers:
base = (p.get("api_base_url") or "").strip()
if not base:
continue
try:
host = urlparse(base).hostname
if host:
hosts.add(host.lower())
except Exception:
continue
return hosts
async def _resolve_host_ips(hostname: str):
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
loop = asyncio.get_event_loop()
try:
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
ips = []
for info in infos:
try:
ips.append(ipaddress.ip_address(info[4][0]))
except ValueError:
continue
return ips
except Exception:
return []
async def _validate_target_url(url: str, user_id: str):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
hostname = (parsed.hostname or "").lower()
if not hostname:
raise HTTPException(status_code=400, detail="URL không hợp lệ")
allowed_hosts = _configured_ai_hosts(user_id)
# Hostname-level fast path for loopback hosts
if hostname in _LOOPBACK_HOSTS:
if hostname in allowed_hosts:
return
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
# Try direct IP parse (hostname may itself be an IP)
try:
ip = ipaddress.ip_address(hostname)
ips = [ip]
except ValueError:
ips = await _resolve_host_ips(hostname)
if not ips:
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
for ip in ips:
if any(ip in net for net in _BLOCKED_NETWORKS):
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
if any(ip in net for net in _PRIVATE_NETWORKS):
if hostname in allowed_hosts:
continue
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
@router.post("/proxy") @router.post("/proxy")
async def proxy_llm(req: ProxyRequest): async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
await _validate_target_url(req.url, current_user["user_id"])
# Never forward the app's own auth token upstream.
headers = {
k: v for k, v in req.headers.items()
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
}
try: try:
async with httpx.AsyncClient(timeout=180.0) as client: async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
resp = await client.post( resp = await client.post(req.url, headers=headers, json=req.body)
req.url,
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
json=req.body
)
raw = resp.text raw = resp.text
try: try:
return resp.json() return resp.json()
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
if 'localhost' in req.url or '127.0.0.1' in req.url: if 'localhost' in req.url or '127.0.0.1' in req.url:
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)." msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
raise HTTPException(status_code=502, detail=msg) raise HTTPException(status_code=502, detail=msg)
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
+69 -73
View File
@@ -8,12 +8,32 @@ from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
import json import json
from app.config import settings from app.config import settings
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user, enforce_password_changed
from app.api.v1.projects import get_optional_user from app.api.v1.projects import get_optional_user
from app.models.user import get_db_connection from app.models.user import get_db_connection
router = APIRouter() router = APIRouter()
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
def _safe_file_id(file_id: str) -> str:
"""Strip any path components from a client-supplied file id."""
if not file_id:
return ""
return os.path.basename(file_id.replace("\\", "/"))
def _resolve_storage_path(file_id: str) -> str:
"""Return the existing file path (processed first, then uploads) for a
sanitized file id, or '' when not found."""
fid = _safe_file_id(file_id)
if not fid:
return ""
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
p = os.path.join(d, fid)
if os.path.isfile(p):
return p
return ""
class EditRequest(BaseModel): class EditRequest(BaseModel):
file_id: str file_id: str
cut_start_ms: Optional[float] = None cut_start_ms: Optional[float] = None
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
@router.post("/upload") @router.post("/upload")
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)): async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
if current_user:
enforce_password_changed(current_user)
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
ext = os.path.splitext(file.filename)[1] ext = os.path.splitext(file.filename or "")[1]
if not ext: if not ext:
ext = ".wav" ext = ".wav"
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}" file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
file_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = os.path.join(settings.UPLOADS_DIR, file_id)
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
# WAV into RAM and bounds disk usage).
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
content = await file.read() size = 0
f.write(content) while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > MAX_AUDIO_UPLOAD_BYTES:
f.close()
try:
os.remove(file_path)
except OSError:
pass
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
f.write(chunk)
# Save original filename as sidecar metadata # Save original filename as sidecar metadata
import json import json
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
@router.post("/edit") @router.post("/edit")
async def edit_audio(req: EditRequest): async def edit_audio(req: EditRequest):
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
# Use uploaded file if it exists, or look in processed if it was already edited # Use uploaded file if it exists, or look in processed if it was already edited
if not os.path.exists(upload_path) and not os.path.exists(processed_path): if not _resolve_storage_path(req.file_id):
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import edit_audio_task from app.tasks.worker import edit_audio_task
task = edit_audio_task.delay(req.dict()) task = edit_audio_task.delay(req.model_dump())
return { return {
"task_id": task.id "task_id": task.id
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
@router.get("/download/{file_id}") @router.get("/download/{file_id}")
async def download_audio(file_id: str): async def download_audio(file_id: str):
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) path = _resolve_storage_path(file_id)
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) if not path:
raise HTTPException(status_code=404, detail="File not found")
if os.path.exists(processed_path): return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
elif os.path.exists(upload_path):
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
raise HTTPException(status_code=404, detail="File not found")
@router.get("/waveform/{file_id}") @router.get("/waveform/{file_id}")
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)): async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
API endpoint vẽ Peak Waveform đồng bộ (Week 2). API endpoint vẽ Peak Waveform đồng bộ (Week 2).
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend. Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = _resolve_storage_path(file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.core.dsp_utils import generate_peak_waveform from app.core.dsp_utils import generate_peak_waveform
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
""" """
API endpoint vẽ RMS Waveform (mượt hơn peak). API endpoint vẽ RMS Waveform (mượt hơn peak).
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = _resolve_storage_path(file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.core.dsp_utils import generate_rms_waveform from app.core.dsp_utils import generate_rms_waveform
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4). API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục. Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id) file_path = _resolve_storage_path(req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import analyze_ai_task from app.tasks.worker import analyze_ai_task
task = analyze_ai_task.delay( task = analyze_ai_task.delay(
file_id=req.file_id, file_id=_safe_file_id(req.file_id),
api_base_url=req.api_base_url, api_base_url=req.api_base_url,
model=req.model model=req.model
) )
return { return {
"task_id": task.id, "task_id": task.id,
"file_id": req.file_id "file_id": _safe_file_id(req.file_id)
} }
@router.post("/export") @router.post("/export")
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
""" """
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG). API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id) source_path = _resolve_storage_path(req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id) if not source_path:
if os.path.exists(processed_path):
source_path = processed_path
elif os.path.exists(upload_path):
source_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import export_audio_task from app.tasks.worker import export_audio_task
task = export_audio_task.delay( task = export_audio_task.delay(
file_id=req.file_id, file_id=_safe_file_id(req.file_id),
format=req.format, format=req.format,
sample_rate=req.sample_rate, sample_rate=req.sample_rate,
bit_depth=req.bit_depth bit_depth=req.bit_depth
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
return { return {
"task_id": task.id, "task_id": task.id,
"file_id": req.file_id "file_id": _safe_file_id(req.file_id)
} }
@router.post("/ai-scan") @router.post("/ai-scan")
async def ai_scan_audio(req: AIScanRequest): async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
""" """
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling. 17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment. Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
""" """
if current_user:
enforce_password_changed(current_user)
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
import numpy as np import numpy as np
file_path = None file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if req.file_id:
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
if file_path and os.path.exists(file_path): if file_path:
data, sr = sf.read(file_path) data, sr = sf.read(file_path)
if data.ndim > 1: if data.ndim > 1:
data = data.T data = data.T
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
Executes raw binary sample slice at exact zero-crossing coordinates. Executes raw binary sample slice at exact zero-crossing coordinates.
""" """
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
if current_user:
enforce_password_changed(current_user)
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
import numpy as np import numpy as np
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav" output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id) out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
file_path = None file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if req.file_id:
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
if file_path and os.path.exists(file_path): if file_path:
data, sr = sf.read(file_path) data, sr = sf.read(file_path)
if data.ndim > 1: if data.ndim > 1:
data = data.T data = data.T
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation. Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
""" """
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
if current_user:
enforce_password_changed(current_user)
from app.core.python_tools_engine import PythonToolsEngine from app.core.python_tools_engine import PythonToolsEngine
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
+107 -13
View File
@@ -1,10 +1,15 @@
import uuid import uuid
import time import time
from fastapi import APIRouter, HTTPException, Header, Depends import threading
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from app.models.user import get_db_connection from app.models.user import get_db_connection
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin from app.core.auth import (
hash_password, verify_password, create_token, decode_token, seed_admin,
COOKIE_NAME, X_AUTH_HEADER,
)
router = APIRouter() router = APIRouter()
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
old_password: str old_password: str
new_password: str new_password: str
def get_current_user(authorization: Optional[str] = Header(None)): # ── Brute-force guard: in-memory per-IP failed-login limiter ──
if not authorization or not authorization.startswith("Bearer "): _LOGIN_FAILURES = {} # ip -> [timestamps]
_LOGIN_LOCK = threading.Lock()
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW_SEC = 900 # 15 min
LOGIN_BLOCK_SEC = 900
def _check_login_ratelimit(ip: str):
now = time.time()
with _LOGIN_LOCK:
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
_LOGIN_FAILURES[ip] = stamps
def _record_login_failure(ip: str):
now = time.time()
with _LOGIN_LOCK:
stamps = _LOGIN_FAILURES.setdefault(ip, [])
stamps.append(now)
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
def _record_login_success(ip: str):
with _LOGIN_LOCK:
_LOGIN_FAILURES.pop(ip, None)
def _set_auth_cookie(response: Response, token: str):
response.set_cookie(
COOKIE_NAME, token,
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
# path="/" (default); secure flag set by proxy when behind TLS
)
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
elif x_auth_token:
token = x_auth_token
elif request.cookies.get(COOKIE_NAME):
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ") raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
token = authorization.split(" ")[1]
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ") raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
) )
@router.post("/login") @router.post("/login")
async def login(req: LoginRequest): async def login(req: LoginRequest, request: Request):
client_ip = request.client.host if request.client else "unknown"
_check_login_ratelimit(client_ip)
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
conn.close() conn.close()
if not user or not user["is_active"]: if not user or not user["is_active"]:
_record_login_failure(client_ip)
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
if not verify_password(password, user["hashed_password"]): if not verify_password(password, user["hashed_password"]):
_record_login_failure(client_ip)
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"]) token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
_record_login_success(client_ip)
return { resp = JSONResponse({
"access_token": token, "access_token": token,
"user": { "user": {
"id": user["id"], "id": user["id"],
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
"role": user["role"], "role": user["role"],
"must_change_password": bool(user["must_change_password"]) "must_change_password": bool(user["must_change_password"])
} }
} })
_set_auth_cookie(resp, token)
return resp
def _validate_password_strength(password: str):
"""Minimal strength policy: >= 8 chars and not trivially common."""
if len(password) < 8:
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
lowered = password.lower()
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
@router.post("/register") @router.post("/register")
async def register(req: RegisterRequest): async def register(req: RegisterRequest, request: Request):
username = req.username.strip() username = req.username.strip()
email = req.email.strip() email = req.email.strip()
password = req.password.strip() password = req.password.strip()
_validate_password_strength(password)
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
conn.close() conn.close()
token = create_token(user_id, username, "standard", False) token = create_token(user_id, username, "standard", False)
return { resp = JSONResponse({
"access_token": token, "access_token": token,
"user": { "user": {
"id": user_id, "id": user_id,
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
"role": "standard", "role": "standard",
"must_change_password": False "must_change_password": False
} }
} })
_set_auth_cookie(resp, token)
return resp
@router.post("/change-password") @router.post("/change-password")
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)): async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
conn.close() conn.close()
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False) new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
return { resp = JSONResponse({
"message": "Đổi mật khẩu thành công!", "message": "Đổi mật khẩu thành công!",
"access_token": new_token "access_token": new_token
} })
_set_auth_cookie(resp, new_token)
return resp
@router.get("/profile") @router.get("/profile")
async def get_profile(current_user: dict = Depends(get_current_user)): async def get_profile(current_user: dict = Depends(get_current_user)):
@@ -194,3 +259,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
"max_tracks": row["max_tracks"] or 16 "max_tracks": row["max_tracks"] or 16
} }
} }
@router.get("/first-time")
async def auth_first_time():
# "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH
# (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa
# gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu:
# admin123, nút Điền nhanh).
try:
conn = get_db_connection()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, hashed_password, must_change_password FROM users "
"WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1"
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return {"first_time": True}
still_default = False
try:
still_default = verify_password("admin123", row["hashed_password"])
except Exception:
still_default = False
return {"first_time": bool(row["must_change_password"]) and still_default}
except Exception:
return {"first_time": True}
+6 -4
View File
@@ -2,9 +2,11 @@ import os
import platform import platform
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.api.v1.auth import get_current_user
router = APIRouter() router = APIRouter()
MEDIA_EXTS = { MEDIA_EXTS = {
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
@router.get("/computer") @router.get("/computer")
async def list_computer_roots(): async def list_computer_roots(current_user: dict = Depends(get_current_user)):
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer).""" """Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
system = platform.system() system = platform.system()
roots = [] roots = []
@@ -97,7 +99,7 @@ async def list_computer_roots():
@router.get("/browse") @router.get("/browse")
async def browse_directory(path: str = Query(...)): async def browse_directory(path: str = Query(...), current_user: dict = Depends(get_current_user)):
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI.""" """Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
resolved = _safe_path(path) resolved = _safe_path(path)
if not os.path.isdir(resolved): if not os.path.isdir(resolved):
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
@router.get("/file") @router.get("/file")
async def serve_local_file(path: str = Query(...)): async def serve_local_file(path: str = Query(...), current_user: dict = Depends(get_current_user)):
"""Phục vụ file audio/MIDI cục bộ để preview.""" """Phục vụ file audio/MIDI cục bộ để preview."""
resolved = _safe_path(path) resolved = _safe_path(path)
if not os.path.isfile(resolved): if not os.path.isfile(resolved):
+2 -2
View File
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
# Gửi task xuống Celery Worker # Gửi task xuống Celery Worker
from app.tasks.worker import mix_multitrack_task from app.tasks.worker import mix_multitrack_task
task = mix_multitrack_task.delay(req.dict()) task = mix_multitrack_task.delay(req.model_dump())
return { return {
"task_id": task.id, "task_id": task.id,
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau. Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
""" """
from app.tasks.worker import process_multitrack_session_task from app.tasks.worker import process_multitrack_session_task
task = process_multitrack_session_task.delay(req.dict()) task = process_multitrack_session_task.delay(req.model_dump())
return { return {
"task_id": task.id, "task_id": task.id,
+737 -14
View File
@@ -1,22 +1,145 @@
import os, uuid, json, tempfile import os, sys, uuid, json, tempfile, subprocess, time as _time
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any from typing import Optional, Any
from app.config import settings from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin
from app.core.render_engine import PythonRenderEngine from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter() router = APIRouter()
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts") UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True) os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts" SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
SYSTEM_VST_DIR = settings.VST_DIR
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
# Plugins Manager). File global (không per-user): desktop app 1 user.
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
def _load_plugin_dirs() -> dict:
if os.path.exists(PLUGIN_DIRS_FILE):
try:
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_plugin_dirs(dirs: dict):
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
json.dump(dirs, f, indent=2)
def _effective_dirs() -> dict:
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
"""
user = _load_plugin_dirs()
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
vst_dir = settings.VST_DIR
soundfont_dir = settings.SOUNDFONT_DIR
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
if not plugin_dirs:
if user.get("vst_dir"):
plugin_dirs.append(user["vst_dir"])
if user.get("soundfont_dir"):
plugin_dirs.append(user["soundfont_dir"])
if not plugin_dirs:
plugin_dirs = [vst_dir, soundfont_dir]
return {
"plugin_dirs": plugin_dirs,
"vst_dir": vst_dir,
"soundfont_dir": soundfont_dir,
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
}
class DirsRequest(BaseModel):
vst_dir: Optional[str] = None
soundfont_dir: Optional[str] = None
plugin_dirs: Optional[list] = None
@router.get("/dirs")
async def get_plugin_dirs():
return {"success": True, **(_effective_dirs())}
@router.post("/dirs")
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
user = _load_plugin_dirs()
if req.plugin_dirs is not None:
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
user.pop("vst_dir", None)
user.pop("soundfont_dir", None)
else:
if req.vst_dir is not None:
user["vst_dir"] = req.vst_dir.strip()
if req.soundfont_dir is not None:
user["soundfont_dir"] = req.soundfont_dir.strip()
_save_plugin_dirs(user)
return {"success": True, **(_effective_dirs())}
@router.post("/scan")
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)):
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
enforce_password_changed(current_user)
dirs = _effective_dirs()
plugin_dirs = dirs["plugin_dirs"]
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
if background_tasks:
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
catalog = scanner.get_catalog()
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
vst_found = []
sf_found = []
for d in plugin_dirs:
if not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
vst_found.append({"name": os.path.splitext(sub)[0],
"path": os.path.join(root, sub),
"dir": d,
"type": "VST3"})
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
vst_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d,
"type": "VST3" if low.endswith(".vst3") else "VST2"})
elif low.endswith(".sf2") or low.endswith(".sf3"):
sf_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d})
return {
"success": True,
"plugin_dirs": plugin_dirs,
"vst_found": vst_found,
"vst_count": len(vst_found),
"soundfonts": sf_found,
"soundfont_count": len(sf_found),
}
_inspector = None _inspector = None
_scanner = None _scanner = None
@@ -24,21 +147,199 @@ _scanner = None
def get_inspector(): def get_inspector():
global _inspector global _inspector
if _inspector is None: if _inspector is None:
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
upload_sf_dir=UPLOAD_SF_DIR)
return _inspector return _inspector
def get_scanner(): def get_scanner():
global _scanner global _scanner
if _scanner is None: if _scanner is None:
_scanner = SoundFontAutoScanner(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
_scanner.scan_once() _scanner.scan_once()
return _scanner return _scanner
@router.get("/available") @router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)): async def list_plugins(current_user: dict = Depends(get_current_user)):
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
return pm.list_available() pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
avail = pm.list_available()
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
extra = _scan_vst_in_dirs(d["plugin_dirs"])
by_id = {v["id"]: v for v in avail["vst_instruments"]}
for v in extra:
by_id.setdefault(v["id"], v)
avail["vst_instruments"] = list(by_id.values())
# Tương tự cho SOUNDFONT: gộp .sf2/.sf3 từ plugin_dirs user — nếu không,
# nút Synth không liệt kê instrument của soundfont trong thư mục user thêm.
sf_by_id = {s["id"]: s for s in avail["soundfonts"]}
for s in _scan_soundfonts_in_dirs(d["plugin_dirs"]):
sf_by_id.setdefault(s["id"], s)
avail["soundfonts"] = list(sf_by_id.values())
return avail
def _scan_soundfonts_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách soundfont .sf2/.sf3
(id = tên file; name từ .meta nếu có — cùng format PluginManager)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, dirs2, files in os.walk(d):
for f in files:
low = f.lower()
if not (low.endswith(".sf2") or low.endswith(".sf3")):
continue
base_id = os.path.splitext(f)[0]
if base_id in found:
continue
meta = {}
mp = os.path.join(root, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(mp):
try:
with open(mp, "r", encoding="utf-8") as mf:
meta = json.load(mf)
except Exception:
meta = {}
name = meta.get("original_name", f) if meta else f
found[base_id] = {
"id": base_id,
"name": name,
"file": f,
"display": os.path.splitext(name)[0][:40],
"source": "user",
}
# Không walk sâu thêm (soundfont thường đặt ngay trong thư mục khai báo)
dirs2[:] = []
return list(found.values())
def _scan_vst_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa
X.vst3.dll bên trong)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
name = os.path.splitext(sub)[0]
if name not in found:
found[name] = {
"id": name, "name": name, "type": "VST3",
"path": os.path.join(root, sub),
}
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
name = os.path.splitext(f)[0]
if name not in found:
found[name] = {
"id": name, "name": name,
"type": "VST3" if low.endswith(".vst3") else "VST2",
"path": os.path.join(root, f),
}
return list(found.values())
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
# nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
def _pick_dir_via_tauri_bridge() -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
return None
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
return None
req = os.path.join(ipc, "pick_dir.request")
resp = os.path.join(ipc, "pick_dir.response")
try:
for f in (req, resp):
if os.path.exists(f):
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = _time.time() + PICK_DIR_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
with open(resp, "r", encoding="utf-8") as fh:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
_time.sleep(0.1)
except Exception:
pass
return None
def _pick_dir_native_engine() -> Optional[str]:
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
if os.name == "nt":
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
if sys.platform == "darwin":
try:
r = subprocess.run(
["osascript", "-e",
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
for cmd in (["zenity", "--file-selection", "--directory"],
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
if r.returncode == 0:
p = r.stdout.strip()
if p:
return p
except Exception:
continue
return None
@router.post("/pick-dir")
def pick_plugin_directory():
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
trong lúc user chọn thư mục (có thể mất phút)."""
path = _pick_dir_via_tauri_bridge()
if path is None:
path = _pick_dir_native_engine()
return {"path": path}
@router.get("/default-soundfonts") @router.get("/default-soundfonts")
@@ -68,7 +369,9 @@ async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
@router.get("/soundfont-instruments/{sf_id}") @router.get("/soundfont-instruments/{sf_id}")
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)): async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR) d = _effective_dirs()
# extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
presets = pm.list_soundfont_instruments(sf_id) presets = pm.list_soundfont_instruments(sf_id)
return {"presets": presets, "count": len(presets)} return {"presets": presets, "count": len(presets)}
@@ -79,11 +382,23 @@ async def upload_soundfont(
background_tasks: BackgroundTasks = None, background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
enforce_password_changed(current_user)
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))): if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed") raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
contents = await file.read() # Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
if not PluginManager.validate_sf2_header(contents): # 500MB; reading the whole body into RAM would OOM the server).
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
contents = bytearray()
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
contents.extend(chunk)
if len(contents) > MAX_SF_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header") raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
file_ext = os.path.splitext(file.filename)[1] file_ext = os.path.splitext(file.filename)[1]
@@ -139,7 +454,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
@router.get("/soundfonts/download/{sf_id}") @router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str): async def download_soundfont_asset(sf_id: str):
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]: # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
if not os.path.isdir(base_dir): if not os.path.isdir(base_dir):
continue continue
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis) # Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
@@ -173,13 +492,417 @@ class RenderRequest(BaseModel):
output_filename: Optional[str] = "render_output.wav" output_filename: Optional[str] = "render_output.wav"
class OpenInCarlaRequest(BaseModel):
"""Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop
và Carla được cài trên cùng máy — tự phát hiện qua runtime profile)."""
plugin_name: Optional[str] = None
plugin_path: Optional[str] = None
class PreviewRequest(BaseModel):
"""Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin +
preset (cùng code path với export) → trả wav để browser phát.
Âm preview = âm export (khác Preview Synth WASM hiện tại)."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
soundfont_bank: Optional[int] = 0
soundfont_program: Optional[int] = 0
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
class CarlaMidiRequest(BaseModel):
"""Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off).
Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc,
CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của
Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app).
Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0."""
event: str # "note_on" | "note_off"
note: int
velocity: Optional[int] = 100
channel: Optional[int] = 0
@router.post("/carla-midi")
async def carla_midi(req: CarlaMidiRequest):
"""MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime."""
if req.event not in ("note_on", "note_off"):
raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off")
ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel)
if not ok:
raise HTTPException(
status_code=502,
detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; "
"nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)",
)
return {"success": True, "event": req.event, "note": req.note, "channel": req.channel}
def _carla_osc_port() -> int:
"""Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong
storage/carla_path.json → mặc định 22752 (CarlaEngineData)."""
try:
p = os.environ.get("SF_CARLA_OSC_PORT")
if p:
return int(p)
except Exception:
pass
try:
from app.core.runtime import _carla_config_path
with open(_carla_config_path(), "r", encoding="utf-8") as f:
data = json.load(f)
p = data.get("osc_port")
if p:
return int(p)
except Exception:
pass
return 22752
def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool:
"""Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load).
OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4.
Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và
tên client mặc định của app standalone là "Carla" (carla_host.py
fClientName = CARLA_CLIENT_NAME or "Carla")."""
try:
import socket
import struct
port = _carla_osc_port()
path = f"/Carla/0/{event}".encode("utf-8")
typetag = b",iii" if event == "note_on" else b",ii"
vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)]
def _pad(b: bytes) -> bytes:
rem = len(b) % 4
return b + b"\x00" * (4 - rem) if rem else b
msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.sendto(msg, ("127.0.0.1", port))
s.close()
return True
except Exception:
return False
@router.post("/open-in-carla")
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
"""Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard).
Cơ chế: sinh file project .carxs (định dạng XML chính thức của Carla —
`carla.exe [FILE]` nhận project file) chứa node <Plugin><Info><Type>VST3
</Type><Binary>...</Binary></Info> → Carla mở lên là plugin đã load sẵn,
kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime.
Carla là app ngoài do user tự giải nén (GPL-2.0+ → không bundle/nhúng);
app chỉ spawn tiến trình + trao đổi file preset."""
enforce_password_changed(current_user)
from app.core.runtime import find_carla
carla = find_carla()
if not carla:
raise HTTPException(
status_code=409,
detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla "
"portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, "
"rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn "
"thư mục chứa carla.exe (bản portable không dùng PATH).",
)
plugin_path = req.plugin_path or ""
if not plugin_path and req.plugin_name:
try:
pm = PluginManager()
plugins = pm._scan_plugins()
if req.plugin_name in plugins:
plugin_path = plugins[req.plugin_name]
except Exception:
plugin_path = ""
carxs_path = ""
if plugin_path:
carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path)
cmd = [carla]
if carxs_path:
cmd.append(carxs_path)
try:
cwd = os.path.dirname(carla) or None
proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
_register_carla_process(proc)
return {
"success": True,
"started": True,
"carla_path": carla,
"plugin_path": plugin_path,
"project_file": carxs_path,
"cmd": cmd,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")
# ── Carla bridge lifecycle ─────────────────────────────────────────────────
# App spawn Carla (open_in_carla) và PHẢI có khả năng dừng nó khi track chuyển
# sang instrument KHÔNG phải VST (soundfont/GM) — nếu không, Carla vẫn chạy và
# MIDI vẫn vào VSTi cũ → âm sai instrument + âm kẹt không dừng được.
_CARLA_PROCESSES = [] # list[subprocess.Popen]
def _register_carla_process(proc):
"""Lưu handle tiến trình Carla do app spawn (để carla-stop terminate được)."""
global _CARLA_PROCESSES
_prune_carla_processes()
_CARLA_PROCESSES.append(proc)
def _prune_carla_processes():
"""Bỏ các handle đã thoát (poll() trả code) — tránh list rác."""
global _CARLA_PROCESSES
_CARLA_PROCESSES = [p for p in _CARLA_PROCESSES if p is not None and p.poll() is None]
def _send_carla_all_notes_off() -> bool:
"""Gửi note_off TẤT CẢ pitch (0-127) trên mọi channel (0-15) tới Carla.
Dừng mọi âm đang ngân trong Carla (kể cả sustain) — dùng ngay trước khi
terminate để không còn tiếng kẹt khi bridge bị unload."""
ok = False
try:
for ch in range(16):
for note in range(128):
if _send_carla_osc("note_off", note, 0, ch):
ok = True
except Exception:
pass
return ok
@router.get("/carla-status")
async def carla_status():
"""Kiểm tra Carla bridge còn sống không (do app spawn) + cổng OSC đang dùng.
Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi
Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn
phần khi Carla bị đóng."""
_prune_carla_processes()
return {
"success": True,
"running": len(_CARLA_PROCESSES) > 0,
"osc_port": _carla_osc_port(),
}
@router.post("/carla-stop")
async def carla_stop(authorization: Optional[str] = Header(None)):
"""Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
do app spawn. Gọi khi track chuyển từ VSTi sang instrument khác (soundfont)
để âm KHÔNG còn play qua Carla bridge."""
# Auth là optional (desktop app có thể chưa login) — chỉ cần decode nếu có
try:
if authorization and authorization.startswith("Bearer "):
from app.core.auth import decode_token
decode_token(authorization.split(" ")[1])
except Exception:
pass
# 1. Tắt hết âm đang ngân trong Carla (trước khi giết tiến trình)
try:
_send_carla_all_notes_off()
except Exception:
pass
# 2. Terminate tiến trình Carla đã spawn
killed = 0
_prune_carla_processes()
for proc in list(_CARLA_PROCESSES):
try:
proc.terminate()
except Exception:
pass
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
try:
import time as _t
deadline = _t.time() + 2.0
for proc in list(_CARLA_PROCESSES):
while proc.poll() is None and _t.time() < deadline:
_t.sleep(0.05)
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
if proc.poll() is not None:
killed += 1
except Exception:
pass
_CARLA_PROCESSES.clear()
return {
"success": True,
"stopped": True,
"killed": killed,
"all_notes_off": True,
}
def _write_carla_project(plugin_name: str, plugin_path: str) -> str:
"""Sinh file project .carxs cho Carla load sẵn VSTi (chỉ VST3 — VST2 cần
uniqueID không đoán được → mở Carla trống để user tự Add Plugin).
Định dạng theo source Carla (CarlaEngine::saveProjectInternal +
CarlaStateSave::dumpToMemoryStream): root <CARLA-PROJECT VERSION='2.5'>,
<Plugin><Info><Type>VST3</Type><Binary>path</Binary><Label>..</Label>
</Info><Data><Active>Yes</Active><ControlChannel>1</ControlChannel>
<Options>0x0</Options></Data></Plugin>."""
if not plugin_path or not os.path.exists(plugin_path):
return ""
low = plugin_path.lower()
is_vst3 = low.endswith(".vst3") or low.endswith(".vst3/") or "\\" in plugin_path and plugin_path.rstrip("\\/").lower().endswith(".vst3")
if not is_vst3:
# VST2 (.dll/.so ngoài .vst3): không auto-load được tin cậy → Carla trống
return ""
from xml.sax.saxutils import escape
name = escape(plugin_name or os.path.splitext(os.path.basename(plugin_path))[0])
binary = escape(plugin_path)
# Patchbay: kết nối TỰ ĐỘNG MIDI input + audio output → default speaker.
# Mặc định Carla KHÔNG connect khi load project (chỉ restore connection có
# trong file) → lần đầu mở không có âm, không nhận MIDI (phải unload/load
# lại mới auto-connect như thêm plugin thủ công). Connection nào trỏ tới
# port không tồn tại sẽ bị Carla bỏ qua im lặng → thêm nhiều biến thể tên.
pb_name = name
patchbay = (
" <Patchbay>\n"
# Audio out plugin → loa mặc định (system:playback_1/2)
f" <Connection><Source>{pb_name}:audio_out1</Source><Target>system:playback_1</Target></Connection>\n"
f" <Connection><Source>{pb_name}:audio_out2</Source><Target>system:playback_2</Target></Connection>\n"
# MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
f" <Connection><Source>Carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
f" <Connection><Source>carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
f" <Connection><Source>system:midi_capture_1</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
" </Patchbay>\n"
)
xml = (
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<!DOCTYPE CARLA-PROJECT>\n"
f"<CARLA-PROJECT VERSION='2.5'>\n"
" <EngineSettings>\n"
" <ForceStereo>false</ForceStereo>\n"
" <PreferPluginBridges>false</PreferPluginBridges>\n"
" <PreferUiBridges>false</PreferUiBridges>\n"
" <UIsAlwaysOnTop>false</UIsAlwaysOnTop>\n"
" <MaxParameters>100</MaxParameters>\n"
" <UIBridgesTimeout>10000</UIBridgesTimeout>\n"
" </EngineSettings>\n"
" <Plugin>\n"
" <Info>\n"
f" <Type>VST3</Type>\n"
f" <Name>{name}</Name>\n"
f" <Binary>{binary}</Binary>\n"
f" <Label>{name}</Label>\n"
" </Info>\n"
" <Data>\n"
" <Active>Yes</Active>\n"
" <ControlChannel>1</ControlChannel>\n"
" <Options>0x0</Options>\n"
" </Data>\n"
" </Plugin>\n"
+ patchbay +
"</CARLA-PROJECT>\n"
)
try:
proj_dir = os.path.join(settings.STORAGE_DIR, "carla_projects")
os.makedirs(proj_dir, exist_ok=True)
# Dọn project cũ (quá 1 ngày) — tránh rác
try:
now = _time.time()
for f in os.listdir(proj_dir):
fp = os.path.join(proj_dir, f)
try:
if os.path.isfile(fp) and now - os.path.getmtime(fp) > 86400:
os.remove(fp)
except Exception:
pass
except Exception:
pass
safe = "".join(c for c in plugin_name if c.isalnum() or c in " _-")[:40].strip() or "plugin"
carxs = os.path.join(proj_dir, f"{safe}_{uuid.uuid4().hex[:8]}.carxs")
with open(carxs, "w", encoding="utf-8") as fh:
fh.write(xml)
return carxs
except Exception:
return ""
@router.post("/preview")
async def preview_instrument(req: PreviewRequest):
"""Quick-render preview VSTi (âm thật, cùng code path với export)."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
try:
pm = PluginManager()
vst = pm.load_vst(req.instrument_id)
if vst is None:
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
apply_preset_to_plugin(
vst,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
from pedalboard import Pedalboard
midi_events = []
for n in req.notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, req.bpm, req.sample_rate,
bank=req.soundfont_bank, program=req.soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, req.bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * req.sample_rate) > total_needed:
total_needed = int(end_sec * req.sample_rate)
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, fname)
sf.write(out_path, buf.T, req.sample_rate)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
@router.post("/render") @router.post("/render")
async def render_project( async def render_project(
req: RenderRequest, req: RenderRequest,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
enforce_password_changed(current_user)
engine = PythonRenderEngine() engine = PythonRenderEngine()
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav") # Prevent path traversal: strip any directory components and force .wav.
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
if not safe_name.lower().endswith(".wav"):
safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try: try:
result_path = engine.render_project(req.project_json, output_path) result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path} return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
+129
View File
@@ -0,0 +1,129 @@
# SonicForge Preset Library API — thư viện preset VST3 (.vstpreset) nằm trong
# storage/presets (mount qua volume trong docker; thư mục storage trên Windows).
#
# Vai trò: cầu nối Carla → pedalboard. User chỉnh preset trong Carla (native
# GUI) → xuất .vstpreset → upload vào thư viện → gán vào track (preset_id trong
# synth_engine) → render_engine tải qua load_preset → âm render = âm đã chỉnh.
import os
import uuid
import json
import time
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from fastapi.responses import FileResponse
from typing import Optional
from app.core.vst_engine import preset_library_dir, PRESET_EXTENSIONS
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
def _safe_preset_path(preset_id: str) -> str:
"""Chống path traversal: chỉ cho phép tên file (không chứa separator)."""
if not preset_id or os.path.basename(preset_id) != preset_id:
return ""
d = preset_library_dir()
p = os.path.join(d, preset_id)
if os.path.isfile(p) and os.path.dirname(os.path.abspath(p)) == os.path.abspath(d):
return p
return ""
@router.get("")
async def list_presets():
"""Danh sách preset trong thư viện (public — frontend cần trước login)."""
d = preset_library_dir()
items = []
try:
names = sorted(os.listdir(d))
except Exception:
names = []
for f in names:
low = f.lower()
if not low.endswith(PRESET_EXTENSIONS):
continue
meta = {}
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as mf:
meta = json.load(mf)
except Exception:
meta = {}
try:
size = os.path.getsize(os.path.join(d, f))
except Exception:
size = 0
items.append({
"id": f,
"name": meta.get("original_name", f),
"plugin_hint": meta.get("plugin_hint", ""),
"size_bytes": size,
"created_at": meta.get("created_at", ""),
})
return {"success": True, "presets": items}
@router.post("/upload")
async def upload_preset(
file: UploadFile = File(...),
plugin_hint: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""Upload preset (.vstpreset / .fxp / .fxb / .dspreset) vào thư viện."""
enforce_password_changed(current_user)
filename = (file.filename or "preset.vstpreset").replace("\\", "/").split("/")[-1]
ext = os.path.splitext(filename)[1].lower()
if ext not in PRESET_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Định dạng preset không hỗ trợ: {ext or '(không có đuôi)'} — hỗ trợ: {', '.join(PRESET_EXTENSIONS)}",
)
contents = await file.read()
if not contents:
raise HTTPException(status_code=400, detail="File rỗng")
d = preset_library_dir()
preset_id = uuid.uuid4().hex + ext
dest = os.path.join(d, preset_id)
try:
with open(dest, "wb") as fh:
fh.write(contents)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không lưu được preset: {e}")
meta = {
"original_name": filename,
"plugin_hint": plugin_hint or "",
"size_bytes": len(contents),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
try:
with open(os.path.join(d, os.path.splitext(preset_id)[0] + ".meta"), "w", encoding="utf-8") as mf:
json.dump(meta, mf, ensure_ascii=False, indent=2)
except Exception:
pass
return {"success": True, "preset_id": preset_id, **meta}
@router.get("/{preset_id}/download")
async def download_preset(preset_id: str):
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
return FileResponse(path, filename=preset_id, media_type="application/octet-stream")
@router.delete("/{preset_id}")
async def delete_preset(preset_id: str, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
try:
os.remove(path)
mp = os.path.join(preset_library_dir(), os.path.splitext(preset_id)[0] + ".meta")
if os.path.isfile(mp):
os.remove(mp)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không xóa được preset: {e}")
return {"success": True, "preset_id": preset_id}
+110 -13
View File
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
return project_data return project_data
tracks = project_data.get("tracks", []) tracks = project_data.get("tracks", [])
# Legacy format stores item start times in SECONDS; convert using the real
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
# position for any tempo other than the one where 1 bar = 4s).
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
seconds_per_bar = (60.0 / bpm_val) * 4
upgraded_tracks = [] upgraded_tracks = []
for t in tracks: for t in tracks:
track_id = str(t.get("id", "")) track_id = str(t.get("id", ""))
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
"id": c.get("id"), "id": c.get("id"),
"name": c.get("name", "Audio Clip"), "name": c.get("name", "Audio Clip"),
"type": "AUDIO_ITEM", "type": "AUDIO_ITEM",
"start_bar": c.get("startTime", 0.0) / 4.0, "start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
"duration_bars": 4.0, "duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
"clip_start_offset_bars": 0.0, "clip_start_offset_bars": 0.0,
"source_data": { "source_data": {
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "", "audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
"id": m.get("id"), "id": m.get("id"),
"name": m.get("name", "MIDI Item"), "name": m.get("name", "MIDI Item"),
"type": "MIDI_ITEM", "type": "MIDI_ITEM",
"start_bar": m.get("startTime", 0.0) / 4.0, "start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
"duration_bars": m.get("duration", 4.0), "duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
"clip_start_offset_bars": 0.0, "clip_start_offset_bars": 0.0,
"source_data": { "source_data": {
"total_buffer_bars": m.get("duration", 8.0), "total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
"notes": m.get("notes", []) "notes": m.get("notes", [])
} }
}) })
@@ -157,6 +162,18 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
conn.commit() conn.commit()
conn.close() conn.close()
# ── Ghi file autosave vào thư mục temp CỦA ỨNG DỤNG (storage/temp) ──
# Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp trên thư mục temp của ứng
# dụng để khi load lại thì tải lại dự án đang làm dở." Ngoài row trong DB,
# ghi thẳng file JSON để luôn có bản sao thật trên ổ đĩa OS.
try:
from app.config import settings as _st
temp_dir = os.path.join(_st.STORAGE_DIR, "temp")
os.makedirs(temp_dir, exist_ok=True)
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
except Exception:
pass
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now} return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@router.get("/temp") @router.get("/temp")
@@ -170,15 +187,78 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
row = cursor.fetchone() row = cursor.fetchone()
conn.close() conn.close()
if not row: if row:
return {"has_temp": False} return {
"has_temp": True,
"data_json": row["data_json"],
"updated_at": row["updated_at"]
}
# Fallback: file autosave.json trong thư mục temp của ứng dụng (khi lưu lúc
# đóng app qua sendBeacon — user anonymous) — tải lại dự án đang làm dở.
try:
from app.config import settings as _st
autosave_path = os.path.join(_st.STORAGE_DIR, "temp", "autosave.json")
if os.path.isfile(autosave_path):
with open(autosave_path, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("data_json"):
return {
"has_temp": True,
"data_json": data["data_json"],
"updated_at": data.get("updated_at", 0),
"source": "file",
}
except Exception:
pass
return {"has_temp": False}
def _os_projects_dir() -> str:
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
Linux/macOS → ~/SonicForgeDAW/Projects. Luôn tồn tại (tự tạo)."""
try:
if os.name == "nt":
docs = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), "Documents")
base = docs if os.path.isdir(docs) else (os.environ.get("USERPROFILE") or os.path.expanduser("~"))
else:
base = os.path.expanduser("~")
d = os.path.join(base, "SonicForgeDAW", "Projects")
os.makedirs(d, exist_ok=True)
return d
except Exception:
return os.path.join(os.path.expanduser("~"), "SonicForgeDAW", "Projects")
@router.post("/save-to-disk")
async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[str] = Header(None)):
"""Ctrl-S trên desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
(Documents/SonicForgeDAW/Projects — bản desktop). Docker/headless KHÔNG
dùng endpoint này (frontend lưu Cloud). Auth optional — desktop có thể
chưa login."""
try:
if authorization and authorization.startswith("Bearer "):
decode_token(authorization.split(" ")[1])
except Exception:
pass
validated_data_json = validate_project_data(req.data_json)
safe_name = "".join(c for c in (req.name or "Dự án mới") if c.isalnum() or c in " _-.").strip() or "Du-an-moi"
if len(safe_name) > 80:
safe_name = safe_name[:80].strip()
safe_name = safe_name.replace(".", "_") if safe_name.endswith(".") else safe_name
fname = safe_name + ".sonicforge.json"
out_dir = _os_projects_dir()
out_path = os.path.join(out_dir, fname)
# Không ghi đè file đang mở ở nơi khác? Ghi đè OK (Ctrl-S = save).
with open(out_path, "w", encoding="utf-8") as f:
f.write(validated_data_json)
return { return {
"has_temp": True, "success": True,
"data_json": row["data_json"], "name": req.name or "Dự án mới",
"updated_at": row["updated_at"] "path": out_path,
"filename": fname,
} }
@router.post("/cloud") @router.post("/cloud")
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)): async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
validated_data_json = validate_project_data(req.data_json) validated_data_json = validate_project_data(req.data_json)
@@ -283,13 +363,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id)) cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
exists = cursor.fetchone() existing = cursor.fetchone()
if not exists: if not existing:
conn.close() conn.close()
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật") raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
new_size_bytes = len(validated_data_json.encode("utf-8")) new_size_bytes = len(validated_data_json.encode("utf-8"))
# Enforce storage quota (same rule as save_cloud_project — previously
# update bypassed the quota entirely).
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
quota_row = cursor.fetchone()
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
used_row = cursor.fetchone()
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
max_bytes = storage_limit_mb * 1024 * 1024
if used_bytes + new_size_bytes > max_bytes:
conn.close()
raise HTTPException(
status_code=400,
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
)
now = time.time() now = time.time()
cursor.execute(""" cursor.execute("""
+51
View File
@@ -0,0 +1,51 @@
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from app.core.runtime import capabilities, save_carla_path
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
@router.get("/capabilities")
async def get_capabilities():
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
"headless" (docker server + browser UI)
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
âm thật giống export) | "wasm" (Preview Synth trong browser)
"""
return capabilities()
class CarlaPathRequest(BaseModel):
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
carla_path: str
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
@router.post("/carla-path")
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
enforce_password_changed(current_user)
target = (req.carla_path or req.carla_dir or "").strip()
if not target:
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
exe = save_carla_path(target)
if not exe:
raise HTTPException(
status_code=400,
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
)
return {"success": True, "carla_path": exe, **capabilities()}
+10 -3
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,
+1 -1
View File
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)): async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
uid = _get_user_id(authorization) uid = _get_user_id(authorization)
configs = _load_ai_configs() configs = _load_ai_configs()
configs[uid] = [p.dict() for p in req.providers] configs[uid] = [p.model_dump() for p in req.providers]
_save_all(ai_configs=configs) _save_all(ai_configs=configs)
return { return {
"success": True, "success": True,
+52 -3
View File
@@ -1,16 +1,65 @@
import os import os
import sys
def _app_dir():
if getattr(sys, "frozen", False):
# PyInstaller onefile: assets read-only nằm trong thư mục giải nén tạm
return os.path.join(sys._MEIPASS, "app")
return os.path.dirname(os.path.abspath(__file__))
def _storage_dir():
if getattr(sys, "frozen", False):
# Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return os.path.join(root, "SonicForgeDAW", "storage")
return os.path.join(_app_dir(), "storage")
def _default_vst_dir():
"""Thư mục VST mặc định theo platform (env VST_DIR vẫn thắng).
Windows: thư mục VST3 chuẩn của hệ thống — user có thể thêm thư mục khác
qua Plugins Manager (plugin_dirs.json). Docker/Linux: mount qua compose."""
if os.name == "nt":
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
return os.path.join(pf, "Common Files", "VST3")
if sys.platform == "darwin":
return "/Library/Audio/Plug-Ins/VST3"
return "/opt/daw_engine/vst3"
def _default_soundfont_dir():
if os.name == "nt":
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return os.path.join(root, "SonicForgeDAW", "soundfonts")
if sys.platform == "darwin":
return os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")
return "/opt/daw_engine/soundfonts"
class Settings: class Settings:
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
# __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root APP_DIR: str = _app_dir()
APP_DIR: str = os.path.dirname(os.path.abspath(__file__))
BASE_DIR: str = os.path.dirname(APP_DIR) BASE_DIR: str = os.path.dirname(APP_DIR)
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates") TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
STORAGE_DIR: str = os.path.join(APP_DIR, "storage") STORAGE_DIR: str = _storage_dir()
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads") UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed") PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user). Default theo
# platform (Windows: thư mục VST3 chuẩn; Linux: mount compose).
VST_DIR: str = os.getenv("VST_DIR", _default_vst_dir())
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", _default_soundfont_dir())
# Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard.
# Nằm trong storage nên tự động nằm trong volume mount (docker) / thư mục
# storage (Windows desktop).
PRESET_DIR: str = os.path.join(STORAGE_DIR, "presets")
settings = Settings() settings = Settings()
+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)
+30 -1
View File
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
from app.models.user import get_db_connection from app.models.user import get_db_connection
from app.config import settings from app.config import settings
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026") COOKIE_NAME = "sf_token"
X_AUTH_HEADER = "X-Auth-Token"
def _load_or_create_secret_key() -> str:
"""Persistent random SECRET_KEY.
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
first run). Never falls back to a hardcoded value: a known secret lets
anyone forge admin tokens.
"""
env_key = os.getenv("SECRET_KEY", "").strip()
if env_key:
return env_key
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
try:
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
if os.path.exists(key_file):
with open(key_file, "r") as f:
key = f.read().strip()
if len(key) >= 32:
return key
key = secrets.token_hex(32)
with open(key_file, "w") as f:
f.write(key)
return key
except Exception:
# Last resort: ephemeral random key (all tokens invalid on restart).
return secrets.token_hex(32)
SECRET_KEY = _load_or_create_secret_key()
def hash_password(password: str, salt: Optional[str] = None) -> str: def hash_password(password: str, salt: Optional[str] = None) -> str:
""" """
+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
+78 -23
View File
@@ -1,7 +1,8 @@
import os, logging import os, logging, math
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
import scipy.signal as signal # scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
from app.config import settings from app.config import settings
from app.core.vst_engine import ( from app.core.vst_engine import (
render_midi_events_to_audio, render_midi_events_to_audio,
@@ -9,6 +10,7 @@ from app.core.vst_engine import (
DecentSamplerManager, DecentSamplerManager,
HAS_PEDALBOARD, HAS_PEDALBOARD,
HAS_PYFLUIDSYNTH, HAS_PYFLUIDSYNTH,
apply_preset_to_plugin,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,6 +35,21 @@ def _find_sf2_path(sf_id: str) -> str:
fbase, fext = os.path.splitext(fname) fbase, fext = os.path.splitext(fname)
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower): if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
return os.path.join(base_dir, fname) return os.path.join(base_dir, fname)
# Thư mục user thêm qua Plugin Manager (plugin_dirs — Add Directory):
# soundfont trong thư mục user phải render được (Windows thường dùng cách này)
try:
from app.core.vst_engine import _load_user_plugin_dirs
for base_dir in _load_user_plugin_dirs():
if not os.path.isdir(base_dir):
continue
for root, dirs, files in os.walk(base_dir):
for fname in files:
fbase, fext = os.path.splitext(fname)
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
return os.path.join(root, fname)
dirs[:] = [] # không walk sâu
except Exception:
pass
static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts") static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
if os.path.isdir(static_dir): if os.path.isdir(static_dir):
for fname in os.listdir(static_dir): for fname in os.listdir(static_dir):
@@ -87,12 +104,18 @@ class PythonRenderEngine:
return url_or_id return url_or_id
return url_or_id return url_or_id
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray: def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
session_buffer = np.zeros((2, total_samples), dtype=np.float32) session_buffer = np.zeros((2, total_samples), dtype=np.float32)
# Solo semantics: when any track is soloed, only soloed tracks sound.
tracks = session.get("tracks", [])
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
_channel_counter = 0 _channel_counter = 0
for track in session.get("tracks", []): for track in tracks:
if solo_ids and track.get("id") not in solo_ids:
continue
track_type = track.get("type", "AUDIO") track_type = track.get("type", "AUDIO")
track_buffer = np.zeros((2, total_samples), dtype=np.float32) track_buffer = np.zeros((2, total_samples), dtype=np.float32)
@@ -123,8 +146,17 @@ class PythonRenderEngine:
try: try:
audio_data, sr = sf.read(resolved_path, dtype='float32') audio_data, sr = sf.read(resolved_path, dtype='float32')
if sr != self.sample_rate: if sr != self.sample_rate:
# Resampling fallback if simple, otherwise skip # Proper resampling: previously a silent no-op that
pass # played 48kHz audio at the wrong speed/pitch.
from scipy.signal import resample_poly
g = math.gcd(sr, self.sample_rate)
audio_data = resample_poly(
audio_data,
up=self.sample_rate // g,
down=sr // g,
axis=-1,
)
sr = self.sample_rate
# Handle channel mapping (Mono/Stereo) # Handle channel mapping (Mono/Stereo)
if len(audio_data.shape) == 1: if len(audio_data.shape) == 1:
@@ -148,7 +180,7 @@ class PythonRenderEngine:
if actual_len > 0: if actual_len > 0:
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len] track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
except Exception as e: except Exception as e:
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}") logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
elif item_type == "MIDI_ITEM": elif item_type == "MIDI_ITEM":
source_data = item.get("source_data", {}) source_data = item.get("source_data", {})
@@ -207,6 +239,21 @@ class PythonRenderEngine:
) )
elif vst and HAS_PEDALBOARD: elif vst and HAS_PEDALBOARD:
from pedalboard import Pedalboard from pedalboard import Pedalboard
# Preset bridge Carla → pedalboard: preset_id
# (thư viện storage/presets), preset_path (file)
# hoặc preset_data (base64 .vstpreset nhúng trong
# project). Cùng sample rate → âm render = âm đã
# chỉnh trong Carla.
try:
se2 = track.get("synth_engine", {}) or {}
apply_preset_to_plugin(
vst,
preset_id=se2.get("preset_id") or track.get("preset_id"),
preset_path=se2.get("preset_path") or track.get("preset_path"),
preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
)
except Exception as e:
logger.warning("[RenderEngine] Preset apply failed: %s", e)
midi_messages = PluginManager.midi_events_to_messages( midi_messages = PluginManager.midi_events_to_messages(
midi_events, bpm, self.sample_rate, midi_events, bpm, self.sample_rate,
bank=soundfont_bank, program=soundfont_program bank=soundfont_bank, program=soundfont_program
@@ -284,21 +331,27 @@ class PythonRenderEngine:
actual_len = min(synth_buffer.shape[1], total_samples) actual_len = min(synth_buffer.shape[1], total_samples)
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len] track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
except Exception as e: except Exception as e:
print(f"[RenderEngine] Error rendering MIDI: {e}") logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
elif item_type == "SECTION_ITEM": elif item_type == "SECTION_ITEM":
source_data = item.get("source_data", {}) source_data = item.get("source_data", {})
sec_id = source_data.get("referenced_section_id", "") sec_id = source_data.get("referenced_section_id", "")
if sec_id and sec_id in section_store: if sec_id and sec_id in section_store:
# Render nested section recursively # Render nested section recursively, cached per section id
sec_container = section_store[sec_id] # so repeated section instances don't re-render every time.
sec_buffer = self.render_session_container( cache = _cache if _cache is not None else {}
session=sec_container, if sec_id in cache:
section_store=section_store, sec_buffer = cache[sec_id]
bpm=bpm, else:
time_sig_num=time_sig_num, sec_buffer = self.render_session_container(
total_samples=total_samples session=section_store[sec_id],
) section_store=section_store,
bpm=bpm,
time_sig_num=time_sig_num,
total_samples=total_samples,
_cache=cache,
)
cache[sec_id] = sec_buffer
# Apply non-destructive crop/slicing on section buffer # Apply non-destructive crop/slicing on section buffer
if offset_sample < total_samples: if offset_sample < total_samples:
@@ -327,7 +380,7 @@ class PythonRenderEngine:
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)]) board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
track_buffer = board(track_buffer, sample_rate=self.sample_rate) track_buffer = board(track_buffer, sample_rate=self.sample_rate)
except Exception as e: except Exception as e:
print(f"[RenderEngine] Pedalboard Chorus failed: {e}") logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
else: else:
# Fallback chorus using simple LFO delay modulation in scipy/numpy # Fallback chorus using simple LFO delay modulation in scipy/numpy
try: try:
@@ -341,14 +394,14 @@ class PythonRenderEngine:
wet[ch, :] = track_buffer[ch, indices] wet[ch, :] = track_buffer[ch, indices]
track_buffer = dry + wet * 0.5 track_buffer = dry + wet * 0.5
except Exception as e: except Exception as e:
print(f"[RenderEngine] Fallback Chorus failed: {e}") logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
elif fx_type == "reverb": elif fx_type == "reverb":
if HAS_PEDALBOARD: if HAS_PEDALBOARD:
try: try:
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)]) board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
track_buffer = board(track_buffer, sample_rate=self.sample_rate) track_buffer = board(track_buffer, sample_rate=self.sample_rate)
except Exception as e: except Exception as e:
print(f"[RenderEngine] Pedalboard Reverb failed: {e}") logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
else: else:
# Fallback reverb using exponentially decaying noise room impulse response # Fallback reverb using exponentially decaying noise room impulse response
try: try:
@@ -364,11 +417,12 @@ class PythonRenderEngine:
for ch in range(2): for ch in range(2):
ir = ir_l if ch == 0 else ir_r ir = ir_l if ch == 0 else ir_r
# Convolve # Convolve
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples] from scipy.signal import convolve
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
wet[ch, :] = conv wet[ch, :] = conv
track_buffer = dry + wet * 0.4 track_buffer = dry + wet * 0.4
except Exception as e: except Exception as e:
print(f"[RenderEngine] Fallback Reverb failed: {e}") logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
# Process track volume # Process track volume
if HAS_PEDALBOARD: if HAS_PEDALBOARD:
@@ -410,7 +464,8 @@ class PythonRenderEngine:
section_store=section_store, section_store=section_store,
bpm=bpm, bpm=bpm,
time_sig_num=time_sig_num, time_sig_num=time_sig_num,
total_samples=total_samples total_samples=total_samples,
_cache={},
) )
# Normalization to prevent clipping # Normalization to prevent clipping
+398
View File
@@ -0,0 +1,398 @@
# SonicForge Runtime Profile — phát hiện môi trường chạy (desktop Windows /
# docker headless) và khả năng của máy, để app TỰ CHỌN cách xử lý:
# - Windows desktop (server + client cùng 1 máy): Carla local để preview +
# chỉnh preset → pedalboard render (Hướng A)
# - Docker / Linux headless (server + browser UI): soundfont + VSTi mở được
# từ storage mount; preset upload qua browser; không có GUI local.
#
# Ưu tiên: SF_RUNTIME env override > heuristic (platform + display + docker).
# Module CHỈ dùng stdlib + app.config (không kéo pedalboard/numpy) để import
# nhẹ và hoạt động trong mọi tiến trình (web/worker/celery).
import os
import sys
import json
import shutil
import functools
from app.config import settings
SF_RUNTIME_ENV = "SF_RUNTIME" # auto | desktop | headless
SF_DOCKER_ENV = "SF_DOCKER" # 1 = chạy trong container Docker
CARLA_CONFIG_FILE = "carla_path.json" # storage/carla_path.json — user định vị
# Bản Carla portable (zip) có thể giải nén ở BẤT KỲ ĐÂU — PATH/Program Files
# không đủ. User tự chọn thư mục chứa carla.exe qua Plugin Manager → lưu file
# này (ưu tiên cao nhất khi detect), kèm tìm kiếm nông Downloads/Desktop.
def _is_windows() -> bool:
return os.name == "nt"
def _is_macos() -> bool:
return sys.platform == "darwin"
def _is_linux() -> bool:
return not _is_windows() and not _is_macos()
def _in_docker() -> bool:
if os.environ.get(SF_DOCKER_ENV) == "1":
return True
# Marker chuẩn của Docker (không tồn tại trên máy host thường)
try:
return os.path.exists("/.dockerenv")
except Exception:
return False
def _has_display() -> bool:
"""Có môi trường GUI hiển thị được hay không.
Windows/macOS luôn có (desktop). Linux cần biến DISPLAY (X11) — nếu chạy
Docker không có DISPLAY → headless."""
if _is_windows() or _is_macos():
return True
return bool(os.environ.get("DISPLAY"))
def _tauri_bridge_ready() -> bool:
"""App desktop Tauri viết marker file lúc setup (xem plugins.py
_pick_dir_via_tauri_bridge) — dùng để nhận diện bản desktop app."""
try:
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return os.path.exists(os.path.join(root, "SonicForgeDAW", "ipc", "tauri_bridge_ready"))
except Exception:
return False
def _carla_config_path() -> str:
return os.path.join(settings.STORAGE_DIR, CARLA_CONFIG_FILE)
def get_configured_carla() -> str:
"""Đường dẫn carla.exe do USER tự định vị (lưu trong storage/carla_path.json).
File lưu đường dẫn tới carla.exe (đã resolve) hoặc thư mục chứa — nếu là
thư mục, tìm carla.exe bên trong (độ sâu ≤ 2). Đây là cách bắt buộc có cho
bản Carla portable (zip) giải nén ở vị trí bất kỳ, không cài đặt/PATH."""
try:
with open(_carla_config_path(), "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
return ""
p = (data.get("carla_path") or "").strip()
if not p:
return ""
if os.path.isfile(p) and os.path.basename(p).lower() in ("carla.exe", "carla-single.exe", "carla"):
return p
if os.path.isdir(p):
try:
for root, dirs, files in os.walk(p):
depth = root[len(p):].count(os.sep)
if depth >= 2:
dirs[:] = []
continue
for f in files:
if f.lower() == "carla.exe":
return os.path.join(root, f)
except Exception:
pass
return ""
def save_carla_path(path: str) -> str:
"""Lưu vị trí Carla do user chọn (Plugin Manager → Định vị Carla...).
Chấp nhận: đường dẫn tới carla.exe, hoặc thư mục chứa carla.exe (portable
zip). Resolve về đường dẫn exe hợp lệ → ghi config → xóa cache detect.
Trả đường dẫn exe, hoặc '' nếu không hợp lệ."""
path = (path or "").strip().strip('"').strip()
exe = ""
if os.path.isfile(path) and os.path.basename(path).lower() in ("carla.exe", "carla-single.exe", "carla"):
exe = path
elif os.path.isdir(path):
try:
for root, dirs, files in os.walk(path):
depth = root[len(path):].count(os.sep)
if depth >= 2:
dirs[:] = []
continue
for f in files:
if f.lower() == "carla.exe":
exe = os.path.join(root, f)
break
if exe:
break
except Exception:
pass
if not exe:
return ""
try:
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
with open(_carla_config_path(), "w", encoding="utf-8") as f:
json.dump({"carla_path": exe, "carla_exe": os.path.basename(exe)}, f, ensure_ascii=False, indent=2)
except Exception:
return ""
# Xóa cache detect/find_carla để capabilities phản ánh ngay
invalidate_carla_cache()
return exe
def _carla_from_registry() -> str:
"""Windows: Carla cài qua installer có thể ghi registry (best-effort)."""
if not _is_windows():
return ""
try:
import winreg
for hive, key in (
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Carla"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Carla"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Carla"),
):
try:
with winreg.OpenKey(hive, key) as k:
val, _ = winreg.QueryValueEx(k, "InstallPath")
p = os.path.join(str(val), "carla.exe")
if os.path.isfile(p):
return p
except Exception:
continue
except Exception:
pass
return ""
def _find_carla_exe_bounded(base: str, max_depth: int = 3) -> str:
"""Quét nông tìm carla.exe (bản portable giải nén thường nằm Downloads/
Desktop/Documents). Giới hạn độ sâu + bỏ qua thư mục lớn — KHÔNG quét
toàn ổ đĩa."""
if not base or not os.path.isdir(base):
return ""
skip = ("node_modules", ".cache", "AppData", ".git", "venv", ".venv",
"__pycache__", "$RECYCLE.BIN", "Windows", "Program Files",
"Program Files (x86)")
try:
for root, dirs, files in os.walk(base):
depth = root[len(base):].count(os.sep) if root != base else 0
if depth >= max_depth:
dirs[:] = []
continue
for f in files:
if f.lower() == "carla.exe":
return os.path.join(root, f)
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
except Exception:
pass
return ""
def _carla_from_shallow_search() -> str:
"""Windows: tìm carla.exe trong Downloads/Desktop/Documents/Home."""
if not _is_windows():
return ""
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
bases = []
for sub in ("Downloads", "Desktop", "Documents"):
bases.append(os.path.join(home, sub))
bases.append(home)
for base in bases:
hit = _find_carla_exe_bounded(base)
if hit:
return hit
return ""
def _carla_candidates() -> list:
"""Các vị trí Carla — theo thứ tự ưu tiên:
1) user định vị (config file — bản portable bắt buộc dùng cách này)
2) PATH (shutil.which)
3) registry (Windows, nếu cài qua installer)
4) thư mục cài đặt chuẩn (Program Files...)
5) quét nông Downloads/Desktop/Documents (Windows) / AppImage (Linux)"""
cands = []
configured = get_configured_carla()
if configured:
cands.append(configured)
try:
which = shutil.which("carla")
if which:
cands.append(which)
except Exception:
pass
reg = _carla_from_registry()
if reg:
cands.append(reg)
try:
if _is_windows():
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
lpf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
la = os.environ.get("LOCALAPPDATA", "")
for base in (pf, lpf, la):
for rel in ("Carla\\carla.exe", "Carla\\bin\\carla.exe", "Programs\\Carla\\carla.exe"):
p = os.path.join(base, rel) if base else ""
if p and os.path.isfile(p):
cands.append(p)
elif _is_linux():
home = os.path.expanduser("~")
for p in ("/usr/bin/carla", "/usr/local/bin/carla", "/opt/carla/carla"):
if os.path.isfile(p):
cands.append(p)
try:
for f in os.listdir(home):
if f.lower().startswith("carla") and f.lower().endswith(".appimage"):
cands.append(os.path.join(home, f))
except Exception:
pass
elif _is_macos():
for p in ("/Applications/Carla.app/Contents/MacOS/Carla", "/Applications/Carla.app/Contents/MacOS/carla"):
if os.path.isfile(p):
cands.append(p)
except Exception:
pass
shallow = _carla_from_shallow_search()
if shallow:
cands.append(shallow)
# Dedup giữ thứ tự
seen, out = set(), []
for c in cands:
if c not in seen:
seen.add(c)
out.append(c)
return out
@functools.lru_cache(maxsize=1)
def find_carla() -> str:
"""Đường dẫn Carla đầu tiên tìm thấy, hoặc '' nếu chưa có.
Lưu ý license: Carla GPL-2.0+ — app KHÔNG bundle/nhúng, chỉ spawn tiến
trình ngoài (user tự cài/giải nén) → không dính copyleft."""
for c in _carla_candidates():
return c
return ""
def invalidate_carla_cache():
"""Xóa cache detect/find_carla — gọi sau khi user định vị Carla mới."""
find_carla.cache_clear()
detect.cache_clear()
def _detect_platform() -> str:
if _is_windows():
return "windows"
if _is_macos():
return "darwin"
return "linux"
def _detect_runtime() -> str:
"""auto → desktop nếu có GUI (Windows/macOS luôn; Linux cần DISPLAY và
không phải docker), ngược lại headless. SF_RUNTIME override thắng."""
override = (os.environ.get(SF_RUNTIME_ENV) or "auto").strip().lower()
if override in ("desktop", "headless"):
return override
if _is_windows() or _is_macos():
return "desktop"
if _in_docker() or not _has_display():
return "headless"
return "desktop"
def default_vst_dirs() -> list:
"""Thư mục VST mặc định theo platform (vẫn ưu tiên env VST_DIR).
Windows: thư mục chuẩn VST3 của hệ thống; user thường khai báo thêm qua
Plugins Manager (plugin_dirs.json)."""
if settings.VST_DIR and settings.VST_DIR != "/opt/daw_engine/vst3":
return [settings.VST_DIR]
if _is_windows():
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
return [os.path.join(pf, "Common Files", "VST3"), os.path.join(pf, "VSTPlugins")]
if _is_macos():
return ["/Library/Audio/Plug-Ins/VST3", os.path.expanduser("~/Library/Audio/Plug-Ins/VST3")]
return ["/opt/daw_engine/vst3", os.path.expanduser("~/.vst3")]
def default_soundfont_dirs() -> list:
if settings.SOUNDFONT_DIR and settings.SOUNDFONT_DIR != "/opt/daw_engine/soundfonts":
return [settings.SOUNDFONT_DIR]
if _is_windows():
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return [os.path.join(root, "SonicForgeDAW", "soundfonts")]
if _is_macos():
return [os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")]
return ["/opt/daw_engine/soundfonts", os.path.expanduser("~/.sf2")]
@functools.lru_cache(maxsize=1)
def detect() -> dict:
"""Detect 1 lần (cache toàn cục) — kết quả bất biến trong 1 tiến trình."""
platform = _detect_platform()
runtime = _detect_runtime()
carla = find_carla() if runtime == "desktop" else ""
return {
"platform": platform,
"runtime": runtime, # "desktop" | "headless"
"docker": _in_docker(),
"has_display": _has_display(),
"tauri_bridge": _tauri_bridge_ready(),
"carla_path": carla,
"carla_local": bool(carla), # chỉ có ý nghĩa khi runtime=desktop
"vst_render": _vst_render_available(),
"default_vst_dirs": default_vst_dirs(),
"default_soundfont_dirs": default_soundfont_dirs(),
"preset_dir": os.path.join(settings.STORAGE_DIR, "presets"),
}
def _vst_render_available() -> bool:
"""pedalboard có sẵn không (dùng find_spec — KHÔNG import pedalboard để
tránh kéo JUCE lib; giống pattern _module_available trong vst_engine)."""
import importlib.util
try:
return importlib.util.find_spec("pedalboard") is not None
except (ImportError, AttributeError, ValueError):
return False
def capabilities() -> dict:
"""Capabilities API — frontend gọi 1 lần lúc boot để bật/tắt tính năng."""
d = detect()
features = {
"carla_local": d["carla_local"],
"carla_path": d["carla_path"],
"preset_upload": True, # cả 2 mode đều upload preset được
"vst_render": d["vst_render"],
"tauri_bridge": d["tauri_bridge"],
# preview VSTi: quick_render (pedalboard render clip ngắn — âm thật,
# giống export) là chuẩn cho cả 2 mode; không có realtime trong web UI.
"preview_mode": "quick_render" if d["vst_render"] else "wasm",
}
return {
"success": True,
"runtime": d["runtime"],
"platform": d["platform"],
"docker": d["docker"],
"features": features,
"default_dirs": {
"vst": d["default_vst_dirs"],
"soundfont": d["default_soundfont_dirs"],
"preset": d["preset_dir"],
},
}
def is_desktop() -> bool:
return detect()["runtime"] == "desktop"
def is_headless() -> bool:
return detect()["runtime"] == "headless"
if __name__ == "__main__":
# python -m app.core.runtime → in capabilities để kiểm tra detect
import json
print(json.dumps(capabilities(), ensure_ascii=False, indent=2))
+16 -10
View File
@@ -280,31 +280,37 @@ class SoundFontConverter:
@staticmethod @staticmethod
def _sf3_plays_audio(path: str) -> bool: def _sf3_plays_audio(path: str) -> bool:
"""Verify a SoundFont actually loads and renders audible audio (guards """Verify a SoundFont actually loads and renders audible audio (guards
against shipping malformed SF3 files that silently play nothing).""" against shipping malformed SF3 files that silently play nothing).
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
high-level Synth() class does not exist in this binding, so it is never
used here.
"""
if not os.path.exists(path): if not os.path.exists(path):
return False return False
try: try:
import fluidsynth import fluidsynth as _fs
import numpy as np import numpy as np
fl = fluidsynth.Synth() _settings = _fs.new_fluid_settings()
_fl = _fs.new_fluid_synth(_settings)
try: try:
h = fl.sfload(path) h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
if h < 0: if h < 0:
return False return False
fl.program_select(0, h, 0, 0) _fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
fl.noteon(0, 60, 100) _fs.fluid_synth_noteon(_fl, 0, 60, 100)
frames = 8820 # 0.2s frames = 8820 # 0.2s
buf = np.zeros(frames * 2, dtype=np.float32) buf = np.zeros(frames * 2, dtype=np.float32)
fluidsynth._fl.fluid_synth_write_float( _fs.fluid_synth_write_float(
fl.synth, frames, buf.ctypes.data, 0, 1, _fl, frames, buf.ctypes.data, 0, 1,
buf.ctypes.data + frames * 4, 0, 1 buf.ctypes.data + frames * 4, 0, 1
) )
fl.noteoff(0, 60) _fs.fluid_synth_noteoff(_fl, 0, 60)
rms = float(np.sqrt(np.mean(buf ** 2))) rms = float(np.sqrt(np.mean(buf ** 2)))
return rms > 1e-4 return rms > 1e-4
finally: finally:
try: try:
fl.delete() _fs.delete_fluid_synth(_fl)
except Exception: except Exception:
pass pass
except Exception: except Exception:
+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(
+235 -81
View File
@@ -1,8 +1,9 @@
# SonicForge Studio VST / VSTi Engine Service # SonicForge Studio VST / VSTi Engine Service
import os import os
import json
import numpy as np import numpy as np
import functools import functools
from ctypes import c_int, c_char_p, c_void_p from ctypes import c_char_p
def midi_note_to_freq(note_number: int) -> float: def midi_note_to_freq(note_number: int) -> float:
return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
@@ -49,27 +50,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
out_r /= max_peak out_r /= max_peak
return np.vstack([out_l, out_r]) return np.vstack([out_l, out_r])
def check_pedalboard_safe(): def _module_available(name: str) -> bool:
import subprocess, sys """Kiem tra module co san khong — KHONG spawn subprocess.
Truoc day dung subprocess.run([sys.executable, '-c', 'import X']) —
khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe
-> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine)
-> moi lan check lai sinh ra engine moi -> de quy spawn vo han
(Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham).
find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen.
"""
import importlib.util
try: try:
res = subprocess.run( return importlib.util.find_spec(name) is not None
[sys.executable, "-c", "import pedalboard"], except (ImportError, AttributeError, ValueError):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False return False
def check_pedalboard_safe():
return _module_available("pedalboard")
def check_pyfluidsynth_safe(): def check_pyfluidsynth_safe():
import subprocess, sys return _module_available("fluidsynth")
try:
res = subprocess.run(
[sys.executable, "-c", "import fluidsynth"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
)
return res.returncode == 0
except Exception:
return False
HAS_PEDALBOARD = check_pedalboard_safe() HAS_PEDALBOARD = check_pedalboard_safe()
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe() HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
@@ -99,18 +102,47 @@ _PLUGIN_MANAGER_INSTANCE = None
_PLUGIN_MANAGER_ARGS = None _PLUGIN_MANAGER_ARGS = None
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets] _SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager": def _load_user_plugin_dirs() -> list:
"""Singleton: reuse PluginManager when args match, else create new.""" """Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
tránh vòng import (plugins.py import vst_engine)."""
try:
from app.config import settings as _st
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return [d for d in (data.get("plugin_dirs") or []) if d]
except Exception:
pass
return []
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
"""Singleton: reuse PluginManager when args match, else create new.
Default dirs từ settings (env/.env/docker-compose hoặc user override).
VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt
kê được VSTi đã scan và load_vst phải tìm thấy chúng khi render."""
from app.config import settings as _st
vst_dir = vst_dir or _st.VST_DIR
sf_dir = sf_dir or _st.SOUNDFONT_DIR
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
extra = _load_user_plugin_dirs()
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
args = (vst_dir, sf_dir, upload_sf_dir) args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args: if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
return _PLUGIN_MANAGER_INSTANCE return _PLUGIN_MANAGER_INSTANCE
_PLUGIN_MANAGER_ARGS = args _PLUGIN_MANAGER_ARGS = args
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir) _PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
return _PLUGIN_MANAGER_INSTANCE return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str): def load_soundfont_cached(path: str):
"""Return a cached FluidSynth instance for path, incrementing refcount.""" """Return a cached low-level FluidSynth instance for path, incrementing refcount.
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
do not exist in this binding, so they are never used here.
"""
global _FLUID_CACHE global _FLUID_CACHE
if not HAS_PYFLUIDSYNTH: if not HAS_PYFLUIDSYNTH:
return None return None
@@ -119,10 +151,15 @@ def load_soundfont_cached(path: str):
_FLUID_CACHE[path] = (fl, ref + 1) _FLUID_CACHE[path] = (fl, ref + 1)
return fl return fl
try: try:
import fluidsynth import fluidsynth as _fs
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5) _settings = _fs.new_fluid_settings()
font_id = fl.sfload(path) _fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
fl.program_select(0, font_id, 0, 0) fl = _fs.new_fluid_synth(_settings)
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
if font_id < 0:
_fs.delete_fluid_synth(fl)
return None
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
_FLUID_CACHE[path] = (fl, 1) _FLUID_CACHE[path] = (fl, 1)
return fl return fl
except Exception: except Exception:
@@ -136,7 +173,8 @@ def release_soundfont(path: str):
fl, ref = _FLUID_CACHE[path] fl, ref = _FLUID_CACHE[path]
if ref <= 1: if ref <= 1:
try: try:
fl.delete() import fluidsynth as _fs
_fs.delete_fluid_synth(fl)
except Exception: except Exception:
pass pass
del _FLUID_CACHE[path] del _FLUID_CACHE[path]
@@ -144,22 +182,34 @@ def release_soundfont(path: str):
_FLUID_CACHE[path] = (fl, ref - 1) _FLUID_CACHE[path] = (fl, ref - 1)
class PluginManager: class PluginManager:
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None): def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None):
self.vst_dir = vst_dir from app.config import settings as _st
self.sf_dir = sf_dir self.vst_dir = vst_dir or _st.VST_DIR
self.sf_dir = sf_dir or _st.SOUNDFONT_DIR
self.upload_sf_dir = upload_sf_dir self.upload_sf_dir = upload_sf_dir
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
self._sf_scan_cache = None # cache for _scan_soundfonts() self._sf_scan_cache = None # cache for _scan_soundfonts()
def _scan_plugins(self) -> dict: def _scan_plugins(self) -> dict:
plugins = {} plugins = {}
if not os.path.isdir(self.vst_dir): for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
return plugins if not scan_dir or not os.path.isdir(scan_dir):
for root, dirs, files in os.walk(self.vst_dir): continue
for file in files: for root, dirs, files in os.walk(scan_dir):
if file.endswith(".vst3") or file.endswith(".so"): # Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
plugin_path = os.path.join(root, file) for d in list(dirs):
plugin_name = os.path.splitext(file)[0] if d.lower().endswith(".vst3"):
plugins[plugin_name] = plugin_path plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
for file in files:
low = file.lower()
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
plugin_path = os.path.join(root, file)
plugin_name = os.path.splitext(file)[0]
if plugin_name not in plugins:
plugins[plugin_name] = plugin_path
return plugins return plugins
def _scan_soundfonts(self) -> list: def _scan_soundfonts(self) -> list:
@@ -228,8 +278,6 @@ class PluginManager:
return load_soundfont_cached(path) return load_soundfont_cached(path)
def list_soundfont_instruments(self, sf_id: str): def list_soundfont_instruments(self, sf_id: str):
if not ensure_pyfluidsynth():
return []
if sf_id in _SF_INSTRUMENTS_CACHE: if sf_id in _SF_INSTRUMENTS_CACHE:
return _SF_INSTRUMENTS_CACHE[sf_id] return _SF_INSTRUMENTS_CACHE[sf_id]
search_dirs = [] search_dirs = []
@@ -237,50 +285,89 @@ class PluginManager:
search_dirs.append(self.sf_dir) search_dirs.append(self.sf_dir)
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir: if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
search_dirs.append(self.upload_sf_dir) search_dirs.append(self.upload_sf_dir)
for d in search_dirs: # Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn
for f in os.listdir(d): # SoundFont): nếu không có, instrument của soundfont trong thư mục user
if not (f.endswith(".sf2") or f.endswith(".sf3")): # không liệt kê được (bug "nhấn tên SF không thấy instrument").
continue for d in (self.extra_vst_dirs or []):
base = os.path.splitext(f)[0] if d and os.path.isdir(d) and d not in search_dirs:
if base == sf_id or base == sf_id.replace("sf_", ""): search_dirs.append(d)
presets = []
# ── 1) FluidSynth (nếu có lib) — đọc bank/program/name từ engine ──
if ensure_pyfluidsynth():
for d in search_dirs:
if presets:
break
for f in os.listdir(d):
if not (f.endswith(".sf2") or f.endswith(".sf3")):
continue
base = os.path.splitext(f)[0]
if base != sf_id and base != sf_id.replace("sf_", ""):
continue
path = os.path.join(d, f) path = os.path.join(d, f)
try: try:
import fluidsynth import fluidsynth as _fs
fl = fluidsynth.Synth() # Low-level CFFI API (same as render_engine); never use
fid = fl.sfload(path) # the high-level Synth() class that this binding lacks.
if fid < 0: _settings = _fs.new_fluid_settings()
fl.delete() _synth = _fs.new_fluid_synth(_settings)
continue try:
presets = [] fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
_fl = fluidsynth._fl if fid >= 0:
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
_fl.fluid_preset_get_name.restype = c_char_p if sfont:
_fl.fluid_sfont_get_preset.restype = c_void_p for bank in range(0, 2):
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid)) for prog_num in range(0, 128):
if sfont_ptr: try:
for bank in range(0, 2): preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
for prog_num in range(0, 128): except Exception:
try: break
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num)) if preset:
except Exception: try:
break name_ptr = _fs.fluid_preset_get_name(preset)
if preset: if name_ptr:
name_ptr = fluidsynth.fluid_preset_get_name(preset) if hasattr(_fs, "ffi"):
if name_ptr: raw = _fs.ffi.string(name_ptr)
name_val = c_char_p(name_ptr).value else:
if name_val: raw = c_char_p(name_ptr).value
presets.append({ if raw:
"bank": bank, presets.append({
"program": prog_num, "bank": bank,
"name": name_val.decode("utf-8", errors="replace") "program": prog_num,
}) "name": raw.decode("utf-8", errors="replace"),
fl.delete() })
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256] except Exception:
return presets[:256] continue
finally:
try:
_fs.delete_fluid_synth(_synth)
except Exception:
pass
except Exception: except Exception:
import traceback; traceback.print_exc() import traceback; traceback.print_exc()
_SF_INSTRUMENTS_CACHE[sf_id] = [] break # đã xử lý file khớp
return [] # ── 2) Fallback: sf2utils đọc TRỰC TIẾP file (thuần Python) ──
# Không cần libfluidsynth — quan trọng trên Windows khi thiếu DLL.
# Đọc preset header (pdta/phdr) → bank/program/name như nhau.
if not presets:
try:
from app.core.soundfont_inspector import SoundFontInspector
insp = SoundFontInspector(system_sf_dir=self.sf_dir, upload_sf_dir=self.upload_sf_dir)
for d in search_dirs:
if presets:
break
for f in os.listdir(d):
if not (f.endswith(".sf2") or f.endswith(".sf3")):
continue
base = os.path.splitext(f)[0]
if base == sf_id or base == sf_id.replace("sf_", ""):
info = insp.inspect_sf2_file(os.path.join(d, f))
if info and info.get("instruments"):
presets = info["instruments"]
break
except Exception:
presets = []
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
return presets[:256]
def list_available(self) -> dict: def list_available(self) -> dict:
return { return {
@@ -349,3 +436,70 @@ class DecentSamplerManager:
os.chdir(cwd_before) os.chdir(cwd_before)
return plugin return plugin
# ── Preset bridge (Carla ↔ pedalboard) ─────────────────────────────────────
# Carla (chạy ngoài, user tự cài — GPL-2.0+ nên app KHÔNG bundle/nhúng) dùng
# để mở native GUI VSTi + xuất file preset (.vstpreset từ nút Save của plugin).
# File preset nằm trong thư viện storage/presets (upload qua web UI hoặc picker
# local trên Windows) → render_engine tải qua apply_preset_to_plugin() khi render.
PRESET_EXTENSIONS = (".vstpreset", ".fxp", ".fxb", ".dspreset")
def preset_library_dir() -> str:
from app.config import settings as _st
d = os.path.join(_st.STORAGE_DIR, "presets")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def resolve_preset_path(preset_id_or_path: str) -> str:
"""preset_id (tên file trong thư viện) hoặc đường dẫn tuyệt đối → path.
Trả '' nếu không tìm thấy. Chống path traversal: chỉ chấp nhận tên file
(không chứa separator) hoặc đường dẫn tuyệt đối tồn tại."""
if not preset_id_or_path:
return ""
p = preset_id_or_path
# Đường dẫn tuyệt đối / tương đối tồn tại → dùng thẳng
if os.path.isfile(p):
return p
# id dạng tên file trong thư viện (uuid + ext)
if os.path.basename(p) == p:
cand = os.path.join(preset_library_dir(), p)
if os.path.isfile(cand):
return cand
# Không có ext → quét theo prefix (uuid.idx → uuid.vstpreset)
try:
for f in os.listdir(preset_library_dir()):
if f.lower().endswith(PRESET_EXTENSIONS) and f.startswith(p + "."):
return os.path.join(preset_library_dir(), f)
except Exception:
pass
return ""
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
áp dụng được; KHÔNG raise (render engine chỉ log warning)."""
if plugin is None:
return False
try:
if preset_data_b64:
import base64
raw = base64.b64decode(preset_data_b64)
if hasattr(plugin, "preset_data"):
plugin.preset_data = raw # bytes dạng .vstpreset (VST3)
return True
p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "")
if p and hasattr(plugin, "load_preset"):
plugin.load_preset(p) # .vstpreset (VST3) / .dspreset (DecentSampler)
return True
except Exception:
return False
return False
+58 -23
View File
@@ -1,8 +1,11 @@
import os import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import settings from app.config import settings
from app.api.v1.audio import router as audio_router from app.api.v1.audio import router as audio_router
from app.api.v1.tasks import router as tasks_router from app.api.v1.tasks import router as tasks_router
@@ -15,6 +18,8 @@ from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.ai_presets import router as ai_presets_router from app.api.v1.ai_presets import router as ai_presets_router
from app.api.v1.plugins import router as plugins_router from app.api.v1.plugins import router as plugins_router
from app.api.v1.media import router as media_router from app.api.v1.media import router as media_router
from app.api.v1.system import router as system_router
from app.api.v1.presets import router as presets_router
from app.core.auth import seed_admin from app.core.auth import seed_admin
from app.core.soundfont_scanner import SoundFontAutoScanner from app.core.soundfont_scanner import SoundFontAutoScanner
@@ -22,16 +27,32 @@ from app.core.soundfont_scanner import SoundFontAutoScanner
os.makedirs(settings.UPLOADS_DIR, exist_ok=True) os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
os.makedirs(settings.PROCESSED_DIR, exist_ok=True) os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
app = FastAPI(title="SonicForge API Engine") _SF_SCANNER_STOP = None
from fastapi.middleware.gzip import GZipMiddleware
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
seed_admin()
scanner = SoundFontAutoScanner()
global _SF_SCANNER_STOP
_SF_SCANNER_STOP = scanner.start_background(interval=30)
yield
# Shutdown
if _SF_SCANNER_STOP is not None:
_SF_SCANNER_STOP.set()
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
app.add_middleware(GZipMiddleware, minimum_size=500) app.add_middleware(GZipMiddleware, minimum_size=500)
# Auth is token/cookie based (no cookies required for CORS), so credentials are
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -39,7 +60,27 @@ app.add_middleware(
# Mount storage directory (must come before general /static mount) # Mount storage directory (must come before general /static mount)
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio") app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
# Mount app static files (js, css) # Mount app static files (js, css)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") # Dùng settings.APP_DIR (freeze-aware) thay vì os.path.dirname(__file__):
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
# nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý).
STATIC_DIR = os.path.join(settings.APP_DIR, "static")
# ⚠️ Fallback an toàn: nếu vì lý do nào đó static không nằm đúng chỗ (vd
# bundle thiếu file, chạy từ nơi khác), thử các vị trí khác; nếu vẫn không
# có → TỰ TẠO thư mục rỗng để app KHÔNG crash khi khởi động (lỗi "Directory
# does not exist" từ StaticFiles làm engine chết ngay lúc import — đã gặp).
if not os.path.isdir(STATIC_DIR):
for cand in [
os.path.join(getattr(sys, "_MEIPASS", ""), "app", "static"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static"),
]:
if cand and os.path.isdir(cand):
STATIC_DIR = cand
break
else:
try:
os.makedirs(STATIC_DIR, exist_ok=True)
except OSError:
pass
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Include routers # Include routers
@@ -54,23 +95,13 @@ app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"]) app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"]) app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
# Seed admin user on startup
@app.on_event("startup")
async def startup_seed_admin():
seed_admin()
@app.on_event("startup") @app.get("/health")
async def startup_convert_soundfonts(): async def health():
# SF2 -> SF3 conversion is disabled: the client FluidSynth WASM cannot decode return {"status": "ok"}
# Ogg Vorbis (SF3) samples, so converted SF3s would play silence. The download
# endpoint serves SF2 when available and converts SF3 -> SF2 on demand instead.
pass
@app.on_event("startup")
async def startup_sf_scanner():
scanner = SoundFontAutoScanner()
scanner.start_background(interval=30)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def get_index(): async def get_index():
@@ -78,14 +109,18 @@ async def get_index():
if not os.path.exists(index_path): if not os.path.exists(index_path):
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404) return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
with open(index_path, "r", encoding="utf-8") as file: with open(index_path, "r", encoding="utf-8") as file:
return HTMLResponse(content=file.read(), status_code=200) resp = HTMLResponse(content=file.read(), status_code=200)
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.get("/favicon.svg") @app.get("/favicon.svg")
async def get_favicon(): async def get_favicon():
import os
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg") favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
if os.path.exists(favicon_path): if os.path.exists(favicon_path):
from fastapi.responses import FileResponse
return FileResponse(favicon_path, media_type="image/svg+xml") return FileResponse(favicon_path, media_type="image/svg+xml")
return HTMLResponse(content="", status_code=404) return HTMLResponse(content="", status_code=404)
+1
View File
@@ -45,6 +45,7 @@
"id": { "type": "string" }, "id": { "type": "string" },
"name": { "type": "string" }, "name": { "type": "string" },
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] }, "type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
"color": { "type": ["string", "null"], "default": null },
"volume_db": { "type": "number", "default": 0.0 }, "volume_db": { "type": "number", "default": 0.0 },
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 }, "pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
"mute": { "type": "boolean", "default": false }, "mute": { "type": "boolean", "default": false },
+17 -1
View File
@@ -5,12 +5,18 @@ import time
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from app.config import settings from app.config import settings
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db") # Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
# dev database is never touched by the test suite.
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
def get_db_connection(): def get_db_connection():
os.makedirs(settings.STORAGE_DIR, exist_ok=True) os.makedirs(settings.STORAGE_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
# WAL improves concurrent read/write; FK enforcement makes quota/backup
# cleanup consistent when users are deleted.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn return conn
def init_db(): def init_db():
@@ -89,6 +95,16 @@ def init_db():
); );
""") """)
# Placeholder user for anonymous autosave: projects are saved with
# user_id='anonymous' when no token is present, so the FK must resolve.
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
if not cursor.fetchone():
import secrets as _secrets
cursor.execute("""
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
""", (_secrets.token_hex(32), time.time()))
conn.commit() conn.commit()
conn.close() conn.close()
+7437 -788
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+56 -1
View File
@@ -121,6 +121,57 @@ const AIGateway = (function() {
} }
}; };
// ai_midi_rearrange_specification.md §3 — Function Tool Schema hỗ trợ 2 mode:
// SIMILAR_VARIATION (biến tấu cùng độ dài) / EXTEND_CONTINUATION (viết tiếp
// các bar sau). AI trả gói dữ liệu có vị trí target trên Timeline.
const REARRANGE_EXTEND_TOOL_SPEC = {
type: 'function',
function: {
name: 'rearrange_or_extend_midi_melody',
description: 'Analyzes source MIDI melody data and returns either a variation (Variation) or continuation (Extend) based on user instructions.',
parameters: {
type: 'object',
properties: {
mode: {
type: 'string',
enum: ['SIMILAR_VARIATION', 'EXTEND_CONTINUATION'],
description: "Mode: 'SIMILAR_VARIATION' (new arrangement of equal length) or 'EXTEND_CONTINUATION' (writes subsequent bars)."
},
composition_title: {
type: 'string',
description: 'Short title describing the new melody style (e.g., Jazz Swing Variation, Epic Extension Part 2)'
},
target_start_bar: {
type: 'number',
description: 'Starting bar number for the generated notes on the Timeline'
},
target_duration_bars: {
type: 'number',
description: 'Total bar duration covered by the generated sequence'
},
soundfont_id: { type: 'string', default: 'generaluser_gs' },
soundfont_bank: { type: 'integer', default: 0 },
soundfont_program: { type: 'integer', default: 0 },
generated_notes: {
type: 'array',
description: 'Array of AI-generated MIDI notes.',
items: {
type: 'object',
properties: {
pitch: { type: 'integer', minimum: 0, maximum: 127 },
start_beat: { type: 'number', description: 'Starting beat position relative to beat 0.0 of the generated item' },
duration_beats: { type: 'number', minimum: 0.1 },
velocity: { type: 'number', minimum: 0.0, maximum: 1.0 }
},
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
}
}
},
required: ['mode', 'composition_title', 'target_start_bar', 'target_duration_bars', 'generated_notes']
}
}
};
const REARRANGE_SCENARIOS = [ const REARRANGE_SCENARIOS = [
{ {
id: 'arpeggio', id: 'arpeggio',
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
} else { } else {
response = await fetch(`${origin}/api/v1/ai/proxy`, { response = await fetch(`${origin}/api/v1/ai/proxy`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
},
body: JSON.stringify({ url, headers, body }) body: JSON.stringify({ url, headers, body })
}); });
} }
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
return { return {
DEFAULT_TOOLS, DEFAULT_TOOLS,
REARRANGE_TOOL_SPEC, REARRANGE_TOOL_SPEC,
REARRANGE_EXTEND_TOOL_SPEC,
REARRANGE_SCENARIOS, REARRANGE_SCENARIOS,
detectRearrangeScenario, detectRearrangeScenario,
buildRearrangeMessage, buildRearrangeMessage,
+41
View File
@@ -17,6 +17,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
async function apiRequest(endpoint, options = {}) { async function apiRequest(endpoint, options = {}) {
const url = `${window.API_BASE_URL}${endpoint}`; const url = `${window.API_BASE_URL}${endpoint}`;
const headers = { ...getAuthHeaders(), ...options.headers }; const headers = { ...getAuthHeaders(), ...options.headers };
// FormData: browser tự đặt Content-Type kèm boundary — không được ép JSON
if (options.body instanceof FormData) delete headers['Content-Type'];
const response = await fetch(url, { ...options, headers }); const response = await fetch(url, { ...options, headers });
if (response.status === 401) { if (response.status === 401) {
@@ -64,6 +66,42 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }), savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }), listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
// Runtime capabilities — frontend gọi lúc boot để biết môi trường
// (desktop Windows / docker headless) và bật/tắt tính năng
getCapabilities: () => apiRequest('/api/v1/system/capabilities', { method: 'GET' }),
// Định vị Carla.exe (bản portable zip không cài đặt/PATH) — lưu config
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
// Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
// do app spawn (gọi khi track chuyển từ VSTi sang instrument soundfont)
stopCarla: () => apiRequest('/api/v1/plugins/carla-stop', { method: 'POST', body: JSON.stringify({}) }),
// Kiểm tra Carla còn sống không (app-spawn) + cổng OSC — quyết định
// route MIDI item exclusive qua Carla hay fallback FluidSynth
carlaStatus: () => apiRequest('/api/v1/plugins/carla-status', { method: 'GET' }),
// Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime
carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }),
// Quick-render preview VSTi (âm thật = âm export, cùng code path)
previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }),
// Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard
listPresets: () => apiRequest('/api/v1/presets', { method: 'GET' }),
uploadPreset: (file, pluginHint) => {
const fd = new FormData();
fd.append('file', file);
if (pluginHint) fd.append('plugin_hint', pluginHint);
return apiRequest('/api/v1/presets/upload', { method: 'POST', body: fd });
},
deletePreset: (presetId) => apiRequest(`/api/v1/presets/${presetId}`, { method: 'DELETE' }),
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
// user yêu cầu dùng window explorer, không nhập tay
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
browseDir: (path) => apiRequest(`/api/v1/media/browse?path=${encodeURIComponent(path)}`, { method: 'GET' }),
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }), getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }), listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }), listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
@@ -77,6 +115,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }), cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }), renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }), deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
uploadSoundFont: async (file) => { uploadSoundFont: async (file) => {
const formData = new FormData(); const formData = new FormData();
+122
View File
@@ -0,0 +1,122 @@
// SonicForge Runtime service — phát hiện môi trường chạy (desktop Windows /
// docker headless) qua /api/v1/system/capabilities, bật/tắt tính năng theo đó.
// - data-runtime trên <html>: "desktop" | "headless"
// - data-carla="1": có Carla local (hiện nút "Mở trong Carla")
// - Phần tử có thuộc tính data-carla-only sẽ bị ẩn khi không có Carla local.
// - Thư viện preset (.vstpreset) cache trong SonicRuntime.presets — dùng cho
// dropdown gán preset vào track (Carla → pedalboard bridge).
window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null, presets: null };
(function () {
function getHeaders() {
const token = localStorage.getItem('sonic_token') || '';
return token ? { 'Authorization': 'Bearer ' + token } : {};
}
function load() {
return fetch(window.API_BASE_URL + '/api/v1/system/capabilities')
.then(function (r) { return r.json(); })
.then(function (data) {
var c = data && data.success ? data : { features: {} };
window.SonicRuntime.capabilities = c;
window.SonicRuntime.loaded = true;
var html = document.documentElement;
html.dataset.runtime = c.runtime || 'unknown';
html.dataset.platform = c.platform || '';
html.dataset.carla = (c.features && c.features.carla_local) ? '1' : '0';
if (c.features && c.features.carla_local === false) {
document.querySelectorAll('[data-carla-only]').forEach(function (el) {
el.style.display = 'none';
});
}
// Cache sẵn danh sách preset (static, ít thay đổi)
listPresets().catch(function () {});
return c;
})
.catch(function () { return null; });
}
function listPresets() {
if (window.SonicRuntime.presets) return Promise.resolve(window.SonicRuntime.presets);
return fetch(window.API_BASE_URL + '/api/v1/presets', { headers: getHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
window.SonicRuntime.presets = (d && d.presets) || [];
return window.SonicRuntime.presets;
})
.catch(function () { return []; });
}
window.SonicRuntime.load = load;
window.SonicRuntime.listPresets = listPresets;
window.SonicRuntime.refreshPresets = function () {
window.SonicRuntime.presets = null;
return window.SonicRuntime.listPresets();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', load);
} else {
load();
}
})();
// ── SonicCarlaMidi: cầu nối MIDI từ track ARM → Carla (OSC /Carla/0/note_*) ──
// Khi track dùng VSTi (synth_engine.type chứa 'vst') + được ARM + máy có Carla
// local → phím bấm trên piano roll / keybed được gửi tới Carla để phát realtime
// (Carla standalone bật OSC UDP mặc định cổng 22752).
window.SonicCarlaMidi = window.SonicCarlaMidi || {
shouldRoute: function (synthEngine, isArmed) {
try {
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
if (!c || !c.features || !c.features.carla_local) return false;
if (!isArmed) return false;
var se = synthEngine || {};
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
} catch (e) { return false; }
},
// Playback MIDI items: route khi track VSTi + Carla local (không cần ARM —
// user đã chủ động bấm Play trên item đó).
shouldRoutePlayback: function (synthEngine) {
try {
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
if (!c || !c.features || !c.features.carla_local) return false;
var se = synthEngine || {};
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
} catch (e) { return false; }
},
noteOn: function (channel, note, velocity) {
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
window.SonicAPI.carlaMidi({ event: 'note_on', note: note, velocity: velocity || 100, channel: channel || 0 }).catch(function () {});
},
noteOff: function (channel, note) {
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
window.SonicAPI.carlaMidi({ event: 'note_off', note: note, channel: channel || 0 }).catch(function () {});
},
// Bật note rồi tự tắt sau durMs (preview ngắn)
playNote: function (channel, note, velocity, durMs) {
this.noteOn(channel, note, velocity);
var self = this;
setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50);
},
// Tắt mọi note đang ngân trong Carla (note_off toàn pitch — dùng khi stop)
allNotesOff: function () {
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
for (var ch = 0; ch < 16; ch++) {
for (var n = 0; n < 128; n++) {
window.SonicAPI.carlaMidi({ event: 'note_off', note: n, channel: ch }).catch(function () {});
}
}
},
// Unload Carla bridge: tắt âm + terminate tiến trình Carla (app spawn).
// Gọi khi track chuyển từ VSTi → instrument soundfont để âm KHÔNG còn
// play qua Carla bridge nữa.
stopBridge: function () {
var self = this;
try { self.allNotesOff(); } catch (e) {}
if (window.SonicAPI && window.SonicAPI.stopCarla) {
return window.SonicAPI.stopCarla().catch(function () {});
}
return Promise.resolve();
}
};
+289 -25
View File
@@ -21,17 +21,38 @@
let _pendingNoteTimers = []; let _pendingNoteTimers = [];
let _loadedFonts = {}; let _loadedFonts = {};
let _activeOscillators = {}; let _activeOscillators = {};
// Fallback oscillator theo 'channel:pitch' — để stopNote/stopAll dừng được
// note khi thả phím (trước đây stopNote không dừng oscillator → âm kêu
// liên tục dù đã thả phím khi FluidSynth chưa sẵn sàng / font load fail).
let _activeOscillatorsByKey = {};
// Epoch chống cache channel STALE: tăng mỗi khi selectInstrument/load
// soundfont — channel cache chỉ được tin khi khớp epoch hiện tại, nếu
// không → note KẾ TIẾP luôn program_select lại (hết "chọn Synth String
// nghe Piano" do cache cũ skip program_select).
let _instrumentEpoch = 0;
let _gainNode = null; let _gainNode = null;
let _pendingOutputDestination = null;
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
let _scheduledNotes = []; let _scheduledNotes = [];
let _loadPromises = {}; let _loadPromises = {};
let _sfloadSeq = 0; let _sfloadSeq = 0;
// Note đang CHỜ load soundfont (chưa noteon) — 'ch:pitch' → số lần chờ.
// Đăng ký TRƯỚC khi load để stopNote/stopAll/panic hủy được note này;
// trước đây note deferred bắn TRỄ sau khi thả phím / sau Stop → âm loop
// không dừng (keybed preview âm soundfont).
let _pendingNoteOns = {};
// Tăng mỗi lần stopAll/panic — deferred doNote của generation cũ thành no-op.
let _noteGeneration = 0;
const getCtx = function () { const getCtx = function () {
if (_audioCtx) { if (_audioCtx) {
if (!_gainNode) { if (!_gainNode) {
_gainNode = _audioCtx.createGain(); _gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_outputDestination);
} }
return _audioCtx; return _audioCtx;
} }
@@ -40,7 +61,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = ctx.createGain(); _gainNode = ctx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
_gainNode.connect(_outputDestination);
} }
return ctx; return ctx;
} }
@@ -53,7 +75,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = window.__sharedAudioCtx.createGain(); _gainNode = window.__sharedAudioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.__sharedAudioCtx.destination); _outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
_gainNode.connect(_outputDestination);
} }
return window.__sharedAudioCtx; return window.__sharedAudioCtx;
}; };
@@ -61,6 +84,41 @@
const SonicSF = { const SonicSF = {
loadedFonts: _loadedFonts, loadedFonts: _loadedFonts,
// Route the shared FluidSynth output through a per-track node (e.g. the
// track's gainNode) so the track's FX chain / fader / pan affect the
// soundfont instrument. Pass null to restore the default master-bus route.
setOutputDestination: function (node) {
try {
if (_gainNode) {
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
// làm ScriptProcessor xuất buffer uninitialized → NaN →
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
// ngay sau setOutputDestination lần 2).
if (dest === _outputDestination) return;
_gainNode.disconnect();
_gainNode.connect(dest);
// Reconnect VU analyser tap (app.jsx VU tick gắn __vuAnalyser
// trên _gainNode): disconnect() không đối số ngắt MỌI kết
// nối kể cả analyser → sfAudioPeak = 0 mãi → track VU MIDI
// không nhảy khi có âm (user bug 06:35).
if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);
_outputDestination = dest;
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
} else {
_pendingOutputDestination = node || null;
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
}
} catch (e) {
console.warn('[SonicSF] setOutputDestination error:', e);
}
},
getOutputNode: function () {
return _gainNode;
},
init: async function (audioContext) { init: async function (audioContext) {
if (_initialized && _fluidModule) return; if (_initialized && _fluidModule) return;
if (_initPromise) return _initPromise; if (_initPromise) return _initPromise;
@@ -81,7 +139,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = _audioCtx.createGain(); _gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_outputDestination);
} }
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate); console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
@@ -117,7 +176,7 @@
// expected notice when a soundfont simply has no // expected notice when a soundfont simply has no
// preset for a bank (e.g. bank 128 on a melodic-only // preset for a bank (e.g. bank 128 on a melodic-only
// font) — the note is just silent, not an error. // font) — the note is just silent, not an error.
if (msg && msg.indexOf('No preset found on channel') !== -1) return; if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
console.warn('[FluidSynth:err]', msg); console.warn('[FluidSynth:err]', msg);
} }
}); });
@@ -168,17 +227,31 @@
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2); var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
var lp = _fluidModule._malloc(spBufSz * 4); var lp = _fluidModule._malloc(spBufSz * 4);
var rp = _fluidModule._malloc(spBufSz * 4); var rp = _fluidModule._malloc(spBufSz * 4);
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
// → master chain "state is bad" → CÂM + stuck. Theo dõi
// buffer + re-malloc khi đổi.
var _heapBufRef = _fluidModule.HEAPU8.buffer;
spn.onaudioprocess = function (e) { spn.onaudioprocess = function (e) {
var left = e.outputBuffer.getChannelData(0); var left = e.outputBuffer.getChannelData(0);
var right = e.outputBuffer.getChannelData(1); var right = e.outputBuffer.getChannelData(1);
var sz = left.length; var sz = left.length;
try { try {
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
lp = _fluidModule._malloc(sz * 4);
rp = _fluidModule._malloc(sz * 4);
_heapBufRef = _fluidModule.HEAPU8.buffer;
}
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1); _fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
var hf = _fluidModule.HEAPF32; var hf = _fluidModule.HEAPF32;
var lpb = lp >> 2, rpb = rp >> 2; var lpb = lp >> 2, rpb = rp >> 2;
for (var si = 0; si < sz; si++) { for (var si = 0; si < sz; si++) {
left[si] = hf[lpb + si]; // NaN sweep: mẫu NaN/Inf → 0 (chain biquad
right[si] = hf[rpb + si]; // KHÔNG BAO GIỜ được nhận NaN → không state-bad).
var L = hf[lpb + si], R = hf[rpb + si];
left[si] = isFinite(L) ? L : 0;
right[si] = isFinite(R) ? R : 0;
} }
} catch (er) {} } catch (er) {}
}; };
@@ -249,6 +322,12 @@
_doLoadSoundFont: async function (sfId) { _doLoadSoundFont: async function (sfId) {
try { try {
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
// thành rác trong khi channel state vẫn trỏ tới → program_select
// bị skip (progAlreadySet) → noteon trên handle đã unload →
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
var cache = window.SonicSFStorage; var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null; var buf = cache ? await cache.getBuffer(sfId) : null;
if (buf) { if (buf) {
@@ -258,6 +337,7 @@
_sfHandleMap.set(sfId, cachedOk); _sfHandleMap.set(sfId, cachedOk);
_currentSfId = sfId; _currentSfId = sfId;
_loadedFonts[sfId] = true; _loadedFonts[sfId] = true;
_instrumentEpoch++;
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk); console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
return true; return true;
} }
@@ -269,8 +349,15 @@
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now(); var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url); var resp = await fetch(url);
if (!resp.ok) { if (!resp.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId); // Fallback: font bundled theo deployment (static/soundfonts —
return false; // serve qua /soundfonts/{f} — catalog default-soundfonts).
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
var resp2 = await fetch(url2);
if (!resp2.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId);
return false;
}
resp = resp2;
} }
buf = await resp.arrayBuffer(); buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf); if (cache) await cache.saveBuffer(sfId, buf);
@@ -286,6 +373,7 @@
_sfHandleMap.set(sfId, sfHandle); _sfHandleMap.set(sfId, sfHandle);
_currentSfId = sfId; _currentSfId = sfId;
_loadedFonts[sfId] = true; _loadedFonts[sfId] = true;
_instrumentEpoch++;
console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle); console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle);
return true; return true;
} catch (e) { } catch (e) {
@@ -315,9 +403,17 @@
_engineChMap[engKey] = channel; _engineChMap[engKey] = channel;
} }
var sfHandle = _sfHandleMap.get(sfId); var sfHandle = _sfHandleMap.get(sfId);
// ⚠️ FIX âm sai instrument: tăng epoch → channel cache cũ thành
// stale → note kế tiếp LUÔN program_select lại (hết "chọn Synth
// String nghe Piano" do cache cũ skip).
_instrumentEpoch++;
if (sfHandle !== undefined) { if (sfHandle !== undefined) {
try { try {
_fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program); var _sr = _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program);
if (_sr !== 0) {
// Preset không tồn tại ở bank/program này → thử bank 0
try { _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, 0, program); } catch (e2) {}
}
} catch (e) {} } catch (e) {}
} else { } else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {} try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {}
@@ -327,6 +423,7 @@
_channels[channel].program = program; _channels[channel].program = program;
_channels[channel].isPercussion = (bank === 128); _channels[channel].isPercussion = (bank === 128);
_channels[channel].sfId = sfId; _channels[channel].sfId = sfId;
_channels[channel]._epoch = _instrumentEpoch;
}, },
controllerChange: function (channel, controller, value) { controllerChange: function (channel, controller, value) {
@@ -398,8 +495,28 @@
stopNote: function (channel, pitch) { stopNote: function (channel, pitch) {
if (channel < 0 || channel > 15) return; if (channel < 0 || channel > 15) return;
var key = channel + ':' + pitch;
// Hủy note đang CHỜ LOAD soundfont (chưa noteon) — trước đây note
// này vẫn bắn TRỄ sau khi thả phím → âm loop không dừng được.
if (_pendingNoteOns[key]) delete _pendingNoteOns[key];
// ⚠️ FIX: dừng fallback oscillator theo 'channel:pitch' — trước đây
// stopNote chỉ noteoff FluidSynth, KHÔNG dừng oscillator (khi WASM
// chưa sẵn sàng / font load fail → _playNoteFallback) → âm kêu liên
// tục mặc dù đã thả phím (không xử lí noteoff).
var _oscIds = _activeOscillatorsByKey[key];
if (_oscIds && _oscIds.length) {
var _nowT = getCtx().currentTime;
_oscIds.slice().forEach(function (oid) {
var entry = _activeOscillators[oid];
if (entry) {
try { entry.gain.gain.cancelScheduledValues(_nowT); entry.gain.gain.setValueAtTime(0, _nowT); } catch (e) {}
try { entry.osc.stop(_nowT); } catch (e) {}
delete _activeOscillators[oid];
}
});
delete _activeOscillatorsByKey[key];
}
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
var key = channel + ':' + pitch;
var mappedChs = _activeNotes[key]; var mappedChs = _activeNotes[key];
if (mappedChs === undefined) mappedChs = [channel]; if (mappedChs === undefined) mappedChs = [channel];
for (var i = 0; i < mappedChs.length; i++) { for (var i = 0; i < mappedChs.length; i++) {
@@ -429,10 +546,11 @@
}, },
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) { _playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60)); var parsedPitch = parseInt(note);
var midiVel = Math.min(127, Math.max(1, Math.floor( var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100 var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
))); if (isNaN(rawVel)) rawVel = 100;
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
var _origChannel = channel; var _origChannel = channel;
var usedBank = 0, usedProg = 0; var usedBank = 0, usedProg = 0;
if (synthEngine) { if (synthEngine) {
@@ -465,8 +583,10 @@
var self = this; var self = this;
var doNote = function () { var doNote = function () {
try { try {
var finalBank = usedBank; var finalBank = parseInt(usedBank);
var finalProg = usedProg; if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
var finalProg = parseInt(usedProg);
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined; var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
var cachedCh = _channels[ch]; var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is // The note's own synth engine (track instrument) is
@@ -475,16 +595,49 @@
// re-picked instrument plays the wrong soundfont. Without an // re-picked instrument plays the wrong soundfont. Without an
// engine, fall back to the soundfont configured on the channel. // engine, fall back to the soundfont configured on the channel.
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) { if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
finalBank = cachedCh.bank; finalBank = parseInt(cachedCh.bank) || 0;
finalProg = cachedCh.program; finalProg = parseInt(cachedCh.program) || 0;
finalSfId = cachedCh.sfId; finalSfId = cachedCh.sfId;
} }
// Ensure the soundfont is actually loaded before the note plays. // Ensure the soundfont is actually loaded before the note plays.
// Quick instrument pick on a track does not pre-load it, so load // Quick instrument pick on a track does not pre-load it, so load
// lazily here and retry the note once the font is ready. // lazily here and retry the note once the font is ready.
if (finalSfId && !_sfHandleMap.has(finalSfId)) { if (finalSfId && !_sfHandleMap.has(finalSfId)) {
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
// — note chạy thẳng fallback để CÓ ÂM.
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
if (Date.now() - _lastFail < 10000) {
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
return;
}
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
// Đăng ký note CHỜ LOAD trước khi load — để stopNote (thả
// phím) / stopAll / panic hủy được note này. Trước đây
// note deferred vẫn bắn TRỄ sau khi thả phím hoặc sau
// Stop → âm loop không dừng với preview MIDI Keyboard.
var _pendKey = ch + ':' + midiPitch;
var _gen = _noteGeneration;
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
self.loadSoundFont(finalSfId).then(function (ok) { self.loadSoundFont(finalSfId).then(function (ok) {
if (ok) doNote(); // stopAll/panic chạy trong lúc load → generation đổi → bỏ
if (_gen !== _noteGeneration) return;
// stopNote (thả phím) đã hủy → KHÔNG bắn note trễ nữa
var _pend = _pendingNoteOns[_pendKey];
if (_pend) {
_pendingNoteOns[_pendKey] = _pend - 1;
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
} else {
return;
}
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
if (ok) {
doNote();
} else {
// Font KHÔNG tải được (404/format) → KHÔNG drop note
// câm lặng ("bỏ qua WASM") — fallback oscillator.
_sfLoadFailAt[finalSfId] = Date.now();
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
}
}); });
return; return;
} }
@@ -492,12 +645,49 @@
// instrument for each item regardless of processing order. // instrument for each item regardless of processing order.
// Skip if the channel already has this exact instrument (avoids // Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches). // per-note soundfont reloads that cause audible crackle/glitches).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId; // ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
// không → vẫn program_select lại (tránh dùng handle đã unload).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
&& cachedCh._epoch === _instrumentEpoch
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
if ((synthEngine || program !== undefined) && !progAlreadySet) { if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined; var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
if (sfHandle !== undefined) { if (sfHandle !== undefined) {
try { try {
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg); // Percussion (bank 128): tìm preset HỢP LỆ trong
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
// cache theo sfId. Trước đây chỉ thử 4 preset cố
// định → font không có → cache channel = (128,0)
// INVALID → note sau skip re-select (progAlreadySet)
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
if (finalBank === 128) {
var _vKey = finalSfId || ('h' + sfHandle);
if (_validPercCache[_vKey] === undefined) {
var _found = null;
for (var _b = 0; _b < 2 && !_found; _b++) {
var _bk = _b === 0 ? 128 : 0;
for (var _p = 0; _p < 128 && !_found; _p++) {
try {
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
_found = [_bk, _p];
}
} catch (e) {}
}
}
_validPercCache[_vKey] = _found;
}
if (_validPercCache[_vKey]) {
finalBank = _validPercCache[_vKey][0];
finalProg = _validPercCache[_vKey][1];
}
}
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
if (_selRet !== 0 && finalBank !== 0) {
// Preset không tồn tại ở bank này → thử bank 0
// (hết "noteon preset rỗng = âm sai/không đúng")
try { _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, 0, finalProg); } catch (e2) {}
}
} catch (e) {} } catch (e) {}
} else { } else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {} try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
@@ -507,7 +697,9 @@
_channels[ch].bank = finalBank; _channels[ch].bank = finalBank;
_channels[ch].program = finalProg; _channels[ch].program = finalProg;
_channels[ch].sfId = finalSfId; _channels[ch].sfId = finalSfId;
_channels[ch]._epoch = _instrumentEpoch;
} }
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel); _fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch; var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = []; if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
@@ -538,6 +730,45 @@
} }
}, },
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
// tự động → ngân vô hạn → VU master nhảy dù không play).
panic: function () {
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
_scheduledNotes = [];
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
_noteGeneration++;
_pendingNoteOns = {};
if (_initialized && _fluidModule) {
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
// thể không có trong WASM exports (catch nuốt → notes kẹt).
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
try {
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
} catch (e) {}
}
// Dừng fallback oscillator (FluidSynth chưa sẵn sàng / font fail)
try {
var _pctx = getCtx();
var _pnow = _pctx.currentTime;
Object.values(_activeOscillators).forEach(function (entry) {
try { if (entry.gain) { entry.gain.gain.cancelScheduledValues(_pnow); entry.gain.gain.setValueAtTime(0, _pnow); } } catch (e) {}
try { if (entry.osc) entry.osc.stop(_pnow); } catch (e) {}
});
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
_activeOscillatorsByKey = {};
} catch (e) {}
_activeNotes = {};
},
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) { _playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
var ctx = getCtx(); var ctx = getCtx();
var freq = 440 * Math.pow(2, (note - 69) / 12); var freq = 440 * Math.pow(2, (note - 69) / 12);
@@ -554,7 +785,11 @@
var oscType = 'triangle'; var oscType = 'triangle';
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25; var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
var prog = program !== undefined ? parseInt(program) : 0; var prog = program !== undefined ? parseInt(program) : 0;
if (channel !== undefined && channel >= 0 && channel < 16) { // CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
// truyền — trước đây override program của track bằng cache channel
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
// nhạc cụ của track TRƯỚC).
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
prog = _channels[channel].program || prog; prog = _channels[channel].program || prog;
} }
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; } if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
@@ -572,7 +807,8 @@
osc.frequency.setValueAtTime(freq, 0); osc.frequency.setValueAtTime(freq, 0);
var startAt = startTime !== undefined ? startTime : ctx.currentTime; var startAt = startTime !== undefined ? startTime : ctx.currentTime;
var durSec = durationMs / 1000; var durSec = durationMs / 1000;
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
if (isNaN(vel)) vel = 0.8;
var targetGain = vel * volFactor; var targetGain = vel * volFactor;
noteGain.gain.setValueAtTime(0, startAt); noteGain.gain.setValueAtTime(0, startAt);
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime); noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
@@ -587,12 +823,38 @@
osc.stop(stopAt); osc.stop(stopAt);
var oscId = note + '_' + Date.now() + '_' + Math.random(); var oscId = note + '_' + Date.now() + '_' + Math.random();
_activeOscillators[oscId] = { osc: osc, gain: noteGain }; _activeOscillators[oscId] = { osc: osc, gain: noteGain };
setTimeout(function () { delete _activeOscillators[oscId]; }, (stopAt - ctx.currentTime) * 1000 + 100); // Đăng ký theo 'channel:pitch' để stopNote dừng được khi thả phím
var oscKey = (channel !== undefined && channel >= 0 && channel < 16) ? (channel + ':' + note) : null;
if (oscKey) {
if (!_activeOscillatorsByKey[oscKey]) _activeOscillatorsByKey[oscKey] = [];
_activeOscillatorsByKey[oscKey].push(oscId);
}
setTimeout(function () {
delete _activeOscillators[oscId];
if (oscKey && _activeOscillatorsByKey[oscKey]) {
var _oi = _activeOscillatorsByKey[oscKey].indexOf(oscId);
if (_oi >= 0) _activeOscillatorsByKey[oscKey].splice(_oi, 1);
if (_activeOscillatorsByKey[oscKey].length === 0) delete _activeOscillatorsByKey[oscKey];
}
}, (stopAt - ctx.currentTime) * 1000 + 100);
return osc; return osc;
}, },
stopAll: function () { stopAll: function () {
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành
// no-op sau Stop (âm loop không dừng khi preview MIDI Keyboard).
_noteGeneration++;
_pendingNoteOns = {};
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
// phòng all_notes_off không có trong WASM exports.
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
for (var ch = 0; ch < 16; ch++) { for (var ch = 0; ch < 16; ch++) {
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {} try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
} }
@@ -611,6 +873,8 @@
} catch (e) {} } catch (e) {}
}); });
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; }); Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
_activeOscillatorsByKey = {};
_activeNotes = {};
}, },
saveToIndexedDB: async function (name, arrayBuffer) { saveToIndexedDB: async function (name, arrayBuffer) {
+20 -2
View File
@@ -60,11 +60,13 @@
} }
let autoSaveTimer = null; let autoSaveTimer = null;
let lastGetProjectStateCallback = null;
function scheduleTempAutoSave(getProjectStateCallback) { function scheduleTempAutoSave(getProjectStateCallback) {
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
if (autoSaveTimer) clearTimeout(autoSaveTimer); if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(async () => { autoSaveTimer = setTimeout(async () => {
try { try {
const state = getProjectStateCallback(); const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
if (!state || (!state.tracks && !state.main_session)) return; if (!state || (!state.tracks && !state.main_session)) return;
const dataJson = JSON.stringify(state); const dataJson = JSON.stringify(state);
localStorage.setItem('sonic_temp_project', dataJson); localStorage.setItem('sonic_temp_project', dataJson);
@@ -76,10 +78,26 @@
} }
}, 2000); }, 2000);
} }
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
async function flushTempAutoSave() {
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
try {
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
if (!state || (!state.tracks && !state.main_session)) return;
const dataJson = JSON.stringify(state);
localStorage.setItem('sonic_temp_project', dataJson);
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
}
} catch (e) {
console.warn("Flush temp project warning:", e);
}
}
window.SonicStorage = { window.SonicStorage = {
exportProjectToSFS, exportProjectToSFS,
importProjectFromSFSFile, importProjectFromSFSFile,
scheduleTempAutoSave scheduleTempAutoSave,
flushTempAutoSave
}; };
})(); })();
+1
View File
@@ -0,0 +1 @@
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
+121 -28
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,30 +12,124 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
celery_app = Celery( # ──────────────────────────────────────────────────────────────────────────
"audio_tasks", # Task layer 2 che do:
broker=settings.CELERY_BROKER_URL, # - Server/Docker: celery day du (broker Redis) — dung nhu cu.
backend=settings.CELERY_RESULT_BACKEND # - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
) # (thread nen + registry dict), API contract GIONG het (.delay() tra
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
# ──────────────────────────────────────────────────────────────────────────
try:
from celery import Celery
HAS_CELERY = True
except Exception: # pragma: no cover - frozen desktop slim build
Celery = None
HAS_CELERY = False
celery_app.conf.update( if HAS_CELERY:
task_serializer="json", celery_app = Celery(
accept_content=["json"], "audio_tasks",
result_serializer="json", broker=settings.CELERY_BROKER_URL,
timezone="UTC", backend=settings.CELERY_RESULT_BACKEND
enable_utc=True, )
)
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ── celery_app.conf.update(
celery_app.conf.beat_schedule = { task_serializer="json",
"cleanup-expired-files-every-hour": { accept_content=["json"],
"task": "app.tasks.worker.cleanup_expired_files_task", result_serializer="json",
"schedule": 3600.0, # Chạy mỗi giờ timezone="UTC",
}, enable_utc=True,
} )
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
if os.getenv("SF_DESKTOP") == "1":
celery_app.conf.update(
task_always_eager=True,
task_eager_propagates=True,
broker_url="memory://",
result_backend="cache+memory://",
)
# ── Lich trinh tu dong don dep file het han (Week 5) ──
celery_app.conf.beat_schedule = {
"cleanup-expired-files-every-hour": {
"task": "app.tasks.worker.cleanup_expired_files_task",
"schedule": 3600.0, # Chay moi gio
},
}
else:
celery_app = None
# Registry in-process cho desktop slim: task_id -> {"status", "result"/"error"}
_results = {}
@celery_app.task def _task(fn):
"""Wrapper: celery task (server) hoac in-process task (desktop slim)."""
if HAS_CELERY:
return celery_app.task(fn)
return _InProcessTask(fn)
class _InProcessTask:
"""Task chay tren thread nen, ket qua luu vao registry dict — dung cho
bundle desktop khong kem celery (tiet kiem ~40MB)."""
def __init__(self, fn):
self._fn = fn
def delay(self, *args, **kwargs):
import threading
tid = uuid.uuid4().hex
_results[tid] = {"status": "PENDING"}
def _run():
try:
result = self._fn(*args, **kwargs)
_results[tid] = {"status": "SUCCESS", "result": result}
except Exception as e: # noqa: BLE001 - bao loi day du cho UI
logger.exception("In-process task %s failed", tid)
_results[tid] = {"status": "FAILURE", "error": str(e)}
threading.Thread(target=_run, daemon=True, name=f"task-{tid[:8]}").start()
return _SimpleAsyncResult(tid)
class _SimpleAsyncResult:
"""Giong celery.result.AsyncResult ve mat API cho desktop slim."""
def __init__(self, task_id):
self.task_id = task_id
@property
def id(self):
"""Giong celery.result.AsyncResult.id — audio.py dung task.id."""
return self.task_id
@property
def status(self):
return _results.get(self.task_id, {}).get("status", "PENDING")
@property
def result(self):
return _results.get(self.task_id, {}).get("result")
def ready(self):
return _results.get(self.task_id, {}).get("status") in ("SUCCESS", "FAILURE")
def successful(self):
return self.status == "SUCCESS"
def get_task_result(task_id):
"""Tra AsyncResult (celery) hoac _SimpleAsyncResult (desktop slim)."""
if HAS_CELERY:
from celery.result import AsyncResult
return AsyncResult(task_id, app=celery_app)
return _SimpleAsyncResult(task_id)
@_task
def analyze_audio_task(file_id: str): def analyze_audio_task(file_id: str):
file_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = os.path.join(settings.UPLOADS_DIR, file_id)
if not os.path.exists(file_path): if not os.path.exists(file_path):
@@ -44,7 +137,7 @@ def analyze_audio_task(file_id: str):
return analyze_audio(file_path) return analyze_audio(file_path)
@celery_app.task @_task
def analyze_ai_task(file_id: str, api_base_url: str = None, def analyze_ai_task(file_id: str, api_base_url: str = None,
model: str = "deepseek-chat"): model: str = "deepseek-chat"):
""" """
@@ -68,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
) )
@celery_app.task @_task
def edit_audio_task(config: dict): def edit_audio_task(config: dict):
file_id = config.get("file_id") file_id = config.get("file_id")
@@ -88,7 +181,7 @@ def edit_audio_task(config: dict):
} }
@celery_app.task @_task
def export_audio_task(file_id: str, format: str = "wav", def export_audio_task(file_id: str, format: str = "wav",
sample_rate: int = 44100, bit_depth: int = 16): sample_rate: int = 44100, bit_depth: int = 16):
""" """
@@ -121,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
return result return result
@celery_app.task @_task
def mix_multitrack_task(session_config: dict): def mix_multitrack_task(session_config: dict):
""" """
Task xử hòa âm đa kênh (Multitrack Mixdown). Task xử hòa âm đa kênh (Multitrack Mixdown).
@@ -174,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
return result return result
@celery_app.task @_task
def process_multitrack_session_task(session_config: dict): def process_multitrack_session_task(session_config: dict):
""" """
Task xử toàn bộ session với nhiều tracks clips. Task xử toàn bộ session với nhiều tracks clips.
@@ -270,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
return result return result
@celery_app.task @_task
def cleanup_expired_files_task(max_age_hours: int = 24): def cleanup_expired_files_task(max_age_hours: int = 24):
""" """
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5). Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
@@ -305,7 +398,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
} }
@celery_app.task @_task
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100): def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
""" """
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md. Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
+61 -6
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,19 +31,20 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script> <script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/api.js?v=202607271016"></script> <script src="/static/js/services/runtime.js?v=202608101800"></script>
<script src="/static/js/services/api.js?v=202608101800"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script> <script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202607271016"></script> <script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script> <script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script> <script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script> <script src="/static/js/services/aiGateway.js?v=202608037200"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script> <script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script> <script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script> <script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script> <script src="/static/js/app.precompiled.js?v=202608101800" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
@@ -33,8 +53,43 @@
--top-bar-height: 80px; --top-bar-height: 80px;
--status-bar-height: 25px; --status-bar-height: 25px;
--panel-border-color: #2a2a2a; --panel-border-color: #2a2a2a;
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
--sf-bg: #1e1e1e;
--sf-panel: #262626;
--sf-header: #2e2e2e;
--sf-border: #181818;
--sf-accent: #00ffcc;
} }
/* ── THEME presets (Tools → Preferences) ── */
html[data-theme="dark"] {
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
}
html[data-theme="midnight"] {
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
}
html[data-theme="forest"] {
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
}
html[data-theme="violet"] {
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
}
html[data-theme="graphite"] {
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
}
/* Áp theme lên các vùng chính của app shell */
.daw-app-shell { background-color: var(--sf-bg) !important; }
.daw-panel { background-color: var(--sf-panel) !important; }
.daw-header { background-color: var(--sf-header) !important; }
body { background-color: var(--sf-bg) !important; }
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
html[data-btnfont="sm"] button { font-size: 10px !important; }
html[data-btnfont="md"] button { font-size: 12px !important; }
html[data-btnfont="lg"] button { font-size: 14px !important; }
body { body {
background-color: #1a1a1a; background-color: #1a1a1a;
color: #c0c0c0; color: #c0c0c0;
+20
View File
@@ -0,0 +1,20 @@
import os
import sys
sys.path.insert(0, "/app")
try:
import fluidsynth
print("fluidsynth import successful.")
fl = fluidsynth.Synth()
# Try to load a pre-existing system SF3
sf3_path = "/opt/daw_engine/soundfonts/Equinox_Grand_Pianos.sf3"
print(f"Checking if {sf3_path} exists: {os.path.exists(sf3_path)}")
if os.path.exists(sf3_path):
h = fl.sfload(sf3_path)
print(f"Loaded {sf3_path}, handle: {h}")
else:
print("Equinox_Grand_Pianos.sf3 not found.")
except Exception as e:
print(f"Failed: {e}")
+66
View File
@@ -0,0 +1,66 @@
import os
import sys
import logging
# Ensure app is in path
sys.path.insert(0, "/app")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test_sf_convert")
from app.core.soundfont_converter import SoundFontConverter
def test():
sf2_dir = "/app/app/storage/soundfonts"
sf2_files = [os.path.join(sf2_dir, f) for f in os.listdir(sf2_dir) if f.endswith(".sf2") and "_decomp" not in f]
if not sf2_files:
logger.error("No SF2 files found in /app/app/storage/soundfonts")
return
sf2_path = sf2_files[0]
logger.info(f"Testing with SF2 file: {sf2_path}")
converter = SoundFontConverter()
# Check ffmpeg encoder support
has_ogg = converter._check_ffmpeg_ogg()
logger.info(f"ffmpeg with libvorbis available: {has_ogg}")
# Convert SF2 -> SF3
sf3_path = sf2_path.replace(".sf2", ".sf3")
if os.path.exists(sf3_path):
os.remove(sf3_path)
logger.info("Converting SF2 -> SF3...")
result_path = converter.convert_sf2_to_sf3(sf2_path)
logger.info(f"Result path from convert_sf2_to_sf3: {result_path}")
if result_path.endswith(".sf3"):
logger.info(f"SF3 file exists: {os.path.exists(sf3_path)}")
if os.path.exists(sf3_path):
logger.info(f"SF3 size: {os.path.getsize(sf3_path)} bytes")
# Verify if it plays audio
plays = converter._sf3_plays_audio(sf3_path)
logger.info(f"SF3 plays audio (pyfluidsynth verify): {plays}")
# Now test decompression back to SF2
decomp_sf2 = sf3_path.replace(".sf3", "_decomp.sf2")
if os.path.exists(decomp_sf2):
os.remove(decomp_sf2)
logger.info("Decompressing SF3 -> SF2...")
try:
decomp_result = converter.sf3_to_sf2(sf3_path, decomp_sf2)
logger.info(f"Decompress result path: {decomp_result}")
if os.path.exists(decomp_sf2):
logger.info(f"Decompressed SF2 size: {os.path.getsize(decomp_sf2)} bytes")
# Check if it plays
decomp_plays = converter._sf3_plays_audio(decomp_sf2)
logger.info(f"Decompressed SF2 plays audio: {decomp_plays}")
except Exception as e:
logger.error(f"Decompression failed: {e}", exc_info=True)
else:
logger.warning("Conversion did not produce an SF3 path.")
if __name__ == "__main__":
test()
+14
View File
@@ -0,0 +1,14 @@
// Rebuild app.precompiled.js from app.jsx using @babel/standalone (avoids the
// Babel 8 ESM-only CLI conflict). Mirrors package.json's build script:
// babel app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js
import * as Babel from '@babel/standalone';
import { readFileSync, writeFileSync } from 'fs';
const src = readFileSync('app/static/js/app.jsx', 'utf8');
const out = Babel.transform(src, {
presets: ['react'],
filename: 'app.jsx',
sourceType: 'script',
}).code;
writeFileSync('app/static/js/app.precompiled.js', out);
console.log('BUILD OK', out.length, 'bytes');
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# build_linux.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (deb + AppImage)
# Chay tren Linux: bash build_linux.sh
# Yeu cau: python3, pip, node/npm, rust/cargo, webkit2gtk-4.1, libappindicator,
# librsvg (xem README / DISTRIBUTION_PLAN.md)
set -euo pipefail
cd "$(dirname "$0")"
echo "== [1/6] Python dependencies =="
# PyInstaller tren Linux can objdump (binutils). May build that phai co:
# sudo apt-get install -y binutils
python3 -m pip install --upgrade pip >/dev/null
python3 -m pip install -r requirements.txt pyinstaller
echo "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
npm install --no-audit --no-fund
if [ ! -d "node_modules/@babel/standalone" ]; then
echo "Thieu @babel/standalone - dang cai them..."
npm install @babel/standalone --no-audit --no-fund
fi
node build.mjs
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
python3 -m PyInstaller engine.spec --clean --noconfirm
echo "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
if [ ! -f "dist/daw_engine/daw_engine" ]; then
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
exit 1
fi
rm -rf src-tauri/resources/daw_engine
mkdir -p src-tauri/resources/daw_engine
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
echo "== [5/6] Kiem tra resources truoc khi tauri build =="
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
exit 1
fi
echo "== [6/6] Tauri build (deb + AppImage) =="
npm install -D @tauri-apps/cli --no-audit --no-fund
npx tauri build
echo ""
echo "== DONE =="
echo " deb : src-tauri/target/release/bundle/deb/sonicforge-daw_1.0.0_amd64.deb"
echo " AppImage: src-tauri/target/release/bundle/appimage/SonicForgeDAW_1.0.0_amd64.AppImage"
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# build_macos.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (.app + .dmg)
# Chay tren macOS: bash build_macos.sh
# LUU Y: macOS yeu cau codesign + notarize truoc khi phat hanh ra ngoai
# (Gatekeeper). Xem DISTRIBUTION_PLAN.md.
set -euo pipefail
cd "$(dirname "$0")"
echo "== [1/6] Python dependencies =="
python3 -m pip install --upgrade pip >/dev/null
python3 -m pip install -r requirements.txt pyinstaller
echo "== [2/6] Frontend bundle =="
npm install --no-audit --no-fund
if [ ! -d "node_modules/@babel/standalone" ]; then
npm install @babel/standalone --no-audit --no-fund
fi
node build.mjs
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
python3 -m PyInstaller engine.spec --clean --noconfirm
echo "== [3.5/6] Verify bundle contents =="
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
if [ ! -f "dist/daw_engine/daw_engine" ]; then
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
exit 1
fi
rm -rf src-tauri/resources/daw_engine
mkdir -p src-tauri/resources/daw_engine
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
echo "== [5/6] Kiem tra resources =="
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
exit 1
fi
echo "== [6/6] Tauri build (dmg) =="
npm install -D @tauri-apps/cli --no-audit --no-fund
npx tauri build
echo ""
echo "== DONE =="
echo " dmg: src-tauri/target/release/bundle/dmg/SonicForgeDAW_1.0.0_x64.dmg"
echo " (Codesign/notarize: codesign --deep -s \"Developer ID Application: ...\" "
echo " src-tauri/target/release/bundle/macos/SonicForgeDAW.app ; xcrun notarytool submit ...)"
+95
View File
@@ -0,0 +1,95 @@
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI)
# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
# LUU Y: file nay chi dung ky tu ASCII (khong dau, khong em-dash) - PowerShell 5.1
# doc .ps1 khong BOM theo ANSI, ky tu Unicode bi hong -> "String is missing terminator".
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
Write-Host "== [1/6] Python dependencies =="
python -m pip install --upgrade pip
python -m pip install -r requirements.txt pyinstaller pywin32
Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
npm install
# Dam bao @babel/standalone co (build.mjs import truc tiep - da gap
# ERR_MODULE_NOT_FOUND tren may Windows khi package.json cu thieu dep).
if (-not (Test-Path "node_modules\@babel\standalone")) {
Write-Host "Thieu @babel/standalone - dang cai them..."
npm install @babel/standalone --no-audit --no-fund
}
if (-not (Test-Path "node_modules\@babel\standalone")) {
Write-Host "ERROR: Khong cai duoc @babel/standalone. Kiem tra ket noi npm!" -ForegroundColor Red
exit 1
}
node build.mjs # @babel/standalone; thay cho 'npm run build' (Babel 8 ESM-only CLI conflict)
Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) =="
pyinstaller engine.spec --clean --noconfirm
Write-Host "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
# Bat loi bundle NGAY tai build (da gap 2 lan: chay pyinstaller tu noi khac
# -> static/templates thieu -> exe crash 'Directory ...\app\static does not exist').
# Dung tools/verify_bundle.py (parse TOC bang ast, chap nhan ca / va \) thay
# vi regex thong thuong (TOC Windows co the dung backslash -> false positive).
# Truoc tien kiem tra engine.spec phai la ban moi (collect_data_files).
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
if ($specContent -notmatch "collect_data_files\('app'") {
Write-Host "ERROR: engine.spec CU (thieu collect_data_files('app')). Pull code moi truoc khi build!" -ForegroundColor Red
exit 1
}
python tools\verify_bundle.py
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Bundle thieu asset - dung build, kiem tra engine.spec datas!" -ForegroundColor Red
exit 1
}
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
# Tauri bundle resources -> resource_dir()/daw_engine/daw_engine.exe luc runtime.
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) {
Write-Host "ERROR: dist\daw_engine\daw_engine.exe khong ton tai (onedir build loi?)" -ForegroundColor Red
exit 1
}
if (Test-Path "src-tauri\resources\daw_engine") {
Remove-Item -Recurse -Force "src-tauri\resources\daw_engine"
}
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
}
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
exit 1
}
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
exit 1
}
npm install -D @tauri-apps/cli
npx tauri build
Write-Host ""
Write-Host "== DONE =="
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
Write-Host ""
Write-Host "== XAC MINH SAU KHI CAI DAT (quan trong) =="
Write-Host " Mo %APPDATA%\SonicForgeDAW\logs\spawn.log - phai thay:"
Write-Host " [resource_dir/daw_engine (map layout)] ...exists=True"
Write-Host " Neu exists=False: bundle resources KHONG vao installer (chay lai buoc [4/6])."
Write-Host " Engine phai nam o: <thu muc cai dat>\daw_engine\daw_engine.exe"
+126
View File
@@ -0,0 +1,126 @@
"""SonicForge DAW — Desktop engine launcher (PyInstaller entry point).
Chay FastAPI backend duoi dang tien trinh nen (sidecar) cho ban cai Standalone
Windows (Tauri v2 shell). Tu chon port 8000-8010 (fallback neu bi chiem), tu
thoat khi tien trinh cha (Tauri) ket thuc Graceful Shutdown.
"""
import logging
import os
import socket
import sys
import threading
import time
PORT_RANGE = (8000, 8010)
APP_DATA_DIR_NAME = "SonicForgeDAW"
def _pick_port():
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# KHONG dung SO_REUSEADDR: tren Windows no CHO PHEP bind trung port
# (2 engine cung 8000 -> request roi vao engine ngau nhien -> UI loi).
# Listen socket khong can SO_REUSEADDR (TIME_WAIT chi ap dung cho
# connection socket, khong phai listen socket).
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return PORT_RANGE[0]
def _parent_alive(pid):
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
# => watchdog se GIET LUON tien trinh cha (Tauri) sau 1s dau tien.
# Dung OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION): tra ve NULL khi
# pid khong con ton tai.
if os.name == "nt":
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return False # tien trinh da chet
ctypes.windll.kernel32.CloseHandle(h)
return True
try:
os.kill(pid, 0)
return True
except OSError:
return False
def main():
# PHONG THU (fix ban 1.1.2): neu exe bi goi nhu 'python -c ...' — vd code
# cu kiem tra thu vien bang subprocess.run([sys.executable, '-c', 'import X'])
# thi frozen sys.executable = daw_engine.exe -> se chay CA ENGINE o day.
# Thoat ngay, KHONG khoi dong server, tranh de quy spawn vo han.
if len(sys.argv) > 1 and sys.argv[1] == "-c":
return
os.environ.setdefault("SF_DESKTOP", "1")
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
log_dir = os.path.join(
os.environ.get("APPDATA") or os.path.expanduser("~"),
APP_DATA_DIR_NAME, "logs",
)
try:
os.makedirs(log_dir, exist_ok=True)
except OSError:
log_dir = os.path.expanduser("~")
# Windowed app (engine.spec console=False): Windows khong tao console ->
# sys.stdout/sys.stderr = None -> uvicorn logging crash
# ('NoneType' object has no attribute 'isatty'). Tro stdout/stderr vao
# FILE (KHONG phai devnull) — neu devnull thi moi exception bi nuot im
# lang (trieu chung 'stuck khong logs, khong loi').
if sys.stdout is None:
sys.stdout = open(os.path.join(log_dir, "stdout.log"), "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.path.join(log_dir, "stderr.log"), "w", encoding="utf-8")
port = _pick_port()
os.environ["SF_PORT"] = str(port)
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
try:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"),
logging.StreamHandler(),
],
)
except Exception:
logging.basicConfig(level=logging.INFO)
import uvicorn
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
config = uvicorn.Config(app.main.app, host="127.0.0.1", port=port,
log_level="info", access_log=False)
server = uvicorn.Server(config)
parent_pid = os.environ.get("SF_PARENT_PID")
if parent_pid:
def _watchdog():
pid = int(parent_pid)
while True:
time.sleep(1.0)
if not _parent_alive(pid):
logging.getLogger("desktop_engine").info(
"Parent process exited — shutting down engine")
server.should_exit = True
return
threading.Thread(target=_watchdog, daemon=True).start()
logging.getLogger("desktop_engine").info(
"SonicForge engine listening on 127.0.0.1:%d", port)
server.run()
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
# ── Production compose — KHÔNG mount source, image từ registry, bí mật qua .env ──
# Dùng: docker compose -f docker-compose.prod.yml up -d
services:
redis:
image: redis:7-alpine
restart: unless-stopped
web:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
ports:
- "${WEB_PORT:-8000}:8000"
env_file: .env
environment:
# Ép nhận diện headless (server docker + browser UI) — soundfont + VSTi
# mở được lấy từ storage mount bên dưới; preset VST3 (.vstpreset) upload
# qua web UI vào storage/presets (nằm trong volume sf_db).
- SF_DOCKER=1
volumes:
- sf_uploads:/app/app/storage/uploads
- sf_processed:/app/app/storage/processed
- sf_db:/app/app/storage
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
depends_on:
- redis
worker:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
env_file: .env
volumes:
- sf_uploads:/app/app/storage/uploads
- sf_processed:/app/app/storage/processed
- sf_db:/app/app/storage
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
depends_on:
- redis
beat:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
env_file: .env
volumes:
- sf_db:/app/app/storage
depends_on:
- redis
volumes:
sf_uploads:
sf_processed:
sf_db:
+15 -9
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
+160
View File
@@ -0,0 +1,160 @@
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
binaries = collect_dynamic_libs('pedalboard')
binaries += collect_dynamic_libs('soundfile')
# Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
# package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
# duoc bundle qua collect_data_files('app').
_SPEC_ROOT = os.path.abspath(SPECPATH)
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
# bundle thieu static/templates -> exe crash luc chay. Fix: chen _SPEC_ROOT
# vao sys.path TRUOC khi collect de import 'app' luon hoạt dong.
import sys as _sys
if _SPEC_ROOT not in _sys.path:
_sys.path.insert(0, _SPEC_ROOT)
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
if not datas:
print("WARN: collect_data_files('app') tra ve rong - dung datas tinh absolute")
datas = [
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg
(os.path.join(_SPEC_ROOT, 'app', 'static'), 'app/static'), # js/css/processors
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
]
datas += [
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
]
# ══════════════════════════════════════════════════════════════════════════
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
# ──────────────────────────────────────────────────────────────────────────
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
# render_engine, audio_features).
# ══════════════════════════════════════════════════════════════════════════
import importlib.util as _ilu
import glob as _glob
def _scan_pkg_modules(pkg_name: str):
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
_spec = _ilu.find_spec(pkg_name)
if _spec is None or _spec.origin is None:
print(f"WARN: khong tim thay package '{pkg_name}' - bo qua scan")
return []
_pkg_dir = os.path.dirname(os.path.abspath(_spec.origin))
_out = []
for _ext in ('*.py', '*.pyd', '*.so'):
for _f in _glob.glob(os.path.join(_pkg_dir, '**', _ext), recursive=True):
_rel = os.path.relpath(_f, _pkg_dir)
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
_sub = os.path.dirname(_rel).replace(os.sep, '.')
_mod = (pkg_name + '.' + _sub + '.' + _base) if _sub else (pkg_name + '.' + _base)
if _mod not in _out:
_out.append(_mod)
return _out
_scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
# Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
_hidden = [
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'pedalboard',
'soundfile',
'sf2utils',
'mido.backends.rtmidi',
] + _scipy_signal_hidden
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
_excludes = [
'tkinter',
# libs da thay the (audio_features.py)
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
# celery/redis chi dung cho server (Docker), khong cho desktop
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
'click_didyoumean', 'click_plugins', 'click_repl',
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
# khong dung trong desktop
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
# keo vao nham boi hooks_contrib (app khong import bao gio)
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
]
a = Analysis(
['desktop_engine.py'],
pathex=[_SPEC_ROOT],
binaries=binaries,
datas=datas,
hiddenimports=_hidden,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=_excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='daw_engine',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # True khi debug (xem log truc tiep), False cho production
# Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
icon='src-tauri/icons/favicon.ico',
)
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
name='daw_engine',
)
+255
View File
@@ -0,0 +1,255 @@
# CARLA BRIDGE — NATIVE GUI VSTi (Windows) + DUAL-MODE RUNTIME (Desktop / Docker Headless)
> Tài liệu kỹ thuật & vận hành: tích hợp Carla làm host native GUI cho VSTi trên
> Windows, tự phát hiện môi trường (desktop / headless), thư viện preset
> (.vstpreset) làm cầu nối Carla → pedalboard, và Plugin Manager quản lý
> soundfont + instrument.
>
> Phạm vi code: `app/core/runtime.py`, `app/api/v1/system.py`,
> `app/api/v1/presets.py`, `app/api/v1/plugins.py`, `app/core/vst_engine.py`,
> `app/core/render_engine.py`, `app/static/js/services/runtime.js`, `app.jsx`.
---
## 1. Bối cảnh & quyết định kiến trúc
| Vấn đề | Quyết định |
|---|---|
| `pedalboard` **cố tình headless** — không mở được native GUI VSTi | Không thay thế pedalboard; dùng **Carla làm host GUI ngoài** (preview + chỉnh preset), pedalboard giữ nguyên làm **render engine** (offline, cùng code path cho preview & export) |
| Browser không chạy được binary `.vst3` (Windows/Linux) | Preview realtime thật chỉ có trong **cửa sổ Carla**; trong web UI dùng **quick-render preview** (pedalboard render clip ngắn → wav — **âm thật = âm export**) |
| App chạy 2 môi trường: **Windows desktop** (server+client 1 máy) và **Docker headless** (server + browser UI) | **Runtime profile tự phát hiện** → bật/tắt tính năng theo môi trường (nút Carla Bridge chỉ hiện trên desktop có Carla) |
| Carla Windows là **bộ zip portable** — không installer, không PATH | **User tự định vị** thư mục chứa `carla.exe` (Plugin Manager → Định vị Carla...) + tìm kiếm dự phòng (registry, Program Files, quét nông Downloads/Desktop) |
```
┌────────────────────────── Windows Desktop (1 máy) ──────────────────────────┐
│ Browser/Tauri UI ──► FastAPI (localhost:8000) ──► pedalboard (render VST3) │
│ │ │ ▲ │
│ ▼ ▼ │ load_preset │
│ Nút "Carla Bridge" ──► spawn carla.exe (native GUI) │ │
│ │ chọn VSTi, chỉnh âm, Save .vstpreset │
│ └──────────────► Upload preset ──► storage/presets ─┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────── Docker Headless (server) ─────────────────────────┐
│ Browser UI ──► FastAPI (container :8000) ──► pedalboard (VSTi Linux) │
│ Soundfont + VSTi mở được: mount volumes (docker-compose.prod.yml) │
│ Preset .vstpreset: user chỉnh ở máy desktop → upload qua web UI │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Runtime tự phát hiện môi trường (`app/core/runtime.py`)
### 2.1 Nguyên tắc
- **Heuristic + override**: `SF_RUNTIME=auto|desktop|headless` **thắng tuyệt đối**
(WSL / docker-in-docker / remote desktop có thể làm heuristic sai).
- `SF_DOCKER=1` hoặc tồn tại `/.dockerenv` → nhận diện container.
- Linux: không có `DISPLAY` hoặc trong docker → `headless`; ngược lại `desktop`.
- Windows/macOS → luôn `desktop`.
### 2.2 Capabilities API (frontend gọi 1 lần lúc boot)
```
GET /api/v1/system/capabilities (public — cần trước khi đăng nhập)
```
```json
{
"success": true,
"runtime": "desktop", // "desktop" | "headless"
"platform": "windows", // "windows" | "linux" | "darwin"
"docker": false,
"features": {
"carla_local": true, // có Carla trên máy → hiện nút "Carla Bridge"
"carla_path": "D:/Tools/Carla/carla.exe",
"preset_upload": true, // luôn true (upload .vstpreset qua web UI)
"vst_render": true, // pedalboard khả dụng
"tauri_bridge": false,
"preview_mode": "quick_render" // "quick_render" | "wasm"
},
"default_dirs": { "vst": [...], "soundfont": [...], "preset": "..." }
}
```
### 2.3 Bảng hành vi theo môi trường
| Tính năng | Windows desktop | Docker headless |
|---|---|---|
| Render VSTi | pedalboard `VST3Plugin` + `load_preset` | giống hệt (VSTi **bản Linux**) |
| Scan VST/SF | thư mục chuẩn Windows + `plugin_dirs.json` | mount volumes + `plugin_dirs.json` |
| Preview VSTi | quick-render (âm thật); realtime trong Carla | quick-render |
| Nút "Carla Bridge" (nút Synth) | **Hiện** → spawn `carla.exe` | **Ẩn** (không có GUI local) |
| Preset | picker local / upload → thư viện | upload → thư viện |
| SoundFont preview | FluidSynth WASM (browser) | giống hệt |
---
## 3. Cài đặt Carla trên Windows (bản portable zip)
Carla phát hành dạng **zip** (`Carla-2.5.x-win64.zip`) — **không có installer,
không ghi PATH**. Không bundle Carla vào installer của app (license GPL-2.0+,
xem §8).
1. Tải: <https://github.com/falkTX/Carla/releases> (bản `win64`).
2. Giải nén ra bất kỳ đâu (VD `D:\Tools\Carla\`, chứa `carla.exe`).
3. Khai báo 1 lần (2 cách, tùy chọn 1):
- **Nút bấm**: Plugin Manager → section **"Carla Bridge (VSTi native GUI)"** →
**"Định vị Carla..."** → chọn thư mục chứa `carla.exe` (hoặc chính file).
- **Nhập tay**: ô text trong section đó → nhập `D:/Tools/Carla`**Lưu**.
4. App lưu vào `storage/carla_path.json` → cache detect bị xóa → nút
**"Carla Bridge"** hiện trong dropdown nút Synth + **tự động mở Carla**
khi chọn VSTi.
### 3.1 Thứ tự phát hiện `carla_local`
| Ưu tiên | Nguồn |
|---|---|
| 1 | **Config user** (`storage/carla_path.json`) — kênh chính cho bản portable |
| 2 | PATH (`shutil.which`) |
| 3 | Registry (`HKLM/HKCU\SOFTWARE\Carla\InstallPath`) — nếu cài qua installer |
| 4 | Thư mục chuẩn (`Program Files\Carla`, `%LOCALAPPDATA%\Programs\Carla`) |
| 5 | **Quét nông** Downloads/Desktop/Documents (độ sâu ≤ 3, bỏ qua `node_modules`, `AppData`, `Windows`...) — không bao giờ quét toàn ổ đĩa |
```
POST /api/v1/system/carla-path (auth) — lưu vị trí Carla
body: { "carla_path": "D:/Tools/Carla" } (thư mục HOẶC file exe)
→ trả { success, carla_path, ...capabilities }
```
---
## 4. Luồng sử dụng end-to-end (Windows)
1. **Nút Synth** → chọn **VSTi** trong danh sách → app **TỰ ĐỘNG gọi Carla**:
sinh file project `.carxs` (định dạng XML chính thức của Carla —
`carla.exe [FILE]` nhận project file) chứa node
`<Plugin><Info><Type>VST3</Type><Binary>path</Binary>...</Info></Plugin>`
`carla.exe <project.carxs>` → Carla mở lên **plugin đã load sẵn** kèm
**on-screen MIDI keyboard** (`PixmapKeyboard`).
- VST3 (file `.vst3` hoặc folder `X.vst3` Windows): auto-load qua `.carxs`.
- VST2 (`.dll`/`.so` ngoài `.vst3`): không auto-load tin cậy (cần uniqueID)
→ mở Carla trống để user **Add Plugin** thủ công.
- Project `.carxs` nằm `{STORAGE_DIR}/carla_projects/`, tự dọn sau 1 ngày.
2. Trong Carla: native GUI của VSTi hiện ra → **chọn instrument/preset** của
plugin → **bật ARM** trên track trong app (tùy chọn) → **bấm phím trên
keyboard ảo của Carla** để preview realtime (âm thật qua audio device).
3. **Save preset** bằng nút của CHÍNH plugin (không dùng project save của Carla)
→ file `.vstpreset`.
4. Trong app: dropdown Synth → **"⬆ Upload preset (từ Carla...)"** → chọn file
`.vstpreset` → nằm trong **thư viện preset** → chọn preset trong danh sách
**"VST Presets"** → gán vào track (`synth_engine.preset_id`).
5. **Render**: `render_engine.py` nạp plugin qua `load_vst()` rồi
`apply_preset_to_plugin()` → **âm render = âm đã chỉnh trong Carla**
(điều kiện: cùng sample rate — xem §7).
Lưu ý: danh sách VSTi trong dropdown vẫn cần thiết — dùng để **map tên →
đường dẫn khi pedalboard nạp plugin lúc render** (không phải để mở GUI).
---
## 5. Thư viện preset — cầu nối Carla ↔ pedalboard
- Thư mục: `{STORAGE_DIR}/presets` (Windows: `%APPDATA%\SonicForgeDAW\storage\presets`;
Docker: nằm trong volume `sf_db``/app/app/storage/presets`).
- Định dạng hỗ trợ: `.vstpreset` (VST3 — chuẩn), `.fxp`/`.fxb` (VST2, chỉ
preview trong Carla), `.dspreset` (DecentSampler/Pianobook).
- `synth_engine` của track có thể chứa 1 trong 3 dạng (ưu tiên giảm dần):
```json
{ "type": "vst3", "plugin_id": "Kontakt 7",
"preset_data": "<base64 bytes .vstpreset nhúng — project tự chứa state, portable>",
"preset_id": "a1b2c3....vstpreset", // thư viện storage/presets
"preset_path": "D:/presets/Piano.vstpreset" }
```
### API
```
GET /api/v1/presets (public) — danh sách
POST /api/v1/presets/upload (auth) — multipart file + plugin_hint
GET /api/v1/presets/{id}/download
DELETE /api/v1/presets/{id} (auth)
```
### Quick-render preview (âm thật = âm export)
```
POST /api/v1/plugins/preview
body: { instrument_id, notes:[{pitch,start_beat,duration_beats,velocity}],
bpm, sample_rate, preset_id?, preset_path?, preset_data? }
→ { success, url: "/static/audio/processed/preview_xxx.wav", duration_sec }
```
Cùng code path với export (pedalboard + `load_preset`) → **preview nghe đúng
plugin/preset** (khác "Preview Synth WASM" cũ — âm giả).
---
## 6. Plugin Manager — SoundFont: Add Directory → Scan → Instrument → Synth
1. **Add Directory** (folder picker native / in-app browser) → thư mục vào
danh sách (lưu `plugin_dirs.json`).
2. **Scan** → quét soundfont trong các thư mục (catalog qua
`SoundFontAutoScanner`) + liệt kê VST.
3. Bấm vào **một soundfont** (▸) → expand danh sách **instrument bên trong**
(Bank/Program/Tên) qua `GET /api/v1/plugins/soundfont-instruments/{sf_id}`.
4. Nút **"Chèn vào Synth"** → gán instrument (bank/program) vào **track đang
chọn** (`setTrackInstrumentWithProgram`) và đóng Plugin Manager.
> SF3: instrument đọc sau khi chuyển đổi SF3→SF2 (endpoint download tự chuyển
> đổi khi cần). Nếu thiếu libfluidsynth, danh sách trả `[]` (graceful).
---
## 7. Lưu ý kỹ thuật & hạn chế (đã xác minh)
1. **Sample rate**: Carla chạy theo audio device (thường 48 kHz), pedalboard
render mặc định 44.1 kHz → plugin phụ thuộc SR (delay/chorus/oversampling)
nghe khác. **Render đúng SR của thiết bị Carla** để preview = export.
2. **VST2**: pedalboard 0.10+ **đã gỡ hỗ trợ VST2** (chỉ còn VST3) → chỉ preset
VST3 (`.vstpreset`) round-trip được. Plugin VST2 cũ: preview được trong
Carla nhưng **không render** được qua pedalboard.
3. **VSTi trên Docker**: chỉ chạy được plugin có bản **Linux** (`.vst3`/`.so`);
plugin Windows-only (`.dll`) không chạy trên server Linux (không khuyến nghị
Wine bridge trong container).
4. **License Carla GPL-2.0+**: app **không bundle/nhúng** Carla — chỉ spawn
tiến trình ngoài + trao đổi file preset (không link code) → không dính
copyleft. User tự tải zip.
5. **Preview WASM ≠ âm thật**: Preview Synth trong browser không phải plugin
thật — dùng quick-render (`/plugins/preview`) nếu cần nghe đúng âm.
---
## 8. Checklist QA
- [ ] Windows: cài Carla zip → Định vị Carla → nút "Carla Bridge" hiện ở nút Synth
- [ ] Bấm Carla Bridge → `carla.exe` chạy, mở được VSTi + native GUI
- [ ] Save `.vstpreset` từ GUI plugin → Upload trong app → gán vào track → render ra âm đúng preset
- [ ] Render cùng SR với Carla → preview (Carla) nghe = âm export
- [ ] Plugin Manager: Add Directory → Scan → expand soundfont → "Chèn vào Synth" gán đúng bank/program vào track đang chọn
- [ ] Docker (`SF_DOCKER=1`): capabilities `runtime=headless`, không hiện Carla Bridge, preset upload hoạt động, render VSTi Linux + soundfont từ mount OK
- [ ] `SF_RUNTIME=desktop` trên Linux có DISPLAY → chạy như desktop
- [ ] pytest: `86 passed, 7 skipped`
## 9. Flow MIDI Keyboard hardware (Web MIDI) — route đầy đủ
Hardware MIDI keyboard (Web MIDI API) đi qua ĐÚNG flow như keybed ảo:
```
MIDI Keyboard (hardware, Web MIDI)
├─ note-on → ensureMasteringRouting() (ép âm qua Mastering FX Chain khi bật)
│ → SonicSF.playNote() (track soundfont/GM — qua track node → mastering)
│ → SonicCarlaMidi.noteOn() (track VSTi + ARM + Carla local — OSC /Carla/0/note_on)
├─ note-off → SonicSF.stopNote() (dừng soundfont)
│ → SonicCarlaMidi.noteOff() (dừng VSTi trong Carla — tránh kẹt âm)
└─ CC64/CC1/Pitch Bend → SonicSF.controllerChange()/pitchBend() (armed tracks)
```
Trước đây chỉ keybed ảo (piano roll) gọi `SonicCarlaMidi` + `__ensureMasteringRouting`;
keyboard hardware bị bỏ sót → track VSTi không kêu khi bấm đàn thật, và âm soundfont
không qua mastering cho tới khi bật/tắt power. Đã sửa tại `onmidimessage` handler
(`app.jsx`): note-on/note-off route qua Carla bridge + ép đồng bộ mastering routing.
Export offline (`clientSideExport`) cũng áp **Main out volume** (master fader) vào
offline master bus — file WAV đúng độ lớn nghe được (trước đây luôn 1.0).
+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"
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "sonicforge-daw"
version = "1.0.0"
description = "SonicForge DAW desktop shell (Tauri v2 + Python sidecar)"
edition = "2021"
[lib]
name = "sonicforge_daw_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
strip = true
lto = true
codegen-units = 1
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}
+7
View File
@@ -0,0 +1,7 @@
; NSIS installer hooks cai Microsoft Visual C++ Redistributable neu thieu
; (MSVCP140.dll missing). Dat vc_redist.x64.exe CANH tauri.conf.json
; (build_windows.ps1 tu tai ve neu chua co).
!macro customInstall
File "/oname=$PLUGINSDIR\vc_redist.x64.exe" "vc_redist.x64.exe"
ExecWait '"$PLUGINSDIR\vc_redist.x64.exe" /install /quiet /norestart'
!macroend
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1 @@
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
+233
View File
@@ -0,0 +1,233 @@
// SonicForge DAW — desktop shell: spawn/terminate daw_engine sidecar.
//
// QUAN TRONG (fix bản 1.1.1 — engine không chạy trên Windows):
// tauri.conf.json bundle.resources giờ dùng dạng MAP:
// { "resources/daw_engine": "daw_engine/" }
// vì dạng ARRAY ("resources/daw_engine") copy file tới
// $RESOURCE_DIR/resources/daw_engine/... (giữ tiền tố "resources/"),
// trong khi code cũ tìm ở $RESOURCE_DIR/daw_engine/... -> exists=false.
// Dạng map (Walk mode) giữ nguyên cây thư mục (_internal) dưới đích
// "daw_engine/" — đúng layout lib.rs chờ.
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
struct EngineProcess(Mutex<Option<CommandChild>>);
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
let exe_name = if cfg!(windows) { "daw_engine.exe" } else { "daw_engine" };
vec![
// 1. Layout chuẩn (resources map): $RESOURCE/daw_engine/daw_engine.exe
(
res_dir.join("daw_engine").join(exe_name),
"resource_dir/daw_engine (map layout)".into(),
),
// 2. Legacy (resources array cũ giữ tiền tố resources/)
(
res_dir.join("resources").join("daw_engine").join(exe_name),
"resource_dir/resources/daw_engine (legacy array)".into(),
),
// 3. Portable: engine đặt cạnh exe
(
exe_dir.join("daw_engine").join(exe_name),
"exe_dir/daw_engine (portable)".into(),
),
// 4. Dev mode: target/{debug,release} -> src-tauri/resources
(
exe_dir.join("..").join("resources").join("daw_engine").join(exe_name),
"exe_dir/../resources/daw_engine (dev)".into(),
),
]
}
fn list_dir_snippet(dir: &Path) -> String {
let mut s = String::new();
match std::fs::read_dir(dir) {
Ok(rd) => {
let mut n = 0;
for e in rd.flatten() {
if n > 0 {
s.push_str(", ");
}
s.push_str(&e.file_name().to_string_lossy());
n += 1;
if n >= 15 {
s.push_str(", ...");
break;
}
}
}
Err(_) => s.push_str("<khong doc duoc dir>"),
}
s
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let res_dir = app
.path()
.resource_dir()
.expect("resource dir not found");
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_default();
let candidates = engine_candidates(&res_dir, &exe_dir);
// Ghi log spawn de chan doan — vao %APPDATA%/SonicForgeDAW/logs/
// spawn.log (thu muc luon ton tai), KHONG ghi vao engine_dir.
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let spawn_log_path = std::path::Path::new(&log_dir)
.join("SonicForgeDAW")
.join("logs")
.join("spawn.log");
if let Some(parent) = spawn_log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut log_line = format!(
"resource_dir={}\nexe_dir={}\n",
res_dir.display(),
exe_dir.display()
);
let mut found: Option<(PathBuf, String)> = None;
for (path, label) in &candidates {
let exists = path.exists();
log_line.push_str(&format!(
" [{label}] {} exists={}\n",
path.display(),
exists
));
if found.is_none() && exists {
found = Some((path.clone(), label.clone()));
}
}
if found.is_none() {
log_line.push_str(&format!(
" NOT FOUND — resource_dir contents: {}\n exe_dir contents: {}\n",
list_dir_snippet(&res_dir),
list_dir_snippet(&exe_dir)
));
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&spawn_log_path)
{
use std::io::Write;
let _ = f.write_all(log_line.as_bytes());
}
match found {
Some((engine_exe, label)) => {
match app
.shell()
.command(&engine_exe)
.env("SF_PARENT_PID", std::process::id().to_string())
.spawn()
{
Ok((_rx, child)) => {
app.manage(EngineProcess(Mutex::new(Some(child))));
println!(
"Python Background Engine started ({label}): {}",
engine_exe.display()
);
}
Err(e) => {
println!("Failed to spawn daw_engine {engine_exe:?}: {e}");
app.manage(EngineProcess(Mutex::new(None)));
}
}
}
None => {
println!(
"daw_engine binary not found in any candidate path — xem spawn.log de biet chi tiet"
);
app.manage(EngineProcess(Mutex::new(None)));
}
}
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
// IPC: engine ghi ipc/pick_dir.request → thread nay mo NATIVE dialog
// (IFileDialog/Explorer — tauri-plugin-dialog) → ghi ket qua vao
// ipc/pick_dir.response → engine tra ve cho frontend.
let data_root = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let ipc_dir = std::path::Path::new(&data_root)
.join("SonicForgeDAW")
.join("ipc");
if let Ok(()) = std::fs::create_dir_all(&ipc_dir) {
// Marker: engine biet bridge ton tai (khong phai chay standalone)
let _ = std::fs::write(ipc_dir.join("tauri_bridge_ready"), "1");
}
let ipc_dir_for_thread = ipc_dir.clone();
let app_handle = app.handle().clone();
std::thread::spawn(move || {
loop {
let req = ipc_dir_for_thread.join("pick_dir.request");
if req.exists() {
let _ = std::fs::remove_file(&req);
let resp = ipc_dir_for_thread.join("pick_dir.response");
let _ = std::fs::remove_file(&resp);
// Dialog phai chay tren main thread (GTK/Windows message loop)
let handle = app_handle.clone();
let resp_for_main = resp.clone();
// Clone rieng cho closure: run_on_main_thread(&self, F)
// borrow `handle`, closure (move) phai dung ban clone.
let handle_for_closure = handle.clone();
let _ = handle.run_on_main_thread(move || {
// tauri-plugin-dialog v2: blocking_pick_folder() tra
// Option<FilePath> (enum Path(PathBuf) | Url(Url))
let picked: Option<FilePath> = handle_for_closure
.dialog()
.file()
.blocking_pick_folder();
let val = picked
.map(|p| match p {
FilePath::Path(pb) => pb.to_string_lossy().to_string(),
FilePath::Url(u) => u.to_string(),
})
.unwrap_or_default();
let _ = std::fs::write(&resp_for_main, val);
});
// Cho den khi co response (user co the de dialog mo lau)
let mut waited_ms = 0u32;
while !resp.exists() && waited_ms < 300_000 {
std::thread::sleep(std::time::Duration::from_millis(100));
waited_ms += 100;
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
});
Ok(())
})
.on_window_event(|window, event| {
// Terminate sidecar khi DAW window dong — tranh orphan process
if let tauri::WindowEvent::Destroyed = event {
let child = window
.state::<EngineProcess>()
.0
.lock()
.ok()
.and_then(|mut lock| lock.take());
if let Some(child) = child {
let _ = child.kill();
println!("daw_engine sidecar terminated.");
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
sonicforge_daw_lib::run()
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SonicForgeDAW",
"version": "1.0.0",
"identifier": "com.sonicforge.daw",
"build": {
"frontendDist": "ui",
"devUrl": "http://localhost:8000"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Sonic Forge DAW - Professional Desktop Studio",
"width": 1440,
"height": 900,
"resizable": true,
"fullscreen": false,
"devtools": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/favicon.ico"
],
"resources": {
"resources/daw_engine": "daw_engine/"
},
"externalBin": [],
"windows": {
"nsis": {
"installerHooks": "hooks.nsh"
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<title>SonicForge DAW</title>
<style>
body { font-family: system-ui, sans-serif; background: #12141a; color: #e8eaf0;
display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
#msg { text-align: center; max-width: 480px; line-height: 1.6; }
</style>
</head>
<body>
<div id="msg">Đang khởi động SonicForge Engine…</div>
<script>
// Engine (sidecar) có thể cần vài giây để boot (import librosa/pedalboard).
// Thử health-check trên dải port 8000-8010, redirect tới port đầu tiên OK.
(async function () {
var tries = 0;
while (tries < 90) { // ~60s tối đa
for (var p = 8000; p <= 8010; p++) {
try {
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
if (r.ok) { location.href = 'http://127.0.0.1:' + p + '/'; return; }
} catch (e) { /* port chưa mở */ }
}
tries++;
await new Promise(function (res) { setTimeout(res, 700); });
}
document.getElementById('msg').textContent =
'Không tìm thấy SonicForge Engine (port 80008010). Hãy đóng và chạy lại ứng dụng. ' +
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
})();
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
"""Pytest bootstrap: isolate the test suite from the development database.
Must be imported before any app module (pytest imports conftest.py first), so
app.models.user picks up the test DB path instead of the dev DB. Without this,
tests that seed/rotate the admin password (test_auth_and_quota) permanently
mutate the developer's sonicforge.db.
"""
import os
import tempfile
os.environ.setdefault(
"SONICFORGE_DB_PATH",
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
)
+63 -1
View File
@@ -13,7 +13,17 @@ client = TestClient(app)
def get_admin_token(): def get_admin_token():
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
if resp.status_code == 200: if resp.status_code == 200:
return resp.json()["access_token"] token = resp.json()["access_token"]
# Admin is seeded with must_change_password=1; the app blocks music
# processing until the first password change. Complete that flow here
# (keeping the same password) so feature tests run unblocked.
user = resp.json()["user"]
if user.get("must_change_password"):
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
json={"old_password": "admin123", "new_password": "admin123"})
if r.status_code == 200:
token = r.json()["access_token"]
return token
return None return None
@@ -83,3 +93,55 @@ class TestPluginAPI:
assert data["size_bytes"] == len(valid_content) assert data["size_bytes"] == len(valid_content)
elif resp.status_code == 403: elif resp.status_code == 403:
pytest.skip("Permission denied for admin user") pytest.skip("Permission denied for admin user")
def test_plugin_dirs_save_list(self):
"""plugin_dirs (list) — save/get/effective + scan phân loại riêng rẽ."""
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
h = {"Authorization": f"Bearer {token}"}
import tempfile
with tempfile.TemporaryDirectory() as td:
# 2 dir giả: 1 chứa VST, 1 chứa SoundFont
vst_dir = os.path.join(td, "vsts")
sf_dir = os.path.join(td, "sfs")
os.makedirs(vst_dir)
os.makedirs(sf_dir)
open(os.path.join(vst_dir, "Synth1.vst3"), "w").write("x")
open(os.path.join(sf_dir, "piano.sf2"), "w").write("x")
# Save list
r = client.post("/api/v1/plugins/dirs", headers=h,
json={"plugin_dirs": [vst_dir, sf_dir]})
assert r.status_code == 200
assert r.json()["plugin_dirs"] == [vst_dir, sf_dir]
# Get lại
g = client.get("/api/v1/plugins/dirs", headers=h)
assert g.json()["plugin_dirs"] == [vst_dir, sf_dir]
# Scan → phân loại riêng rẽ
s = client.post("/api/v1/plugins/scan", headers=h)
assert s.status_code == 200
data = s.json()
assert any(v["name"] == "Synth1" for v in data["vst_found"])
assert any(x["name"] == "piano" for x in data["soundfonts"])
assert data["vst_count"] == 1
assert data["soundfont_count"] == 1
# Mỗi entry có dir gốc
assert data["vst_found"][0]["dir"] == vst_dir
assert data["soundfonts"][0]["dir"] == sf_dir
def test_plugin_dirs_remove(self):
"""Xóa 1 dir khỏi list → save lại → không còn trong effective."""
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
h = {"Authorization": f"Bearer {token}"}
import tempfile
with tempfile.TemporaryDirectory() as td:
d1 = os.path.join(td, "d1")
d2 = os.path.join(td, "d2")
os.makedirs(d1)
os.makedirs(d2)
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1, d2]})
client.post("/api/v1/plugins/dirs", headers=h, json={"plugin_dirs": [d1]})
g = client.get("/api/v1/plugins/dirs", headers=h)
assert g.json()["plugin_dirs"] == [d1]
+68
View File
@@ -0,0 +1,68 @@
"""Regression tests for legacy project upgrade (items must stay on their tracks
with correct bar positions)."""
import json
from app.api.v1.projects import upgrade_project_json_if_needed
def _legacy_project():
return {
"id": "legacy_1",
"name": "Legacy",
"bpm": 120.0, # 1 bar = 2.0s
"tracks": [
{
"id": "1",
"name": "Track 1",
"volumeDb": 0.0,
"pan": 0.0,
"muted": False,
"solo": False,
"serverFileId": "abc.wav",
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
"midiItems": [],
},
{
"id": "2",
"name": "Track 2",
"volumeDb": 0.0,
"pan": 0.0,
"muted": False,
"solo": False,
"serverFileId": None,
"clips": [],
"midiItems": [
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
],
},
],
}
def test_upgrade_keeps_items_on_their_tracks():
upgraded = upgrade_project_json_if_needed(_legacy_project())
tracks = upgraded["main_session"]["tracks"]
assert len(tracks) == 2
t1_items = tracks[0]["items"]
t2_items = tracks[1]["items"]
# Items must NOT be merged into the first track
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
assert t1_items[0]["id"] == "c1"
assert t2_items[0]["id"] == "m1"
def test_upgrade_uses_bpm_based_seconds_per_bar():
upgraded = upgrade_project_json_if_needed(_legacy_project())
tracks = upgraded["main_session"]["tracks"]
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
assert tracks[0]["items"][0]["start_bar"] == 1.0
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
assert tracks[1]["items"][0]["start_bar"] == 2.0
assert tracks[1]["items"][0]["duration_bars"] == 2.0
def test_upgrade_skips_new_format():
data = {"main_session": {"tracks": []}}
assert upgrade_project_json_if_needed(data) is data
+234
View File
@@ -0,0 +1,234 @@
"""Regression tests for security hardening.
Covers the vulnerabilities found during the 2026-08 audit:
- SSRF / open proxy on /api/v1/ai/proxy
- path traversal on render output and audio file ids
- unauthenticated filesystem access via /api/v1/media/*
- hardcoded SECRET_KEY
- quota bypass on project update
- audio resampling correctness in the render engine
"""
import os
import json
import numpy as np
import pytest
import soundfile as sf
from fastapi.testclient import TestClient
from app.main import app
from app.config import settings
from app.core import auth as core_auth
client = TestClient(app)
def get_admin_token():
# test_auth_and_quota.py may have rotated the admin password; try both.
for pwd in ("admin123", "admin_new_password_2026"):
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
if resp.status_code != 200:
continue
token = resp.json()["access_token"]
user = resp.json()["user"]
if user.get("must_change_password"):
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
json={"old_password": pwd, "new_password": pwd})
if r.status_code == 200:
token = r.json()["access_token"]
return token
return None
def auth_headers():
return {"Authorization": f"Bearer {get_admin_token()}"}
# ── 1. SSRF / open proxy ──
class TestAIProxySSRF:
def test_proxy_requires_auth(self):
resp = client.post("/api/v1/ai/proxy", json={"url": "https://api.openai.com/v1", "body": {}})
assert resp.status_code == 401
def test_proxy_blocks_cloud_metadata(self):
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "http://169.254.169.254/latest/meta-data/", "body": {}})
assert resp.status_code == 403
def test_proxy_blocks_private_ip_not_configured(self):
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "http://10.0.0.5/", "body": {}})
assert resp.status_code == 403
def test_proxy_rejects_non_http_scheme(self):
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "file:///etc/passwd", "body": {}})
assert resp.status_code == 400
def test_proxy_allows_configured_localhost_provider(self):
# localhost:11434 is in the default AI provider list; it must pass the
# SSRF check (and then fail to connect in this environment -> 502).
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
assert resp.status_code == 502
# ── 2. Path traversal ──
class TestPathTraversal:
def test_render_output_filename_sanitized(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
project = {
"metadata": {"bpm": 120, "time_signature_numerator": 4},
"main_session": {"length_bars": 1, "tracks": []},
"section_store": {},
}
resp = client.post("/api/v1/plugins/render", headers=auth_headers(),
json={"project_json": project, "output_filename": "/tmp/evil_traversal.wav"})
# Absolute paths must be reduced to a basename inside PROCESSED_DIR.
assert resp.status_code == 200, resp.text
out_path = resp.json()["path"]
assert os.path.dirname(out_path) == settings.PROCESSED_DIR
assert os.path.basename(out_path) == "evil_traversal.wav"
assert os.path.isfile(out_path)
def test_audio_download_rejects_traversal(self):
resp = client.get("/api/v1/audio/download/..%2F..%2Fapp%2Fconfig.py")
assert resp.status_code == 404
def test_ai_scan_rejects_traversal_file_id(self):
resp = client.post("/api/v1/audio/ai-scan",
json={"track_id": "1", "file_id": "../../app/config.py"})
# Traversal must NOT read the file: falls through to the demo branch.
assert resp.status_code == 200
assert resp.json()["success"] is True
# ── 3. Filesystem exposure via media endpoints ──
class TestMediaAuth:
# Use a fresh client (no cookies from earlier logins) to prove 401.
@pytest.fixture(autouse=True)
def _fresh_client(self):
self.fresh = TestClient(app)
yield
self.fresh.close()
def test_media_computer_requires_auth(self):
resp = self.fresh.get("/api/v1/media/computer")
assert resp.status_code == 401
def test_media_browse_requires_auth(self):
resp = self.fresh.get("/api/v1/media/browse", params={"path": "/etc"})
assert resp.status_code == 401
def test_media_file_requires_auth(self):
resp = self.fresh.get("/api/v1/media/file", params={"path": "/etc/passwd"})
assert resp.status_code == 401
# ── 4. Secret key ──
class TestSecretKey:
def test_secret_key_not_hardcoded_default(self):
assert core_auth.SECRET_KEY != "sonicforge_secret_key_super_secure_2026"
assert len(core_auth.SECRET_KEY) >= 32
# ── 5. Quota enforcement on update ──
class TestQuotaUpdate:
def test_update_cloud_project_enforces_quota(self):
token = get_admin_token()
if not token:
pytest.skip("Cannot get admin token")
# Register a fresh user with a small quota (unique name per run so the
# test is re-runnable against a persistent DB).
import uuid as _uuid
uname = f"quota_user_{_uuid.uuid4().hex[:8]}"
resp = client.post("/api/v1/auth/register", json={
"username": uname, "email": f"{uname}@studio.com", "password": "quota_pass_123"})
assert resp.status_code == 200, resp.text
user_token = resp.json()["access_token"]
user_headers = {"Authorization": f"Bearer {user_token}"}
# Shrink quota to 1 MB via admin API.
uid = resp.json()["user"]["id"]
r = client.put(f"/api/v1/admin/quotas/{uid}", headers=auth_headers(),
json={"storage_limit_mb": 1, "max_tracks": 16})
assert r.status_code == 200, r.text
# Save a small project.
small = json.dumps({
"project_id": "p1",
"metadata": {"title": "small", "bpm": 120, "time_signature_numerator": 4,
"time_signature_denominator": 4, "sample_rate": 44100},
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
"length_bars": 16.0, "auto_compute_length": True, "tracks": []},
"section_store": {}})
r = client.post("/api/v1/projects/cloud", headers=user_headers,
json={"name": "small", "data_json": small})
assert r.status_code == 200, r.text
pid = r.json()["project_id"]
# Updating with a payload over the quota must be rejected (was a bypass).
items = [{
"type": "AUDIO_ITEM", "id": f"it_{i}", "name": "n",
"start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0,
"source_data": {"audio_file_url": "", "gain": 1.0},
} for i in range(20000)]
huge = json.dumps({
"project_id": "p1",
"metadata": {"title": "huge", "bpm": 120, "time_signature_numerator": 4,
"time_signature_denominator": 4, "sample_rate": 44100},
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
"length_bars": 16.0, "auto_compute_length": True,
"tracks": [{"id": "t", "name": "x", "type": "AUDIO", "items": items}]},
"section_store": {}})
r = client.put(f"/api/v1/projects/cloud/{pid}", headers=user_headers,
json={"name": "huge", "data_json": huge})
assert r.status_code == 400, r.text
assert "Quota" in r.json()["detail"]
# ── 6. Render engine: resampling correctness ──
class TestRenderResample:
def test_audio_item_resampled_to_engine_rate(self, tmp_path):
from app.core.render_engine import PythonRenderEngine
# 44.1kHz source, engine at 22.05kHz -> exactly 2x downsampling.
sr_src = 44100
t = np.arange(sr_src) / sr_src
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
src_path = os.path.join(settings.UPLOADS_DIR, "resample_test_tone.wav")
sf.write(src_path, tone, sr_src)
engine = PythonRenderEngine(sample_rate=22050)
session = {
"tracks": [{
"type": "AUDIO",
"volume_db": 0.0, "pan": 0.0, "mute": False,
"items": [{
"type": "AUDIO_ITEM",
"start_bar": 0.0, "duration_bars": 4.0,
"clip_start_offset_bars": 0.0,
"source_data": {"audio_file_url": "/static/audio/uploads/resample_test_tone.wav", "gain": 1.0},
}],
}]
}
buf = engine.render_session_container(session, {}, bpm=120.0, time_sig_num=4,
total_samples=engine.sample_rate * 2)
# A 1s 440Hz tone must actually render energy (previously the SR
# mismatch silently skipped the audio).
assert np.max(np.abs(buf)) > 0.01
# Duration should be ~1 second at the engine rate, not 2.
nonzero = np.where(np.abs(buf[0]) > 1e-4)[0]
assert len(nonzero) > 0
assert (nonzero[-1] - nonzero[0]) < int(engine.sample_rate * 1.3)
try:
os.remove(src_path)
except OSError:
pass
+993
View File
@@ -0,0 +1,993 @@
[
{
"id": "bca1bb5f-b656-48a8-b113-8fff900188ab",
"name": "Test",
"data_json": {
"project_id": "project_1784710097790",
"metadata": {
"title": "Test",
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16.0,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Track 01",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
}
]
},
"section_store": {}
},
"updated_at": 1784710143.4476569
},
{
"id": "d627f532-b632-4ef0-a21f-80dcc2ac8396",
"name": "Test",
"data_json": {
"project_id": "project_1784719724178",
"metadata": {
"title": "Test",
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16.0,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Track 01",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
}
]
},
"section_store": {}
},
"updated_at": 1784719770.23144
},
{
"id": "a17fe50b-c51d-408d-b45c-c09645777e68",
"name": "Test",
"data_json": {
"project_id": "project_1784719775936",
"metadata": {
"title": "Test",
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16.0,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Track 01",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
}
]
},
"section_store": {}
},
"updated_at": 1784719821.9856758
},
{
"id": "38a0f344-05dc-47e3-a769-07426c57f5fe",
"name": "Test",
"data_json": {
"project_id": "project_1784720540893",
"metadata": {
"title": "Test",
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16.0,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Cartoon Capers Loop.mp3",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
}
]
},
"section_store": {}
},
"updated_at": 1784720586.9746954
},
{
"id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
"name": "Rose",
"data_json": {
"project_id": "project_1784720555735",
"metadata": {
"title": "Rose",
"bpm": 120.0,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16.0,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Track 01",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"fx_chain": [],
"synth_engine": {
"plugin_id": "synth",
"preset_id": "default",
"parameters": {}
},
"items": []
}
]
},
"section_store": {}
},
"updated_at": 1784720601.8155787
},
{
"id": "525bdab5-e594-4a0c-a3e6-102a0abef816",
"name": "Rose (autosave 03/08)",
"data_json": {
"project_id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
"metadata": {
"title": "Rose (autosave 03/08)",
"bpm": 128,
"time_signature_numerator": 4,
"time_signature_denominator": 4,
"sample_rate": 44100
},
"main_session": {
"id": "main",
"name": "MAIN SESSION",
"is_root": true,
"length_bars": 16,
"auto_compute_length": true,
"tracks": [
{
"id": "1",
"name": "Track 01",
"type": "MIDI",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"instrument_id": "sf_DSK_Asian_DreamZ",
"instrument_program": 5,
"instrument_name": "BAN-DI",
"items": [
{
"id": "midi_1785057918746",
"name": "MIDI Item",
"type": "MIDI_ITEM",
"start_bar": 0,
"duration_bars": 4,
"clip_start_offset_bars": 0,
"source_data": {
"total_buffer_bars": 4,
"notes": [
{
"id": "note_1785057946413j2a54",
"pitch": 55,
"start_beat": 1.5,
"duration_beats": 0.125,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946488uv1f6r6q_2",
"pitch": 56,
"start_beat": 1.5166666666666666,
"duration_beats": 0.2333333333333334,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946505gxl5rre2_3",
"pitch": 57,
"start_beat": 1.75,
"duration_beats": 0.3833333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946522v0dmipvj_4",
"pitch": 60,
"start_beat": 2.1333333333333333,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579465381vqpeamx_5",
"pitch": 62,
"start_beat": 2.4,
"duration_beats": 0.21666666666666679,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946555rmpgksfp_6",
"pitch": 63,
"start_beat": 2.6166666666666667,
"duration_beats": 0.20000000000000018,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946572cvrxri09_7",
"pitch": 64,
"start_beat": 2.816666666666667,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946588nvmp7zld_8",
"pitch": 66,
"start_beat": 3.066666666666667,
"duration_beats": 0.1499999999999999,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946605jkbnh8mf_9",
"pitch": 67,
"start_beat": 3.216666666666667,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946638i28svktt_10",
"pitch": 68,
"start_beat": 3.4833333333333334,
"duration_beats": 0.1499999999999999,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579466559iy45ml8_11",
"pitch": 69,
"start_beat": 3.6333333333333333,
"duration_beats": 0.1333333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946672s712j5m6_12",
"pitch": 70,
"start_beat": 3.7666666666666666,
"duration_beats": 0.1333333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946688pq858zcx_13",
"pitch": 71,
"start_beat": 3.9,
"duration_beats": 0.18333333333333313,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579467054usc79tx_14",
"pitch": 72,
"start_beat": 4.083333333333333,
"duration_beats": 0.15000000000000036,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946722dspmlfaa_15",
"pitch": 73,
"start_beat": 4.233333333333333,
"duration_beats": 0.2999999999999998,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946755h5mvip3h_16",
"pitch": 74,
"start_beat": 4.533333333333333,
"duration_beats": 0.15000000000000036,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946772a5e5iona_17",
"pitch": 75,
"start_beat": 4.683333333333334,
"duration_beats": 0.16666666666666607,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946788cfqxk2c8_18",
"pitch": 76,
"start_beat": 4.85,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_178505794682247xy477z_19",
"pitch": 77,
"start_beat": 5.116666666666666,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946855nygukqis_20",
"pitch": 78,
"start_beat": 5.366666666666666,
"duration_beats": 0.20000000000000018,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946888oyfvmvq2_21",
"pitch": 79,
"start_beat": 5.566666666666666,
"duration_beats": 0.35000000000000053,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947455xrq7rfmm_22",
"pitch": 78,
"start_beat": 5.916666666666667,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579474722sn8bsed_23",
"pitch": 77,
"start_beat": 6.2,
"duration_beats": 0.31666666666666643,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947489mbv5z7q6_24",
"pitch": 76,
"start_beat": 6.516666666666667,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947505qmasllso_25",
"pitch": 75,
"start_beat": 6.8,
"duration_beats": 0.41666666666666696,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947539wkqyr6fw_26",
"pitch": 74,
"start_beat": 7.216666666666667,
"duration_beats": 0.18333333333333357,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947572a2ydjwzu_27",
"pitch": 73,
"start_beat": 7.4,
"duration_beats": 0.1999999999999993,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947589b18pcser_28",
"pitch": 72,
"start_beat": 7.6,
"duration_beats": 0.16666666666666696,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947605bsiv751r_29",
"pitch": 71,
"start_beat": 7.766666666666667,
"duration_beats": 0.13333333333333375,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947622ii453i6x_30",
"pitch": 70,
"start_beat": 7.9,
"duration_beats": 0.18333333333333357,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947639zdv6seea_31",
"pitch": 69,
"start_beat": 8.083333333333334,
"duration_beats": 0.36666666666666536,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947672devle9p7_32",
"pitch": 68,
"start_beat": 8.45,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947689x8rgn7ih_33",
"pitch": 67,
"start_beat": 8.7,
"duration_beats": 0.3333333333333339,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947722u5b36hrl_34",
"pitch": 66,
"start_beat": 9.033333333333333,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947772f9qp4st0_35",
"pitch": 65,
"start_beat": 9.316666666666666,
"duration_beats": 0.38333333333333286,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947805cf5bjiv1_36",
"pitch": 64,
"start_beat": 9.7,
"duration_beats": 0.6666666666666679,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947855ojfcoccj_37",
"pitch": 63,
"start_beat": 10.366666666666667,
"duration_beats": 0.36666666666666536,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579478721jvhzolj_38",
"pitch": 62,
"start_beat": 10.733333333333333,
"duration_beats": 0.3000000000000007,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947889nqk9ckue_39",
"pitch": 61,
"start_beat": 11.033333333333333,
"duration_beats": 0.3333333333333339,
"velocity": 0.8,
"pan": 0
}
]
}
}
]
},
{
"id": "2",
"name": "Track 02",
"type": "AUDIO",
"volume_db": 0,
"pan": 0,
"mute": false,
"solo": false,
"instrument_id": null,
"instrument_program": null,
"instrument_name": null,
"items": []
}
]
},
"sub_tabs": [
{
"id": "midi_1785057919362",
"label": "Piano Roll: MIDI Item",
"type": "PIANO_ROLL",
"track_id": "1",
"target_id": "midi_1785057918746",
"parent_tab_id": null,
"notes": [
{
"id": "note_1785057946413j2a54",
"pitch": 55,
"start_beat": 1.5,
"duration_beats": 0.125,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946488uv1f6r6q_2",
"pitch": 56,
"start_beat": 1.5166666666666666,
"duration_beats": 0.2333333333333334,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946505gxl5rre2_3",
"pitch": 57,
"start_beat": 1.75,
"duration_beats": 0.3833333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946522v0dmipvj_4",
"pitch": 60,
"start_beat": 2.1333333333333333,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579465381vqpeamx_5",
"pitch": 62,
"start_beat": 2.4,
"duration_beats": 0.21666666666666679,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946555rmpgksfp_6",
"pitch": 63,
"start_beat": 2.6166666666666667,
"duration_beats": 0.20000000000000018,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946572cvrxri09_7",
"pitch": 64,
"start_beat": 2.816666666666667,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946588nvmp7zld_8",
"pitch": 66,
"start_beat": 3.066666666666667,
"duration_beats": 0.1499999999999999,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946605jkbnh8mf_9",
"pitch": 67,
"start_beat": 3.216666666666667,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946638i28svktt_10",
"pitch": 68,
"start_beat": 3.4833333333333334,
"duration_beats": 0.1499999999999999,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579466559iy45ml8_11",
"pitch": 69,
"start_beat": 3.6333333333333333,
"duration_beats": 0.1333333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946672s712j5m6_12",
"pitch": 70,
"start_beat": 3.7666666666666666,
"duration_beats": 0.1333333333333333,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946688pq858zcx_13",
"pitch": 71,
"start_beat": 3.9,
"duration_beats": 0.18333333333333313,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579467054usc79tx_14",
"pitch": 72,
"start_beat": 4.083333333333333,
"duration_beats": 0.15000000000000036,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946722dspmlfaa_15",
"pitch": 73,
"start_beat": 4.233333333333333,
"duration_beats": 0.2999999999999998,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946755h5mvip3h_16",
"pitch": 74,
"start_beat": 4.533333333333333,
"duration_beats": 0.15000000000000036,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946772a5e5iona_17",
"pitch": 75,
"start_beat": 4.683333333333334,
"duration_beats": 0.16666666666666607,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946788cfqxk2c8_18",
"pitch": 76,
"start_beat": 4.85,
"duration_beats": 0.2666666666666666,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_178505794682247xy477z_19",
"pitch": 77,
"start_beat": 5.116666666666666,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946855nygukqis_20",
"pitch": 78,
"start_beat": 5.366666666666666,
"duration_beats": 0.20000000000000018,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057946888oyfvmvq2_21",
"pitch": 79,
"start_beat": 5.566666666666666,
"duration_beats": 0.35000000000000053,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947455xrq7rfmm_22",
"pitch": 78,
"start_beat": 5.916666666666667,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579474722sn8bsed_23",
"pitch": 77,
"start_beat": 6.2,
"duration_beats": 0.31666666666666643,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947489mbv5z7q6_24",
"pitch": 76,
"start_beat": 6.516666666666667,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947505qmasllso_25",
"pitch": 75,
"start_beat": 6.8,
"duration_beats": 0.41666666666666696,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947539wkqyr6fw_26",
"pitch": 74,
"start_beat": 7.216666666666667,
"duration_beats": 0.18333333333333357,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947572a2ydjwzu_27",
"pitch": 73,
"start_beat": 7.4,
"duration_beats": 0.1999999999999993,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947589b18pcser_28",
"pitch": 72,
"start_beat": 7.6,
"duration_beats": 0.16666666666666696,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947605bsiv751r_29",
"pitch": 71,
"start_beat": 7.766666666666667,
"duration_beats": 0.13333333333333375,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947622ii453i6x_30",
"pitch": 70,
"start_beat": 7.9,
"duration_beats": 0.18333333333333357,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947639zdv6seea_31",
"pitch": 69,
"start_beat": 8.083333333333334,
"duration_beats": 0.36666666666666536,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947672devle9p7_32",
"pitch": 68,
"start_beat": 8.45,
"duration_beats": 0.25,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947689x8rgn7ih_33",
"pitch": 67,
"start_beat": 8.7,
"duration_beats": 0.3333333333333339,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947722u5b36hrl_34",
"pitch": 66,
"start_beat": 9.033333333333333,
"duration_beats": 0.2833333333333332,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947772f9qp4st0_35",
"pitch": 65,
"start_beat": 9.316666666666666,
"duration_beats": 0.38333333333333286,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947805cf5bjiv1_36",
"pitch": 64,
"start_beat": 9.7,
"duration_beats": 0.6666666666666679,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947855ojfcoccj_37",
"pitch": 63,
"start_beat": 10.366666666666667,
"duration_beats": 0.36666666666666536,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_17850579478721jvhzolj_38",
"pitch": 62,
"start_beat": 10.733333333333333,
"duration_beats": 0.3000000000000007,
"velocity": 0.8,
"pan": 0
},
{
"id": "note_1785057947889nqk9ckue_39",
"pitch": 61,
"start_beat": 11.033333333333333,
"duration_beats": 0.3333333333333339,
"velocity": 0.8,
"pan": 0
}
],
"duration": 7.5,
"instrument_program": 5,
"instrument_name": "BAN-DI",
"current_time": 0,
"color": null
}
],
"section_store": {}
},
"updated_at": 1785058087.45
}
]
+55
View File
@@ -0,0 +1,55 @@
// Sinh src-tauri/icons/favicon.ico tu app/templates/favicon.svg (icon exe).
// Dung: node tools/gen_favicon_ico.js
// (Can @resvg/resvg-js — npm install @resvg/resvg-js trong thu muc lam viec,
// hoac chay trong thu muc da cai. Output: src-tauri/icons/favicon.ico.)
// ICO chua cac size 16/24/32/48/64/128 — Windows dung cho exe icon.
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SVG = path.join(ROOT, 'app', 'templates', 'favicon.svg');
const OUT = path.join(ROOT, 'src-tauri', 'icons', 'favicon.ico');
let Resvg;
try {
({ Resvg } = require('@resvg/resvg-js'));
} catch (e) {
console.error('Thieu @resvg/resvg-js. Chay: npm install @resvg/resvg-js');
process.exit(1);
}
const svg = fs.readFileSync(SVG, 'utf8');
const SIZES = [16, 24, 32, 48, 64, 128];
function buildIco(images) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(images.length, 4);
const entries = [];
const datas = [];
let offset = 6 + 16 * images.length;
for (const { size, data } of images) {
const entry = Buffer.alloc(16);
const dim = size >= 256 ? 0 : size;
entry.writeUInt8(dim, 0);
entry.writeUInt8(dim, 1);
entry.writeUInt8(0, 2);
entry.writeUInt8(0, 3);
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(data.length, 8);
entry.writeUInt32LE(offset, 12);
entries.push(entry);
datas.push(data);
offset += data.length;
}
return Buffer.concat([header, ...entries, ...datas]);
}
const pngs = SIZES.map(s => ({
size: s,
data: new Resvg(svg, { fitTo: { mode: 'width', value: s }, background: 'rgba(0,0,0,0)' }).render().asPng(),
}));
fs.writeFileSync(OUT, buildIco(pngs));
console.log('favicon.ico ->', OUT, fs.statSync(OUT).size, 'bytes');
+59
View File
@@ -0,0 +1,59 @@
"""Sinh bo icon cho Tauri v2 + PyInstaller (chay 1 lan, co the tai chay).
Usage: python tools/gen_icons.py
Output: src-tauri/icons/{32x32,128x128,128x128@2x}.png, icon.png, icon.ico
"""
import os
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ICONS = os.path.join(ROOT, "src-tauri", "icons")
BG = (18, 20, 26, 255) # #12141a — trùng màu nền loader
FG = (77, 182, 255, 255) # xanh dương sáng
RING = (46, 52, 66, 255)
def _font(size):
for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"C:/Windows/Fonts/segoeuib.ttf"):
if os.path.exists(p):
return ImageFont.truetype(p, size)
return ImageFont.load_default()
def _draw(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# nền bo góc + viền
r = size // 8
d.rounded_rectangle([0, 0, size - 1, size - 1], radius=r, fill=BG)
d.rounded_rectangle([size // 32, size // 32, size - 1 - size // 32, size - 1 - size // 32],
radius=max(1, r - size // 32), outline=RING, width=max(2, size // 64))
# chữ "SF"
font = _font(int(size * 0.42))
text = "SF"
bbox = d.textbbox((0, 0), text, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
d.text(((size - w) / 2 - bbox[0], (size - h) / 2 - bbox[1]), text,
font=font, fill=FG)
return img
def main():
os.makedirs(ICONS, exist_ok=True)
sizes = {"32x32.png": 32, "128x128.png": 128, "128x128@2x.png": 256, "icon.png": 512}
imgs = []
for name, size in sizes.items():
img = _draw(size)
img.save(os.path.join(ICONS, name))
imgs.append((img, size))
print("generated", name)
# icon.ico: multi-size
base = _draw(256)
base.save(os.path.join(ICONS, "icon.ico"),
sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
print("generated icon.ico")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
"""Khôi phục các dự án cloud cũ (từ git history) vào bất kỳ sonicforge.db nào.
Cách dùng trên máy deployment thật (host game):
python3 tools/restore_cloud_projects.py /path/to/sonicforge.db
Script đọc backup projects (JSON) đã xuất từ git history chèn vào DB chỉ định,
gán tất cả cho user_id được yêu cầu (mặc định: admin đầu tiên tìm thấy).
"""
import json
import os
import sqlite3
import sys
PROJECTS_BACKUP = os.path.join(os.path.dirname(__file__), "cloud_projects_backup.json")
def main(db_path: str, user_id: str = None):
if not os.path.exists(PROJECTS_BACKUP):
print(f"Không tìm thấy {PROJECTS_BACKUP}")
return 1
if not os.path.exists(db_path):
print(f"Không tìm thấy DB: {db_path}")
return 1
with open(PROJECTS_BACKUP) as f:
projects = json.load(f)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
if user_id is None:
row = conn.execute("SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1").fetchone()
if not row:
print("Không có user admin nào trong DB")
conn.close()
return 1
user_id = row["id"]
print(f"Gán tất cả cho admin: {user_id}")
restored = 0
for p in projects:
if conn.execute("SELECT id FROM projects WHERE id=?", (p["id"],)).fetchone():
print(f" bỏ qua (đã tồn tại): {p['name']}")
continue
conn.execute(
"INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
(p["id"], user_id, p["name"], json.dumps(p["data_json"], ensure_ascii=False),
len(json.dumps(p["data_json"]).encode("utf-8")), p["updated_at"]))
restored += 1
print(f" đã khôi phục: {p['name']}")
conn.commit()
conn.close()
print(f"\nHoàn tất: {restored} dự án đã khôi phục cho user {user_id}")
return 0
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 restore_cloud_projects.py <path/to/sonicforge.db> [user_id]")
sys.exit(1)
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))
+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())
+1820
View File
File diff suppressed because it is too large Load Diff