Compare commits
324 Commits
ae71acef0f
...
standalone
| Author | SHA1 | Date | |
|---|---|---|---|
| aa1cd8d5de | |||
| d31e226f00 | |||
| a0d8725541 | |||
| e89a594b7c | |||
| 61cb4b846f | |||
| 67ad2b8e4b | |||
| a1dc074e35 | |||
| 75eba27dcf | |||
| f492104869 | |||
| 75795653c5 | |||
| 025e0f03a2 | |||
| c08233a163 | |||
| d20932a68b | |||
| 1cf5b18492 | |||
| 1db892bb99 | |||
| 85ac80bb4f | |||
| 250b94cd88 | |||
| aa32272f36 | |||
| fbaac1f673 | |||
| 438ee607fc | |||
| 9f7af1e1b6 | |||
| cacd072f3e | |||
| cc8b286f6c | |||
| 5ae4fd6149 | |||
| 24a39869c8 | |||
| 0c0aadb8ee | |||
| 29ebbfc1c0 | |||
| 1191e46ee5 | |||
| 63af8ec414 | |||
| 6e962dc1b2 | |||
| 6eef16edc0 | |||
| cd2547ef6a | |||
| c538cab745 | |||
| 90493c73f3 | |||
| a3359aa0ed | |||
| a7489f41e6 | |||
| 194c9b52b2 | |||
| 77505fbc86 | |||
| a61f9abd6a | |||
| a7163efaf1 | |||
| 2462bbc1a8 | |||
| 1f1017d78a | |||
| f17bed4e5f | |||
| bc6bc71d0e | |||
| bc8431fe81 | |||
| 466bf25a0b | |||
| 52e1dc6eda | |||
| 30a40b2bca | |||
| 8dd00cc2ea | |||
| 3bd0989031 | |||
| dccf4f45ef | |||
| 9099529403 | |||
| 3795ea9d78 | |||
| fa24c61bbf | |||
| 83427fe503 | |||
| cef2d6666b | |||
| bfe9e47db1 | |||
| bcc91db4a7 | |||
| 1f7a8c6f1b | |||
| 8b0551b18f | |||
| eaca051191 | |||
| b78a629429 | |||
| 366219a269 | |||
| 3618e3c591 | |||
| b490aa9951 | |||
| be93f9f55a | |||
| 7dda81b1d3 | |||
| f7b0172fdc | |||
| 9bebbe8e7a | |||
| 4e4391b315 | |||
| e978abf0e9 | |||
| bb699631bf | |||
| 0a1f3efd64 | |||
| 9ef622f29a | |||
| 8e3c652551 | |||
| 393df8fd7b | |||
| 688a7a57e2 | |||
| 652e745d0a | |||
| 45ab31d42e | |||
| c3182f6baa | |||
| 1d9b426d15 | |||
| 4f34491f3e | |||
| 9e269d3b79 | |||
| 58c6ec794d | |||
| 173a1f227b | |||
| c1aa1eb4d4 | |||
| 6c16bf8410 | |||
| 4981785913 | |||
| bca661bffd | |||
| 4c820bfa77 | |||
| 8cdf2942b9 | |||
| 23675dab83 | |||
| 71b9231b57 | |||
| 93dc13b17a | |||
| f1e914fa62 | |||
| 4dfab954d8 | |||
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca | |||
| 289746f187 | |||
| b3ced7a7b3 | |||
| 4233c1eeda | |||
| 35f7c3822f | |||
| d37f4e7557 | |||
| 8a9d6b3c27 | |||
| 55d3464b1e | |||
| cfc114b9d7 | |||
| 856a8183b6 | |||
| 0272912cff | |||
| c047934fc4 | |||
| fc663921ff | |||
| fe5e8cb58e | |||
| 01fcf51fea | |||
| 1af2119444 | |||
| e5b4321a55 | |||
| ec5fedec33 | |||
| 8c1a8ead56 | |||
| ed91e4534c | |||
| 8fc1c2641b | |||
| a9da813cb1 | |||
| 6f55d36085 | |||
| 7325fbfc45 | |||
| 94b2d2ef41 | |||
| f616edce49 | |||
| 6018263044 | |||
| 36edc9daca | |||
| 71278f2aba | |||
| 022ba38a5e | |||
| 71c3bafdb5 | |||
| 34ad02dd6b | |||
| 47b1633bd3 | |||
| 0182abf7ea | |||
| 9b6de7f857 | |||
| 84ab4ae823 | |||
| 25471e6ea5 | |||
| 0dc95386f2 | |||
| d8227904b6 | |||
| 4d10b9485b | |||
| adccf6430a | |||
| 73025fc387 | |||
| 9f762c2a78 | |||
| a0b6110fd1 | |||
| e88ce2e3ea | |||
| 827693dfc4 | |||
| d04631c7d3 | |||
| 224fd54a56 | |||
| befc0d35fd | |||
| 5e3a283bd9 | |||
| 4a04bc04ef | |||
| dc384b3102 | |||
| 0b9079632a | |||
| 9be6991222 | |||
| 0853a13f62 | |||
| c09f994ede | |||
| 249e2afea2 | |||
| b65188fcc8 | |||
| ba5883951d | |||
| 7000529d8d | |||
| 1de6bd6b57 | |||
| c435d00897 | |||
| b075a5eca7 | |||
| 36ee2bdf90 | |||
| 5423686c73 | |||
| 4db777eb5d | |||
| 1989bfb105 | |||
| d1995307f5 | |||
| f647291523 | |||
| fdaabe3a08 | |||
| 548ab1258f | |||
| 2c2ee59d7d | |||
| 7e5f406226 | |||
| ad5cda5a40 | |||
| 05e6a467d8 | |||
| 4b43f027ce | |||
| 175bce6743 | |||
| 3380b0e201 | |||
| 619a2484ce | |||
| 24ac703d4d | |||
| c47eb5d718 | |||
| 3a345c3f4e | |||
| 86b304e173 | |||
| 01d02a9d71 | |||
| 2bf9f8bcb8 | |||
| 99a445b789 | |||
| ee39dda5b9 | |||
| be8430fbd9 | |||
| f1b00eb060 | |||
| d81afba93d | |||
| 223254eb07 | |||
| 698fa818e9 | |||
| 60d05e2b4d | |||
| 0ea994fb2f | |||
| eb370319cb | |||
| 9b25766fca | |||
| dfbeb24a33 | |||
| 3c4f106e14 | |||
| 4f5aee6830 | |||
| 42d64f93d8 | |||
| 6c11daccf8 | |||
| 88007dce34 | |||
| 1790f743d6 | |||
| ef6370f92e | |||
| 3cac617aff | |||
| e1550c8ffe | |||
| c60803ee2a | |||
| 9c5507f7b2 | |||
| 47e87c8363 | |||
| 652fb98d2c | |||
| 845d1594bb | |||
| 59aa9a32ff | |||
| bd296c63f1 | |||
| 468a1f11a4 | |||
| 0fdb8318f0 | |||
| 437980ea9f | |||
| bc36e40967 | |||
| 6ce77c4f20 | |||
| bc951e75af | |||
| 5e8664af1c | |||
| b1aec24392 | |||
| be3205645f | |||
| 0894f97386 | |||
| ca34a496eb | |||
| e76a6df86b | |||
| 64b9fbadd5 | |||
| 323be6a6f4 | |||
| 5ed6acd4f5 | |||
| 6c227fe6af | |||
| 7b8e10419f | |||
| 872ecabf85 | |||
| 4005088e89 | |||
| 9c487614f1 | |||
| 58a294d845 | |||
| 313d1be5af | |||
| 3386a2b4c2 | |||
| 64f43cd5ad | |||
| 2fb70e2e11 | |||
| 546ad3dd23 | |||
| 402cac56a3 | |||
| 1de69b8f4e | |||
| a9be38b76f | |||
| 938c8d7f87 | |||
| 15193b13a7 | |||
| 4db3e3484a | |||
| f0479dc3d8 | |||
| 7836cfcc76 | |||
| 19f02a5a49 | |||
| dc7ec3af4c | |||
| c073ca7a49 | |||
| 9f6f9763c1 | |||
| bbd06f23d5 | |||
| b67c0264fb | |||
| 74597cfe8b | |||
| 0c1a27ef2c | |||
| eb61015cff | |||
| 6e1f116658 | |||
| 2c55db372a | |||
| e64070a83e | |||
| 66eef405a7 | |||
| 971dad84e7 | |||
| 4cead1010e | |||
| 7b5d4bf27a | |||
| 47af85c072 | |||
| d4ad3bc1f7 | |||
| f721b8242f | |||
| a1767e338a | |||
| 8051cf3edb | |||
| 35e767795a | |||
| 7bfdd72850 | |||
| 30ce0eb306 | |||
| 41b5a70a60 | |||
| fa7917cf54 | |||
| a3e681afc0 | |||
| 8775c960e3 | |||
| 33e35a1af1 | |||
| d4262acb9f | |||
| e40fdf15ad | |||
| 0d763c5a0b | |||
| ad6fa1feb9 | |||
| 9abb0e6a50 | |||
| 6c6a326b7d | |||
| 8e8fc58082 | |||
| ec355da58d | |||
| 9c3a435f26 | |||
| 12d2639455 | |||
| e99bc8624f | |||
| 9869566d3e | |||
| 0f99788d21 | |||
| e544d5e694 | |||
| 614d83b971 | |||
| 895ad0b7d9 | |||
| 98ca21bd9a | |||
| c3d87562e9 | |||
| 5ed943b3d6 | |||
| fa7815bb53 | |||
| caa95177ef | |||
| b00f0cff95 | |||
| 2187440681 | |||
| 33cf804c88 | |||
| a68bebac2a | |||
| 35fb29201d | |||
| 393dd7190f | |||
| fc81f19b1c | |||
| 61f4b90af0 | |||
| a56c616817 | |||
| 9cbf5940f4 | |||
| 4c3d649506 | |||
| 0dc3f10406 | |||
| 6a46b1ffe4 | |||
| 71edf7ffb3 | |||
| c09ad7f48d | |||
| ad29df46d2 | |||
| 2a9748e12e | |||
| bb49035bee | |||
| 6c8eb79aac | |||
| 54c1ee9e94 | |||
| a41284ccf7 | |||
| 6604f85e0d | |||
| cff3950670 | |||
| 3f7abe0c95 | |||
| 2eb327056d | |||
| 421535ca0e | |||
| 77c7486d4e | |||
| 10c0a743c6 |
@@ -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/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ── SonicForge Studio — môi trường (SAO CHÉP sang .env — KHÔNG commit .env) ──
|
||||||
|
|
||||||
|
# Redis / Celery
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
|
||||||
|
# LLM (server-side proxy — client không thấy key)
|
||||||
|
OPENAI_API_BASE=http://localhost:11434/v1
|
||||||
|
OPENAI_API_KEY=ollama
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
SECRET_KEY=thay-bang-chuoi-ngau-nhien-dai
|
||||||
|
DEFAULT_ADMIN_PASSWORD=thay-mat-khau-admin
|
||||||
|
|
||||||
|
# Storage (đường dẫn trong container)
|
||||||
|
STORAGE_DIR=/app/app/storage
|
||||||
|
|
||||||
|
# ── Plugin directories ──
|
||||||
|
# Đường dẫn HOST tới thư mục chứa VST / SoundFont / Pianobook — dùng trong
|
||||||
|
# docker-compose.yml để mount vào container (đổi theo máy chạy Docker).
|
||||||
|
# Mặc định: /home/locpham/daw_assets/...
|
||||||
|
VST_DIR=/home/locpham/daw_assets/vst3
|
||||||
|
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
|
||||||
|
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
|
||||||
|
|
||||||
|
# ── Runtime (tự phát hiện môi trường) ──
|
||||||
|
# auto (mặc định): Windows/macOS → desktop; Linux không DISPLAY hoặc docker → headless.
|
||||||
|
# desktop: server + client cùng 1 máy (bật nút "Mở trong Carla" nếu có Carla local)
|
||||||
|
# headless: server docker + browser UI (soundfont + VSTi mở được từ storage mount;
|
||||||
|
# preset chỉnh trên máy khác → upload .vstpreset qua web UI)
|
||||||
|
SF_RUNTIME=auto
|
||||||
|
# Ép nhận diện Docker (thường tự detect qua /.dockerenv; đặt =1 nếu cần)
|
||||||
|
SF_DOCKER=1
|
||||||
@@ -19,6 +19,7 @@ app/storage/processed/*
|
|||||||
!app/storage/processed/.gitkeep
|
!app/storage/processed/.gitkeep
|
||||||
app/storage/*.db
|
app/storage/*.db
|
||||||
app/storage/sf_scan_state.json
|
app/storage/sf_scan_state.json
|
||||||
|
app/storage/temp/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# VST3 and sample library directories (proprietary binaries)
|
# VST3 and sample library directories (proprietary binaries)
|
||||||
@@ -29,3 +30,10 @@ samples/
|
|||||||
*.log
|
*.log
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
node_modules
|
node_modules
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/binaries/
|
||||||
|
src-tauri/resources/daw_engine/
|
||||||
|
src-tauri/vc_redist.x64.exe
|
||||||
|
app/storage/plugin_dirs.json
|
||||||
|
app/storage/sf_scan_state.json.bak-root
|
||||||
|
app/storage/soundfonts/
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Implementation Plan: Ozone Mastering Module
|
||||||
|
|
||||||
|
## Nguồn DUY NHẤT để tạo giao diện
|
||||||
|
|
||||||
|
**Toàn bộ giao diện của Mastering Modal được lấy TRỰC TIẾP từ HTML trong `md/45_MASTERING_MODULE.md` dòng 74–885.**
|
||||||
|
|
||||||
|
Không thiết kế giao diện mới. Không suy luận UI. Lấy chính xác:
|
||||||
|
- **Cấu trúc HTML**: copy verbatim từ spec, chuyển sang JSX (className thay class, onChange thay onchange, v.v.)
|
||||||
|
- **CSS styles**: copy verbatim từ spec (oz-panel, oz-card, oz-card-active, knob-dial, knob-pointer, gradient backgrounds)
|
||||||
|
- **JavaScript logic**: port verbatim từ spec vào React hooks (useState thay ozState, useRef thay DOM refs, useEffect thay event listeners)
|
||||||
|
- **Web Audio DSP graph**: copy y hệt node graph + connection từ spec (BiquadFilterNode, GainNode, DynamicsCompressorNode, AnalyserNode)
|
||||||
|
- **Canvas renderers**: copy y hệt drawing logic từ spec (frequency grid, FFT fill, EQ curve, polar vectorscope, VU meter gradient)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Dịch spec HTML `md/45_MASTERING_MODULE.md` (lines 74–885) thành `MasteringModal` React component trong `app.jsx`.
|
||||||
|
|
||||||
|
## Ánh xạ HTML → React (chi tiết từ spec)
|
||||||
|
|
||||||
|
### HTML lines 74–118: Dependencies & Styles
|
||||||
|
|
||||||
|
| Spec | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `<script src="cdn.tailwindcss.com">` | Đã có trong `index.html:9` |
|
||||||
|
| `<link FA 6.4.0>` | Đã có trong `index.html:10` (dùng lucide thay FA, giữ nguyên) |
|
||||||
|
| `<link Google Fonts Inter>` | Đã có font-family trong `index.html` style |
|
||||||
|
| CSS `.oz-panel` `oz-card` `oz-card-active` `knob-*` scrollbar | Thêm vào `index.html` `<style>` block |
|
||||||
|
| CSS `.font-mono { JetBrains Mono }` | Thêm vào `index.html` `<style>` |
|
||||||
|
|
||||||
|
**→ Sửa `app/templates/index.html`**: append ~30 dòng CSS class (oz-panel, oz-card, knob-dial, scrollbar tối)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 121–161: Header Bar
|
||||||
|
|
||||||
|
| Spec Element | React JSX Mapping |
|
||||||
|
|---|---|
|
||||||
|
| Logo `.fa-atom` + "OZONE ADVANCED" | `<div>` với cùng class & lucide icon `wand-2` thay FA |
|
||||||
|
| Play/Stop buttons + preset select | JSX nguyên bản, class giữ nguyên |
|
||||||
|
| Target LUFS text | JSX nguyên bản |
|
||||||
|
|
||||||
|
**→ Trong `MasteringModal` return JSX**, copy verbatim HTML, chỉ thay `.fa-*` → lucide `i` tương ứng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 163–213: Module Chain Strip
|
||||||
|
|
||||||
|
| Spec Element | React |
|
||||||
|
|---|---|
|
||||||
|
| `#cardDynamicEq` + power toggle + icon | JSX với `onClick` → set activeModule('eq') |
|
||||||
|
| `#cardImager` + power toggle + icon | JSX với `onClick` → set activeModule('imager') |
|
||||||
|
| `#cardMaximizer` + power toggle + icon | JSX với `onClick` → set activeModule('maximizer') |
|
||||||
|
| Empty add-module slot `.border-dashed` | JSX verbatim |
|
||||||
|
| `oz-card-active` class toggle | `className={\`oz-card ${ozState.activeModule==='eq' ? 'oz-card-active' : ''}\`}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 216–325: Dynamic EQ View
|
||||||
|
|
||||||
|
| Spec Element | React |
|
||||||
|
|---|---|
|
||||||
|
| `#viewDynamicEq` container | `className={ozState.activeModule === 'eq' ? '' : 'hidden'}` |
|
||||||
|
| `#eqCanvas` | `<canvas ref={el => canvasRefs.current.eq = el}>` |
|
||||||
|
| 4-band knobs (Low/Mid1/Mid2/High) | Mỗi band là `<div>` với `.knob-container`, `data-param`, pointer events |
|
||||||
|
| Knob dial HTML `.knob-dial > .knob-pointer` | JSX verbatim + inline `transform: rotate(Xdeg)` |
|
||||||
|
| Band labels & freq text | JSX verbatim |
|
||||||
|
|
||||||
|
**Knob interaction (spec lines 794–843)**: port pointerdown/move/up logic vào `useEffect` với `.knob-container` querySelectorAll, update `ozState[param]` + gọi `updateAudioGraphValues`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 327–363: Stereo Imager View
|
||||||
|
|
||||||
|
| Spec Element | React |
|
||||||
|
|---|---|
|
||||||
|
| `#viewImager` | `className={ozState.activeModule === 'imager' ? '' : 'hidden'}` |
|
||||||
|
| `#imagerCanvas` (polar vectorscope) | `<canvas ref={...}>` |
|
||||||
|
| 4-band width sliders `#w1Slider`–`#w4Slider` | `<input type="range" value={ozState.w1} onChange={...}>` |
|
||||||
|
| Value spans `#w1Val`–`#w4Val` | `<span>{ozState.w1}%</span>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 366–421: Maximizer View
|
||||||
|
|
||||||
|
| Spec Element | React |
|
||||||
|
|---|---|
|
||||||
|
| `#viewMaximizer` | `className={ozState.activeModule === 'maximizer' ? '' : 'hidden'}` |
|
||||||
|
| Maximizer gain knob (`.knob-dial w-24 h-24`) | JSX verbatim, pointer events |
|
||||||
|
| Ceiling level text | `<span>{ozState.ceiling.toFixed(2)} dB</span>` |
|
||||||
|
| Upward compress knob | JSX verbatim + pointer events |
|
||||||
|
| Soft clipper knob | JSX verbatim + pointer events |
|
||||||
|
| Transient emphasis knob | JSX verbatim + pointer events |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 425–480: Right Sidebar (I/O Meters + Utilities)
|
||||||
|
|
||||||
|
| Spec Element | React |
|
||||||
|
|---|---|
|
||||||
|
| `#inPeakText` / `#outPeakText` | `<span>{inPeak.toFixed(1)} dB</span>` etc. |
|
||||||
|
| `#inMeterCanvas` / `#outMeterCanvas` | `<canvas ref={...}>` |
|
||||||
|
| Bypass / Gain Match / Codec / Dither buttons | JSX verbatim |
|
||||||
|
| Module active state checkboxes (power toggles) | Thêm `onClick` set `ozState.eqActive` etc. + gọi `updateAudioGraphValues` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 484–585: Audio Engine (JavaScript → React)
|
||||||
|
|
||||||
|
| Spec Function | React Mapping |
|
||||||
|
|---|---|
|
||||||
|
| `ozState` object | `const [ozState, setOzState] = useState({...})` — values copy verbatim |
|
||||||
|
| `initOzoneAudioEngine()` | `useEffect` on mount: tạo AudioContext (dùng `window.SonicAudio?.getAudioContext()` nếu có), tạo AnalyserNode (fftSize=2048), BiquadFilter nodes (lowshelf 100Hz, peaking 822Hz Q0.7, peaking 3.2kHz Q1.2, highshelf 10kHz), GainNode boost, DynamicsCompressorNode (threshold -0.1, knee 0, ratio 20, attack 0.001, release 0.05), outputAnalyser |
|
||||||
|
| Node graph connection | `inputAnalyser → eqLow → eqMid1 → eqMid2 → eqHigh → boostGain → compressor → outputAnalyser → destination` |
|
||||||
|
| `updateAudioGraphValues()` | Copy verbatim logic: `eqLowFilter.gain.setTargetAtTime(ozState.eqActive ? ozState.eqLowGain : 0, now, 0.01)` etc. |
|
||||||
|
| `startAudioDemo()` | Copy verbatim: create 4-second stereo buffer, kick/snare/synth oscillator, loop playback |
|
||||||
|
| `stopAudioDemo()` | Copy verbatim: `sourceNode.stop(); sourceNode.disconnect();` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 586–791: Canvas Renderers (JavaScript → React)
|
||||||
|
|
||||||
|
| Spec Canvas | React Mapping |
|
||||||
|
|---|---|
|
||||||
|
| `eqCanvas` (lines 680–738) | `useEffect` với `requestAnimationFrame` loop: draw freq grid lines, FFT spectrum bar fill từ `outputAnalyser.getByteFrequencyData`, draw composite EQ curve |
|
||||||
|
| `imagerCanvas` (lines 742–765) | `requestAnimationFrame`: draw polar circle guides + random scatter dots with `ozState.w3` width |
|
||||||
|
| `inMeterCanvas`/`outMeterCanvas` (lines 769–787) | `requestAnimationFrame`: `renderMeterBar()` with linear gradient green→yellow→red, dBFS numeric readout |
|
||||||
|
| `resizeAll()` | `useEffect` + `window.addEventListener('resize', resizeAll)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTML lines 793–882: Interactions & Init (JavaScript → React)
|
||||||
|
|
||||||
|
| Spec | React |
|
||||||
|
|---|---|
|
||||||
|
| `setupKnobs()` (lines 794–843) | `useEffect` on mount: query `.knob-container`, attach pointerdown/move/up, update UI rotation + `ozState` + `updateAudioGraphValues` |
|
||||||
|
| `setupModuleNavigation()` (lines 846–868) | Inline JSX click handlers thay vì event listeners |
|
||||||
|
| `window.addEventListener('load', ...)` (lines 871–882) | `useEffect` on mount: init audio engine, setup event handlers, start canvas renderers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task List
|
||||||
|
|
||||||
|
### Task 1: Append CSS classes vào app/templates/index.html
|
||||||
|
Thêm ~30 dòng CSS vào `<style>` block (`index.html:28-245`):
|
||||||
|
- `.oz-panel` `oz-card` `oz-card-active`
|
||||||
|
- `.knob-container` `.knob-dial` `.knob-pointer`
|
||||||
|
- Scrollbar dark theme
|
||||||
|
- `.font-mono` JetBrains Mono override
|
||||||
|
- Gradient panel backgrounds
|
||||||
|
|
||||||
|
### Task 2: Thêm state + menu item trong app.jsx
|
||||||
|
- `showMasteringModal` state (dòng 7708)
|
||||||
|
- Menu item "Mastering Suite" (dòng 15585)
|
||||||
|
- Render `MasteringModal` (dòng 18664)
|
||||||
|
|
||||||
|
### Task 3: Viết MasteringModal component trong app.jsx (trước dòng 6694)
|
||||||
|
~500 dòng gồm:
|
||||||
|
|
||||||
|
**3a. State**: `ozState` (activeModule, gains, widths, maximizer params), `isPlaying`, `canvasRefs`, `audioRefs`
|
||||||
|
|
||||||
|
**3b. useEffect (mount)**: `initOzoneAudioEngine()` — copy verbatim từ spec lines 529-584, dùng `window.SonicAudio?.getAudioContext()` fallback
|
||||||
|
|
||||||
|
**3c. useEffect (mount)**: `setupKnobs()` — port pointer events từ spec lines 794-843, gọi `updateAudioGraphValues()` khi giá trị thay đổi
|
||||||
|
|
||||||
|
**3d. useEffect (mount + deps)**: Canvas render loop — port `startCanvasRenderers()` từ spec lines 650-791, sử dụng `ozState` thay vì global
|
||||||
|
|
||||||
|
**3e. useEffect (cleanup)**: Stop audio + disconnect nodes khi unmount
|
||||||
|
|
||||||
|
**3f. JSX return**: ~250 dòng JSX, dịch sát spec HTML lines 121-480:
|
||||||
|
- Overlay modal container `fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm`
|
||||||
|
- Bên trong: `flex flex-col` container với `h-[90vh] w-[90vw] max-w-[1400px]`
|
||||||
|
- Header bar (logo, transport, preset select, LUFS)
|
||||||
|
- Module chain strip (3 module cards + add slot, `oz-card-active` toggle)
|
||||||
|
- Main workspace:
|
||||||
|
- Sub toolbar (Delta listen, IRC mode select, Learn Input Gain)
|
||||||
|
- `viewDynamicEq` (EQ canvas + 4-band knob panel, show khi `activeModule==='eq'`)
|
||||||
|
- `viewImager` (vectorscope canvas + 4-band sliders, show khi `activeModule==='imager'`)
|
||||||
|
- `viewMaximizer` (gain knob, upward comp, soft clip, transient knobs, show khi `activeModule==='maximizer'`)
|
||||||
|
- Right sidebar I/O meters (in/out peak text, meter canvases, bypass/gain match/codec/dither buttons)
|
||||||
|
- Close button (góc trên phải)
|
||||||
|
|
||||||
|
### Task 4: Build & Verify
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
Kiểm tra: mở app → menu → "Mastering Suite" → modal hiển thị, Play Reference Loop phát âm thanh, canvas vẽ, knobs kéo được.
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
| File | Lines | Change |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `app/templates/index.html` | +30 | Thêm CSS classes (oz-panel, oz-card, knob-dial, scrollbar) |
|
||||||
|
| `app/static/js/app.jsx` | ~dòng 6690 + 7708 + 15585 + 18664 | Thêm MasteringModal component + state + menu item + render |
|
||||||
|
|
||||||
|
## Skipped (PONYTALL)
|
||||||
|
- Real M/S processing: phase 1 chỉ UI + vectorscope viz. Thêm actual M/S khi user upload file stereo thật.
|
||||||
|
- Delta Listen / Gain Match / Codec / Dither: UI button renders, DSP stubbed. Thêm sau.
|
||||||
|
- Drag-drop module reorder: spec define UX. Phase 1: fixed chain.
|
||||||
|
- Preset save/load: UI dropdown + defaults. Backend integration sau.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# DESKTOP INSTALL PLAN — SonicForge Studio (bản cài trực tiếp trên OS)
|
||||||
|
|
||||||
|
Mô hình: **server chạy nền trên máy người dùng (localhost), client mở bằng browser**.
|
||||||
|
Không cần Electron — tận dụng stack hiện có (FastAPI + static JS precompiled).
|
||||||
|
Soundfonts do **người dùng tự tải vào thư mục riêng** và **khai báo** cho ứng dụng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. KIẾN TRÚC
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────── NGƯỜI DÙNG (1 máy) ───────────────────────────┐
|
||||||
|
│ Browser ──http://127.0.0.1:8000──▶ FastAPI (uvicorn, chạy NỀN) │
|
||||||
|
│ │ │
|
||||||
|
│ ~/SonicForgeStudio/ ├─ data/ (DB, uploads, cache) │
|
||||||
|
│ ├─ data/sonicforge.db ├─ soundfonts/ (user tự bỏ .sf2) │
|
||||||
|
│ ├─ soundfonts/*.sf2/.sf3 └─ quét nền 30s (scanner có sẵn) │
|
||||||
|
│ └─ config.json ← khai báo folder soundfont │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. ĐÓNG GÓI CÀI ĐẶT (per-OS) — server nền + browser client
|
||||||
|
|
||||||
|
### Thành phần đóng gói
|
||||||
|
| Thành phần | Vai trò |
|
||||||
|
|---|---|
|
||||||
|
| Python backend (`app/`, `requirements.txt`) | Server — **PyInstaller `--onedir`** → binary (không cần Python trên máy user; bảo vệ source tốt hơn so với chạy .py) |
|
||||||
|
| `app/static/` (precompiled.js + css + vendor) | Client — đóng vào package, server phục vụ |
|
||||||
|
| VST3 plugins (`vst_plugins/`) | Copy theo platform (win .dll / mac .vst3 / linux .so) |
|
||||||
|
| Service wrapper | Tự khởi động server nền + mở browser khi login |
|
||||||
|
|
||||||
|
### Cài đặt + service nền theo OS
|
||||||
|
| OS | Installer | Service nền | Auto-open browser |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Windows** | Inno Setup (`.exe`) | Windows Service qua `NSSM`/`WinSW` hoặc Task Scheduler (logon) | `start http://127.0.0.1:8000` |
|
||||||
|
| **macOS** | `.dmg` + `.app` (PyInstaller) | `launchd` LaunchAgent (`~/Library/LaunchAgents`) | `open http://127.0.0.1:8000` |
|
||||||
|
| **Linux** | `.deb` / `.AppImage` | systemd **user** service (`~/.config/systemd/user/`) | `xdg-open http://127.0.0.1:8000` |
|
||||||
|
|
||||||
|
- Server bind **`127.0.0.1`** (không lộ mạng), port mặc định 8000 (config được).
|
||||||
|
- App gồm 2 tiến trình nhỏ: `web` (uvicorn) + `worker` (celery) — hoặc gộp worker vào web ở chế độ desktop (đơn giản: `--pool=solo`, chạy celery trong tiến trình riêng nếu cần render nặng).
|
||||||
|
|
||||||
|
## 3. THƯ MỤC DỮ LIỆU NGƯỜI DÙNG
|
||||||
|
```
|
||||||
|
~/SonicForgeStudio/
|
||||||
|
├── data/ # DB, uploads, processed (thay app/storage khi chạy desktop)
|
||||||
|
├── soundfonts/ # USER tự tải .sf2/.sf3 vào đây (mặc định được quét)
|
||||||
|
└── config.json # cấu hình: soundfont_dirs, port, autostart...
|
||||||
|
```
|
||||||
|
- `config.py`: thêm `DATA_DIR` (env `SFDATA_DIR`, mặc định `~/SonicForgeStudio/data`), `SOUNDFONT_DIRS`.
|
||||||
|
|
||||||
|
## 4. SOUNDFONT DO NGƯỜI DÙNG QUẢN LÝ (tải + khai báo)
|
||||||
|
**Cơ chế hiện có (tận dụng):** `app/core/soundfont_scanner.py` — `SoundFontAutoScanner` quét nền 30s
|
||||||
|
`/opt/daw_engine/soundfonts` + `storage/soundfonts` → catalog → API list qua `app/api/v1/plugins.py`.
|
||||||
|
|
||||||
|
**Việc cần làm (mở rộng):**
|
||||||
|
1. **Scanner nhận folder người dùng:** constructor nhận thêm `user_dirs` (từ `SOUNDFONT_DIRS` env + `config.json`) — merge vào catalog (ưu tiên: user > system).
|
||||||
|
2. **Khai báo folder — 2 cách:**
|
||||||
|
- **UI** (chính): Settings → "Soundfonts" → nút **Add folder…** (chọn thư mục chứa .sf2) → lưu vào `config.json` → gọi scanner rescan.
|
||||||
|
- **config.json** (thủ công): `{ "soundfont_dirs": ["D:/SF", "/Users/me/sf"] }`.
|
||||||
|
3. **API:** thêm endpoint `POST /api/v1/plugins/soundfonts/dirs` (đăng ký folder) + `GET .../dirs` (liệt kê) — scanner reload.
|
||||||
|
4. **UI danh sách:** hiển thị catalog (tên SF, kích thước, folder nguồn) — chọn = load vào FluidSynth (luồng có sẵn qua `SonicSF.selectInstrument`).
|
||||||
|
5. **Số hóa:** không upload file — server đọc trực tiếp từ đường dẫn user khai báo (không nhân đôi dữ liệu).
|
||||||
|
|
||||||
|
## 5. BUILD + PHÁT HÀNH
|
||||||
|
```
|
||||||
|
code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đóng installer theo OS
|
||||||
|
→ ký số (tùy chọn) → phát hành (GitHub Releases / trang riêng)
|
||||||
|
```
|
||||||
|
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
|
||||||
|
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
|
||||||
|
|
||||||
|
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
|
||||||
|
|
||||||
|
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
|
||||||
|
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
|
||||||
|
|
||||||
|
Đã xử lý:
|
||||||
|
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
|
||||||
|
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
|
||||||
|
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
|
||||||
|
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
|
||||||
|
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
|
||||||
|
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
|
||||||
|
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
|
||||||
|
desktop slim chạy task in-process (thread + registry), giữ nguyên API
|
||||||
|
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
|
||||||
|
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
|
||||||
|
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
|
||||||
|
còn `scipy.signal` (goi duy nhất app còn dùng).
|
||||||
|
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
|
||||||
|
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
|
||||||
|
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
|
||||||
|
"build xong không chạy daw_engine" trên Windows).
|
||||||
|
- **Fix layout resources (bản 1.1.1 — `exists=false` trong spawn.log)**:
|
||||||
|
`bundle.resources` dạng ARRAY `["resources/daw_engine"]` copy engine tới
|
||||||
|
`$RESOURCE_DIR/resources/daw_engine/...` (giữ tiền tố `resources/` — đọc
|
||||||
|
source `tauri-utils/src/resources.rs`) trong khi lib.rs tìm ở
|
||||||
|
`$RESOURCE_DIR/daw_engine/...` → `exists=false`. Đổi sang dạng MAP
|
||||||
|
`{"resources/daw_engine": "daw_engine/"}` (Walk mode, giữ nguyên cây
|
||||||
|
`_internal`, đích chuẩn `daw_engine/`). `src-tauri/src/lib.rs` đồng thời dò
|
||||||
|
thêm 3 vị trí fallback (legacy/portable/dev) + ghi diagnostic đầy đủ vào
|
||||||
|
`%APPDATA%/SonicForgeDAW/logs/spawn.log` (liệt kê nội dung resource_dir khi
|
||||||
|
không tìm thấy).
|
||||||
|
|
||||||
|
Lệnh build 1 lệnh mỗi OS:
|
||||||
|
```bash
|
||||||
|
# Windows (PowerShell, ASCII-only)
|
||||||
|
powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# Linux (cần binutils: sudo apt-get install -y binutils)
|
||||||
|
bash build_linux.sh
|
||||||
|
# macOS (cần codesign/notarize khi phát hành)
|
||||||
|
bash build_macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. CẬP NHẬT
|
||||||
|
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
||||||
|
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
||||||
|
- `?v=` cache-bust JS mỗi bản (cơ chế đã có) — browser không dính cache cũ.
|
||||||
|
|
||||||
|
## 7. CHECKLIST CODE CẦN LÀM
|
||||||
|
- [ ] `config.py`: `DATA_DIR`, `SOUNDFONT_DIRS` (env + config.json)
|
||||||
|
- [ ] `soundfont_scanner.py`: nhận `user_dirs`, merge catalog, ưu tiên user
|
||||||
|
- [ ] API: `POST/GET .../soundfonts/dirs` (đăng ký/liệt kê folder) + rescan
|
||||||
|
- [ ] UI Settings → Soundfonts (Add folder, Browse, list, load)
|
||||||
|
- [ ] `soundfontStorage.js`: chuyển hướng sang catalog folder-scan (giữ upload path cho dự án cũ)
|
||||||
|
- [ ] PyInstaller spec: bundle static/ + vendor (libfluidsynth wasm, vst), bind 127.0.0.1
|
||||||
|
- [ ] Service wrappers: Windows (NSSM/WinSW), macOS (launchd), Linux (systemd user) + auto-open browser
|
||||||
|
- [ ] Installer scripts: Inno Setup (.exe), dmg (macOS), deb/AppImage (Linux)
|
||||||
|
- [ ] CI matrix build 3 OS + release artifacts
|
||||||
|
- [ ] `version.json` + in-app update check
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 8000–8010 + 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`
|
||||||
@@ -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).
|
||||||
+30
-1
@@ -20,6 +20,12 @@ class UpdateUserRoleRequest(BaseModel):
|
|||||||
role: str # 'admin', 'standard', 'premium'
|
role: str # 'admin', 'standard', 'premium'
|
||||||
is_active: Optional[bool] = True
|
is_active: Optional[bool] = True
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
role: Optional[str] = 'standard'
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users(admin: dict = Depends(require_admin)):
|
async def list_users(admin: dict = Depends(require_admin)):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -52,6 +58,29 @@ async def list_users(admin: dict = Depends(require_admin)):
|
|||||||
})
|
})
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(req: CreateUserRequest, admin: dict = Depends(require_admin)):
|
||||||
|
import uuid, time
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM users WHERE username = ? OR email = ?", (req.username, req.email))
|
||||||
|
if cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=409, detail="Tên đăng nhập hoặc email đã tồn tại")
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
hashed = hash_password(req.password)
|
||||||
|
now = time.time()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, 1)
|
||||||
|
""", (user_id, req.username, req.email, hashed, req.role, now))
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks) VALUES (?, 500, 16)
|
||||||
|
""", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"message": f"Đã tạo người dùng '{req.username}' thành công", "user_id": user_id}
|
||||||
|
|
||||||
@router.put("/users/{user_id}/role")
|
@router.put("/users/{user_id}/role")
|
||||||
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
|
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -81,7 +110,7 @@ async def delete_user(user_id: str, admin: dict = Depends(require_admin)):
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
cursor.execute("DELETE FROM user_quotas WHERE id = ?", (user_id,))
|
cursor.execute("DELETE FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||||
cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,))
|
cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import json, os, time
|
||||||
|
from fastapi import APIRouter, HTTPException, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.core.auth import decode_token
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DATA_FILE = os.path.join(settings.PROCESSED_DIR, "ai_presets.json")
|
||||||
|
|
||||||
|
DEFAULT_PRESETS = [
|
||||||
|
{
|
||||||
|
"id": "preset_epic_orchestra_intro",
|
||||||
|
"name": "Epic Orchestra Intro (8 Bars)",
|
||||||
|
"keywords": ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
|
||||||
|
"category": "Orchestral / Film Score",
|
||||||
|
"default_bars": 8,
|
||||||
|
"default_bpm": 130,
|
||||||
|
"default_scale": "C Minor",
|
||||||
|
"system_instruction_template": "You are a professional film composer. Create a powerful, dramatic 8-bar orchestral intro. Keep the note density low (e.g. use mostly whole notes, half notes, or quarter notes) and do NOT generate dense 16th notes or complex drum rolls. This is critical to avoid output token limit timeouts. The required structure to return via the `generate_multitrack_midi` tool consists of 3 tracks: 1. Strings: plays smooth legato chord changes (one chord per 1 or 2 bars). 2. Brass Theme: plays a swelling simple melodic line in the C3-C5 range. 3. Epic Percussion: hits heavily on beats 1 and 3. Ensure the duration is precisely 8 bars (32 beats).",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": False,
|
||||||
|
"created_at": "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_pop_piano_chords",
|
||||||
|
"name": "Pop Piano Chords (4 Bars)",
|
||||||
|
"keywords": ["pop piano", "piano chords", "ballad piano", "hợp âm piano"],
|
||||||
|
"category": "Pop / Ballad",
|
||||||
|
"default_bars": 4,
|
||||||
|
"default_bpm": 90,
|
||||||
|
"default_scale": "C Major",
|
||||||
|
"system_instruction_template": "You are a professional Pop Piano player. Generate a beautiful 4-bar piano chord progression (e.g. C - G - Am - F) with pleasant chord voicing and simple accompaniment. Return the MIDI notes via `generate_multitrack_midi` function on a track named 'Pop Piano'. Keep notes simple, using mostly whole/half/quarter notes. Ensure the duration of the track is precisely 4 bars (16 beats).",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": False,
|
||||||
|
"created_at": "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_cyberpunk_synth",
|
||||||
|
"name": "Cyberpunk Synthwave (8 Bars)",
|
||||||
|
"keywords": ["cyberpunk synth", "synthwave", "cyberpunk", "futuristic synth"],
|
||||||
|
"category": "Electronic / Synthwave",
|
||||||
|
"default_bars": 8,
|
||||||
|
"default_bpm": 120,
|
||||||
|
"default_scale": "A Minor",
|
||||||
|
"system_instruction_template": "You are a Synthwave producer. Generate a driving 8-bar cyberpunk synth theme. Return MIDI notes via `generate_multitrack_midi` containing: 1. Synth Bass: eighth notes on pitch A1, C2, G1. 2. Synth Lead: simple melodic line in high register C4-E5. Keep notes clean and concise to ensure fast generation.",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": False,
|
||||||
|
"created_at": "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_rearrange_jazz_swing",
|
||||||
|
"name": "Jazz Swing Variation",
|
||||||
|
"keywords": ["jazz", "swing", "rearrange to jazz", "jazz improv"],
|
||||||
|
"category": "Rearrange / Variation",
|
||||||
|
"default_bars": 4,
|
||||||
|
"default_bpm": 120,
|
||||||
|
"default_scale": "C Major",
|
||||||
|
"system_instruction_template": "Rearrange the original melody into a rhythmic Jazz Swing style. Apply syncopation, 7th/9th chord extensions, and off-beat rhythmic feel.",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": True,
|
||||||
|
"created_at": "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_rearrange_synth_arpeggio",
|
||||||
|
"name": "80s Synthwave Arpeggiator",
|
||||||
|
"keywords": ["synthwave", "arpeggio", "arp", "16th variation", "80s synth"],
|
||||||
|
"category": "Rearrange / Variation",
|
||||||
|
"default_bars": 4,
|
||||||
|
"default_bpm": 120,
|
||||||
|
"default_scale": "C Minor",
|
||||||
|
"system_instruction_template": "Transform this sequence into a driving, shimmering 16th-note Arpeggio string in classic 80s Synthwave/Trance style.",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": False,
|
||||||
|
"created_at": "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_rearrange_harmonize",
|
||||||
|
"name": "Chords Harmonization (4-Note)",
|
||||||
|
"keywords": ["harmonize", "harmony", "add chord notes", "rich melody"],
|
||||||
|
"category": "Rearrange / Variation",
|
||||||
|
"default_bars": 4,
|
||||||
|
"default_bpm": 120,
|
||||||
|
"default_scale": "C Major",
|
||||||
|
"system_instruction_template": "Add harmonizing notes to create lush 4-note chord voicing extensions beneath the lead melody.",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": False,
|
||||||
|
"created_at": "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset_rearrange_cinematic_strings",
|
||||||
|
"name": "Cinematic Strings Staccato",
|
||||||
|
"keywords": ["cinematic", "strings", "staccato", "staccato strings"],
|
||||||
|
"category": "Rearrange / Variation",
|
||||||
|
"default_bars": 4,
|
||||||
|
"default_bpm": 120,
|
||||||
|
"default_scale": "C Minor",
|
||||||
|
"system_instruction_template": "Rearrange the original line into a dramatic, driving Staccato rhythm for a Strings Ensemble.",
|
||||||
|
"is_user_defined": False,
|
||||||
|
"is_favorite": True,
|
||||||
|
"created_at": "2026-07-28T11:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AIPresetSchema(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
keywords: List[str]
|
||||||
|
category: str = "General"
|
||||||
|
default_bars: int = 8
|
||||||
|
default_bpm: int = 120
|
||||||
|
default_scale: str = "C Major"
|
||||||
|
system_instruction_template: str
|
||||||
|
is_user_defined: bool = True
|
||||||
|
is_favorite: bool = False
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_data():
|
||||||
|
if not os.path.exists(DATA_FILE):
|
||||||
|
return {"user_presets": {}}
|
||||||
|
try:
|
||||||
|
with open(DATA_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {"user_presets": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_data(user_presets):
|
||||||
|
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump({"user_presets": user_presets}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id(authorization):
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
return "anonymous"
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
payload = decode_token(token)
|
||||||
|
if not payload:
|
||||||
|
return "anonymous"
|
||||||
|
return payload.get("user_id", "anonymous")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/presets")
|
||||||
|
async def list_presets(authorization: Optional[str] = Header(None)):
|
||||||
|
uid = _get_user_id(authorization)
|
||||||
|
data = _load_data()
|
||||||
|
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||||
|
merged = DEFAULT_PRESETS + user_presets
|
||||||
|
return {"success": True, "presets": merged}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/presets")
|
||||||
|
async def save_preset(req: AIPresetSchema, authorization: Optional[str] = Header(None)):
|
||||||
|
uid = _get_user_id(authorization)
|
||||||
|
if uid == "anonymous":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required to save presets")
|
||||||
|
|
||||||
|
data = _load_data()
|
||||||
|
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||||
|
|
||||||
|
if not req.created_at:
|
||||||
|
req.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
existing_idx = next((i for i, p in enumerate(user_presets) if p["id"] == req.id), None)
|
||||||
|
preset_dict = req.model_dump()
|
||||||
|
|
||||||
|
if existing_idx is not None:
|
||||||
|
user_presets[existing_idx] = preset_dict
|
||||||
|
else:
|
||||||
|
user_presets.append(preset_dict)
|
||||||
|
|
||||||
|
if "user_presets" not in data:
|
||||||
|
data["user_presets"] = {}
|
||||||
|
data["user_presets"][uid] = user_presets
|
||||||
|
_save_data(data["user_presets"])
|
||||||
|
return {"success": True, "preset": preset_dict}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/presets/{preset_id}")
|
||||||
|
async def delete_preset(preset_id: str, authorization: Optional[str] = Header(None)):
|
||||||
|
uid = _get_user_id(authorization)
|
||||||
|
if uid == "anonymous":
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required to delete presets")
|
||||||
|
|
||||||
|
data = _load_data()
|
||||||
|
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||||
|
filtered = [p for p in user_presets if p["id"] != preset_id]
|
||||||
|
|
||||||
|
if len(filtered) == len(user_presets):
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
|
||||||
|
if "user_presets" not in data:
|
||||||
|
data["user_presets"] = {}
|
||||||
|
data["user_presets"][uid] = filtered
|
||||||
|
_save_data(data["user_presets"])
|
||||||
|
return {"success": True, "message": "Preset deleted"}
|
||||||
+130
-10
@@ -1,7 +1,16 @@
|
|||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any, Dict, List
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from app.api.v1.auth import get_current_user
|
||||||
|
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,17 +19,126 @@ class ProxyRequest(BaseModel):
|
|||||||
headers: Dict[str, str] = {}
|
headers: Dict[str, str] = {}
|
||||||
body: Dict[str, Any] = {}
|
body: Dict[str, Any] = {}
|
||||||
|
|
||||||
import json
|
|
||||||
|
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||||
|
_BLOCKED_NETWORKS = [
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||||
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
|
]
|
||||||
|
# Private ranges: only reachable when the target host is one the user has
|
||||||
|
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||||
|
_PRIVATE_NETWORKS = [
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"), # ULA
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_ai_hosts(user_id: str) -> set:
|
||||||
|
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||||
|
hosts = set()
|
||||||
|
configs = _load_ai_configs()
|
||||||
|
providers = configs.get(user_id) or _get_default_providers()
|
||||||
|
for p in providers:
|
||||||
|
base = (p.get("api_base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
host = urlparse(base).hostname
|
||||||
|
if host:
|
||||||
|
hosts.add(host.lower())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_host_ips(hostname: str):
|
||||||
|
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||||
|
ips = []
|
||||||
|
for info in infos:
|
||||||
|
try:
|
||||||
|
ips.append(ipaddress.ip_address(info[4][0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ips
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_target_url(url: str, user_id: str):
|
||||||
|
"""Validate target. Trả IP đã validate (str) để proxy connect thẳng vào đó
|
||||||
|
chống DNS rebinding TOCTOU; None khi giữ hostname (loopback / https)."""
|
||||||
|
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 None
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Trả IP đầu tiên đã validate — http sẽ connect thẳng vào IP này (bind),
|
||||||
|
# không cho httpx re-resolve hostname (fix DNS rebinding TOCTOU).
|
||||||
|
return str(ips[0])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxy")
|
@router.post("/proxy")
|
||||||
async def proxy_llm(req: ProxyRequest):
|
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
target_ip = 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")
|
||||||
|
}
|
||||||
|
url = req.url
|
||||||
|
# Bug #6: http + hostname (không phải IP literal) → connect thẳng IP đã
|
||||||
|
# validate, giữ Host gốc. https giữ hostname (SNI + cert validation chống
|
||||||
|
# rebinding sẵn). IPv6 skip (netloc bracket phức tạp, hiếm gặp).
|
||||||
|
if target_ip and ":" not in target_ip:
|
||||||
|
parsed = urlparse(req.url)
|
||||||
|
if parsed.scheme == "http":
|
||||||
|
host_header = parsed.netloc
|
||||||
|
new_netloc = parsed.netloc.replace(parsed.hostname, target_ip)
|
||||||
|
from urllib.parse import urlunsplit
|
||||||
|
url = urlunsplit((parsed.scheme, new_netloc, parsed.path, parsed.query, parsed.fragment))
|
||||||
|
headers["Host"] = host_header
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(url, headers=headers, json=req.body)
|
||||||
req.url,
|
|
||||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
|
||||||
json=req.body
|
|
||||||
)
|
|
||||||
raw = resp.text
|
raw = resp.text
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@@ -36,5 +154,7 @@ async def proxy_llm(req: ProxyRequest):
|
|||||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||||
raise HTTPException(status_code=502, detail=msg)
|
raise HTTPException(status_code=502, detail=msg)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
+89
-76
@@ -8,12 +8,38 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.auth import get_current_user
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
from app.api.v1.projects import get_optional_user
|
from app.api.v1.projects import get_optional_user
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
# Bug #4: whitelist extension upload — trước đây nhận mọi đuôi (`.exe`) rồi
|
||||||
|
# serve qua static. Chỉ chấp nhận định dạng audio phổ biến.
|
||||||
|
ALLOWED_AUDIO_EXTENSIONS = {
|
||||||
|
"wav", "mp3", "ogg", "flac", "aiff", "aif", "m4a", "aac", "opus", "webm",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _safe_file_id(file_id: str) -> str:
|
||||||
|
"""Strip any path components from a client-supplied file id."""
|
||||||
|
if not file_id:
|
||||||
|
return ""
|
||||||
|
return os.path.basename(file_id.replace("\\", "/"))
|
||||||
|
|
||||||
|
def _resolve_storage_path(file_id: str) -> str:
|
||||||
|
"""Return the existing file path (processed first, then uploads) for a
|
||||||
|
sanitized file id, or '' when not found."""
|
||||||
|
fid = _safe_file_id(file_id)
|
||||||
|
if not fid:
|
||||||
|
return ""
|
||||||
|
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||||
|
p = os.path.join(d, fid)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
return ""
|
||||||
|
|
||||||
class EditRequest(BaseModel):
|
class EditRequest(BaseModel):
|
||||||
file_id: str
|
file_id: str
|
||||||
cut_start_ms: Optional[float] = None
|
cut_start_ms: Optional[float] = None
|
||||||
@@ -58,16 +84,35 @@ class PythonToolRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
ext = os.path.splitext(file.filename)[1]
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
# Bug #4: reject non-audio extensions before saving
|
||||||
|
if ext and ext.lstrip(".") not in ALLOWED_AUDIO_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Định dạng file không được hỗ trợ: {ext}")
|
||||||
if not ext:
|
if not ext:
|
||||||
ext = ".wav"
|
ext = ".wav"
|
||||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
|
|
||||||
|
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||||
|
# WAV into RAM and bounds disk usage).
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
content = await file.read()
|
size = 0
|
||||||
f.write(content)
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||||
|
f.close()
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Save original filename as sidecar metadata
|
# Save original filename as sidecar metadata
|
||||||
import json
|
import json
|
||||||
@@ -89,16 +134,15 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/edit")
|
@router.post("/edit")
|
||||||
async def edit_audio(req: EditRequest):
|
async def edit_audio(req: EditRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
if current_user:
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
enforce_password_changed(current_user)
|
||||||
|
|
||||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
if not _resolve_storage_path(req.file_id):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import edit_audio_task
|
from app.tasks.worker import edit_audio_task
|
||||||
task = edit_audio_task.delay(req.dict())
|
task = edit_audio_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id
|
"task_id": task.id
|
||||||
@@ -106,15 +150,10 @@ async def edit_audio(req: EditRequest):
|
|||||||
|
|
||||||
@router.get("/download/{file_id}")
|
@router.get("/download/{file_id}")
|
||||||
async def download_audio(file_id: str):
|
async def download_audio(file_id: str):
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
path = _resolve_storage_path(file_id)
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if os.path.exists(processed_path):
|
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
@router.get("/waveform/{file_id}")
|
@router.get("/waveform/{file_id}")
|
||||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||||
@@ -122,14 +161,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
|||||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_peak_waveform
|
from app.core.dsp_utils import generate_peak_waveform
|
||||||
@@ -140,14 +173,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
|||||||
"""
|
"""
|
||||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_rms_waveform
|
from app.core.dsp_utils import generate_rms_waveform
|
||||||
@@ -159,46 +186,36 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
|||||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
file_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import analyze_ai_task
|
from app.tasks.worker import analyze_ai_task
|
||||||
task = analyze_ai_task.delay(
|
task = analyze_ai_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
api_base_url=req.api_base_url,
|
api_base_url=req.api_base_url,
|
||||||
model=req.model
|
model=req.model
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/export")
|
@router.post("/export")
|
||||||
async def export_audio(req: ExportRequest):
|
async def export_audio(req: ExportRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
if current_user:
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
enforce_password_changed(current_user)
|
||||||
|
source_path = _resolve_storage_path(req.file_id)
|
||||||
if os.path.exists(processed_path):
|
if not source_path:
|
||||||
source_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
source_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import export_audio_task
|
from app.tasks.worker import export_audio_task
|
||||||
task = export_audio_task.delay(
|
task = export_audio_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
format=req.format,
|
format=req.format,
|
||||||
sample_rate=req.sample_rate,
|
sample_rate=req.sample_rate,
|
||||||
bit_depth=req.bit_depth
|
bit_depth=req.bit_depth
|
||||||
@@ -206,29 +223,24 @@ async def export_audio(req: ExportRequest):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/ai-scan")
|
@router.post("/ai-scan")
|
||||||
async def ai_scan_audio(req: AIScanRequest):
|
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -252,6 +264,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -259,16 +273,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -295,6 +302,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
|||||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.python_tools_engine import PythonToolsEngine
|
from app.core.python_tools_engine import PythonToolsEngine
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
@@ -396,7 +405,11 @@ async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_
|
|||||||
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
|
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
user_id = current_user["user_id"]
|
user_id = current_user["user_id"]
|
||||||
prefix = f"user_{user_id}_"
|
prefix = f"user_{user_id}_"
|
||||||
|
# Bug #2: sanitize TRƯỚC khi check prefix — trước đây chỉ check startswith
|
||||||
|
# nên `user_<id>_../../tmp/x` pass guard → os.remove xóa file ngoài storage.
|
||||||
|
file_id = _safe_file_id(file_id)
|
||||||
|
if not file_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Không tìm thấy tệp trên server")
|
||||||
# Guard: only own files can be deleted
|
# Guard: only own files can be deleted
|
||||||
if not file_id.startswith(prefix):
|
if not file_id.startswith(prefix):
|
||||||
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
|
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
|
||||||
|
|||||||
+110
-13
@@ -1,10 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
import threading
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
from app.core.auth import (
|
||||||
|
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||||
|
COOKIE_NAME, X_AUTH_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||||
|
_LOGIN_LOCK = threading.Lock()
|
||||||
|
MAX_LOGIN_ATTEMPTS = 10
|
||||||
|
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||||
|
LOGIN_BLOCK_SEC = 900
|
||||||
|
|
||||||
|
def _check_login_ratelimit(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||||
|
_LOGIN_FAILURES[ip] = stamps
|
||||||
|
|
||||||
|
def _record_login_failure(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||||
|
stamps.append(now)
|
||||||
|
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
|
||||||
|
def _record_login_success(ip: str):
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
_LOGIN_FAILURES.pop(ip, None)
|
||||||
|
|
||||||
|
def _set_auth_cookie(response: Response, token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME, token,
|
||||||
|
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||||
|
# path="/" (default); secure flag set by proxy when behind TLS
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||||
|
token = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
elif x_auth_token:
|
||||||
|
token = x_auth_token
|
||||||
|
elif request.cookies.get(COOKIE_NAME):
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||||
token = authorization.split(" ")[1]
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(req: LoginRequest):
|
async def login(req: LoginRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_login_ratelimit(client_ip)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not user or not user["is_active"]:
|
if not user or not user["is_active"]:
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
if not verify_password(password, user["hashed_password"]):
|
if not verify_password(password, user["hashed_password"]):
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||||
|
_record_login_success(client_ip)
|
||||||
|
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
|||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"must_change_password": bool(user["must_change_password"])
|
"must_change_password": bool(user["must_change_password"])
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _validate_password_strength(password: str):
|
||||||
|
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||||
|
if len(password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||||
|
lowered = password.lower()
|
||||||
|
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register(req: RegisterRequest):
|
async def register(req: RegisterRequest, request: Request):
|
||||||
username = req.username.strip()
|
username = req.username.strip()
|
||||||
email = req.email.strip()
|
email = req.email.strip()
|
||||||
password = req.password.strip()
|
password = req.password.strip()
|
||||||
|
_validate_password_strength(password)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
token = create_token(user_id, username, "standard", False)
|
token = create_token(user_id, username, "standard", False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -124,13 +185,18 @@ async def register(req: RegisterRequest):
|
|||||||
"role": "standard",
|
"role": "standard",
|
||||||
"must_change_password": False
|
"must_change_password": False
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||||
user_id = current_user["user_id"]
|
user_id = current_user["user_id"]
|
||||||
old_pwd = req.old_password.strip()
|
old_pwd = req.old_password.strip()
|
||||||
new_pwd = req.new_password.strip()
|
new_pwd = req.new_password.strip()
|
||||||
|
# Bug #7: register đã validate độ mạnh, change-password thì KHÔNG — admin bị
|
||||||
|
# ép đổi mật khẩu có thể đặt `a` (1 ký tự). Áp cùng policy.
|
||||||
|
_validate_password_strength(new_pwd)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -153,10 +219,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"message": "Đổi mật khẩu thành công!",
|
"message": "Đổi mật khẩu thành công!",
|
||||||
"access_token": new_token
|
"access_token": new_token
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, new_token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||||
@@ -194,3 +262,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
|
|||||||
"max_tracks": row["max_tracks"] or 16
|
"max_tracks": row["max_tracks"] or 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/first-time")
|
||||||
|
async def auth_first_time():
|
||||||
|
# "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH
|
||||||
|
# (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa
|
||||||
|
# gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu:
|
||||||
|
# admin123, nút Điền nhanh).
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, hashed_password, must_change_password FROM users "
|
||||||
|
"WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return {"first_time": True}
|
||||||
|
still_default = False
|
||||||
|
try:
|
||||||
|
still_default = verify_password("admin123", row["hashed_password"])
|
||||||
|
except Exception:
|
||||||
|
still_default = False
|
||||||
|
return {"first_time": bool(row["must_change_password"]) and still_default}
|
||||||
|
except Exception:
|
||||||
|
return {"first_time": True}
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.api.v1.auth import enforce_password_changed
|
||||||
|
from app.api.v1.projects import get_optional_user
|
||||||
|
from app.api.v1.audio import _safe_file_id
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -33,13 +36,19 @@ class MultitrackSessionRequest(BaseModel):
|
|||||||
tracks: List[TrackConfig]
|
tracks: List[TrackConfig]
|
||||||
|
|
||||||
@router.post("/mix")
|
@router.post("/mix")
|
||||||
async def mix_multitrack_session(req: MultitrackSessionRequest):
|
async def mix_multitrack_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
|
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||||
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
|
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
|
||||||
"""
|
"""
|
||||||
# Kiểm tra xem các file nguồn có tồn tại không
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
# Bug #9: guard auth-optional + sanitize file_id (chống traversal vào worker)
|
||||||
for track in req.tracks:
|
for track in req.tracks:
|
||||||
|
track.file_id = _safe_file_id(track.file_id)
|
||||||
|
if not track.file_id:
|
||||||
|
raise HTTPException(status_code=400, detail=f"file_id không hợp lệ cho track {track.track_id}")
|
||||||
|
# Kiểm tra xem các file nguồn có tồn tại không
|
||||||
if track.muted:
|
if track.muted:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -54,7 +63,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
|||||||
|
|
||||||
# Gửi task xuống Celery Worker
|
# Gửi task xuống Celery Worker
|
||||||
from app.tasks.worker import mix_multitrack_task
|
from app.tasks.worker import mix_multitrack_task
|
||||||
task = mix_multitrack_task.delay(req.dict())
|
task = mix_multitrack_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
@@ -63,13 +72,17 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/process-session")
|
@router.post("/process-session")
|
||||||
async def process_session(req: MultitrackSessionRequest):
|
async def process_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
|
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
|
||||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
for track in req.tracks:
|
||||||
|
track.file_id = _safe_file_id(track.file_id)
|
||||||
from app.tasks.worker import process_multitrack_session_task
|
from app.tasks.worker import process_multitrack_session_task
|
||||||
task = process_multitrack_session_task.delay(req.dict())
|
task = process_multitrack_session_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
|
|||||||
+1031
-21
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
|||||||
|
# SonicForge Preset Library API — thư viện preset VST3 (.vstpreset) nằm trong
|
||||||
|
# storage/presets (mount qua volume trong docker; thư mục storage trên Windows).
|
||||||
|
#
|
||||||
|
# Vai trò: cầu nối Carla → pedalboard. User chỉnh preset trong Carla (native
|
||||||
|
# GUI) → xuất .vstpreset → upload vào thư viện → gán vào track (preset_id trong
|
||||||
|
# synth_engine) → render_engine tải qua load_preset → âm render = âm đã chỉnh.
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.vst_engine import preset_library_dir, PRESET_EXTENSIONS
|
||||||
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_preset_path(preset_id: str) -> str:
|
||||||
|
"""Chống path traversal: chỉ cho phép tên file (không chứa separator)."""
|
||||||
|
if not preset_id or os.path.basename(preset_id) != preset_id:
|
||||||
|
return ""
|
||||||
|
d = preset_library_dir()
|
||||||
|
p = os.path.join(d, preset_id)
|
||||||
|
if os.path.isfile(p) and os.path.dirname(os.path.abspath(p)) == os.path.abspath(d):
|
||||||
|
return p
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_presets():
|
||||||
|
"""Danh sách preset trong thư viện (public — frontend cần trước login)."""
|
||||||
|
d = preset_library_dir()
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(d))
|
||||||
|
except Exception:
|
||||||
|
names = []
|
||||||
|
for f in names:
|
||||||
|
low = f.lower()
|
||||||
|
if not low.endswith(PRESET_EXTENSIONS):
|
||||||
|
continue
|
||||||
|
meta = {}
|
||||||
|
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
|
||||||
|
if os.path.isfile(meta_path):
|
||||||
|
try:
|
||||||
|
with open(meta_path, "r", encoding="utf-8") as mf:
|
||||||
|
meta = json.load(mf)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(os.path.join(d, f))
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
items.append({
|
||||||
|
"id": f,
|
||||||
|
"name": meta.get("original_name", f),
|
||||||
|
"plugin_hint": meta.get("plugin_hint", ""),
|
||||||
|
"size_bytes": size,
|
||||||
|
"created_at": meta.get("created_at", ""),
|
||||||
|
})
|
||||||
|
return {"success": True, "presets": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_preset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
plugin_hint: Optional[str] = None,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload preset (.vstpreset / .fxp / .fxb / .dspreset) vào thư viện."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
filename = (file.filename or "preset.vstpreset").replace("\\", "/").split("/")[-1]
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext not in PRESET_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Định dạng preset không hỗ trợ: {ext or '(không có đuôi)'} — hỗ trợ: {', '.join(PRESET_EXTENSIONS)}",
|
||||||
|
)
|
||||||
|
contents = await file.read()
|
||||||
|
if not contents:
|
||||||
|
raise HTTPException(status_code=400, detail="File rỗng")
|
||||||
|
d = preset_library_dir()
|
||||||
|
preset_id = uuid.uuid4().hex + ext
|
||||||
|
dest = os.path.join(d, preset_id)
|
||||||
|
try:
|
||||||
|
with open(dest, "wb") as fh:
|
||||||
|
fh.write(contents)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Không lưu được preset: {e}")
|
||||||
|
meta = {
|
||||||
|
"original_name": filename,
|
||||||
|
"plugin_hint": plugin_hint or "",
|
||||||
|
"size_bytes": len(contents),
|
||||||
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, os.path.splitext(preset_id)[0] + ".meta"), "w", encoding="utf-8") as mf:
|
||||||
|
json.dump(meta, mf, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"success": True, "preset_id": preset_id, **meta}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{preset_id}/download")
|
||||||
|
async def download_preset(preset_id: str):
|
||||||
|
path = _safe_preset_path(preset_id)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset không tồn tại")
|
||||||
|
return FileResponse(path, filename=preset_id, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{preset_id}")
|
||||||
|
async def delete_preset(preset_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
path = _safe_preset_path(preset_id)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset không tồn tại")
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
mp = os.path.join(preset_library_dir(), os.path.splitext(preset_id)[0] + ".meta")
|
||||||
|
if os.path.isfile(mp):
|
||||||
|
os.remove(mp)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Không xóa được preset: {e}")
|
||||||
|
return {"success": True, "preset_id": preset_id}
|
||||||
+265
-19
@@ -2,9 +2,9 @@ import time
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
from fastapi import APIRouter, HTTPException, Depends, Header, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any, Dict
|
from typing import Optional, Any, Dict, List
|
||||||
from jsonschema import validate, ValidationError
|
from jsonschema import validate, ValidationError
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.api.v1.auth import get_current_user, decode_token
|
from app.api.v1.auth import get_current_user, decode_token
|
||||||
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
return project_data
|
return project_data
|
||||||
|
|
||||||
tracks = project_data.get("tracks", [])
|
tracks = project_data.get("tracks", [])
|
||||||
|
# Legacy format stores item start times in SECONDS; convert using the real
|
||||||
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||||
|
# position for any tempo other than the one where 1 bar = 4s).
|
||||||
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||||
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||||
upgraded_tracks = []
|
upgraded_tracks = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track_id = str(t.get("id", ""))
|
track_id = str(t.get("id", ""))
|
||||||
@@ -33,11 +38,12 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"name": c.get("name", "Audio Clip"),
|
"name": c.get("name", "Audio Clip"),
|
||||||
"type": "AUDIO_ITEM",
|
"type": "AUDIO_ITEM",
|
||||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": 4.0,
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||||
|
"server_file_id": t.get("serverFileId") or None,
|
||||||
"sample_rate": 44100,
|
"sample_rate": 44100,
|
||||||
"channels": 2,
|
"channels": 2,
|
||||||
"gain": 1.0
|
"gain": 1.0
|
||||||
@@ -48,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": m.get("id"),
|
"id": m.get("id"),
|
||||||
"name": m.get("name", "MIDI Item"),
|
"name": m.get("name", "MIDI Item"),
|
||||||
"type": "MIDI_ITEM",
|
"type": "MIDI_ITEM",
|
||||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": m.get("duration", 4.0),
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"total_buffer_bars": m.get("duration", 8.0),
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||||
"notes": m.get("notes", [])
|
"notes": m.get("notes", [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -130,6 +136,7 @@ class SaveProjectRequest(BaseModel):
|
|||||||
|
|
||||||
class SaveTempProjectRequest(BaseModel):
|
class SaveTempProjectRequest(BaseModel):
|
||||||
data_json: str
|
data_json: str
|
||||||
|
client_id: Optional[str] = None # client ghi bản này (LAN browser / Tauri UI) — chống ping-pong sync
|
||||||
|
|
||||||
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
||||||
if authorization and authorization.startswith("Bearer "):
|
if authorization and authorization.startswith("Bearer "):
|
||||||
@@ -156,6 +163,22 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
# ── Ghi file autosave vào thư mục temp CỦA ỨNG DỤNG (storage/temp) ──
|
||||||
|
# Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp trên thư mục temp của ứng
|
||||||
|
# dụng để khi load lại thì tải lại dự án đang làm dở." Ngoài row trong DB,
|
||||||
|
# ghi thẳng file JSON để luôn có bản sao thật trên ổ đĩa OS.
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
temp_dir = os.path.join(_st.STORAGE_DIR, "temp")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
|
||||||
|
# Revision sidecar (nhỏ) — realtime sync LAN/Tauri đọc updated_at +
|
||||||
|
# client_id KHÔNG cần parse data_json (có thể MB).
|
||||||
|
with open(os.path.join(temp_dir, f"revision_{user_id}.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"client_id": req.client_id or "", "updated_at": now}, f, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
||||||
|
|
||||||
@router.get("/temp")
|
@router.get("/temp")
|
||||||
@@ -169,15 +192,109 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
|
|||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not row:
|
if row:
|
||||||
return {"has_temp": False}
|
return {
|
||||||
|
"has_temp": True,
|
||||||
|
"data_json": row["data_json"],
|
||||||
|
"updated_at": row["updated_at"]
|
||||||
|
}
|
||||||
|
# Fallback: file autosave.json trong thư mục temp của ứng dụng (khi lưu lúc
|
||||||
|
# đóng app qua sendBeacon — user anonymous) — tải lại dự án đang làm dở.
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
autosave_path = os.path.join(_st.STORAGE_DIR, "temp", "autosave.json")
|
||||||
|
if os.path.isfile(autosave_path):
|
||||||
|
with open(autosave_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if data.get("data_json"):
|
||||||
|
return {
|
||||||
|
"has_temp": True,
|
||||||
|
"data_json": data["data_json"],
|
||||||
|
"updated_at": data.get("updated_at", 0),
|
||||||
|
"source": "file",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"has_temp": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/temp/revision")
|
||||||
|
async def temp_project_revision(current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
"""Revision nhẹ của bản temp: updated_at + client_id — frontend poll 2-3s
|
||||||
|
để realtime sync giữa LAN browser và Tauri standalone (không tải full
|
||||||
|
data_json mỗi lần). client_id = client ghi bản cuối → client khác bỏ qua
|
||||||
|
bản do CHÍNH NÓ ghi (chống ping-pong)."""
|
||||||
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
client_id = ""
|
||||||
|
updated_at = 0.0
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
rev_path = os.path.join(_st.STORAGE_DIR, "temp", f"revision_{user_id}.json")
|
||||||
|
if os.path.isfile(rev_path):
|
||||||
|
with open(rev_path, "r", encoding="utf-8") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
client_id = meta.get("client_id", "") or ""
|
||||||
|
updated_at = float(meta.get("updated_at", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not updated_at:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT updated_at FROM projects WHERE id = ? AND is_temp = 1", (f"temp_{user_id}",))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
updated_at = float(row["updated_at"] or 0)
|
||||||
|
return {"updated_at": updated_at, "client_id": client_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _os_projects_dir() -> str:
|
||||||
|
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
|
||||||
|
Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
|
||||||
|
Linux/macOS → ~/SonicForgeDAW/Projects. Luôn tồn tại (tự tạo)."""
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
docs = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), "Documents")
|
||||||
|
base = docs if os.path.isdir(docs) else (os.environ.get("USERPROFILE") or os.path.expanduser("~"))
|
||||||
|
else:
|
||||||
|
base = os.path.expanduser("~")
|
||||||
|
d = os.path.join(base, "SonicForgeDAW", "Projects")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
except Exception:
|
||||||
|
return os.path.join(os.path.expanduser("~"), "SonicForgeDAW", "Projects")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save-to-disk")
|
||||||
|
async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[str] = Header(None)):
|
||||||
|
"""Ctrl-S trên desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
||||||
|
(Documents/SonicForgeDAW/Projects — bản desktop). Docker/headless KHÔNG
|
||||||
|
dùng endpoint này (frontend lưu Cloud). Auth optional — desktop có thể
|
||||||
|
chưa login."""
|
||||||
|
try:
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
decode_token(authorization.split(" ")[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
validated_data_json = validate_project_data(req.data_json)
|
||||||
|
safe_name = "".join(c for c in (req.name or "Dự án mới") if c.isalnum() or c in " _-.").strip() or "Du-an-moi"
|
||||||
|
if len(safe_name) > 80:
|
||||||
|
safe_name = safe_name[:80].strip()
|
||||||
|
safe_name = safe_name.replace(".", "_") if safe_name.endswith(".") else safe_name
|
||||||
|
fname = safe_name + ".sonicforge.json"
|
||||||
|
out_dir = _os_projects_dir()
|
||||||
|
out_path = os.path.join(out_dir, fname)
|
||||||
|
# Không ghi đè file đang mở ở nơi khác? Ghi đè OK (Ctrl-S = save).
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(validated_data_json)
|
||||||
return {
|
return {
|
||||||
"has_temp": True,
|
"success": True,
|
||||||
"data_json": row["data_json"],
|
"name": req.name or "Dự án mới",
|
||||||
"updated_at": row["updated_at"]
|
"path": out_path,
|
||||||
|
"filename": fname,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cloud")
|
@router.post("/cloud")
|
||||||
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||||
validated_data_json = validate_project_data(req.data_json)
|
validated_data_json = validate_project_data(req.data_json)
|
||||||
@@ -228,9 +345,11 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, name, size_bytes, updated_at FROM projects
|
SELECT p.id, p.name, p.size_bytes, p.updated_at,
|
||||||
WHERE user_id = ? AND is_temp = 0
|
(SELECT COUNT(*) FROM project_backups pb WHERE pb.project_id = p.id AND pb.user_id = p.user_id) as backup_count
|
||||||
ORDER BY updated_at DESC
|
FROM projects p
|
||||||
|
WHERE p.user_id = ? AND p.is_temp = 0
|
||||||
|
ORDER BY p.updated_at DESC
|
||||||
""", (user_id,))
|
""", (user_id,))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -240,7 +359,8 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
|||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"name": r["name"],
|
"name": r["name"],
|
||||||
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
|
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
|
||||||
"updated_at": r["updated_at"]
|
"updated_at": r["updated_at"],
|
||||||
|
"backup_count": r["backup_count"]
|
||||||
} for r in rows
|
} for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -279,13 +399,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||||
exists = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
if not exists:
|
if not existing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||||
|
|
||||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||||
|
|
||||||
|
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||||
|
# update bypassed the quota entirely).
|
||||||
|
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||||
|
quota_row = cursor.fetchone()
|
||||||
|
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||||
|
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||||
|
used_row = cursor.fetchone()
|
||||||
|
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||||
|
max_bytes = storage_limit_mb * 1024 * 1024
|
||||||
|
if used_bytes + new_size_bytes > max_bytes:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||||
|
)
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -298,6 +435,115 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
|||||||
conn.close()
|
conn.close()
|
||||||
return {"success": True, "message": "Đã cập nhật dự án thành công"}
|
return {"success": True, "message": "Đã cập nhật dự án thành công"}
|
||||||
|
|
||||||
|
class BackupConfigRequest(BaseModel):
|
||||||
|
max_count: int = 10
|
||||||
|
|
||||||
|
@router.post("/cloud/{project_id}/backup")
|
||||||
|
async def create_project_backup(project_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail="Không tìm thấy dự án")
|
||||||
|
|
||||||
|
backup_id = f"backup_{uuid.uuid4().hex[:12]}"
|
||||||
|
now = time.time()
|
||||||
|
size_bytes = len(row["data_json"].encode("utf-8"))
|
||||||
|
backup_name = f"[Backup] {row['name']} ({time.strftime('%Y-%m-%d %H:%M', time.localtime(now))})"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO project_backups (id, user_id, project_id, name, data_json, size_bytes, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (backup_id, user_id, project_id, backup_name, row["data_json"], size_bytes, now))
|
||||||
|
|
||||||
|
# Enforce retention limit: lấy max_count từ query param mặc định 10
|
||||||
|
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (project_id, user_id))
|
||||||
|
count = cursor.fetchone()["cnt"]
|
||||||
|
max_backup = 10 # default
|
||||||
|
if count > max_backup:
|
||||||
|
excess = count - max_backup
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM project_backups WHERE id IN (
|
||||||
|
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
|
||||||
|
ORDER BY created_at ASC LIMIT ?
|
||||||
|
)
|
||||||
|
""", (project_id, user_id, excess))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"backup_id": backup_id,
|
||||||
|
"name": backup_name,
|
||||||
|
"created_at": now,
|
||||||
|
"max_backups": max_backup
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/cloud/{project_id}/backups")
|
||||||
|
async def list_project_backups(project_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, name, size_bytes, created_at FROM project_backups
|
||||||
|
WHERE project_id = ? AND user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""", (project_id, user_id))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
|
||||||
|
"created_at": r["created_at"]
|
||||||
|
} for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.delete("/cloud/backups/{backup_id}")
|
||||||
|
async def delete_project_backup(backup_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM project_backups WHERE id = ? AND user_id = ?", (backup_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"success": True, "message": "Đã xóa bản backup"}
|
||||||
|
|
||||||
|
class CleanupBackupsRequest(BaseModel):
|
||||||
|
keep: int = 10
|
||||||
|
|
||||||
|
@router.post("/cloud/backups/cleanup")
|
||||||
|
async def cleanup_all_backups(req: CleanupBackupsRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
keep = max(5, min(20, req.keep))
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Lấy tất cả project_id của user
|
||||||
|
cursor.execute("SELECT DISTINCT project_id FROM project_backups WHERE user_id = ?", (user_id,))
|
||||||
|
projects = cursor.fetchall()
|
||||||
|
total_deleted = 0
|
||||||
|
for p in projects:
|
||||||
|
pid = p["project_id"]
|
||||||
|
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (pid, user_id))
|
||||||
|
cnt = cursor.fetchone()["cnt"]
|
||||||
|
if cnt > keep:
|
||||||
|
excess = cnt - keep
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM project_backups WHERE id IN (
|
||||||
|
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
|
||||||
|
ORDER BY created_at ASC LIMIT ?
|
||||||
|
)
|
||||||
|
""", (pid, user_id, excess))
|
||||||
|
total_deleted += excess
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"success": True, "deleted": total_deleted, "keep": keep}
|
||||||
|
|
||||||
class RenderProjectRequest(BaseModel):
|
class RenderProjectRequest(BaseModel):
|
||||||
sample_rate: Optional[int] = 44100
|
sample_rate: Optional[int] = 44100
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
|
||||||
|
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from app.core.runtime import capabilities, save_carla_path
|
||||||
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capabilities")
|
||||||
|
async def get_capabilities():
|
||||||
|
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
|
||||||
|
|
||||||
|
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
|
||||||
|
"headless" (docker server + browser UI)
|
||||||
|
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
|
||||||
|
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
|
||||||
|
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
|
||||||
|
âm thật giống export) | "wasm" (Preview Synth trong browser)
|
||||||
|
"""
|
||||||
|
return capabilities()
|
||||||
|
|
||||||
|
|
||||||
|
class CarlaPathRequest(BaseModel):
|
||||||
|
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
|
||||||
|
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
|
||||||
|
carla_path: str
|
||||||
|
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/carla-path")
|
||||||
|
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
|
||||||
|
|
||||||
|
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
|
||||||
|
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
|
||||||
|
enforce_password_changed(current_user)
|
||||||
|
target = (req.carla_path or req.carla_dir or "").strip()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
|
||||||
|
exe = save_carla_path(target)
|
||||||
|
if not exe:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
|
||||||
|
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
|
||||||
|
)
|
||||||
|
return {"success": True, "carla_path": exe, **capabilities()}
|
||||||
|
|
||||||
+10
-3
@@ -1,12 +1,19 @@
|
|||||||
|
import os
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from celery.result import AsyncResult
|
|
||||||
from app.tasks.worker import celery_app
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Task status endpoint dung chung cho ca 2 che do:
|
||||||
|
# - Server/Docker: celery (AsyncResult, broker Redis).
|
||||||
|
# - Desktop slim (PyInstaller khong bundle celery): in-process registry
|
||||||
|
# (app/tasks/worker._SimpleAsyncResult) — API contract giong het nhau.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
async def get_task_status(task_id: str):
|
async def get_task_status(task_id: str):
|
||||||
res = AsyncResult(task_id, app=celery_app)
|
from app.tasks.worker import get_task_result
|
||||||
|
|
||||||
|
res = get_task_result(task_id)
|
||||||
response_data = {
|
response_data = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": res.status,
|
"status": res.status,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
|||||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||||
uid = _get_user_id(authorization)
|
uid = _get_user_id(authorization)
|
||||||
configs = _load_ai_configs()
|
configs = _load_ai_configs()
|
||||||
configs[uid] = [p.dict() for p in req.providers]
|
configs[uid] = [p.model_dump() for p in req.providers]
|
||||||
_save_all(ai_configs=configs)
|
_save_all(ai_configs=configs)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+57
-3
@@ -1,16 +1,70 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _app_dir():
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# PyInstaller onefile: assets read-only nằm trong thư mục giải nén tạm
|
||||||
|
return os.path.join(sys._MEIPASS, "app")
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _storage_dir():
|
||||||
|
# Test/dev isolation: env override thắng (pytest set để không đụng storage
|
||||||
|
# thật — app production có thể đang chạy và ghi file state).
|
||||||
|
env_dir = os.getenv("SONICFORGE_STORAGE_DIR")
|
||||||
|
if env_dir:
|
||||||
|
return env_dir
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# Dữ liệu ghi được (DB, uploads, processed) phải ngoài thư mục tạm
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.join(root, "SonicForgeDAW", "storage")
|
||||||
|
return os.path.join(_app_dir(), "storage")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_vst_dir():
|
||||||
|
"""Thư mục VST mặc định theo platform (env VST_DIR vẫn thắng).
|
||||||
|
|
||||||
|
Windows: thư mục VST3 chuẩn của hệ thống — user có thể thêm thư mục khác
|
||||||
|
qua Plugins Manager (plugin_dirs.json). Docker/Linux: mount qua compose."""
|
||||||
|
if os.name == "nt":
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
return os.path.join(pf, "Common Files", "VST3")
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "/Library/Audio/Plug-Ins/VST3"
|
||||||
|
return "/opt/daw_engine/vst3"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_soundfont_dir():
|
||||||
|
if os.name == "nt":
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.join(root, "SonicForgeDAW", "soundfonts")
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")
|
||||||
|
return "/opt/daw_engine/soundfonts"
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
||||||
|
|
||||||
# __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root
|
APP_DIR: str = _app_dir()
|
||||||
APP_DIR: str = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
BASE_DIR: str = os.path.dirname(APP_DIR)
|
BASE_DIR: str = os.path.dirname(APP_DIR)
|
||||||
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
|
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
|
||||||
STORAGE_DIR: str = os.path.join(APP_DIR, "storage")
|
STORAGE_DIR: str = _storage_dir()
|
||||||
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
||||||
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
||||||
|
|
||||||
|
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
|
||||||
|
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user). Default theo
|
||||||
|
# platform (Windows: thư mục VST3 chuẩn; Linux: mount compose).
|
||||||
|
VST_DIR: str = os.getenv("VST_DIR", _default_vst_dir())
|
||||||
|
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", _default_soundfont_dir())
|
||||||
|
|
||||||
|
# Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard.
|
||||||
|
# Nằm trong storage nên tự động nằm trong volume mount (docker) / thư mục
|
||||||
|
# storage (Windows desktop).
|
||||||
|
PRESET_DIR: str = os.path.join(STORAGE_DIR, "presets")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -75,13 +75,16 @@ class AIDSPEngine:
|
|||||||
t_end = min(total_duration, 4.0)
|
t_end = min(total_duration, 4.0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import librosa
|
from app.core.audio_features import chroma_stft as _chroma_stft
|
||||||
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform
|
# 1. Compute harmonic structural properties via Chroma (STFT-based,
|
||||||
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr)
|
# thay chroma_cqt de lo bo librosa/numba/llvmlite ~171MB)
|
||||||
|
chroma = _chroma_stft(y=y_mono, sr=sr)
|
||||||
|
|
||||||
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
# thay sklearn.metrics.pairwise.cosine_similarity bang numpy
|
||||||
ssm = cosine_similarity(chroma.T, chroma.T)
|
c = chroma.T # (n_frames, 12)
|
||||||
|
norms = np.linalg.norm(c, axis=1, keepdims=True)
|
||||||
|
ssm = (c @ c.T) / (norms @ norms.T + 1e-9)
|
||||||
|
|
||||||
num_frames = ssm.shape[0]
|
num_frames = ssm.shape[0]
|
||||||
hop_length = 512
|
hop_length = 512
|
||||||
|
|||||||
+21
-12
@@ -1,19 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import librosa
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Thay librosa bang shim nhe (numpy/scipy/soundfile) — khong keo numba/llvmlite
|
||||||
|
from app.core.audio_features import (
|
||||||
|
load as _load,
|
||||||
|
beat_track as _beat_track,
|
||||||
|
frames_to_time as _frames_to_time,
|
||||||
|
spectral_centroid as _spectral_centroid,
|
||||||
|
rms as _rms,
|
||||||
|
zero_crossing_rate as _zcr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def analyze_audio(file_path: str) -> dict:
|
def analyze_audio(file_path: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
||||||
"""
|
"""
|
||||||
# Load audio
|
# Load audio
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
|
|
||||||
# Track beats
|
# Track beats
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
# Handle tempo which might be scalar or numpy array in different librosa versions
|
# Handle tempo which might be scalar or numpy array in different librosa versions
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
|
|||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
# Convert frames to time (seconds)
|
# Convert frames to time (seconds)
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
|
|
||||||
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
@@ -43,31 +52,31 @@ def analyze_audio_advanced(file_path: str) -> dict:
|
|||||||
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
||||||
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
duration = float(len(y)) / sr
|
duration = float(len(y)) / sr
|
||||||
|
|
||||||
# Beat tracking
|
# Beat tracking
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
||||||
else:
|
else:
|
||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
|
|
||||||
# Spectral centroid (brightness)
|
# Spectral centroid (brightness)
|
||||||
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
spectral_centroids = _spectral_centroid(y=y, sr=sr)[0]
|
||||||
avg_brightness = float(np.mean(spectral_centroids))
|
avg_brightness = float(np.mean(spectral_centroids))
|
||||||
|
|
||||||
# RMS energy
|
# RMS energy
|
||||||
rms = librosa.feature.rms(y=y)[0]
|
rms_vals = _rms(y=y)[0]
|
||||||
avg_energy = float(np.mean(rms))
|
avg_energy = float(np.mean(rms_vals))
|
||||||
|
|
||||||
# Zero crossing rate
|
# Zero crossing rate
|
||||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
zcr_vals = _zcr(y)[0]
|
||||||
avg_zcr = float(np.mean(zcr))
|
avg_zcr = float(np.mean(zcr_vals))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"bpm": round(bpm, 2),
|
"bpm": round(bpm, 2),
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only).
|
||||||
|
|
||||||
|
Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung
|
||||||
|
ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB).
|
||||||
|
|
||||||
|
Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site:
|
||||||
|
- load() ~ librosa.load (sr=None, mono=True)
|
||||||
|
- frames_to_time() ~ librosa.frames_to_time
|
||||||
|
- beat_track() ~ librosa.beat.beat_track (onset spectral flux
|
||||||
|
+ autocorrelation tempo + adaptive peak picking)
|
||||||
|
- spectral_centroid() ~ librosa.feature.spectral_centroid
|
||||||
|
- rms() ~ librosa.feature.rms
|
||||||
|
- zero_crossing_rate() ~ librosa.feature.zero_crossing_rate
|
||||||
|
- time_stretch() ~ librosa.effects.time_stretch (phase vocoder)
|
||||||
|
- pitch_shift() ~ librosa.effects.pitch_shift
|
||||||
|
- chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based,
|
||||||
|
dung cho fingerprint/similarity, KHONG dung cho
|
||||||
|
hien thi pitch chinh xac)
|
||||||
|
|
||||||
|
Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from scipy import signal as _signal
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load", "frames_to_time", "beat_track",
|
||||||
|
"spectral_centroid", "rms", "zero_crossing_rate",
|
||||||
|
"time_stretch", "pitch_shift", "chroma_stft",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048)
|
||||||
|
HOP_LENGTH = 512
|
||||||
|
N_FFT = 2048
|
||||||
|
WIN_LENGTH = 2048
|
||||||
|
|
||||||
|
|
||||||
|
# ── Load / time ──────────────────────────────────────────────────────────────
|
||||||
|
def load(path, sr=None, mono=True, offset=0.0, duration=None):
|
||||||
|
"""Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel.
|
||||||
|
|
||||||
|
sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None).
|
||||||
|
Neu truyen sr -> resample bang scipy.signal.resample_poly.
|
||||||
|
"""
|
||||||
|
if offset or duration:
|
||||||
|
info = sf.info(path)
|
||||||
|
start = int(offset * info.samplerate) if offset else 0
|
||||||
|
n_frames = int(duration * info.samplerate) if duration else -1
|
||||||
|
data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames)
|
||||||
|
else:
|
||||||
|
data, file_sr = sf.read(path, dtype="float32")
|
||||||
|
|
||||||
|
if data.ndim > 1:
|
||||||
|
if mono:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
else:
|
||||||
|
data = data.T # (channels, samples) giong librosa
|
||||||
|
|
||||||
|
if sr is not None and sr != file_sr:
|
||||||
|
from fractions import Fraction
|
||||||
|
ratio = Fraction(int(sr), int(file_sr))
|
||||||
|
up, down = ratio.numerator, ratio.denominator
|
||||||
|
data = _signal.resample_poly(data, up, down).astype(np.float32)
|
||||||
|
file_sr = sr
|
||||||
|
|
||||||
|
return data, file_sr
|
||||||
|
|
||||||
|
|
||||||
|
def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None):
|
||||||
|
"""Chuyen frame index sang giay: frames * hop_length / sr (giong librosa)."""
|
||||||
|
return np.asanyarray(frames) * float(hop_length) / float(sr)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Framing / STFT (center=True, reflect pad, giong librosa) ────────────────
|
||||||
|
def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True)."""
|
||||||
|
pad = frame_length // 2
|
||||||
|
yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect")
|
||||||
|
n_frames = 1 + (len(yp) - frame_length) // hop_length
|
||||||
|
if n_frames < 1:
|
||||||
|
n_frames = 1
|
||||||
|
idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :]
|
||||||
|
return yp[idx]
|
||||||
|
|
||||||
|
|
||||||
|
def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH):
|
||||||
|
"""STFT mot phia (rfft) voi cua so hann periodic, reflect pad."""
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
f, _t, Zxx = _signal.stft(
|
||||||
|
y, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
boundary="even", padded=True,
|
||||||
|
)
|
||||||
|
return Zxx
|
||||||
|
|
||||||
|
|
||||||
|
def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
||||||
|
length=None):
|
||||||
|
"""ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity).
|
||||||
|
|
||||||
|
boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True.
|
||||||
|
"""
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
_t, y = _signal.istft(
|
||||||
|
Zxx, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
input_onesided=True, boundary=True,
|
||||||
|
)
|
||||||
|
if length is not None and len(y) > length:
|
||||||
|
y = y[:length]
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
# ── Features ─────────────────────────────────────────────────────────────────
|
||||||
|
def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
||||||
|
S=None):
|
||||||
|
"""Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram."""
|
||||||
|
if S is None:
|
||||||
|
S = np.abs(_stft(y, n_fft, hop_length)) ** 2
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
mag = np.abs(S)
|
||||||
|
denom = mag.sum(axis=0)
|
||||||
|
cent = np.divide(
|
||||||
|
np.sum(freqs[:, None] * mag, axis=0), denom,
|
||||||
|
out=np.zeros_like(denom), where=denom > 1e-10,
|
||||||
|
)
|
||||||
|
return cent[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None):
|
||||||
|
"""RMS nang luong moi frame - (1, n_frames)."""
|
||||||
|
if S is not None:
|
||||||
|
frames = S # caller truyen power spectrogram
|
||||||
|
else:
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
return np.sqrt(np.mean(frames ** 2, axis=0))[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Ti le zero-crossing moi frame - (1, n_frames)."""
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
signs = np.signbit(frames).astype(np.int8)
|
||||||
|
zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0)
|
||||||
|
return zcr[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH):
|
||||||
|
"""Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding).
|
||||||
|
|
||||||
|
Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi
|
||||||
|
cosine_similarity trong ai_dsp_engine.
|
||||||
|
"""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
# Chi giu bin <= 5kHz (tranh nhieu alias o high freq)
|
||||||
|
keep = freqs <= 5000.0
|
||||||
|
freqs = freqs[keep]
|
||||||
|
mag = mag[keep]
|
||||||
|
# note number -> pitch class
|
||||||
|
note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0
|
||||||
|
pc = np.mod(np.round(note).astype(int), 12)
|
||||||
|
chroma = np.zeros((12, mag.shape[1]), dtype=np.float64)
|
||||||
|
np.add.at(chroma, pc, mag)
|
||||||
|
# L2 normalize tung frame (giong librosa)
|
||||||
|
norms = np.linalg.norm(chroma, axis=0)
|
||||||
|
chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10)
|
||||||
|
return chroma
|
||||||
|
|
||||||
|
|
||||||
|
# ── Onset / tempo / beat (thay librosa.beat) ─────────────────────────────────
|
||||||
|
def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT):
|
||||||
|
"""Onset envelope: spectral flux (log-magnitude diff, chi chieu duong)."""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
logmag = np.log1p(1000.0 * mag)
|
||||||
|
flux = np.diff(logmag, axis=1)
|
||||||
|
onset = np.maximum(flux, 0.0).sum(axis=0)
|
||||||
|
if onset.size == 0:
|
||||||
|
return onset
|
||||||
|
# Tru moving-average ~1s de loai trend (giong librosa detrend)
|
||||||
|
win = max(1, int(round(1.0 * sr / hop_length)))
|
||||||
|
if len(onset) >= win:
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
onset = np.maximum(onset - ma, 0.0)
|
||||||
|
return onset
|
||||||
|
|
||||||
|
|
||||||
|
def _autocorr(x):
|
||||||
|
"""Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 2:
|
||||||
|
return np.ones(n)
|
||||||
|
x = x - x.mean()
|
||||||
|
nfft = 2 ** int(np.ceil(np.log2(2 * n)))
|
||||||
|
X = np.fft.rfft(x, nfft)
|
||||||
|
r = np.fft.irfft(X * np.conj(X), nfft)[:n]
|
||||||
|
denom = np.maximum(n - np.arange(n), 1)
|
||||||
|
r = r / denom
|
||||||
|
r0 = r[0] if r[0] != 0 else 1.0
|
||||||
|
return r / r0
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0),
|
||||||
|
start_bpm=120.0):
|
||||||
|
"""Uoc luong BPM bang autocorrelation cua onset envelope.
|
||||||
|
|
||||||
|
Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de
|
||||||
|
chon dung octave (tranh roi vao nua/double tempo khi autocorrelation
|
||||||
|
bi mo ho giua cac harmonic).
|
||||||
|
"""
|
||||||
|
if len(onset) < 4:
|
||||||
|
return float(start_bpm)
|
||||||
|
min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length)))
|
||||||
|
max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length)))
|
||||||
|
if max_lag <= min_lag or max_lag >= len(onset):
|
||||||
|
return float(start_bpm)
|
||||||
|
ac = _autocorr(onset)
|
||||||
|
lags = np.arange(min_lag, max_lag + 1)
|
||||||
|
tempi = 60.0 * sr / (hop_length * lags)
|
||||||
|
# prior rong ~0.7 octave quanh start_bpm (log2 scale)
|
||||||
|
prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2)
|
||||||
|
seg = ac[lags] * prior
|
||||||
|
best = lags[int(np.argmax(seg))]
|
||||||
|
tempo = 60.0 * sr / (hop_length * best)
|
||||||
|
# Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi
|
||||||
|
if tempo > 200.0 and best * 2 <= max_lag:
|
||||||
|
tempo = 60.0 * sr / (hop_length * best * 2)
|
||||||
|
return float(tempo)
|
||||||
|
|
||||||
|
|
||||||
|
def _localmax(x):
|
||||||
|
"""Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can)."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 3:
|
||||||
|
return np.zeros(n, dtype=bool)
|
||||||
|
out = np.zeros(n, dtype=bool)
|
||||||
|
out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0):
|
||||||
|
"""Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid."""
|
||||||
|
n = len(onset)
|
||||||
|
if n == 0:
|
||||||
|
return np.array([], dtype=int)
|
||||||
|
period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat
|
||||||
|
win = max(1, int(round(period)))
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
thresh = 1.25 * ma + 1e-9
|
||||||
|
|
||||||
|
cand = np.where(_localmax(onset) & (onset >= thresh))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.where(_localmax(onset))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.arange(0, n, max(1, int(round(period))))
|
||||||
|
|
||||||
|
beats = [int(cand[0])]
|
||||||
|
while True:
|
||||||
|
expected = beats[-1] + period
|
||||||
|
if expected >= n:
|
||||||
|
break
|
||||||
|
lo, hi = expected - 0.45 * period, expected + 0.45 * period
|
||||||
|
in_win = cand[(cand >= lo) & (cand <= hi)]
|
||||||
|
if in_win.size == 0:
|
||||||
|
nxt = int(round(expected))
|
||||||
|
if nxt >= n:
|
||||||
|
break
|
||||||
|
beats.append(nxt)
|
||||||
|
else:
|
||||||
|
beats.append(int(in_win[np.argmin(np.abs(in_win - expected))]))
|
||||||
|
# Chong beat kep (khoang cach < 0.5 period)
|
||||||
|
if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period:
|
||||||
|
beats.pop()
|
||||||
|
continue
|
||||||
|
if len(beats) > 2000:
|
||||||
|
break
|
||||||
|
return np.array(beats, dtype=int)
|
||||||
|
|
||||||
|
|
||||||
|
def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0,
|
||||||
|
tightness=100):
|
||||||
|
"""Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int).
|
||||||
|
|
||||||
|
Tempo bang autocorrelation onset; beats bang peak-picking thich nghi.
|
||||||
|
Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site
|
||||||
|
(analyzer xu ly ca scalar lan ndarray).
|
||||||
|
"""
|
||||||
|
onset = _onset_strength(y, sr, hop_length)
|
||||||
|
tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm)
|
||||||
|
beats = _beat_frames(onset, sr, hop_length, tempo)
|
||||||
|
return tempo, beats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Effects (thay librosa.effects) ───────────────────────────────────────────
|
||||||
|
def _phase_vocoder(D, rate, hop_length=HOP_LENGTH):
|
||||||
|
"""Phase vocoder time-stretch kinh dien (DAFX/Puckette).
|
||||||
|
|
||||||
|
D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon).
|
||||||
|
Tra ve STFT da stretch voi so frame ~ n_frames / rate.
|
||||||
|
"""
|
||||||
|
n_freq, n_frames = D.shape
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return D
|
||||||
|
time_steps = np.arange(0, n_frames, rate, dtype=float)
|
||||||
|
n_out = len(time_steps)
|
||||||
|
if n_out == 0:
|
||||||
|
return D[:, :0]
|
||||||
|
out = np.zeros((n_freq, n_out), dtype=np.complex128)
|
||||||
|
# Phase advance moi hop cua tung bin tan so
|
||||||
|
phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq)
|
||||||
|
mag = np.abs(D)
|
||||||
|
phase_acc = np.angle(D[:, 0])
|
||||||
|
for t, step in enumerate(time_steps):
|
||||||
|
idx = int(step)
|
||||||
|
if idx >= n_frames:
|
||||||
|
break
|
||||||
|
if idx + 1 >= n_frames:
|
||||||
|
out[:, t] = mag[:, idx] * np.exp(1j * phase_acc)
|
||||||
|
break
|
||||||
|
# Phase difference that giua 2 frame lien tiep (true frequency)
|
||||||
|
dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv
|
||||||
|
dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi))
|
||||||
|
phase_acc = phase_acc + phase_adv + dphase
|
||||||
|
out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def time_stretch(y, rate, **kwargs):
|
||||||
|
"""Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon."""
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
D = _stft(y)
|
||||||
|
D_stretch = _phase_vocoder(D, rate)
|
||||||
|
y_out = _istft(D_stretch)
|
||||||
|
# Cat ve dung do dai ky vong: len(y) / rate
|
||||||
|
target = int(round(len(y) / rate))
|
||||||
|
if len(y_out) > target:
|
||||||
|
y_out = y_out[:target]
|
||||||
|
return y_out.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_shift(y, sr=22050, n_steps=1, **kwargs):
|
||||||
|
"""Dich pitch n semitone (positive = cao hon), giu nguyen duration.
|
||||||
|
|
||||||
|
Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample
|
||||||
|
nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen.
|
||||||
|
"""
|
||||||
|
if n_steps == 0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
rate = 2.0 ** (-float(n_steps) / 12.0)
|
||||||
|
y_shift = time_stretch(y, rate)
|
||||||
|
# Resample (FFT) ve dung do dai goc: factor = rate
|
||||||
|
target = int(round(len(y_shift) * rate))
|
||||||
|
if target != len(y_shift) and target > 0:
|
||||||
|
y_shift = _signal.resample(y_shift, target)
|
||||||
|
return np.asarray(y_shift, dtype=np.float32)
|
||||||
+57
-19
@@ -5,25 +5,69 @@ import time
|
|||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
COOKIE_NAME = "sf_token"
|
||||||
|
X_AUTH_HEADER = "X-Auth-Token"
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
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.
|
||||||
"""
|
"""
|
||||||
Hash password using PBKDF2 HMAC SHA-256 with salt.
|
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:
|
||||||
|
"""
|
||||||
|
Hash password using PBKDF2 HMAC SHA-256 with a per-user random salt.
|
||||||
Guarantees raw passwords are NEVER stored or exposed in plaintext.
|
Guarantees raw passwords are NEVER stored or exposed in plaintext.
|
||||||
|
Returns format: 'salt_hex:hashed_key_hex' (colon-delimited, stores both values).
|
||||||
|
If salt is provided, uses that salt (for verification).
|
||||||
|
If salt is None, generates a new 32-byte random salt.
|
||||||
"""
|
"""
|
||||||
salt = b"sonicforge_crypto_salt_2026_secure_"
|
if salt is None:
|
||||||
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
salt = secrets.token_hex(32)
|
||||||
return key.hex()
|
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 600000)
|
||||||
|
return f"{salt}:{key.hex()}"
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, stored_value: str) -> bool:
|
||||||
"""Verify plain password against PBKDF2 hashed password using constant-time comparison."""
|
"""Verify plain password against stored 'salt:hash' using constant-time comparison.
|
||||||
computed_hash = hash_password(plain_password)
|
Also supports legacy-format hash (hex only, without salt) for backward compatibility."""
|
||||||
return hmac.compare_digest(computed_hash, hashed_password)
|
if ':' in stored_value:
|
||||||
|
parts = stored_value.split(':', 1)
|
||||||
|
salt = parts[0]
|
||||||
|
expected_hash = parts[1]
|
||||||
|
computed = hash_password(plain_password, salt)
|
||||||
|
computed_hash = computed.split(':', 1)[1]
|
||||||
|
return hmac.compare_digest(computed_hash, expected_hash)
|
||||||
|
else:
|
||||||
|
expected_hash = stored_value
|
||||||
|
computed_hash = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), b"sonicforge_crypto_salt_2026_secure_", 600000).hex()
|
||||||
|
return hmac.compare_digest(computed_hash, expected_hash)
|
||||||
|
|
||||||
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
|
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -56,7 +100,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def seed_admin():
|
def seed_admin():
|
||||||
"""Seed default admin account on initial launch if not exists or update password hash if outdated."""
|
"""Seed default admin account on initial launch only — never resets existing password."""
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -64,9 +108,8 @@ def seed_admin():
|
|||||||
hashed_pwd = hash_password(default_pwd)
|
hashed_pwd = hash_password(default_pwd)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor.execute("SELECT id, hashed_password, must_change_password FROM users WHERE username = ?", ("admin",))
|
cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",))
|
||||||
row = cursor.fetchone()
|
if not cursor.fetchone():
|
||||||
if not row:
|
|
||||||
admin_id = str(uuid.uuid4())
|
admin_id = str(uuid.uuid4())
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||||
@@ -78,11 +121,6 @@ def seed_admin():
|
|||||||
VALUES (?, 10240, 64)
|
VALUES (?, 10240, 64)
|
||||||
""", (admin_id,))
|
""", (admin_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
else:
|
|
||||||
# Kiểm tra và sửa password admin mặc định nếu cần
|
|
||||||
if not verify_password(default_pwd, row["hashed_password"]):
|
|
||||||
cursor.execute("UPDATE users SET hashed_password = ?, must_change_password = 1 WHERE id = ?", (hashed_pwd, row["id"]))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import librosa
|
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
|
from app.core.audio_features import load as _load
|
||||||
|
|
||||||
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load mono audio for zero crossing analysis
|
# Load mono audio for zero crossing analysis
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error finding zero crossing: {e}")
|
print(f"Error finding zero crossing: {e}")
|
||||||
@@ -136,7 +136,7 @@ def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
|||||||
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
# Load mono audio
|
# Load mono audio
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
|
|||||||
+85
-28
@@ -1,7 +1,8 @@
|
|||||||
import os, logging
|
import os, logging, math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import scipy.signal as signal
|
# scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
|
||||||
|
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.vst_engine import (
|
from app.core.vst_engine import (
|
||||||
render_midi_events_to_audio,
|
render_midi_events_to_audio,
|
||||||
@@ -9,6 +10,7 @@ from app.core.vst_engine import (
|
|||||||
DecentSamplerManager,
|
DecentSamplerManager,
|
||||||
HAS_PEDALBOARD,
|
HAS_PEDALBOARD,
|
||||||
HAS_PYFLUIDSYNTH,
|
HAS_PYFLUIDSYNTH,
|
||||||
|
apply_preset_to_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -33,6 +35,21 @@ def _find_sf2_path(sf_id: str) -> str:
|
|||||||
fbase, fext = os.path.splitext(fname)
|
fbase, fext = os.path.splitext(fname)
|
||||||
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
||||||
return os.path.join(base_dir, fname)
|
return os.path.join(base_dir, fname)
|
||||||
|
# Thư mục user thêm qua Plugin Manager (plugin_dirs — Add Directory):
|
||||||
|
# soundfont trong thư mục user phải render được (Windows thường dùng cách này)
|
||||||
|
try:
|
||||||
|
from app.core.vst_engine import _load_user_plugin_dirs
|
||||||
|
for base_dir in _load_user_plugin_dirs():
|
||||||
|
if not os.path.isdir(base_dir):
|
||||||
|
continue
|
||||||
|
for root, dirs, files in os.walk(base_dir):
|
||||||
|
for fname in files:
|
||||||
|
fbase, fext = os.path.splitext(fname)
|
||||||
|
if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
|
||||||
|
return os.path.join(root, fname)
|
||||||
|
dirs[:] = [] # không walk sâu
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
|
static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
|
||||||
if os.path.isdir(static_dir):
|
if os.path.isdir(static_dir):
|
||||||
for fname in os.listdir(static_dir):
|
for fname in os.listdir(static_dir):
|
||||||
@@ -87,12 +104,18 @@ class PythonRenderEngine:
|
|||||||
return url_or_id
|
return url_or_id
|
||||||
return url_or_id
|
return url_or_id
|
||||||
|
|
||||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
|
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||||
|
tracks = session.get("tracks", [])
|
||||||
|
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||||
|
|
||||||
_channel_counter = 0
|
_channel_counter = 0
|
||||||
|
|
||||||
for track in session.get("tracks", []):
|
for track in tracks:
|
||||||
|
if solo_ids and track.get("id") not in solo_ids:
|
||||||
|
continue
|
||||||
track_type = track.get("type", "AUDIO")
|
track_type = track.get("type", "AUDIO")
|
||||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
@@ -123,8 +146,17 @@ class PythonRenderEngine:
|
|||||||
try:
|
try:
|
||||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||||
if sr != self.sample_rate:
|
if sr != self.sample_rate:
|
||||||
# Resampling fallback if simple, otherwise skip
|
# Proper resampling: previously a silent no-op that
|
||||||
pass
|
# played 48kHz audio at the wrong speed/pitch.
|
||||||
|
from scipy.signal import resample_poly
|
||||||
|
g = math.gcd(sr, self.sample_rate)
|
||||||
|
audio_data = resample_poly(
|
||||||
|
audio_data,
|
||||||
|
up=self.sample_rate // g,
|
||||||
|
down=sr // g,
|
||||||
|
axis=-1,
|
||||||
|
)
|
||||||
|
sr = self.sample_rate
|
||||||
|
|
||||||
# Handle channel mapping (Mono/Stereo)
|
# Handle channel mapping (Mono/Stereo)
|
||||||
if len(audio_data.shape) == 1:
|
if len(audio_data.shape) == 1:
|
||||||
@@ -148,7 +180,7 @@ class PythonRenderEngine:
|
|||||||
if actual_len > 0:
|
if actual_len > 0:
|
||||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||||
|
|
||||||
elif item_type == "MIDI_ITEM":
|
elif item_type == "MIDI_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
@@ -194,8 +226,9 @@ class PythonRenderEngine:
|
|||||||
total_needed = dur_samples
|
total_needed = dur_samples
|
||||||
total_needed = max(total_needed, 1024)
|
total_needed = max(total_needed, 1024)
|
||||||
silent = np.zeros((2, total_needed), dtype=np.float32)
|
silent = np.zeros((2, total_needed), dtype=np.float32)
|
||||||
board = Pedalboard([vst])
|
# pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
|
||||||
synth_buffer = board(silent, sample_rate=self.sample_rate, midi_messages=midi_messages)
|
synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
|
||||||
|
duration=total_needed / float(self.sample_rate), num_channels=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[RenderEngine] DecentSampler/Pianobook error: {e}")
|
logger.warning(f"[RenderEngine] DecentSampler/Pianobook error: {e}")
|
||||||
synth_buffer = render_midi_events_to_audio(
|
synth_buffer = render_midi_events_to_audio(
|
||||||
@@ -207,6 +240,21 @@ class PythonRenderEngine:
|
|||||||
)
|
)
|
||||||
elif vst and HAS_PEDALBOARD:
|
elif vst and HAS_PEDALBOARD:
|
||||||
from pedalboard import Pedalboard
|
from pedalboard import Pedalboard
|
||||||
|
# Preset bridge Carla → pedalboard: preset_id
|
||||||
|
# (thư viện storage/presets), preset_path (file)
|
||||||
|
# hoặc preset_data (base64 .vstpreset nhúng trong
|
||||||
|
# project). Cùng sample rate → âm render = âm đã
|
||||||
|
# chỉnh trong Carla.
|
||||||
|
try:
|
||||||
|
se2 = track.get("synth_engine", {}) or {}
|
||||||
|
apply_preset_to_plugin(
|
||||||
|
vst,
|
||||||
|
preset_id=se2.get("preset_id") or track.get("preset_id"),
|
||||||
|
preset_path=se2.get("preset_path") or track.get("preset_path"),
|
||||||
|
preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[RenderEngine] Preset apply failed: %s", e)
|
||||||
midi_messages = PluginManager.midi_events_to_messages(
|
midi_messages = PluginManager.midi_events_to_messages(
|
||||||
midi_events, bpm, self.sample_rate,
|
midi_events, bpm, self.sample_rate,
|
||||||
bank=soundfont_bank, program=soundfont_program
|
bank=soundfont_bank, program=soundfont_program
|
||||||
@@ -219,9 +267,10 @@ class PythonRenderEngine:
|
|||||||
total_needed = dur_samples
|
total_needed = dur_samples
|
||||||
total_needed = max(total_needed, 1024)
|
total_needed = max(total_needed, 1024)
|
||||||
silent = np.zeros((2, total_needed), dtype=np.float32)
|
silent = np.zeros((2, total_needed), dtype=np.float32)
|
||||||
board = Pedalboard([vst])
|
# pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
|
||||||
synth_buffer = board(silent, sample_rate=self.sample_rate, midi_messages=midi_messages)
|
synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
|
||||||
elif instrument_id and (instrument_id.startswith("sf_") or soundfont_id):
|
duration=total_needed / float(self.sample_rate), num_channels=2)
|
||||||
|
elif soundfont_id or (instrument_id and instrument_id.startswith("sf_")):
|
||||||
sf_path = _find_sf2_path(soundfont_id or instrument_id)
|
sf_path = _find_sf2_path(soundfont_id or instrument_id)
|
||||||
# 3-level fallback: selected SF → default SF → oscillator synth
|
# 3-level fallback: selected SF → default SF → oscillator synth
|
||||||
if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH:
|
if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH:
|
||||||
@@ -284,21 +333,27 @@ class PythonRenderEngine:
|
|||||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||||
|
|
||||||
elif item_type == "SECTION_ITEM":
|
elif item_type == "SECTION_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
sec_id = source_data.get("referenced_section_id", "")
|
sec_id = source_data.get("referenced_section_id", "")
|
||||||
if sec_id and sec_id in section_store:
|
if sec_id and sec_id in section_store:
|
||||||
# Render nested section recursively
|
# Render nested section recursively, cached per section id
|
||||||
sec_container = section_store[sec_id]
|
# so repeated section instances don't re-render every time.
|
||||||
sec_buffer = self.render_session_container(
|
cache = _cache if _cache is not None else {}
|
||||||
session=sec_container,
|
if sec_id in cache:
|
||||||
section_store=section_store,
|
sec_buffer = cache[sec_id]
|
||||||
bpm=bpm,
|
else:
|
||||||
time_sig_num=time_sig_num,
|
sec_buffer = self.render_session_container(
|
||||||
total_samples=total_samples
|
session=section_store[sec_id],
|
||||||
)
|
section_store=section_store,
|
||||||
|
bpm=bpm,
|
||||||
|
time_sig_num=time_sig_num,
|
||||||
|
total_samples=total_samples,
|
||||||
|
_cache=cache,
|
||||||
|
)
|
||||||
|
cache[sec_id] = sec_buffer
|
||||||
|
|
||||||
# Apply non-destructive crop/slicing on section buffer
|
# Apply non-destructive crop/slicing on section buffer
|
||||||
if offset_sample < total_samples:
|
if offset_sample < total_samples:
|
||||||
@@ -327,7 +382,7 @@ class PythonRenderEngine:
|
|||||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||||
try:
|
try:
|
||||||
@@ -341,14 +396,14 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = track_buffer[ch, indices]
|
wet[ch, :] = track_buffer[ch, indices]
|
||||||
track_buffer = dry + wet * 0.5
|
track_buffer = dry + wet * 0.5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||||
elif fx_type == "reverb":
|
elif fx_type == "reverb":
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback reverb using exponentially decaying noise room impulse response
|
# Fallback reverb using exponentially decaying noise room impulse response
|
||||||
try:
|
try:
|
||||||
@@ -364,11 +419,12 @@ class PythonRenderEngine:
|
|||||||
for ch in range(2):
|
for ch in range(2):
|
||||||
ir = ir_l if ch == 0 else ir_r
|
ir = ir_l if ch == 0 else ir_r
|
||||||
# Convolve
|
# Convolve
|
||||||
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
from scipy.signal import convolve
|
||||||
|
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
||||||
wet[ch, :] = conv
|
wet[ch, :] = conv
|
||||||
track_buffer = dry + wet * 0.4
|
track_buffer = dry + wet * 0.4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||||
|
|
||||||
# Process track volume
|
# Process track volume
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
@@ -410,7 +466,8 @@ class PythonRenderEngine:
|
|||||||
section_store=section_store,
|
section_store=section_store,
|
||||||
bpm=bpm,
|
bpm=bpm,
|
||||||
time_sig_num=time_sig_num,
|
time_sig_num=time_sig_num,
|
||||||
total_samples=total_samples
|
total_samples=total_samples,
|
||||||
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalization to prevent clipping
|
# Normalization to prevent clipping
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# SonicForge Runtime Profile — phát hiện môi trường chạy (desktop Windows /
|
||||||
|
# docker headless) và khả năng của máy, để app TỰ CHỌN cách xử lý:
|
||||||
|
# - Windows desktop (server + client cùng 1 máy): Carla local để preview +
|
||||||
|
# chỉnh preset → pedalboard render (Hướng A)
|
||||||
|
# - Docker / Linux headless (server + browser UI): soundfont + VSTi mở được
|
||||||
|
# từ storage mount; preset upload qua browser; không có GUI local.
|
||||||
|
#
|
||||||
|
# Ưu tiên: SF_RUNTIME env override > heuristic (platform + display + docker).
|
||||||
|
# Module CHỈ dùng stdlib + app.config (không kéo pedalboard/numpy) để import
|
||||||
|
# nhẹ và hoạt động trong mọi tiến trình (web/worker/celery).
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
SF_RUNTIME_ENV = "SF_RUNTIME" # auto | desktop | headless
|
||||||
|
SF_DOCKER_ENV = "SF_DOCKER" # 1 = chạy trong container Docker
|
||||||
|
CARLA_CONFIG_FILE = "carla_path.json" # storage/carla_path.json — user định vị
|
||||||
|
# Bản Carla portable (zip) có thể giải nén ở BẤT KỲ ĐÂU — PATH/Program Files
|
||||||
|
# không đủ. User tự chọn thư mục chứa carla.exe qua Plugin Manager → lưu file
|
||||||
|
# này (ưu tiên cao nhất khi detect), kèm tìm kiếm nông Downloads/Desktop.
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows() -> bool:
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_macos() -> bool:
|
||||||
|
return sys.platform == "darwin"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_linux() -> bool:
|
||||||
|
return not _is_windows() and not _is_macos()
|
||||||
|
|
||||||
|
|
||||||
|
def _in_docker() -> bool:
|
||||||
|
if os.environ.get(SF_DOCKER_ENV) == "1":
|
||||||
|
return True
|
||||||
|
# Marker chuẩn của Docker (không tồn tại trên máy host thường)
|
||||||
|
try:
|
||||||
|
return os.path.exists("/.dockerenv")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_display() -> bool:
|
||||||
|
"""Có môi trường GUI hiển thị được hay không.
|
||||||
|
|
||||||
|
Windows/macOS luôn có (desktop). Linux cần biến DISPLAY (X11) — nếu chạy
|
||||||
|
Docker không có DISPLAY → headless."""
|
||||||
|
if _is_windows() or _is_macos():
|
||||||
|
return True
|
||||||
|
return bool(os.environ.get("DISPLAY"))
|
||||||
|
|
||||||
|
|
||||||
|
def _tauri_bridge_ready() -> bool:
|
||||||
|
"""App desktop Tauri viết marker file lúc setup (xem plugins.py
|
||||||
|
_pick_dir_via_tauri_bridge) — dùng để nhận diện bản desktop app."""
|
||||||
|
try:
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return os.path.exists(os.path.join(root, "SonicForgeDAW", "ipc", "tauri_bridge_ready"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_config_path() -> str:
|
||||||
|
return os.path.join(settings.STORAGE_DIR, CARLA_CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_configured_carla() -> str:
|
||||||
|
"""Đường dẫn carla.exe do USER tự định vị (lưu trong storage/carla_path.json).
|
||||||
|
|
||||||
|
File lưu đường dẫn tới carla.exe (đã resolve) hoặc thư mục chứa — nếu là
|
||||||
|
thư mục, tìm carla.exe bên trong (độ sâu ≤ 2). Đây là cách bắt buộc có cho
|
||||||
|
bản Carla portable (zip) giải nén ở vị trí bất kỳ, không cài đặt/PATH."""
|
||||||
|
try:
|
||||||
|
with open(_carla_config_path(), "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
p = (data.get("carla_path") or "").strip()
|
||||||
|
if not p:
|
||||||
|
return ""
|
||||||
|
if os.path.isfile(p) and os.path.basename(p).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||||
|
return p
|
||||||
|
if os.path.isdir(p):
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(p):
|
||||||
|
depth = root[len(p):].count(os.sep)
|
||||||
|
if depth >= 2:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
return os.path.join(root, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def save_carla_path(path: str) -> str:
|
||||||
|
"""Lưu vị trí Carla do user chọn (Plugin Manager → Định vị Carla...).
|
||||||
|
|
||||||
|
Chấp nhận: đường dẫn tới carla.exe, hoặc thư mục chứa carla.exe (portable
|
||||||
|
zip). Resolve về đường dẫn exe hợp lệ → ghi config → xóa cache detect.
|
||||||
|
Trả đường dẫn exe, hoặc '' nếu không hợp lệ."""
|
||||||
|
path = (path or "").strip().strip('"').strip()
|
||||||
|
exe = ""
|
||||||
|
if os.path.isfile(path) and os.path.basename(path).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||||
|
exe = path
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
depth = root[len(path):].count(os.sep)
|
||||||
|
if depth >= 2:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
exe = os.path.join(root, f)
|
||||||
|
break
|
||||||
|
if exe:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not exe:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
|
with open(_carla_config_path(), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"carla_path": exe, "carla_exe": os.path.basename(exe)}, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
# Xóa cache detect/find_carla để capabilities phản ánh ngay
|
||||||
|
invalidate_carla_cache()
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_from_registry() -> str:
|
||||||
|
"""Windows: Carla cài qua installer có thể ghi registry (best-effort)."""
|
||||||
|
if not _is_windows():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
for hive, key in (
|
||||||
|
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Carla"),
|
||||||
|
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Carla"),
|
||||||
|
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Carla"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hive, key) as k:
|
||||||
|
val, _ = winreg.QueryValueEx(k, "InstallPath")
|
||||||
|
p = os.path.join(str(val), "carla.exe")
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_carla_exe_bounded(base: str, max_depth: int = 3) -> str:
|
||||||
|
"""Quét nông tìm carla.exe (bản portable giải nén thường nằm Downloads/
|
||||||
|
Desktop/Documents). Giới hạn độ sâu + bỏ qua thư mục lớn — KHÔNG quét
|
||||||
|
toàn ổ đĩa."""
|
||||||
|
if not base or not os.path.isdir(base):
|
||||||
|
return ""
|
||||||
|
skip = ("node_modules", ".cache", "AppData", ".git", "venv", ".venv",
|
||||||
|
"__pycache__", "$RECYCLE.BIN", "Windows", "Program Files",
|
||||||
|
"Program Files (x86)")
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(base):
|
||||||
|
depth = root[len(base):].count(os.sep) if root != base else 0
|
||||||
|
if depth >= max_depth:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for f in files:
|
||||||
|
if f.lower() == "carla.exe":
|
||||||
|
return os.path.join(root, f)
|
||||||
|
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_from_shallow_search() -> str:
|
||||||
|
"""Windows: tìm carla.exe trong Downloads/Desktop/Documents/Home."""
|
||||||
|
if not _is_windows():
|
||||||
|
return ""
|
||||||
|
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
||||||
|
bases = []
|
||||||
|
for sub in ("Downloads", "Desktop", "Documents"):
|
||||||
|
bases.append(os.path.join(home, sub))
|
||||||
|
bases.append(home)
|
||||||
|
for base in bases:
|
||||||
|
hit = _find_carla_exe_bounded(base)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _carla_candidates() -> list:
|
||||||
|
"""Các vị trí Carla — theo thứ tự ưu tiên:
|
||||||
|
1) user định vị (config file — bản portable bắt buộc dùng cách này)
|
||||||
|
2) PATH (shutil.which)
|
||||||
|
3) registry (Windows, nếu cài qua installer)
|
||||||
|
4) thư mục cài đặt chuẩn (Program Files...)
|
||||||
|
5) quét nông Downloads/Desktop/Documents (Windows) / AppImage (Linux)"""
|
||||||
|
cands = []
|
||||||
|
configured = get_configured_carla()
|
||||||
|
if configured:
|
||||||
|
cands.append(configured)
|
||||||
|
try:
|
||||||
|
which = shutil.which("carla")
|
||||||
|
if which:
|
||||||
|
cands.append(which)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
reg = _carla_from_registry()
|
||||||
|
if reg:
|
||||||
|
cands.append(reg)
|
||||||
|
try:
|
||||||
|
if _is_windows():
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
lpf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||||
|
la = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
for base in (pf, lpf, la):
|
||||||
|
for rel in ("Carla\\carla.exe", "Carla\\bin\\carla.exe", "Programs\\Carla\\carla.exe"):
|
||||||
|
p = os.path.join(base, rel) if base else ""
|
||||||
|
if p and os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
elif _is_linux():
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
for p in ("/usr/bin/carla", "/usr/local/bin/carla", "/opt/carla/carla"):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
try:
|
||||||
|
for f in os.listdir(home):
|
||||||
|
if f.lower().startswith("carla") and f.lower().endswith(".appimage"):
|
||||||
|
cands.append(os.path.join(home, f))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif _is_macos():
|
||||||
|
for p in ("/Applications/Carla.app/Contents/MacOS/Carla", "/Applications/Carla.app/Contents/MacOS/carla"):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
cands.append(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
shallow = _carla_from_shallow_search()
|
||||||
|
if shallow:
|
||||||
|
cands.append(shallow)
|
||||||
|
# Dedup giữ thứ tự
|
||||||
|
seen, out = set(), []
|
||||||
|
for c in cands:
|
||||||
|
if c not in seen:
|
||||||
|
seen.add(c)
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def find_carla() -> str:
|
||||||
|
"""Đường dẫn Carla đầu tiên tìm thấy, hoặc '' nếu chưa có.
|
||||||
|
|
||||||
|
Lưu ý license: Carla GPL-2.0+ — app KHÔNG bundle/nhúng, chỉ spawn tiến
|
||||||
|
trình ngoài (user tự cài/giải nén) → không dính copyleft."""
|
||||||
|
for c in _carla_candidates():
|
||||||
|
return c
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_carla_cache():
|
||||||
|
"""Xóa cache detect/find_carla — gọi sau khi user định vị Carla mới."""
|
||||||
|
find_carla.cache_clear()
|
||||||
|
detect.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_platform() -> str:
|
||||||
|
if _is_windows():
|
||||||
|
return "windows"
|
||||||
|
if _is_macos():
|
||||||
|
return "darwin"
|
||||||
|
return "linux"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_runtime() -> str:
|
||||||
|
"""auto → desktop nếu có GUI (Windows/macOS luôn; Linux cần DISPLAY và
|
||||||
|
không phải docker), ngược lại headless. SF_RUNTIME override thắng."""
|
||||||
|
override = (os.environ.get(SF_RUNTIME_ENV) or "auto").strip().lower()
|
||||||
|
if override in ("desktop", "headless"):
|
||||||
|
return override
|
||||||
|
if _is_windows() or _is_macos():
|
||||||
|
return "desktop"
|
||||||
|
if _in_docker() or not _has_display():
|
||||||
|
return "headless"
|
||||||
|
return "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def default_vst_dirs() -> list:
|
||||||
|
"""Thư mục VST mặc định theo platform (vẫn ưu tiên env VST_DIR).
|
||||||
|
|
||||||
|
Windows: thư mục chuẩn VST3 của hệ thống; user thường khai báo thêm qua
|
||||||
|
Plugins Manager (plugin_dirs.json)."""
|
||||||
|
if settings.VST_DIR and settings.VST_DIR != "/opt/daw_engine/vst3":
|
||||||
|
return [settings.VST_DIR]
|
||||||
|
if _is_windows():
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
return [os.path.join(pf, "Common Files", "VST3"), os.path.join(pf, "VSTPlugins")]
|
||||||
|
if _is_macos():
|
||||||
|
return ["/Library/Audio/Plug-Ins/VST3", os.path.expanduser("~/Library/Audio/Plug-Ins/VST3")]
|
||||||
|
return ["/opt/daw_engine/vst3", os.path.expanduser("~/.vst3")]
|
||||||
|
|
||||||
|
|
||||||
|
def default_soundfont_dirs() -> list:
|
||||||
|
if settings.SOUNDFONT_DIR and settings.SOUNDFONT_DIR != "/opt/daw_engine/soundfonts":
|
||||||
|
return [settings.SOUNDFONT_DIR]
|
||||||
|
if _is_windows():
|
||||||
|
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||||
|
return [os.path.join(root, "SonicForgeDAW", "soundfonts")]
|
||||||
|
if _is_macos():
|
||||||
|
return [os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")]
|
||||||
|
return ["/opt/daw_engine/soundfonts", os.path.expanduser("~/.sf2")]
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_ips() -> list:
|
||||||
|
"""IPv4 của máy trên mạng LAN (bỏ loopback) — để client browser ở máy
|
||||||
|
khác mở app qua địa chỉ này. Stdlib only (không kéo thư viện ngoài)."""
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
# IP ra mạng theo default route — không gửi gói tin thật
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Enumerate thêm các interface khác (multi-NIC)
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ips
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def detect() -> dict:
|
||||||
|
"""Detect 1 lần (cache toàn cục) — kết quả bất biến trong 1 tiến trình."""
|
||||||
|
platform = _detect_platform()
|
||||||
|
runtime = _detect_runtime()
|
||||||
|
carla = find_carla() if runtime == "desktop" else ""
|
||||||
|
docker = _in_docker()
|
||||||
|
return {
|
||||||
|
"platform": platform,
|
||||||
|
"runtime": runtime, # "desktop" | "headless"
|
||||||
|
"docker": docker,
|
||||||
|
"environment": "docker" if docker else "standalone", # luồng âm instrument: docker→FluidSynthWASM, standalone→native OS
|
||||||
|
"has_display": _has_display(),
|
||||||
|
"tauri_bridge": _tauri_bridge_ready(),
|
||||||
|
"carla_path": carla,
|
||||||
|
"carla_local": bool(carla), # chỉ có ý nghĩa khi runtime=desktop
|
||||||
|
"vst_render": _vst_render_available(),
|
||||||
|
"default_vst_dirs": default_vst_dirs(),
|
||||||
|
"default_soundfont_dirs": default_soundfont_dirs(),
|
||||||
|
"preset_dir": os.path.join(settings.STORAGE_DIR, "presets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _vst_render_available() -> bool:
|
||||||
|
"""pedalboard có sẵn không (dùng find_spec — KHÔNG import pedalboard để
|
||||||
|
tránh kéo JUCE lib; giống pattern _module_available trong vst_engine)."""
|
||||||
|
import importlib.util
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec("pedalboard") is not None
|
||||||
|
except (ImportError, AttributeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_info() -> dict:
|
||||||
|
"""Địa chỉ + port để client LAN mở app. Port: SF_PORT (desktop engine chọn
|
||||||
|
trước, 8000-8010) hoặc 8000 mặc định."""
|
||||||
|
try:
|
||||||
|
port = int(os.environ.get("SF_PORT") or "8000")
|
||||||
|
except ValueError:
|
||||||
|
port = 8000
|
||||||
|
ips = _lan_ips()
|
||||||
|
return {
|
||||||
|
"ips": ips,
|
||||||
|
"port": port,
|
||||||
|
"urls": [f"http://{ip}:{port}" for ip in ips],
|
||||||
|
}
|
||||||
|
|
||||||
|
def capabilities() -> dict:
|
||||||
|
"""Capabilities API — frontend gọi 1 lần lúc boot để bật/tắt tính năng."""
|
||||||
|
d = detect()
|
||||||
|
features = {
|
||||||
|
"carla_local": d["carla_local"],
|
||||||
|
"carla_path": d["carla_path"],
|
||||||
|
"preset_upload": True, # cả 2 mode đều upload preset được
|
||||||
|
"vst_render": d["vst_render"],
|
||||||
|
"tauri_bridge": d["tauri_bridge"],
|
||||||
|
# preview VSTi: quick_render (pedalboard render clip ngắn — âm thật,
|
||||||
|
# giống export) là chuẩn cho cả 2 mode; không có realtime trong web UI.
|
||||||
|
"preview_mode": "quick_render" if d["vst_render"] else "wasm",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"runtime": d["runtime"],
|
||||||
|
"platform": d["platform"],
|
||||||
|
"docker": d["docker"],
|
||||||
|
"environment": d["environment"],
|
||||||
|
# LAN access (chỉ standalone): URL để client browser ở máy khác trên
|
||||||
|
# mạng mở app. Docker (container) → bỏ qua (IP container không dùng được).
|
||||||
|
**({"lan": _lan_info()} if not d["docker"] else {}),
|
||||||
|
"features": features,
|
||||||
|
"default_dirs": {
|
||||||
|
"vst": d["default_vst_dirs"],
|
||||||
|
"soundfont": d["default_soundfont_dirs"],
|
||||||
|
"preset": d["preset_dir"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_desktop() -> bool:
|
||||||
|
return detect()["runtime"] == "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def is_headless() -> bool:
|
||||||
|
return detect()["runtime"] == "headless"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# python -m app.core.runtime → in capabilities để kiểm tra detect
|
||||||
|
import json
|
||||||
|
print(json.dumps(capabilities(), ensure_ascii=False, indent=2))
|
||||||
+339
-39
@@ -83,6 +83,35 @@ class SoundFontConverter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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:
|
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
|
||||||
has_ogg = self._check_ffmpeg_ogg()
|
has_ogg = self._check_ffmpeg_ogg()
|
||||||
if not has_ogg:
|
if not has_ogg:
|
||||||
@@ -100,7 +129,7 @@ class SoundFontConverter:
|
|||||||
logger.warning("Not a valid SF2 file")
|
logger.warning("Not a valid SF2 file")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Find smpl chunk recursively
|
# Find smpl chunk (PCM sample data) recursively
|
||||||
smpl = _find_chunk(data, b"smpl")
|
smpl = _find_chunk(data, b"smpl")
|
||||||
if smpl is None:
|
if smpl is None:
|
||||||
logger.warning("No smpl chunk found in SF2")
|
logger.warning("No smpl chunk found in SF2")
|
||||||
@@ -113,46 +142,103 @@ class SoundFontConverter:
|
|||||||
logger.warning("Sample data too small")
|
logger.warning("Sample data too small")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tmp_wav = sf3_path + ".tmp.wav"
|
# Locate shdr (sample headers) inside the pdta LIST
|
||||||
tmp_ogg = sf3_path + ".tmp.ogg"
|
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:
|
try:
|
||||||
# Write samples as WAV
|
for i in range(n_samples):
|
||||||
with open(tmp_wav, "wb") as fw:
|
base = shdr_data_off + i * 46
|
||||||
with wave.open(fw, "wb") as w:
|
# shdr layout: name[20] | start(u32) end(u32) loopstart(u32) loopend(u32) samplerate(i32) ...
|
||||||
w.setnchannels(1)
|
start, end, startloop, endloop, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
|
||||||
w.setsampwidth(2)
|
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
|
||||||
w.setframerate(44100)
|
ogg_stream = b""
|
||||||
w.writeframes(sample_data)
|
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
|
if not ogg_parts:
|
||||||
subprocess.run([
|
logger.warning("No samples to encode")
|
||||||
"ffmpeg", "-y", "-i", tmp_wav,
|
return False
|
||||||
"-c:a", "libvorbis", "-q:a", "3",
|
|
||||||
"-f", "ogg", tmp_ogg
|
|
||||||
], capture_output=True, timeout=600, check=True)
|
|
||||||
|
|
||||||
with open(tmp_ogg, "rb") as fo:
|
ogg_padded = b"".join(ogg_parts)
|
||||||
ogg_data = fo.read()
|
if len(ogg_padded) % 2 == 1:
|
||||||
|
ogg_padded += b"\x00"
|
||||||
compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100
|
new_smpl_size = len(ogg_padded)
|
||||||
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)
|
|
||||||
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
||||||
new_padded = len(ogg_padded)
|
delta = len(ogg_padded) - old_padded
|
||||||
delta = new_padded - old_padded
|
|
||||||
|
|
||||||
# Rebuild file: replace smpl chunk and update all sizes
|
# Rebuild file: replace smpl chunk and update all sizes
|
||||||
out = bytearray()
|
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(struct.pack("<I", new_smpl_size)) # new smpl size
|
||||||
out.extend(ogg_padded) # compressed data (even-padded)
|
out.extend(ogg_padded) # concatenated per-sample OGG streams
|
||||||
out.extend(data[smpl_head_off + 8 + old_padded:]) # rest of file
|
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)
|
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
|
# Find sdta LIST and update its size
|
||||||
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
||||||
if sdta:
|
if sdta:
|
||||||
@@ -160,12 +246,11 @@ class SoundFontConverter:
|
|||||||
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
||||||
|
|
||||||
# Update RIFF root size
|
# Update RIFF root size
|
||||||
old_riff_size = struct.unpack("<I", data_out[4:8])[0]
|
|
||||||
new_total = len(data_out) - 8
|
new_total = len(data_out) - 8
|
||||||
data_out = _update_size(data_out, 4, new_total)
|
data_out = _update_size(data_out, 4, new_total)
|
||||||
|
|
||||||
with open(sf3_path, "wb") as fout:
|
with open(sf3_path, "wb") as fout:
|
||||||
fout.write(bytes(data_out))
|
fout.write(data_out)
|
||||||
|
|
||||||
# Validate: check that RIFF size matches actual size
|
# Validate: check that RIFF size matches actual size
|
||||||
written = os.path.getsize(sf3_path)
|
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}")
|
logger.warning(f"Size mismatch: RIFF says {parsed_riff}, actual is {written - 8}")
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -185,12 +271,50 @@ class SoundFontConverter:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
finally:
|
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:
|
try:
|
||||||
if os.path.exists(p):
|
_fs.delete_fluid_synth(_fl)
|
||||||
os.remove(p)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_sf3_converter():
|
def _find_sf3_converter():
|
||||||
@@ -208,9 +332,18 @@ class SoundFontConverter:
|
|||||||
|
|
||||||
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
|
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):
|
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}")
|
if self._sf3_plays_audio(sf3_path):
|
||||||
return 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()
|
converter = self._find_sf3_converter()
|
||||||
try:
|
try:
|
||||||
@@ -228,9 +361,16 @@ class SoundFontConverter:
|
|||||||
|
|
||||||
if converter == "python":
|
if converter == "python":
|
||||||
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
|
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)
|
if self._sf3_plays_audio(sf3_path):
|
||||||
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
|
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
|
||||||
return sf3_path
|
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")
|
logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path")
|
||||||
return sf2_path
|
return sf2_path
|
||||||
|
|
||||||
@@ -242,6 +382,165 @@ class SoundFontConverter:
|
|||||||
logger.error(f"Error converting {sf2_path}: {e}")
|
logger.error(f"Error converting {sf2_path}: {e}")
|
||||||
return sf2_path
|
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):
|
def batch_convert_all(self):
|
||||||
for sdir in self.target_dirs:
|
for sdir in self.target_dirs:
|
||||||
if not os.path.isdir(sdir):
|
if not os.path.isdir(sdir):
|
||||||
@@ -249,3 +548,4 @@ class SoundFontConverter:
|
|||||||
for fname in sorted(os.listdir(sdir)):
|
for fname in sorted(os.listdir(sdir)):
|
||||||
if fname.lower().endswith(".sf2"):
|
if fname.lower().endswith(".sf2"):
|
||||||
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
|
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
|
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
|
||||||
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
|
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
||||||
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +19,11 @@ def _file_sig(path: str) -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
class SoundFontAutoScanner:
|
class SoundFontAutoScanner:
|
||||||
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
|
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR,
|
||||||
self.system_sf_dir = system_sf_dir
|
system_sf_dirs=None):
|
||||||
|
# system_sf_dirs (list) — nhiều thư mục user khai báo trong Plugins
|
||||||
|
# Manager. Fallback system_sf_dir (env/.env) nếu list rỗng.
|
||||||
|
self.system_sf_dirs = [d for d in (system_sf_dirs or []) if d] or [system_sf_dir]
|
||||||
self.upload_sf_dir = upload_sf_dir
|
self.upload_sf_dir = upload_sf_dir
|
||||||
self._catalog = {}
|
self._catalog = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -50,6 +53,15 @@ class SoundFontAutoScanner:
|
|||||||
out.append((fname, os.path.join(directory, fname)))
|
out.append((fname, os.path.join(directory, fname)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _all_sf_files(self) -> list:
|
||||||
|
"""Gộp file .sf2/.sf3 từ TẤT CẢ thư mục hiệu lực (user dirs + upload)."""
|
||||||
|
out = []
|
||||||
|
for d in self.system_sf_dirs:
|
||||||
|
out.extend(self._sf_files(d))
|
||||||
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||||
|
out.extend(self._sf_files(self.upload_sf_dir))
|
||||||
|
return out
|
||||||
|
|
||||||
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
|
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
|
||||||
if fname.lower().endswith(".sf2"):
|
if fname.lower().endswith(".sf2"):
|
||||||
sf_info = inspector.inspect_sf2_file(full) or {}
|
sf_info = inspector.inspect_sf2_file(full) or {}
|
||||||
@@ -68,9 +80,9 @@ class SoundFontAutoScanner:
|
|||||||
|
|
||||||
def scan_once(self) -> bool:
|
def scan_once(self) -> bool:
|
||||||
from app.core.soundfont_inspector import SoundFontInspector
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir)
|
inspector = SoundFontInspector(self.system_sf_dirs[0], self.upload_sf_dir)
|
||||||
found_new = False
|
found_new = False
|
||||||
dirs = [(self.system_sf_dir, "system")]
|
dirs = [(d, "system") for d in self.system_sf_dirs]
|
||||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
||||||
dirs.append((self.upload_sf_dir, "upload"))
|
dirs.append((self.upload_sf_dir, "upload"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import scipy.signal as signal
|
import scipy.signal as signal
|
||||||
import librosa
|
from app.core.audio_features import time_stretch as _time_stretch, pitch_shift as _pitch_shift
|
||||||
|
|
||||||
class SubTabDSPEngine:
|
class SubTabDSPEngine:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
|
|||||||
return y
|
return y
|
||||||
|
|
||||||
if preserve_pitch:
|
if preserve_pitch:
|
||||||
return librosa.effects.time_stretch(y, rate=speed_ratio)
|
return _time_stretch(y, rate=speed_ratio)
|
||||||
else:
|
else:
|
||||||
num_samples_new = int(len(y) / speed_ratio)
|
num_samples_new = int(len(y) / speed_ratio)
|
||||||
return signal.resample(y, num_samples_new)
|
return signal.resample(y, num_samples_new)
|
||||||
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
|
|||||||
"""
|
"""
|
||||||
if n_steps == 0:
|
if n_steps == 0:
|
||||||
return y
|
return y
|
||||||
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
|
return _pitch_shift(y, sr=sr, n_steps=n_steps)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def merge_back_to_parent(
|
def merge_back_to_parent(
|
||||||
|
|||||||
+316
-93
@@ -1,12 +1,81 @@
|
|||||||
# SonicForge Studio VST / VSTi Engine Service
|
# SonicForge Studio VST / VSTi Engine Service
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import functools
|
import functools
|
||||||
from ctypes import c_int, c_char_p, c_void_p
|
from ctypes import c_char_p
|
||||||
|
|
||||||
def midi_note_to_freq(note_number: int) -> float:
|
def midi_note_to_freq(note_number: int) -> float:
|
||||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = 0,
|
||||||
|
program: int = 0, sr: int = 44100, bpm: float = 120.0) -> np.ndarray:
|
||||||
|
"""Native FluidSynth (pyfluidsynth) — render MIDI events bằng soundfont THẬT.
|
||||||
|
|
||||||
|
Dùng cho môi trường standalone: âm instrument xử lí trực tiếp trên OS
|
||||||
|
(backend pyfluidsynth), không phải FluidSynthWASM trong browser.
|
||||||
|
Xử lí đúng note CHỒNG nhau + đuôi release (event-stream noteon/noteoff).
|
||||||
|
Trả stereo float32 (2, N). Raise RuntimeError nếu pyfluidsynth thiếu / SF
|
||||||
|
không load được."""
|
||||||
|
import fluidsynth as _fs
|
||||||
|
settings = _fs.new_fluid_settings()
|
||||||
|
_fs.fluid_settings_setnum(settings, b'synth.sample-rate', float(sr))
|
||||||
|
synth = _fs.new_fluid_synth(settings)
|
||||||
|
try:
|
||||||
|
fid = _fs.fluid_synth_sfload(synth, os.fspath(sf_path).encode('utf-8'), 1)
|
||||||
|
if fid == -1:
|
||||||
|
raise RuntimeError(f"SoundFont load failed: {sf_path}")
|
||||||
|
_fs.fluid_synth_program_select(synth, 0, fid, int(bank), int(program))
|
||||||
|
beat_sec = 60.0 / max(30.0, bpm)
|
||||||
|
events = []
|
||||||
|
total_sec = 0.0
|
||||||
|
for ev in midi_events:
|
||||||
|
start_s = int(float(ev.get('start_beat', 0)) * beat_sec * sr)
|
||||||
|
dur_s = int(float(ev.get('duration_beats', 1)) * beat_sec * sr)
|
||||||
|
note = int(ev.get('note', 60))
|
||||||
|
vel = min(127, max(1, int(float(ev.get('velocity', 100)))))
|
||||||
|
events.append((start_s, 'on', note, vel))
|
||||||
|
events.append((start_s + dur_s, 'off', note, 0))
|
||||||
|
end_sec = (start_s + dur_s) / float(sr)
|
||||||
|
if end_sec > total_sec:
|
||||||
|
total_sec = end_sec
|
||||||
|
events.sort(key=lambda e: e[0])
|
||||||
|
total_sec = max(total_sec, 0.25) + 0.5 # đuôi reverb/release
|
||||||
|
n = int(total_sec * sr)
|
||||||
|
out = np.zeros((2, n), dtype=np.float32)
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
def _render_until(target: int):
|
||||||
|
"""Render từ pos tới target, CỘNG vào out (giữ âm đang ngân)."""
|
||||||
|
nonlocal pos
|
||||||
|
target = min(target, n)
|
||||||
|
while pos < target:
|
||||||
|
chunk = min(target - pos, 44100)
|
||||||
|
block = _fs.fluid_synth_write_s16_stereo(synth, chunk)
|
||||||
|
b = block.astype(np.float32).reshape(-1, 2).T / 32768.0
|
||||||
|
take = min(chunk, b.shape[1], target - pos)
|
||||||
|
if take > 0:
|
||||||
|
out[:, pos:pos + take] += b[:, :take]
|
||||||
|
pos += take
|
||||||
|
|
||||||
|
for t, kind, note, vel in events:
|
||||||
|
if t > pos:
|
||||||
|
_render_until(t)
|
||||||
|
if pos >= n:
|
||||||
|
break
|
||||||
|
if kind == 'on':
|
||||||
|
_fs.fluid_synth_noteon(synth, 0, note, vel)
|
||||||
|
else:
|
||||||
|
_fs.fluid_synth_noteoff(synth, 0, note)
|
||||||
|
_render_until(n) # đuôi release của notes cuối
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fs.delete_fluid_synth(synth)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
||||||
beat_duration_sec = 60.0 / max(30.0, bpm)
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
||||||
max_duration_sec = 2.0
|
max_duration_sec = 2.0
|
||||||
@@ -49,27 +118,29 @@ def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float =
|
|||||||
out_r /= max_peak
|
out_r /= max_peak
|
||||||
return np.vstack([out_l, out_r])
|
return np.vstack([out_l, out_r])
|
||||||
|
|
||||||
def check_pedalboard_safe():
|
def _module_available(name: str) -> bool:
|
||||||
import subprocess, sys
|
"""Kiem tra module co san khong — KHONG spawn subprocess.
|
||||||
|
|
||||||
|
Truoc day dung subprocess.run([sys.executable, '-c', 'import X']) —
|
||||||
|
khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe
|
||||||
|
-> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine)
|
||||||
|
-> moi lan check lai sinh ra engine moi -> de quy spawn vo han
|
||||||
|
(Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham).
|
||||||
|
find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
return importlib.util.find_spec(name) is not None
|
||||||
[sys.executable, "-c", "import pedalboard"],
|
except (ImportError, AttributeError, ValueError):
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
||||||
)
|
|
||||||
return res.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_pedalboard_safe():
|
||||||
|
return _module_available("pedalboard")
|
||||||
|
|
||||||
|
|
||||||
def check_pyfluidsynth_safe():
|
def check_pyfluidsynth_safe():
|
||||||
import subprocess, sys
|
return _module_available("fluidsynth")
|
||||||
try:
|
|
||||||
res = subprocess.run(
|
|
||||||
[sys.executable, "-c", "import fluidsynth"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
||||||
)
|
|
||||||
return res.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
HAS_PEDALBOARD = check_pedalboard_safe()
|
HAS_PEDALBOARD = check_pedalboard_safe()
|
||||||
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
||||||
@@ -82,7 +153,7 @@ def ensure_pyfluidsynth():
|
|||||||
|
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage
|
from pedalboard import VST3Plugin, Pedalboard, Gain
|
||||||
except Exception:
|
except Exception:
|
||||||
HAS_PEDALBOARD = False
|
HAS_PEDALBOARD = False
|
||||||
|
|
||||||
@@ -99,18 +170,47 @@ _PLUGIN_MANAGER_INSTANCE = None
|
|||||||
_PLUGIN_MANAGER_ARGS = None
|
_PLUGIN_MANAGER_ARGS = None
|
||||||
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
||||||
|
|
||||||
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
|
def _load_user_plugin_dirs() -> list:
|
||||||
"""Singleton: reuse PluginManager when args match, else create new."""
|
"""Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với
|
||||||
|
plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để
|
||||||
|
tránh vòng import (plugins.py import vst_engine)."""
|
||||||
|
try:
|
||||||
|
from app.config import settings as _st
|
||||||
|
path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [d for d in (data.get("plugin_dirs") or []) if d]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager":
|
||||||
|
"""Singleton: reuse PluginManager when args match, else create new.
|
||||||
|
Default dirs từ settings (env/.env/docker-compose hoặc user override).
|
||||||
|
VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt
|
||||||
|
kê được VSTi đã scan và load_vst phải tìm thấy chúng khi render."""
|
||||||
|
from app.config import settings as _st
|
||||||
|
vst_dir = vst_dir or _st.VST_DIR
|
||||||
|
sf_dir = sf_dir or _st.SOUNDFONT_DIR
|
||||||
|
upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts"
|
||||||
|
extra = _load_user_plugin_dirs()
|
||||||
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
||||||
args = (vst_dir, sf_dir, upload_sf_dir)
|
args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra))
|
||||||
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
||||||
return _PLUGIN_MANAGER_INSTANCE
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
_PLUGIN_MANAGER_ARGS = args
|
_PLUGIN_MANAGER_ARGS = args
|
||||||
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
|
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir, extra_vst_dirs=extra)
|
||||||
return _PLUGIN_MANAGER_INSTANCE
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
|
|
||||||
def load_soundfont_cached(path: str):
|
def load_soundfont_cached(path: str):
|
||||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||||
|
|
||||||
|
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||||
|
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||||
|
do not exist in this binding, so they are never used here.
|
||||||
|
"""
|
||||||
global _FLUID_CACHE
|
global _FLUID_CACHE
|
||||||
if not HAS_PYFLUIDSYNTH:
|
if not HAS_PYFLUIDSYNTH:
|
||||||
return None
|
return None
|
||||||
@@ -119,10 +219,15 @@ def load_soundfont_cached(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||||
return fl
|
return fl
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
_settings = _fs.new_fluid_settings()
|
||||||
font_id = fl.sfload(path)
|
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||||
fl.program_select(0, font_id, 0, 0)
|
fl = _fs.new_fluid_synth(_settings)
|
||||||
|
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||||
|
if font_id < 0:
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
|
return None
|
||||||
|
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||||
_FLUID_CACHE[path] = (fl, 1)
|
_FLUID_CACHE[path] = (fl, 1)
|
||||||
return fl
|
return fl
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -136,7 +241,8 @@ def release_soundfont(path: str):
|
|||||||
fl, ref = _FLUID_CACHE[path]
|
fl, ref = _FLUID_CACHE[path]
|
||||||
if ref <= 1:
|
if ref <= 1:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
import fluidsynth as _fs
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
del _FLUID_CACHE[path]
|
del _FLUID_CACHE[path]
|
||||||
@@ -144,22 +250,34 @@ def release_soundfont(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref - 1)
|
_FLUID_CACHE[path] = (fl, ref - 1)
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
|
def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None):
|
||||||
self.vst_dir = vst_dir
|
from app.config import settings as _st
|
||||||
self.sf_dir = sf_dir
|
self.vst_dir = vst_dir or _st.VST_DIR
|
||||||
|
self.sf_dir = sf_dir or _st.SOUNDFONT_DIR
|
||||||
self.upload_sf_dir = upload_sf_dir
|
self.upload_sf_dir = upload_sf_dir
|
||||||
|
# Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) —
|
||||||
|
# list_available()/load_vst phải thấy VSTi user đã scan (bug: nút
|
||||||
|
# Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3).
|
||||||
|
self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d]
|
||||||
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
||||||
|
|
||||||
def _scan_plugins(self) -> dict:
|
def _scan_plugins(self) -> dict:
|
||||||
plugins = {}
|
plugins = {}
|
||||||
if not os.path.isdir(self.vst_dir):
|
for scan_dir in [self.vst_dir] + self.extra_vst_dirs:
|
||||||
return plugins
|
if not scan_dir or not os.path.isdir(scan_dir):
|
||||||
for root, dirs, files in os.walk(self.vst_dir):
|
continue
|
||||||
for file in files:
|
for root, dirs, files in os.walk(scan_dir):
|
||||||
if file.endswith(".vst3") or file.endswith(".so"):
|
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
|
||||||
plugin_path = os.path.join(root, file)
|
for d in list(dirs):
|
||||||
plugin_name = os.path.splitext(file)[0]
|
if d.lower().endswith(".vst3"):
|
||||||
plugins[plugin_name] = plugin_path
|
plugins[os.path.splitext(d)[0]] = os.path.join(root, d)
|
||||||
|
for file in files:
|
||||||
|
low = file.lower()
|
||||||
|
if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"):
|
||||||
|
plugin_path = os.path.join(root, file)
|
||||||
|
plugin_name = os.path.splitext(file)[0]
|
||||||
|
if plugin_name not in plugins:
|
||||||
|
plugins[plugin_name] = plugin_path
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
def _scan_soundfonts(self) -> list:
|
def _scan_soundfonts(self) -> list:
|
||||||
@@ -228,8 +346,6 @@ class PluginManager:
|
|||||||
return load_soundfont_cached(path)
|
return load_soundfont_cached(path)
|
||||||
|
|
||||||
def list_soundfont_instruments(self, sf_id: str):
|
def list_soundfont_instruments(self, sf_id: str):
|
||||||
if not ensure_pyfluidsynth():
|
|
||||||
return []
|
|
||||||
if sf_id in _SF_INSTRUMENTS_CACHE:
|
if sf_id in _SF_INSTRUMENTS_CACHE:
|
||||||
return _SF_INSTRUMENTS_CACHE[sf_id]
|
return _SF_INSTRUMENTS_CACHE[sf_id]
|
||||||
search_dirs = []
|
search_dirs = []
|
||||||
@@ -237,50 +353,89 @@ class PluginManager:
|
|||||||
search_dirs.append(self.sf_dir)
|
search_dirs.append(self.sf_dir)
|
||||||
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
|
||||||
search_dirs.append(self.upload_sf_dir)
|
search_dirs.append(self.upload_sf_dir)
|
||||||
for d in search_dirs:
|
# Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn
|
||||||
for f in os.listdir(d):
|
# SoundFont): nếu không có, instrument của soundfont trong thư mục user
|
||||||
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
# không liệt kê được (bug "nhấn tên SF không thấy instrument").
|
||||||
continue
|
for d in (self.extra_vst_dirs or []):
|
||||||
base = os.path.splitext(f)[0]
|
if d and os.path.isdir(d) and d not in search_dirs:
|
||||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
search_dirs.append(d)
|
||||||
|
presets = []
|
||||||
|
# ── 1) FluidSynth (nếu có lib) — đọc bank/program/name từ engine ──
|
||||||
|
if ensure_pyfluidsynth():
|
||||||
|
for d in search_dirs:
|
||||||
|
if presets:
|
||||||
|
break
|
||||||
|
for f in os.listdir(d):
|
||||||
|
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
||||||
|
continue
|
||||||
|
base = os.path.splitext(f)[0]
|
||||||
|
if base != sf_id and base != sf_id.replace("sf_", ""):
|
||||||
|
continue
|
||||||
path = os.path.join(d, f)
|
path = os.path.join(d, f)
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.Synth()
|
# Low-level CFFI API (same as render_engine); never use
|
||||||
fid = fl.sfload(path)
|
# the high-level Synth() class that this binding lacks.
|
||||||
if fid < 0:
|
_settings = _fs.new_fluid_settings()
|
||||||
fl.delete()
|
_synth = _fs.new_fluid_synth(_settings)
|
||||||
continue
|
try:
|
||||||
presets = []
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||||
_fl = fluidsynth._fl
|
if fid >= 0:
|
||||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||||
_fl.fluid_preset_get_name.restype = c_char_p
|
if sfont:
|
||||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
for bank in range(0, 2):
|
||||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
for prog_num in range(0, 128):
|
||||||
if sfont_ptr:
|
try:
|
||||||
for bank in range(0, 2):
|
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||||
for prog_num in range(0, 128):
|
except Exception:
|
||||||
try:
|
break
|
||||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
if preset:
|
||||||
except Exception:
|
try:
|
||||||
break
|
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||||
if preset:
|
if name_ptr:
|
||||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
if hasattr(_fs, "ffi"):
|
||||||
if name_ptr:
|
raw = _fs.ffi.string(name_ptr)
|
||||||
name_val = c_char_p(name_ptr).value
|
else:
|
||||||
if name_val:
|
raw = c_char_p(name_ptr).value
|
||||||
presets.append({
|
if raw:
|
||||||
"bank": bank,
|
presets.append({
|
||||||
"program": prog_num,
|
"bank": bank,
|
||||||
"name": name_val.decode("utf-8", errors="replace")
|
"program": prog_num,
|
||||||
})
|
"name": raw.decode("utf-8", errors="replace"),
|
||||||
fl.delete()
|
})
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
except Exception:
|
||||||
return presets[:256]
|
continue
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fs.delete_fluid_synth(_synth)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback; traceback.print_exc()
|
import traceback; traceback.print_exc()
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
break # đã xử lý file khớp
|
||||||
return []
|
# ── 2) Fallback: sf2utils đọc TRỰC TIẾP file (thuần Python) ──
|
||||||
|
# Không cần libfluidsynth — quan trọng trên Windows khi thiếu DLL.
|
||||||
|
# Đọc preset header (pdta/phdr) → bank/program/name như nhau.
|
||||||
|
if not presets:
|
||||||
|
try:
|
||||||
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
|
insp = SoundFontInspector(system_sf_dir=self.sf_dir, upload_sf_dir=self.upload_sf_dir)
|
||||||
|
for d in search_dirs:
|
||||||
|
if presets:
|
||||||
|
break
|
||||||
|
for f in os.listdir(d):
|
||||||
|
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
||||||
|
continue
|
||||||
|
base = os.path.splitext(f)[0]
|
||||||
|
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||||
|
info = insp.inspect_sf2_file(os.path.join(d, f))
|
||||||
|
if info and info.get("instruments"):
|
||||||
|
presets = info["instruments"]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
presets = []
|
||||||
|
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||||
|
return presets[:256]
|
||||||
|
|
||||||
def list_available(self) -> dict:
|
def list_available(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -296,22 +451,23 @@ class PluginManager:
|
|||||||
if not HAS_PEDALBOARD:
|
if not HAS_PEDALBOARD:
|
||||||
return []
|
return []
|
||||||
beat_duration_sec = 60.0 / max(30.0, bpm)
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
||||||
|
# pedalboard >= 0.9: MIDI messages là tuple (bytes, timestamp_seconds)
|
||||||
|
# (MidiMessage class đã bị bỏ). bytes là raw MIDI event:
|
||||||
|
# 0x9n note_on, 0x8n note_off, 0xB0 control_change, 0xC0 program_change
|
||||||
messages = []
|
messages = []
|
||||||
if bank is not None:
|
if bank is not None:
|
||||||
messages.append(MidiMessage(control_change=0, value=bank, sample_offset=0))
|
messages.append((bytes([0xB0, 0, int(bank) & 0x7F]), 0.0))
|
||||||
if program is not None:
|
if program is not None:
|
||||||
messages.append(MidiMessage(program_change=program, sample_offset=0))
|
messages.append((bytes([0xC0, int(program) & 0x7F]), 0.0))
|
||||||
for ev in midi_events:
|
for ev in midi_events:
|
||||||
note = ev.get("note", 60)
|
note = int(ev.get("note", 60)) & 0x7F
|
||||||
velocity = ev.get("velocity", 100)
|
velocity = max(0, min(127, int(ev.get("velocity", 100))))
|
||||||
start_beat = ev.get("start_beat", 0.0)
|
start_beat = float(ev.get("start_beat", 0.0))
|
||||||
dur_beats = ev.get("duration_beats", 1.0)
|
dur_beats = float(ev.get("duration_beats", 1.0))
|
||||||
start_sec = start_beat * beat_duration_sec
|
start_sec = start_beat * beat_duration_sec
|
||||||
dur_sec = dur_beats * beat_duration_sec
|
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
||||||
sample_offset = int(start_sec * sr)
|
messages.append((bytes([0x90, note, velocity]), start_sec))
|
||||||
end_sample_offset = int((start_sec + dur_sec) * sr)
|
messages.append((bytes([0x80, note, 0]), end_sec))
|
||||||
messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset))
|
|
||||||
messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset))
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -349,3 +505,70 @@ class DecentSamplerManager:
|
|||||||
os.chdir(cwd_before)
|
os.chdir(cwd_before)
|
||||||
|
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preset bridge (Carla ↔ pedalboard) ─────────────────────────────────────
|
||||||
|
# Carla (chạy ngoài, user tự cài — GPL-2.0+ nên app KHÔNG bundle/nhúng) dùng
|
||||||
|
# để mở native GUI VSTi + xuất file preset (.vstpreset từ nút Save của plugin).
|
||||||
|
# File preset nằm trong thư viện storage/presets (upload qua web UI hoặc picker
|
||||||
|
# local trên Windows) → render_engine tải qua apply_preset_to_plugin() khi render.
|
||||||
|
PRESET_EXTENSIONS = (".vstpreset", ".fxp", ".fxb", ".dspreset")
|
||||||
|
|
||||||
|
|
||||||
|
def preset_library_dir() -> str:
|
||||||
|
from app.config import settings as _st
|
||||||
|
d = os.path.join(_st.STORAGE_DIR, "presets")
|
||||||
|
try:
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_preset_path(preset_id_or_path: str) -> str:
|
||||||
|
"""preset_id (tên file trong thư viện) hoặc đường dẫn tuyệt đối → path.
|
||||||
|
|
||||||
|
Trả '' nếu không tìm thấy. Chống path traversal: chỉ chấp nhận tên file
|
||||||
|
(không chứa separator) hoặc đường dẫn tuyệt đối tồn tại."""
|
||||||
|
if not preset_id_or_path:
|
||||||
|
return ""
|
||||||
|
p = preset_id_or_path
|
||||||
|
# Đường dẫn tuyệt đối / tương đối tồn tại → dùng thẳng
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
# id dạng tên file trong thư viện (uuid + ext)
|
||||||
|
if os.path.basename(p) == p:
|
||||||
|
cand = os.path.join(preset_library_dir(), p)
|
||||||
|
if os.path.isfile(cand):
|
||||||
|
return cand
|
||||||
|
# Không có ext → quét theo prefix (uuid.idx → uuid.vstpreset)
|
||||||
|
try:
|
||||||
|
for f in os.listdir(preset_library_dir()):
|
||||||
|
if f.lower().endswith(PRESET_EXTENSIONS) and f.startswith(p + "."):
|
||||||
|
return os.path.join(preset_library_dir(), f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
||||||
|
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
|
||||||
|
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
|
||||||
|
áp dụng được; KHÔNG raise (render engine chỉ log warning)."""
|
||||||
|
if plugin is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if preset_data_b64:
|
||||||
|
import base64
|
||||||
|
raw = base64.b64decode(preset_data_b64)
|
||||||
|
if hasattr(plugin, "preset_data"):
|
||||||
|
plugin.preset_data = raw # bytes dạng .vstpreset (VST3)
|
||||||
|
return True
|
||||||
|
p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "")
|
||||||
|
if p and hasattr(plugin, "load_preset"):
|
||||||
|
plugin.load_preset(p) # .vstpreset (VST3) / .dspreset (DecentSampler)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|||||||
+89
-29
@@ -1,8 +1,11 @@
|
|||||||
import os, threading
|
import os
|
||||||
from fastapi import FastAPI
|
from contextlib import asynccontextmanager
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.audio import router as audio_router
|
from app.api.v1.audio import router as audio_router
|
||||||
from app.api.v1.tasks import router as tasks_router
|
from app.api.v1.tasks import router as tasks_router
|
||||||
@@ -12,33 +15,87 @@ from app.api.v1.admin import router as admin_router
|
|||||||
from app.api.v1.projects import router as projects_router
|
from app.api.v1.projects import router as projects_router
|
||||||
from app.api.v1.user_config import router as user_config_router
|
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_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.plugins import router as plugins_router
|
||||||
|
from app.api.v1.media import router as media_router
|
||||||
|
from app.api.v1.system import router as system_router
|
||||||
|
from app.api.v1.presets import router as presets_router
|
||||||
from app.core.auth import seed_admin
|
from app.core.auth import seed_admin
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
|
|
||||||
# Ensure storage directories exist
|
# Ensure storage directories exist
|
||||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||||
|
|
||||||
app = FastAPI(title="SonicForge API Engine")
|
_SF_SCANNER_STOP = None
|
||||||
|
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
seed_admin()
|
||||||
|
scanner = SoundFontAutoScanner()
|
||||||
|
global _SF_SCANNER_STOP
|
||||||
|
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
if _SF_SCANNER_STOP is not None:
|
||||||
|
_SF_SCANNER_STOP.set()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
|
|
||||||
|
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||||
|
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mount storage directory (must come before general /static mount)
|
# Mount storage directory (must come before general /static mount)
|
||||||
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
# Bug #1: trước đây mount TOÀN BỘ STORAGE_DIR tại /static/audio — StaticFiles
|
||||||
|
# serve cả dotfile → attacker đọc .secret_key (forge admin token) + sonicforge.db
|
||||||
|
# (mọi user/hash) + temp/autosave.json mà KHÔNG cần auth. Fix: mount riêng từng
|
||||||
|
# thư mục con + chặn dotfile bằng SafeStaticFiles.
|
||||||
|
class SafeStaticFiles(StaticFiles):
|
||||||
|
"""StaticFiles chặn mọi đường dẫn chứa dotfile/.. (404 thay vì serve)."""
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope):
|
||||||
|
parts = path.split("/")
|
||||||
|
if any(p.startswith(".") or p in ("", "..") for p in parts):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
return await super().get_response(path, scope)
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static/audio/uploads", SafeStaticFiles(directory=settings.UPLOADS_DIR), name="uploads")
|
||||||
|
app.mount("/static/audio/processed", SafeStaticFiles(directory=settings.PROCESSED_DIR), name="processed")
|
||||||
# Mount app static files (js, css)
|
# Mount app static files (js, css)
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
# Dùng settings.APP_DIR (freeze-aware) thay vì os.path.dirname(__file__):
|
||||||
|
# khi PyInstaller onefile, __file__ trỏ vào thư mục giải nén tạm _MEI...
|
||||||
|
# nhưng static/templates nằm trong sys._MEIPASS/app (config.py đã xử lý).
|
||||||
|
STATIC_DIR = os.path.join(settings.APP_DIR, "static")
|
||||||
|
# ⚠️ Fallback an toàn: nếu vì lý do nào đó static không nằm đúng chỗ (vd
|
||||||
|
# bundle thiếu file, chạy từ nơi khác), thử các vị trí khác; nếu vẫn không
|
||||||
|
# có → TỰ TẠO thư mục rỗng để app KHÔNG crash khi khởi động (lỗi "Directory
|
||||||
|
# does not exist" từ StaticFiles làm engine chết ngay lúc import — đã gặp).
|
||||||
|
if not os.path.isdir(STATIC_DIR):
|
||||||
|
for cand in [
|
||||||
|
os.path.join(getattr(sys, "_MEIPASS", ""), "app", "static"),
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "static"),
|
||||||
|
]:
|
||||||
|
if cand and os.path.isdir(cand):
|
||||||
|
STATIC_DIR = cand
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
@@ -50,26 +107,16 @@ app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
|
|||||||
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
|
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
|
||||||
app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"])
|
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_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(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||||
|
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||||
|
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
||||||
|
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
||||||
|
|
||||||
# Seed admin user on startup
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_seed_admin():
|
|
||||||
seed_admin()
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.get("/health")
|
||||||
async def startup_convert_soundfonts():
|
async def health():
|
||||||
def _run():
|
return {"status": "ok"}
|
||||||
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("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def get_index():
|
async def get_index():
|
||||||
@@ -77,13 +124,26 @@ async def get_index():
|
|||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||||
|
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||||
|
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def get_favicon():
|
async def get_favicon():
|
||||||
import os
|
|
||||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||||
if os.path.exists(favicon_path):
|
if os.path.exists(favicon_path):
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||||
return HTMLResponse(content="", status_code=404)
|
return HTMLResponse(content="", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ai-prompt-generator", response_class=HTMLResponse)
|
||||||
|
async def get_ai_prompt_generator():
|
||||||
|
md_path = os.path.join(settings.BASE_DIR, "md", "49_AI_PROMPT_GENERATOR.md")
|
||||||
|
if not os.path.exists(md_path):
|
||||||
|
return HTMLResponse(content="<h1>File not found</h1>", status_code=404)
|
||||||
|
with open(md_path, "r", encoding="utf-8") as file:
|
||||||
|
return HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"id": { "type": "string" },
|
"id": { "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||||
|
"color": { "type": ["string", "null"], "default": null },
|
||||||
"volume_db": { "type": "number", "default": 0.0 },
|
"volume_db": { "type": "number", "default": 0.0 },
|
||||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||||
"mute": { "type": "boolean", "default": false },
|
"mute": { "type": "boolean", "default": false },
|
||||||
|
|||||||
+41
-1
@@ -5,12 +5,18 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||||
|
# dev database is never touched by the test suite.
|
||||||
|
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||||
|
# cleanup consistent when users are deleted.
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -65,6 +71,40 @@ def init_db():
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Migration: thêm cột backup nếu chưa tồn tại
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN is_backup INTEGER DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN original_id TEXT DEFAULT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Bảng Project Backups (snapshot riêng, không lẫn với projects chính)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS project_backups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Placeholder user for anonymous autosave: projects are saved with
|
||||||
|
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||||
|
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
import secrets as _secrets
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||||
|
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||||
|
""", (_secrets.token_hex(32), time.time()))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+14728
-1363
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1558
-174
File diff suppressed because one or more lines are too long
@@ -54,10 +54,10 @@ const AIGateway = (function() {
|
|||||||
properties: {
|
properties: {
|
||||||
composition_title: { type: 'string', description: 'Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)' },
|
composition_title: { type: 'string', description: 'Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)' },
|
||||||
bpm: { type: 'integer' },
|
bpm: { type: 'integer' },
|
||||||
total_bars: { type: 'integer' },
|
total_bars: { type: 'integer', description: 'Total length of the composition in bars. You MUST populate all bars with notes.' },
|
||||||
tracks: {
|
tracks: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Array of instrument tracks with MIDI notes and SoundFont instrument selection',
|
description: 'CRITICAL: Array of instrument tracks. You MUST generate exactly the number of tracks requested by the user. Every track in this array MUST contain a full sequence of notes that spans the entire duration of the piece (from start_beat 0.0 to total_bars * 4.0).',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -68,6 +68,7 @@ const AIGateway = (function() {
|
|||||||
soundfont_program: { type: 'integer', description: 'MIDI Program Number 0-127 matching the instrument name in the SoundFont catalog' },
|
soundfont_program: { type: 'integer', description: 'MIDI Program Number 0-127 matching the instrument name in the SoundFont catalog' },
|
||||||
notes: {
|
notes: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
description: 'CRITICAL: Array of MIDI notes. You MUST write notes completely filling all bars from bar 0 (beat 0.0) up to the final bar (beat total_bars * 4.0). Do NOT leave empty bars or stop early. Fill the entire duration of the composition with continuous musical notes.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -88,6 +89,197 @@ const AIGateway = (function() {
|
|||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
const REARRANGE_TOOL_SPEC = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rearrange_midi_melody',
|
||||||
|
description: 'Accepts source MIDI notes and rearranges/re-harmonizes them into a new musical variation while preserving the core melody. Use this when the user asks to rearrange, remix, or create variations of an existing MIDI item.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
rearrange_title: { type: 'string', description: 'Title/description of the rearranged version (e.g. "Jazz Variation", "Dark Orchestral Remix")' },
|
||||||
|
soundfont_id: { type: 'string', default: 'generaluser_gs' },
|
||||||
|
soundfont_bank: { type: 'integer', default: 0 },
|
||||||
|
soundfont_program: { type: 'integer', default: 0 },
|
||||||
|
rearranged_notes: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of rearranged MIDI notes. Keep the same total_duration_beats as the original unless user explicitly requests length change.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pitch: { type: 'integer', description: 'MIDI note pitch 0-127 (C4=60)' },
|
||||||
|
start_beat: { type: 'number', description: 'Note start position in beats from 0.0' },
|
||||||
|
duration_beats: { type: 'number', description: 'Note length in beats (quarter=1.0)' },
|
||||||
|
velocity: { type: 'number', description: 'Velocity 0.0-1.0' }
|
||||||
|
},
|
||||||
|
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['rearrange_title', 'rearranged_notes']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
keywords: ['arpeggio', 'arp', 'broken chord', 'broken chords', 'shimmering sequence'],
|
||||||
|
description: 'Arpeggio variation',
|
||||||
|
technique: 'Subdivide sustained chord notes (longer than 0.5 beats) into sequential 0.25-beat arpeggiated interval steps using octave/triad jumps. Create a driving, shimmering sequence.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'harmonies',
|
||||||
|
keywords: ['harmony', 'harmonize', 'harmonies', '3rd', '3rds', 'duet', 'chord voicing', 'voicing'],
|
||||||
|
description: 'Add parallel harmonies',
|
||||||
|
technique: 'For each source pitch P, insert parallel harmonizing notes at pitch P+3 or P+4 (Major/Minor 3rds) matching the same start_beat. Create richer, fuller texture resembling two instruments playing in duet.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'syncopation',
|
||||||
|
keywords: ['syncopation', 'syncopate', 'syncopated', 'off-beat', 'off beat', 'funk', 'latin', 'rnb', 'r&b'],
|
||||||
|
description: 'Syncopation / off-beat feel',
|
||||||
|
technique: 'Shift start_beat alignment off strong beats (0.0, 1.0, 2.0, 3.0) onto off-beats (0.5, 1.5, 2.5, 3.5). Create a rhythmic, syncopated feel.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'walking_bass',
|
||||||
|
keywords: ['walking bass', 'bassline', 'bass line', 'walking'],
|
||||||
|
description: 'Walking bass line',
|
||||||
|
technique: 'Lower pitches to bass register (36-48 range). Build continuous quarter-note steps (1.0 beat duration) following the chord outline. Create an improvisational walking bassline.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz_swing',
|
||||||
|
keywords: ['jazz', 'swing', 'bebop', 'bop', 'jazz swing'],
|
||||||
|
description: 'Jazz Swing style',
|
||||||
|
technique: 'Apply Jazz Swing characteristics: off-beat syncopation, 7th/9th chord extensions, rhythmic variations, and swung eighth notes (triplet feel). Preserve the core melodic outline while adding jazz harmony.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'synthwave',
|
||||||
|
keywords: ['synthwave', '80s', 'retro', 'synth wave', 'outrun', 'retrowave'],
|
||||||
|
description: 'Synthwave / 80s style',
|
||||||
|
technique: 'Transform into 80s Synthwave style: driving 8th-note bass arpeggios, gated reverb snare, analog synth lead with portamento, and pulsating chord pads. Use octave jumps in the bass.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cinematic',
|
||||||
|
keywords: ['cinematic', 'orchestral', 'epic', 'film score', 'movie', 'hollywood', 'symphonic'],
|
||||||
|
description: 'Cinematic Orchestral style',
|
||||||
|
technique: 'Transform into dramatic Cinematic Orchestral style: swelling dynamics, brass stabs on downbeats, string ostinato patterns, taiko percussion hits on strong beats. Layer multiple octaves for epic width.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'simplify',
|
||||||
|
keywords: ['simplify', 'minimal', 'strip', 'downbeat', 'reduce', 'sparse'],
|
||||||
|
description: 'Simplify / strip down',
|
||||||
|
technique: 'Keep only the downbeat notes (start_beat at 0.0, 1.0, 2.0, 3.0, etc.). Strip out embellishments, passing tones, and grace notes. Reduce chord voicings to root and 5th only.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'passing_tones',
|
||||||
|
keywords: ['passing tone', 'chromatic', 'chromaticism', 'smooth', 'approach note'],
|
||||||
|
description: 'Add passing tones / chromaticism',
|
||||||
|
technique: 'Insert passing tones and chromatic approach notes between chord tones to smooth out the melodic progression. Use half-step and whole-step approach notes targeting chord tones on strong beats.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectRearrangeScenario(prompt) {
|
||||||
|
if (!prompt) return null;
|
||||||
|
const lower = prompt.toLowerCase();
|
||||||
|
const matched = [];
|
||||||
|
for (const sc of REARRANGE_SCENARIOS) {
|
||||||
|
const hits = sc.keywords.filter(kw => lower.includes(kw.toLowerCase()));
|
||||||
|
if (hits.length > 0) {
|
||||||
|
matched.push({ scenario: sc, hitCount: hits.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched.sort((a, b) => b.hitCount - a.hitCount);
|
||||||
|
return matched.length > 0 ? matched[0].scenario : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRearrangeMessage(prompt, sourceContext) {
|
||||||
|
const notesJson = JSON.stringify(sourceContext.notes, null, 2);
|
||||||
|
const scenario = detectRearrangeScenario(prompt);
|
||||||
|
const techniqueSection = scenario
|
||||||
|
? `\nDETECTED SCENARIO: ${scenario.description}\nSPECIFIC TECHNIQUE REQUIRED: ${scenario.technique}\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rules = [
|
||||||
|
'1. Use the rearrange_midi_melody tool to return the rearranged notes.',
|
||||||
|
'2. PRESERVE the core melodic outline and overall structure unless the user explicitly asks for a complete transformation.',
|
||||||
|
'3. Keep the total duration (' + sourceContext.total_beats + ' beats) the same unless user requests a different length.',
|
||||||
|
'4. The rearranged_notes array MUST contain notes with pitch (0-127), start_beat (0.0 to ' + sourceContext.total_beats + '.0), duration_beats, and velocity (0.0-1.0).',
|
||||||
|
'5. Start beats should remain within 0-' + sourceContext.total_beats + ' range.',
|
||||||
|
'6. You may add, remove, or modify notes to achieve the requested style.',
|
||||||
|
'7. ALWAYS return a rearrange_title describing what was created (e.g. "Jazz Swing Variation of Piano Lead").'
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: `You are a professional Music Composer & Arranger.
|
||||||
|
|
||||||
|
SOURCE MIDI CONTEXT:
|
||||||
|
- Track: ${sourceContext.track_name}
|
||||||
|
- Item: ${sourceContext.item_name}
|
||||||
|
- Duration: ${sourceContext.duration_bars} bars (${sourceContext.total_beats} beats)
|
||||||
|
- BPM: ${sourceContext.bpm}
|
||||||
|
- Time Signature: 4/4
|
||||||
|
- Total notes: ${sourceContext.total_notes}
|
||||||
|
|
||||||
|
ORIGINAL MELODY NOTES (pitch, note_name, start_beat, duration_beats, velocity):
|
||||||
|
${notesJson}
|
||||||
|
${techniqueSection}
|
||||||
|
STRICT REARRANGE RULES:
|
||||||
|
${rules.join('\n')}` },
|
||||||
|
{ role: 'user', content: `${prompt}\n\nRearrange the source MIDI notes above according to this request. Return the result via the rearrange_midi_melody tool.` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function parseOrigin(urlStr) {
|
function parseOrigin(urlStr) {
|
||||||
try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; }
|
try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; }
|
||||||
}
|
}
|
||||||
@@ -134,7 +326,10 @@ const AIGateway = (function() {
|
|||||||
} else {
|
} else {
|
||||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({ url, headers, body })
|
body: JSON.stringify({ url, headers, body })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -202,6 +397,14 @@ const AIGateway = (function() {
|
|||||||
Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng.
|
Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng.
|
||||||
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
|
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
|
||||||
${catalogSection}
|
${catalogSection}
|
||||||
|
|
||||||
|
=== HƯỚNG DẪN SOẠN NHẠC MIDI / MIDI COMPOSITION RULES ===
|
||||||
|
KHI NGƯỜI DÙNG YÊU CẦU TẠO NHẠC / COMPOSITION RULES:
|
||||||
|
1. FULL TRACKS & BARS: If the user requests X tracks and Y bars, you MUST generate exactly X tracks. Each track MUST contain a continuous sequence of MIDI notes starting from beat 0.0 and stretching all the way to beat Y * 4.0 (the end of the composition).
|
||||||
|
2. NO EARLY STOPPING: Do NOT stop early or leave empty bars at the end or in the middle. Every track must be fully populated with notes throughout the entire duration.
|
||||||
|
3. EXPRESS MELODY & EMOTION: The generated MIDI notes (pitch, start_beat, duration_beats, velocity) must express the requested musical emotion (e.g., happy, sad, epic, energetic, melancholic). Use rich harmonies/chords for backing tracks (Strings, Pads, Piano) and expressive, rhythmic melodies for Lead/Solo tracks. Do NOT write single repeating notes or overly sparse patterns unless explicitly asked.
|
||||||
|
4. VIẾT ĐẦY ĐỦ CÁC NOTE: Bạn phải viết đầy đủ các note cho TẤT CẢ các tracks được yêu cầu, và trải dài trong SUỐT chiều dài số bars yêu cầu (ví dụ: yêu cầu 8 bars và 6 tracks thì phải tạo đủ 6 tracks, mỗi track phải có các note MIDI bắt đầu từ beat 0.0 kéo dài liên tục đến beat 32.0 (8 bars * 4 beat/bar)).
|
||||||
|
|
||||||
QUAN TRỌNG:
|
QUAN TRỌNG:
|
||||||
1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat.
|
1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat.
|
||||||
2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về.
|
2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về.
|
||||||
@@ -303,6 +506,11 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
DEFAULT_TOOLS,
|
DEFAULT_TOOLS,
|
||||||
|
REARRANGE_TOOL_SPEC,
|
||||||
|
REARRANGE_EXTEND_TOOL_SPEC,
|
||||||
|
REARRANGE_SCENARIOS,
|
||||||
|
detectRearrangeScenario,
|
||||||
|
buildRearrangeMessage,
|
||||||
callLLM,
|
callLLM,
|
||||||
extractFunctionCalls,
|
extractFunctionCalls,
|
||||||
buildUserMessage,
|
buildUserMessage,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
async function apiRequest(endpoint, options = {}) {
|
async function apiRequest(endpoint, options = {}) {
|
||||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||||
|
// FormData: browser tự đặt Content-Type kèm boundary — không được ép JSON
|
||||||
|
if (options.body instanceof FormData) delete headers['Content-Type'];
|
||||||
const response = await fetch(url, { ...options, headers });
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -41,9 +43,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }),
|
updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }),
|
||||||
updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }),
|
updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }),
|
||||||
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
|
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
|
||||||
|
createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }),
|
||||||
|
|
||||||
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }),
|
saveTempProject: (dataJson, clientId) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson, client_id: clientId || '' }) }),
|
||||||
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
||||||
|
getTempRevision: () => apiRequest('/api/v1/projects/temp/revision', { method: 'GET' }),
|
||||||
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
||||||
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
|
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
|
||||||
@@ -63,10 +67,65 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
|
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }),
|
||||||
|
|
||||||
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
|
listPlugins: () => apiRequest('/api/v1/plugins/available', { method: 'GET' }),
|
||||||
|
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
|
||||||
|
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
|
||||||
|
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
|
||||||
|
// Runtime capabilities — frontend gọi lúc boot để biết môi trường
|
||||||
|
// (desktop Windows / docker headless) và bật/tắt tính năng
|
||||||
|
getCapabilities: () => apiRequest('/api/v1/system/capabilities', { method: 'GET' }),
|
||||||
|
// Định vị Carla.exe (bản portable zip không cài đặt/PATH) — lưu config
|
||||||
|
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
|
||||||
|
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
|
||||||
|
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
|
||||||
|
// Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
|
||||||
|
// do app spawn (gọi khi track chuyển từ VSTi sang instrument soundfont)
|
||||||
|
stopCarla: () => apiRequest('/api/v1/plugins/carla-stop', { method: 'POST', body: JSON.stringify({}) }),
|
||||||
|
// Kiểm tra Carla còn sống không (app-spawn) + cổng OSC — quyết định
|
||||||
|
// route MIDI item exclusive qua Carla hay fallback FluidSynth
|
||||||
|
carlaStatus: () => apiRequest('/api/v1/plugins/carla-status', { method: 'GET' }),
|
||||||
|
// Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime
|
||||||
|
carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Quick-render preview VSTi (âm thật = âm export, cùng code path)
|
||||||
|
previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Render MIDI notes → WAV bằng VSTi (pedalboard, âm thật) — trả file_id
|
||||||
|
// để UI preview / gán clip vào track / download
|
||||||
|
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Phát dãy MIDI notes realtime qua Carla bridge (OSC) — preview khi
|
||||||
|
// pedalboard không render được plugin (VD VST2)
|
||||||
|
carlaPlayNotes: (payload) => apiRequest('/api/v1/plugins/carla-play-notes', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
// Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard
|
||||||
|
listPresets: () => apiRequest('/api/v1/presets', { method: 'GET' }),
|
||||||
|
uploadPreset: (file, pluginHint) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (pluginHint) fd.append('plugin_hint', pluginHint);
|
||||||
|
return apiRequest('/api/v1/presets/upload', { method: 'POST', body: fd });
|
||||||
|
},
|
||||||
|
deletePreset: (presetId) => apiRequest(`/api/v1/presets/${presetId}`, { method: 'DELETE' }),
|
||||||
|
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
|
||||||
|
// user yêu cầu dùng window explorer, không nhập tay
|
||||||
|
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
|
||||||
|
// Folder picker cho Plugin Manager: duyet thu muc qua backend (media)
|
||||||
|
// — hoat dong moi OS, khong can window.__TAURI__ (UI chay tren localhost:8000)
|
||||||
|
browseComputer: () => apiRequest('/api/v1/media/computer', { method: 'GET' }),
|
||||||
|
browseDir: (path) => apiRequest(`/api/v1/media/browse?path=${encodeURIComponent(path)}`, { method: 'GET' }),
|
||||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||||
|
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||||
|
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||||
|
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
createBackup: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}/backup`, { method: 'POST' }),
|
||||||
|
listBackups: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}/backups`, { method: 'GET' }),
|
||||||
|
deleteBackup: (backupId) => apiRequest(`/api/v1/projects/cloud/backups/${backupId}`, { method: 'DELETE' }),
|
||||||
|
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
|
||||||
|
|
||||||
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
|
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
|
||||||
|
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
||||||
|
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
|
||||||
|
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
||||||
uploadSoundFont: async (file) => {
|
uploadSoundFont: async (file) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const DAWCommandDispatcher = (function() {
|
|||||||
register('MODIFY_MIDI_NOTES', (args) => api.modifyMidiNotes(args));
|
register('MODIFY_MIDI_NOTES', (args) => api.modifyMidiNotes(args));
|
||||||
register('PROCESS_AI_DSP', (args) => api.processAudioDsp(args));
|
register('PROCESS_AI_DSP', (args) => api.processAudioDsp(args));
|
||||||
register('GENERATE_MULTITRACK_MIDI', (args) => api.generateMultitrackMidi(args));
|
register('GENERATE_MULTITRACK_MIDI', (args) => api.generateMultitrackMidi(args));
|
||||||
|
register('REARRANGE_MIDI_MELODY', (args) => api.rearrangeMidiMelody(args));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
if (!track.midiItems || !track.midiItems.length) continue;
|
if (!track.midiItems || !track.midiItems.length) continue;
|
||||||
if (track.muted) continue;
|
if (track.muted) continue;
|
||||||
|
|
||||||
|
var isSameTrack = track.id === targetTrackId;
|
||||||
var trackGhostNotes = [];
|
var trackGhostNotes = [];
|
||||||
|
|
||||||
for (var j = 0; j < track.midiItems.length; j++) {
|
for (var j = 0; j < track.midiItems.length; j++) {
|
||||||
@@ -32,7 +33,10 @@
|
|||||||
var itemStartBeat = item.startTime / secondsPerBeat;
|
var itemStartBeat = item.startTime / secondsPerBeat;
|
||||||
var itemEndBeat = (item.startTime + item.duration) / 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 || [];
|
var notes = item.notes || [];
|
||||||
for (var k = 0; k < notes.length; k++) {
|
for (var k = 0; k < notes.length; k++) {
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
||||||
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
||||||
|
|
||||||
if (noteAbsStart >= windowEndBeat) continue;
|
if (noteAbsStart >= windowEndBeat && !isSameTrack) continue;
|
||||||
|
|
||||||
trackGhostNotes.push({
|
trackGhostNotes.push({
|
||||||
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
relative_start_beat: noteAbsStart - windowStartBeat,
|
relative_start_beat: noteAbsStart - windowStartBeat,
|
||||||
duration_beats: (note.duration_beats || 1),
|
duration_beats: (note.duration_beats || 1),
|
||||||
velocity: note.velocity,
|
velocity: note.velocity,
|
||||||
|
item_id: item.id,
|
||||||
original_track_name: track.name,
|
original_track_name: track.name,
|
||||||
original_track_color: track.color || '#888888'
|
original_track_color: track.color || '#888888'
|
||||||
});
|
});
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
track_id: track.id,
|
track_id: track.id,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
track_color: track.color || '#6b7280',
|
track_color: track.color || '#6b7280',
|
||||||
|
isSameTrack: isSameTrack,
|
||||||
notes: trackGhostNotes
|
notes: trackGhostNotes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const SonicMidiExtractor = (function() {
|
||||||
|
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||||
|
|
||||||
|
function midiPitchToNoteName(pitch) {
|
||||||
|
const note = NOTE_NAMES[pitch % 12];
|
||||||
|
const octave = Math.floor(pitch / 12) - 1;
|
||||||
|
return note + octave;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSelectedMIDIContext(tracks, selectedItemId, bpm) {
|
||||||
|
let targetItem = null;
|
||||||
|
let targetTrack = null;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const items = track.midiItems || [];
|
||||||
|
const item = items.find(i => i.id === selectedItemId);
|
||||||
|
if (item) { targetItem = item; targetTrack = track; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetItem) {
|
||||||
|
throw new Error('Please select a MIDI Item on the Timeline before requesting a Rearrangement!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = targetItem.notes || [];
|
||||||
|
if (notes.length === 0) {
|
||||||
|
throw new Error('Selected MIDI item has no notes to rearrange.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactNotes = notes.map(n => ({
|
||||||
|
pitch: n.pitch,
|
||||||
|
note_name: midiPitchToNoteName(n.pitch),
|
||||||
|
start_beat: parseFloat((n.start_beat || 0).toFixed(2)),
|
||||||
|
duration_beats: parseFloat((n.duration_beats || 1).toFixed(2)),
|
||||||
|
velocity: parseFloat((n.velocity || 0.8).toFixed(2))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalDurationBeats = compactNotes.reduce((max, n) => Math.max(max, n.start_beat + n.duration_beats), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
track_name: targetTrack.name,
|
||||||
|
track_id: targetTrack.id,
|
||||||
|
item_id: targetItem.id,
|
||||||
|
item_name: targetItem.name,
|
||||||
|
duration_bars: targetItem.length_bars || Math.ceil(totalDurationBeats / 4),
|
||||||
|
total_beats: Math.ceil(totalDurationBeats),
|
||||||
|
bpm: parseInt(bpm || '120'),
|
||||||
|
total_notes: compactNotes.length,
|
||||||
|
notes: compactNotes,
|
||||||
|
instrument: targetTrack.synth_engine ? {
|
||||||
|
soundfont_id: targetTrack.synth_engine.soundfont_id || '',
|
||||||
|
soundfont_bank: targetTrack.synth_engine.soundfont_bank ?? 0,
|
||||||
|
soundfont_program: targetTrack.synth_engine.soundfont_program ?? 0
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extractSelectedMIDIContext, midiPitchToNoteName };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.SonicMidiExtractor = SonicMidiExtractor;
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
const PromptTemplateManager = (function() {
|
||||||
|
const DEFAULT_PRESETS = [
|
||||||
|
{
|
||||||
|
id: "preset_epic_orchestra_intro",
|
||||||
|
name: "Epic Orchestra Intro (8 Bars)",
|
||||||
|
keywords: ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
|
||||||
|
category: "Orchestral / Film Score",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 130,
|
||||||
|
default_scale: "C Minor",
|
||||||
|
system_instruction_template: "You are a professional film composer. Create a powerful, dramatic 8-bar orchestral intro. Write notes continuously across all 8 bars (from beat 0.0 to 32.0) for every track. Use whole notes, half notes, or quarter notes to maintain a clean layout without overloading the sequence. The required structure to return via the `generate_multitrack_midi` tool consists of 3 tracks: 1. Strings: plays smooth legato chord changes (one chord per 1 or 2 bars continuously). 2. Brass Theme: plays a swelling simple melodic line in the C3-C5 range spanning all 8 bars. 3. Epic Percussion: hits heavily on beats 1 and 3 throughout the entire 8 bars.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_pop_piano_chords",
|
||||||
|
name: "Pop Piano Chords (4 Bars)",
|
||||||
|
keywords: ["pop piano", "piano chords", "ballad piano", "hợp âm piano"],
|
||||||
|
category: "Pop / Ballad",
|
||||||
|
default_bars: 4,
|
||||||
|
default_bpm: 90,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional Pop Piano player. Generate a beautiful 4-bar piano chord progression (e.g. C - G - Am - F) with pleasant chord voicing and simple accompaniment. Return the MIDI notes via the `generate_multitrack_midi` function on a track named 'Pop Piano'. Write notes continuously across all 4 bars (from beat 0.0 to 16.0). Use whole/half/quarter notes continuously to ensure every bar has piano chords playing.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_cyberpunk_synth",
|
||||||
|
name: "Cyberpunk Synthwave (8 Bars)",
|
||||||
|
keywords: ["cyberpunk synth", "synthwave", "cyberpunk", "futuristic synth"],
|
||||||
|
category: "Electronic / Synthwave",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "A Minor",
|
||||||
|
system_instruction_template: "You are a Synthwave producer. Generate a driving 8-bar cyberpunk synth theme. Return MIDI notes via `generate_multitrack_midi` containing: 1. Synth Bass: plays eighth notes on pitch A1, C2, G1 continuously across all 8 bars (from beat 0.0 to 32.0). 2. Synth Lead: plays a simple, melodic line in the high register (C4-E5) continuously across all 8 bars (from beat 0.0 to 32.0).",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-23T16:00:00Z"
|
||||||
|
},
|
||||||
|
// ── Rearrange & Variation Scenarios ──
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_arpeggio",
|
||||||
|
name: "Arpeggio Variation",
|
||||||
|
keywords: ["arpeggio", "arp", "broken chord", "arpeggiate"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Rearrange the source MIDI notes into an arpeggiated variation. Subdivide sustained chord notes into sequential 0.25-beat arpeggiated steps with octave/triad jumps. Create a driving, shimmering sequence while preserving the underlying chord progression.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_harmonies",
|
||||||
|
name: "Add 3rd/4th Harmonies",
|
||||||
|
keywords: ["harmony", "harmonize", "harmonies", "3rd", "duet", "chord voicing"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Add parallel harmonizing notes to the source melody. For each source pitch P, insert notes at P+3 or P+4 (Major/Minor 3rds) at the same start_beat. Create a richer, fuller texture resembling two instruments playing in duet.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_syncopation",
|
||||||
|
name: "Syncopation / Off-beat",
|
||||||
|
keywords: ["syncopation", "syncopate", "syncopated", "off-beat", "off beat", "funk", "latin"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Apply syncopation to the source melody. Shift start_beat alignment off strong beats (0.0, 1.0, 2.0, 3.0) onto off-beats (0.5, 1.5, 2.5, 3.5). Preserve the core melodic outline while creating a rhythmic, syncopated feel.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_walking_bass",
|
||||||
|
name: "Walking Bass Line",
|
||||||
|
keywords: ["walking bass", "bassline", "walking"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Transform the source melody into a walking bass line. Lower pitches to bass register (36-48 range). Build continuous quarter-note steps following the chord outline. Create an improvisational walking bassline matching the original chord progression.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_jazz",
|
||||||
|
name: "Jazz Swing Rearrangement",
|
||||||
|
keywords: ["jazz", "swing", "jazz swing", "bebop"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional Jazz arranger. Rearrange the source melody into a rhythmic Jazz Swing style. Apply: off-beat syncopation, 7th/9th chord extensions, swung eighth notes (triplet feel), and rhythmic variations while preserving the core melodic outline.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_synthwave",
|
||||||
|
name: "Synthwave / 80s Style",
|
||||||
|
keywords: ["synthwave", "80s", "retro", "synth wave", "retrowave", "outrun"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "A Minor",
|
||||||
|
system_instruction_template: "You are a Synthwave producer. Transform the source melody into an 80s Synthwave style. Apply: driving 8th-note bass arpeggios, analog synth lead with portamento, pulsating chord pads, and octave jumps in the bass.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_cinematic",
|
||||||
|
name: "Cinematic Orchestral",
|
||||||
|
keywords: ["cinematic", "orchestral", "epic", "film score", "symphonic", "hollywood"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 130,
|
||||||
|
default_scale: "C Minor",
|
||||||
|
system_instruction_template: "You are a cinematic composer. Transform the source melody into a dramatic Cinematic Orchestral arrangement. Apply: swelling dynamics, brass stabs on downbeats, string ostinato patterns, taiko percussion hits. Layer multiple octaves for epic width.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_simplify",
|
||||||
|
name: "Simplify / Strip Down",
|
||||||
|
keywords: ["simplify", "minimal", "strip down", "sparse", "reduce"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Simplify the source melody: keep only downbeat notes (start_beat at 0.0, 1.0, 2.0, 3.0). Strip out embellishments, passing tones, and grace notes. Reduce chord voicings to root and 5th only.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "preset_rearrange_chromatic",
|
||||||
|
name: "Passing Tones & Chromaticism",
|
||||||
|
keywords: ["passing tone", "chromatic", "chromaticism", "smooth", "approach note"],
|
||||||
|
category: "Rearrange / Variation",
|
||||||
|
default_bars: 8,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Major",
|
||||||
|
system_instruction_template: "You are a professional arranger. Add passing tones and chromatic approach notes to the source melody. Insert half-step and whole-step approach notes targeting chord tones on strong beats. Smooth out the melodic progression while preserving the harmonic framework.",
|
||||||
|
is_user_defined: false,
|
||||||
|
is_favorite: false,
|
||||||
|
created_at: "2026-07-28T11:00:00Z"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'daw_ai_prompt_presets';
|
||||||
|
|
||||||
|
function PromptTemplateManager() {
|
||||||
|
this.presets = [];
|
||||||
|
this.loadPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.loadPresets = function() {
|
||||||
|
try {
|
||||||
|
const localData = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (localData) {
|
||||||
|
const parsed = JSON.parse(localData);
|
||||||
|
const userPresets = parsed.filter(p => p.is_user_defined);
|
||||||
|
this.presets = [...DEFAULT_PRESETS, ...userPresets];
|
||||||
|
} else {
|
||||||
|
this.presets = [...DEFAULT_PRESETS];
|
||||||
|
this.savePresets();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
this.presets = [...DEFAULT_PRESETS];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.savePresets = function() {
|
||||||
|
const userData = this.presets.filter(p => p.is_user_defined);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(userData));
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.getPresets = function() {
|
||||||
|
return this.presets;
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.matchPreset = function(userQuery) {
|
||||||
|
if (!userQuery) return null;
|
||||||
|
const queryLower = userQuery.toLowerCase();
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (const preset of this.presets) {
|
||||||
|
for (const kw of preset.keywords) {
|
||||||
|
const kwLower = kw.toLowerCase();
|
||||||
|
if (queryLower === kwLower) {
|
||||||
|
if (3 > bestScore) {
|
||||||
|
bestScore = 3;
|
||||||
|
bestMatch = { preset, score: 3 };
|
||||||
|
}
|
||||||
|
} else if (queryLower.includes(kwLower)) {
|
||||||
|
if (2 > bestScore) {
|
||||||
|
bestScore = 2;
|
||||||
|
bestMatch = { preset, score: 2 };
|
||||||
|
}
|
||||||
|
} else if (kwLower.includes(queryLower)) {
|
||||||
|
if (1 > bestScore) {
|
||||||
|
bestScore = 1;
|
||||||
|
bestMatch = { preset, score: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.saveUserPreset = function(presetObject) {
|
||||||
|
const index = this.presets.findIndex(p => p.id === presetObject.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.presets[index] = presetObject;
|
||||||
|
} else {
|
||||||
|
this.presets.push(presetObject);
|
||||||
|
}
|
||||||
|
this.savePresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.deletePreset = function(id) {
|
||||||
|
this.presets = this.presets.filter(p => p.id !== id);
|
||||||
|
this.savePresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.getContextualSuggestions = function(hasSelectedItem) {
|
||||||
|
if (hasSelectedItem) {
|
||||||
|
return this.presets.filter(p => p.category === 'Rearrange / Variation' || p.is_favorite);
|
||||||
|
} else {
|
||||||
|
return this.presets.filter(p => p.category !== 'Rearrange / Variation');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PromptTemplateManager.prototype.toggleFavorite = function(id) {
|
||||||
|
const preset = this.presets.find(p => p.id === id);
|
||||||
|
if (preset) {
|
||||||
|
preset.is_favorite = !preset.is_favorite;
|
||||||
|
if (preset.is_user_defined) {
|
||||||
|
this.savePresets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return PromptTemplateManager;
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.PromptTemplateManager = PromptTemplateManager;
|
||||||
|
window.DEFAULT_PRESETS = (new PromptTemplateManager()).getPresets();
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// SonicForge Runtime service — phát hiện môi trường chạy (desktop Windows /
|
||||||
|
// docker headless) qua /api/v1/system/capabilities, bật/tắt tính năng theo đó.
|
||||||
|
// - data-runtime trên <html>: "desktop" | "headless"
|
||||||
|
// - data-carla="1": có Carla local (hiện nút "Mở trong Carla")
|
||||||
|
// - Phần tử có thuộc tính data-carla-only sẽ bị ẩn khi không có Carla local.
|
||||||
|
// - Thư viện preset (.vstpreset) cache trong SonicRuntime.presets — dùng cho
|
||||||
|
// dropdown gán preset vào track (Carla → pedalboard bridge).
|
||||||
|
window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null, presets: null };
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
function getHeaders() {
|
||||||
|
const token = localStorage.getItem('sonic_token') || '';
|
||||||
|
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
return fetch(window.API_BASE_URL + '/api/v1/system/capabilities')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var c = data && data.success ? data : { features: {} };
|
||||||
|
window.SonicRuntime.capabilities = c;
|
||||||
|
window.SonicRuntime.loaded = true;
|
||||||
|
// environment: "docker" | "standalone" — quyết định luồng âm
|
||||||
|
// instrument: docker → FluidSynthWASM (client), standalone →
|
||||||
|
// native (backend pyfluidsynth / Carla). Không phải docker =
|
||||||
|
// standalone (Windows/Linux/macOS chạy trực tiếp trên OS).
|
||||||
|
window.SonicRuntime.environment = c.environment || (c.docker ? 'docker' : 'standalone');
|
||||||
|
var html = document.documentElement;
|
||||||
|
html.dataset.runtime = c.runtime || 'unknown';
|
||||||
|
html.dataset.platform = c.platform || '';
|
||||||
|
html.dataset.environment = window.SonicRuntime.environment;
|
||||||
|
html.dataset.carla = (c.features && c.features.carla_local) ? '1' : '0';
|
||||||
|
if (c.features && c.features.carla_local === false) {
|
||||||
|
document.querySelectorAll('[data-carla-only]').forEach(function (el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cache sẵn danh sách preset (static, ít thay đổi)
|
||||||
|
listPresets().catch(function () {});
|
||||||
|
return c;
|
||||||
|
})
|
||||||
|
.catch(function () { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPresets() {
|
||||||
|
if (window.SonicRuntime.presets) return Promise.resolve(window.SonicRuntime.presets);
|
||||||
|
return fetch(window.API_BASE_URL + '/api/v1/presets', { headers: getHeaders() })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
window.SonicRuntime.presets = (d && d.presets) || [];
|
||||||
|
return window.SonicRuntime.presets;
|
||||||
|
})
|
||||||
|
.catch(function () { return []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SonicRuntime.load = load;
|
||||||
|
window.SonicRuntime.listPresets = listPresets;
|
||||||
|
window.SonicRuntime.refreshPresets = function () {
|
||||||
|
window.SonicRuntime.presets = null;
|
||||||
|
return window.SonicRuntime.listPresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', load);
|
||||||
|
} else {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── SonicCarlaMidi: cầu nối MIDI từ track ARM → Carla (OSC /Carla/0/note_*) ──
|
||||||
|
// Khi track dùng VSTi (synth_engine.type chứa 'vst') + được ARM + máy có Carla
|
||||||
|
// local → phím bấm trên piano roll / keybed được gửi tới Carla để phát realtime
|
||||||
|
// (Carla standalone bật OSC UDP mặc định cổng 22752).
|
||||||
|
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||||
|
shouldRoute: function (synthEngine, isArmed) {
|
||||||
|
try {
|
||||||
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
|
if (!isArmed) return false;
|
||||||
|
var se = synthEngine || {};
|
||||||
|
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
},
|
||||||
|
// Playback MIDI items: route khi track VSTi + Carla local (không cần ARM —
|
||||||
|
// user đã chủ động bấm Play trên item đó).
|
||||||
|
shouldRoutePlayback: function (synthEngine) {
|
||||||
|
try {
|
||||||
|
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||||
|
if (!c || !c.features || !c.features.carla_local) return false;
|
||||||
|
var se = synthEngine || {};
|
||||||
|
return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
},
|
||||||
|
noteOn: function (channel, note, velocity) {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_on', note: note, velocity: velocity || 100, channel: channel || 0 }).catch(function () {});
|
||||||
|
},
|
||||||
|
noteOff: function (channel, note) {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_off', note: note, channel: channel || 0 }).catch(function () {});
|
||||||
|
},
|
||||||
|
// Bật note rồi tự tắt sau durMs (preview ngắn)
|
||||||
|
playNote: function (channel, note, velocity, durMs) {
|
||||||
|
this.noteOn(channel, note, velocity);
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50);
|
||||||
|
},
|
||||||
|
// Tắt mọi note đang ngân trong Carla (note_off toàn pitch — dùng khi stop)
|
||||||
|
allNotesOff: function () {
|
||||||
|
if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return;
|
||||||
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
|
for (var n = 0; n < 128; n++) {
|
||||||
|
window.SonicAPI.carlaMidi({ event: 'note_off', note: n, channel: ch }).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Unload Carla bridge: tắt âm + terminate tiến trình Carla (app spawn).
|
||||||
|
// Gọi khi track chuyển từ VSTi → instrument soundfont để âm KHÔNG còn
|
||||||
|
// play qua Carla bridge nữa.
|
||||||
|
stopBridge: function () {
|
||||||
|
var self = this;
|
||||||
|
try { self.allNotesOff(); } catch (e) {}
|
||||||
|
if (window.SonicAPI && window.SonicAPI.stopCarla) {
|
||||||
|
return window.SonicAPI.stopCarla().catch(function () {});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const RENDER_BLOCK = 512;
|
const RENDER_BLOCK = 512;
|
||||||
const QUEUE_TARGET = 4;
|
const QUEUE_TARGET = 16;
|
||||||
let _audioCtx = null;
|
let _audioCtx = null;
|
||||||
let _fluidModule = null;
|
let _fluidModule = null;
|
||||||
let _synthPtr = null;
|
let _synthPtr = null;
|
||||||
@@ -21,16 +21,48 @@
|
|||||||
let _pendingNoteTimers = [];
|
let _pendingNoteTimers = [];
|
||||||
let _loadedFonts = {};
|
let _loadedFonts = {};
|
||||||
let _activeOscillators = {};
|
let _activeOscillators = {};
|
||||||
|
// Fallback oscillator theo 'channel:pitch' — để stopNote/stopAll dừng được
|
||||||
|
// note khi thả phím (trước đây stopNote không dừng oscillator → âm kêu
|
||||||
|
// liên tục dù đã thả phím khi FluidSynth chưa sẵn sàng / font load fail).
|
||||||
|
let _activeOscillatorsByKey = {};
|
||||||
|
// Epoch chống cache channel STALE: tăng mỗi khi selectInstrument/load
|
||||||
|
// soundfont — channel cache chỉ được tin khi khớp epoch hiện tại, nếu
|
||||||
|
// không → note KẾ TIẾP luôn program_select lại (hết "chọn Synth String
|
||||||
|
// nghe Piano" do cache cũ skip program_select).
|
||||||
|
let _instrumentEpoch = 0;
|
||||||
let _gainNode = null;
|
let _gainNode = null;
|
||||||
|
let _pendingOutputDestination = null;
|
||||||
|
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
|
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||||
|
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||||
let _scheduledNotes = [];
|
let _scheduledNotes = [];
|
||||||
|
let _loadPromises = {};
|
||||||
|
let _sfloadSeq = 0;
|
||||||
|
// Note đang CHỜ load soundfont (chưa noteon) — 'ch:pitch' → số lần chờ.
|
||||||
|
// Đăng ký TRƯỚC khi load để stopNote/stopAll/panic hủy được note này;
|
||||||
|
// trước đây note deferred bắn TRỄ sau khi thả phím / sau Stop → âm loop
|
||||||
|
// không dừng (keybed preview âm soundfont).
|
||||||
|
let _pendingNoteOns = {};
|
||||||
|
// Tăng mỗi lần stopAll/panic — deferred doNote của generation cũ thành no-op.
|
||||||
|
let _noteGeneration = 0;
|
||||||
|
|
||||||
const getCtx = function () {
|
const getCtx = function () {
|
||||||
|
if (_audioCtx) {
|
||||||
|
if (!_gainNode) {
|
||||||
|
_gainNode = _audioCtx.createGain();
|
||||||
|
_gainNode.gain.value = 0.3;
|
||||||
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
|
}
|
||||||
|
return _audioCtx;
|
||||||
|
}
|
||||||
if (typeof getAudioContext === 'function') {
|
if (typeof getAudioContext === 'function') {
|
||||||
var ctx = getAudioContext();
|
var ctx = getAudioContext();
|
||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = ctx.createGain();
|
_gainNode = ctx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(ctx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -43,7 +75,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = window.__sharedAudioCtx.createGain();
|
_gainNode = window.__sharedAudioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return window.__sharedAudioCtx;
|
return window.__sharedAudioCtx;
|
||||||
};
|
};
|
||||||
@@ -51,6 +84,41 @@
|
|||||||
const SonicSF = {
|
const SonicSF = {
|
||||||
loadedFonts: _loadedFonts,
|
loadedFonts: _loadedFonts,
|
||||||
|
|
||||||
|
// Route the shared FluidSynth output through a per-track node (e.g. the
|
||||||
|
// track's gainNode) so the track's FX chain / fader / pan affect the
|
||||||
|
// soundfont instrument. Pass null to restore the default master-bus route.
|
||||||
|
setOutputDestination: function (node) {
|
||||||
|
try {
|
||||||
|
if (_gainNode) {
|
||||||
|
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||||
|
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||||
|
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||||
|
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||||
|
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||||
|
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||||
|
// ngay sau setOutputDestination lần 2).
|
||||||
|
if (dest === _outputDestination) return;
|
||||||
|
_gainNode.disconnect();
|
||||||
|
_gainNode.connect(dest);
|
||||||
|
// Reconnect VU analyser tap (app.jsx VU tick gắn __vuAnalyser
|
||||||
|
// trên _gainNode): disconnect() không đối số ngắt MỌI kết
|
||||||
|
// nối kể cả analyser → sfAudioPeak = 0 mãi → track VU MIDI
|
||||||
|
// không nhảy khi có âm (user bug 06:35).
|
||||||
|
if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);
|
||||||
|
_outputDestination = dest;
|
||||||
|
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||||
|
} else {
|
||||||
|
_pendingOutputDestination = node || null;
|
||||||
|
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SonicSF] setOutputDestination error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getOutputNode: function () {
|
||||||
|
return _gainNode;
|
||||||
|
},
|
||||||
|
|
||||||
init: async function (audioContext) {
|
init: async function (audioContext) {
|
||||||
if (_initialized && _fluidModule) return;
|
if (_initialized && _fluidModule) return;
|
||||||
if (_initPromise) return _initPromise;
|
if (_initPromise) return _initPromise;
|
||||||
@@ -67,17 +135,31 @@
|
|||||||
try {
|
try {
|
||||||
_audioCtx = audioContext;
|
_audioCtx = audioContext;
|
||||||
if (_audioCtx.state === 'suspended') await _audioCtx.resume();
|
if (_audioCtx.state === 'suspended') await _audioCtx.resume();
|
||||||
|
// Create gain node for master bus routing before any node connections
|
||||||
|
if (!_gainNode) {
|
||||||
|
_gainNode = _audioCtx.createGain();
|
||||||
|
_gainNode.gain.value = 0.3;
|
||||||
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||||
|
|
||||||
|
// ── 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;
|
var _useScriptNode = true;
|
||||||
try {
|
try {
|
||||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
|
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
|
||||||
console.log("[SonicSF] Worklet registered OK");
|
console.log("[SonicSF] Worklet registered OK (unused)");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[SonicSF] Worklet reg failed:", e);
|
console.warn("[SonicSF] Worklet reg failed:", e);
|
||||||
}
|
}
|
||||||
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
|
|
||||||
|
|
||||||
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
||||||
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
||||||
@@ -90,6 +172,11 @@
|
|||||||
},
|
},
|
||||||
TOTAL_MEMORY: TOTAL_MEMORY,
|
TOTAL_MEMORY: TOTAL_MEMORY,
|
||||||
printErr: function (msg) {
|
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);
|
console.warn('[FluidSynth:err]', msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -100,9 +187,9 @@
|
|||||||
|
|
||||||
_settingsPtr = _fluidModule._new_fluid_settings();
|
_settingsPtr = _fluidModule._new_fluid_settings();
|
||||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.sample-rate", _audioCtx.sampleRate || 44100);
|
_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_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_setint(_settingsPtr, "synth.ladspa.active", 0);
|
||||||
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
|
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
|
||||||
console.log("[SonicSF] FluidSynth settings configured");
|
console.log("[SonicSF] FluidSynth settings configured");
|
||||||
@@ -118,9 +205,17 @@
|
|||||||
|
|
||||||
if (!_useScriptNode) {
|
if (!_useScriptNode) {
|
||||||
try {
|
try {
|
||||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
|
// Force stereo output regardless of the device's
|
||||||
_workletNode.connect(_audioCtx.destination);
|
// channel count — FluidSynth renders stereo, and a
|
||||||
console.log("[SonicSF] AudioWorklet node connected");
|
// 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();
|
_startRenderLoop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[SonicSF] AudioWorkletNode failed:", e);
|
console.warn("[SonicSF] AudioWorkletNode failed:", e);
|
||||||
@@ -132,23 +227,37 @@
|
|||||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||||
|
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||||
|
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||||
|
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||||
|
// buffer + re-malloc khi đổi.
|
||||||
|
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
spn.onaudioprocess = function (e) {
|
spn.onaudioprocess = function (e) {
|
||||||
var left = e.outputBuffer.getChannelData(0);
|
var left = e.outputBuffer.getChannelData(0);
|
||||||
var right = e.outputBuffer.getChannelData(1);
|
var right = e.outputBuffer.getChannelData(1);
|
||||||
var sz = left.length;
|
var sz = left.length;
|
||||||
try {
|
try {
|
||||||
|
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||||
|
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||||
|
lp = _fluidModule._malloc(sz * 4);
|
||||||
|
rp = _fluidModule._malloc(sz * 4);
|
||||||
|
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
|
}
|
||||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||||
var hf = _fluidModule.HEAPF32;
|
var hf = _fluidModule.HEAPF32;
|
||||||
var lpb = lp >> 2, rpb = rp >> 2;
|
var lpb = lp >> 2, rpb = rp >> 2;
|
||||||
for (var si = 0; si < sz; si++) {
|
for (var si = 0; si < sz; si++) {
|
||||||
left[si] = hf[lpb + si];
|
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||||
right[si] = hf[rpb + si];
|
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||||
|
var L = hf[lpb + si], R = hf[rpb + si];
|
||||||
|
left[si] = isFinite(L) ? L : 0;
|
||||||
|
right[si] = isFinite(R) ? R : 0;
|
||||||
}
|
}
|
||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
};
|
};
|
||||||
spn.connect(_audioCtx.destination);
|
spn.connect(_gainNode);
|
||||||
_workletNode = spn;
|
_workletNode = spn;
|
||||||
console.log("[SonicSF] ScriptProcessorNode fallback active (buf:", spBufSz, ")");
|
console.log("[SonicSF] ScriptProcessorNode connected via gain (buf:", spBufSz, ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
@@ -172,11 +281,16 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_tryLoadSFL: function (buf, ext) {
|
_tryLoadSFL: function (buf, ext) {
|
||||||
var fname = '/' + ext + '_' + Date.now();
|
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
|
||||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||||
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
||||||
var cPath = this._allocCStr(fname);
|
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);
|
_fluidModule._free(cPath);
|
||||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||||
return handle;
|
return handle;
|
||||||
@@ -189,19 +303,64 @@
|
|||||||
_currentSfId = sfId;
|
_currentSfId = sfId;
|
||||||
return true;
|
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 {
|
try {
|
||||||
|
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
|
||||||
|
// thành rác trong khi channel state vẫn trỏ tới → program_select
|
||||||
|
// bị skip (progAlreadySet) → noteon trên handle đã unload →
|
||||||
|
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
|
||||||
|
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
|
||||||
|
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
|
||||||
var cache = window.SonicSFStorage;
|
var cache = window.SonicSFStorage;
|
||||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||||
if (!buf) {
|
if (buf) {
|
||||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
var cachedOk = this._tryLoadSFL(buf, '.sf3');
|
||||||
var resp = await fetch(url);
|
if (cachedOk === -1) cachedOk = this._tryLoadSFL(buf, '.sf2');
|
||||||
if (!resp.ok) {
|
if (cachedOk !== -1) {
|
||||||
|
_sfHandleMap.set(sfId, cachedOk);
|
||||||
|
_currentSfId = sfId;
|
||||||
|
_loadedFonts[sfId] = true;
|
||||||
|
_instrumentEpoch++;
|
||||||
|
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);
|
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf = await resp.arrayBuffer();
|
resp = resp2;
|
||||||
if (cache) await cache.saveBuffer(sfId, buf);
|
|
||||||
}
|
}
|
||||||
|
buf = await resp.arrayBuffer();
|
||||||
|
if (cache) await cache.saveBuffer(sfId, buf);
|
||||||
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
||||||
if (sfHandle === -1) {
|
if (sfHandle === -1) {
|
||||||
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
|
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
|
||||||
@@ -214,6 +373,7 @@
|
|||||||
_sfHandleMap.set(sfId, sfHandle);
|
_sfHandleMap.set(sfId, sfHandle);
|
||||||
_currentSfId = sfId;
|
_currentSfId = sfId;
|
||||||
_loadedFonts[sfId] = true;
|
_loadedFonts[sfId] = true;
|
||||||
|
_instrumentEpoch++;
|
||||||
console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle);
|
console.log("[SonicSF] SoundFont loaded:", sfId, "handle:", sfHandle);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -221,10 +381,9 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectInstrument: async function (channel, bank, program, sfId) {
|
selectInstrument: async function (channel, bank, program, sfId) {
|
||||||
if (!_initialized || !_fluidModule) {
|
if (!_initialized || !_fluidModule) {
|
||||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
var ctx = (typeof getAudioContext === 'function') ? getAudioContext() : new (window.AudioContext || window.webkitAudioContext)();
|
||||||
await this.init(ctx);
|
await this.init(ctx);
|
||||||
}
|
}
|
||||||
if (sfId) {
|
if (sfId) {
|
||||||
@@ -232,21 +391,29 @@
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
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);
|
var allocCh = this.allocateChannel(bank);
|
||||||
_engineChMap[engKey] = allocCh;
|
_engineChMap[engKey] = allocCh;
|
||||||
channel = allocCh;
|
channel = allocCh;
|
||||||
} else {
|
} else {
|
||||||
_engineChMap[engKey] = channel;
|
channel = _engineChMap[engKey];
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!_engineChMap[engKey]) {
|
||||||
channel = _engineChMap[engKey];
|
_engineChMap[engKey] = channel;
|
||||||
}
|
}
|
||||||
var sfHandle = _sfHandleMap.get(sfId);
|
var sfHandle = _sfHandleMap.get(sfId);
|
||||||
|
// ⚠️ FIX âm sai instrument: tăng epoch → channel cache cũ thành
|
||||||
|
// stale → note kế tiếp LUÔN program_select lại (hết "chọn Synth
|
||||||
|
// String nghe Piano" do cache cũ skip).
|
||||||
|
_instrumentEpoch++;
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program);
|
var _sr = _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, bank, program);
|
||||||
|
if (_sr !== 0) {
|
||||||
|
// Preset không tồn tại ở bank/program này → thử bank 0
|
||||||
|
try { _fluidModule._fluid_synth_program_select(_synthPtr, channel, sfHandle, 0, program); } catch (e2) {}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, channel, bank); } catch (e) {}
|
||||||
@@ -255,6 +422,8 @@
|
|||||||
_channels[channel].bank = bank;
|
_channels[channel].bank = bank;
|
||||||
_channels[channel].program = program;
|
_channels[channel].program = program;
|
||||||
_channels[channel].isPercussion = (bank === 128);
|
_channels[channel].isPercussion = (bank === 128);
|
||||||
|
_channels[channel].sfId = sfId;
|
||||||
|
_channels[channel]._epoch = _instrumentEpoch;
|
||||||
},
|
},
|
||||||
|
|
||||||
controllerChange: function (channel, controller, value) {
|
controllerChange: function (channel, controller, value) {
|
||||||
@@ -326,8 +495,28 @@
|
|||||||
|
|
||||||
stopNote: function (channel, pitch) {
|
stopNote: function (channel, pitch) {
|
||||||
if (channel < 0 || channel > 15) return;
|
if (channel < 0 || channel > 15) return;
|
||||||
|
var key = channel + ':' + pitch;
|
||||||
|
// Hủy note đang CHỜ LOAD soundfont (chưa noteon) — trước đây note
|
||||||
|
// này vẫn bắn TRỄ sau khi thả phím → âm loop không dừng được.
|
||||||
|
if (_pendingNoteOns[key]) delete _pendingNoteOns[key];
|
||||||
|
// ⚠️ FIX: dừng fallback oscillator theo 'channel:pitch' — trước đây
|
||||||
|
// stopNote chỉ noteoff FluidSynth, KHÔNG dừng oscillator (khi WASM
|
||||||
|
// chưa sẵn sàng / font load fail → _playNoteFallback) → âm kêu liên
|
||||||
|
// tục mặc dù đã thả phím (không xử lí noteoff).
|
||||||
|
var _oscIds = _activeOscillatorsByKey[key];
|
||||||
|
if (_oscIds && _oscIds.length) {
|
||||||
|
var _nowT = getCtx().currentTime;
|
||||||
|
_oscIds.slice().forEach(function (oid) {
|
||||||
|
var entry = _activeOscillators[oid];
|
||||||
|
if (entry) {
|
||||||
|
try { entry.gain.gain.cancelScheduledValues(_nowT); entry.gain.gain.setValueAtTime(0, _nowT); } catch (e) {}
|
||||||
|
try { entry.osc.stop(_nowT); } catch (e) {}
|
||||||
|
delete _activeOscillators[oid];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
delete _activeOscillatorsByKey[key];
|
||||||
|
}
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
var key = channel + ':' + pitch;
|
|
||||||
var mappedChs = _activeNotes[key];
|
var mappedChs = _activeNotes[key];
|
||||||
if (mappedChs === undefined) mappedChs = [channel];
|
if (mappedChs === undefined) mappedChs = [channel];
|
||||||
for (var i = 0; i < mappedChs.length; i++) {
|
for (var i = 0; i < mappedChs.length; i++) {
|
||||||
@@ -350,17 +539,18 @@
|
|||||||
if (_initialized && _fluidModule) return;
|
if (_initialized && _fluidModule) return;
|
||||||
if (_initPromise) return;
|
if (_initPromise) return;
|
||||||
try {
|
try {
|
||||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
var ctx = (typeof getAudioContext === 'function') ? getAudioContext() : new (window.AudioContext || window.webkitAudioContext)();
|
||||||
if (ctx.state === 'suspended') await ctx.resume();
|
if (ctx.state === 'suspended') await ctx.resume();
|
||||||
await this.init(ctx);
|
await this.init(ctx);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
var parsedPitch = parseInt(note);
|
||||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
|
||||||
)));
|
if (isNaN(rawVel)) rawVel = 100;
|
||||||
|
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
|
||||||
var _origChannel = channel;
|
var _origChannel = channel;
|
||||||
var usedBank = 0, usedProg = 0;
|
var usedBank = 0, usedProg = 0;
|
||||||
if (synthEngine) {
|
if (synthEngine) {
|
||||||
@@ -393,24 +583,123 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var doNote = function () {
|
var doNote = function () {
|
||||||
try {
|
try {
|
||||||
|
var finalBank = parseInt(usedBank);
|
||||||
|
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||||
|
var finalProg = parseInt(usedProg);
|
||||||
|
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||||
|
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||||
|
var cachedCh = _channels[ch];
|
||||||
|
// The note's own synth engine (track instrument) is
|
||||||
|
// 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);
|
||||||
|
// Đăng ký note CHỜ LOAD trước khi load — để stopNote (thả
|
||||||
|
// phím) / stopAll / panic hủy được note này. Trước đây
|
||||||
|
// note deferred vẫn bắn TRỄ sau khi thả phím hoặc sau
|
||||||
|
// Stop → âm loop không dừng với preview MIDI Keyboard.
|
||||||
|
var _pendKey = ch + ':' + midiPitch;
|
||||||
|
var _gen = _noteGeneration;
|
||||||
|
_pendingNoteOns[_pendKey] = (_pendingNoteOns[_pendKey] || 0) + 1;
|
||||||
|
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||||
|
// stopAll/panic chạy trong lúc load → generation đổi → bỏ
|
||||||
|
if (_gen !== _noteGeneration) return;
|
||||||
|
// stopNote (thả phím) đã hủy → KHÔNG bắn note trễ nữa
|
||||||
|
var _pend = _pendingNoteOns[_pendKey];
|
||||||
|
if (_pend) {
|
||||||
|
_pendingNoteOns[_pendKey] = _pend - 1;
|
||||||
|
if (_pendingNoteOns[_pendKey] <= 0) delete _pendingNoteOns[_pendKey];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||||
|
if (ok) {
|
||||||
|
doNote();
|
||||||
|
} else {
|
||||||
|
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||||
|
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||||
|
_sfLoadFailAt[finalSfId] = Date.now();
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Program change at note time, not call time — ensures correct
|
// Program change at note time, not call time — ensures correct
|
||||||
// instrument for each item regardless of processing order.
|
// instrument for each item regardless of processing order.
|
||||||
if (synthEngine) {
|
// Skip if the channel already has this exact instrument (avoids
|
||||||
var sfHandle = synthEngine.soundfont_id ? _sfHandleMap.get(synthEngine.soundfont_id) : undefined;
|
// 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
|
||||||
|
&& cachedCh._epoch === _instrumentEpoch
|
||||||
|
&& (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) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, usedBank, usedProg);
|
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||||
|
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||||
|
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||||
|
// định → font không có → cache channel = (128,0)
|
||||||
|
// INVALID → note sau skip re-select (progAlreadySet)
|
||||||
|
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||||
|
if (finalBank === 128) {
|
||||||
|
var _vKey = finalSfId || ('h' + sfHandle);
|
||||||
|
if (_validPercCache[_vKey] === undefined) {
|
||||||
|
var _found = null;
|
||||||
|
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||||
|
var _bk = _b === 0 ? 128 : 0;
|
||||||
|
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||||
|
try {
|
||||||
|
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||||
|
_found = [_bk, _p];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validPercCache[_vKey] = _found;
|
||||||
|
}
|
||||||
|
if (_validPercCache[_vKey]) {
|
||||||
|
finalBank = _validPercCache[_vKey][0];
|
||||||
|
finalProg = _validPercCache[_vKey][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||||
|
if (_selRet !== 0 && finalBank !== 0) {
|
||||||
|
// Preset không tồn tại ở bank này → thử bank 0
|
||||||
|
// (hết "noteon preset rỗng = âm sai/không đúng")
|
||||||
|
try { _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, 0, finalProg); } catch (e2) {}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, usedBank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||||
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg); } catch (e) {}
|
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } catch (e) {}
|
||||||
}
|
}
|
||||||
} else if (program !== undefined) {
|
if (!_channels[ch]) _channels[ch] = {};
|
||||||
try {
|
_channels[ch].bank = finalBank;
|
||||||
_fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg);
|
_channels[ch].program = finalProg;
|
||||||
} catch (e) {}
|
_channels[ch].sfId = finalSfId;
|
||||||
|
_channels[ch]._epoch = _instrumentEpoch;
|
||||||
}
|
}
|
||||||
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);
|
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||||
@@ -426,7 +715,7 @@
|
|||||||
if (arr.length === 0) delete _activeNotes[noteMapKey];
|
if (arr.length === 0) delete _activeNotes[noteMapKey];
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, durSec * 1000);
|
}, durationMs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[SonicSF] FluidSynth noteOn error:", e);
|
console.warn("[SonicSF] FluidSynth noteOn error:", e);
|
||||||
@@ -441,6 +730,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
|
||||||
|
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
|
||||||
|
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
|
||||||
|
// tự động → ngân vô hạn → VU master nhảy dù không play).
|
||||||
|
panic: function () {
|
||||||
|
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||||
|
_scheduledNotes = [];
|
||||||
|
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành no-op
|
||||||
|
_noteGeneration++;
|
||||||
|
_pendingNoteOns = {};
|
||||||
|
if (_initialized && _fluidModule) {
|
||||||
|
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
|
||||||
|
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
|
||||||
|
// thể không có trong WASM exports (catch nuốt → notes kẹt).
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// Dừng fallback oscillator (FluidSynth chưa sẵn sàng / font fail)
|
||||||
|
try {
|
||||||
|
var _pctx = getCtx();
|
||||||
|
var _pnow = _pctx.currentTime;
|
||||||
|
Object.values(_activeOscillators).forEach(function (entry) {
|
||||||
|
try { if (entry.gain) { entry.gain.gain.cancelScheduledValues(_pnow); entry.gain.gain.setValueAtTime(0, _pnow); } } catch (e) {}
|
||||||
|
try { if (entry.osc) entry.osc.stop(_pnow); } catch (e) {}
|
||||||
|
});
|
||||||
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeOscillatorsByKey = {};
|
||||||
|
} catch (e) {}
|
||||||
|
_activeNotes = {};
|
||||||
|
},
|
||||||
|
|
||||||
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||||
var ctx = getCtx();
|
var ctx = getCtx();
|
||||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||||
@@ -457,7 +785,11 @@
|
|||||||
var oscType = 'triangle';
|
var oscType = 'triangle';
|
||||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||||
var prog = program !== undefined ? parseInt(program) : 0;
|
var prog = program !== undefined ? parseInt(program) : 0;
|
||||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||||
|
// truyền — trước đây override program của track bằng cache channel
|
||||||
|
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||||
|
// nhạc cụ của track TRƯỚC).
|
||||||
|
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||||
prog = _channels[channel].program || prog;
|
prog = _channels[channel].program || prog;
|
||||||
}
|
}
|
||||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||||
@@ -475,7 +807,8 @@
|
|||||||
osc.frequency.setValueAtTime(freq, 0);
|
osc.frequency.setValueAtTime(freq, 0);
|
||||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||||
var durSec = durationMs / 1000;
|
var durSec = durationMs / 1000;
|
||||||
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||||
|
if (isNaN(vel)) vel = 0.8;
|
||||||
var targetGain = vel * volFactor;
|
var targetGain = vel * volFactor;
|
||||||
noteGain.gain.setValueAtTime(0, startAt);
|
noteGain.gain.setValueAtTime(0, startAt);
|
||||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||||
@@ -490,15 +823,40 @@
|
|||||||
osc.stop(stopAt);
|
osc.stop(stopAt);
|
||||||
var oscId = note + '_' + Date.now() + '_' + Math.random();
|
var oscId = note + '_' + Date.now() + '_' + Math.random();
|
||||||
_activeOscillators[oscId] = { osc: osc, gain: noteGain };
|
_activeOscillators[oscId] = { osc: osc, gain: noteGain };
|
||||||
setTimeout(function () { delete _activeOscillators[oscId]; }, (stopAt - ctx.currentTime) * 1000 + 100);
|
// Đăng ký theo 'channel:pitch' để stopNote dừng được khi thả phím
|
||||||
|
var oscKey = (channel !== undefined && channel >= 0 && channel < 16) ? (channel + ':' + note) : null;
|
||||||
|
if (oscKey) {
|
||||||
|
if (!_activeOscillatorsByKey[oscKey]) _activeOscillatorsByKey[oscKey] = [];
|
||||||
|
_activeOscillatorsByKey[oscKey].push(oscId);
|
||||||
|
}
|
||||||
|
setTimeout(function () {
|
||||||
|
delete _activeOscillators[oscId];
|
||||||
|
if (oscKey && _activeOscillatorsByKey[oscKey]) {
|
||||||
|
var _oi = _activeOscillatorsByKey[oscKey].indexOf(oscId);
|
||||||
|
if (_oi >= 0) _activeOscillatorsByKey[oscKey].splice(_oi, 1);
|
||||||
|
if (_activeOscillatorsByKey[oscKey].length === 0) delete _activeOscillatorsByKey[oscKey];
|
||||||
|
}
|
||||||
|
}, (stopAt - ctx.currentTime) * 1000 + 100);
|
||||||
return osc;
|
return osc;
|
||||||
},
|
},
|
||||||
|
|
||||||
stopAll: function () {
|
stopAll: function () {
|
||||||
|
// Hủy mọi note đang CHỜ LOAD soundfont (deferred) — note cũ thành
|
||||||
|
// no-op sau Stop (âm loop không dừng khi preview MIDI Keyboard).
|
||||||
|
_noteGeneration++;
|
||||||
|
_pendingNoteOns = {};
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
|
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
||||||
|
// phòng all_notes_off không có trong WASM exports.
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
for (var ch = 0; ch < 16; ch++) {
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||||
try { _fluidModule._fluid_synth_all_sounds_off(_synthPtr, ch); } catch (e) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (_scheduledNotes.length > 0) {
|
while (_scheduledNotes.length > 0) {
|
||||||
@@ -515,6 +873,8 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeOscillatorsByKey = {};
|
||||||
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||||
@@ -539,10 +899,13 @@
|
|||||||
var leftPtr = _leftBufPtr;
|
var leftPtr = _leftBufPtr;
|
||||||
var rightPtr = _rightBufPtr;
|
var rightPtr = _rightBufPtr;
|
||||||
var block = RENDER_BLOCK;
|
var block = RENDER_BLOCK;
|
||||||
var queueDepth = 0;
|
|
||||||
var maxQueue = QUEUE_TARGET;
|
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() {
|
function pushFrame() {
|
||||||
if (!Module || !synth || !node) return;
|
if (!Module || !synth || !node) return;
|
||||||
try {
|
try {
|
||||||
@@ -553,18 +916,6 @@
|
|||||||
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
|
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
|
||||||
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
|
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
|
||||||
var rightArr = new Float32Array(Module.HEAPF32.subarray(rpb, rpb + 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]);
|
node.port.postMessage({ type: 'PCM', L: leftArr, R: rightArr }, [leftArr.buffer, rightArr.buffer]);
|
||||||
queueDepth++;
|
queueDepth++;
|
||||||
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
|
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
|
||||||
@@ -575,14 +926,16 @@
|
|||||||
_renderTimer = null;
|
_renderTimer = null;
|
||||||
return;
|
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++) {
|
for (var i = 0; i < needed; i++) {
|
||||||
pushFrame();
|
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() {
|
function _stopRenderLoop() {
|
||||||
|
|||||||
@@ -21,7 +21,11 @@
|
|||||||
solo: t.solo,
|
solo: t.solo,
|
||||||
color: t.color,
|
color: t.color,
|
||||||
markers: t.markers || [],
|
markers: t.markers || [],
|
||||||
serverFileId: t.serverFileId || null
|
serverFileId: t.serverFileId || null,
|
||||||
|
clips: t.clips || [],
|
||||||
|
sections: t.sections || [],
|
||||||
|
midiItems: t.midiItems || [],
|
||||||
|
channelInfo: t.channelInfo || null
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,26 +60,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
|
let lastGetProjectStateCallback = null;
|
||||||
|
// ⚠️ FIX (Bug 3): hash-skip — nếu state serialize GIỐNG HỆT bản đã lưu
|
||||||
|
// thì KHÔNG ghi lại (server + localStorage). Chặn ping-pong realtime sync:
|
||||||
|
// client B apply state của A rồi autosave lại → server bump updated_at →
|
||||||
|
// A tưởng mới → apply → ... vô hạn. Bản giống hệt → bỏ qua → hội tụ.
|
||||||
|
let lastSavedJson = '';
|
||||||
|
function getClientId() {
|
||||||
|
try {
|
||||||
|
let c = localStorage.getItem('sonic_client_id');
|
||||||
|
if (!c) {
|
||||||
|
c = 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
|
||||||
|
localStorage.setItem('sonic_client_id', c);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||||
|
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||||
autoSaveTimer = setTimeout(async () => {
|
autoSaveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const state = getProjectStateCallback();
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
if (!state || (!state.tracks && !state.main_session)) return;
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
const dataJson = JSON.stringify(state);
|
const dataJson = JSON.stringify(state);
|
||||||
|
if (dataJson === lastSavedJson) return; // không đổi → không ghi
|
||||||
|
lastSavedJson = dataJson;
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Auto-save temp project warning:", e);
|
console.warn("Auto-save temp project warning:", e);
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
|
||||||
|
async function flushTempAutoSave() {
|
||||||
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
||||||
|
try {
|
||||||
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
|
const dataJson = JSON.stringify(state);
|
||||||
|
if (dataJson === lastSavedJson) return; // không đổi → không ghi
|
||||||
|
lastSavedJson = dataJson;
|
||||||
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
|
await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Flush temp project warning:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.SonicStorage = {
|
window.SonicStorage = {
|
||||||
exportProjectToSFS,
|
exportProjectToSFS,
|
||||||
importProjectFromSFSFile,
|
importProjectFromSFSFile,
|
||||||
scheduleTempAutoSave
|
scheduleTempAutoSave,
|
||||||
|
flushTempAutoSave,
|
||||||
|
getClientId
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// SonicForge Studio - Unified Undo/Redo Engine
|
||||||
|
// Handles all undoable actions across MAIN SESSION and SECTION-TAB
|
||||||
|
|
||||||
|
const UndoRedoEngine = (function() {
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
const history = [];
|
||||||
|
let historyIndex = -1;
|
||||||
|
|
||||||
|
function push(entry) {
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
historyIndex = history.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
if (historyIndex < 0) return null;
|
||||||
|
const entry = history[historyIndex];
|
||||||
|
historyIndex--;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (historyIndex >= history.length - 1) return null;
|
||||||
|
historyIndex++;
|
||||||
|
const entry = history[historyIndex];
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUndo() { return historyIndex >= 0; }
|
||||||
|
function canRedo() { return historyIndex < history.length - 1; }
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
history.length = 0;
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(entry) {
|
||||||
|
// entry: { type, scope, label, before, after, undo, redo }
|
||||||
|
// Trim future history if we're not at the end
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
history.splice(historyIndex + 1);
|
||||||
|
}
|
||||||
|
push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return {
|
||||||
|
canUndo: canUndo(),
|
||||||
|
canRedo: canRedo(),
|
||||||
|
undoCount: historyIndex + 1,
|
||||||
|
redoCount: history.length - historyIndex - 1,
|
||||||
|
lastAction: history[historyIndex]?.type || null,
|
||||||
|
lastLabel: history[historyIndex]?.label || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
push,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
clear,
|
||||||
|
execute,
|
||||||
|
getStatus,
|
||||||
|
history,
|
||||||
|
historyIndex
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.UndoRedoEngine = UndoRedoEngine;
|
||||||
@@ -15,29 +15,28 @@ class FluidSynthBridge extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
process(inputs, outputs) {
|
process(inputs, outputs) {
|
||||||
const out = outputs[0];
|
const out = outputs[0];
|
||||||
if (!out) return true;
|
if (!out || out.length === 0) return true;
|
||||||
this.called++;
|
this.called++;
|
||||||
|
const numCh = out.length;
|
||||||
const len = out[0].length;
|
const len = out[0].length;
|
||||||
const qL = this.leftQ;
|
const qL = this.leftQ;
|
||||||
const qR = this.rightQ;
|
const qR = this.rightQ;
|
||||||
let fi = 0;
|
let fi = 0;
|
||||||
let si = 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++) {
|
for (let i = 0; i < len; i++) {
|
||||||
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
|
if (fi >= qL.length) {
|
||||||
out[0][i] = qL[fi][si];
|
for (let c = 0; c < numCh; c++) out[c][i] = 0;
|
||||||
out[1][i] = qR[fi][si];
|
continue;
|
||||||
|
}
|
||||||
|
for (let c = 0; c < numCh; c++) {
|
||||||
|
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
|
||||||
|
}
|
||||||
si++;
|
si++;
|
||||||
if (si >= qL[fi].length) { fi++; si = 0; }
|
if (si >= qL[fi].length) { fi++; si = 0; }
|
||||||
}
|
}
|
||||||
if (fi > 0) { this.leftQ.splice(0, fi); this.rightQ.splice(0, fi); }
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
File diff suppressed because one or more lines are too long
Vendored
+12
File diff suppressed because one or more lines are too long
+267
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
(function(){/*
|
||||||
|
Modernizr 3.0.0pre (Custom Build) | MIT
|
||||||
|
*/
|
||||||
|
'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;c<arguments.length;c++)b+="&args[]="+encodeURIComponent(arguments[c]);return"Minified React error #"+a+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}
|
||||||
|
function mb(a,b){Ab(a,b);Ab(a+"Capture",b)}function Ab(a,b){$b[a]=b;for(a=0;a<b.length;a++)cg.add(b[a])}function bj(a){if(Zd.call(dg,a))return!0;if(Zd.call(eg,a))return!1;if(cj.test(a))return dg[a]=!0;eg[a]=!0;return!1}function dj(a,b,c,d){if(null!==c&&0===c.type)return!1;switch(typeof b){case "function":case "symbol":return!0;case "boolean":if(d)return!1;if(null!==c)return!c.acceptsBooleans;a=a.toLowerCase().slice(0,5);return"data-"!==a&&"aria-"!==a;default:return!1}}function ej(a,b,c,d){if(null===
|
||||||
|
b||"undefined"===typeof b||dj(a,b,c,d))return!0;if(d)return!1;if(null!==c)switch(c.type){case 3:return!b;case 4:return!1===b;case 5:return isNaN(b);case 6:return isNaN(b)||1>b}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2<b.length)||"o"!==
|
||||||
|
b[0]&&"O"!==b[0]||"n"!==b[1]&&"N"!==b[1])ej(b,c,e,d)&&(c=null),d||null===e?bj(b)&&(null===c?a.removeAttribute(b):a.setAttribute(b,""+c)):e.mustUseProperty?a[e.propertyName]=null===c?3===e.type?!1:"":c:(b=e.attributeName,d=e.attributeNamespace,null===c?a.removeAttribute(b):(e=e.type,c=3===e||4===e&&!0===c?"":""+c,d?a.setAttributeNS(d,b,c):a.setAttribute(b,c)))}function ac(a){if(null===a||"object"!==typeof a)return null;a=fg&&a[fg]||a["@@iterator"];return"function"===typeof a?a:null}function bc(a,b,
|
||||||
|
c){if(void 0===ae)try{throw Error();}catch(d){ae=(b=d.stack.trim().match(/\n( *(at )?)/))&&b[1]||""}return"\n"+ae+a}function be(a,b){if(!a||ce)return"";ce=!0;var c=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(b)if(b=function(){throw Error();},Object.defineProperty(b.prototype,"props",{set:function(){throw Error();}}),"object"===typeof Reflect&&Reflect.construct){try{Reflect.construct(b,[])}catch(n){var d=n}Reflect.construct(a,[],b)}else{try{b.call()}catch(n){d=n}a.call(b.prototype)}else{try{throw Error();
|
||||||
|
}catch(n){d=n}a()}}catch(n){if(n&&d&&"string"===typeof n.stack){for(var e=n.stack.split("\n"),f=d.stack.split("\n"),g=e.length-1,h=f.length-1;1<=g&&0<=h&&e[g]!==f[h];)h--;for(;1<=g&&0<=h;g--,h--)if(e[g]!==f[h]){if(1!==g||1!==h){do if(g--,h--,0>h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("<anonymous>")&&(k=k.replace("<anonymous>",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a):
|
||||||
|
""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode";
|
||||||
|
case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type;
|
||||||
|
switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";
|
||||||
|
case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===
|
||||||
|
b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker=
|
||||||
|
null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0,
|
||||||
|
value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=
|
||||||
|
c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;
|
||||||
|
c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e<c.length;e++)b["$"+c[e]]=!0;for(c=0;c<a.length;c++)e=b.hasOwnProperty("$"+a[c].value),a[c].selected!==e&&(a[c].selected=e),e&&d&&(a[c].defaultSelected=
|
||||||
|
!0)}else{c=""+Ua(c);b=null;for(e=0;e<a.length;e++){if(a[e].value===c){a[e].selected=!0;d&&(a[e].defaultSelected=!0);return}null!==b||a[e].disabled||(b=a[e])}null!==b&&(b.selected=!0)}}function ne(a,b){if(null!=b.dangerouslySetInnerHTML)throw Error(m(91));return E({},b,{value:void 0,defaultValue:void 0,children:""+a._wrapperState.initialValue})}function ng(a,b){var c=b.value;if(null==c){c=b.children;b=b.defaultValue;if(null!=c){if(null!=b)throw Error(m(92));if(cc(c)){if(1<c.length)throw Error(m(93));
|
||||||
|
c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:Ua(c)}}function og(a,b){var c=Ua(b.value),d=Ua(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function pg(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b)}function qg(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}
|
||||||
|
function oe(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?qg(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function rg(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||dc.hasOwnProperty(a)&&dc[a]?(""+b).trim():b+"px"}function sg(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rg(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}function pe(a,b){if(b){if(ij[a]&&
|
||||||
|
(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(m(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(m(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(m(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(m(62));}}function qe(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;
|
||||||
|
default:return!0}}function re(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}function tg(a){if(a=ec(a)){if("function"!==typeof se)throw Error(m(280));var b=a.stateNode;b&&(b=Rc(b),se(a.stateNode,a.type,b))}}function ug(a){Eb?Fb?Fb.push(a):Fb=[a]:Eb=a}function vg(){if(Eb){var a=Eb,b=Fb;Fb=Eb=null;tg(a);if(b)for(a=0;a<b.length;a++)tg(b[a])}}function wg(a,b,c){if(te)return a(b,c);te=!0;try{return xg(a,b,c)}finally{if(te=
|
||||||
|
!1,null!==Eb||null!==Fb)yg(),vg()}}function fc(a,b){var c=a.stateNode;if(null===c)return null;var d=Rc(c);if(null===d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;
|
||||||
|
if(c&&"function"!==typeof c)throw Error(m(231,b,typeof c));return c}function jj(a,b,c,d,e,f,g,h,k){gc=!1;Sc=null;kj.apply(lj,arguments)}function mj(a,b,c,d,e,f,g,h,k){jj.apply(this,arguments);if(gc){if(gc){var n=Sc;gc=!1;Sc=null}else throw Error(m(198));Tc||(Tc=!0,ue=n)}}function nb(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.flags&4098)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function zg(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,
|
||||||
|
null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Ag(a){if(nb(a)!==a)throw Error(m(188));}function nj(a){var b=a.alternate;if(!b){b=nb(a);if(null===b)throw Error(m(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Ag(e),a;if(f===d)return Ag(e),b;f=f.sibling}throw Error(m(188));}if(c.return!==d.return)c=e,d=f;
|
||||||
|
else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h=f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(m(189));}}if(c.alternate!==d)throw Error(m(190));}if(3!==c.tag)throw Error(m(188));return c.stateNode.current===c?a:b}function Bg(a){a=nj(a);return null!==a?Cg(a):null}function Cg(a){if(5===a.tag||6===a.tag)return a;for(a=a.child;null!==a;){var b=Cg(a);if(null!==b)return b;a=a.sibling}return null}
|
||||||
|
function oj(a,b){if(Ca&&"function"===typeof Ca.onCommitFiberRoot)try{Ca.onCommitFiberRoot(Uc,a,void 0,128===(a.current.flags&128))}catch(c){}}function pj(a){a>>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&
|
||||||
|
4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&
|
||||||
|
(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0<b;)c=31-ta(b),e=1<<c,d|=a[c],b&=~e;return d}function sj(a,b){switch(a){case 1:case 2:case 4:return b+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return b+5E3;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return-1;
|
||||||
|
case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tj(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes;0<f;){var g=31-ta(f),h=1<<g,k=e[g];if(-1===k){if(0===(h&c)||0!==(h&d))e[g]=sj(h,b)}else k<=b&&(a.expiredLanes|=h);f&=~h}}function ve(a){a=a.pendingLanes&-1073741825;return 0!==a?a:a&1073741824?1073741824:0}function Dg(){var a=Wc;Wc<<=1;0===(Wc&4194240)&&(Wc=64);return a}function we(a){for(var b=[],c=0;31>c;c++)b.push(a);
|
||||||
|
return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0<c;){var e=31-ta(c),f=1<<e;b[e]=0;d[e]=-1;a[e]=-1;c&=~f}}function xe(a,b){var c=a.entangledLanes|=b;for(a=a.entanglements;c;){var d=31-ta(c),e=1<<d;e&b|a[d]&
|
||||||
|
b&&(a[d]|=b);c&=~e}}function Eg(a){a&=-a;return 1<a?4<a?0!==(a&268435455)?16:536870912:4:1}function Fg(a,b){switch(a){case "focusin":case "focusout":Va=null;break;case "dragenter":case "dragleave":Wa=null;break;case "mouseover":case "mouseout":Xa=null;break;case "pointerover":case "pointerout":jc.delete(b.pointerId);break;case "gotpointercapture":case "lostpointercapture":kc.delete(b.pointerId)}}function lc(a,b,c,d,e,f){if(null===a||a.nativeEvent!==f)return a={blockedOn:b,domEventName:c,eventSystemFlags:d,
|
||||||
|
nativeEvent:f,targetContainers:[e]},null!==b&&(b=ec(b),null!==b&&Gg(b)),a;a.eventSystemFlags|=d;b=a.targetContainers;null!==e&&-1===b.indexOf(e)&&b.push(e);return a}function vj(a,b,c,d,e){switch(b){case "focusin":return Va=lc(Va,a,b,c,d,e),!0;case "dragenter":return Wa=lc(Wa,a,b,c,d,e),!0;case "mouseover":return Xa=lc(Xa,a,b,c,d,e),!0;case "pointerover":var f=e.pointerId;jc.set(f,lc(jc.get(f)||null,a,b,c,d,e));return!0;case "gotpointercapture":return f=e.pointerId,kc.set(f,lc(kc.get(f)||null,a,b,
|
||||||
|
c,d,e)),!0}return!1}function Hg(a){var b=ob(a.target);if(null!==b){var c=nb(b);if(null!==c)if(b=c.tag,13===b){if(b=zg(c),null!==b){a.blockedOn=b;wj(a.priority,function(){xj(c)});return}}else if(3===b&&c.stateNode.current.memoizedState.isDehydrated){a.blockedOn=3===c.tag?c.stateNode.containerInfo:null;return}}a.blockedOn=null}function Xc(a){if(null!==a.blockedOn)return!1;for(var b=a.targetContainers;0<b.length;){var c=ye(a.domEventName,a.eventSystemFlags,b[0],a.nativeEvent);if(null===c){c=a.nativeEvent;
|
||||||
|
var d=new c.constructor(c.type,c);ze=d;c.target.dispatchEvent(d);ze=null}else return b=ec(c),null!==b&&Gg(b),a.blockedOn=c,!1;b.shift()}return!0}function Ig(a,b,c){Xc(a)&&c.delete(b)}function yj(){Ae=!1;null!==Va&&Xc(Va)&&(Va=null);null!==Wa&&Xc(Wa)&&(Wa=null);null!==Xa&&Xc(Xa)&&(Xa=null);jc.forEach(Ig);kc.forEach(Ig)}function mc(a,b){a.blockedOn===b&&(a.blockedOn=null,Ae||(Ae=!0,Jg(Kg,yj)))}function nc(a){if(0<Yc.length){mc(Yc[0],a);for(var b=1;b<Yc.length;b++){var c=Yc[b];c.blockedOn===a&&(c.blockedOn=
|
||||||
|
null)}}null!==Va&&mc(Va,a);null!==Wa&&mc(Wa,a);null!==Xa&&mc(Xa,a);b=function(b){return mc(b,a)};jc.forEach(b);kc.forEach(b);for(b=0;b<Ya.length;b++)c=Ya[b],c.blockedOn===a&&(c.blockedOn=null);for(;0<Ya.length&&(b=Ya[0],null===b.blockedOn);)Hg(b),null===b.blockedOn&&Ya.shift()}function zj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=1,Be(a,b,c,d)}finally{z=e,Gb.transition=f}}function Aj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=4,Be(a,b,c,d)}finally{z=e,Gb.transition=
|
||||||
|
f}}function Be(a,b,c,d){if(Zc){var e=ye(a,b,c,d);if(null===e)Ce(a,b,d,$c,c),Fg(a,d);else if(vj(e,a,b,c,d))d.stopPropagation();else if(Fg(a,d),b&4&&-1<Bj.indexOf(a)){for(;null!==e;){var f=ec(e);null!==f&&Cj(f);f=ye(a,b,c,d);null===f&&Ce(a,b,d,$c,c);if(f===e)break;e=f}null!==e&&d.stopPropagation()}else Ce(a,b,d,null,c)}}function ye(a,b,c,d){$c=null;a=re(d);a=ob(a);if(null!==a)if(b=nb(a),null===b)a=null;else if(c=b.tag,13===c){a=zg(b);if(null!==a)return a;a=null}else if(3===c){if(b.stateNode.current.memoizedState.isDehydrated)return 3===
|
||||||
|
b.tag?b.stateNode.containerInfo:null;a=null}else b!==a&&(a=null);$c=a;return null}function Lg(a){switch(a){case "cancel":case "click":case "close":case "contextmenu":case "copy":case "cut":case "auxclick":case "dblclick":case "dragend":case "dragstart":case "drop":case "focusin":case "focusout":case "input":case "invalid":case "keydown":case "keypress":case "keyup":case "mousedown":case "mouseup":case "paste":case "pause":case "play":case "pointercancel":case "pointerdown":case "pointerup":case "ratechange":case "reset":case "resize":case "seeked":case "submit":case "touchcancel":case "touchend":case "touchstart":case "volumechange":case "change":case "selectionchange":case "textInput":case "compositionstart":case "compositionend":case "compositionupdate":case "beforeblur":case "afterblur":case "beforeinput":case "blur":case "fullscreenchange":case "focus":case "hashchange":case "popstate":case "select":case "selectstart":return 1;
|
||||||
|
case "drag":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "mousemove":case "mouseout":case "mouseover":case "pointermove":case "pointerout":case "pointerover":case "scroll":case "toggle":case "touchmove":case "wheel":case "mouseenter":case "mouseleave":case "pointerenter":case "pointerleave":return 4;case "message":switch(Dj()){case De:return 1;case Mg:return 4;case ad:case Ej:return 16;case Ng:return 536870912;default:return 16}default:return 16}}function Og(){if(bd)return bd;
|
||||||
|
var a,b=Ee,c=b.length,d,e="value"in Za?Za.value:Za.textContent,f=e.length;for(a=0;a<c&&b[a]===e[a];a++);var g=c-a;for(d=1;d<=g&&b[c-d]===e[f-d];d++);return bd=e.slice(a,1<d?1-d:void 0)}function cd(a){var b=a.keyCode;"charCode"in a?(a=a.charCode,0===a&&13===b&&(a=13)):a=b;10===a&&(a=13);return 32<=a||13===a?a:0}function dd(){return!0}function Pg(){return!1}function ka(a){function b(b,d,e,f,g){this._reactName=b;this._targetInst=e;this.type=d;this.nativeEvent=f;this.target=g;this.currentTarget=null;
|
||||||
|
for(var c in a)a.hasOwnProperty(c)&&(b=a[c],this[c]=b?b(f):f[c]);this.isDefaultPrevented=(null!=f.defaultPrevented?f.defaultPrevented:!1===f.returnValue)?dd:Pg;this.isPropagationStopped=Pg;return this}E(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var a=this.nativeEvent;a&&(a.preventDefault?a.preventDefault():"unknown"!==typeof a.returnValue&&(a.returnValue=!1),this.isDefaultPrevented=dd)},stopPropagation:function(){var a=this.nativeEvent;a&&(a.stopPropagation?a.stopPropagation():
|
||||||
|
"unknown"!==typeof a.cancelBubble&&(a.cancelBubble=!0),this.isPropagationStopped=dd)},persist:function(){},isPersistent:dd});return b}function Fj(a){var b=this.nativeEvent;return b.getModifierState?b.getModifierState(a):(a=Gj[a])?!!b[a]:!1}function Fe(a){return Fj}function Qg(a,b){switch(a){case "keyup":return-1!==Hj.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function Rg(a){a=a.detail;return"object"===typeof a&&
|
||||||
|
"data"in a?a.data:null}function Ij(a,b){switch(a){case "compositionend":return Rg(b);case "keypress":if(32!==b.which)return null;Sg=!0;return Tg;case "textInput":return a=b.data,a===Tg&&Sg?null:a;default:return null}}function Jj(a,b){if(Hb)return"compositionend"===a||!Ge&&Qg(a,b)?(a=Og(),bd=Ee=Za=null,Hb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1<b.char.length)return b.char;if(b.which)return String.fromCharCode(b.which)}return null;
|
||||||
|
case "compositionend":return Ug&&"ko"!==b.locale?null:b.data;default:return null}}function Vg(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return"input"===b?!!Kj[a.type]:"textarea"===b?!0:!1}function Lj(a){if(!Ia)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Wg(a,b,c,d){ug(d);b=ed(b,"onChange");0<b.length&&(c=new He("onChange","change",null,c,d),a.push({event:c,listeners:b}))}function Mj(a){Xg(a,
|
||||||
|
0)}function fd(a){var b=Ib(a);if(jg(b))return a}function Nj(a,b){if("change"===a)return b}function Yg(){oc&&(oc.detachEvent("onpropertychange",Zg),pc=oc=null)}function Zg(a){if("value"===a.propertyName&&fd(pc)){var b=[];Wg(b,pc,a,re(a));wg(Mj,b)}}function Oj(a,b,c){"focusin"===a?(Yg(),oc=b,pc=c,oc.attachEvent("onpropertychange",Zg)):"focusout"===a&&Yg()}function Pj(a,b){if("selectionchange"===a||"keyup"===a||"keydown"===a)return fd(pc)}function Qj(a,b){if("click"===a)return fd(b)}function Rj(a,b){if("input"===
|
||||||
|
a||"change"===a)return fd(b)}function Sj(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}function qc(a,b){if(ua(a,b))return!0;if("object"!==typeof a||null===a||"object"!==typeof b||null===b)return!1;var c=Object.keys(a),d=Object.keys(b);if(c.length!==d.length)return!1;for(d=0;d<c.length;d++){var e=c[d];if(!Zd.call(b,e)||!ua(a[e],b[e]))return!1}return!0}function $g(a){for(;a&&a.firstChild;)a=a.firstChild;return a}function ah(a,b){var c=$g(a);a=0;for(var d;c;){if(3===c.nodeType){d=a+c.textContent.length;
|
||||||
|
if(a<=b&&d>=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;
|
||||||
|
b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);
|
||||||
|
else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),
|
||||||
|
a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c<b.length;c++)a=b[c],a.element.scrollLeft=a.left,a.element.scrollTop=a.top}}function dh(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Je||null==Jb||Jb!==Qc(d)||(d=Jb,"selectionStart"in d&&Ie(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d=
|
||||||
|
{anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),rc&&qc(rc,d)||(rc=d,d=ed(Ke,"onSelect"),0<d.length&&(b=new He("onSelect","select",null,b,c),a.push({event:b,listeners:d}),b.target=Jb)))}function gd(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function hd(a){if(Le[a])return Le[a];if(!Kb[a])return a;var b=Kb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in eh)return Le[a]=b[c];return a}function $a(a,
|
||||||
|
b){fh.set(a,b);mb(b,[a])}function gh(a,b,c){var d=a.type||"unknown-event";a.currentTarget=c;mj(d,b,void 0,a);a.currentTarget=null}function Xg(a,b){b=0!==(b&4);for(var c=0;c<a.length;c++){var d=a[c],e=d.event;d=d.listeners;a:{var f=void 0;if(b)for(var g=d.length-1;0<=g;g--){var h=d[g],k=h.instance,n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;gh(e,h,n);f=k}else for(g=0;g<d.length;g++){h=d[g];k=h.instance;n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;
|
||||||
|
gh(e,h,n);f=k}}}if(Tc)throw a=ue,Tc=!1,ue=null,a;}function B(a,b){var c=b[Me];void 0===c&&(c=b[Me]=new Set);var d=a+"__bubble";c.has(d)||(hh(b,a,2,!1),c.add(d))}function Ne(a,b,c){var d=0;b&&(d|=4);hh(c,a,d,b)}function sc(a){if(!a[id]){a[id]=!0;cg.forEach(function(b){"selectionchange"!==b&&(Uj.has(b)||Ne(b,!1,a),Ne(b,!0,a))});var b=9===a.nodeType?a:a.ownerDocument;null===b||b[id]||(b[id]=!0,Ne("selectionchange",!1,b))}}function hh(a,b,c,d,e){switch(Lg(b)){case 1:e=zj;break;case 4:e=Aj;break;default:e=
|
||||||
|
Be}c=e.bind(null,b,c,a);e=void 0;!Oe||"touchstart"!==b&&"touchmove"!==b&&"wheel"!==b||(e=!0);d?void 0!==e?a.addEventListener(b,c,{capture:!0,passive:e}):a.addEventListener(b,c,!0):void 0!==e?a.addEventListener(b,c,{passive:e}):a.addEventListener(b,c,!1)}function Ce(a,b,c,d,e){var f=d;if(0===(b&1)&&0===(b&2)&&null!==d)a:for(;;){if(null===d)return;var g=d.tag;if(3===g||4===g){var h=d.stateNode.containerInfo;if(h===e||8===h.nodeType&&h.parentNode===e)break;if(4===g)for(g=d.return;null!==g;){var k=g.tag;
|
||||||
|
if(3===k||4===k)if(k=g.stateNode.containerInfo,k===e||8===k.nodeType&&k.parentNode===e)return;g=g.return}for(;null!==h;){g=ob(h);if(null===g)return;k=g.tag;if(5===k||6===k){d=f=g;continue a}h=h.parentNode}}d=d.return}wg(function(){var d=f,e=re(c),g=[];a:{var h=fh.get(a);if(void 0!==h){var k=He,m=a;switch(a){case "keypress":if(0===cd(c))break a;case "keydown":case "keyup":k=Vj;break;case "focusin":m="focus";k=Pe;break;case "focusout":m="blur";k=Pe;break;case "beforeblur":case "afterblur":k=Pe;break;
|
||||||
|
case "click":if(2===c.button)break a;case "auxclick":case "dblclick":case "mousedown":case "mousemove":case "mouseup":case "mouseout":case "mouseover":case "contextmenu":k=ih;break;case "drag":case "dragend":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "dragstart":case "drop":k=Wj;break;case "touchcancel":case "touchend":case "touchmove":case "touchstart":k=Xj;break;case jh:case kh:case lh:k=Yj;break;case mh:k=Zj;break;case "scroll":k=ak;break;case "wheel":k=bk;break;case "copy":case "cut":case "paste":k=
|
||||||
|
ck;break;case "gotpointercapture":case "lostpointercapture":case "pointercancel":case "pointerdown":case "pointermove":case "pointerout":case "pointerover":case "pointerup":k=nh}var l=0!==(b&4),p=!l&&"scroll"===a,w=l?null!==h?h+"Capture":null:h;l=[];for(var A=d,t;null!==A;){t=A;var M=t.stateNode;5===t.tag&&null!==M&&(t=M,null!==w&&(M=fc(A,w),null!=M&&l.push(tc(A,M,t))));if(p)break;A=A.return}0<l.length&&(h=new k(h,m,null,c,e),g.push({event:h,listeners:l}))}}if(0===(b&7)){a:{h="mouseover"===a||"pointerover"===
|
||||||
|
a;k="mouseout"===a||"pointerout"===a;if(h&&c!==ze&&(m=c.relatedTarget||c.fromElement)&&(ob(m)||m[Ja]))break a;if(k||h){h=e.window===e?e:(h=e.ownerDocument)?h.defaultView||h.parentWindow:window;if(k){if(m=c.relatedTarget||c.toElement,k=d,m=m?ob(m):null,null!==m&&(p=nb(m),m!==p||5!==m.tag&&6!==m.tag))m=null}else k=null,m=d;if(k!==m){l=ih;M="onMouseLeave";w="onMouseEnter";A="mouse";if("pointerout"===a||"pointerover"===a)l=nh,M="onPointerLeave",w="onPointerEnter",A="pointer";p=null==k?h:Ib(k);t=null==
|
||||||
|
m?h:Ib(m);h=new l(M,A+"leave",k,c,e);h.target=p;h.relatedTarget=t;M=null;ob(e)===d&&(l=new l(w,A+"enter",m,c,e),l.target=t,l.relatedTarget=p,M=l);p=M;if(k&&m)b:{l=k;w=m;A=0;for(t=l;t;t=Lb(t))A++;t=0;for(M=w;M;M=Lb(M))t++;for(;0<A-t;)l=Lb(l),A--;for(;0<t-A;)w=Lb(w),t--;for(;A--;){if(l===w||null!==w&&l===w.alternate)break b;l=Lb(l);w=Lb(w)}l=null}else l=null;null!==k&&oh(g,h,k,l,!1);null!==m&&null!==p&&oh(g,p,m,l,!0)}}}a:{h=d?Ib(d):window;k=h.nodeName&&h.nodeName.toLowerCase();if("select"===k||"input"===
|
||||||
|
k&&"file"===h.type)var ma=Nj;else if(Vg(h))if(ph)ma=Rj;else{ma=Pj;var va=Oj}else(k=h.nodeName)&&"input"===k.toLowerCase()&&("checkbox"===h.type||"radio"===h.type)&&(ma=Qj);if(ma&&(ma=ma(a,d))){Wg(g,ma,c,e);break a}va&&va(a,h,d);"focusout"===a&&(va=h._wrapperState)&&va.controlled&&"number"===h.type&&me(h,"number",h.value)}va=d?Ib(d):window;switch(a){case "focusin":if(Vg(va)||"true"===va.contentEditable)Jb=va,Ke=d,rc=null;break;case "focusout":rc=Ke=Jb=null;break;case "mousedown":Je=!0;break;case "contextmenu":case "mouseup":case "dragend":Je=
|
||||||
|
!1;dh(g,c,e);break;case "selectionchange":if(dk)break;case "keydown":case "keyup":dh(g,c,e)}var ab;if(Ge)b:{switch(a){case "compositionstart":var da="onCompositionStart";break b;case "compositionend":da="onCompositionEnd";break b;case "compositionupdate":da="onCompositionUpdate";break b}da=void 0}else Hb?Qg(a,c)&&(da="onCompositionEnd"):"keydown"===a&&229===c.keyCode&&(da="onCompositionStart");da&&(Ug&&"ko"!==c.locale&&(Hb||"onCompositionStart"!==da?"onCompositionEnd"===da&&Hb&&(ab=Og()):(Za=e,Ee=
|
||||||
|
"value"in Za?Za.value:Za.textContent,Hb=!0)),va=ed(d,da),0<va.length&&(da=new qh(da,a,null,c,e),g.push({event:da,listeners:va}),ab?da.data=ab:(ab=Rg(c),null!==ab&&(da.data=ab))));if(ab=ek?Ij(a,c):Jj(a,c))d=ed(d,"onBeforeInput"),0<d.length&&(e=new fk("onBeforeInput","beforeinput",null,c,e),g.push({event:e,listeners:d}),e.data=ab)}Xg(g,b)})}function tc(a,b,c){return{instance:a,listener:b,currentTarget:c}}function ed(a,b){for(var c=b+"Capture",d=[];null!==a;){var e=a,f=e.stateNode;5===e.tag&&null!==
|
||||||
|
f&&(e=f,f=fc(a,c),null!=f&&d.unshift(tc(a,f,e)),f=fc(a,b),null!=f&&d.push(tc(a,f,e)));a=a.return}return d}function Lb(a){if(null===a)return null;do a=a.return;while(a&&5!==a.tag);return a?a:null}function oh(a,b,c,d,e){for(var f=b._reactName,g=[];null!==c&&c!==d;){var h=c,k=h.alternate,n=h.stateNode;if(null!==k&&k===d)break;5===h.tag&&null!==n&&(h=n,e?(k=fc(c,f),null!=k&&g.unshift(tc(c,k,h))):e||(k=fc(c,f),null!=k&&g.push(tc(c,k,h))));c=c.return}0!==g.length&&a.push({event:b,listeners:g})}function rh(a){return("string"===
|
||||||
|
typeof a?a:""+a).replace(gk,"\n").replace(hk,"")}function jd(a,b,c,d){b=rh(b);if(rh(a)!==b&&c)throw Error(m(425));}function kd(){}function Qe(a,b){return"textarea"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function ik(a){setTimeout(function(){throw a;})}function Re(a,b){var c=b,d=0;do{var e=c.nextSibling;a.removeChild(c);if(e&&8===e.nodeType)if(c=
|
||||||
|
e.data,"/$"===c){if(0===d){a.removeChild(e);nc(b);return}d--}else"$"!==c&&"$?"!==c&&"$!"!==c||d++;c=e}while(c);nc(b)}function Ka(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break;if(8===b){b=a.data;if("$"===b||"$!"===b||"$?"===b)break;if("/$"===b)return null}}return a}function sh(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if("$"===c||"$!"===c||"$?"===c){if(0===b)return a;b--}else"/$"===c&&b++}a=a.previousSibling}return null}function ob(a){var b=a[Da];
|
||||||
|
if(b)return b;for(var c=a.parentNode;c;){if(b=c[Ja]||c[Da]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=sh(a);null!==a;){if(c=a[Da])return c;a=sh(a)}return b}a=c;c=a.parentNode}return null}function ec(a){a=a[Da]||a[Ja];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Ib(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(m(33));}function Rc(a){return a[uc]||null}function bb(a){return{current:a}}function v(a,b){0>Mb||(a.current=Se[Mb],Se[Mb]=null,Mb--)}
|
||||||
|
function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168));
|
||||||
|
y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S);
|
||||||
|
y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a<c.length;a++){var d=c[a];do d=d(!0);while(null!==d)}La=null;md=!1}catch(e){throw null!==La&&(La=La.slice(a+1)),xh(De,db),e;}finally{z=b,Te=!1}}return null}function qb(a,b){Ob[Pb++]=nd;Ob[Pb++]=od;od=a;nd=b}function yh(a,b,c){na[oa++]=Ma;na[oa++]=Na;na[oa++]=rb;rb=a;var d=Ma;a=Na;var e=32-ta(d)-1;d&=~(1<<e);c+=1;var f=32-ta(b)+e;if(30<f){var g=e-e%5;
|
||||||
|
f=(d&(1<<g)-1).toString(32);d>>=g;e-=g;Ma=1<<32-ta(b)+e|c<<e|d;Na=f+a}else Ma=1<<f|c<<e|d,Na=a}function Ue(a){null!==a.return&&(qb(a,1),yh(a,1,0))}function Ve(a){for(;a===od;)od=Ob[--Pb],Ob[Pb]=null,nd=Ob[--Pb],Ob[Pb]=null;for(;a===rb;)rb=na[--oa],na[oa]=null,Na=na[--oa],na[oa]=null,Ma=na[--oa],na[oa]=null}function zh(a,b){var c=pa(5,null,null,0);c.elementType="DELETED";c.stateNode=b;c.return=a;b=a.deletions;null===b?(a.deletions=[c],a.flags|=16):b.push(c)}function Ah(a,b){switch(a.tag){case 5:var c=
|
||||||
|
a.type;b=1!==b.nodeType||c.toLowerCase()!==b.nodeName.toLowerCase()?null:b;return null!==b?(a.stateNode=b,la=a,fa=Ka(b.firstChild),!0):!1;case 6:return b=""===a.pendingProps||3!==b.nodeType?null:b,null!==b?(a.stateNode=b,la=a,fa=null,!0):!1;case 13:return b=8!==b.nodeType?null:b,null!==b?(c=null!==rb?{id:Ma,overflow:Na}:null,a.memoizedState={dehydrated:b,treeContext:c,retryLane:1073741824},c=pa(18,null,null,0),c.stateNode=b,c.return=a,a.child=c,la=a,fa=null,!0):!1;default:return!1}}function We(a){return 0!==
|
||||||
|
(a.mode&1)&&0===(a.flags&128)}function Xe(a){if(D){var b=fa;if(b){var c=b;if(!Ah(a,b)){if(We(a))throw Error(m(418));b=Ka(c.nextSibling);var d=la;b&&Ah(a,b)?zh(d,c):(a.flags=a.flags&-4097|2,D=!1,la=a)}}else{if(We(a))throw Error(m(418));a.flags=a.flags&-4097|2;D=!1;la=a}}}function Bh(a){for(a=a.return;null!==a&&5!==a.tag&&3!==a.tag&&13!==a.tag;)a=a.return;la=a}function pd(a){if(a!==la)return!1;if(!D)return Bh(a),D=!0,!1;var b;(b=3!==a.tag)&&!(b=5!==a.tag)&&(b=a.type,b="head"!==b&&"body"!==b&&!Qe(a.type,
|
||||||
|
a.memoizedProps));if(b&&(b=fa)){if(We(a)){for(a=fa;a;)a=Ka(a.nextSibling);throw Error(m(418));}for(;b;)zh(a,b),b=Ka(b.nextSibling)}Bh(a);if(13===a.tag){a=a.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(m(317));a:{a=a.nextSibling;for(b=0;a;){if(8===a.nodeType){var c=a.data;if("/$"===c){if(0===b){fa=Ka(a.nextSibling);break a}b--}else"$"!==c&&"$!"!==c&&"$?"!==c||b++}a=a.nextSibling}fa=null}}else fa=la?Ka(a.stateNode.nextSibling):null;return!0}function Qb(){fa=la=null;D=!1}function Ye(a){null===
|
||||||
|
wa?wa=[a]:wa.push(a)}function vc(a,b,c){a=c.ref;if(null!==a&&"function"!==typeof a&&"object"!==typeof a){if(c._owner){c=c._owner;if(c){if(1!==c.tag)throw Error(m(309));var d=c.stateNode}if(!d)throw Error(m(147,a));var e=d,f=""+a;if(null!==b&&null!==b.ref&&"function"===typeof b.ref&&b.ref._stringRef===f)return b.ref;b=function(a){var b=e.refs;null===a?delete b[f]:b[f]=a};b._stringRef=f;return b}if("string"!==typeof a)throw Error(m(284));if(!c._owner)throw Error(m(290,a));}return a}function qd(a,b){a=
|
||||||
|
Object.prototype.toString.call(b);throw Error(m(31,"[object Object]"===a?"object with keys {"+Object.keys(b).join(", ")+"}":a));}function Ch(a){var b=a._init;return b(a._payload)}function Dh(a){function b(b,c){if(a){var d=b.deletions;null===d?(b.deletions=[c],b.flags|=16):d.push(c)}}function c(c,d){if(!a)return null;for(;null!==d;)b(c,d),d=d.sibling;return null}function d(a,b){for(a=new Map;null!==b;)null!==b.key?a.set(b.key,b):a.set(b.index,b),b=b.sibling;return a}function e(a,b){a=eb(a,b);a.index=
|
||||||
|
0;a.sibling=null;return a}function f(b,c,d){b.index=d;if(!a)return b.flags|=1048576,c;d=b.alternate;if(null!==d)return d=d.index,d<c?(b.flags|=2,c):d;b.flags|=2;return c}function g(b){a&&null===b.alternate&&(b.flags|=2);return b}function h(a,b,c,d){if(null===b||6!==b.tag)return b=Ze(c,a.mode,d),b.return=a,b;b=e(b,c);b.return=a;return b}function k(a,b,c,d){var f=c.type;if(f===Bb)return l(a,b,c.props.children,d,c.key);if(null!==b&&(b.elementType===f||"object"===typeof f&&null!==f&&f.$$typeof===Ta&&
|
||||||
|
Ch(f)===b.type))return d=e(b,c.props),d.ref=vc(a,b,c),d.return=a,d;d=rd(c.type,c.key,c.props,null,a.mode,d);d.ref=vc(a,b,c);d.return=a;return d}function n(a,b,c,d){if(null===b||4!==b.tag||b.stateNode.containerInfo!==c.containerInfo||b.stateNode.implementation!==c.implementation)return b=$e(c,a.mode,d),b.return=a,b;b=e(b,c.children||[]);b.return=a;return b}function l(a,b,c,d,f){if(null===b||7!==b.tag)return b=sb(c,a.mode,d,f),b.return=a,b;b=e(b,c);b.return=a;return b}function u(a,b,c){if("string"===
|
||||||
|
typeof b&&""!==b||"number"===typeof b)return b=Ze(""+b,a.mode,c),b.return=a,b;if("object"===typeof b&&null!==b){switch(b.$$typeof){case sd:return c=rd(b.type,b.key,b.props,null,a.mode,c),c.ref=vc(a,null,b),c.return=a,c;case Cb:return b=$e(b,a.mode,c),b.return=a,b;case Ta:var d=b._init;return u(a,d(b._payload),c)}if(cc(b)||ac(b))return b=sb(b,a.mode,c,null),b.return=a,b;qd(a,b)}return null}function r(a,b,c,d){var e=null!==b?b.key:null;if("string"===typeof c&&""!==c||"number"===typeof c)return null!==
|
||||||
|
e?null:h(a,b,""+c,d);if("object"===typeof c&&null!==c){switch(c.$$typeof){case sd:return c.key===e?k(a,b,c,d):null;case Cb:return c.key===e?n(a,b,c,d):null;case Ta:return e=c._init,r(a,b,e(c._payload),d)}if(cc(c)||ac(c))return null!==e?null:l(a,b,c,d,null);qd(a,c)}return null}function p(a,b,c,d,e){if("string"===typeof d&&""!==d||"number"===typeof d)return a=a.get(c)||null,h(b,a,""+d,e);if("object"===typeof d&&null!==d){switch(d.$$typeof){case sd:return a=a.get(null===d.key?c:d.key)||null,k(b,a,d,
|
||||||
|
e);case Cb:return a=a.get(null===d.key?c:d.key)||null,n(b,a,d,e);case Ta:var f=d._init;return p(a,b,c,f(d._payload),e)}if(cc(d)||ac(d))return a=a.get(c)||null,l(b,a,d,e,null);qd(b,d)}return null}function x(e,g,h,k){for(var n=null,m=null,l=g,t=g=0,q=null;null!==l&&t<h.length;t++){l.index>t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t<
|
||||||
|
h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);t<h.length;t++)q=p(l,e,t,h[t],k),null!==q&&(a&&null!==q.alternate&&l.delete(null===q.key?t:q.key),g=f(q,g,t),null===m?n=q:m.sibling=q,m=q);a&&l.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function I(e,g,h,k){var n=ac(h);if("function"!==typeof n)throw Error(m(150));h=n.call(h);if(null==h)throw Error(m(151));for(var l=n=null,q=g,t=g=0,A=null,w=h.next();null!==q&&!w.done;t++,w=
|
||||||
|
h.next()){q.index>t?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling=
|
||||||
|
w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a,
|
||||||
|
n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a;
|
||||||
|
a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=
|
||||||
|
b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved;
|
||||||
|
null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue=
|
||||||
|
{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b=
|
||||||
|
b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,
|
||||||
|
shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l=
|
||||||
|
n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=
|
||||||
|
[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a,
|
||||||
|
b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;b<a.length;b++){var d=a[b],e=d.callback;if(null!==e){d.callback=null;d=c;if("function"!==typeof e)throw Error(m(191,e));e.call(d)}}}function ub(a){if(a===wc)throw Error(m(174));return a}function gf(a,b){y(xc,b);y(yc,a);y(Ea,wc);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:oe(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=oe(b,a)}v(Ea);y(Ea,b)}function Tb(a){v(Ea);v(yc);v(xc)}function Ih(a){ub(xc.current);
|
||||||
|
var b=ub(Ea.current);var c=oe(b,a.type);b!==c&&(y(yc,a),y(Ea,c))}function hf(a){yc.current===a&&(v(Ea),v(yc))}function xd(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=
|
||||||
|
b.return;b=b.sibling}return null}function jf(){for(var a=0;a<kf.length;a++)kf[a]._workInProgressVersionPrimary=null;kf.length=0}function V(){throw Error(m(321));}function lf(a,b){if(null===b)return!1;for(var c=0;c<b.length&&c<a.length;c++)if(!ua(a[c],b[c]))return!1;return!0}function mf(a,b,c,d,e,f){vb=f;C=b;b.memoizedState=null;b.updateQueue=null;b.lanes=0;yd.current=null===a||null===a.memoizedState?lk:mk;a=c(d,e);if(zc){f=0;do{zc=!1;Ac=0;if(25<=f)throw Error(m(301));f+=1;N=K=null;b.updateQueue=null;
|
||||||
|
yd.current=nk;a=c(d,e)}while(zc)}yd.current=zd;b=null!==K&&null!==K.next;vb=0;N=K=C=null;Ad=!1;if(b)throw Error(m(300));return a}function nf(){var a=0!==Ac;Ac=0;return a}function Fa(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===N?C.memoizedState=N=a:N=N.next=a;return N}function sa(){if(null===K){var a=C.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===N?C.memoizedState:N.next;if(null!==b)N=b,K=a;else{if(null===a)throw Error(m(310));K=a;
|
||||||
|
a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===N?C.memoizedState=N=a:N=N.next=a}return N}function Bc(a,b){return"function"===typeof b?b(a):b}function of(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){f=e.next;d=d.baseState;var h=g=null,k=null,n=f;do{var l=n.lane;if((vb&
|
||||||
|
l)===l)null!==k&&(k=k.next={lane:0,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null}),d=n.hasEagerState?n.eagerState:a(d,n.action);else{var u={lane:l,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null};null===k?(h=k=u,g=d):k=k.next=u;C.lanes|=l;ra|=l}n=n.next}while(null!==n&&n!==f);null===k?g=d:k.next=h;ua(d,b.memoizedState)||(ha=!0);b.memoizedState=d;b.baseState=g;b.baseQueue=k;c.lastRenderedState=d}a=c.interleaved;if(null!==a){e=a;do f=
|
||||||
|
e.lane,C.lanes|=f,ra|=f,e=e.next;while(e!==a)}else null===e&&(c.lanes=0);return[b.memoizedState,c.dispatch]}function pf(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);ua(f,b.memoizedState)||(ha=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function Jh(a,b,c){}function Kh(a,b,c){c=C;var d=sa(),
|
||||||
|
e=b(),f=!ua(d.memoizedState,e);f&&(d.memoizedState=e,ha=!0);d=d.queue;qf(Lh.bind(null,c,d,a),[a]);if(d.getSnapshot!==b||f||null!==N&&N.memoizedState.tag&1){c.flags|=2048;Cc(9,Mh.bind(null,c,d,e,b),void 0,null);if(null===O)throw Error(m(349));0!==(vb&30)||Nh(c,b,e)}return e}function Nh(a,b,c){a.flags|=16384;a={getSnapshot:b,value:c};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.stores=[a]):(c=b.stores,null===c?b.stores=[a]:c.push(a))}function Mh(a,b,c,d){b.value=c;b.getSnapshot=
|
||||||
|
d;Oh(b)&&Ph(a)}function Lh(a,b,c){return c(function(){Oh(b)&&Ph(a)})}function Oh(a){var b=a.getSnapshot;a=a.value;try{var c=b();return!ua(a,c)}catch(d){return!0}}function Ph(a){var b=Oa(a,1);null!==b&&xa(b,a,1,-1)}function Qh(a){var b=Fa();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Bc,lastRenderedState:a};b.queue=a;a=a.dispatch=ok.bind(null,C,a);return[b.memoizedState,a]}function Cc(a,b,c,d){a={tag:a,create:b,
|
||||||
|
destroy:c,deps:d,next:null};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function Rh(a){return sa().memoizedState}function Bd(a,b,c,d){var e=Fa();C.flags|=a;e.memoizedState=Cc(1|b,c,void 0,void 0===d?null:d)}function Cd(a,b,c,d){var e=sa();d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&lf(d,g.deps)){e.memoizedState=
|
||||||
|
Cc(b,c,f,d);return}}C.flags|=a;e.memoizedState=Cc(1|b,c,f,d)}function Sh(a,b){return Bd(8390656,8,a,b)}function qf(a,b){return Cd(2048,8,a,b)}function Th(a,b){return Cd(4,2,a,b)}function Uh(a,b){return Cd(4,4,a,b)}function Vh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function Wh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Cd(4,4,Vh.bind(null,b,a),c)}function rf(a,b){}function Xh(a,b){var c=
|
||||||
|
sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function Yh(a,b){var c=sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Zh(a,b,c){if(0===(vb&21))return a.baseState&&(a.baseState=!1,ha=!0),a.memoizedState=c;ua(c,b)||(c=Dg(),C.lanes|=c,ra|=c,a.baseState=!0);return b}function pk(a,b,c){c=z;z=0!==c&&4>c?c:4;a(!0);var d=sf.transition;sf.transition=
|
||||||
|
{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,
|
||||||
|
h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&&
|
||||||
|
a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&&
|
||||||
|
null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&&
|
||||||
|
b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!==
|
||||||
|
typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+
|
||||||
|
"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)};
|
||||||
|
c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b;
|
||||||
|
if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f,
|
||||||
|
e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref===
|
||||||
|
b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c;
|
||||||
|
else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a,
|
||||||
|
b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b,
|
||||||
|
n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount||
|
||||||
|
("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props=
|
||||||
|
n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState),
|
||||||
|
(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=
|
||||||
|
4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1),
|
||||||
|
Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a,
|
||||||
|
cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!==
|
||||||
|
f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f=
|
||||||
|
sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d}
|
||||||
|
function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return=
|
||||||
|
b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e=
|
||||||
|
32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate;
|
||||||
|
null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&
|
||||||
|
vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c=
|
||||||
|
null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!==
|
||||||
|
b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current&
|
||||||
|
1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!==
|
||||||
|
b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,
|
||||||
|
d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&
|
||||||
|
256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break;
|
||||||
|
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],d);break;case "source":B("error",d);break;case "img":case "image":case "link":B("error",d);B("load",d);break;case "details":B("toggle",d);break;case "input":kg(d,f);B("invalid",d);break;case "select":d._wrapperState={wasMultiple:!!f.multiple};B("invalid",d);break;case "textarea":ng(d,f),B("invalid",d)}pe(c,f);e=null;for(var g in f)if(f.hasOwnProperty(g)){var h=f[g];"children"===g?"string"===typeof h?d.textContent!==h&&(!0!==f.suppressHydrationWarning&&
|
||||||
|
jd(d.textContent,h,a),e=["children",h]):"number"===typeof h&&d.textContent!==""+h&&(!0!==f.suppressHydrationWarning&&jd(d.textContent,h,a),e=["children",""+h]):$b.hasOwnProperty(g)&&null!=h&&"onScroll"===g&&B("scroll",d)}switch(c){case "input":Pc(d);mg(d,f,!0);break;case "textarea":Pc(d);pg(d);break;case "select":case "option":break;default:"function"===typeof f.onClick&&(d.onclick=kd)}d=e;b.updateQueue=d;null!==d&&(b.flags|=4)}else{g=9===e.nodeType?e:e.ownerDocument;"http://www.w3.org/1999/xhtml"===
|
||||||
|
a&&(a=qg(c));"http://www.w3.org/1999/xhtml"===a?"script"===c?(a=g.createElement("div"),a.innerHTML="<script>\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break;
|
||||||
|
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],a);e=d;break;case "source":B("error",a);e=d;break;case "img":case "image":case "link":B("error",a);B("load",a);e=d;break;case "details":B("toggle",a);e=d;break;case "input":kg(a,d);e=ke(a,d);B("invalid",a);break;case "option":e=d;break;case "select":a._wrapperState={wasMultiple:!!d.multiple};e=E({},d,{value:void 0});B("invalid",a);break;case "textarea":ng(a,d);e=ne(a,d);B("invalid",a);break;default:e=d}pe(c,e);h=e;for(f in h)if(h.hasOwnProperty(f)){var k=
|
||||||
|
h[f];"style"===f?sg(a,k):"dangerouslySetInnerHTML"===f?(k=k?k.__html:void 0,null!=k&&yi(a,k)):"children"===f?"string"===typeof k?("textarea"!==c||""!==k)&&Fc(a,k):"number"===typeof k&&Fc(a,""+k):"suppressContentEditableWarning"!==f&&"suppressHydrationWarning"!==f&&"autoFocus"!==f&&($b.hasOwnProperty(f)?null!=k&&"onScroll"===f&&B("scroll",a):null!=k&&$d(a,f,k,g))}switch(c){case "input":Pc(a);mg(a,d,!1);break;case "textarea":Pc(a);pg(a);break;case "option":null!=d.value&&a.setAttribute("value",""+Ua(d.value));
|
||||||
|
break;case "select":a.multiple=!!d.multiple;f=d.value;null!=f?Db(a,!!d.multiple,f,!1):null!=d.defaultValue&&Db(a,!!d.multiple,d.defaultValue,!0);break;default:"function"===typeof e.onClick&&(a.onclick=kd)}switch(c){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}}d&&(b.flags|=4)}null!==b.ref&&(b.flags|=512,b.flags|=2097152)}W(b);return null;case 6:if(a&&null!=b.stateNode)Ak(a,b,a.memoizedProps,d);else{if("string"!==typeof d&&null===
|
||||||
|
b.stateNode)throw Error(m(166));c=ub(xc.current);ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.memoizedProps;d[Da]=b;if(f=d.nodeValue!==c)if(a=la,null!==a)switch(a.tag){case 3:jd(d.nodeValue,c,0!==(a.mode&1));break;case 5:!0!==a.memoizedProps.suppressHydrationWarning&&jd(d.nodeValue,c,0!==(a.mode&1))}f&&(b.flags|=4)}else d=(9===c.nodeType?c:c.ownerDocument).createTextNode(d),d[Da]=b,b.stateNode=d}W(b);return null;case 13:v(F);d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){if(D&&
|
||||||
|
null!==fa&&0!==(b.mode&1)&&0===(b.flags&128)){for(f=fa;f;)f=Ka(f.nextSibling);Qb();b.flags|=98560;f=!1}else if(f=pd(b),null!==d&&null!==d.dehydrated){if(null===a){if(!f)throw Error(m(318));f=b.memoizedState;f=null!==f?f.dehydrated:null;if(!f)throw Error(m(317));f[Da]=b}else Qb(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;W(b);f=!1}else null!==wa&&(Gf(wa),wa=null),f=!0;if(!f)return b.flags&65536?b:null}if(0!==(b.flags&128))return b.lanes=c,b;d=null!==d;d!==(null!==a&&null!==a.memoizedState)&&
|
||||||
|
d&&(b.child.flags|=8192,0!==(b.mode&1)&&(null===a||0!==(F.current&1)?0===L&&(L=3):Ef()));null!==b.updateQueue&&(b.flags|=4);W(b);return null;case 4:return Tb(),xi(a,b),null===a&&sc(b.stateNode.containerInfo),W(b),null;case 10:return cf(b.type._context),W(b),null;case 17:return ea(b.type)&&(v(S),v(J)),W(b),null;case 19:v(F);f=b.memoizedState;if(null===f)return W(b),null;d=0!==(b.flags&128);g=f.rendering;if(null===g)if(d)Dc(f,!1);else{if(0!==L||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){g=
|
||||||
|
xd(a);if(null!==g){b.flags|=128;Dc(f,!1);d=g.updateQueue;null!==d&&(b.updateQueue=d,b.flags|=4);b.subtreeFlags=0;d=c;for(c=b.child;null!==c;)f=c,a=d,f.flags&=14680066,g=f.alternate,null===g?(f.childLanes=0,f.lanes=a,f.child=null,f.subtreeFlags=0,f.memoizedProps=null,f.memoizedState=null,f.updateQueue=null,f.dependencies=null,f.stateNode=null):(f.childLanes=g.childLanes,f.lanes=g.lanes,f.child=g.child,f.subtreeFlags=0,f.deletions=null,f.memoizedProps=g.memoizedProps,f.memoizedState=g.memoizedState,
|
||||||
|
f.updateQueue=g.updateQueue,f.type=g.type,a=g.dependencies,f.dependencies=null===a?null:{lanes:a.lanes,firstContext:a.firstContext}),c=c.sibling;y(F,F.current&1|2);return b.child}a=a.sibling}null!==f.tail&&P()>Hf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|=
|
||||||
|
128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null;
|
||||||
|
case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(),
|
||||||
|
null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();
|
||||||
|
if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null:
|
||||||
|
{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t=
|
||||||
|
b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}}
|
||||||
|
function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk],
|
||||||
|
delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&
|
||||||
|
2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);
|
||||||
|
else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za?
|
||||||
|
(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=
|
||||||
|
c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;d<c.length;d++){var e=
|
||||||
|
c[d];try{var f=a,g=b,h=g;a:for(;null!==h;){switch(h.tag){case 5:T=h.stateNode;za=!1;break a;case 3:T=h.stateNode.containerInfo;za=!0;break a;case 4:T=h.stateNode.containerInfo;za=!0;break a}h=h.return}if(null===T)throw Error(m(160));Di(f,g,e);T=null;za=!1;var k=e.alternate;null!==k&&(k.return=null);e.return=null}catch(n){G(e,b,n)}}if(b.subtreeFlags&12854)for(b=b.child;null!==b;)Fi(b,a),b=b.sibling}function Fi(a,b,c){var d=a.alternate;c=a.flags;switch(a.tag){case 0:case 11:case 14:case 15:Aa(b,a);
|
||||||
|
Ha(a);if(c&4){try{Gc(3,a,a.return),Id(3,a)}catch(I){G(a,a.return,I)}try{Gc(5,a,a.return)}catch(I){G(a,a.return,I)}}break;case 1:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);break;case 5:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);if(a.flags&32){var e=a.stateNode;try{Fc(e,"")}catch(I){G(a,a.return,I)}}if(c&4&&(e=a.stateNode,null!=e)){var f=a.memoizedProps,g=null!==d?d.memoizedProps:f,h=a.type,k=a.updateQueue;a.updateQueue=null;if(null!==k)try{"input"===h&&"radio"===f.type&&null!=f.name&&lg(e,f);
|
||||||
|
qe(h,g);var n=qe(h,f);for(g=0;g<k.length;g+=2){var q=k[g],u=k[g+1];"style"===q?sg(e,u):"dangerouslySetInnerHTML"===q?yi(e,u):"children"===q?Fc(e,u):$d(e,q,u,n)}switch(h){case "input":le(e,f);break;case "textarea":og(e,f);break;case "select":var r=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!f.multiple;var p=f.value;null!=p?Db(e,!!f.multiple,p,!1):r!==!!f.multiple&&(null!=f.defaultValue?Db(e,!!f.multiple,f.defaultValue,!0):Db(e,!!f.multiple,f.multiple?[]:"",!1))}e[uc]=f}catch(I){G(a,a.return,
|
||||||
|
I)}}break;case 6:Aa(b,a);Ha(a);if(c&4){if(null===a.stateNode)throw Error(m(162));e=a.stateNode;f=a.memoizedProps;try{e.nodeValue=f}catch(I){G(a,a.return,I)}}break;case 3:Aa(b,a);Ha(a);if(c&4&&null!==d&&d.memoizedState.isDehydrated)try{nc(b.containerInfo)}catch(I){G(a,a.return,I)}break;case 4:Aa(b,a);Ha(a);break;case 13:Aa(b,a);Ha(a);e=a.child;e.flags&8192&&(f=null!==e.memoizedState,e.stateNode.isHidden=f,!f||null!==e.alternate&&null!==e.alternate.memoizedState||(Of=P()));c&4&&Ei(a);break;case 22:q=
|
||||||
|
null!==d&&null!==d.memoizedState;a.mode&1?(X=(n=X)||q,Aa(b,a),X=n):Aa(b,a);Ha(a);if(c&8192){n=null!==a.memoizedState;if((a.stateNode.isHidden=n)&&!q&&0!==(a.mode&1))for(l=a,q=a.child;null!==q;){for(u=l=q;null!==l;){r=l;p=r.child;switch(r.tag){case 0:case 11:case 14:case 15:Gc(4,r,r.return);break;case 1:Wb(r,r.return);var x=r.stateNode;if("function"===typeof x.componentWillUnmount){c=r;b=r.return;try{d=c,x.props=d.memoizedProps,x.state=d.memoizedState,x.componentWillUnmount()}catch(I){G(c,b,I)}}break;
|
||||||
|
case 5:Wb(r,r.return);break;case 22:if(null!==r.memoizedState){Gi(u);continue}}null!==p?(p.return=r,l=p):Gi(u)}q=q.sibling}a:for(q=null,u=a;;){if(5===u.tag){if(null===q){q=u;try{e=u.stateNode,n?(f=e.style,"function"===typeof f.setProperty?f.setProperty("display","none","important"):f.display="none"):(h=u.stateNode,k=u.memoizedProps.style,g=void 0!==k&&null!==k&&k.hasOwnProperty("display")?k.display:null,h.style.display=rg("display",g))}catch(I){G(a,a.return,I)}}}else if(6===u.tag){if(null===q)try{u.stateNode.nodeValue=
|
||||||
|
n?"":u.memoizedProps}catch(I){G(a,a.return,I)}}else if((22!==u.tag&&23!==u.tag||null===u.memoizedState||u===a)&&null!==u.child){u.child.return=u;u=u.child;continue}if(u===a)break a;for(;null===u.sibling;){if(null===u.return||u.return===a)break a;q===u&&(q=null);u=u.return}q===u&&(q=null);u.sibling.return=u.return;u=u.sibling}}break;case 19:Aa(b,a);Ha(a);c&4&&Ei(a);break;case 21:break;default:Aa(b,a),Ha(a)}}function Ha(a){var b=a.flags;if(b&2){try{a:{for(var c=a.return;null!==c;){if(Bi(c)){var d=c;
|
||||||
|
break a}c=c.return}throw Error(m(160));}switch(d.tag){case 5:var e=d.stateNode;d.flags&32&&(Fc(e,""),d.flags&=-33);var f=Ci(a);Nf(a,f,e);break;case 3:case 4:var g=d.stateNode.containerInfo,h=Ci(a);Mf(a,h,g);break;default:throw Error(m(161));}}catch(k){G(a,a.return,k)}a.flags&=-3}b&4096&&(a.flags&=-4097)}function Hk(a,b,c){l=a;Hi(a,b,c)}function Hi(a,b,c){for(var d=0!==(a.mode&1);null!==l;){var e=l,f=e.child;if(22===e.tag&&d){var g=null!==e.memoizedState||Jd;if(!g){var h=e.alternate,k=null!==h&&null!==
|
||||||
|
h.memoizedState||X;h=Jd;var n=X;Jd=g;if((X=k)&&!n)for(l=e;null!==l;)g=l,k=g.child,22===g.tag&&null!==g.memoizedState?Ii(e):null!==k?(k.return=g,l=k):Ii(e);for(;null!==f;)l=f,Hi(f,b,c),f=f.sibling;l=e;Jd=h;X=n}Ji(a,b,c)}else 0!==(e.subtreeFlags&8772)&&null!==f?(f.return=e,l=f):Ji(a,b,c)}}function Ji(a,b,c){for(;null!==l;){b=l;if(0!==(b.flags&8772)){c=b.alternate;try{if(0!==(b.flags&8772))switch(b.tag){case 0:case 11:case 15:X||Id(5,b);break;case 1:var d=b.stateNode;if(b.flags&4&&!X)if(null===c)d.componentDidMount();
|
||||||
|
else{var e=b.elementType===b.type?c.memoizedProps:ya(b.type,c.memoizedProps);d.componentDidUpdate(e,c.memoizedState,d.__reactInternalSnapshotBeforeUpdate)}var f=b.updateQueue;null!==f&&Hh(b,f,d);break;case 3:var g=b.updateQueue;if(null!==g){c=null;if(null!==b.child)switch(b.child.tag){case 5:c=b.child.stateNode;break;case 1:c=b.child.stateNode}Hh(b,g,c)}break;case 5:var h=b.stateNode;if(null===c&&b.flags&4){c=h;var k=b.memoizedProps;switch(b.type){case "button":case "input":case "select":case "textarea":k.autoFocus&&
|
||||||
|
c.focus();break;case "img":k.src&&(c.src=k.src)}}break;case 6:break;case 4:break;case 12:break;case 13:if(null===b.memoizedState){var n=b.alternate;if(null!==n){var q=n.memoizedState;if(null!==q){var p=q.dehydrated;null!==p&&nc(p)}}}break;case 19:case 17:case 21:case 22:case 23:case 25:break;default:throw Error(m(163));}X||b.flags&512&&Lf(b)}catch(r){G(b,b.return,r)}}if(b===a){l=null;break}c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Gi(a){for(;null!==l;){var b=l;if(b===
|
||||||
|
a){l=null;break}var c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Ii(a){for(;null!==l;){var b=l;try{switch(b.tag){case 0:case 11:case 15:var c=b.return;try{Id(4,b)}catch(k){G(b,c,k)}break;case 1:var d=b.stateNode;if("function"===typeof d.componentDidMount){var e=b.return;try{d.componentDidMount()}catch(k){G(b,e,k)}}var f=b.return;try{Lf(b)}catch(k){G(b,f,k)}break;case 5:var g=b.return;try{Lf(b)}catch(k){G(b,g,k)}}}catch(k){G(b,b.return,k)}if(b===a){l=null;break}var h=b.sibling;
|
||||||
|
if(null!==h){h.return=b.return;l=h;break}l=b.return}}function Hc(){Hf=P()+500}function Z(){return 0!==(p&6)?P():-1!==Kd?Kd:Kd=P()}function hb(a){if(0===(a.mode&1))return 1;if(0!==(p&2)&&0!==U)return U&-U;if(null!==Ik.transition)return 0===Ld&&(Ld=Dg()),Ld;a=z;if(0!==a)return a;a=window.event;a=void 0===a?16:Lg(a.type);return a}function xa(a,b,c,d){if(50<Ic)throw Ic=0,Pf=null,Error(m(185));ic(a,c,d);if(0===(p&2)||a!==O)a===O&&(0===(p&2)&&(Md|=c),4===L&&kb(a,U)),ia(a,d),1===c&&0===p&&0===(b.mode&1)&&
|
||||||
|
(Hc(),md&&db())}function ia(a,b){var c=a.callbackNode;tj(a,b);var d=Vc(a,a===O?U:0);if(0===d)null!==c&&Ki(c),a.callbackNode=null,a.callbackPriority=0;else if(b=d&-d,a.callbackPriority!==b){null!=c&&Ki(c);if(1===b)0===a.tag?jk(Li.bind(null,a)):wh(Li.bind(null,a)),Jk(function(){0===(p&6)&&db()}),c=null;else{switch(Eg(d)){case 1:c=De;break;case 4:c=Mg;break;case 16:c=ad;break;case 536870912:c=Ng;break;default:c=ad}c=Mi(c,Ni.bind(null,a))}a.callbackPriority=b;a.callbackNode=c}}function Ni(a,b){Kd=-1;
|
||||||
|
Ld=0;if(0!==(p&6))throw Error(m(327));var c=a.callbackNode;if(Xb()&&a.callbackNode!==c)return null;var d=Vc(a,a===O?U:0);if(0===d)return null;if(0!==(d&30)||0!==(d&a.expiredLanes)||b)b=Nd(a,d);else{b=d;var e=p;p|=2;var f=Oi();if(O!==a||U!==b)Ra=null,Hc(),wb(a,b);do try{Kk();break}catch(h){Pi(a,h)}while(1);af();Od.current=f;p=e;null!==H?b=0:(O=null,U=0,b=L)}if(0!==b){2===b&&(e=ve(a),0!==e&&(d=e,b=Qf(a,e)));if(1===b)throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;if(6===b)kb(a,d);else{e=a.current.alternate;
|
||||||
|
if(0===(d&30)&&!Lk(e)&&(b=Nd(a,d),2===b&&(f=ve(a),0!==f&&(d=f,b=Qf(a,f))),1===b))throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;a.finishedWork=e;a.finishedLanes=d;switch(b){case 0:case 1:throw Error(m(345));case 2:xb(a,ja,Ra);break;case 3:kb(a,d);if((d&130023424)===d&&(b=Of+500-P(),10<b)){if(0!==Vc(a,0))break;e=a.suspendedLanes;if((e&d)!==d){Z();a.pingedLanes|=a.suspendedLanes&e;break}a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),b);break}xb(a,ja,Ra);break;case 4:kb(a,d);if((d&4194240)===d)break;b=a.eventTimes;
|
||||||
|
for(e=-1;0<d;){var g=31-ta(d);f=1<<g;g=b[g];g>e&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10<d){a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),d);break}xb(a,ja,Ra);break;case 5:xb(a,ja,Ra);break;default:throw Error(m(329));}}}ia(a,P());return a.callbackNode===c?Ni.bind(null,a):null}function Qf(a,b){var c=Kc;a.current.memoizedState.isDehydrated&&(wb(a,b).flags|=256);a=Nd(a,b);2!==a&&(b=ja,ja=c,null!==b&&Gf(b));return a}function Gf(a){null===
|
||||||
|
ja?ja=a:ja.push.apply(ja,a)}function Lk(a){for(var b=a;;){if(b.flags&16384){var c=b.updateQueue;if(null!==c&&(c=c.stores,null!==c))for(var d=0;d<c.length;d++){var e=c[d],f=e.getSnapshot;e=e.value;try{if(!ua(f(),e))return!1}catch(g){return!1}}}c=b.child;if(b.subtreeFlags&16384&&null!==c)c.return=b,b=c;else{if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return!0;b=b.return}b.sibling.return=b.return;b=b.sibling}}return!0}function kb(a,b){b&=~Sf;b&=~Md;a.suspendedLanes|=b;a.pingedLanes&=
|
||||||
|
~b;for(a=a.expirationTimes;0<b;){var c=31-ta(b),d=1<<c;a[c]=-1;b&=~d}}function Li(a){if(0!==(p&6))throw Error(m(327));Xb();var b=Vc(a,0);if(0===(b&1))return ia(a,P()),null;var c=Nd(a,b);if(0!==a.tag&&2===c){var d=ve(a);0!==d&&(b=d,c=Qf(a,d))}if(1===c)throw c=Jc,wb(a,0),kb(a,b),ia(a,P()),c;if(6===c)throw Error(m(345));a.finishedWork=a.current.alternate;a.finishedLanes=b;xb(a,ja,Ra);ia(a,P());return null}function Tf(a,b){var c=p;p|=1;try{return a(b)}finally{p=c,0===p&&(Hc(),md&&db())}}function yb(a){null!==
|
||||||
|
lb&&0===lb.tag&&0===(p&6)&&Xb();var b=p;p|=1;var c=ca.transition,d=z;try{if(ca.transition=null,z=1,a)return a()}finally{z=d,ca.transition=c,p=b,0===(p&6)&&db()}}function wb(a,b){a.finishedWork=null;a.finishedLanes=0;var c=a.timeoutHandle;-1!==c&&(a.timeoutHandle=-1,Nk(c));if(null!==H)for(c=H.return;null!==c;){var d=c;Ve(d);switch(d.tag){case 1:d=d.type.childContextTypes;null!==d&&void 0!==d&&(v(S),v(J));break;case 3:Tb();v(S);v(J);jf();break;case 5:hf(d);break;case 4:Tb();break;case 13:v(F);break;
|
||||||
|
case 19:v(F);break;case 10:cf(d.type._context);break;case 22:case 23:ba=Ga.current,v(Ga)}c=c.return}O=a;H=a=eb(a.current,null);U=ba=b;L=0;Jc=null;Sf=Md=ra=0;ja=Kc=null;if(null!==tb){for(b=0;b<tb.length;b++)if(c=tb[b],d=c.interleaved,null!==d){c.interleaved=null;var e=d.next,f=c.pending;if(null!==f){var g=f.next;f.next=e;d.next=g}c.pending=d}tb=null}return a}function Pi(a,b){do{var c=H;try{af();yd.current=zd;if(Ad){for(var d=C.memoizedState;null!==d;){var e=d.queue;null!==e&&(e.pending=null);d=d.next}Ad=
|
||||||
|
!1}vb=0;N=K=C=null;zc=!1;Ac=0;Uf.current=null;if(null===c||null===c.return){L=1;Jc=b;H=null;break}a:{var f=a,g=c.return,h=c,k=b;b=U;h.flags|=32768;if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var n=k,l=h,p=l.tag;if(0===(l.mode&1)&&(0===p||11===p||15===p)){var r=l.alternate;r?(l.updateQueue=r.updateQueue,l.memoizedState=r.memoizedState,l.lanes=r.lanes):(l.updateQueue=null,l.memoizedState=null)}var v=ji(g);if(null!==v){v.flags&=-257;ki(v,g,h,f,b);v.mode&1&&ii(f,n,b);b=v;k=n;var x=b.updateQueue;
|
||||||
|
if(null===x){var z=new Set;z.add(k);b.updateQueue=z}else x.add(k);break a}else{if(0===(b&1)){ii(f,n,b);Ef();break a}k=Error(m(426))}}else if(D&&h.mode&1){var y=ji(g);if(null!==y){0===(y.flags&65536)&&(y.flags|=256);ki(y,g,h,f,b);Ye(Ub(k,h));break a}}f=k=Ub(k,h);4!==L&&(L=2);null===Kc?Kc=[f]:Kc.push(f);f=g;do{switch(f.tag){case 3:f.flags|=65536;b&=-b;f.lanes|=b;var w=gi(f,k,b);Gh(f,w);break a;case 1:h=k;var A=f.type,t=f.stateNode;if(0===(f.flags&128)&&("function"===typeof A.getDerivedStateFromError||
|
||||||
|
null!==t&&"function"===typeof t.componentDidCatch&&(null===ib||!ib.has(t)))){f.flags|=65536;b&=-b;f.lanes|=b;var B=hi(f,h,b);Gh(f,B);break a}}f=f.return}while(null!==f)}Qi(c)}catch(ma){b=ma;H===c&&null!==c&&(H=c=c.return);continue}break}while(1)}function Oi(){var a=Od.current;Od.current=zd;return null===a?zd:a}function Ef(){if(0===L||3===L||2===L)L=4;null===O||0===(ra&268435455)&&0===(Md&268435455)||kb(O,U)}function Nd(a,b){var c=p;p|=2;var d=Oi();if(O!==a||U!==b)Ra=null,wb(a,b);do try{Ok();break}catch(e){Pi(a,
|
||||||
|
e)}while(1);af();p=c;Od.current=d;if(null!==H)throw Error(m(261));O=null;U=0;return L}function Ok(){for(;null!==H;)Ri(H)}function Kk(){for(;null!==H&&!Pk();)Ri(H)}function Ri(a){var b=Qk(a.alternate,a,ba);a.memoizedProps=a.pendingProps;null===b?Qi(a):H=b;Uf.current=null}function Qi(a){var b=a;do{var c=b.alternate;a=b.return;if(0===(b.flags&32768)){if(c=xk(c,b,ba),null!==c){H=c;return}}else{c=Bk(c,b);if(null!==c){c.flags&=32767;H=c;return}if(null!==a)a.flags|=32768,a.subtreeFlags=0,a.deletions=null;
|
||||||
|
else{L=6;H=null;return}}b=b.sibling;if(null!==b){H=b;return}H=b=a}while(null!==b);0===L&&(L=5)}function xb(a,b,c){var d=z,e=ca.transition;try{ca.transition=null,z=1,Rk(a,b,c,d)}finally{ca.transition=e,z=d}return null}function Rk(a,b,c,d){do Xb();while(null!==lb);if(0!==(p&6))throw Error(m(327));c=a.finishedWork;var e=a.finishedLanes;if(null===c)return null;a.finishedWork=null;a.finishedLanes=0;if(c===a.current)throw Error(m(177));a.callbackNode=null;a.callbackPriority=0;var f=c.lanes|c.childLanes;
|
||||||
|
uj(a,f);a===O&&(H=O=null,U=0);0===(c.subtreeFlags&2064)&&0===(c.flags&2064)||Pd||(Pd=!0,Mi(ad,function(){Xb();return null}));f=0!==(c.flags&15990);if(0!==(c.subtreeFlags&15990)||f){f=ca.transition;ca.transition=null;var g=z;z=1;var h=p;p|=4;Uf.current=null;Ck(a,c);Fi(c,a);Tj(Kf);Zc=!!Jf;Kf=Jf=null;a.current=c;Hk(c,a,e);Sk();p=h;z=g;ca.transition=f}else a.current=c;Pd&&(Pd=!1,lb=a,Qd=e);f=a.pendingLanes;0===f&&(ib=null);oj(c.stateNode,d);ia(a,P());if(null!==b)for(d=a.onRecoverableError,c=0;c<b.length;c++)e=
|
||||||
|
b[c],d(e.value,{componentStack:e.stack,digest:e.digest});if(Ed)throw Ed=!1,a=xf,xf=null,a;0!==(Qd&1)&&0!==a.tag&&Xb();f=a.pendingLanes;0!==(f&1)?a===Pf?Ic++:(Ic=0,Pf=a):Ic=0;db();return null}function Xb(){if(null!==lb){var a=Eg(Qd),b=ca.transition,c=z;try{ca.transition=null;z=16>a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;k<h.length;k++){var n=
|
||||||
|
h[k];for(l=n;null!==l;){var q=l;switch(q.tag){case 0:case 11:case 15:Gc(8,q,f)}var u=q.child;if(null!==u)u.return=q,l=u;else for(;null!==l;){q=l;var r=q.sibling,v=q.return;Ai(q);if(q===n){l=null;break}if(null!==r){r.return=v;l=r;break}l=v}}}var x=f.alternate;if(null!==x){var y=x.child;if(null!==y){x.child=null;do{var C=y.sibling;y.sibling=null;y=C}while(null!==y)}}l=f}}if(0!==(f.subtreeFlags&2064)&&null!==g)g.return=f,l=g;else b:for(;null!==l;){f=l;if(0!==(f.flags&2048))switch(f.tag){case 0:case 11:case 15:Gc(9,
|
||||||
|
f,f.return)}var w=f.sibling;if(null!==w){w.return=f.return;l=w;break b}l=f.return}}var A=a.current;for(l=A;null!==l;){g=l;var t=g.child;if(0!==(g.subtreeFlags&2064)&&null!==t)t.return=g,l=t;else b:for(g=A;null!==l;){h=l;if(0!==(h.flags&2048))try{switch(h.tag){case 0:case 11:case 15:Id(9,h)}}catch(ma){G(h,h.return,ma)}if(h===g){l=null;break b}var B=h.sibling;if(null!==B){B.return=h.return;l=B;break b}l=h.return}}p=e;db();if(Ca&&"function"===typeof Ca.onPostCommitFiberRoot)try{Ca.onPostCommitFiberRoot(Uc,
|
||||||
|
a)}catch(ma){}d=!0}return d}finally{z=c,ca.transition=b}}return!1}function Si(a,b,c){b=Ub(c,b);b=gi(a,b,1);a=fb(a,b,1);b=Z();null!==a&&(ic(a,1,b),ia(a,b))}function G(a,b,c){if(3===a.tag)Si(a,a,c);else for(;null!==b;){if(3===b.tag){Si(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError||"function"===typeof d.componentDidCatch&&(null===ib||!ib.has(d))){a=Ub(c,a);a=hi(b,a,1);b=fb(b,a,1);a=Z();null!==b&&(ic(b,1,a),ia(b,a));break}}b=b.return}}function sk(a,
|
||||||
|
b,c){var d=a.pingCache;null!==d&&d.delete(b);b=Z();a.pingedLanes|=a.suspendedLanes&c;O===a&&(U&c)===c&&(4===L||3===L&&(U&130023424)===U&&500>P()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);
|
||||||
|
break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a=
|
||||||
|
a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=
|
||||||
|
a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a=
|
||||||
|
pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType=
|
||||||
|
Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=
|
||||||
|
0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,
|
||||||
|
pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Cb,key:null==d?null:""+d,children:a,containerInfo:b,implementation:c}}function Vi(a){if(!a)return cb;a=a._reactInternals;a:{if(nb(a)!==a||1!==a.tag)throw Error(m(170));var b=a;do{switch(b.tag){case 3:b=b.stateNode.context;break a;case 1:if(ea(b.type)){b=b.stateNode.__reactInternalMemoizedMergedChildContext;break a}}b=b.return}while(null!==b);throw Error(m(171));
|
||||||
|
}if(1===a.tag){var c=a.type;if(ea(c))return uh(a,c,b)}return b}function Wi(a,b,c,d,e,f,g,h,k,l){a=Vf(c,d,!0,a,e,f,g,h,k);a.context=Vi(null);c=a.current;d=Z();e=hb(c);f=Pa(d,e);f.callback=void 0!==b&&null!==b?b:null;fb(c,f,e);a.current.lanes=e;ic(a,e,d);ia(a,d);return a}function Sd(a,b,c,d){var e=b.current,f=Z(),g=hb(e);c=Vi(c);null===b.context?b.context=c:b.pendingContext=c;b=Pa(f,g);b.payload={element:a};d=void 0===d?null:d;null!==d&&(b.callback=d);a=fb(e,b,g);null!==a&&(xa(a,e,g,f),vd(a,e,g));return g}
|
||||||
|
function Td(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Xi(a,b){a=a.memoizedState;if(null!==a&&null!==a.dehydrated){var c=a.retryLane;a.retryLane=0!==c&&c<b?c:b}}function Wf(a,b){Xi(a,b);(a=a.alternate)&&Xi(a,b)}function Xk(a){a=Bg(a);return null===a?null:a.stateNode}function Yk(a){return null}function Xf(a){this._internalRoot=a}function Ud(a){this._internalRoot=a}function Yf(a){return!(!a||1!==a.nodeType&&9!==
|
||||||
|
a.nodeType&&11!==a.nodeType)}function Vd(a){return!(!a||1!==a.nodeType&&9!==a.nodeType&&11!==a.nodeType&&(8!==a.nodeType||" react-mount-point-unstable "!==a.nodeValue))}function Yi(){}function Zk(a,b,c,d,e){if(e){if("function"===typeof d){var f=d;d=function(){var a=Td(g);f.call(a)}}var g=Wi(b,d,a,0,null,!1,!1,"",Yi);a._reactRootContainer=g;a[Ja]=g.current;sc(8===a.nodeType?a.parentNode:a);yb();return g}for(;e=a.lastChild;)a.removeChild(e);if("function"===typeof d){var h=d;d=function(){var a=Td(k);
|
||||||
|
h.call(a)}}var k=Vf(a,0,!1,null,null,!1,!1,"",Yi);a._reactRootContainer=k;a[Ja]=k.current;sc(8===a.nodeType?a.parentNode:a);yb(function(){Sd(b,k,c,d)});return k}function Wd(a,b,c,d,e){var f=c._reactRootContainer;if(f){var g=f;if("function"===typeof e){var h=e;e=function(){var a=Td(g);h.call(a)}}Sd(b,g,a,e)}else g=Zk(c,b,a,e,d);return Td(g)}var cg=new Set,$b={},Ia=!("undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement),Zd=Object.prototype.hasOwnProperty,
|
||||||
|
cj=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eg={},dg={},R={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){R[a]=
|
||||||
|
new Y(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];R[b]=new Y(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){R[a]=new Y(a,2,!1,a.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){R[a]=new Y(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){R[a]=
|
||||||
|
new Y(a,3,!1,a.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(a){R[a]=new Y(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){R[a]=new Y(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){R[a]=new Y(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){R[a]=new Y(a,5,!1,a.toLowerCase(),null,!1,!1)});var Zf=/[\-:]([a-z])/g,$f=function(a){return a[1].toUpperCase()};"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=
|
||||||
|
a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!1,!1)});R.xlinkHref=new Y("xlinkHref",
|
||||||
|
1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!0,!0)});var Sa=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,sd=Symbol.for("react.element"),Cb=Symbol.for("react.portal"),Bb=Symbol.for("react.fragment"),fe=Symbol.for("react.strict_mode"),ee=Symbol.for("react.profiler"),hg=Symbol.for("react.provider"),gg=Symbol.for("react.context"),ie=Symbol.for("react.forward_ref"),ge=Symbol.for("react.suspense"),
|
||||||
|
he=Symbol.for("react.suspense_list"),je=Symbol.for("react.memo"),Ta=Symbol.for("react.lazy");Symbol.for("react.scope");Symbol.for("react.debug_trace_mode");var Ui=Symbol.for("react.offscreen");Symbol.for("react.legacy_hidden");Symbol.for("react.cache");Symbol.for("react.tracing_marker");var fg=Symbol.iterator,E=Object.assign,ae,ce=!1,cc=Array.isArray,Xd,yi=function(a){return"undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,
|
||||||
|
c,d,e)})}:a}(function(a,b){if("http://www.w3.org/2000/svg"!==a.namespaceURI||"innerHTML"in a)a.innerHTML=b;else{Xd=Xd||document.createElement("div");Xd.innerHTML="<svg>"+b.valueOf().toString()+"</svg>";for(b=Xd.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}),Fc=function(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b},dc={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,
|
||||||
|
borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,
|
||||||
|
strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$k=["Webkit","ms","Moz","O"];Object.keys(dc).forEach(function(a){$k.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);dc[b]=dc[a]})});var ij=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),ze=null,se=null,Eb=null,Fb=null,xg=function(a,b){return a(b)},yg=function(){},te=!1,Oe=!1;if(Ia)try{var Lc={};Object.defineProperty(Lc,
|
||||||
|
"passive",{get:function(){Oe=!0}});window.addEventListener("test",Lc,Lc);window.removeEventListener("test",Lc,Lc)}catch(a){Oe=!1}var kj=function(a,b,c,d,e,f,g,h,k){var l=Array.prototype.slice.call(arguments,3);try{b.apply(c,l)}catch(q){this.onError(q)}},gc=!1,Sc=null,Tc=!1,ue=null,lj={onError:function(a){gc=!0;Sc=a}},Ba=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler,Jg=Ba.unstable_scheduleCallback,Kg=Ba.unstable_NormalPriority,xh=Jg,Ki=Ba.unstable_cancelCallback,Pk=Ba.unstable_shouldYield,
|
||||||
|
Sk=Ba.unstable_requestPaint,P=Ba.unstable_now,Dj=Ba.unstable_getCurrentPriorityLevel,De=Ba.unstable_ImmediatePriority,Mg=Ba.unstable_UserBlockingPriority,ad=Kg,Ej=Ba.unstable_LowPriority,Ng=Ba.unstable_IdlePriority,Uc=null,Ca=null,ta=Math.clz32?Math.clz32:pj,qj=Math.log,rj=Math.LN2,Wc=64,Rd=4194304,z=0,Ae=!1,Yc=[],Va=null,Wa=null,Xa=null,jc=new Map,kc=new Map,Ya=[],Bj="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "),
|
||||||
|
Gb=Sa.ReactCurrentBatchConfig,Zc=!0,$c=null,Za=null,Ee=null,bd=null,Yb={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(a){return a.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},He=ka(Yb),Mc=E({},Yb,{view:0,detail:0}),ak=ka(Mc),ag,bg,Nc,Yd=E({},Mc,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Fe,button:0,buttons:0,relatedTarget:function(a){return void 0===a.relatedTarget?a.fromElement===a.srcElement?a.toElement:a.fromElement:
|
||||||
|
a.relatedTarget},movementX:function(a){if("movementX"in a)return a.movementX;a!==Nc&&(Nc&&"mousemove"===a.type?(ag=a.screenX-Nc.screenX,bg=a.screenY-Nc.screenY):bg=ag=0,Nc=a);return ag},movementY:function(a){return"movementY"in a?a.movementY:bg}}),ih=ka(Yd),al=E({},Yd,{dataTransfer:0}),Wj=ka(al),bl=E({},Mc,{relatedTarget:0}),Pe=ka(bl),cl=E({},Yb,{animationName:0,elapsedTime:0,pseudoElement:0}),Yj=ka(cl),dl=E({},Yb,{clipboardData:function(a){return"clipboardData"in a?a.clipboardData:window.clipboardData}}),
|
||||||
|
ck=ka(dl),el=E({},Yb,{data:0}),qh=ka(el),fk=qh,fl={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},gl={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",
|
||||||
|
112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},Gj={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"},hl=E({},Mc,{key:function(a){if(a.key){var b=fl[a.key]||a.key;if("Unidentified"!==b)return b}return"keypress"===a.type?(a=cd(a),13===a?"Enter":String.fromCharCode(a)):"keydown"===a.type||"keyup"===a.type?gl[a.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,
|
||||||
|
metaKey:0,repeat:0,locale:0,getModifierState:Fe,charCode:function(a){return"keypress"===a.type?cd(a):0},keyCode:function(a){return"keydown"===a.type||"keyup"===a.type?a.keyCode:0},which:function(a){return"keypress"===a.type?cd(a):"keydown"===a.type||"keyup"===a.type?a.keyCode:0}}),Vj=ka(hl),il=E({},Yd,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),nh=ka(il),jl=E({},Mc,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,
|
||||||
|
ctrlKey:0,shiftKey:0,getModifierState:Fe}),Xj=ka(jl),kl=E({},Yb,{propertyName:0,elapsedTime:0,pseudoElement:0}),Zj=ka(kl),ll=E({},Yd,{deltaX:function(a){return"deltaX"in a?a.deltaX:"wheelDeltaX"in a?-a.wheelDeltaX:0},deltaY:function(a){return"deltaY"in a?a.deltaY:"wheelDeltaY"in a?-a.wheelDeltaY:"wheelDelta"in a?-a.wheelDelta:0},deltaZ:0,deltaMode:0}),bk=ka(ll),Hj=[9,13,27,32],Ge=Ia&&"CompositionEvent"in window,Oc=null;Ia&&"documentMode"in document&&(Oc=document.documentMode);var ek=Ia&&"TextEvent"in
|
||||||
|
window&&!Oc,Ug=Ia&&(!Ge||Oc&&8<Oc&&11>=Oc),Tg=String.fromCharCode(32),Sg=!1,Hb=!1,Kj={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},oc=null,pc=null,ph=!1;Ia&&(ph=Lj("input")&&(!document.documentMode||9<document.documentMode));var ua="function"===typeof Object.is?Object.is:Sj,dk=Ia&&"documentMode"in document&&11>=document.documentMode,Jb=null,Ke=null,rc=null,Je=!1,Kb={animationend:gd("Animation","AnimationEnd"),
|
||||||
|
animationiteration:gd("Animation","AnimationIteration"),animationstart:gd("Animation","AnimationStart"),transitionend:gd("Transition","TransitionEnd")},Le={},eh={};Ia&&(eh=document.createElement("div").style,"AnimationEvent"in window||(delete Kb.animationend.animation,delete Kb.animationiteration.animation,delete Kb.animationstart.animation),"TransitionEvent"in window||delete Kb.transitionend.transition);var jh=hd("animationend"),kh=hd("animationiteration"),lh=hd("animationstart"),mh=hd("transitionend"),
|
||||||
|
fh=new Map,Zi="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");
|
||||||
|
(function(){for(var a=0;a<Zi.length;a++){var b=Zi[a],c=b.toLowerCase();b=b[0].toUpperCase()+b.slice(1);$a(c,"on"+b)}$a(jh,"onAnimationEnd");$a(kh,"onAnimationIteration");$a(lh,"onAnimationStart");$a("dblclick","onDoubleClick");$a("focusin","onFocus");$a("focusout","onBlur");$a(mh,"onTransitionEnd")})();Ab("onMouseEnter",["mouseout","mouseover"]);Ab("onMouseLeave",["mouseout","mouseover"]);Ab("onPointerEnter",["pointerout","pointerover"]);Ab("onPointerLeave",["pointerout","pointerover"]);mb("onChange",
|
||||||
|
"change click focusin focusout input keydown keyup selectionchange".split(" "));mb("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" "));mb("onBeforeInput",["compositionend","keypress","textInput","paste"]);mb("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));
|
||||||
|
var Ec="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Uj=new Set("cancel close invalid load scroll toggle".split(" ").concat(Ec)),id="_reactListening"+Math.random().toString(36).slice(2),gk=/\r\n?/g,hk=/\u0000|\uFFFD/g,Jf=null,Kf=null,Rf="function"===typeof setTimeout?setTimeout:void 0,Nk="function"===typeof clearTimeout?
|
||||||
|
clearTimeout:void 0,$i="function"===typeof Promise?Promise:void 0,Jk="function"===typeof queueMicrotask?queueMicrotask:"undefined"!==typeof $i?function(a){return $i.resolve(null).then(a).catch(ik)}:Rf,Zb=Math.random().toString(36).slice(2),Da="__reactFiber$"+Zb,uc="__reactProps$"+Zb,Ja="__reactContainer$"+Zb,Me="__reactEvents$"+Zb,Dk="__reactListeners$"+Zb,Ek="__reactHandles$"+Zb,Se=[],Mb=-1,cb={},J=bb(cb),S=bb(!1),pb=cb,La=null,md=!1,Te=!1,Ob=[],Pb=0,od=null,nd=0,na=[],oa=0,rb=null,Ma=1,Na="",la=
|
||||||
|
null,fa=null,D=!1,wa=null,Ik=Sa.ReactCurrentBatchConfig,Vb=Dh(!0),li=Dh(!1),ud=bb(null),td=null,Rb=null,bf=null,tb=null,kk=Oa,gb=!1,wc={},Ea=bb(wc),yc=bb(wc),xc=bb(wc),F=bb(0),kf=[],yd=Sa.ReactCurrentDispatcher,sf=Sa.ReactCurrentBatchConfig,vb=0,C=null,K=null,N=null,Ad=!1,zc=!1,Ac=0,ml=0,zd={readContext:qa,useCallback:V,useContext:V,useEffect:V,useImperativeHandle:V,useInsertionEffect:V,useLayoutEffect:V,useMemo:V,useReducer:V,useRef:V,useState:V,useDebugValue:V,useDeferredValue:V,useTransition:V,
|
||||||
|
useMutableSource:V,useSyncExternalStore:V,useId:V,unstable_isNewReconciler:!1},lk={readContext:qa,useCallback:function(a,b){Fa().memoizedState=[a,void 0===b?null:b];return a},useContext:qa,useEffect:Sh,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Bd(4194308,4,Vh.bind(null,b,a),c)},useLayoutEffect:function(a,b){return Bd(4194308,4,a,b)},useInsertionEffect:function(a,b){return Bd(4,2,a,b)},useMemo:function(a,b){var c=Fa();b=void 0===b?null:b;a=a();c.memoizedState=
|
||||||
|
[a,b];return a},useReducer:function(a,b,c){var d=Fa();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=qk.bind(null,C,a);return[d.memoizedState,a]},useRef:function(a){var b=Fa();a={current:a};return b.memoizedState=a},useState:Qh,useDebugValue:rf,useDeferredValue:function(a){return Fa().memoizedState=a},useTransition:function(){var a=Qh(!1),b=a[0];a=pk.bind(null,a[1]);Fa().memoizedState=
|
||||||
|
a;return[b,a]},useMutableSource:function(a,b,c){},useSyncExternalStore:function(a,b,c){var d=C,e=Fa();if(D){if(void 0===c)throw Error(m(407));c=c()}else{c=b();if(null===O)throw Error(m(349));0!==(vb&30)||Nh(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;Sh(Lh.bind(null,d,f,a),[a]);d.flags|=2048;Cc(9,Mh.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Fa(),b=O.identifierPrefix;if(D){var c=Na;var d=Ma;c=(d&~(1<<32-ta(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Ac++;0<c&&
|
||||||
|
(b+="H"+c.toString(32));b+=":"}else c=ml++,b=":"+b+"r"+c.toString(32)+":";return a.memoizedState=b},unstable_isNewReconciler:!1},mk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:of,useRef:Rh,useState:function(a){return of(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return Zh(b,K.memoizedState,a)},useTransition:function(){var a=of(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,
|
||||||
|
useSyncExternalStore:Kh,useId:$h,unstable_isNewReconciler:!1},nk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:pf,useRef:Rh,useState:function(a){return pf(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return null===K?b.memoizedState=a:Zh(b,K.memoizedState,a)},useTransition:function(){var a=pf(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,useSyncExternalStore:Kh,useId:$h,
|
||||||
|
unstable_isNewReconciler:!1},Dd={isMounted:function(a){return(a=a._reactInternals)?nb(a)===a:!1},enqueueSetState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueReplaceState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.tag=1;f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueForceUpdate:function(a,b){a=a._reactInternals;
|
||||||
|
var c=Z(),d=hb(a),e=Pa(c,d);e.tag=2;void 0!==b&&null!==b&&(e.callback=b);b=fb(a,e,d);null!==b&&(xa(b,a,d,c),vd(b,a,d))}},rk="function"===typeof WeakMap?WeakMap:Map,tk=Sa.ReactCurrentOwner,ha=!1,Cf={dehydrated:null,treeContext:null,retryLane:0};var zk=function(a,b,c,d){for(c=b.child;null!==c;){if(5===c.tag||6===c.tag)a.appendChild(c.stateNode);else if(4!==c.tag&&null!==c.child){c.child.return=c;c=c.child;continue}if(c===b)break;for(;null===c.sibling;){if(null===c.return||c.return===b)return;c=c.return}c.sibling.return=
|
||||||
|
c.return;c=c.sibling}};var xi=function(a,b){};var yk=function(a,b,c,d,e){var f=a.memoizedProps;if(f!==d){a=b.stateNode;ub(Ea.current);e=null;switch(c){case "input":f=ke(a,f);d=ke(a,d);e=[];break;case "select":f=E({},f,{value:void 0});d=E({},d,{value:void 0});e=[];break;case "textarea":f=ne(a,f);d=ne(a,d);e=[];break;default:"function"!==typeof f.onClick&&"function"===typeof d.onClick&&(a.onclick=kd)}pe(c,d);var g;c=null;for(l in f)if(!d.hasOwnProperty(l)&&f.hasOwnProperty(l)&&null!=f[l])if("style"===
|
||||||
|
l){var h=f[l];for(g in h)h.hasOwnProperty(g)&&(c||(c={}),c[g]="")}else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&($b.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in d){var k=d[l];h=null!=f?f[l]:void 0;if(d.hasOwnProperty(l)&&k!==h&&(null!=k||null!=h))if("style"===l)if(h){for(g in h)!h.hasOwnProperty(g)||k&&k.hasOwnProperty(g)||(c||(c={}),c[g]="");for(g in k)k.hasOwnProperty(g)&&h[g]!==k[g]&&(c||
|
||||||
|
(c={}),c[g]=k[g])}else c||(e||(e=[]),e.push(l,c)),c=k;else"dangerouslySetInnerHTML"===l?(k=k?k.__html:void 0,h=h?h.__html:void 0,null!=k&&h!==k&&(e=e||[]).push(l,k)):"children"===l?"string"!==typeof k&&"number"!==typeof k||(e=e||[]).push(l,""+k):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&($b.hasOwnProperty(l)?(null!=k&&"onScroll"===l&&B("scroll",a),e||h===k||(e=[])):(e=e||[]).push(l,k))}c&&(e=e||[]).push("style",c);var l=e;if(b.updateQueue=l)b.flags|=4}};var Ak=function(a,
|
||||||
|
b,c,d){c!==d&&(b.flags|=4)};var Jd=!1,X=!1,Fk="function"===typeof WeakSet?WeakSet:Set,l=null,zi=!1,T=null,za=!1,Mk=Math.ceil,Od=Sa.ReactCurrentDispatcher,Uf=Sa.ReactCurrentOwner,ca=Sa.ReactCurrentBatchConfig,p=0,O=null,H=null,U=0,ba=0,Ga=bb(0),L=0,Jc=null,ra=0,Md=0,Sf=0,Kc=null,ja=null,Of=0,Hf=Infinity,Ra=null,Ed=!1,xf=null,ib=null,Pd=!1,lb=null,Qd=0,Ic=0,Pf=null,Kd=-1,Ld=0;var Qk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||S.current)ha=!0;else{if(0===(a.lanes&c)&&0===(b.flags&
|
||||||
|
128))return ha=!1,wk(a,b,c);ha=0!==(a.flags&131072)?!0:!1}else ha=!1,D&&0!==(b.flags&1048576)&&yh(b,nd,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;Fd(a,b);a=b.pendingProps;var e=Nb(b,J.current);Sb(b,c);e=mf(null,b,d,a,e,c);var f=nf();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=null,ea(d)?(f=!0,ld(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ff(b),e.updater=Dd,b.stateNode=
|
||||||
|
e,e._reactInternals=b,uf(b,d,a,c),b=Af(null,b,d,!0,f,c)):(b.tag=0,D&&f&&Ue(b),aa(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{Fd(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Uk(d);a=ya(d,a);switch(e){case 0:b=zf(null,b,d,a,c);break a;case 1:b=ri(null,b,d,a,c);break a;case 11:b=mi(null,b,d,a,c);break a;case 14:b=ni(null,b,d,ya(d.type,a),c);break a}throw Error(m(306,d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),zf(a,b,d,e,c);
|
||||||
|
case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),ri(a,b,d,e,c);case 3:a:{si(b);if(null===a)throw Error(m(387));d=b.pendingProps;f=b.memoizedState;e=f.element;Fh(a,b);wd(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){e=Ub(Error(m(423)),b);b=ti(a,b,d,c,e);break a}else if(d!==e){e=
|
||||||
|
Ub(Error(m(424)),b);b=ti(a,b,d,c,e);break a}else for(fa=Ka(b.stateNode.containerInfo.firstChild),la=b,D=!0,wa=null,c=li(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Qb();if(d===e){b=Qa(a,b,c);break a}aa(a,b,d,c)}b=b.child}return b;case 5:return Ih(b),null===a&&Xe(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Qe(d,e)?g=null:null!==f&&Qe(d,f)&&(b.flags|=32),qi(a,b),aa(a,b,g,c),b.child;case 6:return null===a&&Xe(b),null;case 13:return ui(a,b,c);case 4:return gf(b,
|
||||||
|
b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Vb(b,null,d,c):aa(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),mi(a,b,d,e,c);case 7:return aa(a,b,b.pendingProps,c),b.child;case 8:return aa(a,b,b.pendingProps.children,c),b.child;case 12:return aa(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;g=e.value;y(ud,d._currentValue);d._currentValue=g;if(null!==f)if(ua(f.value,g)){if(f.children===
|
||||||
|
e.children&&!S.current){b=Qa(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=Pa(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var p=l.pending;null===p?k.next=k:(k.next=p.next,p.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);df(f.return,c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===
|
||||||
|
f.tag){g=f.return;if(null===g)throw Error(m(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);df(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}aa(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Sb(b,c),e=qa(e),d=d(e),b.flags|=1,aa(a,b,d,c),b.child;case 14:return d=b.type,e=ya(d,b.pendingProps),e=ya(d.type,e),ni(a,b,d,e,c);case 15:return oi(a,
|
||||||
|
b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),Fd(a,b),b.tag=1,ea(d)?(a=!0,ld(b)):a=!1,Sb(b,c),ei(b,d,e),uf(b,d,e,c),Af(null,b,d,!0,a,c);case 19:return wi(a,b,c);case 22:return pi(a,b,c)}throw Error(m(156,b.tag));};var pa=function(a,b,c,d){return new Tk(a,b,c,d)},aj="function"===typeof reportError?reportError:function(a){console.error(a)};Ud.prototype.render=Xf.prototype.render=function(a){var b=this._internalRoot;if(null===b)throw Error(m(409));
|
||||||
|
Sd(a,b,null,null)};Ud.prototype.unmount=Xf.prototype.unmount=function(){var a=this._internalRoot;if(null!==a){this._internalRoot=null;var b=a.containerInfo;yb(function(){Sd(null,a,null,null)});b[Ja]=null}};Ud.prototype.unstable_scheduleHydration=function(a){if(a){var b=nl();a={blockedOn:null,target:a,priority:b};for(var c=0;c<Ya.length&&0!==b&&b<Ya[c].priority;c++);Ya.splice(c,0,a);0===c&&Hg(a)}};var Cj=function(a){switch(a.tag){case 3:var b=a.stateNode;if(b.current.memoizedState.isDehydrated){var c=
|
||||||
|
hc(b.pendingLanes);0!==c&&(xe(b,c|1),ia(b,P()),0===(p&6)&&(Hc(),db()))}break;case 13:yb(function(){var b=Oa(a,1);if(null!==b){var c=Z();xa(b,a,1,c)}}),Wf(a,1)}};var Gg=function(a){if(13===a.tag){var b=Oa(a,134217728);if(null!==b){var c=Z();xa(b,a,134217728,c)}Wf(a,134217728)}};var xj=function(a){if(13===a.tag){var b=hb(a),c=Oa(a,b);if(null!==c){var d=Z();xa(c,a,b,d)}Wf(a,b)}};var nl=function(){return z};var wj=function(a,b){var c=z;try{return z=a,b()}finally{z=c}};se=function(a,b,c){switch(b){case "input":le(a,
|
||||||
|
c);b=c.name;if("radio"===c.type&&null!=b){for(c=a;c.parentNode;)c=c.parentNode;c=c.querySelectorAll("input[name="+JSON.stringify(""+b)+'][type="radio"]');for(b=0;b<c.length;b++){var d=c[b];if(d!==a&&d.form===a.form){var e=Rc(d);if(!e)throw Error(m(90));jg(d);le(d,e)}}}break;case "textarea":og(a,c);break;case "select":b=c.value,null!=b&&Db(a,!!c.multiple,b,!1)}};(function(a,b,c){xg=a;yg=c})(Tf,function(a,b,c,d,e){var f=z,g=ca.transition;try{return ca.transition=null,z=1,a(b,c,d,e)}finally{z=f,ca.transition=
|
||||||
|
g,0===p&&Hc()}},yb);var ol={usingClientEntryPoint:!1,Events:[ec,Ib,Rc,ug,vg,Tf]};(function(a){a={bundleType:a.bundleType,version:a.version,rendererPackageName:a.rendererPackageName,rendererConfig:a.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:Sa.ReactCurrentDispatcher,findHostInstanceByFiber:Xk,
|
||||||
|
findFiberByHostInstance:a.findFiberByHostInstance||Yk,findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:"18.3.1"};if("undefined"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)a=!1;else{var b=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(b.isDisabled||!b.supportsFiber)a=!0;else{try{Uc=b.inject(a),Ca=b}catch(c){}a=b.checkDCE?!0:!1}}return a})({findFiberByHostInstance:ob,bundleType:0,version:"18.3.1-next-f1338f8080-20240426",
|
||||||
|
rendererPackageName:"react-dom"});Q.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=ol;Q.createPortal=function(a,b){var c=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Yf(b))throw Error(m(200));return Wk(a,b,null,c)};Q.createRoot=function(a,b){if(!Yf(a))throw Error(m(299));var c=!1,d="",e=aj;null!==b&&void 0!==b&&(!0===b.unstable_strictMode&&(c=!0),void 0!==b.identifierPrefix&&(d=b.identifierPrefix),void 0!==b.onRecoverableError&&(e=b.onRecoverableError));b=Vf(a,1,!1,null,null,
|
||||||
|
c,!1,d,e);a[Ja]=b.current;sc(8===a.nodeType?a.parentNode:a);return new Xf(b)};Q.findDOMNode=function(a){if(null==a)return null;if(1===a.nodeType)return a;var b=a._reactInternals;if(void 0===b){if("function"===typeof a.render)throw Error(m(188));a=Object.keys(a).join(",");throw Error(m(268,a));}a=Bg(b);a=null===a?null:a.stateNode;return a};Q.flushSync=function(a){return yb(a)};Q.hydrate=function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!0,c)};Q.hydrateRoot=function(a,b,c){if(!Yf(a))throw Error(m(405));
|
||||||
|
var d=null!=c&&c.hydratedSources||null,e=!1,f="",g=aj;null!==c&&void 0!==c&&(!0===c.unstable_strictMode&&(e=!0),void 0!==c.identifierPrefix&&(f=c.identifierPrefix),void 0!==c.onRecoverableError&&(g=c.onRecoverableError));b=Wi(b,null,a,1,null!=c?c:null,e,!1,f,g);a[Ja]=b.current;sc(a);if(d)for(a=0;a<d.length;a++)c=d[a],e=c._getVersion,e=e(c._source),null==b.mutableSourceEagerHydrationData?b.mutableSourceEagerHydrationData=[c,e]:b.mutableSourceEagerHydrationData.push(c,e);return new Ud(b)};Q.render=
|
||||||
|
function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!1,c)};Q.unmountComponentAtNode=function(a){if(!Vd(a))throw Error(m(40));return a._reactRootContainer?(yb(function(){Wd(null,null,a,!1,function(){a._reactRootContainer=null;a[Ja]=null})}),!0):!1};Q.unstable_batchedUpdates=Tf;Q.unstable_renderSubtreeIntoContainer=function(a,b,c,d){if(!Vd(c))throw Error(m(200));if(null==a||void 0===a._reactInternals)throw Error(m(38));return Wd(a,b,c,!1,d)};Q.version="18.3.1-next-f1338f8080-20240426"});
|
||||||
|
})();
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
(function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
|
||||||
|
e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
|
||||||
|
function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
|
||||||
|
N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
|
||||||
|
("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
|
||||||
|
0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
|
||||||
|
function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
|
||||||
|
k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
|
||||||
|
ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
|
||||||
|
b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
|
||||||
|
ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
|
||||||
|
void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
|
||||||
|
ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
|
||||||
|
b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
|
||||||
|
k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
|
||||||
|
ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
|
||||||
|
t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
|
||||||
|
Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
|
||||||
|
render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
|
||||||
|
c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
|
||||||
|
c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
|
||||||
|
})();
|
||||||
Vendored
+83
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||||
Binary file not shown.
+153
-29
@@ -3,7 +3,6 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
from celery import Celery
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
||||||
from app.core.audio_editor import (
|
from app.core.audio_editor import (
|
||||||
@@ -13,30 +12,124 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
celery_app = Celery(
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
"audio_tasks",
|
# Task layer 2 che do:
|
||||||
broker=settings.CELERY_BROKER_URL,
|
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
|
||||||
backend=settings.CELERY_RESULT_BACKEND
|
# - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
|
||||||
)
|
# (thread nen + registry dict), API contract GIONG het (.delay() tra
|
||||||
|
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from celery import Celery
|
||||||
|
HAS_CELERY = True
|
||||||
|
except Exception: # pragma: no cover - frozen desktop slim build
|
||||||
|
Celery = None
|
||||||
|
HAS_CELERY = False
|
||||||
|
|
||||||
celery_app.conf.update(
|
if HAS_CELERY:
|
||||||
task_serializer="json",
|
celery_app = Celery(
|
||||||
accept_content=["json"],
|
"audio_tasks",
|
||||||
result_serializer="json",
|
broker=settings.CELERY_BROKER_URL,
|
||||||
timezone="UTC",
|
backend=settings.CELERY_RESULT_BACKEND
|
||||||
enable_utc=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
celery_app.conf.update(
|
||||||
celery_app.conf.beat_schedule = {
|
task_serializer="json",
|
||||||
"cleanup-expired-files-every-hour": {
|
accept_content=["json"],
|
||||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
result_serializer="json",
|
||||||
"schedule": 3600.0, # Chạy mỗi giờ
|
timezone="UTC",
|
||||||
},
|
enable_utc=True,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
||||||
|
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
|
||||||
|
if os.getenv("SF_DESKTOP") == "1":
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_always_eager=True,
|
||||||
|
task_eager_propagates=True,
|
||||||
|
broker_url="memory://",
|
||||||
|
result_backend="cache+memory://",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Lich trinh tu dong don dep file het han (Week 5) ──
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"cleanup-expired-files-every-hour": {
|
||||||
|
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||||
|
"schedule": 3600.0, # Chay moi gio
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
celery_app = None
|
||||||
|
# Registry in-process cho desktop slim: task_id -> {"status", "result"/"error"}
|
||||||
|
_results = {}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
def _task(fn):
|
||||||
|
"""Wrapper: celery task (server) hoac in-process task (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
return celery_app.task(fn)
|
||||||
|
return _InProcessTask(fn)
|
||||||
|
|
||||||
|
|
||||||
|
class _InProcessTask:
|
||||||
|
"""Task chay tren thread nen, ket qua luu vao registry dict — dung cho
|
||||||
|
bundle desktop khong kem celery (tiet kiem ~40MB)."""
|
||||||
|
|
||||||
|
def __init__(self, fn):
|
||||||
|
self._fn = fn
|
||||||
|
|
||||||
|
def delay(self, *args, **kwargs):
|
||||||
|
import threading
|
||||||
|
tid = uuid.uuid4().hex
|
||||||
|
_results[tid] = {"status": "PENDING"}
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
result = self._fn(*args, **kwargs)
|
||||||
|
_results[tid] = {"status": "SUCCESS", "result": result}
|
||||||
|
except Exception as e: # noqa: BLE001 - bao loi day du cho UI
|
||||||
|
logger.exception("In-process task %s failed", tid)
|
||||||
|
_results[tid] = {"status": "FAILURE", "error": str(e)}
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"task-{tid[:8]}").start()
|
||||||
|
return _SimpleAsyncResult(tid)
|
||||||
|
|
||||||
|
|
||||||
|
class _SimpleAsyncResult:
|
||||||
|
"""Giong celery.result.AsyncResult ve mat API cho desktop slim."""
|
||||||
|
|
||||||
|
def __init__(self, task_id):
|
||||||
|
self.task_id = task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
"""Giong celery.result.AsyncResult.id — audio.py dung task.id."""
|
||||||
|
return self.task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status", "PENDING")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self):
|
||||||
|
return _results.get(self.task_id, {}).get("result")
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status") in ("SUCCESS", "FAILURE")
|
||||||
|
|
||||||
|
def successful(self):
|
||||||
|
return self.status == "SUCCESS"
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_result(task_id):
|
||||||
|
"""Tra AsyncResult (celery) hoac _SimpleAsyncResult (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
return AsyncResult(task_id, app=celery_app)
|
||||||
|
return _SimpleAsyncResult(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@_task
|
||||||
def analyze_audio_task(file_id: str):
|
def analyze_audio_task(file_id: str):
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -44,7 +137,7 @@ def analyze_audio_task(file_id: str):
|
|||||||
return analyze_audio(file_path)
|
return analyze_audio(file_path)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
||||||
model: str = "deepseek-chat"):
|
model: str = "deepseek-chat"):
|
||||||
"""
|
"""
|
||||||
@@ -68,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def edit_audio_task(config: dict):
|
def edit_audio_task(config: dict):
|
||||||
file_id = config.get("file_id")
|
file_id = config.get("file_id")
|
||||||
|
|
||||||
@@ -88,7 +181,7 @@ def edit_audio_task(config: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def export_audio_task(file_id: str, format: str = "wav",
|
def export_audio_task(file_id: str, format: str = "wav",
|
||||||
sample_rate: int = 44100, bit_depth: int = 16):
|
sample_rate: int = 44100, bit_depth: int = 16):
|
||||||
"""
|
"""
|
||||||
@@ -121,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def mix_multitrack_task(session_config: dict):
|
def mix_multitrack_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||||
@@ -174,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def process_multitrack_session_task(session_config: dict):
|
def process_multitrack_session_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý toàn bộ session với nhiều tracks và clips.
|
Task xử lý toàn bộ session với nhiều tracks và clips.
|
||||||
@@ -270,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def cleanup_expired_files_task(max_age_hours: int = 24):
|
def cleanup_expired_files_task(max_age_hours: int = 24):
|
||||||
"""
|
"""
|
||||||
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
||||||
@@ -279,6 +372,34 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
max_age_seconds = max_age_hours * 3600
|
max_age_seconds = max_age_hours * 3600
|
||||||
|
|
||||||
|
# Bug #3: KHÔNG xóa file đang được project tham chiếu (tracks[].serverFileId)
|
||||||
|
# — trước đây xóa mọi file > 24h → mất audio của project đã lưu (JSON còn,
|
||||||
|
# file mất, track câm). Quét DB giống list_user_files.
|
||||||
|
referenced = set()
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
from app.models.user import get_db_connection
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT data_json FROM projects")
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
try:
|
||||||
|
proj = _json.loads(row["data_json"])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for track in proj.get("tracks", []):
|
||||||
|
fid = track.get("serverFileId")
|
||||||
|
if fid:
|
||||||
|
referenced.add(os.path.basename(fid.replace("\\", "/")))
|
||||||
|
# Clip audio (kể cả clip sinh từ MIDI render) cũng giữ file
|
||||||
|
for clip in track.get("clips", []):
|
||||||
|
cfid = clip.get("serverFileId")
|
||||||
|
if cfid:
|
||||||
|
referenced.add(os.path.basename(cfid.replace("\\", "/")))
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("cleanup: không đọc được projects DB, bỏ qua tham chiếu: %s", e)
|
||||||
|
|
||||||
cleaned_count = 0
|
cleaned_count = 0
|
||||||
cleaned_size = 0
|
cleaned_size = 0
|
||||||
|
|
||||||
@@ -288,6 +409,8 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
|
|
||||||
for filepath in glob.glob(os.path.join(directory, "*")):
|
for filepath in glob.glob(os.path.join(directory, "*")):
|
||||||
if os.path.isfile(filepath):
|
if os.path.isfile(filepath):
|
||||||
|
if os.path.basename(filepath) in referenced:
|
||||||
|
continue
|
||||||
file_age = now - os.path.getmtime(filepath)
|
file_age = now - os.path.getmtime(filepath)
|
||||||
if file_age > max_age_seconds:
|
if file_age > max_age_seconds:
|
||||||
file_size = os.path.getsize(filepath)
|
file_size = os.path.getsize(filepath)
|
||||||
@@ -301,11 +424,12 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
return {
|
return {
|
||||||
"cleaned_files": cleaned_count,
|
"cleaned_files": cleaned_count,
|
||||||
"cleaned_size_mb": round(cleaned_size / (1024 * 1024), 2),
|
"cleaned_size_mb": round(cleaned_size / (1024 * 1024), 2),
|
||||||
"max_age_hours": max_age_hours
|
"max_age_hours": max_age_hours,
|
||||||
|
"referenced_files_kept": len(referenced),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
||||||
"""
|
"""
|
||||||
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
||||||
|
|||||||
+235
-10
@@ -3,24 +3,50 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>SonicForge Studio - Professional DAW Editor</title>
|
<title>SonicForge Studio - Professional DAW Editor</title>
|
||||||
|
<script>
|
||||||
|
// ── Chặn BROWSER ZOOM toàn trang ──
|
||||||
|
// UI dùng px cố định cho item/button/label — browser zoom (Ctrl+wheel,
|
||||||
|
// Ctrl+plus/minus/0, pinch) scale TOÀN BỘ làm control phóng to theo.
|
||||||
|
// Chặn ở capture phase bằng preventDefault() (KHÔNG stopPropagation —
|
||||||
|
// các vùng zoom chuyên dụng: timeline, piano roll, canvas, EQ vẫn nhận
|
||||||
|
// event và tự xử lý zoom nội dung của chúng).
|
||||||
|
document.addEventListener('wheel', function (e) {
|
||||||
|
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
||||||
|
}, { capture: true, passive: false });
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '_', '0'].indexOf(e.key) !== -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
// Pinch zoom (Safari/WebKit gesture events)
|
||||||
|
document.addEventListener('gesturestart', function (e) { e.preventDefault(); }, { passive: false });
|
||||||
|
document.addEventListener('gesturechange', function (e) { e.preventDefault(); }, { passive: false });
|
||||||
|
</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Bug #11: vendor local — trước đây load CDN (unpkg/cdn.tailwindcss/cdnjs) →
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
desktop Tauri offline trắng màn hình. Các file này nằm /static/vendor/. -->
|
||||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
<script src="/static/vendor/tailwindcdn.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
<script src="/static/vendor/lucide.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/static/vendor/fontawesome.min.css">
|
||||||
|
<script src="/static/vendor/react.production.min.js"></script>
|
||||||
|
<script src="/static/vendor/react-dom.production.min.js"></script>
|
||||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||||
<script src="/static/js/services/api.js?v=202607271016"></script>
|
<script src="/static/js/services/runtime.js?v=202608101800"></script>
|
||||||
|
<script src="/static/js/services/api.js?v=202608101800"></script>
|
||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202607271016"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608101800"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202607271245" defer></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=202608102202" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -29,8 +55,43 @@
|
|||||||
--top-bar-height: 80px;
|
--top-bar-height: 80px;
|
||||||
--status-bar-height: 25px;
|
--status-bar-height: 25px;
|
||||||
--panel-border-color: #2a2a2a;
|
--panel-border-color: #2a2a2a;
|
||||||
|
/* Theme (Preferences) — có thể override bằng data-theme trên <html> */
|
||||||
|
--sf-bg: #1e1e1e;
|
||||||
|
--sf-panel: #262626;
|
||||||
|
--sf-header: #2e2e2e;
|
||||||
|
--sf-border: #181818;
|
||||||
|
--sf-accent: #00ffcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── THEME presets (Tools → Preferences) ── */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--sf-bg: #1e1e1e; --sf-panel: #262626; --sf-header: #2e2e2e; --sf-border: #181818; --sf-accent: #00ffcc;
|
||||||
|
}
|
||||||
|
html[data-theme="midnight"] {
|
||||||
|
--sf-bg: #0f172a; --sf-panel: #1e293b; --sf-header: #1e293b; --sf-border: #0f172a; --sf-accent: #38bdf8;
|
||||||
|
}
|
||||||
|
html[data-theme="forest"] {
|
||||||
|
--sf-bg: #111c15; --sf-panel: #1b2a1e; --sf-header: #1e2d21; --sf-border: #0c1510; --sf-accent: #34d399;
|
||||||
|
}
|
||||||
|
html[data-theme="violet"] {
|
||||||
|
--sf-bg: #170f26; --sf-panel: #221537; --sf-header: #251640; --sf-border: #110a1c; --sf-accent: #a78bfa;
|
||||||
|
}
|
||||||
|
html[data-theme="graphite"] {
|
||||||
|
--sf-bg: #18181b; --sf-panel: #232327; --sf-header: #27272a; --sf-border: #101012; --sf-accent: #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Áp theme lên các vùng chính của app shell */
|
||||||
|
.daw-app-shell { background-color: var(--sf-bg) !important; }
|
||||||
|
.daw-panel { background-color: var(--sf-panel) !important; }
|
||||||
|
.daw-header { background-color: var(--sf-header) !important; }
|
||||||
|
body { background-color: var(--sf-bg) !important; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--sf-accent); }
|
||||||
|
|
||||||
|
/* ── BUTTON FONT SIZE (Tools → Preferences) ── */
|
||||||
|
html[data-btnfont="sm"] button { font-size: 10px !important; }
|
||||||
|
html[data-btnfont="md"] button { font-size: 12px !important; }
|
||||||
|
html[data-btnfont="lg"] button { font-size: 14px !important; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
color: #c0c0c0;
|
color: #c0c0c0;
|
||||||
@@ -239,6 +300,170 @@
|
|||||||
svg, i[data-lucide] {
|
svg, i[data-lucide] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ozone Mastering Module Styles */
|
||||||
|
.oz-panel {
|
||||||
|
background: linear-gradient(180deg, #131822 0%, #0d1118 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
.oz-card {
|
||||||
|
background: #18202c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.oz-card-active {
|
||||||
|
background: #1e2a3a;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 12px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
.knob-container { touch-action: none; user-select: none; }
|
||||||
|
.knob-dial {
|
||||||
|
transition: transform 0.05s ease-out;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(255,255,255,0.15), 0 3px 8px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
.oz-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
.oz-scrollbar::-webkit-scrollbar-track { background: #080b12; }
|
||||||
|
.oz-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 2px; }
|
||||||
|
.oz-scrollbar::-webkit-scrollbar-thumb:hover { background: #334155; }
|
||||||
|
.oz-font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Master & Track Strip Console Styles (from md/47, md/48) */
|
||||||
|
.strip-bg {
|
||||||
|
background: linear-gradient(180deg, #2f2f2f 0%, #222222 100%);
|
||||||
|
border: 1px solid #141414;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 8px 24px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
.strip-bg-track {
|
||||||
|
background: linear-gradient(180deg, #2c2c2c 0%, #1e1e1e 100%);
|
||||||
|
border: 1px solid #111111;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 10px 30px rgba(0,0,0,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-track {
|
||||||
|
background: #121212;
|
||||||
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.fader-track-bg {
|
||||||
|
background: #101010;
|
||||||
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DAW Metallic Button */
|
||||||
|
.btn-daw {
|
||||||
|
background: linear-gradient(180deg, #444 0%, #2a2a2a 100%);
|
||||||
|
border: 1px solid #1a1a1a;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 2px 4px rgba(0,0,0,0.5);
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
.btn-daw:hover {
|
||||||
|
background: linear-gradient(180deg, #555 0%, #333 100%);
|
||||||
|
}
|
||||||
|
.btn-daw:active {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.8);
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Teal Active State (FX & Power Button) */
|
||||||
|
.btn-teal-active {
|
||||||
|
background: linear-gradient(180deg, #10b981 0%, #047857 100%) !important;
|
||||||
|
border-color: #34d399 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mute Active State */
|
||||||
|
.btn-mute-active {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #991b1b 100%) !important;
|
||||||
|
border-color: #f87171 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solo Active State */
|
||||||
|
.btn-solo-active {
|
||||||
|
background: linear-gradient(180deg, #eab308 0%, #854d0e 100%) !important;
|
||||||
|
border-color: #fde047 !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mono Active State */
|
||||||
|
.btn-mono-active {
|
||||||
|
background: linear-gradient(180deg, #3b82f6 0%, #1e40af 100%) !important;
|
||||||
|
border-color: #60a5fa !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Record Arm Active State */
|
||||||
|
.btn-arm-active {
|
||||||
|
background: radial-gradient(circle, #ef4444 0%, #7f1d1d 100%) !important;
|
||||||
|
border-color: #fca5a5 !important;
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.9), inset 0 1px 2px rgba(255,255,255,0.8) !important;
|
||||||
|
animation: pulse-red 1.2s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); }
|
||||||
|
100% { box-shadow: 0 0 16px rgba(239, 68, 68, 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Vertical Range Fader Slider */
|
||||||
|
input[type=range].fader-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input[type=range].fader-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 24px;
|
||||||
|
width: 22px;
|
||||||
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
input[type=range].fader-slider::-moz-range-thumb {
|
||||||
|
height: 24px;
|
||||||
|
width: 22px;
|
||||||
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Rebuild app.precompiled.js from app.jsx using @babel/standalone (avoids the
|
||||||
|
// Babel 8 ESM-only CLI conflict). Mirrors package.json's build script:
|
||||||
|
// babel app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js
|
||||||
|
import * as Babel from '@babel/standalone';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
const src = readFileSync('app/static/js/app.jsx', 'utf8');
|
||||||
|
const out = Babel.transform(src, {
|
||||||
|
presets: ['react'],
|
||||||
|
filename: 'app.jsx',
|
||||||
|
sourceType: 'script',
|
||||||
|
}).code;
|
||||||
|
writeFileSync('app/static/js/app.precompiled.js', out);
|
||||||
|
console.log('BUILD OK', out.length, 'bytes');
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_linux.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (deb + AppImage)
|
||||||
|
# Chay tren Linux: bash build_linux.sh
|
||||||
|
# Yeu cau: python3, pip, node/npm, rust/cargo, webkit2gtk-4.1, libappindicator,
|
||||||
|
# librsvg (xem README / DISTRIBUTION_PLAN.md)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
# PyInstaller tren Linux can objdump (binutils). May build that phai co:
|
||||||
|
# sudo apt-get install -y binutils
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
echo "Thieu @babel/standalone - dang cai them..."
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources truoc khi tauri build =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (deb + AppImage) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " deb : src-tauri/target/release/bundle/deb/sonicforge-daw_1.0.0_amd64.deb"
|
||||||
|
echo " AppImage: src-tauri/target/release/bundle/appimage/SonicForgeDAW_1.0.0_amd64.AppImage"
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_macos.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (.app + .dmg)
|
||||||
|
# Chay tren macOS: bash build_macos.sh
|
||||||
|
# LUU Y: macOS yeu cau codesign + notarize truoc khi phat hanh ra ngoai
|
||||||
|
# (Gatekeeper). Xem DISTRIBUTION_PLAN.md.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (dmg) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " dmg: src-tauri/target/release/bundle/dmg/SonicForgeDAW_1.0.0_x64.dmg"
|
||||||
|
echo " (Codesign/notarize: codesign --deep -s \"Developer ID Application: ...\" "
|
||||||
|
echo " src-tauri/target/release/bundle/macos/SonicForgeDAW.app ; xcrun notarytool submit ...)"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# build_windows.ps1 - ONE-COMMAND build: daw_engine.exe (PyInstaller) + Tauri v2 (NSIS + MSI)
|
||||||
|
# Chay tren Windows: powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# LUU Y: file nay chi dung ky tu ASCII (khong dau, khong em-dash) - PowerShell 5.1
|
||||||
|
# doc .ps1 khong BOM theo ANSI, ky tu Unicode bi hong -> "String is missing terminator".
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
# Kill moi daw_engine con song tu build/truoc (windowed, khong console ->
|
||||||
|
# de quen -> file exe dang chay bi khoa -> Tauri build loi PermissionDenied
|
||||||
|
# khi doc externalBin). Stop-Process im lang neu khong co tien trinh nao.
|
||||||
|
Write-Host "== [0/6] Kill daw_engine.exe cu (neu dang chay) =="
|
||||||
|
Get-Process -Name "daw_engine" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
Write-Host "== [1/6] Python dependencies =="
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt pyinstaller pywin32
|
||||||
|
|
||||||
|
Write-Host "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install
|
||||||
|
# Dam bao @babel/standalone co (build.mjs import truc tiep - da gap
|
||||||
|
# ERR_MODULE_NOT_FOUND tren may Windows khi package.json cu thieu dep).
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) {
|
||||||
|
Write-Host "Thieu @babel/standalone - dang cai them..."
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "node_modules\@babel\standalone")) {
|
||||||
|
Write-Host "ERROR: Khong cai duoc @babel/standalone. Kiem tra ket noi npm!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
node build.mjs # @babel/standalone; thay cho 'npm run build' (Babel 8 ESM-only CLI conflict)
|
||||||
|
|
||||||
|
Write-Host "== [3/6] Build daw_engine.exe (PyInstaller) =="
|
||||||
|
pyinstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
Write-Host "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
|
||||||
|
# Bat loi bundle NGAY tai build (da gap 2 lan: chay pyinstaller tu noi khac
|
||||||
|
# -> static/templates thieu -> exe crash 'Directory ...\app\static does not exist').
|
||||||
|
# Dung tools/verify_bundle.py (parse TOC bang ast, chap nhan ca / va \) thay
|
||||||
|
# vi regex thong thuong (TOC Windows co the dung backslash -> false positive).
|
||||||
|
# Truoc tien kiem tra engine.spec phai la ban moi (collect_data_files).
|
||||||
|
$specContent = Get-Content "engine.spec" -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($specContent -notmatch "collect_data_files\('app'") {
|
||||||
|
Write-Host "ERROR: engine.spec CU (thieu collect_data_files('app')). Pull code moi truoc khi build!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python tools\verify_bundle.py
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERROR: Bundle thieu asset - dung build, kiem tra engine.spec datas!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [4/6] ONEDIR engine -> src-tauri/resources/daw_engine (Tauri resources) =="
|
||||||
|
# ONEDIR: copy ca thu muc dist\daw_engine\ (exe + _internal) vao resources.
|
||||||
|
# Tauri bundle resources -> resource_dir()/daw_engine/daw_engine.exe luc runtime.
|
||||||
|
if (-not (Test-Path "dist\daw_engine\daw_engine.exe")) {
|
||||||
|
Write-Host "ERROR: dist\daw_engine\daw_engine.exe khong ton tai (onedir build loi?)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (Test-Path "src-tauri\resources\daw_engine") {
|
||||||
|
Remove-Item -Recurse -Force "src-tauri\resources\daw_engine"
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force "src-tauri\resources\daw_engine" | Out-Null
|
||||||
|
Copy-Item "dist\daw_engine\*" "src-tauri\resources\daw_engine\" -Recurse -Force
|
||||||
|
Write-Host "Copied onedir engine -> src-tauri\resources\daw_engine"
|
||||||
|
|
||||||
|
Write-Host "== [5/6] VC++ Redistributable cho hooks.nsh =="
|
||||||
|
if (-not (Test-Path src-tauri\vc_redist.x64.exe)) {
|
||||||
|
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vc_redist.x64.exe" -OutFile src-tauri\vc_redist.x64.exe
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "== [6/6] Tauri build (NSIS .exe + MSI) =="
|
||||||
|
# Guard: resources/daw_engine PHAI co exe + _internal truoc khi tauri build
|
||||||
|
# (glob trong tauri.conf.json fail ngay "path not found" neu thieu).
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\daw_engine.exe")) {
|
||||||
|
Write-Host "ERROR: src-tauri\resources\daw_engine\daw_engine.exe khong co - buoc [4/6] that bai?" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "src-tauri\resources\daw_engine\_internal")) {
|
||||||
|
Write-Host "ERROR: thieu src-tauri\resources\daw_engine\_internal (onedir khong day du)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
npm install -D @tauri-apps/cli
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== DONE =="
|
||||||
|
Write-Host " NSIS: src-tauri\target\release\bundle\nsis\SonicForgeDAW_1.0.0_x64-setup.exe"
|
||||||
|
Write-Host " MSI : src-tauri\target\release\bundle\msi\SonicForgeDAW_1.0.0_x64_en-US.msi"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== XAC MINH SAU KHI CAI DAT (quan trong) =="
|
||||||
|
Write-Host " Mo %APPDATA%\SonicForgeDAW\logs\spawn.log - phai thay:"
|
||||||
|
Write-Host " [resource_dir/daw_engine (map layout)] ...exists=True"
|
||||||
|
Write-Host " Neu exists=False: bundle resources KHONG vao installer (chay lai buoc [4/6])."
|
||||||
|
Write-Host " Engine phai nam o: <thu muc cai dat>\daw_engine\daw_engine.exe"
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""SonicForge DAW — Desktop engine launcher (PyInstaller entry point).
|
||||||
|
|
||||||
|
Chay FastAPI backend duoi dang tien trinh nen (sidecar) cho ban cai Standalone
|
||||||
|
Windows (Tauri v2 shell). Tu chon port 8000-8010 (fallback neu bi chiem), tu
|
||||||
|
thoat khi tien trinh cha (Tauri) ket thuc — Graceful Shutdown.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
PORT_RANGE = (8000, 8010)
|
||||||
|
APP_DATA_DIR_NAME = "SonicForgeDAW"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_port():
|
||||||
|
for port in range(PORT_RANGE[0], PORT_RANGE[1] + 1):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
# KHONG dung SO_REUSEADDR: tren Windows no CHO PHEP bind trung port
|
||||||
|
# (2 engine cung 8000 -> request roi vao engine ngau nhien -> UI loi).
|
||||||
|
# Listen socket khong can SO_REUSEADDR (TIME_WAIT chi ap dung cho
|
||||||
|
# connection socket, khong phai listen socket).
|
||||||
|
# Bind 0.0.0.0 (GIONG uvicorn) — kiem tra dung port ma server se
|
||||||
|
# dung, truong hop port bi chiem tren interface khac khong sot.
|
||||||
|
try:
|
||||||
|
s.bind(("0.0.0.0", port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return PORT_RANGE[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_ips():
|
||||||
|
"""IPv4 cua may tren LAN (bo loopback) — client browser o may khac mo app."""
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||||
|
ip = info[4][0]
|
||||||
|
if ip and not ip.startswith("127.") and ip not in ips:
|
||||||
|
ips.append(ip)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_alive(pid):
|
||||||
|
# Windows: os.kill(pid, 0) KHONG kiem tra ton tai — no goi
|
||||||
|
# TerminateProcess (giai thich: moi sig khac CTRL_C/BREAK deu terminate).
|
||||||
|
# => watchdog se GIET LUON tien trinh cha (Tauri) sau 1s dau tien.
|
||||||
|
# Dung OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION): tra ve NULL khi
|
||||||
|
# pid khong con ton tai.
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||||
|
if not h:
|
||||||
|
return False # tien trinh da chet
|
||||||
|
ctypes.windll.kernel32.CloseHandle(h)
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# PHONG THU (fix ban 1.1.2): neu exe bi goi nhu 'python -c ...' — vd code
|
||||||
|
# cu kiem tra thu vien bang subprocess.run([sys.executable, '-c', 'import X'])
|
||||||
|
# thi frozen sys.executable = daw_engine.exe -> se chay CA ENGINE o day.
|
||||||
|
# Thoat ngay, KHONG khoi dong server, tranh de quy spawn vo han.
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "-c":
|
||||||
|
return
|
||||||
|
|
||||||
|
os.environ.setdefault("SF_DESKTOP", "1")
|
||||||
|
|
||||||
|
# Log dir tao SOM de stderr co the tro vao file (xem duoi).
|
||||||
|
log_dir = os.path.join(
|
||||||
|
os.environ.get("APPDATA") or os.path.expanduser("~"),
|
||||||
|
APP_DATA_DIR_NAME, "logs",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
log_dir = os.path.expanduser("~")
|
||||||
|
|
||||||
|
# Windowed app (engine.spec console=False): Windows khong tao console ->
|
||||||
|
# sys.stdout/sys.stderr = None -> uvicorn logging crash
|
||||||
|
# ('NoneType' object has no attribute 'isatty'). Tro stdout/stderr vao
|
||||||
|
# FILE (KHONG phai devnull) — neu devnull thi moi exception bi nuot im
|
||||||
|
# lang (trieu chung 'stuck khong logs, khong loi').
|
||||||
|
if sys.stdout is None:
|
||||||
|
sys.stdout = open(os.path.join(log_dir, "stdout.log"), "w", encoding="utf-8")
|
||||||
|
if sys.stderr is None:
|
||||||
|
sys.stderr = open(os.path.join(log_dir, "stderr.log"), "w", encoding="utf-8")
|
||||||
|
|
||||||
|
port = _pick_port()
|
||||||
|
os.environ["SF_PORT"] = str(port)
|
||||||
|
|
||||||
|
# Log ra file — khi dong goi console=False, stdout khong nhin thay duoc.
|
||||||
|
try:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(os.path.join(log_dir, "engine.log"), encoding="utf-8"),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
import app.main # noqa: F401 — import tuong minh de PyInstaller bundle du package app
|
||||||
|
|
||||||
|
# Bind 0.0.0.0: client browser o may khac tren LAN mo app qua dia chi IP
|
||||||
|
# (http://<LAN-IP>:port) — lam viec realtime voi may chay standalone.
|
||||||
|
# Cua so Tauri (shell) van mo http://127.0.0.1:port nhu cu.
|
||||||
|
config = uvicorn.Config(app.main.app, host="0.0.0.0", 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()
|
||||||
|
|
||||||
|
logger = logging.getLogger("desktop_engine")
|
||||||
|
logger.info("SonicForge engine listening on 0.0.0.0:%d", port)
|
||||||
|
lan_ips = _lan_ips()
|
||||||
|
if lan_ips:
|
||||||
|
logger.info("LAN access — mo browser o may khac: %s",
|
||||||
|
" | ".join("http://%s:%d" % (ip, port) for ip in lan_ips))
|
||||||
|
else:
|
||||||
|
logger.warning("Khong phat hien dia chi LAN — client may khac khong truy cap duoc")
|
||||||
|
server.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ── Production compose — KHÔNG mount source, image từ registry, bí mật qua .env ──
|
||||||
|
# Dùng: docker compose -f docker-compose.prod.yml up -d
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8000}:8000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Ép nhận diện headless (server docker + browser UI) — soundfont + VSTi
|
||||||
|
# mở được lấy từ storage mount bên dưới; preset VST3 (.vstpreset) upload
|
||||||
|
# qua web UI vào storage/presets (nằm trong volume sf_db).
|
||||||
|
- SF_DOCKER=1
|
||||||
|
volumes:
|
||||||
|
- sf_uploads:/app/app/storage/uploads
|
||||||
|
- sf_processed:/app/app/storage/processed
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
|
||||||
|
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
|
||||||
|
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- sf_uploads:/app/app/storage/uploads
|
||||||
|
- sf_processed:/app/app/storage/processed
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
- ${VST3_DIR:-./vst_plugins}:/opt/daw_engine/vst3:ro
|
||||||
|
- ${SOUNDFONTS_DIR:-./soundfonts}:/opt/daw_engine/soundfonts:ro
|
||||||
|
- ${PIANOBOOK_DIR:-./samples/pianobook}:/opt/daw_engine/samples/pianobook:ro
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
beat:
|
||||||
|
image: ${IMAGE:-sonicforge-studio:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- sf_db:/app/app/storage
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sf_uploads:
|
||||||
|
sf_processed:
|
||||||
|
sf_db:
|
||||||
+15
-9
@@ -10,13 +10,15 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
@@ -25,13 +27,15 @@ services:
|
|||||||
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
command: celery -A app.tasks.worker.celery_app worker --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
@@ -40,12 +44,14 @@ services:
|
|||||||
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
command: celery -A app.tasks.worker.celery_app beat --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /home/locpham/daw_assets/vst3:/opt/daw_engine/vst3
|
- ${VST_DIR:-/home/locpham/daw_assets/vst3}:/opt/daw_engine/vst3
|
||||||
- /home/locpham/daw_assets/soundfonts:/opt/daw_engine/soundfonts
|
- ${SOUNDFONT_DIR:-/home/locpham/daw_assets/soundfonts}:/opt/daw_engine/soundfonts
|
||||||
- /home/locpham/daw_assets/pianobook:/opt/daw_engine/samples/pianobook
|
- ${PIANOBK_DIR:-/home/locpham/daw_assets/pianobook}:/opt/daw_engine/samples/pianobook
|
||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
- VST_DIR=/opt/daw_engine/vst3
|
||||||
|
- SOUNDFONT_DIR=/opt/daw_engine/soundfonts
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
|
||||||
|
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
import os
|
||||||
|
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
|
||||||
|
|
||||||
|
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
|
||||||
|
binaries = collect_dynamic_libs('pedalboard')
|
||||||
|
binaries += collect_dynamic_libs('soundfile')
|
||||||
|
|
||||||
|
# Root tuyet doi cua thu muc chua spec — dung cho cac datas KHONG nam trong
|
||||||
|
# package 'app' (vd thu muc md/). Cac assets cua app (static/templates/models)
|
||||||
|
# duoc bundle qua collect_data_files('app').
|
||||||
|
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
||||||
|
|
||||||
|
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
|
||||||
|
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
||||||
|
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
||||||
|
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
||||||
|
# bundle thieu static/templates -> exe crash luc chay. Fix: chen _SPEC_ROOT
|
||||||
|
# vao sys.path TRUOC khi collect de import 'app' luon hoạt dong.
|
||||||
|
import sys as _sys
|
||||||
|
if _SPEC_ROOT not in _sys.path:
|
||||||
|
_sys.path.insert(0, _SPEC_ROOT)
|
||||||
|
|
||||||
|
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
||||||
|
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
|
||||||
|
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
||||||
|
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
||||||
|
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
|
||||||
|
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
|
||||||
|
if not datas:
|
||||||
|
print("WARN: collect_data_files('app') tra ve rong - dung datas tinh absolute")
|
||||||
|
datas = [
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'templates'), 'app/templates'), # index.html, favicon.svg
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'static'), 'app/static'), # js/css/processors
|
||||||
|
(os.path.join(_SPEC_ROOT, 'app', 'models'), 'app/models'), # project_schema.json (projects.py)
|
||||||
|
]
|
||||||
|
datas += [
|
||||||
|
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
|
||||||
|
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
|
||||||
|
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
|
||||||
|
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
|
||||||
|
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
|
||||||
|
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
|
||||||
|
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
|
||||||
|
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
|
||||||
|
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
|
||||||
|
# render_engine, audio_features).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
import importlib.util as _ilu
|
||||||
|
import glob as _glob
|
||||||
|
|
||||||
|
def _scan_pkg_modules(pkg_name: str):
|
||||||
|
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
|
||||||
|
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
|
||||||
|
_spec = _ilu.find_spec(pkg_name)
|
||||||
|
if _spec is None or _spec.origin is None:
|
||||||
|
print(f"WARN: khong tim thay package '{pkg_name}' - bo qua scan")
|
||||||
|
return []
|
||||||
|
_pkg_dir = os.path.dirname(os.path.abspath(_spec.origin))
|
||||||
|
_out = []
|
||||||
|
for _ext in ('*.py', '*.pyd', '*.so'):
|
||||||
|
for _f in _glob.glob(os.path.join(_pkg_dir, '**', _ext), recursive=True):
|
||||||
|
_rel = os.path.relpath(_f, _pkg_dir)
|
||||||
|
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
|
||||||
|
_sub = os.path.dirname(_rel).replace(os.sep, '.')
|
||||||
|
_mod = (pkg_name + '.' + _sub + '.' + _base) if _sub else (pkg_name + '.' + _base)
|
||||||
|
if _mod not in _out:
|
||||||
|
_out.append(_mod)
|
||||||
|
return _out
|
||||||
|
|
||||||
|
_scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
|
||||||
|
|
||||||
|
# Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
|
||||||
|
_hidden = [
|
||||||
|
'uvicorn.logging',
|
||||||
|
'uvicorn.loops',
|
||||||
|
'uvicorn.loops.auto',
|
||||||
|
'uvicorn.protocols',
|
||||||
|
'uvicorn.protocols.http',
|
||||||
|
'uvicorn.protocols.http.auto',
|
||||||
|
'uvicorn.protocols.websockets',
|
||||||
|
'uvicorn.protocols.websockets.auto',
|
||||||
|
'pedalboard',
|
||||||
|
'soundfile',
|
||||||
|
'sf2utils',
|
||||||
|
'mido.backends.rtmidi',
|
||||||
|
] + _scipy_signal_hidden
|
||||||
|
|
||||||
|
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
|
||||||
|
_excludes = [
|
||||||
|
'tkinter',
|
||||||
|
# libs da thay the (audio_features.py)
|
||||||
|
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
|
||||||
|
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
|
||||||
|
# celery/redis chi dung cho server (Docker), khong cho desktop
|
||||||
|
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
|
||||||
|
'click_didyoumean', 'click_plugins', 'click_repl',
|
||||||
|
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
|
||||||
|
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
|
||||||
|
# khong dung trong desktop
|
||||||
|
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
|
||||||
|
# keo vao nham boi hooks_contrib (app khong import bao gio)
|
||||||
|
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['desktop_engine.py'],
|
||||||
|
pathex=[_SPEC_ROOT],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=_hidden,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=_excludes,
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=None,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='daw_engine',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False, # True khi debug (xem log truc tiep), False cho production
|
||||||
|
# Icon exe = favicon cua app (app/templates/favicon.svg -> render PNG -> ICO,
|
||||||
|
# sinh boi tools/gen_favicon_ico.py). Cung nguon voi icon hien thi trong app.
|
||||||
|
icon='src-tauri/icons/favicon.ico',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ONEDIR (khong phai onefile): exe 700MB+ onefile phai giai nen toan bo vao
|
||||||
|
# %TEMP% moi lan chay -> load RAT CHAM tren Windows. Onedir chay truc tiep tu
|
||||||
|
# thu muc (bundle qua Tauri resources), khoi dong gan nhu tuc thi.
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
name='daw_engine',
|
||||||
|
)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Fix: MAIN SESSION & SECTION-TAB Mouse Behavior (MOUSE.md)
|
||||||
|
|
||||||
|
## Mô tả
|
||||||
|
Kiểm tra và sửa các lỗi trong MAIN SESSION và SECTION-TAB theo đặc tả [MOUSE.md](file:///home/locpham/SonicForgeStudio/md/MOUSE.md):
|
||||||
|
|
||||||
|
| Hành động | Đặc tả MOUSE.md |
|
||||||
|
|---|---|
|
||||||
|
| Click item | select item, drag to move |
|
||||||
|
| Ctrl+Click item | toggle selection (add/remove from group) |
|
||||||
|
| Ctrl+Click empty | deselect all |
|
||||||
|
| Ctrl+Click+Drag item/group | **copy** item/group to new track |
|
||||||
|
| Ctrl+Click+Drag empty | marquee selection/deselection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs đã tìm thấy
|
||||||
|
|
||||||
|
### Bug 1 – `handleSetPendingDrag`: luôn thêm `itemId` vào `ids`, bất kể đã toggle hay deselect
|
||||||
|
**Vị trí**: [app.jsx L11834-11837](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11834-L11837)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleSetPendingDrag = (trackId, itemType, itemId, clickOffset, e, preToggleSnapshot) => {
|
||||||
|
var ids = preToggleSnapshot || new Set(selectedItemIds);
|
||||||
|
ids.add(itemId); // ← luôn ADD itemId, bất kể đây là lần toggle-off
|
||||||
|
pendingDragRef.current = { ... };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Khi user **Ctrl+Click** vào một item đang được selected → item đó bị deselect (line 1107).
|
||||||
|
- Nhưng `preToggleSnapshot` (được build **trước** toggle tại line 1111/1178) luôn được `ids.add(itemId)`, nên pendingDrag vẫn include item đó trong nhóm copy.
|
||||||
|
- **Kết quả**: Ctrl+Drag sau deselect vẫn copy item bị deselect → số lượng copies bị lỗi.
|
||||||
|
|
||||||
|
**Fix**: Khi pendingDrag được set sau toggle, nếu item bị deselect (tức `preToggleSnapshot.has(itemId)`) thì **xóa** `itemId` khỏi ids thay vì add.
|
||||||
|
|
||||||
|
Caller (line 1111) truyền `preToggleSnapshot` (snapshot TRƯỚC toggle), nên logic cần:
|
||||||
|
```js
|
||||||
|
// Nếu item đang có trong snapshot (tức là toggle-off / deselect), xóa khỏi ids
|
||||||
|
// Nếu item không có trong snapshot (tức là toggle-on / add), thêm vào ids
|
||||||
|
if (preToggleSnapshot && preToggleSnapshot.has(itemId)) {
|
||||||
|
ids.delete(itemId);
|
||||||
|
} else {
|
||||||
|
ids.add(itemId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 2 – `handleSectionItemDragStart` với `isDuplicate=true`: `newOriginals` bị build bên trong callback async của `updateActiveTracks`
|
||||||
|
|
||||||
|
**Vị trí**: [app.jsx L11888-11924](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11888-L11924)
|
||||||
|
|
||||||
|
```js
|
||||||
|
var newOriginals = {};
|
||||||
|
updateActiveTracks(function(prev) {
|
||||||
|
return prev.map(function(t) {
|
||||||
|
// ... build newOriginals ở đây (bên trong callback)
|
||||||
|
newOriginals[newId] = { ... };
|
||||||
|
return { ...t, ... };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Sử dụng newOriginals NGAY SAU updateActiveTracks (có thể chưa được fill)
|
||||||
|
var newItemId = Object.keys(newOriginals)[0] || itemId;
|
||||||
|
setDraggedSectionItem({ ..., itemId: newItemId, multiIds: newOriginals });
|
||||||
|
```
|
||||||
|
|
||||||
|
Vì `updateActiveTracks` gọi setState (React batch), callback của nó được gọi đồng bộ (React's setState updater function chạy đồng bộ). Nhưng vì `newOriginals` được closure-captured bên trong callback, điều này **thực ra hoạt động đúng** trong React 18 với batching.
|
||||||
|
|
||||||
|
Tuy nhiên: nếu **nhiều items có cùng loại** (section + midiItem + clip), vòng lặp qua `Object.keys(multiIds)` gọi `Date.now()` nhiều lần trong cùng một tick → có thể cho **cùng một newId** cho các items khác nhau, dẫn đến items bị merge.
|
||||||
|
|
||||||
|
**Fix**: Thêm index counter vào ID tạo để đảm bảo unique:
|
||||||
|
```js
|
||||||
|
var dupCounter = 0;
|
||||||
|
var newId = 'sec_dup_' + Date.now() + '_' + (dupCounter++) + '_' + Math.random()...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 3 – Section-only path trong `isDuplicate` (non-multi): không xử lý `clip` type
|
||||||
|
**Vị trí**: [app.jsx L11925-11938](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11925-L11938)
|
||||||
|
|
||||||
|
Khi không có `multiIds` (chỉ 1 item đơn lẻ), code chỉ handle `section` và `midiItem`:
|
||||||
|
```js
|
||||||
|
var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Nếu `itemType === 'clip'`, `items` sẽ dùng `midiItems` (sai). Cần thêm case cho `clip`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 4 – Ctrl+Click trên CLIP ở line 1172: điều kiện kiểm tra bỏ qua hitItem
|
||||||
|
**Vị trí**: [app.jsx L1172](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L1172)
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (clickedClip && (e.altKey || e.ctrlKey)) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Đoạn này xử lý Ctrl+Click trên **audio clip**. Tuy nhiên ở trên (line 1101), block `if (hitItem && !e.altKey && !e.shiftKey)` đã `return` nếu hitItem (section/midiItem) được click với Ctrl. Nhưng nếu không có hitItem và không có clickedClip, flow đúng (sweep select). Không có lỗi ở đây.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 5 – `handleSetPendingDrag` cho Clip (line 1184): cùng vấn đề như Bug 1
|
||||||
|
|
||||||
|
Tương tự Bug 1, khi Ctrl+Click trên clip đang selected (sẽ bị deselect ở line 1180), nhưng `clipPreToggleSnapshot.has(clipCanonicalId)` là true và `ids.add(clipCanonicalId)` vẫn được thực thi trong `handleSetPendingDrag`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### [MODIFY] [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx)
|
||||||
|
|
||||||
|
#### Fix Bug 1 & 5 – `handleSetPendingDrag` (L11834-11838)
|
||||||
|
Sửa logic toggle để đúng: nếu item **có** trong preToggleSnapshot → nó vừa bị deselect → **xóa** khỏi ids. Nếu không có → vừa được add → **thêm** vào ids.
|
||||||
|
|
||||||
|
#### Fix Bug 2 – ID uniqueness trong multi-copy loop
|
||||||
|
Thêm counter index vào ID generation trong vòng lặp `Object.keys(multiIds).forEach`.
|
||||||
|
|
||||||
|
#### Fix Bug 3 – Single-item copy: xử lý `clip` type
|
||||||
|
Trong nhánh `else` (non-multiIds) của `isDuplicate`, thêm xử lý `clip` type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. Mở MAIN SESSION
|
||||||
|
2. Thêm vài sections/MIDI items trên nhiều tracks
|
||||||
|
3. **Test Ctrl+Click toggle**: Click vào item A → selected. Ctrl+Click A → deselected. Ctrl+Click B → B selected (không phải A+B).
|
||||||
|
4. **Test Ctrl+Drag single**: Ctrl+Click item → drag → chỉ copy 1 item, không thêm item nào bị deselect
|
||||||
|
5. **Test Ctrl+Drag group**: Ctrl+Click A, Ctrl+Click B (2 selected) → drag từ A → copy cả A và B, không thêm C hay D
|
||||||
|
6. **Test Ctrl+Click empty**: Click trống → deselect all
|
||||||
|
7. **Test Ctrl+Drag empty**: Drag trên empty → marquee select
|
||||||
|
8. Lặp lại trên SECTION-TAB (session_xxx)
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# TECHNICAL SPECIFICATION & SYSTEM ARCHITECTURE FOR MAIN SESSION AND SECTION-TAB MIXER
|
||||||
|
|
||||||
|
This document details the implementation of a docked Mixer Console (Mixer Control Panel - MCP) positioned at the bottom of the screen (similar to REAPER), alongside the hierarchical audio signal routing architecture when SECTION-TABs are nested within MAIN SESSION tracks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. HIERARCHICAL AUDIO SIGNAL ROUTING ARCHITECTURE
|
||||||
|
|
||||||
|
To allow a SECTION-TAB to maintain an independent mix while assigning final master control of its output to a single track in the MAIN SESSION, the system employs a **Sub-Mix Bus Piping** model via the Web Audio API.
|
||||||
|
|
||||||
|
### Audio Signal Graph Tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ SECTION-TAB (Sub-Session) ]
|
||||||
|
├── Track S1 (Audio/MIDI) ──► Gain ──► Pan ──┐
|
||||||
|
├── Track S2 (Audio/MIDI) ──► Gain ──► Pan ──┼──► [ SECTION SUB-MIX BUS ]
|
||||||
|
└── Track S3 (Audio/MIDI) ──► Gain ──► Pan ──┘ │ (Section Master Out)
|
||||||
|
│
|
||||||
|
▼ (Piped to host track)
|
||||||
|
[ MAIN SESSION (Root Session) ] │
|
||||||
|
├── Track 1 (Normal Audio) ──► Gain ──► Pan ─────────────┼──┐
|
||||||
|
├── Track 2 (SECTION ITEM Track) ◄───────────────────────┘ │
|
||||||
|
│ └─ Gain Node ──► FX Chain ──► Pan Node ───────────────┼──► [ MAIN MASTER BUS ]
|
||||||
|
└── Track 3 (Normal MIDI) ──► Gain ──► Pan ─────────────┘ │
|
||||||
|
▼
|
||||||
|
[ AudioContext Destination ]
|
||||||
|
(Speakers / Soundcard)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audio Routing Principles:
|
||||||
|
|
||||||
|
* **Inside SECTION-TAB:** Internal tracks ($S1, S2, S3$) are balanced via volume, pan, and Mute/Solo controls on the SECTION-TAB's dedicated mixer. Output signals from these tracks are summed at the `Section Sub-Mix Bus Node`.
|
||||||
|
* **Routing to MAIN SESSION:** The output of the `Section Sub-Mix Bus Node` does not connect directly to `audioCtx.destination`. Instead, it routes into the Input Node of Track 2 on the MAIN SESSION (the track hosting the `SECTION_ITEM`).
|
||||||
|
* **MAIN SESSION Management:**
|
||||||
|
* The Volume Fader on Track 2 of the MAIN SESSION controls the overall volume of the entire SECTION-TAB.
|
||||||
|
* Mute, Solo, and FX Chain controls on Track 2 of the MAIN SESSION directly process all audio originating from that SECTION-TAB before it reaches the Main Master Bus.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DOCKED MIXER CONSOLE (MCP) UI STRUCTURE
|
||||||
|
|
||||||
|
The Mixer Console is designed as a docked panel underneath the Timeline, split into two main sections: **Master Channel Strip** (Left) and **Track Channel Strips** (Right).
|
||||||
|
|
||||||
|
```text
|
||||||
|
+------------------------------------------------------------------------------------------------+
|
||||||
|
| DOCKED MIXER CONSOLE |
|
||||||
|
+-------------------+----------------------------------------------------------------------------+
|
||||||
|
| MASTER STRIP | TRACK CHANNEL STRIPS (Track 1, Track 2 [SECTION], Track 3...) |
|
||||||
|
| | |
|
||||||
|
| (Knob) Pan | (Knob) Pan (Knob) Pan (Knob) Pan (Knob) Pan |
|
||||||
|
| [MONO] | -inf dB -inf dB -inf dB -inf dB |
|
||||||
|
| [M] [S] | [M] [S] [M] [S] [M] [S] [M] [S] |
|
||||||
|
| [ROUTE] | [|||] [|||] Peak [|||] [|||] Peak [|||] [|||] Peak [|||] [|||] Peak|
|
||||||
|
| [FX] [Power] | [===] Fader [===] Fader [===] Fader [===] Fader |
|
||||||
|
| [TRIM] | [FX] [Power] [FX] [Power] [FX] [Power] [FX] [Power] |
|
||||||
|
| [FADER] (VU) | [🔴] [ROUTE] [🔴] [ROUTE] [🔴] [ROUTE] [🔴] [ROUTE] |
|
||||||
|
| | ------------------ ------------------ ------------------ ----------------|
|
||||||
|
| RMS/Peak Meter | Track 1 (Kick) Track 2 (SECTION) Track 3 (Guitars) Track 4 (Lead) |
|
||||||
|
| MASTER | [🎵 SF2 Synth] [📦 Sec: Chorus] [🎸 Pedalboard] [🎹 Vital] |
|
||||||
|
+-------------------+----------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel Strip Components:
|
||||||
|
|
||||||
|
* **Top Pan Knob:** Rotatable control for Left/Right stereo balance (Pan Left/Right, $-1.0$ to $+1.0$).
|
||||||
|
* **Numeric dB Readout:** Displays current volume level in numeric dB (e.g., $0.0\text{ dB}$, $-6.5\text{ dB}$, $-inf$).
|
||||||
|
* **Mute [M] & Solo [S] Buttons:**
|
||||||
|
* Orange **[M]** button: Mutes track output.
|
||||||
|
* Yellow **[S]** button: Solos track output (mutes all non-soloed tracks).
|
||||||
|
|
||||||
|
|
||||||
|
* **Vertical Level Meter (VU Peak Meter):** Real-time LED signal amplitude meter (Green $\rightarrow$ Yellow $\rightarrow$ Red clipping indicator above $0\text{ dBFS}$).
|
||||||
|
* **Volume Fader Slider:** Vertical linear slider for gain control.
|
||||||
|
* **FX & Power Toggle:**
|
||||||
|
* **[FX]** button: Opens the Effects Plugin management window (Reverb, Delay, EQ, Compressor).
|
||||||
|
* **[Power]** button: Toggles bypass state for the track's entire FX chain.
|
||||||
|
|
||||||
|
|
||||||
|
* **Record Arm [🔴] & Routing/Trim Buttons:**
|
||||||
|
* Red circle **[🔴]**: Arms track for recording.
|
||||||
|
* **[ROUTE]** button: Opens the Input/Output Matrix configuration panel.
|
||||||
|
|
||||||
|
|
||||||
|
* **Track Identifier Footer:** Displays track index ($1, 2, 3...$) and track name (e.g., *Track 2 - Chorus Section*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. STATE STORE SCHEMAS
|
||||||
|
|
||||||
|
### 3.1 Main Session State (`main_session_mixer_state`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "main_session_root",
|
||||||
|
"master_bus": {
|
||||||
|
"volume_db": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"mute": false,
|
||||||
|
"is_mono": false,
|
||||||
|
"fx_chain": []
|
||||||
|
},
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track_01",
|
||||||
|
"name": "Track 1 - Drums",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": -2.5,
|
||||||
|
"pan": 0.0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"is_armed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "track_02_section_host",
|
||||||
|
"name": "Track 2 - Chorus Section",
|
||||||
|
"type": "SECTION",
|
||||||
|
"volume_db": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"referenced_section_id": "section_chorus_001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Section-Tab State (`section_store`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"section_store": {
|
||||||
|
"section_chorus_001": {
|
||||||
|
"id": "section_chorus_001",
|
||||||
|
"name": "Chorus Section Tab",
|
||||||
|
"is_root": false,
|
||||||
|
"parent_track_id": "track_02_section_host",
|
||||||
|
"sub_mix_bus": {
|
||||||
|
"volume_db": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"mute": false,
|
||||||
|
"fx_chain": []
|
||||||
|
},
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "sec_track_1",
|
||||||
|
"name": "Sec Guitar 1",
|
||||||
|
"volume_db": -1.0,
|
||||||
|
"pan": -0.5,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sec_track_2",
|
||||||
|
"name": "Sec Vocal Lead",
|
||||||
|
"volume_db": +1.5,
|
||||||
|
"pan": 0.0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AUDIO GRAPH NODE ENGINE (WEB AUDIO API)
|
||||||
|
|
||||||
|
To execute audio summing according to the architecture above, the application uses a manager class to maintain Web Audio nodes (`AudioNode Graph`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// app/static/js/services/audioMixerGraphManager.js
|
||||||
|
|
||||||
|
export class AudioMixerGraphManager {
|
||||||
|
constructor(audioCtx) {
|
||||||
|
this.audioCtx = audioCtx;
|
||||||
|
|
||||||
|
// Main Session Master Nodes
|
||||||
|
this.mainMasterGain = this.audioCtx.createGain();
|
||||||
|
this.mainMasterPan = this.audioCtx.createStereoPanner();
|
||||||
|
this.mainMasterAnalyser = this.audioCtx.createAnalyser();
|
||||||
|
|
||||||
|
// Connect Master Graph -> Speakers
|
||||||
|
this.mainMasterGain
|
||||||
|
.connect(this.mainMasterPan)
|
||||||
|
.connect(this.mainMasterAnalyser)
|
||||||
|
.connect(this.audioCtx.destination);
|
||||||
|
|
||||||
|
// Map storing track AudioNodes
|
||||||
|
this.trackNodesMap = new Map(); // trackId -> { inputGain, faderGain, panNode, analyserNode }
|
||||||
|
this.sectionBusesMap = new Map(); // sectionId -> { subMixGain, subMixPan, outputNode }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes Audio Nodes for a track on the Main Session
|
||||||
|
*/
|
||||||
|
createMainTrackNodes(track) {
|
||||||
|
const inputGain = this.audioCtx.createGain();
|
||||||
|
const faderGain = this.audioCtx.createGain();
|
||||||
|
const panNode = this.audioCtx.createStereoPanner();
|
||||||
|
const analyserNode = this.audioCtx.createAnalyser();
|
||||||
|
analyserNode.fftSize = 64;
|
||||||
|
|
||||||
|
// Track internal signal chain connection:
|
||||||
|
// InputGain -> FaderGain -> PanNode -> Analyser -> Main Master Gain
|
||||||
|
inputGain
|
||||||
|
.connect(faderGain)
|
||||||
|
.connect(panNode)
|
||||||
|
.connect(analyserNode)
|
||||||
|
.connect(this.mainMasterGain);
|
||||||
|
|
||||||
|
const nodeBundle = { inputGain, faderGain, panNode, analyserNode, track };
|
||||||
|
this.trackNodesMap.set(track.id, nodeBundle);
|
||||||
|
|
||||||
|
// Update initial Gain/Pan values
|
||||||
|
this.updateTrackVolumePan(track.id, track.volume_db, track.pan, track.mute);
|
||||||
|
return nodeBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a Sub-Mix Bus for a SECTION-TAB and PIPES it to a Main Session track
|
||||||
|
* @param {string} sectionId - Section ID
|
||||||
|
* @param {string} hostTrackId - Main Session Track ID hosting this section
|
||||||
|
*/
|
||||||
|
createSectionSubMixBus(sectionId, hostTrackId) {
|
||||||
|
const subMixGain = this.audioCtx.createGain();
|
||||||
|
const subMixPan = this.audioCtx.createStereoPanner();
|
||||||
|
|
||||||
|
// Section Sub-Mix Bus internal connection
|
||||||
|
subMixGain.connect(subMixPan);
|
||||||
|
|
||||||
|
// CRITICAL STEP: Locate host track on Main Session to pipe signal
|
||||||
|
const hostTrackNodes = this.trackNodesMap.get(hostTrackId);
|
||||||
|
|
||||||
|
if (hostTrackNodes) {
|
||||||
|
// Pipe Output of Section Bus directly into InputGain of Main Track
|
||||||
|
subMixPan.connect(hostTrackNodes.inputGain);
|
||||||
|
console.log(`[MixerRouter] Sub-Mix Bus of Section "${sectionId}" successfully PIPED into Main Track "${hostTrackId}"`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[MixerRouter] Host Track "${hostTrackId}" not found. Section bus falling back to Master.`);
|
||||||
|
subMixPan.connect(this.mainMasterGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionBundle = { subMixGain, subMixPan, hostTrackId };
|
||||||
|
this.sectionBusesMap.set(sectionId, sectionBundle);
|
||||||
|
return sectionBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes an INTERNAL track inside a SECTION-TAB
|
||||||
|
*/
|
||||||
|
createSectionTrackNodes(sectionId, secTrack) {
|
||||||
|
const sectionBundle = this.sectionBusesMap.get(sectionId);
|
||||||
|
if (!sectionBundle) {
|
||||||
|
throw new Error(`Section Sub-Mix Bus for "${sectionId}" has not been initialized!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGain = this.audioCtx.createGain();
|
||||||
|
const faderGain = this.audioCtx.createGain();
|
||||||
|
const panNode = this.audioCtx.createStereoPanner();
|
||||||
|
const analyserNode = this.audioCtx.createAnalyser();
|
||||||
|
|
||||||
|
// Connect Track in Section to the Section's Sub-Mix Bus
|
||||||
|
inputGain
|
||||||
|
.connect(faderGain)
|
||||||
|
.connect(panNode)
|
||||||
|
.connect(analyserNode)
|
||||||
|
.connect(sectionBundle.subMixGain); // Direct to Section Sub-Mix Bus!
|
||||||
|
|
||||||
|
const nodeBundle = { inputGain, faderGain, panNode, analyserNode, secTrack };
|
||||||
|
this.trackNodesMap.set(secTrack.id, nodeBundle);
|
||||||
|
|
||||||
|
this.updateTrackVolumePan(secTrack.id, secTrack.volume_db, secTrack.pan, secTrack.mute);
|
||||||
|
return nodeBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates Volume (dB) & Pan using standard audio formulas
|
||||||
|
*/
|
||||||
|
updateTrackVolumePan(trackId, volumeDb, panValue, isMuted) {
|
||||||
|
const bundle = this.trackNodesMap.get(trackId);
|
||||||
|
if (!bundle) return;
|
||||||
|
|
||||||
|
// Convert dB to Linear Gain Factor: Gain = 10 ^ (dB / 20)
|
||||||
|
const linearGain = isMuted ? 0.0 : Math.pow(10, volumeDb / 20.0);
|
||||||
|
|
||||||
|
bundle.faderGain.gain.setTargetAtTime(linearGain, this.audioCtx.currentTime, 0.01);
|
||||||
|
bundle.panNode.pan.setTargetAtTime(panValue, this.audioCtx.currentTime, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. NESTED MUTE/SOLO LOGIC MATRIX
|
||||||
|
|
||||||
|
When muting or soloing a track on the Main Session or within a Section Tab, the system applies hierarchical rules:
|
||||||
|
|
||||||
|
| User Action | Main Session Impact | Internal Section Tracks Impact |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Mute Track 2 (Section Host)** on Main Session | Track 2 is muted. Other tracks play normally. | Entire Section is muted (signal is blocked at Track 2's Fader Gain). |
|
||||||
|
| **Mute Section Track 1** inside Section Tab | No impact on other Main Tracks. | Only Section Track 1 is silenced. Other Section Tracks ($S2, S3$) continue routing to Track 2. |
|
||||||
|
| **Solo Section Track 1** inside Section Tab | No impact on Main Session. | Mutes $S2, S3$ within the Section. Only $S1$ outputs to Track 2 of the Main Session. |
|
||||||
|
| **Solo Track 2** on Main Session | Mutes Track 1 and Track 3 on Main Session. | The Section audio stream plays normally to the Main Master Bus. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. STATE-DRIVEN REACT/CANVAS COMPONENT
|
||||||
|
|
||||||
|
Below is the React implementation for the Mixer Console supporting dynamic switching between MAIN SESSION and SECTION-TAB mixers:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// app/static/js/components/MixerConsole.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function MixerConsole({ sessionState, activeTabContext, mixerGraphMgr }) {
|
||||||
|
// Determine if Mixer is rendering for MAIN SESSION or SECTION-TAB
|
||||||
|
const isSectionTab = activeTabContext?.type === 'SECTION_TAB';
|
||||||
|
|
||||||
|
// Retrieve target tracks list for Mixer Console display
|
||||||
|
const currentTracks = isSectionTab
|
||||||
|
? sessionState.section_store[activeTabContext.referenced_section_id]?.tracks || []
|
||||||
|
: sessionState.main_session.tracks;
|
||||||
|
|
||||||
|
const currentTitle = isSectionTab
|
||||||
|
? `MIXER: SECTION TAB (${activeTabContext.title})`
|
||||||
|
: "MIXER: MAIN SESSION";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-64 bg-slate-900 border-t border-slate-700 text-slate-200 select-none">
|
||||||
|
{/* Mixer Header / Tab Indicator */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-1 bg-slate-950 border-b border-slate-800 text-xs font-semibold">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${isSectionTab ? "bg-amber-400 animate-pulse" : "bg-indigo-500"}`} />
|
||||||
|
<span className="text-white tracking-wide">{currentTitle}</span>
|
||||||
|
</div>
|
||||||
|
{isSectionTab && (
|
||||||
|
<span className="text-[10px] text-amber-300/80 bg-amber-950/60 px-2 py-0.5 rounded border border-amber-800/50">
|
||||||
|
Routed to Main Track: {activeTabContext.parent_track_name || "Track Host"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mixer Strips Container */}
|
||||||
|
<div className="flex-1 flex overflow-x-auto p-2 gap-1.5 custom-scrollbar bg-slate-900/90">
|
||||||
|
{/* 1. MASTER STRIP (Far Left) */}
|
||||||
|
<MasterChannelStrip
|
||||||
|
isSectionBus={isSectionTab}
|
||||||
|
mixerGraphMgr={mixerGraphMgr}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-[1px] bg-slate-700/60 mx-1 my-1" />
|
||||||
|
|
||||||
|
{/* 2. TRACK STRIPS (Audio Channels) */}
|
||||||
|
{currentTracks.map((track, idx) => (
|
||||||
|
<TrackChannelStrip
|
||||||
|
key={track.id}
|
||||||
|
track={track}
|
||||||
|
index={idx + 1}
|
||||||
|
mixerGraphMgr={mixerGraphMgr}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel Strip Component representing a single Track
|
||||||
|
*/
|
||||||
|
function TrackChannelStrip({ track, index, mixerGraphMgr }) {
|
||||||
|
const [volumeDb, setVolumeDb] = useState(track.volume_db || 0);
|
||||||
|
const [pan, setPan] = useState(track.pan || 0);
|
||||||
|
const [isMuted, setIsMuted] = useState(Boolean(track.mute));
|
||||||
|
const [isSoloed, setIsSoloed] = useState(Boolean(track.solo));
|
||||||
|
|
||||||
|
const handleVolumeChange = (e) => {
|
||||||
|
const newDb = parseFloat(e.target.value);
|
||||||
|
setVolumeDb(newDb);
|
||||||
|
track.volume_db = newDb;
|
||||||
|
mixerGraphMgr?.updateTrackVolumePan(track.id, newDb, pan, isMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMute = () => {
|
||||||
|
const newMute = !isMuted;
|
||||||
|
setIsMuted(newMute);
|
||||||
|
track.mute = newMute;
|
||||||
|
mixerGraphMgr?.updateTrackVolumePan(track.id, volumeDb, pan, newMute);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-24 bg-slate-950 border border-slate-800 rounded flex flex-col p-1.5 items-center justify-between shadow-lg">
|
||||||
|
{/* 1. Pan Knob */}
|
||||||
|
<div className="flex flex-col items-center w-full">
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
|
{pan === 0 ? "center" : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-1"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={pan}
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = parseFloat(e.target.value);
|
||||||
|
setPan(p);
|
||||||
|
track.pan = p;
|
||||||
|
mixerGraphMgr?.updateTrackVolumePan(track.id, volumeDb, p, isMuted);
|
||||||
|
}}
|
||||||
|
className="w-12 h-1 accent-indigo-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. dB Readout */}
|
||||||
|
<div className="text-[11px] font-mono text-slate-300 font-semibold my-0.5">
|
||||||
|
{volumeDb <= -60 ? "-inf" : `${volumeDb > 0 ? "+" : ""}${volumeDb.toFixed(1)}dB`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Mute / Solo Buttons */}
|
||||||
|
<div className="flex gap-1 w-full my-1">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMute}
|
||||||
|
className={`flex-1 text-[10px] font-bold py-0.5 rounded border transition-colors ${
|
||||||
|
isMuted ? "bg-amber-600 text-white border-amber-500" : "bg-slate-800 text-slate-400 border-slate-700 hover:bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const s = !isSoloed;
|
||||||
|
setIsSoloed(s);
|
||||||
|
track.solo = s;
|
||||||
|
}}
|
||||||
|
className={`flex-1 text-[10px] font-bold py-0.5 rounded border transition-colors ${
|
||||||
|
isSoloed ? "bg-yellow-500 text-black border-yellow-400" : "bg-slate-800 text-slate-400 border-slate-700 hover:bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Fader & Meter Section */}
|
||||||
|
<div className="flex-1 flex items-center justify-center gap-1.5 w-full my-1 relative">
|
||||||
|
{/* Fader Slider */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-60"
|
||||||
|
max="12"
|
||||||
|
step="0.5"
|
||||||
|
value={volumeDb}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="h-28 w-2 appearance-none bg-slate-800 rounded outline-none cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* VU Peak Meter Bar */}
|
||||||
|
<div className="w-2.5 h-28 bg-slate-900 border border-slate-800 rounded overflow-hidden flex flex-col justify-end p-0.5">
|
||||||
|
<div className="w-full bg-gradient-to-t from-emerald-500 via-yellow-400 to-red-500 h-[40%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. FX & Power Buttons */}
|
||||||
|
<div className="flex gap-1 w-full my-1">
|
||||||
|
<button className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[9px] py-0.5 rounded border border-slate-700 font-semibold">
|
||||||
|
FX
|
||||||
|
</button>
|
||||||
|
<button className="px-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-[9px] py-0.5 rounded border border-slate-700">
|
||||||
|
⑈
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Footer Name */}
|
||||||
|
<div className="w-full text-center bg-slate-900 py-1 rounded border border-slate-800/80 mt-1">
|
||||||
|
<div className="text-[10px] font-bold text-white truncate px-1">{index}. {track.name}</div>
|
||||||
|
<div className="text-[8px] text-slate-500 uppercase tracking-tighter truncate">{track.type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master Channel Strip Component
|
||||||
|
*/
|
||||||
|
function MasterChannelStrip({ isSectionBus, mixerGraphMgr }) {
|
||||||
|
return (
|
||||||
|
<div className="w-28 bg-slate-950 border-2 border-indigo-900/60 rounded flex flex-col p-1.5 items-center justify-between shadow-2xl">
|
||||||
|
<div className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider">
|
||||||
|
{isSectionBus ? "SEC BUS" : "MASTER"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Peak / RMS Canvas Meter */}
|
||||||
|
<div className="w-full h-32 bg-black border border-indigo-950 rounded relative my-2 overflow-hidden flex items-center justify-center">
|
||||||
|
<span className="text-[9px] font-mono text-emerald-400/80 font-semibold">-inf dB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full text-center bg-indigo-950/80 border border-indigo-800/60 py-1 rounded mt-auto">
|
||||||
|
<div className="text-[10px] font-bold text-indigo-200 uppercase">
|
||||||
|
{isSectionBus ? "SUB-MIX" : "MAIN OUT"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. OPERATIONAL VERIFICATION CHECKLIST
|
||||||
|
|
||||||
|
* [ ] **Main Session Mixer Operations:**
|
||||||
|
* Adjust Track 1 Fader $\rightarrow$ Track 1 volume increases/decreases as expected.
|
||||||
|
* Mute Track 1 $\rightarrow$ Track 1 output is silenced.
|
||||||
|
|
||||||
|
|
||||||
|
* [ ] **SECTION-TAB Mixer & Routing Operations:**
|
||||||
|
* Open a SECTION-TAB into a sub-tab $\rightarrow$ Mixer Console dynamically switches header to `MIXER: SECTION TAB` displaying internal section tracks.
|
||||||
|
* Adjust Section Track 1 Fader $\rightarrow$ Guitar volume within the Section increases/decreases without affecting other Main Tracks.
|
||||||
|
|
||||||
|
|
||||||
|
* [ ] **Bus Routing Point Verification:**
|
||||||
|
* Switch back to MAIN SESSION tab.
|
||||||
|
* Adjust Track 2 Fader (Track hosting Section Item) $\rightarrow$ Entire SECTION-TAB volume adjusts synchronously.
|
||||||
|
* Mute Track 2 $\rightarrow$ Entire SECTION-TAB output is silenced completely.
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
# TECHNICAL SPECIFICATION & OPERATIONAL WORKFLOW MANUAL: OZONE WEB MASTERING SUITE
|
||||||
|
|
||||||
|
This document compiles the complete architectural layout, module inventory, control parameter specifications, dynamic re-routing execution matrices, visual metering systems, and full single-file implementation code for the Ozone-Style Web Mastering Engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AUDIO PROCESSING MODULES SUMMARY
|
||||||
|
|
||||||
|
| Module Name | Core Function | Web Audio API Nodes / Class | Key Control Parameters | Inherited Acoustic Impact |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Dynamic EQ** | Dynamic frequency filtering and equalization | `BiquadFilterNode` (`lowshelf`, `peaking`, `highshelf`) | 4-Band Gain ($-12 \text{ to } +12\text{ dB}$), Frequency, Q Factor | Balances frequency spectrum, carves out harsh resonances, or boosts air/warmth dynamically without introducing harsh clipping. |
|
||||||
|
| **Stereo Imager** | Mid/Side spatial expansion and balance | `ChannelSplitterNode`, `ChannelMergerNode`, `GainNode` | 4-Band Width ($-100\% \text{ to } +100\%$), Mid/Side Gain | Expands stereo width across High/Mid bands while collapsing Low/Sub-bass bands to Mono for maximum low-end punch. |
|
||||||
|
| **IRC IV Maximizer** | True-Peak Limiter & Peak Gain Boosting | `WaveShaperNode` (Soft Clip), `DynamicsCompressorNode` | Boost Gain ($+0 \text{ to } +12\text{ dB}$), Soft Clip %, Upward Comp, Ceiling | Maximizes perceived loudness (LUFS) to commercial distribution standards while suppressing transient spikes below $0\text{ dBFS}$. |
|
||||||
|
| **Master Utilities** | I/O Management, Metering & Codec Emulation | `AnalyserNode` (FFT 2048), `GainNode` | Delta Listen, Gain Match, Codec Preview, Dither | Enables objective A/B listening tests between pre/post-mastered signals, emulates lossy MP3/AAC compression, and manages bit-depth reduction. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PARAMETER CONTROL SPECIFICATIONS MATRIX
|
||||||
|
|
||||||
|
| Module Group | Parameter Name | Range | Unit | Node Handler / Formula | Technical Purpose |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| **Dynamic EQ** | `eqLowGain` | $-12.0 \to +12.0$ | dB | Lowshelf @ $100\text{Hz}$ | Controls low-end punch and sub-bass weight. |
|
||||||
|
| **Dynamic EQ** | `eqMid1Gain` | $-12.0 \to +12.0$ | dB | Peaking @ $822\text{Hz}$, $Q=0.7$ | Cleans up muddy or boxy low-mid frequencies. |
|
||||||
|
| **Dynamic EQ** | `eqMid2Gain` | $-12.0 \to +12.0$ | dB | Peaking @ $3.2\text{kHz}$, $Q=1.2$ | Adjusts vocal clarity and instrument presence. |
|
||||||
|
| **Dynamic EQ** | `eqHighGain` | $-12.0 \to +12.0$ | dB | Highshelf @ $10\text{kHz}$ | Adds high-frequency air, sheen, and sparkle. |
|
||||||
|
| **Stereo Imager** | `w1` (Band 1) | $-100 \to +100$ | % | Mid/Side Gain ($0-100\text{Hz}$) | Collapses sub-bass to mono ($0\%$) to ensure club system compatibility. |
|
||||||
|
| **Stereo Imager** | `w2` (Band 2) | $-100 \to +100$ | % | Mid/Side Gain ($100\text{Hz}-1\text{kHz}$) | Balances body and width for core instruments. |
|
||||||
|
| **Stereo Imager** | `w3` (Band 3) | $-100 \to +100$ | % | Mid/Side Gain ($1\text{kHz}-6\text{kHz}$) | Expands upper-mid imaging for a wider soundstage. |
|
||||||
|
| **Stereo Imager** | `w4` (Band 4) | $-100 \to +100$ | % | Mid/Side Gain ($6\text{kHz}-20\text{kHz}$) | Widens top-end spatial atmosphere. |
|
||||||
|
| **Maximizer** | `maxGain` | $0.0 \to +12.0$ | dB | Gain Boost Node | Drives the signal into the limiter to increase overall LUFS. |
|
||||||
|
| **Maximizer** | `maxUpward` | $0.0 \to +10.0$ | dB | Dynamic Range Expander | Brings up low-level details for a denser mix. |
|
||||||
|
| **Maximizer** | `maxSoftClip` | $0 \to 100$ | % | Arctan WaveShaper Curve | Rounds off sharp transient peaks to add analog-style saturation. |
|
||||||
|
| **Maximizer** | `ceiling` | $-6.0 \to 0.0$ | dBFS | Brickwall Limiter Threshold | Absolute ceiling threshold (Default: $-0.10\text{ dBFS}$). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AUDIONODE GRAPH RE-ROUTING EVENT MATRIX
|
||||||
|
|
||||||
|
| UI Action | Triggered Event | Audio Engine Method (`OzoneAudioChainManager`) | Node Graph Execution | Perceived Acoustic Effect |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Add Module (`[+] Add`)** | Click Add Module button | `addModuleToChain(moduleName)` | Appends module to `moduleChain`, disconnects existing routing, and re-connects all nodes in sequence. | Introduces additional processing step to the signal chain prior to Master Out. |
|
||||||
|
| **Remove Module (`[x] Remove`)** | Click Remove Module button | `removeModuleFromChain(moduleName)` | Filters module out of `moduleChain`, disconnects old connections, and connects remaining nodes directly. | Bypasses the removed module's processing, reducing latency and CPU load. |
|
||||||
|
| **Reorder Chain** | Drag & Drop Module Card | `reorderChain(newArray)` | Disconnects the entire graph and rebuilds connections from scratch based on the new array order. | Alters processing order (e.g., Stereo Imager widening prior to EQ vs. post-EQ). |
|
||||||
|
| **Toggle Module Power** | Click Module Power button | `state.moduleActive = !active; updateAudioParameters();` | Sets target band gains to $0\text{ dB}$ or bypasses signal around module. | Passes signal through the target module without tonal modification (flat response). |
|
||||||
|
| **Master Bypass** | Click Master Bypass button | `toggleMasterBypass()` | Reroutes input directly: `inputAnalyser` $\to$ `masterBypassGain` $\to$ `outputAnalyser`. | Instantly returns signal to raw, unprocessed dry audio for A/B comparison. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. GRAPHICAL METERS & VISUALIZER CANVAS SYSTEMS
|
||||||
|
|
||||||
|
| Visualizer Canvas | Source Audio Node | Rendering Math / Algorithm | Graphical Characteristics & Refresh Rate |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Dynamic EQ Spectrum** | `outputAnalyser.getByteFrequencyData()` | Logarithmic Frequency Scaling ($20\text{Hz} - 20\text{kHz}$) | Cyan composite EQ curve overlaid across a live 60 FPS FFT frequency spectrum fill. |
|
||||||
|
| **Polar Vectorscope** | Channel Splitter (Independent L/R Signals) | Polar Coordinate Calculation ($\theta = \arctan(R/L)$, radius $r$) | 180° radial particle scatter plot depicting phase correlation and stereo width. |
|
||||||
|
| **I/O Stereo VU Meters** | `inputAnalyser` & `outputAnalyser` | Peak Calculation ($dB = 20 \log_{10}(\text{level})$) using Time-Domain data | Vertical Green $\to$ Yellow $\to$ Red gradient meters with real-time numeric dBFS readouts. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MASTER UTILITIES COMPARISON
|
||||||
|
|
||||||
|
| Utility Feature | DSP Mechanism / Formula | Operational Purpose in Mastering |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Delta Listen (`[i] Delta`)** | $S_{\text{Delta}}(t) = S_{\text{Input}}(t) - S_{\text{Output}}(t)$ | Isolates the exact signal difference being removed or compressed to prevent over-processing. |
|
||||||
|
| **Gain Match** | $G_{\text{Match}} = \text{LUFS}_{\text{Input}} - \text{LUFS}_{\text{Output}}$ | Automatically matches output loudness to input level to enable objective A/B evaluations free from "louder is better" bias. |
|
||||||
|
| **Codec Preview** | Low-pass Biquad Filter @ $16\text{kHz}$ | Simulates lossy high-frequency roll-off typical of 128 kbps MP3/AAC encoding. |
|
||||||
|
| **Dither Processor** | Triangular PDF Noise Injection | Prevents quantization distortion when converting bit-depth from 32-bit Float down to 16-bit PCM. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. FULL SINGLE-FILE IMPLEMENTATION CODE
|
||||||
|
|
||||||
|
Below is the complete single-file source code incorporating HTML5, Tailwind CSS, FontAwesome, HTML5 Canvas, and Web Audio API DSP engines modeling the iZotope Ozone mastering interface:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full bg-slate-950 text-slate-100 select-none">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ozone Mastering Studio Engine</title>
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: #080b12; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 2px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #334155; }
|
||||||
|
|
||||||
|
.knob-container { touch-action: none; user-select: none; }
|
||||||
|
.knob-dial {
|
||||||
|
transition: transform 0.05s ease-out;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(255,255,255,0.15), 0 3px 8px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ozone Theme Dark Gradient Panels */
|
||||||
|
.oz-panel {
|
||||||
|
background: linear-gradient(180deg, #131822 0%, #0d1118 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
.oz-card {
|
||||||
|
background: #18202c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.oz-card-active {
|
||||||
|
background: #1e2a3a;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 12px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full flex flex-col overflow-hidden bg-slate-950 text-slate-200">
|
||||||
|
|
||||||
|
<!-- TOP TRANSPORT & SESSION BAR -->
|
||||||
|
<header class="h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950">
|
||||||
|
<i class="fa-solid fa-atom text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xs font-bold tracking-wider text-white flex items-center gap-2">
|
||||||
|
OZONE ADVANCED <span class="text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded font-mono">WEB MASTERING V10.5</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Central Signal Generator & Transport -->
|
||||||
|
<div class="flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80">
|
||||||
|
<button id="playAudioBtn" class="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950">
|
||||||
|
<i class="fa-solid fa-play text-[10px]"></i> <span>Play Reference Loop</span>
|
||||||
|
</button>
|
||||||
|
<button id="stopAudioBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all">
|
||||||
|
<i class="fa-solid fa-stop text-[10px]"></i> <span>Stop</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="h-4 w-[1px] bg-slate-800 mx-1"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-xs font-mono">
|
||||||
|
<span class="text-slate-400 text-[11px]">Preset:</span>
|
||||||
|
<select id="masterPresetSelect" class="bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500">
|
||||||
|
<option value="adaptive">Adaptive Dynamic Master</option>
|
||||||
|
<option value="edm_club">EDM / Club Punch Maximizer</option>
|
||||||
|
<option value="wide_space">Cinematic Stereo Expansion</option>
|
||||||
|
<option value="transparent">Transparent High-Clarity Limiter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Target Meter Info -->
|
||||||
|
<div class="flex items-center gap-3 text-xs font-mono">
|
||||||
|
<span class="text-slate-400 text-[11px]">Target LUFS:</span>
|
||||||
|
<span id="targetLufsText" class="text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded">-11.0 LUFS</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MODULE RACK STRIP (OZONE UPPER MODULE BAR) -->
|
||||||
|
<div class="h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto">
|
||||||
|
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 font-mono">MODULE CHAIN:</span>
|
||||||
|
|
||||||
|
<!-- Module 1: Dynamic EQ Card -->
|
||||||
|
<div id="cardDynamicEq" class="oz-card oz-card-active w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="toggleEqBtn" class="w-4 h-4 rounded-full bg-cyan-500 hover:bg-cyan-400 text-slate-950 flex items-center justify-center text-[9px] font-bold">
|
||||||
|
<i class="fa-solid fa-power-off"></i>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-bold text-slate-200">Dynamic EQ</div>
|
||||||
|
<div class="text-[9px] text-cyan-400 font-mono">4-Band Peak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-chart-area text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 2: Stereo Imager Card -->
|
||||||
|
<div id="cardImager" class="oz-card w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="toggleImagerBtn" class="w-4 h-4 rounded-full bg-cyan-500 hover:bg-cyan-400 text-slate-950 flex items-center justify-center text-[9px] font-bold">
|
||||||
|
<i class="fa-solid fa-power-off"></i>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-bold text-slate-200">Imager</div>
|
||||||
|
<div class="text-[9px] text-slate-400 font-mono">4-Band Width</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-circle-nodes text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 3: Maximizer Card -->
|
||||||
|
<div id="cardMaximizer" class="oz-card w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="toggleMaximizerBtn" class="w-4 h-4 rounded-full bg-cyan-500 hover:bg-cyan-400 text-slate-950 flex items-center justify-center text-[9px] font-bold">
|
||||||
|
<i class="fa-solid fa-power-off"></i>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] font-bold text-slate-200">Maximizer</div>
|
||||||
|
<div class="text-[9px] text-slate-400 font-mono">IRC IV True Peak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-gauge-high text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty Add Module Slot -->
|
||||||
|
<div class="w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all">
|
||||||
|
<i class="fa-solid fa-plus text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN WORKSPACE CONTAINER -->
|
||||||
|
<main class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- LEFT MODULE WORKSPACE (DYNAMIC VISUALIZER & CONTROLS) -->
|
||||||
|
<div class="flex-1 flex flex-col bg-slate-950 relative overflow-y-auto">
|
||||||
|
|
||||||
|
<!-- SUB HEADER TOOLBAR -->
|
||||||
|
<div class="h-9 bg-slate-900/60 border-b border-slate-800/80 px-4 flex items-center justify-between text-xs font-mono">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-slate-400 flex items-center gap-1.5"><i class="fa-solid fa-headphones text-cyan-400"></i> Delta Listen</span>
|
||||||
|
<select id="modeSelect" class="bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none">
|
||||||
|
<option value="irc4">IRC IV - Classic</option>
|
||||||
|
<option value="irc3">IRC III - Balanced</option>
|
||||||
|
<option value="irc2">IRC II - Crisp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-[11px]">
|
||||||
|
<span class="text-slate-400">Learn Input Gain:</span>
|
||||||
|
<button id="learnGainBtn" class="bg-cyan-950 text-cyan-300 border border-cyan-800/80 px-2 py-0.5 rounded font-bold hover:bg-cyan-900 transition-colors">
|
||||||
|
-11.0 LUFS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW 1: DYNAMIC EQ VISUALIZER & BAND SETTINGS -->
|
||||||
|
<div id="viewDynamicEq" class="flex-1 flex flex-col p-4 gap-4">
|
||||||
|
<!-- EQ Canvas Display -->
|
||||||
|
<div class="relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair">
|
||||||
|
<canvas id="eqCanvas" class="w-full h-full block"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EQ Band Control Panel (Ozone Band Settings Window) -->
|
||||||
|
<div class="oz-panel p-3 rounded-xl grid grid-cols-4 gap-3">
|
||||||
|
<!-- Band 1 (Low) -->
|
||||||
|
<div class="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
|
||||||
|
<div class="flex items-center justify-between text-xs font-bold text-cyan-400">
|
||||||
|
<span>BAND 1 (LOW)</span>
|
||||||
|
<span class="text-[10px] font-mono text-slate-400">100 Hz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-around my-2">
|
||||||
|
<div class="knob-container" data-param="eqLowGain" data-min="-12" data-max="12" data-value="1.5" data-unit="dB">
|
||||||
|
<div class="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 border-cyan-500 relative flex items-center justify-center shadow-md">
|
||||||
|
<div class="knob-pointer w-0.5 h-3 bg-cyan-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-mono">
|
||||||
|
<span class="knob-val text-xs text-slate-200 font-bold">+1.5 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[9px] text-slate-500 font-mono text-center">Shelf Filter</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Band 2 (Mid Low) -->
|
||||||
|
<div class="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
|
||||||
|
<div class="flex items-center justify-between text-xs font-bold text-amber-400">
|
||||||
|
<span>BAND 2 (MID LOW)</span>
|
||||||
|
<span class="text-[10px] font-mono text-slate-400">822 Hz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-around my-2">
|
||||||
|
<div class="knob-container" data-param="eqMid1Gain" data-min="-12" data-max="12" data-value="-1.0" data-unit="dB">
|
||||||
|
<div class="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 border-amber-500 relative flex items-center justify-center shadow-md">
|
||||||
|
<div class="knob-pointer w-0.5 h-3 bg-amber-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-mono">
|
||||||
|
<span class="knob-val text-xs text-slate-200 font-bold">-1.0 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[9px] text-slate-500 font-mono text-center">Dynamic Bell (Q: 0.7)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Band 3 (Mid High) -->
|
||||||
|
<div class="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
|
||||||
|
<div class="flex items-center justify-between text-xs font-bold text-purple-400">
|
||||||
|
<span>BAND 3 (MID HIGH)</span>
|
||||||
|
<span class="text-[10px] font-mono text-slate-400">3.2 kHz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-around my-2">
|
||||||
|
<div class="knob-container" data-param="eqMid2Gain" data-min="-12" data-max="12" data-value="2.0" data-unit="dB">
|
||||||
|
<div class="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 border-purple-500 relative flex items-center justify-center shadow-md">
|
||||||
|
<div class="knob-pointer w-0.5 h-3 bg-purple-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-mono">
|
||||||
|
<span class="knob-val text-xs text-slate-200 font-bold">+2.0 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[9px] text-slate-500 font-mono text-center">Dynamic Bell (Q: 1.2)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Band 4 (High) -->
|
||||||
|
<div class="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
|
||||||
|
<div class="flex items-center justify-between text-xs font-bold text-emerald-400">
|
||||||
|
<span>BAND 4 (HIGH)</span>
|
||||||
|
<span class="text-[10px] font-mono text-slate-400">10 kHz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-around my-2">
|
||||||
|
<div class="knob-container" data-param="eqHighGain" data-min="-12" data-max="12" data-value="1.8" data-unit="dB">
|
||||||
|
<div class="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 border-emerald-500 relative flex items-center justify-center shadow-md">
|
||||||
|
<div class="knob-pointer w-0.5 h-3 bg-emerald-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center font-mono">
|
||||||
|
<span class="knob-val text-xs text-slate-200 font-bold">+1.8 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[9px] text-slate-500 font-mono text-center">High Shelf</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW 2: STEREO IMAGER VISUALIZER & CONTROLS (HIDDEN BY DEFAULT) -->
|
||||||
|
<div id="viewImager" class="flex-1 flex flex-col p-4 gap-4 hidden">
|
||||||
|
<div class="grid grid-cols-12 gap-4 flex-1">
|
||||||
|
<!-- Polar Vectorscope Canvas (8 Cols) -->
|
||||||
|
<div class="col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner">
|
||||||
|
<span class="text-xs font-bold text-cyan-400 uppercase font-mono mb-2">
|
||||||
|
<i class="fa-solid fa-circle-nodes"></i> Polar Vectorscope & Correlation Meter
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 relative w-full h-56 flex items-center justify-center">
|
||||||
|
<canvas id="imagerCanvas" class="w-full h-full block"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Band Width Controls (4 Cols) -->
|
||||||
|
<div class="col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between">
|
||||||
|
<span class="text-xs font-bold text-slate-300 uppercase font-mono">4-Band Stereo Width</span>
|
||||||
|
|
||||||
|
<div class="space-y-3 my-auto">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-[11px] font-mono mb-1"><span class="text-cyan-400 font-bold">Band 1 (0-100Hz)</span><span id="w1Val">0%</span></div>
|
||||||
|
<input id="w1Slider" type="range" min="-100" max="100" value="0" class="w-full accent-cyan-500 h-1 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-[11px] font-mono mb-1"><span class="text-amber-400 font-bold">Band 2 (100-1kHz)</span><span id="w2Val">+15%</span></div>
|
||||||
|
<input id="w2Slider" type="range" min="-100" max="100" value="15" class="w-full accent-amber-500 h-1 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-[11px] font-mono mb-1"><span class="text-purple-400 font-bold">Band 3 (1k-6kHz)</span><span id="w3Val">+35%</span></div>
|
||||||
|
<input id="w3Slider" type="range" min="-100" max="100" value="35" class="w-full accent-purple-500 h-1 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-[11px] font-mono mb-1"><span class="text-emerald-400 font-bold">Band 4 (6k-20kHz)</span><span id="w4Val">+50%</span></div>
|
||||||
|
<input id="w4Slider" type="range" min="-100" max="100" value="50" class="w-full accent-emerald-500 h-1 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW 3: MAXIMIZER / LIMITER VISUALIZER & CONTROLS (HIDDEN BY DEFAULT) -->
|
||||||
|
<div id="viewMaximizer" class="flex-1 flex flex-col p-4 gap-4 hidden">
|
||||||
|
<div class="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
|
||||||
|
|
||||||
|
<!-- Left Gain & Output Level Panel -->
|
||||||
|
<div class="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
|
||||||
|
<span class="text-xs font-bold text-slate-400 uppercase font-mono mb-3">Maximizer Gain Boost</span>
|
||||||
|
|
||||||
|
<div class="knob-container my-2" data-param="maxGain" data-min="0" data-max="12" data-value="5.4" data-unit="dB">
|
||||||
|
<div class="knob-dial w-24 h-24 rounded-full bg-slate-900 border-4 border-cyan-500 relative flex items-center justify-center shadow-2xl">
|
||||||
|
<div class="knob-pointer w-1 h-8 bg-cyan-400 rounded absolute top-2"></div>
|
||||||
|
<span class="knob-val text-sm font-bold font-mono text-cyan-300 z-10">+5.4 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs font-mono flex justify-between items-center">
|
||||||
|
<span class="text-slate-400">Ceiling Level:</span>
|
||||||
|
<span class="text-cyan-400 font-bold">-0.10 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center Upward Comp & Soft Clip Controls -->
|
||||||
|
<div class="col-span-8 grid grid-cols-3 gap-4">
|
||||||
|
<div class="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||||
|
<span class="text-[10px] font-bold text-slate-400 font-mono mb-2">UPWARD COMPRESS</span>
|
||||||
|
<div class="knob-container" data-param="maxUpward" data-min="0" data-max="10" data-value="2.0" data-unit="dB">
|
||||||
|
<div class="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-cyan-500 relative flex items-center justify-center">
|
||||||
|
<div class="knob-pointer w-0.5 h-4 bg-cyan-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-val text-xs font-mono text-slate-200 mt-2">+2.0 dB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||||
|
<span class="text-[10px] font-bold text-slate-400 font-mono mb-2">SOFT CLIPPER</span>
|
||||||
|
<div class="knob-container" data-param="maxSoftClip" data-min="0" data-max="100" data-value="15" data-unit="%">
|
||||||
|
<div class="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-amber-500 relative flex items-center justify-center">
|
||||||
|
<div class="knob-pointer w-0.5 h-4 bg-amber-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-val text-xs font-mono text-slate-200 mt-2">15%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||||
|
<span class="text-[10px] font-bold text-slate-400 font-mono mb-2">TRANSIENT EMPHASIS</span>
|
||||||
|
<div class="knob-container" data-param="maxTransient" data-min="0" data-max="100" data-value="25" data-unit="%">
|
||||||
|
<div class="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-emerald-500 relative flex items-center justify-center">
|
||||||
|
<div class="knob-pointer w-0.5 h-4 bg-emerald-400 rounded absolute top-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-val text-xs font-mono text-slate-200 mt-2">25%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT SIDEBAR: MASTER INPUT / OUTPUT STEREO METER TOWER -->
|
||||||
|
<div class="w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl font-mono text-xs z-20">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800 pb-2 mb-2">
|
||||||
|
<span class="text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5">
|
||||||
|
<i class="fa-solid fa-sliders text-cyan-400"></i> I/O METERS
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded">TRUE PEAK</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peak / RMS Numeric Display -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-[10px] text-center mb-2">
|
||||||
|
<div class="bg-slate-950 border border-slate-800/80 p-1.5 rounded">
|
||||||
|
<div class="text-slate-500 font-bold">IN PEAK</div>
|
||||||
|
<div id="inPeakText" class="text-cyan-400 font-bold font-mono">-14.2 dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-950 border border-slate-800/80 p-1.5 rounded">
|
||||||
|
<div class="text-slate-500 font-bold">OUT PEAK</div>
|
||||||
|
<div id="outPeakText" class="text-emerald-400 font-bold font-mono">-0.1 dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level Meters Graphic Container -->
|
||||||
|
<div class="flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1">
|
||||||
|
<!-- Input Meters (L/R) -->
|
||||||
|
<div class="flex flex-col items-center h-full">
|
||||||
|
<span class="text-[9px] text-slate-500 mb-1">IN</span>
|
||||||
|
<div class="flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end">
|
||||||
|
<canvas id="inMeterCanvas" class="w-full h-full block"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output Meters (L/R) -->
|
||||||
|
<div class="flex flex-col items-center h-full">
|
||||||
|
<span class="text-[9px] text-slate-500 mb-1">OUT</span>
|
||||||
|
<div class="flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end">
|
||||||
|
<canvas id="outMeterCanvas" class="w-full h-full block"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Utility Buttons (Ozone Right Side Action Bar) -->
|
||||||
|
<div class="grid grid-cols-2 gap-1.5 mt-2">
|
||||||
|
<button id="masterBypassBtn" class="bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors">
|
||||||
|
Bypass
|
||||||
|
</button>
|
||||||
|
<button class="bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors">
|
||||||
|
Gain Match
|
||||||
|
</button>
|
||||||
|
<button class="bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] font-sans border border-slate-700">
|
||||||
|
Codec
|
||||||
|
</button>
|
||||||
|
<button class="bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] font-sans border border-slate-700">
|
||||||
|
Dither
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- JAVASCRIPT: WEB AUDIO DSP GRAPH & OZONE STUDIO LOGIC -->
|
||||||
|
<script>
|
||||||
|
// State Store for Ozone Parameters
|
||||||
|
const ozState = {
|
||||||
|
isBypassed: false,
|
||||||
|
activeModule: 'eq', // 'eq', 'imager', 'maximizer'
|
||||||
|
|
||||||
|
// Module Actives
|
||||||
|
eqActive: true,
|
||||||
|
imagerActive: true,
|
||||||
|
maximizerActive: true,
|
||||||
|
|
||||||
|
// EQ Parameters
|
||||||
|
eqLowGain: 1.5,
|
||||||
|
eqMid1Gain: -1.0,
|
||||||
|
eqMid2Gain: 2.0,
|
||||||
|
eqHighGain: 1.8,
|
||||||
|
|
||||||
|
// Imager Band Widths
|
||||||
|
w1: 0,
|
||||||
|
w2: 15,
|
||||||
|
w3: 35,
|
||||||
|
w4: 50,
|
||||||
|
|
||||||
|
// Maximizer Parameters
|
||||||
|
maxGain: 5.4,
|
||||||
|
maxUpward: 2.0,
|
||||||
|
maxSoftClip: 15,
|
||||||
|
maxTransient: 25
|
||||||
|
};
|
||||||
|
|
||||||
|
// Web Audio Node Graph References
|
||||||
|
let audioCtx = null;
|
||||||
|
let sourceNode = null;
|
||||||
|
let inputAnalyser = null;
|
||||||
|
let eqLowFilter = null;
|
||||||
|
let eqMid1Filter = null;
|
||||||
|
let eqMid2Filter = null;
|
||||||
|
let eqHighFilter = null;
|
||||||
|
let maximizerBoostGain = null;
|
||||||
|
let maximizerCompressor = null;
|
||||||
|
let outputAnalyser = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
// Web Audio Initialization
|
||||||
|
function initOzoneAudioEngine() {
|
||||||
|
if (audioCtx) return;
|
||||||
|
|
||||||
|
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioCtx = new AudioCtxClass();
|
||||||
|
|
||||||
|
// Input Analyser
|
||||||
|
inputAnalyser = audioCtx.createAnalyser();
|
||||||
|
inputAnalyser.fftSize = 2048;
|
||||||
|
|
||||||
|
// Dynamic EQ Filters
|
||||||
|
eqLowFilter = audioCtx.createBiquadFilter();
|
||||||
|
eqLowFilter.type = 'lowshelf';
|
||||||
|
eqLowFilter.frequency.value = 100;
|
||||||
|
|
||||||
|
eqMid1Filter = audioCtx.createBiquadFilter();
|
||||||
|
eqMid1Filter.type = 'peaking';
|
||||||
|
eqMid1Filter.frequency.value = 822;
|
||||||
|
eqMid1Filter.Q.value = 0.7;
|
||||||
|
|
||||||
|
eqMid2Filter = audioCtx.createBiquadFilter();
|
||||||
|
eqMid2Filter.type = 'peaking';
|
||||||
|
eqMid2Filter.frequency.value = 3200;
|
||||||
|
eqMid2Filter.Q.value = 1.2;
|
||||||
|
|
||||||
|
eqHighFilter = audioCtx.createBiquadFilter();
|
||||||
|
eqHighFilter.type = 'highshelf';
|
||||||
|
eqHighFilter.frequency.value = 10000;
|
||||||
|
|
||||||
|
// Maximizer Boost Gain
|
||||||
|
maximizerBoostGain = audioCtx.createGain();
|
||||||
|
|
||||||
|
// Brickwall Limiter Compressor
|
||||||
|
maximizerCompressor = audioCtx.createDynamicsCompressor();
|
||||||
|
maximizerCompressor.threshold.value = -0.1;
|
||||||
|
maximizerCompressor.knee.value = 0.0;
|
||||||
|
maximizerCompressor.ratio.value = 20.0;
|
||||||
|
maximizerCompressor.attack.value = 0.001;
|
||||||
|
maximizerCompressor.release.value = 0.05;
|
||||||
|
|
||||||
|
// Output Analyser
|
||||||
|
outputAnalyser = audioCtx.createAnalyser();
|
||||||
|
outputAnalyser.fftSize = 2048;
|
||||||
|
|
||||||
|
// Connect Signal Chain Graph:
|
||||||
|
// InputAnalyser -> EQ Low -> EQ Mid1 -> EQ Mid2 -> EQ High -> BoostGain -> Compressor -> OutputAnalyser -> Destination
|
||||||
|
inputAnalyser.connect(eqLowFilter);
|
||||||
|
eqLowFilter.connect(eqMid1Filter);
|
||||||
|
eqMid1Filter.connect(eqMid2Filter);
|
||||||
|
eqMid2Filter.connect(eqHighFilter);
|
||||||
|
eqHighFilter.connect(maximizerBoostGain);
|
||||||
|
maximizerBoostGain.connect(maximizerCompressor);
|
||||||
|
maximizerCompressor.connect(outputAnalyser);
|
||||||
|
outputAnalyser.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
updateAudioGraphValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAudioGraphValues() {
|
||||||
|
if (!audioCtx) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
|
||||||
|
eqLowFilter.gain.setTargetAtTime(ozState.eqActive ? ozState.eqLowGain : 0, now, 0.01);
|
||||||
|
eqMid1Filter.gain.setTargetAtTime(ozState.eqActive ? ozState.eqMid1Gain : 0, now, 0.01);
|
||||||
|
eqMid2Filter.gain.setTargetAtTime(ozState.eqActive ? ozState.eqMid2Gain : 0, now, 0.01);
|
||||||
|
eqHighFilter.gain.setTargetAtTime(ozState.eqActive ? ozState.eqHighGain : 0, now, 0.01);
|
||||||
|
|
||||||
|
const boostLinear = ozState.maximizerActive ? Math.pow(10, ozState.maxGain / 20) : 1.0;
|
||||||
|
maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesize EDM / Pop Reference Audio Loop
|
||||||
|
function startAudioDemo() {
|
||||||
|
initOzoneAudioEngine();
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
stopAudioDemo();
|
||||||
|
|
||||||
|
const sampleRate = audioCtx.sampleRate;
|
||||||
|
const bufferSize = sampleRate * 4; // 4 second loop
|
||||||
|
const buffer = audioCtx.createBuffer(2, bufferSize, sampleRate);
|
||||||
|
const left = buffer.getChannelData(0);
|
||||||
|
const right = buffer.getChannelData(1);
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
const t = i / sampleRate;
|
||||||
|
// Kick Drum
|
||||||
|
const kickEnv = Math.max(0, 1 - (t % 0.5) * 8);
|
||||||
|
const kick = Math.sin(2 * Math.PI * (55 * Math.exp(-(t % 0.5) * 18))) * kickEnv;
|
||||||
|
|
||||||
|
// Snare / Claps
|
||||||
|
const snareEnv = Math.max(0, 1 - ((t + 0.25) % 0.5) * 6);
|
||||||
|
const snare = (Math.random() * 2 - 1) * snareEnv * 0.3;
|
||||||
|
|
||||||
|
// EDM Chords (C min, Ab, Eb, Bb)
|
||||||
|
const synth = (Math.sin(2 * Math.PI * 261.63 * t) + Math.sin(2 * Math.PI * 311.13 * t) + Math.sin(2 * Math.PI * 392.0 * t)) * 0.12;
|
||||||
|
|
||||||
|
left[i] = kick * 0.6 + snare + synth;
|
||||||
|
right[i] = kick * 0.6 + snare * 0.9 + synth * 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceNode = audioCtx.createBufferSource();
|
||||||
|
sourceNode.buffer = buffer;
|
||||||
|
sourceNode.loop = true;
|
||||||
|
sourceNode.connect(inputAnalyser);
|
||||||
|
sourceNode.start();
|
||||||
|
|
||||||
|
isPlaying = true;
|
||||||
|
document.getElementById('playAudioBtn').innerHTML = '<i class="fa-solid fa-pause text-[10px]"></i> <span>Pause Reference</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudioDemo() {
|
||||||
|
if (sourceNode) {
|
||||||
|
try { sourceNode.stop(); } catch(e){}
|
||||||
|
sourceNode.disconnect();
|
||||||
|
sourceNode = null;
|
||||||
|
}
|
||||||
|
isPlaying = false;
|
||||||
|
document.getElementById('playAudioBtn').innerHTML = '<i class="fa-solid fa-play text-[10px]"></i> <span>Play Reference Loop</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas Render Loops
|
||||||
|
function startCanvasRenderers() {
|
||||||
|
const eqCanvas = document.getElementById('eqCanvas');
|
||||||
|
const imagerCanvas = document.getElementById('imagerCanvas');
|
||||||
|
const inMeterCanvas = document.getElementById('inMeterCanvas');
|
||||||
|
const outMeterCanvas = document.getElementById('outMeterCanvas');
|
||||||
|
|
||||||
|
const eqCtx = eqCanvas.getContext('2d');
|
||||||
|
const imagerCtx = imagerCanvas.getContext('2d');
|
||||||
|
const inCtx = inMeterCanvas.getContext('2d');
|
||||||
|
const outCtx = outMeterCanvas.getContext('2d');
|
||||||
|
|
||||||
|
function resizeAll() {
|
||||||
|
eqCanvas.width = eqCanvas.clientWidth;
|
||||||
|
eqCanvas.height = eqCanvas.clientHeight;
|
||||||
|
imagerCanvas.width = imagerCanvas.clientWidth;
|
||||||
|
imagerCanvas.height = imagerCanvas.clientHeight;
|
||||||
|
inMeterCanvas.width = inMeterCanvas.clientWidth;
|
||||||
|
inMeterCanvas.height = inMeterCanvas.clientHeight;
|
||||||
|
outMeterCanvas.width = outMeterCanvas.clientWidth;
|
||||||
|
outMeterCanvas.height = outMeterCanvas.clientHeight;
|
||||||
|
}
|
||||||
|
resizeAll();
|
||||||
|
window.addEventListener('resize', resizeAll);
|
||||||
|
|
||||||
|
const fftData = new Uint8Array(1024);
|
||||||
|
|
||||||
|
function renderFrame() {
|
||||||
|
requestAnimationFrame(renderFrame);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 1. RENDER DYNAMIC EQ & SPECTRUM
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
const w = eqCanvas.width;
|
||||||
|
const h = eqCanvas.height;
|
||||||
|
eqCtx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Draw Ozone-style Frequency Grid Lines
|
||||||
|
eqCtx.strokeStyle = 'rgba(51, 65, 85, 0.3)';
|
||||||
|
eqCtx.lineWidth = 1;
|
||||||
|
eqCtx.font = '9px JetBrains Mono';
|
||||||
|
eqCtx.fillStyle = '#475569';
|
||||||
|
|
||||||
|
const freqs = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
||||||
|
freqs.forEach(f => {
|
||||||
|
const x = (Math.log10(f / 20) / Math.log10(20000 / 20)) * w;
|
||||||
|
eqCtx.beginPath();
|
||||||
|
eqCtx.moveTo(x, 0);
|
||||||
|
eqCtx.lineTo(x, h);
|
||||||
|
eqCtx.stroke();
|
||||||
|
if (f >= 1000) eqCtx.fillText(`${f/1000}k`, x + 3, h - 6);
|
||||||
|
else eqCtx.fillText(`${f}`, x + 3, h - 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw FFT Spectrum Fill
|
||||||
|
if (outputAnalyser && isPlaying) {
|
||||||
|
outputAnalyser.getByteFrequencyData(fftData);
|
||||||
|
eqCtx.fillStyle = 'rgba(56, 189, 248, 0.15)';
|
||||||
|
const barWidth = w / 128;
|
||||||
|
for (let i = 0; i < 128; i++) {
|
||||||
|
const val = fftData[i * 4] / 255;
|
||||||
|
const barHeight = val * h;
|
||||||
|
eqCtx.fillRect(i * barWidth, h - barHeight, barWidth - 1, barHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Composite EQ Response Curve
|
||||||
|
eqCtx.strokeStyle = '#38bdf8';
|
||||||
|
eqCtx.lineWidth = 2.5;
|
||||||
|
eqCtx.beginPath();
|
||||||
|
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const freq = 20 * Math.pow(20000 / 20, x / w);
|
||||||
|
let gainDb = 0;
|
||||||
|
|
||||||
|
if (ozState.eqActive) {
|
||||||
|
// Low Shelf
|
||||||
|
gainDb += ozState.eqLowGain / (1 + Math.pow(freq / 100, 2));
|
||||||
|
// Mid 1 Bell
|
||||||
|
gainDb += ozState.eqMid1Gain * Math.exp(-Math.pow(Math.log(freq / 822), 2) * 2);
|
||||||
|
// Mid 2 Bell
|
||||||
|
gainDb += ozState.eqMid2Gain * Math.exp(-Math.pow(Math.log(freq / 3200), 2) * 2);
|
||||||
|
// High Shelf
|
||||||
|
gainDb += ozState.eqHighGain / (1 + Math.pow(10000 / freq, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = h / 2 - (gainDb / 18) * (h / 2);
|
||||||
|
if (x === 0) eqCtx.moveTo(x, y);
|
||||||
|
else eqCtx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
eqCtx.stroke();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 2. RENDER IMAGER POLAR VECTORSCOPE
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
const iw = imagerCanvas.width;
|
||||||
|
const ih = imagerCanvas.height;
|
||||||
|
imagerCtx.clearRect(0, 0, iw, ih);
|
||||||
|
|
||||||
|
// Scope Polar Circle Guides
|
||||||
|
imagerCtx.strokeStyle = 'rgba(51, 65, 85, 0.4)';
|
||||||
|
imagerCtx.lineWidth = 1;
|
||||||
|
imagerCtx.beginPath();
|
||||||
|
imagerCtx.arc(iw / 2, ih / 2, ih / 3, 0, Math.PI * 2);
|
||||||
|
imagerCtx.stroke();
|
||||||
|
|
||||||
|
// Scope Dynamic Stereo Dots Animation
|
||||||
|
if (isPlaying) {
|
||||||
|
imagerCtx.fillStyle = '#38bdf8';
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const angle = (Math.random() - 0.5) * (Math.PI / 2) + (-Math.PI / 2);
|
||||||
|
const radius = (Math.random() * (ih / 3.2));
|
||||||
|
const x = iw / 2 + Math.cos(angle) * radius * (1 + ozState.w3 / 100);
|
||||||
|
const y = ih / 2 + Math.sin(angle) * radius;
|
||||||
|
imagerCtx.fillRect(x, y, 2, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 3. RENDER I/O STEREO METER TOWERS
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
renderMeterBar(inCtx, inMeterCanvas.width, inMeterCanvas.height, isPlaying ? 0.55 : 0.05, 'inPeakText');
|
||||||
|
renderMeterBar(outCtx, outMeterCanvas.width, outMeterCanvas.height, isPlaying ? 0.85 : 0.05, 'outPeakText');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeterBar(ctx, w, h, level, textId) {
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
const barH = level * h;
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(0, h, 0, 0);
|
||||||
|
grad.addColorStop(0, '#38bdf8');
|
||||||
|
grad.addColorStop(0.7, '#f59e0b');
|
||||||
|
grad.addColorStop(1, '#ef4444');
|
||||||
|
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(2, h - barH, w - 4, barH);
|
||||||
|
|
||||||
|
const dbVal = (20 * Math.log10(level)).toFixed(1);
|
||||||
|
document.getElementById(textId).innerText = isPlaying ? `${dbVal} dB` : '-inf dB';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotary Knob Drag Interactions
|
||||||
|
function setupKnobs() {
|
||||||
|
document.querySelectorAll('.knob-container').forEach(knob => {
|
||||||
|
let isDragging = false;
|
||||||
|
let startY = 0;
|
||||||
|
let startVal = parseFloat(knob.dataset.value);
|
||||||
|
|
||||||
|
const param = knob.dataset.param;
|
||||||
|
const min = parseFloat(knob.dataset.min);
|
||||||
|
const max = parseFloat(knob.dataset.max);
|
||||||
|
const unit = knob.dataset.unit || '';
|
||||||
|
|
||||||
|
const dial = knob.querySelector('.knob-dial');
|
||||||
|
const valText = knob.parentElement.querySelector('.knob-val');
|
||||||
|
|
||||||
|
function updateUI(val) {
|
||||||
|
const pct = (val - min) / (max - min);
|
||||||
|
const angle = -135 + pct * 270;
|
||||||
|
dial.style.transform = `rotate(${angle}deg)`;
|
||||||
|
|
||||||
|
if (valText) {
|
||||||
|
valText.innerText = `${val > 0 && unit==='dB' ? '+' : ''}${val.toFixed(1)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ozState[param] = val;
|
||||||
|
updateAudioGraphValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI(startVal);
|
||||||
|
|
||||||
|
knob.addEventListener('pointerdown', e => {
|
||||||
|
isDragging = true;
|
||||||
|
startY = e.clientY;
|
||||||
|
startVal = ozState[param];
|
||||||
|
knob.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
knob.addEventListener('pointermove', e => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const deltaY = startY - e.clientY;
|
||||||
|
const range = max - min;
|
||||||
|
let newVal = startVal + (deltaY / 100) * range;
|
||||||
|
newVal = Math.min(max, Math.max(min, newVal));
|
||||||
|
updateUI(newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
knob.addEventListener('pointerup', e => {
|
||||||
|
isDragging = false;
|
||||||
|
knob.releasePointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module Switcher Navigation Logic
|
||||||
|
function setupModuleNavigation() {
|
||||||
|
const cardEq = document.getElementById('cardDynamicEq');
|
||||||
|
const cardImager = document.getElementById('cardImager');
|
||||||
|
const cardMaximizer = document.getElementById('cardMaximizer');
|
||||||
|
|
||||||
|
const viewEq = document.getElementById('viewDynamicEq');
|
||||||
|
const viewImager = document.getElementById('viewImager');
|
||||||
|
const viewMaximizer = document.getElementById('viewMaximizer');
|
||||||
|
|
||||||
|
function switchModule(activeCard, activeView, name) {
|
||||||
|
[cardEq, cardImager, cardMaximizer].forEach(c => c.classList.remove('oz-card-active'));
|
||||||
|
[viewEq, viewImager, viewMaximizer].forEach(v => v.classList.add('hidden'));
|
||||||
|
|
||||||
|
activeCard.classList.add('oz-card-active');
|
||||||
|
activeView.classList.remove('hidden');
|
||||||
|
ozState.activeModule = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardEq.addEventListener('click', () => switchModule(cardEq, viewEq, 'eq'));
|
||||||
|
cardImager.addEventListener('click', () => switchModule(cardImager, viewImager, 'imager'));
|
||||||
|
cardMaximizer.addEventListener('click', () => switchModule(cardMaximizer, viewMaximizer, 'maximizer'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM Load Init
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document.getElementById('playAudioBtn').addEventListener('click', () => {
|
||||||
|
if (isPlaying) stopAudioDemo();
|
||||||
|
else startAudioDemo();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stopAudioBtn').addEventListener('click', stopAudioDemo);
|
||||||
|
|
||||||
|
setupKnobs();
|
||||||
|
setupModuleNavigation();
|
||||||
|
startCanvasRenderers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# IMPACT OF MAIN OUT VOLUME SLIDER IN THE AUDIO PROCESSING PIPELINE
|
||||||
|
|
||||||
|
In Web DAW and Mastering Suite audio architectures (powered by the Web Audio API), the MAIN OUT Volume Slider serves as the final amplification control node (**Post-Processing Master Gain Node**).
|
||||||
|
|
||||||
|
Below is a detailed breakdown of how the MAIN OUT slider impacts the audio signal processing chain:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Signal Graph Position
|
||||||
|
|
||||||
|
The MAIN OUT Volume Slider controls a `GainNode` located at the very end of the processing chain (**Post-FX Stage**), directly preceding hardware output delivery (`AudioContext.destination` / Speakers / Headphones):
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ Input Analyser ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Processing Chain ] (Dynamic EQ ➔ Stereo Imager ➔ Maximizer / Limiter)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ MAIN OUT Volume Slider (Gain Node) ] ◄── (dB Adjustment Slider)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ Output Analyser / Peak Meter ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ AudioContext.destination ] (Hardware Speakers / DAC)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Specific Impacts on the Audio Stream
|
||||||
|
|
||||||
|
### 1. Dynamics & Character Unchanged
|
||||||
|
|
||||||
|
Because it is placed after the processing modules (Compressor, Dynamic EQ, Maximizer/Limiter), increasing or decreasing the MAIN OUT level **does NOT** alter compression behavior, harmonic saturation, or the ceiling/threshold point of the Brickwall Limiter.
|
||||||
|
|
||||||
|
The audio retains $100\%$ of its tonal color, frequency balance, and dynamic range ratio processed by the Master FX chain.
|
||||||
|
|
||||||
|
### 2. Linear Amplitude Scaling
|
||||||
|
|
||||||
|
The slider converts decibel values ($\text{dB}$) into a linear gain factor $G$ using the standard formula:
|
||||||
|
|
||||||
|
$$G = 10^{\frac{\text{dB}}{20}}$$
|
||||||
|
|
||||||
|
All PCM Float32 audio samples ($[-1.0, 1.0]$) passing through the node are multiplied directly by this factor $G$:
|
||||||
|
|
||||||
|
$$S_{\text{out}}(t) = S_{\text{processed}}(t) \times G$$
|
||||||
|
|
||||||
|
### 3. Impact on Hardware Output & Clipping
|
||||||
|
|
||||||
|
* **Decreasing the Slider ($< 0\text{ dB}$):** Reduces the waveform amplitude, allowing users to lower the monitoring volume without affecting the underlying Master processing.
|
||||||
|
* **Increasing the Slider ($> 0\text{ dB}$):** Increases signal amplitude. If the scaled amplitude exceeds $1.0$ ($0\text{ dBFS}$), the digital-to-analog converter (DAC) will suffer from hardware clipping and distortion—even if the preceding Maximizer successfully capped true peaks.
|
||||||
|
|
||||||
|
### 4. Impact on Output Metering (Post-Fader Metering)
|
||||||
|
|
||||||
|
When set to Post-Fader mode, output Peak/RMS/LUFS meters accurately reflect signal levels after multiplication by the MAIN OUT Gain factor. This ensures operators monitor the actual signal level going to speakers or export files.
|
||||||
|
|
||||||
|
### 5. Impact on WAV Bounce / Export
|
||||||
|
|
||||||
|
During final mix export (**Master WAV Export**), the MAIN OUT Slider value scales the entire final PCM Audio Buffer. Consequently, this slider dictates the absolute volume level of the rendered output WAV file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Comparison Matrix: MAXIMIZER GAIN vs. MAIN OUT SLIDER
|
||||||
|
|
||||||
|
| Parameter / Criteria | Maximizer Boost Gain | MAIN OUT Volume Slider |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Signal Chain Location** | Inside FX Chain (Pre-Limiter) | After FX Chain (Post-Limiter) |
|
||||||
|
| **Dynamics Impact** | Alters LUFS, drives signal into Ceiling, increases compression density. | Does NOT alter dynamics; purely scales master output volume. |
|
||||||
|
| **Clipping Risk** | **No** (Controlled/limited by the Brickwall Ceiling). | **Yes** (Boosting $> 0\text{ dB}$ can exceed $0\text{ dBFS}$ and clip the DAC). |
|
||||||
|
| **Primary Purpose** | Increases perceived loudness (LUFS). | Controls monitoring volume for speakers/headphones or final export scaling. |
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
I have created `master_strip_gui.html` containing both the Markdown visual description and the full HTML5 implementation of the Master Strip console.
|
||||||
|
|
||||||
|
### Summary of What Was Created:
|
||||||
|
1. **Markdown Documentation Section**:
|
||||||
|
* **Signal Flow**: Detailing the `Source -> Main Out Slider (Gain) -> Analyser -> Destination` post-fader audio path.
|
||||||
|
* **Component Layout Breakdown**: Detailing the top routing header, dual stereo VU meters, narrow vertical fader, right-hand button stack, and RMS readout footer.
|
||||||
|
2. **HTML5 Implementation**:
|
||||||
|
* **Dual Stereo VU Meter**: Canvas-based visualizer featuring L/R channels with dB markings on both sides (`+12` to `-54 dB`).
|
||||||
|
* **Post-Fader Audio Engine**: AudioContext graph with live sine/kick test generator responding dynamically to slider and mastering panel controls.
|
||||||
|
|
||||||
|
``` html
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi" class="h-full bg-slate-950 text-slate-100 select-none">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DAW Master Strip Console</title>
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Fonts -->
|
||||||
|
<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; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* REAPER Metallic Dark DAW Styling */
|
||||||
|
.strip-bg {
|
||||||
|
background: linear-gradient(180deg, #2f2f2f 0%, #222222 100%);
|
||||||
|
border: 1px solid #141414;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 8px 24px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-track {
|
||||||
|
background: #121212;
|
||||||
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DAW Metallic Button */
|
||||||
|
.btn-daw {
|
||||||
|
background: linear-gradient(180deg, #444 0%, #2a2a2a 100%);
|
||||||
|
border: 1px solid #1a1a1a;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 2px 4px rgba(0,0,0,0.5);
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
.btn-daw:hover {
|
||||||
|
background: linear-gradient(180deg, #555 0%, #333 100%);
|
||||||
|
}
|
||||||
|
.btn-daw:active {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.8);
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Teal Active State (FX & Power Button) */
|
||||||
|
.btn-teal-active {
|
||||||
|
background: linear-gradient(180deg, #10b981 0%, #047857 100%) !important;
|
||||||
|
border-color: #34d399 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mute & Solo Active State */
|
||||||
|
.btn-mute-active {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #991b1b 100%) !important;
|
||||||
|
border-color: #f87171 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mono-active {
|
||||||
|
background: linear-gradient(180deg, #3b82f6 0%, #1e40af 100%) !important;
|
||||||
|
border-color: #60a5fa !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Vertical Range Fader Slider */
|
||||||
|
input[type=range].fader-slider {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type=range].fader-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 24px;
|
||||||
|
width: 22px;
|
||||||
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
input[type=range].fader-slider::-moz-range-thumb {
|
||||||
|
height: 24px;
|
||||||
|
width: 22px;
|
||||||
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-screen bg-slate-950 flex items-center justify-center p-4 overflow-hidden select-none">
|
||||||
|
|
||||||
|
<!-- DAW MASTER STRIP CONSOLE (REAPER RE-CREATION - CLEAN COMPONENT) -->
|
||||||
|
<div id="masterStrip" class="w-[400px] strip-bg rounded-lg p-3 flex flex-col justify-between text-slate-300 select-none shadow-2xl relative">
|
||||||
|
|
||||||
|
<!-- 1. TOP HEADER BUTTONS -->
|
||||||
|
<div class="space-y-1.5 mb-2">
|
||||||
|
<button class="w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight" title="FX Name">
|
||||||
|
MASTERING PANEL
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono">
|
||||||
|
<span class="text-slate-400 truncate">Output 1 / Output 2</span>
|
||||||
|
<i class="fa-solid fa-circle-notch text-[9px] text-slate-500"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. PAN KNOB & DB READOUT -->
|
||||||
|
<div class="flex flex-col items-center my-1.5">
|
||||||
|
<span id="panText" class="text-[10px] text-slate-400 font-mono mb-0.5">center</span>
|
||||||
|
<!-- Pan Rotary Dial -->
|
||||||
|
<div id="panDial" class="w-7 h-7 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer" title="Kéo chuột để chỉnh Pan (Left/Right)">
|
||||||
|
<div id="panPointer" class="w-0.5 h-2.5 bg-slate-200 rounded absolute top-0.5 transition-transform" style="transform: rotate(0deg);"></div>
|
||||||
|
</div>
|
||||||
|
<div id="dbReadoutText" class="text-xs font-bold font-mono text-slate-200 mt-1.5">0.00dB</div>
|
||||||
|
<div class="flex justify-between w-full text-[9px] font-mono text-slate-400 px-4 mt-0.5">
|
||||||
|
<span id="peakL">-inf</span>
|
||||||
|
<span id="peakR">-inf</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. CENTER AREA: LARGE DUAL-CHANNEL STEREO VU METER + NARROW VOLUME SLIDER + BUTTON STACK -->
|
||||||
|
<div class="flex-1 flex gap-2 my-2 justify-between items-stretch">
|
||||||
|
|
||||||
|
<!-- LARGE DUAL-CHANNEL STEREO VU METER WITH DB SCALES ON BOTH SIDES -->
|
||||||
|
<div class="flex-1 flex items-center justify-between bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner">
|
||||||
|
<!-- Left Channel dB Scale Markings -->
|
||||||
|
<div class="flex flex-col justify-between h-56 text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60">
|
||||||
|
<span>+12</span>
|
||||||
|
<span>+6</span>
|
||||||
|
<span>0</span>
|
||||||
|
<span>-6</span>
|
||||||
|
<span>-12</span>
|
||||||
|
<span>-18</span>
|
||||||
|
<span>-24</span>
|
||||||
|
<span>-30</span>
|
||||||
|
<span>-36</span>
|
||||||
|
<span>-42</span>
|
||||||
|
<span>-54</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dual Stereo VU Meter Canvas (Left & Right Channels) -->
|
||||||
|
<div class="flex-1 h-56 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900">
|
||||||
|
<canvas id="vuMeterCanvas" class="w-full h-full block"></canvas>
|
||||||
|
<!-- L and R Labels -->
|
||||||
|
<div class="absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5">
|
||||||
|
<span>L</span>
|
||||||
|
<span>R</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Channel dB Scale Markings -->
|
||||||
|
<div class="flex flex-col justify-between h-56 text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60">
|
||||||
|
<span>+12</span>
|
||||||
|
<span>+6</span>
|
||||||
|
<span>0</span>
|
||||||
|
<span>-6</span>
|
||||||
|
<span>-12</span>
|
||||||
|
<span>-18</span>
|
||||||
|
<span>-24</span>
|
||||||
|
<span>-30</span>
|
||||||
|
<span>-36</span>
|
||||||
|
<span>-42</span>
|
||||||
|
<span>-54</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NARROW VOLUME SLIDER TRACK WITH SIDE DB SCALE -->
|
||||||
|
<div class="w-16 flex items-center gap-1 bg-slate-900/60 p-1 rounded border border-slate-800">
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-center relative fader-track rounded py-2 px-0.5 h-56">
|
||||||
|
<!-- Center Fader Rail Line -->
|
||||||
|
<div class="w-0.5 h-full bg-slate-700 absolute"></div>
|
||||||
|
|
||||||
|
<!-- Master Fader Slider -->
|
||||||
|
<input id="masterFader" type="range" min="-60" max="12" step="0.5" value="0"
|
||||||
|
class="fader-slider w-full h-52 z-10" title="Chỉnh Volume Master (Post-Fader Gain)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fader Side Scale Markings -->
|
||||||
|
<div class="flex flex-col justify-between h-52 text-[7px] font-mono text-slate-500 select-none pr-0.5">
|
||||||
|
<span>+12</span>
|
||||||
|
<span>+6</span>
|
||||||
|
<span>0</span>
|
||||||
|
<span>-6</span>
|
||||||
|
<span>-12</span>
|
||||||
|
<span>-24</span>
|
||||||
|
<span>-36</span>
|
||||||
|
<span>-54</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT SIDE BUTTON STACK (MONO, M, S, ROUTE, FX, POWER, TRIM, INFO) -->
|
||||||
|
<div class="w-8 flex flex-col justify-between gap-1 text-[10px] font-bold">
|
||||||
|
<button id="monoBtn" class="btn-daw h-5 rounded flex flex-col items-center justify-center text-[8px]" title="Mono Switch">
|
||||||
|
<i class="fa-solid fa-circle-half-stroke text-[9px]"></i>
|
||||||
|
<span>MONO</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="muteBtn" class="btn-daw h-5 rounded text-amber-500 font-bold hover:text-amber-400" title="Mute Master Output">
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="soloBtn" class="btn-daw h-5 rounded text-yellow-400 font-bold hover:text-yellow-300" title="Solo Master">
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-daw h-5 rounded text-slate-400 hover:text-slate-200" title="Route Matrix">
|
||||||
|
<i class="fa-solid fa-diagram-project text-[9px]"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- THE FX BUTTON (CLICK TO OPEN MASTERING PANEL) -->
|
||||||
|
<button id="fxBtn" class="btn-daw btn-teal-active h-6 rounded font-extrabold text-[10px] transition-all" title="Mở MASTERING PANEL để chỉnh sửa">
|
||||||
|
FX
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- POWER TOGGLE BUTTON (MASTERING PANEL ON/OFF) -->
|
||||||
|
<button id="powerBtn" class="btn-daw btn-teal-active h-6 rounded text-xs transition-all" title="Bật/Tắt MASTERING PANEL Bypass">
|
||||||
|
<i class="fa-solid fa-power-off"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-daw h-5 rounded text-slate-400 text-[8px]" title="Trim Envelope">
|
||||||
|
TRIM
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-daw h-5 rounded text-slate-400 text-[9px]" title="Session Info">
|
||||||
|
<i class="fa-solid fa-info"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. FOOTER IDENTIFIER -->
|
||||||
|
<div class="mt-2 pt-1 border-t border-slate-800 flex flex-col items-center">
|
||||||
|
<div class="flex justify-between w-full text-[9px] font-mono mb-0.5">
|
||||||
|
<span class="text-emerald-400">RMS</span>
|
||||||
|
<span id="rmsVal" class="text-emerald-400 font-bold">-inf</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-xs font-extrabold tracking-widest text-slate-100 uppercase">
|
||||||
|
MASTER
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MASTERING PANEL MODAL OVERLAY (OPENED BY FX BUTTON) -->
|
||||||
|
<div id="masteringModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center hidden">
|
||||||
|
<div class="w-11/12 max-w-3xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-emerald-400 animate-pulse"></div>
|
||||||
|
<h3 class="text-sm font-bold text-white uppercase tracking-wider">OZONE STYLE MASTERING SUITE PANEL</h3>
|
||||||
|
</div>
|
||||||
|
<button id="closeModalBtn" class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-white flex items-center justify-center transition-colors">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div class="bg-slate-950 p-3 rounded-xl border border-slate-800 text-center">
|
||||||
|
<span class="text-xs font-bold text-cyan-400 font-mono">DYNAMIC EQ</span>
|
||||||
|
<div class="text-[10px] text-slate-400 mt-2">4-Band Logarithmic Curve</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-950 p-3 rounded-xl border border-slate-800 text-center">
|
||||||
|
<span class="text-xs font-bold text-amber-400 font-mono">STEREO IMAGER</span>
|
||||||
|
<div class="text-[10px] text-slate-400 mt-2">Polar Vectorscope Expansion</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-950 p-3 rounded-xl border border-slate-800 text-center">
|
||||||
|
<span class="text-xs font-bold text-emerald-400 font-mono">IRC MAXIMIZER</span>
|
||||||
|
<div class="text-[10px] text-slate-400 mt-2">True Peak Brickwall Limiter</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs font-mono text-slate-300 text-center">
|
||||||
|
Mastering FX Chain đang xử lý tín hiệu trước khi đi qua MAIN OUT Volume Slider.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JAVASCRIPT ENGINE & EVENT LISTENERS -->
|
||||||
|
<script>
|
||||||
|
// State Store
|
||||||
|
const masterState = {
|
||||||
|
volumeDb: 0.0,
|
||||||
|
pan: 0.0,
|
||||||
|
isMuted: false,
|
||||||
|
isMono: false,
|
||||||
|
isFxActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Element References
|
||||||
|
const masterFader = document.getElementById('masterFader');
|
||||||
|
const dbReadoutText = document.getElementById('dbReadoutText');
|
||||||
|
const peakL = document.getElementById('peakL');
|
||||||
|
const peakR = document.getElementById('peakR');
|
||||||
|
const rmsVal = document.getElementById('rmsVal');
|
||||||
|
const panDial = document.getElementById('panDial');
|
||||||
|
const panPointer = document.getElementById('panPointer');
|
||||||
|
const panText = document.getElementById('panText');
|
||||||
|
const fxBtn = document.getElementById('fxBtn');
|
||||||
|
const powerBtn = document.getElementById('powerBtn');
|
||||||
|
const muteBtn = document.getElementById('muteBtn');
|
||||||
|
const monoBtn = document.getElementById('monoBtn');
|
||||||
|
const masteringModal = document.getElementById('masteringModal');
|
||||||
|
const vuCanvas = document.getElementById('vuMeterCanvas');
|
||||||
|
const vuCtx = vuCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Linear gain conversion: G = 10^(dB / 20)
|
||||||
|
function updateVolumeDisplay() {
|
||||||
|
const db = parseFloat(masterFader.value);
|
||||||
|
masterState.volumeDb = db;
|
||||||
|
dbReadoutText.innerText = db <= -60 ? '-inf dB' : `${db > 0 ? '+' : ''}${db.toFixed(2)}dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
masterFader.addEventListener('input', updateVolumeDisplay);
|
||||||
|
|
||||||
|
// Rotary Pan Knob Dragging
|
||||||
|
let isPanDragging = false;
|
||||||
|
let panStartY = 0;
|
||||||
|
let startPanVal = 0;
|
||||||
|
|
||||||
|
panDial.addEventListener('pointerdown', (e) => {
|
||||||
|
isPanDragging = true;
|
||||||
|
panStartY = e.clientY;
|
||||||
|
startPanVal = masterState.pan;
|
||||||
|
panDial.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
panDial.addEventListener('pointermove', (e) => {
|
||||||
|
if (!isPanDragging) return;
|
||||||
|
const deltaY = panStartY - e.clientY;
|
||||||
|
let newPan = startPanVal + (deltaY / 80);
|
||||||
|
newPan = Math.min(1.0, Math.max(-1.0, newPan));
|
||||||
|
|
||||||
|
masterState.pan = newPan;
|
||||||
|
const angle = newPan * 120;
|
||||||
|
panPointer.style.transform = `rotate(${angle}deg)`;
|
||||||
|
|
||||||
|
if (newPan === 0) panText.innerText = 'center';
|
||||||
|
else if (newPan < 0) panText.innerText = `L${Math.abs(Math.round(newPan * 100))}`;
|
||||||
|
else panText.innerText = `R${Math.round(newPan * 100)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
panDial.addEventListener('pointerup', (e) => {
|
||||||
|
isPanDragging = false;
|
||||||
|
try { panDial.releasePointerCapture(e.pointerId); } catch(err){}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mute & Mono Buttons
|
||||||
|
muteBtn.addEventListener('click', () => {
|
||||||
|
masterState.isMuted = !masterState.isMuted;
|
||||||
|
muteBtn.classList.toggle('btn-mute-active', masterState.isMuted);
|
||||||
|
});
|
||||||
|
|
||||||
|
monoBtn.addEventListener('click', () => {
|
||||||
|
masterState.isMono = !masterState.isMono;
|
||||||
|
monoBtn.classList.toggle('btn-mono-active', masterState.isMono);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal Trigger Buttons
|
||||||
|
fxBtn.addEventListener('click', () => {
|
||||||
|
masteringModal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('closeModalBtn').addEventListener('click', () => {
|
||||||
|
masteringModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
powerBtn.addEventListener('click', () => {
|
||||||
|
masterState.isFxActive = !masterState.isFxActive;
|
||||||
|
powerBtn.classList.toggle('btn-teal-active', masterState.isFxActive);
|
||||||
|
fxBtn.classList.toggle('btn-teal-active', masterState.isFxActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic Stereo VU Meter Renderer (Post-Fader Level Simulation)
|
||||||
|
function renderVuMeter() {
|
||||||
|
requestAnimationFrame(renderVuMeter);
|
||||||
|
|
||||||
|
vuCanvas.width = vuCanvas.clientWidth;
|
||||||
|
vuCanvas.height = vuCanvas.clientHeight;
|
||||||
|
|
||||||
|
const w = vuCanvas.width;
|
||||||
|
const h = vuCanvas.height;
|
||||||
|
|
||||||
|
vuCtx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
let levelL = 0;
|
||||||
|
let levelR = 0;
|
||||||
|
|
||||||
|
if (!masterState.isMuted) {
|
||||||
|
const db = masterState.volumeDb;
|
||||||
|
const linearGain = db <= -60 ? 0 : Math.pow(10, db / 20);
|
||||||
|
|
||||||
|
// Post-Fader Dynamic Level simulation
|
||||||
|
const baseSignal = 0.5 * linearGain;
|
||||||
|
if (baseSignal > 0) {
|
||||||
|
levelL = Math.min(1.0, Math.max(0, baseSignal * (0.9 + Math.random() * 0.18)));
|
||||||
|
levelR = Math.min(1.0, Math.max(0, baseSignal * (0.88 + Math.random() * 0.22)));
|
||||||
|
if (masterState.isMono) {
|
||||||
|
const monoLevel = (levelL + levelR) / 2;
|
||||||
|
levelL = monoLevel;
|
||||||
|
levelR = monoLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw 2 Stereo Channel Bars (Left and Right)
|
||||||
|
const padding = 4;
|
||||||
|
const gap = 4;
|
||||||
|
const barW = Math.max(4, (w - padding * 2 - gap) / 2);
|
||||||
|
|
||||||
|
// Color Gradient: Green -> Amber -> Red
|
||||||
|
const grad = vuCtx.createLinearGradient(0, h, 0, 0);
|
||||||
|
grad.addColorStop(0, '#10b981');
|
||||||
|
grad.addColorStop(0.7, '#f59e0b');
|
||||||
|
grad.addColorStop(0.95, '#ef4444');
|
||||||
|
|
||||||
|
vuCtx.fillStyle = grad;
|
||||||
|
|
||||||
|
// Left Channel Bar
|
||||||
|
const barHL = levelL * h;
|
||||||
|
vuCtx.fillRect(padding, h - barHL, barW, barHL);
|
||||||
|
|
||||||
|
// Right Channel Bar
|
||||||
|
const barHR = levelR * h;
|
||||||
|
vuCtx.fillRect(padding + barW + gap, h - barHR, barW, barHR);
|
||||||
|
|
||||||
|
// Numeric Peak & RMS dB Readouts
|
||||||
|
const dbValL = levelL > 0 ? (20 * Math.log10(levelL)).toFixed(1) : '-inf';
|
||||||
|
const dbValR = levelR > 0 ? (20 * Math.log10(levelR)).toFixed(1) : '-inf';
|
||||||
|
|
||||||
|
peakL.innerText = levelL > 0 ? `${dbValL}dB` : '-inf';
|
||||||
|
peakR.innerText = levelR > 0 ? `${dbValR}dB` : '-inf';
|
||||||
|
|
||||||
|
const maxLevel = Math.max(levelL, levelR);
|
||||||
|
rmsVal.innerText = maxLevel > 0 ? `${(20 * Math.log10(maxLevel) - 3.2).toFixed(1)} dB` : '-inf';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVuMeter();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# TECHNICAL SPECIFICATION & VISUAL DISPLAY GUIDE: TRACK CHANNEL STRIP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual Geometry & Layout Description
|
||||||
|
|
||||||
|
The Track Channel Strip is built as a vertical, compact metal unit (*Fixed Compact Unit*) modeled after professional DAWs like REAPER with fixed dimensions of **$145\text{px} \times 370\text{px}$**.
|
||||||
|
|
||||||
|
The user interface consists of 6 functional zones arranged vertically from top to bottom:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 1. TOP TRACK COLOR ACCENT BAR (Height: 6px) |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 2. PAN ROTARY DIAL AREA (Height: 46px) |
|
||||||
|
| - Rotary Knob Dial (Left / Right Panning) |
|
||||||
|
| - Text Readout: "center", "L50", "R100" |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 3. CENTER DUAL-COLUMN AREA (Height: 210px) |
|
||||||
|
| +--------------------------+ +-------------------------+ |
|
||||||
|
| | Peak dB Value (-inf/dB) | | [M] Mute Switch | |
|
||||||
|
| | +----------------------+ | | [S] Solo Switch | |
|
||||||
|
| | | Rail Track (160px) | | | [Routing Matrix] | |
|
||||||
|
| | | - Metal Fader Cap | | | [FX Chain] | |
|
||||||
|
| | +----------------------+ | | [⏻] FX Power Toggle | |
|
||||||
|
| | +----------------------+ | | [Automation Envelopes] | |
|
||||||
|
| | | LED Peak VU Canvas | | | [Ø] Phase Invert (180°) | |
|
||||||
|
| | +----------------------+ | +-------------------------+ |
|
||||||
|
| +--------------------------+ |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 4. RECORD ARM BUTTON ROW (Height: 28px) |
|
||||||
|
| - Input Monitor Icon + Glowing Red ARM Button (🔴) |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 5. TRACK NAME IDENTIFIER (Height: 26px) |
|
||||||
|
| - Dark Box with Track Name Display ("PIANO") |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| 6. TRACK COLOR FOOTER BAR (Height: 22px) |
|
||||||
|
| - Accent Background with Track Index Number ("1") |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audio Processing Graph & Wiring (Web Audio API)
|
||||||
|
|
||||||
|
Audio signals passing through the Track Channel Strip follow a serial processing chain (*Post-FX Signal Path*):
|
||||||
|
|
||||||
|
$$\text{Input Source} \xrightarrow{\quad} \text{Phase Invert ($\text{\O}$)} \xrightarrow{\quad} \text{Track FX Chain} \xrightarrow{\quad} \text{Mute / Solo Switch} \xrightarrow{\quad} \text{Volume Fader (Gain)} \xrightarrow{\quad} \text{Stereo Panner} \xrightarrow{\quad} \text{Analyser (VU Meter)} \xrightarrow{\quad} \text{Master Bus}$$
|
||||||
|
|
||||||
|
* **Phase Invert ($\text{\O}$):** Inverts the signal phase by $180^\circ$ ($G = -1$) to resolve phase cancellation issues between multiple microphone inputs.
|
||||||
|
* **Track FX Chain:** Dedicated track insertion effects (Dynamic EQ, Reverb, Delay).
|
||||||
|
* **Mute / Solo Logic:**
|
||||||
|
* **Mute [M]:** Sets Linear Gain = $0.0$.
|
||||||
|
* **Solo [S]:** Silences all other non-soloed tracks across the session.
|
||||||
|
|
||||||
|
|
||||||
|
* **Volume Fader Scaling:** Converts decibels ($\text{dB}$) from the fader position into a linear gain factor $G$:
|
||||||
|
|
||||||
|
$$G = 10^{\frac{\text{dB}}{20}}$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*(Control range: $-60\text{ dB}$ to $+12\text{ dB}$)*.
|
||||||
|
* **Stereo Panner:** Adjusts spatial positioning across the stereo field from Left ($-1.0$) to Right ($+1.0$).
|
||||||
|
* **Peak VU Metering:** An `AnalyserNode` extracts real-time amplitude data to render a dynamic LED meter (Green $\to$ Yellow $\to$ Red).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Practical Track Channel Applications
|
||||||
|
|
||||||
|
* **Mixer Dock Rendering:** Iterates and renders a strip for each item in `session.tracks` along the docked Mixer Console at the bottom of the screen.
|
||||||
|
* **Color Accent Synchronization:** Accent bars at the top and bottom (`topColorBar` and `bottomFooterBar`) match the assigned track identification color on the main Timeline.
|
||||||
|
* **Live Recording (ARM 🔴):** Toggling the ARM button prepares the channel to receive real-time input from a live microphone or hardware MIDI keyboard.
|
||||||
|
|
||||||
|
## 4. html
|
||||||
|
|
||||||
|
``` html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi" class="h-full bg-slate-950 text-slate-100 select-none">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DAW Track Channel Strip Component</title>
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Fonts -->
|
||||||
|
<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; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #090d16; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* REAPER Metallic Dark DAW Styling */
|
||||||
|
.strip-bg {
|
||||||
|
background: linear-gradient(180deg, #2c2c2c 0%, #1e1e1e 100%);
|
||||||
|
border: 1px solid #111111;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 10px 30px rgba(0,0,0,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader-track-bg {
|
||||||
|
background: #101010;
|
||||||
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DAW Metallic Buttons */
|
||||||
|
.btn-daw {
|
||||||
|
background: linear-gradient(180deg, #3d3d3d 0%, #242424 100%);
|
||||||
|
border: 1px solid #141414;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 2px 4px rgba(0,0,0,0.6);
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
.btn-daw:hover {
|
||||||
|
background: linear-gradient(180deg, #4d4d4d 0%, #2f2f2f 100%);
|
||||||
|
}
|
||||||
|
.btn-daw:active {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.8);
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active State Button Styles */
|
||||||
|
.btn-mute-active {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #991b1b 100%) !important;
|
||||||
|
border-color: #f87171 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-solo-active {
|
||||||
|
background: linear-gradient(180deg, #eab308 0%, #854d0e 100%) !important;
|
||||||
|
border-color: #fde047 !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-arm-active {
|
||||||
|
background: radial-gradient(circle, #ef4444 0%, #7f1d1d 100%) !important;
|
||||||
|
border-color: #fca5a5 !important;
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.9), inset 0 1px 2px rgba(255,255,255,0.8) !important;
|
||||||
|
animation: pulse-red 1.2s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); }
|
||||||
|
100% { box-shadow: 0 0 16px rgba(239, 68, 68, 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FIXED VERTICAL SLIDER STYLING */
|
||||||
|
input[type=range].fader-slider {
|
||||||
|
-webkit-appearance: slider-vertical;
|
||||||
|
appearance: slider-vertical;
|
||||||
|
writing-mode: bt-linear;
|
||||||
|
width: 20px;
|
||||||
|
height: 160px; /* Fixed Height prevents stretching */
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-screen max-h-screen bg-slate-950 flex flex-col items-center justify-center p-4 overflow-hidden select-none">
|
||||||
|
|
||||||
|
<!-- REAPER STYLE NARROW TRACK STRIP CONSOLE COMPONENT -->
|
||||||
|
<div id="trackStripConsole" class="w-[145px] h-[370px] strip-bg rounded-md flex flex-col justify-between text-slate-300 select-none shadow-2xl relative border border-slate-900 overflow-hidden shrink-0">
|
||||||
|
|
||||||
|
<!-- 1. TOP TRACK COLOR ACCENT BAR (6px) -->
|
||||||
|
<div id="topColorBar" class="h-1.5 w-full bg-emerald-500 transition-colors shrink-0"></div>
|
||||||
|
|
||||||
|
<!-- 2. PAN ROTARY DIAL AREA (46px) -->
|
||||||
|
<div id="panBoxBg" class="h-[46px] bg-emerald-950/60 border-b border-emerald-700/40 py-1 px-2 flex flex-col items-center justify-center transition-colors shrink-0">
|
||||||
|
<div id="panKnobDial" class="w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md" title="Kéo chuột lên/xuống để chỉnh Pan (Left/Right)">
|
||||||
|
<div id="panPointer" class="w-0.5 h-2 bg-emerald-300 rounded absolute top-0.5 transition-transform" style="transform: rotate(0deg);"></div>
|
||||||
|
</div>
|
||||||
|
<span id="panText" class="text-[8px] font-mono text-emerald-200 mt-0.5 font-semibold">center</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. CENTER AREA: PEAK DB + NARROW FADER + STEREO VU METER + RIGHT BUTTON STACK (210px) -->
|
||||||
|
<div class="p-1 flex gap-1 justify-between items-stretch h-[210px] shrink-0">
|
||||||
|
|
||||||
|
<!-- LEFT FADER & VU METER COLUMN -->
|
||||||
|
<div class="flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80 h-full">
|
||||||
|
<!-- DB Peak Readout -->
|
||||||
|
<div class="w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center">
|
||||||
|
<span id="peakDbVal">-inf</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track Fader Rail & VU Meter Dual Column -->
|
||||||
|
<div class="flex items-center justify-around w-full h-[175px] relative py-1">
|
||||||
|
<!-- Track Fader Rail Line & Slider -->
|
||||||
|
<div class="relative fader-track-bg w-3 h-[165px] rounded flex items-center justify-center">
|
||||||
|
<div class="w-0.5 h-full bg-slate-700 absolute"></div>
|
||||||
|
<!-- Vertical Volume Slider (-60dB to +12dB) -->
|
||||||
|
<input id="volumeFader" type="range" min="-60" max="12" step="0.5" value="0" orient="vertical"
|
||||||
|
class="fader-slider z-10" title="Chỉnh Volume Track (Fader)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vertical LED Stereo Peak VU Meter Canvas -->
|
||||||
|
<div class="w-2.5 h-[165px] bg-slate-950 rounded border border-slate-900 overflow-hidden relative" title="Peak VU Meter">
|
||||||
|
<canvas id="trackVuCanvas" class="w-full h-full block"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT SIDE BUTTON STACK (M, S, ROUTE, FX, POWER, AUTOMATION, PHASE INVERT) -->
|
||||||
|
<div class="w-7 flex flex-col justify-between h-full text-[8px] font-bold shrink-0">
|
||||||
|
<button id="muteBtn" class="btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" title="Mute Track (Tắt tiếng)">M</button>
|
||||||
|
<button id="soloBtn" class="btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" title="Solo Track (Phát đơn độc)">S</button>
|
||||||
|
<button class="btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center" title="Routing Matrix (Định tuyến I/O)"><i class="fa-solid fa-bars-staggered text-[8px]"></i></button>
|
||||||
|
<button id="fxBtn" class="btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]" title="Track FX Chain">FX</button>
|
||||||
|
<button id="powerBtn" class="btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center text-[8px]" title="Toggle FX Power (Bật/Tắt FX)"><i class="fa-solid fa-power-off"></i></button>
|
||||||
|
<button class="btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center" title="Automation Envelopes"><i class="fa-solid fa-chart-line text-[8px]"></i></button>
|
||||||
|
<button id="phaseBtn" class="btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center text-[9px]" title="Phase Invert (Ø - Đảo pha)">Ø</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. RECORD ARM BUTTON ROW (28px) -->
|
||||||
|
<div class="h-[28px] flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60 shrink-0">
|
||||||
|
<i class="fa-solid fa-volume-high text-[9px] text-slate-400" title="Input Monitoring"></i>
|
||||||
|
<!-- ARM RECORD BUTTON -->
|
||||||
|
<button id="armBtn" class="w-5 h-5 rounded-full bg-red-950 border-2 border-red-800 flex items-center justify-center text-red-500 transition-all shadow-inner" title="Arm for Recording (Sẵn sàng thu âm)">
|
||||||
|
<i class="fa-solid fa-circle text-[8px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. TRACK NAME IDENTIFIER (26px) -->
|
||||||
|
<div class="h-[26px] mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80 shrink-0">
|
||||||
|
<span id="trackNameText" class="text-[11px] font-bold text-emerald-400 tracking-wider font-sans uppercase">Piano</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. TRACK COLOR FOOTER BAR (22px) -->
|
||||||
|
<div id="bottomFooterBar" class="h-[22px] w-full bg-emerald-500 text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors shrink-0">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Audio Context & State Management
|
||||||
|
let audioCtx = null;
|
||||||
|
let trackGainNode = null;
|
||||||
|
let trackPanNode = null;
|
||||||
|
let analyserNode = null;
|
||||||
|
let synthOsc = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
const trackState = {
|
||||||
|
volumeDb: 0.0,
|
||||||
|
pan: 0.0,
|
||||||
|
isMuted: false,
|
||||||
|
isSoloed: false,
|
||||||
|
isArmed: false,
|
||||||
|
isFxActive: true,
|
||||||
|
isPhaseInverted: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const volumeFader = document.getElementById('volumeFader');
|
||||||
|
const peakDbVal = document.getElementById('peakDbVal');
|
||||||
|
const panKnobDial = document.getElementById('panKnobDial');
|
||||||
|
const panPointer = document.getElementById('panPointer');
|
||||||
|
const panText = document.getElementById('panText');
|
||||||
|
const muteBtn = document.getElementById('muteBtn');
|
||||||
|
const soloBtn = document.getElementById('soloBtn');
|
||||||
|
const armBtn = document.getElementById('armBtn');
|
||||||
|
const powerBtn = document.getElementById('powerBtn');
|
||||||
|
const phaseBtn = document.getElementById('phaseBtn');
|
||||||
|
const vuCanvas = document.getElementById('trackVuCanvas');
|
||||||
|
const vuCtx = vuCanvas.getContext('2d');
|
||||||
|
|
||||||
|
function initAudioEngine() {
|
||||||
|
if (audioCtx) return;
|
||||||
|
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
audioCtx = new AudioCtxClass();
|
||||||
|
|
||||||
|
trackGainNode = audioCtx.createGain();
|
||||||
|
trackPanNode = audioCtx.createStereoPanner ? audioCtx.createStereoPanner() : null;
|
||||||
|
analyserNode = audioCtx.createAnalyser();
|
||||||
|
analyserNode.fftSize = 256;
|
||||||
|
|
||||||
|
if (trackPanNode) {
|
||||||
|
trackGainNode.connect(trackPanNode);
|
||||||
|
trackPanNode.connect(analyserNode);
|
||||||
|
} else {
|
||||||
|
trackGainNode.connect(analyserNode);
|
||||||
|
}
|
||||||
|
analyserNode.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
updateAudioNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAudioNodes() {
|
||||||
|
if (!audioCtx) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
|
||||||
|
let linearGain = 0;
|
||||||
|
if (!trackState.isMuted) {
|
||||||
|
const db = trackState.volumeDb;
|
||||||
|
linearGain = db <= -60 ? 0 : Math.pow(10, db / 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackState.isPhaseInverted) {
|
||||||
|
linearGain = -linearGain;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackGainNode.gain.setTargetAtTime(linearGain, now, 0.01);
|
||||||
|
|
||||||
|
if (trackPanNode) {
|
||||||
|
trackPanNode.pan.setTargetAtTime(trackState.pan, now, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play Synth Sound Preview when interacting with controls
|
||||||
|
function playPreviewNote() {
|
||||||
|
initAudioEngine();
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
|
||||||
|
if (synthOsc) {
|
||||||
|
try { synthOsc.stop(); } catch(e){}
|
||||||
|
synthOsc.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
synthOsc = audioCtx.createOscillator();
|
||||||
|
const oscGain = audioCtx.createGain();
|
||||||
|
|
||||||
|
synthOsc.type = 'triangle';
|
||||||
|
synthOsc.frequency.setValueAtTime(440, audioCtx.currentTime); // Note A4
|
||||||
|
|
||||||
|
oscGain.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||||
|
oscGain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 1.2);
|
||||||
|
|
||||||
|
synthOsc.connect(oscGain);
|
||||||
|
oscGain.connect(trackGainNode);
|
||||||
|
|
||||||
|
synthOsc.start();
|
||||||
|
synthOsc.stop(audioCtx.currentTime + 1.2);
|
||||||
|
isPlaying = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume Slider Event
|
||||||
|
volumeFader.addEventListener('input', (e) => {
|
||||||
|
const db = parseFloat(e.target.value);
|
||||||
|
trackState.volumeDb = db;
|
||||||
|
updateAudioNodes();
|
||||||
|
playPreviewNote();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rotary Pan Knob Dragging
|
||||||
|
let isPanDragging = false;
|
||||||
|
let panStartY = 0;
|
||||||
|
let startPanVal = 0;
|
||||||
|
|
||||||
|
panKnobDial.addEventListener('pointerdown', (e) => {
|
||||||
|
isPanDragging = true;
|
||||||
|
panStartY = e.clientY;
|
||||||
|
startPanVal = trackState.pan;
|
||||||
|
panKnobDial.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
panKnobDial.addEventListener('pointermove', (e) => {
|
||||||
|
if (!isPanDragging) return;
|
||||||
|
const deltaY = panStartY - e.clientY;
|
||||||
|
let newPan = startPanVal + (deltaY / 80);
|
||||||
|
newPan = Math.min(1.0, Math.max(-1.0, newPan));
|
||||||
|
|
||||||
|
trackState.pan = newPan;
|
||||||
|
const angle = newPan * 120;
|
||||||
|
panPointer.style.transform = `rotate(${angle}deg)`;
|
||||||
|
|
||||||
|
if (newPan === 0) panText.innerText = 'center';
|
||||||
|
else if (newPan < 0) panText.innerText = `L${Math.abs(Math.round(newPan * 100))}`;
|
||||||
|
else panText.innerText = `R${Math.round(newPan * 100)}`;
|
||||||
|
|
||||||
|
updateAudioNodes();
|
||||||
|
playPreviewNote();
|
||||||
|
});
|
||||||
|
|
||||||
|
panKnobDial.addEventListener('pointerup', (e) => {
|
||||||
|
isPanDragging = false;
|
||||||
|
try { panKnobDial.releasePointerCapture(e.pointerId); } catch(err){}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons Interactions
|
||||||
|
muteBtn.addEventListener('click', () => {
|
||||||
|
trackState.isMuted = !trackState.isMuted;
|
||||||
|
muteBtn.classList.toggle('btn-mute-active', trackState.isMuted);
|
||||||
|
updateAudioNodes();
|
||||||
|
});
|
||||||
|
|
||||||
|
soloBtn.addEventListener('click', () => {
|
||||||
|
trackState.isSoloed = !trackState.isSoloed;
|
||||||
|
soloBtn.classList.toggle('btn-solo-active', trackState.isSoloed);
|
||||||
|
});
|
||||||
|
|
||||||
|
armBtn.addEventListener('click', () => {
|
||||||
|
trackState.isArmed = !trackState.isArmed;
|
||||||
|
armBtn.classList.toggle('btn-arm-active', trackState.isArmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
powerBtn.addEventListener('click', () => {
|
||||||
|
trackState.isFxActive = !trackState.isFxActive;
|
||||||
|
powerBtn.classList.toggle('text-slate-500', !trackState.isFxActive);
|
||||||
|
powerBtn.classList.toggle('text-emerald-400', trackState.isFxActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
phaseBtn.addEventListener('click', () => {
|
||||||
|
trackState.isPhaseInverted = !trackState.isPhaseInverted;
|
||||||
|
phaseBtn.classList.toggle('bg-amber-600', trackState.isPhaseInverted);
|
||||||
|
phaseBtn.classList.toggle('text-white', trackState.isPhaseInverted);
|
||||||
|
updateAudioNodes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peak VU Meter Renderer
|
||||||
|
function renderVuMeter() {
|
||||||
|
requestAnimationFrame(renderVuMeter);
|
||||||
|
|
||||||
|
vuCanvas.width = vuCanvas.clientWidth;
|
||||||
|
vuCanvas.height = vuCanvas.clientHeight;
|
||||||
|
|
||||||
|
const w = vuCanvas.width;
|
||||||
|
const h = vuCanvas.height;
|
||||||
|
|
||||||
|
vuCtx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
let level = 0;
|
||||||
|
if (isPlaying && !trackState.isMuted) {
|
||||||
|
const db = trackState.volumeDb;
|
||||||
|
const linearGain = db <= -60 ? 0 : Math.pow(10, db / 20);
|
||||||
|
level = (0.25 + Math.random() * 0.6) * linearGain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const barH = level * h;
|
||||||
|
const grad = vuCtx.createLinearGradient(0, h, 0, 0);
|
||||||
|
grad.addColorStop(0, '#10b981');
|
||||||
|
grad.addColorStop(0.7, '#f59e0b');
|
||||||
|
grad.addColorStop(0.95, '#ef4444');
|
||||||
|
|
||||||
|
vuCtx.fillStyle = grad;
|
||||||
|
vuCtx.fillRect(1, h - barH, w - 2, barH);
|
||||||
|
|
||||||
|
if (level > 0) {
|
||||||
|
const dbVal = (20 * Math.log10(level)).toFixed(1);
|
||||||
|
peakDbVal.innerText = `${dbVal}dB`;
|
||||||
|
} else {
|
||||||
|
peakDbVal.innerText = '-inf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVuMeter();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi" class="h-full bg-slate-950 text-slate-100 select-none">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MIDI AI Prompt Generator & Preset Builder</title>
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #090d16; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #334155; }
|
||||||
|
|
||||||
|
/* Form Panels */
|
||||||
|
.panel-bg {
|
||||||
|
background: linear-gradient(180deg, #111827 0%, #0f172a 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full flex flex-col bg-slate-950 text-slate-200 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- TOP HEADER TOOLBAR -->
|
||||||
|
<header class="h-14 bg-slate-900 border-b border-slate-800 px-5 flex items-center justify-between z-20 shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-cyan-600 to-indigo-600 flex items-center justify-center text-white shadow-lg shadow-cyan-950">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-sm font-bold tracking-wide text-white flex items-center gap-2">
|
||||||
|
MIDI AI PROMPT GENERATOR <span class="text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded font-mono">FULL BEAT COVERAGE</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-[11px] text-slate-400">Công cụ tạo mẫu Prompt nhạc lý ép AI sinh đủ nốt phủ kín số ô nhịp</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Header Actions -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="btnLoadDefaultPreset" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 transition-colors">
|
||||||
|
<i class="fa-solid fa-rotate-left text-cyan-400"></i> Nạp Mẫu Piano
|
||||||
|
</button>
|
||||||
|
<button id="btnExportJSON" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 transition-colors">
|
||||||
|
<i class="fa-solid fa-download"></i> Xuất Preset JSON
|
||||||
|
</button>
|
||||||
|
<label id="btnImportJSON" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 cursor-pointer transition-colors">
|
||||||
|
<i class="fa-solid fa-upload"></i> Nhập JSON
|
||||||
|
<input type="file" id="jsonFileInput" accept=".json" class="hidden">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MAIN WORKSPACE -->
|
||||||
|
<main class="flex-1 grid grid-cols-12 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: FORM CONTROLS (WIDTH: 7 COLS) -->
|
||||||
|
<div class="col-span-7 border-r border-slate-800/80 p-5 overflow-y-auto space-y-5 custom-scrollbar bg-slate-950/60">
|
||||||
|
|
||||||
|
<!-- 1. VAI TRÒ & PHONG CÁCH -->
|
||||||
|
<section class="panel-bg rounded-xl p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-bold text-cyan-400 uppercase tracking-wider border-b border-slate-800/80 pb-2">
|
||||||
|
<i class="fa-solid fa-user-ninja"></i> 1. Vai Trò Nghệ Sĩ & Phong Cách
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Vai trò / Chuyên môn:</label>
|
||||||
|
<input type="text" id="inputRole" list="roleDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim" placeholder="Chọn hoặc nhập vai trò mới...">
|
||||||
|
<datalist id="roleDatalist"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Hành động / Thể loại đoạn nhạc:</label>
|
||||||
|
<input type="text" id="inputActionDesc" list="actionDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="tiếp tục bản nhạc piano đầy cảm xúc này" placeholder="Chọn hoặc nhập hành động mới...">
|
||||||
|
<datalist id="actionDatalist"></datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Loại Phân đoạn (Section Type):</label>
|
||||||
|
<input type="text" id="inputSectionType" list="sectionTypeDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="Cao trào (Build-Up) kịch tính" placeholder="Chọn hoặc nhập phân đoạn mới...">
|
||||||
|
<datalist id="sectionTypeDatalist"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Tên Tool AI Gọi (Function Tool Name):</label>
|
||||||
|
<input type="text" id="inputToolName" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-cyan-400 font-mono focus:outline-none focus:border-cyan-500" value="generate_multitrack_midi">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. Ô NHỊP & PHÁCH (BARS & BEATS COMPUTATION) -->
|
||||||
|
<section class="panel-bg rounded-xl p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-bold text-amber-400 uppercase tracking-wider">
|
||||||
|
<i class="fa-solid fa-stopwatch"></i> 2. Ô Nhịp & Phách (Bars & Beats Auto-Calc)
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] font-mono bg-amber-950/80 border border-amber-800/60 text-amber-300 px-2 py-0.5 rounded">Auto-Computed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Ô nhịp Bắt đầu:</label>
|
||||||
|
<input type="number" id="inputStartBar" min="1" max="999" value="9" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Ô nhịp Kết thúc:</label>
|
||||||
|
<input type="number" id="inputEndBar" min="1" max="999" value="16" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Số phách/Ô nhịp (Time Sig):</label>
|
||||||
|
<select id="selectBeatsPerBar" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-amber-300 font-mono focus:outline-none focus:border-amber-500">
|
||||||
|
<option value="4">4 Phách (Nhịp 4/4)</option>
|
||||||
|
<option value="3">3 Phách (Nhịp 3/4 Waltz)</option>
|
||||||
|
<option value="6">6 Phách (Nhịp 6/8)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Quy đổi Tổng Số Phách:</label>
|
||||||
|
<div id="readoutTotalBeats" class="w-full bg-slate-950 border border-amber-900/60 rounded-lg px-3 py-2 text-amber-400 font-mono font-bold">
|
||||||
|
64.0 Phách
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] font-mono bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-slate-400 flex items-center justify-between">
|
||||||
|
<span>Chuỗi Ô nhịp tự động sinh:</span>
|
||||||
|
<span id="readoutBarList" class="text-cyan-300 font-bold">9, 10, 11, 12, 13, 14, 15 và 16</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. MẬT ĐỘ NỐT & QUY TẮC HIỆU NĂNG -->
|
||||||
|
<section class="panel-bg rounded-xl p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-bold text-emerald-400 uppercase tracking-wider border-b border-slate-800/80 pb-2">
|
||||||
|
<i class="fa-solid fa-gauge-simple-high"></i> 3. Mật Độ Nốt & Giới Hạn Token
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs">
|
||||||
|
<label class="block text-slate-400 mb-1 font-medium">Hướng dẫn Mật độ Nốt (Note Density Instruction):</label>
|
||||||
|
<textarea id="inputDensityRule" rows="2" class="w-full bg-slate-900 border border-slate-800 rounded-lg p-3 text-white font-sans focus:outline-none focus:border-emerald-500 leading-relaxed text-xs">Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).</textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. DANH SÁCH TRACKS (DYNAMIC TRACKS EDITOR) -->
|
||||||
|
<section class="panel-bg rounded-xl p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800/80 pb-2">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-bold text-indigo-400 uppercase tracking-wider">
|
||||||
|
<i class="fa-solid fa-layer-group"></i> 4. Danh Sách Track Cấu Trúc (<span id="trackCountBadge">3</span> Tracks)
|
||||||
|
</div>
|
||||||
|
<button id="btnAddTrack" class="px-2.5 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs font-bold transition-all flex items-center gap-1 shadow-md shadow-indigo-950">
|
||||||
|
<i class="fa-solid fa-plus text-[10px]"></i> Thêm Track
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datalists for Track Names and Track Labels History -->
|
||||||
|
<datalist id="trackNameDatalist"></datalist>
|
||||||
|
<datalist id="trackLabelDatalist"></datalist>
|
||||||
|
|
||||||
|
<!-- Tracks Container -->
|
||||||
|
<div id="tracksContainer" class="space-y-3">
|
||||||
|
<!-- Dynamic Track Cards Rendered via JS -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: LIVE PROMPT PREVIEW & COPY (WIDTH: 5 COLS) -->
|
||||||
|
<div class="col-span-5 bg-slate-900 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- Preview Header -->
|
||||||
|
<div class="h-10 shrink-0 bg-slate-950 border-b border-slate-800 px-4 flex items-center justify-between text-xs font-mono">
|
||||||
|
<span class="text-slate-300 font-bold flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-code text-cyan-400"></i> LIVE PROMPT PREVIEW
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="btnCopyPrompt" class="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-sans font-semibold text-xs rounded flex items-center gap-1 transition-all border border-slate-700" title="Sao Chép Văn Bản">
|
||||||
|
<i class="fa-solid fa-copy text-cyan-400"></i> <span>Sao Chép</span>
|
||||||
|
</button>
|
||||||
|
<button id="btnSavePresetModal" class="px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white font-sans font-bold text-xs rounded flex items-center gap-1.5 transition-all shadow-md shadow-purple-950">
|
||||||
|
<i class="fa-solid fa-bookmark"></i> <span>Lưu thành Preset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Generated Text Output Box -->
|
||||||
|
<div class="flex-1 flex flex-col min-h-0 p-4">
|
||||||
|
<div id="promptOutputText" class="w-full flex-1 bg-slate-950 border border-slate-800 rounded-xl p-4 font-mono text-xs text-slate-200 leading-relaxed whitespace-pre-wrap select-text selection:bg-cyan-900 selection:text-white overflow-y-auto custom-scrollbar">
|
||||||
|
<!-- Text generated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notification Container -->
|
||||||
|
<div id="toastNotification" class="absolute bottom-5 right-5 bg-emerald-600 text-white px-4 py-2.5 rounded-xl shadow-2xl font-semibold text-xs flex items-center gap-2 transition-all duration-300 transform translate-y-10 opacity-0 pointer-events-none z-50">
|
||||||
|
<i class="fa-solid fa-circle-check text-base"></i>
|
||||||
|
<span id="toastMessage">Đã sao chép câu Prompt vào bộ nhớ tạm!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- MODAL: AI PROMPT PRESET MANAGER (FORM TẠO PRESET MỚI) -->
|
||||||
|
<div id="presetManagerModal" class="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center hidden p-4">
|
||||||
|
<div class="w-full max-w-3xl bg-[#13141a] border border-slate-800 rounded-2xl shadow-2xl p-6 space-y-4 text-slate-200 relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||||
|
<h2 class="text-sm font-bold tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-400 font-mono uppercase">
|
||||||
|
AI PROMPT PRESET MANAGER
|
||||||
|
</h2>
|
||||||
|
<button id="btnCloseModalX" class="w-7 h-7 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Title -->
|
||||||
|
<div class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-purple-400"></i> TẠO PRESET MỚI
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Form Grid -->
|
||||||
|
<div class="space-y-3 text-xs">
|
||||||
|
|
||||||
|
<!-- Row 1: Name & Category -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">TÊN PRESET</label>
|
||||||
|
<input type="text" id="presetNameInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="Nhập tên preset (VD: Epic Orchestra Intro)...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">DANH MỤC</label>
|
||||||
|
<select id="presetCategorySelect" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans">
|
||||||
|
<option value="Orchestral / Film Score">Orchestral / Film Score</option>
|
||||||
|
<option value="Piano Solo">Piano Solo</option>
|
||||||
|
<option value="EDM / Pop">EDM / Pop</option>
|
||||||
|
<option value="Cinematic">Cinematic</option>
|
||||||
|
<option value="Jazz & Swing">Jazz & Swing</option>
|
||||||
|
<option value="Lo-fi Chillhop">Lo-fi Chillhop</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Keywords -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">TỪ KHÓA KÍCH HOẠT (NGĂN CÁCH BẰNG DẤU PHẨY)</label>
|
||||||
|
<input type="text" id="presetKeywordsInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="Ví dụ: epic orchestra, hoành tráng, nhạc phim epic">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Bars, BPM, Scale -->
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">SỐ BARS MẶC ĐỊNH</label>
|
||||||
|
<input type="number" id="presetBarsInput" value="8" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">BPM MẶC ĐỊNH</label>
|
||||||
|
<input type="number" id="presetBpmInput" value="120" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">ÂM GIAI (SCALE) MẶC ĐỊNH</label>
|
||||||
|
<input type="text" id="presetScaleInput" value="C Minor" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500" placeholder="VD: C Minor, A Major...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: System Prompt Template -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">SYSTEM PROMPT TEMPLATE / LUẬT SOẠN NHẠC</label>
|
||||||
|
<textarea id="presetTemplateTextarea" rows="6" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg p-3 text-white font-mono text-xs focus:outline-none focus:border-purple-500 leading-relaxed custom-scrollbar"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons Row -->
|
||||||
|
<div class="space-y-3 pt-2 border-t border-slate-800/80">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button id="btnCreateStructuredPreset" class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg text-xs font-bold transition-all shadow-md flex items-center gap-1.5">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles"></i> Tạo preset có cấu trúc
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="btnModalBack" class="px-4 py-2 bg-[#252836] hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
|
||||||
|
Quay lại
|
||||||
|
</button>
|
||||||
|
<button id="btnSavePresetSubmit" class="px-5 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-xs font-bold transition-all shadow-lg shadow-purple-950/80 flex items-center gap-1.5">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Preset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="btnCloseModalBottom" class="px-4 py-1.5 bg-[#1f222e] hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JAVASCRIPT LOGIC & STATE ENGINE -->
|
||||||
|
<script>
|
||||||
|
// Default Template State matching User Specification
|
||||||
|
const state = {
|
||||||
|
role: "nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim",
|
||||||
|
actionDesc: "tiếp tục bản nhạc piano đầy cảm xúc này",
|
||||||
|
sectionType: "Cao trào (Build-Up) kịch tính",
|
||||||
|
startBar: 9,
|
||||||
|
endBar: 16,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
densityRule: "Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).",
|
||||||
|
toolName: "generate_multitrack_midi",
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: "track_1",
|
||||||
|
name: "Piano Bass & Octaves",
|
||||||
|
label: "Tiếng Bass & Quãng 8 Piano",
|
||||||
|
description: "đánh các xung bass trầm nốt đen đều đặn (trong dải C1-C3) liên tục trên mỗi phách từ phách 0.0 đến phách 64.0 (tổng cộng 64 nốt đen)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "track_2",
|
||||||
|
name: "Piano Accompaniment",
|
||||||
|
label: "Tiếng Đệm Piano",
|
||||||
|
description: "đánh các đoạn rải hợp âm (broken chord arpeggios) nốt móc đơn có nhịp điệu ở âm vực trung (dải C3-C4), lấp đầy liên tục toàn bộ 8 ô nhịp từ phách 0.0 đến phách 64.0."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "track_3",
|
||||||
|
name: "Piano Melody - Right Hand",
|
||||||
|
label: "Giai điệu Piano - Tay phải",
|
||||||
|
description: "đánh một tuyến giai điệu đi lên ở âm vực cao (dải C4-C6) sử dụng nốt trắng và nốt tròn trải dài từ Ô nhịp 9 đến 16, kết thúc bằng một hợp âm chủ (tonic chord) ngân dài được giữ cho đến phách 64.0."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// History & Dropdown Options Management
|
||||||
|
const defaultHistory = {
|
||||||
|
roles: [
|
||||||
|
"nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim",
|
||||||
|
"nhà sản xuất âm nhạc EDM / Electro Pop",
|
||||||
|
"nhà soạn nhạc phim Epic Orchestra hoành tráng",
|
||||||
|
"nghệ sĩ nhạc Jazz ngẫu hứng chuyên nghiệp",
|
||||||
|
"nhà sản xuất nhạc Lo-fi Chillhop & Beatmaker"
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
"tiếp tục bản nhạc piano đầy cảm xúc này",
|
||||||
|
"tạo một đoạn nhạc dồn dập kịch tính",
|
||||||
|
"phát triển giai điệu mở đầu du dương",
|
||||||
|
"xây dựng đoạn điệp khúc bùng nổ và hoành tráng"
|
||||||
|
],
|
||||||
|
sectionTypes: [
|
||||||
|
"Cao trào (Build-Up) kịch tính",
|
||||||
|
"Mở đầu (Intro) du dương",
|
||||||
|
"Điệp khúc (Chorus / Climax) bùng nổ",
|
||||||
|
"Đoạn lắng (Breakdown / Interlude)",
|
||||||
|
"Đoạn kết (Outro / Resolution)"
|
||||||
|
],
|
||||||
|
trackNames: [
|
||||||
|
"Piano Bass & Octaves",
|
||||||
|
"Piano Accompaniment",
|
||||||
|
"Piano Melody - Right Hand",
|
||||||
|
"Strings Ensemble",
|
||||||
|
"Brass Theme",
|
||||||
|
"Epic Percussion",
|
||||||
|
"Synth Lead",
|
||||||
|
"Bassline"
|
||||||
|
],
|
||||||
|
trackLabels: [
|
||||||
|
"Tiếng Bass & Quãng 8 Piano",
|
||||||
|
"Tiếng Đệm Piano",
|
||||||
|
"Giai điệu Piano - Tay phải",
|
||||||
|
"Dàn dây Ostinato",
|
||||||
|
"Tuyến Kèn Brass",
|
||||||
|
"Bộ Trống Epic",
|
||||||
|
"Giai điệu Synth",
|
||||||
|
"Tuyến Bass Sub"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM References
|
||||||
|
const inputRole = document.getElementById('inputRole');
|
||||||
|
const inputActionDesc = document.getElementById('inputActionDesc');
|
||||||
|
const inputSectionType = document.getElementById('inputSectionType');
|
||||||
|
const inputToolName = document.getElementById('inputToolName');
|
||||||
|
const roleDatalist = document.getElementById('roleDatalist');
|
||||||
|
const actionDatalist = document.getElementById('actionDatalist');
|
||||||
|
const sectionTypeDatalist = document.getElementById('sectionTypeDatalist');
|
||||||
|
const inputStartBar = document.getElementById('inputStartBar');
|
||||||
|
const inputEndBar = document.getElementById('inputEndBar');
|
||||||
|
const selectBeatsPerBar = document.getElementById('selectBeatsPerBar');
|
||||||
|
const inputDensityRule = document.getElementById('inputDensityRule');
|
||||||
|
const readoutTotalBeats = document.getElementById('readoutTotalBeats');
|
||||||
|
const readoutBarList = document.getElementById('readoutBarList');
|
||||||
|
const tracksContainer = document.getElementById('tracksContainer');
|
||||||
|
const trackCountBadge = document.getElementById('trackCountBadge');
|
||||||
|
const promptOutputText = document.getElementById('promptOutputText');
|
||||||
|
const btnAddTrack = document.getElementById('btnAddTrack');
|
||||||
|
const btnCopyPrompt = document.getElementById('btnCopyPrompt');
|
||||||
|
const btnLoadDefaultPreset = document.getElementById('btnLoadDefaultPreset');
|
||||||
|
const btnExportJSON = document.getElementById('btnExportJSON');
|
||||||
|
const jsonFileInput = document.getElementById('jsonFileInput');
|
||||||
|
const toastNotification = document.getElementById('toastNotification');
|
||||||
|
|
||||||
|
// Preset Modal Elements
|
||||||
|
const presetManagerModal = document.getElementById('presetManagerModal');
|
||||||
|
const btnSavePresetModal = document.getElementById('btnSavePresetModal');
|
||||||
|
const btnCloseModalX = document.getElementById('btnCloseModalX');
|
||||||
|
const btnCloseModalBottom = document.getElementById('btnCloseModalBottom');
|
||||||
|
const btnModalBack = document.getElementById('btnModalBack');
|
||||||
|
const btnSavePresetSubmit = document.getElementById('btnSavePresetSubmit');
|
||||||
|
const btnCreateStructuredPreset = document.getElementById('btnCreateStructuredPreset');
|
||||||
|
const presetNameInput = document.getElementById('presetNameInput');
|
||||||
|
const presetCategorySelect = document.getElementById('presetCategorySelect');
|
||||||
|
const presetKeywordsInput = document.getElementById('presetKeywordsInput');
|
||||||
|
const presetBarsInput = document.getElementById('presetBarsInput');
|
||||||
|
const presetBpmInput = document.getElementById('presetBpmInput');
|
||||||
|
const presetScaleInput = document.getElementById('presetScaleInput');
|
||||||
|
const presetTemplateTextarea = document.getElementById('presetTemplateTextarea');
|
||||||
|
|
||||||
|
// LocalStorage Helper Functions for History
|
||||||
|
function getHistory(key, defaults) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(`midi_prompt_history_${key}`);
|
||||||
|
return saved ? JSON.parse(saved) : defaults;
|
||||||
|
} catch (e) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistoryItem(key, newValue, defaults) {
|
||||||
|
const val = (newValue || "").trim();
|
||||||
|
if (!val) return;
|
||||||
|
let list = getHistory(key, defaults);
|
||||||
|
if (!list.includes(val)) {
|
||||||
|
list.unshift(val); // Put newest entry first
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`midi_prompt_history_${key}`, JSON.stringify(list));
|
||||||
|
} catch (e) {}
|
||||||
|
renderDatalistOptions(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDatalistOptions(key, list) {
|
||||||
|
let datalistElem;
|
||||||
|
if (key === 'roles') datalistElem = roleDatalist;
|
||||||
|
else if (key === 'actions') datalistElem = actionDatalist;
|
||||||
|
else if (key === 'sectionTypes') datalistElem = sectionTypeDatalist;
|
||||||
|
else if (key === 'trackNames') datalistElem = document.getElementById('trackNameDatalist');
|
||||||
|
else if (key === 'trackLabels') datalistElem = document.getElementById('trackLabelDatalist');
|
||||||
|
|
||||||
|
if (!datalistElem) return;
|
||||||
|
datalistElem.innerHTML = list.map(item => `<option value="${escapeHtml(item)}"></option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHistoryDatalists() {
|
||||||
|
const roles = getHistory('roles', defaultHistory.roles);
|
||||||
|
const actions = getHistory('actions', defaultHistory.actions);
|
||||||
|
const sectionTypes = getHistory('sectionTypes', defaultHistory.sectionTypes);
|
||||||
|
const trackNames = getHistory('trackNames', defaultHistory.trackNames);
|
||||||
|
const trackLabels = getHistory('trackLabels', defaultHistory.trackLabels);
|
||||||
|
|
||||||
|
renderDatalistOptions('roles', roles);
|
||||||
|
renderDatalistOptions('actions', actions);
|
||||||
|
renderDatalistOptions('sectionTypes', sectionTypes);
|
||||||
|
renderDatalistOptions('trackNames', trackNames);
|
||||||
|
renderDatalistOptions('trackLabels', trackLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save history on input change/blur
|
||||||
|
inputRole.addEventListener('change', () => saveHistoryItem('roles', inputRole.value, defaultHistory.roles));
|
||||||
|
inputActionDesc.addEventListener('change', () => saveHistoryItem('actions', inputActionDesc.value, defaultHistory.actions));
|
||||||
|
inputSectionType.addEventListener('change', () => saveHistoryItem('sectionTypes', inputSectionType.value, defaultHistory.sectionTypes));
|
||||||
|
|
||||||
|
// Helper to calculate total bars, total beats, and format bar range list
|
||||||
|
function computeTimeMetrics() {
|
||||||
|
const start = parseInt(inputStartBar.value) || 1;
|
||||||
|
const end = parseInt(inputEndBar.value) || 1;
|
||||||
|
const beatsPerBar = parseInt(selectBeatsPerBar.value) || 4;
|
||||||
|
|
||||||
|
const barCount = Math.max(1, (end - start) + 1);
|
||||||
|
const totalBeats = (barCount * beatsPerBar).toFixed(1);
|
||||||
|
|
||||||
|
// Format bar list string: e.g. "9, 10, 11, 12, 13, 14, 15 và 16"
|
||||||
|
const barNumbers = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
barNumbers.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let barListStr = "";
|
||||||
|
if (barNumbers.length === 1) {
|
||||||
|
barListStr = `${barNumbers[0]}`;
|
||||||
|
} else if (barNumbers.length === 2) {
|
||||||
|
barListStr = `${barNumbers[0]} và ${barNumbers[1]}`;
|
||||||
|
} else {
|
||||||
|
const leading = barNumbers.slice(0, -1).join(', ');
|
||||||
|
const last = barNumbers[barNumbers.length - 1];
|
||||||
|
barListStr = `${leading} và ${last}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
readoutTotalBeats.innerText = `${totalBeats} Phách`;
|
||||||
|
readoutBarList.innerText = barListStr;
|
||||||
|
|
||||||
|
return {
|
||||||
|
barCount,
|
||||||
|
totalBeats,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
barListStr
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Dynamic Track Input Cards
|
||||||
|
function renderTrackEditors() {
|
||||||
|
tracksContainer.innerHTML = "";
|
||||||
|
trackCountBadge.innerText = state.tracks.length;
|
||||||
|
|
||||||
|
state.tracks.forEach((track, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = "bg-slate-900/90 border border-slate-800 p-3 rounded-xl space-y-2 relative group";
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between text-xs font-bold text-slate-300">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-[10px] text-cyan-400 font-mono">${idx + 1}</span>
|
||||||
|
Track ${idx + 1}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onclick="moveTrack(${idx}, -1)" class="w-6 h-6 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors" title="Di chuyển lên">
|
||||||
|
<i class="fa-solid fa-chevron-up text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="moveTrack(${idx}, 1)" class="w-6 h-6 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors" title="Di chuyển xuống">
|
||||||
|
<i class="fa-solid fa-chevron-down text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="removeTrack(${idx})" class="w-6 h-6 rounded bg-red-950/80 hover:bg-red-900 text-red-400 flex items-center justify-center transition-colors ml-1" title="Xóa Track">
|
||||||
|
<i class="fa-solid fa-trash-can text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Tên Track (Mã Tiếng Anh):</label>
|
||||||
|
<input type="text" list="trackNameDatalist" value="${escapeHtml(track.name)}" oninput="updateTrackData(${idx}, 'name', this.value)" onchange="saveHistoryItem('trackNames', this.value, defaultHistory.trackNames); updateTrackData(${idx}, 'name', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-white font-mono text-xs focus:outline-none focus:border-cyan-500" placeholder="Chọn hoặc nhập tên track...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Nhãn / Chú Thích Tiếng Việt:</label>
|
||||||
|
<input type="text" list="trackLabelDatalist" value="${escapeHtml(track.label)}" oninput="updateTrackData(${idx}, 'label', this.value)" onchange="saveHistoryItem('trackLabels', this.value, defaultHistory.trackLabels); updateTrackData(${idx}, 'label', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-slate-300 text-xs focus:outline-none focus:border-cyan-500" placeholder="Chọn hoặc nhập nhãn chú thích...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs">
|
||||||
|
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Mô tả Quy cách Đánh & Tiết tấu:</label>
|
||||||
|
<textarea rows="2" oninput="updateTrackData(${idx}, 'description', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded p-2 text-white font-sans text-xs focus:outline-none focus:border-cyan-500 leading-relaxed">${escapeHtml(track.description)}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
tracksContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Prompt Generator
|
||||||
|
function generatePromptText() {
|
||||||
|
const timeData = computeTimeMetrics();
|
||||||
|
const role = inputRole.value.trim();
|
||||||
|
const actionDesc = inputActionDesc.value.trim();
|
||||||
|
const sectionType = inputSectionType.value.trim();
|
||||||
|
const toolName = inputToolName.value.trim();
|
||||||
|
const densityRule = inputDensityRule.value.trim();
|
||||||
|
const trackCount = state.tracks.length;
|
||||||
|
|
||||||
|
let promptStr = `Bạn là ${role}. Hãy ${actionDesc} bằng một đoạn ${sectionType} dài ${timeData.barCount} ô nhịp (Từ Ô nhịp ${timeData.start} đến ${timeData.end}, tương ứng với Phách 0.0 đến ${timeData.totalBeats}).\n\n`;
|
||||||
|
|
||||||
|
promptStr += `YÊU CẦU BẮT BUỘC VỀ ĐỘ DÀI TOÀN BỘ:\n\n`;
|
||||||
|
promptStr += `Tất cả ${trackCount} track ĐỀU PHẢI tạo ra các nốt nhạc bao phủ toàn bộ khoảng từ phách 0.0 đến phách ${timeData.totalBeats}, không được dừng lại giữa chừng.\n\n`;
|
||||||
|
promptStr += `Nhịp điệu phải liên tục không đứt đoạn qua các Ô nhịp ${timeData.barListStr}.\n\n`;
|
||||||
|
promptStr += `Nốt/hợp âm cuối cùng của mỗi track phải kéo dài đến hoặc giải kết (resolve) tại phách ${timeData.totalBeats}.\n\n`;
|
||||||
|
|
||||||
|
if (densityRule) {
|
||||||
|
promptStr += `${densityRule}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
promptStr += `Cấu trúc bắt buộc trả về thông qua công cụ ${toolName} gồm ${trackCount} track:\n\n`;
|
||||||
|
|
||||||
|
state.tracks.forEach((track, i) => {
|
||||||
|
promptStr += `${track.name} (${track.label}): ${track.description.trim()}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
promptStr += `Đảm bảo mọi track đều chạm tới phách ${timeData.totalBeats}.`;
|
||||||
|
|
||||||
|
promptOutputText.innerText = promptStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic Track Actions
|
||||||
|
window.updateTrackData = function(index, key, val) {
|
||||||
|
state.tracks[index][key] = val;
|
||||||
|
generatePromptText();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.removeTrack = function(index) {
|
||||||
|
if (state.tracks.length <= 1) {
|
||||||
|
showToast("Cần giữ ít nhất 1 Track!", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.tracks.splice(index, 1);
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.moveTrack = function(index, direction) {
|
||||||
|
const newIndex = index + direction;
|
||||||
|
if (newIndex < 0 || newIndex >= state.tracks.length) return;
|
||||||
|
const temp = state.tracks[index];
|
||||||
|
state.tracks[index] = state.tracks[newIndex];
|
||||||
|
state.tracks[newIndex] = temp;
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
};
|
||||||
|
|
||||||
|
btnAddTrack.addEventListener('click', () => {
|
||||||
|
const newId = `track_${Date.now()}`;
|
||||||
|
state.tracks.push({
|
||||||
|
id: newId,
|
||||||
|
name: `Track ${state.tracks.length + 1}`,
|
||||||
|
label: `Mô tả Track ${state.tracks.length + 1}`,
|
||||||
|
description: `đánh tuyến nốt hòa âm liên tục từ phách 0.0 đến phách 64.0.`
|
||||||
|
});
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast Notification System
|
||||||
|
function showToast(msg, type = "success") {
|
||||||
|
const toastMsg = document.getElementById('toastMessage');
|
||||||
|
toastMsg.innerText = msg;
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
toastNotification.classList.remove('bg-emerald-600');
|
||||||
|
toastNotification.classList.add('bg-red-600');
|
||||||
|
} else {
|
||||||
|
toastNotification.classList.remove('bg-red-600');
|
||||||
|
toastNotification.classList.add('bg-emerald-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
toastNotification.classList.remove('translate-y-10', 'opacity-0', 'pointer-events-none');
|
||||||
|
setTimeout(() => {
|
||||||
|
toastNotification.classList.add('translate-y-10', 'opacity-0', 'pointer-events-none');
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Save Preset Modal Event Handler — post data to parent React app
|
||||||
|
btnSavePresetModal.addEventListener('click', () => {
|
||||||
|
const timeData = computeTimeMetrics();
|
||||||
|
const currentPromptText = promptOutputText.innerText.trim();
|
||||||
|
|
||||||
|
const sectionType = inputSectionType.value.trim() || "Building Section";
|
||||||
|
const role = inputRole.value.trim() || "Composer";
|
||||||
|
|
||||||
|
// Auto-infer Category based on Role
|
||||||
|
let category = "Orchestral / Film Score";
|
||||||
|
const roleLower = role.toLowerCase();
|
||||||
|
if (roleLower.includes("piano")) category = "Piano Solo";
|
||||||
|
else if (roleLower.includes("edm") || roleLower.includes("electro")) category = "EDM / Pop";
|
||||||
|
else if (roleLower.includes("jazz")) category = "Jazz & Swing";
|
||||||
|
else if (roleLower.includes("lo-fi") || roleLower.includes("chillhop")) category = "Lo-fi Chillhop";
|
||||||
|
|
||||||
|
// Auto-generate Keyword Tags
|
||||||
|
const keywordList = [
|
||||||
|
sectionType.toLowerCase(),
|
||||||
|
roleLower.split(' ')[0],
|
||||||
|
`${timeData.barCount} bars`
|
||||||
|
];
|
||||||
|
const keywordsStr = keywordList.filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
// Post data to parent window (React AI PROMPT PRESET MANAGER)
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'GENERATOR_PRESET_DATA',
|
||||||
|
name: `${sectionType} (${timeData.barCount} Bars)`,
|
||||||
|
category: category,
|
||||||
|
keywords: keywordsStr,
|
||||||
|
default_bars: timeData.barCount,
|
||||||
|
default_bpm: 120,
|
||||||
|
default_scale: "C Minor",
|
||||||
|
template: currentPromptText
|
||||||
|
}, '*');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close Modal Event Handlers
|
||||||
|
[btnCloseModalX, btnCloseModalBottom, btnModalBack].forEach(btn => {
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
presetManagerModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Structured Preset — replace static numbers with template variables
|
||||||
|
btnCreateStructuredPreset.addEventListener('click', () => {
|
||||||
|
let promptText = presetTemplateTextarea.value;
|
||||||
|
const bars = presetBarsInput.value || "8";
|
||||||
|
|
||||||
|
promptText = promptText
|
||||||
|
.replace(new RegExp(`dài ${bars} ô nhịp`, 'g'), 'dài {{bars}} ô nhịp')
|
||||||
|
.replace(/Từ Ô nhịp \d+ đến \d+/g, 'Từ Ô nhịp {{start_bar}} đến {{end_bar}}')
|
||||||
|
.replace(/Phách 0\.0 đến \d+\.0/g, 'Phách 0.0 đến {{total_beats}}')
|
||||||
|
.replace(/phách \d+\.0/g, 'phách {{total_beats}}');
|
||||||
|
|
||||||
|
presetTemplateTextarea.value = promptText;
|
||||||
|
showToast("Đã chuyển đổi Prompt thành mẫu biến cấu trúc!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Preset Action Handler (Saves to daw_ai_prompt_presets localStorage & dispatches event)
|
||||||
|
btnSavePresetSubmit.addEventListener('click', () => {
|
||||||
|
const name = presetNameInput.value.trim();
|
||||||
|
const category = presetCategorySelect.value;
|
||||||
|
const keywordsRaw = presetKeywordsInput.value.trim();
|
||||||
|
const defaultBars = parseInt(presetBarsInput.value) || 8;
|
||||||
|
const defaultBpm = parseInt(presetBpmInput.value) || 120;
|
||||||
|
const defaultScale = presetScaleInput.value.trim() || "C Minor";
|
||||||
|
const template = presetTemplateTextarea.value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast("Vui lòng nhập Tên Preset!", "error");
|
||||||
|
presetNameInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
showToast("Nội dung System Prompt không được trống!", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = keywordsRaw ? keywordsRaw.split(',').map(k => k.trim()).filter(Boolean) : [name.toLowerCase()];
|
||||||
|
|
||||||
|
const newPresetObj = {
|
||||||
|
id: `preset_${Date.now()}`,
|
||||||
|
name: name,
|
||||||
|
category: category,
|
||||||
|
keywords: keywords,
|
||||||
|
default_bars: defaultBars,
|
||||||
|
default_bpm: defaultBpm,
|
||||||
|
default_scale: defaultScale,
|
||||||
|
system_instruction_template: template,
|
||||||
|
is_user_defined: true,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read existing saved presets list from localStorage
|
||||||
|
let savedPresets = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('daw_ai_prompt_presets');
|
||||||
|
savedPresets = stored ? JSON.parse(stored) : [];
|
||||||
|
} catch (e) {
|
||||||
|
savedPresets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
savedPresets.unshift(newPresetObj);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('daw_ai_prompt_presets', JSON.stringify(savedPresets));
|
||||||
|
// Dispatch Custom Event so parent window updates preset list instantly
|
||||||
|
window.dispatchEvent(new CustomEvent('AI_PRESET_SAVED', { detail: newPresetObj }));
|
||||||
|
showToast(`Đã lưu Preset "${name}" thành công!`);
|
||||||
|
presetManagerModal.classList.add('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
showToast("Lỗi khi lưu vào bộ nhớ trình duyệt!", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy Prompt Button
|
||||||
|
btnCopyPrompt.addEventListener('click', () => {
|
||||||
|
// Save current input values to history when copying
|
||||||
|
saveHistoryItem('roles', inputRole.value, defaultHistory.roles);
|
||||||
|
saveHistoryItem('actions', inputActionDesc.value, defaultHistory.actions);
|
||||||
|
saveHistoryItem('sectionTypes', inputSectionType.value, defaultHistory.sectionTypes);
|
||||||
|
|
||||||
|
// Save tracks history
|
||||||
|
state.tracks.forEach(track => {
|
||||||
|
saveHistoryItem('trackNames', track.name, defaultHistory.trackNames);
|
||||||
|
saveHistoryItem('trackLabels', track.label, defaultHistory.trackLabels);
|
||||||
|
});
|
||||||
|
|
||||||
|
const textToCopy = promptOutputText.innerText;
|
||||||
|
|
||||||
|
// Fallback copy execCommand for iframe compatibility
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = textToCopy;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
showToast("Đã sao chép câu Prompt thành công!");
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Lỗi khi sao chép!", "error");
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset Default Piano Preset
|
||||||
|
btnLoadDefaultPreset.addEventListener('click', () => {
|
||||||
|
inputRole.value = "nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim";
|
||||||
|
inputActionDesc.value = "tiếp tục bản nhạc piano đầy cảm xúc này";
|
||||||
|
inputSectionType.value = "Cao trào (Build-Up) kịch tính";
|
||||||
|
inputToolName.value = "generate_multitrack_midi";
|
||||||
|
inputStartBar.value = "9";
|
||||||
|
inputEndBar.value = "16";
|
||||||
|
selectBeatsPerBar.value = "4";
|
||||||
|
inputDensityRule.value = "Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).";
|
||||||
|
|
||||||
|
state.tracks = [
|
||||||
|
{
|
||||||
|
id: "track_1",
|
||||||
|
name: "Piano Bass & Octaves",
|
||||||
|
label: "Tiếng Bass & Quãng 8 Piano",
|
||||||
|
description: "đánh các xung bass trầm nốt đen đều đặn (trong dải C1-C3) liên tục trên mỗi phách từ phách 0.0 đến phách 64.0 (tổng cộng 64 nốt đen)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "track_2",
|
||||||
|
name: "Piano Accompaniment",
|
||||||
|
label: "Tiếng Đệm Piano",
|
||||||
|
description: "đánh các đoạn rải hợp âm (broken chord arpeggios) nốt móc đơn có nhịp điệu ở âm vực trung (dải C3-C4), lấp đầy liên tục toàn bộ 8 ô nhịp từ phách 0.0 đến phách 64.0."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "track_3",
|
||||||
|
name: "Piano Melody - Right Hand",
|
||||||
|
label: "Giai điệu Piano - Tay phải",
|
||||||
|
description: "đánh một tuyến giai điệu đi lên ở âm vực cao (dải C4-C6) sử dụng nốt trắng và nốt tròn trải dài từ Ô nhịp 9 đến 16, kết thúc bằng một hợp âm chủ (tonic chord) ngân dài được giữ cho đến phách 64.0."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
showToast("Đã nạp lại Mẫu Prompt Piano!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export Preset JSON
|
||||||
|
btnExportJSON.addEventListener('click', () => {
|
||||||
|
const exportData = {
|
||||||
|
role: inputRole.value,
|
||||||
|
actionDesc: inputActionDesc.value,
|
||||||
|
sectionType: inputSectionType.value,
|
||||||
|
startBar: parseInt(inputStartBar.value),
|
||||||
|
endBar: parseInt(inputEndBar.value),
|
||||||
|
beatsPerBar: parseInt(selectBeatsPerBar.value),
|
||||||
|
densityRule: inputDensityRule.value,
|
||||||
|
toolName: inputToolName.value,
|
||||||
|
tracks: state.tracks
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
|
||||||
|
const downloadAnchor = document.createElement('a');
|
||||||
|
downloadAnchor.setAttribute("href", dataStr);
|
||||||
|
downloadAnchor.setAttribute("download", `midi_prompt_preset_${Date.now()}.json`);
|
||||||
|
document.body.appendChild(downloadAnchor);
|
||||||
|
downloadAnchor.click();
|
||||||
|
downloadAnchor.remove();
|
||||||
|
showToast("Đã xuất tệp JSON Preset!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import Preset JSON
|
||||||
|
jsonFileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(event.target.result);
|
||||||
|
if (imported.role) inputRole.value = imported.role;
|
||||||
|
if (imported.actionDesc) inputActionDesc.value = imported.actionDesc;
|
||||||
|
if (imported.sectionType) inputSectionType.value = imported.sectionType;
|
||||||
|
if (imported.startBar) inputStartBar.value = imported.startBar;
|
||||||
|
if (imported.endBar) inputEndBar.value = imported.endBar;
|
||||||
|
if (imported.beatsPerBar) selectBeatsPerBar.value = imported.beatsPerBar;
|
||||||
|
if (imported.densityRule !== undefined) inputDensityRule.value = imported.densityRule;
|
||||||
|
if (imported.toolName) inputToolName.value = imported.toolName;
|
||||||
|
if (Array.isArray(imported.tracks)) state.tracks = imported.tracks;
|
||||||
|
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
showToast("Đã nạp Preset từ tệp JSON thành công!");
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Tệp JSON không hợp lệ!", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach input listeners
|
||||||
|
[inputRole, inputActionDesc, inputSectionType, inputToolName, inputStartBar, inputEndBar, selectBeatsPerBar, inputDensityRule].forEach(elem => {
|
||||||
|
elem.addEventListener('input', generatePromptText);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial Render
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
initHistoryDatalists();
|
||||||
|
renderTrackEditors();
|
||||||
|
generatePromptText();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# TECHNICAL SPECIFICATION: AUTOMATED DATA TRANSFER FROM MIDI PROMPT GENERATOR TO AI PRESET MANAGER
|
||||||
|
|
||||||
|
This document details the software implementation plan to automatically extract calculated parameters (Bars, BPM, Category, Keywords, Prompt Content) and auto-populate the AI Prompt Preset Manager form whenever a user clicks the "Save as Preset" button on the MIDI AI Prompt Generator tool, adhering strictly to the designated interface layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TECHNICAL GOALS
|
||||||
|
|
||||||
|
* **Zero Re-typing:** Eliminates manual copy-pasting of generated prompt text or re-entering parameters such as Bars, Role, and Category.
|
||||||
|
* **Smart Data Mapping:**
|
||||||
|
* Auto-suggests a **Preset Name** based on Section Type and Bar Count.
|
||||||
|
* Auto-extracts **Trigger Keywords** from Role, Section Type, and Action Descriptions.
|
||||||
|
* Auto-calculates **Default Bars** using the formula $(\text{EndBar} - \text{StartBar} + 1)$.
|
||||||
|
* Overwrites the complete generated prompt text directly into the **SYSTEM PROMPT TEMPLATE** area.
|
||||||
|
|
||||||
|
|
||||||
|
* **Structured Preset Generation ("Create Structured Preset" Green Button):** Enables converting static prompt text into a structured variable template containing dynamic placeholders (e.g., `{{start_bar}}`, `{{end_bar}}`, `{{total_beats}}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DATA MAPPING MATRIX
|
||||||
|
|
||||||
|
Upon clicking "Save as Preset", the system reads the active State from the Prompt Generator and maps it to the Modal fields:
|
||||||
|
|
||||||
|
| AI Prompt Preset Manager Field | Source Data from Prompt Generator | Processing Rules / Logic | Example Auto-Populated Data |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **PRESET NAME** | `inputSectionType` + `barCount` | `${sectionType} (${barCount} Bars)` | `"Dramatic Build-Up (8 Bars)"` |
|
||||||
|
| **CATEGORY** | `inputRole` | Evaluates role keywords to assign category (Piano, Orchestral, EDM, Cinematic, etc.) | `"Piano Solo"` or `"Orchestral / Film Score"` |
|
||||||
|
| **TRIGGER KEYWORDS** | `sectionType` + `role` + `trackLabels` | Extracts key nouns, formatted as a comma-separated string | `"build-up, dramatic, piano solo, 8 bars"` |
|
||||||
|
| **DEFAULT BARS** | `inputStartBar` & `inputEndBar` | $\max(1, (\text{EndBar} - \text{StartBar} + 1))$ | `8` |
|
||||||
|
| **DEFAULT BPM** | Form / State Context | Inherits active project BPM or defaults to 120 | `120` |
|
||||||
|
| **DEFAULT SCALE** | Form / State Context | Inherits active project key signature or defaults to C Minor | `"C Minor"` |
|
||||||
|
| **SYSTEM PROMPT TEMPLATE** | `promptOutputText.innerText` | Extracts the compiled output text directly from the Live Preview component | *(Full generated prompt text)* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DATA FLOW LIFECYCLE
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ USER GENERATES PROMPT ON GENERATOR FORM ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ CLICK BUTTON: "Save as Preset" ]
|
||||||
|
│
|
||||||
|
├─► 1. Call `computeTimeMetrics()` (Extract StartBar, EndBar, TotalBeats)
|
||||||
|
├─► 2. Extract `generatePromptText()`
|
||||||
|
├─► 3. Invoke `autoFillPresetModal(stateData)`
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ POPUP MODAL: "AI PROMPT PRESET MANAGER" OPENS ]
|
||||||
|
- Preset Name <-- Auto-populated
|
||||||
|
- Category <-- Auto-selected via dropdown
|
||||||
|
- Trigger Keywords <-- Auto-generated tag string
|
||||||
|
- Bars / BPM <-- Auto-populated numbers
|
||||||
|
- System Prompt <-- Full prompt text inserted
|
||||||
|
│
|
||||||
|
├──► [ CLICK "Create Structured Preset" (Green Button) ]
|
||||||
|
│ └─► Converts static text to template variable placeholders (`{{total_beats}}`)
|
||||||
|
│
|
||||||
|
└──► [ CLICK "Save Preset" (Purple Button) ]
|
||||||
|
└─► Saves JSON object to `localStorage('daw_ai_prompt_presets')` & closes Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. STEP-BY-STEP IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
### Step 1: Update Modal UI
|
||||||
|
|
||||||
|
Position the green **"Create Structured Preset"** button at the bottom-left of the modal action bar (adjacent to the Back, Save Preset, and Close button group).
|
||||||
|
|
||||||
|
### Step 2: Build `autoFillPresetModal()` Function
|
||||||
|
|
||||||
|
Implement a JavaScript function responsible for parsing active form control values and injecting them into the target modal input elements when triggered.
|
||||||
|
|
||||||
|
### Step 3: Implement "Create Structured Preset" Logic (Green Button Feature)
|
||||||
|
|
||||||
|
Program the handler to parse static numeric values within the prompt text (e.g., 8 bars, beat 64.0, Bars 9 to 16) and replace them with dynamic template placeholders (`{{bars}}`, `{{total_beats}}`, `{{start_bar}}`, `{{end_bar}}`). This allows saved presets to adapt dynamically to variable bar lengths when reused.
|
||||||
|
|
||||||
|
### Step 4: LocalStorage & Event Bus Integration
|
||||||
|
|
||||||
|
Save the compiled preset object to `localStorage` under the key `daw_ai_prompt_presets` and dispatch a `PRESET_CREATED` CustomEvent so that the AI Copilot Chat Bar updates its preset options instantly without requiring a page refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CODE IMPLEMENTATION SNIPPETS
|
||||||
|
|
||||||
|
### 5.1 Modal HTML Markup
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- MODAL: AI PROMPT PRESET MANAGER -->
|
||||||
|
<div id="presetManagerModal" class="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center hidden p-4">
|
||||||
|
<div class="w-full max-w-3xl bg-[#13141a] border border-slate-800 rounded-2xl shadow-2xl p-6 space-y-4 text-slate-200 relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-800/80 pb-3">
|
||||||
|
<h2 class="text-sm font-bold tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-400 font-mono uppercase">
|
||||||
|
AI PROMPT PRESET MANAGER
|
||||||
|
</h2>
|
||||||
|
<button id="btnCloseModalX" class="w-7 h-7 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Sub-Title -->
|
||||||
|
<div class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-purple-400"></i> CREATE NEW PRESET
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Input Grid -->
|
||||||
|
<div class="space-y-3 text-xs">
|
||||||
|
|
||||||
|
<!-- Row 1: Name & Category -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">PRESET NAME</label>
|
||||||
|
<input type="text" id="presetNameInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="Enter preset name...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">CATEGORY</label>
|
||||||
|
<select id="presetCategorySelect" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans">
|
||||||
|
<option value="Orchestral / Film Score">Orchestral / Film Score</option>
|
||||||
|
<option value="Piano Solo">Piano Solo</option>
|
||||||
|
<option value="EDM / Pop">EDM / Pop</option>
|
||||||
|
<option value="Cinematic">Cinematic</option>
|
||||||
|
<option value="Jazz & Swing">Jazz & Swing</option>
|
||||||
|
<option value="Lo-fi Chillhop">Lo-fi Chillhop</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Trigger Keywords -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">TRIGGER KEYWORDS (COMMA-SEPARATED)</label>
|
||||||
|
<input type="text" id="presetKeywordsInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="e.g. epic orchestra, dramatic, film score">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Bars, BPM, Scale -->
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">DEFAULT BARS</label>
|
||||||
|
<input type="number" id="presetBarsInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">DEFAULT BPM</label>
|
||||||
|
<input type="number" id="presetBpmInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">DEFAULT SCALE</label>
|
||||||
|
<input type="text" id="presetScaleInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: Template Textarea -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">SYSTEM PROMPT TEMPLATE</label>
|
||||||
|
<textarea id="presetTemplateTextarea" rows="6" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg p-3 text-white font-mono text-xs focus:outline-none focus:border-purple-500 leading-relaxed custom-scrollbar"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Action Row -->
|
||||||
|
<div class="space-y-3 pt-2 border-t border-slate-800/80">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Green Button: Create Structured Preset -->
|
||||||
|
<button id="btnCreateStructuredPreset" class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg text-xs font-bold transition-all shadow-md flex items-center gap-1.5">
|
||||||
|
Create Structured Preset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Right Button Group: Back + Save Preset -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="btnModalBack" class="px-4 py-2 bg-[#252836] hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button id="btnSavePresetSubmit" class="px-5 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-xs font-bold transition-all shadow-lg shadow-purple-950/80 flex items-center gap-1.5">
|
||||||
|
Save Preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Close Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="btnCloseModalBottom" class="px-4 py-1.5 bg-[#1f222e] hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Auto-Populate Logic (`autoFillPresetModal`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Function: Auto-populate Modal fields when "Save as Preset" is clicked
|
||||||
|
function handleOpenSavePresetModal() {
|
||||||
|
// 1. Calculate time metrics from the Form Engine
|
||||||
|
const timeData = computeTimeMetrics(); // returns { barCount, totalBeats, start, end }
|
||||||
|
const currentGeneratedPrompt = promptOutputText.innerText.trim();
|
||||||
|
|
||||||
|
// 2. Read values from form controls
|
||||||
|
const sectionType = inputSectionType.value.trim() || "Building Section";
|
||||||
|
const role = inputRole.value.trim() || "Composer";
|
||||||
|
const actionDesc = inputActionDesc.value.trim();
|
||||||
|
|
||||||
|
// 3. Auto-infer Category based on Role or Section
|
||||||
|
let category = "Orchestral / Film Score";
|
||||||
|
const roleLower = role.toLowerCase();
|
||||||
|
if (roleLower.includes("piano")) category = "Piano Solo";
|
||||||
|
else if (roleLower.includes("edm") || roleLower.includes("electro")) category = "EDM / Pop";
|
||||||
|
else if (roleLower.includes("jazz")) category = "Jazz & Swing";
|
||||||
|
else if (roleLower.includes("lo-fi") || roleLower.includes("chillhop")) category = "Lo-fi Chillhop";
|
||||||
|
|
||||||
|
// 4. Auto-generate Keyword Tags
|
||||||
|
const keywordList = [
|
||||||
|
sectionType.toLowerCase(),
|
||||||
|
roleLower.split(' ')[0],
|
||||||
|
`${timeData.barCount} bars`
|
||||||
|
];
|
||||||
|
const keywordsStr = keywordList.filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
// 5. Populate Form Fields
|
||||||
|
presetNameInput.value = `${sectionType} (${timeData.barCount} Bars)`;
|
||||||
|
presetCategorySelect.value = category;
|
||||||
|
presetKeywordsInput.value = keywordsStr;
|
||||||
|
presetBarsInput.value = timeData.barCount;
|
||||||
|
presetBpmInput.value = 120; // Default BPM or read from session context
|
||||||
|
presetScaleInput.value = "C Minor";
|
||||||
|
presetTemplateTextarea.value = currentGeneratedPrompt;
|
||||||
|
|
||||||
|
// 6. Display Modal
|
||||||
|
presetManagerModal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listener for the main button
|
||||||
|
btnSavePresetModal.addEventListener('click', handleOpenSavePresetModal);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Structured Preset Variable Replacement ("Create Structured Preset")
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Function: Converts static numbers in prompt into dynamic placeholders
|
||||||
|
btnCreateStructuredPreset.addEventListener('click', () => {
|
||||||
|
let promptText = presetTemplateTextarea.value;
|
||||||
|
const bars = presetBarsInput.value || "8";
|
||||||
|
|
||||||
|
// Replace specific numbers with template variables
|
||||||
|
promptText = promptText
|
||||||
|
.replace(new RegExp(`dài ${bars} ô nhịp`, 'g'), 'dài {{bars}} ô nhịp')
|
||||||
|
.replace(/Từ Ô nhịp \d+ đến \d+/g, 'Từ Ô nhịp {{start_bar}} đến {{end_bar}}')
|
||||||
|
.replace(/Phách 0\.0 đến \d+\.0/g, 'Phách 0.0 đến {{total_beats}}')
|
||||||
|
.replace(/phách \d+\.0/g, 'phách {{total_beats}}');
|
||||||
|
|
||||||
|
presetTemplateTextarea.value = promptText;
|
||||||
|
showToast("Converted Prompt to Structured Variable Template!");
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 Save Preset Handler (`localStorage` & Event Dispatch)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
btnSavePresetSubmit.addEventListener('click', () => {
|
||||||
|
const name = presetNameInput.value.trim();
|
||||||
|
const category = presetCategorySelect.value;
|
||||||
|
const keywordsRaw = presetKeywordsInput.value.trim();
|
||||||
|
const defaultBars = parseInt(presetBarsInput.value) || 8;
|
||||||
|
const defaultBpm = parseInt(presetBpmInput.value) || 120;
|
||||||
|
const defaultScale = presetScaleInput.value.trim() || "C Minor";
|
||||||
|
const template = presetTemplateTextarea.value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast("Please enter a Preset Name!", "error");
|
||||||
|
presetNameInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
showToast("System Prompt content cannot be empty!", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPresetObj = {
|
||||||
|
id: `preset_${Date.now()}`,
|
||||||
|
name: name,
|
||||||
|
category: category,
|
||||||
|
keywords: keywordsRaw ? keywordsRaw.split(',').map(k => k.trim()) : [name.toLowerCase()],
|
||||||
|
default_bars: defaultBars,
|
||||||
|
default_bpm: defaultBpm,
|
||||||
|
default_scale: defaultScale,
|
||||||
|
system_instruction_template: template,
|
||||||
|
is_user_defined: true,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read existing presets and prepend new one
|
||||||
|
let savedPresets = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('daw_ai_prompt_presets');
|
||||||
|
savedPresets = stored ? JSON.parse(stored) : [];
|
||||||
|
} catch (e) {
|
||||||
|
savedPresets = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
savedPresets.unshift(newPresetObj);
|
||||||
|
localStorage.setItem('daw_ai_prompt_presets', JSON.stringify(savedPresets));
|
||||||
|
|
||||||
|
// Dispatch Custom Event so Copilot Chat UI updates dynamically
|
||||||
|
window.dispatchEvent(new CustomEvent('AI_PRESET_SAVED', { detail: newPresetObj }));
|
||||||
|
|
||||||
|
showToast(`Preset "${name}" saved successfully!`);
|
||||||
|
presetManagerModal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. OPERATIONAL VERIFICATION CHECKLIST
|
||||||
|
|
||||||
|
* [ ] Open Form Generator and set parameters (e.g., Bars 9 to 16, Role: Pianist).
|
||||||
|
* [ ] Click "Save as Preset":
|
||||||
|
* Modal opens.
|
||||||
|
* **PRESET NAME** displays: `Dramatic Build-Up (8 Bars)`.
|
||||||
|
* **CATEGORY** selects: `Piano Solo`.
|
||||||
|
* **DEFAULT BARS** populates: `8`.
|
||||||
|
* **SYSTEM PROMPT TEMPLATE** contains the generated prompt text.
|
||||||
|
|
||||||
|
|
||||||
|
* [ ] Test the "Create Structured Preset" green button:
|
||||||
|
* Click button $\rightarrow$ values such as 8 bars or beat 64.0 are converted to variables like `{{bars}}` and `{{total_beats}}`.
|
||||||
|
|
||||||
|
|
||||||
|
* [ ] Click "Save Preset" (Purple Button):
|
||||||
|
* Toast notification confirms save.
|
||||||
|
* `localStorage.getItem('daw_ai_prompt_presets')` contains the new preset object at the beginning of the array.
|
||||||
@@ -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> <Track Templates></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> <Project Directory></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>
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# CARLA BRIDGE — NATIVE GUI VSTi (Windows) + DUAL-MODE RUNTIME (Desktop / Docker Headless)
|
||||||
|
|
||||||
|
> Tài liệu kỹ thuật & vận hành: tích hợp Carla làm host native GUI cho VSTi trên
|
||||||
|
> Windows, tự phát hiện môi trường (desktop / headless), thư viện preset
|
||||||
|
> (.vstpreset) làm cầu nối Carla → pedalboard, và Plugin Manager quản lý
|
||||||
|
> soundfont + instrument.
|
||||||
|
>
|
||||||
|
> Phạm vi code: `app/core/runtime.py`, `app/api/v1/system.py`,
|
||||||
|
> `app/api/v1/presets.py`, `app/api/v1/plugins.py`, `app/core/vst_engine.py`,
|
||||||
|
> `app/core/render_engine.py`, `app/static/js/services/runtime.js`, `app.jsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bối cảnh & quyết định kiến trúc
|
||||||
|
|
||||||
|
| Vấn đề | Quyết định |
|
||||||
|
|---|---|
|
||||||
|
| `pedalboard` **cố tình headless** — không mở được native GUI VSTi | Không thay thế pedalboard; dùng **Carla làm host GUI ngoài** (preview + chỉnh preset), pedalboard giữ nguyên làm **render engine** (offline, cùng code path cho preview & export) |
|
||||||
|
| Browser không chạy được binary `.vst3` (Windows/Linux) | Preview realtime thật chỉ có trong **cửa sổ Carla**; trong web UI dùng **quick-render preview** (pedalboard render clip ngắn → wav — **âm thật = âm export**) |
|
||||||
|
| App chạy 2 môi trường: **Windows desktop** (server+client 1 máy) và **Docker headless** (server + browser UI) | **Runtime profile tự phát hiện** → bật/tắt tính năng theo môi trường (nút Carla Bridge chỉ hiện trên desktop có Carla) |
|
||||||
|
| Carla Windows là **bộ zip portable** — không installer, không PATH | **User tự định vị** thư mục chứa `carla.exe` (Plugin Manager → Định vị Carla...) + tìm kiếm dự phòng (registry, Program Files, quét nông Downloads/Desktop) |
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────── Windows Desktop (1 máy) ──────────────────────────┐
|
||||||
|
│ Browser/Tauri UI ──► FastAPI (localhost:8000) ──► pedalboard (render VST3) │
|
||||||
|
│ │ │ ▲ │
|
||||||
|
│ ▼ ▼ │ load_preset │
|
||||||
|
│ Nút "Carla Bridge" ──► spawn carla.exe (native GUI) │ │
|
||||||
|
│ │ chọn VSTi, chỉnh âm, Save .vstpreset │
|
||||||
|
│ └──────────────► Upload preset ──► storage/presets ─┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌────────────────────────── Docker Headless (server) ─────────────────────────┐
|
||||||
|
│ Browser UI ──► FastAPI (container :8000) ──► pedalboard (VSTi Linux) │
|
||||||
|
│ Soundfont + VSTi mở được: mount volumes (docker-compose.prod.yml) │
|
||||||
|
│ Preset .vstpreset: user chỉnh ở máy desktop → upload qua web UI │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Runtime tự phát hiện môi trường (`app/core/runtime.py`)
|
||||||
|
|
||||||
|
### 2.1 Nguyên tắc
|
||||||
|
|
||||||
|
- **Heuristic + override**: `SF_RUNTIME=auto|desktop|headless` **thắng tuyệt đối**
|
||||||
|
(WSL / docker-in-docker / remote desktop có thể làm heuristic sai).
|
||||||
|
- `SF_DOCKER=1` hoặc tồn tại `/.dockerenv` → nhận diện container.
|
||||||
|
- Linux: không có `DISPLAY` hoặc trong docker → `headless`; ngược lại `desktop`.
|
||||||
|
- Windows/macOS → luôn `desktop`.
|
||||||
|
|
||||||
|
### 2.2 Capabilities API (frontend gọi 1 lần lúc boot)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/system/capabilities (public — cần trước khi đăng nhập)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"runtime": "desktop", // "desktop" | "headless"
|
||||||
|
"platform": "windows", // "windows" | "linux" | "darwin"
|
||||||
|
"docker": false,
|
||||||
|
"features": {
|
||||||
|
"carla_local": true, // có Carla trên máy → hiện nút "Carla Bridge"
|
||||||
|
"carla_path": "D:/Tools/Carla/carla.exe",
|
||||||
|
"preset_upload": true, // luôn true (upload .vstpreset qua web UI)
|
||||||
|
"vst_render": true, // pedalboard khả dụng
|
||||||
|
"tauri_bridge": false,
|
||||||
|
"preview_mode": "quick_render" // "quick_render" | "wasm"
|
||||||
|
},
|
||||||
|
"default_dirs": { "vst": [...], "soundfont": [...], "preset": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Bảng hành vi theo môi trường
|
||||||
|
|
||||||
|
| Tính năng | Windows desktop | Docker headless |
|
||||||
|
|---|---|---|
|
||||||
|
| Render VSTi | pedalboard `VST3Plugin` + `load_preset` | giống hệt (VSTi **bản Linux**) |
|
||||||
|
| Scan VST/SF | thư mục chuẩn Windows + `plugin_dirs.json` | mount volumes + `plugin_dirs.json` |
|
||||||
|
| Preview VSTi | quick-render (âm thật); realtime trong Carla | quick-render |
|
||||||
|
| Nút "Carla Bridge" (nút Synth) | **Hiện** → spawn `carla.exe` | **Ẩn** (không có GUI local) |
|
||||||
|
| Preset | picker local / upload → thư viện | upload → thư viện |
|
||||||
|
| SoundFont preview | FluidSynth WASM (browser) | giống hệt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cài đặt Carla trên Windows (bản portable zip)
|
||||||
|
|
||||||
|
Carla phát hành dạng **zip** (`Carla-2.5.x-win64.zip`) — **không có installer,
|
||||||
|
không ghi PATH**. Không bundle Carla vào installer của app (license GPL-2.0+,
|
||||||
|
xem §8).
|
||||||
|
|
||||||
|
1. Tải: <https://github.com/falkTX/Carla/releases> (bản `win64`).
|
||||||
|
2. Giải nén ra bất kỳ đâu (VD `D:\Tools\Carla\`, chứa `carla.exe`).
|
||||||
|
3. Khai báo 1 lần (2 cách, tùy chọn 1):
|
||||||
|
- **Nút bấm**: Plugin Manager → section **"Carla Bridge (VSTi native GUI)"** →
|
||||||
|
**"Định vị Carla..."** → chọn thư mục chứa `carla.exe` (hoặc chính file).
|
||||||
|
- **Nhập tay**: ô text trong section đó → nhập `D:/Tools/Carla` → **Lưu**.
|
||||||
|
4. App lưu vào `storage/carla_path.json` → cache detect bị xóa → nút
|
||||||
|
**"Carla Bridge"** hiện trong dropdown nút Synth + **tự động mở Carla**
|
||||||
|
khi chọn VSTi.
|
||||||
|
|
||||||
|
### 3.1 Thứ tự phát hiện `carla_local`
|
||||||
|
|
||||||
|
| Ưu tiên | Nguồn |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Config user** (`storage/carla_path.json`) — kênh chính cho bản portable |
|
||||||
|
| 2 | PATH (`shutil.which`) |
|
||||||
|
| 3 | Registry (`HKLM/HKCU\SOFTWARE\Carla\InstallPath`) — nếu cài qua installer |
|
||||||
|
| 4 | Thư mục chuẩn (`Program Files\Carla`, `%LOCALAPPDATA%\Programs\Carla`) |
|
||||||
|
| 5 | **Quét nông** Downloads/Desktop/Documents (độ sâu ≤ 3, bỏ qua `node_modules`, `AppData`, `Windows`...) — không bao giờ quét toàn ổ đĩa |
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/system/carla-path (auth) — lưu vị trí Carla
|
||||||
|
body: { "carla_path": "D:/Tools/Carla" } (thư mục HOẶC file exe)
|
||||||
|
→ trả { success, carla_path, ...capabilities }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Luồng sử dụng end-to-end (Windows)
|
||||||
|
|
||||||
|
1. **Nút Synth** → chọn **VSTi** trong danh sách → app **TỰ ĐỘNG gọi Carla**:
|
||||||
|
sinh file project `.carxs` (định dạng XML chính thức của Carla —
|
||||||
|
`carla.exe [FILE]` nhận project file) chứa node
|
||||||
|
`<Plugin><Info><Type>VST3</Type><Binary>path</Binary>...</Info></Plugin>`
|
||||||
|
→ `carla.exe <project.carxs>` → Carla mở lên **plugin đã load sẵn** kèm
|
||||||
|
**on-screen MIDI keyboard** (`PixmapKeyboard`).
|
||||||
|
- VST3 (file `.vst3` hoặc folder `X.vst3` Windows): auto-load qua `.carxs`.
|
||||||
|
- VST2 (`.dll`/`.so` ngoài `.vst3`): không auto-load tin cậy (cần uniqueID)
|
||||||
|
→ mở Carla trống để user **Add Plugin** thủ công.
|
||||||
|
- Project `.carxs` nằm `{STORAGE_DIR}/carla_projects/`, tự dọn sau 1 ngày.
|
||||||
|
2. Trong Carla: native GUI của VSTi hiện ra → **chọn instrument/preset** của
|
||||||
|
plugin → **bật ARM** trên track trong app (tùy chọn) → **bấm phím trên
|
||||||
|
keyboard ảo của Carla** để preview realtime (âm thật qua audio device).
|
||||||
|
3. **Save preset** bằng nút của CHÍNH plugin (không dùng project save của Carla)
|
||||||
|
→ file `.vstpreset`.
|
||||||
|
4. Trong app: dropdown Synth → **"⬆ Upload preset (từ Carla...)"** → chọn file
|
||||||
|
`.vstpreset` → nằm trong **thư viện preset** → chọn preset trong danh sách
|
||||||
|
**"VST Presets"** → gán vào track (`synth_engine.preset_id`).
|
||||||
|
5. **Render**: `render_engine.py` nạp plugin qua `load_vst()` rồi
|
||||||
|
`apply_preset_to_plugin()` → **âm render = âm đã chỉnh trong Carla**
|
||||||
|
(điều kiện: cùng sample rate — xem §7).
|
||||||
|
|
||||||
|
Lưu ý: danh sách VSTi trong dropdown vẫn cần thiết — dùng để **map tên →
|
||||||
|
đường dẫn khi pedalboard nạp plugin lúc render** (không phải để mở GUI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Thư viện preset — cầu nối Carla ↔ pedalboard
|
||||||
|
|
||||||
|
- Thư mục: `{STORAGE_DIR}/presets` (Windows: `%APPDATA%\SonicForgeDAW\storage\presets`;
|
||||||
|
Docker: nằm trong volume `sf_db` → `/app/app/storage/presets`).
|
||||||
|
- Định dạng hỗ trợ: `.vstpreset` (VST3 — chuẩn), `.fxp`/`.fxb` (VST2, chỉ
|
||||||
|
preview trong Carla), `.dspreset` (DecentSampler/Pianobook).
|
||||||
|
- `synth_engine` của track có thể chứa 1 trong 3 dạng (ưu tiên giảm dần):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "vst3", "plugin_id": "Kontakt 7",
|
||||||
|
"preset_data": "<base64 bytes .vstpreset nhúng — project tự chứa state, portable>",
|
||||||
|
"preset_id": "a1b2c3....vstpreset", // thư viện storage/presets
|
||||||
|
"preset_path": "D:/presets/Piano.vstpreset" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/presets (public) — danh sách
|
||||||
|
POST /api/v1/presets/upload (auth) — multipart file + plugin_hint
|
||||||
|
GET /api/v1/presets/{id}/download
|
||||||
|
DELETE /api/v1/presets/{id} (auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick-render preview (âm thật = âm export)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/plugins/preview
|
||||||
|
body: { instrument_id, notes:[{pitch,start_beat,duration_beats,velocity}],
|
||||||
|
bpm, sample_rate, preset_id?, preset_path?, preset_data? }
|
||||||
|
→ { success, url: "/static/audio/processed/preview_xxx.wav", duration_sec }
|
||||||
|
```
|
||||||
|
|
||||||
|
Cùng code path với export (pedalboard + `load_preset`) → **preview nghe đúng
|
||||||
|
plugin/preset** (khác "Preview Synth WASM" cũ — âm giả).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Plugin Manager — SoundFont: Add Directory → Scan → Instrument → Synth
|
||||||
|
|
||||||
|
1. **Add Directory** (folder picker native / in-app browser) → thư mục vào
|
||||||
|
danh sách (lưu `plugin_dirs.json`).
|
||||||
|
2. **Scan** → quét soundfont trong các thư mục (catalog qua
|
||||||
|
`SoundFontAutoScanner`) + liệt kê VST.
|
||||||
|
3. Bấm vào **một soundfont** (▸) → expand danh sách **instrument bên trong**
|
||||||
|
(Bank/Program/Tên) qua `GET /api/v1/plugins/soundfont-instruments/{sf_id}`.
|
||||||
|
4. Nút **"Chèn vào Synth"** → gán instrument (bank/program) vào **track đang
|
||||||
|
chọn** (`setTrackInstrumentWithProgram`) và đóng Plugin Manager.
|
||||||
|
|
||||||
|
> SF3: instrument đọc sau khi chuyển đổi SF3→SF2 (endpoint download tự chuyển
|
||||||
|
> đổi khi cần). Nếu thiếu libfluidsynth, danh sách trả `[]` (graceful).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lưu ý kỹ thuật & hạn chế (đã xác minh)
|
||||||
|
|
||||||
|
1. **Sample rate**: Carla chạy theo audio device (thường 48 kHz), pedalboard
|
||||||
|
render mặc định 44.1 kHz → plugin phụ thuộc SR (delay/chorus/oversampling)
|
||||||
|
nghe khác. **Render đúng SR của thiết bị Carla** để preview = export.
|
||||||
|
2. **VST2**: pedalboard 0.10+ **đã gỡ hỗ trợ VST2** (chỉ còn VST3) → chỉ preset
|
||||||
|
VST3 (`.vstpreset`) round-trip được. Plugin VST2 cũ: preview được trong
|
||||||
|
Carla nhưng **không render** được qua pedalboard.
|
||||||
|
3. **VSTi trên Docker**: chỉ chạy được plugin có bản **Linux** (`.vst3`/`.so`);
|
||||||
|
plugin Windows-only (`.dll`) không chạy trên server Linux (không khuyến nghị
|
||||||
|
Wine bridge trong container).
|
||||||
|
4. **License Carla GPL-2.0+**: app **không bundle/nhúng** Carla — chỉ spawn
|
||||||
|
tiến trình ngoài + trao đổi file preset (không link code) → không dính
|
||||||
|
copyleft. User tự tải zip.
|
||||||
|
5. **Preview WASM ≠ âm thật**: Preview Synth trong browser không phải plugin
|
||||||
|
thật — dùng quick-render (`/plugins/preview`) nếu cần nghe đúng âm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Checklist QA
|
||||||
|
|
||||||
|
- [ ] Windows: cài Carla zip → Định vị Carla → nút "Carla Bridge" hiện ở nút Synth
|
||||||
|
- [ ] Bấm Carla Bridge → `carla.exe` chạy, mở được VSTi + native GUI
|
||||||
|
- [ ] Save `.vstpreset` từ GUI plugin → Upload trong app → gán vào track → render ra âm đúng preset
|
||||||
|
- [ ] Render cùng SR với Carla → preview (Carla) nghe = âm export
|
||||||
|
- [ ] Plugin Manager: Add Directory → Scan → expand soundfont → "Chèn vào Synth" gán đúng bank/program vào track đang chọn
|
||||||
|
- [ ] Docker (`SF_DOCKER=1`): capabilities `runtime=headless`, không hiện Carla Bridge, preset upload hoạt động, render VSTi Linux + soundfont từ mount OK
|
||||||
|
- [ ] `SF_RUNTIME=desktop` trên Linux có DISPLAY → chạy như desktop
|
||||||
|
- [ ] pytest: `86 passed, 7 skipped`
|
||||||
|
|
||||||
|
## 9. Flow MIDI Keyboard hardware (Web MIDI) — route đầy đủ
|
||||||
|
|
||||||
|
Hardware MIDI keyboard (Web MIDI API) đi qua ĐÚNG flow như keybed ảo:
|
||||||
|
|
||||||
|
```
|
||||||
|
MIDI Keyboard (hardware, Web MIDI)
|
||||||
|
├─ note-on → ensureMasteringRouting() (ép âm qua Mastering FX Chain khi bật)
|
||||||
|
│ → SonicSF.playNote() (track soundfont/GM — qua track node → mastering)
|
||||||
|
│ → SonicCarlaMidi.noteOn() (track VSTi + ARM + Carla local — OSC /Carla/0/note_on)
|
||||||
|
├─ note-off → SonicSF.stopNote() (dừng soundfont)
|
||||||
|
│ → SonicCarlaMidi.noteOff() (dừng VSTi trong Carla — tránh kẹt âm)
|
||||||
|
└─ CC64/CC1/Pitch Bend → SonicSF.controllerChange()/pitchBend() (armed tracks)
|
||||||
|
```
|
||||||
|
|
||||||
|
Trước đây chỉ keybed ảo (piano roll) gọi `SonicCarlaMidi` + `__ensureMasteringRouting`;
|
||||||
|
keyboard hardware bị bỏ sót → track VSTi không kêu khi bấm đàn thật, và âm soundfont
|
||||||
|
không qua mastering cho tới khi bật/tắt power. Đã sửa tại `onmidimessage` handler
|
||||||
|
(`app.jsx`): note-on/note-off route qua Carla bridge + ép đồng bộ mastering routing.
|
||||||
|
|
||||||
|
Export offline (`clientSideExport`) cũng áp **Main out volume** (master fader) vào
|
||||||
|
offline master bus — file WAV đúng độ lớn nghe được (trước đây luôn 1.0).
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
From 46d3778a5d5305ba625318433b7f98c1e4dac932 Mon Sep 17 00:00:00 2001
|
||||||
|
From: 3dtours <yeunhiepanh.photo@gmail.com>
|
||||||
|
Date: Wed, 29 Jul 2026 09:11:54 +0700
|
||||||
|
Subject: [PATCH] =?UTF-8?q?FIX:=20chi=E1=BB=81u=20cao=20c=E1=BB=A7a=20trac?=
|
||||||
|
=?UTF-8?q?k=20thu=20nh=E1=BB=8F=20theo=20chi=E1=BB=81u=20cao=20c=E1=BB=A7?=
|
||||||
|
=?UTF-8?q?a=20Mixer=20Panel?=
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
---
|
||||||
|
.kilo/plans/mixer-equalizer-plan.md | 293 ++++++++++++++++++
|
||||||
|
.../js/services/audioMixerGraphManager.js | 20 +-
|
||||||
|
app/storage/sonicforge.db | Bin 77824 -> 77824 bytes
|
||||||
|
app/templates/index.html | 2 +-
|
||||||
|
4 files changed, 310 insertions(+), 5 deletions(-)
|
||||||
|
create mode 100644 .kilo/plans/mixer-equalizer-plan.md
|
||||||
|
|
||||||
|
diff --git a/.kilo/plans/mixer-equalizer-plan.md b/.kilo/plans/mixer-equalizer-plan.md
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..2fbf79e
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/.kilo/plans/mixer-equalizer-plan.md
|
||||||
|
@@ -0,0 +1,293 @@
|
||||||
|
+# Mixer Console & Equalizer Implementation Plan
|
||||||
|
+
|
||||||
|
+## Goal
|
||||||
|
+Implement a docked Mixer Console panel (similar to REAPER's MCP) at the bottom of the DAW, featuring per-track channel strips with volume fader, pan knob, Mute/Solo, VU meter, and FX controls. Support MAIN SESSION and SECTION-TAB contexts with hierarchical audio routing.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 1: Create `AudioMixerGraphManager`
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/services/audioMixerGraphManager.js` (new)
|
||||||
|
+
|
||||||
|
+Class managing all Web Audio nodes with a proper Master Bus signal chain:
|
||||||
|
+
|
||||||
|
+```
|
||||||
|
+source → trackGain → faderGain → panNode → analyserNode → masterGain → masterPan → masterAnalyser → destination
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+### Responsibilities
|
||||||
|
+
|
||||||
|
+- **Singleton pattern** — created once with `audioCtx`, stored on `window.AudioMixerGraphManager`.
|
||||||
|
+- **Master Bus** — `masterGain → masterPan → masterAnalyser → audioCtx.destination`.
|
||||||
|
+- **Track Node Map** — `Map<trackId, { inputGain, faderGain, panNode, analyserNode, track }>`.
|
||||||
|
+- **Section Bus Map** — `Map<sectionId, { subMixGain, subMixPan, hostTrackId }>`.
|
||||||
|
+- `createMainTrackNodes(track)` — creates node chain, connects analyser → masterGain. Stores in `trackNodesMap`. Calls `updateTrackVolumePan()`.
|
||||||
|
+- `createSectionSubMixBus(sectionId, hostTrackId)` — creates subMixGain→subMixPan, pipes to host track's `inputGain`. Falls back to masterGain if host track not found.
|
||||||
|
+- `createSectionTrackNodes(sectionId, secTrack)` — creates nodes for internal section tracks, connects analyser → section's `subMixGain`.
|
||||||
|
+- `updateTrackVolumePan(trackId, volumeDb, panValue, isMuted)` — updates gain/pan in real-time. `gain = isMuted ? 0 : Math.pow(10, volumeDb / 20.0)`.
|
||||||
|
+- `getAnalyserData(trackId)` — returns `analyserNode` frequency/time-domain data for VU meter rendering.
|
||||||
|
+- `removeTrackNodes(trackId)` — disconnects and removes from map.
|
||||||
|
+
|
||||||
|
+### Signal Equations
|
||||||
|
+- `linearGain = isMuted ? 0 : Math.pow(10, volumeDb / 20.0)` — dB to linear scale.
|
||||||
|
+- `panValue = track.pan / 100` — existing -100..+100 range maps to -1.0..+1.0 `StereoPanner.pan`.
|
||||||
|
+- Use `setTargetAtTime(linearGain, ctx.currentTime, 0.01)` for smooth transitions.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 2: Refactor `app.jsx` Audio Node Creation
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx` — modify existing audio routing functions.
|
||||||
|
+
|
||||||
|
+### 2a. Replace `getOrCreateTrackNode()` with `AudioMixerGraphManager`
|
||||||
|
+
|
||||||
|
+Currently lines 10314-10343 create a local `{ gainNode, pannerNode }` per track and connect `pannerNode → context.destination`. Replace this with:
|
||||||
|
+
|
||||||
|
+```js
|
||||||
|
+function getOrCreateTrackNode(track, context) {
|
||||||
|
+ if (!window.AudioMixerGraphManager) return null;
|
||||||
|
+ const mgr = window.AudioMixerGraphManager;
|
||||||
|
+ const bundle = mgr.trackNodesMap.get(track.id);
|
||||||
|
+ if (bundle) return bundle.faderGain;
|
||||||
|
+ // Create nodes via AudioMixerGraphManager
|
||||||
|
+ mgr.createMainTrackNodes(track);
|
||||||
|
+ return mgr.trackNodesMap.get(track.id).faderGain;
|
||||||
|
+}
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+### 2b. Section Sub-Track Audio Routing
|
||||||
|
+
|
||||||
|
+Replace `getOrCreateSubTrackNode()` (lines 10344-10359) to:
|
||||||
|
+1. Create section sub-mix bus via `AudioMixerGraphManager.createSectionSubMixBus()` (first time only, lazy init).
|
||||||
|
+2. Create section internal track nodes via `AudioMixerGraphManager.createSectionTrackNodes()`.
|
||||||
|
+3. Return `faderGain` node for audio connection.
|
||||||
|
+
|
||||||
|
+### 2c. SoundFont MIDI Audio Routing Fix
|
||||||
|
+
|
||||||
|
+**Critical:** FluidSynth renders directly to `audioCtx.destination` via ScriptProcessorNode. SoundFont player (`soundfontPlayer.js` lines 27-49) has its own `_gainNode` connected to destination. This bypasses the track's gain/panner nodes.
|
||||||
|
+
|
||||||
|
+**Fix in `soundfontPlayer.js`:**
|
||||||
|
+- Modify the first `_gainNode` creation so that instead of `_gainNode.connect(ctx.destination)`, it connects to a pass-through that can be dynamically routed to any destination node.
|
||||||
|
+- OR: In `startTrackPlayback()` (app.jsx ~line 10382), after calling `window.SonicSF.playNote(...)`, re-route the SF output by calling a new method `window.SonicSF.setOutputNode(destinationNode)` that disconnects the internal `_gainNode` from `destination` and reconnects it to the track's `faderGain`.
|
||||||
|
+
|
||||||
|
+**Simpler approach:** Add `setOutputNode(destinationNode)` to `soundfontPlayer.js`:
|
||||||
|
+```js
|
||||||
|
+function setOutputNode(newDest) {
|
||||||
|
+ _gainNode.disconnect();
|
||||||
|
+ _gainNode.connect(newDest || ctx.destination);
|
||||||
|
+}
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+Call this from `getOrCreateTrackNode()` after creating/retrieving the track's `faderGain`, passing `trackFaderGain` as the destination.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 3: Mixer Console State & Toggle
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx`
|
||||||
|
+
|
||||||
|
+### 3a. Add Mixer Visibility State
|
||||||
|
+
|
||||||
|
+```js
|
||||||
|
+const [showMixer, setShowMixer] = useState(false);
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+### 3b. Mixer Toggle Button
|
||||||
|
+
|
||||||
|
+Add a toolbar button (e.g., in the TCP header near the "Add Track" button, or in a bottom toolbar). Icon: "mixer" (sliders icon). Toggles `showMixer`.
|
||||||
|
+
|
||||||
|
+### 3c. Determine Active Context
|
||||||
|
+
|
||||||
|
+When `activeTab === 'main'`, mixer shows MAIN SESSION tracks. When a SECTION-TAB is active, mixer shows the section's internal tracks (from `sessionTabs.find(s => s.id === activeTab).tracks`).
|
||||||
|
+
|
||||||
|
+### 3d. Pass to MixerConsole
|
||||||
|
+
|
||||||
|
+```
|
||||||
|
+mixerContext = { isSectionTab, currentTracks, currentTitle, activeTabContext }
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 4: Create MixerConsole React Component
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/components/MixerConsole.jsx` (new)
|
||||||
|
+
|
||||||
|
+Docked at the bottom of the timeline (inside the `bottom` dock panel or a fixed container at bottom of the `flex-1 flex flex-col overflow-hidden` area, below the timeline canvas).
|
||||||
|
+
|
||||||
|
+### 4a. Component Structure
|
||||||
|
+
|
||||||
|
+```
|
||||||
|
+MixerConsole (flex-col h-48 border-t border-zinc-700 bg-slate-900)
|
||||||
|
+├── MixerHeader (flex, shows "MAIN SESSION" or "SECTION TAB" label + close button)
|
||||||
|
+└── MixerStripsWrapper (flex-1 flex overflow-x-auto p-2 gap-1.5)
|
||||||
|
+ ├── MasterChannelStrip (w-28, fixed, left-aligned)
|
||||||
|
+ ├── Divider (w-[1px] bg-zinc-700)
|
||||||
|
+ └── Track strips (horizontal scroll, each TrackChannelStrip w-24)
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+### 4b. MasterChannelStrip
|
||||||
|
+
|
||||||
|
+Props: `isSectionBus, mixerGraphMgr`
|
||||||
|
+
|
||||||
|
+- Shows "MASTER" or "SEC BUS" header.
|
||||||
|
+- VU Meter canvas element (reads `masterAnalyser` frequency data, draws RMS/Peak bar).
|
||||||
|
+- Peak/rms numeric readout.
|
||||||
|
+- Mono toggle button.
|
||||||
|
+- "MAIN OUT" / "SUB-MIX" footer.
|
||||||
|
+
|
||||||
|
+### 4c. TrackChannelStrip
|
||||||
|
+
|
||||||
|
+Props: `track, index, mixerGraphMgr`
|
||||||
|
+
|
||||||
|
+Local state: `volumeDb, pan, isMuted, isSoloed` — initialized from `track` props.
|
||||||
|
+
|
||||||
|
+Renders:
|
||||||
|
+1. **Pan knob** — `<input type="range" min="-1" max="1" step="0.05">` vertical/horizontal slider + numeric label (C / Lxx / Rxx).
|
||||||
|
+2. **dB Readout** — `volumeDb <= -60 ? "-inf" : "[+/-]N.NdB"`.
|
||||||
|
+3. **Mute / Solo buttons** — `[M]` orange when muted, `[S]` yellow when soloed. Mute dispatches `updateTrackVolumePan(trackId, vol, pan, newMute)`. Solo dispatches `toggleTrackSoloEvaluate(trackId)`.
|
||||||
|
+4. **Fader + VU Meter** — vertical fader `<input type="range" min="-60" max="+12" step="0.5">` with CSS `writing-mode: vertical-lr`. VU meter bar (vertical `div` with gradient, height calculated from analyser RMS data). RequestAnimationFrame loop reads `analyserNode.getByteTimeDomainData()` and updates VU height.
|
||||||
|
+5. **FX / Power buttons** — `[FX]` stub that shows toast "FX panel coming soon". `[⑈]` bypass toggle (stub).
|
||||||
|
+6. **Footer** — `{index}. {track.name}` + `{track.type}`.
|
||||||
|
+
|
||||||
|
+### 4e. VU Meter Animation
|
||||||
|
+
|
||||||
|
+Use `requestAnimationFrame` loop inside `TrackChannelStrip`:
|
||||||
|
+```js
|
||||||
|
+useEffect(() => {
|
||||||
|
+ const analyser = mixerGraphMgr?.trackNodesMap.get(track.id)?.analyserNode;
|
||||||
|
+ if (!analyser) return;
|
||||||
|
+ analyser.fftSize = 128;
|
||||||
|
+ const bufferLength = analyser.frequencyBinCount;
|
||||||
|
+ const dataArray = new Uint8Array(bufferLength);
|
||||||
|
+ let rafId;
|
||||||
|
+ const draw = () => {
|
||||||
|
+ analyser.getByteTimeDomainData(dataArray);
|
||||||
|
+ const rms = Math.sqrt(dataArray.reduce((sum, v) => sum + ((v - 128) / 128) ** 2, 0) / bufferLength);
|
||||||
|
+ setMeterLevel(Math.min(1, rms * 2));
|
||||||
|
+ rafId = requestAnimationFrame(draw);
|
||||||
|
+ };
|
||||||
|
+ rafId = requestAnimationFrame(draw);
|
||||||
|
+ return () => cancelAnimationFrame(rafId);
|
||||||
|
+}, [track.id, mixerGraphMgr]);
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 5: Add Mixer Toggle Button (TCP Header)
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx` — TCP header area (line 16648-16656).
|
||||||
|
+
|
||||||
|
+Add a mixer toggle button next to the "Add Track" button:
|
||||||
|
+
|
||||||
|
+```jsx
|
||||||
|
+/*#__PURE__*/React.createElement("button", {
|
||||||
|
+ onClick: () => setShowMixer(!showMixer),
|
||||||
|
+ className: "px-2 py-1 bg-indigo-700 hover:bg-indigo-600 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
|
||||||
|
+}, /*#__PURE__*/React.createElement("span", {
|
||||||
|
+ className: "inline-flex items-center shrink-0"
|
||||||
|
+}, /*#__PURE__*/React.createElement("i", {
|
||||||
|
+ "data-lucide": "sliders-horizontal",
|
||||||
|
+ className: "w-3.5 h-3.5"
|
||||||
|
+})), " Mixer")
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+## Task 6: Add F7 Keyboard Shortcut for Mixer Toggle
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx` — modify the global keyboard handler (lines 8037-8335).
|
||||||
|
+
|
||||||
|
+The keydown handler at line 8334 (`window.addEventListener('keydown', handler, { capture: true })`) already handles hotkeys like Space (play/pause) and function keys for sub-tab operations.
|
||||||
|
+
|
||||||
|
+Add an `F7` check inside the handler, **before** the sub-tab-only block at line 8054 (so it works in both MAIN SESSION and sub-tabs):
|
||||||
|
+
|
||||||
|
+```js
|
||||||
|
+// F7: Toggle Mixer Console
|
||||||
|
+if (e.key === 'F7') {
|
||||||
|
+ e.preventDefault();
|
||||||
|
+ setShowMixerRef.current(prev => !prev);
|
||||||
|
+ return;
|
||||||
|
+}
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+Use a ref (`showMixerRef`) to access the current `showMixer` state without needing it as a dependency of the effect. Create the ref and keep it synced in the component body:
|
||||||
|
+
|
||||||
|
+```js
|
||||||
|
+const showMixerRef = useRef(showMixer);
|
||||||
|
+showMixerRef.current = showMixer;
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+Keep `setShowMixerRef` as a ref to `setShowMixer` to call it from the event listener without stale closures.
|
||||||
|
+
|
||||||
|
+This avoids adding `showMixer` as a dependency to the keyboard effect, which would cause unnecessary listener re-registrations.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 7: Integrate MixerConsole into App Layout
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx`
|
||||||
|
+
|
||||||
|
+### 5a. Placement
|
||||||
|
+
|
||||||
|
+After the timeline track list (the `div` containing `activeTracks.map(...)` and the add-track row), and before the transport bar, insert:
|
||||||
|
+
|
||||||
|
+```jsx
|
||||||
|
+showMixer && React.createElement(MixerConsole, {
|
||||||
|
+ key: 'mixer-console',
|
||||||
|
+ mixerContext: { isSectionTab, currentTracks, currentTitle, activeTabContext },
|
||||||
|
+ mixerGraphMgr: window.AudioMixerGraphManager,
|
||||||
|
+ trackActions: { updateTrackVolumeDb, updateTrackPan, toggleTrackMute, toggleTrackSoloEvaluate }
|
||||||
|
+})
|
||||||
|
+```
|
||||||
|
+
|
||||||
|
+### 5b. Panel System Integration
|
||||||
|
+
|
||||||
|
+Alternatively, register "mixer" as a panel that can be docked to the `bottom` dock panel. The panel content renders `MixerConsole`. This reuses the existing `dockPanels` / `renderDock` system — add a menu item or button that calls `addToDock('bottom', 'mixer')`.
|
||||||
|
+
|
||||||
|
+Simpler approach: fixed position below the timeline with a toggle button.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 8: Section-Tab Context Switching
|
||||||
|
+
|
||||||
|
+**File:** `app/static/js/app.jsx`
|
||||||
|
+
|
||||||
|
+When `activeTab` changes to a SECTION-TAB (`sessionTabs.some(s => s.id === activeTab)`):
|
||||||
|
+- MixerConsole's `currentTracks` switches to the section's internal tracks.
|
||||||
|
+- The Master strip label changes from "MASTER" to "SEC BUS".
|
||||||
|
+- Header shows "MIXER: SECTION TAB ({sectionName})".
|
||||||
|
+- Audio routing uses `createSectionSubMixBus` + `createSectionTrackNodes`.
|
||||||
|
+
|
||||||
|
+When switching back to `activeTab === 'main'`:
|
||||||
|
+- MixerConsole reverts to MAIN SESSION tracks and Master strip.
|
||||||
|
+
|
||||||
|
+This logic lives inside a `useEffect` that watches `activeTab` and re-renders the MixerConsole with the correct context.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Task 9: Mixer Resize & Toggle Persistence
|
||||||
|
+
|
||||||
|
+- Add a top drag handle on the MixerConsole (`h-1 cursor-ns-resize hover:bg-cyan-500/50`) to adjust height (range: `h-32` to `h-80`). Store height in a ref.
|
||||||
|
+- Store `showMixer` and mixer height in `localStorage` for persistence.
|
||||||
|
+- Close button (`[X]`) on the Mixer header sets `showMixer = false`.
|
||||||
|
+
|
||||||
|
+---
|
||||||
|
+
|
||||||
|
+## Validation Plan (Additions for F7 + Toggle Button)
|
||||||
|
+
|
||||||
|
+1. [ ] **Button toggle** — Click "Mixer" button in TCP header → MixerConsole appears. Click again → hides.
|
||||||
|
+2. [ ] **F7 hotkey** — Press F7 → MixerConsole toggles. Works in both MAIN SESSION and SECTION-TAB. Does NOT interfere with input/textarea focus.
|
||||||
|
+3. [ ] **No stale closure** — Repeated F7 presses reliably toggle state (setShowMixerRef.current used, not a stale function).
|
||||||
|
+
|
||||||
|
+## Extended Validation Plan
|
||||||
|
+
|
||||||
|
+1. **Toggle mixer** — Click the Mixer button → MixerConsole appears at bottom. Click again → hides.
|
||||||
|
+2. **MAIN SESSION mode** — Shows all tracks in the session. Master strip on left.
|
||||||
|
+3. **Volume fader** — Drag fader → track volume changes audibly in real-time. Numeric dB readout updates.
|
||||||
|
+4. **Pan knob** — Drag pan → stereo position changes. Label shows C / Lxx / Rxx.
|
||||||
|
+5. **Mute** — Click [M] → track mutes, button turns orange. Click again → unmutes.
|
||||||
|
+6. **Solo** — Click [S] on track 1 → track 1 plays, all other tracks silence. Click again → all tracks resume.
|
||||||
|
+7. **VU Meter** — During playback, meter bars animate with signal level (green/yellow/red gradient).
|
||||||
|
+8. **SECTION-TAB** — Open a section → MixerConsole switches to section tracks, Master strip shows "SEC BUS".
|
||||||
|
+9. **Section sub-mix volume** — Adjust fader on the SECTION host track → entire section volume changes.
|
||||||
|
+10. **MIDI audio routing** — SoundFont MIDI notes play through the track's fader/panner, not bypassing.
|
||||||
|
+11. **Resize** — Drag the resize handle on top of MixerConsole → height changes.
|
||||||
|
+12. **Persistence** — Reload page → mixer state restored from localStorage.
|
||||||
|
diff --git a/app/static/js/services/audioMixerGraphManager.js b/app/static/js/services/audioMixerGraphManager.js
|
||||||
|
index 93c530b..60c39c2 100644
|
||||||
|
--- a/app/static/js/services/audioMixerGraphManager.js
|
||||||
|
+++ b/app/static/js/services/audioMixerGraphManager.js
|
||||||
|
@@ -4,10 +4,22 @@
|
||||||
|
function AudioMixerGraphManager(audioCtx) {
|
||||||
|
this.audioCtx = audioCtx;
|
||||||
|
this.trackNodesMap = new Map();
|
||||||
|
- this.sectionBusMap = new Map();
|
||||||
|
-
|
||||||
|
- // Master bus chain: masterGain -> masterPan -> masterAnalyser -> destination
|
||||||
|
- this.masterGain = audioCtx.createGain();
|
||||||
|
+ this.sectionBusMap = new Map();
|
||||||
|
+ this.trackFxBypassGains = new Map();
|
||||||
|
+
|
||||||
|
+ // Master bus chain: masterGain -> masterPan -> masterBypassGain -> masterAnalyser -> destination
|
||||||
|
+ this.masterGain = audioCtx.createGain();
|
||||||
|
+ this.masterGain.gain.setValueAtTime(1, audioCtx.currentTime);
|
||||||
|
+ this.masterPan = audioCtx.createStereoPanner();
|
||||||
|
+ this.masterPan.pan.setValueAtTime(0, audioCtx.currentTime);
|
||||||
|
+ this.masterBypassGain = audioCtx.createGain();
|
||||||
|
+ this.masterBypassGain.gain.setValueAtTime(1, audioCtx.currentTime);
|
||||||
|
+ this.masterAnalyser = audioCtx.createAnalyser();
|
||||||
|
+ this.masterAnalyser.fftSize = 128;
|
||||||
|
+ this.masterGain.connect(this.masterPan);
|
||||||
|
+ this.masterPan.connect(this.masterBypassGain);
|
||||||
|
+ this.masterBypassGain.connect(this.masterAnalyser);
|
||||||
|
+ this.masterAnalyser.connect(audioCtx.destination);
|
||||||
|
this.masterGain.gain.setValueAtTime(1, audioCtx.currentTime);
|
||||||
|
this.masterPan = audioCtx.createStereoPanner();
|
||||||
|
this.masterPan.pan.setValueAtTime(0, audioCtx.currentTime);
|
||||||
|
diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db
|
||||||
|
index b1d2ec94d1a0c8be003360e1202979abb9d62c6c..0ffa5a826df5e7beabb1bee853e683414f554125 100644
|
||||||
|
GIT binary patch
|
||||||
|
delta 1068
|
||||||
|
zcmV+{1k?L~-~@o+1dtmIp8x;=6951J5dZ)H29YQ?0iUs85FY^vlW`wHKsjPDIWspl
|
||||||
|
zH)J(pH#0ObH8f*2V_{=rW@b1zGG;b2HaIb6GB{#mH8eM3FfleWFl1(AWH2#dGdX5v
|
||||||
|
zHDfX{FthU?5nu`f50U^6k`L|=z_Sq`gb$Ozj|n9-F*!6aI5;^iF*rG8Ei`0iG%YwW
|
||||||
|
zV=^sdVlZQ2VKp=~VK8L_v%rr;FtY{V{0|Hb3zPr?2gCpjEeFK0fw%`*hfM<sUsXSi
|
||||||
|
zH3<R-bY*RDUo<f}G%z?gIV~|bIb|(0WM(uiI5A@~Eo5RaV_{)6G&Er_Wi&B4G%z?g
|
||||||
|
zIV~|bIb|(0WM(uiI5A@~Eo5RaV_{)6G&Er_Ws_^sC?j-bZE#<3a&Ky7V{{@cB5h@K
|
||||||
|
zVPs)+VIn$vB6MkVY-J)kB9m*;8I#h{<`-0QVPk6`Ffk%5B6N9hWg<EvK~+RaPm{UF
|
||||||
|
zLN#u6Y-}tdX>N0La&>KGZggL8a&KpHVQnHhZgp&IEFx)cb98cbZDnqBUv6P-Wg<Fm
|
||||||
|
zb!=>tK*$v?Zgp&IEFyDnb#7#4Z*Fv7X=EZgZgp&IEFyDnb#7#4Z*Fv7VqtD;B06q$
|
||||||
|
zY;2Pd)D=2zb!==bB58DGZF3?zTU~uDdm?FMB03^6A}k_qVQpn1IwDkZVPk6`Fft-6
|
||||||
|
zB6N9hWg<EvK~+RaPm{UFLN#u6Y-}tdX>N0La&>KGZggL8a&KpHVQnHhZgp&IEFx)c
|
||||||
|
zb98cbZDnqBUv6P-Wg<Fmb!=>tK*$v?Zgp&IEFyDnb#7#4Z*Fv7X=EZgZgp&IEFyDn
|
||||||
|
zb#7#4Z*Fv7VqtD;B06q$Y;2Pd)D;eHb!==bB58DGZF3?zTa(bwCJqBsLE4&AiE|l4
|
||||||
|
z0001)k%5&7hfo3uUsXSiJF^?nodFkAa$#d@ATTi^EFyGyaAhJoB0*I|Nl%lx$3iu3
|
||||||
|
zb!==bB57`ObaHiVWo~p|aB^>Fa$#*EI&O7rY%C&aZgX^Ub!}yCbYE^^ZDk@lZgp&I
|
||||||
|
zlR(H7E^c*fY%C&kZ*^{DW^ZnEUuk3_I&O7rY%C&kZ*^{DW^ZnEUt(cyYa%*sb!=>t
|
||||||
|
z5Y!bqZgp&IEFx)iWo>gJI$K?REPEnpWFk5uG9oM@ZeeX@B03^ea$#d@ATTl_EFyGy
|
||||||
|
zaAhJoB0*I|Nl%lx$3iu3b!==bB57`ObaHiVWo~p|aB^>Fa$#*EI&O7rY%C&aZgX^U
|
||||||
|
zb!}yCbYE^^ZDk@lZgp&IlR(H7E^c*fY%C&kZ*^{DW^ZnEUuk3_I&O7rY%C&kZ*^{D
|
||||||
|
zW^ZnEUt(cyYa%*sb!=>t5Y!b8Zgp&IEFx)iWo>gJI$M*_&n5~3S3%mESWSxm%(DUD
|
||||||
|
m1|Svy1poj55C8xG6951J5&!@I4*&oF4YL8@1|YK_aNZB((q(7>
|
||||||
|
|
||||||
|
delta 7203
|
||||||
|
zcmV+;9Ngo8-~@o+1dtmIV*mgE6951J5dZ)H1(7H>0b{XX5FY^ulW`wHKw~*EVl+8n
|
||||||
|
zHaB53W@a~GF=jbAIW;&mVK!o9G-NP2GB;v5VPP{lV=yo@IbvfpI5uW7F=aVrGd3}1
|
||||||
|
zVl`uBF|+d@5nu=c55NEq?hnAT5iqn5v%rr;Fn?}eVr5};b0Ru0E;2PNB6ekLZ)0h6
|
||||||
|
zc_KP6E;TG7aA9sDIxu}Kdm?FMB03^&Z**l}a%Ew1a$hkwI5jdfH#IOYF*z|`F*PDA
|
||||||
|
zB5-MRV`w5eHa9FHb97;HbYEg+VRRxoIW9LfEFxrea$$67Z*E^=Wnpx4B04ZGGBqqB
|
||||||
|
zc7J7TZ)0h6c_KP6E;KA6aA9sDIxu}Kdm?FMB03^&Z**l}a%Ew1a$hkwI5jdfH#IOY
|
||||||
|
zF*z|`F*YJBB5-MRV`w5eH8(6Gb97;HbYEg+VRRxoF*YnBWOZ_3bZKvHUt(opbaNs)
|
||||||
|
zFfKAREFyMgY;R*}ba^5=FfKMMB5+}DB7ZtCeJpz-X=EZgB5rSVWnXe-VRCX`F*i6h
|
||||||
|
zGBh_eFfcJWF<&t^A}k_sX>?;~B04rOEFyDsVRCd|Vr5};B04cPE;2PNB4l-PVRUJ4
|
||||||
|
zZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-X=EZgB5rSVWq)6C
|
||||||
|
zWnpr1Uokg0H8M0eH83zSIWb=`I3g?}aA|a7Xd*f`G%O->bYXIIUt(opbRs%2HZC<R
|
||||||
|
zB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-X=EZg
|
||||||
|
zB5rSVWnXe-VRCX`F*i6hGBh_eFn=&HIWb=`IU+0~aA|a7Xd*f`IV>V`bYXIIUt(op
|
||||||
|
zbRs%2HZC_cEFxrea$$67Z*E^=Wnpx4B04ZGGBqqBc4cgDV`+4GB04ZGG%O-;VQwNi
|
||||||
|
zFnuh0B57nIIwEdwbY)+1Wnpr1Uokg0H8M0eH83zSIWb={Fd{4>aA|a7Xn!I)H8(6G
|
||||||
|
zb97;HbYEg+VRRxoF*htCWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}ba^5=FfKMM
|
||||||
|
zB5+}DB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jviG&eOcFflnXUotTwEFy4ebYo~D
|
||||||
|
zIyNvYB6D<Ma&%u}Wnpw8I)5=YE;2PNB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_
|
||||||
|
zX>@raIxsFZEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&w=
|
||||||
|
zA}k_sX>?;~B04rSEFyDsVRCd|Vr5};B04cQE;TG7WOZ_3bZKvHUw>j{VRUmMIxsFW
|
||||||
|
zH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6hGBh_e
|
||||||
|
zFfcJWF<&w>A}k_sX>?;~B04rXEFyDsVRCd|Vr5};B04cQE;ltSB4l-PVRUJ4ZeL<$
|
||||||
|
zVRUmMIxsFWH7p`_Wq)jMV`+4GB04ZGG%O-;VQwNiFnuh0B57nIIwEdwbY)+1Wnpr1
|
||||||
|
zUokg0H8M0eH83zSIWb={G$Je_aA|a7Xd*f_IV>V`bYXIIUt(opbRs%3G%O-yb#h^J
|
||||||
|
zX>V>{Vr5};b0Ru0E;2PNB6ekLZ)0h6c_KP6E;cM8aA9sDI)5;IEPEnpWFk5uZf|sD
|
||||||
|
zUvgz(a&liWH#jviG&eOcFflnXUotf!EFy4ebYo~DIyN#aB6D<Ma&%u}Wnpw8Ix;ja
|
||||||
|
zGBqqBWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}ba^5=FfKJLB5+}DB04aAEPEnp
|
||||||
|
zWFk5uZf|sDUw?9CVRCX`F*i6hGBh_eFfcJWF<&w^A}k_sX>?;~B04rUEFyDsVRCd|
|
||||||
|
zVr5};B04fOE;TG7WOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}ba^5=FfKJLB5+}D
|
||||||
|
zB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jviG&eOcFn=*QF<&w_A}k_sX>?;~B04uQ
|
||||||
|
zEFyDsVRCd|Vr5};B04fOE;ltSB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@ra
|
||||||
|
zIxsFYEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&w`A}k_s
|
||||||
|
zX>?;~B7ZtHIV>V`bYXIIUt(opbRs%3H7p`zb#h^JX>V>{Vr5};b0Ru0E;2PNB6ekL
|
||||||
|
zZ)0h6c_KP6E;cM8aA9sDIxu}Kdm?FMB03^&Z**l}a%Ew1a$hkwI5jdfH#IOYF*z|`
|
||||||
|
zGC3kFB5-MRV`w5eHZm+Cb97;HbYEg+VRRxoGJiELGBqqBWOZ_3bZKvHUt(opbaNs)
|
||||||
|
zFfKAREFyMgY;R*}ba^5=FfKJLB5+}DB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jvi
|
||||||
|
zG&eOcFflnXUo$WwEFy4ebYo~DIyN>eB6D<Ma&%u}Wnpw8Ix;mbH7p`zb#h^JX>V>{
|
||||||
|
zVt-{}baNs)FfKAREFyMgY;R*}ba^5=FfKJLB5+}DB04aAEPEnpWFk5uZf|sDUvgz(
|
||||||
|
za&liWH#jviG&eOcFflnXUo$ZxEFy4ebYo~DIyW&aB6D<Ma&%u}Wnpw8Ix;mbH#ICG
|
||||||
|
zWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY(#HkX>@raIxsFYEFy4WZX!A`eO-NhU41Nj
|
||||||
|
zB57nIIwCS6EFx}UZDk@lB0_Rub8{d~Z**y9A}k_wd2nSSIwDO;L`jo=*+LB>b7o&@
|
||||||
|
zY;1LNX>V>KlQ7;EA~85DB57`ObaHiVWo~p|ZeeX@B03^dd2V!QAVP9sb8{k-Vcr!!
|
||||||
|
zB6DwbZe(U}Zge6nB6DwbZe(U}ZggL1WFk5uXJu|>a$#(Bb7gX0XLBMfB6DwbZe(U}
|
||||||
|
zZggK_VQy<8Ixv$#+Z9qVI4mM_d2V!QUuAA*X>MgAI(s5?d2nSSIwEs#b#7#4Z*FuV
|
||||||
|
zEFy4hb!TaAUuk3_IwEssUukS?b#rNNZXzrqb8mHSWM*$}bYEg&ZfhbsFq1*s6@M`}
|
||||||
|
zEFyDnb#7#4Z*Fv7X=EZgB57=Fb#rNNZX$gwB58DGZF3?zTYDmDWFk5uX>?_6Utwuq
|
||||||
|
zF*i6hGBh_dHa0aiG+#1gZf<QNEFx}UZDk@lB1Ld%V<1m*V`yb_baG*AAW3d?WpZqF
|
||||||
|
zWMv>iVRCaIGBqtTG9WD=LULhqbAKRBZ**y9A}k_wd2nSSIwDO;L`h#sR7Fi9EFyDs
|
||||||
|
zVRCd|VqtP3Ixs9EWOZ_3bZKvHUt(c$b0Ru8EFxoUX>eb2bYXIIUvFk+b7gd2VqtP~
|
||||||
|
zB04ZEB6Dwba${v*WMOn+B075_bZ>NFY+qt^W@cq_Ut(c$b0Ru8EFx}ibbn=YB05`p
|
||||||
|
zB57nIIwEdwbY)**X<sonI5jdfH#9ajH8nI}Fd{4>aA|a7Xd*f`G%O->bYXIIUt(op
|
||||||
|
zbRs%3EFxrea$$67Z*E^=Wnpx4B04fGB6ekLZ)0h6c_KP6E;B45aA9sDIxu}Kdm?FM
|
||||||
|
zB03^&Z**l}VQF76H#jviG=DcVHa0aiG+!|yEFy4ebYo~DIyN^fB6D<Ma&%u}Wnpw8
|
||||||
|
zIyNjKWOZ_3bZKvHUt(opbaNs)GAtr?Wo&O_X>@raIxsFXEFy4WZX!A`eJpz-X=EZg
|
||||||
|
zB5rSVWnW=wUokg0H8M0eG&VLhH8fu`A}k_sX>?;~B04rSEFyDsVSjRTUt(opbRs%2
|
||||||
|
zI4mM$b#h^JX>V>{Vr5};b0Ru2EFyMgY;R*}ba^5=FfKDJB5+}DB04aAEPEnpWFk5u
|
||||||
|
zZf|sDUtwuqF*i6hGBh_dHa0aiG+#3!EFy4ebYo~DIyN^fB6D<Ma&%u}Wnpw8Ix;dW
|
||||||
|
zB4l-PVRUJ4ZeL<$VNG;%B04fGB6ekLZ)0h6c_KP6E;B45aA9sDIxu}*eSKYhEPEnp
|
||||||
|
zWFk5uGa@V^ZeeX@B03^eVQFh`AV_I+b0RDvba`-PB03^XNkmDLe%V3|B6DV6b7^j7
|
||||||
|
zZ*DU-B9k!Q7AY_+B57`ObaHiVWo~p|ZeeX@B03^Oa&>JWTT^slZZTaTEmK1{I8Y*!
|
||||||
|
zVcr!$B6DwbZe(U}Zge6nB6DwbZe(U}ZggL1WFk5uXJu|>a$#(Bb7gX0XLBMfB6Dwb
|
||||||
|
zZe(U}ZggK_VQy<8Ix#XhlR?`RQ!p$db9rubXkTS+XK8L_B075_ba`-PB03^-Z*^{D
|
||||||
|
zW^ZnEA}k_sY;|X8ZeM9+B03^-W?yq@Zf0+8Gd3bDB6DwbZe(U}ZggK_VQy<8Ix#Xh
|
||||||
|
zlR?`Re=sZ}b8mHSWM*$}bYE#?B03^-X>Mk3ZZkF_eJmnrbY*RGB05`pB57nIIwEOw
|
||||||
|
zWo=(!X<sonI5jdfH#9ajH8nI}WpieFY9cHmZeeX@B03^PaA{*8PjX{uWpi|LVQe5t
|
||||||
|
zZggdGY;|O1AVOhsb09J`Ei*D8Eg)23X=`sFe@JO`b0RDvba`-PB03^XNkmCsNmNBm
|
||||||
|
zA}k_vbYXIIUt(c$B04ZEB4l-PVRUJ4ZeL<ya&sa&I4mM#Y-w;`b97;HbYE{~W^-k9
|
||||||
|
zUt(c$b0Ru0EFyDnb#h~6Uu0o)VIn$vB6M$bVQgPwb!KK|a$jO$a&sa&I4mM=Z**mI
|
||||||
|
ze<C_tdm?FMB03^&Z**l}VQF76H#jviG&eLhHZ?UgUoavpB5-MRV`w5eGd3(Db97;H
|
||||||
|
zbYEg+VRRxoFf1Zub#h^JX>V>{Vr5};b0Ru1EFyMgY;R*}ba^5=FfKJLB5+}DB04aA
|
||||||
|
zEPEnpWFk5uZf|sDUtwuqF*i6hGBh_de>OHXH8fu_A}k_sX>?;~B04iREFyDsVRCd|
|
||||||
|
zVr5};B04xMB4l-PVRUJ4ZeL<$VRUmMIx#FFc4cgDV`+4GB04ZGH7p`<VQwNiFnuh0
|
||||||
|
zB57nIIwEdwbY)**X<sonI5jdfH#9ajH8nI}G9oM@aA|a7Xd*f@HY_4@bYXIIe_vu{
|
||||||
|
zVRRxoF*YnBWOZ_3bZKvHUt(opbaNs)F)Si>Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-
|
||||||
|
zX=EZgB5rSVWnW=wUokg0H8M0eG&VLhH8fu{A}k_sX>?;~B04iREFyDsVRCd|Vr5};
|
||||||
|
zB04fOEFxrea$$67Z*E^=Wnpx49wIt1EFyMgY;R*}ba^5=FfKJLB5+}DB04aAU44C%
|
||||||
|
z(BCE$9%@0_nl0}B-C_*@000325&!`bv4IEy3B6+i313w|`C+p*+?@d&Q*?4^ZfA2K
|
||||||
|
zOl4<bbZ;UoB6N9hWg<EvO-V#alYZGk4I*=9Uvp`0W^ZmYHX@TS-WDA+Ff1ZzZgX^U
|
||||||
|
zb!}yCbYE^^ZDk@lB35Z{Y-w&HlVRQ!J|c5(b#7#4Z*FuVEFyDnb#7#4Z*Fv7X=EZg
|
||||||
|
zB4=f8WpZI`b#rBMUuSb7EFyDnb#7#4Z*Fv7VqtD;B04aWLE9BlG%ze8b9rubXkTS+
|
||||||
|
zXK8L_B075_ba`-PB03^-Z*^{DW^ZnEA}k_sY;|X8ZeM9+B03^-W?yq@Zf0+8Gd3bD
|
||||||
|
zB6DwbZe(U}ZggK_VQy<8Ixv$#+ZBH_Ff1Z-Z*^{DW^ZnEUuk3_IwEsvZf0+8Gd3c9
|
||||||
|
zEFx)iWo>gJI$L`pX=EZgB58DGZC_z&Uokg0H8M0eG&VLhG%;UcHZ^HAA}k_qVQpn1
|
||||||
|
zIwD1IX=5Nya${&^b98cHY#>Q)bY*gEb!25ALSb@qATl*AGcq78AX9X5X>NaKb0AD*
|
||||||
|
zXJK@2A}k_wd2nSSIwDO;L`h#sR7Fi9EFyDsVRCd|VqtP3Ixs9EWOZ_3bZKvHUt(c$
|
||||||
|
zb0Ru8EFxoUX>eb2bYXIIUvFk+b7gd2VqtP~B04ZEB6Dwba${v*WMOn+B075_bZ>NF
|
||||||
|
zY+qt^W@cq_Ut(c$b0Ru8EFynyZ**mIB05`pB57nIIwEdwbY)**X<sonI5jdfH#9aj
|
||||||
|
zH8e3_Fd{4>aA|a7Xd*f_GAtr<bYXIIUt(opbRs%1EFxrea$$67Z*E^=Wnpx4B04xM
|
||||||
|
zB6ekLZ)0h6c_KP6E;KA6aA9sDIxu}Kdm?FMB03^&Z**l}VQF76H#mPaGBh_dHa0ah
|
||||||
|
zF<&tvEFy4ebYo~DIyE&cB6D<Ma&%u}Wnpw8IyfvMWOZ_3bZKvHUt(opbaNs)I4mM|
|
||||||
|
zWo&O_X>@raIxsFYEFy4WZX!A`eJpz-X=EZgB5rSVWnW=wUokg0H8M0eG&VLhG%;T?
|
||||||
|
zA}k_sX>?;~B04oUEFyn%bYXIIUt(opbRs%2HY_4!b#h^JX>V>{Vr5};b0Ru8EFyMg
|
||||||
|
zY;R*}ba^5=FfKGKB5+}DB04aAEPEnpWFk5uZf|sDUtwuqF*i6hGBh_dHa0ahF<&zx
|
||||||
|
zEFy4ebYo~DIyE^gB6D<Ma&%u}Wnpw8Ix;jYB4l-PVRUJ4ZeM?5Wnpx4B04xMB6ekL
|
||||||
|
zZ)0h6c_KP6E;KA6aA9sDIxu}*eSKYhEPEnpWFk5uG$Je_ZeeX@B03^lK}jG|Wnpr1
|
||||||
|
zVQyz-T_8bnaAjv_X>TA_VRC6<bZKvHAa7<MMQ~|jAX9X5X>Mn8A}k_wd2nSSIwDO;
|
||||||
|
zL`fnnB6e?Vb#0S|**Y5{b7o(2bY*F7cVT&7V{dL_WpZ?1aA{#~Zz7X0-WDA&EFx)c
|
||||||
|
zb98cbZDnqBUv6P-Wg<EvP-$UqZy+%ulVRQ!Mj~@>b#7#4Z*FuVEFyDnb#7#4Z*Fv7
|
||||||
|
zX=EZgB6D<QX>NC6d0%61ZewL~bYF03VQz0CEFyDnb#7#4Z*Fv7VqtD;B04aWLE9By
|
||||||
|
zFf1Z-d2V!QUuAA*X>MgAI(s5?d2nSSIwEs#b#7#4Z*FuVEFy4hb!TaAUuk3_IwEss
|
||||||
|
zUvqS2X>NC6d0%61ZewL~bYF03VQz0CEFyDnb#7#4Z*Fv7VqtD;B04aWLE9C7Ff1Z-
|
||||||
|
zZ*^{DW^ZnEUuk3_IwEs)Wod4AVR>I;Z*F5{a&%vCX<=?}B7H0(X>?_6b0Ruhdm?FM
|
||||||
|
zB03^zbY*Q{a%Ew1a$hkwI5jdfH#IOYF*z|`WNB+|WFjmgZeeX@B03^lK}lU8L2__q
|
||||||
|
zXJ=_|AXZ^=X<>9}Z*CxOW*|j>aA{*8Q*?4^ZfA2KEg*Jbaw04uba`-PB03^XNkmCs
|
||||||
|
zNmNBmA}k_vbYXIIUt(c$B04ZEB4l-PVRUJ4ZeL<ya&sa&H!e9iHa0ggFf=eVF*rFl
|
||||||
|
zGBqqBV{B<~UvqR}a&%vBW@d9`bYEg&a&sa&Ff1Z-Z*_8GWnW}rbYUWYI(s5?Z**a7
|
||||||
|
zUt)D;W@U0;VqtP~B04uNIXE^pH!(0YFf}naIX5yjEFx}ibY*iQI$L`pX=EZgB5rSV
|
||||||
|
zWnXe-VRCX`F*i6hGBh_eFfcJWF<&quEFy4ebYo~DIyEvZB6D<Ma&%u}Wnpw8Ixs9E
|
||||||
|
zWOZ_3bZKvHUt(opbaNtqIxsFWH7p`_Wo&O_X>@raIxsFaEFy4WZX!A`eJpz-X=EZg
|
||||||
|
zB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&tvEFy4ebYo~DIyE&cB6D<Ma&%u}Wnpw8
|
||||||
|
zIxsFWH7p`zb#h^JX>V>{Vr5};b0Ru0E;2PNB6ekLZ)0h6c_KQ0FfKJLB5+}DB04aA
|
||||||
|
zEPEnpWFk5uZf|sDUvgz(a&liWH#jviG&eOcFflnXUos*rB5-MRV`w5eH90IIb97;H
|
||||||
|
zbYEg+VRRxoFfKJLB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFZEFy4W
|
||||||
|
zZX!A`eJpz-X=EaQIwEdwbY)+1Wnpr1Uokg0H8M0eH83zSIWb=|A}k_sX>?;~B04rS
|
||||||
|
zEFyDsVRCd|Vr5};B04ZGH#ICGWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}ba^5=
|
||||||
|
zFfKGKB5+}DB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jwaGBh_eFfcJWF<&$yEFy4e
|
||||||
|
zbYo~DIyEvZB6D<Ma&%u}Wnpw8Ix#FFWOZ_3bZKvHUt(opbaNs)FfKAREFyMgY;R*}
|
||||||
|
zba^5=FfKMMB5+}DB04aAEPEnpWFk5uZf|sDUvgz(a&liWH#jviG&eOcFflnXUo|2u
|
||||||
|
zB5-MRV`w6OIyE&cB6D<Ma&%u}Wnpw8Ix#LXH7p`zb#h^JX>V>{Vr5};b0Ru0E;2PN
|
||||||
|
zB6ekLZ)0h6c_KP6E;TG7aA9sDIxu}Kdm?FMB03^&Z**l}a%Ew1a$hkwI5jdfH#IOY
|
||||||
|
zF*z|`HX<w{aA|a7Xd*f_IV>V`bYXIIUt(opbRs%`F)lSMB4l-PVRUJ4ZeL<$VRUmM
|
||||||
|
zIxsFWH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6h
|
||||||
|
zGBh_eFfcJWF<&<#EFy4ebYo~DIyN*cB6D<Ma&%u}Wnpw8Ix#LcH7p`zb#h^JX>V>{
|
||||||
|
zVr5}}baNs)FfKAREFyMgY;R*}ba^5=FfKGKB5+}DB04aAEPEnpWFk5uZf|sDUvgz(
|
||||||
|
za&liWH#jviG&eOcFflnXUpOKxB5-MRV`w5eH8m_Eb97;HbYEg+VRRxoI4mM$b#h^J
|
||||||
|
zX>V>{Vr5};b0Ru0E;2PNB6ekLZ)0h6c_KQ0FfKMMB5+}DB04aAEPEnpWFk5uZf|sD
|
||||||
|
zUvgz(a&liWH#jviG&eOcFflnXUpXQyB5-MRV`w5eH90IIb97;HbYEg+VRRxoI4&|Z
|
||||||
|
zEFxrea$$67Z*E^=Wnpx4B04ZGGBqqBc4cgDV`+4GB04ZGH7p`<VQwNiFnuh0B57oQ
|
||||||
|
zB03^&Z**l}a%Ew1a$hkwI5jdfH#IOYF*z|`F)$)5B5-MRV`w5eHZm+Cb97;HbYEg+
|
||||||
|
zVRRxoI4(6TB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFZEFy4WZX!A`
|
||||||
|
zeJpz-X=EZgB5rSVWnXe-VRCX`F*i7WH8M0eH83zSIWb=`F(ND?aA|a7Xd*f`H!LD^
|
||||||
|
zbYXIIUt(opbRs%9E;ltSB4l-PVRUJ4ZeL<$VRUmMIxsFWH7p`_Wo&O_X>@raIxsFY
|
||||||
|
zEFy4WZX!A`eJpz-X=EZgB5rSVWnXe-VRCX`F*i6hGBh_eFfcJWF<&t<A}k_*aA|a7
|
||||||
|
zXd*f_H7p`?bYXIIUt(opbRs%AEFxrea$$67Z*E^=Wnpx4B04ZGGBqqBc4cgDV`+4G
|
||||||
|
zB04ZGHY_4=VQwNiFnuh0B57nIIwEdwbY)+1Wnpr1Uokg0H8M0eH83zSIWb=`Ga@V^
|
||||||
|
zaA|a7Xd*f_IV>V`bYXIIUt(o{VRRxoIW96aEFxrea$$67Z*E^=Wnpx4B04ZGGBqqB
|
||||||
|
zc4cgDV`+4GB04ZGH7p`<VQwNiFnuh0B57nIIwEdwbY)+1Wnpr1Uokg0H8M0eH83zS
|
||||||
|
zIWb=`G$Je_aA|a7Xd*f`GAtr<bYXIIUt(opbRs%AE;TG7WOZ_3bZKt{0000DvjN}+
|
||||||
|
lAQk`w0000G0000J0000I0000F0000FvjN}+AhRHF-Vfh6-aP;S
|
||||||
|
|
||||||
|
diff --git a/app/templates/index.html b/app/templates/index.html
|
||||||
|
index 1207348..5fa01fe 100644
|
||||||
|
--- a/app/templates/index.html
|
||||||
|
+++ b/app/templates/index.html
|
||||||
|
@@ -23,7 +23,7 @@
|
||||||
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
|
<script src="/static/js/services/audioMixerGraphManager.js?v=202607282218"></script>
|
||||||
|
- <script src="/static/js/app.precompiled.js?v=202607282359" defer></script>
|
||||||
|
+ <script src="/static/js/app.precompiled.js?v=20260729085936" defer></script>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--
|
||||||
|
2.47.3
|
||||||
|
|
||||||
|
diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
|
||||||
|
index c53adde..8189c12 100644
|
||||||
|
--- a/app/static/js/app.jsx
|
||||||
|
+++ b/app/static/js/app.jsx
|
||||||
|
@@ -6025,7 +6025,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||||
|
keybedMouseDownRef.current = true;
|
||||||
|
try {
|
||||||
|
if (window.SonicSF) {
|
||||||
|
- window.SonicSF.playNote(pitch, 100, 500, undefined, st.instrumentProgram, null, kbCh, kbSynth);
|
||||||
|
+ window.SonicSF.playNote(pitch, 100, 60000, undefined, st.instrumentProgram, null, kbCh, kbSynth);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('playNote error:', err);
|
||||||
|
@@ -6035,12 +6035,17 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||||
|
if (keybedMouseDownRef.current) {
|
||||||
|
try {
|
||||||
|
if (window.SonicSF) {
|
||||||
|
- window.SonicSF.playNote(pitch, 100, 200, undefined, st.instrumentProgram, null, kbCh, kbSynth);
|
||||||
|
+ window.SonicSF.playNote(pitch, 100, 60000, undefined, st.instrumentProgram, null, kbCh, kbSynth);
|
||||||
|
}
|
||||||
|
} catch (err) { console.error('playNote error:', err); }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
- onMouseUp: () => { keybedMouseDownRef.current = false; }
|
||||||
|
+ onMouseUp: () => {
|
||||||
|
+ keybedMouseDownRef.current = false;
|
||||||
|
+ if (window.SonicSF && window.SonicSF.stopNote) {
|
||||||
|
+ window.SonicSF.stopNote(kbCh, pitch);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}, showLabel && label)
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+533
-1
@@ -8,7 +8,42 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/cli": "^8.0.4",
|
"@babel/cli": "^8.0.4",
|
||||||
"@babel/core": "^8.0.1",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/preset-react": "^8.0.1"
|
"@babel/preset-react": "^8.0.1",
|
||||||
|
"@babel/standalone": "^7.29.8",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "6.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||||
|
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/css-calc": "^3.2.1",
|
||||||
|
"@csstools/css-color-parser": "^4.1.9",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/cli": {
|
"node_modules/@babel/cli": {
|
||||||
@@ -321,6 +356,15 @@
|
|||||||
"@babel/core": "^8.0.0"
|
"@babel/core": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/standalone": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-XgbPNz+u6JzB7cKGnPDoS1U24J5td8yp3HFWbT/6f4/ASZ0PqaQGIvts1o5v5AI+f6i3jdVB/L/o2CYLCv101A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
||||||
@@ -366,6 +410,169 @@
|
|||||||
"node": "^22.18.0 || >=24.11.0"
|
"node": "^22.18.0 || >=24.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.1.0",
|
||||||
|
"@csstools/css-calc": "^3.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||||
|
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -434,6 +641,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
@@ -529,6 +745,52 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.393",
|
"version": "1.5.393",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
|
||||||
@@ -544,6 +806,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -579,6 +853,18 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-meta-resolve": {
|
"node_modules/import-meta-resolve": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||||
@@ -589,12 +875,58 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "30.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||||
|
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^6.0.5",
|
||||||
|
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||||
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.5.2",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.2",
|
||||||
|
"undici": "^8.9.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^17.1.0",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.2.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -628,6 +960,12 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
@@ -674,6 +1012,18 @@
|
|||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
@@ -696,6 +1046,36 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@@ -709,6 +1089,33 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
@@ -733,6 +1140,72 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
|
||||||
|
"integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.9"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
|
||||||
|
"integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -762,6 +1235,65 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "17.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||||
|
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.15.1",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.14.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -7,6 +7,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/cli": "^8.0.4",
|
"@babel/cli": "^8.0.4",
|
||||||
"@babel/core": "^8.0.1",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/preset-react": "^8.0.1"
|
"@babel/preset-react": "^8.0.1",
|
||||||
|
"@babel/standalone": "^7.29.8",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ soundfile>=0.12.1
|
|||||||
jinja2>=3.1.2
|
jinja2>=3.1.2
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
jsonschema>=4.18.0
|
jsonschema>=4.18.0
|
||||||
pedalboard>=0.8.0
|
pedalboard==0.9.19
|
||||||
mido>=1.3.0
|
mido>=1.3.0
|
||||||
pyfluidsynth>=1.3.0
|
pyfluidsynth>=1.3.0
|
||||||
sf2utils>=0.9.0
|
sf2utils>=0.9.0
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user