158 Commits

Author SHA1 Message Date
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
3dtours 7325fbfc45 fix: câm toàn cục (IN peak có OUT peak không) - WaveShaper luôn identity curve không null + clamp tần số biquad dưới Nyquist + watchdog tự bypass master chain khi hỏng 2026-08-03 12:30:56 +07:00
3dtours 94b2d2ef41 fix: câm toàn cục + BiquadFilterNode state is bad - gỡ mastering khỏi getAudioContext (chỉ áp dụng ở initMasterBus/effect), time constant chậm hơn + cancelScheduledValues, toggleMasteringOnMaster luôn reconnect dù lỗi 2026-08-03 12:26:03 +07:00
3dtours f616edce49 fix: mất âm thanh khi nhấp đôi MIDI item vào Piano Roll - preload instrument bằng dedicated channel (assignTrackMidiChannel) thay vì trkIdx%16 2026-08-03 12:20:20 +07:00
3dtours 6018263044 fix: Lỗi không có âm thanh - quay lại ScriptProcessor vĩnh viễn (worklet push-model bị nghẽn main thread -> câm) 2026-08-03 12:13:26 +07:00
3dtours 36edc9daca fix: bật AudioWorklet hết deprecation ScriptProcessor + signature guard applyMasteringSettings hết BiquadFilterNode state is bad 2026-08-03 12:11:11 +07:00
3dtours 71278f2aba fix: mất soundfont khi ARM - clamp tham số master chain chặn NaN làm biquad state bad, toggleMasteringOnMaster idempotent, loadSoundFont retry khi lỗi thoáng qua 2026-08-03 11:44:13 +07:00
3dtours 022ba38a5e fix: piano roll tab không phát âm thanh - bỏ sessionBeatOffset để note phát theo item-relative khớp playhead (item ở vị trí != 0 trước đây schedule note ở now+startTime -> câm) 2026-08-03 11:34:23 +07:00
3dtours 71c3bafdb5 fix: không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định (worklet gây lỗi thiết bị), giữ worklet mono-safe + cache-busting 2026-08-03 11:24:59 +07:00
3dtours 34ad02dd6b fix: crash AudioWorklet khi output mono - xử lý mọi số channel trong fluidsynth-bridge + ép stereo outputChannelCount=2 2026-08-03 11:20:30 +07:00
3dtours 47b1633bd3 fix: ReferenceError assignTrackMidiChannel - chuyển allocator channel lên module-level cho PianoRollTabEditor + bật AudioWorklet thay ScriptProcessor 2026-08-03 11:17:19 +07:00
3dtours 0182abf7ea fix: play MIDI item luôn dùng instrument của track chứa item - sửa startLocalTrackPlayback thiếu channel/synth_engine + đồng nhất assignTrackMidiChannel mọi đường phát 2026-08-03 11:07:58 +07:00
3dtours 9b6de7f857 fix: sfload reset_presets=0 giữ nguyên instrument các track khác; preloadTrackInstruments module-level (sửa ReferenceError reload); truyền props cho ProfileModal; lọc warning No preset found channel 9 2026-08-03 11:00:08 +07:00
3dtours 84ab4ae823 feat: ARM multitrack mỗi track dùng MIDI channel riêng (0-15, trừ 9) - không làm đổi instrument track khác, lưu midi_channel khi save project 2026-08-03 10:49:41 +07:00
3dtours 25471e6ea5 fix: lag khi ARM sau reload + câm sau vài lần nhấn MIDI - dedup loadSoundFont (1 font=1 sfload, tránh cạn heap 256MB) + preload instrument khi mở/khôi phục dự án 2026-08-03 10:43:26 +07:00
3dtours 0dc95386f2 fix: ARM nhiều track phát sai instrument - synth_engine của track là nguồn quyết định thay vì channel state (bỏ che mask khi channel giữ instrument cũ/trùng) 2026-08-03 10:33:40 +07:00
3dtours d8227904b6 fix: MIDI ARM không phát âm thanh sau khi load instrument - lazy-load soundfont trong playNote + giữ channel khi selectInstrument truyền channel tường minh 2026-08-03 10:23:32 +07:00
3dtours 4d10b9485b FEAT: Media Explorer preview loop liên tục vùng chọn + margin 2px canvas 2026-08-03 07:56:19 +07:00
3dtours adccf6430a FIX: Media Explorer panel chọn tập tin ở thư mục nào thì di chuyển đến thư mục đó 2026-08-02 21:55:52 +07:00
3dtours 73025fc387 fix: instrument SF3/Sonatina không phát âm thanh - chuyển SF3->SF2, bỏ convert SF2->SF3, sửa converter 2026-08-02 21:44:49 +07:00
3dtours 9f762c2a78 FIX: Media Explorer panel preview nút Synth cho phép lọc nhanh nhạc cụ 2026-08-02 21:09:01 +07:00
3dtours a0b6110fd1 fix: Media Explorer canvas wheel dùng native non-passive listener 2026-08-02 20:57:05 +07:00
3dtours e88ce2e3ea FIX: Media Explorer panel preview cho phép zoom in zoom out và copy nội dung 2026-08-02 20:55:39 +07:00
3dtours 827693dfc4 FIX: Media Explorer panel hiển thị nội dung thư mục của máy client và thêm các link của các thư mục Favorited 2026-08-02 20:31:57 +07:00
3dtours d04631c7d3 feat: My Computer hiển thị cây thư mục client-side qua directory picker 2026-08-02 19:06:35 +07:00
3dtours 224fd54a56 fix: My Computer hiển thị ổ đĩa/thư mục, fallback khi client handle hết quyền 2026-08-02 18:59:35 +07:00
3dtours befc0d35fd fix: My Computer luôn hiển thị cây hệ thống client, bỏ restore folder cũ 2026-08-02 18:55:14 +07:00
3dtours 5e3a283bd9 fix: My Computer hiển thị cây hệ thống thay vì Favorites session cũ 2026-08-02 18:47:39 +07:00
3dtours 4a04bc04ef fix: My Computer client-side tự động quét cây không mở picker 2026-08-02 18:41:54 +07:00
3dtours dc384b3102 fix: My Computer auto-scan không mở picker, playhead chạy tới cuối canvas 2026-08-02 18:35:02 +07:00
3dtours 0b9079632a feat: My Computer auto-load cây + Favorites thư mục 2026-08-02 18:30:19 +07:00
3dtours 9be6991222 fix: canvas audioclip hiển thị theo tỉ lệ tempo, không giãn khung 2026-08-02 18:20:48 +07:00
3dtours 0853a13f62 feat: fix canvas audio, drag&drop Media Explorer vào timeline 2026-08-02 18:10:02 +07:00
3dtours c09f994ede feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path 2026-08-02 18:01:34 +07:00
3dtours 249e2afea2 feat: canvas scroll preview, giữ state khi toggle, restore session folder/files 2026-08-02 17:47:44 +07:00
3dtours b65188fcc8 fix: canvas MIDI lần đầu + thêm Tempo control preview 2026-08-02 17:35:36 +07:00
3dtours ba5883951d fix: MIDI auto-play đọc nhầm file cũ, canvas/audio không đồng bộ 2026-08-02 17:26:35 +07:00
3dtours 7000529d8d fix: MIDI preview dừng file cũ khi click file midi khác 2026-08-02 17:18:08 +07:00
3dtours 1de6bd6b57 feat: Media Explorer persist tree width, canvas realtime, Synth instrument preview MIDI 2026-08-02 17:08:54 +07:00
3dtours c435d00897 fix: Media Explorer preview cập nhật realtime khi chọn file 2026-08-02 16:48:11 +07:00
3dtours b075a5eca7 feat: Media Explorer resize chiều rộng cây thư mục 2026-08-02 16:40:14 +07:00
3dtours 36ee2bdf90 fix: My Computer client liệt kê folder+files, điều hướng file list đúng 2026-08-02 14:53:37 +07:00
3dtours 5423686c73 feat: My Computer mở explorer client qua File System Access API 2026-08-02 14:47:54 +07:00
3dtours 4db777eb5d feat: Media Explorer duyệt toàn bộ folder và files (tree đệ quy) 2026-08-02 14:31:42 +07:00
3dtours 1989bfb105 fix: Window Explorer liệt kê ổ đĩa thật + xử lý lỗi API 2026-08-02 14:27:03 +07:00
3dtours d1995307f5 feat: Media Explorer My Computer truy cập filesystem thật 2026-07-31 13:08:54 +07:00
3dtours f647291523 FEAT: thêm Media explorer panel 2026-07-31 13:02:38 +07:00
3dtours fdaabe3a08 feat: Media Explorer panel (F6) với mutual exclusion Mixer (F7) 2026-07-31 11:59:20 +07:00
3dtours 548ab1258f FIX: sửa lỗi play MIDI items bị cracking 2026-07-31 10:58:20 +07:00
3dtours 2c2ee59d7d fix: bump soundfontPlayer cache version 2026-07-31 10:52:35 +07:00
3dtours 7e5f406226 fix: crackling - time-based render buffer, no voice stacking, lower synth gain/verbose 2026-07-31 10:52:22 +07:00
3dtours ad5cda5a40 fix: reduce MIDI playback crackling (no per-note prog reload, underrun-proof render loop, gentle stopAll) 2026-07-31 10:49:31 +07:00
3dtours 05e6a467d8 FIX: sửa lỗi drag and drop vào track thì sẽ mở midi file ở track đó 2026-07-31 10:47:33 +07:00
3dtours 4b43f027ce fix: drop MIDI on existing track loads into that track 2026-07-31 10:44:22 +07:00
3dtours 175bce6743 FIX: sửa lỗi nhấn SOLO không play realtime 2026-07-31 10:39:01 +07:00
3dtours 3380b0e201 feat: realtime solo toggle during playback 2026-07-31 10:37:52 +07:00
3dtours 619a2484ce FIX: sửa lỗi vẫn diễn anim khi track đã bị mute 2026-07-31 10:34:48 +07:00
3dtours 24ac703d4d fix: track VU meter stays idle when track not audible (solo/mute) 2026-07-31 10:33:14 +07:00
3dtours c47eb5d718 FIX: sửa lỗi bật nút POWER FX nhưng âm thanh vẫn bị bypass 2026-07-31 10:29:36 +07:00
3dtours 3a345c3f4e fix: master strip PWR button now un-bypasses mastering when enabled 2026-07-31 10:27:16 +07:00
3dtours 86b304e173 fix: piano roll dropdown lists tracks only, not every MIDI item 2026-07-31 10:22:35 +07:00
3dtours 01d02a9d71 FIX: cho phép click và active, sửa các MIDI item trong cùng track 2026-07-31 10:21:19 +07:00
3dtours 2bf9f8bcb8 feat: click wait-to-active note switches to editing that MIDI item 2026-07-31 10:19:39 +07:00
3dtours 99a445b789 fix: selected dim note's MIDI item wins focus over opened item 2026-07-31 10:13:49 +07:00
3dtours ee39dda5b9 feat: ctrl+click dim note selects it and focuses its MIDI item 2026-07-31 10:10:32 +07:00
3dtours be8430fbd9 revert: remove left-column MIDI item buttons (ghost note click focuses item) 2026-07-31 10:07:53 +07:00
3dtours f1b00eb060 feat: all same-track MIDI items selectable to focus in piano roll left column 2026-07-31 10:06:42 +07:00
3dtours d81afba93d feat: click note focuses its MIDI item in piano roll 2026-07-31 10:02:49 +07:00
3dtours 223254eb07 feat: per-MIDI-item focus brightness in piano roll 2026-07-31 09:59:14 +07:00
3dtours 698fa818e9 fix: focused MIDI item notes brighter than siblings 2026-07-31 09:53:17 +07:00
3dtours 60d05e2b4d feat: opened MIDI item notes darker than same-track siblings 2026-07-31 09:50:54 +07:00
3dtours 0ea994fb2f FIX: active tất cả các note của các MIDI item cùng track 2026-07-31 09:48:42 +07:00
3dtours eb370319cb feat: piano roll shows all same-track MIDI items notes as active 2026-07-31 09:46:55 +07:00
3dtours 9b25766fca FIX: drag and drop vào khung track timeline thì tự động tạo track cho midi 2026-07-31 09:40:50 +07:00
3dtours dfbeb24a33 feat: drag-drop MIDI file creates new tracks 2026-07-31 09:31:10 +07:00
3dtours 3c4f106e14 FIX: lỗi khi play MIDI thì cho phép active multi track khác realtime 2026-07-31 09:26:51 +07:00
3dtours 4f5aee6830 fix: multitrack play select plays immediately in realtime 2026-07-31 09:21:39 +07:00
3dtours 42d64f93d8 feat: piano roll loads track instrument + multi-track play select 2026-07-31 08:50:14 +07:00
3dtours 6c11daccf8 FIX: lỗi khi play MIDI thì cho phép active các track khác realtime 2026-07-31 08:47:37 +07:00
3dtours 88007dce34 feat: click track name triggers realtime MIDI playback 2026-07-31 08:39:45 +07:00
3dtours 1790f743d6 fix: add 400ms decay to MIDI noteoff for natural release 2026-07-31 08:26:06 +07:00
3dtours ef6370f92e FIX: lỗi cắt MIDI note thành nhiều notes 2026-07-31 08:23:38 +07:00
3dtours 3cac617aff fix: drag-move delta relative to clicked note, not first selected 2026-07-31 08:20:15 +07:00
3dtours e1550c8ffe fix: insert noteB after noteA in split, not at end of array 2026-07-31 08:15:56 +07:00
3dtours c60803ee2a fix: bump precompiled cache version for piano roll split fix 2026-07-31 08:10:06 +07:00
3dtours 9c5507f7b2 feat: MIDI note duration inheritance & Ctrl+Shift+click split 2026-07-31 08:01:52 +07:00
3dtours 47e87c8363 FIX: lỗi load dự án và hiển thị mixer panel 2026-07-31 07:51:18 +07:00
3dtours 652fb98d2c fix: resolve 3 bugs - panText undefined, cloud project load, auto-restore error handling 2026-07-31 07:46:47 +07:00
66 changed files with 16696 additions and 1347 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/
+17
View File
@@ -0,0 +1,17 @@
# ── 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
+4 -1
View File
@@ -28,4 +28,7 @@ samples/
.vscode/
*.log
celerybeat-schedule
node_modules
node_modules
src-tauri/target/
src-tauri/binaries/
src-tauri/vc_redist.x64.exe
+87
View File
@@ -0,0 +1,87 @@
# DESKTOP INSTALL PLAN — SonicForge Studio (bản cài trực tiếp trên OS)
Mô hình: **server chạy nền trên máy người dùng (localhost), client mở bằng browser**.
Không cần Electron — tận dụng stack hiện có (FastAPI + static JS precompiled).
Soundfonts do **người dùng tự tải vào thư mục riêng****khai báo** cho ứng dụng.
---
## 1. KIẾN TRÚC
```
┌─────────────────────────── NGƯỜI DÙNG (1 máy) ───────────────────────────┐
│ Browser ──http://127.0.0.1:8000──▶ FastAPI (uvicorn, chạy NỀN) │
│ │ │
│ ~/SonicForgeStudio/ ├─ data/ (DB, uploads, cache) │
│ ├─ data/sonicforge.db ├─ soundfonts/ (user tự bỏ .sf2) │
│ ├─ soundfonts/*.sf2/.sf3 └─ quét nền 30s (scanner có sẵn) │
│ └─ config.json ← khai báo folder soundfont │
└──────────────────────────────────────────────────────────────────────────┘
```
## 2. ĐÓNG GÓI CÀI ĐẶT (per-OS) — server nền + browser client
### Thành phần đóng gói
| Thành phần | Vai trò |
|---|---|
| Python backend (`app/`, `requirements.txt`) | Server — **PyInstaller `--onedir`** → binary (không cần Python trên máy user; bảo vệ source tốt hơn so với chạy .py) |
| `app/static/` (precompiled.js + css + vendor) | Client — đóng vào package, server phục vụ |
| VST3 plugins (`vst_plugins/`) | Copy theo platform (win .dll / mac .vst3 / linux .so) |
| Service wrapper | Tự khởi động server nền + mở browser khi login |
### Cài đặt + service nền theo OS
| OS | Installer | Service nền | Auto-open browser |
|---|---|---|---|
| **Windows** | Inno Setup (`.exe`) | Windows Service qua `NSSM`/`WinSW` hoặc Task Scheduler (logon) | `start http://127.0.0.1:8000` |
| **macOS** | `.dmg` + `.app` (PyInstaller) | `launchd` LaunchAgent (`~/Library/LaunchAgents`) | `open http://127.0.0.1:8000` |
| **Linux** | `.deb` / `.AppImage` | systemd **user** service (`~/.config/systemd/user/`) | `xdg-open http://127.0.0.1:8000` |
- Server bind **`127.0.0.1`** (không lộ mạng), port mặc định 8000 (config được).
- App gồm 2 tiến trình nhỏ: `web` (uvicorn) + `worker` (celery) — hoặc gộp worker vào web ở chế độ desktop (đơn giản: `--pool=solo`, chạy celery trong tiến trình riêng nếu cần render nặng).
## 3. THƯ MỤC DỮ LIỆU NGƯỜI DÙNG
```
~/SonicForgeStudio/
├── data/ # DB, uploads, processed (thay app/storage khi chạy desktop)
├── soundfonts/ # USER tự tải .sf2/.sf3 vào đây (mặc định được quét)
└── config.json # cấu hình: soundfont_dirs, port, autostart...
```
- `config.py`: thêm `DATA_DIR` (env `SFDATA_DIR`, mặc định `~/SonicForgeStudio/data`), `SOUNDFONT_DIRS`.
## 4. SOUNDFONT DO NGƯỜI DÙNG QUẢN LÝ (tải + khai báo)
**Cơ chế hiện có (tận dụng):** `app/core/soundfont_scanner.py``SoundFontAutoScanner` quét nền 30s
`/opt/daw_engine/soundfonts` + `storage/soundfonts` → catalog → API list qua `app/api/v1/plugins.py`.
**Việc cần làm (mở rộng):**
1. **Scanner nhận folder người dùng:** constructor nhận thêm `user_dirs` (từ `SOUNDFONT_DIRS` env + `config.json`) — merge vào catalog (ưu tiên: user > system).
2. **Khai báo folder — 2 cách:**
- **UI** (chính): Settings → "Soundfonts" → nút **Add folder…** (chọn thư mục chứa .sf2) → lưu vào `config.json` → gọi scanner rescan.
- **config.json** (thủ công): `{ "soundfont_dirs": ["D:/SF", "/Users/me/sf"] }`.
3. **API:** thêm endpoint `POST /api/v1/plugins/soundfonts/dirs` (đăng ký folder) + `GET .../dirs` (liệt kê) — scanner reload.
4. **UI danh sách:** hiển thị catalog (tên SF, kích thước, folder nguồn) — chọn = load vào FluidSynth (luồng có sẵn qua `SonicSF.selectInstrument`).
5. **Số hóa:** không upload file — server đọc trực tiếp từ đường dẫn user khai báo (không nhân đôi dữ liệu).
## 5. BUILD + PHÁT HÀNH
```
code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đóng installer theo OS
→ ký số (tùy chọn) → phát hành (GitHub Releases / trang riêng)
```
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
## 6. CẬP NHẬT
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
- `?v=` cache-bust JS mỗi bản (cơ chế đã có) — browser không dính cache cũ.
## 7. CHECKLIST CODE CẦN LÀM
- [ ] `config.py`: `DATA_DIR`, `SOUNDFONT_DIRS` (env + config.json)
- [ ] `soundfont_scanner.py`: nhận `user_dirs`, merge catalog, ưu tiên user
- [ ] API: `POST/GET .../soundfonts/dirs` (đăng ký/liệt kê folder) + rescan
- [ ] UI Settings → Soundfonts (Add folder, Browse, list, load)
- [ ] `soundfontStorage.js`: chuyển hướng sang catalog folder-scan (giữ upload path cho dự án cũ)
- [ ] PyInstaller spec: bundle static/ + vendor (libfluidsynth wasm, vst), bind 127.0.0.1
- [ ] Service wrappers: Windows (NSSM/WinSW), macOS (launchd), Linux (systemd user) + auto-open browser
- [ ] Installer scripts: Inno Setup (.exe), dmg (macOS), deb/AppImage (Linux)
- [ ] CI matrix build 3 OS + release artifacts
- [ ] `version.json` + in-app update check
+85
View File
@@ -0,0 +1,85 @@
# DISTRIBUTION PLAN — SonicForge Studio (Alpha → Beta → Production)
Mục tiêu: phân phối ứng dụng cho người dùng **không chia sẻ mã nguồn**, có pipeline
cập nhật liên tục. Áp dụng cho backend Python/FastAPI + frontend JS (Babel precompiled).
---
## 1. TÁCH BÍ MẬT — FILE .env
| Biến | Dùng cho | Bắt buộc |
|---|---|---|
| `REDIS_URL` | Redis/Celery broker | ✓ |
| `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` | Celery | ✓ |
| `OPENAI_API_BASE` / `OPENAI_API_KEY` | LLM server-side (client KHÔNG thấy key — `aiGateway.js` chỉ gọi proxy server) | ✓ |
| `SECRET_KEY` | Auth token (app/core/auth.py) | ✓ (mặc định trống → phải set) |
| `DEFAULT_ADMIN_PASSWORD` | Admin mặc định | thay đổi ở production |
- File `.env.example` đã tạo (commit được). `.env` thật KHÔNG commit (đã vào `.dockerignore`).
- **Nguyên tắc:** client JS không bao giờ chứa secret — mọi API key nằm server (env) hoặc proxy.
## 2. BUILD IMAGE — KHÔNG CHIA SẺ MÃ NGUỒN
### 2.1 Chặn source khỏi image (`.dockerignore` — đã tạo)
Loại khỏi build context: `.git`, `app/static/js/app.jsx` (source JSX), `wiki.md`, `plans/`, `*.patch`, `.env`, `tests/`, `node_modules`, backup...
### 2.2 Frontend — chỉ ship bản precompiled
- Build JS: `node build.mjs` (Babel → `app.precompiled.js` minified) — **CHỈ bản minified vào image**, source `.jsx` bị `.dockerignore` chặn.
- Bump `?v=` (cache-bust) mỗi bản phát hành — user cập nhật không dính cache cũ.
### 2.3 Backend Python — giới hạn đọc source
- Python không biên dịch native mặc định → `.py` vẫn đọc được trong image. 3 lớp bảo vệ:
1. **Registry riêng tư** (GHCR / Docker Hub private / Harbor) — image KHÔNG public — người dùng chỉ nhận qua pull có auth.
2. **Secret chỉ qua env** — không hardcode gì trong image.
3. *(Tùy chọn, giai đoạn sau)* compile Python bằng **Nuitka**`.so` cho `app/core`, `app/api` (giữ `main.py`/`config.py` đọc được — không chứa bí mật).
### 2.4 Cách build + push
```bash
# 1. Precompile JS + bump ?v=
node build.mjs # hoặc: NODE=... babel ... (xem wiki)
# 2. Build image (tag theo version)
docker build -t ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1 .
docker push ghcr.io/<org>/sonicforge-studio:v1.0.0-alpha.1
# 3. Production chạy image đã push (KHÔNG mount source):
docker compose -f docker-compose.prod.yml up -d
```
- Compose production (`docker-compose.prod.yml` — đã tạo): image từ registry, `env_file: .env`,
volumes bền (uploads/processed/db), asset dirs qua env (`VST3_DIR`, `SOUNDFONTS_DIR`, `PIANOBOOK_DIR`),
KHÔNG có `.:/app` (bỏ mount dev của compose cũ).
## 3. KẾ HOẠCH PHÁT HÀNH
### Giai đoạn 1 — ALPHA (nội bộ dev/QA)
- Tag: `v0.x.x-alpha.N` — chạy `docker compose up --build` (dev mount OK).
- Log verbose; chưa quan tâm bảo mật; test tính năng + thu hồi nhanh.
- Mỗi thay đổi: bump `?v=` → build → tag → ghi wiki.md.
### Giai đoạn 2 — BETA (nhóm người dùng mời)
- Tag: `v0.x.x-beta.N` — image push **registry riêng tư**.
- Người dùng: `docker compose -f docker-compose.prod.yml pull && up -d` (chỉ cần `.env` + image).
- Thu log lỗi: thêm endpoint `/health` + (tùy chọn) Sentry/self-host error tracking.
- **Feature flags** (biến env `FEATURE_*`) để tắt tính năng rủi ro từ xa.
### Giai đoạn 3 — PRODUCTION
- Tag: `vX.Y.Z` (semver) + `latest`.
- **Trước khi release:** backup volumes (`docker run --rm -v sf_db:/data -v $PWD:/backup alpine tar czf /backup/db-<ver>.tgz /data`), migrate dữ liệu nếu schema đổi.
- Triển khai: pull image mới → recreate (rolling — web trước, worker sau) → healthcheck.
- `SECRET_KEY`, `DEFAULT_ADMIN_PASSWORD`, API keys: quản lý qua secret manager (Docker secrets / vault) — không ở compose file.
## 4. PIPELINE CẬP NHẬT (lặp lại mỗi release)
```
code mới → build.mjs (precompiled + ?v=) → docker build (tag mới)
→ push registry → [beta/prod] pull + recreate → backup trước nếu prod → kiểm tra /health
```
- Phiên bản: `git tag vX.Y.Z` — build tag tự động từ git (`git describe --tags`).
- Rollback: giữ tag cũ — `docker compose -f docker-compose.prod.yml up -d` với `IMAGE=...:<tag cũ>`.
## 5. VIỆC CẦN LÀM (checklist)
- [x] `.env.example` — liệt kê đủ 7 biến
- [x] `.dockerignore` — chặn source/secret/docs
- [x] `docker-compose.prod.yml` — production (registry + env_file + volumes, bỏ mount source)
- [ ] Kiểm tra endpoint `/health` (tạo nếu chưa có — healthcheck compose đang trỏ tới)
- [ ] Chọn registry (GHCR/Docker Hub private/Harbor) + tạo token CI
- [ ] Nuitka compile `app/core`+`app/api` (tùy chọn — nếu cần bảo vệ backend chặt hơn)
- [ ] CI (GitHub Actions): test → build → push tag → deploy beta tự động
- [ ] Backup script volumes trước mỗi production release
+40
View File
@@ -0,0 +1,40 @@
MIT License
Copyright (c) 2026 SonicForge Studio contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Third-party components
Code viết riêng của dự án (app/static/js/services/*, components/*, app.jsx)
thuộc license MIT trên.
Các thành phần bên thứ ba giữ license gốc:
- **React / react-dom** — MIT
- **Babel** (@babel/cli, core, preset-react) — MIT
- **jsdom** — MIT
- **Tailwind CSS** (app/static/css/tailwind.min.css) — MIT
- **Lucide icons** — ISC
- **FluidSynth 2.3.0** (app/static/js/vendor/libfluidsynth-2.3.0-sf3.js/.wasm) —
GNU LGPL v2.1+ — xem https://www.gnu.org/licenses/lgpl-2.1.html
Chi tiết + attribution từng thành phần: xem THIRD_PARTY_LICENSES.md.
+95
View File
@@ -0,0 +1,95 @@
# TEST & DEBUG PLAN — SonicForge DAW Standalone (Windows .exe / .msi)
Áp dụng cho bản đóng gói Tauri v2 + PyInstaller (xem `DESKTOP_INSTALL_PLAN.md`,
`build_windows.ps1`, `engine.spec`, `src-tauri/`).
---
## 0. Cổng kiểm soát TRƯỚC khi build (pre-build gates — chạy trên máy dev)
```powershell
# 1. Backend tests (repo đã có 86 tests pytest)
python -m pytest tests/ -x -q
# 2. Frontend syntax + bundle
node --check app/static/js/app.precompiled.js
node build.mjs # rebuild app.precompiled.js từ app.jsx
# 3. Python syntax của entry + spec
python -m py_compile desktop_engine.py engine.spec app/config.py app/tasks/worker.py
# 4. Chạy thử engine độc lập (chưa cần Tauri)
python desktop_engine.py # mở browser: http://127.0.0.1:8000/docs
```
Gate: tất cả PASS mới chạy `build_windows.ps1`.
---
## 1. TEST PLAN — theo 6 kịch bản của tài liệu gốc
| # | Kịch bản | Cách kiểm thử | Kết quả mong đợi |
|---|---|---|---|
| 1 | **Lifecycle & Process Spawn** | Mở app → Task Manager tìm `daw_engine.exe` → đóng cửa sổ → kiểm tra lại | Engine xuất hiện lúc launch; BIẾN MẤT hoàn toàn khi đóng (không orphan). Engine còn có watchdog: nếu cha chết đột ngột, tự thoát ≤1s |
| 2 | **Port 8000 & Fallback** | Mở app → browser truy cập `http://localhost:8000/docs`; hoặc chiếm sẵn port 8000 (vd `net stop` dịch vụ khác / chạy server khác) rồi mở app | Swagger UI hiển thị. Khi 8000 bị chiếm → engine tự chuyển 8001…8010, loader page trong app tự redirect tới port đúng |
| 3 | **VST3 Local Scan (Windows)** | Plugin Manager → set path `C:\Program Files\Common Files\VST3`**[Scan]** | Phát hiện đúng VST3 đã cài (Vital, Surge XT, FabFilter…). Scan state lưu ở `%APPDATA%\SonicForgeDAW\storage\sf_scan_state.json` |
| 4 | **Isolation & Crash Protection** | Load 1 VST3 không ổn định / giả lập crash | UI hiện thông báo lỗi, cửa sổ DAW vẫn phản hồi (tiến trình engine riêng — shell không chết theo) |
| 5 | **Storage Persistence (`%APPDATA%`)** | Mở Explorer tới `%APPDATA%\SonicForgeDAW\storage\` | Có `sonicforge.db`, `sf_scan_state.json`, `uploads/`, `processed/`, `soundfonts/`**không** nằm trong thư mục cài đặt (onefile giải nén tạm sẽ bị xóa khi thoát) |
| 6 | **Audio Export / Bouncing** | Click **Export WAV** ở chế độ Standalone | File `.wav` render đúng (VST3 + SoundFont qua Pedalboard/FluidSynth) |
### 1.1 Kiểm thử cài đặt (installer)
- **NSIS**: cài trên máy sạch (không có Python, không có VC++ redist) → app chạy được, `hooks.nsh` tự cài VC redist. Gỡ cài bằng Control Panel → không còn process, không còn shortcut.
- **MSI**: cài qua `msiexec /i SonicForgeDAW_1.0.0_x64_en-US.msi` (hoặc double-click) → tương đương; kiểm tra repair/uninstall.
- **Nâng cấp**: cài bản mới đè bản cũ → `%APPDATA%\SonicForgeDAW\storage` GIỮ NGUYÊN (dữ liệu người dùng không mất).
- **SmartScreen/AV**: lần đầu chạy có thể bị chặn ("More info → Run anyway"); bản production phải ký số (signtool + certificate DigiCert/Sectigo).
### 1.2 Kiểm thử hồi quy (regression — sau mỗi thay đổi code)
- `pytest tests/` — 86 test hiện có (conftest fixture dùng test client).
- Smoke UI thủ công: play/pause, piano roll, FX rack, save/load project, soundfont scan.
---
## 2. DEBUG WORKFLOWS
### 2.1 Engine (Python sidecar) không start / crash âm thầm
1. Mở log: `%APPDATA%\SonicForgeDAW\logs\engine.log` (desktop_engine.py ghi log ra file vì `console=False`).
2. Chạy thủ công engine có console: sửa `console=True` trong `engine.spec` → rebuild → chạy `dist\daw_engine.exe` từ cmd → xem traceback trực tiếp.
3. Test độc lập không cần Tauri: `python desktop_engine.py` (dev) — đúng code path, có stdout.
### 2.2 UI (WebView2)
- **F12 / Ctrl+Shift+I** trong cửa sổ app: mở DevTools (đã bật `"devtools": true` trong `tauri.conf.json`) — Console, Network, Web Audio context.
- `devUrl: "http://localhost:8000"` — bản dev có thể chạy `tauri dev` với engine chạy tay.
### 2.3 Sidecar không spawn / không thấy engine
- Kiểm tra `src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe` có tồn tại (build script copy tự động).
- Chạy `npx tauri build` lần đầu: nếu thiếu sidecar → lib.rs `expect("sidecar daw_engine not found")` fail rõ ràng.
- Kiểm tra port bị chiếm: `netstat -ano | findstr :8000`.
### 2.4 Windows build lỗi (máy dev)
- Rust toolchain: `rustup default stable-msvc` (bắt buộc MSVC toolchain cho Tauri Windows).
- WebView2 runtime: Win10/11 có sẵn; máy cũ cài [WebView2 Evergreen](https://developer.microsoft.com/microsoft-edge/webview2/).
- Nếu `tauri build` lỗi NSIS/MSI riêng: build riêng từng target `npx tauri build --bundles nsis` / `--bundles msi`.
---
## 3. Known Gaps (đã xử lý / chấp nhận)
| Vấn đề | Trạng thái |
|---|---|
| Celery cần Redis broker | **Đã xử lý**: `SF_DESKTOP=1``task_always_eager=True` (chạy đồng bộ trong tiến trình, `memory://` broker + `cache+memory://` backend). Chế độ docker/cloud không đổi |
| Storage khi frozen (onefile giải nén tạm) | **Đã xử lý**: `config.py` freeze-aware → writable data về `%APPDATA%\SonicForgeDAW\storage`, assets read-only trong `_MEIPASS` |
| Port 8000 bị chiếm | **Đã xử lý**: probe 80008010 + loader page redirect tự động |
| `engine.spec` console=False → mù log | **Đã xử lý**: log ra file `%APPDATA%\SonicForgeDAW\logs\engine.log` |
| Soundfont scanner path `/opt/daw_engine/soundfonts` (Linux-only) | **Chấp nhận**: cần xác minh scanner bỏ qua path không tồn tại trên Windows (test scenario #5) |
| `child.kill()` = TerminateProcess (không graceful 100%) | **Giảm thiểu**: watchdog parent-PID trong `desktop_engine.py` → engine tự shutdown sạch khi cha chết |
---
## 4. Checklist phát hành (release)
- [ ] Pre-build gates PASS (mục 0)
- [ ] `build_windows.ps1` chạy sạch, đủ 6 bước
- [ ] 6 kịch bản test (mục 1) PASS trên máy sạch
- [ ] Installer NSIS + MSI cài/gỡ/nâng cấp đúng
- [ ] Ký số (production) — SmartScreen hết cảnh báo
- [ ] Commit artifacts: `engine.spec`, `desktop_engine.py`, `src-tauri/`, `build_windows.ps1`, `tools/gen_icons.py`
+37
View File
@@ -0,0 +1,37 @@
# THIRD-PARTY LICENSES — SonicForgeStudio
Kiểm tra ngày 2026-08-07. Danh sách thành phần bên thứ ba + license.
## 1. Dependencies (npm — package.json)
| Thành phần | License | Ghi chú |
|---|---|---|
| react / react-dom 19.x | MIT | OK |
| @babel/cli, @babel/core, @babel/preset-react | MIT | Chỉ build-time |
| jsdom | MIT | Chỉ build-time/test |
## 2. Thư viện nhúng (app/static)
| File | Nguồn | License | Trạng thái |
|---|---|---|---|
| `css/tailwind.min.css` | Tailwind CSS | MIT | OK — nên giữ attribution |
| `js/services/spessasynth_processor.min.js` | SpessaSynth (KHÔNG còn dùng — không được include trong index.html — chỉ FluidSynth) | MIT | ✅ KHÔNG có code nào gọi (chỉ còn comment cũ app.jsx:14276 + file chết) — có thể xóa file |
| `js/vendor/libfluidsynth-2.3.0-sf3.js` + `.wasm` | FluidSynth 2.3.0 (Emscripten) | **LGPL-2.1+** | ⚠️ Bắt buộc giữ license notice + attribution + (nếu phân phối bản build) cung cấp link/tài liệu LGPL |
| `js/services/fluidsynthLoader.js`, `worklets/fluidsynth-bridge.js` | Bản tự viết (bọc FluidSynth) | Dự án | OK |
| Lucide icons (inline `data-lucide`) | Lucide | ISC (MIT-compatible) | OK |
## 3. Nội dung âm thanh (app/storage)
| File | Nguồn | License | Trạng thái |
|---|---|---|---|
| `soundfonts/518e850f-...sf2` | "General MIDI SoundFont v3.0" — © 2006-2010 Rich "Weeds" Nagel — "Some rights reserved" | Không có text đầy đủ trong file (chỉ ICOP ngắn). Tuyên bố: "created from various **commercial**, custom, and freeware soundfonts and samples" | ⚠️ RỦI RO: (a) điều kiện "some rights reserved" không rõ ràng (thường là CC-BY — cần ghi attribution; có thể hạn chế thương mại); (b) samples gốc có nguồn commercial — quyền tái phân phối phụ thuộc tuyên bố tác giả. **Khuyến nghị: thay bằng SF2 license rõ (FluidR3_GM — MIT/GPL-2; GeneralUser GS — CC-BY-SA; Arachno — public domain) HOẶC giữ + ghi attribution đầy đủ.** |
| `uploads/user_anonymous_*.mp3` | File người dùng upload | Thuộc người dùng | OK — không phải thành phần phân phối |
| SGM-V2.01 (xuất hiện trong log `sfId: SGM-V2.01`) | SGM-V2.01 | Freeware — tác giả cho phép dùng nhưng **hạn chế redistribution** (cần permission) | ⚠️ Nếu vẫn dùng để phân phối bản build — cần permission từ tác giả; không nhúng vào sản phẩm |
## 4. Assets
- `templates/favicon.svg`, `images/SonicForgeUI.png` — nội bộ (tự tạo) — OK.
- Code trong `app/static/js/services/*`, `app/static/js/components/*`, `app.jsx` — tự viết — thuộc dự án.
## KẾT LUẬN
- **Không phát hiện vi phạm bản quyền rõ ràng** (không có code GPL bị nhúng vào project MIT; LGPL của FluidSynth tương thích nếu giữ notice).
- **3 điểm cần xử lý trước khi phân phối công khai:**
1. Bổ sung `LICENSE` text (MIT + LGPL-2.1) + attribution cho SpessaSynth (MIT notice) và Tailwind.
2. Ghi attribution SF2 Rich Nagel ("General MIDI SoundFont v3.0 — © 2006-2010 Rich 'Weeds' Nagel — Some rights reserved") hoặc thay soundfont khác license rõ.
3. Không nhúng SGM-V2.01 vào bản phân phối (hạn chế redistribution).
+112 -10
View File
@@ -1,7 +1,16 @@
import asyncio
import ipaddress
import json
import socket
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
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()
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
headers: Dict[str, str] = {}
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")
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:
async with httpx.AsyncClient(timeout=180.0) as client:
resp = await client.post(
req.url,
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
json=req.body
)
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
resp = await client.post(req.url, headers=headers, json=req.body)
raw = resp.text
try:
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:
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)
except HTTPException:
raise
except Exception as 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
import json
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.models.user import get_db_connection
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):
file_id: str
cut_start_ms: Optional[float] = None
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
@router.post("/upload")
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"
ext = os.path.splitext(file.filename)[1]
ext = os.path.splitext(file.filename or "")[1]
if not ext:
ext = ".wav"
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
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:
content = await file.read()
f.write(content)
size = 0
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
import json
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
@router.post("/edit")
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
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")
from app.tasks.worker import edit_audio_task
task = edit_audio_task.delay(req.dict())
task = edit_audio_task.delay(req.model_dump())
return {
"task_id": task.id
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
@router.get("/download/{file_id}")
async def download_audio(file_id: str):
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
if os.path.exists(processed_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")
path = _resolve_storage_path(file_id)
if not path:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
@router.get("/waveform/{file_id}")
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).
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)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
file_path = _resolve_storage_path(file_id)
if not file_path:
raise HTTPException(status_code=404, detail="File not found")
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).
"""
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
file_path = _resolve_storage_path(file_id)
if not file_path:
raise HTTPException(status_code=404, detail="File not found")
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).
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)
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
else:
file_path = _resolve_storage_path(req.file_id)
if not file_path:
raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import analyze_ai_task
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,
model=req.model
)
return {
"task_id": task.id,
"file_id": req.file_id
"file_id": _safe_file_id(req.file_id)
}
@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).
"""
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):
source_path = processed_path
elif os.path.exists(upload_path):
source_path = upload_path
else:
source_path = _resolve_storage_path(req.file_id)
if not source_path:
raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import export_audio_task
task = export_audio_task.delay(
file_id=req.file_id,
file_id=_safe_file_id(req.file_id),
format=req.format,
sample_rate=req.sample_rate,
bit_depth=req.bit_depth
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
return {
"task_id": task.id,
"file_id": req.file_id
"file_id": _safe_file_id(req.file_id)
}
@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.
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
import soundfile as sf
import numpy as np
file_path = None
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
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if file_path and os.path.exists(file_path):
if file_path:
data, sr = sf.read(file_path)
if data.ndim > 1:
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.
"""
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
import soundfile as sf
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"
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
file_path = None
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
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if file_path and os.path.exists(file_path):
if file_path:
data, sr = sf.read(file_path)
if data.ndim > 1:
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.
"""
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.ai_dsp_engine import AIDSPEngine
import soundfile as sf
+108 -14
View File
@@ -1,10 +1,15 @@
import uuid
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 typing import Optional
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()
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
def get_current_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
_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ệ")
token = authorization.split(" ")[1]
payload = decode_token(token)
if not payload:
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")
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()
cursor = conn.cursor()
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
conn.close()
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")
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")
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
return {
_record_login_success(client_ip)
resp = JSONResponse({
"access_token": token,
"user": {
"id": user["id"],
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
"role": user["role"],
"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")
async def register(req: RegisterRequest):
async def register(req: RegisterRequest, request: Request):
username = req.username.strip()
email = req.email.strip()
password = req.password.strip()
_validate_password_strength(password)
conn = get_db_connection()
cursor = conn.cursor()
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
conn.close()
token = create_token(user_id, username, "standard", False)
return {
resp = JSONResponse({
"access_token": token,
"user": {
"id": user_id,
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
"role": "standard",
"must_change_password": False
}
}
})
_set_auth_cookie(resp, token)
return resp
@router.post("/change-password")
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()
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!",
"access_token": new_token
}
})
_set_auth_cookie(resp, new_token)
return resp
@router.get("/profile")
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
}
}
@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}
+160
View File
@@ -0,0 +1,160 @@
import os
import platform
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from app.api.v1.auth import get_current_user
router = APIRouter()
MEDIA_EXTS = {
".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus",
".mid", ".midi"
}
AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus"}
MIDI_EXTS = {".mid", ".midi"}
def _safe_path(path: str) -> str:
if not path:
raise HTTPException(status_code=400, detail="Thiếu path")
if "\x00" in path:
raise HTTPException(status_code=400, detail="Path không hợp lệ")
return os.path.normpath(path)
REAL_FS_TYPES = {
"ext2", "ext3", "ext4", "xfs", "btrfs", "jfs", "reiserfs",
"ntfs", "ntfs3", "vfat", "exfat", "fat", "hfs", "hfsplus", "apfs",
"zfs", "f2fs", "iso9660", "udf", "nfs", "nfs4", "cifs", "smb3", "fuseblk",
}
PSEUDO_FS_TYPES = {
"proc", "sysfs", "devpts", "tmpfs", "devtmpfs", "overlay", "squashfs",
"cgroup", "cgroup2", "pstore", "securityfs", "debugfs", "tracefs",
"configfs", "fusectl", "hugetlbfs", "mqueue", "binfmt_misc", "nsfs",
"autofs", "ramfs", "efivarfs", "rpc_pipefs", "fuse", "fusefs",
}
@router.get("/computer")
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)."""
system = platform.system()
roots = []
if system == "Windows":
import string
for drive in string.ascii_uppercase:
root = drive + ":\\"
try:
if os.path.exists(root):
roots.append({"path": root, "name": drive + ":", "is_dir": True})
except OSError:
continue
else:
# Unix/Linux/macOS: chỉ liệt kê filesystem thật, bỏ pseudo/docker/systemd mounts
seen = set()
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
device, mount, fstype = parts[0], parts[1], parts[2]
if fstype in PSEUDO_FS_TYPES:
continue
if fstype not in REAL_FS_TYPES:
# giữ mount point root "/" nếu không thuộc pseudo
if mount != "/":
continue
if mount in seen:
continue
seen.add(mount)
# lọc mount point rác kiểu /run/credentials/...
if mount.startswith("/run/") or mount.startswith("/var/lib/docker"):
continue
try:
if os.path.isdir(mount):
label = mount if mount != "/" else "Root (/)"
roots.append({"path": mount, "name": label, "is_dir": True})
except OSError:
pass
except OSError:
pass
# macOS fallback: liệt kê /Volumes
if system == "Darwin":
try:
for name in sorted(os.listdir("/Volumes")):
full = os.path.join("/Volumes", name)
if os.path.isdir(full):
roots.append({"path": full, "name": name, "is_dir": True})
except OSError:
pass
if not roots:
roots = [{"path": "/", "name": "Root (/)", "is_dir": True}]
return {"system": system, "roots": roots}
@router.get("/browse")
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."""
resolved = _safe_path(path)
if not os.path.isdir(resolved):
raise HTTPException(status_code=404, detail="Không tìm thấy thư mục")
dirs, files = [], []
try:
entries = os.listdir(resolved)
except OSError as e:
raise HTTPException(status_code=403, detail=f"Không thể đọc thư mục: {e}")
for name in entries:
if name.startswith("."):
continue
full = os.path.join(resolved, name)
try:
if os.path.isdir(full):
dirs.append({"name": name, "path": full, "is_dir": True})
else:
ext = os.path.splitext(name)[1].lower()
try:
size = os.path.getsize(full)
except OSError:
size = 0
kind = "midi" if ext in MIDI_EXTS else ("audio" if ext in AUDIO_EXTS else "other")
files.append({
"name": name,
"path": full,
"is_dir": False,
"size_mb": round(size / (1024 * 1024), 2),
"ext": ext,
"kind": kind
})
except OSError:
continue
dirs.sort(key=lambda d: d["name"].lower())
files.sort(key=lambda f: f["name"].lower())
parent = os.path.dirname(resolved)
return {
"path": resolved,
"parent": parent if parent != resolved else None,
"dirs": dirs,
"files": files
}
@router.get("/file")
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."""
resolved = _safe_path(path)
if not os.path.isfile(resolved):
raise HTTPException(status_code=404, detail="Không tìm thấy file")
ext = os.path.splitext(resolved)[1].lower()
if ext not in MEDIA_EXTS:
raise HTTPException(status_code=403, detail="Loại file không được hỗ trợ preview")
media_type = "audio/wav" if ext in AUDIO_EXTS else "audio/midi"
return FileResponse(resolved, media_type=media_type, filename=os.path.basename(resolved))
+2 -2
View File
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
# Gửi task xuống Celery Worker
from app.tasks.worker import mix_multitrack_task
task = mix_multitrack_task.delay(req.dict())
task = mix_multitrack_task.delay(req.model_dump())
return {
"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.
"""
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 {
"task_id": task.id,
+49 -11
View File
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
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()
@@ -79,11 +79,23 @@ async def upload_soundfont(
background_tasks: BackgroundTasks = None,
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"))):
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
contents = await file.read()
if not PluginManager.validate_sf2_header(contents):
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
# 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")
file_ext = os.path.splitext(file.filename)[1]
@@ -139,15 +151,36 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
@router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str):
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
if not os.path.isdir(base_dir):
continue
for ext in [".sf2", ".sf3"]:
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ext and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename=f"soundfont{ext}")
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
# samples, so any SF3 would play silence in the browser.
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
# Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
try:
from app.core.soundfont_converter import SoundFontConverter
sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
if result != full and os.path.exists(result):
return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
except Exception as e:
print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
raise HTTPException(status_code=404, detail="SoundFont asset not found")
@@ -161,8 +194,13 @@ async def render_project(
req: RenderRequest,
current_user: dict = Depends(get_current_user)
):
enforce_password_changed(current_user)
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:
result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
+31 -9
View File
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
return project_data
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 = []
for t in tracks:
track_id = str(t.get("id", ""))
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
"id": c.get("id"),
"name": c.get("name", "Audio Clip"),
"type": "AUDIO_ITEM",
"start_bar": c.get("startTime", 0.0) / 4.0,
"duration_bars": 4.0,
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
"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,
"source_data": {
"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"),
"name": m.get("name", "MIDI Item"),
"type": "MIDI_ITEM",
"start_bar": m.get("startTime", 0.0) / 4.0,
"duration_bars": m.get("duration", 4.0),
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
"clip_start_offset_bars": 0.0,
"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", [])
}
})
@@ -283,13 +288,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
exists = cursor.fetchone()
if not exists:
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
existing = cursor.fetchone()
if not existing:
conn.close()
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"))
# 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()
cursor.execute("""
+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)):
uid = _get_user_id(authorization)
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)
return {
"success": True,
+19 -3
View File
@@ -1,15 +1,31 @@
import os
import sys
def _app_dir():
if getattr(sys, "frozen", False):
# PyInstaller onefile: assets read-only nằm trong thư mục giải nén tạm
return os.path.join(sys._MEIPASS, "app")
return os.path.dirname(os.path.abspath(__file__))
def _storage_dir():
if getattr(sys, "frozen", False):
# Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return os.path.join(root, "SonicForgeDAW", "storage")
return os.path.join(_app_dir(), "storage")
class Settings:
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
# __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root
APP_DIR: str = os.path.dirname(os.path.abspath(__file__))
APP_DIR: str = _app_dir()
BASE_DIR: str = os.path.dirname(APP_DIR)
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
STORAGE_DIR: str = os.path.join(APP_DIR, "storage")
STORAGE_DIR: str = _storage_dir()
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
+30 -1
View File
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
from app.models.user import get_db_connection
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:
"""
+43 -21
View File
@@ -1,4 +1,4 @@
import os, logging
import os, logging, math
import numpy as np
import soundfile as sf
import scipy.signal as signal
@@ -87,12 +87,18 @@ class PythonRenderEngine:
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)
# 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
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_buffer = np.zeros((2, total_samples), dtype=np.float32)
@@ -123,8 +129,17 @@ class PythonRenderEngine:
try:
audio_data, sr = sf.read(resolved_path, dtype='float32')
if sr != self.sample_rate:
# Resampling fallback if simple, otherwise skip
pass
# Proper resampling: previously a silent no-op that
# 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)
if len(audio_data.shape) == 1:
@@ -148,7 +163,7 @@ class PythonRenderEngine:
if actual_len > 0:
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
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":
source_data = item.get("source_data", {})
@@ -284,21 +299,27 @@ class PythonRenderEngine:
actual_len = min(synth_buffer.shape[1], total_samples)
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
except Exception as e:
print(f"[RenderEngine] Error rendering MIDI: {e}")
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
elif item_type == "SECTION_ITEM":
source_data = item.get("source_data", {})
sec_id = source_data.get("referenced_section_id", "")
if sec_id and sec_id in section_store:
# Render nested section recursively
sec_container = section_store[sec_id]
sec_buffer = self.render_session_container(
session=sec_container,
section_store=section_store,
bpm=bpm,
time_sig_num=time_sig_num,
total_samples=total_samples
)
# Render nested section recursively, cached per section id
# so repeated section instances don't re-render every time.
cache = _cache if _cache is not None else {}
if sec_id in cache:
sec_buffer = cache[sec_id]
else:
sec_buffer = self.render_session_container(
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
if offset_sample < total_samples:
@@ -327,7 +348,7 @@ class PythonRenderEngine:
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
except Exception as e:
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
else:
# Fallback chorus using simple LFO delay modulation in scipy/numpy
try:
@@ -341,14 +362,14 @@ class PythonRenderEngine:
wet[ch, :] = track_buffer[ch, indices]
track_buffer = dry + wet * 0.5
except Exception as e:
print(f"[RenderEngine] Fallback Chorus failed: {e}")
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
elif fx_type == "reverb":
if HAS_PEDALBOARD:
try:
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)
except Exception as e:
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
else:
# Fallback reverb using exponentially decaying noise room impulse response
try:
@@ -368,7 +389,7 @@ class PythonRenderEngine:
wet[ch, :] = conv
track_buffer = dry + wet * 0.4
except Exception as e:
print(f"[RenderEngine] Fallback Reverb failed: {e}")
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
# Process track volume
if HAS_PEDALBOARD:
@@ -410,7 +431,8 @@ class PythonRenderEngine:
section_store=section_store,
bpm=bpm,
time_sig_num=time_sig_num,
total_samples=total_samples
total_samples=total_samples,
_cache={},
)
# Normalization to prevent clipping
+339 -39
View File
@@ -83,6 +83,35 @@ class SoundFontConverter:
except Exception:
return False
def _encode_sample_ogg(self, pcm: bytes, rate: int, tmp_dir: str) -> bytes:
"""Encode a single mono 16-bit PCM slice to an Ogg Vorbis stream."""
tmp_wav = os.path.join(tmp_dir, "sample_tmp.wav")
tmp_ogg = os.path.join(tmp_dir, "sample_tmp.ogg")
try:
with open(tmp_wav, "wb") as fw:
with wave.open(fw, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm)
subprocess.run([
"ffmpeg", "-y", "-i", tmp_wav,
"-c:a", "libvorbis", "-q:a", "3",
"-f", "ogg", tmp_ogg
], capture_output=True, timeout=600, check=True)
with open(tmp_ogg, "rb") as fo:
return fo.read()
except Exception as e:
logger.error(f"Sample OGG encode failed: {e}")
return b""
finally:
for p in [tmp_wav, tmp_ogg]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
has_ogg = self._check_ffmpeg_ogg()
if not has_ogg:
@@ -100,7 +129,7 @@ class SoundFontConverter:
logger.warning("Not a valid SF2 file")
return False
# Find smpl chunk recursively
# Find smpl chunk (PCM sample data) recursively
smpl = _find_chunk(data, b"smpl")
if smpl is None:
logger.warning("No smpl chunk found in SF2")
@@ -113,46 +142,103 @@ class SoundFontConverter:
logger.warning("Sample data too small")
return False
tmp_wav = sf3_path + ".tmp.wav"
tmp_ogg = sf3_path + ".tmp.ogg"
# Locate shdr (sample headers) inside the pdta LIST
pdta = _find_list_of_type(data, b"pdta", 12)
if not pdta:
logger.warning("No pdta LIST found")
return False
_, _, pdta_size, pdta_data_off = pdta
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
if not shdr:
logger.warning("No shdr chunk found")
return False
_, _, shdr_size, shdr_data_off = shdr
if shdr_size <= 0 or shdr_size % 46 != 0:
logger.warning(f"Invalid shdr size {shdr_size}")
return False
n_samples = shdr_size // 46
# Locate ifil (version) inside the INFO LIST
ifil_abs = None
info = _find_list_of_type(data, b"INFO", 12)
if info:
_, _, info_size, info_data_off = info
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
if ifil:
ifil_abs = ifil[3]
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="sf3conv_")
ogg_parts = []
new_shdr = bytearray()
byte_offset = 0
ogg_bytes = 0
try:
# Write samples as WAV
with open(tmp_wav, "wb") as fw:
with wave.open(fw, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(sample_data)
for i in range(n_samples):
base = shdr_data_off + i * 46
# shdr layout: name[20] | start(u32) end(u32) loopstart(u32) loopend(u32) samplerate(i32) ...
start, end, startloop, endloop, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
ogg_stream = b""
if end >= start and start * 2 < len(sample_data):
pcm = sample_data[start * 2:(end + 1) * 2]
if len(pcm) >= 4:
safe_rate = rate if 1000 < rate < 192000 else 44100
ogg_stream = self._encode_sample_ogg(pcm, safe_rate, tmp_dir)
# SF3: start/end are byte offsets into the concatenated OGG stream.
# FluidSynth treats shdr `end` as EXCLUSIVE (reads [start..end-1]),
# so end = start + ogg length.
new_start = byte_offset
new_end = byte_offset + len(ogg_stream)
# OGG loop pointers are relative to the individual decompressed sample
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
new_stype = sampletype | 0x20
new_shdr += data[base:base + 20] # sample name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
new_shdr += struct.pack("<H", new_stype)
ogg_parts.append(ogg_stream)
ogg_bytes += len(ogg_stream)
byte_offset = new_start + len(ogg_stream)
if len(ogg_stream) % 2 == 1:
ogg_parts.append(b"\x00")
byte_offset += 1
# Compress to Ogg Vorbis
subprocess.run([
"ffmpeg", "-y", "-i", tmp_wav,
"-c:a", "libvorbis", "-q:a", "3",
"-f", "ogg", tmp_ogg
], capture_output=True, timeout=600, check=True)
if not ogg_parts:
logger.warning("No samples to encode")
return False
with open(tmp_ogg, "rb") as fo:
ogg_data = fo.read()
compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100
logger.info(f"Compressed {len(sample_data)} -> {len(ogg_data)} bytes ({compression:.0f}%)")
ogg_padded = ogg_data if len(ogg_data) % 2 == 0 else ogg_data + b"\x00"
new_smpl_size = len(ogg_data)
ogg_padded = b"".join(ogg_parts)
if len(ogg_padded) % 2 == 1:
ogg_padded += b"\x00"
new_smpl_size = len(ogg_padded)
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
new_padded = len(ogg_padded)
delta = new_padded - old_padded
delta = len(ogg_padded) - old_padded
# Rebuild file: replace smpl chunk and update all sizes
out = bytearray()
out.extend(data[:smpl_head_off]) # up to smpl chunk header
out.extend(data[:smpl_head_off]) # up to smpl chunk header (excl. id)
out.extend(b"smpl") # chunk id (required for valid SF3)
out.extend(struct.pack("<I", new_smpl_size)) # new smpl size
out.extend(ogg_padded) # compressed data (even-padded)
out.extend(data[smpl_head_off + 8 + old_padded:]) # rest of file
out.extend(ogg_padded) # concatenated per-sample OGG streams
rest_off = smpl_head_off + 8 + old_padded
rest = bytearray(data[rest_off:])
# Patch the shdr sample headers inside the pdta copy
shdr_in_rest = shdr_data_off - rest_off
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
logger.warning("shdr not found after smpl chunk")
return False
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
out.extend(rest)
data_out = bytes(out)
# SF3 requires version 3.0 so FluidSynth treats it as an SF3 file
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
data_out = data_out[:ifil_abs] + struct.pack("<HH", 3, 0) + data_out[ifil_abs + 4:]
# Find sdta LIST and update its size
sdta = _find_list_of_type(data_out, b"sdta", 12)
if sdta:
@@ -160,12 +246,11 @@ class SoundFontConverter:
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
# Update RIFF root size
old_riff_size = struct.unpack("<I", data_out[4:8])[0]
new_total = len(data_out) - 8
data_out = _update_size(data_out, 4, new_total)
with open(sf3_path, "wb") as fout:
fout.write(bytes(data_out))
fout.write(data_out)
# Validate: check that RIFF size matches actual size
written = os.path.getsize(sf3_path)
@@ -174,6 +259,7 @@ class SoundFontConverter:
logger.warning(f"Size mismatch: RIFF says {parsed_riff}, actual is {written - 8}")
return False
logger.info(f"Converted {n_samples} samples -> {ogg_bytes} bytes OGG ({100 * (1 - ogg_bytes / max(len(sample_data), 1)):.0f}% smaller)")
return True
except subprocess.TimeoutExpired:
@@ -185,12 +271,50 @@ class SoundFontConverter:
traceback.print_exc()
return False
finally:
for p in [tmp_wav, tmp_ogg]:
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
@staticmethod
def _sf3_plays_audio(path: str) -> bool:
"""Verify a SoundFont actually loads and renders audible audio (guards
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):
return False
try:
import fluidsynth as _fs
import numpy as np
_settings = _fs.new_fluid_settings()
_fl = _fs.new_fluid_synth(_settings)
try:
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
if h < 0:
return False
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
frames = 8820 # 0.2s
buf = np.zeros(frames * 2, dtype=np.float32)
_fs.fluid_synth_write_float(
_fl, frames, buf.ctypes.data, 0, 1,
buf.ctypes.data + frames * 4, 0, 1
)
_fs.fluid_synth_noteoff(_fl, 0, 60)
rms = float(np.sqrt(np.mean(buf ** 2)))
return rms > 1e-4
finally:
try:
if os.path.exists(p):
os.remove(p)
_fs.delete_fluid_synth(_fl)
except Exception:
pass
except Exception:
return False
@staticmethod
def _find_sf3_converter():
@@ -208,9 +332,18 @@ class SoundFontConverter:
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
# Reuse a working SF3 if it is newer than the SF2 AND actually plays
# audio. Malformed SF3s (e.g. produced by an older converter) are
# re-converted automatically instead of being shipped silently broken.
if os.path.exists(sf3_path) and os.path.getmtime(sf3_path) >= os.path.getmtime(sf2_path):
logger.info(f"SF3 already up-to-date: {sf3_path}")
return sf3_path
if self._sf3_plays_audio(sf3_path):
logger.info(f"SF3 already up-to-date: {sf3_path}")
return sf3_path
logger.warning(f"Existing SF3 does not play audio, re-converting: {sf3_path}")
try:
os.remove(sf3_path)
except Exception:
pass
converter = self._find_sf3_converter()
try:
@@ -228,9 +361,16 @@ class SoundFontConverter:
if converter == "python":
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
return sf3_path
if self._sf3_plays_audio(sf3_path):
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
return sf3_path
# Conversion produced a broken file — never ship it
logger.warning(f"Converted SF3 failed audio verification, removing: {sf3_path}")
try:
os.remove(sf3_path)
except Exception:
pass
logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path")
return sf2_path
@@ -242,6 +382,165 @@ class SoundFontConverter:
logger.error(f"Error converting {sf2_path}: {e}")
return sf2_path
def sf3_to_sf2(self, sf3_path: str, sf2_path: str = None) -> str:
"""Convert an SF3 (Ogg Vorbis samples) soundfont into a playable SF2.
The client FluidSynth WASM cannot decode Ogg Vorbis/SF3 samples, so any
SF3 soundfont (converted or uploaded) plays silence. Decompressing to
SF2 makes every instrument audible again.
"""
if not os.path.exists(sf3_path):
raise FileNotFoundError(f"Source SF3 file not found: {sf3_path}")
try:
with open(sf3_path, "rb") as f:
data = f.read()
except Exception as e:
raise IOError(f"Cannot read {sf3_path}: {e}")
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"sfbk":
raise ValueError(f"Not a valid SoundFont file: {sf3_path}")
smpl = _find_chunk(data, b"smpl")
if not smpl:
raise ValueError("No smpl chunk found")
smpl_head_off, _, smpl_old_size, smpl_data_off = smpl
sample_data = data[smpl_data_off:smpl_data_off + smpl_old_size]
pdta = _find_list_of_type(data, b"pdta", 12)
if not pdta:
raise ValueError("No pdta LIST found")
_, _, pdta_size, pdta_data_off = pdta
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
if not shdr:
raise ValueError("No shdr chunk found")
_, _, shdr_size, shdr_data_off = shdr
if shdr_size <= 0 or shdr_size % 46 != 0:
raise ValueError(f"Invalid shdr size {shdr_size}")
n_samples = shdr_size // 46
ifil_abs = None
info = _find_list_of_type(data, b"INFO", 12)
if info:
_, _, info_size, info_data_off = info
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
if ifil:
ifil_abs = ifil[3]
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="sf2conv_")
pcm_parts = []
new_shdr = bytearray()
frame_offset = 0
total_pcm_bytes = 0
try:
for i in range(n_samples):
base = shdr_data_off + i * 46
name = data[base:base + 20]
start, end, loopstart, loopend, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
frames = 0
pcm = b""
if end > start and start < len(sample_data):
# FluidSynth reads the OGG region as [start..end-1]
ogg = sample_data[start:min(end, len(sample_data))]
if len(ogg) >= 4 and ogg[:4] == b"OggS":
pcm = self._decode_sample_ogg(ogg, tmp_dir)
frames = len(pcm) // 2
new_start = frame_offset
new_end = frame_offset + frames
# SF3 loop points are relative to the decompressed sample; SF2 needs absolute
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
new_loopend = loopend + new_start if (loopstart or loopend) else 0
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
new_stype = sampletype & ~0x20
new_shdr += name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
new_shdr += data[base + 40:base + 44]
new_shdr += struct.pack("<H", new_stype)
pcm_parts.append(pcm)
frame_offset += frames
if frames:
total_pcm_bytes += frames * 2
if total_pcm_bytes == 0:
logger.warning("SF3 contained no decodable samples")
return sf3_path
new_smpl_size = total_pcm_bytes
if new_smpl_size % 2 == 1:
new_smpl_size += 1
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
delta = new_smpl_size - old_padded
out = bytearray()
out.extend(data[:smpl_head_off])
out.extend(b"smpl")
out.extend(struct.pack("<I", new_smpl_size))
for part in pcm_parts:
out.extend(part)
if total_pcm_bytes % 2 == 1:
out.extend(b"\x00")
rest_off = smpl_head_off + 8 + old_padded
rest = bytearray(data[rest_off:])
shdr_in_rest = shdr_data_off - rest_off
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
logger.warning("shdr not found after smpl chunk")
return sf3_path
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
out.extend(rest)
data_out = bytes(out)
# Back to SF2 version 2.01
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
data_out = data_out[:ifil_abs] + struct.pack("<HH", 2, 1) + data_out[ifil_abs + 4:]
sdta = _find_list_of_type(data_out, b"sdta", 12)
if sdta:
lh_off, _, lh_size, ld_off = sdta
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
data_out = _update_size(data_out, 4, len(data_out) - 8)
sf2_path = sf2_path or (os.path.splitext(sf3_path)[0] + ".sf2")
with open(sf2_path, "wb") as fout:
fout.write(data_out)
logger.info(f"Converted SF3 -> SF2: {sf2_path} ({os.path.getsize(sf2_path) / 1048576:.1f} MB)")
return sf2_path
finally:
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
def _decode_sample_ogg(self, ogg: bytes, tmp_dir: str) -> bytes:
"""Decode an Ogg Vorbis stream to mono 16-bit PCM; returns PCM bytes."""
tmp_ogg = os.path.join(tmp_dir, "sample.ogg")
tmp_pcm = os.path.join(tmp_dir, "sample.pcm")
try:
with open(tmp_ogg, "wb") as fo:
fo.write(ogg)
r = subprocess.run(
["ffmpeg", "-y", "-v", "error", "-i", tmp_ogg, "-f", "s16le", "-ac", "1", tmp_pcm],
capture_output=True, timeout=600)
if r.returncode != 0:
logger.warning(f"OGG decode failed: {r.stderr.decode(errors='replace')[:120]}")
return b""
with open(tmp_pcm, "rb") as fp:
return fp.read()
except Exception as e:
logger.warning(f"OGG decode error: {e}")
return b""
finally:
for p in [tmp_ogg, tmp_pcm]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
def batch_convert_all(self):
for sdir in self.target_dirs:
if not os.path.isdir(sdir):
@@ -249,3 +548,4 @@ class SoundFontConverter:
for fname in sorted(os.listdir(sdir)):
if fname.lower().endswith(".sf2"):
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
+59 -39
View File
@@ -2,7 +2,7 @@
import os
import numpy as np
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:
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
return _PLUGIN_MANAGER_INSTANCE
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
if not HAS_PYFLUIDSYNTH:
return None
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
_FLUID_CACHE[path] = (fl, ref + 1)
return fl
try:
import fluidsynth
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
font_id = fl.sfload(path)
fl.program_select(0, font_id, 0, 0)
import fluidsynth as _fs
_settings = _fs.new_fluid_settings()
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.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)
return fl
except Exception:
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
fl, ref = _FLUID_CACHE[path]
if ref <= 1:
try:
fl.delete()
import fluidsynth as _fs
_fs.delete_fluid_synth(fl)
except Exception:
pass
del _FLUID_CACHE[path]
@@ -245,38 +256,47 @@ class PluginManager:
if base == sf_id or base == sf_id.replace("sf_", ""):
path = os.path.join(d, f)
try:
import fluidsynth
fl = fluidsynth.Synth()
fid = fl.sfload(path)
if fid < 0:
fl.delete()
continue
presets = []
_fl = fluidsynth._fl
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
_fl.fluid_preset_get_name.restype = c_char_p
_fl.fluid_sfont_get_preset.restype = c_void_p
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
if sfont_ptr:
for bank in range(0, 2):
for prog_num in range(0, 128):
try:
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
except Exception:
break
if preset:
name_ptr = fluidsynth.fluid_preset_get_name(preset)
if name_ptr:
name_val = c_char_p(name_ptr).value
if name_val:
presets.append({
"bank": bank,
"program": prog_num,
"name": name_val.decode("utf-8", errors="replace")
})
fl.delete()
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
return presets[:256]
import fluidsynth as _fs
# Low-level CFFI API (same as render_engine); never use
# the high-level Synth() class that this binding lacks.
_settings = _fs.new_fluid_settings()
_synth = _fs.new_fluid_synth(_settings)
try:
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
if fid < 0:
continue
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
presets = []
if sfont:
for bank in range(0, 2):
for prog_num in range(0, 128):
try:
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
except Exception:
break
if preset:
try:
name_ptr = _fs.fluid_preset_get_name(preset)
if name_ptr:
if hasattr(_fs, "ffi"):
raw = _fs.ffi.string(name_ptr)
else:
raw = c_char_p(name_ptr).value
if raw:
presets.append({
"bank": bank,
"program": prog_num,
"name": raw.decode("utf-8", errors="replace")
})
except Exception:
continue
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
return presets[:256]
finally:
try:
_fs.delete_fluid_synth(_synth)
except Exception:
pass
except Exception:
import traceback; traceback.print_exc()
_SF_INSTRUMENTS_CACHE[sf_id] = []
+57 -27
View File
@@ -1,8 +1,11 @@
import os, threading
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import settings
from app.api.v1.audio import router as audio_router
from app.api.v1.tasks import router as tasks_router
@@ -14,24 +17,40 @@ from app.api.v1.user_config import router as user_config_router
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.plugins import router as plugins_router
from app.api.v1.media import router as media_router
from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
# Ensure storage directories exist
os.makedirs(settings.UPLOADS_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)
# 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(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -39,7 +58,27 @@ app.add_middleware(
# Mount storage directory (must come before general /static mount)
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
# Mount app static files (js, css)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
# Dùng settings.APP_DIR (freeze-aware) thay vì os.path.dirname(__file__):
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
# nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý).
STATIC_DIR = os.path.join(settings.APP_DIR, "static")
# ⚠️ Fallback an toàn: nếu vì lý do nào đó static không nằm đúng chỗ (vd
# bundle thiếu file, chạy từ nơi khác), thử các vị trí khác; nếu vẫn không
# có → TỰ TẠO thư mục rỗng để app KHÔNG crash khi khởi động (lỗi "Directory
# does not exist" từ StaticFiles làm engine chết ngay lúc import — đã gặp).
if not os.path.isdir(STATIC_DIR):
for cand in [
os.path.join(getattr(sys, "_MEIPASS", ""), "app", "static"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static"),
]:
if cand and os.path.isdir(cand):
STATIC_DIR = cand
break
else:
try:
os.makedirs(STATIC_DIR, exist_ok=True)
except OSError:
pass
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Include routers
@@ -53,25 +92,12 @@ app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config
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(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
# Seed admin user on startup
@app.on_event("startup")
async def startup_seed_admin():
seed_admin()
@app.on_event("startup")
async def startup_convert_soundfonts():
def _run():
try:
SoundFontConverter().batch_convert_all()
except Exception as e:
print(f"[Startup] SoundFont conversion error: {e}")
threading.Thread(target=_run, daemon=True).start()
@app.on_event("startup")
async def startup_sf_scanner():
scanner = SoundFontAutoScanner()
scanner.start_background(interval=30)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
async def get_index():
@@ -79,14 +105,18 @@ async def get_index():
if not os.path.exists(index_path):
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
with open(index_path, "r", encoding="utf-8") as file:
return HTMLResponse(content=file.read(), status_code=200)
resp = HTMLResponse(content=file.read(), status_code=200)
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.get("/favicon.svg")
async def get_favicon():
import os
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
if os.path.exists(favicon_path):
from fastapi.responses import FileResponse
return FileResponse(favicon_path, media_type="image/svg+xml")
return HTMLResponse(content="", status_code=404)
+1
View File
@@ -45,6 +45,7 @@
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
"color": { "type": ["string", "null"], "default": null },
"volume_db": { "type": "number", "default": 0.0 },
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
"mute": { "type": "boolean", "default": false },
+17 -1
View File
@@ -5,12 +5,18 @@ import time
from typing import Optional, Dict, Any, List
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():
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
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
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.close()
+8903 -856
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 = [
{
id: 'arpeggio',
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
} else {
response = await fetch(`${origin}/api/v1/ai/proxy`, {
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 })
});
}
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
return {
DEFAULT_TOOLS,
REARRANGE_TOOL_SPEC,
REARRANGE_EXTEND_TOOL_SPEC,
REARRANGE_SCENARIOS,
detectRearrangeScenario,
buildRearrangeMessage,
+8 -2
View File
@@ -23,6 +23,7 @@
if (!track.midiItems || !track.midiItems.length) continue;
if (track.muted) continue;
var isSameTrack = track.id === targetTrackId;
var trackGhostNotes = [];
for (var j = 0; j < track.midiItems.length; j++) {
@@ -32,7 +33,10 @@
var itemStartBeat = item.startTime / secondsPerBeat;
var itemEndBeat = (item.startTime + item.duration) / secondsPerBeat;
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) continue;
// Same-track items always contribute notes (show whole track); cross-track only when overlapping the window
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) {
if (!isSameTrack) continue;
}
var notes = item.notes || [];
for (var k = 0; k < notes.length; k++) {
@@ -40,7 +44,7 @@
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
if (noteAbsStart >= windowEndBeat) continue;
if (noteAbsStart >= windowEndBeat && !isSameTrack) continue;
trackGhostNotes.push({
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
@@ -48,6 +52,7 @@
relative_start_beat: noteAbsStart - windowStartBeat,
duration_beats: (note.duration_beats || 1),
velocity: note.velocity,
item_id: item.id,
original_track_name: track.name,
original_track_color: track.color || '#888888'
});
@@ -59,6 +64,7 @@
track_id: track.id,
track_name: track.name,
track_color: track.color || '#6b7280',
isSameTrack: isSameTrack,
notes: trackGhostNotes
});
}
+291 -61
View File
@@ -1,6 +1,6 @@
(function () {
const RENDER_BLOCK = 512;
const QUEUE_TARGET = 4;
const QUEUE_TARGET = 16;
let _audioCtx = null;
let _fluidModule = null;
let _synthPtr = null;
@@ -22,14 +22,21 @@
let _loadedFonts = {};
let _activeOscillators = {};
let _gainNode = null;
let _pendingOutputDestination = null;
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
let _scheduledNotes = [];
let _loadPromises = {};
let _sfloadSeq = 0;
const getCtx = function () {
if (_audioCtx) {
if (!_gainNode) {
_gainNode = _audioCtx.createGain();
_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;
}
@@ -38,7 +45,8 @@
if (!_gainNode) {
_gainNode = ctx.createGain();
_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;
}
@@ -51,7 +59,8 @@
if (!_gainNode) {
_gainNode = window.__sharedAudioCtx.createGain();
_gainNode.gain.value = 0.3;
_gainNode.connect(window.__sharedAudioCtx.destination);
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
_gainNode.connect(_outputDestination);
}
return window.__sharedAudioCtx;
};
@@ -59,6 +68,41 @@
const SonicSF = {
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) {
if (_initialized && _fluidModule) return;
if (_initPromise) return _initPromise;
@@ -79,19 +123,27 @@
if (!_gainNode) {
_gainNode = _audioCtx.createGain();
_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);
// ── Renderer selection ──
// ScriptProcessor is the default and FINAL choice: it is
// pull-based (onaudioprocess is invoked by the audio thread),
// so it cannot starve when the main thread is busy (font
// loading, WASM decode, UI). The AudioWorklet push model
// (setInterval on the main thread) starves under load and
// produces SILENCE on this machine — repeatedly confirmed.
// The deprecation console warning is purely cosmetic.
var _useScriptNode = true;
try {
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
console.log("[SonicSF] Worklet registered OK");
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
console.log("[SonicSF] Worklet registered OK (unused)");
} catch (e) {
console.warn("[SonicSF] Worklet reg failed:", e);
}
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
var TOTAL_MEMORY = 256 * 1024 * 1024;
@@ -104,6 +156,11 @@
},
TOTAL_MEMORY: TOTAL_MEMORY,
printErr: function (msg) {
// "No preset found on channel" is FluidSynth's
// expected notice when a soundfont simply has no
// preset for a bank (e.g. bank 128 on a melodic-only
// font) — the note is just silent, not an error.
if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
console.warn('[FluidSynth:err]', msg);
}
});
@@ -114,9 +171,9 @@
_settingsPtr = _fluidModule._new_fluid_settings();
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.sample-rate", _audioCtx.sampleRate || 44100);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 2.0);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 1.0);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.polyphony", 256);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 1);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 0);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.ladspa.active", 0);
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
console.log("[SonicSF] FluidSynth settings configured");
@@ -132,7 +189,15 @@
if (!_useScriptNode) {
try {
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
// Force stereo output regardless of the device's
// channel count — FluidSynth renders stereo, and a
// mono output would crash the worklet (out[1] undefined).
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge', {
numberOfOutputs: 1,
outputChannelCount: [2],
channelCount: 2,
channelCountMode: 'explicit'
});
_workletNode.connect(_gainNode);
console.log("[SonicSF] AudioWorklet node connected via gain");
_startRenderLoop();
@@ -146,17 +211,31 @@
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
var lp = _fluidModule._malloc(spBufSz * 4);
var rp = _fluidModule._malloc(spBufSz * 4);
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
// → master chain "state is bad" → CÂM + stuck. Theo dõi
// buffer + re-malloc khi đổi.
var _heapBufRef = _fluidModule.HEAPU8.buffer;
spn.onaudioprocess = function (e) {
var left = e.outputBuffer.getChannelData(0);
var right = e.outputBuffer.getChannelData(1);
var sz = left.length;
try {
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
lp = _fluidModule._malloc(sz * 4);
rp = _fluidModule._malloc(sz * 4);
_heapBufRef = _fluidModule.HEAPU8.buffer;
}
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
var hf = _fluidModule.HEAPF32;
var lpb = lp >> 2, rpb = rp >> 2;
for (var si = 0; si < sz; si++) {
left[si] = hf[lpb + si];
right[si] = hf[rpb + si];
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
var L = hf[lpb + si], R = hf[rpb + si];
left[si] = isFinite(L) ? L : 0;
right[si] = isFinite(R) ? R : 0;
}
} catch (er) {}
};
@@ -186,11 +265,16 @@
},
_tryLoadSFL: function (buf, ext) {
var fname = '/' + ext + '_' + Date.now();
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
try { _fluidModule.FS.unlink(fname); } catch (e) {}
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
var cPath = this._allocCStr(fname);
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 1);
// reset_presets = 0: loading a NEW soundfont must NOT reset the
// presets already selected on other channels. With 1, FluidSynth
// re-points every channel to the new font's preset 0, so loading a
// second instrument silently changes the first one's sound
// (decay/loop envelope…).
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 0);
_fluidModule._free(cPath);
try { _fluidModule.FS.unlink(fname); } catch (e) {}
return handle;
@@ -203,19 +287,63 @@
_currentSfId = sfId;
return true;
}
// Deduplicate concurrent loads: rapid key presses (or several armed
// tracks) all call loadSoundFont for the same font before the first
// load resolves. Without this, the same soundfont is sfload'd several
// times (handles 1,2,3,4…) — wasting the 256MB WASM heap and stalling
// notes until each load finishes (audible lag, then silence).
if (!_loadPromises[sfId]) {
_loadPromises[sfId] = this._doLoadSoundFont(sfId).then(function (ok) {
// Do NOT cache failures: a transient error (network hiccup,
// memory pressure) must not permanently kill the instrument —
// the next note retries the load and recovers.
if (!ok) delete _loadPromises[sfId];
return ok;
});
}
return _loadPromises[sfId];
},
_doLoadSoundFont: async function (sfId) {
try {
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
// thành rác trong khi channel state vẫn trỏ tới → program_select
// bị skip (progAlreadySet) → noteon trên handle đã unload →
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null;
if (!buf) {
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url);
if (!resp.ok) {
if (buf) {
var cachedOk = this._tryLoadSFL(buf, '.sf3');
if (cachedOk === -1) cachedOk = this._tryLoadSFL(buf, '.sf2');
if (cachedOk !== -1) {
_sfHandleMap.set(sfId, cachedOk);
_currentSfId = sfId;
_loadedFonts[sfId] = true;
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
return true;
}
// Stale/corrupt cache (e.g. old SF3 buffers the WASM can't
// decode) — drop it and re-download from the server.
console.warn("[SonicSF] Cached SoundFont unplayable, re-downloading:", sfId);
try { await cache.saveBuffer(sfId, null); } catch (e2) {}
}
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url);
if (!resp.ok) {
// Fallback: font bundled theo deployment (static/soundfonts —
// serve qua /soundfonts/{f} — catalog default-soundfonts).
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
var resp2 = await fetch(url2);
if (!resp2.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId);
return false;
}
buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf);
resp = resp2;
}
buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf);
var sfHandle = this._tryLoadSFL(buf, '.sf3');
if (sfHandle === -1) {
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
@@ -245,16 +373,16 @@
if (!ok) return;
}
var engKey = (sfId || '') + ':' + bank + ':' + program;
if (!_engineChMap[engKey]) {
if (channel === undefined || channel === null) {
if (channel === undefined || channel === null) {
if (!_engineChMap[engKey]) {
var allocCh = this.allocateChannel(bank);
_engineChMap[engKey] = allocCh;
channel = allocCh;
} else {
_engineChMap[engKey] = channel;
channel = _engineChMap[engKey];
}
} else {
channel = _engineChMap[engKey];
} else if (!_engineChMap[engKey]) {
_engineChMap[engKey] = channel;
}
var sfHandle = _sfHandleMap.get(sfId);
if (sfHandle !== undefined) {
@@ -371,10 +499,11 @@
},
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
var midiVel = Math.min(127, Math.max(1, Math.floor(
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
)));
var parsedPitch = parseInt(note);
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
if (isNaN(rawVel)) rawVel = 100;
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
var _origChannel = channel;
var usedBank = 0, usedProg = 0;
if (synthEngine) {
@@ -407,30 +536,99 @@
var self = this;
var doNote = function () {
try {
var finalBank = usedBank;
var finalProg = usedProg;
var finalBank = parseInt(usedBank);
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
var finalProg = parseInt(usedProg);
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
if (_channels[ch] && _channels[ch].program !== undefined) {
finalBank = _channels[ch].bank;
finalProg = _channels[ch].program;
if (_channels[ch].sfId !== undefined) {
finalSfId = _channels[ch].sfId;
var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is
// authoritative. Channel state is only a cache: it must never
// mask the track's instrument, otherwise multi-track ARM or a
// re-picked instrument plays the wrong soundfont. Without an
// engine, fall back to the soundfont configured on the channel.
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
finalBank = parseInt(cachedCh.bank) || 0;
finalProg = parseInt(cachedCh.program) || 0;
finalSfId = cachedCh.sfId;
}
// Ensure the soundfont is actually loaded before the note plays.
// Quick instrument pick on a track does not pre-load it, so load
// lazily here and retry the note once the font is ready.
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
// — note chạy thẳng fallback để CÓ ÂM.
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
if (Date.now() - _lastFail < 10000) {
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
return;
}
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
self.loadSoundFont(finalSfId).then(function (ok) {
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;
}
// Program change at note time, not call time — ensures correct
// instrument for each item regardless of processing order.
if (synthEngine || program !== undefined) {
// Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches).
// ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
// không → vẫn program_select lại (tránh dùng handle đã unload).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
if (sfHandle !== undefined) {
try {
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
// Percussion (bank 128): tìm preset HỢP LỆ trong
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
// cache theo sfId. Trước đây chỉ thử 4 preset cố
// định → font không có → cache channel = (128,0)
// INVALID → note sau skip re-select (progAlreadySet)
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
if (finalBank === 128) {
var _vKey = finalSfId || ('h' + sfHandle);
if (_validPercCache[_vKey] === undefined) {
var _found = null;
for (var _b = 0; _b < 2 && !_found; _b++) {
var _bk = _b === 0 ? 128 : 0;
for (var _p = 0; _p < 128 && !_found; _p++) {
try {
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
_found = [_bk, _p];
}
} catch (e) {}
}
}
_validPercCache[_vKey] = _found;
}
if (_validPercCache[_vKey]) {
finalBank = _validPercCache[_vKey][0];
finalProg = _validPercCache[_vKey][1];
}
}
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
} catch (e) {}
} else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } catch (e) {}
}
if (!_channels[ch]) _channels[ch] = {};
_channels[ch].bank = finalBank;
_channels[ch].program = finalProg;
_channels[ch].sfId = finalSfId;
}
console.log("[SonicSF] noteOn ch:", ch, "pitch:", midiPitch, "vel:", midiVel);
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
@@ -446,7 +644,7 @@
if (arr.length === 0) delete _activeNotes[noteMapKey];
}
} catch (e) {}
}, durSec * 1000);
}, durationMs);
}
} catch (e) {
console.warn("[SonicSF] FluidSynth noteOn error:", e);
@@ -461,6 +659,31 @@
}
},
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
// tự động → ngân vô hạn → VU master nhảy dù không play).
panic: function () {
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
_scheduledNotes = [];
if (_initialized && _fluidModule) {
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
// thể không có trong WASM exports (catch nuốt → notes kẹt).
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
try {
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
} catch (e) {}
}
_activeNotes = {};
},
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
var ctx = getCtx();
var freq = 440 * Math.pow(2, (note - 69) / 12);
@@ -477,7 +700,11 @@
var oscType = 'triangle';
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
var prog = program !== undefined ? parseInt(program) : 0;
if (channel !== undefined && channel >= 0 && channel < 16) {
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
// truyền — trước đây override program của track bằng cache channel
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
// nhạc cụ của track TRƯỚC).
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
prog = _channels[channel].program || prog;
}
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
@@ -495,7 +722,8 @@
osc.frequency.setValueAtTime(freq, 0);
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
var durSec = durationMs / 1000;
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
if (isNaN(vel)) vel = 0.8;
var targetGain = vel * volFactor;
noteGain.gain.setValueAtTime(0, startAt);
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
@@ -516,9 +744,17 @@
stopAll: function () {
if (_initialized && _fluidModule) {
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
// phòng all_notes_off không có trong WASM exports.
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
for (var ch = 0; ch < 16; ch++) {
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
try { _fluidModule._fluid_synth_all_sounds_off(_synthPtr, ch); } catch (e) {}
}
}
while (_scheduledNotes.length > 0) {
@@ -535,6 +771,7 @@
} catch (e) {}
});
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
_activeNotes = {};
},
saveToIndexedDB: async function (name, arrayBuffer) {
@@ -559,10 +796,13 @@
var leftPtr = _leftBufPtr;
var rightPtr = _rightBufPtr;
var block = RENDER_BLOCK;
var queueDepth = 0;
var maxQueue = QUEUE_TARGET;
var queueDepth = 0;
var lastTick = performance.now();
var frameMs = (block / _audioCtx.sampleRate) * 1000;
var _dbgPeak = 0;
// Track consumption by wall-clock time instead of async messages — immune
// to message-latency races that could underrun (silence gaps → crackle).
function pushFrame() {
if (!Module || !synth || !node) return;
try {
@@ -573,18 +813,6 @@
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
var rightArr = new Float32Array(Module.HEAPF32.subarray(rpb, rpb + block));
var peak = 0;
var avg = 0;
for (var si = 0; si < leftArr.length; si++) {
var abs = leftArr[si] > 0 ? leftArr[si] : -leftArr[si];
if (abs > peak) peak = abs;
avg += abs;
}
avg /= leftArr.length;
if (!_dbgPeak) {
_dbgPeak = 1;
console.log("[SonicSF] FRAME peak:", peak.toFixed(6), "avg:", avg.toFixed(8), "gain check:", Module._fluid_synth_get_gain ? Module._fluid_synth_get_gain(synth) : 'N/A');
}
node.port.postMessage({ type: 'PCM', L: leftArr, R: rightArr }, [leftArr.buffer, rightArr.buffer]);
queueDepth++;
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
@@ -595,14 +823,16 @@
_renderTimer = null;
return;
}
var needed = maxQueue - queueDepth;
var now = performance.now();
queueDepth = Math.max(0, queueDepth - (now - lastTick) / frameMs);
lastTick = now;
var needed = Math.min(maxQueue - queueDepth, maxQueue);
for (var i = 0; i < needed; i++) {
pushFrame();
}
queueDepth = Math.max(0, queueDepth - 1);
}
_renderTimer = setInterval(fillLoop, Math.max(8, (block / _audioCtx.sampleRate) * 1000 * 0.75));
_renderTimer = setInterval(fillLoop, Math.max(4, frameMs * 0.5));
}
function _stopRenderLoop() {
+20 -2
View File
@@ -60,11 +60,13 @@
}
let autoSaveTimer = null;
let lastGetProjectStateCallback = null;
function scheduleTempAutoSave(getProjectStateCallback) {
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(async () => {
try {
const state = getProjectStateCallback();
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
if (!state || (!state.tracks && !state.main_session)) return;
const dataJson = JSON.stringify(state);
localStorage.setItem('sonic_temp_project', dataJson);
@@ -76,10 +78,26 @@
}
}, 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 = {
exportProjectToSFS,
importProjectFromSFSFile,
scheduleTempAutoSave
scheduleTempAutoSave,
flushTempAutoSave
};
})();
+11 -12
View File
@@ -15,29 +15,28 @@ class FluidSynthBridge extends AudioWorkletProcessor {
process(inputs, outputs) {
const out = outputs[0];
if (!out) return true;
if (!out || out.length === 0) return true;
this.called++;
const numCh = out.length;
const len = out[0].length;
const qL = this.leftQ;
const qR = this.rightQ;
let fi = 0;
let si = 0;
// Handle any output channel count (mono devices produce 1 channel, so
// out[1] may be undefined — never write into a missing channel).
for (let i = 0; i < len; i++) {
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
out[0][i] = qL[fi][si];
out[1][i] = qR[fi][si];
if (fi >= qL.length) {
for (let c = 0; c < numCh; c++) out[c][i] = 0;
continue;
}
for (let c = 0; c < numCh; c++) {
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
}
si++;
if (si >= qL[fi].length) { fi++; si = 0; }
}
if (fi > 0) { this.leftQ.splice(0, fi); this.rightQ.splice(0, fi); }
if (this.called % 50 === 0) {
var pk = 0;
for (var j = 0; j < len; j++) {
var v = out[0][j] > 0 ? out[0][j] : -out[0][j];
if (v > pk) pk = v;
}
if (pk > 0) console.log('[FluidSynth:bridge] process #' + this.called + ' peak:' + pk.toFixed(6) + ' q:' + qL.length);
}
return true;
}
}
+1
View File
@@ -0,0 +1 @@
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
+10
View File
@@ -27,6 +27,16 @@ celery_app.conf.update(
enable_utc=True,
)
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker.
if os.getenv("SF_DESKTOP") == "1":
celery_app.conf.update(
task_always_eager=True,
task_eager_propagates=True,
broker_url="memory://",
result_backend="cache+memory://",
)
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
celery_app.conf.beat_schedule = {
"cleanup-expired-files-every-hour": {
+86 -5
View File
@@ -3,27 +3,47 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>SonicForge Studio - Professional DAW Editor</title>
<script>
// ── Chặn BROWSER ZOOM toàn trang ──
// UI dùng px cố định cho item/button/label — browser zoom (Ctrl+wheel,
// Ctrl+plus/minus/0, pinch) scale TOÀN BỘ làm control phóng to theo.
// Chặn ở capture phase bằng preventDefault() (KHÔNG stopPropagation —
// các vùng zoom chuyên dụng: timeline, piano roll, canvas, EQ vẫn nhận
// event và tự xử lý zoom nội dung của chúng).
document.addEventListener('wheel', function (e) {
if (e.ctrlKey || e.metaKey) e.preventDefault();
}, { capture: true, passive: false });
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '_', '0'].indexOf(e.key) !== -1) {
e.preventDefault();
}
}, { capture: true });
// Pinch zoom (Safari/WebKit gesture events)
document.addEventListener('gesturestart', function (e) { e.preventDefault(); }, { passive: false });
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/api.js?v=202607271016"></script>
<script src="/static/js/services/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/soundfontPlayer.js?v=202607271245"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script>
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
<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/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202607302132" defer></script>
<script src="/static/js/app.precompiled.js?v=202608081600" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
@@ -32,8 +52,43 @@
--top-bar-height: 80px;
--status-bar-height: 25px;
--panel-border-color: #2a2a2a;
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
--sf-bg: #1e1e1e;
--sf-panel: #262626;
--sf-header: #2e2e2e;
--sf-border: #181818;
--sf-accent: #00ffcc;
}
/* ── THEME presets (Tools → Preferences) ── */
html[data-theme="dark"] {
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
}
html[data-theme="midnight"] {
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
}
html[data-theme="forest"] {
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
}
html[data-theme="violet"] {
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
}
html[data-theme="graphite"] {
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
}
/* Áp theme lên các vùng chính của app shell */
.daw-app-shell { background-color: var(--sf-bg) !important; }
.daw-panel { background-color: var(--sf-panel) !important; }
.daw-header { background-color: var(--sf-header) !important; }
body { background-color: var(--sf-bg) !important; }
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
html[data-btnfont="sm"] button { font-size: 10px !important; }
html[data-btnfont="md"] button { font-size: 12px !important; }
html[data-btnfont="lg"] button { font-size: 14px !important; }
body {
background-color: #1a1a1a;
color: #c0c0c0;
@@ -380,6 +435,32 @@
border-radius: 2px;
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
}
/* Media Explorer: horizontal volume slider + selected file row */
input[type=range].me-fader-slider {
-webkit-appearance: none;
appearance: none;
background: #111;
height: 6px;
border-radius: 3px;
border: 1px solid #555;
cursor: pointer;
}
input[type=range].me-fader-slider::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 12px;
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
border: 1px solid #000;
border-radius: 2px;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
cursor: pointer;
}
.file-row-selected {
background-color: #3399ff !important;
color: #ffffff !important;
}
.file-row-selected .file-icon { color: #ffffff !important; }
</style>
</head>
+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');
+62
View File
@@ -0,0 +1,62 @@
# 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
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] Sidecar binary -> src-tauri/binaries (tauri triple naming) =="
New-Item -ItemType Directory -Force src-tauri\binaries | Out-Null
Copy-Item dist\daw_engine.exe src-tauri\binaries\daw_engine-x86_64-pc-windows-msvc.exe -Force
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) =="
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"
+87
View File
@@ -0,0 +1,87 @@
"""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:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return PORT_RANGE[0]
def _parent_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
def main():
os.environ.setdefault("SF_DESKTOP", "1")
port = _pick_port()
os.environ["SF_PORT"] = str(port)
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
try:
log_dir = os.path.join(
os.environ.get("APPDATA") or os.path.expanduser("~"),
APP_DATA_DIR_NAME, "logs",
)
os.makedirs(log_dir, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"),
logging.StreamHandler(),
],
)
except Exception:
logging.basicConfig(level=logging.INFO)
import uvicorn
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
config = uvicorn.Config(app.main.app, host="127.0.0.1", port=port,
log_level="info", access_log=False)
server = uvicorn.Server(config)
parent_pid = os.environ.get("SF_PARENT_PID")
if parent_pid:
def _watchdog():
pid = int(parent_pid)
while True:
time.sleep(1.0)
if not _parent_alive(pid):
logging.getLogger("desktop_engine").info(
"Parent process exited — shutting down engine")
server.should_exit = True
return
threading.Thread(target=_watchdog, daemon=True).start()
logging.getLogger("desktop_engine").info(
"SonicForge engine listening on 127.0.0.1:%d", port)
server.run()
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
# ── Production compose — KHÔNG mount source, image từ registry, bí mật qua .env ──
# Dùng: docker compose -f docker-compose.prod.yml up -d
services:
redis:
image: redis:7-alpine
restart: unless-stopped
web:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
ports:
- "${WEB_PORT:-8000}:8000"
env_file: .env
volumes:
- sf_uploads:/app/app/storage/uploads
- sf_processed:/app/app/storage/processed
- sf_db:/app/app/storage
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
depends_on:
- redis
worker:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
env_file: .env
volumes:
- sf_uploads:/app/app/storage/uploads
- sf_processed:/app/app/storage/processed
- sf_db:/app/app/storage
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
depends_on:
- redis
beat:
image: ${IMAGE:-sonicforge-studio:latest}
restart: unless-stopped
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
env_file: .env
volumes:
- sf_db:/app/app/storage
depends_on:
- redis
volumes:
sf_uploads:
sf_processed:
sf_db:
+128
View File
@@ -0,0 +1,128 @@
# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python)
# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows)
# -*- mode: python ; coding: utf-8 -*-
import os
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules, collect_data_files
# Native DLL cho pedalboard va soundfile
binaries = collect_dynamic_libs('pedalboard')
binaries += collect_dynamic_libs('soundfile')
# Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
# package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
# duoc bundle qua collect_data_files('app').
_SPEC_ROOT = os.path.abspath(SPECPATH)
# ⚠️ GOC ROOT CUA MOI LOI 'app\static does not exist' (gap 3 lan):
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
# bundle thieu static/templates -> exe crash luc chay. Fix: chen _SPEC_ROOT
# vao sys.path TRUOC khi collect de import 'app' luon hoạt dong.
import sys as _sys
if _SPEC_ROOT not in _sys.path:
_sys.path.insert(0, _SPEC_ROOT)
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
# Loai tru storage (57MB soundfonts/uploads — vo ich trong onefile, config.py
# da chuyen storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
if not datas:
print("WARN: collect_data_files('app') tra ve rong - dung datas tinh absolute")
datas = [
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg
(os.path.join(_SPEC_ROOT, 'app', 'static'), 'app/static'), # js/css/processors
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
]
datas += [
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
]
# librosa 0.11 dùng lazy_loader.attach_stub -> lúc RUNTIME cần file .pyi
# ton tai tren disk ('Cannot load imports from non-existent stub ...librosa\__init__.pyi').
# PyInstaller mac dinh KHONG bundle .pyi -> phai collect explicit.
datas += collect_data_files('librosa', includes=['**/*.pyi'])
# scipy >= 1.18 tach scipy.stats thanh nhieu module con (vd
# _ansari_swilk_statistics) import lazy ben trong ham -> hook scipy cua
# PyInstaller miss -> ModuleNotFoundError luc runtime. Giai phap TRIET DE:
# scan FILESYSTEM toan bo site-packages/scipy (khong import, khong walk —
# pkgutil.walk_packages BO QUA AM THAM subpackage import loi luc build,
# da gap: may user mat ca cay scipy.sparse.csgraph._shortest_path).
# Bat moi module .py + C-extension .pyd/.so -> hiddenimports day du.
import importlib.util as _ilu
import glob as _glob
_scipy_spec = _ilu.find_spec('scipy')
_scipy_dir = os.path.dirname(os.path.abspath(_scipy_spec.origin))
_scipy_hidden = []
for _ext in ('*.py', '*.pyd', '*.so'):
for _f in _glob.glob(os.path.join(_scipy_dir, '**', _ext), recursive=True):
_rel = os.path.relpath(_f, _scipy_dir)
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
_pkg = os.path.dirname(_rel).replace(os.sep, '.')
_mod = ('scipy.' + _pkg + '.' + _base) if _pkg else ('scipy.' + _base)
if _mod not in _scipy_hidden:
_scipy_hidden.append(_mod)
# Cung co bang hiddenimport TINH: _morestats import module nay o top-level
# (scipy 1.18+); collect_submodules du phong nhung neu miss (version khac
# tren may user) thi dong nay van dam bao bundle co.
if 'scipy.stats._ansari_swilk_statistics' not in _scipy_hidden:
_scipy_hidden.append('scipy.stats._ansari_swilk_statistics')
# scipy.sparse.csgraph cung lazy-import C-extension tu ben trong ham (vd
# _shortest_path, _traversal, _matching) — hiddenimport tinh phong walk miss.
for _m in ('scipy.sparse.csgraph._shortest_path', 'scipy.sparse.csgraph._traversal',
'scipy.sparse.csgraph._matching', 'scipy.sparse.csgraph._min_spanning_tree'):
if _m not in _scipy_hidden:
_scipy_hidden.append(_m)
a = Analysis(
['desktop_engine.py'],
pathex=[_SPEC_ROOT],
binaries=binaries,
datas=datas,
hiddenimports=collect_submodules('celery.fixups') + _scipy_hidden + [
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'pedalboard',
'soundfile',
'sf2utils',
'mido.backends.rtmidi',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
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='src-tauri/icons/icon.ico',
)
+349
View File
@@ -0,0 +1,349 @@
# DETAILED SPECIFICATION FOR DAW MEDIA EXPLORER PANEL INTERFACE & HTML5 SOURCE CODE
This document details the design structure, features, and executable HTML5 source code for a general-purpose Media Explorer Panel interface styled after the REAPER DAW.
---
## I. DETAILED INTERFACE REGIONS
The interface is divided vertically into 3 primary functional regions, arranged from top to bottom:
### 1. Top Navigation Toolbar
* **Left Navigation Button Group:** **←** (Back), **→** (Forward), **↑** (Up to Parent Directory), **↻** (Refresh).
* **Directory Address Bar:** Displays the current folder path with a drop-down menu for quick-access path selection.
* **Filter / Search Box:** Input field supporting quick file filtering and keyword searching.
* **View Mode Button:** Toggle button for switching file display layouts (*Details* / *List* view).
### 2. Middle Split Panel (Directory Tree + File List)
* **Left Directory Tree View:**
* Hierarchical tree structure displaying project directories, system shortcuts, drives, and audio sample library folders.
* Supports expand/collapse toggle buttons and highlighted background states indicating the actively selected folder.
* **Right File List Table:**
* Displays audio and MIDI files with standard category icons.
* Highlights the currently selected file with a prominent blue row background.
### 3. Bottom Preview & Transport Panel
* **Transport Control Bar & Playback Parameters:**
* **Transport Buttons:** **■** (Stop), **▶** (Play), **❚❚** (Pause), **↻** (Loop/Repeat), **⚡ Auto-Play** (Automatically previews files upon selection).
* **Parameter Controls:** Pitch adjustment (Pitch matching), Rate (Playback speed factor), Volume Slider (Gain adjustment in dB).
* **Media Type Badge:** Label displaying file format classification (*MIDI* / *Audio*).
* **Visualizer Canvas Display & Metadata:**
* **Visualizer Canvas (Left):** Dark display area rendering an overview Piano Roll note grid (for MIDI files) or waveform display (for Audio files), integrated with a timeline/bar ruler and white Playhead cursor.
* **Metadata Text Box (Right):** Information pane displaying detailed file parameters (MIDI event count, duration, sample rate / resolution).
* **Footer Status Bar:**
* Displays current playback timestamp vs. total file length.
* Name of the currently selected/playing file.
* Tempo metadata (BPM) and playback rate scale factor.
---
## II. EXECUTABLE HTML5 & TAILWIND CSS SOURCE CODE
Below is the complete HTML5 source code integrating Web Audio API synthesis, a Canvas visualizer, and real-time interactive file selection and audio preview playback:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DAW Media Explorer Panel Component</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; user-select: none; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
/* REAPER Classic Panel Style */
.reaper-panel {
background: #c0c0c0;
border: 2px solid #ffffff;
border-right-color: #808080;
border-bottom-color: #808080;
}
.reaper-inset {
background: #ffffff;
border: 1px solid #808080;
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.3);
}
.reaper-dark-inset {
background: #181818;
border: 1px solid #3a3a3a;
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.8);
}
.file-row-selected {
background-color: #3399ff !important;
color: #ffffff !important;
}
.file-row-selected .file-icon { color: #ffffff !important; }
/* Scrollbar */
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: #e0e0e0; border-left: 1px solid #a0a0a0; }
::-webkit-scrollbar-thumb { background: #b0b0b0; border: 1px solid #ffffff; border-right-color: #707070; border-bottom-color: #707070; }
::-webkit-scrollbar-thumb:hover { background: #909090; }
/* Volume Slider */
input[type=range].fader-slider {
appearance: none; -webkit-appearance: none;
background: #111; height: 6px; border-radius: 3px; border: 1px solid #555;
}
input[type=range].fader-slider::-webkit-slider-thumb {
-webkit-appearance: none; height: 16px; width: 12px;
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
border: 1px solid #000; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,0.5); cursor: pointer;
}
</style>
</head>
<body class="bg-slate-950 text-slate-900 h-screen flex items-center justify-center p-2 overflow-hidden">
<!-- MEDIA EXPLORER PANEL CONTAINER -->
<div class="w-full max-w-4xl h-[420px] flex flex-col reaper-panel text-slate-900 overflow-hidden shadow-2xl relative">
<!-- 1. TOP NAVIGATION TOOLBAR -->
<div class="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
<div class="flex items-center gap-1 flex-1 mr-2">
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back">
<i class="fa-solid fa-arrow-left"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward">
<i class="fa-solid fa-arrow-right"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh">
<i class="fa-solid fa-rotate-right"></i>
</button>
<div class="flex-1 flex items-center reaper-inset h-5 px-1 bg-white">
<i class="fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"></i>
<input type="text" id="addressBarInput" value="Root:\Media Library" class="w-full text-xs outline-none bg-transparent font-sans text-slate-800" readonly>
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center reaper-inset h-5 px-1 bg-white w-40">
<input type="text" placeholder="Filter/Search..." class="w-full text-xs outline-none bg-transparent font-sans text-slate-800">
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
<button class="px-2 py-0.5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold">
<span>Details</span> <i class="fa-solid fa-caret-down text-[9px]"></i>
</button>
</div>
</div>
<!-- 2. MIDDLE SPLIT VIEW (DIRECTORY TREE + FILE LIST) -->
<div class="flex-1 flex overflow-hidden">
<!-- DIRECTORY TREE (LEFT) -->
<div class="w-60 reaper-inset m-1 mr-0 overflow-y-auto p-1 text-xs select-none bg-white">
<div class="space-y-0.5 font-sans">
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> &lt;Track Templates&gt;</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> &lt;Project Directory&gt;</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Computer</div>
<!-- Selected Folder -->
<div id="folderMidi" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer font-semibold text-slate-900 bg-slate-300 rounded-sm">
<i class="fa-solid fa-minus text-[9px] text-slate-600"></i>
<i class="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
</div>
<div class="pl-3 space-y-0.5">
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 cursor-pointer text-slate-800">
<i class="fa-solid fa-plus text-[9px] text-slate-500"></i> <i class="fa-solid fa-folder text-[#d9a752]"></i> Sound Effects
</div>
<div id="folderDigitalJuice" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer text-slate-800 pl-4">
<i class="fa-solid fa-folder text-[#d9a752]"></i> MIDI Collections
</div>
</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> Desktop</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Documents</div>
</div>
</div>
<!-- FILE LIST TABLE (RIGHT) -->
<div class="flex-1 reaper-inset m-1 overflow-y-auto relative bg-white">
<table class="w-full text-xs text-left border-collapse">
<thead class="sticky top-0 bg-[#e0e0e0] border-b border-[#a0a0a0] text-slate-800 font-semibold select-none shadow-sm z-10">
<tr><th class="py-1 px-2 border-r border-[#b0b0b0]">File</th></tr>
</thead>
<tbody id="fileListTbody" class="font-sans text-slate-800">
<!-- Dynamic File Rows -->
</tbody>
</table>
</div>
</div>
<!-- 3. BOTTOM PREVIEW CONTROL BAR & VISUALIZER CANVAS -->
<div class="h-32 bg-[#d4d0c8] border-t border-[#808080] p-1.5 flex flex-col justify-between text-xs shrink-0 select-none">
<!-- CONTROLS ROW -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1">
<button id="btnStop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-800"><i class="fa-solid fa-square text-[10px]"></i></button>
<button id="btnPlay" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-emerald-700 font-bold"><i class="fa-solid fa-play text-xs"></i></button>
<button id="btnPause" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-amber-700"><i class="fa-solid fa-pause text-xs"></i></button>
<button id="btnLoop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-700"><i class="fa-solid fa-rotate-right text-xs"></i></button>
<button id="btnAutoPlay" class="h-6 px-2 bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border border-slate-700 rounded-sm font-bold text-[10px] flex items-center gap-1 shadow-sm">
<i class="fa-solid fa-bolt text-[9px]"></i> <span>Auto-Play</span>
</button>
</div>
<div class="flex items-center gap-3 font-mono text-[11px]">
<div class="flex items-center gap-1">
<span>Pitch:</span>
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-14">
<input type="number" value="0.00" step="0.5" class="w-full text-xs text-right outline-none bg-transparent">
</div>
</div>
<div class="flex items-center gap-1">
<span>Rate:</span>
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-12">
<input type="number" value="1.0" step="0.1" class="w-full text-xs text-right outline-none bg-transparent">
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-sans text-slate-700">Volume:</span>
<input id="volSlider" type="range" min="-60" max="12" step="0.5" value="0" class="fader-slider w-28">
<div class="reaper-inset px-1 bg-white h-5 flex items-center justify-center w-14 font-mono text-[11px]">
<span id="volDbText">0.00 dB</span>
</div>
</div>
<div id="badgeType" class="px-2 py-0.5 bg-purple-950 text-purple-300 border border-purple-800 font-mono font-bold text-[10px] rounded-sm">
MIDI
</div>
</div>
<!-- CANVAS & METADATA ROW -->
<div class="flex items-stretch gap-2 my-1 h-16">
<div class="flex-1 reaper-dark-inset relative overflow-hidden">
<canvas id="previewCanvas" class="w-full h-full block cursor-pointer"></canvas>
</div>
<div class="w-56 reaper-dark-inset p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto">
<div id="metadataContent">
76 MIDI events<br>
Length: 16 quarter notes<br>
Length: 0:08.000 (est)<br>
Ticks per quarter note: 480
</div>
</div>
</div>
<!-- BOTTOM STATUS BAR -->
<div class="h-5 bg-[#c0c0c0] border-t border-[#ffffff] flex items-center justify-between text-[11px] font-mono px-1">
<div class="flex items-center gap-3">
<div id="timeRange" class="reaper-inset px-1.5 bg-white text-slate-900 font-bold">0.0000 / 16.0000</div>
<div class="reaper-inset px-1.5 bg-white text-slate-900">0.0000</div>
<div class="reaper-inset px-1.5 bg-white text-slate-900">16.0000</div>
</div>
<div id="statusFileName" class="text-slate-800 font-bold truncate max-w-xs">File_Selected.mid</div>
<div id="statusBpm" class="text-slate-700">130 bpm x0.923</div>
</div>
</div>
</div>
<!-- SCRIPT LOGIC -->
<script>
const sampleDb = {
midi: [
{ name: "MIDI_Loop_01.mid", events: 95, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_02_Bass.mid", events: 48, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_03_Lead.mid", events: 110, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_04.mid", events: 76, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_05_Bass.mid", events: 52, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 }
]
};
let selectedFile = sampleDb.midi[3];
const fileListTbody = document.getElementById('fileListTbody');
const previewCanvas = document.getElementById('previewCanvas');
const canvasCtx = previewCanvas.getContext('2d');
function renderFiles() {
fileListTbody.innerHTML = '';
sampleDb.midi.forEach((f) => {
const tr = document.createElement('tr');
tr.className = `cursor-pointer hover:bg-blue-100 ${f.name === selectedFile.name ? 'file-row-selected' : ''}`;
tr.innerHTML = `<td class="py-1 px-2 flex items-center gap-2"><i class="fa-solid fa-music text-purple-600 file-icon"></i><span>${f.name}</span></td>`;
tr.onclick = () => {
selectedFile = f;
renderFiles();
updatePreview();
};
fileListTbody.appendChild(tr);
});
}
function updatePreview() {
document.getElementById('statusFileName').innerText = selectedFile.name;
document.getElementById('metadataContent').innerHTML = `${selectedFile.events} MIDI events<br>Length: ${selectedFile.lengthQn} quarter notes<br>Length: ${selectedFile.time} (est)<br>Ticks per quarter note: ${selectedFile.tpqn}`;
renderCanvas();
}
function renderCanvas() {
previewCanvas.width = previewCanvas.clientWidth;
previewCanvas.height = previewCanvas.clientHeight;
const w = previewCanvas.width, h = previewCanvas.height;
canvasCtx.clearRect(0, 0, w, h);
// Grid
canvasCtx.strokeStyle = '#222';
for (let y = 0; y < h - 14; y += 8) {
canvasCtx.beginPath(); canvasCtx.moveTo(0, y); canvasCtx.lineTo(w, y); canvasCtx.stroke();
}
canvasCtx.strokeStyle = '#333';
for (let b = 0; b <= 16; b += 4) {
const x = (b / 16) * w;
canvasCtx.beginPath(); canvasCtx.moveTo(x, 0); canvasCtx.lineTo(x, h - 14); canvasCtx.stroke();
}
// Notes
canvasCtx.fillStyle = '#9ca3af';
for (let i = 0; i < selectedFile.events; i++) {
const noteX = ((i * 17 + 76) % 95) / 100 * w;
const noteY = ((i * 13 + 76) % (h - 24)) + 4;
canvasCtx.fillRect(noteX, noteY, Math.max(8, (i % 5 + 1) * 12), 3);
}
// Bar Ruler
canvasCtx.fillStyle = '#111';
canvasCtx.fillRect(0, h - 14, w, 14);
canvasCtx.fillStyle = '#888';
canvasCtx.font = '9px JetBrains Mono';
for (let b = 0; b <= 12; b += 4) {
canvasCtx.fillText(b.toString(), (b / 16) * w + 2, h - 3);
}
}
window.onload = () => { renderFiles(); updatePreview(); };
</script>
</body>
</html>
```
+10
View File
@@ -9,6 +9,7 @@
"@babel/cli": "^8.0.4",
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@babel/standalone": "^7.29.8",
"jsdom": "^30.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
@@ -355,6 +356,15 @@
"@babel/core": "^8.0.0"
}
},
"node_modules/@babel/standalone": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.8.tgz",
"integrity": "sha512-XgbPNz+u6JzB7cKGnPDoS1U24J5td8yp3HFWbT/6f4/ASZ0PqaQGIvts1o5v5AI+f6i3jdVB/L/o2CYLCv101A==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
+1
View File
@@ -8,6 +8,7 @@
"@babel/cli": "^8.0.4",
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@babel/standalone": "^7.29.8",
"jsdom": "^30.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
+23
View File
@@ -0,0 +1,23 @@
[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"
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"]
}
+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: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+46
View File
@@ -0,0 +1,46 @@
// SonicForge DAW — desktop shell: spawn/terminate daw_engine.exe sidecar.
use tauri::Manager;
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::sync::Mutex;
struct EngineProcess(Mutex<Option<CommandChild>>);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
// 1. Spawn sidecar daw_engine.exe (PyInstaller bundle)
let sidecar_command = app
.shell()
.sidecar("daw_engine")
.expect("sidecar daw_engine not found — run build_windows.ps1 first");
let (_rx, child) = sidecar_command
.env("SF_PARENT_PID", std::process::id().to_string())
.spawn()
.expect("Failed to spawn daw_engine sidecar");
app.manage(EngineProcess(Mutex::new(Some(child))));
println!("Python Background Engine started (localhost:8000, auto-fallback 8000-8010)");
Ok(())
})
.on_window_event(|window, event| {
// 2. Terminate sidecar khi DAW window dong — tranh orphan process
if let tauri::WindowEvent::Destroyed = event {
// Lay child ra khoi lock, guard drop ngay tai day (trach E0597)
let child = window
.state::<EngineProcess>()
.0
.lock()
.ok()
.and_then(|mut lock| lock.take());
if let Some(child) = child {
let _ = child.kill();
println!("daw_engine sidecar terminated.");
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
sonicforge_daw_lib::run()
}
+43
View File
@@ -0,0 +1,43 @@
{
"$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": {
"windows": [
{
"title": "Sonic Forge DAW - Professional Desktop Studio",
"width": 1440,
"height": 900,
"resizable": true,
"fullscreen": false,
"devtools": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["msi", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"externalBin": [
"binaries/daw_engine"
],
"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"),
)
+11 -1
View File
@@ -13,7 +13,17 @@ client = TestClient(app)
def get_admin_token():
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
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
+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
}
]
+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())
+2094
View File
File diff suppressed because it is too large Load Diff