Compare commits
115 Commits
ae71acef0f
...
845d1594bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,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.
|
||||
+30
-1
@@ -20,6 +20,12 @@ class UpdateUserRoleRequest(BaseModel):
|
||||
role: str # 'admin', 'standard', 'premium'
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
role: Optional[str] = 'standard'
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
@@ -52,6 +58,29 @@ async def list_users(admin: dict = Depends(require_admin)):
|
||||
})
|
||||
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")
|
||||
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
@@ -81,7 +110,7 @@ async def delete_user(user_id: str, admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
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,))
|
||||
conn.commit()
|
||||
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"}
|
||||
+119
-6
@@ -2,9 +2,9 @@ import time
|
||||
import json
|
||||
import uuid
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict
|
||||
from typing import Optional, Any, Dict, List
|
||||
from jsonschema import validate, ValidationError
|
||||
from app.models.user import get_db_connection
|
||||
from app.api.v1.auth import get_current_user, decode_token
|
||||
@@ -38,6 +38,7 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {
|
||||
"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,
|
||||
"channels": 2,
|
||||
"gain": 1.0
|
||||
@@ -228,9 +229,11 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, name, size_bytes, updated_at FROM projects
|
||||
WHERE user_id = ? AND is_temp = 0
|
||||
ORDER BY updated_at DESC
|
||||
SELECT p.id, p.name, p.size_bytes, p.updated_at,
|
||||
(SELECT COUNT(*) FROM project_backups pb WHERE pb.project_id = p.id AND pb.user_id = p.user_id) as backup_count
|
||||
FROM projects p
|
||||
WHERE p.user_id = ? AND p.is_temp = 0
|
||||
ORDER BY p.updated_at DESC
|
||||
""", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
@@ -240,7 +243,8 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"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
|
||||
]
|
||||
|
||||
@@ -298,6 +302,115 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
||||
conn.close()
|
||||
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):
|
||||
sample_rate: Optional[int] = 44100
|
||||
|
||||
|
||||
+27
-18
@@ -5,25 +5,40 @@ import time
|
||||
import base64
|
||||
import uuid
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional, Dict, Any
|
||||
from app.models.user import get_db_connection
|
||||
from app.config import settings
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||
"""
|
||||
Hash password using PBKDF2 HMAC SHA-256 with salt.
|
||||
Hash password using PBKDF2 HMAC SHA-256 with a per-user random salt.
|
||||
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_"
|
||||
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
||||
return key.hex()
|
||||
if salt is None:
|
||||
salt = secrets.token_hex(32)
|
||||
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:
|
||||
"""Verify plain password against PBKDF2 hashed password using constant-time comparison."""
|
||||
computed_hash = hash_password(plain_password)
|
||||
return hmac.compare_digest(computed_hash, hashed_password)
|
||||
def verify_password(plain_password: str, stored_value: str) -> bool:
|
||||
"""Verify plain password against stored 'salt:hash' using constant-time comparison.
|
||||
Also supports legacy-format hash (hex only, without salt) for backward compatibility."""
|
||||
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:
|
||||
payload = {
|
||||
@@ -56,7 +71,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
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()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -64,9 +79,8 @@ def seed_admin():
|
||||
hashed_pwd = hash_password(default_pwd)
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("SELECT id, hashed_password, must_change_password FROM users WHERE username = ?", ("admin",))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",))
|
||||
if not cursor.fetchone():
|
||||
admin_id = str(uuid.uuid4())
|
||||
cursor.execute("""
|
||||
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
@@ -78,11 +92,6 @@ def seed_admin():
|
||||
VALUES (?, 10240, 64)
|
||||
""", (admin_id,))
|
||||
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()
|
||||
|
||||
|
||||
+11
@@ -12,6 +12,7 @@ from app.api.v1.admin import router as admin_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.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.core.auth import seed_admin
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
@@ -50,6 +51,7 @@ 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(user_config_router, prefix="/api/v1/user", tags=["user_config"])
|
||||
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@@ -87,3 +89,12 @@ async def get_favicon():
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||
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)
|
||||
|
||||
+25
-1
@@ -65,8 +65,32 @@ 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
|
||||
);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# Tự động khởi tạo DB khi module được import
|
||||
init_db()
|
||||
|
||||
+4429
-690
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+185
-108
File diff suppressed because one or more lines are too long
@@ -54,10 +54,10 @@ const AIGateway = (function() {
|
||||
properties: {
|
||||
composition_title: { type: 'string', description: 'Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)' },
|
||||
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: {
|
||||
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: {
|
||||
type: 'object',
|
||||
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' },
|
||||
notes: {
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -88,6 +89,146 @@ 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']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; }
|
||||
}
|
||||
@@ -202,6 +343,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.
|
||||
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
|
||||
${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:
|
||||
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ề.
|
||||
@@ -303,6 +452,10 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
||||
|
||||
return {
|
||||
DEFAULT_TOOLS,
|
||||
REARRANGE_TOOL_SPEC,
|
||||
REARRANGE_SCENARIOS,
|
||||
detectRearrangeScenario,
|
||||
buildRearrangeMessage,
|
||||
callLLM,
|
||||
extractFunctionCalls,
|
||||
buildUserMessage,
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 }) }),
|
||||
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' }),
|
||||
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 }) }),
|
||||
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
||||
@@ -66,6 +67,15 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
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 }) }),
|
||||
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
||||
uploadSoundFont: async (file) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ const DAWCommandDispatcher = (function() {
|
||||
register('MODIFY_MIDI_NOTES', (args) => api.modifyMidiNotes(args));
|
||||
register('PROCESS_AI_DSP', (args) => api.processAudioDsp(args));
|
||||
register('GENERATE_MULTITRACK_MIDI', (args) => api.generateMultitrackMidi(args));
|
||||
register('REARRANGE_MIDI_MELODY', (args) => api.rearrangeMidiMelody(args));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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();
|
||||
@@ -25,12 +25,20 @@
|
||||
let _scheduledNotes = [];
|
||||
|
||||
const getCtx = function () {
|
||||
if (_audioCtx) {
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
}
|
||||
return _audioCtx;
|
||||
}
|
||||
if (typeof getAudioContext === 'function') {
|
||||
var ctx = getAudioContext();
|
||||
if (!_gainNode) {
|
||||
_gainNode = ctx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(ctx.destination);
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -67,6 +75,12 @@
|
||||
try {
|
||||
_audioCtx = audioContext;
|
||||
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;
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
}
|
||||
|
||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||
|
||||
@@ -119,8 +133,8 @@
|
||||
if (!_useScriptNode) {
|
||||
try {
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
|
||||
_workletNode.connect(_audioCtx.destination);
|
||||
console.log("[SonicSF] AudioWorklet node connected");
|
||||
_workletNode.connect(_gainNode);
|
||||
console.log("[SonicSF] AudioWorklet node connected via gain");
|
||||
_startRenderLoop();
|
||||
} catch (e) {
|
||||
console.warn("[SonicSF] AudioWorkletNode failed:", e);
|
||||
@@ -146,9 +160,9 @@
|
||||
}
|
||||
} catch (er) {}
|
||||
};
|
||||
spn.connect(_audioCtx.destination);
|
||||
spn.connect(_gainNode);
|
||||
_workletNode = spn;
|
||||
console.log("[SonicSF] ScriptProcessorNode fallback active (buf:", spBufSz, ")");
|
||||
console.log("[SonicSF] ScriptProcessorNode connected via gain (buf:", spBufSz, ")");
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
@@ -221,10 +235,9 @@
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
selectInstrument: async function (channel, bank, program, sfId) {
|
||||
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);
|
||||
}
|
||||
if (sfId) {
|
||||
@@ -255,6 +268,7 @@
|
||||
_channels[channel].bank = bank;
|
||||
_channels[channel].program = program;
|
||||
_channels[channel].isPercussion = (bank === 128);
|
||||
_channels[channel].sfId = sfId;
|
||||
},
|
||||
|
||||
controllerChange: function (channel, controller, value) {
|
||||
@@ -350,7 +364,7 @@
|
||||
if (_initialized && _fluidModule) return;
|
||||
if (_initPromise) return;
|
||||
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();
|
||||
await this.init(ctx);
|
||||
} catch (e) {}
|
||||
@@ -393,22 +407,28 @@
|
||||
var self = this;
|
||||
var doNote = function () {
|
||||
try {
|
||||
var finalBank = usedBank;
|
||||
var finalProg = usedProg;
|
||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||
if (_channels[ch] && _channels[ch].program !== undefined) {
|
||||
finalBank = _channels[ch].bank;
|
||||
finalProg = _channels[ch].program;
|
||||
if (_channels[ch].sfId !== undefined) {
|
||||
finalSfId = _channels[ch].sfId;
|
||||
}
|
||||
}
|
||||
// Program change at note time, not call time — ensures correct
|
||||
// instrument for each item regardless of processing order.
|
||||
if (synthEngine) {
|
||||
var sfHandle = synthEngine.soundfont_id ? _sfHandleMap.get(synthEngine.soundfont_id) : undefined;
|
||||
if (synthEngine || program !== undefined) {
|
||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||
if (sfHandle !== undefined) {
|
||||
try {
|
||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, usedBank, usedProg);
|
||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||
} catch (e) {}
|
||||
} else {
|
||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, usedBank); } catch (e) {}
|
||||
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg); } catch (e) {}
|
||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } catch (e) {}
|
||||
}
|
||||
} else if (program !== undefined) {
|
||||
try {
|
||||
_fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg);
|
||||
} catch (e) {}
|
||||
}
|
||||
console.log("[SonicSF] noteOn ch:", ch, "pitch:", midiPitch, "vel:", midiVel);
|
||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
solo: t.solo,
|
||||
color: t.color,
|
||||
markers: t.markers || [],
|
||||
serverFileId: t.serverFileId || null
|
||||
serverFileId: t.serverFileId || null,
|
||||
clips: t.clips || [],
|
||||
sections: t.sections || [],
|
||||
midiItems: t.midiItems || [],
|
||||
channelInfo: t.channelInfo || null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Binary file not shown.
+142
-1
@@ -20,7 +20,10 @@
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||
<script src="/static/js/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=202607302132" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
@@ -239,6 +242,144 @@
|
||||
svg, i[data-lucide] {
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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,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
+523
-1
@@ -8,7 +8,41 @@
|
||||
"dependencies": {
|
||||
"@babel/cli": "^8.0.4",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/preset-react": "^8.0.1"
|
||||
"@babel/preset-react": "^8.0.1",
|
||||
"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": {
|
||||
@@ -366,6 +400,169 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -434,6 +631,15 @@
|
||||
"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": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
@@ -529,6 +735,52 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"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": {
|
||||
"version": "1.5.393",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
|
||||
@@ -544,6 +796,18 @@
|
||||
"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": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -579,6 +843,18 @@
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
@@ -589,12 +865,58 @@
|
||||
"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": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -628,6 +950,12 @@
|
||||
"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": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -674,6 +1002,18 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
@@ -696,6 +1036,36 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
@@ -709,6 +1079,33 @@
|
||||
"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": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
@@ -733,6 +1130,72 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -762,6 +1225,65 @@
|
||||
"peerDependencies": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -7,6 +7,9 @@
|
||||
"dependencies": {
|
||||
"@babel/cli": "^8.0.4",
|
||||
"@babel/core": "^8.0.1",
|
||||
"@babel/preset-react": "^8.0.1"
|
||||
"@babel/preset-react": "^8.0.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
diff --git a/app/static/js/services/soundfontPlayer.js b/app/static/js/services/soundfontPlayer.js
|
||||
index bb0a556..e85c936 100644
|
||||
--- a/app/static/js/services/soundfontPlayer.js
|
||||
+++ b/app/static/js/services/soundfontPlayer.js
|
||||
@@ -130,6 +130,7 @@
|
||||
if (_useScriptNode) {
|
||||
var spBufSz = 2048;
|
||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||
+ getCtx();
|
||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||
spn.onaudioprocess = function (e) {
|
||||
@@ -151,6 +152,11 @@
|
||||
console.log("[SonicSF] ScriptProcessorNode fallback active (buf:", spBufSz, ")");
|
||||
}
|
||||
|
||||
+ // Re-route through mixer graph if it exists (created before SonicSF was ready)
|
||||
+ if (window.__mixerGraphManager && typeof SonicSF.setOutputNode === 'function') {
|
||||
+ SonicSF.setOutputNode(window.__mixerGraphManager.masterGain);
|
||||
+ }
|
||||
+
|
||||
_initialized = true;
|
||||
console.log("[SonicSF] FluidSynth WASM Engine initialized.");
|
||||
} catch (e) {
|
||||
@@ -0,0 +1,145 @@
|
||||
import os, json, time
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
from app.core.auth import create_token
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
TEST_DATA_FILE = os.path.join(settings.PROCESSED_DIR, "ai_presets.json")
|
||||
if os.path.exists(TEST_DATA_FILE):
|
||||
os.remove(TEST_DATA_FILE)
|
||||
|
||||
|
||||
def _auth_header():
|
||||
token = create_token("test_user_presets", "testuser", "standard", False)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_list_presets_defaults():
|
||||
res = client.get("/api/v1/ai/presets")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["presets"]) == 7
|
||||
names = [p["name"] for p in data["presets"]]
|
||||
assert "Epic Orchestra Intro (8 Bars)" in names
|
||||
assert "Pop Piano Chords (4 Bars)" in names
|
||||
assert "Cyberpunk Synthwave (8 Bars)" in names
|
||||
assert "Jazz Swing Variation" in names
|
||||
assert "80s Synthwave Arpeggiator" in names
|
||||
|
||||
|
||||
def test_create_user_preset():
|
||||
preset = {
|
||||
"id": "preset_test_my_beat",
|
||||
"name": "My Beat (4 Bars)",
|
||||
"keywords": ["my beat", "custom beat", "beat của tôi"],
|
||||
"category": "Hip Hop / Beat",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 100,
|
||||
"default_scale": "A Minor",
|
||||
"system_instruction_template": "Generate a 4-bar hip hop beat with kick, snare, hi-hat.",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False,
|
||||
"created_at": "2026-07-28T00:00:00Z"
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset, headers=_auth_header())
|
||||
assert res.status_code == 200, res.text
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
assert data["preset"]["id"] == "preset_test_my_beat"
|
||||
|
||||
# Verify it appears in listing
|
||||
res2 = client.get("/api/v1/ai/presets", headers=_auth_header())
|
||||
assert res2.status_code == 200
|
||||
names = [p["name"] for p in res2.json()["presets"]]
|
||||
assert "My Beat (4 Bars)" in names
|
||||
assert len(res2.json()["presets"]) == 8 # 7 defaults + 1 user
|
||||
|
||||
|
||||
def test_update_user_preset():
|
||||
preset = {
|
||||
"id": "preset_test_my_beat",
|
||||
"name": "My Beat Updated (8 Bars)",
|
||||
"keywords": ["my beat", "custom beat"],
|
||||
"category": "Hip Hop / Beat",
|
||||
"default_bars": 8,
|
||||
"default_bpm": 110,
|
||||
"default_scale": "C Minor",
|
||||
"system_instruction_template": "Generate an 8-bar hip hop beat.",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": True,
|
||||
"created_at": "2026-07-28T00:00:00Z"
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset, headers=_auth_header())
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["preset"]["name"] == "My Beat Updated (8 Bars)"
|
||||
assert data["preset"]["is_favorite"] is True
|
||||
assert data["preset"]["default_bars"] == 8
|
||||
|
||||
|
||||
def test_delete_user_preset():
|
||||
res = client.delete("/api/v1/ai/presets/preset_test_my_beat", headers=_auth_header())
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
|
||||
res2 = client.get("/api/v1/ai/presets", headers=_auth_header())
|
||||
assert res2.status_code == 200
|
||||
names = [p["name"] for p in res2.json()["presets"]]
|
||||
assert "My Beat Updated (8 Bars)" not in names
|
||||
|
||||
|
||||
def test_delete_nonexistent_preset():
|
||||
res = client.delete("/api/v1/ai/presets/nonexistent_id", headers=_auth_header())
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_presets_anonymous_cannot_save():
|
||||
preset = {
|
||||
"id": "preset_anon",
|
||||
"name": "Anon Preset",
|
||||
"keywords": ["anon"],
|
||||
"category": "General",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 120,
|
||||
"default_scale": "C Major",
|
||||
"system_instruction_template": "Test",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset)
|
||||
assert res.status_code == 401
|
||||
|
||||
res2 = client.delete("/api/v1/ai/presets/preset_anon")
|
||||
assert res2.status_code == 401
|
||||
|
||||
|
||||
def test_presets_isolation_between_users():
|
||||
token_a = create_token("user_a", "usera", "standard", False)
|
||||
token_b = create_token("user_b", "userb", "standard", False)
|
||||
|
||||
preset_a = {
|
||||
"id": "preset_user_a_only",
|
||||
"name": "User A Preset",
|
||||
"keywords": ["user a"],
|
||||
"category": "General",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 120,
|
||||
"default_scale": "C Major",
|
||||
"system_instruction_template": "User A specific",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset_a, headers={"Authorization": f"Bearer {token_a}"})
|
||||
assert res.status_code == 200
|
||||
|
||||
# User B shouldn't see User A's presets
|
||||
res_b = client.get("/api/v1/ai/presets", headers={"Authorization": f"Bearer {token_b}"})
|
||||
assert res_b.status_code == 200
|
||||
names_b = [p["name"] for p in res_b.json()["presets"]]
|
||||
assert "User A Preset" not in names_b
|
||||
assert "Epic Orchestra Intro (8 Bars)" in names_b
|
||||
@@ -1,5 +1,9 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
import os
|
||||
import app.api.v1.user_config as user_config
|
||||
user_config.DATA_FILE = os.path.join(os.path.dirname(__file__), "user_configs_test.json")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"ai_configs": {
|
||||
"anonymous": [
|
||||
{
|
||||
"id": "openai_default",
|
||||
"name": "OpenAI Official",
|
||||
"provider_type": "openai",
|
||||
"api_base_url": "https://api.openai.com/v1",
|
||||
"api_key": "test-sk-key-123",
|
||||
"model_name": "gpt-4o",
|
||||
"temperature": 0.7,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": "openai_compat_default",
|
||||
"name": "OpenAI Compatible (Ollama/LocalAI/DeepSeek)",
|
||||
"provider_type": "openai_compatible",
|
||||
"api_base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama",
|
||||
"model_name": "deepseek-r1",
|
||||
"temperature": 0.7,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"id": "anthropic_default",
|
||||
"name": "Anthropic Claude",
|
||||
"provider_type": "anthropic",
|
||||
"api_base_url": "https://api.anthropic.com/v1",
|
||||
"api_key": "",
|
||||
"model_name": "claude-3-5-sonnet",
|
||||
"temperature": 0.7,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"id": "gemini_default",
|
||||
"name": "Google Gemini",
|
||||
"provider_type": "gemini",
|
||||
"api_base_url": "https://generativelanguage.googleapis.com",
|
||||
"api_key": "",
|
||||
"model_name": "gemini-1.5-pro",
|
||||
"temperature": 0.7,
|
||||
"is_active": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"preferences": {}
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
### [2026-07-31 07:03] Task: Fix Lucide icons not rendering on new tracks (TCP + SECTION-TAB)
|
||||
- **Tóm tắt thay đổi:** `useEffect` gọi `lucide.createIcons()` thiếu `activeTracks` trong dependency array → khi track được tạo (user/AI/MIDI import/clone section), DOM nodes mới có `data-lucide` nhưng không được chuyển thành SVG → icon ẩn hoặc hiển thị sai. Fix: thêm `activeTracks` vào deps để auto-refresh icons sau mỗi lần track list thay đổi.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** Không cần build. Fix áp dụng cho tất cả đường dẫn tạo track: addNewTrack, insertTrackBelow, AI cut/composition/rearrange, MIDI import, section clone.
|
||||
---
|
||||
|
||||
### [2026-07-31 07:09] Task: Allow multiple simultaneous solo buttons (Track TCP)
|
||||
- **Tóm tắt thay đổi:** `toggleTrackSoloEvaluate` trước đây set `solo: false` cho tất cả track trừ track được click → chỉ cho phép 1 solo. Fix: toggle solo chỉ trên track được click, giữ nguyên trạng thái solo của track khác. `soloedTrackId` (single track ID) được thay bằng `hasAnySolo` (boolean). `startTrackPlayback` dùng `track.solo` trực tiếp thay vì so sánh với `soloedTrackId`. Loop restart với solo: iterate tất cả soloed tracks thay vì chỉ play 1 track. UI highlight các nút solo dùng `track.solo`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** Bấm solo track A → active, bấm solo track B → cả A và B đều solo. Audio playback chỉ play track có solo = true.
|
||||
---
|
||||
|
||||
### [2026-07-28 07:56] Task: Fix PIANO_ROLL activeTracks + instrument auto-assign trong SECTION-TAB
|
||||
- **Tóm tắt thay đổi:** Fix `activeTracks` useMemo: khi `activeTab` là PIANO_ROLL sub-tab từ SECTION-TAB (`parent_tab_id` bắt đầu `session_`), trả về tracks của section tab thay vì main tracks. Trước đây `activeTracks` luôn trả về main tracks khi activeTab !== session_xxx → dropdown MIDI item rỗng, track name không hiển thị, instrument lookup sai. Fix: check `subTabs` cho PIANO_ROLL type để resolve đúng parent session tracks.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
@@ -539,6 +551,36 @@
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Ctrl+Click+Drag section/MIDI item → copy đến vị trí mới, item không bị selected.
|
||||
---
|
||||
|
||||
### [2026-07-28 11:17] Task: AI Copilot Suggestion Chips + Typeahead (spec 44_AI_SUGGEST_PROMT.md)
|
||||
- **Tóm tắt thay đổi:** Thêm suggestion chips bar trên Copilot Prompt: context-aware (MIDI selected → amber rearrange chips, no selection → indigo creation chips). Typeahead dropdown với keyword matching khi gõ ≥2 ký tự, Tab để chọn. `getSelectedMidiItemInfo` helper. Backend ai_presets.py thêm 4 rearrange presets. `promptTemplateManager.js` thêm `getContextualSuggestions(hasSelectedItem)`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/promptTemplateManager.js`, `app/api/v1/ai_presets.py`, `tests/test_ai_presets.py`
|
||||
- **Ghi chú/Test (nếu có):** `npx babel` compile pass. `pytest tests/test_ai_presets.py -v` — 7/7 pass.
|
||||
---
|
||||
|
||||
### [2026-07-28 11:02] Task: AI Rearrange Scenario Detection (spec 45_SCENARIAO_AI.md)
|
||||
- **Tóm tắt thay đổi:** Thêm 9 rearrange scenarios vào `aiGateway.js` (arpeggio, harmonies, syncopation, walking bass, jazz swing, synthwave, cinematic, simplify, passing tones) với keyword mapping → `detectRearrangeScenario()`. `buildRearrangeMessage` tự động inject specific technique rules dựa trên scenario detect được. Thêm 9 rearrange presets vào `promptTemplateManager.js` với category "Rearrange / Variation".
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/aiGateway.js`, `app/static/js/services/promptTemplateManager.js`
|
||||
- **Ghi chú/Test (nếu có):** `node --check` pass. `npx babel` compile pass. Preset Manager hiển thị 12 presets (3 cũ + 9 rearrange). Gõ "arpeggio" → AI nhận dạng scenario → inject technique "Subdivide sustained chord notes into 0.25-beat arpeggiated steps".
|
||||
---
|
||||
|
||||
### [2026-07-28 10:52] Task: MIDI Rearrange Feature (spec 41_AIMIDI_FIX.md)
|
||||
- **Tóm tắt thay đổi:** Tạo `midiExtractor.js` — `extractSelectedMIDIContext` trích xuất notes + note names từ selected MIDI item. Thêm `REARRANGE_TOOL_SPEC` + `buildRearrangeMessage` trong `aiGateway.js`. `handleAISend` phát hiện selected MIDI item → dùng rearrange flow riêng (source notes → AI → `rearrange_midi_melody` → A/B track bên dưới source). Handler `rearrangeMidiMelody` tạo track `[AI Rearrange] <title>` + MIDI item ngay dưới source track.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/midiExtractor.js` (NEW), `app/static/js/services/aiGateway.js`, `app/static/js/services/dawCommandDispatcher.js`, `app/static/js/app.jsx`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `node --check` pass. `npx babel` compile pass. Cần chọn MIDI item → gõ prompt rearrange → track A/B mới xuất hiện.
|
||||
---
|
||||
|
||||
### [2026-07-28 10:39] Task: AI MIDI Prompt Template & Preset Engine (spec 33_MIDI_AI.md)
|
||||
- **Tóm tắt thay đổi:** Tạo `PromptTemplateManager` service (keyword matching với scoring, CRUD, star/fav toggle). Backend `ai_presets.py` CRUD router (JSON file storage, auth isolation). Refactor `AIPresetModal` dùng service + star toggle + backend API. Piano Roll AI wiring. 7 tests cho matching + backend CRUD + auth isolation.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/promptTemplateManager.js` (NEW), `app/api/v1/ai_presets.py` (NEW), `tests/test_ai_presets.py` (NEW), `app/main.py`, `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `python3 -m pytest tests/test_ai_presets.py -v` — 7 tests pass. Pre-existing 1 test fail (`test_api_user_ai_config` permission error, unrelated).
|
||||
---
|
||||
|
||||
### [2026-07-28 10:15] Task: Dropdown CC lane — Velocity, Sustain, Modulation, Pitch Bend, Pan
|
||||
- **Tóm tắt thay đổi:** Thay button group (velocity/pan) bằng `<select>` dropdown với 5 options. Hiển thị mặc định là Velocity. Button toggle CC lane hiển thị short label (Vel/Sus/Mod/Bend/Pan/CC).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. CC lane dropdown có 5 options, default Velocity.
|
||||
---
|
||||
|
||||
### [2026-07-28 10:10] Task: Ctrl+Click vùng trống không sweep select, chỉ deselect
|
||||
- **Tóm tắt thay đổi:** Thay `onSweepSelectStart` bằng `onClearSelection` trong Ctrl+Click vùng không có items. Trước đây gọi `handleSweepSelectStart` → yellow cursor + mousemove chọn items. Fix: `onClearSelection = () => setSelectedItemIds(new Set())` — chỉ clear selection, không sweep.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
@@ -634,3 +676,264 @@
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Mở SECTION-TAB → PIANO_ROLL → Synth → chọn instrument → verify instrument được áp dụng cho section track (không phải main track).
|
||||
---
|
||||
|
||||
### [2026-07-28 18:17] Task: Keyboard shortcuts Ctrl+A select all + Del delete selected items/groups
|
||||
- **Tóm tắt thay đổi:** (1) Ctrl+A đã tồn tại để chọn tất cả items trên timeline. (2) Thêm xử lý phím Delete/Backspace cho `selectedItemIds`: xóa tất cả section, MIDI item, và audio clip đang được chọn khỏi track, xóa selection, hiện toast thông báo.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** Build có lỗi parse pre-existing (Babel 8) không liên quan đến thay đổi này.
|
||||
---
|
||||
|
||||
### [2026-07-29 09:40] Task: Reset to 33cf804 — clean slate for mixer reimplementation
|
||||
- **Tóm tắt thay đổi:** Hard reset về commit 33cf804, xóa toàn bộ mixer code (MasterChannelStrip, TrackChannelStrip, MixerConsole, AudioMixerGraphManager, setOutputNode) và MIDI keyboard note sustain. Giữ lại TimelineRuler (tempo bar/timebar/lead-in/0-index). Backup mixer panel code lưu trong `mixer_panel_backup_*.patch`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/audioMixerGraphManager.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes. Sẵn sàng cài đặt lại mixer panel display-only.
|
||||
---
|
||||
|
||||
### [2026-07-29 09:46] Task: Install Mixer Panel display-only with F7 toggle
|
||||
- **Tóm tắt thay đổi:** Thêm MixerStrip component (display-only, không audio routing). Mixer panel bottom dock với drag handle resize (80-400px). Track strip hiển thị index, M/S buttons, fader slider, dB readout, track name. Master strip với "MAIN OUT" label. F7 toggle + status bar button + View menu item. onUpdateTrack thay đổi mute/solo/volumeDb trực tiếp.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 09:55] Task: Mixer panel — VU meter, fader scaling, track name color
|
||||
- **Tóm tắt thay đổi:** Master strip thêm fader placeholder + VU meter. MixerStrip fader container `items-end` → `items-stretch` để fill chiều cao. Thêm VU meter (volume-dependent color: green/amber/red, height theo volumeDb). Track name dùng `track.color` thay vì fixed text-zinc-400.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:02] Task: Fix stuck MIDI notes when ARM toggled off mid-note
|
||||
- **Tóm tắt thay đổi:** Xóa `if (!st.isArmed) return;` trong note-off handler. Note-off giờ stopNote trên ALL tracks thay vì chỉ armed tracks — FluidSynth stopNote trên channel không có note đang play là no-op.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:08] Task: Fix AI Suggestion list changing on item select/deselect
|
||||
- **Tóm tắt thay đổi:** Xóa filter `hasSelItem ? ...` khỏi `suggestions` — luôn hiển thị toàn bộ preset list thay vì chỉ rearrange/favorites khi có item được chọn. Tránh tự động thêm/xóa suggestion khi click chọn/bỏ chọn item.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:12] Task: Restore AI suggestion filter by selection + fix stale instance
|
||||
- **Tóm tắt thay đổi:** Restore filter: khi chọn MIDI item chỉ hiện Rearrange/favorites, không chọn thì hiện tất cả. Fix gốc: cache PromptTemplateManager qua `aiPromptMgrRef` thay vì `new` mỗi render — tránh thay đổi array reference gây re-render suggestion list.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:17] Task: Fix filter including wrong presets in AI suggestion list
|
||||
- **Tóm tắt thay đổi:** Xóa `|| p.is_favorite` khỏi filter khi có MIDI item selected — chỉ lọc theo `category === 'Rearrange / Variation'`, tránh preset favorite bị lọt vào list không đúng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:19] Task: Show all presets always, no filter by selection
|
||||
- **Tóm tắt thay đổi:** Luôn hiển thị `promptMgr.presets` đầy đủ, không filter theo selection state — tránh preset đầu tiên nhảy vào list khi bỏ chọn item.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:42] Task: Fix multi-item drag merging all items into target track
|
||||
- **Tóm tắt thay đổi:** Khi cross-track drag, chỉ item đang được kéo (`drag.itemId`) được chuyển track — các item được chọn khác giữ nguyên track cũ, chỉ cập nhật vị trí ngang (delta). Fix `isDraggedItem` check trong multiIds loop.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:48] Task: Fix group drag — all items move, clamp to track 0, auto-add track
|
||||
- **Tóm tắt thay đổi:** Restore group drag: tất cả selected items di chuyển cùng nhau. Thêm clamp targetTrackId về track đầu tiên (0). Auto-add track mới khi kéo xuống dưới track cuối cùng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:50] Task: Fix group drag merging items — keep items on original tracks
|
||||
- **Tóm tắt thay đổi:** Xóa toàn bộ cross-track logic (currentTrackMap, crossTracks). MultiIds drag chỉ cập nhật time position (delta) trên từng track — items luôn giữ nguyên track cũ, không gộp.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:55] Task: Fix group drag cross-track — each item keeps relative track offset
|
||||
- **Tóm tắt thay đổi:** Khi drag group cross-track, mỗi item tính target track riêng dựa trên `srcIdx + crossOffset`. Item trên track 1 kéo xuống 2 track → đến track 3. Item trên track 2 kéo xuống 2 track → đến track 4. Không gộp vào 1 track.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 10:58] Task: Fix group drag cross-track stale track index bug
|
||||
- **Tóm tắt thay đổi:** Sửa `trkIds` dùng `prev.map(tr => tr.id)` (từ live state trong callback) thay vì `activeTracksRef.current`. Sửa insert section dùng `for` loop tìm srcItem đúng thay vì `flatMap` sai.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:00] Task: Fix group drag — use ORIGINAL trackId from drag start
|
||||
- **Tóm tắt thay đổi:** Lưu `trackId: t.id` vào `multiIds` lúc bắt đầu drag. Dùng `inf.trackId` (original) để tính `origIdx + crossOffset` — không dùng `currentTrackMap` (live state có thể stale). Item track1 drag n track → track1+n, item track2+n track → track2+n.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:04] Task: Fix single-item cross-track drag
|
||||
- **Tóm tắt thay đổi:** Sửa single-item drag path: mỗi track filter `drag.itemId` riêng, chỉ insert vào `targetTrackId`. Xóa duplicate `setDraggedSectionItem`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:06] Task: Fix cross-track drag — compute target track from mouse Y
|
||||
- **Tóm tắt thay đổi:** Thay `hoveredTrackIdRef` bằng tính toán target track từ mouse Y position + track heights (`prev[ti].height`). Không phụ thuộc `onMouseEnter` event có thể không fire khi drag.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:07] Task: Auto-add tracks when drag exceeds existing track count
|
||||
- **Tóm tắt thay đổi:** Tính `maxTrg` từ tất cả items trong multiIds. Nếu `prev.length <= maxTrg`, tự động thêm track mới vào `prev` trước khi xử lí. Item track2 cần xuống track3 → auto-add track3.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:10] Task: Fix targetTrackId undefined outside closure
|
||||
- **Tóm tắt thay đổi:** Chuyển target track computation ra ngoài `updateActiveTracks` callback để `setDraggedSectionItem` thấy `targetTrackId`. Compute dùng `activeTracksRef` cho track heights.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:12] Task: Fix drag cross-track — use ORIGINAL baseTrackId, never update
|
||||
- **Tóm tắt thay đổi:** Dùng `dragBaseTrackId` từ `multiIds[drag.itemId].trackId` (không đổi) thay vì `drag.trackId` (bị update mỗi mousemove). Xóa `setDraggedSectionItem` update trackId — không cần thiết. crossOffset ổn định xuyên suốt drag.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:16] Task: Rewrite drag handler — separate multi/single paths, pre-compute crossOffset
|
||||
- **Tóm tắt thay đổi:** Tách multiIds path và single path. Pre-compute crossOffset + outTracks ngoài `updateActiveTracks`. outTracks merge vào prev bằng filter id unique. Mỗi item compute targetTid từ origIdx+crossOffset, filter + upsert trên target track.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:27] Task: Fix seed_admin() resetting admin password on every restart
|
||||
- **Tóm tắt thay đổi:** Xóa `else` branch trong `seed_admin()` — từng kiểm tra `if not verify_password(default_pwd, ...)` và reset password admin về `admin123` + `must_change_password=1` mỗi lần server restart nếu password đã bị đổi. Giờ chỉ create admin khi chưa tồn tại.
|
||||
- **Các file ảnh hưởng:** `app/core/auth.py`
|
||||
---
|
||||
|
||||
### [2026-07-29 11:34] Task: Fix instrument list not loading on new machine after login
|
||||
- **Tóm tắt thay đổi:** Thêm `listPlugins()` + `getSoundfontCatalog()` fetch trong `handleAuthSuccess` — re-fetch instrument data sau khi login vì useEffect mount chạy trước khi token được set, fail silent.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:45] Task: AI SUGGEST auto-update after saving preset in AI Preset Manager
|
||||
- **Tóm tắt thay đổi:** Thêm `aiPresetVersion` counter, increment khi AIPresetModal đóng. Trong AI panel, so sánh version với `window.__aiPresetVersion`, nếu khác thì gọi `aiPromptMgrRef.current.loadPresets()` để reload từ localStorage.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:53] Task: Synth button shows track dropdown for quick instrument assign
|
||||
- **Tóm tắt thay đổi:** Synth button TCP giờ mở dropdown list tất cả tracks thay vì mở modal trực tiếp. Chọn track trong dropdown → modal instrument selector mở cho track đó. Không cần đóng modal để click Synth từng track.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 11:57] Task: Fix Synth button — track selector inside instrument modal
|
||||
- **Tóm tắt thay đổi:** Xóa dropdown track riêng (synthTrackDropdownId). Thêm `<select>` track list trong modal Select Instrument, giữa title và textbox tìm kiếm. Chọn track → mở instrument cho track đó trong cùng modal.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 12:00] Task: Fix missing aiTypeaheadRef definition
|
||||
- **Tóm tắt thay đổi:** Thêm `aiTypeaheadRef = useRef(null)` — ref được dùng trong typeahead dropdown nhưng chưa được khai báo, gây ReferenceError.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 12:03] Task: Select Instrument no longer auto-closes on instrument pick
|
||||
- **Tóm tắt thay đổi:** Xóa `setInstrumentSelectorTrackId(null)` khỏi `setTrackInstrumentWithProgram` — modal không tự đóng khi chọn instrument. Người dùng đổi track trong dropdown, chọn instrument, modal giữ nguyên. Đóng bằng nút ✕ hoặc click outside.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-29 15:39] Task: Implement full Ctrl-Z/Ctrl-Y undo/redo for MAIN SESSION and SECTION-TAB
|
||||
- **Tóm tắt thay đổi:** (1) Tạo `UndoRedoEngine` service (`app/static/js/services/undoRedoEngine.js`) — global stack hỗ trợ cross-cutting state changes (BPM, playhead, selection, AI prompt, instrument). (2) Extend `handleUndo`/`handleRedo` trong `app.jsx` để ưu tiên `UndoRedoEngine`, fallback về track-level undo stack cũ. (3) Áp dụng undo/redo cho: Delete/Create items (section, midi, clip, track), text AI Prompt (native textarea undo stack + engine integration), instrument assignment, playhead position, item drag position, selection changes, BPM/tempo changes. (4) Update undo/redo buttons disabled state + keyboard shortcut indicators. (5) Global Ctrl+Z/Y handler skip INPUT/TEXTAREA focus để text input native undo hoạt động đúng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/undoRedoEngine.js` (NEW), `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-30 12:37] Task: Install Master Strip Console in mixer panel
|
||||
- **Tóm tắt thay đổi:** (1) Thêm CSS styles cho Master Strip Console (`strip-bg`, `fader-track`, `btn-daw`, `btn-teal-active`, `fader-slider`) vào `app/templates/index.html`. (2) Tạo component `MasterStripConsole` trong `app/static/js/app.jsx` — thay thế MASTER strip cũ (fader đơn giản) bằng console theo spec `md/47_MASTER_STRIP_CONSOLE.md`: dual stereo VU meter canvas với dB scales L/R, vertical fader -60~+12dB, button stack (Mono/M/S/Route/FX/Power/Trim/Info), pan knob, dB readout, RMS footer. (3) Tích hợp test signal engine (Play/Stop test audio vào masterBus.input), FX button mở MasteringModal, Power button toggle mastering active/bypass. (4) VU meter animation loop sử dụng `masterBus.leftAnalyser`/`rightAnalyser` thay vì synthetic data.
|
||||
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes (Babel note: deoptimised styling for file >500KB, không phải lỗi).
|
||||
---
|
||||
|
||||
### [2026-07-30 12:53] Task: Install Master Strip + Track Strip Console in mixer panel
|
||||
- **Tóm tắt thay đổi:** (1) Refine `MasterStripConsole` theo spec `md/47`: top button "MASTERING PANEL", pan knob pointer-drag (L/R/center label), mute/mono buttons toggle `.btn-mute-active`/`.btn-mono-active`, VU meter dùng synthetic post-fader simulation khi muted. (2) Tạo `TrackStripConsole` từ spec `md/48_TRACK_STRIP_COMPONENT.md`: `w-[145px]` với top color accent bar, pan rotary dial, peak dB fader rail + LED VU canvas, button stack (M/S/Route/FX/Power/Auto/Phase Ø), record arm row, track name, color footer index. (3) CSS mới: `.strip-bg-track`, `.fader-track-bg`, `.btn-solo-active`, `.btn-arm-active`, `@keyframes pulse-red`, cập nhật `.btn-daw:active`/`fader-slider` thumb từ spec. (4) Replace `MixerStrip` → `TrackStripConsole` trong mixer panel.
|
||||
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
|
||||
### [2026-07-30 21:15] Task: PIANO ROLL draw notes snapped to grid
|
||||
- **Tóm tắt thay đổi:** Note `start_beat` + `duration_beats` được snap bằng `getSnapBeat` trước khi vẽ lên canvas. Áp dụng cho active notes, ghost notes, recording notes.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Note hiển thị thẳng hàng với grid lines theo snap value.
|
||||
---
|
||||
|
||||
### [2026-07-30 21:07] Task: Fix PIANO ROLL snap grid — snap values inverted in 3 places
|
||||
- **Tóm tắt thay đổi:** snapVal mapping bị đảo ngược: snap='1' (1 beat) cho q=4.0 (4 beats) → grid vẽ mỗi 4 beat, note snap mỗi 4 beat. Fix: `getSnapBeat`, `getSnapDuration`, grid drawing: snap='1'→1.0, '1/2'→0.5, '1/4'→0.25, '1/8'→0.125, '1/16'→0.0625, '1/32'→0.03125, '4'→4.0.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Piano roll snap=1 → grid mỗi 1 beat, note snap đúng vị trí.
|
||||
---
|
||||
|
||||
### [2026-07-30 21:02] Task: Fix audio clips lost — draw clip rect even without buffer + store duration in deserialize
|
||||
- **Tóm tắt thay đổi:** WaveformLane từng `if (!clip.buffer) return;` skip toàn bộ clip (kể cả rect/label). Fix: draw rect/label luôn, chỉ skip waveform khi null buffer. Thêm label "(audio chưa được tải)". Deserialize giờ restore `duration` trên clip từ `duration_bars`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Clip không có buffer vẫn hiện rect + label + duration đúng.
|
||||
---
|
||||
|
||||
### [2026-07-30 20:58] Task: Fix snap default + Power button mastering bypass
|
||||
- **Tóm tắt thay đổi:** (1) snapValue mặc định `'1'` (thay `'free'`), snapVal PIANO_ROLL mặc định `'1'` (thay `'1/16'`); (2) Power button trong MasterStripConsole: bỏ `isFxActive` local state dùng chung sai với FX button, đọc `masteringSettings.masterConnected` trực tiếp → toggle `masterConnected` + set `isBypassed` tương ứng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Load project → snap = 1. Mixer → Power button → mastering bật/tắt đúng.
|
||||
---
|
||||
|
||||
### [2026-07-30 20:52] Task: Fix audio clip items lost after reload
|
||||
- **Tóm tắt thay đổi:** 3 root causes: (1) `serverFileId` không được lưu vào clip/track khi upload audio — `insertSoundClipAtCursor` gọi `uploadToServer` nhưng chỉ lưu vào global `serverFileIdMap`, không set `t.serverFileId`; (2) Serialize dùng `t.serverFileId` (null) → `audio_file_url` rỗng → Deserialize không lấy được fileId → `loadAudioBuffersForTracks` không load được buffer; (3) WaveformLane canvas `clip.buffer.numberOfChannels` crash khi buffer null → clip items không vẽ được. Fix: set `serverFileId` trên clip + track khi upload; serialize/deserialize `server_file_id` trong `source_data`; guard `if (!clip.buffer) return` trong WaveformLane.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Chèn audio clip → lưu project → reload → clip hiển thị + waveform load lại.
|
||||
---
|
||||
|
||||
### [2026-07-30 20:46] Task: Auto-save 5min + Backup 30min + Backup management UI
|
||||
- **Tóm tắt thay đổi:** (1) DB: thêm bảng `project_backups` + migration cột `is_backup`/`original_id`; (2) Backend: `POST/POST /cloud/{id}/backup`, `GET /cloud/{id}/backups`, `DELETE /cloud/backups/{id}`, `POST /cloud/backups/cleanup` — tự động giới hạn retention (mặc định 10, cấu hình 5-20); (3) Frontend API: 4 methods backup; (4) App: `setInterval` 5 phút auto-save + 30 phút backup; (5) ProfileModal: nút Backup (hiện số lượng) mở rộng danh sách, xóa từng bản, thanh cấu hình retention slider 5-20, nút dọn dẹp.
|
||||
- **Các file ảnh hưởng:** `app/models/user.py`, `app/api/v1/projects.py`, `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: mở dự án Cloud → đợi 30p → backup tự động tạo; slider 5-20 lưu vào localStorage; nút 🧹 DỌN BACKUP giữ lại đúng số lượng.
|
||||
---
|
||||
|
||||
### [2026-07-30 20:36] Task: Fix project load crash — loadAudioBuffersForTracks scoped inside ProfileModal
|
||||
- **Tóm tắt thay đổi:** `loadAudioBuffersForTracks` defined inside `ProfileModal`, but `restoreLastSessionProject` in App component called it → ReferenceError on startup + silent catch → project not restored. Fix: move function to App component, pass as prop to ProfileModal.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Mở lại trang → dự án cũ được khôi phục.
|
||||
---
|
||||
|
||||
### [2026-07-30 20:32] Task: Add "Tạo preset với cấu trúc" button in AI Preset Manager
|
||||
- **Tóm tắt thay đổi:** Thêm `handleNewStructured` — tạo preset mới với template có cấu trúc (mục: thể loại, bố cục, nhạc cụ, hòa âm, hiệu ứng, MIDI). Button xanh lục "Tạo preset với cấu trúc" bên trái "Quay lại" trong form edit.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
- **Ghi chú/Test (nếu có):** Nhấn "Tạo mới" → form edit hiện button xanh "Tạo preset với cấu trúc" → click → pre-fill template.
|
||||
---
|
||||
|
||||
### [2026-07-30 22:48] Task: Fix audio clip mất âm thanh sau khi mở lại dự án
|
||||
- **Tóm tắt thay đổi:** Sửa 3 lỗi khiến clip audio không tải được buffer sau khi save/reload project: (1) `deserializeTracksList` thiếu fallback từ `audio_file_url` khi `server_file_id` không có trong source_data (2) `serializeTracksList` không lưu `server_file_id` ở track level (3) legacy upgrade function trong Python không copy `server_file_id` vào source_data.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/api/v1/projects.py`
|
||||
- **Ghi chú/Test (nếu có):** Mở lại dự án có clip audio → waveform hiển thị + playback có âm thanh.
|
||||
|
||||
---
|
||||
### [2026-07-31 05:42] Task: Cải thiện AI Preset Manager - Category dropdown + Generator modal
|
||||
- **Tóm tắt thay đổi:** (1) Phần DANH MỤC trong form tạo preset đổi từ input text sang dropdown `<select>` có đầy đủ danh mục từ presets + danh mục đã lưu trong localStorage, cho phép người dùng chọn danh mục có sẵn hoặc nhấn "+ Nhập danh mục mới..." để thêm; danh mục mới được lưu vào `midi_prompt_categories` trong localStorage để load lần sau. (2) Nút "Tạo preset với cấu trúc" mở modal mới chứa iframe `/ai-prompt-generator` phục vụ nội dung HTML từ `md/49_AI_PROMPT_GENERATOR.md`, cho phép người dùng dùng giao diện đầy đủ để tạo preset; đóng modal sẽ tự động refresh danh sách preset từ localStorage.
|
||||
- **Các file ảnh hưởng:** `app/main.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Backend route `/ai-prompt-generator` trả về HTML từ `md/49_AI_PROMPT_GENERATOR.md`. Test: mở AI Preset Manager → Tạo mới → dropdown DANH MỤC hiện danh sách → thêm danh mục mới → lưu → reload modal vẫn giữ danh mục mới. Click "Tạo preset với cấu trúc" → mở iframe generator.
|
||||
|
||||
---
|
||||
### [2026-07-31 06:15] Task: Fix preview layout in AI Prompt Generator modal
|
||||
- **Tóm tắt thay đổi:** Sửa layout right column của generator page: header thêm `shrink-0` để không bị co lại; container preview đổi thành `flex-1 flex flex-col min-h-0` thay vì `overflow-y-auto`; `#promptOutputText` đổi từ `h-full` sang `flex-1` + `overflow-y-auto` để bottom luôn neo với cạnh modal và nội dung scroll trong element.
|
||||
- **Các file ảnh hưởng:** `md/49_AI_PROMPT_GENERATOR.md`
|
||||
- **Ghi chú/Test (nếu có):** Refresh trang generator → right column fill full height → content scroll bên trong #promptOutputText.
|
||||
|
||||
---
|
||||
### [2026-07-31 06:35] Task: Apply generator-to-preset-manager transfer spec
|
||||
- **Tóm tắt thay đổi:** Áp dụng spec từ md/50_APPLY_GENERATOR.md vào md/49_AI_PROMPT_GENERATOR.md: (1) Category input chuyển thành `<select id="presetCategorySelect">`; (2) Thêm nút "Tạo preset có cấu trúc" (xanh lá) bên trái action bar; (3) autoFillPresetModal cập nhật logic infer category từ role/keywords; (4) Thêm handler `btnCreateStructuredPreset` thay static number bằng template variable; (5) Save handler dùng `presetCategorySelect.value`, dispatch `AI_PRESET_SAVED` CustomEvent.
|
||||
- **Các file ảnh hưởng:** `md/49_AI_PROMPT_GENERATOR.md`
|
||||
- **Ghi chú/Test (nếu có):** Refresh generator → Lưu thành Preset → Category tự chọn dropdown → Tạo preset có cấu trúc → số bars/beats biến thành {{bars}}/{{total_beats}}.
|
||||
|
||||
---
|
||||
### [2026-07-31 06:42] Task: Transfer generator data to parent AI PRESET MANAGER form
|
||||
- **Tóm tắt thay đổi:** Thay vì mở modal standalone trong iframe generator, "Lưu thành Preset" giờ postMessage `GENERATOR_PRESET_DATA` lên parent window. React AIPresetModal thêm `message` listener nhận data, auto-populate form (name, category, keywords, bars, bpm, scale, template), set editingPreset='new' và đóng generator modal. Nút "Tạo preset có cấu trúc" trong React form vẫn hoạt động độc lập.
|
||||
- **Các file ảnh hưởng:** `md/49_AI_PROMPT_GENERATOR.md`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Flow: generator iframe → Lưu thành Preset → form React tự điền → generator modal đóng.
|
||||
|
||||
Reference in New Issue
Block a user