fix(export): MIDI export qua server endpoint thay blob download (WebView2 chặn) + velocity 1..127, bỏ rest-track

This commit is contained in:
2026-08-21 11:10:32 +07:00
parent d7756b08ab
commit 96da8b5ac0
7 changed files with 863 additions and 167 deletions
+277
View File
@@ -0,0 +1,277 @@
# BIG PHASE 3 — Export MIDI/Mix, FX CHAIN, Virtual MIDI Keyboard & Chords Panel
---
## ⚠️ GHI NHỚ — QUY TẮC BẮT BUỘC KHI THỰC HIỆN (đọc trước, tuân thủ mọi phase)
1. **KHÔNG ĐƯỢC LÀM HỎNG code đã chạy ổn định.** 4 lỗi nghiêm trọng đã fix ở phase trước, tuyệt đối không tái phạm (verify regression sau MỖI thay đổi):
- Âm lượng track không đồng đều (jbridge/SandboxVst2Host — calibrate makeup phải ≈2.59–2.71 cho Qin, KHÔNG được quay lại makeup=1.0).
- VST GUI đã load không hiện / mất GUI sau khi đóng-mở (PluginHost z-order top, embed child #32770 jBridge + Qin_VST_Window).
- Bị câm khi đã load instrument (VSTi/SF2 qua bridge — load xong phải nghe được ngay).
- Bị câm khi preview MIDI (preview VSTi/midi-render phải ra âm).
- Trước khi commit bất kỳ phase nào: chạy lại bộ verify âm thanh cũ (note_quick / open_gui / silence_all) xác nhận KHÔNG regression.
2. **PHẢI OPTIMIZE sau mỗi lần sửa code** (xóa biến chết, branch chết, code trùng; gọn nhất có thể — YAGNI).
3. **LUÔN LUÔN tuân thủ đường dẫn của các lần compile/build trước:**
- Frontend: sửa trong `app/static/js/app.jsx` → `node build.mjs` → `app/static/js/app.precompiled.js`.
- Engine: `python -m PyInstaller engine.spec --clean --noconfirm` từ repo root → output `dist/daw_engine/` → **copy toàn folder** sang `src-tauri/target/release/daw_engine/`.
- C++ bridge: build trong `native_bridge/` bằng `cmake --build build --config Release --target plugin_host daw_vst_bridge` → copy `plugin_host.exe` + `daw_vst_bridge.exe` sang `src-tauri/binaries/` (gồm bản `-x86_64-pc-windows-msvc.exe`) + `src-tauri/target/release/`.
4. **KHÔNG TỰ Ý thay đổi các đường dẫn cũ** (SHM layout, storage paths `%APPDATA%/SonicForgeDAW`, plugin dirs, soundfont dir, port 8000–8010, tên exe, cấu trúc `src-tauri/`). Chỉ đổi khi có lý do bắt buộc và ghi rõ trong commit.
5. **SAU KHI BUILD HOÀN THÀNH phải dọn dẹp tập tin tạm / tập tin rác:** xóa `build/`, `dist/` (sau khi đã copy xong), `__pycache__/`, `.pytest_cache/`, file `.bak`/`.new`/`.recovered` không dùng, file log rác — rồi `git status` phải sạch (chỉ còn thay đổi chủ đích).
---
> Repo: `C:/Users/locpham/SonicForgeStudio` · branch: `standalone-shm-bridge`
> Frontend là 1 file monolith: `app/static/js/app.jsx` (JSX) → build bằng `node build.mjs` → `app/static/js/app.precompiled.js` (file được template nạp: `app/templates/index.html` load `app.precompiled.js?v=...`).
> Backend engine (FastAPI) đóng băng bằng PyInstaller → **MỌI thay đổi frontend/backend đều phải rebuild engine** rồi copy sang folder chạy thật (xem §0).
---
## 0. QUY TRÌNH BUILD & CHẠY THỬ (bắt buộc cho mọi phase)
```bash
# 1. Sửa app/static/js/app.jsx (hoặc file backend .py)
# 2. Recompile frontend:
node build.mjs
# 3. Rebuild engine đóng băng (nếu đổi app.jsx HOẶC app/**.py):
cd /c/Users/locpham/SonicForgeStudio
python -m PyInstaller engine.spec --clean --noconfirm
# 4. Copy engine mới sang nơi app thật dùng:
cp -r dist/daw_engine/* src-tauri/target/release/daw_engine/
# 5. Kill tiến trình cũ rồi chạy app thật:
taskkill //IM sonicforge-daw.exe //F 2>/dev/null; taskkill //IM daw_engine.exe //F 2>/dev/null
src-tauri/target/release/sonicforge-daw.exe &
# 6. Login API (backend port 8000):
# POST http://127.0.0.1:8000/api/v1/auth/login {"username":"admin","password":"admin123"}
# → token → header `Authorization: Bearer <token>` cho các endpoint cần auth.
```
Ghi chú:
- `app/static/js/*.js` (services) và `app/static/js/app.jsx` là LF; `native_bridge/` C++ là CRLF — kiểm tra bằng `file` trước khi patch binary.
- Chạy thật = `src-tauri/target/release/sonicforge-daw.exe` (Tauri WebView2). Không test bằng browser Chrome thuần — lỗi "sau khi chuyển standalone" chỉ tái hiện trong WebView2/engine đóng băng.
---
## PHASE 1 — FIX EXPORT MIDI (đã có feature, lỗi sau khi chuyển standalone)
### 1.1 Hiện trạng
- Code xuất MIDI nằm ở `triggerMidiExport()` trong `app/static/js/app.jsx` (anchor ~dòng 26059).
- Hoàn toàn client-side: gom note từ `track.midiItems[].notes` → build bytes MIDI format 1 (header `MThd`, từng track `MTrk`, delta time bằng `writeVLQ`) → `Blob` → download bằng `a.href = URL.createObjectURL(blob); a.click()`.
- Menu: File → Export MIDI... (anchor ~29344). Còn 1 nút "Export MIDI" trong piano roll toolbar (~10534) chỉ gọi handler khác (midi-render → WAV), không phải export .mid.
### 1.2 Nghi vấn root cause (chưa xác nhận — chẩn đoán trước)
1. **WebView2 chặn blob download**: `URL.createObjectURL` + `a.click()` với thuộc tính `download` không hoạt động trong WebView2 (không có hộp thoại lưu, không có sự kiện download). → Nghi ngờ cao nhất.
2. Bug phụ trong code hiện tại (sửa luôn):
- `velocity = Math.round((note.velocity || 0.8) * 100)` → scale 0..100, **sai chuẩn MIDI 0..127** (đáng lẽ `* 127`).
- Track audio không có MIDI được xuất thành "rest track" với note pitch 0 velocity 1 — gây tiếng "bíp" nếu DAW khác phát file này; nên bỏ hẳn track không có note MIDI.
### 1.3 Giải pháp
- **Bước A — Chẩn đoán xác nhận (làm trước khi code):**
1. Mở app thật, tạo project có MIDI item (vài nốt), bấm File → Export MIDI... → quan sát: có hộp thoại lưu không? console WebView2 có lỗi không? (dùng `--remote-debugging-port` nếu cần).
2. Nếu không có download → xác nhận giả thuyết WebView2 chặn blob.
- **Bước B — Fix (2 lựa chọn, ưu tiên B1):**
- **B1 (khuyên dùng): Server-side export endpoint.** Thêm `POST /api/v1/export/midi` vào `app/api/v1/audio.py` (hoặc file mới `app/api/v1/midi.py`):
- Request: `{ bpm, ppq, tracks: [{ name, notes: [{ pitch, start_beat, duration_beats, velocity }] }] }`.
- Backend dùng **stdlib thuần** (không thêm dependency) viết file .mid (format 1, `writeVLQ` giống frontend, velocity scale chuẩn `*127`, bỏ track không có note).
- Trả `file_id` (lưu vào `app/storage/processed/`) + `download_url` → frontend tải qua `GET /api/v1/audio/download/{file_id}` (pattern đã có sẵn, chạy tốt trong WebView2 vì là network download http://127.0.0.1, không phải blob).
- **B2 (fallback nếu B1 không khả thi):** giữ generation client-side nhưng đổi cơ chế tải: thử `a.click()` → nếu không có download, dùng Tauri dialog: `window.__TAURI__.dialog.save()` (capability `dialog:default` đã có) + ghi file qua... lưu ý **thiếu permission fs** — cần thêm `fs:default` vào `src-tauri/capabilities/default.json` nếu đi hướng này. → B1 đơn giản hơn, không đụng Rust/capabilities.
- **Bước C — Cập nhật UI:** `triggerMidiExport()` gọi API mới, hiện spinner `setIsExporting`, toast kết quả; giữ nguyên menu/nút cũ.
### 1.4 Verify
- Tạo project MIDI (2 track, nhiều note, velocity khác nhau) → Export MIDI → file .mid tải về được.
- Mở file bằng DAW khác (hoặc script đọc bytes): số track đúng, tempo đúng, pitch/duration đúng, velocity nằm trong 1..127 (không còn giới hạn 100).
- Export project rỗng → toast "Không có dữ liệu MIDI" (không crash).
---
## PHASE 2 — FIX EXPORT MIX (xuất âm thanh) + TÙY CHỌN LOOP (không fade in/out)
### 2.1 Hiện trạng
- Menu: File → Export Mix... (anchor ~29340) → `triggerWavExport()` (anchor ~26293).
- `ExportModal` (anchor ~11881): option Nguồn (`project`/`track_mix`/`active_clip`/`clip_selection`), Định dạng (wav/mp3/ogg), SR, Bit, Kênh. **Chưa có option loop, chưa có option fade.**
- 3 đường xuất âm:
1. **Server-side** (`allOnServer && serverStatus === 'connected'`): POST `/api/v1/multitrack/mix` → task → poll → download file_id. Điều kiện: mọi track có `serverFileIdMap[t.id]` (đã upload file lên server) và `serverStatus === 'connected'`.
2. **Client-side** `clientSideExport()` (anchor ~26734): `OfflineAudioContext` build lại full graph (track FX Rack + mastering chain) → encode WAV bằng tay (RIFF header + PCM). Chỉ hỗ trợ WAV; mp3/ogg bắt buộc server (`serverStatus === 'connected'`, anchor ~26849).
3. **Bounce realtime** `triggerBounceExport()` (anchor ~26450): khi project có MIDI items mà MIDI cache chưa đủ → chạy lại project thật, capture PCM master bus → WAV.
- Server path gửi `fade_in_ms: 0, fade_out_ms: 0` rồi nhưng `ClipConfig` backend mặc định 150/150 — kiểm tra worker có tôn trọng 0 không (xem `app/tasks/worker.py` `mix_multitrack_task`).
### 2.2 Nghi vấn root cause (chẩn đoán trước)
1. Sau standalone, `serverStatus` thường = `checking...`/`offline` (không có server riêng) → nhánh server-side không chạy; nếu user chọn mp3/ogg → báo lỗi/không xuất. Kiểm tra `serverStatus` khi chạy app thật.
2. Client-side path dùng `OfflineAudioContext` — trong WebView2 có thể fail nếu audio context chưa từng được unlock (autoplay policy) → export ra file rỗng/crash. Kiểm tra log.
3. File tải về qua blob download — cùng vấn đề WebView2 như Phase 1.
4. MIDI bounce: nếu master bus chưa init → toast "Hãy phát thử một lần" (đã có guard) — nhưng dễ bị kẹt ở trạng thái này khi user chưa Play.
### 2.3 Giải pháp
- **Bước A — Chẩn đoán:** chạy app thật, tạo project (audio clip + 1 MIDI track), thử Export Mix cả 3 nguồn; ghi lại: toast nào hiện, file có tải về không, file có đúng độ dài/âm thanh không.
- **Bước B — Sửa đường xuất âm cho standalone:**
1. `clientSideExport`: bọc `OfflineAudioContext` bằng try/catch; trước khi render gọi `await ensureAudioUnlocked()` (unlock context bằng `resume()`/silent buffer nếu chưa unlock).
2. Chuyển hết đường tải file sang pattern download qua server (giống Phase 1 B1): hoặc dùng helper `downloadBlobViaServer(url)`; đơn giản nhất: mọi export (client/bounce) lưu ra `app/storage/processed/` qua 1 endpoint upload hoặc tự backend sinh → trả file_id → `a.href = /api/v1/audio/download/{file_id}` (network download, hoạt động trong WebView2).
3. mp3/ogg khi server offline: thêm fallback — nếu không server, chuyển tự động về WAV kèm toast thông báo (không im lặng fail).
- **Bước C — Tùy chọn LOOP (yêu cầu chính):**
1. Thêm state `exportSettings.loop` (boolean) + `exportSettings.loopBars` (số ô nhịp, default 4) vào `useState` (anchor ~17249).
2. `ExportModal`: thêm checkbox **"Xuất loop (không fade in / không fade out)"** + input số ô nhịp (hoặc dùng vùng chọn hiện tại nếu có selection).
3. Khi `loop = true`:
- Nguồn buộc = vùng loop: `duration = loopBars * (60/bpm) * 4` giây (hoặc selection bounds), bắt đầu từ playhead/bar đầu selection.
- **Mọi path đều zero-fade:** server path `fade_in_ms: 0, fade_out_ms: 0` (đã gửi, kiểm tra worker tôn trọng); client path `clientSideExport` không áp fade (clip buffer thô — xác nhận `buildOfflineTrackNode` không chèn fade); bounce path capture PCM không fade.
- Không kéo đuôi: cắt chính xác tại `start + duration` (không để tail reverb/decay thừa — nếu muốn giữ decay thì là option riêng, ngoài phạm vi; note `ponytail:`).
4. Đảm bảo file loop không bị "click" ở biên: zero-fade nghĩa là giữ nguyên dạng sóng; yêu cầu người dùng tự chọn loop point sạch (không tự động crossfade — đúng spec "không fade").
- **Bước D — Cập nhật nút/UI:** giữ menu Export Mix..., thêm shortcut nếu tiện.
### 2.4 Verify
- Export WAV loop 4 bars (project có audio + MIDI qua FX CHAIN): file đúng độ dài `4*4*60/bpm` giây (±1 sample), **mẫu đầu và mẫu cuối ≠ 0** nếu nội dung phát tại biên (chứng minh không fade in/out), loop nối vòng không click rõ rệt.
- Export project không loop (bình thường) vẫn ra đúng như trước (không regression fade 150ms mặc định nếu có).
- mp3/ogg khi server offline → tự về WAV + toast.
---
## PHASE 3 — ĐỔI MASTERING PANEL / FX RACK PANEL → FX CHAIN (modal dùng chung: plugin tự viết + VST FX)
### 3.1 Hiện trạng
- 2 modal riêng biệt:
- `FXRackModal` (anchor ~12160): cho 1 track. Header `{track.name} — FX RACK PANEL` (anchor ~12492). Chain built-in = `track.fxChain` (module tự viết: EQ/comp/limiter/reverb/delay..., meta `TRACK_FX_META` anchor ~1331); VST FX riêng = `track.vstFxChain` (danh sách từ `window.SonicAPI.listFx()` = `fx_vst_bridge --scan`, áp khi render/export).
- `MasteringModal` (anchor ~12743): cho master. Header `MASTERING SUITE ... WEB MASTERING V10.5` (anchor ~13503). State `masteringSettings` (ozState): built-in mastering (EQ 4 băng/comp/limiter) + `ozState.vstFxChain`.
- Nút mở:
- Master: nút "MASTERING PANEL" trong `MasterStripConsole` (anchor ~2393), nút PWR cạnh master fader (~2526), menu File → "Mastering Suite" (anchor ~29348, Ctrl+Shift+M).
- Track: nút `FX` trên TCP (anchor ~2740) → `window.__openFxRack(track.id, track.name)` (định nghĩa ~18065) → set `fxRackTarget` → render `FXRackModal` (anchor ~32645).
### 3.2 Mục tiêu (yêu cầu)
1. Bỏ tên "MASTERING PANEL"/"FX RACK PANEL" → thống nhất **"FX CHAIN"**.
2. Một modal chung chứa **cả plugin xử lí âm thanh tự viết (built-in DSP) lẫn VST FX** trong **một chain duy nhất**, dùng được cho **master** và cho **track**.
3. Chain áp realtime khi Play và áp đúng khi Export (cả client offline lẫn bounce).
### 3.3 Giải pháp
- **Bước A — Tách component chung:** tạo `FxChainModal({ target, onClose })` với `target = { kind: 'master' } | { kind: 'track', trackId }` (thay 2 component cũ, hoặc giữ wrapper cũ gọi chung — chọn cách ít đụng nhất: giữ `FXRackModal`/`MasteringModal` làm wrapper mỏng, body dùng chung component `FxChainBody`).
- **Bước B — Hợp nhất chain trong UI:**
- Hiển thị 1 hàng chain duy nhất: mỗi slot = `{ type: 'builtin' | 'vst', id, name, icon, bypass, preset_b64? }`.
- Backing state giữ nguyên để không phá lưu project cũ: track → `track.fxChain` (builtin) + `track.vstFxChain` (vst); master → `masteringSettings` builtin + `masteringSettings.vstFxChain`. Chỉ hợp nhất ở tầng render + thao tác (thêm/xóa/kéo sắp xếp/bypass ghi vào đúng mảng gốc).
- Picker thêm slot: 2 tab — "Module tự viết" (TRACK_FX_META + mastering built-ins) và "VST FX" (`listFx` → `vstFxChain`), chèn vào đúng vị trí kéo thả.
- **Bước C — Đổi tên toàn bộ hiển thị:**
- `FXRackModal` header → `{track.name} — FX CHAIN`.
- `MasteringModal` header → `MASTER FX CHAIN` (bỏ "MASTERING SUITE"/"WEB MASTERING V10.5" hoặc chuyển thành subtitle nhỏ).
- Nút `MasterStripConsole` → "FX CHAIN".
- Menu File "Mastering Suite" → "Master FX Chain" (giữ Ctrl+Shift+M).
- Cập nhật tooltip "nút A/♪" mô tả ("Mastering FX Chain" → "Master FX Chain").
- **Bước D — Đảm bảo chain áp dụng đúng chỗ (không đổi logic DSP):**
- Track: giữ graph hiện có — `buildOfflineTrackNode`/`__rebuildTrackFxGraph`/`fxActive` PWR (đừng đụng nếu đang chạy đúng).
- Master: giữ `toggleMasteringOnMaster` + `NativeBridgeService.setFxChain` (anchor ~12799) cho VST master realtime.
- Export: clientSideExport/bounce đã đi qua cả 2 chain — verify không regression.
### 3.4 Verify
- Mở FX CHAIN master: thêm 1 module tự viết (EQ) + 1 VST FX → header "MASTER FX CHAIN", cả 2 hiện trong 1 chain, kéo thay đổi thứ tự, bypass từng slot, nghe thay đổi realtime, export ra đúng chain.
- Mở project cũ (lưu trước phase): chain cũ vẫn hiện đúng (không mất cấu hình) — test migration state.
- Không còn chuỗi "MASTERING PANEL"/"FX RACK PANEL" trong UI (grep app.jsx).
---
## PHASE 4 — NÚT FX TRÊN TCP MỞ FX CHAIN CỦA TRACK
### 4.1 Hiện trạng
- Nút `FX` trên TCP track đã gọi `window.__openFxRack(track.id, track.name)` (anchor ~2740) → `fxRackTarget` → `FXRackModal` (anchor ~32645). **Phần wiring có vẻ đã có** — cần xác nhận nó còn hoạt động sau Phase 3 refactor.
### 4.2 Giải pháp
- **Bước A — Kiểm tra hiện trạng:** mở app thật, chọn track → bấm nút FX → modal có mở không? Chain có hiện đúng `track.fxChain` + `track.vstFxChain` không?
- **Bước B — Fix nếu hỏng:** các nguyên nhân có thể: `tracks.find(t => t.id === fxRackTarget.trackId)` trả null (id mismatch giữa `tracks` và `activeTracks`/sessionTabs), hoặc `FXRackModal` trả `null` sớm khi `track` undefined. Sửa lookup: dùng `allTracks` gồm main + session tabs (`(activeTracksRef.current || tracks)` như triggerWavExport làm).
- **Bước C — Sau Phase 3:** nút FX mở `FxChainModal` với `target = { kind: 'track', trackId }` → header "FX CHAIN" hiển thị chain track.
### 4.3 Verify
- Bấm nút FX trên track A → modal FX CHAIN của track A (đúng tên, đúng chain); bấm trên track B → chain track B; đóng/mở nhiều lần không lỗi; VST GUI embed (nếu có) vẫn mở được từ chain.
---
## PHASE 5 — VIRTUAL MIDI KEYBOARD (spec `virtual_keyboard_and_chords_panel_spec.md` Part I)
### 5.1 Nền tảng có sẵn
- `app/static/js/services/unifiedMidiRouter.js` đã có: `dispatchMidiEvent({ command: 'NOTE_ON'|'NOTE_OFF', channel, pitch, velocity, sourceType })`, `allocateChannel(trackId, isPercussion)` (ch 0-8,10-14 melodic / 9 percussion), `bridgeConnected` → NativeBridgeService hoặc `onFallback` (SF/WASM). → Tận dụng, không viết router mới.
- Audio preview: nếu bridge active → `NativeBridgeService.dispatchMidiEvent` (âm thật VSTi/SF2 đã load trên channel); nếu không → fallback hiện có (SonicSF/Carla) — giữ nguyên cơ chế.
- Recording: transport Record + track armed đã có; cần hook NOTE_ON/NOTE_OFF vào recorder tạo note trong `midiItems`.
### 5.2 Giải pháp (thêm vào `app.jsx` — nhất quán monolith; code thuần logic tách service nếu >100 dòng)
- **Bước A — Component `VirtualMidiKeyboard`:**
- Floating window draggable, luôn trên timeline (`z-index` cao, không nuốt phím tắt transport Space/R).
- 2 octave keybed hiển thị mapping bảng spec §3: `Z S X D C V G B N M J` (root octave) + `Q 2 W 3 E R 5 T 6 Y 7 U I` (+1 oct), `O` (+2 C). Vẽ key trắng/đen theo Key Type.
- Control bar: Oct -/+, Octave Shift hiển thị (default C4), Velocity slider (1..127, default 100), Channel select (1..16, mặc định channel của track đang chọn/arm), Transpose, Scale Highlight (dropdown scale — phục vụ highlight, không bắt buộc chặn phím).
- Toggle: phím **F2** + menu **Tools → Virtual MIDI Keyboard**. Không chặn khi focus trong `<input>/<textarea>`.
- Pitch formula đúng spec: `Pitch = (Octave + 1) * 12 + PitchOffset` với root = C.
- KeyDown: bỏ `e.repeat`; highlight key; `dispatchMidiEvent NOTE_ON` (velocity 0..1 = slider/127); KeyUp: NOTE_OFF.
- **Live record:** nếu transport đang Record và track đang arm → tạo note `{ pitch, start_beat, duration_beats, velocity }` vào `item.source_data.notes`/`midiItems[].notes` của item MIDI đang mở (hoặc item active) → dispatch `DAW_STATE_UPDATED` để canvas redraw (pattern đã có trong app).
- Persist `localStorage`: `{ visible, x, y, octave, velocity, channel, transpose, scale }`; khôi phục khi mở.
- **Bước B — State + menu:**
- `const [vkbOpen, setVkbOpen] = useState(false)` + ref lưu rect.
- Global keydown listener cho F2 (tránh trùng transport), menu Tools thêm item.
- **Bước C — Âm thanh:** không code engine mới — chỉ gọi router. Nếu track chưa có instrument: giữ hành vi fallback hiện có (SF mặc định/Carla), kèm toast khi track chưa arm/chưa có instrument.
### 5.3 Verify (spec §IV checklist 1-3)
- F2 mở/đóng; kéo thả được; vị trí nhớ sau reload.
- Bấm Z/S/X/D/C/V/G/B/N/M/J/Q/2/W/3/E/R/5/T/6/Y/7/U/I → key sáng, âm phát (bridge VSTi nếu có, không thì SF fallback); octave +/- đổi cao độ đúng 12 semitone.
- Record: arm track → R → bấm phím → note hiện realtime trên timeline/piano roll → Stop → MIDI item hoàn chỉnh.
- Bấm phím trong ô text input → không trigger note (pass-through).
---
## PHASE 6 — CHORDS PANEL (spec Part II)
### 6.1 Giải pháp
- **Bước A — Component `ChordsPanel`** (modal centered / dock phải):
- Trigger: menu **Insert → Chords panel**, phím **Shift+K**, nút 🎼 trên piano roll toolbar.
- 3 tab: **Preset Library | Custom Builder | Internet/AI Search** (AI tab defer — xem 6.3).
- Filter: Style, Key, Scale, Search box.
- **Bước B — Preset Library** (bảng spec §III.2, hardcode 6 styles):
- Pop/Ballad `I-V-vi-IV`, Jazz/Neo-Soul `ii7-V7-Imaj7-VI7`, Cinematic/Epic `i-VI-III-VII`, EDM `vi-IV-I-V`, Lofi `i7-iv7-v7-i7`, R&B `Imaj7-iii7-IVmaj7-V13`.
- Mỗi preset: nút [Preview] (gửi note tạm qua router NOTE_ON/NOTE_OFF nhanh), [Insert to Timeline], [Favorite ★] (localStorage).
- **Bước C — Custom Builder:**
- Input: tên, genre tag, chord tokens (roman numeral `I-vi-IV-V` hoặc chord name `Cmaj7-Am9-Fadd9-G13`), rhythm pattern (whole/quarter/arpeggio/strum).
- Lưu `localStorage['daw_user_custom_chords']`; dispatch `CUSTOM_CHORD_SAVED` để panel tự refresh (spec §III.3).
- **Bước D — Chord insertion engine (spec §III.5):**
- Parse chord symbol → pitch array (ví dụ `Cmaj7` = [60,64,67,71]; áp voicing: root/first inversion/drop-2).
- Insert tại playhead (hoặc beat click): `StartBeat(Ck) = Beat_insert + Σ duration trước`; tạo `MIDINote { pitch, start_beat, duration_beats, velocity: 0.8 }` vào MIDI item active; dispatch `DAW_STATE_UPDATED`.
- Code parse thuần hàm → để service riêng `app/static/js/services/chordTheory.js` (test được, xem §6.4).
- **Bước E — Internet/AI Search:**
- Tận dụng `app/static/js/services/aiGateway.js` (đã có tool-calling infra). Thêm tool `search_or_generate_chords` + endpoint backend `/api/v1/ai/search-chords` (FastAPI, gọi model qua aiGateway/AI_PROXY hiện có).
- Kết quả hiển thị progression + [Listen Preview] [Save] [Insert] — spec §III.4.
- **Defer nếu AI backend chưa cấu hình key:** tab hiện thông báo "AI chưa cấu hình" thay vì crash — xem rủi ro.
### 6.2 Verify (spec §IV checklist 4-7)
- Shift+K / Insert menu mở panel; chọn Jazz Neo-Soul → Insert → `Dm7-G7-Cmaj7-A7` chèn đúng vị trí playhead, đúng duration.
- Custom builder lưu → xuất hiện lại sau reload; event CUSTOM_CHORD_SAVED refresh không cần reload trang.
- AI search (nếu cấu hình): query "Hotel California" → progression Bm-F#7-A-E7-G-D-Em-F#7.
### 6.3 Defer (`ponytail:`)
- Scale Highlight chặn phím ngoài scale (chỉ highlight, không chặn) — thêm khi có yêu cầu.
- Arpeggiation/strum preview phức tạp trong preset (chỉ sustain preview) — thêm khi cần.
- AI chord search phụ thuộc key model — tách phase riêng nếu chưa có key.
### 6.4 Test đơn vị (non-trivial logic)
- `chordTheory.js`: 1 file test nhỏ (Node assert, không framework) — parse `Cmaj7`, `Am9`, `Dm7`, `G13`, `I-vi-IV-V` với key C, voicing root/inversion/drop-2 ra đúng pitch array. Chạy: `node app/static/js/services/chordTheory.test.js`.
---
## PHASE 7 — BUILD TỔNG & VERIFICATION CHECKLIST
```bash
node build.mjs
python -m PyInstaller engine.spec --clean --noconfirm
cp -r dist/daw_engine/* src-tauri/target/release/daw_engine/
```
| # | Test | Kỳ vọng |
|---|------|---------|
| 1 | Export MIDI (project 2 track MIDI) | File .mid tải về, mở đúng, velocity 1..127 |
| 2 | Export Mix WAV loop 4 bars | Đúng độ dài, không fade ở biên, loop nối mượt |
| 3 | Export Mix WAV bình thường | Không regression, có FX CHAIN + master |
| 4 | Export mp3/ogg không server | Fallback WAV + toast |
| 5 | Master FX CHAIN | Header mới, builtin + VST 1 chain, realtime + export đúng |
| 6 | Track FX CHAIN qua nút FX TCP | Mở đúng track, chain đủ builtin + VST |
| 7 | Project cũ sau refactor | Chain cũ hiện đúng, không mất cấu hình |
| 8 | F2 Virtual MIDI Keyboard | Mở/đóng, bấm phím ra âm, record vào timeline |
| 9 | Chords panel | Insert progression đúng vị trí, custom lưu được |
| 10 | grep UI | Không còn "MASTERING PANEL"/"FX RACK PANEL" |
Commit theo từng phase (message tiếng Việt, kiểu `fix(export): ...`), `git status` sạch cuối mỗi phase.
---
## RỦI RO / LƯU Ý
- **WebView2 blob download** là nghi vấn chính cho cả Phase 1 & 2 — xác nhận bằng chẩn đoán trước khi chọn hướng fix (server-download là hướng an toàn nhất vì đã có pattern `/api/v1/audio/download/{file_id}`).
- **Rebuild engine bắt buộc** sau mọi đổi frontend — quên copy `dist/daw_engine/*` là test nhầm bản cũ (đã vấp ở phase trước).
- **Auth API**: endpoint export mới nên dùng `get_optional_user` (không bắt buộc token) cho đồng bộ với `/mix` hiện tại, nhưng nếu UI đã có token thì gửi kèm `Authorization: Bearer` (an toàn).
- **Không đụng logic DSP** khi refactor FX CHAIN (chỉ đổi tên/hợp nhất render) — giảm rủi ro regression âm thanh.
- **state cũ project**: `fxChain`/`vstFxChain`/`masteringSettings.vstFxChain` giữ nguyên schema — không migration dữ liệu.
+53
View File
@@ -0,0 +1,53 @@
# IMPLEMENT_PHASE_3 — Nhật ký thực hiện BIG_PHASE_3
> Cập nhật theo từng phase. Trạng thái: ✅ hoàn thành · 🔄 đang làm · ⏳ chưa làm · ⚠️ cần xác nhận/review
> Repo: `C:/Users/locpham/SonicForgeStudio` · branch: `standalone-shm-bridge`
## TỔNG QUAN
| Phase | Nội dung | Trạng thái | Ghi chú |
|-------|----------|-----------|---------|
| 1 | Fix Export MIDI | ✅ | backend + frontend + build + verify xong (commit riêng) |
| 2 | Fix Export Mix + loop | ⏳ | |
| 3 | FX CHAIN (hợp nhất modal) | ⏳ | |
| 4 | Nút FX TCP → FX CHAIN | ⏳ | wiring đã có, cần verify |
| 5 | Virtual MIDI Keyboard | ⏳ | |
| 6 | Chords Panel | ⏳ | AI search defer |
| 7 | Build tổng + verify | ⏳ | |
---
## PHASE 1 — FIX EXPORT MIDI
### Đã làm
- [x] Khảo sát: 2 nơi export MIDI đều dùng blob download (`a.click()` + `URL.createObjectURL`) — nghi vấn WebView2 chặn (đã fix bằng server download, verify chạy thật OK).
- [x] Backend: thêm `POST /api/v1/export/midi` vào `app/api/v1/audio.py` — sinh file .mid format 1 (stdlib thuần), lưu vào PROCESSED_DIR, trả `file_id` + `download_url` (download qua GET network — pattern đã chạy ổn định). Bỏ track không nốt; velocity 0..1 → scale 1..127.
- [x] Frontend: thêm helper `serverDownloadMidi(payload, filename)` (POST endpoint → anchor download `${API_BASE_URL}${download_url}`); đổi `triggerMidiExport()` (File menu, bỏ velocity `*100`, bỏ rest-track pitch 0) + nút piano roll "Export MIDI" (payload 1 track, guard không nốt).
- [x] Build: `node build.mjs` OK + PyInstaller rebuild engine + copy `dist/daw_engine/*` sang `src-tauri/target/release/daw_engine/` + bump cache-bust `app.precompiled.js?v=202608211103`.
- [x] Verify: chạy app thật (engine mới), POST `/export/midi` → GET download → parse bytes: format 1, division 480, đủ track, velocity đúng (0.5→64, 1.0→127, 0.8→102), track rỗng bị bỏ (2→1 track).
### Tồn tại / cần bổ sung
- (trống)
### Cần xác nhận (review)
- (trống)
---
## PHASE 2 — FIX EXPORT MIX + LOOP
(chưa bắt đầu)
## PHASE 3 — FX CHAIN
(chưa bắt đầu)
## PHASE 4 — NÚT FX TCP
(chưa bắt đầu)
## PHASE 5 — VIRTUAL MIDI KEYBOARD
(chưa bắt đầu)
## PHASE 6 — CHORDS PANEL
(chưa bắt đầu)
## PHASE 7 — BUILD TỔNG & VERIFY
(chưa bắt đầu)
+84 -1
View File
@@ -56,6 +56,21 @@ class ExportRequest(BaseModel):
sample_rate: int = 44100
bit_depth: int = 16
class MidiNoteModel(BaseModel):
pitch: int = 60
start_beat: float = 0.0
duration_beats: float = 1.0
velocity: float = 0.8 # 0..1
class MidiExportTrackModel(BaseModel):
name: str = "Track"
notes: List[MidiNoteModel] = []
class MidiExportRequest(BaseModel):
bpm: float = 120.0
ppq: int = 480
tracks: List[MidiExportTrackModel] = []
class AIAnalysisRequest(BaseModel):
file_id: str
api_base_url: Optional[str] = None
@@ -153,7 +168,8 @@ async def download_audio(file_id: str):
path = _resolve_storage_path(file_id)
if not path:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
media = "audio/midi" if os.path.splitext(path)[1].lower() == ".mid" else "audio/wav"
return FileResponse(path, media_type=media, filename=os.path.basename(path))
@router.get("/waveform/{file_id}")
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
@@ -226,6 +242,73 @@ async def export_audio(req: ExportRequest, current_user: Optional[dict] = Depend
"file_id": _safe_file_id(req.file_id)
}
# ── MIDI export (BIG_PHASE_3 Phase 1) ──
# Sinh file .mid format 1 bằng stdlib thuần — trước đây frontend tự build bytes
# rồi blob-download (bị WebView2 chặn sau khi chuyển standalone). Server sinh
# file → GET /download/{file_id} (network download, pattern đã chạy ổn định).
def _write_vlq(data: bytearray, value: int) -> None:
buf = [value & 0x7F]
while value > 0x7F:
value >>= 7
buf.append(0x80 | (value & 0x7F))
for b in reversed(buf):
data.append(b)
def _build_midi_file(req: MidiExportRequest) -> bytes:
import struct
tpb = max(1, req.ppq or 480)
track_chunks = []
for tr in req.tracks:
events = []
for n in tr.notes:
if (n.duration_beats or 0) <= 0:
continue
pitch = max(0, min(127, int(round(n.pitch or 60))))
vel = max(1, min(127, int(round((n.velocity if n.velocity is not None else 0.8) * 127))))
start = max(0, int(round((n.start_beat or 0) * tpb)))
end = start + max(1, int(round((n.duration_beats or 1) * tpb)))
events.append((start, 0x90, pitch, vel))
events.append((end, 0x80, pitch, 0))
if not events:
continue # bỏ track không có nốt (trước đây chèn "rest note" pitch 0)
# note_off (0x80) trước note_on (0x90) cùng tick — tránh note kẹt
events.sort(key=lambda e: (e[0], 0 if e[1] == 0x80 else 1))
body = bytearray()
name_bytes = (tr.name or "Track").encode("utf-8", "replace")[:255]
body.append(0xFF); body.append(0x03); body.append(len(name_bytes)); body.extend(name_bytes)
last = 0
for tick, status, pitch, vel in events:
_write_vlq(body, tick - last)
last = tick
body.append(status); body.append(pitch); body.append(vel)
_write_vlq(body, 0)
body.append(0xFF); body.append(0x2F); body.append(0x00)
chunk = bytearray(b"MTrk")
chunk += struct.pack(">I", len(body))
chunk += body
track_chunks.append(bytes(chunk))
if not track_chunks:
return b""
header = bytearray(b"MThd")
header += struct.pack(">IHHH", 6, 1, len(track_chunks), tpb)
return bytes(header) + b"".join(track_chunks)
@router.post("/export/midi")
async def export_midi(req: MidiExportRequest, current_user: Optional[dict] = Depends(get_optional_user)):
if current_user:
enforce_password_changed(current_user)
data = _build_midi_file(req)
if not data:
raise HTTPException(status_code=400, detail="Không có dữ liệu MIDI nào để xuất")
file_id = "midi_export_%s.mid" % uuid.uuid4().hex[:12]
with open(os.path.join(settings.PROCESSED_DIR, file_id), "wb") as f:
f.write(data)
return {
"file_id": file_id,
"download_url": "/api/v1/audio/download/%s" % file_id,
"bytes": len(data)
}
@router.post("/ai-scan")
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
"""
+67 -148
View File
@@ -10482,53 +10482,27 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
onClick: () => {
const ppq = 480;
const bpmNum = parseInt(bpm) || 120;
const ticksPerBeat = ppq;
const events = [];
(notes || []).forEach(n => {
const startTick = Math.round((n.start_beat || 0) * ticksPerBeat);
const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat);
const pitch = n.pitch || 60;
const vel = Math.round((n.velocity || 0.8) * 127);
events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
});
events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1));
const writeVLQ = (bytes, v) => {
let val = Math.max(0, v);
const buf = [];
buf.push(val & 0x7F);
while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); }
for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]);
onClick: async () => {
if (!notes || !notes.length) { showToast('Chưa có nốt nhạc để xuất MIDI', 'warning'); return; }
const payload = {
bpm: parseFloat(bpm) || 120,
ppq: 480,
tracks: [{
name: st.label || 'Track',
notes: (notes || []).map(n => ({
pitch: n.pitch || 60,
start_beat: n.start_beat || 0,
duration_beats: n.duration_beats || 1,
velocity: n.velocity != null ? n.velocity : 0.8
}))
}]
};
const trackBytes = [];
let lastTick = 0;
events.forEach(ev => {
const delta = Math.max(0, ev.tick - lastTick);
writeVLQ(trackBytes, delta);
trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity);
lastTick = ev.tick;
});
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
const trackData = [0x4D, 0x54, 0x72, 0x6B];
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF];
const all = header.concat(trackData);
const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (st.label || 'midi') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Đã xuất file MIDI!', 'success');
try {
await serverDownloadMidi(payload, (st.label || 'midi') + '.mid');
showToast('Đã xuất file MIDI!', 'success');
} catch (err) {
showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error');
}
},
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"), React.createElement("button", {
@@ -26056,117 +26030,62 @@ const App = () => {
};
// ── MIDI Export: tạo file MIDI từ tất cả tracks ──
const triggerMidiExport = () => {
// ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ──
// Blob download bị WebView2 chặn sau khi chuyển standalone → POST
// /api/v1/export/midi (server sinh file .mid) + GET download (network
// download, Content-Disposition attachment — pattern đã chạy ổn định).
// velocity gửi dạng 0..1 — backend scale sang 1..127 (sửa bug *100 cũ).
function serverDownloadMidi(payload, filename) {
return fetch(`${API_AUDIO}/export/midi`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(async res => {
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.detail || ('HTTP ' + res.status));
}
return res.json();
}).then(data => {
const a = document.createElement('a');
a.href = `${API_BASE_URL}${data.download_url}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return data;
});
}
const triggerMidiExport = async () => {
const bpmNum = parseFloat(bpm) || 120;
const ppq = 480; // Pulses Per Quarter Note
const ticksPerBeat = ppq;
const beatDuration = 60 / bpmNum;
// Build MIDI tracks
let midiTracks = [];
let currentTrackNum = 0;
const midiTracks = [];
tracks.forEach(track => {
currentTrackNum++;
const events = [];
let hasNotes = false;
// Collect MIDI events from midiItems
const midiItems = track.midiItems || [];
midiItems.forEach(item => {
const notes = item.notes || [];
notes.forEach(note => {
hasNotes = true;
const startTick = Math.round(note.startBeat * ticksPerBeat);
const durTick = Math.round(note.durationBeats * ticksPerBeat);
const velocity = Math.round((note.velocity || 0.8) * 100);
const pitch = note.pitch || 60;
events.push({ tick: startTick, type: 'note_on', pitch, velocity });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
const notes = [];
(track.midiItems || []).forEach(item => {
(item.notes || []).forEach(note => {
notes.push({
pitch: note.pitch || 60,
start_beat: note.startBeat || note.start_beat || 0,
duration_beats: note.durationBeats || note.duration_beats || 1,
velocity: note.velocity != null ? note.velocity : 0.8
});
});
});
// If track has audio buffer but no MIDI, create a "rest" track with one silent note
if (!hasNotes && track.buffer) {
const durSec = track.buffer.duration;
const durBeats = durSec / beatDuration;
const durTicks = Math.round(durBeats * ticksPerBeat);
// Use a C-2 (pitch 0 = rest note) indicator
events.push({ tick: 0, type: 'note_on', pitch: 0, velocity: 1 });
events.push({ tick: durTicks, type: 'note_off', pitch: 0, velocity: 0 });
}
if (events.length === 0 && !track.buffer) return; // Skip empty tracks
// Sort events by tick
events.sort((a, b) => a.tick - b.tick);
// MIDI track header bytes
const trackBytes = [];
// Track name
const nameStr = (track.name || ('Track ' + currentTrackNum)).slice(0, 255);
trackBytes.push(0xFF, 0x03, nameStr.length);
for (let i = 0; i < nameStr.length; i++) trackBytes.push(nameStr.charCodeAt(i));
// End of track marker will be calculated later
let lastTick = 0;
events.forEach(ev => {
const delta = ev.tick - lastTick;
lastTick = ev.tick;
// Delta time as variable-length quantity
writeVLQ(trackBytes, delta);
if (ev.type === 'note_on') {
trackBytes.push(0x90, ev.pitch, ev.velocity);
} else {
trackBytes.push(0x80, ev.pitch, 0);
}
});
// End of track
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
// Track chunk: "MTrk" + length + data
const trackData = [0x4D, 0x54, 0x72, 0x6B]; // "MTrk"
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
midiTracks.push(trackData);
// bỏ track không có nốt MIDI (trước đây chèn "rest note" pitch 0)
if (notes.length) midiTracks.push({ name: track.name || 'Track', notes });
});
if (midiTracks.length === 0) {
if (!midiTracks.length) {
showToast("Không có dữ liệu MIDI nào để xuất.", "warning");
return;
}
// Header: "MThd" + length(6) + format(1) + tracks + division
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, (midiTracks.length >> 8) & 0xFF, midiTracks.length & 0xFF, (ppq >> 8) & 0xFF, ppq & 0xFF];
const allBytes = header.concat(...midiTracks.flat());
const uint8 = new Uint8Array(allBytes);
const blob = new Blob([uint8], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (projectName || 'Project') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
};
function writeVLQ(bytes, value) {
if (value < 0) value = 0;
const buf = [];
buf.push(value & 0x7F);
while (value > 0x7F) {
value >>= 7;
buf.push(0x80 | (value & 0x7F));
try {
await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid');
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
} catch (err) {
showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error');
}
buf.reverse();
buf.forEach(b => bytes.push(b));
}
};
// ── Insert Track Below Selected ──
const insertTrackBelow = () => {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -50,7 +50,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/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608182235" defer></script>
<script src="/static/js/app.precompiled.js?v=202608211103" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+373
View File
@@ -0,0 +1,373 @@
# DESIGN SPECIFICATION & IMPLEMENTATION ROADMAP: FLOATING VIRTUAL MIDI KEYBOARD & CHORDS PANEL ENGINE
This document specifies the technical architecture, UI/UX design, data flow diagrams, and interaction matrices for integrating two core features into the DAW system:
1. **Floating Virtual MIDI Keyboard:** A draggable floating virtual keyboard window that receives input from computer QWERTY keys, supporting both live preview and real-time recording of MIDI notes directly into the Timeline / Piano Roll.
2. **Comprehensive Scale & Chords System / Insert Chords Panel:** A multi-genre chord theory engine, automated chord progression insertion panel, custom chord builder/storage, and internet/AI chord lookup system.
---
## I. SYSTEM ARCHITECTURE OVERVIEW
```text
┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│ CLIENT FRONTEND STUDIO │
│ │
│ ┌─────────────────────────────────────────┐ ┌─────────────────────────────────────┐ │
│ │ Menu: Tools -> Virtual MIDI Keyboard │ │ Menu: Insert -> Chords panel │ │
│ │ Shortcut: [F2] │ │ Shortcut: [Shift + K] │ │
│ └────────────────────┬────────────────────┘ └──────────────────┬──────────────────┘ │
└────────────────────────┼─────────────────────────────────────────────────┼──────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐ ┌────────────────────────────────────────┐
│ FLOATING VIRTUAL MIDI KEYBOARD (UI OVERLAY) │ │ INSERT CHORDS PANEL (MODAL / SIDEBAR) │
│ - Visual 25/49-Keybed Rendering │ │ - Style Catalog (Pop, Jazz, Epic...) │
│ - QWERTY Key Mapping Engine │ │ - Custom Chord Builder & LocalStorage │
│ - Octave Shift (-4 to +4) & Velocity Adjuster │ │ - Internet / AI Chord Search Engine │
└────────────────────────┬─────────────────────────┘ └─────────────────┬──────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────────────────────────┐
│ UNIFIED MIDI ROUTER & DISPATCHER │
│ - Normalizes to `UnifiedMidiEvent`: { command, channel, pitch, velocity, timestamp } │
└────────────────────────┬─────────────────────────────────────────────────┬──────────────────────┘
│ │
├─────────────────────────────────┐ │
▼ ▼ ▼
┌──────────────────────────────────────────────────┐ ┌────────────────────────────────────────────┐
│ REAL-TIME SYNTH ENGINE (CLIENT WASM / BRIDGE) │ │ CLIENT MIDI RECORDER & TIMELINE INGESTION │
│ - Real-time Audio Preview (< 5ms Latency) │ │ - Live Recording to active `MIDIItem` │
│ - FluidSynth WASM / Native VSTi Bridge │ │ - Direct Canvas Redraw on Piano Roll │
└──────────────────────────────────────────────────┘ └────────────────────────────────────────────┘
```
---
## II. PART I: FLOATING VIRTUAL MIDI KEYBOARD SYSTEM
### 1. Activation & Floating Window Management
* **Activation Triggers:**
* **System Shortcut:** **F2** (Toggle On/Off).
* **Menu Bar:** **Tools ▾ -> Virtual MIDI Keyboard**.
* **Window Properties:**
* **Floating & Draggable:** Allows dynamic positioning across MAIN SESSION, SECTION-TAB, and PIANO ROLL TAB views.
* **Always-on-Top / Z-Index Isolation:** Renders above all Timeline Canvases without capturing global Transport hotkeys (**Space** for Play/Stop, **R** for Record).
* **State Persistence:** Saves coordinates ($x, y$), active toggle state, current octave offset, and default velocity to `localStorage`.
---
### 2. Virtual Keyboard User Interface Layout
```text
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ 🎹 VIRTUAL MIDI KEYBOARD [–] [X] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ [ Octave: -1 ] [ Octave: +1 ] │ Octave Shift: 4 (C4-C6) │ Velocity: [ 100 ] [Slider---|] │
│ [ Channel: 1 ▾ ] │ Transpose: 0 semitones │ Scale Highlight: [ C Minor ▾ ] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | │
│ | |W| |E | |T| |Y| |U | |2| |3 | |5| |6| |7 | | | | | | | | | | | │
│ | |_| |_| | |_| |_| |_| | |_| |_| | |_| |_| |_| | |_| |_| | |_| |_| |_| | │
│ | A | S | D | F | G | H | J | K | Q | W | E | R | T | Y | U | I | O | P | | | | │
│ └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘ │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
#### Keyboard Control Bar Components:
* **Octave Down / Up (`[Oct -]`, `[Oct +]`):** Shifts pitch octave range (Shortcuts: **Shift + Z** / **Shift + X** or **[** / **]**).
* **Velocity Slider:** Adjusts note velocity from **1 to 127** (Default: **100**).
* **Target MIDI Channel:** Selects MIDI Channel from **1 to 16** (Defaults automatically to the currently selected/armed track's channel).
* **Scale Highlight Toggle:** Visual keybed guide highlighting keys that belong to the active musical scale.
---
### 3. Computer QWERTY Keyboard Mapping Matrix
Uses two rows of QWERTY keys to span two continuous octaves:
#### Lower Octave (Root Base):
| Computer Key | Relative Pitch | Note Name | Key Type |
| --- | --- | --- | --- |
| **Z** | $\text{Root} + 0$ | C | White Key |
| **S** | $\text{Root} + 1$ | C# / Db | Black Key |
| **X** | $\text{Root} + 2$ | D | White Key |
| **D** | $\text{Root} + 3$ | D# / Eb | Black Key |
| **C** | $\text{Root} + 4$ | E | White Key |
| **V** | $\text{Root} + 5$ | F | White Key |
| **G** | $\text{Root} + 6$ | F# / Gb | Black Key |
| **B** | $\text{Root} + 7$ | G | White Key |
| **H** | $\text{Root} + 8$ | G# / Ab | Black Key |
| **N** | $\text{Root} + 9$ | A | White Key |
| **J** | $\text{Root} + 10$ | A# / Bb | Black Key |
| **M** | $\text{Root} + 11$ | B | White Key |
#### Upper Octave ($+12$ Semitones):
| Computer Key | Relative Pitch | Note Name | Key Type |
| --- | --- | --- | --- |
| **Q** | $\text{Root} + 12$ | C (+1 Oct) | White Key |
| **2** | $\text{Root} + 13$ | C# (+1 Oct) | Black Key |
| **W** | $\text{Root} + 14$ | D (+1 Oct) | White Key |
| **3** | $\text{Root} + 15$ | D# (+1 Oct) | Black Key |
| **E** | $\text{Root} + 16$ | E (+1 Oct) | White Key |
| **R** | $\text{Root} + 17$ | F (+1 Oct) | White Key |
| **5** | $\text{Root} + 18$ | F# (+1 Oct) | Black Key |
| **T** | $\text{Root} + 19$ | G (+1 Oct) | White Key |
| **6** | $\text{Root} + 20$ | G# (+1 Oct) | Black Key |
| **Y** | $\text{Root} + 21$ | A (+1 Oct) | White Key |
| **7** | $\text{Root} + 22$ | A# (+1 Oct) | Black Key |
| **U** | $\text{Root} + 23$ | B (+1 Oct) | White Key |
| **I** | $\text{Root} + 24$ | C (+2 Oct) | White Key |
---
### 4. Audio Playback & Recording Event Pipeline
#### Key Press Handler (`KeyDown` Event):
1. Verifies `e.repeat` is false to prevent event flood on key holds.
2. If the active DOM element is a text input field (`<input>`, `<textarea>`), passes through and skips the piano handler.
3. Computes the absolute MIDI Pitch value:
$$Pitch = (Octave + 1) \times 12 + PitchOffset$$
4. Highlights the corresponding visual key on the Virtual Keyboard UI.
5. Dispatches the event to the `UnifiedMidiRouter`:
```javascript
unifiedMidiRouter.dispatchMidiEvent({
command: 'NOTE_ON',
channel: activeTrackChannel,
pitch: calculatedPitch,
velocity: currentVirtualVelocity,
sourceType: 'VIRTUAL_KEYBOARD'
});
```
6. **Live Recording Mode (If Transport Record is ACTIVE):**
The `ClientMIDIRecorder` captures the `NOTE_ON` event, creates a note instance in the active `MIDIItem` on the armed track, and invokes a `requestAnimationFrame` loop to render real-time red/orange preview blocks on the Timeline and Piano Roll canvases.
#### Key Release Handler (`KeyUp` Event):
1. Removes key highlight from the Virtual Keyboard UI.
2. Dispatches a `NOTE_OFF` event to `UnifiedMidiRouter`.
3. `ClientMIDIRecorder` calculates total note duration:
$$Duration = Beat_{\text{release}} - Beat_{\text{press}}$$
and writes the finalized note data to the clip's state array.
---
## III. PART II: COMPREHENSIVE SCALE & CHORD SYSTEM & CHORDS PANEL
### 1. Chords Panel Trigger
* **Activation Triggers:**
* **Menu Bar:** **Insert ▾ -> Chords panel**.
* **System Shortcut:** **Shift + K** or clicking the 🎼 **Chords** icon on the Piano Roll Toolbar.
* **Display Format:**
* Centered modal window or sliding dockable panel positioned on the right side of the Piano Roll.
---
### 2. Style-Based Chord Catalog
Contains pre-coded chord progressions categorized by musical genre and emotional mood:
```text
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ 🎼 INSERT CHORDS PANEL [X] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ [ Filter Style: Pop / Ballad ▾ ] [ Key: C ▾ ] [ Scale: Major ▾ ] [ Search: _______ ] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ PRESET PROGRESSIONS: │
│ ┌────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 🌟 Pop Classic Emotional (I - V - vi - IV) │ │
│ │ Chords: Cmaj -> Gmaj -> Am -> Fmaj | Voicing: Root Position | Rhythm: 1/2 Beat │ │
│ │ [ Preview Sound ] [ Insert to Timeline ] [ Favorite ★ ] │ │
│ ├────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ 🎷 Jazz Neo-Soul Smooth (ii7 - V7 - Imaj7 - VI7) │ │
│ │ Chords: Dm7 -> G7 -> Cmaj7 -> A7 | Voicing: Drop-2 | Rhythm: Syncopated │ │
│ │ [ Preview Sound ] [ Insert to Timeline ] [ Favorite ★ ] │ │
│ ├────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ 🎬 Epic Film Score Rising (i - VI - III - VII) │ │
│ │ Chords: Cm -> Ab -> Eb -> Bb | Voicing: Power Octaves | Rhythm: 8th Arp │ │
│ │ [ Preview Sound ] [ Insert to Timeline ] [ Favorite ★ ] │ │
│ └────────────────────────────────────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ TAB: [ Preset Library ] [ Custom Chord Builder ] [ Internet / AI Search ] │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
#### Music Style Catalog Matrix:
| Genre / Style | Standard Progression (Roman Numerals) | Transposed Example (Root Key C) | Voicing & Rhythmic Characteristics |
| --- | --- | --- | --- |
| **Pop / Ballad** | $I - V - vi - IV$ | $C - G - Am - F$ | Sustained Whole Notes, Triad Voicings |
| **Jazz / Neo-Soul** | $ii^7 - V^7 - I^{\text{maj}7} - VI^7$ | $Dm^7 - G^7 - C^{\text{maj}7} - A^7$ | Drop-2 Voicings, Off-beat Syncopation |
| **Cinematic / Epic** | $i - VI - III - VII$ | $Cm - Ab - Eb - Bb$ | Low-octave bass root, Octave layering, 8th-note arpeggiation |
| **EDM / Future Bass** | $vi - IV - I - V$ | $Am - F - C - G$ | 16th-note synth stabs, Inverted voicings |
| **Lofi Chillhop** | $i^7 - iv^7 - v^7 - i^7$ | $Cm^7 - Fm^7 - Gm^7 - Cm^7$ | Add9 / Min7 Extensions, Micro-timing jitter |
| **R&B / Soul** | $I^{\text{maj}7} - iii^7 - IV^{\text{maj}7} - V^{13}$ | $C^{\text{maj}7} - Em^7 - F^{\text{maj}7} - G^{13}$ | Rolled Strumming, 9th/11th Extensions |
---
### 3. Custom Chord Builder & Storage
Enables users to create and store custom chord progressions persistently for subsequent sessions:
#### Custom Builder Input Fields:
* **Progression Name:** e.g., *"Sad Indie Pop Progression 2026"*.
* **Genre Category:** Dropdown selection or free-text tag.
* **Chord Tokens:**
* Roman Numeral input: `I - vi - IV - V`
* Direct Chord Name input: `Cmaj7 - Am9 - Fadd9 - G13`
* **Rhythm Pattern:** Whole Note (Sustained), Quarter Stabs, Arpeggiated, Strummed.
#### Persistence Engine:
* Persists to `localStorage` under the key `daw_user_custom_chords`.
* Dispatches a custom event `CUSTOM_CHORD_SAVED` to update the Chords Panel list in real time without refreshing the page.
---
### 4. Internet & AI Chord Search Engine
Allows users to look up chord progressions for any song or style query directly through an integrated AI Gateway or web endpoint:
```text
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ 🔍 SEARCH CHORDS VIA INTERNET & AI GATEWAY │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ Search: [ Hotel California - Eagles / Melancholy Lofi Progression... ] [ Search ] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ AI / SEARCH RESULTS: │
│ │
│ 🎵 Song: Hotel California (Eagles) - Key: B Minor │
│ Progression: Bm -> F#7 -> A -> E7 -> G -> D -> Em -> F#7 │
│ AI Analysis: "Classic Flamenco-influenced rock progression using secondary dominants"│
│ │
│ [ 🔊 Listen Preview ] [ ➕ Save to My Library ] [ 🎹 Insert to Piano Roll ] │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
#### Query Flow & Data Extraction:
1. User enters a song title or style description into the search bar.
2. The client submits a request to `/api/v1/ai/search-chords` or dispatches an AI Gateway call with the following Tool Schema:
```json
{
"type": "function",
"function": {
"name": "search_or_generate_chords",
"description": "Searches for existing chord progressions or generates custom progressions matching a target style.",
"parameters": {
"type": "object",
"properties": {
"song_or_style_title": { "type": "string" },
"detected_key": { "type": "string" },
"chords_list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"chord_name": { "type": "string" },
"roman_numeral": { "type": "string" },
"pitches": { "type": "array", "items": { "type": "integer" } },
"duration_beats": { "type": "number", "default": 4.0 }
}
}
}
}
}
}
}
```
3. The API/Backend returns structured MIDI pitch numbers ($0 \to 127$).
4. The client inserts the parsed notes directly into the Piano Roll Canvas.
---
### 5. Chord Insertion Engine
When clicking **[ Insert to Timeline / Piano Roll ]**:
#### Insertion Algorithm:
1. Let $Beat_{\text{insert}}$ represent the Playhead timestamp (or clicked beat index on the Piano Roll).
2. For each chord $C_k$ in the progression sequence:
* Compute starting beat position:
$$StartBeat(C_k) = Beat_{\text{insert}} + \sum_{i=0}^{k-1} Duration(C_i)$$
* Convert chord symbols to MIDI pitch arrays (e.g., $C^{\text{maj}7}$ at Key C4, where $\text{Pitch} = 60$):
$$Pitches(C^{\text{maj}7}) = [60, 64, 67, 71] \quad (C4, E4, G4, B4)$$
* Apply chosen Voicing/Inversion rules:
* **Root Position:** Standard ascending pitch order.
* **First Inversion:** Transpose root pitch up $+12$ semitones.
* **Drop-2 Voicing:** Transpose the second-highest note down $-12$ semitones.
* Generate `MIDINote` objects:
```javascript
pitches.forEach(pitch => {
newNotes.push({
id: `note_chord_${Date.now()}_${pitch}`,
pitch: pitch,
start_beat: startBeat(C_k),
duration_beats: duration(C_k),
velocity: 0.8,
pan: 0.0
});
});
```
3. **State Mutation & Canvas Redraw:**
* If an active `MIDIItem` is open: Injects `newNotes` into `item.source_data.notes`.
* Dispatches the `DAW_STATE_UPDATED` event to trigger an immediate canvas redraw across both the Piano Roll and Timeline views.
---
## IV. VERIFICATION & TEST PLAN (E2E CHECKLIST)
| Test Item | Action | Expected Result |
| --- | --- | --- |
| **1. Toggle Virtual Keyboard (F2)** | Press **F2** or select **Tools -> Virtual MIDI Keyboard**. | The Floating Keyboard window opens overlaid on the DAW view; pressing **F2** again closes/hides the window. |
| **2. QWERTY Preview Playback** | Press **Z, S, X, D, C, V, G, B** on the computer keyboard. | Virtual keys light up; audio from the active track's synth engine triggers immediately with latency **< 5ms**. |
| **3. Live Recording to Timeline** | Arm track with **[R]** $\rightarrow$ Click **Record + Play** on Transport $\rightarrow$ Play QWERTY keys. | Notes render in real time on the Timeline canvas; clicking **Stop** creates a finalized `MIDIItem` containing the recorded notes. |
| **4. Open Chords Panel** | Select **Insert -> Chords panel** (or press **Shift + K**). | Chords Panel opens displaying progression presets for Pop, Jazz, Epic, and EDM styles. |
| **5. Insert Progression into Piano Roll** | Select *Jazz Neo-Soul* progression $\rightarrow$ Click **[ Insert to Piano Roll ]**. | $Dm^7 - G^7 - C^{\text{maj}7} - A^7$ sequence inserts at the Playhead location with calculated durations. |
| **6. Custom Chord Builder** | Open Custom Builder tab $\rightarrow$ Enter `Cmaj7 - Am9 - Fadd9` $\rightarrow$ Click **Save**. | New progression saves to `localStorage` and appears under the user's custom catalog. |
| **7. AI / Online Chord Lookup** | Open Internet Search tab $\rightarrow$ Query *"Hotel California"* $\rightarrow$ Click **Search**. | Resolves progression ($Bm - F\#7 - A - E7...$) with preview playback and direct insertion actions. |