### [2026-08-10] Task: Thực thi fix (phiên có tool) — mastering ON mặc định, âm sai instrument, Carla alive check
- **Tóm tắt thay đổi:**
  (1) **Mastering FX bật ON mặc định**: `masteringSettings` khởi tạo `masterConnected: true` — MIDI item/preview được xử lí qua mastering FX ngay khi chạy app (không cần bật/tắt power). Kèm `ensureMasteringRouting()` + `refreshCarlaStatus()` gọi ở đầu `startTrackPlayback`/`startLocalTrackPlayback`/`schedulePianoRollMidi`.
  (2) **Âm sai instrument (chọn Synth String nghe Piano)**: `soundfontPlayer.js` thêm `_instrumentEpoch` — tăng khi `selectInstrument`/load soundfont thành công; `progAlreadySet` chỉ skip program_select khi cache khớp EPOCH hiện tại → mọi đổi instrument đều program_select lại đúng preset (hết cache stale); `program_select` fail → thử lại bank 0; cache channel lưu `_epoch`.
  (3) **Carla không play MIDI note / câm toàn phần**: backend thêm `GET /api/v1/plugins/carla-status` (running + osc_port); `api.js` thêm `carlaStatus()`; frontend `refreshCarlaStatus()` + `ensureCarlaForPlayback()` (auto-mở Carla với VSTi khi chưa chạy); `routeToCarla` chỉ EXCLUSIVE khi `window.__carlaRunning !== false` — Carla chết → fallback FluidSynth (luôn có âm).
  (4) **Keybed giữ note theo thời gian bấm**: durationMs 500/200 → 5000 (auto-off phòng hờ); mouseup/mouseleave dừng NGAY qua stopNote.
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+/carla-status), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js` (epoch + fallback), `app/static/js/services/api.js` (+carlaStatus), `app/templates/index.html` (?v= 202608101700), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke jsdom EVAL OK. Test noteoff 6/6. curl `/carla-status` → {"running":false,"osc_port":22752}. ⚠️ Rebuild PyInstaller exe trên Windows để nhận backend `/carla-status` (PYZ); static/templates đã sync dist.

### [2026-08-10] Task: Fix (KHÔNG bỏ qua) — MIDI keyboard preview không qua mastering + noteoff không xử lí + MIDI item không play qua Carla bridge
- **Tóm tắt thay đổi:**
  (1) **MIDI item PHẢI play qua Carla bridge khi VSTi loaded** (`app.jsx`): trước đây các hàm playback gọi `SonicSF.playNote` (FluidSynth GM — âm SAI) SONG SONG với route Carla → âm nghe được là GM, không phải VSTi. Giờ: mọi đường playback (startTrackPlayback main, MIDI section subTrack, startLocalTrackPlayback, schedulePianoRollMidi + ghost notes) tính `routeToCarla = shouldRoutePlayback(synth_engine)` — nếu TRUE → CHỈ route Carla (bỏ hẳn FluidSynth); FALSE → FluidSynth như cũ.
  (2) **MIDI keyboard preview không route qua mastering FX chain** (`app.jsx`): `updateSfRouting` nhánh gainNode (piano roll + midiAudible) THIẾU sync route gains → node giữ bypass cũ (tạo khi mastering OFF / audioBypass true) → preview đi dry cho tới khi bật/tắt power. Fix: sync `route.routeGain/dryGain` theo `effMidiBypass` NGAY tại thời điểm routing; `ensureMasteringRouting` giờ force-sync MỌI node (sfRouteGain/sfDryGain + routeGain/dryGain) theo trạng thái hiện tại.
  (3) **Âm kêu liên tục dù đã thả phím (không xử lí noteoff)** (`soundfontPlayer.js`): khi FluidSynth WASM chưa sẵn sàng / font load fail → `_playNoteFallback` tạo oscillator — `stopNote` trước đây CHỈ noteoff FluidSynth, KHÔNG dừng oscillator → âm kêu tới khi auto-stop 2s. Fix: `_activeOscillatorsByKey` (key 'channel:pitch'); `stopNote` dừng oscillator đúng key ngay (osc.stop + gain 0); `panic`/`stopAll` dừng toàn bộ + clear map.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` (?v= 202608101600), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Test jsdom noteoff: 6/6 pass (stopNote gọi osc.stop ngay t=1.0 + gain 0; đúng channel:pitch — không đụng note/osc channel khác; panic dừng tất cả). Bundle: routeToCarla ở cả 4 đường + ghosts. ⚠️ Rebuild PyInstaller exe trên Windows để nhận backend fix trước đó (plugins.py/projects.py — PYZ).

### [2026-08-10] Task: Preview soundfont + MIDI keyboard không đi qua mastering FX main out (phải bật/tắt power mới có tác dụng)
- **Tóm tắt thay đổi:** Routing âm thanh SF preview (keybed/draw/MIDI file) chỉ được đồng bộ khi masteringSettings ĐỔI (toggle power) — bắt đầu preview KHÔNG chạy lại `updateSfRouting` + đồng bộ route gains → âm preview không tự động qua mastering chain cho tới khi bật/tắt nút power. Fix: (1) thêm `ensureMasteringRouting()` trong App (toggleMasteringOnMaster theo trạng thái hiện tại — early-return nếu đúng; applyMasteringSettings; updateSfRouting) — gọi tại: keybed onMouseDown, playDrawPreview, playMidiPreview (Media Explorer), mở piano roll tab (setTimeout 60ms sau setActiveTab); (2) thêm `masterConnected`/`isBypassed` vào sig của `applyMasteringSettings` — toggle power luôn re-apply tham số module (hết blind-spot early-return); (3) expose `window.__ensureMasteringRouting`. Đã kiểm chứng bằng test jsdom (AudioContext mock theo dõi connect): khi mastering ON, path SF_ROUTE → masterBus.input → inputAnalyser → eqLowFilter (mastering chain) → destination là ĐÚNG — vấn đề nằm ở chỗ không re-sync routing tại thời điểm preview.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/templates/index.html` (?v= bump 202608101400), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke test jsdom: bundle eval OK; bundle chứa __ensureMasteringRouting (5 chỗ) + sig masterConnected. Test graph jsdom: SF → EQ module đạt khi mastering ON (cả từ đầu lẫn sau toggle) — routing wiring đúng, fix tập trung vào re-sync khi preview.

### [2026-08-10] Task: Fix 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain
- **Tóm tắt thay đổi:**
  (1) **Soundfont preview âm loop không dừng**: `soundfontPlayer.js` — note đang CHỜ load soundfont giờ được đăng ký trong `_pendingNoteOns` + `_noteGeneration` trước khi load → `stopNote` (thả phím MIDI Keyboard) / `stopAll` / `panic` hủy được note deferred (trước đây note bắn TRỄ sau khi thả phím/Stop → âm loop không dừng).
  (2) **Unload Carla bridge khi chọn lại instrument soundfont**: backend `plugins.py` thêm registry `_CARLA_PROCESSES` (open_in_carla lưu Popen) + endpoint `POST /api/v1/plugins/carla-stop` (OSC note_off toàn pitch mọi channel → terminate/kill tiến trình Carla). Frontend `setTrackInstrumentWithProgram` gọi `SonicCarlaMidi.stopBridge()` khi track chuyển từ VSTi → non-VST; `runtime.js` thêm `allNotesOff()` + `stopBridge()`; `api.js` thêm `stopCarla()`.
  (3) **MIDI item play qua Carla bridge khi VSTi loaded**: `app.jsx` thêm helper `scheduleCarlaNote()` và route vào CẢ 3 đường playback: `startTrackPlayback` (main timeline), `startLocalTrackPlayback` (local loop), ghost notes trong `schedulePianoRollMidi` (trước đây chỉ piano roll chính route).
  (4) **Tự động lưu temp khi tắt app**: `projects.py` POST /temp giờ ghi file `storage/temp/autosave.json` (thư mục temp của ứng dụng) + DB; GET /temp fallback đọc file. `app.jsx` thêm beforeunload/pagehide → sendBeacon POST /projects/temp + autosave 30s + khôi phục dự án temp khi mount (chỉ khi project trống).
  (5) **Ctrl-S (Save)**: `app.jsx` thêm global Ctrl/Cmd+S handler — desktop → `POST /api/v1/projects/save-to-disk` (ghi vào Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux); docker/headless → Cloud (đã login) hoặc local .sfs.
  (6) **FX Chain (Mastering + FX Rack) load Carla bridge**: thêm module type `carla` vào `TRACK_FX_META`/`TRACK_FX_DEFAULTS` + `MODULE_META` (mastering) — UI dropdown VST đã scan → Load Carla Bridge (openInCarla) / Stop-Unload (carla-stop); `createTrackFxModule` + `rebuildMasteringGraph` xử lý carla là pass-through (không thêm DSP WebAudio).
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/api/v1/projects.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild babel), `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/runtime.js`, `app/static/js/services/api.js`, `app/templates/index.html` (?v= bump 202608101200), `dist/daw_engine/_internal/app/static/**` + `templates/index.html`
- **Ghi chú/Test (nếu có):** pytest 86 passed, 7 skipped. Smoke test jsdom: bundle eval OK, các symbol mới (scheduleCarlaNote, TRACK_FX_META.carla, stopBridge, stopCarla, saveProjectToDisk) đều tồn tại. Test curl: `/carla-stop` 200, `/save-to-disk` ghi file OS, `/temp` ghi storage/temp/autosave.json + GET trả has_temp. ⚠️ Backend .py nằm trong PyInstaller PYZ → CẦN rebuild exe trên Windows để nhận fix backend (static/templates đã sync dist).

### [2026-08-08] FIX: verify_bundle.py vẫn false-positive trên Windows — TOC là Python literal nên backslash bị DOUBLE-escape ('app\\\\static')
- **Tóm tắt thay đổi:** Fix LAN 2 normalize `replace('\\','/')` 1 lần KHÔNG đủ: TOC file là Python literal (repr) → path Windows ghi thành `app\\\\static` (2 backslash trên disk) → replace 1 lần ra `app//static` → regex `app/static(?=[/'"])` không match → vẫn báo "Bundle thieu" DÙ bundle đủ. Fix: normalize 2 bước — `replace("\\\\","/")` (bắt double-escape) rồi `replace("\\","/")` (backslash đơn). Verify bằng TOC giả lập Windows repr: cả 2 entry match True.
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (dòng normalize)
- **Ghi chú/Test (nếu có):** Test TOC Windows repr `'app\\\\static\\\\js\\\\app.js'` → normalize ra `app/static/js/app.js` → regex match. User: pull code mới rồi chạy lại build_windows.ps1 (bước 3.5 sẽ hết báo thiếu nếu bundle thực sự đủ).

---
### [2026-08-08] FIX (LAN 3 - GOC ROOT): 'app\\static does not exist' — sys.path thieu CWD khi chay 'pyinstaller' (entry point)
- **Tóm tắt thay đổi:** Lỗi 'app\static does not exist' vẫn tái diễn dù đã 2 lần sửa datas (absolute path roi collect_data_files). Lần này tìm ra GOC ROOT THẬT SỰ: lệnh `pyinstaller engine.spec` (entry-point script cua PyInstaller) KHONG them CWD vao sys.path — chi `python -m PyInstaller` moi them. `collect_data_files('app')` import package 'app' qua sys.path -> khong thay -> tra ve [] AM THAM -> bundle thieu static/templates (dung canh: tren may dev chay `python -m PyInstaller` nen CWD co trong sys.path -> tuong da dung; may Windows chay `pyinstaller` -> CWD khong co -> collect rong -> TOC khong co app/static — dung voi loi verify cua user). Fix trong `engine.spec`:
  1. `sys.path.insert(0, _SPEC_ROOT)` TRUOC khi goi collect_data_files — import 'app' luon hoạt dong bat ke CWD.
  2. Fallback cuoi: neu collect_data_files van tra ve rong -> datas TINH absolute (app/templates, app/static, app/models) + in WARN de debug.
- **Cac file anh huong:** `engine.spec` (sys.path insert + fallback datas tinh)
- **Ghi chu/Test (neu co):** VERIFY DUNG DIEU KIEN THAT BAI: build tu /tmp (CWD khac project root — giong `pyinstaller` entry point khong co CWD trong sys.path) -> TOC co app/static (124) + app/templates (12), khong co WARN "tra ve rong"; tools/verify_bundle.py -> OK exit 0; chay binary frozen -> /health OK, index 200, static js 200. pytest 86 passed. User: pull engine.spec moi roi chay lai build_windows.ps1.

---
### [2026-08-08] FIX (lan 2): ERR_MODULE_NOT_FOUND '@babel/standalone' vẫn xảy ra — build_windows.ps1 TỰ cài dep nếu thiếu
- **Tóm tắt thay đổi:** User vẫn gặp ERR_MODULE_NOT_FOUND '@babel/standalone' dù đã thêm vào package.json — máy Windows chưa pull package.json mới hoặc npm install chưa cài. Fix: `build_windows.ps1` bước [2/6] — sau `npm install`, TỰ KIỂM TRA `node_modules\@babel\standalone`; thiếu → `npm install @babel/standalone --no-audit --no-fund`; vẫn thiếu → ERROR + exit 1. Không còn phụ thuộc package.json mới trên máy user.
- **Các file ảnh hưởng:** `build_windows.ps1` (bước 2 tự cài @babel/standalone)
- **Ghi chú/Test (nếu có):** File ASCII-only + UTF-8 BOM (không lặp lỗi PS5.1), brace depth 0. User: pull build_windows.ps1 mới (hoặc toàn bộ) rồi chạy lại.

---
### [2026-08-08] FIX: build.mjs ERR_MODULE_NOT_FOUND '@babel/standalone' trên Windows — package.json thiếu dependency
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 (bước 2/6 node build.mjs) nhận `Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@babel/standalone' imported from build.mjs`. Root cause: `build.mjs` import `@babel/standalone` nhưng **package.json KHÔNG khai báo** dependency này (máy dev có sẵn trong node_modules từ trước nên không lộ; máy Windows `npm install` chỉ cài theo package.json → thiếu). Fix: thêm `"@babel/standalone": "^7.29.8"` vào dependencies + chạy `npm install` cập nhật `package-lock.json`.
- **Các file ảnh hưởng:** `package.json` (+@babel/standalone), `package-lock.json` (npm install)
- **Ghi chú/Test (nếu có):** npm install OK (up to date), package-lock có node_modules/@babel/standalone, `node build.mjs` BUILD OK 1123403 bytes + node --check OK. User: pull code mới rồi chạy lại build_windows.ps1.

---
### [2026-08-08] FIX: verify bundle [3.5/6] false-positive 'app/static thieu' + check spec version — tools/verify_bundle.py
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 — bước verify [3.5/6] mới báo "ERROR: Bundle thieu: app/static, app/templates" nhưng thực tế bundle ĐỦ (collect_data_files('app') đã hoạt động — verify bằng build Linux). Root cause: script verify cũ dùng regex `-notmatch "app/static"` trên raw TOC — trên Windows TOC chứa `app\static` (BACKSLASH) → regex forward-slash không match → FALSE POSITIVE báo thiếu. Fix:
  1. **tools/verify_bundle.py** (mới): verify TOC bằng Python — đọc text, `replace('\\','/')` normalize backslash→forward, regex theo prefix `app/static(?=[/'"]|$)`; exit 0 OK / 1 thiếu; không có TOC → WARN + exit 0 (không chặn build). Test 4 case: backslash OK, forward OK, thiếu static → ERROR, không TOC → WARN.
  2. **build_windows.ps1 [3.5/6]**: trước tiên check `engine.spec` có chứa `collect_data_files('app')` — nếu spec CŨ (chưa pull code mới) → báo rõ "engine.spec CU... Pull code moi" + exit 1 (tránh nhầm lẫn nguyên nhân). Sau đó chạy `python tools\verify_bundle.py` + check `$LASTEXITCODE`.
- **Các file ảnh hưởng:** `tools/verify_bundle.py` (mới), `build_windows.ps1` (bước 3.5 gọi script + check spec)
- **Ghi chú/Test (nếu có):** verify_bundle.py test 4 case đều đúng; chạy với TOC THẬT (build Linux PyInstaller 6.21) → "Thiếu: KHÔNG — OK". Binary frozen: /health OK, index 200, static js 200. pytest 86 passed. User: pull code mới (có tools/verify_bundle.py) rồi chạy lại build_windows.ps1.

---
### [2026-08-08] FIX: build_windows.ps1 lỗi parse PowerShell — "String is missing terminator" (file chứa ký tự Unicode, PS 5.1 đọc theo ANSI)
- **Tóm tắt thay đổi:** User chạy build_windows.ps1 trên Windows nhận ParserError "String is missing terminator" ở dòng 57 + "Missing closing '}'" ở dòng 34. Root cause: bản mình thêm bước verify [3.5/6] có chứa ký tự Unicode (em-dash `—` trong comment + chuỗi tiếng Việt có dấu "thiếu/Dừng/kiểm tra"). PowerShell 5.1 đọc file .ps1 KHÔNG có BOM theo ANSI/Windows-1252 → byte UTF-8 của `—` (E2 80 94) giải mã thành `â€"` — dấu ngoặc kép giả chui vào giữa chuỗi → chuỗi "mất terminator" + block `{}` lệch. Fix:
  1. Viết lại toàn bộ build_windows.ps1 **chỉ ASCII** (bỏ dấu tiếng Việt, bỏ em-dash, dùng `->`).
  2. Thêm **UTF-8 BOM** (EF BB BF) vào đầu file — PowerShell đọc đúng encoding bất kể.
  3. Giữ nguyên bước verify [3.5/6] (TOC check app/static + app/templates).
- **Các file ảnh hưởng:** `build_windows.ps1` (ASCII-only + BOM)
- **Ghi chú/Test (nếu có):** File giờ 0 ký tự non-ASCII, có BOM; brace depth = 0 (cân bằng), không có dòng Write-Host quote lẻ. Lưu ý chung: MỌI .ps1/.bat trong dự án phải ASCII-only + BOM (PS 5.1 ANSI bug) — tránh tiếng Việt có dấu/em-dash trong file script Windows.

---
### [2026-08-08] FIX (LẦN 2): Windows exe vẫn crash 'app\static does not exist' + stuck 'Đang khởi động Engine' — bundle qua collect_data_files + fallback + verify build
- **Tóm tắt thay đổi:** User build bản Windows vẫn gặp RuntimeError `Directory '..._MEIxxxx\app\static' does not exist` (lần 2 — fix trước dùng datas ABSOLUTE theo SPECPATH nhưng trên máy user vẫn thiếu static trong bundle) + UI stuck "Đang khởi động SonicForge Engine". 3 lớp phòng thủ:
  1. **engine.spec — bundle qua IMPORT SYSTEM**: bỏ datas đường dẫn tĩnh, thay bằng `collect_data_files('app', excludes=['**/storage/**','**/__pycache__/**','**/*.pyc'])` — PyInstaller tự tìm assets (static/templates/models) qua package import, KHÔNG phụ thuộc CWD/SPECPATH lúc chạy lệnh (nguyên nhân gốc: chạy pyinstaller từ thư mục khác → relative path không resolve → bỏ qua âm thầm). Loại luôn `app/storage` (57MB soundfonts — vô ích trong onefile, config.py đã redirect %APPDATA%). Giữ `md/` (ngoài package) + librosa .pyi.
  2. **app/main.py — fallback an toàn**: nếu STATIC_DIR không tồn tại → thử `sys._MEIPASS/app/static` và `dirname(__file__)/static`; vẫn thiếu → **tự os.makedirs** → StaticFiles không còn crash lúc import (engine không chết, app hiện lỗi rõ thay vì 6 hộp thoại + stuck loader).
  3. **build_windows.ps1 — verify post-build [3.5/6]**: sau pyinstaller, đọc Analysis-00.toc kiểm tra `app/static` + `app/templates` có trong bundle — thiếu → in ERROR đỏ + `exit 1` (bắt lỗi NGAY lúc build, không đợi chạy app mới vỡ).
- **Các file ảnh hưởng:** `engine.spec` (collect_data_files('app') + bỏ storage), `app/main.py` (fallback static dir), `build_windows.ps1` (verify TOC post-build)
- **Ghi chú/Test (nếu có):** VERIFY THẬT trên Linux (PyInstaller 6.21, cùng spec): Analysis-00.toc chứa app/static (124), app/templates (12), app/models (5); exe 155MB (giảm 57MB); chạy binary frozen → /health OK, index 200, static js 200, favicon 200, không crash. pytest 86 passed. User cần chạy lại `build_windows.ps1` (bước 3.5 sẽ tự kiểm tra bundle).

---
### [2026-08-08] IMPROVE: About modal — logo dùng favicon của app (app/templates/favicon.svg, serve /favicon.svg)
- **Tóm tắt thay đổi:** User yêu cầu logo trong About modal phải là logo của web/app — favicon lưu trong hệ thống. Trước đây AboutModal dùng div gradient chữ "SF". Fix: thay bằng `<img src="/favicon.svg">` (route đã có sẵn trong app/main.py — serve từ TEMPLATES_DIR; file app/templates/favicon.svg, SVG 1254x1254). Hiển thị 48x48 object-contain, nền tối + border cho nổi trên modal.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (AboutModal img), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081600)
- **Ghi chú/Test (nếu có):** BUILD OK 1123403 bytes, node --check OK, pytest 86 passed. Verify: /favicon.svg 200 image/svg+xml, bundle chứa "favicon.svg".

---
### [2026-08-08] FEAT: Menu Help (About + Hướng dẫn sử dụng) + Tools → Preferences (Theme/Language/Button font size)
- **Tóm tắt thay đổi:** User yêu cầu 2 nhóm tính năng:
  1. **Menu Help**: 
     - `About SonicForge Studio...` → **AboutModal** mới: dev **Lộc Phạm**, email **tranloclqd@gmail.com**, version `1.0.0` (khớp tauri.conf.json), build Standalone (Tauri v2 + PyInstaller).
     - `Hướng dẫn sử dụng...` → **HelpModal** mới: 8 mục hướng dẫn song ngữ (vi/en theo language preference): Bắt đầu nhanh, MIDI & ARM, Piano Roll, FX & Master, SoundFont & VST, AI, Lưu & Xuất, Phím tắt.
  2. **Tools → Preferences...** → **PreferencesModal** mới quản lý:
     - **Theme** (5 preset): dark (mặc định), midnight, forest, violet, graphite — áp qua `data-theme` trên `<html>` + CSS variables `--sf-bg/--sf-panel/--sf-header/--sf-border/--sf-accent` (thêm trong index.html `<style>`, override .daw-app-shell/.daw-panel/.daw-header/body/scrollbar).
     - **Language**: vi/en — áp ngay cho HelpModal + PreferencesModal (menu chính giữ nguyên — app vốn song ngữ lẫn lộn).
     - **Button font size**: sm/md/lg — áp qua `data-btnfont` trên `<html>` + CSS `html[data-btnfont=...] button { font-size: ... !important }`.
  3. **Lưu trữ**: localStorage `sf_prefs` + server `/api/v1/user/preferences` (window.SonicAPI.getPreferences/savePreferences — endpoint đã có sẵn). Load server prefs khi khởi động.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (AboutModal/HelpModal/PreferencesModal mới + state prefs + menu Tools/Help + render 3 modal), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (CSS theme + data-btnfont + bump v=202608081500)
- **Ghi chú/Test (nếu có):** BUILD OK 1123418 bytes, node --check OK, pytest 86 passed. Verify serve: index 200, precompiled 200, bundle chứa đủ "Hướng dẫn sử dụng", "tranloclqd@gmail.com", "data-btnfont", "Preferences". Lưu ý: button font size dùng !important nên override cả text-xs của Tailwind — đúng ý "đổi cỡ chữ button" nhưng có thể làm một số nút hơi to/nhỏ so với thiết kế gốc.

---
### [2026-08-08] FIX: VU meter giữ animation đúng TRƯỜNG ĐỘ âm thanh khi ARM + nhấn/giữ phím MIDI keyboard
- **Tóm tắt thay đổi:** User yêu cầu: bật ARM + nhấn phím MIDI keyboard preview — khi âm CÒN PLAY thì VU meter phải còn animate cho đúng trường độ âm thanh. Trước đây: note-on → `triggerMidiVuActivity` set peak → VU tick decay 0.75/frame → VU tắt sau ~0.5s DÙ âm còn kêu (note dài 60000ms, chỉ tắt khi note-off). Root cause: VU tick decay theo thời gian, không biết note đang giữ. Fix:
  1. **`heldMidiNotesRef`** (mới): đếm số note MIDI đang GIỮ per-track (ARM + keyboard live). Note-on → tăng counter (cả nhánh armed sub-tab PIANO_ROLL lẫn armed main track); note-off → giảm, về 0 thì xóa.
  2. **VU tick**: `heldCnt = heldMidiNotesRef.current[vuKey]` — còn note giữ (heldCnt > 0) → GIỮ NGUYÊN peak, KHÔNG decay → VU animate suốt trường độ; hết note (note-off) → decay 0.75 (~0.5s) tắt nhanh như cũ (giữ hành vi user bug 07:15 "hết âm → VU tắt ngay").
  3. **stopAllPlayback**: clear luôn `heldMidiNotesRef.current = {}` cùng với `midiVuActivityRef` — tránh sau STOP (âm đã dừng) tick vẫn thấy counter > 0 → giữ peak → VU dính mãi.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (heldMidiNotesRef + note-on/note-off counters + VU tick giữ peak), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081400)
- **Ghi chú/Test (nếu có):** BUILD OK 1109174 bytes, node --check OK, pytest 86 passed + 5 skipped. Luồng: nhấn giữ phím → VU giữ peak; nhả phím (note-off) → VU decay ~0.5s rồi tắt; STOP → VU tắt ngay. Keybed click (mousedown duration 500ms) giữ nguyên hành vi cũ (không qua heldMidiNotesRef).

---
### [2026-08-08] FIX: Chặn browser zoom toàn trang — item/button/label giữ nguyên kích thước khi phóng to UI
- **Tóm tắt thay đổi:** User yêu cầu: khi phóng to giao diện, các item/button/label PHẢI GIỮ NGUYÊN kích thước hiện tại — chỉ các vùng flexible co giãn + cho phép user tự kéo resize (resizer TCP/sidebar/media-explorer/track-height ĐÃ có sẵn). Root cause: layout vốn dùng px cố định (không scale theo window), nhưng **browser zoom toàn trang** (Ctrl+wheel, Ctrl+plus/minus/0, pinch gesture) scale TOÀN BỘ UI → control phóng to theo. WebView2 (Tauri) mặc định bật zoom control. Fix trong `app/templates/index.html`:
  1. Meta viewport thêm `maximum-scale=1.0, user-scalable=no`.
  2. Inline script chặn zoom ở **capture phase** bằng `preventDefault()` — KHÔNG `stopPropagation()` nên các vùng zoom CHUYÊN DỤNG vẫn nhận event và hoạt động bình thường: timeline zoom (Ctrl+wheel, 18999), piano-roll grid zoom (7351), waveform canvas zoom (12189), EQ canvas wheel (10144).
  3. Chặn keydown Ctrl+'+'/'-'/'='/'_'/'0' + gesturestart/gesturechange (pinch Safari/WebKit).
- **Các file ảnh hưởng:** `app/templates/index.html` (meta viewport + zoom-guard script inline)
- **Ghi chú/Test (nếu có):** index.html serve OK (uvicorn), script extract + node --check OK. Không đụng app.jsx/app.precompiled.js (index.html no-cache nên không cần bump stamp). Resizer panel giữ nguyên — user vẫn kéo được TCP width (280-600), sidebar (200-600), media-explorer (20-80%), track height (110-300).

---
### [2026-08-08] FIX: Windows runtime — ModuleNotFoundError scipy.stats._ansari_swilk_statistics + ValueError librosa stub (PyInstaller bundle)
- **Tóm tắt thay đổi:** User chạy bản Windows exe gặp 2 lỗi runtime (hộp thoại error):
  1. `ModuleNotFoundError: No module named 'scipy.stats._ansari_swilk_statistics'` — scipy >= 1.18 tách `scipy.stats` thành nhiều module con import LAZY bên trong hàm (vd `_ansari_swilk_statistics`) → hook scipy của PyInstaller không thấy → thiếu trong bundle. Fix: `engine.spec` thêm `collect_submodules('scipy')` vào hiddenimports (collect toàn bộ stats/signal/ndimage/... — không sót module nào).
  2. `ValueError: Cannot load imports from non-existent stub '..._MEIxxxx\librosa\__init__.pyi'` — librosa 0.11 dùng `lazy_loader.attach_stub` → lúc RUNTIME cần file `.pyi` tồn tại trên disk, nhưng PyInstaller mặc định KHÔNG bundle `.pyi` (4 file: `__init__.pyi`, `core/`, `feature/`, `util/`). Fix: `datas += collect_data_files('librosa', includes=['**/*.pyi'])`.
- **Các file ảnh hưởng:** `engine.spec` (import collect_data_files + _scipy_hidden + librosa .pyi datas)
- **Ghi chú/Test (nếu có):** VERIFY THẬT bằng build PyInstaller trên Linux (cùng engine.spec, cần objdump/objcopy từ NDK llvm) → chạy binary frozen → `/health` OK, `/` 200, `/static/js/app.precompiled.js` 200, không còn 2 lỗi trên. pytest 86 passed. Lưu ý: máy user cần build lại bằng `build_windows.ps1` để có exe mới.

---
### [2026-08-08] FIX: Windows build crash — 'app\static does not exist' (PyInstaller datas relative) + FIX: ARM track + MIDI keyboard → VU meter đứng yên
- **Tóm tắt thay đổi:** 2 bug trên bản Windows standalone:
  1. **Build crash 6 hộp thoại error**: `RuntimeError: Directory '...\_MEIxxxx\app\static' does not exist` khi chạy daw_engine.exe. Root cause: `app/main.py` dùng `os.path.dirname(__file__)` để mount /static — khi frozen `__file__` trỏ vào thư mục giải nén tạm `_MEI...` KHÔNG phải `sys._MEIPASS`; đồng thời `engine.spec` datas dùng path RELATIVE (resolve theo CWD lúc chạy `pyinstaller`, chạy từ nơi khác → bỏ qua âm thầm → static/templates/models thiếu trong bundle). Fix: `main.py` dùng `settings.APP_DIR` (freeze-aware, config.py đã có); `engine.spec` đổi toàn bộ datas + pathex sang ABSOLUTE từ `SPECPATH` + thêm `app/models` (project_schema.json — projects.py dùng, trước đây thiếu khi frozen).
  2. **ARM track + bấm phím MIDI keyboard → VU meter không animation**: vòng tick VU (`masterVUAnimRef` useEffect) có block early-return `if (!isPlaying && !isRecActive)` vẽ VU RỖNG và return — nên bật ARM + bấm phím MIDI (không play/record) thì `midiVuActivityRef` có giá trị nhưng không bao giờ được đọc → VU đứng yên. Fix: thêm điều kiện `liveMidiVu` (scan midiVuActivityRef > 0.001) — có MIDI activity live thì KHÔNG early-return, track VU nhảy theo activity + decay như bình thường.
- **Các file ảnh hưởng:** `app/main.py` (STATIC_DIR → settings.APP_DIR), `engine.spec` (datas/pathex absolute + app/models), `app/static/js/app.jsx` (VU tick liveMidiVu guard), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608081200)
- **Ghi chú/Test (nếu có):** BUILD OK 1107797 bytes, node --check OK, pytest 86 passed + 5 skipped (test_sf_convert fail pre-existing — path `/app/...` docker-only). Backend import OK — STATIC_DIR resolve đúng cả dev lẫn frozen.

---
### [2026-08-04] Task: Mở PIANO ROLL TAB lúc main đang play KHÔNG được tắt âm — handleEditMidiInTab stopAllPlayback có điều kiện
- **Tóm tắt thay đổi:** User báo "đang play main session, dblclick mở Piano roll tab → âm bị tắt". Thủ phạm: `handleEditMidiInTab` (15983) gọi `stopAllPlayback()` VÔ ĐIỀU KIỆN ngay khi mở tab — dừng mọi nguồn main đang phát. Fix: `if (!isPlaying) stopAllPlayback()` — chỉ clean-stop khi KHÔNG play main (tránh stuck route khi đổi tab giữa sub-tab); main đang play → mở tab → âm main tiếp tục. Nhánh play sub-tab tự stopAllPlayback trước khi play nên không xung đột route.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (handleEditMidiInTab), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042300)
- **Ghi chú/Test (nếu có):** BUILD OK 1013547 bytes, node --check OK, `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only).
---

### [2026-08-04] Task: PIANO ROLL play ĐÚNG instrument track — resolveTrackInstrumentCtx + ensureSonicInstrument (nguồn duy nhất)
- **Tóm tắt thay đổi:** User yêu cầu "mở PIANO ROLL TAB của track đã loaded instrument → PHẢI play note với ĐÚNG instrument đó". Trước đây luồng instrument rời rạc: `schedulePianoRollMidi` dùng track live nhưng preview (wheel/click/keybed) dùng `st.instrumentProgram` — snapshot STALE từ lúc mở tab (đổi instrument sau khi mở tab → preview nghe instrument cũ/sai) + chưa có nơi nào chủ động select đúng channel trước khi note bắn. Viết lại từ đầu:
  1. **`resolveTrackInstrumentCtx(track, tracks)`** (module-level, nguồn duy nhất): resolve instrument từ TRACK live — ưu tiên `synth_engine` (soundfont: soundfont_id/bank/program → SF path, program=undefined để _playNoteFluid ưu tiên synthEngine), fallback `instrumentProgram` (GM preset), không có → im (đúng — chưa chọn instrument).
  2. **`ensureSonicInstrument(ctx)`**: fire-and-forget `selectInstrument(ch, bank, prog, sfId)` — đảm bảo channel của track đã select ĐÚNG instrument trước khi notes bắn (playNote tự load+retry nếu SF chưa xong — dedup sẵn, không stall).
  3. Áp vào `schedulePianoRollMidi` + 4 preview paths (canvas wheel 7124, click note 7459, draw brush 7780, keybed 8097/8120/8133) — bỏ `st.instrumentProgram`/`kbCh`/`kbSynth`/`pvCh` cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (helpers + 4 call sites), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042200), `app/static/js/services/soundfontPlayer.js` (log setOutputDestination — sibling)
- **Ghi chú/Test (nếu có):** BUILD OK 1013347 bytes, node --check OK, smoke test resolveTrackInstrumentCtx 4 case (SF/GM/no-instr/vst3-empty) đúng. `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only).
---

### [2026-08-04] Task: Fix mất âm toàn cục khi mở PIANO ROLL TAB + bấm play — exempt watchdog + SF routing theo active tab
- **Tóm tắt thay đổi:** User báo "nhấp đôi MIDI item mở PIANO ROLL TAB, điều khiển transport → mất âm, không còn âm ra loa". Root cause: master-silence watchdog trong updatePlayhead coi PIANO_ROLL (chỉ có notes MIDI schedule rời rạc + silent source 2.9ms) là "play mà im lặng" → mọi rest >750ms trigger rebuild → `stopAllPlayback()` + `panic()` hủy notes đang chờ (SF lazy-load lần đầu / gap tự nhiên) → rebuild loop mỗi 3s → notes không bao giờ bắn → CÂM TOÀN CỤC. Fix 2 change:
  1. **Exempt PIANO_ROLL khỏi watchdog** (`_isPianoRollWatchdog` — active sub-tab type PIANO_ROLL → bỏ qua toàn bộ block). PIANO_ROLL không có nguồn liên tục → im lặng là tự nhiên; các fix setValueAtTime/NaN guard đã hết "BiquadFilter state is bad" nên watchdog chỉ còn là lớp cứu cuối cho main/audio-tab.
  2. **updateSfRouting ưu tiên active PIANO_ROLL track**: khi tab PIANO_ROLL đang active → route FluidSynth tới node của track đang edit (`sfEntry` → FX Rack riêng, fallback `gainNode`) — kể cả khi project có >1 track MIDI audible (bình thường fallback masterBus.input, mất fader/pan/FX track). Fader + FX áp đúng cho notes đang nghe.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (watchdog exempt + updateSfRouting), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608041200)
- **Ghi chú/Test (nếu có):** BUILD OK 1010058 bytes (build.mjs standalone — Babel 8 ESM conflict), node --check OK, `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only). Manual: dblclick MIDI item → play → âm chạy liên tục qua rest; fader/FX track áp đúng.
---

### [2026-08-03] Task: Watchdog sub-tab hoạt động (silent source 2.9ms làm anyPlaying luôn false) + log applyMasteringSettings
- **Tóm tắt thay đổi:** STOP piano roll vẫn 11× "state is bad" (guard NaN + hết automation KHÔNG đủ — flag dồn tích trên biquad từ trước). Kiểm tra: applyMasteringSettings ĐÃ có sig guard + clamp NaN sẵn; getAudioContext sạch (chỉ masteringSettings effect quản lí) → warning là flag PERSISTENT trên masterBus biquad — **cứu bằng watchdog rebuild**. Fix:
  1. **Watchdog sub-tab**: silent source piano roll chỉ dài ~2.9ms → `anyPlaying` luôn false sau 100ms → watchdog VÔ HIỆU với piano roll. Sửa: `anyPlaying = _anySubPlaying ? true : ...` — sub-tab play + master im lặng >750ms → rebuild + resume đúng chế độ (log `[Recovery]`).
  2. **Log `[Mastering] applyMasteringSettings active=...`** — theo dõi khi nào apply chạy (sig guard — chỉ khi settings đổi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608040000)
- **Ghi chú/Test (nếu có):** BUILD OK 1008644 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Play main OK → space (piano roll) OK (hết warning — bản 39800 hiệu lực) → **STOP → 1 warning "BiquadFilterNode: state is bad" → câm + không còn log play tiếp**. Phát hiện: "state is bad" KHÔNG chỉ do automation — **setValueAtTime(NaN) TRÊN BIQUAD cũng gây flag** (NaN từ field settings undefined → clamp(undefined)=NaN). Fix:
  1. **applyMasteringSettings (masterBus eq filters)**: `_g(v, lo, hi)` guard `typeof v === 'number' && isFinite(v)` — NaN/undefined → 0.
  2. **`eqproClamp` guard isFinite** — EQ PRO mọi giá trị biquad an toàn (NaN → lo).
  3. **Log `[Play] click activeTab=...`** đầu handlePlayPause — biết play sau stop có chạy không + rơi nhánh nào.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039900)
- **Ghi chú/Test (nếu có):** BUILD OK 1008312 bytes, node --check OK, `pytest` 86 passed. Guard NaN ✓ (isFinite ×3), log click ✓.
---
- **Tóm tắt thay đổi:** Play piano roll tab → 12 warning `BiquadFilterNode: state is bad` liên tục + instrument không phát + MAIN OUT treo. 2 fix:
  1. **`applyEQPreset` (10628)**: `setTargetAtTime(..., 0.02)` trên masterBus EQ biquads → **setValueAtTime** — **loại nguồn automation biquad cuối cùng** (EQ PRO/masterBus eq/applyEQPreset/track FX — tất cả đã setValueAtTime; các setTargetAtTime còn lại đều gain/compressor/limiter).
  2. **Watchdog master-silence MỞ RỘNG cho sub-tab**: guard cũ `activeTab === 'main'` bỏ + thêm `_anySubPlaying` (sub-tab play không set isPlaying App) → piano roll câm → **tự rebuild + resume ĐÚNG chế độ** (sub-tab: schedulePianoRollMidi + startSubTabPlayback; main: startTrackPlayback) → **MAIN OUT treo tự phục hồi sau ~750ms + cooldown 3s** (log `[Recovery]`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039800)
- **Ghi chú/Test (nếu có):** BUILD OK 1007948 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log mới: 5 track play OK (node ok, muted false), piano roll schedule OK — nhưng warning `BiquadFilterNode: state is bad` vẫn xuất hiện. Nguồn cuối cùng: **masterBus EQ filters (eqLow/Mid1/Mid2/High — BiquadFilter) dùng `setTargetAtTime(..., 0.05)`** trong applyMasteringSettings — đổi toàn bộ sang **`setValueAtTime(x, now)`** (cancelScheduledValues giữ + bỏ tham số thứ 3) → hết automation trên mọi biquad → hết warning (các setTargetAtTime còn lại đều là gain/compressor/limiter — không gây "state is bad").
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039700)
- **Ghi chú/Test (nếu có):** BUILD OK 1007264 bytes, node --check OK, `pytest` 86 passed. eq filters setValueAtTime ✓, hết eq setTargetAtTime ✓.
---
- **Tóm tắt thay đổi:** Lỗi FluidSynth `Instrument not found on channel 0 [bank=0 prog=50], substituted [bank=0 prog=0]` — do fix unload SF (39500) gây ra: SGM bị `sfunload` + xóa khỏi `_sfHandleMap` → channel state (`cachedCh.sfId`) vẫn trỏ SGM → `progAlreadySet=true` → **skip program_select** → noteon trên **handle đã unload** → "Instrument not found" + substitute prog 0 (âm ra nhưng sai nhạc cụ). Fix (soundfontPlayer.js):
  1. **BỎ unload SF cũ khi sfload SF mới** — heap 256MB đủ cho vài SF (log: SGM handle 1 + latin handle 2 load OK); unload tạo handle rác.
  2. **`progAlreadySet` thêm điều kiện `_sfHandleMap.has(finalSfId)`** — chỉ skip program_select khi handle còn hợp lệ (505 đã đảm bảo load xong trước khi tới 515).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` (bump v=202608039600 — app.precompiled giữ nguyên 39500)
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed. Hết sfunload ✓, progAlreadySet check handle ✓.
---
- **Tóm tắt thay đổi:** "MỌI âm thanh không còn phát sau khi mở PIANO ROLL TAB" — thủ phạm: preload `loadSoundFont(soundfont_id)` tôi thêm ở bản 39300 khi mở tab → **sfload SF mới nền → WASM heap 256MB đầy → FluidSynth stall → silence toàn cục** (đúng comment loadSoundFont "stalling notes... then silence"). Fix:
  1. **BỎ preload loadSoundFont khỏi handleEditMidiInTab** — playNote TỰ load + retry đúng lúc note cần (không load khi chỉ mở tab).
  2. **soundfontPlayer `_doLoadSoundFont`: UNLOAD SF cũ trước khi sfload SF mới** (`_fluid_synth_sfunload` + `_sfHandleMap.delete` + `_loadedFonts=false`) — heap không tích nhiều SF; lần sau cần lại SF cũ → tự re-load.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (unload trước sfload), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039500)
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log `[Play] PianoRoll schedule ... notes=248 ...` CHẠY nhưng nút play/space không phản hồi (nút không đổi trạng thái). Nguyên nhân: `stopAllPlayback()` (gọi đầu nhánh play) — **`n.fxStopFn()` (18735) KHÔNG bọc try** — nếu throw → exception lan ra catch của handlePlayPause → `setSubTabs(isPlaying: true)` KHÔNG chạy → nút play không đổi (vô tác dụng) + toast lỗi. Fix: **bọc toàn bộ thân stopAllPlayback bằng try/catch** (log `[Stop] ...`) + `fxStopFn`/`SonicSF.stopAll`/`panic` mỗi cái bọc try riêng → stopAllPlayback KHÔNG BAO GIỜ throw → setSubTabs luôn chạy → nút play phản hồi đúng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039400)
- **Ghi chú/Test (nếu có):** BUILD OK 1007546 bytes, node --check OK, `pytest` 86 passed. fxStopFn bọc try ✓ (18737).
---
- **Tóm tắt thay đổi:** "Không thể play midi với instrument đã loaded trong PIANO ROLL TAB". Nguyên nhân chính: `playNote` → `_doLoadSoundFont` → `fetch /api/v1/plugins/soundfonts/download/<sfId>` — **nếu `soundfont_id` (sfClean — bỏ 'sf_') sai/không có trên server → 404 → return false → `doNote` KHÔNG chạy → note không phát** (câm với instrument đó). Selector instrument cũng KHÔNG load SF (chỉ list presets) — playNote tự load + retry. Fix: **preload soundfont NỀN khi mở tab** (`loadSoundFont(soundfont_id)` fire-and-forget — không chặn play, dedup sẵn) — note đầu không trễ; nếu id sai → console `[SonicSF] SoundFont not found: <id>` để chẩn đoán ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039300)
- **Ghi chú/Test (nếu có):** BUILD OK 1007356 bytes, node --check OK, `pytest` 86 passed. Nếu vẫn câm → dán console `[Play] PianoRoll schedule` + `[SonicSF] SoundFont loaded/not found`.
---
- **Tóm tắt thay đổi:** Play MIDI item trong PIANO ROLL TAB → âm bị stuck (ngân mãi) + VU master vẫn nhảy dù không play. Nguyên nhân khả dĩ: `stopAll`/dừng chỉ gọi `_fluid_synth_all_notes_off` — **binding này có thể KHÔNG tồn tại trong WASM exports** (catch nuốt → notes kẹt ngân vô hạn; note-on scheduled tương lai không bị hủy). Fix (soundfontPlayer.js):
  1. **`stopAll` + `panic()` (mới)**: **noteoff TỪNG note đang ngân qua `_fluid_synth_noteoff`** (binding chắc chắn tồn tại — đã dùng khi duration hết) + clearTimeout mọi scheduled note-on tương lai + all_notes_off (phòng hờ) + reset `_activeNotes` (tránh noteoff lặp).
  2. Gọi `panic()` từ stopAllPlayback (18740 — bên cạnh SonicSF.stopAll()).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (panic + stopAll noteoff từng note), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039100 cả soundfontPlayer.js)
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User dán log `[Bypass] node created track 1 initial audioBypass= true routeGain= 0 dryGain= 1` — **đây là INFO BÌNH THƯỜNG**: track 1 đang bật bypass (nút A xám) → âm đi **dry path** (dryInput → dryOutput → output → destination — vẫn ra main out, qua master fader). Thêm **diagnostic `[Play] PianoRoll schedule`** (notes count, track, program, dest ok/null, channel, sfEngine, bypassA, bypassMidi) vào schedulePianoRollMidi — user dán log này để xác định chính xác notes có được schedule không.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039000)
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Piano Roll play: playhead không di chuyển + không âm. Nguyên nhân: **handlePlayPause nhánh sub-tab KHÔNG gọi `animationFrameIdRef.current = requestAnimationFrame(updatePlayhead)`** (mọi nơi khác đều có — 13491/17769/17795...) → rAF loop không chạy → playhead đứng im + loop/stop sub-tab không bao giờ kích hoạt. Fix: thêm rAF sau khi setSubTabs(isPlaying: true) trong nhánh play của handlePlayPause (18662).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038900)
- **Ghi chú/Test (nếu có):** BUILD OK 1006951 bytes, node --check OK, `pytest` 86 passed. rAF count 10 ✓.
---
- **Tóm tắt thay đổi:** Double-click MIDI item mở PIANO ROLL → không âm (cả tab lẫn MAIN/SECTION sau đó). Nguyên nhân: `handleEditMidiInTab` gọi **`SonicSF.selectInstrument(seCh, ...)`** — **loadSoundFont BẤT ĐỒNG BỘ (async, không await)** — nếu SF chưa load xong lúc play → **FluidSynth stall → SILENCE** (đúng comment loadSoundFont: "stalling notes until each load finishes (audible lag, then silence)"). Nhạc cụ đã được chọn đúng khi playNote (track.instrumentProgram + synth_engine truyền trực tiếp trong schedulePianoRollMidi) — preload lúc mở tab là không cần thiết + gây hại. **Fix: BỎ khối selectInstrument khỏi handleEditMidiInTab.**
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038800)
- **Ghi chú/Test (nếu có):** BUILD OK 1006759 bytes, node --check OK, `pytest` 86 passed. selectInstrument(seCh — không còn khi mở tab ✓.
---
- **Tóm tắt thay đổi:** "Mở PIANO ROLL TAB không nhấn play được + không âm" — nhóm fix:
  1. **handlePlayPause nhánh sub-tab bọc try/catch** — exception (nếu có) log `[Play] Piano Roll play error` + toast — nút luôn phản hồi (không "chết im").
  2. **schedulePianoRollMidi guard `!track`** — log warn + return (tránh throw khi track không còn).
  3. **Loop hết bài (updatePlayhead 17794)**: thêm `schedulePianoRollMidi(st, 0)` trước `startSubTabPlayback` — **âm piano roll bị mất ở lần loop 2+** (trước chỉ chạy silent buffer).
  4. **startSubTabPlayback KHÔNG ghi đè track node thật khi là PIANO_ROLL** — giữ node thật trong activeTrackNodesRef (SF notes schedule tới node đó — ghi đè bằng silent node làm mất mastering route/updateSfRouting đúng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038700)
- **Ghi chú/Test (nếu có):** BUILD OK 1006797 bytes, node --check OK, `pytest` 86 passed. try/catch ✓, guard ✓, loop schedule notes ✓, giữ node thật ✓.
---
- **Tóm tắt thay đổi:** Save project (Cloud/.sfs) → reload → mất màu track. Nguyên nhân: **`serializeTracksList` (8733-8757) KHÔNG serialize `color`** (serializeSafe có color nhưng chỉ là helper temp autosave không dùng; serializeProjectToSchema dùng serializeTracksList) → data lưu server/.sfs không có màu → deserialize (8825 có `color: t.color`) nhận null → mất. Fix:
  1. `serializeTracksList`: thêm **`color: t.color || null`** vào track-level fields.
  2. `app/models/project_schema.json`: thêm **`color: { type: ["string","null"], default: null }`** vào Track properties (schema validate cho phép + khớp).
  (deserialize đã có `color: t.color || default` ✓; validate_project_data không strip color ✓)
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/models/project_schema.json`, `app/templates/index.html` (bump v=202608038600)
- **Ghi chú/Test (nếu có):** BUILD OK 1006266 bytes, node --check OK, `pytest` 86 passed. serialize color ✓, schema color ✓.
---
- **Tóm tắt thay đổi:** Click TCP header (số track "01") → container onClick cũ: nếu track có PIANO ROLL tab với notes → `stopAllPlayback()` + `startSubTabPlayback` → **DỪNG main play** (nghe như "mất âm") — đây cũng là cơ chế lỗi "đổi màu → mất âm" trước (click bubble tới container). Fix:
  1. Container onClick **chỉ `setSelectedTrackId`** — bỏ toàn bộ auto-play piano roll tab (không phá main playback).
  2. Color input thêm `onClick`/`onMouseDown` **stopPropagation** (click chấm màu không bubble → không trigger container select).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038500)
- **Ghi chú/Test (nếu có):** BUILD OK 1006246 bytes, node --check OK, `pytest` 86 passed. Số track còn ✓, vTrack nguyên vẹn ✓, hết auto-play ✓.
---
- **Tóm tắt thay đổi:** Bản 38300 watchdog rebuild cứu câm — NHƯNG trigger SAI khi nhạc đang ở đoạn im lặng tự nhiên (intro/rest >750ms): rebuild → loop vô hạn → âm không qua main out + master VU đứng im (đúng triệu chứng user báo). Fix:
  1. **`anyPlaying` guard**: chỉ đếm im lặng khi có **source ĐANG TRONG KHOẢNG PHÁT** (`ctxNow ∈ [source.startTime, startTime+duration]` — so với audioCtx.currentTime) — đoạn lặng tự nhiên (mọi source ngoài khoảng) → KHÔNG rebuild.
  2. **Cooldown 3s** (`lastMasterRebuildTimeRef`) — sau rebuild, 3s mới được rebuild tiếp (phòng lặp).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038400)
- **Ghi chú/Test (nếu có):** BUILD OK 1006318 bytes, node --check OK, `pytest` 86 passed. anyPlaying ✓, cooldown 3s ✓.
---
- **Tóm tắt thay đổi:** User: nhấn TCP đổi màu → warning `BiquadFilterNode: state is bad` + CÂM ngay sau đó. Warning này từ fast automation (EQ PRO 0.005 — đã sửa setValueAtTime) nhưng **node bị cache dính** (activeTrackNodesRef) → graph hỏng vĩnh viễn → câm. Fix: **master-silence watchdog trong updatePlayhead**: khi đang play + có sources hoạt động nhưng masterBus.analyser im lặng liên tục **>45 frame (~750ms)** → **TỰ ĐỘNG rebuild**: disconnect + xóa toàn bộ track nodes → `initMasterBus()` (graph mới sạch) → `startTrackPlayback(playhead)` → hết câm tự phục hồi (log `[Recovery]`). Reset counter khi có tín hiệu/không play. (Lưu ý: bundle cũ vẫn gây warning — cần hard refresh để EQ PRO setValueAtTime có hiệu lực.)
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038300)
- **Ghi chú/Test (nếu có):** BUILD OK 1005657 bytes, node --check OK, `pytest` 86 passed. Watchdog ✓, pendingRescheduleRef nguyên vẹn ✓.
---
- **Tóm tắt thay đổi:** 3 fix (sau khi đổi track color):
  1. **VU dính animation dù stop + Master VU full**: VU render loop (rAF) chạy mỗi frame bất kể play/stop + không try/catch (exception → loop chết → VU dính giá trị cuối, master full). Fix: **guard `!isPlaying && !recording` → vẽ VU RỖNG (master 0, track -60)** + bọc **try/catch** quanh tick (exception → log, loop vẫn sống).
  2. **Màu track không lưu khi reload**: autosave temp debounce 2s — reload nhanh sau đổi màu → mất. Fix: storage.js thêm **`flushTempAutoSave()`** (bỏ debounce — lưu localStorage + saveTempProject NGAY) + `updateTrackColor` gọi schedule + flush với state tracks mới.
  3. **Câm sau đổi màu** (chưa tái hiện được): guard audioSig (bản trước) + nếu còn — console `[Play]` logs + `VU tick error` sẽ lộ nguyên nhân.
- **Các file ảnh hưởng:** `app/static/js/services/storage.js` (flushTempAutoSave), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038200 cả storage.js)
- **Ghi chú/Test (nếu có):** BUILD OK 1004289 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Đổi màu track → `setTracks` → effect sync (mute/solo/route) chạy → `updateSfRouting()` chạy thừa → SF destination có thể bị đặt sai thời điểm (node chưa tồn tại → setOutputDestination(null)) → play sau đó mất âm thanh. Fix: thêm **`trackAudioSyncSigRef`** — signature audio-relevant (muted/solo/volumeDb/audioBypass/midiBypass + số lượng midiItems/clips/sections) — đổi MÀU/rename (không liên quan audio) → signature giống → **skip toàn bộ re-sync/re-route**; mọi thay đổi audio thực sự (mute/solo/volume/bypass/items) vẫn sync bình thường.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038100)
- **Ghi chú/Test (nếu có):** BUILD OK 1003238 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** TrackStripConsole trước set `height = track.height` ngay trong component — nhưng component CHỈ được dùng ở Mixer panel (F7) → mixer strip bị thu nhỏ bằng track.height (140) trong row cao hơn. Fix: bỏ height cố định — `style: style || undefined` → mixer strip **stretch full row** (items-stretch của container); TCP header panel trái không dùng component này (riêng, đã neo autoHeight).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038000)
- **Ghi chú/Test (nếu có):** BUILD OK 1002528 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** 3 yêu cầu:
  1. **Color picker "A user gesture is required"**: input type=color cũ có `pointer-events-none` + label onClick gọi `el.click()` (JS-click bị Chrome chặn). Fix: bỏ el.click — **input phủ label** (`absolute inset-0 w-full h-full opacity-0 cursor-pointer`) → click TRỰC TIẾP vào input (user gesture hợp lệ) — áp cả track header TCP + Sub-Tab editor (vTrack).
  2. **Items đổi màu theo track.color**: MIDI items trước dùng màu tím cố định #a78bfa — giờ `(track.color || '#a78bfa')` cho fill/stroke/text/notes; sections fallback `sec.color || track.color` (sec.color riêng vẫn ưu tiên); clips đã theo track.color sẵn.
  3. **Mixer strip chỉ 1/2 row** — do fix trước set height track.height TRONG TrackStripConsole (áp cả mixer). KHẮC PHỤC: (đã kiểm tra — height vẫn còn trong component — cần xem lại nếu user còn báo; bản này giữ nguyên vì TCP header dùng chung công thức).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037900)
- **Ghi chú/Test (nếu có):** BUILD OK 1002593 bytes, node --check OK, `pytest` 86 passed. el.click() = 0 ✓, vTrack nguyên vẹn ✓, items/sections theo track.color ✓.
---
- **Tóm tắt thay đổi:** VU meter chỉ nhảy khi play MIDI item — không nhảy khi ARM + nhấn phím MIDI. Nguyên nhân: VU render loop (rAF) tính `midiPeak = isPlaying && isAudible ? midiVuActivityRef[...] : 0` — `triggerMidiVuActivity` ĐÃ được gọi khi phím MIDI (13658) nhưng bị guard `isPlaying` chặn. Fix: bỏ `isPlaying` — `midiPeak = isAudible ? midiVuActivityRef[...] : 0` (velocity đã normalize 0-1 trong triggerMidiVuActivity; decay 0.9/frame giữ nguyên) → VU nhảy khi ARM + phím MIDI lẫn khi play item.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037800)
- **Ghi chú/Test (nếu có):** BUILD OK 1002683 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** 3 yêu cầu TCP:
  1. **Chiều cao TCP = chiều cao timeline row (neo)**: TrackStripConsole nhận `style` prop + tự set `height = track.height || (isArmed ? 164 : 140)` (CÙNG công thức timeline row) — resize track → TCP đổi theo, không lệch; bỏ `overflow-hidden` → `overflow-y-auto` + center `min-h-[170px]` → **các nút (M/S/A/♪/FX/PWR...) không bị che** (đủ chỗ / cuộn được).
  2. **Xóa label MIDI note** (khi ARM + nhấn phím MIDI hiện `pitch:velocity:length` sát ô input dropdown).
  3. **VU meter lên bên phải, CÙNG HÀNG với input dropdown** (`In: [select] [VU ▓▓▓]` — hiện mọi lúc, không chỉ khi ARM).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037700)
- **Ghi chú/Test (nếu có):** BUILD OK 1002496 bytes, node --check OK, `pytest` 86 passed. Label note đã xóa ✓, VU cùng hàng ✓, TCP neo height ✓.
---
- **Tóm tắt thay đổi:** User báo play + ARM MIDI preview đều không có âm thanh; console: `BiquadFilterNode: state is bad, probably due to unstable filter caused by fast parameter automation` — đúng cảnh báo cũ trong code (fast automation → master routing broken → CÂM toàn cục). Thủ phạm: EQ PRO `setBand`/`setAmount` dùng `setTargetAtTime(..., now, 0.005)` — automation 5ms quá nhanh → Chromium đánh dấu filter unstable vĩnh viễn (node cache dính). **Fix: đổi toàn bộ sang `setValueAtTime(x, now)`** (tức thì, không automation ramp → không flag) — 4 chỗ (freq/Q/gain trong setBand + gain trong setAmount). Hard refresh → module EQ PRO mới (filter mới) → hết câm.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037600)
- **Ghi chú/Test (nếu có):** BUILD OK 1002801 bytes, node --check OK, `pytest` 86 passed. Không còn setTargetAtTime 0.005 (EQ PRO) — setValueAtTime ✓.
---
- **Tóm tắt thay đổi:** Track `[AI Var]` do AI tạo không có âm thanh — thiếu cấu trúc như track MIDI gốc (instrumentProgram/instrumentName/synth_engine + các field rules). Fix: track mới = **clone toàn bộ track nguồn** (`...(srcTrack)`) — kế thừa synth_engine, instrumentProgram/Name, volumeDb, pan, fxActive, bypass flags, color... — rồi reset phần content (id, name `[AI Var] title`, buffer null, clips/sections rỗng, midiItems = [item AI], muted/solo false, markers rỗng, serverFileId null); fallback synth_engine/instrument từ aiResult hoặc item gốc nếu track nguồn thiếu. Track AI giờ tuân thủ rules như mọi track khác trong session.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037500)
- **Ghi chú/Test (nếu có):** BUILD OK 1002829 bytes, node --check OK, `pytest` 86 passed. Clone srcTrack ✓, instrumentProgram kế thừa ✓.
---
- **Tóm tắt thay đổi:** Debug log cho thấy provider trả **nhiều tool calls `_unknown`** (arguments chỉ chứa "reason" — không có notes) → tool-calling không hoạt động với provider hiện tại. Fix: **KHÔNG gửi `tools`** (`tools: []`); prompt yêu cầu **JSON thuần** (không markdown) với shape chính xác `{mode, composition_title, target_start_bar, target_duration_bars, generated_notes[]}` + constraint "notes phủ 0→total_beats, kết thúc sạch, start_beat relative". Parse: **ưu tiên textResponse** (JSON); functionCalls chỉ dùng khi `name === 'rearrange_or_extend_midi_melody'` (bỏ qua _unknown). Giữ `pickNotes` (generated_notes/notes/rearranged_notes/midi_notes/mảng thuần) + diagnostic raw khi vẫn fail.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037400)
- **Ghi chú/Test (nếu có):** BUILD OK 1002469 bytes, node --check OK, `pytest` 86 passed. tools:[] ✓, pickNotes ×2, JSON shape constraint ✓.
---
- **Tóm tắt thay đổi:** Lỗi "AI không trả về generated_notes hợp lệ" — model trả key/format khác (notes/rearranged_notes/mảng thuần) hoặc functionCalls arguments là mảng trực tiếp. Fix: `pickNotes(obj)` nhận `generated_notes | notes | rearranged_notes | midi_notes` hoặc mảng thuần; functionCalls arguments là mảng → bọc lại; textResponse parse → pickNotes; Khi vẫn fail: log `console.error` chi tiết (raw text 600 chars + functionCalls 500 + textResponse 500) + action log kèm Raw 200 chars để user dán lại chẩn đoán.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037300)
- **Ghi chú/Test (nếu có):** BUILD OK 1001539 bytes, node --check OK, `pytest` 86 passed. pickNotes ×5, aiNotes ×2.
---
- **Tóm tắt thay đổi:** Sửa tool AI theo spec đính kèm — chỉ cần click MIDI item + gõ prompt:
  1. **STEP 1 — Extract/Nén**: `SonicMidiExtractor.extractSelectedMIDIContext(tracks, itemId, bpm)` — note gồm `note_name` (C4/D4 — tiết kiệm token), start_beat/duration_beats/velocity round 2dp; context: track/item, duration_bars, total_beats, bpm, total_notes.
  2. **STEP 2 — Function Tool Schema**: thêm `REARRANGE_EXTEND_TOOL_SPEC` vào aiGateway.js (name `rearrange_or_extend_midi_melody`, 2 mode SIMILAR_VARIATION/EXTEND_CONTINUATION, params: mode, composition_title, target_start_bar, target_duration_bars, soundfont_id/bank/program, generated_notes[]) + export + window.AIGateway.
  3. **STEP 3 — Prompt Engineering**: `handleAiComposeFromItem(mode)` build prompt đúng spec: ORIGINAL CONTEXT (track/item/bpm/time_sig/location/notes JSON) + USER DIRECTIVE + MODE DIRECTIVE (Extend: continuation từ bar kế; Variation: equal length, giữ hòa âm + kỹ thuật biến tấu) + STRICT CONSTRAINTS (bắt buộc function tool, notes phủ 0→total_beats, kết thúc sạch).
  4. **STEPS 4-5 — Dispatch + Ingest**: gửi kèm `tools: [REARRANGE_EXTEND_TOOL_SPEC]`; decode functionCalls (fallback text JSON); **SIMILAR_VARIATION → track mới `[AI Var] title` ngay dưới track nguồn (A/B) + soundfont từ item gốc; EXTEND_CONTINUATION → append item `[Extend] title` cuối track gốc** (target_start_bar × secPerBar → giây).
  5. **UI**: nút mode **Variation/Extend** (toggle, amber/sky) + nút **Compose** gọi `handleAiComposeFromItem(aiComposeMode)`; state `aiComposeMode`.
- **Các file ảnh hưởng:** `app/static/js/services/aiGateway.js` (REARRANGE_EXTEND_TOOL_SPEC), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037200 cả aiGateway.js)
- **Ghi chú/Test (nếu có):** BUILD OK 1000935 bytes, node --check OK, `pytest` 86 passed. handleAiComposeFromItem ×3, REARRANGE_EXTEND ×1, rearrange_or_extend ×2.
---
- **Tóm tắt thay đổi:** Tool AI mới theo yêu cầu: click MIDI item trên timeline → AI panel hiện nút **"Compose"** (teal, icon music-2; mờ khi chưa chọn item) → gõ prompt → **`handleAiComposeToNextTrack`**:
  1. Lấy cấu trúc giai điệu item (`getSelectedMidiItemInfo` mở rộng trả notes/startTime/duration): noteCount, pitchRange, pitches, starts, durations, velocities (≤60 notes).
  2. Gửi AIGateway: "Compose NEW melody SIMILAR in style/rhythm/motif but NOT identical" + yêu cầu user → parse JSON notes (giống handleAISend).
  3. **Track kế tiếp**: track sau track nguồn — nếu RỖNG (không buffer/clips/midiItems/sections) → ghi vào đó; nếu KHÔNG RỖNG → **chèn track mới "AI Melody Track"** ngay sau track nguồn.
  4. Tạo MIDI item (id midi_ai_*, startTime 0, duration = maxBeat × beatSec, name "AI Melody (tên item)") + notes mới → push vào track mục tiêu; action log + toast.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037100)
- **Ghi chú/Test (nếu có):** BUILD OK 996444 bytes, node --check OK, `pytest` 86 passed. handleAiComposeToNextTrack ×2 (định nghĩa + onClick).
---
- **Tóm tắt thay đổi:**
  1. **Re-schedule vẫn không chạy khi kéo clip**: rAF loop giữ `updatePlayhead` của RENDER CŨ — closure nắm `activeTracks`/`sessionTabs` STALE (effect deps không gồm chúng) → signature luôn cũ → không bao giờ phát hiện kéo. **Fix**: re-schedule dùng `activeTracksRef.current` + `sessionTabsRef.current` (sync mỗi render); solo check tính lại từ ref (`curTracks.some(t => t.solo)`).
  2. **Zoom persist**: `zoom` (App) khởi tạo từ `localStorage.sf_zoom` + effect lưu khi đổi → kích thước items giữ nguyên sau reload (zoom in/out).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037000)
- **Ghi chú/Test (nếu có):** BUILD OK 989793 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Debounce 250ms cũ chờ sig "ổn định" — trong lúc kéo clip liên tục, sig đổi mỗi frame → KHÔNG BAO GIỜ re-schedule → clip kéo đi vẫn phát âm thanh cũ (không cập nhật realtime). Thay bằng **THROTTLE ~300ms**: khi sig đổi (dù đang kéo hay không) → re-schedule định kỳ (≤3.3 lần/giây, không giật) — clip kéo đi dừng ngay ≤300ms; clip mới phát khi playhead tới. `triggerRescheduleNow` (mouseup) reset cooldown → re-schedule ở check kế (~100ms) sau khi thả. Giữ mode-aware (solo/loop local/toàn session) + signature đầy đủ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036900)
- **Ghi chú/Test (nếu có):** BUILD OK 989194 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** `triggerRescheduleNow()` — set `pendingRescheduleRef = { sig hiện tại, time: 0 }` → check kế tiếp trong updatePlayhead (~100ms) re-schedule LUÔN (time 0 → điều kiện >250ms thoả) — gọi ở mouseup của drag clip (`draggedClipRef`) + drag section/midi item (`draggedSectionItemRef`). Kịch bản: playhead bar 3, clip ở bar 3 → kéo clip tới bar 5 (playhead 3 không còn âm thanh ✓ — clip schedule ở 5) → kéo clip QUAY LẠI bar 3 → **thả → ~100ms → re-schedule → phát NGAY** (playOffset = playhead − clipStart ≈ 0 → phát từ đầu clip). Debounce 250ms chỉ còn dành cho thao tác kéo liên tục (chống giật).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036800)
- **Ghi chú/Test (nếu có):** BUILD OK 989510 bytes, node --check OK, `pytest` 86 passed. triggerRescheduleNow ×3.
---
- **Tóm tắt thay đổi:** `buildItemsSignature` thêm **track-level default clip** (`track.buffer` → `def:startTime:speed`) — vị trí clip default lưu ở `track.startTime`, KHÔNG nằm trong `t.clips` → trước đây kéo default clip không đổi signature → không re-schedule (vẫn phát nội dung cũ). Kết hợp với re-schedule mode-aware (loop local/solo chỉ phát track liên quan) + debounce 250ms: kịch bản "playhead bar 3 trong clip → kéo clip tới bar 3" → sau khi thả, re-schedule `pt = playhead` → `playOffset = pt − clip.startTime = 0` → **phát TỪ ĐẦU clip realtime**; clip nằm trước playhead → phát từ offset tương ứng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036700)
- **Ghi chú/Test (nếu có):** BUILD OK 988860 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Re-schedule (khi items đổi vị trí lúc play) trước đây luôn gọi `startTrackPlayback(pt)` → LOOP LOCAL bị phá (phát nhầm các track khác + mất hành vi loop). Fix: re-schedule theo ĐÚNG chế độ play — `hasAnySolo` → chỉ track solo (`startLocalTrackPlayback`); `selectionMode==='local'` → chỉ `localSelectionTrackId`; ngược lại `startTrackPlayback(pt)`. Cả 2 hàm tính `playOffset = pt − clip.startTime` → **kéo clip tới đúng vị trí playhead → phát TỪ ĐẦU clip** (offset 0) realtime (debounce 250ms của user giữ nguyên — tránh stop/start liên tục khi kéo).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036600)
- **Ghi chú/Test (nếu có):** BUILD OK 988538 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Khi đang play/loop mà user kéo/thay đổi vị trí item → vẫn phát nội dung cũ (source đã schedule với startTime cũ). Fix: **`buildItemsSignature(tracksList, tabsList)`** (module-level — signature vị trí/speed/duration của clips + midiItems + sections + nội dung section tab); `scheduledItemsSigRef` được capture mỗi lần `startTrackPlayback`; `updatePlayhead` kiểm tra ~10fps (mỗi 6 frame, guard `activeTab==='main'` + không RECORDING): nếu signature ĐỔI → `stopAllPlayback()` + `startTrackPlayback(playhead hiện tại)` — item mới phát đúng vị trí mới gần như realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036500)
- **Ghi chú/Test (nếu có):** BUILD OK 987414 bytes, node --check OK, `pytest` 86 passed. buildItemsSignature ×3, scheduledItemsSigRef ×4, playheadFrameCountRef ×3.
---
- **Tóm tắt thay đổi:** Click tempo track lane (không tạo selection) → nhấn nút Loop → auto-derive `0 → (maxEnd + 2 bars)` (bar 18 trong khi maxDuration chỉ tới bar 16). Fix: dùng **`computeMainSessionEndTime(activeTracks)`** làm maxEnd (clips theo buffer.duration/speed, midiItems endTime/duration, section bounds — đầy đủ hơn logic cũ vốn bỏ sót speed + midiItems + sections) và **BỎ `+ secPerBar * 2`** — loop region = đúng endtime của session.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036400)
- **Ghi chú/Test (nếu có):** BUILD OK 985447 bytes, node --check OK, `pytest` 86 passed. Không còn `maxEnd + secPerBar * 2` trong bundle.
---
- **Tóm tắt thay đổi:** `maxDuration` = endtime + **12 bars buffer + scrollBufferExtra** (dành cho scroll/zoom) — nhưng updatePlayhead dùng nó làm điểm dừng LOOP → loop kéo dài quá duration thật. Thêm **`projectEnd`** (useMemo + `projectEndRef`): endtime THẬT của session (main → `computeMainSessionEndTime(activeTracks)`; section-tab → `secStart + computeMainSessionEndTime(tab.tracks)`; RECORDING → +60s; tối thiểu 1s) — KHÔNG buffer. `updatePlayhead` (nhánh hết bài) đổi `maxDurationRef.current` → **`projectEndRef.current`** → master loop play lại từ 0 đúng tại endtime; maxDuration giữ nguyên cho scroll/zoom/minZoom.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036300)
- **Ghi chú/Test (nếu có):** BUILD OK 985502 bytes, node --check OK, `pytest` 86 passed. projectEndRef ×3, loop stop dùng projectEnd ✓, secPerBar*12 còn (scroll).
---
- **Tóm tắt thay đổi:** Hành động **alt-click-drag** (speed stretch) ở rìa phải clip trong section-tab graph giờ có undo/redo: `stretchStartRef` lưu thêm `finalSpeed` (cập nhật liên tục khi kéo) + `clipName`; `handleMouseUp` push entry **`SET_CLIP_SPEED`** vào `window.UndoRedoEngine` (chỉ khi speed thực sự đổi > 0.001): undo → `onSpeedChange(before)` (speed gốc), redo → `onSpeedChange(after)` — `onSpeedChange` tự tính lại volumeNodes/panningNodes/fade/label theo ratio nên khớp cả 2 chiều. Phím Ctrl+Z / nút Undo ưu tiên UndoRedoEngine (đã có sẵn) nên hành động này undo/redo ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036200)
- **Ghi chú/Test (nếu có):** BUILD OK 984340 bytes, node --check OK, `pytest` 86 passed. SET_CLIP_SPEED ×2, finalSpeed ×8 trong bundle.
---
- **Tóm tắt thay đổi:** `computeProjectEndTime` (gộp section content vào main) thay bằng **`computeMainSessionEndTime(tracksList)`**: chỉ tính endtime của items TRÊN tracks đó (clips + midiItems + section bounds) — **mặc kệ nội dung SECTION-TAB** (khi play trong main session, sub-track items bị clamp trong section bounds nên tab dài không kéo dài project). `maxDuration` useMemo tách theo `activeTab`: MAIN → `computeMainSessionEndTime(activeTracks)`; SECTION-TAB → `secStart(section) + computeMainSessionEndTime(tab.tracks)` (tab.sectionId → tìm section start trên main). Export (bounce + offline) dùng `computeMainSessionEndTime` (main-based, giữ max với midiCache preview).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036100)
- **Ghi chú/Test (nếu có):** BUILD OK 983477 bytes, node --check OK, `pytest` 86 passed. computeMainSessionEndTime ×5, computeProjectEndTime = 0.
---
- **Tóm tắt thay đổi:** `maxDuration` (loop stop) + `triggerBounceExport` + `clientSideExport` tính end time THIẾU nội dung SECTION-TAB (sub-track clips/MIDI bên trong section + session tabs độc lập) → loop dừng sớm / export cắt cuối bài. Thêm **`computeProjectEndTime(tracksList, tabsList)`** (module-level): gộp clips (start + duration/speed), midiItems (endTime ưu tiên, fallback startTime + duration), sections (start + duration) VÀ nội dung bên trong từng section (sub-track clips schedule tại sec.start + local, sub MIDI tại sec.start + item.startTime) + session tabs độc lập. Áp dụng đồng bộ 3 nơi: `maxDuration` useMemo (thêm sessionTabs vào deps), `triggerBounceExport` durationLimit, `clientSideExport` durationLimit (giữ max với midiCache).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036000)
- **Ghi chú/Test (nếu có):** BUILD OK 983950 bytes, node --check OK, `pytest` 86 passed. computeProjectEndTime ×4 trong bundle.
---
- **Tóm tắt thay đổi:**
  1. **Export modal float không tương tác được** → chuyển từ JSX inline trong IIFE dockPanels thành **component riêng `ExportModal`** (render ở App level cạnh FXRackModal — cùng vị trí với modal đã chứng minh hoạt động tốt). Nội dung giữ nguyên: Nguồn/Định dạng/SR/Bit/Chất lượng/Kênh + nút Bounce MIDI + Export. Loại bỏ mọi nghi vấn stacking-context/pointer-events từ IIFE.
  2. **Thêm diagnostic log `[Play]`** trong startTrackPlayback (offset, số track, masterBus, destination, node ok từng track) — để xác định lỗi "không có âm thanh ra main out khi play": user dán console log (F12) lại để tôi chẩn đoán chính xác.
  3. Kiểm tra: `getOrCreateSubTrackNode` + section playback + getOrCreateTrackNode (fxEntry/SF chain/route) + initMasterBus (input→compressor→inputAnalyser→outputAnalyser→output→destination) + dryInput→dryOutput→output + computeTrackAudibleGain — TẤT CẢ ĐÚNG, không có lỗi tĩnh.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035900)
- **Ghi chú/Test (nếu có):** BUILD OK 982816 bytes, node --check OK, `pytest` 86 passed. ExportModal ×2 (định nghĩa + render), [Play] log có trong bundle.
---
- **Tóm tắt thay đổi:** Theo yêu cầu: xóa 3 panel khỏi dock rows (bottom) — `addPanel('selection'/'fx_rack'/'midi_events', ...)` bị bỏ (không render bottom row nữa); 3 nút toolbar tương ứng (Selection / FX Rack / MIDI Events) bị xóa (regex chính xác, verify syntax từng bước). FX Rack vẫn dùng modal float (nút FX trên track strip → `__openFxRack`); Export vẫn là modal float (tooltip "Export (floating modal)"); Mixer (F7) + Media Explorer (F6) + AI/Python Tools giữ nguyên.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035800)
- **Ghi chú/Test (nếu có):** BUILD OK 977199 bytes, node --check OK, `pytest` 86 passed. Verify: addPanel + onClick của 3 panel = False trong source; Export/Mixer/Media Explorer còn.
---
- **Tóm tắt thay đổi:** Export panel không còn nằm trong dock rows (bottom row) — bỏ `addPanel('export', ...)`; khi bấm nút Export → modal **fixed inset-0 z-[300]**, overlay đen + blur, panel **căn giữa màn hình** (max-w-2xl, rounded, header EXPORT + nút X đóng, body scroll max-h 72vh) — nội dung dùng lại `renderPanelContent('export')`; click overlay → đóng; lucide icons re-render khi mở; tooltip nút cập nhật "Export (floating modal)".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035700)
- **Ghi chú/Test (nếu có):** BUILD OK 979154 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Hiện thực hóa ý tưởng "bounce về client + cache khi preview":
  1. **Cache khi preview**: `ensureMidiCapture(track, node)` — ScriptProcessor tap trên `node.sfEntry` (SF output PRE track-FX, sau volume/mute) ghi PCM vào `midiCacheRef[trackId]` (chunks + duration); gọi trong startTrackPlayback nhánh MIDI; play từ 0 → reset cache (bản mới nhất); `stopMidiCapture()` trong stopAllPlayback.
  2. **Export thông minh**: `triggerWavExport` nhánh MIDI — nếu MỌI track MIDI có cache ≥ độ dài cần → **export OFFLINE NHANH** qua `clientSideExport` (cache buffer schedule vào `node.sfEntry` của offline graph → qua sfModules FX rack + sfRouteGain/sfDryGain mastering — đúng như playback); ngược lại → bounce realtime (đảm bảo trung thực).
  3. **buildOfflineTrackNode** thêm SF chain (sfEntry/sfOut/sfPan/sfRouteGain/sfDryGain + sfMods) — cache chảy qua track FX + mastering giống hệt live.
  4. durationLimit của offline export gồm cả độ dài MIDI cache.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035600)
- **Ghi chú/Test (nếu có):** BUILD OK 977934 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** `triggerWavExport` (nút Export) giờ kiểm tra project: nếu CÓ midiItems (track chính hoặc session tabs) → TỰ ĐỘNG gọi `triggerBounceExport()` (realtime bounce — giữ trung thực MIDI + FX Rack + Mastering Chain) kèm toast thông báo; nếu KHÔNG có MIDI → giữ export offline/server nhanh như cũ. User chỉ cần nhấn Export sau khi mix.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035500)
- **Ghi chú/Test (nếu có):** BUILD OK 973121 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Export client (OfflineAudioContext) không render được MIDI (FluidSynth = ScriptProcessor, không hỗ trợ offline). Giải pháp: **Realtime Bounce** — nút mới "Bounce MIDI" (màu tím, icon radio) cạnh nút Export trong panel Export:
  1. `triggerBounceExport()`: tính duration (clips + midiItems + sections + tail 1.2s) → tạo **ScriptProcessor capture tap** trên `masterBus.output` (post-FX + post-mastering) → rewind + `startTrackPlayback(0)` chạy lại TOÀN BỘ project (FluidSynth thật + FX racks + mastering chain — đúng 100% như nghe) → chờ realtime → dừng + encode WAV 16/8-bit (stereo/mono theo exportSettings) → download.
  2. Capture = PCM interleaved từ master bus output → WAV header chuẩn; sample rate = context thật.
  3. **Đo chất lượng** (bổ sung trước đó): I/O METERS trong MasteringModal có OUT RMS + LOUDNESS (LUFS approx) realtime — so sánh trước/sau mastering.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035400)
- **Ghi chú/Test (nếu có):** BUILD OK 972502 bytes, node --check OK, `pytest` 86 passed. Bounce chạy realtime (thời gian = độ dài bài); giữ nguyên âm thanh đang nghe; MIDI + FX + mastering ĐỀU có trong file.
---
- **Tóm tắt thay đổi:** 2 yêu cầu:
  1. **Export qua FX Rack + Mastering**: `clientSideExport` trước đây dựng graph đơn giản (source→gain→panner→destination) — KHÔNG có FX rack/mastering. Giờ: hàm mới `buildOfflineTrackNode(track, ctx, nodeMap)` (bản sao chính xác của getOrCreateTrackNode: fxEntry→[modules theo fxChain/fxActive]→fxLegacyIn→chorus/reverb→panner→analyser→route mastering, tôn trọng audioBypass qua trackAudioBypassMap) + trong export: snapshot `savedMasterBus`, `masterBus=null` + `initMasterBus(offlineCtx)` + `toggleMasteringOnMaster(masterConnected, isBypassed)` (mastering chain áp vào offline bus), schedule clips qua node.gainNode, render → WAV; `finally` KHÔI PHỤC masterBus + trạng thái mastering live. Export WAV client giờ NGHE ĐÚNG NHƯ PLAYBACK (track FX + mastering).
  2. **Đo chất lượng**: panel I/O METERS trong MasteringModal thêm **OUT RMS** (dBFS từ time-domain outputAnalyser) + **LOUDNESS** (LUFS xấp xỉ ITU-R BS.1770 — mean-square blocks ~400ms, không K-weighting) — hiển thị realtime sau mastering; kết hợp sẵn có: IN/OUT TRUE PEAK, Wave Observer, correlation, FFT (EQ Pro), Delta listen → đủ bộ đo trước/sau xử lí.
  - LƯU Ý: export client chỉ render AUDIO tracks (clips/sections có buffer) — MIDI items cần FluidSynth (ScriptProcessor không hỗ trợ OfflineAudioContext) → không nằm trong file export; server-side export (multitrack /mix) vẫn dùng cho trường hợp đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035300)
- **Ghi chú/Test (nếu có):** BUILD OK 967644 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Xử lý 2 phản hồi:
  1. **Band 1 không tăng giảm âm lượng**: default bands đổi thành lowshelf 80Hz + 3× peaking (250/1k/4k) + highshelf 10k — MỌI band mặc định đều có gain control → kéo lên/xuống nghe rõ. (Highpass/lowpass vốn không có gain trong WebAudio — giờ chỉ xuất hiện khi user chủ động chọn type.)
  2. **Low cut vẽ chưa đúng**: node của highpass/lowpass trước đây nằm trên trục 0dB trong khi curve hạ xuống → lệch. Thêm `eqproNodeDb(b)`: node highpass/lowpass nằm tại điểm −3dB tại fc (chuẩn Pro-Q), notch tại đỉnh hõm (−30dB visual), bandpass tại 0dB — node KHỚP curve; hit-test kéo cũng dùng cùng tọa độ. Verify RBJ: highpass −3.01dB@fc, −12.3dB/oct, −24.1dB/2oct; lowpass −3.01@fc; notch −53.5; bandpass 0; peaking +6; highshelf −4 ✓.
  3. **rebuild() disconnect input** trước khi dựng chain mới — phòng syncBands tạo 2 chain song song (âm thanh nhân đôi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035200)
- **Ghi chú/Test (nếu có):** BUILD OK 963191 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Xử lý 2 phản hồi:
  1. **Spectrum audioclip không hiển thị**: trước đây spectrum chỉ lấy 1 analyser (ưu tiên SF). Giờ `spectrumModules` là MẢNG — renderEqProFrame vẽ spectrum CHỒNG của mọi module live: audioclip path (sky `rgba(56,189,248,0.20)`) + soundfont/MIDI path (pink `rgba(236,72,153,0.16)`) — play clip hay MIDI đều hiện.
  2. **Band 1/5 (low cut/high cut) kéo không tác dụng**: nguyên nhân WebAudio — highpass/lowpass/notch/bandpass KHÔNG có gain control (gain bị bỏ qua). Thêm `eqproBandHasGain(type)` (peaking/lowshelf/highshelf có gain): node của filter no-gain giờ DÍNH TRỤC 0dB (chỉ kéo ngang đổi freq), HUD GAIN hiện "0.0 dB" — UI không gây hiểu lầm; kéo freq (vd highpass 25→200Hz) vẫn cắt bass rõ ràng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035100)
- **Ghi chú/Test (nếu có):** BUILD OK 962906 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Xử lý 5 phản hồi:
  1. **EQ PRO chưa hiện trong Mastering chain**: ADD MODULE POPUP mastering dùng danh sách CỨNG (7 loại, không eqpro) — đã thêm entry eqpro (teal, chart-area).
  2. **Không thấy waveform khi play MIDI**: InteractiveEqPro thêm prop `spectrumModule` — ưu tiên analyser của SF module (`__getTrackSfFxModule`) → play MIDI hiển thị spectrum tiếng đàn trên canvas EQ; fallback module audio.
  3+5. **Low cut/bands không xử lí chính xác**: thêm `syncBands(newBands)` trong createEqProModule (thay bands nội bộ + rebuild NGAY) — dblclick add/delete band, Reset, + Band giờ áp dụng DSP tức thì (trước đây rebuild từ bands nội bộ cũ → band mới chỉ có hiệu lực sau commit/rebuild graph). Kéo node → setBand realtime trên cả audio + SF (đã có từ v34900).
  4. **HUD drag xong nút không tác dụng**: clamp hudPos (hx ≤ w−268, hy ≤ h−225) — HUD luôn nằm gọn trong vùng canvas, nút không bị tràn ra ngoài vùng click.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035000)
- **Ghi chú/Test (nếu có):** BUILD OK 961976 bytes, node --check OK, `pytest` 86 passed; verify createEqProModule: 5 default filters đúng types, highpass freq 200 đúng, inactive→gain 0, amount 50%→gain 3 (mock).
---
- **Tóm tắt thay đổi:** Xử lý 4 phản hồi:
  1. **EQ PRO trong Mastering chain**: MODULE_META mastering + chainFlag('eqpro'→'eqproActive') + `addModuleToChain` thêm params mặc định (deep-clone bands) + `rebuildMasteringGraph` tạo module eqpro ĐỘNG (createEqProModule) lưu `masterBus.eqProInstances[mod.id]` (destroy + dọn khi rebuild) + view eqpro trong MasteringModal (InteractiveEqPro với `applyTo` trỏ masterBus.eqProInstances + `updateChainEntryParams`).
  2. **EQ PRO tác dụng MIDI item**: `applyAll` trong InteractiveEqPro giờ cập nhật CẢ module audio (`__getTrackFxModule` = node.fxMods) lẫn SF (`__getTrackSfFxModule` mới = node.sfMods) — kéo node → tiếng đàn + clip đều đổi realtime; add/delete/reset/amount/type cũng applyAll.
  3. **HUD drag được**: HUD floating giờ kéo được bằng chuột (pointer capture; vị trí user giữ qua `st.hudPos`, tự động clamp trong canvas; bấm node khác → vẫn giữ vị trí đã kéo).
  4. **Flicker/không kéo được khi play**: effect sync params giờ (a) bỏ qua khi đang drag (`dragId !== null`), (b) chỉ sync khi JSON thực sự đổi (lastSig) — trước đây params (object mới mỗi render cha) reset bands giữa cú kéo → node nhảy/flicker.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034900)
- **Ghi chú/Test (nếu có):** BUILD OK 960862 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Tạo module EQ chuyên nghiệp theo spec (FabFilter Pro-Q / iZotope Ozone style) tích hợp FX Rack Panel:
  1. **DSP (`createEqProModule`)**: cascade BiquadFilterNode nối tiếp (input → band1 → … → bandN → output) + analyser post-module; 7 filter shapes (peaking/highpass/lowpass/lowshelf/highshelf/notch/bandpass); params {type, freq 20-20k, gain ±24dB, q 0.1-18, active} + global Amount 0-200%; `setBand` realtime (setTargetAtTime 5ms), `setAmount`, `rebuild`; band active=false → gain 0.
  2. **Toán học RBJ chuẩn** (`eqproBiquadMagDb`): công thức Audio-EQ-Cookbook đầy đủ cho 7 loại filter — verify bằng test: peaking +12dB@f0=+12.00, notch Q10=-97.86dB, shelf đúng hướng, highpass -27.9dB@20Hz ✓.
  3. **Log mapping**: freq→x = W·log10(f/20)/3, gain→y ±24dB, Q→wing offset = 110/√Q.
  4. **InteractiveEqPro UI**: canvas rAF render — grid log-freq + dB, **realtime FFT spectrum** (từ analyser module), **band fills** màu trong suốt + **master white curve** (Σ dB); drag center node = freq+gain, drag **wings** = Q, **scroll wheel** = Q, **double-click canvas** = thêm band (tối đa 8), **double-click node** = xóa; **HUD floating** (glass): filter type dropdown, readouts FREQ/GAIN/Q, bypass band, delete; toolbar: Band count, Amount slider, Reset, + Band. Khi play → kéo trực tiếp vào module instance live (`window.__getTrackFxModule` → setBand).
  5. **Tích hợp**: TRACK_FX_META thêm 'eqpro' (+ nút [+]), TRACK_FX_DEFAULTS.eqpro (deep-clone bands khi add), node lưu `fxMods`/`sfMods` (audio + SF instances) để UI truy cập; view 'eqpro' trong FXRackModal; serialize params (bands) qua fx_chain tự động.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034800)
- **Ghi chú/Test (nếu có):** BUILD OK 957341 bytes, node --check OK, `pytest` 86 passed; verify RBJ math 8/8 case đúng.
---
- **Tóm tắt thay đổi:** `__getTrackScopeAnalysers` ưu tiên `sfAnalyser` (chỉ tiếng MIDI) khi track có midiItems → scope chỉ hiển thị MIDI, audioclip/section không xuất hiện. FIX: (1) nối thêm `sfOut → scopeSplitter` (cùng splitter với pannerNode) → scopeAnalyserL/R giờ trộn TÍN HIỆU ĐẦY ĐỦ post-FX của track: audioclip + section (pannerNode) + midi (sfOut); (2) bỏ nhánh ưu tiên sfAnalyser trong `__getTrackScopeAnalysers` (luôn dùng scopeAnalyserL/R đã trộn).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034700)
- **Ghi chú/Test (nếu có):** BUILD OK 937588 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Theo yêu cầu bắt buộc của user: **PWR sáng (ON) = mọi items (MIDI/section/audioclip) qua FX Rack Panel**; **A và ♪ chỉ dùng với Mastering FX Chain** (không ảnh hưởng track FX):
  1. **Audio path**: dryGain của route chuyển từ tap PRE-FX (gainNode) sang **POST-FX (analyserNode)** — A bypass giờ chỉ bỏ mastering; track FX Rack (fxEntry → modules) vẫn áp dụng cho clip/section bất kể A.
  2. **MIDI path**: thêm route riêng `sfRouteGain`/`sfDryGain` (sfPan → cả 2; sfRouteGain → masterBus.input, sfDryGain → masterBus.dryInput) — ♪ bypass chỉ bỏ mastering; SF vẫn qua sfModules (track FX, PWR-controlled). `__setTrackBypass('midi')` toggle trực tiếp route này; `updateSfRouting` bỏ nhánh dryInput (SF LUÔN vào sfEntry); effect sync áp lại khi load/undo.
  3. Tooltip cả 2 strip cập nhật: "A/♪ = Mastering FX Chain... XÁM = bypass mastering (vẫn qua track FX Rack)".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034600)
- **Ghi chú/Test (nếu có):** BUILD OK 937380 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User báo: FX Rack ON chỉ MIDI được xử lí, audioclip/section không. **NGUYÊN NHÂN THẬT**: `startLocalTrackPlayback` (loop selection) có dòng cũ `gainNode.connect(pannerNode)` — connect THẲNG gainNode → pannerNode, BỎ QUA fxEntry → [FX modules] → fxLegacyIn. Node track được CACHE → mọi playback sau (kể cả play thường) giữ đường thẳng này → clip vào panner 2 lần (1 qua FX + 1 thẳng) → FX bị pha loãng/không nghe rõ. FIX: bỏ dòng đó (node đã tự wire đúng). Lưu ý thứ 2 cho user: nếu nút A (bypass audio) đang XÁM, clip/section đi thẳng (dry — bỏ FX + mastering) theo thiết kế — MIDI vẫn qua FX (đường SF riêng) → muốn clip qua FX phải tắt nút A (sáng xanh).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034500)
- **Ghi chú/Test (nếu có):** BUILD OK 936122 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Nút PWR trước đây chỉ toggle state local UI (không tác dụng DSP). Giờ thành state THẬT trên track (`fxActive`, serialize `fx_active`, default true):
  1. **getOrCreateTrackNode**: `fxEnabled = track.fxActive !== false` gate cả 3 nhánh — audio fxChain modules, chorus/reverb legacy, SF chain modules (MIDI) — khi OFF: gainNode → fxLegacyIn → panner (không module nào), SF → sfEntry → thẳng sfOut.
  2. **rebuildTrackFxGraph**: gate cả 2 chain (audio + SF) — bấm PWR khi đang play re-route ngay.
  3. **Nút PWR (TrackStripConsole)**: toggle `track.fxActive` + `__rebuildTrackFxGraph` — sáng = ON (mọi items qua FX Rack Panel), tối = OFF (bỏ FX). Bỏ state local isFxActive.
  4. Track mới default `fxActive: true`; deserialize cũ → true (không phá project cũ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034400)
- **Ghi chú/Test (nếu có):** BUILD OK 935880 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User (bundle mới nhất, log route ĐÚNG: track1 bypass routeGain=0/dryGain=1) vẫn nghe audioclip qua mastering + tăng gain quá mức. **NGUYÊN NHÂN THẬT**: patch "SF path độc lập" trước đó nối `gainNode.connect(sfEntry)` VĨNH VIỄN — clip audio ở gainNode chảy luôn vào đường SF (`sfEntry → sfModules → sfOut → sfPan → masterBus.input → mastering`) → clip nghe **2 đường cùng lúc** (dry + SF path qua mastering) dù bypass bật → tăng gain + vẫn bị mastering xử lý. FIX:
  1. **XÓA `gainNode.connect(sfEntry)`** — SF output vào sfEntry TRỰC TIẾP qua `setOutputDestination(sfEntry)` (updateSfRouting), không đi qua gainNode → clip không bao giờ chảy vào SF chain.
  2. **sfEntry.gain mirror audible gain** (volume/mute/solo) ở 3 nơi: getOrCreateTrackNode (khởi tạo), applyAllTrackMuteSolo, effect sync `[tracks, sessionTabs]` — SF vẫn tuân theo fader/mute/solo.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034300)
- **Ghi chú/Test (nếu có):** BUILD OK 935143 bytes, node --check OK, `pytest` 86 passed. Verify: `gainNode.connect(sfEntry)` không còn trong bundle.
---
- **Tóm tắt thay đổi:** User yêu cầu rõ: **tắt nút A (xám) = phải bypass Mastering** — semantic đã đúng (xám = audioBypass=true = dry). Củng cố thêm: (1) `setMasteringRoute` giờ gán **`.value` TRỰC TIẾP** sau `cancelScheduledValues` (hard switch, không phụ thuộc automation queue — không thể trễ/treo); (2) thêm log `[Bypass] node created track <id> initial audioBypass=... routeGain=... dryGain=...` khi tạo track node — xác minh trạng thái ban đầu của route đúng với map. Rà lại MỌI path playback (startTrackPlayback, startLocalTrackPlayback loop-selection, section sub-nodes) đều qua track route → bypass áp cho audioclip + section ✓.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034200)
- **Ghi chú/Test (nếu có):** BUILD OK 934318 bytes, node --check OK, `pytest` 86 passed. Log mới khi bấm A: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)`.
---
- **Tóm tắt thay đổi:** User dán log: `audioBypass=false routeGain.gain=0.000 dryGain.gain=1.000` — giá trị `.value` ĐẢO ngược so với cờ. Nguyên nhân: `setTargetAtTime` là automation TRỄ (timeConstant 0.02) — `.value` đọc ngay sau khi schedule vẫn hiển thị trạng thái CŨ → log gây hiểu lầm (không phải DSP sai). Fix: `setMasteringRoute` dùng `setValueAtTime` tại currentTime (áp dụng TỨC THÌ, không ramp) + log mục tiêu rõ ràng: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)` / `→ MASTERING CHAIN (qua FX + mastering)` — không còn log giá trị stale. Lưu ý user: nếu track có MIDI items và ♪ chưa bật, tiếng đàn đi qua mastering (đúng thiết kế) — chỉ audio clips/sections mới chịu nút A.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034100)
- **Ghi chú/Test (nếu có):** BUILD OK 934145 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log `route is not defined` do patch trước: trong `createMasteringRoute` gán `route._trackId`/`route._bypass` nhưng biến `route` KHÔNG tồn tại trong hàm (hàm trả `{routeGain, dryGain}`; `route` là tên biến ở caller). Lỗi bắn ra MỖI lần tạo track node (play). Fix: tạo `routeObj` trước rồi gán `_trackId`/`_bypass` lên đó; `setMasteringRoute` đọc `route._trackId` hợp lệ (param).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034000)
- **Ghi chú/Test (nếu có):** BUILD OK 934143 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User báo nút A vẫn đưa tín hiệu qua Mastering panel. Rà toàn bộ chuỗi: clip → gainNode → (bypass) dryGain → masterBus.dryInput → dryOutput → output — **bỏ hoàn toàn mastering modules** (wiring đã đúng từ Pha H; masterBus: dryInput.connect(dryOutput), dryOutput.connect(output); wet: input → compressor → inputAnalyser → [modules] → outputAnalyser → output). Section items qua sub-node → parent gainNode → cùng route ✓. Củng cố: (1) `setMasteringRoute` dùng `getAudioContext()` (loại bỏ nguy cơ return sớm khi audioCtx null) + lưu `route._trackId/_bypass`; (2) **console.log `[Bypass] track <id> audioBypass=... routeGain.gain=... dryGain.gain=... → dry bus (bỏ mastering)` mỗi lần toggle** — user mở DevTools console để xác nhận đường tín hiệu thực tế. **Lưu ý: MIDI items (♪ chưa bật) ĐI QUA mastering** (masterBus.input → modules) — đúng thiết kế tách A/♪; nếu user test track có MIDI và thấy "vẫn qua mastering", đó là tiếng đàn chứ không phải audio clips.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033900)
- **Ghi chú/Test (nếu có):** BUILD OK 934112 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Theo yêu cầu user: **nút không sáng (xám) = cho phép bypass đang bật; nút sáng = không bypass (xử lý bình thường)**. Đảo UI cả 2 strip (MixerStrip + TrackStripConsole): A bypass bật → xám, tắt → sáng xanh; ♪ bypass bật → xám, tắt → sáng tím. Củng cố DSP để bypass audio chắc chắn tác dụng:
  1. `createMasteringRoute` **ưu tiên đọc `trackAudioBypassMap`** (sync từ state mỗi render) trước khi fallback track object — tránh stale object.
  2. `__setTrackBypass('audio')` giờ **re-route LIVE mọi section sub-node** (`<trackId>_sub_<subId>`) cùng main node → audioclip item + section item bypass ngay khi bấm, kể cả đang play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033800)
- **Ghi chú/Test (nếu có):** BUILD OK 933696 bytes, node --check OK, `pytest` 86 passed. Tooltip nút ghi rõ: "XÁM = bypass đang bật, SÁNG = xử lý bình thường".
---

### [2026-08-03] Task: Fix màu nút bypass A/♪ + tách hoàn toàn đường SF (MIDI không còn lệ thuộc nút A)
- **Tóm tắt thay đổi:**
  1. **Đường SF độc lập (fix lệ thuộc A)**: trước đây SF chảy qua chain DÙNG CHUNG (gainNode → fxEntry → ... → panner → routeGain) — khi bật nút A, routeGain=0 → MIDI mất FX dù ♪ chưa bật. Fix: track có midiItems giờ dựng **SF chain riêng**: `gainNode → sfEntry → [module instances RIÊNG (cùng loại/params)] → sfOut → sfPan → masterBus.input` — hoàn toàn tách khỏi chuỗi audio. `updateSfRouting`: ♪ off → SF → sfEntry (FX giữ nguyên dù A bật/tắt); ♪ on → SF → masterBus.dryInput. `rebuildTrackFxGraph` rebuild cả 2 chain + sync sfPan.pan. Scope rack: track MIDI hiển thị `sfAnalyser` (post-FX).
  2. **Màu nút theo yêu cầu**: TrackStripConsole — A/♪ inactive = **xám** (`text-slate-500`), active = **sáng xanh** (A: `text-sky-300 bg-sky-500/30`) / **sáng tím** (♪: `text-fuchsia-300 bg-fuchsia-500/30`). MixerStrip bỏ fallback `?? masteringBypass` khỏi visual (state hoàn toàn độc lập: A chỉ đọc `audioBypass`, ♪ chỉ đọc `midiBypass`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033700)
- **Ghi chú/Test (nếu có):** BUILD OK 933049 bytes, node --check OK, `pytest` 86 passed.
---

### [2026-08-03] Task: Tách nút bypass track thành 2 nút (Audio vs MIDI) + fix loop 0→18 khi click tempo track
- **Tóm tắt thay đổi:**
  1. **Tách bypass**: track giờ có `audioBypass` + `midiBypass` (migrate từ `masteringBypass` cũ — deserialize: `audio_bypass ?? mastering_bypass`, `midi_bypass ?? mastering_bypass`; serialize ghi cả 3). UI MixerStrip + TrackStripConsole: thay nút B đơn bằng **nút [A]** (bypass audio items = clips + sections; route kép dry/route như cũ — `trackAudioBypassMap` + `createMasteringRoute` đọc `audioBypass ?? masteringBypass`) và **nút [♪]** (bypass MIDI riêng — `trackMidiBypassMap` + `updateSfRouting`: khi bật, SF output → `masterBus.dryInput` trực tiếp, bỏ FX + mastering nhưng giữ CC7 mute/solo). `window.__setTrackBypass(trackId, 'audio'|'midi', on)`; giữ `__setTrackMasteringBypass` (set cả 2) cho tương thích.
  2. **Fix loop**: click lên tempo track (hoặc click đơn bất kỳ) tạo selection RỖNG (start==end) → `hasSel` cũ FALSE → nút loop auto-derive 0→18 ghi đè. Fix: `hasSel` giờ nhận mọi selection state có tồn tại (`selectionStart !== null && selectionEnd !== null` — kể cả start==end) → không bao giờ tự derive khi user đã click lane.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033600)
- **Ghi chú/Test (nếu có):** BUILD OK 931110 bytes, node --check OK, `pytest` 86 passed.
---

### [2026-08-03] Task: Wave Observer + Module Vector Display Canvas trong FX Rack Panel (unified_fx_rack_panel_update.md)
- **Tóm tắt thay đổi:** Tích hợp theo spec cập nhật:
  1. **Scope analysers per-track (post-FX)**: `getOrCreateTrackNode` thêm `scopeSplitter` + `scopeAnalyserL/R` (fftSize 2048) nối từ `pannerNode` (output context, SAU FX chain — đúng sơ đồ spec §III.3); `analyserNode` chính tăng 256→2048. Expose `window.__getTrackScopeAnalysers(trackId)` → {L, R, sr}.
  2. **Wave Observer UI trong FXRackModal**: canvas scope + toolbar đúng spec — Input L/R meters, **Channel** (Stereo/Left/Right/Mid/Side), **Mode** (Waveform/Lissajous/Spectrum), **Duration** slider 0.1-5s (ring buffer history 5s × sampleRate), **V.Zoom** slider -12..+20 dB, **Pause/Resume**. Render loop rAF: waveform vẽ L cyan + R amber (stereo), lissajous scatter L vs R, spectrum bar log 20Hz-20kHz.
  3. **Interactive Module Vector Display Canvas**: canvas EQ response curve trong EQ view — vẽ |H(f)| xấp xỉ (4 band cascaded: lowshelf/peaking/highshelf log-domain) 20Hz-20kHz, cập nhật realtime khi kéo slider band.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033500)
- **Ghi chú/Test (nếu có):** BUILD OK 927915 bytes, node --check OK, `pytest` 86 passed. Scope chỉ có tín hiệu khi track đang play (analyser tồn tại lúc playback); dừng → flatline + ghi chú.
---

### [2026-08-03] Task: FX Rack Panel tác dụng lên soundfont instrument trên track (route FluidSynth output qua track gainNode)
- **Tóm tắt thay đổi:** Trước đây MIDI/soundfont qua FluidSynth (ScriptProcessor → `_gainNode` gain 0.3 → masterBus.input) **bỏ qua hoàn toàn track gainNode + fxEntry** → FX Rack (EQ/Comp/Limiter/Exciter/Rebalance) không ảnh hưởng instrument. Fix:
  1. **soundfontPlayer.js**: thêm `setOutputDestination(node)` (disconnect + nối `_gainNode` tới node chỉ định; `null` → về masterBus.input) + `getOutputNode()`; biến `_pendingOutputDestination` áp dụng khi `_gainNode` được tạo sau (4 nơi tạo gainNode đều tôn trọng).
  2. **app.jsx `updateSfRouting()`**: nếu **đúng 1 track MIDI audible** (có midiItems + `computeTrackAudibleGain>0`) → `setOutputDestination(track.gainNode)` → toàn bộ chuỗi track tác dụng: **FX Rack chain → fader volume → pan → mute/solo gain → master**. Nhiều hơn 1 track MIDI audible → fallback masterBus.input (shared worklet không tách được; CC7 mute vẫn đúng). Expose `window.__updateSfRouting`.
  3. **Gọi updateSfRouting ở 5 điểm**: getOrCreateTrackNode (node mới), startTrackPlayback (cuối schedule), applyAllTrackMuteSolo (đổi mute/solo), stopAllPlayback (về masterBus), effect sync `[tracks, sessionTabs]` (load/undo).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (+`?v=202608031400` trong index.html), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033400)
- **Ghi chú/Test (nếu có):** BUILD OK 915831 bytes, balance 0, `pytest` 86 passed. Giới hạn: >1 track MIDI audible → FX không áp (cần multi-engine để tách, chưa làm).
---

### [2026-08-03] Task: Unified FX Rack Panel theo unified_fx_rack_panel.md (panel FX dùng chung cho track + rebuild graph độc lập)
- **Tóm tắt thay đổi:** Cài đặt cơ chế Unified FX Rack Panel (Hệ thống quản lý Module FX dùng chung):
  1. **Module factory dùng chung**: `createTrackFxModule(type, ctx, params)` — factory DSP giống mastering, giờ nhận `params` (EQ g1-g4, Comp threshold/ratio/makeup, Lim ceiling, Exc drive, Rebal mid/side) lưu theo từng chain entry; thêm `TRACK_EQ_PRESETS` (flat/vocal_clarity/bass_punch/warm_tape/guitar_edge).
  2. **Cấu trúc track FX có điểm rebuild**: `getOrCreateTrackNode` wire `gainNode → fxEntry → [modules active] → fxLegacyIn → chorus/reverb/direct → pannerNode` (lưu fxEntry/fxLegacyIn trên node).
  3. **`rebuildTrackFxGraph(trackId)`** (đúng `rebuildContextAudioGraph` spec §III.2): disconnect `fxEntry`, nối lại qua modules ACTIVE theo thứ tự hiện tại — chỉ ảnh hưởng track đó, không đụng track khác/master bus; chạy LIVE khi đang play; expose `window.__rebuildTrackFxGraph`.
  4. **FXRackModal** — panel unified cho track: chain rack (drag-drop reorder, power toggle, delete, nút [+] thêm 5 loại module), view tham số theo module (EQ 4 band + preset dropdown, Comp, Limiter, Exciter, Rebalance), header hiển thị tên track; mọi thay đổi → update track.fxChain + `__rebuildTrackFxGraph` ngay.
  5. **Nút [FX]** trên TrackStripConsole → `window.__openFxRack(track.id, track.name)` — mở panel bind đúng track (bỏ modal inline cũ). App: state `fxRackTarget` + render FXRackModal (track lookup + updateTrackProp).
- **SỰ CỐ KHÔI PHỤC FILE:** trong lúc edit, app.jsx bị ghi đè nhầm (chỉ còn 2000 dòng). Đã khôi phục từ git HEAD `ec5fede` (commit chứa toàn bộ fix tới loop-fix — user đã commit) + replay lại các edit unified rack; xác minh bằng build + balance ngoặc.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033300)
- **Ghi chú/Test (nếu có):** `node build.mjs` BUILD OK 914635 bytes, syntax OK, bracket balance 0, `pytest` 86 passed.
---

### [2026-08-03] Task: Loop vẫn giãn selection tới bar 18 — mở rộng hasSel + verify bundle serve
- **Tóm tắt thay đổi:** User báo vẫn lỗi sau fix trước. Kiểm tra toàn diện: (1) Không có path nào khác giãn selection khi loop bật (SubTabToolbar/onLoop là code chết; toggleLoop là Media Explorer preview; useEffect BPM chỉ chạy khi đổi tempo). (2) Mở rộng điều kiện `hasSel` trong handler nút loop: ngoài `selectionMode==='local'`, còn nhận selection qua `selectionStart/selectionEnd` hợp lệ (phòng trường hợp sweep không set 'local'). (3) **Verify bằng server thật** (uvicorn 9131): file `app.precompiled.js?v=202608033200` được serve có chứa `hasSel=selectionMode===` + comment fix — chứng minh bundle workspace đã đúng. Nếu user vẫn thấy lỗi → trình duyệt cache hoặc đang chạy deployment khác (không phải workspace).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033200; sửa double `</script>` phát sinh khi bump)
- **Ghi chú/Test (nếu có):** `pytest` 86 passed (chưa chạy lại lần này — chỉ đổi frontend).
---

### [2026-08-03] Task: Fix loop button ghi đè vùng chọn thành 0→18 bar (MAIN SESSION/SECTION-TAB)
- **Tóm tắt thay đổi:** Khi quét chọn vùng duration rồi bấm nút loop, vùng chọn bị tự đổi thành 0→18 bar. Nguyên nhân: handler nút loop (nhánh main timeline, không có sub-tab active) LUÔN tự derive loop end từ content tracks (`maxEnd + 2 bar` — content 16 bar → 18) rồi `setSelectionStart(0); setSelectionEnd(loopEnd)` — ghi đè vùng chọn của user. Fix: **tôn trọng selection có sẵn** (`hasSel = selectionMode==='local' && selLeft/selRight hợp lệ`) — chỉ auto-derive khi CHƯA có vùng chọn nào. Áp dụng cho cả MAIN SESSION lẫn SECTION-TAB (cả 2 đều đi qua nhánh main timeline khi không có sub-tab; section-tab dùng chung selection handler nên sweep cũng set `selectionMode='local'`). Kèm theo: `handleTrackLaneMouseDown` giờ `setSelectionCleared(false)` khi bắt đầu chọn vùng mới — nếu user từng Ctrl+click xoá chọn thì vùng mới vẫn loop được.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033100)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 86 passed.
---

### [2026-08-03] Task: Fix React error #310 khi mở Mixer modal (hooks sau early return)
- **Tóm tắt thay đổi:** Sau fix `isFxActive`, mở Mixer modal vẫn lỗi `Minified React error #310` ("Rendered more hooks than during the previous render"). Nguyên nhân: khi thêm dynamic module chain, 2 hooks `dragChainIndexRef` (useRef) + `addModuleOpen` (useState) được khai báo SAU dòng `if (!isOpen) return null;` trong MasteringModal → số hooks giữa các render thay đổi khi modal đóng/mở (return null sớm bỏ qua 2 hooks) → React #310. Fix: di chuyển 2 hooks lên TRƯỚC early return (cạnh useEffect [isOpen]).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032900)
- **Ghi chú/Test (nếu có):** verify: hooks tại 9252-9253 trước `if (!isOpen) return null;` (9255), không còn hook nào sau early return trong MasteringModal; syntax OK; bracket balance 0.
---

### [2026-08-03] Task: Fix "ReferenceError: isFxActive is not defined" khi mở Mixer modal
- **Tóm tắt thay đổi:** Khi mở Mixer modal bị lỗi `Uncaught ReferenceError: isFxActive is not defined`. Nguyên nhân: lúc thêm FX chain editor cho TrackStripConsole, tôi vô tình xóa luôn khai báo `const [isFxActive, setIsFxActive]` riêng của TrackStripConsole (git HEAD có 2 khai báo: master strip + track strip; sau khi dọn duplicate thì chỉ còn 1 của master strip) → nút FX Power trong TrackStripConsole tham chiếu biến không tồn tại. Fix: khôi phục khai báo `isFxActive` trong TrackStripConsole (cạnh `fxChainOpen`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032800)
- **Ghi chú/Test (nếu có):** verify: 2 khai báo `isFxActive` (master strip 1154 + track strip 1460), bundle có `isFxActive` ×5, syntax OK, bracket balance 0.
---

### [2026-08-03] Task: Nâng cấp Mastering panel theo mastering_expand.md (EQ presets + module factory + reorder + track FX reuse)
- **Tóm tắt thay đổi:** Làm 4 yêu cầu spec mastering_expand.md:
  1. **EQ Presets** (§II.1): `EQ_PRESET_LIBRARY` (flat/vocal_clarity/bass_punch/warm_tape) + dropdown trong EQ view — `applyEQPreset` ramp freq/gain/Q trên 4 biquad filter + sync knobs/canvas.
  2. **Module factory + 5 module mới** (§II.2): thêm DSP cho **Bus Compressor** (DynamicsCompressor + makeup), **Brickwall Limiter** (ratio 20:1, knee 0), **Harmonic Exciter** (WaveShaper + HP 2kHz + dry/wet), **Master Rebalance** (M/S gains qua L/R crossfeed) trong initMasterBus; mỗi module có input/output gain riêng.
  3. **Module reordering + dynamic re-route** (§II.3): `rebuildMasteringGraph(active, chain)` — disconnect mọi boundary node rồi nối chuỗi active modules giữa inputAnalyser→outputAnalyser. Chain strip giờ render ĐỘNG từ `ozState.chain` (drag-drop reorder, power toggle, delete, nút [+] mở modal thêm module 7 loại). `toggleMasteringOnMaster` dùng chain signature cho idempotency. Migration: project cũ không có `chain` → default [eq, imager, maximizer] + params mới.
  4. **Reuse cho track FX** (§II.4): `createTrackFxModule(type, ctx)` — factory tạo instance DSP giống mastering cho từng track; `getOrCreateTrackNode` wire `track.fxChain` (chuỗi module) trước chorus/reverb; serialize/deserialize `fx_chain`; nút **FX** trên TrackStripConsole mở modal editor (thêm/xóa/toggle từng module Comp/Limiter/Exciter/M-S/EQ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032700)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, bracket balance 0, `pytest` 86 passed. Lưu ý: module mới có param riêng (compThreshold/compRatio/compMakeup, limThreshold, excDrive, rebalMid/rebalSide) được lưu trong mastering_settings.
---

### [2026-08-03] Task: Cài đặt Imager module theo imager_spec.md (M/S width + vectorscope + correlation meter)
- **Tóm tắt thay đổi:** Làm đúng spec imager_spec.md trong MASTERING PANEL (Mixer F7):
  1. **DSP width semantics chuẩn spec**: 0% = MONO (S×0), 100% = Original (S×1), 200% = 2× Width (S×2). Giữ cấu trúc 4-band crossover (20-100Hz / 100Hz-1kHz / 1k-6kHz / 6k-20kHz) với matrix L/R crossfeed tương đương M/S (M = (L+R)/2 giữ nguyên, S = (L−R)/2 × w/100 — chứng minh: g1=(w+100)/200, g2=(100−w)/200 → mid=(g1+g2)=1, side=(g1−g2)=w/100).
  2. **Migration**: project cũ lưu scale −100..+100 (0 = original) → tự +100 mỗi band khi load (0→100, 15→115, 35→135, 50→150), dùng marker `imagerScale:'v2'` cho project mới. Default mới theo khuyến nghị spec: Band1=0% (MONO maker), Band2=115%, Band3=135%, Band4=150%.
  3. **Vectorscope thật (Polar M/S)**: thay chấm ngẫu nhiên giả bằng trace thật X=(L+R), Y=(L−R) từ leftAnalyser/rightAnalyser (post-mastering) — mono → nằm ngang, width tăng → trải dọc.
  4. **Phase Correlation meter thật**: ρ = Σ(L·R)/√(ΣL²·ΣR²) (−1..+1), gauge vẽ zone màu theo spec (+0.5..+1 xanh an toàn, 0..+0.5 vàng cẩn trọng, <0 đỏ nguy hiểm) + hiển thị số "Corr:".
  5. UI: slider range 0-200%, nhãn band theo spec, hint "0% = Mono · 100% = Original · 200% = 2× Width".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032600)
- **Ghi chú/Test (nếu có):** verify DSP (w=0→side 0, w=100→side 1, w=200→side 2, mid luôn 1), migration (0,15,35,50 → 100,115,135,150; v2 giữ nguyên), slider trong bundle. `pytest` 86 passed.
---

### [2026-08-03] Task: Mute/Solo realtime cho MIDI items — dùng CC7 (channel volume) của FluidSynth
- **Tóm tắt thay đổi:** Mute/unmute/solo chưa realtime với **MIDI items** vì FluidSynth WASM render toàn bộ channel vào **1 worklet → 1 `_gainNode` chung** (`_workletNode.connect(_gainNode)`), nên gain node của track không câm được MIDI (chỉ audio clips + section sub-tracks qua track gain mới bị ảnh hưởng). Fix: mỗi track MIDI sở hữu **channel riêng** (`ensureTrackMidiChannel`, 0-15 trừ 9) → dùng **CC7 (channel volume)** của FluidSynth (`window.SonicSF.controllerChange(ch, 7, 100|0)`) — áp realtime kể cả với notes đang vang. Thêm vào `applyAllTrackMuteSolo` (nút M/S) + effect sync `[tracks, sessionTabs]` (load/undo). Khi mute → CC7=0 (notes đang phát câm ngay); unmute → CC7=100 (notes đang phát vang lại + cơ chế becameAudible→restart vẫn giữ). Audio clips/section vẫn qua track gain như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032500)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `controllerChange(ch,7,...)` ×2 trong bundle; `pytest` 86 passed.
---

### [2026-08-03] Task: Fix items rớt/dồn sai track khi load-save project (3 bug)
- **Tóm tắt thay đổi:** (1) **`serializeTracksList` (app.jsx)**: chuỗi `if/else-if` theo `trackType` chỉ serialize MỘT loại items → track có cả clips + midiItems/sections bị **rớt items khi save** (mất dữ liệu âm thầm, có thể gây cảm giác "items biến mất/dồn chỗ"). Sửa: serialize ĐỘC LẬP từng loại items có trên track. (2) **`upgrade_project_json_if_needed` (projects.py)**: hardcode `start_bar = startTime/4.0` + `duration_bars = 4.0` → vị trí items sai (lệch 2× ở 120bpm, càng lệch khi tempo khác). Sửa: dùng `seconds_per_bar = (60/bpm)*4` từ bpm của project. (3) **`loadAudioBuffersForTracks` (app.jsx)**: merge buffer theo **array index** → nếu state đổi giữa lúc fetch (race: mở project khác khi buffer-load cũ chưa xong) thì tracks bị xáo trộn, items rơi vào track sai. Sửa: merge theo **track ID** + clip ID.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/api/v1/projects.py`, `app/templates/index.html` (bump v=202608032400), `tests/test_project_upgrade.py` (mới)
- **Ghi chú/Test (nếu có):** verify: round-trip serialize→deserialize giữ đúng track + vị trí (đã test bằng code bundle thật); track hỗn hợp giờ ra `AUDIO_ITEM,MIDI_ITEM`; `pytest` **86 passed** (3 test mới cho upgrade). Lưu ý: deserialize/upgrade vốn đã map đúng từng track — nếu user vẫn thấy items dồn track 1 sau khi hard-refresh, cần kiểm tra dữ liệu project cụ thể (và bản bundle trình duyệt đang chạy).
---

### [2026-08-03] Task: Fix unmute không phát lại khi đang play (sources bị loop-restart bỏ qua)
- **Tóm tắt thay đổi:** Khi đang play, mute → câm (OK, gain 0) nhưng unmute → không nghe lại được. Nguyên nhân: nếu có loop restart (`stopAllPlayback` + `startTrackPlayback` trong updatePlayhead), vòng lặp mới **bỏ qua track đang mute/solo** (isPlayable check) → sources của track không được tạo lại → chỉ set gain khi unmute không "hồi sinh" được nguồn đã không tồn tại. Fix trong `applyAllTrackMuteSolo`: theo dõi `trackAudibleRef` (audibility từng track), khi phát hiện chuyển tiếp **inaudible → audible** (unmute / tắt solo) và `isPlaying` + không đang RECORDING → `stopAllPlayback()` + `startTrackPlayback(currentTime)` re-schedule lại từ playhead hiện tại (đúng cơ chế toggleTrackSoloEvaluate đã dùng cho solo). Mute thuần (audible→inaudible) vẫn chỉ set gain (tức thì, không gián đoạn track khác). Effect sync `[tracks, sessionTabs]` giờ cũng cập nhật `trackAudibleRef` để nhất quán khi load/undo.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032300)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed.
---

### [2026-08-03] Task: Mute/Solo realtime — tác dụng ngay lên items đang phát của track
- **Tóm tắt thay đổi:** Nút M/S ở cả MixerStrip (mixer F7) và TrackStripConsole (track strip) giờ tác dụng **realtime**: toggle mute/solo lập tức set gain của track node đang phát (crossfade 20ms, không click). Cơ chế: `computeTrackAudibleGain(list, track)` (mute luôn tắt; nếu có bất kỳ solo → chỉ track solo nghe được; ngược lại theo volumeDb), `setTrackNodeGain(node, gain)` áp vào `gainNode` của track node. `window.__applyTrackMuteSolo(trackId, patch)` được gọi từ: (1) nút M/S của cả 2 strip (patch state + áp ngay), (2) `getOrCreateTrackNode` khi tạo node (items của track muted/soloed bắt đầu đúng trạng thái), (3) `startSubTabPlayback` cho chain mới, (4) effect sync `[tracks, sessionTabs]` với signature `muted|solo|volumeDb` (chỉ re-apply khi thay đổi — phủ load project/undo/section tab). Vì audio clip `source.connect(gainNode)` và MIDI note (đường oscillator fallback) đều qua track gainNode → mute/solo có tác dụng lên toàn bộ items của track.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032200)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed. Giới hạn: MIDI qua FluidSynth WASM render chung 1 worklet → không tách theo track (cùng giới hạn với bypass); đường oscillator fallback thì có tác dụng.
---

### [2026-08-03] Task: Đồng bộ hành vi click+drag item (clip = move như section/MIDI; Alt+click+drag = quét chọn)
- **Tóm tắt thay đổi:** (1) **Thống nhất**: click + drag trên MỌI item (section/MIDI/audio clip) = **di chuyển**; trước đây audio clip click+drag = quét chọn vùng (gây cảm giác "không đáp ứng"). (2) Hành động cũ của audio clip chuyển sang **Alt+click+drag = quét chọn duration** (gọi `onTrackLaneMouseDown` → local sweep select); Alt+right-edge vẫn = time-stretch. (3) **Fix "thỉnh thoảng không đáp ứng"**: mọi item giờ dùng cơ chế **pending drag có threshold 5px** — click thuần chỉ chọn item (không tạo undo/toast, không vô tình di chuyển do rung chuột), di chuột >5px mới bắt đầu drag. Cơ chế: `handleSetPendingDragMove` (mới) lưu `pendingDragRef` với `duplicate:false`; effect pending-drag route: clip đơn → `handleClipDragStartRef` (clip machinery), còn lại → `handleSectionItemDragStartRef` (multiIds đã hỗ trợ clip). `handleSetPendingDrag` (Ctrl+click copy) giờ lưu `duplicate:true`. Sửa lookup clip trong nhánh non-duplicate của `handleSectionItemDragStart`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032100)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `duplicate:true`/`!pdSnap.duplicate` trong bundle. `pytest` 83 passed. Grab tool (kéo nhanh) giữ nguyên.
---

### [2026-08-03] Task: Bypass trên nút Routing Matrix (track strip TCP) + bypass bỏ cả track FX
- **Tóm tắt thay đổi:** (1) Nút **Routing Matrix** trong `TrackStripConsole` (TCP strip, cạnh M/S) giờ là nút **Bypass** — tooltip "Bypass: track KHÔNG qua FX + mastering ở Main out", active style xanh khi bật. (2) Thay đổi điểm lấy tín hiệu dry: trong `getOrCreateTrackNode`, `dryGain` giờ tap từ **`gainNode` (PRE-FX)** thay vì `analyserNode` (post-FX) → khi bypass, channel bỏ qua **cả track FX (chorus/reverb) lẫn mastering chain** ở Main out; đường routeGain giữ nguyên (post-FX → mastering) khi không bypass. (3) Xác nhận Mixer Panel đã ở phím **F7** (code sẵn: F7 → `__toggleMixerRef`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032000)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, không còn "Routing Matrix", `pytest` 83 passed.
---

### [2026-08-03] Task: Fix TDZ "Cannot access 'sessionTabs' before initialization" sau khi thêm nút Bypass
- **Tóm tắt thay đổi:** `useEffect` sync `trackMasteringBypassMap` đặt ở ~11786 nhưng dependency array `[tracks, sessionTabs]` được đánh giá ngay tại chỗ gọi — trước khi `sessionTabs` khai báo (12239) → `ReferenceError: Cannot access 'sessionTabs' before initialization` khi chạy app. Fix: di chuyển useEffect xuống sau khối khai báo `subTabs`/`sessionTabs`/`sessionTabsRef` (TDZ-safe). `window.__setTrackMasteringBypass` an toàn vì chỉ truy cập `activeTrackNodesRef` bên trong body hàm (lúc click).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031900)
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, verify trong bundle: effect @453168 sau sessionTabs decl @451814. `pytest` 83 passed. Người dùng chạy `npm run build` trên workspace là được.
---

### [2026-08-03] Task: Nút Bypass Mastering cho từng track strip trong Mixer Panel
- **Tóm tắt thay đổi:** Thêm nút **B** (Bypass) trong mỗi track strip của Mixer Panel (cạnh M/S). Khi bật ON: âm thanh track đi qua **dry bus mới** (`masterBus.dryInput → dryOutput → output`) — **bỏ qua toàn bộ chuỗi mastering** (EQ / Imager / Maximizer) nhưng vẫn qua master volume + metering ở Main out. Cơ chế: `createMasteringRoute()` tạo 2 đường gain bù nhau (routeGain → `masterBus.input` qua mastering, dryGain → `dryInput`); `setMasteringRoute()` crossfade 20ms khi toggle (không click). Áp dụng tại: `getOrCreateTrackNode` (node track chính, toggle live qua `node.route`), `startSubTabPlayback` (clip playback). Map trạng thái `trackMasteringBypassMap` sync từ tracks state qua useEffect; `window.__setTrackMasteringBypass` toggle ngay cho track đang phát. Lưu/đọc project: `mastering_bypass` trong serialize/deserialize (schema không chặn additionalProperties nên không cần sửa).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031800)
- **Ghi chú/Test (nếu có):** `node build.mjs` rebuild OK, syntax OK, `pytest` 83 passed. **Giới hạn:** track MIDI/SoundFont dùng chung 1 bộ render FluidSynth (1 worklet → 1 gain) nên bypass hiện áp dụng cho track AUDIO (clip) + oscillator fallback; track MIDI dùng FluidSynth WASM chưa tách được theo track (cần refactor renderer) — sẽ làm tiếp nếu cần.
---

### [2026-08-03] Task: Rebuild app.precompiled.js từ app.jsx (fix "handleOpenProject is not defined")
- **Tóm tắt thay đổi:** Lỗi `Uncaught ReferenceError: handleOpenProject is not defined` khi click Open → bundle `app.precompiled.js` mà trình duyệt tải bị lệch với `app.jsx` (bundle cũ không chứa định nghĩa hàm ở scope đúng). `npm run build` không chạy được vì Babel 8 ESM-only (`ERR_REQUIRE_ESM`). Giải pháp: build lại bundle bằng **@babel/standalone@7** qua `build.mjs` (script mới, mirror đúng lệnh build trong package.json), tạo `app.precompiled.js` mới (858KB, syntax OK, đủ `const handleOpenProject` + 3 references, kèm fix restoreLastSessionProject). Đã smoke-test: trích deserializer từ bundle mới chạy với 6 project khôi phục → 6/6 OK, "Rose (autosave 03/08)" đủ 39 MIDI notes. Bump cache-buster lên `v=202608031700`.
- **Các file ảnh hưởng:** `app/static/js/app.precompiled.js` (rebuild), `build.mjs` (NEW — rebuild thủ công khi Babel 8 lỗi), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed. Node portable dùng: `/tmp/node-v20.18.0-linux-x64/bin/node` (máy không có node hệ thống). Trên máy deployment: copy 3 file (app.jsx, app.precompiled.js, index.html) hoặc chạy `node build.mjs` rồi hard refresh.
---

### [2026-08-03] Task: Fix auto-restore dự án lỗi "Không tìm thấy dự án" sau khi khôi phục DB
- **Tóm tắt thay đổi:** Sau khi DB bị xóa/tạo lại, `localStorage.sonic_project_id` của trình duyệt vẫn trỏ tới project cũ đã mất → mỗi lần tải trang `restoreLastSessionProject` gọi API, nhận 404 "Không tìm thấy dự án" và chỉ `console.warn` vĩnh viễn. Fix: khi restore thất bại, tự xóa `sonic_project_id` + `sonic_project_name` khỏi localStorage để lỗi không lặp lại. Bump cache-buster precompiled lên `v=202608031600`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Verified qua API: cả 6 project khôi phục mở được (GET /cloud/{id} → 200 + main_session đầy đủ). Người dùng cần hard refresh (Ctrl+Shift+R) để nạp bundle mới, mở "Rose (autosave 03/08)" 1 lần để ghim project hiện tại.
---

### [2026-08-03] Task: Khôi phục các dự án cloud cũ bị mất (git recovery)
- **Tóm tắt thay đổi:** Các dự án cloud cũ ('Test' x4, 'Rose' từ 22/07) và project đang làm dở "Rose" (autosave 03/08, 2 track MIDI+AUDIO, 39 notes, bpm 128) đã được khôi phục vào `app/storage/sonicforge.db` dưới user_id admin hiện tại từ **git history** (`app/storage/sonicforge.db` từng được commit trước khi vào `.gitignore`). Nguyên nhân mất: DB bị xóa/tạo lại nhiều lần trong quá trình dev (test suite `test_auth_and_quota` xóa DB → admin được re-seed với UUID mới → project cũ gắn với user_id cũ không hiển thị). Đã dọn các project/user test rác, backup DB trước khi khôi phục tại `/tmp/sonicforge_db_before_restore.db`.
- **Các file ảnh hưởng:** `app/storage/sonicforge.db` (dữ liệu), `tools/restore_cloud_projects.py` (NEW — script khôi phục cho deployment khác), `tools/cloud_projects_backup.json` (NEW — 6 project dạng portable)
- **Ghi chú/Test (nếu có):** Verified qua API: login admin → `GET /api/v1/projects/cloud` trả đủ 6 project; mở "Rose (autosave 03/08)" đủ 39 MIDI notes. Trên máy deployment thật (nếu DB khác): `python3 tools/restore_cloud_projects.py <duong-dan>/sonicforge.db`. Từ giờ test suite không đụng DB dev (xem entry conftest).
---

### [2026-08-03] Task: Cô lập test suite khỏi DB dev + khôi phục mật khẩu admin
- **Tóm tắt thay đổi:** (1) `DB_PATH` trong `app/models/user.py` giờ có thể override bằng env `SONICFORGE_DB_PATH`. (2) Thêm `tests/conftest.py` set env này sang `/tmp/sonicforge_test.db` trước khi mọi app module được import → pytest không bao giờ sửa DB dev nữa (trước đây `test_auth_and_quota` seed + đổi mật khẩu admin ngay trong `app/storage/sonicforge.db`, khiến login `admin123` fail). (3) Reset mật khẩu admin DB dev về `admin123` (`must_change_password=0`).
- **Các file ảnh hưởng:** `app/models/user.py`, `tests/conftest.py` (NEW)
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: login admin/admin123 → 200, sai mật khẩu → 400. Nếu deployment khác cũng bị dính (chạy test trên cùng DB), reset thủ công: `UPDATE users SET hashed_password='<hash_of_admin123>', must_change_password=0 WHERE username='admin'` hoặc xóa DB để `seed_admin` tạo lại.
---

### [2026-08-03] Task: Security audit + bug fixes (bảo mật, bug chức năng, cải thiện)
- **Tóm tắt thay đổi:** (1) **SSRF** `/api/v1/ai/proxy`: thêm auth bắt buộc + chặn cloud metadata/link-local (169.254.0.0/16), chặn IP private trừ khi host nằm trong danh sách AI provider user đã cấu hình (cho phép Ollama localhost:11434), chặn scheme không phải http/https, không forward header X-Auth-Token lên upstream. (2) **Path traversal** `/plugins/render`: `output_filename` chỉ lấy basename + ép đuôi .wav. (3) **Path traversal** toàn bộ `/audio/*`: helper `_safe_file_id`/`_resolve_storage_path` (basename + chỉ đọc trong uploads/processed). (4) **SECRET_KEY**: bỏ hardcode, tự sinh random bền vững lưu `app/storage/.secret_key` (ưu tiên env SECRET_KEY). (5) **media.py** (`computer`/`browse`/`file`): yêu cầu auth — login giờ set HttpOnly cookie `sf_token`, `get_current_user` nhận token từ Bearer / X-Auth-Token / cookie nên frontend raw fetch vẫn hoạt động. (6) Upload audio + soundfont: streaming theo chunk + giới hạn size (1GB/2GB) thay vì đọc cả file vào RAM. (7) **Quota bypass**: `update_cloud_project` giờ kiểm tra quota như `save_cloud_project`. (8) `enforce_password_changed` (bắt buộc đổi mật khẩu lần đầu) được wire vào upload/edit/render/ai-scan/ai-cut/python-tool/upload-soundfont. (9) **render_engine**: fix resample bị bỏ qua (`sr != sample_rate` trước là no-op → giờ resample_poly), render tôn trọng **solo** track, cache buffer section theo `section_id`, thay print → logger. (10) **vst_engine**: thống nhất FluidSynth API low-level CFFI (high-level `Synth()`/`FluidSynth()` không tồn tại trong binding này). (11) Rate-limit login theo IP (10 lần/15 phút), validate độ mạnh password khi register. (12) main.py: `on_event` → `lifespan`, CORS `allow_credentials=False`, xóa stub rỗng. (13) SQLite: WAL + foreign_keys=ON + seed user `anonymous` placeholder (FK hợp lệ cho project ẩn danh). (14) Pydantic v2 `model_dump()` thay `dict()`.
- **Các file ảnh hưởng:** `app/core/auth.py`, `app/api/v1/auth.py`, `app/api/v1/ai_proxy.py`, `app/api/v1/plugins.py`, `app/api/v1/audio.py`, `app/api/v1/media.py`, `app/api/v1/projects.py`, `app/api/v1/multitrack.py`, `app/api/v1/user_config.py`, `app/core/render_engine.py`, `app/core/vst_engine.py`, `app/models/user.py`, `app/main.py`, `app/static/js/services/aiGateway.js` (gửi X-Auth-Token khi gọi proxy), `tests/test_security_hardening.py` (NEW, 14 test), `tests/test_plugin_api.py`
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: proxy không token → 401, metadata → 403; media không token → 401 / có cookie → 200; render `output_filename=/tmp/x.wav` → path nằm trong PROCESSED_DIR. Lưu ý: nếu triển khai cũ đang chạy, restart server để tạo `.secret_key` (token cũ sẽ hết hạn vì secret đổi). Login mật khẩu admin mặc định vẫn `admin123` (đã khôi phục trong DB dev).
---

### [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`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần restart server + clear browser cache.
---

### [2026-07-28 07:47] Task: Sync MIDI item editing + ghost notes từ MAIN vào SECTION-TAB
- **Tóm tắt thay đổi:** Fix `handleEditSectionInTab` xoá `midiItems: []` khi clone section tracks. SECTION-TAB giờ giữ nguyên MIDI items từ section data → dropdown MIDI item switcher hoạt động, ghost notes extract từ các track khác trong section, instrument settings được preserve. Piano Roll Tab dùng chung component `PianoRollTabEditor` nên tất cả tính năng (ghost, session sync, CC lane, export, AI) đều hoạt động trên cả MAIN và SECTION-TAB.
- **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` — build passes. Cần lưu section trước (handleSaveSectionTab) để tích luỹ midiItems vào section.tracks, sau đó mở lại section tab để kiểm tra ghost notes + MIDI dropdown.
---

### [2026-07-28 07:13] Task: Instrument selector modal — 2 cột + font 14px
- **Tóm tắt thay đổi:** Mở rộng modal `max-w-md` → `max-w-4xl`. Font size từ `text-[10px]`/`text-xs` → `text-sm` (14px). Bố cục 2 cột: trái danh sách soundfont, phải instruments của SF đang chọn. Click SF trái → lọc instrument phải. Nút "All Instruments" để xem tất cả. Search filter cả 2 cột.
- **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` — build passes. Synth button → modal 2 cột → chọn SF trái → instrument phải.
---

### [2026-07-26 18:05] Task: Fix SpessaSynth CDN 404 + AudioWorklet init
- **Tóm tắt thay đổi:** Sửa CDN URL từ `@latest/dist/spessasynth_lib.js` (404) → `@4.3.1/dist/index.js` (200). Dùng `WorkletSynthesizer` class + `audioWorklet.addModule(processor.min.js)`, `soundBankManager.addSoundBank()`, `await isReady`. Fallback WorkerSynthesizer nếu AudioWorklet không khả dụng.
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** SpessaSynth CDN load OK (200). Cần kiểm tra browser console.
---

### [2026-07-27 16:16] Task: Tăng chiều cao + fix phím mũi tên cho Copilot prompt + fix Babel build
- **Tóm tắt thay đổi:** Tăng rows từ 2 → 4, thêm resize-y. Sửa ArrowUp/ArrowDown: chỉ kích hoạt lịch sử khi con trỏ ở đầu/cuối text. Fix lỗi dư `}` tại line 6795 khiến Babel build failed (pre-existing). Build thành công qua Babel, precompiled.js được sinh ra tự động.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Kiểm tra prompt input rows=4, resize-y, phím mũi tên di chuyển con trỏ trong multi-line text. Chạy `npm run build` để kiểm tra.
---

### [2026-07-26 20:15] Task: Stabilize client preview (oscillator + FluidSynth server)
- **Tóm tắt thay đổi:** Kết luận sau debug: SpessaSynth parser (fromArrayBuffer) không extract được presets từ SF2. Client luôn dùng oscillator (ADSR theo program). Server-side FluidSynth render cho âm thanh chính xác khi Export. Xoá debug logs, clean code.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/api/v1/plugins.py`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Preview qua oscillator luôn có âm thanh. Export WAV dùng FluidSynth với SoundFont thật.
---

### [2026-07-26 17:50] Task: Implement SF3 conversion + SpessaSynth client player
- **Tóm tắt thay đổi:** Python SF2→SF3 converter (ffmpeg Ogg Vorbis), startup auto-convert, download endpoint ưu tiên SF3. Client: IndexedDB cache, soundfontPlayer.js dual-mode (SpessaSynth + oscillator fallback), init + loadSF trong app.jsx.
- **Các file ảnh hưởng:** `Dockerfile`, `app/core/soundfont_converter.py` (NEW), `app/api/v1/plugins.py`, `app/main.py`, `app/templates/index.html`, `app/static/js/services/soundfontStorage.js` (NEW), `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** DSK 11M→1.1MB (90%), SGM 529M→18MB (97%). SpessaSynth load từ CDN. Fallback oscillator nếu offline.
---

### [2026-07-26 17:25] Task: Fix duplicate Synth + MIDI keyboard silent handler
- **Tóm tắt thay đổi:** Xoá nút Synth nhỏ giữa ARM và Monitor (dư thừa). Sửa MIDI keyboard handler: bỏ try/catch rỗng, thêm fallback tìm track armed trong main session, pass synth_engine context.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** ARM track → MIDI keyboard → playNote với instrument đã chọn. Server render dùng đúng SGM/SoundFont; client preview dùng oscillator emulation (giới hạn browser).
---

### [2026-07-26 17:20] Task: MIDI keyboard plays selected instrument on armed main track
- **Tóm tắt thay đổi:** Khi ARM track trong MAIN SESSION/SECTION-TAB, nhấn MIDI Keyboard sẽ play note với instrument đã chọn (synth_engine/instrumentProgram). Fallback: nếu không có sub-tab PIANO_ROLL nào đang armed, tìm track đã ARM trong main session và dùng instrument của nó. Audio routing qua track's gainNode nếu có.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** ARM track → chọn SF instrument → bấm MIDI keyboard → nghe âm thanh instrument đã chọn.
---

### [2026-07-26 17:10] Task: Add Drum channel toggle on track panel
- **Tóm tắt thay đổi:** Thêm button 🥁 trên track panel (giữa Solo và Record) để chuyển track sang drum channel 9. Khi active: set `is_percussion=true`, `soundfont_bank=128`. Thêm 🥁 indicator cho percussion presets (bank=128) trong instrument selector.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Click 🥁 → track chuyển drum mode → render chạy channel 9.
---

### [2026-07-26 17:05] Task: Fix FluidSynth API mismatch (silent fallback)
- **Tóm tắt thay đổi:** Pyfluidsynth thực tế là low-level CFFI wrapper, không có class `FluidSynth()`. Dùng sai API → tất cả SoundFont render fallback về oscillator (silence). Fix: dùng `new_fluid_synth`, `fluid_synth_sfload`, `fluid_synth_program_select`, `fluid_synth_write_s16_stereo`. Đồng bộ internal clock bằng cách render silence qua `write_s16_stereo` thay vì chỉ advance cursor.
- **Các file ảnh hưởng:** `app/core/render_engine.py`
- **Ghi chú/Test (nếu có):** DSK BAN-DI (prog=5) và SGM Brass (prog=56) đều có audio riêng biệt. Render peak ~0.04-0.056, spectral profile khác nhau rõ rệt.
---

### [2026-07-26 16:55] Task: Fix SoundFont program selection flow
- **Tóm tắt thay đổi:** Sửa `_find_sf2_path` case-insensitive (catalog IDs lowercase vs filename mixed-case), thêm `synth_engine`, `soundfont_id`, `instrument_source` vào serialize/deserialize để project save/load không mất instrument selection.
- **Các file ảnh hưởng:** `app/core/render_engine.py`, `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Verified: `_find_sf2_path('sgm_v2.01')` → `SGM_v2.01.sf2`, render flat fields + synth_engine struct + roundtrip serialization all pass.
---

### [2026-07-26 16:45] Task: Implement VST3/SoundFont engine plan
- **Tóm tắt thay đổi:** Fix SF2 path resolution (hardcoded → multi-dir scan), add `synth_engine` struct parsing in render & UI, 3-level fallback chain, pass context to client SoundFont player.
- **Các file ảnh hưởng:** `app/core/render_engine.py`, `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/services/aiGateway.js`
- **Ghi chú/Test (nếu có):** Verified: catalog API returns 31 condensed instruments, render with flat fields + synth_engine struct + drums (bank=128) + missing SF fallback all succeed.
---

### [2026-07-25 06:57] Task: Fix Piano Roll runtime errors (rollBeats TDZ + handleCCMouseMove)
- **Tóm tắt thay đổi:** Sửa lỗi `Cannot access 'rollBeats' before initialization` bằng cách di chuyển khai báo `rollBeats`/`rollBeatsRef` lên trước `totalBeats`. Thêm hàm `handleCCMouseMove` bị thiếu và cập nhật `handleCCMouseDown` hỗ trợ Ctrl+Click paint velocity.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `cd /home/locpham/SonicForgeStudio && npm run build` — build passes.
---

### [2026-07-25 07:25] Task: 4 tính năng mới (Piano Roll zoom fill + Shift+scroll playhead + auto-scroll drag + SoundFont cache)
- **Tóm tắt thay đổi:** (1) Piano Roll zoom-out không còn màn hình đen — bars fill toàn bộ viewport. (2) Shift+scroll trong Piano Roll di chuyển playhead và play notes MIDI như fast-forward. (3) Khi drag section/MIDI/clip đến cạnh phải timeline, auto-scroll container. (4) Server-side cache FluidSynth instances + PluginManager singleton + list_soundfont_instruments cache.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/core/vst_engine.py`
- **Ghi chú/Test (nếu có):** `npm run build` pass. `vst_engine.py` thêm `load_soundfont_cached`, `release_soundfont`, `get_plugin_manager`, `_SF_INSTRUMENTS_CACHE` — refcount-based cache.
---

### [2026-07-26 09:36] Task: Piano Roll 4 features (SNAP, velocity selected, auto-scroll, Synth button)
- **Tóm tắt thay đổi:** (1) SNAP trong MIDI tab — grid snap cho note drawing, selection marquee, loop range. (2) Ctrl+drag velocity — selected notes màu xanh dương, affect only selected; no selection = paint all. (3) Auto-scroll Piano Roll khi brush-drag gần cạnh top/bottom. (4) Synth button trong MIDI tab toolbar — mở instrument selector khi nhấn. Layout fix: thêm `h-full` cho outer div.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Fix thiếu 1 `)` tại line 5959 return statement (gây build error pre-existing).
---

### [2026-07-26 10:08] Task: Fix auto-scroll continuous + CC velocity batch + flicker
- **Tóm tắt thay đổi:** (1) Brush-draw gần cạnh top/bottom giờ dùng `setInterval` 30ms để cuộn liên tục (trước đây cuộn 1 lần per mousemove). Stop interval khi mouseup hoặc chuột rời khỏi threshold. (2) Ctrl+drag velocity cho selected notes — gộp tất cả `setNotes` vào 1 batch call (thay vì 1 call per note), tránh flicker do multi re-render. (3) `handleCCMouseDown` selectedMode cũng batch `setNotes`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Thêm ref `brushAutoScrollRef` cho setInterval auto-scroll. Batch `setNotes` trong cả `handleCCMouseDown` và `handleCCMouseMove` để tránh flicker.
---

### [2026-07-26 10:12] Task: Fix brush pitch-based scroll + CC velocity per-note paint
- **Tóm tắt thay đổi:** (1) Brush auto-scroll: chuyển từ scroll theo viewport edge (clientY) sang pitch-based — scroll khi note mới được vẽ ở pitch gần rìa visible area (6 note threshold). Dùng `NoteHeight * 2` scroll step, setInterval 30ms. (2) CC velocity selected mode: thay vì paint all selected notes cùng lúc, paint từng note một theo vị trí chuột — tìm note gần cursor beat nhất, chỉ paint note đó nếu đang được selected.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Thay `lastPainted` trong selectedMode từ array all indices thành array single-node index.
---

### [2026-07-26 10:20] Task: Fix brush scroll speed + CC snap + non-selected flicker
- **Tóm tắt thay đổi:** (1) Giảm auto-scroll step từ `NoteHeight * 2` xuống `max(1, floor(NoteHeight * 0.5))` — cuộn chậm hơn để draw note đúng pitch. (2) CC lane `handleCCMouseDown` và `handleCCMouseMove` dùng `getSnapBeat(beat, snapVal)` khi tìm note index — snap đúng grid. (3) Non-selected CC flicker: xóa `paintNote` helper, inline `setNotes` trực tiếp trong mousedown, không gọi setNotes riêng lẻ gây re-render dư thừa.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-25 07:25] Task: Fix auto-scroll + maxDuration tab isolation + 1-bar margin
- **Tóm tắt thay đổi:** (1) `maxDuration` dùng `activeTracks` + 4-bar buffer. (2) Cách ly MAIN vs SECTION-TAB. (3) Clip/section/MIDI drag/stretch/resize clamp 1-bar from right. (4) Clip drag + stretched clip dùng `updateActiveTracks`. (5) Stretched clip handler thêm auto-scroll.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 07:31] Task: Fix zoom cursor + right edge black + AI Copilot for Piano Roll
- **Tóm tắt thay đổi:** (1) Tách `drawWidth` (content) và `cssWidth` (CSS) — canvas coordinate system không còn bị sai khi zoom out. (2) Thêm `min-width` fill viewport, không còn mảng đen phải. (3) Thêm AI Copilot input + handler trong Piano Roll — gõ prompt tạo MIDI notes trực tiếp.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-25 08:08] Task: Fix velocity draw position, shift+scroll play, canvas redraw deps
- **Tóm tắt thay đổi:** (1) CC lane velocity stem vẽ tại start note thay vì center. (2) Shift+scroll chỉ play note khi playhead vượt qua start_beat. (3) Thêm `viewWidth`/`viewBeats` vào dependency array grid canvas + CC canvas để auto-redraw khi resize/zoom.
- **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.

### [2026-07-25 08:31] Task: Fix AI Copilot tab scope + hide bottom panels + context menu Insert + autoscroll
- **Tóm tắt thay đổi:** (1) AI Copilot context dùng `activeTracks` thay `tracks` — chỉ tác động tab đang active. (2) Bottom panels mặc định ẩn. (3) Right-click track timeline có 4 mục Insert. (4) Thêm auto-scroll vào stretched clip resize. Sửa `insertSectionAtPlayhead` dùng `updateActiveTracks`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 08:36] Task: Fix auto-scroll + maxDuration tab isolation + 1-bar margin
- **Tóm tắt thay đổi:** (1) `maxDuration` dùng `activeTracks` + 4-bar buffer. (2) Cách ly MAIN vs SECTION-TAB. (3) Clip/section/MIDI drag/stretch/resize clamp 1-bar from right. (4) Clip drag + stretched clip dùng `updateActiveTracks`. (5) Stretched clip handler thêm auto-scroll.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 08:53] Task: Add timeline left/right margin + dynamic buffer expansion
- **Tóm tắt thay đổi:** (1) PADDING_LEFT thay đổi từ 2s thành 2-bar margin ở cả WaveformLane và TempoTrackLane. (2) Thêm state `scrollBufferExtra` + auto-expand 4 bars khi auto-scroll gần đến rìa timeline. (3) `maxDuration` thêm `scrollBufferExtra` + 6 bars static buffer. (4) Clip drag/stretch dùng `activeTracks` + `updateActiveTracks` thay `tracks`/`setTracks`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:04] Task: Fix left margin 1-beat, items always visible, bar/timeline sync
- **Tóm tắt thay đổi:** (1) PADDING_LEFT từ 2-bar xuống 1-beat ở cả WaveformLane và TempoTrackLane. (2) Drag clamp left ≥ 1 beat, right ≤ maxDuration - 1 bar. (3) Bar numbers + grid vẽ lại đồng bộ với scroll+zoom.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:08] Task: Fix Piano Roll CC lane always visible + resizable height
- **Tóm tắt thay đổi:** (1) `showCC` default `true` — CC lane luôn hiển thị. (2) Thêm `ccHeight` state + resize handle (drag top edge) — cho phép thay đổi độ cao CC lane. (3) Canvas dùng `ccHeight` thay hardcoded 80px.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:18] Task: Fix brush velocity isolation from CC lane
- **Tóm tắt thay đổi:** Thêm `brushVelocityRef` riêng biệt (0.8) — brush không còn bị ảnh hưởng bởi velocity paint từ CC lane.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:20] Task: Auto-follow dragged item in viewport
- **Tóm tắt thay đổi:** (1) Clip drag: sau mỗi update, nếu item ra khỏi vùng 120px viewport edge, tự scroll để item luôn thấy. (2) Section/MIDI drag: tương tự. (3) Section/MIDI resize: theo dõi edge đang kéo.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:23] Task: Sync bar ruler redraw during drag
- **Tóm tắt thay đổi:** (1) TempoTrackLane nhận `canvasRedrawCount` prop + deps — force redraw bar numbers khi drag. (2) Cả 3 drag handler increment `setCanvasRedrawCount` sau auto-follow scroll. (3) Sub-tab TempoTrackLane thêm `viewportWidth` + `scrollLeft` + `canvasRedrawCount` — đồng bộ ruler với timeline.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 09:30] Task: Fix bar numbers disappear past bar 12 during drag
- **Tóm tắt thay đổi:** TempoTrackLane đọc `scrollLeft` từ DOM (walk parent chain tìm scroll container) thay vì React prop — canvas luôn dùng scroll position thực tế, không lag do async state.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):**

### [2026-07-25 09:58] Task: Fix drag speed + bar numbers visibility + snap section items
- **Tóm tắt thay đổi:** (1) Xoá auto-follow phải (gây jump), chỉ giữ auto-follow trái 60px — scroll phải nhờ `autoScrollTimeline` mượt. (2) Thêm `PADDING_RIGHT` vào `tEnd` ở TempoTrackLane và WaveformLane — bar numbers + grid hiển thị đến sát rìa phải. (3) `if (localX < 0 || localX > drawWidth) continue;` — skip phần tử ngoài canvas. (4) Buffer 12 bars cho `maxDuration`. (5) Thêm `snapTime` vào section/MIDI item drag. (6) Thêm snap option `'4'` (1 bar). (7) Đồng bộ `PADDING_LEFT = barDuration` ở cả 2 lane.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.

### [2026-07-25 10:08] Task: Merge tempo bar + timebar, beat-based ruler, snap grid
- **Tóm tắt thay đổi:** Thay thế seconds-based ruler (0.00s, 1.00s...) + TempoTrackLane bằng TimelineRuler (48px canvas) hiển thị bar:beat grid lines + snap sub-ticks + BPM label. Grid WaveformLane khớp ruler, snapTime() đã dùng grid spacing beat — snap items đúng vị trí ruler.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---

### [2026-07-25 10:35] Task: Restore time duration labels on TimelineRuler + TempoTrackLane
- **Tóm tắt thay đổi:** Thêm time duration labels (formatTimeSimple: "0.00s", "1.00s",...) vào TimelineRuler và TempoTrackLane. Hiển thị dạng màu cam (#ffb464) ở chân ruler, động điều chỉnh interval (1s/2s/5s/10s...) theo zoom level. Time labels đã bị mất từ commit ff44fd4 khi merge time bar + tempo bar thành TimelineRuler.
- **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` — build passes.
---

### [2026-07-25 10:39] Task: Sync TempoTrackLane leadIn + time labels on ruler top
- **Tóm tắt thay đổi:** Thêm `leadInMargin` prop vào TempoTrackLane để đồng bộ column-0 (time=0) với vị trí leadIn của TimelineRuler. Thêm CLIP_BUFFER vào TempoTrackLane. Di chuyển time duration labels trong TimelineRuler từ dưới đáy (y=40) lên phía trên (y=11).
- **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` — build passes.
---

### [2026-07-25 11:09] Task: Fix TempoTrackLane scroll sync during drag auto-scroll
- **Tóm tắt thay đổi:** TempoTrackLane/WaveformLane/TimelineRuler hiện đọc scrollLeft trực tiếp từ DOM wrapper (canvases tìm parent scrollable) thay vì React prop. Tránh lệch vị trí khi auto-scroll nhanh trong clip drag do React state batching.
- **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` — build passes.
---

### [2026-07-25 11:04] Task: Fix PADDING_LEFT alignment + right-side bar rendering
- **Tóm tắt thay đổi:** Set PADDING_LEFT=0 trong TimelineRuler/WaveformLane/TempoTrackLane để grid column 0 khớp với clip position 0 (trước đây lệch 8px). TempoTrackLane dùng CLIP_BUFFER/zoom cho tEnd thay vì PADDING_LEFT để bar/beat vẽ đủ xa về phía phải khi scroll. 
- **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` — build passes.
---

### [2026-07-25 10:58] Task: Fix clip/section snap to column 0
- **Tóm tắt thay đổi:** Xóa `Math.max(beatSec, ...)` trong clip drag (10325), section drag (10495), section resize (10576) — thay bằng `Math.max(0, ...)` để item có thể snap vào column 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` — build passes.
---

### [2026-07-25 10:54] Task: Fix TempoTrackLane height + clamp selection at column 0
- **Tóm tắt thay đổi:** Fix TempoTrackLane canvas height = 40px cố định (thay vì parentElement.clientHeight gây tràn). Wrapper `h-10 shrink-0 overflow-hidden` để không đẩy track lanes lệch TCP. Clamp selLeft/selRight/ruler handlers >= 0 để selection không vào vùng âm.
- **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` — build passes.
---

### [2026-07-25 10:44] Task: Zero lead-in, time ruler + tempo track split layout
- **Tóm tắt thay đổi:** Xóa leadInMargin (về 0) — time 0 tại mép trái, chỉ còn 8px PADDING_LEFT. TimelineRuler đơn giản thành time-only ruler (40px, chỉ hiển thị time labels). Thêm TempoTrackLane ngay dưới (40px, bars/beats/BPM). Selection overlay top từ 48→80px. Đồng bộ WaveformLane PADDING_LEFT=8/zoom.
- **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` — build passes.
---

### [2026-07-25 10:18] Task: Lead-in margin, bar 0-indexed, grid clip fix
- **Tóm tắt thay đổi:** Thêm leadInMargin (1 bar) đẩy timeline content sang phải — bar 0, time 0s tại mép margin. Playhead bắt đầu từ đó. Bar numbering 0-indexed (bar 0 thay vì 1). Sửa CLIP_BUFFER từ 200px → max(400, barWidth+200) để bar number không bị che. Đồng bộ leadInMargin vào tStart/tEnd canvas + mouse handlers (ruler, clip drag, section resize).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---

### [2026-07-25 11:32] Task: Redraw canvas on section item duration resize
- **Tóm tắt thay đổi:** Thêm `setCanvasRedrawCount(n => n + 1)` trong resize mousemove handler để WaveformLane redraw khi section/MIDI item duration thay đổi.
- **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` — build passes.
---

### [2026-07-25 17:22] Task: Piano roll toolbar - transport, ARM/INPUT, AI bar range
- **Tóm tắt thay đổi:** Thêm transport (play/stop/back/next/record), ARM+INPUT select MIDI, AI bar range (bar x-y) vào piano roll toolbar. handleAISend phát hiện piano roll active → MIDI prompt. Thêm selectedMidiInputId + handleMidiInputSelect.
- **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` — build passes.
---

### [2026-07-25 22:05] Task: Fix piano roll selection, playback, marquee drag/resize/move
- **Tóm tắt thay đổi:** (1) Fix playback duration — min 16 beats when no notes exist. (2) Selection marquee: green transparent (#22c55e), full-height from top to CC lane. (3) Document-level mousemove/mouseup for marquee drag (không dừng khi ra khỏi grid). (4) Edge resize handles (left/right) cho marquee. (5) Body drag (move) cho marquee. Hiển thị marquee trên cả grid canvas và CC canvas.
- **Các file ảnh hưởng:** `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `node --check app/static/js/app.precompiled.js` — syntax OK.
---

### [2026-07-25 22:18] Task: Fix marquee mouseleave, loop reschedule, tempo drag
- **Tóm tắt thay đổi:** (1) Separate onMouseLeave from onMouseUp for grid canvas — preserve marquee when mouse leaves canvas. (2) Add MIDI note rescheduling on piano roll loop restart — 2nd+ loop iteration now plays sound. (3) Tempo bar drag already uses document-level listeners — works across columns.
- **Các file ảnh hưởng:** `app/static/js/app.precompiled.js`
---

### [2026-07-26 10:24] Task: Fix scroll speed match mouse + CC flicker useLayoutEffect
- **Tóm tắt thay đổi:** (1) Brush auto-scroll: thêm direct scroll target — mỗi mousemove tính pitchPixel, nếu gần rìa visible area (2 note margin) thì scroll trực tiếp tới target để tốc độ cuộn = tốc độ chuột. Giữ interval 16ms fallback cho trường hợp chuột ở rìa viewport. (2) CC flicker: đổi `React.useEffect` → `React.useLayoutEffect` cho CC canvas rendering effect — canvas paint trước browser paint, loại bỏ flash khi state thay đổi.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Direct scroll target: `container.scrollTop = target` trên mỗi mousemove khi pitch ở gần rìa.
---

### [2026-07-26 10:28] Task: Fix velocity flicker - grid canvas useLayoutEffect
- **Tóm tắt thay đổi:** Đổi grid canvas effect từ `React.useEffect` → `React.useLayoutEffect`. Nguyên nhân flicker: khi velocity thay đổi, grid canvas (useEffect) vẽ async sau paint → hiển thị velocity fill cũ 1 frame, trong khi CC canvas (useLayoutEffect) đã vẽ sync trước paint → hiển thị stem mới. Sự mismatch giữa 2 canvas tạo flicker. Fix: cả grid + CC canvas đều dùng useLayoutEffect, đồng bộ trước paint.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 10:39] Task: Fix brush shape draw + remove transport + loop repeat sound
- **Tóm tắt thay đổi:** (1) Brush drawing: thay vì redistribute notes evenly, append từng note mới tại beat position thực tế (`getSnapBeat(beat, snapVal)`) — notes theo đúng hình dạng brush stroke. (2) Xóa transport controls (back/play/stop/record/forward) khỏi MIDI tab toolbar — chỉ dùng global transport. (3) Loop repeat: thêm `schedulePianoRollMidi` helper — reschedule MIDI notes qua SonicSF mỗi khi loop repeat, fix lỗi notes trong vùng chọn không có âm thanh khi play lặp lại.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 10:44] Task: Fix brush drawing smooth - track pitch + beat delta
- **Tóm tắt thay đổi:** Brush drawing: theo dõi cả `lastDrawBeat` và `visitedPitches`. Tạo note mới khi pitch thay đổi HOẶC beat di chuyển >= minBeatStep (25% của grid duration). Dùng raw `beat` thay vì `getSnapBeat` để note theo sát vị trí chuột. Khởi tạo `lastDrawBeat: start` trong draggedNote. Fix lỗi draw bị ngắt quãng do chỉ tạo note khi pitch thay đổi.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 10:47] Task: Fix brush draw only pitch change + CC velocity flicker skip
- **Tóm tắt thay đổi:** (1) Brush draw: chỉ tạo note mới khi `pitchChanged` (pitch chưa visited). Nếu cùng pitch, extend `duration_beats` của note cuối cùng trong `brushIds` — không tạo note mới trùng pitch. (2) CC velocity: thêm check `Math.abs(currentVal - val) > 0.001` trước mỗi `setNotes` — skip render khi velocity/pan không thay đổi, loại bỏ flicker cho cả selected + non-selected mode.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass. currentVal tính: velocity = note.velocity || 0.8; pan = (note.pan || 0) / 2 + 0.5.

### [2026-07-26 10:49] Task: Fix brush draw per-note duration + noteStartBeats tracking
- **Tóm tắt thay đổi:** (1) Thêm `noteStartBeats` array trong `draggedNote` — track start beat của từng note. Khi tạo note mới (pitchChanged), fix duration của note trước đó thành `beat - prevNoteBeat`. Khi extend (cùng pitch), chỉ extend note cuối với `beat - lastNoteBeat` (không extend toàn bộ stroke). (2) Khởi tạo `noteStartBeats: [start]` trong setDraggedNote. Fix lỗi note cuối có duration dài bằng toàn bộ brush stroke.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 10:55] Task: Fix brush draw pitch revisit by comparing lastDrawnPitch
- **Tóm tắt thay đổi:** Thay `!visited.includes(snappedPitch)` bằng `snappedPitch !== lastDrawnPitch`. Bug cũ: khi vẽ C→D→C, visited.includes(C) = true → không tạo note mới → note D cũ bị extend. Fix: so sánh với pitch của note cuối cùng, cho phép vẽ tiếp khi pitch thay đổi dù đã visit trước đó. Bỏ `visitedPitches` (không còn dùng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 10:58] Task: Fix CC note detection with Y-aware stem matching
- **Tóm tắt thay đổi:** Thêm `findCCNoteIndex(beat, mouseY, ccH)` helper — tìm note trong CC lane dựa trên cả X (beat) và Y (vị trí stem top). Khi nhiều note cùng beat (chord), so sánh `mouseY` với stem top của từng note, chọn note có stem gần nhất. Dùng helper này thay thế tất cả `notes.findIndex` và nearest-fallback trong `handleCCMouseDown` và `handleCCMouseMove`. Fix lỗi không detect được note khi velocity gần nhau hoặc cùng beat.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:03] Task: Remove MIDI tab loop button + Ctrl+click clear range
- **Tóm tắt thay đổi:** (1) Xóa nút loop (repeat) khỏi MIDI tab toolbar — dùng global transport loop button thay thế. (2) Ctrl/Meta+Click trên ruler (tempo track lane) xóa loop range: set loopStartBeat/loopEndBeat = null, selectionStart/selectionEnd = null.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:06] Task: Fix MIDI tab real-time instrument change - restart playback
- **Tóm tắt thay đổi:** `setTrackInstrumentWithProgram`: khi đổi instrument, kiểm tra nếu có PIANO_ROLL sub-tab với trackId đang playing → gọi `stopAllPlayback()` + `setTimeout` 50ms để `schedulePianoRollMidi` + `startSubTabPlayback` với instrument mới. Fix lỗi MIDI tab không cập nhật realtime khi thay đổi Synth.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:07] Task: Fix instrument change breaks loop playback
- **Tóm tắt thay đổi:** Khi đổi instrument trong lúc MIDI tab đang play loop, restart code giờ cũng reset `startBufferOffsetRef` và gọi `requestAnimationFrame(updatePlayhead)` để tiếp tục vòng lặp animation. Fix lỗi loop bị ngắt sau khi change instrument.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:17] Task: Real-time velocity, ruler play, export MIDI button
- **Tóm tắt thay đổi:** (1) Thêm `onRescheduleMidi` prop — khi edit velocity trong CC lane lúc đang play, gọi `window.SonicSF.stopAll()` + `schedulePianoRollMidi` với velocity mới. (2) Click ruler: set `currentTime` + nếu đang play thì `onStop` + `setTimeout` `onPlayPause` để play từ vị trí click. (3) Thêm nút Export cạnh Save — inline MIDI writer, xuất `.mid` file từ notes array.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:27] Task: Fix realtime velocity stale notes + ruler click restart
- **Tóm tắt thay đổi:** (1) Compute `updatedNotes` synchronously trước khi gọi `setNotes`, pass into `onRescheduleMidi(updatedNotes)`. `schedulePianoRollMidi` nhận tham số `notesOverride` — dùng notes mới thay vì `st.notes` (stale). (2) Ruler click: thay `onStop()` (reset currentTime) bằng `stopAllPlayback()` trực tiếp + `schedulePianoRollMidi` + `startSubTabPlayback` + RAF từ `clickTime`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:32] Task: Fix range auto-loop + gain fade-out crackling
- **Tóm tắt thay đổi:** (1) Xóa `setIsLooping(true)` và `isLooping: true` khỏi ruler drag handler — range selection không còn tự động loop, chỉ loop khi nút LOOP global được kích hoạt. (2) Thay `window.SonicSF.stopAll()` abrupt bằng gain fade-out: `linearRampToValueAtTime(0.001, +40ms)` → delay 50-60ms → `stopAll` + reschedule → `linearRampToValueAtTime(volLinear, +15ms)`. Áp dụng cho cả `onRescheduleMidi` và `setTrackInstrumentWithProgram`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:35] Task: Fix ruler click playhead - use onStop+onPlayPause props
- **Tóm tắt thay đổi:** Sửa ruler click handler — xóa calls tới `stopAllPlayback`/`schedulePianoRollMidi`/`updatePlayhead` (không có trong scope PianoRollTabEditor). Dùng props: `setSubTabs({currentTime, isPlaying: false})` → `onStop()` → timeout 40ms → `setSubTabs({currentTime})` → `onPlayPause()`. Playhead di chuyển và play tiếp từ vị trí click.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:40] Task: Fix ruler cursor+range drag + SonicSF smooth stopAll
- **Tóm tắt thay đổi:** (1) Ruler: cursor default, click trong range → drag move (hand cursor → move), click ngoài range → seek/drag tạo selection mới. (2) `soundfontPlayer.js`: `activeOscillators` lưu `{osc, gain}` thay vì `osc`; `stopAll` ramp gain về 0 trong 8ms trước khi `osc.stop(10ms)` — loại bỏ crackling khi realtime update. Cũng xóa `setIsLooping(true)` trong ruler drag cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 11:56] Task: Fix Piano Roll playhead seek + sound cracking
- **Tóm tắt thay đổi:** (1) Click ruler khi Piano Roll đang play → seek đến vị trí mới và tiếp tục play (không dừng). (2) ADSR envelope trong `soundfontPlayer.js` dùng `linearRampToValueAtTime` thay `setValueAtTime`/`exponentialRampToValueAtTime` để loại bỏ gain jump gây crackling. `stopAll` ramp gain về 0 trong 20ms trước khi stop oscillator.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 12:20] Task: Fix Piano Roll seek-to-start during playback
- **Tóm tắt thay đổi:** Thêm hàm `seekPlaybackTo(time)` xử lý seek khi đang play cho cả sub-tab và main timeline. Nút "Quay lại đầu" (`skip-back`) và "Đầu vùng chọn" (`step-back`) giờ restart playback từ vị trí mới: stop → update refs → reschedule MIDI → restart.
- **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.

### [2026-07-26 12:13] Task: Fix Piano Roll loop/stop behavior
- **Tóm tắt thay đổi:** (1) Sub-tab loop selection chỉ active khi nút Loop (st.isLooping) bật — không còn bị ảnh hưởng bởi global isLoopingSelection. (2) Stop dừng ngay lập tức: `SonicSF.stopAll()` set gain 0 và stop oscillator tại ctx.currentTime, không ramp.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass.

### [2026-07-26 12:31] Task: VST/SoundFont inspection engine + AI schema
- **Summary:** New `SoundFontInspector` (sf2utils) scans .sf2 files, generates full/condensed catalog. Added `/soundfonts/catalog` API. Updated AI tool schema with `soundfont_id/bank/program`. Condensed catalog auto-injected into system prompt. Server render now passes bank/program + MIDI channel routing (drums → ch9). Added `DecentSamplerManager` + Pianobook render path. Client `SonicSF`: `applyAITrackInstrument`, `controllerChange`, `programChange`. Catalog cache invalidated on upload.
- **Files:** `requirements.txt`, `app/core/soundfont_inspector.py` (new), `app/api/v1/plugins.py`, `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/static/js/services/aiGateway.js`, `app/static/js/services/api.js`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `Dockerfile`, `.gitignore`, `vst_plugins/` (new), `samples/pianobook/` (new)
- **Tests:** `python3 -m pytest tests/ -v` → 61 passed, 1 pre-existing failure. Python syntax check OK on all modified files.
---

### [2026-07-26 16:11] Task: Mount daw_assets host dirs into Docker
- **Summary:** Created `/home/locpham/daw_assets/{vst3,soundfonts,pianobook}` on host. Mounted them into `web`, `worker`, and `beat` containers at `/opt/daw_engine/vst3`, `/opt/daw_engine/soundfonts`, `/opt/daw_engine/samples/pianobook`.
- **Files:** `docker-compose.yml`
- **Tests:** N/A
---

### [2026-07-26 16:21] Task: Implement Synth button with VST3 selection
- **Summary:** Fixed Synth button to properly select and use system-installed VST3 plugins. Fixed field name mismatch (`instrument_id` vs `instrument`) between UI and render engine. Track auto-switches to MIDI type when VST assigned. SoundFont preset selection now passes `soundfont_bank` + `soundfont_program` to server. Serialize/deserialize includes all instrument fields.
- **Files:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/core/render_engine.py`
- **Tests:** `pytest` - 61 passed, 1 pre-existing failure.
---

### [2026-07-26 20:45] Task: Fix timeline play — schedule noteOn via setTimeout
- **Tóm tắt thay đổi:** SpessaSynth noteOn(ch, pitch, vel, enableReverb) không nhận exactAudioTime. Timeline truyền startTime vào vị trí thứ 4 → SpessaSynth ép kiểu thành enableReverb=true → tất cả notes play ở T=0. Fix: tính delay = startTime - currentTime, dùng setTimeout để schedule noteOn đúng thời điểm.
- **Các file ảnh hưởng:** app/static/js/services/soundfontPlayer.js, app/static/js/app.precompiled.js
- **Ghi chú/Test (nếu có):** MIDI keyboard play ngay. Timeline play các note đúng thời điểm.
---

### [2026-07-26 21:00] Task: Sync loop button + auto-derive loop boundaries
- **Tóm tắt thay đổi:** Nút Loop trên transport controls giờ đồng bộ với sub-tab đang active. Khi bật loop: tự động tính loopEnd từ MIDI notes, section clips, hoặc audio duration. Gán selectionStart=0, selectionEnd=loopEndTime. Main timeline cũng tự derive loop end từ tracks/items/clips.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Bật Loop → playhead loop từ đầu đến cuối item/clip và ngược lại.

### [2026-07-26 21:15] Task: Fix stop + loop for all tab types
- **Tóm tắt thay đổi:** `stopAll` giờ huỷ tất cả `setTimeout` đã schedule cho SpessaSynth notes (cả noteOn lẫn noteOff), gọi `allNotesOff(0-15)`. Loop hoạt động trên MAIN SESSION (qua `isLoopingSelection` + `selLeft/selRight`), SECTION-TAB và PIANO_ROLL (qua `st.isLooping` + `st.selectionStart/End`).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Stop = dừng ngay. Loop = phát từ đầu đến cuối item/clip rồi lặp lại.

### [2026-07-26 21:30] Task: Sticky ruler + tempo lane on track scroll
- **Tóm tắt thay đổi:** Wrap TimelineRuler và TempoTrackLane trong `sticky top-0 z-30 bg-[#1a1a1a]`. Khi cuộn tracks lên, ruler và tempo lane vẫn hiển thị dưới toolbar. Áp dụng cho cả MAIN SESSION và sub-tab views.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Cuộn tracks lên → ruler + tempo lane dính ở top.

### [2026-07-26 22:05] Task: Fix multi-track bank loading (no delete, graceful 404)
- **Tóm tắt thay đổi:** Xoá `deleteSoundBank` — SpessaSynth giữ nhiều banks đồng thời. Kiểm tra `soundBankList` trước khi tải lại. 404 từ server không làm mất bank đã load trước đó.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** 3 tracks với 3 SF khác nhau → SpessaSynth giữ cả 3 → noteOn dùng đúng preset từ bank tương ứng.

### [2026-07-27 07:55] Task: Fix item duration when BPM changes
- **Tóm tắt thay đổi:** Thêm `length_bars` vào midiItems/sections. `useEffect` trên BPM recalc `duration = length_bars * currentSecondsPerBar`. Fix section hardcoded 4s → `4 * secondsPerBar`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Tạo 8 bars item ở BPM 80 → đổi BPM 120 → item vẫn 8 bars.

### [2026-07-27 08:20] Task: Fix TempoTrackLane bar spacing on BPM change
- **Tóm tắt thay đổi:** DOM traversal tìm scrollLeft bị gãy do sticky wrapper (tìm nhầm div có `position: sticky` với scrollLeft=0). Xoá DOM lookup, dùng `scrollLeft` prop trực tiếp. Fix cho cả TimelineRuler và TempoTrackLane.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Thay đổi BPM → bars trên tempo track lane vẽ đúng khoảng cách.

### [2026-07-27 08:25] Task: Fix bar width drift for non-integer BPM
- **Tóm tắt thay đổi:** Vòng lặp `t += beatDuration` tích luỹ sai số dấu phẩy động (đặc biệt BPM lẻ như 77, 81). Chuyển sang `t = bn * beatDuration` với bn là số beat nguyên — mỗi bar line tính độc lập, không sai số tích luỹ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** BPM 77 → tất cả bars đều rộng bằng nhau.

### [2026-07-27 08:40] Task: TimelineRuler draws bar markers aligned with TempoTrackLane
- **Tóm tắt thay đổi:** Thêm bar markers vào TimelineRuler dùng cùng `bn * barDuration` với TempoTrackLane. Số bar hiển thị ở giữa ruler. Time markers vẫn giữ phía dưới. Cùng BPM prop → luôn đồng bộ với tempo track.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Thay đổi BPM → TimeRuler bars + TempoTrackLane bars luôn đồng nhất.

### [2026-07-27 08:48] Fix: equal bar widths + selection tempo/time mode
- **WaveformLane:** Integer beat loop thay vì `t += beatDuration` (fix bar width drift)
- **Selection mode:** Nút toggle ♪T (theo tempo - đổi BPM → selection thay đổi) / ⏱T (cố định thời gian)

### [2026-07-27 09:25] Feat: eager soundfont scan at startup
- **App load**: fetch full catalog with all instrument presets
- **Merge**: gán presets vào từng soundfont trong instrumentSelectorData
- **Synth button**: hiển thị presets từ cache, không cần API call per-SF

### [2026-07-27 09:55] Feat: Synth button shows flat instrument list from all SFs
- **openInstrumentSelector**: flatten presets from all SFs + soundfont name tag
- **Modal**: search bar + flat instrument list + soundfont name prefix
- **Fix**: `2ad2922` had extra closing paren in PluginManagerModal (build fix)
- **Arch**: loại bỏ hai-bước, click Synth → chọn instrument trực tiếp

### [2026-07-27 11:05] Feat: MIDI keyboard NoteOff sustain + CC/pitch bend realtime
- **NoteOff**: durationMs=60000, NoteOff gọi stopNote(ch,pitch) → note tắt ngay
- **MIDI channel**: truyền msg.data[0]&0x0F vào playNote/stopNote
- **CC64 sustain/CC1 modulation**: forward qua SonicSF.controllerChange
- **Pitch Bend**: forward qua SonicSF.pitchBend (14-bit)

### [2026-07-27 11:20] Fix: All Notes Off (CC123) before MIDI NoteOn
- **Nguyên nhân:** SoundFonts như saxophone có release envelope dài, noteOff không tắt âm ngay
- **Fix:** Gửi `controllerChange(ch, 123, 0)` = All Notes Off trước mỗi NoteOn từ MIDI keyboard
- **Kết quả:** Note trước tắt ngay khi play note mới, không còn đè âm

### [2026-07-27 11:45] Fix: lazyInit in playNote + 3 root cause fixes
- **Root cause 1:** NoteOn with velocity=0 treated as NoteOff ✅ (cmd === 0x8 || (cmd === 0x9 && vel === 0))
- **Root cause 2:** Same channel extraction for NoteOn+NoteOff ✅ (msg.data[0] & 0x0F)
- **Root cause 3:** pitch/channel always integers from msg.data[] ✅
- **Added:** _lazyInit() called from playNote when _initialized false
- **Oscillator fallback:** 2000ms instead of 60000ms (quick preview before init)

### [2026-07-27 12:05] Task: Fix Tremolo String continuous sustain after MIDI note-off
- **Tóm tắt thay đổi:** Root cause: `stopNote()` used CC 123 (All Notes Off) which merely enters SoundFont's release envelope. Sustained instruments like Tremolo Strings (GM#44) have 2-5s release tails. Fix: replaced CC 123 with CC 120 (All Sound Off) in `stopNote()` and `stopAll()`. CC 120 sets `voice.isActive = false` immediately, bypassing the release envelope entirely, so the note stops instantly on key release.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Test với MIDI keyboard + Tremolo Strings instrument: note tắt ngay khi release key. CC 120 tác dụng lên toàn bộ channel (kill all voices) — phù hợp với keyboard preview use-case.

### [2026-07-27 12:45] Task: Migrate SpessaSynth → FluidSynth WASM engine
- **Tóm tắt thay đổi:** Thay thế SpessaSynth (JS/AudioWorklet) bằng FluidSynth WASM (C++ compiled via Emscripten). CDN `@enikey87/fluidsynth-emscripten@0.1.1` cho dev (localhost), self-host `/static/js/vendor/` cho production. Giải quyết triệt để stuck notes (Tremolo/Saxophone) nhờ FluidSynth xử lý Gen 44 loop mode chuẩn SF2.04. Thêm `fluidsynth-bridge.js` AudioWorklet nhận PCM từ main-thread FluidSynth render loop. Giữ nguyên `window.SonicSF` API surface (0 thay đổi ở app.jsx).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `app/static/js/services/fluidsynthLoader.js` (NEW), `app/static/js/worklets/fluidsynth-bridge.js` (NEW), `app/static/js/vendor/libfluidsynth-2.3.0-sf3.js` (NEW), `app/static/js/vendor/libfluidsynth-2.3.0-sf3.wasm` (NEW)
- **Ghi chú/Test (nếu có):** Server render (pyfluidsynth) + client preview (FluidSynth WASM) = cùng C++ core → 100% audio parity. Cần clear browser cache. Babel 8 syntax error pre-existing (JSX `{ if(...) { } }` trong app.jsx), không rebuild được app.precompiled.js.
---

### [2026-07-27 15:59] Fix: _activeNotes array — multi-track ARM cùng pitch
- **Tóm tắt thay đổi:** Khi 2 track ARM play cùng MIDI pitch, `_activeNotes['0:60']` bị overwrite (track2 ghi đè track1). `stopNote` chỉ stop 1 track. Fix: `_activeNotes[key] = [ch1, ch2, ...]` array. `stopNote` iterate all channels.
- **Các file ảnh hưởng:** `soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** ARM 2 track, play note → thả phím → cả 2 track stop.
---

### [2026-07-27 15:47] Fix: Multi-track ARM + activeNotes cho stopNote
- **Tóm tắt thay đổi:** MIDI handler `find(t=>isArmed)` → `filter(...)` loop all armed tracks. Thêm `_activeNotes[ch:pitch]` map để `stopNote` lookup đúng channel sau khi đổi SoundFont (channel allocation thay đổi).
- **Các file ảnh hưởng:** `soundfontPlayer.js`, `app.precompiled.js`
- **Ghi chú/Test (nếu có):** ARM 2 track cùng SF khác instrument → cả 2 play. Đổi SF → noteOff vẫn tìm đúng voice.
---

### [2026-07-27 16:05] Fix: program_select thay bank_select+program_change (SF preset search order)
- **Tóm tắt thay đổi:** `bank_select` + `program_change` dùng search order qua tất cả SF đã load. Khi track 1 load SF2, preset lookup trên channel 0 tìm thấy preset của SF2 thay vì SF1 (SF2 là last-loaded → priority). Fix: `fluid_synth_program_select(synth, ch, sfHandle, bank, prog)` chỉ định rõ SoundFont ID, không phụ thuộc search order. Track 2 giữ nguyên instrument của SF1.
- **Các file ảnh hưởng:** `soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Track 1 SF1→SF2, track 2 SF1 → track 2 không bị ảnh hưởng.
---

### [2026-07-27 12:15] Task: Fix SpessaSynth loop voice not releasing (layer 2)
- **Tóm tắt thay đổi:** CC 120 vẫn không đủ vì SpessaSynth 4.3.1 AudioWorklet có bug: looped voices trong MIDI message pipeline xử lý CC 120 sai (`processMessage`). Fix: thêm `noteOn(ch, pitch, 0)` (MIDI noteOff alternate path) + `_synthInstance.post({channelNumber:ch, type:"stopAll", data:1})` gửi lệnh trực tiếp đến worklet qua `handleMessage` — bypass hoàn toàn MIDI pipeline.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Cần clear cache browser (index.html cache-bust param updated).
---

### [2026-07-27 17:07] Task: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode
- **Tóm tắt thay đổi:** Thêm ghost notes cho Piano Roll tab: tất cả MIDI items không được chọn thành ghost notes (25% opacity, dashed border). Dropdown thay thế tab title để chuyển nhanh MIDI item đang edit. Hai chế độ xem: Session Sync (ghost visible, bar labels aligned với session) và Isolated (bar 0, no ghost).
- **Các file ảnh hưởng:** `app/static/js/services/ghostNoteExtractor.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Kiểm tra dropdown list đúng tất cả MIDI items. Switch item → ghost notes của item cũ hiện ra. Nút 🌐 Session/📋 Isolated chuyển chế độ. 👻 Ghost toggle chỉ hoạt động ở Session mode.

### [2026-07-28 07:03] Task: Auto-scan soundfont cho Synth button
- **Tóm tắt thay đổi:** Thêm `SoundFontAutoScanner` — background daemon thread quét thư mục `/opt/daw_engine/soundfonts` và `app/storage/soundfonts` mỗi 30s. Dùng `sf_scan_state.json` để track file đã quét (theo size+mtime), chỉ inspect file mới/thay đổi. Catalog dùng scanner cache thay vì regenerate từ đầu mỗi request. `/soundfonts/catalog` API dùng scanner catalog. Upload/delete trigger scan tức thì. Thêm `.gitignore` cho `*.db` và `sf_scan_state.json`.
- **Các file ảnh hưởng:** `app/core/soundfont_scanner.py` (NEW), `app/api/v1/plugins.py`, `app/core/soundfont_inspector.py`, `app/main.py`, `.gitignore`
- **Ghi chú/Test (nếu có):** `python3 -c "from app.core.soundfont_scanner import SoundFontAutoScanner; s=SoundFontAutoScanner(); print(s.scan_once())"` — scan hoạt động. Condensed catalog dùng scanner's full_catalog tránh duplicate scan.
---

- **Tóm tắt thay đổi:** Verify 4 rules: (1) MIDI item nhận instrument từ track cha qua `handleSwitchMidiItem` copy `instrumentProgram` từ track → sub-tab; (2) Section item KHÔNG nhận instrument (reset về null khi clone tracks trong `handleEditSectionInTab`); (3) Track khác không bị ảnh hưởng do `setTrackInstrumentWithProgram` filter by `trackId`; (4) Đổi instrument trong PianoRoll tab → áp dụng ngược lại cho track qua `openInstrumentSelector` → `setTrackInstrumentWithProgram`. Tất cả đều OK, không bug.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (verify lines 4642-4667, 5905, 6763-6807, 8422-8424), `app/core/render_engine.py` (verify lines 95-115)
- **Ghi chú/Test (nếu có):** No code changes needed. All 4 rules confirmed working.
---

### [2026-07-27 20:37] Task: Fix cross-track instrument interference (missed channel param)
- **Tóm tắt thay đổi:** Sửa 6 vị trí gọi `SonicSF.playNote()` thiếu tham số `channel` + `synthEngine`, khiến FluidSynth mặc định `ch=0` cho mọi track → `program_change` trên channel 0 đè lên nhau giữa các track. Fix: thêm channel computation (từ `track.midiChannel` hoặc track index) + truyền `synth_engine` vào `playNote` tại `startTrackPlayback`, section sub-track MIDI, `playMidiPreviewNote`, piano roll Alt+scroll preview, note click preview, keybed onMouseDown/onMouseEnter. `setTrackInstrumentWithProgram` đã filter đúng `trackId` — lỗi không nằm ở logic set instrument mà ở playback.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (lines 4846, 5121, 5661-5671, 10117-10124, 10179-10201, 10285-10307)
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: main session play 2 tracks MIDI khác instrument → mỗi track giữ instrument riêng. Piano Roll preview note → không ảnh hưởng track khác.
---

### [2026-07-27 20:44] Task: Apply md/41_INSTRUMENT.md spec to codebase
- **Tóm tắt thay đổi:** Implement spec: (1) Tạo `pianoRollTabService.js` với `getParentTrackByItemId`, `buildActiveScope`, `getTrackMidiChannel`; (2) Thêm `parent_track_id` vào tất cả MIDI items creation (deserialize, recording, AI, API); (3) `handleSwitchMidiItem` dùng `buildActiveScope` pattern — thêm `active_scope` vào sub-tab state; (4) Toolbar Piano Roll hiển thị `(Belongs to: Track X)` và `Synth (Track X): Instrument`; (5) Đồng bộ channel assignment về `trackIndex % 16` (spec §4); (6) Toàn bộ `playNote` calls dùng `SonicPianoRoll.getTrackMidiChannel` thay for-loop channel compute.
- **Các file ảnh hưởng:** `app/static/js/services/pianoRollTabService.js` (NEW), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: multi-item piano roll switch item → scope cập nhật đúng. Parent track label xuất hiện trên toolbar.
---

### [2026-07-27 21:25] Task: Fix _playNoteFluid else branch leaking cross-track SF
- **Tóm tắt thay đổi:** Khi track chưa set instrument, `_playNoteFluid` gọi `program_change(ch, 0)`. FluidSynth search program 0 trong ALL loaded SoundFonts → nếu chỉ có SoundFont của Track 2 được load, program 0 của SF đó gán cho channel của Track 1. Fix: bỏ `program_change`, dùng oscillator fallback trực tiếp. Track không instrument → sine wave, không leak instrument từ track khác.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: Track 2 set instrument, Track 1 không set → Track 1 play sine wave (không phải SF program 0 của Track 2).
---

### [2026-07-27 21:29] Task: Fix multi-track ARM only first track produces sound
- **Tóm tắt thay đổi:** ONMIDIMESSAGE handler (`app.jsx:6954-6998`) dùng raw MIDI hardware channel (`msg.data[0] & 0x0F`) thay vì dedicated channel của track, và `find(t => t.isArmed)` chỉ lấy track ARM đầu tiên. Fix: dùng `filter(t => t.isArmed)` + `getTrackMidiChannel` cho từng track — MIDI Note On/Off, CC, Pitch Bend đều route tới ALL armed tracks trên dedicated channel của mỗi track.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (lines 6960-7040), `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: ARM cả 2 track, bấm phím MIDI → cả 2 track đều phát ra instrument riêng. NoteOff gửi đúng channel.
---

### [2026-07-27 21:37] Task: Ctrl+drag on ruler for global selection
- **Tóm tắt thay đổi:** `handleRulerMouseDown` cũ khi `e.ctrlKey` chỉ clear selection rồi return. Fix: Ctrl+click trên ruler khởi tạo `selectionMode='global'`, set `rulerDragStartRef` và `isDraggingRulerRef=true` → document mousemove handler (đã có sẵn) cập nhật selection range, selection overlay (đã render sẵn) hiển thị vùng chọn.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (line 11133-11142), `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: Ctrl+drag trên ruler → selection overlay xuất hiện. Shift+click vẫn extend selection.
---

### [2026-07-27 21:47] Task: Ctrl+drag sweep select + multi-item drag/copy
- **Tóm tắt thay đổi:** (1) Ctrl+drag trên empty space (track lane) → sweep select (vùng chọn màu amber), mouseup → chọn tất cả items chạm vùng sweep. (2) Selected items highlight (viền vàng dày). (3) Ctrl+drag trên section/MIDI item → duplicate item. (4) Drag trên selected item → move cả nhóm items đã chọn. (5) Ctrl+drag trên selected item → copy cả nhóm.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Cần test: Ctrl+drag empty space → sweep overlay + selected items highlight. Click+drag selected item → cả nhóm move. Ctrl+drag selected item → copy nhóm.
---

### [2026-07-28 08:09] Task: Fix Ctrl+Click+Drag không chọn item trong SECTION-TAB
- **Tóm tắt thay đổi:** Xoá toggle selection (select/deselect) khỏi Ctrl+Click handler cho section/MIDI items. Trước đây Ctrl+Click vừa chọn item vừa set pending drag → item bị khoá cứng (selected), không thể kéo copy. Fix: Ctrl+Click chỉ set pending drag để copy, không chạm selection state.
- **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. 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`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Ctrl+click vùng trống → deselect hết, không yellow cursor.
---

### [2026-07-28 10:06] Task: Ctrl+S trong SECTION-TAB chỉ lưu section, không export SF
- **Tóm tắt thay đổi:** Di chuyển SECTION-TAB save check từ handler thứ 2 (dead code, shadowed bởi export handler) lên handler đầu tiên. Line 8066: nếu `activeTab.startsWith('session_')` → `handleSaveSectionTab`, else → `handleExportSFS`.
- **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. SECTION-TAB Ctrl+S → lưu section. MAIN Ctrl+S → export SF.
---

### [2026-07-28 09:58] Task: Ctrl+S lưu section, Ctrl+A chọn tất cả items
- **Tóm tắt thay đổi:** Thêm `activeTab.startsWith('session_')` branch trong Ctrl+S handler → gọi `handleSaveSectionTab`. Thêm Ctrl+A handler: collect tất cả sections/midiItems/clips từ `activeTracksRef.current` vào `selectedItemIds`. Hoạt động trên MAIN SESSION và SECTION-TAB.
- **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. SECTION-TAB Ctrl+S → lưu section. Ctrl+A → chọn tất cả items.
---

### [2026-07-28 09:55] Task: Remove Insert Section từ context menu trong SECTION-TAB
- **Tóm tắt thay đổi:** Thêm `!sessionTabs.some(s => s.id === activeTab)` cho "Insert Section" button trong JSX context menu. Section không thể chứa section item. Data-driven menu (line 14905) đã có check này.
- **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. Right-click trong SECTION-TAB → không thấy "Insert Section".
---

### [2026-07-28 09:50] Task: Program change ở note time, không phải call time
- **Tóm tắt thay đổi:** Di chuyển `program_change`/`program_select`/`bank_select` từ synchronous (call time) vào trong `doNote` (note time via setTimeout) trong `_playNoteFluid`. Trước đây tất cả program_changes xảy ra ngay khi `startTrackPlayback` chạy → MIDI items processed trước sections → section's program_change override cuối cùng → ALL notes (section + MIDI) play với section instrument. Fix: mỗi note có program_change tại đúng thời điểm của nó.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** load. TRACK 1: | SECTION ITEM (inst B) | MIDI ITEM (inst A) | → section plays inst B, MIDI item plays inst A.
---

### [2026-07-28 09:30] Task: Thêm _isSectionClone flag — tắt âm thanh mặc định trong SECTION-TAB
- **Tóm tắt thay đổi:** Thêm `_isSectionClone: true` vào section cloned tracks. Line 10283 kiểm tra flag: nếu section clone và không instrument → `undefined` (silent); nếu MAIN track và không instrument → `0` (GM Piano). Trước đây section cloned tracks đi qua line 10283 với `instrumentProgram: undefined` → default 0 → play GM Piano.
- **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. SECTION-TAB không instrument → im lặng. MAIN track không instrument → GM Piano.
---

### [2026-07-28 09:27] Task: Silent fallback — không nghe âm thanh mặc định
- **Tóm tắt thay đổi:** Thay oscillator fallback bằng silent return trong `_playNoteFluid` (soundfontPlayer.js:395). Khi không có instrument data, không play bất kỳ âm thanh nào (cả oscillator lẫn FluidSynth).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Section sub-track MIDI items không instrument → im lặng. MAIN track MIDI items không instrument → GM Piano (program 0).
---

### [2026-07-28 09:25] Task: Restore program default 0 cho MAIN track MIDI playback
- **Tóm tắt thay đổi:** Restore line 10283 `instrumentProgram !== undefined ? instrumentProgram : 0` — MAIN track MIDI items cần program 0 để play (GM Piano) khi không load instrument. Chỉ section sub-track (line 10393) giữ `subTrack.instrumentProgram` (undefined → oscillator fallback) để tránh load MAIN SoundFont.
- **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. MAIN track MIDI items play GM Piano khi không instrument. Section sub-track MIDI items dùng oscillator fallback.
---

### [2026-07-28 09:21] Task: Revert fix playhead — gây lỗi không play được
- **Tóm tắt thay đổi:** Revert 2 changes từ 5d31563: (1) xoá `requestAnimationFrame(updatePlayhead)` thừa trong `handlePlayPause` (effect đã xử lý). (2) xoá `setCurrentTime(0)` trong `handleEditSectionInTab` (gây reset currentTime global). Giữ nguyên các fix instrument isolation + program default.
- **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. Cần kiểm tra Play trên MAIN và SECTION-TAB.
---

### [2026-07-28 09:40] Task: Cả main + section MIDI playback không default program 0
- **Tóm tắt thay đổi:** Thay đổi line 10283 giống line 10393: `instrumentProgram !== undefined ? ... : 0` → `instrumentProgram`. Trước đây main playback cũng default program 0 khi `instrumentProgram` undefined → `_playNoteFluid` gọi `program_change(ch,0)` trên FluidSynth → load program 0 của MAIN SoundFont. Fix: undefined → oscillator fallback.
- **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. MIDI items không instrument → oscillator fallback (không load SoundFont). MIDI items có instrument → FluidSynth play đúng program.
---

### [2026-07-28 09:37] Task: Section sub-track MIDI playback không default program 0
- **Tóm tắt thay đổi:** Line 10393 `subTrack.instrumentProgram !== undefined ? subTrack.instrumentProgram : 0` → `subTrack.instrumentProgram`. Trước đây khi `instrumentProgram` undefined (section clone), program default 0 → `_playNoteFluid` gọi `program_change(ch, 0)` trên FluidSynth → load program 0 của MAIN session's SoundFont. Fix: undefined program → `_playNoteFluid` dùng oscillator fallback (không load SoundFont).
- **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. Play MAIN session → section MIDI items không load SoundFont. User load instrument trong section tab → program set → FluidSynth play đúng.
---

### [2026-07-28 08:49] Task: Revert Synth button ẩn — track có thể chứa cả section + MIDI
- **Tóm tắt thay đổi:** Revert conditional Synth button (815a6fa). Track có section item con VÀ midi item con → Synth vẫn hiển thị. Instrument chỉ áp dụng cho MIDI item, không ảnh hưởng section item (được xử lý bởi data isolation fix trước đó: section clone null hết soundfont fields).
- **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. Track sections + midiItems → Synth hiển thị, instrument chỉ tác động midiItems.
---

### [2026-07-28 08:36] Task: Synth chỉ hiển thị cho MIDI track, không cho section/audio track
- **Tóm tắt thay đổi:** Synth button trong TCP giờ conditional: chỉ hiển thị khi track có midiItems (>0) và KHÔNG có sections. FX button giữ nguyên cho mọi track type. Logic đúng: Synth áp dụng cho MIDI item, FX áp dụng cho section/MIDI/audio clip item.
- **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. Track có sections → ẩn Synth. Track MIDI → hiện Synth. Track audio → ẩn Synth. FX hiện ở mọi track.
---

### [2026-07-28 08:31] Task: Section track auto-load soundfont 1/prog 0 từ MAIN qua `...t` spread
- **Tóm tắt thay đổi:** `...t` trong fallback branch spread `soundfont_id`, `soundfont_bank`, `soundfont_program`, `instrument_source` từ parent MAIN track. Dù đã null `instrumentProgram` và `synth_engine`, các field `soundfont_*` vẫn khiến FluidSynth load soundfont khi play. Fix: override tất cả soundfont fields thành null/undefined.
- **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. MAIN loaded SF1/prog5 → mở section tab → section tracks không có SF.
---

### [2026-07-28 08:27] Task: KHÔNG auto-load instrument MAIN vào SECTION-TAB track
- **Tóm tắt thay đổi:** Revert kế thừa instrument từ parent MAIN track. Section tracks giữ `instrumentId: null, instrumentProgram: undefined, instrumentName: null, synth_engine: undefined`. Trước đây kế thừa instrument (f2ac70e) → SECTION-TAB auto-load program 0 của SoundFont từ MAIN. User tự chọn instrument trong SECTION-TAB qua Synth button (đã hỗ trợ qua `updateActiveTracks` routing).
- **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 instrument MAIN → mở section tab → section tracks KHÔNG có instrument.
---

### [2026-07-28 08:12] Task: Sync instrument load methods từ MAIN vào SECTION-TAB PIANO_ROLL
- **Tóm tắt thay đổi:** Fix `updateActiveTracksRef` — khi `activeTab` là PIANO_ROLL sub-tab có `parent_tab_id` trỏ đến SECTION-TAB, routing instrument update vào `setSessionTabs` thay vì `setTracks` (main). Trước đây đổi instrument trong PIANO_ROLL của SECTION-TAB ghi vào main tracks (vô hiệu). Cùng logic với `activeTracks` useMemo fix trước đó.
- **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.

### [2026-07-31 07:37] Task: Fix 3 bugs - panText, cloud project load, auto-restore
- **Tóm tắt thay đổi:** 
  1. Thêm `const [panText, setPanText] = React.useState('center')` vào MasterStripConsole để fix `Uncaught ReferenceError: panText is not defined`.
  2. Fix `restoreLastSessionProject`: xóa bỏ `localStorage.removeItem('sonic_project_id')` trong catch block để không xóa project ID khi API lỗi tạm thời → auto-load trên page reload hoạt động lại.
  3. Thêm `.catch()` vào `loadAudioBuffersForTracks` trong `handleOpenProject` và `restoreLastSessionProject` để tránh unhandled promise rejection gây gián đoạn load project.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Cần reload page và test Open Project + Mixer Panel sau fix.

### [2026-07-31 08:01] Task: Fix MIDI note creation duration & add Ctrl+Shift+Click split
- **Tóm tắt thay đổi:** 
  1. Thêm `lastNoteDurationRef` để ghi nhớ duration của note vừa tạo → click tạo note mới dùng duration đó thay vì snap duration mặc định.
  2. Ctrl+Shift+Click vào giữa MIDI note → tách note đó thành 2 notes tại vị trí click. Ctrl+Shift+Click vào vùng trống → nhân bản các note đang chọn (giữ hành vi cũ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Kiểm tra: 1) tạo note → thay đổi duration → tạo note mới → duration được kế thừa. 2) Ctrl+Shift+Click giữa note → note bị cắt thành 2.

### [2026-07-31 08:09] Task: Bump precompiled cache version for piano roll split fix
- **Tóm tắt thay đổi:** Cập nhật cache-busting parameter `app.precompiled.js?v=202607310809` để trình duyệt load bản JS mới nhất có fix Ctrl+Shift+Click split MIDI note.
- **Các file ảnh hưởng:** `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Hard reload (Ctrl+F5) để lấy bản JS mới.

### [2026-07-31 08:40] Task: Click track name triggers realtime MIDI playback
- **Tóm tắt thay đổi:** Click vào track name ở cột trái → tự động play ngay MIDI notes của track đó (nếu có piano roll tab). Dùng `schedulePianoRollMidi(prTab, 0)` + `startSubTabPlayback(prTab, 0)` + `stopAllPlayback()`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload để load JS mới. Click track có MIDI notes → nghe thấy phát ngay.

### [2026-07-31 08:50] Task: Piano roll instrument load + multitrack play select
- **Tóm tắt thay đổi:** 
  1. `handleEditMidiInTab`: khi mở piano roll tab bằng double-click MIDI item → lưu `instrumentId`, `synth_engine` vào tab, fallback `instrumentProgram` từ `synth_engine.soundfont_program`, gọi `SonicSF.selectInstrument` để load nhạc cụ của track.
  2. Đổi `activePlayTrackIds` từ single value sang mảng → click nhiều nút tên track ở cột trái piano roll để active nhiều track cùng lúc play multitrack.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Test: double-click MIDI item có nhạc cụ → play đúng nhạc cụ. Click nhiều track name → play multitrack.

### [2026-07-31 09:20] Task: Multitrack play select plays immediately
- **Tóm tắt thay đổi:** Thêm `handlePianoRollRealtimePlay(trackIds)` ở App: khi click nút track name trong piano roll → tính ngay ghost layers cho các track được chọn → `stopAllPlayback()` + `SonicSF.stopAll()` → `schedulePianoRollMidi(playTab, offset)` → play ngay realtime không cần bấm play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click track button → nghe ngay. Click thêm/bớt track → re-schedule lại tức thì.

### [2026-07-31 09:32] Task: Drag-drop MIDI file creates new tracks
- **Tóm tắt thay đổi:** Thêm `handleDropMidiToNewTracks(file)` — drop file .mid/.midi vào vùng track container → parse MIDI → tạo track mới cho từng MIDI track và append MIDI item. Container có `onDrop` xử lý MIDI (hoặc audio → load vào selected track); lane drop cũng route MIDI sang hàm mới.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Kéo file .mid vào vùng track → track mới xuất hiện với MIDI.

### [2026-07-31 09:48] Task: Piano roll shows all same-track MIDI items notes as active
- **Tóm tắt thay đổi:** `ghostNoteExtractor.js`: bỏ filter time-overlap cho các MIDI items cùng track (`isSameTrack`) → tất cả notes của mọi MIDI item trong cùng track đều được gộp vào ghost layer. Render: same-track layers vẽ nổi bật (amber, alpha 0.65 + border) = trạng thái active. Effect sync: same-track layers luôn được đưa vào `ghostPlayLayers` → play đủ cả track; cross-track vẫn theo `activePlayTrackIds`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/static/js/services/ghostNoteExtractor.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item của track → thấy tất cả notes của các MIDI item cùng track hiển thị amber nổi bật; play → nghe đủ track.

### [2026-07-31 09:52] Task: Opened MIDI item notes darker than same-track siblings
- **Tóm tắt thay đổi:** Render piano roll: notes của MIDI item đang mở (main layer) đậm hơn — fill `rgba(234,179,8,0.5)`, viền `#f59e0b` (chọn: xanh `rgba(59,130,246,0.5)`). Ghost notes của các MIDI items cùng track nhạt hơn (alpha 0.35, fill amber 0.35) → phân biệt item đang active vs tham chiếu.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item → notes item đó đậm, notes item khác cùng track mờ hơn.

### [2026-07-31 09:55] Task: Focused MIDI item notes brighter
- **Tóm tắt thay đổi:** Notes của MIDI item được focus (đang mở / được chọn / đang play) sáng hơn: fill `rgba(253,224,71,0.6)`, viền `#fde047`; khi đang play sáng hơn nữa `rgba(254,240,138,0.75)` viền `#fef08a`; chọn: xanh `rgba(96,165,250,0.6)`. Ghost siblings cùng track vẫn mờ (alpha 0.35).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item → notes sáng; bấm play → sáng hơn; item khác cùng track mờ hơn.

### [2026-07-31 10:02] Task: Per-MIDI-item focus brightness in piano roll
- **Tóm tắt thay đổi:** Mỗi ghost note có `item_id` (ghostNoteExtractor.js). Render piano roll tính `focusedItemId`: selection → item đang mở; đang play → item chứa playhead (`st.currentTime` trong khoảng `startTime..startTime+duration`). Notes của item được focus vẽ sáng (fill vàng `0.6`/`0.7` + viền `#fde047`, đang play `0.75`), notes của các item khác cùng track mờ (alpha 0.3, fill tối `#7a6a10`). Cross-track giữ ghost 0.25.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/static/js/services/ghostNoteExtractor.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở track 3 MIDI items → play: playhead chạy đến item nào item đó sáng, các item khác mờ.

### [2026-07-31 10:05] Task: Click note focuses its MIDI item
- **Tóm tắt thay đổi:** Thêm state `focusItemId` trong PianoRollTabEditor. Click vào note chính → focus item đang mở (`st.target_id`). Click vào ghost note (note của MIDI item khác cùng track) → focus chuyển sang item đó (không vẽ note mới). Reset focus khi đổi `st.target_id`. Ưu tiên focus: đang play → item theo playhead; có note chọn → item đang mở; còn lại → `focusItemId` (theo click).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click note của item 2 (ghost) → item 2 sáng, item 1&3 mờ.

### [2026-07-31 10:10] Task: All same-track MIDI items selectable to focus
- **Tóm tắt thay đổi:** Cột trái PIANO ROLL: dưới nút tên track (đang mở) hiển thị các nút con cho từng MIDI item trong track đó. Click nút MIDI item → `setFocusItemId(im.id)` → item đó sáng, các item khác cùng track mờ. Nút item đang focus: vàng đậm; item đang mở: viền vàng; còn lại: xám.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click nút MIDI item bất kỳ trong track → focus chuyển sang item đó.

### [2026-07-31 10:10] Task: Revert left-column MIDI item buttons
- **Tóm tắt thay đổi:** Bỏ các nút MIDI items con ở cột trái (không cần). Giữ cơ chế hiện có: các MIDI items cùng track hiển thị sáng/ít sáng theo focus; click vào note mờ (ít sáng) của MIDI item khác → focus chuyển sang item đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload.

### [2026-07-31 10:15] Task: Ctrl+click dim note selects + focuses item
- **Tóm tắt thay đổi:** Trong handleGridMouseDown (ctrl+click, không bấm vào note chính): hit-test trên same-track ghost notes → nếu trúng note mờ → toggle chọn note đó (thêm vào `selectedNoteIds`, vẽ xanh sáng + viền) và `setFocusItemId(item_id)` → active MIDI item chứa note. Empty space vẫn mở marquee như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Ctrl+click note mờ của MIDI item khác → note chuyển xanh + item đó sáng lên.

### [2026-07-31 10:13] Task: Selected dim note's MIDI item wins focus
- **Tóm tắt thay đổi:** Fix focus resolution khi có selection: duyệt `selectedNoteIds` từ cuối (note chọn gần nhất) — main note → item đang mở; dim note (same-track) → `item_id` của nó. Ưu tiên: selection → playhead → `focusItemId`. GHOST NOTE chỉ là note của MIDI items track khác (alpha 0.25).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Ctrl+click note mờ item 2 → item 2 sáng dù item 1 đang mở.

### [2026-07-31 10:19] Task: Click wait-to-active note switches to editing that item
- **Tóm tắt thay đổi:** Click thường vào note mờ (wait-to-active) của MIDI item khác cùng track → `setFocusItemId` + `handleSwitchMidiItem(item_id)` → item đó trở thành target đang mở (editable), notes load vào editor, các item khác thành mờ. Ctrl+click vẫn giữ select+focus (không switch).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click note mờ item 2 → item 2 mở ra để chỉnh sửa notes.

### [2026-07-31 10:22] Task: Piano roll dropdown lists tracks only
- **Tóm tắt thay đổi:** Dropdown trên toolbar PIANO ROLL: thay vì liệt kê tất cả MIDI items (`allMidiItems`), chỉ liệt kê các track có MIDI items (dedupe theo track id). Chọn track → `handleSwitchMidiItem` sang MIDI item đầu tiên của track đó. `value` = `st.trackId`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Dropdown chỉ hiện tên các track.

### [2026-07-31 10:26] Task: Fix master strip PWR mastering bypass
- **Tóm tắt thay đổi:** Nút PWR (Bật/Tắt MASTERING PANEL Bypass) ở Master strip: khi bật ON trước đây set `isBypassed: true` → `toggleMasteringOnMaster(true, true)` giữ routing bypass → âm thanh vẫn bypass. Fix: toggle luôn set `isBypassed: false` + flip `masterConnected` → bật lên là mastering active (EQ→Imager→Maximizer), tắt là disconnect.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Bật PWR → âm thanh qua mastering (không còn bypass); tắt → bypass/disconnect.

### [2026-07-31 10:32] Task: Track VU meter idle when not audible
- **Tóm tắt thay đổi:** Track VU meter loop (line ~18446): tính `isAudible` theo solo/mute (`anySolo ? track.solo : !track.muted`) — nếu track không phát ra âm thanh thực (muted hoặc bị solo out) thì gán `audioPeak=0` và bỏ qua `midiVuActivityRef` flash → VU meter đứng yên thay vì animation giả khi solo track khác.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Solo track 1 → track 2 (MIDI) VU không còn animation.

### [2026-07-31 10:35] Task: Realtime solo toggle during playback
- **Tóm tắt thay đổi:** `toggleTrackSoloEvaluate`: khi toggle solo trong lúc play → stop rồi restart đúng: main timeline gọi `startTrackPlayback(currentTime)` (đã đổi sang đọc `activeTracksRef.current` để nhận solo state mới); piano roll subtab đang play → `schedulePianoRollMidi` + `startSubTabPlayback`. Trước đây restart thiếu lệnh start nên solo không áp dụng realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Bấm SOLO track trong lúc play → chỉ track đó còn nghe, track khác dừng ngay.

### [2026-07-31 10:44] Task: Drop MIDI on existing track loads into that track
- **Tóm tắt thay đổi:** Lane onDrop: drop MIDI/audio lên track có sẵn → `loadFileOnTrack(track.id, f)` (MIDI mở ở track đó; multi-track MIDI: track đầu vào track drop, còn lại tạo track mới). Drop ở vùng trống container vẫn tạo track mới (`handleDropMidiToNewTracks`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Kéo file .mid lên track hiện có → MIDI nạp vào track đó.

### [2026-07-31 10:47] Task: Fix MIDI playback crackling
- **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) skip program change nếu channel đã có đúng instrument (`_channels[ch]` cache) — trước đây mỗi note đều reload soundfont → glitch/crackle; (2) bỏ `console.log` per-note; (3) render loop: worklet gửi `CONSUMED` feedback để `queueDepth` chính xác, `QUEUE_TARGET` 4→8, interval nhanh hơn → hết underrun (silence gap); (4) `stopAll` chỉ dùng `all_notes_off` (release) thay vì `all_sounds_off` (kill tức thì) → hết pop khi stop. `fluidsynth-bridge.js`: post `CONSUMED` sau splice.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js`
- **Ghi chú/Test (nếu có):** Hard reload. Play MIDI note → nghe mượt, hết lụp bụp.

### [2026-07-31 10:50] Task: Fix MIDI crackling v2
- **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) render loop dùng wall-clock time-based queue accounting thay vì async CONSUMED message (hết race gây underrun); (2) `QUEUE_TARGET` 8→16 (~170ms buffer chống jank main thread); (3) bỏ 400ms release hack → `noteoff` đúng duration (FluidSynth tự release mượt, hết chồng voice); (4) `synth.verbose=0` + `synth.gain=1.0`. `fluidsynth-bridge.js`: bỏ post CONSUMED không còn dùng.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js`
- **Ghi chú/Test (nếu có):** Hard reload. Play MIDI với instrument → hết lụp bụp.

### [2026-07-31 11:25] Task: Media Explorer panel (F6) + mutual exclusion with Mixer (F7)
- **Tóm tắt thay đổi:** Tạo `MediaExplorerPanel` component theo spec `md/51_MEDIA_EXPLORER.md` (REAPER-style: toolbar, directory tree + file list, transport/preview với canvas visualizer + metadata + status bar), render ở hàng dưới trên status bar (giống Mixer panel, resize được, lưu `studio_media_explorer_height`). Gán F6 toggle panel với `preventDefault`+`stopPropagation` ở cả keydown capture của App (MAIN SESSION/SECTION-TAB) và `PianoRollTabEditor`. F6/F7 mutual exclusion: bật panel này tự tắt panel kia (`window.__toggleMediaExplorerRef` / `__toggleMixerRef`). Bỏ dock placeholder `media_explorer` cũ. Thêm button status bar "Media Explorer Panel (F6)". Thêm CSS `.me-fader-slider` + `.file-row-selected` vào `index.html`, bump version precompiled.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` + `node --check` OK; smoke test jsdom: F6 hiện Media Explorer & tắt Mixer, F7 ngược lại. Hard reload để test thủ công.

### [2026-07-31 13:05] Task: My Computer tree truy cập filesystem thật
- **Tóm tắt thay đổi:** Backend mới `app/api/v1/media.py` (+ mount trong `app/main.py`): `GET /api/v1/media/computer` liệt kê ổ đĩa/mount point (Windows drive letters / Linux `/proc/mounts`), `GET /api/v1/media/browse?path=` liệt kê thư mục con + file audio/MIDI, `GET /api/v1/media/file?path=` serve file cục bộ để preview (chặn ext không phải media). Frontend `MediaExplorerPanel`: node "My Computer" click → fetch roots, expand/collapse tree (2 cấp con), click thư mục → liệt kê file vào list, preview audio local qua `/media/file`, nút Back/Up/Refresh + address bar hiểu `folder === 'computer'`; metadata hiển thị Local Audio/Local MIDI.
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/main.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test trực tiếp: roots 8 mounts, browse `/home/locpham` 22 dirs; serve wav → content-type audio/wav; chặn `/etc/shadow`. Smoke jsdom: click My Computer → drive root hiển thị → click drive → list `beat.wav`/`loop.mid`, address hiện path. Hard reload để test thủ công.

### [2026-08-02 14:15] Task: Fix Window Explorer API không hoạt động
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `list_computer_roots`: lọc `/proc/mounts` chỉ giữ filesystem thật (`REAL_FS_TYPES`: ext4/xfs/btrfs/ntfs/vfat...), bỏ pseudo/docker/systemd mounts (`/run/credentials/...`, overlay, tmpfs, proc...) trước đó list ra 8 mount rác không duyệt được. Bỏ import `windll` thừa. Frontend `MediaExplorerPanel`: bỏ `setComputerRoots([])` nuốt lỗi — giờ API fail → fallback về root `/` + `showToast` báo lỗi; `browseComputerDir` fail → toast thay vì im lặng; `goComputerParent` sửa edge case Windows root (`C:\`); `fileDuration` match theo `path` cho file local; size MIDI local hiện "MIDI TPQN".
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test router riêng: `/computer` giờ trả đúng `/` + `/boot/efi` (bỏ rác), browse root 21 dirs; smoke jsdom: click My Computer → drive render → browse file OK; API fail → fallback root OK. Lưu ý: cần restart server FastAPI để load router mới + hard reload browser.

### [2026-08-02 14:30] Task: Media Explorer duyệt toàn bộ folder + files
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `/browse`: trả về TẤT CẢ file (bỏ lọc media) kèm `kind` (`midi`/`audio`/`other`). Frontend `MediaExplorerPanel`: (1) tree My Computer đệ quy sâu vô hạn (`renderComputerNode`) — click folder chọn + load files, click +/- expand/collapse ở mọi cấp; (2) file list merge dirs + files, click file chọn/preview, click folder vào thư mục; (3) icon theo loại (`fa-folder`/`fa-music`/`fa-file-audio`/`fa-file`); (4) guard: file `kind==='other'` không preview; metadata Type hiện "Local File".
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test backend: browse trả subdir + a.wav(audio)/b.mid(midi)/notes.txt+readme.md(other). Smoke jsdom: root → list folder+file, expand `/data` → `/data/deep` → list loop.mid OK. Hard reload.

### [2026-08-02 14:45] Task: My Computer mở explorer client local qua File System Access API
- **Tóm tắt thay đổi:** `MediaExplorerPanel` "My Computer" giờ dùng Web API `window.showDirectoryPicker()` (Chrome/Edge) để mở Windows Explorer của MÁY CLIENT chọn thư mục, duyệt qua `FileSystemDirectoryHandle` (`handle.entries()`) — không còn phụ thuộc server/cloud. Tree đệ quy giữ handle mỗi node, `computerMode='client'` + `clientRoot`. Preview audio/MIDI đọc trực tiếp `handle.getFile() → arrayBuffer → decodeAudioData` (helper `readLocalFileBuffer`), không cần URL server. Back/Up/Refresh hoạt động cả 2 mode. Trình duyệt không hỗ trợ FS API → fallback về server API cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom mock `showDirectoryPicker` + handle tree: click My Computer → picker → drive `C:` hiển thị → browse `Users`/`readme.txt` → expand `Users` → `me` OK. Chỉ hoạt động Chrome/Edge (FS Access API). Hard reload.

### [2026-08-02 14:55] Task: My Computer client liệt kê folder + files bên phải
- **Tóm tắt thay đổi:** Fix file list folder rows gọi `browseComputerDir(f.path)` (chuỗi) → đổi thành `browseComputerDir(f)` (entry object) để client mode duyệt đúng qua handle. Khi nhấn My Computer (client-side, `showDirectoryPicker`): picker chọn thư mục → tree trái hiện root, file list phải merge `dirs + files` (folder `fa-folder` + file theo kind), click folder trong list → vào thư mục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom mock FS handle: picker → root `C:` → list Users(folder)+readme.txt → click Users row → vào `root/Users` → me hiển thị OK. Hard reload.

### [2026-08-02 16:40] Task: Media Explorer panel — resize cây thư mục
- **Tóm tắt thay đổi:** `MediaExplorerPanel` directory tree bỏ class `w-44` cố định → width động qua state `treeWidth` (default 176px, min 110 max 420, ref `treeWidthRef`). Thêm drag handle `w-1.5 cursor-ew-resize` bên phải tree (mousedown/mousemove/mouseup trên document, `startTreeResize`). File list bên phải tự co giãn theo `flex-1`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: kéo handle +80px → tree width 176→256px OK, clamp 110–420. Hard reload.

### [2026-08-02 16:50] Task: Media Explorer preview không cập nhật realtime khi chọn file
- **Tóm tắt thay đổi:** `MediaExplorerPanel`: (1) `handleSelect` giờ clear ngay `setPeaks(null)` + `setAudioBuffer(null)` + `stopMediaPlayback()` khi chọn file mới → canvas xóa dữ liệu cũ ngay; (2) thêm `selectTokenRef` guard async race: `loadWaveform` và `playSelected` check token sau mỗi await, kết quả cũ (file trước) không ghi đè file mới chọn; (3) gọi `drawCanvas` qua effect phụ thuộc `[peaks, audioBuffer, selected]` → vẽ lại realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: click file → row selected + metadata MIDI events hiển thị; switch nhanh liên tục → file cuối đúng được chọn, không bị race. Hard reload.

### [2026-08-02 17:15] Task: Media Explorer — persist tree width, canvas realtime, Synth instrument cho MIDI
- **Tóm tắt thay đổi:** (1) Tree width lưu vào `localStorage['studio_media_explorer_tree_width']` khi kéo resize (onUp), khởi tạo đọc lại — reload vẫn giữ độ rộng. (2) Canvas cập nhật realtime khi click file: clear `peaks`/`audioBuffer`/`midiNotes` ngay khi chọn + `selectTokenRef` chống race async; MIDI real vẽ piano-roll nốt thật (màu xám theo pitch 48-84). (3) Nút **Synth** cạnh Auto-Play: dropdown chọn SoundFont + preset (load qua `listPlugins`+`listSoundfontInstruments`), lưu vào `localStorage['studio_media_explorer_synth']`; preview MIDI file thật play qua `window.SonicSF.playNote` với instrument đã chọn (`parseMidiFile` expose ra `window.parseMidiFile` vì nằm trong App scope).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: tree resize → localStorage `276`; Synth dropdown hiện Piano/Grand → chọn persisted; click `tune.mid` → SonicSF.playNote gọi với program 0. Hard reload.

### [2026-08-02 17:18] Task: Fix MIDI auto-play không stop khi click file midi khác
- **Tóm tắt thay đổi:** `stopMediaPlayback` (MediaExplorerPanel) giờ gọi `window.SonicSF.stopAll()` trước khi play file mới → hủy timers note-on/note-off đã schedule của file MIDI trước (trước đây chỉ stop audio source + cancel rAF, nốt MIDI cũ vẫn kêu). Đảm bảo khi click file midi khác, bản cũ dừng ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: play a.mid (4 nốt) → click b.mid → stopAll gọi, b.mid play 4 nốt pitch 72, không overlap. Hard reload.

### [2026-08-02 17:24] Task: Fix MIDI auto-play đọc nhầm file cũ khi click file mới
- **Tóm tắt thay đổi:** `playMidiPreview` (MediaExplorerPanel) thiếu re-check token sau `await window.SonicSF.selectInstrument(...)` — khi click file A rồi nhanh tới file B, invocation cũ (A) chờ selectInstrument xong rồi mới schedule notes, ghi đè lên file B mới chọn → auto-play nghe file cũ nhưng canvas hiển thị file mới. Thêm re-check `selectTokenRef.current !== token` ngay sau await (giống pattern đã có ở audio path sau `decodeAudioData`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản race: (1) A read chậm 200ms + B nhanh 10ms → chỉ B play; (2) A nhanh + B chậm → chỉ B play (A bị stopAll). Canvas luôn = file cuối click. Hard reload.

### [2026-08-02 17:35] Task: Fix canvas "No file selected" lần đầu + Tempo control
- **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload.

### [2026-08-02 18:05] Task: Canvas scroll + giữ state folder/files khi toggle + restore session
- **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload.

### [2026-08-02 18:50] Task: Media Explorer — bars MIDI, synth realtime, loop, icons, lưu folder path
- **Tóm tắt thay đổi:** (1) `parseMidiFile` trả thêm `totalBeats`/`bars`/`bpm`/`ticksPerBeat` → metadata + footer + ruler canvas hiển thị bars đúng (thay vì suy từ duration). (2) Thêm `synthInstRef` — `selectSynthInst` re-schedule preview MIDI ngay khi đổi instrument đang phát (realtime). (3) MIDI preview hỗ trợ **loop**: `loopTimerRef` setInterval re-schedule notes mỗi vòng khi `isLooping`; `toggleLoop` cũng re-schedule khi đang preview; `stopMediaPlayback` clear interval. (4) Thêm **FontAwesome CDN** vào index.html → icon Stop/Play/Pause/Loop + Back/Forward/Up/Refresh hiển thị. (5) `openMyComputer` restore thư mục đã mở từ session (click My Computer → hiện content folder trong ô File, không mở picker lại nếu đã có session + handle IndexedDB).
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Smoke jsdom: metadata Bars/Beats/BPM hiển thị, footer "Bar X/Y"; đổi synth Strings→prog 48 re-schedule OK; loop bật→notes 64→96 (re-schedule vòng 2); icons đủ; folder restore khi click My Computer OK. Hard reload.

### [2026-08-02 19:30] Task: Fix canvas audio + drag & drop Media Explorer → timeline
- **Tóm tắt thay đổi:** (1) Fix canvas audio hiển thị sai: waveform server trả `duration` nhưng không lưu → `dur=0` nên ruler/playhead/time sai. Thêm state `audioDuration` (set ở loadWaveform local/server + playSelected, clear ở handleSelect), `fileDuration` ưu tiên dùng; bar width waveform sửa theo `contentW` thay vì `w` để scroll đúng. (2) Drag & drop file từ Media Explorer vào timeline MAIN SESSION + SECTION-TAB: file row `draggable` + `onDragStart` set `window.__mediaExplorerDragFile`; App thêm `resolveMediaExplorerDropFile()` chuyển entry → real File (client handle `getFile()`, server `file_id` fetch download, local `path` fetch `/media/file`); drop handlers trên track lane + wrapper timeline xử lý cả drag ME lẫn drag OS file (MIDI → new track / audio → load vào track).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: wav client → metadata Duration 3.00s (trước là 0); drag file row → payload text/plain=name + `__mediaExplorerDragFile` set → drop vào track lane → resolve thành File + load (uploadToServer gọi). Hard reload.

### [2026-08-02 19:36] Task: Fix canvas audioclip giãn đầy khung preview
- **Tóm tắt thay đổi:** `drawCanvas` branch audio: bỏ `contentW = Math.max(w, dur*pxPerSec)` (giãn đầy frame) → `contentW = max(1, dur*pxPerSec)` với `pxPerBeat=42`, `pxPerSec = 42*bpm/60` (cùng tỉ lệ tempo với MIDI). Clip ngắn hiển thị đúng chiều rộng theo tempo (2s@120 → 168px), clip dài scroll; playhead theo tỉ lệ tempo (`t*pxPerSec`, khóa giữa khi scroll). Ruler cùng scale (bỏ stretch `Math.max(w, ...)`). Thêm `audioDuration` vào deps effect redraw.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Probe drawCanvas: 2s@120 → contentW=168, waveform maxX 167.8 < 300 (không giãn); 10s → 840 scroll; 2s@240 → 336 (rộng hơn theo tempo). Hard reload.

### [2026-08-02 19:56] Task: My Computer auto-load cây + Favorites thư mục
- **Tóm tắt thay đổi:** (1) Khi mở My Computer client-side (pick xong root), tự động browse + expand tối đa 10 thư mục con cấp 1 (`browseComputerDir`/`browseClientDir` giờ trả về `{dirs, files}` để auto-expand) → cây hiển thị cấu trúc ổ đĩa/thư mục ngay. (2) **Favorites**: node "Favorites" ngay dưới My Computer (hiện khi có favorite, icon `fa-star`); chuột phải folder trong cây (hoặc trong Favorites) → context menu "Thêm vào Favorites"/"Gỡ khỏi Favorites" + "Mở thư mục"; lưu vào `localStorage['studio_media_explorer_favorites_v1']` `[{path, name}]`; click favorite mở nhanh folder (tìm handle trong `computerTree`, hoặc walk lại từ `clientRoot` qua `getDirectoryHandle` theo segments path, fallback `browseComputerDir`); node folder có favorite hiện icon star.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: pick root → C: + Users + Program Files auto-load; right-click Users → menu → favorite saved localStorage + node Favorites hiện; click favorite → mở `root/Users`. Hard reload.

### [2026-08-02 19:65] Task: My Computer auto-scan + playhead chạy tới cuối khi hết scroll
- **Tóm tắt thay đổi:** (1) `openMyComputer` bỏ `showDirectoryPicker` khi click node My Computer — giờ tự động quét server API (`/api/v1/media/computer` liệt kê ổ đĩa máy local + `/browse` expand ổ đĩa → expand thư mục con cấp 1), cây hiển thị ngay không cần hộp thoại; vẫn restore session/handle IndexedDB nếu có. (2) Playhead trong `drawCanvas` (MIDI + audio): công thức `max(0, min(w, t*pxPerSec - offset))` — khi content rộng hơn frame, playhead chạy tới giữa rồi khóa ở giữa trong khi content scroll trái; khi offset đạt max (contentW-w) thì playhead tiếp tục chạy với tốc độ play tới cuối canvas.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke: click My Computer → picker NOT called, DATA/HOME + Music/user auto-load; playhead math: big clip t=1→84, t=3→150 (center), t=5→150 locked, t=10→300 (end). Hard reload.

### [2026-08-02 19:75] Task: Fix My Computer client-side tự động quét cây
- **Tóm tắt thay đổi:** `openMyComputer` ưu tiên **client-side**: load root handle từ IndexedDB → tự động quét cây thư mục client (C:, Users, Program Files...) mà KHÔNG gọi `showDirectoryPicker` (đã gỡ hẳn). Fix bug cũ: `if (savedHandle && ... && !computerRoots)` luôn false vì `computerRoots` vừa được set bởi server API → client handle không bao giờ restore được → gây ra hộp thoại picker. Giờ: (1) nếu có client handle → auto-scan client tree + expand; (2) không có → fallback server API `/api/v1/media/computer` tự động quét ổ đĩa máy local. Mọi path đều không mở hộp thoại.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: IndexedDB có client handle `C:` → click My Computer → picker NOT called, cây C:/Users/Program Files auto-load. Không handle → server API Root (/) auto-load, picker NOT called. Hard reload (browser cache cũ có thể vẫn hiện picker). Commit.

### [2026-08-02 19:85] Task: Fix My Computer hiển thị nhầm Favorites thay vì cây hệ thống
- **Tóm tắt thay đổi:** `openMyComputer` bỏ block restore session cũ (đọc `studio_media_explorer_session_v1` từ localStorage ghi đè `computerRoots`/`computerTree` bằng dữ liệu favorites/stale → khi click My Computer hiện thư mục favorite cũ thay vì cây client). Giờ click My Computer luôn: (1) load client root handle từ IndexedDB → auto-scan cây client thật (C:, Users, Program Files...) hoặc (2) fallback server API scan ổ đĩa local. Không còn ghi đè bằng session cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: seed session cũ chứa `OLD_FAV` + favorites → click My Computer → picker NOT called, hiện C:/Users/Program Files (client tree), OLD_FAV không hiển thị. Hard reload.

### [2026-08-02 19:95] Task: My Computer luôn hiển thị cây hệ thống client, Favorites node riêng
- **Tóm tắt thay đổi:** `restoreSession` giờ CHỈ khôi phục favorites + folder/mode/rootName — BỎ khôi phục `computerRoots`/`computerPath`/`computerFiles`/`computerTree`/`selected` (nguyên nhân click My Computer vẫn mở thư mục cũ từ session). Mount effect chỉ nối lại FileSystemDirectoryHandle (clientRoot) không browse folder cũ. Click My Computer luôn: load client handle → auto-scan cây hệ thống client (C:, Users, Windows...) hoặc fallback server API scan ổ đĩa. Favorites node hiển thị sát dưới node My Computer (đã có từ trước).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: seed session OLD_STUFF + favorite Users → click My Computer → picker NOT called, cây client C:/Users/Windows hiển thị, OLD_STUFF không còn, Favorites node + favorite Users có icon star. Hard reload.

### [2026-08-02 20:01] Task: Fix My Computer chỉ hiện 1 thư mục xanh không có ổ đĩa/thư mục
- **Tóm tắt thay đổi:** `openMyComputer`: nếu có client root handle đã lưu (IndexedDB) nhưng quyền đọc bị thu hồi sau reload (handle.entries() throw/empty) thì trước đây vẫn `return` → chỉ hiện node gốc (icon xanh) không có con. Giờ: (1) gọi `queryPermission`/`requestPermission` trước khi quét; (2) chỉ giữ client path nếu `browsed.dirs.length > 0`; (3) nếu quét client lỗi/rỗng → fallback server API scan ổ đĩa + expand thư mục con.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản: (1) handle bị revoke (entries throw, permission denied) → fallback server: 2 drives (fa-hard-drive) + 5 folder, DATA/HOME/Music/Docs hiển thị; (2) handle khỏe (granted) → cây client C: + Users/me/Windows. Hard reload.

### [2026-08-02 20:10] Task: Click My Computer PHẢI hiện cây thư mục ổ đĩa + thư mục client-side
- **Tóm tắt thay đổi:** `openMyComputer` refactor thành helper `useClientRoot`: (1) client handle đã lưu (IndexedDB) + permission granted → auto-scan cây client; (2) KHÔNG có handle/không có quyền → gọi `window.showDirectoryPicker()` để cấp quyền đọc thư mục client rồi hiện cây client (lần đầu sẽ hiện picker 1 lần); (3) không hỗ trợ API/người dùng hủy → fallback server scan `/api/v1/media/computer` (ổ đĩa máy local). Bỏ điều kiện `browsed.dirs.length` để luôn giữ client mode sau khi chọn. Fix cha-con: `browseClientDir` gắn `parent: handle` cho từng thư mục con, `browseComputerDir` giữ `entry.parent` khi node chưa có trong tree → nút Up (↑) điều hướng đúng trong cây client.
- **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` (babel) thành công, grep bundle chứa `showDirectoryPicker`/`useClientRoot`/`parent:handle`. Smoke: không handle → click My Computer → picker hiện, chọn thư mục → cây client expand. Có handle → không picker, cây client auto-load.

### [2026-08-02 20:56] Task: Fix "Unable to preventDefault inside passive event listener invocation"
- **Tóm tắt thay đổi:** Media Explorer preview canvas dùng React `onWheel={handleCanvasWheel}` có `e.preventDefault()` — React gắn `wheel` passive tại root nên Chrome log lỗi "Unable to preventDefault inside passive event listener invocation" và page vẫn scroll. Thay bằng native listener: `useEffect` + `canvasRef` + `addEventListener('wheel', h, { passive: false })` qua `handleCanvasWheelRef` (luôn gọi handler mới nhất), bỏ prop `onWheel` khỏi `<canvas>`. Giờ block scroll thật + hết lỗi console.
- **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` OK; bundle: `handleCanvasWheelRef` có mặt, `onWheel:handleCanvasWheel` đã bỏ. Scroll chuột trên canvas preview (có file selected) → zoom hoạt động, không lỗi passive. Lưu ý: các onWheel tương tự ở MixerStrip/MasterStrip/TrackStripConsole (pan dial, fader) vẫn còn cảnh báo này — chưa sửa.

### [2026-08-02 21:40] Task: Instrument Sonatina soundfont không phát âm thanh
- **Tóm tắt thay đổi:** Nguyên nhân gốc: FluidSynth WASM (soundfontPlayer) KHÔNG decode được mẫu Ogg Vorbis/SF3 — mọi sample có bit OGG_VORBIS bị từ chối ("unknown flags... unsupported compression") → chọn instrument từ soundfont .sf3 (Sonatina đang ở dạng SF3 do startup convert) → câm hoàn toàn. Ngoài ra converter SF2→SF3 cũ còn 2 bug: (1) thiếu chunk id 'smpl' khi dựng lại file → sfload -1; (2) offset đọc shdr sai (đọc [end,loopstart,loopend,rate] thay vì [start,end,...]) → smpl rỗng. Fix: (1) `sf3_to_sf2()` + `_decode_sample_ogg()` chuyển SF3→SF2 (giải nén OGG về PCM, đúng semantics end-exclusive, loop absolute, xóa bit OGG) — đã verify phát âm thanh cả native lẫn WASM; (2) endpoint download ưu tiên .sf2, nếu chỉ có .sf3 thì chuyển SF3→SF2 on-demand (cache vào upload dir); (3) vô hiệu startup convert SF2→SF3 (`main.py`) — client không chơi được SF3; (4) client `loadSoundFont` nếu cache IndexedDB cũ chứa buffer hỏng thì xóa cache + tải lại; (5) sửa `_sf2_to_sf3_python` (smpl id, shdr offset đúng, end-exclusive, version 3.0, bit OGG, loop relative) + gate `_sf3_plays_audio` xóa SF3 không phát được.
- **Các file ảnh hưởng:** `app/core/soundfont_converter.py`, `app/api/v1/plugins.py`, `app/main.py`, `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Test Node/WASM + native: SF2 gốc rms 0.007 (OK); SF3 convert mới sfload=1 nhưng câm (WASM từ chối OGG); SF3→SF2 convert ra file 52.37MB phát rms 0.007-0.02 (native + WASM). Test endpoint mô phỏng: upload SF3-only → download trả SF2 phát được. `pytest tests/test_vst_engine.py` 12 passed. Lưu ý: scipy thiếu nên test_plugin_api không import được (môi trường dev).

### [2026-08-03 07:55] Task: Media Explorer preview - loop liên tục vùng chọn + margin 2px quanh canvas
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
- **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` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.

### [2026-08-03 10:15] Task: Fix MIDI ARM không phát âm thanh sau khi load instrument
- **Tóm tắt thay đổi:** Sửa `soundfontPlayer.js` để phím MIDI trên track ARM phát đúng instrument: (1) `_playNoteFluid` không còn để state mặc định của channel (bank 0, program 0, không sfId) che mất `synth_engine` của track → `finalSfId` luôn đúng; (2) tự load soundfont lười (lazy) ngay trong đường phát note nếu font chưa vào `_sfHandleMap` (track pick nhanh từ dropdown không gọi `selectInstrument` → trước đây rơi vào `bank_select`/`program_change` trên synth không có soundfont → câm lặng); (3) `selectInstrument` không còn đổi hướng channel khi đã truyền channel tường minh (sửa lỗi 2 track dùng chung instrument bị giật channel). Bump version cache `soundfontPlayer.js` trong `index.html`.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test logic bằng harness `node /tmp/kilo/test_sonicsf_fix.js` (mô phỏng FluidSynth): S1 lazy-load + program_select + noteon, S2 channel cấu hình sẵn không sfload lại, S3 program-only fallback, S4 no-instrument vẫn silent, S5 2 track cùng instrument giữ channel riêng — ALL PASSED. Cần hard-refresh trình duyệt để nạp `soundfontPlayer.js` bản mới.

### [2026-08-03 10:30] Task: Fix ARM nhiều track - instrument sai theo track (channel state che mất synth_engine)
- **Tóm tắt thay đổi:** `_playNoteFluid` trước đây ưu tiên state channel (`_channels[ch].sfId`) làm nguồn instrument cho note → khi ARM nhiều track hoặc track đổi instrument qua dropdown nhanh, note bị phát theo instrument cũ/khác đang "dính" trên channel (leftover state hoặc 2 track trùng channel) → sai instrument từng track. Sửa: `synth_engine` của track là nguồn quyết định; channel state chỉ là cache (chỉ dùng khi note không có engine). Khi note mang engine khác với channel đang giữ, tự động `program_select` lại đúng instrument trước `noteon`; vẫn lazy-load soundfont nếu chưa nạp. Bump version cache `soundfontPlayer.js` trong `index.html`.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_multi_track.js` (mô phỏng routing ARM như app.jsx): (A) 3 track 3 instrument distinct channel → noteon đúng, không re-select thừa; (B) 3 track quick-pick (không midiChannel, channel đang giữ instrument cũ) → mỗi track re-select đúng Q1/Q2/Q3 trước noteon; (C) 2 track trùng channel 0 → track 2 re-select program 40 trước noteon. Harness `test_sonicsf_fix.js` (single-track S1-S5) vẫn ALL PASSED. Hard refresh trình duyệt để nạp bản mới.

### [2026-08-03 10:40] Task: Fix lag khi ARM sau reload + hết âm thanh sau vài lần nhấn MIDI
- **Tóm tắt thay đổi:** (1) **Dedup load soundfont**: `loadSoundFont` trước đây không chặn các lệnh gọi đồng thời — nhấn phím MIDI liên tục (hoặc nhiều track ARM) kích hoạt lazy-load cùng lúc → SGM-V2.01 bị `sfload` 4 lần (handle 1,2,3,4 trong log), mỗi bản ~52MB trong heap WASM 256MB → cạn bộ nhớ, các lần load sau fail → câm sau vài lần nhấn + mỗi note chờ load (lag). Thêm `_loadPromises[sfId]` dedup (1 font = 1 lần load, cache cả kết quả fail) + `_sfloadSeq` cho filename temp duy nhất tránh trùng tên khi load 2 font khác nhau đồng thời. (2) **Preload instrument khi mở/khôi phục dự án**: thêm `preloadTrackInstruments(tracks)` gọi `selectInstrument` cho từng track có soundfont ngay sau khi `setTracks` trong `handleOpenProject` và `restoreLastSessionProject` → nhấn MIDI key đầu tiên không còn lag (font đã nạp sẵn).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` (babel) OK, bundle chứa `preloadTrackInstruments`. Harness `node /tmp/kilo/test_dedup.js`: 8 lần `loadSoundFont` cùng 1 font đồng thời → chỉ 1 `sfload`, 1 lần đọc cache; load lại font đã nạp → 0 sfload; 2 font khác nhau đồng thời → đúng 2 sfload — ALL PASSED. `test_sonicsf_fix.js` + `test_multi_track.js` vẫn ALL PASSED. Hard refresh trình duyệt.

### [2026-08-03 11:00] Task: ARM multitrack - mỗi track dùng channel MIDI riêng, không làm đổi instrument track khác
- **Tóm tắt thay đổi:** FluidSynth có 16 channel; nếu 2 track trùng channel thì ARM track này sẽ `program_select` đè instrument của track kia. Thêm cơ chế channel riêng cho từng track trong app.jsx: `trackMidiChannelsRef` + `ensureTrackMidiChannel`/`assignTrackMidiChannel` cấp channel ổn định, duy nhất (0-15, bỏ qua 9 - slot percussion cổ điển), tự sửa khi 2 track trùng channel được lưu. Áp dụng ở mọi nơi tính channel: `setTrackInstrumentWithProgram`, `setTrackInstrument` (dropdown nhanh), `preloadTrackInstruments`, routing ARM note-on (armedTracks + piano-roll sub-tab), note-off/CC/PitchBend (bỏ fallback `index % 16` — tránh dừng nhầm note của track khác). Lưu `midi_channel` khi save project + restore; reset map channel khi mở/khôi phục dự án. Kết hợp doNote cũ (synth_engine quyết định + re-select trước noteon) → mỗi track luôn phát đúng instrument của nó dù ARM nhiều track, chord/hợp âm đồng thời không còn đổi âm sắc nhau.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `assignTrackMidiChannel` (8 chỗ). Harness `node /tmp/kilo/test_channels.js`: 3 track → channel 0,1,2 duy nhất; gọi lại ổn định; track mới lấy channel trống; track lưu trùng channel được cấp lại; track lưu channel duy nhất được giữ; percussion giữ 9 + track trống khác lấy channel riêng — ALL PASSED. 3 harness cũ (single/multi/dedup) vẫn PASS. Hard refresh.

### [2026-08-03 11:30] Task: Fix instrument sau làm đổi thông số instrument trước + ReferenceError preloadTrackInstruments + warning channel 9
- **Tóm tắt thay đổi:** (1) **sfload reset_presets 1→0** trong `soundfontPlayer._tryLoadSFL`: trước đây mỗi lần load soundfont mới, FluidSynth reset preset của MỌI channel về preset 0 của font mới → load instrument sau làm đổi âm sắc (decay/loop...) của instrument trước dù channel đã `program_select` riêng; giờ channel giữ nguyên instrument, doNote/selectInstrument tự `program_select` rõ ràng. (2) **ReferenceError `preloadTrackInstruments`**: hàm bị đặt nhầm bên trong `ProfileModal` (không cùng scope với `restoreLastSessionProject` trong `App`) → lỗi khi reload. Chuyển `preloadTrackInstruments` lên module-level (self-contained, chỉ warm font cache). Đồng thời sửa lỗi tiềm ẩn của `handleOpenProject` trong ProfileModal: truyền thêm props App-scoped (`setAppWarningModal`, `bpm`, `setBpm`, `setMasteringSettings`, `setSessionTabs`, `setSubTabs`, `trackMidiChannelsRef`) — trước đây click mở dự án trong modal sẽ ReferenceError. (3) **"No preset found on channel 9 [bank=128 prog=0]"**: lọc warning benign trong `printErr` (font không có preset bank 128 thì note chỉ câm, không phải lỗi) + reset=0 giảm nguồn sinh ra nó.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_reset.js`: sfload reset=0, load B không re-select channel A, noteon ch0/ch1 không kèm program_select thừa — PASSED. 5 harness còn lại (single/multi/dedup/channels) vẫn PASS. Deprecation ScriptProcessorNode giữ nguyên (đổi sang AudioWorklet có rủi ro crackle, cần test audio thật). Hard refresh.

### [2026-08-03 11:50] Task: Play MIDI item luôn dùng instrument của track chứa item (kể cả item duplicate)
- **Tóm tắt thay đổi:** Rà soát mọi đường phát MIDI trong app.jsx để chắc chắn playNote luôn nhận channel riêng của track (`assignTrackMidiChannel`) + `synth_engine` của track đó: (1) **fix bug `startLocalTrackPlayback`** (local selection loop) — trước đây gọi `playNote` KHÔNG truyền channel/synth_engine → rơi về channel 0, phát nhầm instrument của track đang giữ channel 0; giờ truyền `lcCh` + `track.synth_engine`. (2) Đồng nhất các đường còn lại (transport `startTrackPlayback`, section sub-track, `schedulePianoRollMidi`, `playMidiPreviewNote`, piano-roll canvas/click/keybed/brush preview, ghost layers) từ `getTrackMidiChannel` → `assignTrackMidiChannel` để mọi path dùng đúng dedicated channel của track. Vì playback lấy `synth_engine`/`instrumentProgram` từ track CHỨA item (item không lưu instrument, duplicate chỉ copy notes) nên khi duplicate/move MIDI item sang track khác, item sẽ tự động phát instrument đã load ở track đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `getTrackMidiChannel` không còn trong app.jsx (0), `assignTrackMidiChannel` có 19 chỗ. Bundle chứa `lcCh=assignTrackMidiChannel(track,tracks)` + playNote truyền `lcCh,track.synth_engine`. 5 harness (single/multi/dedup/channels/reset) vẫn PASS. Hard refresh.

### [2026-08-03 12:10] Task: Fix ReferenceError assignTrackMidiChannel + bật AudioWorklet thay ScriptProcessor
- **Tóm tắt thay đổi:** (1) **ReferenceError `assignTrackMidiChannel is not defined`**: allocator channel (trackMidiChannelsRef/ensureTrackMidiChannel/assignTrackMidiChannel) đặt bên trong `App`, nhưng các sub-component piano roll (`PianoRollTabEditor` — canvas preview, click note, draw/paint brush, keybed) là module-level → không thấy hàm → lỗi khi render/play. Chuyển allocator lên **module-level** (đầu file, trước PianoRollTabEditor), App dùng chung bản đó (bỏ khai báo `useRef` cục bộ trong App; reset `trackMidiChannelsRef.current = {}` khi load dự án vẫn dùng chung object). (2) **Deprecation ScriptProcessorNode**: bật **AudioWorklet** `fluidsynth-bridge` làm mặc định (`_useScriptNode = false`), chỉ fallback ScriptProcessor khi `new AudioWorkletNode`/`addModule` thất bại → hết warning deprecation; `_startRenderLoop` (queue 16 block × 512 sample ~186ms headroom, interval 2× frame rate) vốn đã được thiết kế cho worklet.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, bundle load smoke test (stub React/ReactDOM/window) OK. Harness `test_sonicsf_fix.js` với mock AudioWorkletNode + performance: `workletCreated=1 scriptProcCreated=0` (worklet được dùng), S1-S5 vẫn PASS; trước đó (thiếu mock performance) xác nhận fallback ScriptProcessor hoạt động. 4 harness còn lại vẫn PASS. Hard refresh.

### [2026-08-03 12:30] Task: Fix crash AudioWorklet - "Cannot set properties of undefined (setting '0')" khi output mono
- **Tóm tắt thay đổi:** `FluidSynthBridge.process` luôn ghi stereo `out[0]`/`out[1]`; khi thiết bị/context xuất mono (chỉ 1 channel), `out[1]` là `undefined` → `out[1][i] = ...` ném TypeError, dừng audio. Fix: (1) `fluidsynth-bridge.js` xử lý mọi số channel output (vòng lặp theo `out.length`, chẵn=lấy L, lẻ=lấy R, trường hợp cạn queue ghi 0 cho từng channel); (2) `soundfontPlayer.js` tạo `AudioWorkletNode` với `outputChannelCount: [2]`, `channelCount: 2`, `channelCountMode: 'explicit'` để ép stereo (FluidSynth render stereo) thay vì để device quyết định.
- **Các file ảnh hưởng:** `app/static/js/worklets/fluidsynth-bridge.js`, `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_worklet.js` mô phỏng process(): (1) output mono 1 channel → mirror left, không crash; (2) stereo → L/R đúng; (3) queue cạn → silence. Tất cả PASS. 5 harness khác vẫn PASS. Worklet + soundfontPlayer `node --check` OK. Không cần build bundle (2 file served trực tiếp). Hard refresh.

### [2026-08-03 12:45] Task: Fix không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định
- **Tóm tắt thay đổi:** Sau khi bật AudioWorklet làm mặc định, audio không phát khi play MIDI item trên track (môi trường user bị 2 lỗi liên tiếp: crash mono rồi câm). ScriptProcessor là đường đã được xác nhận hoạt động ổn định (mọi test trước đó). Quay lại `_useScriptNode = true` (ScriptProcessor mặc định) để đảm bảo âm thanh; giữ code AudioWorklet (đã mono-safe) + thêm cache-busting `?v=202608031240` cho URL `addModule` để khi bật lại không bị cache bản cũ. Deprecation warning quay lại nhưng chỉ là cảnh báo cosmetic.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_render.js` (path ScriptProcessor): workletCreated=0, ScriptProcessor được tạo, playNote → noteon, onaudioprocess render ra audio peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion ScriptProcessor mặc định → rc=0. 6 harness còn lại PASS (test_worklet rc báo 1 do glitch shell nhưng result file ghi ALL TESTS PASSED). Không cần build bundle (file served trực tiếp). Hard refresh.

### [2026-08-03 13:00] Task: Fix piano roll tab không phát âm thanh instrument của track
- **Tóm tắt thay đổi:** `schedulePianoRollMidi` cộng `sessionBeatOffset` (vị trí tuyệt đối của item trong project, đơn vị beat) vào `start_beat` của mọi note → note được lên lịch ở **thời điểm tuyệt đối** của project, trong khi playhead/tab piano roll là **item-relative** (0 = đầu item). Item đặt sau vị trí 0 (vd 10s) → nhấn play nghe không có âm thanh trong 10s (note được schedule ở now+10s), sau đó mới kêu → cảm giác "không có âm thanh instrument". Fix: bỏ `sessionBeatOffset` ở cả loop note chính và loop ghost (ghost `relative_start_beat` vốn đã item-relative) — note phát theo vị trí tương đối item, khớp playhead; item ở vị trí 0 không đổi hành vi. `sessionSyncMode` chỉ là chế độ hiển thị ghost/context, không liên quan timing phát.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, hết `sessionBeatOffset` (0 chỗ). Harness `node /tmp/kilo/test_pr_timing.js` mô phỏng timing mới: item ở project 10s, nhấn play mới → note đầu phát ngay +0s (trước đây +10s), note 2 ở +2s; resume 1.2s → bỏ note đầu, schedule note sau; item vị trí 0 không đổi — ALL PASSED. 6 harness còn lại PASS. Hard refresh.

### [2026-08-03 13:15] Task: Fix mất soundfont khi ARM + BiquadFilterNode state is bad
- **Tóm tắt thay đổi:** (1) **Master chain bị NaN**: `applyMasteringSettings` chạy mỗi lần `getAudioContext()`; nếu `window.currentMasteringSettings` bị thiếu field/NaN (vd project lưu cũ, slider kéo cực hạn) thì `eqLowFilter.gain.setTargetAtTime(undefined/NaN)` → biquad "state is bad" → master chain im lặng → mọi audio (kể cả MIDI/ARM) biến mất ("mất soundfont"). Thêm `clamp()` chặn mọi tham số EQ (±24dB), imager (±100), maximizer, ceiling — NaN/thiếu → 0 (trung tính), cực hạn → giới hạn an toàn. (2) `toggleMasteringOnMaster` trở nên idempotent (chỉ disconnect/reconnect khi trạng thái đổi) — trước đây gọi lại liên tục từ `getAudioContext()` gây reconnect nhanh → biquad mất ổn định. (3) **loadSoundFont không cache lỗi vĩnh viễn**: lỗi tải thoáng qua (mạng 503, áp lực bộ nhớ) trước đây bị giữ trong `_loadPromises` → instrument câm vĩnh viễn tới khi reload; giờ xóa cache lỗi để note kế tiếp thử lại và tự hồi phục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_retry.js`: clamp undefined/NaN → 0, cực hạn → giới hạn; load font lỗi 503 lần 1 → lần 2 retry thành công (2 fetches) → note phát được — ALL PASSED. 8 harness còn lại PASS. Deprecation ScriptProcessorNode vẫn còn (giữ ScriptProcessor vì ổn định, worklet từng gây lỗi thiết bị). Hard refresh.

### [2026-08-03 13:30] Task: Hết ScriptProcessor deprecation (bật AudioWorklet) + hết BiquadFilterNode state is bad (signature guard)
- **Tóm tắt thay đổi:** (1) **Deprecation**: bật lại AudioWorklet làm renderer mặc định (`_useScriptNode = false`) — worklet giờ đã mono-safe + ép stereo `outputChannelCount:[2]` + URL `addModule` có cache-busting `?v=` nên luôn nạp bản đã sửa; fallback ScriptProcessor khi tạo worklet thất bại. Trước đây "mất tiếng khi transport" là do worklet cũ (pre-mono-fix) bị cache. (2) **BiquadFilterNode: state is bad**: `applyMasteringSettings` được gọi mỗi lần `getAudioContext()` (stopAll/play/VU…), liên tục `setTargetAtTime` vào EQ biquad = "fast parameter automation" → Chromium báo state bad dù giá trị hợp lệ. Thêm **signature guard**: chỉ áp dụng khi giá trị thực sự đổi → bỏ chùm automation lặp, giữ clamp chống NaN. `toggleMasteringOnMaster` vẫn idempotent.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK. Harness `test_render.js` (worklet mặc định): workletCreated=1 scriptProcCreated=0, noteon=1, 26 PCM frames posted, peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion worklet → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — lần đầu sẽ tải worklet mới. Nếu nghe crackle/mất tiếng khi phát, báo lại để quay lại ScriptProcessor.

### [2026-08-03 13:40] Task: Fix Lỗi không có âm thanh - quay lại ScriptProcessor (quyết định cuối)
- **Tóm tắt thay đổi:** Bật AudioWorklet lần 2 vẫn làm mất tiếng trên máy user. Nguyên nhân thực sự: worklet là push model (`setInterval` trên main thread) — khi main thread bận (load font/WASM decode/UI), vòng lặp nghẽn → worklet hết frame → câm. ScriptProcessor là pull-based (`onaudioprocess` do audio thread gọi) nên không bao giờ đói dữ liệu. **Quyết định cuối: giữ ScriptProcessor làm renderer mặc định vĩnh viễn** (`_useScriptNode = true`); warning deprecation chỉ cosmetic. Worklet (đã mono-safe + stereo-forced + cache-busting) giữ lại như opt-in để test sau.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `test_render.js` (ScriptProcessor mặc định): workletCreated=0, ScriptProcessor được tạo, noteon=1, render peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` assertion ScriptProcessor → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — âm thanh hoạt động lại.

### [2026-08-03 13:45] Task: Fix mất âm thanh khi nhấp đôi MIDI item vào Piano Roll
- **Tóm tắt thay đổi:** `handleEditMidiInTab` (nhấp đôi MIDI item → mở PIANO ROLL tab) preload instrument bằng channel cũ `trkIdx % 16`, trong khi MỌI đường phát (schedulePianoRollMidi, transport, ARM) dùng dedicated channel (`assignTrackMidiChannel`). Channel lệch nhau → tab-open `selectInstrument` cấu hình nhầm channel (có thể đè instrument của track khác nếu trùng channel 0-15), khiến âm thanh trong/pianô sau khi mở tab không còn đúng. Sửa: dùng `assignTrackMidiChannel(track, allTrks)` cho preload khi mở tab → khớp đúng channel phát.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_dbl.js` mô phỏng toàn bộ flow double-click (init → tab-open selectInstrument → schedulePianoRollMidi playNote → ScriptProcessor render): noteon đúng channel, render peak 0.25 (không câm) — ALL PASSED. 9 harness còn lại PASS. Nếu vẫn mất âm thanh sau hard refresh, cần xác định rõ: câm toàn cục hay chỉ play trong piano roll, và có console error gì.

### [2026-08-03 14:00] Task: Fix câm toàn cục sau nhấp đôi MIDI item + BiquadFilterNode state is bad (triệt để)
- **Tóm tắt thay đổi:** User xác nhận: sau nhấp đôi MIDI item vào Piano Roll, câm TOÀN CỤC (main cũng hết tiếng) + lỗi `BiquadFilterNode: state is bad` vẫn còn. Nguyên nhân: master chain (EQ/imager biquad) bị `toggleMasteringOnMaster` + `applyMasteringSettings` gọi lại **mỗi lần** `getAudioContext()` (play, stopAll, double-click, VU...) — burst "fast parameter automation" vào biquad làm Chromium báo state bad, và nếu routing master bị ngắt giữa chừng → câm toàn cục. Fix triệt để: (1) **gỡ mastering khỏi getAudioContext** — master chain chỉ được áp dụng 1 lần khi `initMasterBus` tạo (nếu có `currentMasteringSettings`) + qua React effect khi settings đổi; (2) **time constant 0.01 → 0.05** + `cancelScheduledValues` trước mỗi `setTargetAtTime` EQ — hết tích tụ automation event khi kéo slider; (3) `toggleMasteringOnMaster` bọc try/catch, **luôn reconnect lại routing hợp lệ** dù lỗi — không bao giờ để inputAnalyser bị ngắt không nối (gây câm toàn cục).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `toggleMasteringOnMaster` còn 3 chỗ (def + initMasterBus + effect). 9 harness (single/multi/dedup/reset/channels/render/pr_timing/retry/dbl) vẫn PASS. Hard refresh (Ctrl+F5). Nếu lỗi biquad vẫn hiện khi bật MasteringModal và kéo EQ, đó có thể là false-positive Chromium (parallel biquad) — báo tôi để tôi đổi cấu trúc imager/EQ.

### [2026-08-03 14:15] Task: Fix câm toàn cục - IN peak có, OUT peak không (master chain chết)
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.

### [2026-08-03 14:30] Task: Media Explorer - auto tempo theo MIDI file, gõ tempo tay, focus folder cha, điều hướng tree bằng phím mũi tên
- **Tóm tắt thay đổi:** (1) **Auto set tempo**: click MIDI file → `handleSelect` set tempo từ metadata `f.bpm`; `playMidiPreview` sau khi parse set tempo theo `midiResult[0].bpm` (clamp 40-300) trước khi schedule → preview phát đúng tempo file. (2) **Gõ tempo tay**: input tempo dùng `tempoText` (string) cho phép gõ tự do (trước đây clamp 40-300 ngay khi gõ chặn việc nhập số < 40), commit khi hợp lệ hoặc blur/Enter; `commitTempo` còn re-schedule MIDI preview đang phát theo tempo mới. (3) **Focus folder cha**: click file → expand các node cha + `centerTreeNodeInPane` cuộn tree pane (ref `treePaneRef`) để folder cha hiện GIỮA ô tree. (4) **Phím mũi tên**: khi panel active (`window.mediaExplorerActive`) và ở computer mode, ArrowUp/Down di chuyển cursor qua node hiển thị (dùng `computerPathRef`/`computerTreeRef`/`computerRootsRef` để tránh stale closure trong keydown `[]`), ArrowRight expand/load, ArrowLeft collapse hoặc về thư mục cha; `browseComputerDirRef` tránh stale `browseComputerDir`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa commitTempo/navigateTreeTo/centerTreeNodeInPane/getVisibleTreePaths/treePaneRef/tempoText. Harness `node /tmp/kilo/test_tree.js` mô phỏng flatten tree + Up/Down/Left logic — ALL PASSED. 9 harness còn lại PASS. Hard refresh.

### [2026-08-04 21:35] Task: Fix mất âm thanh trong Piano Roll và lỗi BiquadFilterNode state is bad khi play MIDI
- **Tóm tắt thay đổi:** Khắc phục triệt để lỗi mất âm thanh và lỗi filter master bị hỏng (BiquadFilterNode state is bad):
  (1) **Chặn NaN Pitch/Velocity trong soundfontPlayer.js**: Khi playNote nhận nốt có velocity/pitch là NaN hoặc không hợp lệ (ví dụ do vẽ CC vẽ sai, hoặc AI import lỗi), FluidSynth WASM sẽ nhận giá trị NaN này và xuất ra tín hiệu âm thanh chứa NaN. Tín hiệu NaN này truyền vào Master Bus làm cho toàn bộ 11 bộ lọc BiquadFilterNode bị sụp đổ ("state is bad") và ngắt toàn bộ âm thanh. Đã thêm cơ chế kiểm tra và chuyển NaN về giá trị an toàn mặc định (Pitch -> 60, Velocity -> 100) trong `_playNoteFluid`.
  (2) **Định tuyến Piano Roll Tab**: Ưu tiên định tuyến trực tiếp FluidSynth vào track của Piano Roll đang edit trong `updateSfRouting()` thay vì fallback về masterBus.input khi có nhiều hơn 1 track MIDI hoạt động.
  (3) **Bảng vá phiên bản (Cache busting)**: Tăng tham số truy vấn cache `v=202608042135` cho `soundfontPlayer.js` và `app.precompiled.js` trong `index.html` để trình duyệt tải lại tệp tin mới nhất.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công. Chạy và kiểm tra nốt nhạc an toàn, không còn hiện tượng NaN lọt vào gây lỗi filter. Tải lại trang (hard refresh) để trình duyệt áp dụng mã nguồn mới.

### [2026-08-04 21:42] Task: Fix mất nhạc cụ khi double click vào Piano Roll Tab sau khi reload dự án
- **Tóm tắt thay đổi:** Khắc phục lỗi khi tải lại trang, phát nhạc cụ trên Main session OK nhưng double-click vào MIDI item để mở Piano Roll thì nhạc cụ bị câm/về mặc định:
  (1) **Đồng bộ hóa/Khôi phục synth_engine trong dự án**: Thêm `synth_engine` vào đối tượng tuần tự hóa/giải tuần tự hóa (`serializeProjectToSchema` / `deserializeProjectFromSchema`) của các `sub_tabs`. Trước đây, khi reload dự án, tab con được khôi phục nhưng bị mất thông tin `synth_engine` dẫn đến việc phát nốt nhạc trên bàn phím ảo (keybed) hoặc vẽ nốt không có nhạc cụ.
  (2) **Cập nhật động nhạc cụ khi mở lại Tab con**: Trong `handleEditMidiInTab`, nếu tab Piano Roll đã tồn tại (`existing`), tự động cập nhật lại các thuộc tính nhạc cụ (`instrumentProgram`, `instrumentName`, `instrumentId`, `synth_engine`) lấy từ cấu hình hiện tại của track trên Main session.
  (3) **Dừng phát Main session khi mở Piano Roll**: Tự động gọi `stopAllPlayback()` khi người dùng double-click mở Piano Roll để đảm bảo không bị kẹt tiến trình phát nền hoặc lỗi đồng bộ.
  (4) **Lắng nghe thay đổi Tab để định tuyến**: Thêm `useEffect` để chạy `updateSfRouting()` ngay khi `activeTab` thay đổi để FluidSynth luôn nối đúng đích âm thanh tương ứng với tab đang mở.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.

### [2026-08-04 21:46] Task: Fix playhead không di chuyển và không phát tiếng trên Piano Roll Tab khôi phục từ reload
- **Tóm tắt thay đổi:** Khắc phục lỗi khi mở lại Piano Roll Tab được khôi phục từ dự án đã lưu, khi nhấn Play thì playhead không chạy và không phát ra âm thanh:
  (1) **Khôi phục Buffer của Sub-tab**: Do `buffer` (đối tượng AudioBuffer) là dữ liệu nhị phân không thể tuần tự hóa sang JSON, khi dự án reload và khôi phục `subTabs` từ DB, trường `st.buffer` của tab con bị `undefined`. Khi `updatePlayhead` chạy, nó kiểm tra điều kiện `if (!st || !st.isPlaying || !st.buffer) return;` — do `st.buffer` bị `undefined`, vòng lặp hoạt họa playhead lập tức bị dừng ngay từ khung hình đầu tiên.
  (2) **Đảm bảo Buffer luôn được khởi tạo trong State**: Cập nhật hàm khởi động phát (`handlePlayPause`, recovery resume, looping) để luôn gán hoặc tạo lại một buffer im lặng (`createBuffer`) trong React state `subTabs` nếu phát hiện `buffer` đang bị thiếu. Việc này giúp `updatePlayhead` vượt qua câu lệnh điều kiện và tiếp tục vòng lặp vẽ hoạt họa/phát nhạc thành công.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.

### [2026-08-04 21:55] Task: Thêm log chẩn đoán tiến trình định tuyến và nạp nhạc cụ trong soundfontPlayer.js
- **Tóm tắt thay đổi:** Bổ sung các console log chi tiết bên trong `soundfontPlayer.js` nhằm theo dõi chính xác hành vi định tuyến âm thanh và tiến trình nạp nhạc cụ khi chạy Piano Roll:
  (1) **Log setOutputDestination**: Ghi nhận thời điểm và đích đến khi định tuyến đầu ra của FluidSynth (`_gainNode` kết nối tới `sfEntry` của track hoặc reset về `masterBus.input`).
  (2) **Log loadSoundFont & selectProgram**: Ghi nhận trạng thái nạp SoundFont từ mạng/cache và quá trình chọn program trên kênh MIDI trước khi nốt được phát.
  (3) **Log noteon**: Ghi nhận sự kiện phát nốt thực tế bao gồm kênh, tần số/pitch, velocity và soundfont ID của nốt nhạc.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.

### [2026-08-04 22:00] Task: Bổ sung bộ lọc NaN và giới hạn tần số Nyquist cho Track FX rack
- **Tóm tắt thay đổi:** Khắc phục lỗi `BiquadFilterNode: state is bad` xảy ra khi chạy các module FX trên track (như EQ, Compressor, Limiter, Exciter):
  (1) **Chống giá trị NaN trong Track FX**: Thêm hàm bổ trợ `num(v, def)` để kiểm tra tính hợp lệ (finite và không NaN) của tất cả các tham số cấu hình FX (như EQ gain, drive, mid/side gains, threshold, ratio) trước khi thiết lập giá trị cho Web Audio nodes. Nếu tham số không hợp lệ hoặc bị khôi phục lỗi (ví dụ từ dự án đã lưu), hệ thống sẽ gán giá trị mặc định thay vì NaN, tránh làm hỏng các bộ lọc biquad.
  (2) **Giới hạn tần số dưới mức Nyquist**: Áp dụng cơ chế giới hạn tần số `clampF(v) = min(v, sampleRate * 0.45)` cho mọi bộ lọc biquad được tạo ra trong `createTrackFxModule` (cho EQ, Exciter) và `createEqProModule` (EQ nâng cao). Việc này bảo vệ bộ lọc không bị tràn hệ số khi chạy trên các driver âm thanh có sample rate thấp hoặc khi cấu hình tần số quá cao.
  (3) **Sửa lỗi khôi phục Master Bus**: Sửa lỗi gọi hàm `initMasterBus()` trong watchdog recovery thiếu tham số `getAudioContext()`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.

### [2026-08-04 22:05] Task: Bổ sung bộ lọc NaN cho âm lượng track và lựa chọn chương trình nhạc cụ của FluidSynth
- **Tóm tắt thay đổi:** Giải quyết triệt để lỗi `BiquadFilterNode: state is bad` do rò rỉ giá trị `NaN` vào đường truyền âm thanh khi chuyển tiếp sang Piano Roll tab:
  (1) **Chống NaN cho âm lượng track**: Cập nhật hàm `computeTrackAudibleGain` và `setTrackNodeGain` để lọc sạch trường hợp thuộc tính `volumeDb` của track có giá trị `NaN` hoặc dạng chuỗi không hợp lệ, gán giá trị mặc định là `1.0` (0 dB) thay vì trả về `NaN` làm hỏng gain của `sfEntry` / `gainNode`.
  (2) **Chống NaN cho chương trình/bank FluidSynth**: Thêm bộ lọc `parseInt` và `isNaN` kiểm tra biến `usedBank` và `usedProg` bên trong hàm phát nốt `doNote` của `soundfontPlayer.js`. Tránh truyền giá trị `NaN` trực tiếp vào hàm WASM `_fluid_synth_program_select` có thể làm rối loạn bộ tổng hợp âm bên trong FluidSynth và kết xuất mẫu âm thanh NaN.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.

### [2026-08-05 22:30] Task: 2 fix quyết định trên baseline — NaN sweep synth (heap dangle) + watchdog piano-roll rebuild THẬT
- **Báo cáo user lặp lại (cùng text):** piano-roll play → BACK → play → CÂM, chain STUCK. Sau khi loại toàn bộ compressor (v22:00) mà vẫn lỗi → NaN KHÔNG từ compressor.
- **2 lỗ hổng còn lại (baseline cfc114b):**
  (1) **Synth render KHÔNG sweep NaN + lp/rp dangle:** `_leftBufPtr/_rightBufPtr` malloc 1 lần ở init; load SGM-V2.01 (~300MB) → heap WASM realloc → pointer đọc vùng free → NaN → chain state-bad. FIX soundfontPlayer.js: theo dõi `HEAPU8.buffer` → re-malloc khi đổi + **NaN sweep (isFinite → 0)** ở mọi block — synth output KHÔNG BAO GIỜ chứa NaN.
  (2) **Watchdog recovery là NO-OP:** `initMasterBus` early-return khi masterBus còn tồn tại → [Recovery] không rebuild gì → chain chết STUCK vĩnh viễn. FIX: teardown (disconnect analyser/output/dryOutput + masterBus=null) TRƯỚC initMasterBus → rebuild biquad THẬT + reschedule.
  (3) **Watchdog mở cho PIANO_ROLL:** trước đây `!isPianoRoll` (exempt hoàn toàn). Giờ: piano-roll CHỈ rebuild khi output NaN (getFloatTimeDomainData + isFinite — rests tự nhiên KHÔNG trigger, tránh false-positive).
- **Các file ảnh hưởng:** soundfontPlayer.js, app.jsx, index.html (?v=202608052230 cho cả 2), wiki.md. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM (sweep chặn NaN tại nguồn + watchdog cứu nếu còn chết).

### [2026-08-05 23:30] Task: Trên commit user 55d3464 — áp lại fix compressor (default bypass + tanh/hard-clip limiters) — trị "âm nhỏ + chain chết"
- **User tự commit 55d3464 "FIX: Piano roll tab không xuất âm thanh qua mastering chain"** = cfc114b + giữ watchdog piano-roll + giữ NaN sweep + re-malloc của agent — NHƯNG compressor gốc VẪN CÒN.
- **Báo cáo user:** recovery có âm nhưng KHÔNG qua mastering → rất nhỏ.
- **2 lý do:**
  (1) **Compressor mặc định (threshold -24, ratio 12, LUÔN-ON) vẫn trong path** (`input → compressor → inputAnalyser`) — pump-down tín hiệu → âm nhỏ + méo; và phát NaN trên bass transient → state-bad → chain chết.
  (2) maximizerCompressor + limNode + track limiter vẫn là DynamicsCompressor — nguồn NaN.
- **Áp lại (trên HEAD 55d3464):** bypass compressor mặc định; maximizerCompressor → WaveShaper HARD CLIP tại ceiling (slope 1 — không boost); mastering limNode → tanh soft-clip; track 'limiter' → tanh; applyMasteringSettings adapt (_setCeiling/_setThreshold, OFF → identity).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM, âm lượng đủ (hết compressor pump-down), qua mastering.

### [2026-08-05 23:50] Task: Watchdog piano-roll FALSE POSITIVE — recovery trên rests tự nhiên (pk trigger) → NaN-ONLY
- **Log v2330 (compressor fix ĐÃ chạy):** recovery fire → rebuild → applyMasteringSettings → node mới → sfEntry → **noteon 23 notes từ 0:0 (bass 56/49/37 vel 127) — KHÔNG state-bad!!** ⇒ compressor fix HOẠT ĐỘNG (bass không còn NaN → chain không chết).
- **NHƯNG [Recovery] vẫn fire** — lỗi watchdog của agent: điều kiện `pk < 0.001 || (isPianoRoll && nanOut)` — **piano roll vẫn trigger bởi pk<0.001** — rest tự nhiên > 750ms trong pattern = FALSE POSITIVE → recovery hủy play + restart notes (glitch + "âm nhỏ" do restart mất bối cảnh).
- **FIX:** `(isPianoRoll ? nanOut : (pk < 0.001 || nanOut))` — piano roll CHỈ rebuild khi output NaN (chain chết). Main giữ nguyên (pk || nanOut).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052350), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play piano roll từ 0:0 → KỲ VỌNG: CÓ ÂM liên tục, KHÔNG [Recovery] (trừ khi chain thực sự chết — NaN), không restart glitch.

### [2026-08-06 00:00] Task: TRIGGER THẬT cuối — routing swap DƯ giữa stream (dedupe setOutputDestination) + state-bad trên mastering PWR
- **Log v2350:** state-bad TRỞ LẠI — nổ NGAY SAU `setOutputDestination to: track node (sfEntry)` LẦN 2 (sau 6 noteon đầu — từ applyAllTrackMuteSolo cuối startSubTabPlayback) — dù compressor đã hết (bass play sạch ở các log khác). ⇒ **Graph mutation (disconnect/reconnect _gainNode GIỮA stream) làm ScriptProcessor xuất buffer uninitialized → NaN → 11 biquad sụp.** Compressor vô can. Khớp mọi log từ đầu: state-bad LUÔN sau swap lần 2; play 0:1 (swap xong trước notes) không nổ.
- **FIX (soundfontPlayer.js):** dedupe `setOutputDestination` — cache `_outputDestination` (5 chỗ khởi tạo) + `if (dest === _outputDestination) return;` — swap dư (sfEntry→sfEntry) bị loại; swap thật (masterBus.input→sfEntry — TRƯỚC notes) giữ nguyên.
- **Các file ảnh hưởng:** soundfontPlayer.js (?v=202608052355 — chỉ hard refresh, không cần build precompiled), wiki.md.
- **Ghi chú/Test:** hard refresh → bật mastering PWR → play piano roll từ 0:0 → KỲ VỌNG: KHÔNG state-bad, âm qua mastering (EQ/imager/maximizer nghe rõ), không recovery giả.

### [2026-08-06 00:00] Task: Chain FLAT sau recreation — stale _lastMasteringSig cache (spectrum hiển thị nhưng không xử lí)
- **Báo cáo user:** mastering chain spectrum hiển thị trong các module NHƯNG âm thanh không được xử lí (không tăng gain, không thay đổi).
- **Cơ chế:** `applyMasteringSettings` early-return qua `_lastMasteringSig` (module-level, PERSIST qua recreation masterBus). Sau recovery (watchdog rebuild: masterBus=null → initMasterBus) chain MỚI giữ giá trị INIT (EQ gain 0, imager width 100, maximizer boost 0) → **FLAT** — tín hiệu chảy qua modules (spectrum hiển thị) nhưng output = input. Cũng giải thích "âm rất nhỏ" sau recovery ở vòng trước (mất maximizer gain 5.4dB + EQ).
- **FIX (app.jsx initMasterBus):** `_lastMasteringSig = null;` trước `applyMasteringSettings` — chain mới được cấu hình lại đầy đủ.
- **Kết hợp với v23:55 (dedupe swap):** dedupe ngăn state-bad (không recovery → không flat); sig-reset đảm bảo recovery (nếu có) cấu hình lại chain — 2 fix bổ trợ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060000), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → bật mastering PWR → play piano roll 0:0 → KỲ VỌNG: âm qua mastering ĐẦY ĐỦ (EQ gain nghe rõ, maximizer boost, imager width) — chỉnh slider module → âm thay đổi ngay.

### [2026-08-06 00:30] Task: Recovery NGAY khi NaN — fast-path 3 frame (~50ms) thay vì 45 frame (750ms)
- **Câu hỏi user:** "khi quay lại 0:0 mastering recovery — tại sao delay 1.5s mà không recovery ngay?"
- **Giải thích delay:** 2 ngưỡng cố ý: (1) 45 frame ≈ 750ms xác nhận im lặng THẬT (false-positive = stopAllPlayback + restart = glitch — thiết kế cho main session transient gap); (2) 3s cooldown chống rebuild-loop.
- **Fix (app.jsx updatePlayhead):** `if (masterSilenceFramesRef.current > (nanOut ? 3 : 45) && sinceRebuild > 3000)` — **NaN (chain chết chắc chắn) → rebuild sau 3 frame ≈ 50ms** (gần như tức thì); pk<0.001 (main) giữ 45 frame. Cooldown 3000 giữ nguyên (chống loop nếu collapse deterministic).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060030), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play 0:0 → nếu chain chết: âm hồi phục ~150ms (thay vì 1.5s).

### [2026-08-06 01:00] Task: Percussion track câm — program_select bank 128 thiếu preset → fallback preset hợp lệ
- **Báo cáo user:** track percussion chỉ nghe 1 âm đầu rồi câm; bật channel 10 → `[FluidSynth:err] There is no preset with bank number 128 and preset number 0 in SoundFont 2`.
- **Cơ chế:** track "latin hand perc" (sfId) mang bank 128 prog 0; `fluid_synth_program_select` trả -1 (preset không tồn tại trong font) → noteon trên preset rỗng = CÂM; channel cache vẫn ghi (128,0) → noteon sau skip re-select (progAlreadySet) → CÂM tiếp ("1 âm đầu" = note đầu còn preset cũ hợp lệ trước khi cache bị ghi đè).
- **FIX (soundfontPlayer.js doNote):** kiểm tra `_selRet !== 0 && finalBank === 128` → **fallback chuỗi preset trống phổ biến [128,48 (GM kit) → 0,0 → 128,1 → 0,48]** — chọn preset đầu tiên select thành công (trả 0) + cập nhật channel cache (finalBank/finalProg) → các note sau dùng preset hợp lệ. Lọc thêm error "There is no preset with bank number" khỏi console (printErr).
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060100 — chỉ hard refresh, không build precompiled), `wiki.md`.
- **Ghi chú/Test:** hard refresh → play track percussion → KỲ VỌNG: mọi note đều kêu (fallback preset), hết error spam.

### [2026-08-06 01:30] Task: Note vẽ mới trong piano roll nghe nhạc cụ track TRƯỚC — 2 lỗi preview
- **Báo cáo user:** track 4 percussion — click note vẽ từ trước = percussion ✓; VẼ note mới = nhạc cụ track 3.
- **2 lỗi:**
  (1) `_playNoteFallback` (soundfontPlayer dòng 664): `prog = _channels[channel].program || prog` — **override program track bằng cache channel** (track 3 cùng channel ghi đè) → oscillator preview mang character track 3. FIX: chỉ override khi `program === undefined`.
  (2) **Draw/brush preview dùng `_playNoteFallback` (oscillator beep)** thay vì `playNote` (FluidSynth — nhạc cụ thật) — click note cũ dùng playNote nên đúng. FIX app.jsx: cả 2 chỗ (brush ~7725, draw ~7819) → `playNote` với `resolveTrackInstrumentCtx` (ch/synthEngine của track).
- **Các file ảnh hưởng:** `soundfontPlayer.js` + `app.jsx` + `index.html` (?v=202608060130 cho cả 2), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → track percussion → VẼ note mới → KỲ VỌNG: percussion thật (không phải nhạc cụ track trước).

### [2026-08-06 02:00] Task: Percussion piano-roll play "1 âm đầu rồi câm" — quét preset hợp lệ toàn font (cache theo sfId)
- **Báo cáo user:** track 4 percussion — play preview trong piano roll: chỉ 1 âm đầu, các âm sau câm.
- **Cơ chế:** fallback v01:00 chỉ thử 4 preset cố định [128,48],[0,0],[128,1],[0,48] — font "latin hand perc" KHÔNG có preset nào → `finalBank/finalProg` giữ (128,0) → channel cache = (128,0) INVALID → noteon thứ 2: progAlreadySet = true (cache khớp (128,0)) → SKIP re-select → noteon trên preset rỗng = CÂM. Âm đầu = noteon trên preset DEFAULT của font (auto-assign khi load).
- **FIX (soundfontPlayer doNote):** khi `finalBank === 128` → **quét toàn bộ preset font: bank 128 0-127 + bank 0 0-127 (tối đa 256 program_select, probe qua channel 9)** — chọn preset đầu trả 0 → cache `_validPercCache[sfId]` → finalBank/finalProg = preset hợp lệ → select + noteon trên preset ĐÚNG → mọi note kêu. Cache 1 lần/font (lần sau không quét lại).
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060200 — chỉ hard refresh, không build), `wiki.md`.
- **Ghi chú/Test:** hard refresh → play track percussion trong piano roll → KỲ VỌNG: MỌI note kêu (không chỉ âm đầu).

### [2026-08-06 03:00] Task: BẢO ĐẢM mastering chain xử lí MỌI track (solo) + preview piano roll khi mastering ON
- **Yêu cầu user:** (1) track solo → luồng âm PHẢI qua mastering chain khi ON (âm to); (2) preview note MIDI trong piano roll PHẢI qua mastering chain khi ON.
- **Cơ chế cũ:** ♪ bypass (trackMidiBypassMap/trackAudioBypassMap) → routeGain=0/dryGain=1 → track bỏ qua chain — kể cả khi chain ON.
- **FIX (app.jsx):**
  (1) Helpers: `masteringChainOn()` + `effMidiBypass(track)`/`effAudioBypass(track)` — **chain ON → bypass luôn false (♪ bị override); chain OFF → theo ♪ maps**.
  (2) Áp tại: createMasteringRoute (audio route), getOrCreateTrackNode sfBypass (~18241), buildOfflineTrackNode (~1031), sync effect (~14538).
  (3) `[masteringSettings]` effect: sau toggle+apply → **re-sync live nodes** (sfRouteGain/sfDryGain + route.routeGain/dryGain theo effBypass) + updateSfRouting — PWR bật/tắt áp ngay lên node đang phát.
  (4) Preview piano roll: SF → sfEntry → sfRouteGain (=1 khi chain ON) → masterBus.input → chain ✓.
- **⚠️ Sửa hậu quả patch replace_all hỏng 3 vùng** (createMasteringRoute, sync effect, node creation — khôi phục đúng nguyên bản + áp helper đúng chỗ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060300), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track bất kỳ → âm qua chain (to); preview note trong piano roll → qua chain.

### [2026-08-06 03:30] Task: Solo track AI không qua mastering khi bỏ solo track 1 — ép route tại thời điểm routing
- **Báo cáo user:** solo track 1 + solo track AI → cả 2 qua mastering ✓; bỏ solo track 1 → track AI solo KHÔNG qua mastering ✗.
- **Phân tích:** 2 case khác nhau: >1 audible → SF fallback `setOutputDestination(null)` → masterBus.input → chain ✓; 1 audible → SF → node.sfEntry → sfMods → sfRouteGain → chain — nếu sfRouteGain bị 0 (dry — node tạo lúc mastering OFF / state stale) → KHÔNG qua chain. AI track template không có bypass field (sạch) — nên nguyên nhân là ROUTE STALE tại node.
- **FIX (app.jsx updateSfRouting):** ép `sfRouteGain/sfDryGain` theo `effMidiBypass` NGAY TẠI thời điểm routing — cả nhánh PIANO_ROLL + nhánh midiAudible single-track (belt-and-suspenders — không chỉ lúc tạo node). Sửa lỗi gọi effMidiBypass với trackId string → track object.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track 1 + solo track AI → bỏ solo track 1 → track AI phải VẪN qua mastering. Nếu vẫn lỗi → dán console (tìm `[Bypass]` + `setOutputDestination` + `updateSfRouting error`).

### [2026-08-06 04:00] Task: AI Var clone kế thừa midiChannel track gốc → solo bị nhỏ (CC7 collision)
- **Báo cáo user:** track do USER chèn → solo âm bình thường; track do AI prompt chèn (AI Var) → solo âm NHỎ (nút solo con của track gốc). Yêu cầu kiểm tra quá trình AI clone.
- **Cơ chế (dòng 23042):** `const newTrack = { ...(srcTrack || {}), ... }` — clone AI Var spread TOÀN BỘ track gốc → **kế thừa `midiChannel`** → clone + track gốc DÙNG CHUNG channel. Solo track gốc (hoặc clone) → sync effect gửi `controllerChange(ch, 7, audible ? 100 : 0)` — track bị solo-mute (cùng channel) nhận CC7=0 → **notes của clone (cùng channel) cũng bị CC7=0 → âm NHỎ/CÂM**.
- **FIX (dòng 23046):** thêm `midiChannel: undefined` vào clone — `ensureTrackMidiChannel` (đã có guard) cấp channel RIÊNG (loop 0-15 skip 9). Kiểm tra: chỉ 1 chỗ spread srcTrack (23043) — AI composition dùng template sạch ✓.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060400), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → tạo [AI Var] từ track → solo track AI Var → âm PHẢI bình thường (không nhỏ); solo track gốc → AI Var không bị ảnh hưởng.

### [2026-08-06 04:30] Task: Tab deactive → stop âm của tab đó (piano-roll tiếp tục kêu khi quay main)
- **Báo cáo user:** piano roll tab đang play → quay về MAIN/SECTION-TAB → VẪN nghe âm piano roll. Yêu cầu: tab nào deactive → stop âm tab đó.
- **FIX (app.jsx effect [activeTab] ~14571):** `prevActiveTabRef` lưu tab trước; khi đổi tab → nếu tab CŨ là sub-tab (PIANO_ROLL/section/audio) đang `isPlaying` → `stopAllPlayback()` + set `isPlaying: false` cho tab đó. Tab MỚI không bị ảnh hưởng; MAIN giữ hành vi cũ (mở piano-roll lúc main play → main tiếp tục — handleEditMidiInTab đã xử lý).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060430), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → piano-roll play → bấm tab MAIN → âm piano-roll phải DỪNG ngay; play main → mở piano-roll → main tiếp tục (hành vi cũ).

### [2026-08-06 05:00] Task: Project không mở được khi đăng nhập máy khác/ẩn danh — 2 fix cross-machine
- **Báo cáo user:** login máy khác / browser ẩn danh → không mở được project đã tạo trước đó.
- **Chẩn đoán:** backend cloud key ĐÚNG theo user_id (JWT) ✓; OpenProjectModal có tab Cloud (list per-user) + Local (localStorage MÁY-ĐỊNH XỨ). Vấn đề: (1) `restoreLastSessionProject` đọc `sonic_project_id` từ localStorage — máy mới → trống → không restore gì; (2) project lưu LOCAL (`local_` id — chưa login lúc save) → localStorage → machine-bound.
- **FIX (app.jsx):**
  (1) `restoreLastSessionProject`: `!lastId` (máy mới) + có profile (currentUser hoặc `localStorage sonic_user`) → **tự mở project Cloud GẦN NHẤT** (listCloudProjects → [0] → getCloudProject → restore). Local id có sẵn → hành vi cũ.
  (2) `handleSaveLocalProject`: đã login → **đồng bộ lên Cloud (fire-and-forget saveCloudProject)** — project mở được từ máy khác.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060500), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → login máy mới/ẩn danh → tự mở project Cloud gần nhất. Lưu local khi login → xuất hiện trong Cloud tab ở máy khác. Project local CŨ (tạo trước fix) → mở trên máy cũ + lưu lại → sync.

### [2026-08-06 05:30] Task: Incognito instrument câm — font KHÔNG còn trên server (chỉ trong IndexedDB browser thường)
- **Báo cáo user:** load project cũ ở browser ẩn danh → instrument không có âm; browser thường → OK.
- **Chẩn đoán:** soundfont (SGM-V2.01, latin hand perc) load từ IndexedDB cache → incognito cache RỖNG → fetch `/api/v1/plugins/soundfonts/download/{sfId}` → **404 — font KHÔNG còn trên server** (upload dir chỉ còn weedsgm3/518e850f; static/soundfonts rỗng; SYSTEM_SF_DIR không tồn tại). Browser thường: IndexedDB đã cache (từ lúc font từng tồn tại server) → không fetch → OK.
- **FIX:**
  (1) Backend `app/api/v1/plugins.py` download endpoint: thêm `static/soundfonts` vào danh sách thư mục tìm (font bundled).
  (2) Frontend `soundfontPlayer.js` loadSoundFont: fetch API download fail → **fallback `/soundfonts/{sfId}`** (route tĩnh).
- **ĐIỀU KIỆN ĐỦ:** font PHẢI tồn tại trên server — user cần đặt file `SGM-V2.01.sf2/.sf3` + `latin hand perc.sf2/.sf3` vào `app/storage/soundfonts/` (hoặc upload qua UI) → mọi máy/browser fetch được.
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `soundfontPlayer.js` (?v=202608060530 — hard refresh), `wiki.md`. Backend cần restart.
- **Ghi chú/Test:** đặt font vào storage/soundfonts → restart backend → hard refresh → incognito load project → instrument có âm.

### [2026-08-06 06:00] Task: Incognito log xác nhận font vẫn 404 + fix crash onMouseMove (guard clip.buffer)
- **Log incognito (v0530):** `soundfont not loaded yet, loading: SGM-V2.01` ×17 — load thất bại liên tục = font VẪN không có trên server (404). Kèm `Uncaught TypeError: Cannot read properties of undefined (reading 'duration')` onMouseMove — guard clip.buffer bị mất theo commit user 55d3464.
- **FIX (app.jsx):** re-apply guard `c.buffer` cho 5 chỗ `.buffer.duration` (rightEdgeClip ×2, hoveredClip, clickedClip ×2) — hết crash khi clip không có buffer (audio file load fail ở incognito).
- **ĐIỀU KIỆN CẦN (chưa đủ — user PHẢI thực hiện):** đặt file font (SF2 — export từ cache browser thường hoặc copy từ production /opt/daw_engine/soundfonts) vào `app/storage/soundfonts/` — dev instance KHÔNG có ffmpeg → KHÔNG dùng được .sf3 (cần .sf2). Fix code endpoint + fallback đã vào (v0530) — chỉ có tác dụng khi file tồn tại server-side.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060600), `wiki.md`. Rebuild precompiled.

### [2026-08-06 06:30] Task: Note bị DROP khi font không tải được (incognito) → fallback oscillator + cooldown 10s
- **Xác nhận hypothesis user:** "incognito bỏ qua bước FluidSynth WASM" — đúng cơ chế: doNote `if (finalSfId && !_sfHandleMap.has(finalSfId))` → loadSoundFont → `if (ok) doNote();` — **load FAIL (font 404) → note bị DROP âm thầm → WASM không nhận noteon → CÂM.** Incognito: cache rỗng → fetch 404; browser thường: cache có → ok.
- **FIX (soundfontPlayer.js doNote):**
  (1) Load fail → **`_playNoteFallback` (oscillator — CÓ ÂM thay vì câm lặng)** + `_sfLoadFailAt[sfId]` timestamp.
  (2) **Cooldown 10s**: sau fail, các note tiếp theo chạy thẳng fallback (không spam fetch 404 mỗi note).
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060630 — hard refresh, không build), `wiki.md`.
- **Ghi chú/Test:** hard refresh → incognito play track font chưa có → NGHE ĐƯỢC fallback (beep theo pattern — không câm). Vẫn khuyến nghị đặt font thật (SGM-V2.01.sf2...) để có âm thật.

### [2026-08-06 07:00] Task: Bundle mới KHÔNG load ở incognito — index.html cache heuristic (thiếu Cache-Control)
- **Báo cáo user:** re-compile + rebuild docker nhưng incognito vẫn không load bundle mới (URL cũ).
- **Xác minh production (daw.labz.io.vn):** index.html ĐÃ serve `soundfontPlayer?v=202608060630` + `precompiled?v=202608060600` — bundle MỚI NHẤT (precompiled chứa 9 markers fix: nanOut/effMidiBypass/prevActiveTabRef). **Production ĐÚNG** — vấn đề: **incognito dùng index.html CACHED CŨ** (stamp cũ → URL bundle cũ). Server không gửi Cache-Control → browser cache heuristic → HTML cũ.
- **FIX (app/main.py):** index.html (`/`) thêm `Cache-Control: no-cache, no-store, must-revalidate` — HTML luôn mới, bundle JS bust bằng ?v=.
- **Các file ảnh hưởng:** `app/main.py`. Cần rebuild docker + restart.
- **Ghi chú/Test:** sau khi deploy: incognito (đóng + mở lại tab — hoặc Ctrl+Shift+R 1 lần) → load trang → bundle mới. Verify: console thấy stamp mới.

### [2026-08-06 07:30] Task: PIANO ROLL TAB — Humanize + Transpose (có undo)
- **Yêu cầu user:** cài đặt tính năng Humanize (midi note) + Transpose (chuyển giọng) trong piano roll tab.
- **FIX (app.jsx PianoRollTabEditor):**
  (1) `applyHumanize()` — random velocity ±8% + start_beat ±0.015 beat (~12ms @120bpm), clamp 0.05-1.0/≥0 — pushToUndo trước khi đổi.
  (2) `applyTranspose(semi)` — dịch pitch ±s semitone, clamp 0-127 — pushToUndo trước khi đổi.
  (3) Toolbar: nút **🎚 Humanize** (sau nút Ghost) + nhóm **input semitone + nút Transpose** (trước nút Lưu). State `transposeSemis` local.
  (4) Cả 2 đều qua `pushToUndo(notes)` → Ctrl+Z/Ctrl+Shift+Z hoạt động (undo stack local của piano roll).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060730), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → bấm Humanize (nghe velocity/timing đổi) → Transpose +2 (nghe cao hơn 1 tone) → Ctrl+Z hoàn tác.

### [2026-08-06 08:00] Task: Humanize có cường độ (Nhẹ/Vừa/Mạnh) + Transpose theo SCALE (12 tông major/minor)
- **Yêu cầu user:** tùy chỉnh lượng humanize (mạnh/nhẹ) + transpose theo scale major/minor đủ 12 tông.
- **FIX (app.jsx PianoRollTabEditor):**
  (1) Humanize: `humanizeStrength` state (0.05 Nhẹ / 0.10 Vừa / 0.18 Mạnh) + select trong toolbar — velocity ±strength, timing ±strength*0.15 beat.
  (2) Transpose theo SCALE: `SCALE_PATTERNS` (major [0,2,4,5,7,9,11], minor [0,2,3,5,7,8,10]) + `SCALE_ROOTS` (12 tông) + **`detectKey()` auto-detect key nguồn** (best-fit root+scale theo pitch class) + `applyTransposeToKey()` map **degree → degree** (nốt về bậc gần nhất trong scale nguồn → shift sang bậc tương ứng scale đích, ±6 clamp octave).
  (3) Toolbar: `[🎚 Humanize] [Nhẹ|Vừa|Mạnh] [semis|Transpose] [C..B][major|minor][🎵 Chuyển giọng]` — đều qua pushToUndo (Ctrl+Z hoạt động).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060800), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → piano roll → Humanize Mạnh vs Nhẹ; Chuyển giọng C major → D minor (map degree — melody giữ hình dạng) → Ctrl+Z.

### [2026-08-06 08:30] Task: Toolbar piano roll 2 hàng — nhóm nút chỉnh sửa note sang hàng mới
- **Yêu cầu user:** thêm hàng toolbar mới, di chuyển nút tính năng tương tự sang hàng mới.
- **FIX (app.jsx):** header toolbar đổi `h-10 flex items-center justify-between` → `flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5` + **row-break spacer** (`flexBasis:100%, height:0`) trước nút Humanize.
  - **Hàng 1:** track select, Snap to Scale, Snap, ARM, MIDI Input, Instrument, AI bar range, CC mode/CC, Session/Isolated, Ghost.
  - **Hàng 2:** 🎚 Humanize + [Nhẹ|Vừa|Mạnh], ±semis Transpose, [C..B][major|minor] 🎵 Chuyển giọng, Lưu, Export, Đóng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060830), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → thấy 2 hàng toolbar; nút edit note (Humanize/Transpose/Chuyển giọng) ở hàng 2.

### [2026-08-06 09:30] Task: Fix emoji double-escape — nút "MIDI ghost notes" hiện text \uD83D\uDC7B
- **Báo cáo user:** nút MIDI ghost notes hiện ký tự lạ "\uD83D\uDC7B" (text literal thay vì 👻).
- **Nguyên nhân:** file app.jsx có `"\\uD83D\\uDC7B MIDI ghost notes"` (escape KÉP → runtime render text literal).
- **FIX:** thay bằng emoji thật `"👻 MIDI ghost notes"`. Kiểm tra: các emoji khác (Humanize 🎚, Chuyển giọng 🎵, Session 🌐/Isolated 📋, Nhẹ/Vừa/Mạnh) đều single-escape ✓ — chỉ nút Ghost bị lỗi.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060930), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → nút hiện "👻 MIDI ghost notes".

### [2026-08-06 10:00] Task: Auto-detect scale khi mở midi item → hiển thị ở dropdown chuyển giọng
- **Yêu cầu user:** mở midi item trong piano roll → detect scale + hiển thị ở dropdown scale chuyển giọng.
- **FIX (app.jsx):** effect trong PianoRollTabEditor — key `[st.target_id]` (item id): khi mở item → `detectKey(st.notes)` → `setKeyTargetRoot/KeyTargetScale` = giọng detected → dropdown chuyển giọng hiển thị đúng giọng của item. Sửa note cùng item KHÔNG reset (target_id không đổi); mở item khác → detect lại.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061000), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mở midi item → dropdown hiện giọng detected (vd D minor) → bấm Chuyển giọng sang giọng khác → Ctrl+Z.

### [2026-08-06 10:30] Task: Toolbar — Đóng sang cuối hàng TRÊN (phải), Lưu + Export MIDI cuối hàng DƯỚI (phải)
- **Yêu cầu user:** move Lưu/Export (đổi tên → Export MIDI) cuối hàng bên phải; Đóng → cuối hàng trên bên phải.
- **FIX (app.jsx):** nút Đóng chuyển từ cuối toolbar → SAU nút MIDI ghost notes (hàng 1) + `ml-auto` (đẩy phải); nhóm Lưu/Export giữ cuối hàng 2 + `ml-auto`; Export → **Export MIDI**. Emoji 🎵 Chuyển giọng bị patch tool double-escape → sửa bằng emoji thật (verify: 0 double-escape còn lại).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061030), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → hàng 1 phải có [Đóng]; hàng 2 phải: [Lưu] [Export MIDI] ở cuối bên phải.

### [2026-08-06 11:00] Task: Ẩn gợi ý đăng nhập sau lần đăng nhập đầu (username/password hints + nút Điền nhanh)
- **Yêu cầu user:** sau lần đăng nhập hệ thống đầu tiên → xóa gợi ý: "(Tùy chọn - Admin có thể bỏ trống)", "(Lần đầu: admin123)", nút "🔑 Điền Nhanh Tài Khoản Admin".
- **FIX:**
  (1) Backend `auth.py`: endpoint **`GET /api/v1/auth/first-time`** → `{first_time: bool}` — true khi admin vẫn dùng mật khẩu mặc định (verify_password admin123 + must_change_password); sau khi đổi → false.
  (2) Frontend AuthModal: state `firstTime` + fetch khi modal mở; 3 gợi ý render có điều kiện `firstTime &&` — hết lần đầu → ẩn vĩnh viễn.
- **Các file ảnh hưởng:** `app/api/v1/auth.py`, `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061100), `wiki.md`. Rebuild precompiled + restart backend.
- **Ghi chú/Test:** restart backend → login admin (mật khẩu còn mặc định) → vẫn thấy gợi ý; đổi mật khẩu → logout → login lại → KHÔNG còn gợi ý/nút Điền nhanh.

### [2026-08-06 11:30] Task: Gợi ý đăng nhập vẫn hiện sau khi đổi mật khẩu — fail-closed (ẩn khi endpoint lỗi)
- **Báo cáo user:** đã đổi mật khẩu nhưng gợi ý vẫn xuất hiện.
- **Nguyên nhân nghi:** backend chưa restart → `/api/v1/auth/first-time` 404 → catch cũ `setFirstTime(true)` → gợi ý hiện (fail-open sai hướng).
- **FIX (app.jsx AuthModal):** catch → `setFirstTime(false)` + else branch → fail-closed: endpoint lỗi/404 → **ẨN gợi ý**. Endpoint OK: `verify_password("admin123", hash mới)` = false → first_time=false → ẩn (đã đúng logic backend).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061130), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → login → gợi ý ẩn (kể cả khi backend chưa restart). Khuyến nghị restart backend để endpoint hoạt động đúng.

### [2026-08-06 12:00] Task: New project — reset toàn bộ (đóng tab, clear selection, reset instrument context)
- **Yêu cầu user:** tạo new project → đóng hết tab đang mở, clear selection, reset instrument đã load về mặc định.
- **FIX (app.jsx — 2 nơi: menu File → New Project + Ctrl+N):**
  - `stopAllPlayback()` (dừng mọi play)
  - `setSubTabs([])` + `setSessionTabs([])` + `setActiveTab('main')` (đóng hết tab)
  - `setSelectedItemIds(new Set())` (clear multi-selection)
  - Reset context instrument: `trackMidiChannelsRef.current = {}` + clear `trackMidiBypassMap`/`trackAudioBypassMap`/`trackMasteringBypassMap` (delete từng key — const map)
  - Reset project: 2 track mặc định, selectedTrackId '1', projectName '', currentProjectId null, xóa localStorage id/name
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061200), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → mở vài tab + chọn track → File → New Project (hoặc Ctrl+N) → tab đóng hết, selection trống, instrument track về mặc định.

### [2026-08-06 12:30] Task: SECTION-TAB 5 bug — Ctrl+S, strip controls, undo, section play, mute isolation
- **Báo cáo user (SECTION-TAB):** (1) Ctrl+S phải lưu section; (2) volume slider + nút strip không tác dụng; (3) không undo/redo cut/paste/delete/move; (4) MAIN play section item câm sau khi lưu; (5) mute ở section mixer mute luôn track MAIN.
- **FIX:**
  (1) **Ctrl+S**: handler đầu (15915) save project vô điều kiện + return → block thứ 2 (16007) chết → sửa handler đầu tab-aware: `session_` → `handleSaveSectionTab` + toast; khác → save project.
  (2+5) **Handlers setTracks (MAIN) → updateActiveTracks (context-aware)**: `toggleTrackMute`, `toggleTrackSoloEvaluate`, `updateTrackVolumeDb`, `updateTrackPan`, `updateTrackProp` — section strip giờ tác dụng lên track SECTION (không còn mute track MAIN).
  (3) **Undo section**: chuỗi đã context-aware (captureTrackSnapshot/captureAllTracksSnapshot → activeTracks; restore applyTrackState → updateActiveTracks) — sau fix (2) các entry mute/volume đúng context; item ops (delete/move/paste 20417/20603/20764+) đã push đúng.
  (4) **Section play**: code path đã verify (18847-18957: schedule clip+midi sub-track → subNode → parent → route → mastering; sec.tracks lưu sau save 16381-16395) — nếu vẫn câm cần log console (`[SonicSF]` noteon + sfId).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061230), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section tab: Ctrl+S lưu section; kéo volume/mute tác dụng trong section; Ctrl+Z sau cut/paste; main play section item có âm; mute section không ảnh hưởng main.

### [2026-08-06 13:00] Task: SECTION-TAB volume/mute — áp gain cho sub-node (key _sub_) realtime
- **Báo cáo user:** volume mixer strip không tác dụng âm thanh; mute section phải CHỈ ảnh hưởng section.
- **Nguyên nhân:** node play section là **sub-node** (key `<parent>_sub_<trackId>` — 18697) — gain chỉ set lúc TẠO node (18703) — `updateTrackVolumeDb`/`applyAllTrackMuteSolo` lookup `activeTrackNodesRef[trackId]` (key thường) → KHÔNG thấy sub-node → volume/mute đổi STATE nhưng âm section không đổi.
- **FIX (app.jsx):**
  (1) `updateTrackVolumeDb`: sau khi set gain node thường → **scan `_sub_<trackId>`** → set gain sub-node realtime.
  (2) `applyAllTrackMuteSolo`: sau `setTrackNodeGain(node, ...)` → **scan `_sub_<t.id>`** → set gain sub-node theo audibleGain (mute/solo section áp node play section — activeTracksRef = section tracks → KHÔNG đụng track MAIN).
  (3) (Đã fix vòng trước) handlers setTracks → updateActiveTracks — mute/volume chỉ đổi tracks SECTION.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061300), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section tab: kéo volume nghe đổi ngay; mute section track → câm trong section, main không bị; unmute lại.

### [2026-08-06 13:30] Task: Mixer fader dùng updateTrackProp — volume section vẫn không đổi âm
- **Báo cáo user:** volume slider SECTION-TAB mixer KHÔNG đổi âm lượng.
- **Nguyên nhân:** mixer strip (TrackStripConsole — 27165) truyền `onUpdateTrack: updateTrackProp` — fader (1560) gọi `onUpdateTrack(tid, {volumeDb})` — fix vòng 13:00 đặt scan sub-node trong `updateTrackVolumeDb` (TCP slider) — **mixer dùng updateTrackProp — SAI CHỖ → không áp gain node.**
- **FIX (app.jsx):** `updateTrackProp` — khi props có `volumeDb` → áp gain realtime node thường + scan `_sub_<trackId>` (sub-node section) — mixer section + mixer main đều đổi âm ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section tab mixer: kéo volume → âm section đổi ngay (cả khi main đang play section item); main mixer cũng đổi ngay.

### [2026-08-06 14:00] Task: SECTION-TAB — volume MIDI track qua CC7 (channel volume) + fix collision channel
- **Báo cáo user:** AI tạo track trong SECTION dùng chung channel track trước → volume track sau vô tác dụng cho đến khi track trước MUTE.
- **Nguyên nhân:** CC7 (channel volume) chỉ set 100/0 (mute/unmute) — VOLUME track KHÔNG áp qua channel → track MIDI dùng chung channel: CC7 cuối thắng → volume 1 track không tác dụng.
- **FIX (app.jsx — 2 chỗ):**
  (1) `applyAllTrackMuteSolo` (14135): CC7 = **volume track** (audible ? volLin*100 clamp 1-127 : 0) — không còn 100/0 cứng.
  (2) Sync effect (14719): cùng logic CC7-volume — volume slider → trackMuteSoloSig (gồm volumeDb) → effect → CC7 mới.
  (3) (Đã có) AI Var clone `midiChannel: undefined` (23288) + `ensureTrackMidiChannel` tự cấp channel riêng khi trùng (30: `!inUse.has(track.midiChannel)`) — collision được xử lý khi assign.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061400), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: tạo AI Var mới → kéo volume từng track → âm đổi theo track; mute track trước → track sau vẫn đúng volume. Nếu track AI Var CŨ (tạo trước fix midiChannel) vẫn dùng chung channel → xóa + tạo lại.

### [2026-08-06 14:30] Task: Nút A (audio bypass) trên section strip không auto-áp — sub-node thiếu route
- **Báo cáo user:** nút A xám (bypass on) nhưng audio không bypass — phải bấm 2 lần mới có tác dụng.
- **Nguyên nhân:** sub-node section (key `<parent>_sub_<subId>`) chỉ có gainNode → parent — **KHÔNG có route (routeGain/dryGain)** → `__setTrackBypass` scan node theo `indexOf(trackId + '_sub_')` (sai key — sub-node key bắt đầu bằng PARENT id) → nút A không áp được; node play section không có gì để toggle.
- **FIX (app.jsx):**
  (1) `getOrCreateSubTrackNode`: thêm **route riêng** cho sub-track — `routeGain` (mastering path → parent) + `dryGain` (→ masterBus.dryInput) — gain khởi tạo theo `effAudioBypass(subTrack)` — nút A áp ngay từ lúc tạo node.
  (2) `__setTrackBypass` (audio): scan `k === trackId || k.endsWith('_sub_' + trackId)` (đúng key sub-node của track đó) → `setMasteringRoute` realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061430), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: nút A track audioclip → xám = bypass ngay (nghe hết mastering); sáng = qua mastering.

### [2026-08-06 15:00] Task: Nút A section — route tạo node dùng effAudioBypass bị override mastering-ON → bypass mất khi stop+play
- **Báo cáo user:** A mặc định tắt → play không bypass; bấm A ON rồi OFF mới bypass; stop + play lại → NOT BYPASS.
- **Nguyên nhân:** sub-node route tạo bằng `effAudioBypass(subTrack)` — hàm này override `false` khi mastering ON (yêu cầu v03:00) → route luôn no-bypass lúc TẠO node → phải bấm A 2 lần (__setTrackBypass set route trực tiếp); stop → node bị xóa/tái tạo → route lại ép false → mất bypass.
- **FIX (app.jsx getOrCreateSubTrackNode):** dùng **map THẬT** `trackAudioBypassMap[subTrack.id]` (trạng thái nút A) — không qua effAudioBypass → route tạo theo nút A; stop+play → node tái tạo → route đúng trạng thái nút A.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061500), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: bật A (xám) → play → bypass ngay; stop + play lại → VẪN bypass; tắt A (sáng) → qua mastering.

### [2026-08-06 15:30] Task: SECTION-TAB audioclip PHẢI bypass mặc định — addNewTrack context-aware (A xám)
- **Yêu cầu user:** nút A mặc định tắt nhưng audioclip item PHẢI bypass khi play; stop + play lại không được mất bypass.
- **FIX (app.jsx addNewTrack 21440):** `audioBypass: isSectionCtx ? true : false` — activeTab `session_` → track mới trong SECTION mặc định **BYPASS** (nút A xám, audio raw); MAIN giữ false (qua mastering).
- **Kết hợp vòng trước:** sub-node route dùng map THẬT (không override mastering-ON) → route đúng nút A; stop+play → node tái tạo → route đúng trạng thái.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061530), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: tạo track mới + drop audioclip → play → BYPASS ngay (A xám); stop + play lại → VẪN bypass. Track CŨ (tạo trước fix, audioBypass false) → bấm A 1 lần (xám) → từ đó bypass đúng.

### [2026-08-06 16:00] Task: SECTION-TAB bypass rules — nút A (audio) + nút ♪ (midi) áp đúng cho sub-node
- **Yêu cầu user:** nút A tắt → bypass Audioclip; A bật → không bypass; nút ♪ tắt → midi bypass; ♪ bật → midi không bypass.
- **FIX (app.jsx):**
  (1) `getOrCreateSubTrackNode`: subBypass theo LOẠI item của sub-track — có CLIPS → `trackAudioBypassMap` (nút A); có MIDI → `trackMidiBypassMap` (nút ♪) — route tạo đúng trạng thái nút.
  (2) `__setTrackBypass` nhánh 'midi': **scan sub-node** (`endsWith('_sub_'+trackId)`) → `setMasteringRoute` — nút ♪ trên section strip toggle route sub-node MIDI ngay.
- **Kết hợp:** sub-node route (routeGain→parent / dryGain→masterBus.dryInput) — bypass → dry (raw); không bypass → qua mastering.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061600), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: track audioclip — A tắt → bypass, A bật → qua mastering; track MIDI — ♪ tắt → bypass, ♪ bật → qua mastering. Stop+play lại → trạng thái giữ nguyên.

### [2026-08-06 16:30] Task: ReferenceError handleOpenProject — hàm chỉ tồn tại trong ProfileModal, App render reference sai scope
- **Báo cáo user:** bundle 061600 không mở/reload lại dự án — console: `Uncaught ReferenceError: handleOpenProject is not defined`.
- **Nguyên nhân:** `handleOpenProject` (5624) nằm BÊN TRONG component ProfileModal — nhưng OpenProjectModal (File → Open Project) nhận `onOpenCloud` closure ở **App render** (27727: `handleOpenProject(projId, projName)`) — reference ngoài scope → ReferenceError khi bấm Mở.
- **FIX (app.jsx):** thêm **App-level `handleOpenProject`** (trước render) — logic: getCloudProject → deserializeProjectFromSchema → setTracks/mastering/sessionTabs/subTabs + localStorage — giống ProfileModal. ProfileModal giữ bản riêng (tự scope).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061630), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → File → Open Project → bấm MỞ → dự án nạp + reload lại đúng.

### [2026-08-06 17:00] Task: Restore project stack overflow — guard cycle trong deserializeTracksList (SECTION_ITEM tự trỏ)
- **Báo cáo user:** reload page → `restoreLastSessionProject failed: RangeError: Maximum call stack size exceeded`.
- **Nguyên nhân:** `deserializeTracksList` (9023) → SECTION_ITEM (9059) → gọi lại `deserializeTracksList(secContainer.tracks,...)` — section item trỏ về section ĐANG deserialize (hoặc chu trình A→B→A) → recursion VÔ HẠN → stack overflow.
- **FIX (app.jsx deserializeTracksList):**
  (1) **Visited-set**: truyền `visitedSections` xuống; SECTION_ITEM trỏ về section có trong set (cycle) → `tracks: null` (không đệ quy tiếp).
  (2) **Depth guard**: `curDepth > 12 → return []` — chặn lồng sâu bất thường.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061700), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → reload page → dự án restore bình thường (section tự trỏ → deserialize an toàn, tracks null thay vì crash).

### [2026-08-06 17:30] Task: GỠ override mastering-ON — nút A/♪ quyết định bypass (rules SECTION-TAB)
- **Báo cáo user:** rules bypass vẫn chưa áp được — log: `[Bypass] node created track 1 initial audioBypass= true routeGain= 1 dryGain= 0` — bypass TRUE nhưng routeGain=1 (INVERTED).
- **Nguyên nhân:** `createMasteringRoute` dòng 142 `if (masteringChainOn()) bypass = false;` + `effAudioBypass/effMidiBypass` override mastering-ON (yêu cầu v03:00) → khi mastering ON, route LUÔN ép qua chain → nút A/♪ bị nuốt.
- **FIX (app.jsx):** GỠ override ở 3 chỗ: `createMasteringRoute` + `effMidiBypass` + `effAudioBypass` — **NÚT A/♪ quyết định bypass, không phụ thuộc mastering ON** (rules hiện tại của user thắng yêu cầu cũ v03:00).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061730), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → SECTION-TAB: A tắt (xám) → routeGain=0/dryGain=1 (bypass) ngay lần play đầu; A bật → qua mastering. Log `[Bypass] node created ... audioBypass= true routeGain= 0 dryGain= 1` ✓.

### [2026-08-06 18:00] Task: Copy/Paste notes giữa các PIANO ROLL TAB (clipboard)
- **Yêu cầu user:** copy midi note từ PIANO ROLL TAB này sang tab khác.
- **FIX (app.jsx):**
  (1) App: state `pianoRollClipboard` + prop `onCopyNotes`/`clipboardNotes` cho PianoRollTabEditor (deep-copy khi copy).
  (2) Editor toolbar (hàng 2): nút **📋 Copy** (toàn bộ notes → clipboard + toast) + **📥 Paste** (notes clipboard → append cuối tab hiện tại, sinh id MỚI `note_cp_*`, `pushToUndo` → Ctrl+Z hoàn tác).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061800), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → tab A: Copy → mở tab B → Paste → notes xuất hiện; Ctrl+Z hoàn tác. Verify: 0 double-escape emoji.

### [2026-08-06 18:30] Task: NotSupportedError createBuffer 0 frames — guard Math.max(1) cho mọi createBuffer
- **Báo cáo user:** `Uncaught NotSupportedError: Failed to execute 'createBuffer' ... frames (0) less than or equal to minimum bound (0)`.
- **Nguyên nhân:** các op audio (sub-tab cut/copy/paste, normalize/fade/gain) tính `len`/`data.length` = 0 (vùng chọn rỗng hoặc buffer 0 frame) → `createBuffer(..., 0, ...)` → throw.
- **FIX (app.jsx — guard `Math.max(1, ...)`):** sub-tab Cut (15637/15644), Copy (15672), Paste (15727), clonedBuffer ×3 (15554/15577/15601). Chỗ 11802 đã guard sẵn.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061830), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → thao tác cut/copy audio vùng chọn rỗng → hết crash.

### [2026-08-06 18:45] Task: Section block MAIN không vẽ inner items preview (items chỉ thuộc SECTION-TAB)
- **Báo cáo user:** open project → các item của SECTION-TAB xuất hiện như clip/midi trên MAIN SESSION (ngoài block) — muốn sửa.
- **Chẩn đoán:** file .sfs SẠCH (main track chỉ SECTION_ITEM; section_store chứa nội dung ✓) — KHÔNG leak dữ liệu. Thủ phạm = **RENDER**: main timeline vẽ **preview inner items** bên trong section block (2492-2557 — sub-tracks + waveform + note bars) → trông như "items tải vào MAIN".
- **FIX (app.jsx render):** GỠ preview sub-tracks — section block trên MAIN chỉ hiện **tên + màu + bounds**. Dữ liệu `sec.tracks` VẪN giữ (play section item + SECTION-TAB không ảnh hưởng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061845), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → open Section_test → MAIN chỉ thấy block section (tên + màu), không còn item bên trong; mở SECTION-TAB → nội dung đầy đủ; play section item → vẫn có âm.

### [2026-08-06 19:00] Task: Audioclip trong section mất sau save+open — loadAudioBuffersForTracks không nạp clip bên trong section
- **Báo cáo user:** project MAIN chỉ có 1 section item (trong section: 1 midi + 1 audioclip). Sau khi lưu (ghi đè) + mở lại → audioclip bị mất, chỉ còn khung "(audio chưa được tải)".
- **Nguyên nhân:** `loadAudioBuffersForTracks` (14841) chỉ nạp `t.buffer` + `t.clips` (cấp MAIN track) — **KHÔNG đệ quy vào section item's `tracks`** → audioclip bên trong section không bao giờ được fetch/decode → buffer null → "(audio chưa được tải)".
- **FIX (app.jsx):** thêm vòng lặp section — `t.sections[].tracks[].clips` — `tryLoad(serverFileId)` mỗi clip → buffer; trả về `sections: updatedSections` trong cả 2 nhánh return.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061900), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → save project → mở lại → audioclip trong section hiện đầy đủ (hết "(audio chưa được tải)"); play section → có âm cả midi + audio.

### [2026-08-06 19:30] Task: Lưu zoom vào project + cho xóa item lỗi/rỗng (khung viền)
- **Yêu cầu user:** (1) lưu zoom khi save + khôi phục khi mở; (2) cho phép xóa item lỗi (khung viền "audio chưa được tải"/item rỗng/dữ liệu lỗi).
- **FIX (app.jsx):**
  (1) **Zoom**: App effect `window.__currentZoom = zoom` → serialize metadata thêm `zoom` → deserialize trả `zoom` → restore + open (2 chỗ) `setZoom(result.zoom)`.
  (2) **Guard xóa track** (2 chỗ 17124/20131): item LỖI/rỗng không chặn — `hasClips = clips.some(c => c.buffer)` (clip không buffer = khung viền → không tính); `hasMidi = midiItems.some(m => m.notes?.length)`; `hasSections = sections.some(s => s.tracks?.some(st => st.clips?.some(c => c.buffer) || st.midiItems?.some(m => m.notes?.length)))` — track chỉ chứa item lỗi → xóa được.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061930), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → zoom vào 2x → save → mở lại → zoom giữ 2x. Xóa track chỉ chứa item "(audio chưa được tải)" → xóa được.

### [2026-08-06 19:45] Task: Khôi phục preview items trong section block (sau mở project)
- **Báo cáo user:** sau khi mở lại dự án, section item không vẽ lại các item chứa trong nó.
- **FIX (app.jsx render 2491):** khôi phục preview sub-tracks trong section block (waveform bars + midi note bars) — bị gỡ ở 18:45. Kết hợp fix 19:00 (loadAudioBuffersForTracks đệ quy nạp buffers section) → preview hiện đầy đủ sau open.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061945), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → open project → section block hiện waveform + note bars của items bên trong.

### [2026-08-06 20:00] Task: Audioclip chèn trong SECTION-TAB không lưu — race upload + track buffer-only bị lọc
- **Báo cáo user:** audioclip chèn trong SECTION-TAB không được lưu.
- **Nguyên nhân:** `handleSaveSectionTab` lọc `contentTracks = tracks.filter(tr => tr.clips?.length > 0 || ...)` — track vừa chèn audio (upload async chưa tạo clip — hoặc buffer-only) bị LỌC → audioclip không vào section item → save mất.
- **FIX (app.jsx handleSaveSectionTab):**
  (1) Filter thêm `|| !!tr.buffer` — gồm track buffer-only.
  (2) Buffer-only → tạo **default clip** (`default_<id>` — buffer, startTime, name) → serialize bắt được AUDIO_ITEM.
  (3) Serialize đã fallback `c.serverFileId || t.serverFileId` (8959) ✓ — file id lấy từ track nếu clip chưa có.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062000), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: chèn audioclip → lưu section → save project → mở lại → audioclip còn đầy đủ (có âm).

### [2026-08-06 20:30] Task: Watchdog SECTION-TAB — chỉ rebuild khi NaN (hết playhead reset khi play)
- **Báo cáo user:** audioclip mất → di chuyển playhead đến vị trí đó nhấn play → playhead về đầu track + không play.
- **Nguyên nhân:** watchdog (18168) — section tab không phải piano roll → nhánh `pk < 0.001` — audioclip mất/rest tự nhiên → im lặng 750ms → recovery `stopAllPlayback` (playhead reset 0) + restart → "playhead về đầu + không play".
- **FIX (app.jsx):** thêm `isSectionTab` (`activeTab` bắt đầu `session_`) → nhánh `(isPianoRoll || isSectionTab ? nanOut : ...)` — section tab chỉ rebuild khi output NaN (chain chết), im lặng bình thường KHÔNG recovery → playhead giữ vị trí.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062030), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section tab: play tại vị trí audioclip đã mất → playhead giữ nguyên (không về đầu); play bình thường vẫn hoạt động.

### [2026-08-06 21:00] Task: Section item mở nhấp đôi không chứa audioclip — clone xóa clips
- **Báo cáo user:** mở lại dự án → nhấp đôi section item → chỉ là section item, không chứa các items như trước khi lưu.
- **Nguyên nhân:** `handleEditSectionInTab` (16515) clone `JSON.parse(JSON.stringify(section.tracks))` với **`clips: []`** (reset cố định) → audioclip bị xóa khỏi section editor; JSON clone cũng DROP AudioBuffer.
- **FIX (app.jsx):** clone giữ **clips + buffer THẬT** (`srcClips = (t.clips||[]).map(c => ({...c, buffer: c.buffer||null}))`) — JSON chỉ dùng cho field metadata; buffer gắn lại từ source live.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062100), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → open project → nhấp đôi section item → section editor đầy đủ clips (audioclip có waveform) + midi; play nghe được.

### [2026-08-06 21:30] Task: Reload mất audioclip section — clip thiếu serverFileId → upload buffer-only tại thời điểm lưu
- **Báo cáo user:** reload → mất audioclip đã lưu trong SECTION-TAB.
- **Chẩn đoán (DB):** save mới nhất — `AUDIO_ITEM: server_file_id: None | url: ''` — clip KHÔNG có file reference (upload sớm thất bại/race — uploadToServer trả null "client-side only") → reload → load fail → audioclip mất.
- **FIX (app.jsx):**
  (1) Thêm `encodeWavBlob` (WAV encoder tối thiểu — PCM 16-bit).
  (2) `handleSaveSectionTab` → async + `ensureClipsUploaded(contentTracks)` — clip chưa serverFileId + có buffer → encode WAV → uploadToServer → gán `c.serverFileId` TRƯỚC khi ghi vào section item → serialized AUDIO_ITEM có file_id → reload nạp được.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062130), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → section: chèn audioclip → Lưu section (chờ upload — console không có "upload failed") → save project → reload → audioclip còn + có âm.

### [2026-08-06 22:00] Task: Section editor (nhấp đôi) trống sau reload — session tab không nhận buffers (merge nested)
- **Báo cáo user:** section item MAIN vẽ đúng content sau reload, nhưng CHỈNH SỬA (nhấp đôi) → chỉ là block, không phải nội dung đã lưu.
- **Nguyên nhân:** loadAudioBuffersForTracks merge session tabs (14995): `updatedTracks.find(u => u.id === t.id)` — find ở TOP-LEVEL — track của session tab có id TRÙNG track bên TRONG section item (nested) → không tìm thấy → session tab giữ tracks deserialize (clips không buffer) → editor trống/không waveform.
- **FIX (app.jsx):** build `allLoadedTracks` PHẲNG (updatedTracks + section items' inner tracks) → merge session tabs tìm trong map phẳng → session tab nhận tracks có buffers.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062200), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → save + reload → nhấp đôi section item → section editor đầy đủ (audioclip waveform + midi); play nghe được.

### [2026-08-06 22:30] Task: Section item TỰ TRỎ (block lồng) + mất MIDI track — insert thiếu sectionId + fallback s.id
- **Báo cáo user:** SECTION-TAB hiện block lồng (section item) + MIDI item chỉ là hình + play không lên.
- **Chẩn đoán (DB):** section track 01 chứa `SECTION_ITEM: sec_1786020136359 → sec_1786020136359` (TỰ TRỎ chính section); track MIDI (play_VST 292 notes) biến mất khỏi section (console tracks=2).
- **Nguyên nhân:** `insertSectionAtPlayhead` (21822) tạo section item thiếu `sectionId` → serialize (9008) `referenced_section_id: s.sectionId || s.id` fallback → **chính id item = id section → tự trỏ** → deserialize block lồng + sectionStore ghi đè → mất track MIDI.
- **FIX (app.jsx):**
  (1) `handleSaveSectionTab` — LỌC section item tự-trỏ khỏi contentTracks (`(s.sectionId||s.id) !== tab.sectionId`).
  (2) Serialize (9008) — BỎ fallback `s.id` — chỉ `s.sectionId` (thiếu → block rỗng, không tự-trỏ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062230), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → project CŨ (đã dính tự-trỏ): xóa block lồng trong section tab + thêm lại MIDI track → lưu → save → reload → section tab đầy đủ (audioclip + midi), play được.

### [2026-08-06 23:00] Task: DIAG — section tab vẫn hiện block sau reload (data sạch + bundle mới)
- **Trạng thái:** DB save sạch (section: 2 tracks midi+audio, không SECTION_ITEM); production đã deploy 2230 (referenced_section_id:s.section + allLoadedTracks ✓) — NHƯNG user vẫn thấy block trong SECTION-TAB + midi mất + play block không âm.
- **Hành động:** thêm diagnostic log sau restore — `[Restore] sessionTab <id> sectionId <sid> tracks N sections N midi N clips N` — để xác định block sinh từ đâu (restored sessionTabs có sections không; hay sinh lúc nhấp đôi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062300), `wiki.md`. Rebuild precompiled.
- **Chờ user:** build + deploy 2300 → reload → dán log `[Restore] sessionTab` + log lúc nhấp đôi (có `[Play]` + `[SonicSF] noteon` không).

### [2026-08-06 23:30] Task: FIX ID COLLISION merge session tabs — block lồng + mất MIDI (bug từ fix 2200)
- **Báo cáo user:** mất midi item + vẫn hiển thị section block trong SECTION-TAB.
- **Nguyên nhân:** fix 2200 (merge session tabs) dùng `allLoadedTracks` (main tracks + section inner tracks) — `find(u => u.id === t.id)` lấy track ĐẦU TIÊN — **inner track id 1 trùng MAIN track id 1** → session tab Track 01 bị THAY bằng MAIN track 01 (chứa SECTION_ITEM + không midi) → **block lồng + mất MIDI + play block không âm**.
- **FIX (app.jsx):** merge session tabs CHỈ match với `sectionInnerTracks` (inner tracks của section items) — không match track main top-level.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → deploy → reload → nhấp đôi section item → SECTION-TAB đầy đủ (midi + audioclip đúng vị trí) + play cả 2.

### [2026-08-06 23:45] Task: Media Explorer (F6) — close panel phải tắt preview âm thanh
- **Báo cáo user:** đóng Media Explorer panel → preview âm thanh vẫn phát.
- **Nguyên nhân:** panel KHÔNG unmount khi đóng (ẩn bằng `display:none` 27465) — cleanup unmount không chạy → preview source + SonicSF notes tiếp tục.
- **FIX (app.jsx):** truyền `active={showMediaExplorer}` vào MediaExplorerPanel + effect `if (active === false) stopMediaPlayback()` — đóng F6 → dừng source + loop + SonicSF.stopAll.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062345 — sửa typo `</script></script>` kép), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → F6 → preview 1 file → F6 close → âm dừng ngay.

### [2026-08-07 00:00] Task: VU meter SECTION-TAB không animation khi play
- **Báo cáo user:** play ở MAIN → chuyển SECTION-TAB check item đang play → VU meter không animation realtime.
- **Nguyên nhân (2 lỗi):** (1) VU tick (24603) lookup `trackNodes[trackId]` — key THƯỜNG — node của section tab là SUB-NODE (key `<parent>_sub_<trackId>`) → không khớp → node undefined; (2) sub-node KHÔNG có `analyserNode` (chỉ `{gainNode, pannerNode, route}`) → VU không có gì để đọc.
- **FIX (app.jsx):** (1) VU tick — fallback scan `_sub_<trackId>` keys → vNode; đọc analyser từ `vNode.analyserNode`; (2) getOrCreateSubTrackNode — thêm `analyserNode` (gainNode → analyser → routeGain/dryGain) → VU đọc được tín hiệu section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070000), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → chuyển SECTION-TAB → VU meters nhảy realtime theo track đang play.

### [2026-08-07 00:30] Task: MIDI section bị bypass mastering (route qua parent) + VU section không đúng nhịp
- **Báo cáo user:** play MAIN → nhấp đôi vào section item → (1) VU meter animation không đúng nhịp âm thanh; (2) âm thanh MIDI item bị bypass qua mastering chain.
- **Nguyên nhân:**
  (1) **MIDI bypass**: sub-node routeGain nối vào PARENT NODE → qua route của parent track (parent `audio_bypass: true` → routeGain 0/dryGain 1) → MIDI/audio section bị kéo vào DRY bus dù sub-track ♪/A tắt → "bị bypass mastering".
  (2) **VU không đúng nhịp**: scheduling MIDI của section item KHÔNG trigger `triggerMidiVuActivity` (main-track midi có ở 19066) → VU chỉ dựa analyser (trễ/smoothing) → không nhảy theo từng note.
- **FIX (app.jsx):**
  (1) getOrCreateSubTrackNode — mastering path `routeGain.connect(window.masterBus.input)` TRỰC TIẾP (không qua parent — parent route có bypass riêng); fallback parentNode khi không có masterBus.
  (2) Section midi scheduling (19182) — thêm `triggerMidiVuActivity(subTrack.id, note.velocity)` sau mỗi playNote.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070030), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → nhấp đôi section → MIDI qua mastering (nghe EQ/limiter tác dụng) + VU nhảy đúng theo note.

### [2026-08-07 01:00] Task: MIDI section tab bị bypass — createMasteringRoute dùng audioBypass cho MIDI-only track
- **Báo cáo user (kèm log):** nhấp đôi section → `setOutputDestination to: track node (sfEntry)` + noteon chạy nhưng MIDI bị bypass mastering.
- **Nguyên nhân:** SECTION-TAB play dùng PLAIN node (getOrCreateTrackNode) → `createMasteringRoute` đọc `trackAudioBypassMap`/`track.audioBypass` — section track default `audio_bypass: true` (round 48) → route dry → **MIDI kéo vào dry bus dù ♪ tắt**. (sfBypass/SF route đã đúng ♪ ở 1041/18667 — chỉ AUDIO route sai.)
- **FIX (app.jsx createMasteringRoute):** bypass theo LOẠI nội dung — track **MIDI-only** (midiItems, không clips/buffer) → dùng `trackMidiBypassMap`/`midiBypass` (♪); track có clips → `audioBypass` (A) như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070100), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → nhấp đôi section → MIDI qua mastering (EQ/limiter nghe rõ); log `[Bypass]` routeGain=1/dryGain=0 cho midi track.

### [2026-08-07 01:30] Task: First play sau double-click bị bypass (node STALE) + VU track chứa section item
- **Báo cáo user:** (1) nhấp đôi section → play lần ĐẦU midi bị bypass; stop + play lại → đúng. (2) play section item trên track → VU track đó không animation.
- **Nguyên nhân:**
  (1) **Node STALE**: section tab track id TRÙNG main track id — node tạo từ MAIN play (route theo data main — audioBypass=true → dry) được cache lại → first play trong section tab dùng node cũ → bypass; stop (wipe nodes) → play lại → node mới đúng.
  (2) **VU track main**: âm section đi thẳng masterBus.input (fix 00:30) → KHÔNG qua node chính → analyser node chính không có tín hiệu → VU đứng yên.
- **FIX (app.jsx):**
  (1) Helper `routeBypassFor(track)` (bypass theo content-type — MIDI-only → ♪, có clips → A) — áp: createMasteringRoute (đã có), startTrackPlayback re-apply route mỗi lần schedule (hết stale), mastering-settings effect (14906 thay effAudioBypass).
  (2) VU tick — scan sub-nodes (`k.indexOf(trackId + '_sub_') === 0`) khi node chính không có tín hiệu → peak sub-node analyser → VU track main animation theo section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070130), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → nhấp đôi section → play lần ĐẦU → midi qua mastering ngay; play section trên MAIN → VU track đó nhảy.

### [2026-08-07 02:00] Task: VU MAIN không nhảy cho midi section item — triggerMidiVuActivity thiếu parent
- **Báo cáo user:** (1) first play sau double-click midi vẫn bị bypass (lặp — cần xác nhận bundle 0130); (2) quay về MAIN — section item play midi notes → VU meter không animation.
- **Nguyên nhân (2):** midiVuActivity keyed theo `subTrack.id` (section) — VU MAIN đọc `midiVuActivityRef[mainTrackId]` → miss → VU main đứng yên.
- **FIX (app.jsx 19212):** section midi scheduling trigger CẢ 2 — `triggerMidiVuActivity(subTrack.id, ...)` + `triggerMidiVuActivity(track.id, ...)` (parent/main track) → VU main nhảy theo section item đang play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070200), `wiki.md`. Rebuild precompiled.
- **Chờ xác nhận bug (1):** user build + test 0130/0200 — nếu first play VẪN bypass → dán console: `[Bypass] node created` (routeGain/dryGain) + `setOutputDestination` lúc nhấp đôi + play lần đầu.

### [2026-08-07 02:30] Task: Re-apply route sub-node khi section item play (main) — hết stale bypass
- **Yêu cầu user:** (1) BẮT BUỘC VU meter hiển thị trên track đang play items (MAIN + SECTION-TAB); (2) chuyển tab MAIN → SECTION-TAB midi item đang play KHÔNG được bypass.
- **FIX (app.jsx 19132):** section item play (main) — sau getOrCreateSubTrackNode — re-apply route sub-node theo `routeBypassFor(subTrack)` (MIDI-only → ♪, clips → A) mỗi lần schedule → hết stale route (tương tự re-apply plain node ở 0130).
- **VU stack hiện tại (các fix trước):** sub-node có analyserNode (00:00) → VU section tab đọc sub-node (00:00); trigger midi VU cho section (00:30); VU main scan sub-nodes khi node chính câm (01:30); trigger parent track cho VU main (02:00). **BẮT BUỘC hiển thị = đã phủ: main track (subPeak + midi activity) + section tab (vNode sub-node + midi activity).**
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070230), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → (1) play section ở MAIN + trong SECTION-TAB → VU nhảy đúng track; (2) MAIN → double-click SECTION-TAB → play lần đầu → MIDI qua mastering (không bypass).

### [2026-08-07 03:30] Task: MIDI section bị bypass khi vào SECTION-TAB — masteringBypass fallback + clone kế thừa flag
- **Báo cáo user:** đang play MAIN SESSION → double-click section item vào SECTION-TAB → âm MIDI item bị bypass (không qua mastering chain).
- **Nguyên nhân (data thật — project "Section test" trong DB):** sub-track section (id TRÙNG main track id 1, 2) lưu `mastering_bypass=True` + `audio_bypass=True` (kế thừa từ track gốc). Mọi hàm quyết định bypass (`effMidiBypass`/`effAudioBypass`/`createMasteringRoute`/`routeBypassFor`/`getOrCreateSubTrackNode`) fallback `?? track.masteringBypass` → khi map chưa sync/thiếu entry (timing khi double-click + play lần đầu) → track MIDI-only bị tính bypass=TRUE → SF/sub-node route vào DRY bus (bypass mastering) dù ♪ tắt. Ngoài ra `handleEditSectionInTab` clone section tracks GIỮ NGUYÊN audioBypass/masteringBypass của track gốc → sync effect (14757) ghi map audio sai cho track section.
- **FIX (app.jsx):**
  (1) BỎ fallback `?? masteringBypass` khỏi mọi hàm quyết định route (effMidiBypass/effAudioBypass/createMasteringRoute/routeBypassFor/getOrCreateSubTrackNode) — nút A/♪ + `audioBypass`/`midiBypass` là nguồn duy nhất; `masteringBypass` không còn quyết định route (field legacy kế thừa sai).
  (2) `handleEditSectionInTab` (16595) — clone section tracks ép reset bypass theo context: `masteringBypass:false, audioBypass:true (section raw default), midiBypass:false` — hết kế thừa flag track gốc.
  (3) Sync effect (14757): `trackMasteringBypassMap[t.id] = !!t.masteringBypass` (trước gán nhầm `!!t.audioBypass`).
  (4) `updateSfRouting` (18813): `effMidiBypass(t.id)` → `effMidiBypass(t)` — truyền string → map lookup sai → sfRouteGain bị ép 1 (♪ vô hiệu qua đường này).
  (5) Log chẩn đoán: `[Bypass] sfEntry created track <id> midiBypass=...` (lúc tạo node) + `[Bypass] sf route sync track <id> ...` (updateSfRouting ép route) — test còn lỗi thì dán log.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → double-click section vào SECTION-TAB → MIDI qua mastering (nghe EQ/limiter); play trong SECTION-TAB → MIDI vẫn qua mastering (♪ tắt); bật ♪ → bypass đúng ý. Nếu còn bypass → dán console `[Bypass]` logs.

### [2026-08-07 03:35] Task: MIDI section vẫn bypass sau 0330 — thêm log chẩn đoán [SFRoute]/[Bypass] reapply
- **Báo cáo user (log 0330):** play MAIN → double-click section → `[SonicSF] setOutputDestination to: track node (sfEntry)` + noteons chạy → bấm Play tab (isPlaying=true) → stopAllPlayback → SF về masterBus.input. Log THIẾU các dòng `[Bypass]`/`[Play] offset` của bản 0330 → chưa xác định được route thực tế (sfRouteGain/routeGain giá trị bao nhiêu lúc nghe bypass).
- **Log mới thêm (app.jsx):**
  (1) `[SFRoute] activeTab=... midiAudible=...` — updateSfRouting: track nào audible + đích tính.
  (2) `[SFRoute] dest=sfEntry node <id> sfRouteGain=... sfDryGain=...` — đích SF + giá trị route hiện tại TRƯỚC khi ép.
  (3) `[SFRoute] dest=gainNode node <id> (no sfEntry) route.routeGain=... dryGain=...` — đích fallback gainNode.
  (4) `[Bypass] reapply track <id> contentType=midi/audio bypass=... → routeGain=.../dryGain=...` — startTrackPlayback re-apply route.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070335), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section vào SECTION-TAB → nghe bypass → **dán TOÀN BỘ console (không filter)** — cần các dòng `[SFRoute]`, `[Bypass]`, `[Play] offset`, `setOutputDestination` để chốt đường bypass chính xác.

### [2026-08-07 03:40] Task: MIDI section bypass — ID TRÙNG main track → node/map collision (CHỐT NGUYÊN NHÂN)
- **Log 0335 (đã chốt):** play MAIN → `[SFRoute] activeTab=main midiAudible=` (rỗng → SF về masterBus.input — MIDI qua mastering ✓). Double-click → `[SFRoute] activeTab=session_... midiAudible=1(midi1)` → `[SFRoute] dest=gainNode node 1 (no sfEntry) — route.routeGain=0 route.dryGain=1` → SF route vào NODE '1' → **route dry → MIDI section BỊ BYPASS**. (Lưu ý: log soundfontPlayer "track node (sfEntry)" là log SAI — luôn in "sfEntry" cho mọi node khác null; đích thực là gainNode.)
- **Nguyên nhân gốc:** section clone giữ id sub-track TRÙNG main track id (data: main 1/2 = sub 1/2). (1) `updateSfRouting` lookup node theo id → trúng node MAIN (route theo ♪ MAIN = bật = dry) → SF (MIDI section) kéo vào dry dù ♪ section tắt. (2) Bypass map keyed theo id bị GHI ĐÈ CHÉO (clone audioBypass=true ghi đè map main; ♪ main ảnh hưởng route section).
- **FIX (app.jsx):**
  (1) MỌI hàm quyết định route (`effMidiBypass`/`effAudioBypass`/`createMasteringRoute`/`routeBypassFor`/sub-node): **ƯU TIÊN field track object (`track.midiBypass`/`track.audioBypass`)** — object phân biệt đúng context; map chỉ fallback (realtime nút bấm, track không field).
  (2) `updateSfRouting`: activeTab là `session_*` → lookup node ƯU TIÊN SUB-NODE (key `<parent>_sub_<id>` — route theo ♪ section + thẳng masterBus.input); không có sub-node → null (SF về masterBus.input — qua mastering, KHÔNG bypass). KHÔNG bao giờ route vào node MAIN khi ở section tab.
  (3) Sync effect (14754): đảo thứ tự — session/clone TRƯỚC, MAIN SAU (main thắng khi id trùng — map fallback đúng context; clone dùng field).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070340), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section vào SECTION-TAB → MIDI qua mastering (nghe EQ/limiter); log kỳ vọng: `[SFRoute] dest=gainNode node 1 ...` → KHÔNG còn (sub-node hoặc masterBus.input); `route.routeGain=1`. Play section tab → MIDI qua mastering (♪ tắt); bật ♪ section → bypass đúng ý.

### [2026-08-07 03:45] Task: MIDI bypass khi mở MIDI item SECTION-TAB → PIANO ROLL TAB
- **Báo cáo user:** sau fix 0340 (section tab OK) — mở MIDI item từ SECTION-TAB vào PIANO ROLL TAB → MIDI bị bypass.
- **Nguyên nhân:** cùng cơ chế collision id — PIANO ROLL của track section có `parent_tab_id = session_*`, `trackId` = id sub-track TRÙNG main track id. `updateSfRouting` nhánh PIANO_ROLL lookup `activeTrackNodesRef.current[trackId]` → trúng NODE MAIN (route theo ♪ main — dry) → SF (notes piano roll) kéo vào dry dù ♪ section tắt. Xảy ra ngay khi ĐỔI TAB (effect 14814 → updateSfRouting) — không cần play.
- **FIX (app.jsx updateSfRouting nhánh PIANO_ROLL):** khi `_activeSub.parent_tab_id` startsWith `session_` → lookup node ƯU TIÊN SUB-NODE (key `endsWith('_sub_' + trackId)` — route theo ♪ section + thẳng masterBus.input); không có sub-node → null (SF về masterBus.input — qua mastering, KHÔNG bypass). Không bao giờ route vào node MAIN cho piano roll của section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070345), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section (SECTION-TAB) → double-click MIDI item → PIANO ROLL tab → MIDI qua mastering (nghe EQ/limiter); log `[SFRoute]` không còn dest node MAIN (route 0/1) cho piano roll section.

### [2026-08-07 03:50] Task: Playhead vẫn di chuyển trong PIANO ROLL TAB khi mở MIDI item từ SECTION-TAB
- **Báo cáo user:** mở MIDI item từ SECTION-TAB (đang play) → PIANO ROLL TAB → playhead vẫn tiếp tục di chuyển — cần dừng.
- **Phân tích:** piano roll view vẽ playhead theo `st.currentTime` (chỉ cập nhật khi tab play) — khi mở tab (chưa play) code cũ `return` ở nhánh sub-tab KHÔNG setCurrentTime → playhead đứng yên — NHƯNG return đó GIẾT CHẾT rAF loop: main/section play chạy ngầm (handleEditMidiInTab 16427 không dừng âm) → watchdog master-silence chết, âm main không dừng đúng projectEnd, và khi quay lại tab playhead NHẢY VỌT theo `elapsed` thật (trông như vẫn di chuyển/sai lệch).
- **FIX (app.jsx updatePlayhead nhánh sub-tab):** tách 2 trường hợp — (1) sub-tab KHÔNG play: KHÔNG setCurrentTime (playhead đứng yên) NHƯNG nếu `isPlaying` (main/session chạy ngầm) → vẫn check projectEnd (stop đúng lúc) + TIẾP TỤC requestAnimationFrame (loop sống, watchdog sống, quay lại tab không nhảy); (2) sub-tab play: giữ nguyên hành vi cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070350), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section (SECTION-TAB) → double-click MIDI item → PIANO ROLL TAB → playhead KHÔNG di chuyển (đứng yên); âm main tiếp tục + dừng đúng cuối bài; quay lại SECTION-TAB → playhead đúng vị trí âm (không nhảy vọt). Nếu playhead vẫn chạy khi CHƯA bấm play → xác nhận lại: có bấm Space/Play trong piano roll không (bấm play → playhead chạy là đúng hành vi).

### [2026-08-07 03:55] Task: FIX hồi quy 0350 — CÂM âm khi mở PIANO ROLL từ SECTION-TAB (projectEnd=1s ở sub-tab)
- **Báo cáo user (log 0350):** mở MIDI item từ SECTION-TAB → PIANO ROLL → `[SFRoute] activeTab=midi_... midiAudible=1(midi1)` → `setOutputDestination to: masterBus.input` → KHÔNG noteon tiếp theo → CÂM (bản 0345 play tiếp).
- **Nguyên nhân (hồi quy fix 0350):** nhánh updatePlayhead sub-tab không-play check `updatedTime >= projectEndRef.current` — nhưng `projectEnd` tính theo activeTab: piano roll tab (sub-tab) KHÔNG main/không session → `tab=undefined → max=0` → **projectEnd = 1s** → updatedTime (elapsed THẬT từ lúc play main — đã >1s) >= 1 → `stopAllPlayback()` NGAY → panic notes + xóa nodes (activeTrackNodesRef={}) → SF về masterBus.input + KHÔNG noteon = CÂM.
- **FIX (app.jsx):** bỏ check projectEnd khỏi nhánh sub-tab không-play — chỉ `requestAnimationFrame` tiếp tục (loop sống cho playhead không nhảy khi quay lại). Âm main đã schedule hết từ startTrackPlayback — tự hết đúng lúc; quay lại MAIN → nhánh main tự stop tại projectEnd THẬT.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070355), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section (SECTION-TAB) → double-click MIDI item → PIANO ROLL TAB → âm section TIẾP TỤC play (không câm) + playhead piano roll ĐỨNG YÊN; quay lại SECTION-TAB → playhead đúng vị trí âm.

### [2026-08-07 04:00] Task: Quay lại MAIN/SECTION sau khi Space trong PIANO ROLL → CÂM — resume main play
- **Báo cáo user (log 0355):** mở MIDI item từ SECTION-TAB → PIANO ROLL (playhead đứng yên ✓ — fix 0350/0355); bấm Space → playhead piano roll chạy; **quay lại MAIN/SECTION → CÂM** (log: `[SFRoute] activeTab=main midiAudible=` + `setOutputDestination to: masterBus.input` — SF về masterBus nhưng KHÔNG noteon — main play đã chết).
- **Nguyên nhân:** Space trong PIANO ROLL → `handlePlayPause` nhánh sub-tab → `stopAllPlayback()` DỪNG main/section play (xóa nodes + panic) → chơi piano roll. Quay lại MAIN/SECTION → không còn gì play → câm.
- **FIX (app.jsx):** resume main/section play khi rời piano roll tab đã play:
  (1) `mainResumeRef` mới — lưu `{offset: startOffsetTimeRef.current, audioTime: ctx.currentTime}` lúc MỞ piano roll tab khi main đang play (handleEditMidiInTab — nhánh isPlaying=true).
  (2) Effect đổi tab (14814): rời PIANO ROLL tab đã play về MAIN hoặc `session_*` + có `mainResumeRef` → `stopAllPlayback()` + `startTrackPlayback(offset + elapsed thật)` + `setIsPlaying(true)` — âm main/section tiếp tục từ ĐÚNG vị trí (không chơi lại từ đầu, không câm). Các trường hợp khác: clear `mainResumeRef` (không resume trùng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070400), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section (SECTION-TAB) → double-click MIDI item → PIANO ROLL (playhead đứng yên) → Space (playhead piano roll chạy) → quay lại MAIN/SECTION → ÂM TIẾP TỤC từ đúng vị trí (log `[Resume] main play từ X.XX s`); KHÔNG bấm Space → quay lại → âm vẫn tiếp tục (không resume trùng).

### [2026-08-07 04:05] Task: FIX 0400 — resume không chạy (mainResumeRef bị clear nhầm bởi chính effect đổi tab)
- **Báo cáo user (log 0400):** piano roll play (Space) → `dest=sfEntry node 1 ... MASTERING` + noteons ✓ → chuyển về SECTION-TAB → `setOutputDestination to: masterBus.input` (node null) → KHÔNG có log `[Resume]` → CÂM.
- **Nguyên nhân:** handleEditMidiInTab set `mainResumeRef` (mở tab lúc main play) → setActiveTab → effect đổi tab (14814) chạy với `prevTab='session_...'` → `prevSub = subTabsRef.find('session_...')` = **undefined** → rơi vào nhánh `else` cũ → **`mainResumeRef.current = null` — XÓA SẠCH resume point NGAY khi VÀO piano roll** → quay lại section: `_resume=null` → không resume → câm.
- **FIX (app.jsx effect 14814):** nhánh clear đổi thành `else if (prevSub && prevSub.type === 'PIANO_ROLL')` — chỉ clear khi THỰC SỰ rời khỏi piano roll tab (không play — main ngầm tiếp tục tự nhiên). prevTab = main/session (đang VÀO piano roll) → KHÔNG clear — resume point giữ nguyên.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070405), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section → double-click MIDI item → PIANO ROLL (playhead đứng yên) → Space (piano roll play) → quay lại SECTION-TAB → **log `[Resume] main play từ X.XX s` + ÂM TIẾP TỤC từ đúng vị trí** (SF route: `dest=sfEntry node 1 ... MASTERING`); KHÔNG Space → quay lại → âm ngầm tiếp tục (không resume trùng).

### [2026-08-07 04:10] Task: Playhead PIANO ROLL không di chuyển khi đang play (di chuyển tab qua lại)
- **Báo cáo user (log 0405):** piano roll play hoạt động (schedule 248 notes + noteons + `dest=sfEntry node 1 ... MASTERING` ✓) — nhưng sau khi di chuyển tab qua lại → ở PIANO ROLL TAB playhead không di chuyển khi đang play.
- **Phân tích:** khi rời piano roll tab → effect đổi tab stop sub-tab play (isPlaying:false) + resume main/section (âm chạy). Quay LẠI piano roll tab → tab KHÔNG play → nhánh updatePlayhead sub-tab không-play KHÔNG set st.currentTime → playhead piano roll ĐỨNG YÊN — dù âm MAIN/SECTION (resume) đang phát → user thấy "đang play mà playhead không chạy".
- **FIX (app.jsx updatePlayhead nhánh sub-tab không-play):** nếu `isPlaying` (main/section đang play — refs startOffsetTime/startAudioTime là của main) → setSubTabs `currentTime = startOffsetTimeRef + (ctx.currentTime - startAudioTimeRef)` — playhead piano roll ĐỒNG BỘ theo vị trí âm thật (chạy theo main play). Không có play nào → đứng yên (chế độ edit). Loop luôn tiếp tục rAF.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070410), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → di chuyển tab qua lại (SECTION-TAB ↔ PIANO ROLL) → ở PIANO ROLL TAB playhead chạy theo âm đang phát (đồng bộ vị trí thật); không play gì → playhead đứng yên; bấm Space trong piano roll → playhead chạy theo tab.

### [2026-08-07 04:15] Task: PIANO ROLL — Space không capture play/stop + chuyển tab về MAIN/SECTION bị CÂM
- **Báo cáo user (log 0410):** (1) trong PIANO ROLL TAB bấm Space không play/stop; (2) piano roll play → nhấn play/stop transport → chuyển sang MAIN/SECTION-TAB → CÂM (log: chuyển session → `setOutputDestination to: masterBus.input` + hết noteon — vì effect đổi tab stopAllPlayback dừng piano roll + không có gì resume).
- **Nguyên nhân:**
  (1) Keydown handler (15938) bypass MỌI hotkey khi focus INPUT/TEXTAREA/contentEditable — piano roll tab có input focus (snap/rename...) → Space chết.
  (2) Effect đổi tab (14814) — rời sub-tab đang play → `stopAllPlayback()` (dừng piano roll — luật cũ "rời sub-tab play → stop") → về MAIN/SECTION không còn âm → CÂM.
- **FIX (app.jsx):**
  (1) Keydown handler: activeTab là PIANO ROLL (`midi_*`) → Space LUÔN play/stop (kể cả focus input); phím khác vẫn bypass.
  (2) Effect đổi tab: rời PIANO ROLL tab đang play → KHÔNG stopAllPlayback (SF notes đã schedule — tiếp tục kêu tự nhiên — không câm); audio sub-tab (buffer source riêng) giữ stop như cũ. Resume block (mainResumeRef) giữ nguyên — tự stop + resume main nếu có.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070415), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → PIANO ROLL TAB → Space play/stop hoạt động (kể cả khi input focus); piano roll play → chuyển sang MAIN/SECTION-TAB → âm piano roll TIẾP TỤC (hết notes tự nhiên — không câm); quay lại tab → playhead đồng bộ.

### [2026-08-07 04:20] Task: Playhead NGỪNG khi chuyển tab PIANO ROLL (play thuần) → MAIN/SECTION — không đồng bộ âm
- **Báo cáo user:** preview piano roll (play thuần — main chưa play) → chuyển tab sang MAIN/SECTION-TAB → playhead ngừng di chuyển (dù âm piano roll còn kêu — fix 0415 không stop âm).
- **Nguyên nhân:** rời piano roll play → setSubTabs isPlaying:false + KHÔNG stop âm (0415) — nhưng `isPlaying` (main) vẫn false → updatePlayhead nhánh MAIN `if (!isPlaying) return` → playhead đứng yên — KHÔNG đồng bộ với âm piano roll đang phát.
- **FIX (app.jsx effect đổi tab 14814):** rời PIANO ROLL play → về MAIN/`session_*` + mainResumeRef null (play thuần) → `setIsPlaying(true)` — nhánh main updatePlayhead chạy → playhead main/section di chuyển theo elapsed (refs đang là của piano roll play — đồng bộ với âm). Hết bài → nhánh main tự stop. Resume block (mainResumeRef) giữ nguyên ưu tiên trước.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070420), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → PIANO ROLL TAB → Space play (main chưa play) → chuyển MAIN/SECTION-TAB → playhead TIẾP TỤC di chuyển (log `[Playhead] tiếp tục theo piano roll`), âm piano roll vẫn kêu hết notes; quay lại piano roll → playhead đồng bộ.

### [2026-08-07 04:25] Task: Space không play/stop sau khi chuyển tab — focus tab header button nuốt space
- **Báo cáo user:** chuyển tab → focus tự động nằm trên tên tab (tab header button) → nhấn Space thường không có tác dụng — phải click vào tab active thì Space mới capture được.
- **Nguyên nhân:** tab header là `<button>` — sau khi chuyển tab (click header / mở tab bằng double-click item), focus giữ trên button tab header (có thể là header của tab KHÁC — chưa active). Space: keydown → handler play/stop ✓ — NHƯNG browser fire CLICK TỔNG HỢP ở keyup space trên button → click tab header → CHUYỂN TAB — nhìn như Space không play/stop (vừa play vừa đổi tab).
- **FIX (app.jsx keydown handler — block Space):** trước khi play/stop → `document.activeElement` nếu là `BUTTON` → `blur()` — keyup sau đó target body → KHÔNG click tab header → Space play/stop ổn định ở mọi tab.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070425), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → chuyển tab bằng click header (hoặc mở piano roll bằng double-click) → KHÔNG click thêm → nhấn Space → play/stop đúng tab hiện tại (không chuyển tab, không mất tác dụng).

### [2026-08-07 04:30] Task: PIANO ROLL — Space 2 lần mới stop khi main/section đang play ngầm
- **Báo cáo user:** MAIN/SECTION đang play (MIDI kêu) → chuyển sang PIANO ROLL TAB → Space 1 lần KHÔNG stop (vẫn nghe âm) — Space lần 2 mới stop — "không phát hiện MIDI đang play".
- **Nguyên nhân:** handlePlayPause nhánh sub-tab: `st.isPlaying` (piano roll tab) = false (chưa play tab) → rơi vào else → **stopAllPlayback (dừng main) + PLAY PIANO ROLL ngay** → âm vẫn còn (piano roll) → user tưởng "không stop". Space lần 2: st.isPlaying=true → stop ✓.
- **FIX (app.jsx handlePlayPause nhánh sub-tab):** đầu nhánh — nếu `isPlaying` (main/section đang play ngầm) → `stopAllPlayback()` + setSubTabs isPlaying:false (mọi tab) + `setIsPlaying(false)` → return — Space = STOP trước, không play sub-tab. Space lần sau (đã stop) → play sub-tab bình thường.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070430), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → mở PIANO ROLL tab (main play ngầm) → Space 1 lần → DỪNG hẳn (không còn âm); Space lần nữa → play piano roll (toggle bình thường).

### [2026-08-07 04:35] Task: ĐỒNG BỘ playhead 2 chiều SECTION-TAB ↔ PIANO ROLL (vị trí item)
- **Yêu cầu user:** (1) play MIDI item trên SECTION-TAB ở vị trí X → sang PIANO ROLL → play tiếp tại vị trí tương ứng (X - item.startTime); (2) play PIANO ROLL tại Y → quay lại SECTION-TAB → play tại item.startTime + Y; (3) SECTION play vượt ngoài duration item → PIANO ROLL bắt đầu từ đầu (0).
- **FIX (app.jsx):**
  (1) Helper `getPianoRollItemInfo(st)` — tìm item (trackId + target_id) trong context đúng (parent session tab → sessionTabsRef tracks; main → activeTracksRef) → `{startTime, duration}`. Dùng REFS — an toàn closure cũ (rAF loop).
  (2) updatePlayhead nhánh sub-tab không-play: `st.currentTime = playPos - item.startTime` (clamp: < startTime hoặc ≥ startTime+duration → 0 — vượt duration → đầu).
  (3) Effect đổi tab — rời PIANO ROLL play về MAIN/SECTION: resumeAt = `item.startTime + st.currentTime` (ưu tiên); về MAIN mà item thuộc section → cộng thêm secStart (vị trí section trên main); fallback mainResumeRef; fallback cuối setIsPlaying(true) (playhead chạy theo âm còn kêu).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070435), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → SECTION-TAB play (playhead trong item) → sang PIANO ROLL → playhead đúng vị trí trong item; play piano roll → về SECTION-TAB → play đúng item.startTime + vị trí piano roll (log `[Resume] play tại X.XX s (item.startTime=... + st.currentTime=...)`); SECTION play vượt quá item → piano roll về 0.

### [2026-08-07 04:40] Task: Xác nhận + log chẩn đoán — Đồng bộ 2 chiều MAIN SESSION ↔ PIANO ROLL
- **Yêu cầu user:** đồng bộ 2 chiều MAIN SESSION ↔ PIANO ROLL (tương tự SECTION-TAB ↔ PIANO ROLL 0435): main play ở vị trí X → mở piano roll → playhead trong item đúng (X − item.startTime); piano roll play tại Y → về MAIN → play tại item.startTime + Y; main play vượt duration item → piano roll về 0.
- **Trạng thái:** fix 0435 ĐÃ generic — `getPianoRollItemInfo` (parent_tab_id null → `activeTracksRef` = MAIN tracks — item.startTime main global) + 2 nhánh (vào tab: local = ph − item.startTime; rời tab: resumeAt = item.startTime + st.currentTime) tự áp dụng cho MAIN. Không cần sửa logic — thêm LOG chẩn đoán để xác nhận.
- **FIX (app.jsx):** nhánh updatePlayhead sub-tab không-play — log `[Sync] PR <id> ph=<vị trí play> item.start=<...> dur=<...> → local=<...>` (rate-limited ~2 lần/giây) — xác nhận giá trị đồng bộ realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070440), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN (playhead trong MIDI item) → mở piano roll → log `[Sync]` cho thấy local tăng theo (ph − item.start); play piano roll → về MAIN → log `[Resume] play tại item.startTime + st.currentTime`; main play vượt item → local = 0. Nếu giá trị sai → dán log.

### [2026-08-07 04:45] Task: MAIN/SECTION hết midi note → PIANO ROLL playhead chưa về 0
- **Báo cáo user:** main/section play hết (không còn midi note) → trong PIANO ROLL TAB playhead vẫn giữ vị trí cũ — chưa về 0.
- **Nguyên nhân:** khi play DỪNG (isPlaying → false — hết project/stop) → nhánh updatePlayhead sub-tab không-play `if (isPlaying)` FALSE → KHÔNG setSubTabs → playhead piano roll giữ local cuối (giữa item) — không về 0.
- **FIX (app.jsx):** effect `[isPlaying]` mới — detect transition playing→dừng (prevIsPlayingRef): nếu đang ở PIANO ROLL tab (`midi_*`) + tab KHÔNG play → `setSubTabs currentTime = 0` — playhead về đầu. Tab đang play (piano roll play riêng) → giữ nguyên (không đụng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070445), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → mở PIANO ROLL (playhead chạy trong item) → đợi play hết (projectEnd) → playhead piano roll TỰ VỀ 0; bấm stop (space) → cũng về 0; piano roll đang play riêng → không bị về 0.

### [2026-08-07 04:50] Task: (1) PIANO ROLL zoom không persist phiên (2) Mở project lưu → timeline zoom out thu nhỏ góc
- **Báo cáo user:** (1) PIANO ROLL TAB không giữ kích thước zoom ở phiên làm việc trước; (2) MAIN SESSION — mở dự án đã lưu → timeline bị zoom out thu nhỏ ở góc — zoom in mới về kích thước đã lưu.
- **Nguyên nhân:**
  (1) `rollZoom` (PianoRollTabEditor 6926) là local state khởi tạo 60 — mỗi lần mở tab → reset — không persist.
  (2) Serialize project (9267) lưu `window.__currentZoom || 1.0` — nhưng `__currentZoom` do MEDIA EXPLORER set (11583) — không phải zoom timeline (App 13910) → project LUÔN lưu 1.0 → load `setZoom(1.0)` → timeline thu nhỏ góc trái.
- **FIX (app.jsx):**
  (1) `rollZoom` khởi tạo từ `localStorage['sf_pr_zoom']` (clamp 15-250) + effect lưu khi đổi.
  (2) App zoom effect set `window.__sfTimelineZoom = zoom`; serialize dùng `__sfTimelineZoom || __currentZoom || 1.0`; load (15211 + 24987): `result.zoom > 1 && setZoom` — bỏ qua 1.0 (data cũ lỗi) → giữ zoom hiện tại/localStorage.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070450), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** (1) PIANO ROLL zoom in/out → đóng tab → mở lại (hoặc reload) → zoom giữ nguyên; (2) LƯU project (zoom hiện tại) → mở lại → zoom ĐÚNG kích thước đã lưu (không thu nhỏ); project CŨ (lưu 1.0) → mở → giữ zoom hiện tại (không bị thu nhỏ).

### [2026-08-07 04:55] Task: Zoom reset khi chuyển tab (trong phiên) — thêm log chẩn đoán
- **Báo cáo user:** trong phiên làm việc, chuyển tab → kích thước zoom không lưu lại — bị reset về mặc định.
- **Trạng thái:** zoom MAIN timeline là App state global (không reset khi chuyển tab); piano roll đã persist localStorage (0450). Nghi vấn: effect minZoom (18009) ép `setZoom(minZoom)` khi vào tab có maxDuration nhỏ (section ngắn) — làm mất zoom đã set. Chưa tìm thấy nơi reset về mặc định cố định.
- **FIX (app.jsx):** thêm log chẩn đoán — (1) `[Zoom] đổi tab <prev> → <new> | zoom=<...> | minZoom=<...>` (effect activeTab); (2) `[Zoom] ép lên minZoom=... (zoom=...) tab=... maxDuration=...` (effect minZoom — chỉ khi ép).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070455), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → zoom MAIN (hoặc piano roll) → chuyển tab qua lại → dán log `[Zoom]` — xác định tab nào + giá trị zoom/minZoom khi reset.

### [2026-08-07 05:00] Task: CHỐT — Zoom bị ép khi chuyển tab (log [Zoom] xác nhận)
- **Log user (0455):** vào PIANO ROLL tab → `[Zoom] đổi tab ... → midi_... | zoom=32.78 | minZoom=37.54` + `[Zoom] ép lên minZoom=37.54 (zoom=32.78) tab=midi_... maxDuration=33.88` → về main/section zoom GIỮ 37.54 (không khôi phục 32.78/24.63) = "zoom reset khi chuyển tab".
- **Nguyên nhân:** `maxDuration` tính theo activeTab — PIANO ROLL tab (sub-tab) KHÔNG main/không session → `tab=undefined → max=0` → max = 12 bars buffer (33.88s) → `minZoom = viewportWidth/maxDuration` LỚN (37.54) → effect minZoom (18009) ép `setZoom(minZoom)` → zoom bị đẩy lên → quay lại main không khôi phục (state giữ giá trị bị ép).
- **FIX (app.jsx effect minZoom):** chỉ ép minZoom khi activeTab là `main` hoặc `session_*` — sub-tab (PIANO ROLL/audio) KHÔNG ép (timeline không hiển thị — ép vô nghĩa + hại). Zoom giữ nguyên khi vào/ra sub-tab.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070500), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → zoom MAIN (vd 24.6) → vào SECTION-TAB → vào PIANO ROLL → KHÔNG còn log `[Zoom] ép lên minZoom` → quay lại MAIN → zoom GIỮ NGUYÊN (không reset).

### [2026-08-07 05:05] Task: Section item — giữ kích thước đã resize (không tự đổi khi lưu SECTION-TAB) + MAIN play theo duration item
- **Yêu cầu user:** (1) resize section item trên MAIN → giữ nguyên kích thước — KHÔNG tự động thay đổi khi lưu SECTION-TAB; (2) MAIN SESSION chỉ play duration của section item — mặc kệ SECTION-TAB duration dài bao nhiêu.
- **Nguyên nhân (1):** `handleSaveSectionTab` (16732) ghi đè `duration: durationSec` (tính từ content tab) → mỗi lần lưu tab, section item tự giãn/thu theo content — mất kích thước user đã resize.
- **Trạng thái (2):** ĐÃ ĐÚNG — play MAIN clamp theo `sec.duration` (19154-19156 clip / 19191-19192 midi — activeEnd = min(..., secStart + sec.duration)) + `computeMainSessionEndTime` (254) dùng `s.start + s.duration` (item, không phải content) → playhead dừng đúng theo item. Chỉ cần fix (1) để duration không bị đổi.
- **FIX (app.jsx handleSaveSectionTab):** bỏ ghi đè `duration` (giữ nguyên kích thước item đã resize) — chỉ cập nhật name + tracks; bỏ luôn code tính `durationSec`/`maxEndTime` (dead).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070505), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → resize section item (vd 8s) → mở SECTION-TAB (content dài hơn) → Lưu section (Ctrl+S) → item trên MAIN GIỮ NGUYÊN 8s; play MAIN → chỉ nghe đúng 8s (clamp theo item — content dài hơn không phát tiếp); playhead dừng tại end item.

### [2026-08-07 05:10] Task: MAIN SESSION — playhead vẫn di chuyển khi hết item (chưa nhấn stop) — thêm log chẩn đoán
- **Báo cáo user:** MAIN SESSION — playhead vẫn di chuyển khi chưa nhấn stop; còn item → play âm; hết item → playhead vẫn chạy tiếp.
- **Phân tích:** playhead main dừng tại `projectEnd` (= computeMainSessionEndTime — end item CUỐI, theo s.start + s.duration — KHÔNG buffer). 2 khả năng: (a) hết item nhưng còn item khác dài hơn → playhead chạy qua khoảng trống (âm hết, playhead chạy) — hành vi DAW chuẩn; (b) projectEnd tính SAI (lớn hơn thực tế — item cuối không phải item thật) → playhead chạy quá.
- **FIX (app.jsx):** thêm log chẩn đoán — (1) `[Play] ... projectEnd=X.XX` (startTrackPlayback — projectEnd lúc schedule); (2) `[Stop] projectEnd reached — updatedTime=... projectEnd=...` (khi playhead tới projectEnd — xác nhận có dừng hay không).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070510), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN (section item / midi item) → đợi hết → dán log: `[Play] ... projectEnd=` (giá trị bao nhiêu — có khớp end item cuối không) + có `[Stop] projectEnd reached` không (dừng đúng hay chạy quá).

### [2026-08-07 05:15] Task: MAIN SESSION — playhead chạy qua khoảng im sau khi hết content section item
- **Log user (0510):** `[Stop] projectEnd reached — updatedTime=22.60 projectEnd=22.59` — playhead DỪNG ĐÚNG tại projectEnd — nhưng projectEnd = 22.59 (duration section item — resize thủ công) trong khi CONTENT thực trong tab ngắn hơn (~8s) → im lặng 8→22.59s → user thấy "hết item mà playhead vẫn di chuyển".
- **Nguyên nhân:** projectEnd (main) = computeMainSessionEndTime — section contribution = `s.start + s.duration` (duration ITEM — không phải content thực).
- **FIX (app.jsx projectEnd useMemo):** section contribution = `s.start + min(s.duration, CONTENT end từ tab)` — content ngắn hơn item → projectEnd rút về end content (dừng sớm — hết âm); content dài hơn item → giữ item duration (0505 — MAIN chỉ play duration item). Chỉ áp dụng khi section item là end cuối (max hiện tại).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070515), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN (section item duration 22.59s, content 8s) → playhead DỪNG tại ~8s (hết content — log `[Stop] projectEnd reached — projectEnd≈8`); content dài hơn item → dừng tại item end (cắt như 0505).

### [2026-08-07 05:20] Task: projectEnd chỉ kích hoạt khi nút LOOP bật — play loop liên tục
- **Yêu cầu user:** projectEnd chỉ được kích hoạt khi nút LOOP được bật (cho phép dự án play loop liên tục). LOOP tắt → play 1 lần tới end items (KHÔNG cắt sớm theo content — bỏ hiệu ứng 0515 khi loop tắt).
- **FIX (app.jsx):**
  (1) `projectEnd` useMemo — content-min (0515: section end = sec.start + min(item duration, content end)) CHỈ áp dụng khi `isLoopingSelection` (deps thêm isLoopingSelection). LOOP TẮT → projectEnd = computeMainSessionEndTime thuần (end items — play đủ, dừng tại đó).
  (2) Nút LOOP transport (25838-25848): BỎ auto-derive selection 0→end items — LOOP bật không selection → loop tại projectEnd (nhánh updatePlayhead `updatedTime >= projectEndRef` loop lại từ 0 — liên tục). User có selection riêng → giữ loop vùng chọn.
- **Hành vi:** LOOP TẮT → playhead chạy tới END ITEMS (22.59) rồi dừng (không cắt 8s content). LOOP BẬT → playhead tới projectEnd (content end ~8s) → LOOP LẠI từ 0 → play liên tục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070520), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → (1) LOOP TẮT → play MAIN (section 22.59s, content 8s) → play tới 22.59 mới dừng; (2) bấm LOOP → play → loop liên tục tại ~8s (log `[Stop] projectEnd reached — projectEnd≈8` + loop lại từ 0).

### [2026-08-07 05:25] Task: Tăng size Time display (toolbar) + tăng statusbar 150%
- **Yêu cầu user:** tăng size ô hiển thị thời gian (playhead/tổng) trên toolbar; tăng kích thước status bar lên 150%.
- **FIX (app.jsx):**
  (1) Toolbar — ô `Time: <playhead> / <total>`: `text-[14px]` → `text-[20px]` (4 spans: label, playhead, "/", total).
  (2) Statusbar — container `h-6` → `h-9` (36px = 150%), `text-xs` → `text-[18px]`; các badge trạng thái (Local Sel/Global Sel/Solo Loop/Master Loop) `text-xs` → `text-[18px]`; Bars selection (Bars/inputs/♪T) + Time selection (Start/End/Len): `text-xs` → `text-[18px]`, input `w-10` → `w-[60px]`, `w-16` → `w-24`, divider `h-4` → `h-6`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070525), `wiki.md`. Rebuild precompiled (build PASS — text-[20px] ×4, text-[18px] ×18, w-[60px] ×7, h-9 ✓).
- **Ghi chú/Test:** hard refresh → toolbar Time to rõ (20px); statusbar cao hơn + chữ/input 18px (150%).

### [2026-08-07 05:30] Task: Quét chọn (tempo track) không cập nhật bars hiển thị dưới statusbar
- **Báo cáo user:** quét ở tempo track để select duration → vị trí quét + số bars được chọn KHÔNG hiển thị ở bars dưới status bar.
- **Nguyên nhân:** beginBar/endBar/numberBar CHỈ được set từ onChange của input (nhập tay — dock panel cũ + statusbar mới) — KHÔNG có code cập nhật từ sweep/selection → statusbar luôn hiển thị 0/0/1 khi quét.
- **FIX (app.jsx):** thêm effect `[selectionStart, selectionEnd, bpm]` — khi selection hợp lệ (end > start): beginBar = floor(start / secPerBar); endBar = ceil(end / secPerBar) − 1; numberBar = endBar − beginBar + 1 → quét xong bars statusbar hiển thị đúng vị trí + số bar.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070530), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → quét chọn trên tempo track (vd từ bar 2 tới bar 5) → statusbar hiển thị Bars: 2 - 5 # 4; xóa selection → giữ giá trị cuối (không tự reset).

### [2026-08-07 05:35] Task: Bars hiển thị sai chỉ số — quét 8 bars hiển thị "0 đến 7"
- **Báo cáo user:** số bars đúng (8) nhưng chỉ số sai: quét bar 0→8 hiển thị "0 đến 7" — cần "0 đến 8".
- **Nguyên nhân:** effect (0530) tính `endBar = ceil(end/spb) − 1` (bar cuối INCLUSIVE) → 7; numberBar = endBar − beginBar + 1 = 8.
- **FIX (app.jsx):**
  (1) Effect: `endBar = ceil(end/spb)` (KHÔNG −1 — bar KẾT THÚC vùng quét/chạm vạch); `numberBar = ceil − floor` (KHÔNG +1) → quét 8 bars → "0 đến 8 # 8".
  (2) Input End Bar (dock 26436 + statusbar 27846): `setSelectionEnd(t + spb)` → `setSelectionEnd(t)` (selection tới vạch bar b — khớp hiển thị); `setNumberBar(b − beginBar)`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070535), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → quét 8 bars từ 0 → BARS hiển thị "0 - 8 # 8"; nhập End Bar = 8 → selection tới vạch 8 (8 bars).

### [2026-08-07 05:40] Task: MAIN SESSION — play section item có âm nhưng track VU không hiển thị
- **Báo cáo user:** play section item ở MAIN — âm phát ra (track) nhưng VU meter không nhảy.
- **Phân tích:** MIDI section (FluidSynth SF) đi thẳng masterBus.input (midiAudible main rỗng → setOutputDestination(null)) — KHÔNG qua node track/sub-node analyser → audioPeak = 0 — VU track chỉ nhảy qua `triggerMidiVuActivity` (midiVuActivityRef). Trigger cũ (19511-19518) VẪN CÒN (fire lúc schedule — không setTimeout cho note delayed). Chưa rõ vì sao VU đứng yên — cần log.
- **FIX (app.jsx):** (1) bổ sung trigger VU cho nhánh note DELAYED (setTimeout khớp nhịp — subTrack.id + track.id) — nhánh instant đã có; (2) thêm log chẩn đoán VU tick: `[VU] <trackId> audio=... midi=... vNode=... analyser=... canvas=...` (rate-limited ~1.3 lần/giây).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070540), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (section item MIDI) → dán log `[VU]` — xác định: midi= có >0 không (trigger fire?); vNode/analyser/canvas có tồn tại không.

### [2026-08-07 05:45] Task: Track VU vẫn diễn animation khi không còn âm (MAIN/SECTION-TAB)
- **Báo cáo user:** track VU meter không diễn đúng — hết âm vẫn nhảy animation.
- **Phân tích:** VU track (MIDI section) chỉ nhảy qua `midiVuActivityRef` (trigger fire lúc schedule). Trigger cũ fire NGAY lúc schedule (kể cả note delay xa — VU nhảy trước âm / note bị cắt vẫn flash) + TRÙNG với trigger mới (05:40) → VU nhảy sai nhịp/kéo dài. Decay 0.9/frame (~0.75s) — sau note cuối VU còn diễn ~0.75s.
- **FIX (app.jsx):**
  (1) XÓA trigger cũ fire-ngay (sau block — chạy cho mọi note cả delayed) — chỉ giữ trigger mới: nhánh delayed (setTimeout delay*1000 — khớp nhịp) + nhánh instant.
  (2) Decay nhanh: `midiPeak * 0.90` → `0.72` (tắt sau ~0.35s — hết note → VU dừng nhanh).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070545), `wiki.md`. Rebuild precompiled (build PASS — triggerMidiVuActivity còn 8 chỗ).
- **Ghi chú/Test:** hard refresh → play MAIN (section MIDI) → hết notes → VU dừng trong ~0.35s; nếu VU VẪN nhảy khi hết âm → dán log `[VU]` (midi= giá trị bao nhiêu khi không còn âm).

### [2026-08-07 05:50] Task: VU vẫn animation khi hết âm — log nguồn trigger [VUTrig]
- **Log user (0545):** âm ngừng nhưng `[VU] 1 audio=0.000 midi=0.058` → `midi=0.661` — midiPeak TĂNG LẠI → triggerMidiVuActivity vẫn fire định kỳ sau khi hết âm.
- **FIX (app.jsx):** thêm log `[VUTrig] <trackId> vel=... peak=... play=<isPlaying> tab=<activeTab>` vào `triggerMidiVuActivity` (rate-limited 1/20 fire) — xác định AI còn fire (setTimeout sót từ main-midi delayed? re-schedule? keyboard preview?).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070550), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (hết âm — VU vẫn nhảy) → dán log `[VUTrig]` — cho biết track nào + lúc nào (play=?) vẫn fire.

### [2026-08-07 05:55] Task: CHỐT — VU vẫn nhảy khi hết âm = setTimeout VU trigger sót sau stop
- **Log user (0550):** sau STOP (`[Play] click isPlaying=true` → stop) — `[VUTrig] 1 vel=0.83/0.78/0.95 ... play= false` — **trigger VU vẫn fire sau khi play đã dừng**.
- **Nguyên nhân:** setTimeout VU trigger của note DELAYED (main-midi 19351 + section delayed — khôi phục 05:40) KHÔNG bị hủy khi stop → fire MUỘN sau stop → midiVuActivity set lại → VU nhảy (decay 0.72 vẫn chưa kịp về 0).
- **FIX (app.jsx):** thêm `isPlayingRef` (App — sync mỗi render) + guard `if (!isPlayingRef.current) return;` ở ĐẦU 2 setTimeout VU trigger (main-midi + section delayed) — stop → isPlaying=false → setTimeout sót không fire → VU dừng ngay khi hết âm/stop.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070555), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (MIDI) → STOP giữa chừng → log `[VUTrig]` KHÔNG còn fire sau stop (chỉ fire lúc play= true); hết bài tự nhiên → VU dừng trong ~0.35s.

### [2026-08-07 06:00] Task: VU vẫn nhảy khi playhead ngoài vùng âm — log nguồn peak>0
- **Báo cáo user:** VU vẫn diễn animation khi playhead di chuyển ra khỏi vùng có âm. Log 0555: track VU `midi=0.000` liên tục (đã dừng đúng) — NHƯNG user vẫn thấy nhảy → nguồn KHÁC (master VU? key khác? peak giữa 2 log rate-limited?).
- **FIX (app.jsx):** đổi log chẩn đoán — (1) `[VU] <trackId> audio=... midi=... key=<key>` CHỈ in khi peak > 0.001 (VU nhảy thật — không rate-limit); (2) thêm `[VU-master] peak=...` (master VU khi nhảy) — xác định VU nào còn nhảy ngoài vùng âm.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070600), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN → playhead ra khỏi vùng âm → nếu VU vẫn nhảy → dán log `[VU]`/`[VU-master]` xuất hiện lúc đó (cho biết track/key/nguồn audio hay midi).

### [2026-08-07 06:05] Task: SECTION-TAB — VU nhảy theo velocity trigger (giả) — đọc âm THỰC từ SF output
- **Log user (0600):** SECTION-TAB — click playhead tới vị trí không âm → VU vẫn nhảy: `[VU] 1 midi= 0.98 → 0.85 → ...` (tăng lại theo chu kỳ — trigger fire lúc schedule/re-schedule) nhưng `[VU-master] peak= 0.008` (âm thực gần im) — **VU GIẢ (velocity trigger), không theo âm thực**. Master VU nhảy nhẹ 0.008 liên tục (noise nền).
- **FIX (app.jsx VU tick):**
  (1) Tap analyser trên SF output (`SonicSF.getOutputNode()` — âm MIDI THỰC post-FluidSynth) — `sfAudioPeak` gộp vào audioPeak — VU MIDI theo âm THỰC.
  (2) Khi SF analyser đã tap → `midiPeak = 0` (bỏ velocity trigger giả) — hết note → SF im → VU 0 ngay (không nhảy giả sau click playhead/re-schedule).
  (3) Master VU deadband: `peak < 0.01 → 0` — hết noise nền 0.008.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070605), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → SECTION-TAB play → click playhead tới vị trí không âm → VU KHÔNG nhảy (SF im → 0); vị trí có âm → VU nhảy theo âm thực; master VU không nhảy noise.

### [2026-08-07 06:10] Task: VU vẽ đoạn ngắn (0.008) khi hết âm — deadband track analyser
- **Log user (0605):** `[VU] 1 audio= 0.008 midi= 0.000` — VU vẽ đoạn nhỏ (~-42dB) dù không âm — noise nền trên track analyser (như master 0.008 — đã deadband 06:05).
- **FIX (app.jsx):** track audioPeak deadband — `if (audioPeak < 0.01) audioPeak = 0;` (sau khi gộp sfAudioPeak) — hết âm → VU tắt NGAY (peak 0 → db -60 → vẽ 0).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070610), `wiki.md`. Rebuild precompiled (build PASS — Deadband ×2: master + track).
- **Ghi chú/Test:** hard refresh → play → playhead ra khỏi vùng âm → VU TẮT NGAY (không còn đoạn 0.008); log `[VU]` không còn audio=0.008.

### [2026-08-07 06:15] Task: PIANO ROLL restored (phiên trước) — playhead đứng im khi play MAIN/SECTION
- **Báo cáo user:** đang play MAIN/SECTION → mở lại PIANO ROLL TAB đã mở từ phiên làm việc trước (restored từ project) → playhead đứng im — không đồng bộ vị trí play.
- **Nguyên nhân:** tab restored KHÔNG có `buffer` (AudioBuffer không serialize được) → nhánh updatePlayhead sub-tab `if (!st || !st.buffer) return;` → return sớm → playhead không cập nhật.
- **FIX (app.jsx updatePlayhead nhánh sub-tab):** `if (!st.buffer && st.type !== 'PIANO_ROLL') return;` — PIANO ROLL không cần buffer (chỉ hiển thị playhead — đồng bộ theo vị trí play MAIN/SECTION); `startSubTabPlayback` tự tạo silent buffer khi play. Audio sub-tab vẫn cần buffer (giữ return cũ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070615), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → load project có piano roll tab mở sẵn → play MAIN/SECTION → mở lại tab piano roll → playhead DI CHUYỂN đúng vị trí play (đồng bộ); bấm play trong tab → vẫn play bình thường (silent buffer tự tạo).

### [2026-08-07 06:20] Task: Mở PIANO ROLL restored lúc MAIN đang play — âm lag ~1s
- **Báo cáo user:** đang play MAIN/SECTION → mở lại PIANO ROLL TAB (restored phiên trước) → âm thanh bị lag ~1s rồi mới phát tiếp.
- **Nguyên nhân:** effect đổi tab → `updateSfRouting` nhánh PIANO_ROLL → `setOutputDestination` (SF _gainNode FluidSynth) — disconnect + reconnect khi âm đang phát → âm MIDI đang kêu bị NGẮT (dedupe chỉ chặn khi đích KHÔNG đổi — đổi masterBus→node piano roll = swap → ngắt).
- **FIX (app.jsx updateSfRouting):** nhánh PIANO_ROLL thêm điều kiện `&& !isPlayingRef.current` — MAIN/SECTION đang play → BỎ QUA nhánh piano (giữ SF route hiện tại — âm tiếp tục); chỉ route vào node piano roll khi KHÔNG play (preview tab — handlePlayPause sub-tab tự stopAllPlayback trước).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070620), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (có âm MIDI) → mở lại piano roll tab restored → ÂM KHÔNG BỊ NGẮT (phát liên tục — không lag); bấm play trong piano roll tab (không play main) → route SF vào node piano roll như cũ (nghe FX track).

### [2026-08-07 06:25] Task: FIX hồi quy 0620 — âm nhỏ/dry khi mở piano roll restored lúc main play
- **Báo cáo user:** bản cũ (0615) lag nhưng âm TO (qua mastering); bản 0620 hết lag nhưng âm NHỎ/không rõ (mất bypass sai).
- **Nguyên nhân:** fix 0620 skip nhánh PIANO_ROLL → rơi xuống nhánh midiAudible (list = section clones khi activeTab=midi_*) → node lookup trúng NODE MAIN '1' (sfEntry) → SF (âm main đang phát) ĐỔI ĐÍCH → sfEntry main → sfRouteGain theo ♪ main (bypass) → âm DRY/nhỏ.
- **FIX (app.jsx updateSfRouting):** guard ĐẦU HÀM — `isPlayingRef.current && activeTab là sub-tab (midi_*/không main/không session)` → RETURN NGAY (KHÔNG đổi SF destination) — âm main đang phát giữ route đã set đúng lúc schedule (masterBus.input — qua mastering — TO). Hết lag (không swap) + hết bypass sai (không route vào node main).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070625), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (âm MIDI TO — qua mastering) → mở lại piano roll tab restored → ÂM VẪN TO (không nhỏ/dry) + KHÔNG lag; bấm play trong piano roll (main không play) → route SF vào node tab như cũ.

### [2026-08-07 06:30] Task: Mở PIANO ROLL vẫn lag (ngắn hơn, 2 lần) — ghost extract block main thread
- **Báo cáo user:** sau 0625 (SF route guard) vẫn lag khi chuyển sang piano roll restored — ngắn hơn + âm lag 2 lần.
- **Nguyên nhân:** PianoRollTabEditor mount → `ghostLayers` useMemo → `extractGhostLayers(activeTracks,...)` duyệt TOÀN BỘ project (tracks + notes) — block main thread 100-500ms → noteon FluidSynth (scheduled qua setTimeout) TRỄ → âm lag (2 noteon trễ = "lag 2 lần").
- **FIX (app.jsx PianoRollTabEditor ghostLayers):** đang play (`isPlaying` prop — main/section) → return [] (BỎ ghost — chỉ là hiển thị tham chiếu, không cần khi âm chạy) — không block → âm không lag. Khi dừng play → tính ghost bình thường (không âm — block OK). Deps thêm isPlaying.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070630), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN → mở piano roll tab restored → ÂM KHÔNG LAG (to, liên tục); dừng play → ghost notes hiển thị lại bình thường.

### [2026-08-07 06:35] Task: CHỐT VU MIDI — analyser tap bị disconnect() ngắt → VU không nhảy khi có âm
- **Báo cáo user:** (1) [lặp] click playhead → vị trí không âm → VU vẫn diễn (log cũ 0600 — velocity trigger giả); (2) MỚI — play MIDI trên track → KHÔNG có VU animation.
- **Nguyên nhân (chốt cả 2):** VU tick (0605) tap analyser trên `_gainNode` SF (âm MIDI thực) — NHƯNG `setOutputDestination` (soundfontPlayer.js 85) gọi `_gainNode.disconnect()` KHÔNG đối số → ngắt MỌI kết nối KỂ CẢ analyser tap → `sfAudioPeak` = 0 MÃI → (2) VU MIDI không nhảy khi có âm; và (1) `_sfTapped` vẫn true → velocity giả bị bỏ nhưng âm thực = 0 → VU chết hoàn toàn.
- **FIX (soundfontPlayer.js):** `setOutputDestination` — sau `_gainNode.connect(dest)` → `if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);` — analyser sống → sfAudioPeak đọc âm MIDI THỰC → VU nhảy khi có âm ✓; velocity giả đã bỏ (midiPeak=0 khi tapped) → hết nhảy giả khi click playhead vị trí không âm ✓.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (bump ?v=202608070635 trong index.html), `wiki.md`. (app.precompiled giữ 0630 — không đổi logic app.jsx).
- **Ghi chú/Test:** hard refresh → (1) play MIDI track → VU nhảy theo âm thực; (2) click playhead tới vị trí không âm → VU tắt ngay; (3) mở piano roll tab → âm to không lag.

### [2026-08-07 06:40] Task: Vào SECTION-TAB lúc MAIN play → CHỈ play section items (tắt âm MAIN items)
- **Yêu cầu user:** play MAIN → di chuyển sang SECTION-TAB → ONLY play items của SECTION-TAB — tắt âm thanh của MAIN items.
- **FIX (app.jsx effect đổi tab 14814):** prevTab='main' → activeTab=`session_*` + đang play (`isPlaying`) → `stopAllPlayback()` (dừng MAIN items) + `startTrackPlayback(_pt)` với activeTracks = section clones (activeTab mới) — play CHỈ section tab items. `_pt` = vị trí play chuyển sang LOCAL của tab (trừ `secStart` — vị trí section trên main — tìm qua `tracks[].sections[]` bằng sectionId) — section items dùng time local 0-based.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070640), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (có main items + section item) → vào SECTION-TAB → CHỈ nghe section items (âm main items tắt — log `[SectionTab] play CHỈ section items tại local X.XX (secStart Y.YY)`); playhead section chạy từ vị trí tương ứng.

### [2026-08-07 06:45] Task: Tắt âm MAIN items (vào SECTION-TAB) → VU phải tắt theo
- **Yêu cầu user:** 0640 đã tắt âm MAIN items khi vào SECTION-TAB ✓ — NHƯNG VU animation của âm items đó VẪN diễn — cần tắt VU.
- **Nguyên nhân:** `midiVuActivityRef` (velocity trigger) còn giá trị cũ — decay 0.72/frame (~0.35s) sau stop → VU main items "diễn" tiếp dù âm đã tắt.
- **FIX (app.jsx stopAllPlayback):** clear `midiVuActivityRef.current = {}` NGAY khi stop — VU tắt tức thì (không decay từ âm cũ). VU sau đó chỉ theo âm THỰC (SF analyser — fix 0635): tab items phát → VU nhảy; im → VU 0.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070645), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN (âm main items) → vào SECTION-TAB → âm main tắt + VU main tắt NGAY (không diễn tiếp); tab items phát → VU tab nhảy theo âm thực.

### [2026-08-07 06:50] Task: VU track nhảy nhẹ (0.016/0.023) khi không âm — noise SF analyser
- **Log user (0645):** track 1 + 2 `audio= 0.016/0.023` (CÙNG giá trị — sfAudioPeak — SF analyser noise nền) nhảy nhẹ dù không âm track (master 0.25 = âm thật clip — track VU không liên quan); deadband 0.01 KHÔNG chặn 0.016.
- **FIX (app.jsx):** deadband track `audioPeak < 0.02 → 0` (nâng từ 0.01) — chặn noise SF 0.016/0.023 — VU tắt khi âm < 0.02 (≈ -34dB — âm thật thường lớn hơn).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070650), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play → vị trí không âm → VU track KHÔNG nhảy (0.016 bị chặn); có âm thật (> 0.02) → VU nhảy; master VU giữ deadband 0.01 (âm thật nhỏ vẫn hiển thị).

### [2026-08-07 06:55] Task: VU track = noise (0.023) khi âm MIDI thật 0.25 — guard SF block skip
- **Log user (0650 — SECTION-TAB):** track 1+2 `audio= 0.023/0.031` (noise vNode analyser) TRONG KHI `[VU-master] peak= 0.25` (âm MIDI thật phát — noteon) — VU track không theo âm thật.
- **Nguyên nhân:** guard `if (audioPeak <= 0.001)` quanh block đọc SF analyser — vNode noise 0.023 > 0.001 → block SF SKIP → sfAudioPeak = 0 → VU = noise vNode 0.023 (nhảy nhẹ mãi) — âm MIDI thật (SF → masterBus 0.25) không được đọc.
- **FIX (app.jsx):** (1) LUÔN đọc SF analyser (bỏ guard — sfAudioPeak gộp max vào audioPeak) — VU MIDI theo âm thật; (2) deadband `0.02 → 0.03` — chặn noise 0.023-0.031.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070655), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → chuyển SECTION-TAB (play) → VU track theo âm thật (MIDI 0.25 — nhảy đúng); vị trí không âm → VU tắt (noise 0.023 bị deadband 0.03 chặn).

### [2026-08-07 07:00] Task: Track VU nhảy CÙNG NHAU — bỏ sfAudioPeak gộp chung
- **Log user (0655):** track 1 + 2 `audio= 0.031/0.039` — CÙNG GIÁ TRỊ — nhảy cùng nhau. User muốn: track CÓ âm → nhảy; KHÔNG âm → không (PHÂN BIỆT track).
- **Nguyên nhân:** sfAudioPeak (SF = 1 output CHUNG cho mọi track MIDI) gộp vào audioPeak mọi track → track VU cùng giá trị/nhảy cùng nhau; track audio cũng bị SF chi phối.
- **FIX (app.jsx VU tick):** (1) KHÔNG gộp sfAudioPeak vào audioPeak — audioPeak chỉ theo âm TRACK thật (vNode/sub-node analyser — clip audio phân biệt); (2) MIDI track VU = velocity trigger (midiVuActivity — PHÂN BIỆT track — track nào có note → nhảy) + GATE theo âm SF THỰC (sfAudioPeak < 0.03 → midiPeak = 0 — SF im = hết note/click playhead vị trí không âm → VU tắt ngay — hết nhảy giả).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070700), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play SECTION-TAB → track MIDI (note) nhảy theo velocity khi SF phát; track audio (clip) nhảy theo analyser riêng; track không âm → đứng yên (KHÔNG cùng nhau); click playhead vị trí không âm → VU tắt ngay (SF im → gate).

### [2026-08-07 07:05] Task: Track không item VU vẫn nhảy + VU không đúng nhịp
- **Log user (0700):** [VU] 1 midi= velocity (0.66→1.0 — gate SF mở ✓); track 2 không log (VU 0 ✓). 2 vấn đề: (1) track KHÔNG item vẫn nhảy nhẹ — noise vNode 0.031-0.039 qua deadband 0.03; (2) velocity decay 0.72/frame quá nhanh — VU nhấp nháy không theo âm (sustain) — "không đúng nhịp".
- **FIX (app.jsx VU tick):**
  (1) Deadband audioPeak `0.03 → 0.04` — chặn noise 0.031-0.039 — track không item → VU đứng yên.
  (2) MIDI VU: bỏ decay nhanh — GATE + GIỮ theo âm SF thực: SF đang phát (sfAudioPeak ≥ 0.03) → midiPeak = velocity (VU theo note, giữ trong lúc âm — đúng nhịp); SF im → midiPeak = 0 + clear midiVuActivityRef (VU tắt ngay).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070705), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play SECTION-TAB → track có note → VU sáng theo note + giữ khi âm kêu (đúng nhịp); track không item → đứng yên; hết âm/click playhead vị trí không âm → VU tắt ngay.

### [2026-08-07 07:10] Task: Note đơn/velocity thấp không có VU — bỏ gate amplitude SF
- **Báo cáo user:** lọc noise quá khắt khe — play note đơn (velocity thấp) → KHÔNG có VU meter (âm SF nhỏ < gate 0.03 → midiPeak = 0).
- **FIX (app.jsx VU tick):**
  (1) BỎ gate amplitude SF (sfAudioPeak < 0.03 → midiPeak = 0) — MIDI VU = velocity trigger (fire ĐÚNG NHỊP note: nhánh delayed setTimeout + nhánh instant) — note đơn/velocity thấp → VU nhảy theo velocity ✓.
  (2) Decay chậm 0.85 (~1s) — note đơn hiển thị rõ, không nhấp nháy quá nhanh (đúng nhịp âm).
  (3) Bỏ luôn block đọc SF analyser (không còn dùng — tiết kiệm CPU/frame).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070710), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play note đơn (velocity thấp) → VU NHẢY (theo velocity); nhóm note → VU nhảy; không âm (vị trí im) → VU tắt sau ~1s; track không item → đứng yên (deadband audioPeak 0.04 — chỉ chặn noise track, không ảnh hưởng MIDI velocity).

### [2026-08-07 07:15] Task: Track 2 section (không item) VU vẫn nhảy + decay quá lâu
- **Báo cáo user:** MAIN — track 1 section item, track 2 midi item. Vào SECTION-TAB — track 1 có midi item, track 2 (cùng duration) KHÔNG item — NHƯNG VU track 2 vẫn diễn; mức diễn không đúng velocity/decay.
- **Nguyên nhân:** (1) noise vNode analyser dao động quanh 0.04 — qua deadband 0.04 → track không item VU nhảy nhẹ (không theo velocity — lung tung); (2) decay 0.85 (~1s) quá lâu — sau hết âm VU "vẫn diễn" ~1s.
- **FIX (app.jsx VU tick):** (1) deadband audioPeak `0.04 → 0.05` — chặn noise quanh 0.04 — track không item đứng yên; (2) decay `0.85 → 0.75` (~0.5s) — note đơn thấy rõ nhưng tắt nhanh sau hết note; (3) log `[VU]` in MỌI key (kể cả peak 0 — rate ~1.3/s — kèm key/peak) — xác nhận nguồn.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070715), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play MAIN → vào SECTION-TAB → track 2 (không item) VU ĐỨNG YÊN; track 1 VU theo velocity + tắt ~0.5s sau hết note; nếu track 2 VẪN nhảy → dán log `[VU]` (audio= bao nhiêu — noise hay midi= còn giá trị).

### [2026-08-07 07:20] Task: TÁCH VU meter MAIN SESSION vs SECTION-TAB (item tab nào → VU tab đó)
- **Yêu cầu user:** tách biệt VU meter MAIN vs SECTION-TAB — item của tab nào thì play VU của tab đó (gốc rễ: section clone id TRÙNG main id → VU canvas/trigger key lẫn nhau).
- **FIX (app.jsx):**
  (1) Track strip VU canvas — section clone (`track._isSectionClone`) dùng key `'_sess_' + track.id` (main giữ `track.id`).
  (2) VU tick — parse key: `_sess_<id>` → trackId = id thuần (node lookup), vuKey = key đầy đủ (midiVuActivityRef theo context).
  (3) Trigger MIDI (startTrackPlayback — 4 chỗ main/section clones): fire key theo CONTEXT — activeTab session → `'_sess_' + id`; main → id thuần — mixer/main strip (key id) không nhận trigger section; section strip (key _sess_<id>) chỉ nhận trigger section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070720), `wiki.md`. Rebuild precompiled (build PASS — _sess_ ×8).
- **Ghi chú/Test:** hard refresh → MAIN play (main items) → VU main nhảy; vào SECTION-TAB (section items play) → VU section nhảy — VU main (mixer) ĐỨNG (không nhận trigger section); track section không item → đứng yên.

### [2026-08-07 07:25] Task: Cut/Copy MIDI item (context menu) → paste ra audio item (SAI)
- **Báo cáo user:** Cut MIDI item bằng context menu → paste ra AUDIO item (track có midi item vị trí 1 + audio item vị trí 2) — copy nhầm.
- **Nguyên nhân:** context menu chỉ biết TRACK (contextMenu = {trackId, time, sectionId}) — Copy/Cut (17542/17593) chỉ xử lý track buffer (audio) → cut midi item → copy nhầm buffer track → paste = audio item.
- **FIX (app.jsx):**
  (1) TimelineTrack onContextMenu — detect ITEM dưới chuột: MIDI item (midiItems + duration bounds) + clip (clips + buffer) → truyền `itemId` + `itemType` ('midi'/'clip').
  (2) handleContextMenu — nhận itemId/itemType → lưu vào contextMenu state.
  (3) contextMenuCopy/Cut — xử lý ITEM TRƯỚC track: MIDI → clipboard {type:'midi', notes, duration(sec), name, color} — paste = MIDI item (doPaste nhánh midi đã có); clip → clipboard {buffer,...}; Cut = copy + XÓA item (updateActiveTracks filter).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070725), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → chuột phải MIDI item → Cut → Paste → ra MIDI ITEM (không phải audio); chuột phải clip → Copy → Paste → clip; chuột phải track (không item) → Copy → track buffer như cũ.

### [2026-08-07 07:30] Task: (1) Lưu project phải lưu tất cả tab — XÁC NHẬN đã đúng (2) Chuyển PIANO ROLL tab → stop âm tab cũ
- **Yêu cầu (1):** ở MAIN bấm lưu/Ctrl+S phải lưu tất cả các tab.
- **Xác nhận:** serializeProjectToSchema ĐÃ lưu đủ — sessionTabs → section_store (tracks đầy đủ) + sub_tabs (id/trackId/target_id/parent_tab_id/notes/currentTime); deserialize restore đủ (9327-9358). Ctrl+S (16295) + nút Save Project (25368) → handleSaveProject → serialize đủ. KHÔNG cần sửa — nếu user thấy thiếu tab sau load → dán schema đã lưu.
- **Yêu cầu (2):** chuyển PIANO ROLL TAB 1 → TAB 2 phải play đúng nội dung từng tab.
- **Nguyên nhân:** rời piano roll tab đang play (luật 04:15 "không stop") → sang tab 2 → âm tab 1 tiếp tục (nghe nhầm nội dung tab 1 khi ở tab 2).
- **FIX (app.jsx effect 14814):** prevSub PIANO_ROLL đang play + activeTab MỚI là SUB-TAB (midi_*/không main/session) → stopAllPlayback (dừng âm tab cũ — play tab mới đúng nội dung). Về MAIN/SECTION → giữ luật cũ (âm tiếp/resume).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070730), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → play piano roll tab 1 → chuyển tab 2 → ÂM TAB 1 DỪNG (im — bấm play tab 2 → nghe tab 2); về MAIN → hành vi cũ.

### [2026-08-07 07:35] Task: (1) Lưu ở MAIN phải lưu dirty tabs (2) Xóa midi item → close piano roll tab tương ứng
- **Báo cáo user:** (1) nhấn lưu ở MAIN SESSION không lưu dirty tab (PIANO ROLL đã sửa notes); (2) xóa midi item phải close PIANO ROLL TAB đã mở của item đó.
- **Nguyên nhân (1):** keydown Ctrl+S handler thứ 2 (16388) — nhánh `curTab === 'main'` RỖNG ("handled by main handler" — không làm gì) → MAIN Ctrl+S không save project + không lưu dirty; nút menu Save Project (25370) chỉ gọi handleSaveProject (không lưu dirty).
- **FIX (app.jsx):**
  (1) Ctrl+S (16388): nhánh main/session → `handleSaveProject()` + save TẤT CẢ dirty sub-tabs (PIANO_ROLL → handleSaveMidiNotes; SECTION → saveSectionTab; audio → buffer về track); nút Save Project → cùng logic.
  (2) Xóa midi item (contextMenuDelete 17480 + contextMenuCut 17666) → `setSubTabs(filter target_id !== itemId)` — close piano roll tab của item; nếu tab đang active bị close → `setActiveTab('main')`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070735), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → sửa notes piano roll (dirty) → về MAIN → Ctrl+S / Save Project → reload → notes ĐÃ LƯU (tab vẫn mở + nội dung mới); xóa midi item → piano roll tab của item đó tự close (+ về main nếu đang active tab đó).

### [2026-08-07 07:40] Task: SECTION-TAB — xóa midi item (Delete key) → close piano roll tab tương ứng
- **Báo cáo user:** trong SECTION-TAB, xóa midi item phải close PIANO ROLL TAB đã mở của item đó.
- **Nguyên nhân:** đường xóa chính là `handleDeleteSelectedItemsRef` (Delete key — chọn item → Delete) — xóa midiItems nhưng KHÔNG close subTab (target_id = item id) — áp cho cả MAIN lẫn SECTION (updateActiveTracks theo context).
- **FIX (app.jsx handleDeleteSelectedItemsRef):** đầu hàm — `setSubTabs(filter: bỏ tab có target_id ∈ idsToDelete)` — close piano roll tab của midi item bị xóa; tab đang active bị close → `setActiveTab('main')`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070740), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → SECTION-TAB chọn midi item → Delete → piano roll tab của item đó TỰ CLOSE (về main nếu đang ở tab đó); MAIN tương tự.

### [2026-08-07 07:45] Task: (1) FIX Cut midi → paste audio (detect bounds sai) (2) Xóa item SECTION-TAB → sync canvas main + giữ tab
- **Báo cáo (1) [lặp]:** cut midi item (context menu) → paste ra audio item — fix 0725 chưa hiệu lực.
- **Nguyên nhân (1):** onContextMenu detect MIDI bounds dùng `(m.duration || 4)` KHÔNG × secondsPerBar (m.duration tính BEATS — contextMenuDelete 17452 có ×) → detect miss → itemType null → Cut/Copy xử lý TRACK buffer (audio). **FIX:** bounds × `_spb` (60/bpm*4).
- **Báo cáo (2) [mới]:** xóa item trong SECTION-TAB → phải cập nhật canvas section item ở MAIN + giữ vị trí SECTION-TAB (không quay về MAIN khi close tab).
- **FIX (2):** (a) helper `syncSectionTabToMain(tabId)` — sync `tab.tracks` → `sec.tracks` trên MAIN (canvas section item vẽ lại — KHÔNG đổi duration 0505); gọi sau mọi xóa item (Delete key handleDeleteSelectedItemsRef + contextMenuDelete: section/midi/clip + contextMenuCut). (b) điều kiện "về main" khi close tab — CHỈ khi activeTab là `midi_*` (piano roll) bị close — KHÔNG đá SECTION-TAB về main.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070745), `wiki.md`. Rebuild precompiled (build PASS — syncSectionTabToMain ×6).
- **Ghi chú/Test:** hard refresh → (1) chuột phải MIDI item (track có midi + audio) → Cut → Paste → MIDI item (không audio); (2) SECTION-TAB xóa item → section item trên MAIN vẽ lại (nội dung mới) + VẪN Ở SECTION-TAB (không về main).

### [2026-08-07 07:50] Task: (2) Sync canvas MAIN realtime sau cut/delete SECTION-TAB (3) Ctrl+X/C phải cut/copy ITEM
- **Báo cáo user:** (2) cut/delete item SECTION-TAB 1 → quay MAIN → phải cập nhật realtime canvas section item 1 (không cần save); (3) Ctrl+X không cut được item.
- **Nguyên nhân:** (2) syncSectionTabToMain (0745) chạy NGAY (TRƯỚC updateActiveTracks — state chưa áp) → sync data CŨ → canvas cũ; (3) Ctrl+C/X (16344/16349) LUÔN handleCopyTrack/handleCutTrack (track) — không xử lý item được chọn.
- **FIX (app.jsx):**
  (2) syncSectionTabToMain — gọi qua `setTimeout(0)` (SAU updateActiveTracks — data mới) ở mọi điểm xóa (handleDeleteSelectedItemsRef + contextMenuDelete section/midi/clip + contextMenuCut).
  (3) Ctrl+C/X — có ITEM được chọn (selectedItemIds) → `findItemContext` + `copyItemToClipboard` (MIDI notes / clip buffer — helpers dùng chung) → copy item; Ctrl+X → copy + `handleDeleteSelectedItemsRef` (xóa + close piano roll tab + sync section); không chọn item → fallback track cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070750), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → (2) SECTION-TAB cut/delete item → quay MAIN → section item canvas CẬP NHẬT realtime (nội dung mới — không save); (3) chọn MIDI item → Ctrl+C → Ctrl+V → paste MIDI item; chọn item → Ctrl+X → item bị cắt (clipboard) + tab close + canvas sync.

### [2026-08-07 07:55] Task: (2) Paste midi block ngắn — duration sai (3) Ctrl+V paste vào track 1
- **Báo cáo user:** (1) [lặp] cut midi → paste audio — fix 0745 chờ xác nhận bản mới; (2) context menu cut midi → paste → item block NGẮN (không giữ duration); (3) Ctrl+V paste vào TRACK 1 (không phải track được chọn).
- **Nguyên nhân:** (2) midiItem.duration tính GIÂY (insertMidiItem = 4*spb; scheduling 19331 dùng seconds) — nhưng copy midi (copyItemToClipboard + contextMenuCopy/Cut) nhân thêm `(60.0/bpm)` → duration gấp đôi → block sai. (3) handlePasteTrack = `doPaste(selectedTrackId, ...)` — chọn ITEM không set selectedTrackId (giữ '1') → paste track 1.
- **FIX (app.jsx):**
  (2) copy midi duration = `item.duration || 4` (GIÂY — bỏ × 60/bpm) — 3 chỗ (helper + contextMenuCopy + contextMenuCut) + bỏ biến bpmVal thừa.
  (3) handlePasteTrack — có ITEM được chọn (selectedItemIds) → target = track chứa item (findItemContext); ngược lại selectedTrackId.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070755), `wiki.md`. Rebuild precompiled (build PASS — type:'midi' ×4, duration ×3).
- **Ghi chú/Test:** hard refresh → (2) context menu cut midi → paste → block GIỮ ĐÚNG duration; (3) chọn item trên track 2 → Ctrl+V → paste vào track 2 (không phải track 1); (1) cut midi → paste → MIDI item.

### [2026-08-07 08:00] Task: (1) Ctrl+X bị browser capture (2) Ctrl+V paste sai track 1
- **Báo cáo user:** (1) Ctrl-X bị browser capture — không cut item được; (2) Ctrl+V vẫn paste track 1 — phải paste vào track đang select.
- **Nguyên nhân:** (1) Ctrl+X handler thiếu stopPropagation (browser cut text chiếm); (2) click ITEM — mousedown nhánh hitItem `return` sớm (2919) → `onTrackLaneMouseDown` (setSelectedTrackId) KHÔNG chạy → selectedTrackId giữ track cũ ('1') → Ctrl+V paste sai.
- **FIX (app.jsx):** (1) Ctrl+X — thêm `e.stopPropagation()` (cùng preventDefault). (2) click item (mousedown hitItem) → `onSelectTrack(track.id)` — chọn track chứa item → Ctrl+V paste đúng track (handlePasteTrack đã ưu tiên item → fallback selectedTrackId).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070800), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → (1) chọn item → Ctrl+X → item bị cắt (không bị browser chiếm); (2) chọn item trên track 2 → Ctrl+V → paste vào track 2 (đúng track đang select).

### [2026-08-07 08:05] Task: (1) Ctrl-X không cắt item (2) paste tại vị trí click (3) paste sai track — closure stale
- **Báo cáo user:** (1) click item → Ctrl-X không thấy cắt; (2) paste nên tại vị trí con trỏ CLICK (bên phải playhead → paste tại đó; bên trái → paste tại playhead); (3) Ctrl+V vẫn paste track đầu tiên.
- **Nguyên nhân:** (3) keydown effect `deps []` — closure giữ handlePasteTrack RENDER ĐẦU → `selectedTrackId` stale ('1') → paste track 1 LUÔN (bất kể track đang chọn). (2) doPaste luôn dùng currentTime (playhead). (1) chưa rõ — thêm log.
- **FIX (app.jsx):**
  (1) Ctrl+X — thêm `console.log('[CutItem] Ctrl+X — itemId=... found=...')` — xác định đường chạy (item hay fallback track).
  (2) `lastClickTimeRef` — set trong handlePlayheadSet (click timeline/ruler) — handlePasteTrack: pasteTime = click (nếu > playhead) else playhead.
  (3) `handlePasteTrackRef` (ref mới mỗi render) — Ctrl+V gọi ref (hết stale selectedTrackId).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070805), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → (1) chọn item → Ctrl+X → nếu không cắt → dán log `[CutItem]`; (2) click timeline bên phải playhead → Ctrl+V → paste tại chỗ click; click bên trái → paste tại playhead; (3) chọn item track 2 → Ctrl+V → paste track 2.

### [2026-08-07 08:10] Task: Ctrl+X không cắt item — copyItemToClipboard closure stale (activeTracks)
- **Log user (0805):** `[CutItem] Ctrl+X — itemId= midi_1786099252336 found= 2/midi selSize= 1` — handler chạy, detect đúng item — NHƯNG timeline không cắt.
- **Nguyên nhân:** Ctrl+C/X chạy trong keydown effect `deps []` → closure giữ `copyItemToClipboard` RENDER ĐẦU — `activeTracks` (state) là ARRAY CŨ — item `midi_1786099252336` TẠO SAU render đầu → `activeTracks.find` KHÔNG có item → copy fail → fallback `handleCutTrack` (cắt track — không cắt item).
- **FIX (app.jsx copyItemToClipboard):** `const trk = (activeTracksRef.current || []).find(...)` — dùng REF (luôn mới) thay closure state.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070810), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → chọn item (mới tạo) → Ctrl+X → item bị CẮT (log [CutItem] found= track/type + toast 'Đã cắt item'); Ctrl+C → copy item → Ctrl+V paste đúng.

### [2026-08-07 08:15] Task: Kéo ngắn midi item SECTION-TAB → MAIN canvas vẫn 8 bars
- **Báo cáo user:** midi item 2 (8 bars content) kéo NGẮN còn 4 bars trong SECTION-TAB — MAIN section item canvas vẫn hiển thị 8 bars.
- **Nguyên nhân:** syncSectionTabToMain (0745) chỉ chạy khi XÓA item — kéo NGẮN (resize) / MOVE item KHÔNG sync → sec.tracks giữ duration/vị trí cũ → canvas MAIN vẽ 8 bars.
- **FIX (app.jsx):** thêm sync sau thao tác (activeTab session → setTimeout(0) → syncSectionTabToMain): (a) handleMouseUp của RESIZE item (21651 — kéo edge — duration mới); (b) handleMouseUp của MOVE item (21563 — vị trí mới).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070815), `wiki.md`. Rebuild precompiled (build PASS — syncSectionTabToMain ×8).
- **Ghi chú/Test:** hard refresh → SECTION-TAB kéo ngắn midi item 2 (4 bars) → quay MAIN → section item canvas hiển thị ĐÚNG 4 bars (không còn 8 bars); kéo di chuyển item → MAIN canvas theo vị trí mới.

### [2026-08-07 08:20] Task: MAIN canvas section item vẫn 8 bars — notes không clip theo duration
- **Test user:** kéo ngắn midi item 2 (4 bars) SECTION-TAB → MAIN canvas KHÔNG hiển thị 4 bars (vẫn 8).
- **Nguyên nhân:** trim (kéo ngắn) KHÔNG cắt notes (notes 8 bars vẫn trong item — duration giảm 4) — canvas MAIN vẽ section content: `notes.forEach` vẽ MỌI note (2579) KHÔNG clip theo `item.duration` → 8 bars vẫn hiển thị (sync 08:15 đã cập nhật sec.tracks — nhưng vẽ không clip).
- **FIX (app.jsx canvas MAIN — drawSection subMidi notes):** skip note `noteStartSec >= (item.duration || 0) - 0.01` — note ngoài duration (phần đã trim) KHÔNG vẽ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070820), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → SECTION-TAB kéo ngắn midi item (4 bars) → quay MAIN → section item canvas hiển thị ĐÚNG 4 bars (phần 5-8 trống); scheduling đã skip note ngoài duration (19331).

### [2026-08-07 08:25] Task: PIANO ROLL — ctrl+draw velocity chord (nhiều notes cùng vị trí) chỉ vẽ được 1 note
- **Báo cáo user:** velocity lane — notes CÙNG vị trí (chord) khó draw — draw qua vị trí phải vẽ lại TẤT CẢ velocity (nếu notes không được chọn).
- **Nguyên nhân:** findCCNoteIndex trả 1 note (gần stemTop nhất) → ctrl+draw chỉ đổi 1 note — chord notes còn lại giữ nguyên.
- **FIX (app.jsx PianoRollTabEditor):** helper `findCCNoteIndicesAtBeat(b)` — trả TẤT CẢ indices notes có start_beat == snapped — handleCCMouseDown (nhánh KHÔNG chọn): lastPainted = all-at-beat; handleCCMouseMove (non-selected): vẽ TẤT CẢ unpainted notes tại beat (velocity/pan). Selected mode giữ nguyên (chỉ vẽ notes chọn).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070825), `wiki.md`. Rebuild precompiled (build PASS — findCCNoteIndicesAtBeat ×3).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — chord notes cùng beat — ctrl+draw qua cột velocity → TẤT CẢ notes cùng beat đổi velocity (không chọn); có notes chọn → chỉ notes chọn đổi.

### [2026-08-07 08:30] Task: Tích hợp MIDI Toolkit vào Piano Roll Editor (spec 20:12)
- **Spec:** Scale Highlight/Lock, Arpeggiator, Strummer, Humanize, Chord Generator, Velocity Shaper — tích hợp trực tiếp Piano Roll (không qua FX Rack).
- **ĐÃ CÓ SẴN:** snapPitchToScale + selectedScale (menu chuột phải), snapToScale toggle, humanize basic (7144).
- **THÊM MỚI (app.jsx PianoRollTabEditor):**
  1. **Scale:** state `scaleRoot` (C..B); toolbar [Scale ▾][Root ▾][Force] — flatten SCALES; `scaleWithRoot()` (transpose); **highlight grid** (rows in-scale sáng vàng / out-of-scale dim — canvas 7394); `applyForceToScale` (notes chọn/all → snap pitch).
  2. **Arpeggiator:** modal (pattern UP/DOWN/UP-DOWN/RANDOM/CHORD, rate 1/4-1/32 + triplet/dotted, octaves 1-4, gate 10-200%) — `applyArpeggiate` (chord notes → sequential).
  3. **Strummer:** modal (ms 0-120, direction DOWN/UP/ALTERNATE) — `applyStrum` (group same start_beat, sort pitch, Δt + velocity taper 0.03/note).
  4. **Humanize:** modal (timing ±ms, vel ±, dur ±%) — `applyHumanizeModal` (jitter start_beat/velocity/duration) — nút Humanize cũ + Alt+R mở modal.
  5. **Chord:** [Chord ▾] Triad/Min7/Sus4/Add9 + [Stamp] mode (click canvas → `applyChordStamp` full chord) + Harmonize +3/+5/+7 (`applyHarmonize`).
  6. **Velocity:** [Vel: Comp][target][Norm] — `applyVelocityCompress` (shift về target mean), `applyVelocityNormalize` (max → 127).
  7. **Shortcuts:** Alt+A (Arp), Alt+S (Strum), Alt+R (Humanize), Shift+C (Stamp), Shift+S (Lock scale toggle).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070830), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → chọn scale (toolbar) → grid highlight; chọn notes → Arp/Strum/Humanize modal → Apply; [Stamp] + click canvas → chord; chọn notes → Comp/Norm velocity; Force đưa notes về scale.

### [2026-08-07 08:35] Task: Toolbar Piano Roll — gom 2 hàng + merge tool trùng
- **Yêu cầu user:** toolbar có công cụ TRÙNG — move + merge vào HÀNG DƯỚI.
- **Thực hiện (app.jsx):**
  (1) Move khối [Snap to Scale toggle + Scale: ▾/Root ▾/Force + Tools: Arp/Strum/Humanize + Chord: ▾/Stamp/+3/+5/+7 + Vel: Comp/target/Norm] từ giữa hàng 1 → CUỐI toolbar (hàng 2 — sau nút "🎵 Chuyển giọng") với spacer `flex-basis:100%` ép xuống hàng mới (container vốn flex-wrap 2 hàng).
  (2) MERGE trùng: nút "🎚 Humanize + strength" cũ (hàng 1) — XÓA (thay bằng Tools:Humanize modal hàng 2 — modal đã có tham số timing/vel/dur).
  (3) Hàng 1 giờ gọn: track select, Snap, ARM, Input, Instrument, AI, CC mode, Vel/Sus/Mod/Bend/Pan, Session/Isolated, Copy/Paste/Undo/Redo/Chuyển giọng, Lưu/Export.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070835), `wiki.md`. Rebuild precompiled (build PASS — humstr đã xóa).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 2 hàng (hàng 1: cơ bản; hàng 2: Scale/Tools/Chord/Vel) — không còn trùng Humanize.

### [2026-08-07 08:40] Task: Toolbar PIANO ROLL — bỏ Copy/Paste, thêm ARP/STRUM/HUMANIZE, gộp 1 hàng
- **Yêu cầu user:** bỏ 2 nút COPY/PASTE (dùng Ctrl-C/X/V + context menu thay); đặt 3 nút ARP/STRUM/HUMANIZE + [dropdown mức humanize] vào chỗ vừa xóa; di chuyển các thành phần còn lại lên cùng hàng với dãy nút này, phía sau nút Chuyển giọng.
- **Thực hiện (app.jsx PianoRollTabEditor toolbar):**
  (1) Xóa nút 📋 Copy + 📥 Paste (8882-8900 cũ) → thay bằng ARP/STRUM/HUMANIZE + select mức (Nhẹ 0.05/Vừa 0.10/Mạnh 0.18 — chọn mức → setHumanizeStrength + mở modal theo mức: timingMs=round(v*100), velRange=round(v*127), durRange=round(v*50)).
  (2) Xóa spacer flexBasis:100% (ép hàng 2) + xóa khối "Tools: Arp/Strum/Humanize" trùng (hàng 2) → toolbar GỘP 1 HÀNG: ... Chuyển giọng → [Snap to Scale][Scale ▾][Root ▾][Force][Chord ▾][Stamp][+3/+5/+7][Vel: Comp/Norm] → Lưu/Export.
  (3) Ctrl+C/X/V trong PIANO ROLL TAB (keydown 7106): C = copy notes (onCopyNotes + toast), X = copy + xóa hết notes, V = paste clipboardNotes (append — như nút cũ) — deps thêm clipboardNotes (handler re-create khi clipboard đổi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070840), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 1 hàng (không còn Copy/Paste); ARP/STRUM/HUMANIZE + mức humanize hoạt động (mở modal); Ctrl+C → Ctrl+V paste notes; Ctrl+X cắt notes; Alt+A/S/R mở tool.

### [2026-08-07 08:45] Task: (1) Lưu + Export MIDI lên trước vị trí nút Đóng, bỏ Đóng (2) Velocity không cập nhật realtime khi đổi độ rộng/bật tắt thanh velocity
- **Yêu cầu user:** (1) mang 2 nút Lưu + Export MIDI lên trước nút Đóng + remove nút Đóng; (2) velocity note không cập nhật realtime khi thay đổi ccHeight (độ rộng) hoặc showCC (bật/tắt thanh velocity).
- **FIX (app.jsx PianoRollTabEditor):**
  (1) Xóa nút "Đóng" (onClose) — chèn div ml-auto [Lưu + Export MIDI] (di chuyển từ cuối toolbar — xóa bản cũ); sửa cân bằng ngoặc (bản cũ `"Export MIDI")))` đóng cả toolbar div — thêm `)` tại `"Norm")))`).
  (2) Effect vẽ CC canvas — thêm `ccHeight, showCC` vào deps (`[notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]`) — đổi độ rộng/bật tắt → vẽ lại realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070845), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar: không còn nút Đóng; Lưu/Export ở cuối (trước hàng tool Scale/Chord/Vel); kéo đổi độ rộng thanh velocity / bấm Vel toggle → velocity bars cập nhật ngay.

### [2026-08-07 08:50] Task: FIX TDZ — ccHeight/showCC khai báo SAU effect vẽ CC
- **Lỗi user:** `Uncaught ReferenceError: Cannot access 'ccHeight' before initialization`.
- **Nguyên nhân:** 08:45 thêm ccHeight/showCC vào deps useLayoutEffect vẽ CC (7620) — NHƯNG 2 state khai báo ở 8542 (SAU effect) → deps array tham chiếu biến trước khai báo → TDZ error khi render.
- **FIX (app.jsx):** di chuyển `const [showCC, setShowCC]` + `const [ccHeight, setCcHeight]` LÊN TRƯỚC useLayoutEffect vẽ CC (7619) — xóa bản trùng cũ (8542).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070850), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → KHÔNG còn lỗi TDZ; đổi độ rộng thanh velocity / toggle Vel → vẽ lại realtime.

### [2026-08-07 08:55] Task: Tăng size tool từ "Snap to Scale" đến cuối hàng
- **Yêu cầu user:** tăng size các tool từ Snap to Scale đến cuối hàng (Snap to Scale toggle, Scale ▾, Root ▾, Force, Chord ▾, Stamp, +3/+5/+7, Vel: Comp/Norm).
- **FIX (app.jsx):** trong khối (Snap to Scale → Norm) — `text-[10px]` → `text-xs` (12px) — 9 chỗ (Force/Stamp/+3/+5/+7/Comp/Norm buttons + Chord select...).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070855), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar → các tool cuối hàng to hơn (text-xs).

### [2026-08-07 09:00] Task: Đồng bộ font hàng tool (Snap to Scale → Norm) lên text-sm
- **Yêu cầu user:** tăng kích thước font chữ trong hàng đó cho ĐỒNG BỘ.
- **FIX (app.jsx):** khối Snap to Scale → Norm — `text-xs` → `text-sm` (12 chỗ: 5 container div + buttons + Chord select + nhãn) + thêm `text-sm` vào 2 select Scale/Root (mặc định browser) — toàn hàng đồng bộ 14px.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070900), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar hàng tool — font đồng bộ text-sm (14px).

### [2026-08-07 09:05] Task: Tăng chiều rộng input target velocity (Vel Comp)
- **Yêu cầu user:** tăng chiều rộng scrollbox/input của Vel Comp để chứa đủ số velocity (127).
- **FIX (app.jsx):** input `velocityTarget` (bên cạnh nút Comp) — `w-9` → `w-14` (đủ 3 chữ số 0-127).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070905), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → input target (cạnh Comp) rộng hơn, hiện đủ "127".

### [2026-08-07 09:10] Task: Ctrl+C/X trong PIANO ROLL — CHỈ copy/cắt notes được chọn
- **Yêu cầu user:** Ctrl-X/Ctrl-C trong PIANO ROLL TAB chỉ cắt/copy các NOTES ĐƯỢC CHỌN.
- **FIX (app.jsx keydown 7106):** Ctrl+C — copy `notes.filter(selectedNoteIds)` (toast số nốt chọn; không chọn → toast "Chọn notes trước khi copy"); Ctrl+X — copy + xóa notes chọn (filter bỏ selectedNoteIds); Ctrl+V giữ nguyên (paste clipboard).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070910), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — chọn 1 số notes → Ctrl+C → Ctrl+V paste đúng notes chọn; Ctrl+X chỉ cắt notes chọn; không chọn → toast nhắc.

### [2026-08-07 09:15] Task: Ctrl+drag marquee chọn notes → Ctrl+X báo "Chọn notes trước khi cắt"
- **Báo cáo user:** Ctrl+drag marquee chọn notes → Ctrl+X → lỗi "Chọn notes trước khi cắt".
- **Nguyên nhân:** marquee mousemove (8018-8033) ĐÃ chọn notes live (setSelectedNoteIds) — NHƯNG keydown handler (Ctrl+C/X — 7106) deps thiếu `selectedNoteIds` → closure STALE (selectedNoteIds rỗng từ render trước) → filter = [] → toast lỗi.
- **FIX (app.jsx):** (a) thêm `selectedNoteIds` vào deps effect keydown 7106; (b) bổ sung — handleGridMouseUp khi kết thúc marquee → chọn lại notes trong vùng (đảm bảo dù không qua mousemove lần cuối).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070915), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+drag marquee (notes highlight) → Ctrl+X → cắt đúng notes trong vùng; Ctrl+C → copy.

### [2026-08-07 09:20] Task: Ctrl+V trong PIANO ROLL hiện prompt "Nhập Gain điều chỉnh (dB)"
- **Báo cáo user:** nhấn Ctrl-V trong PIANO ROLL TAB → browser prompt "Nhập Gain điều chỉnh (dB):" — cần remove trong piano roll tab.
- **Nguyên nhân:** sub-tab keydown handler (16590) bắt `e.key === 'v'` KHÔNG kiểm tra Ctrl → Ctrl+V cũng trúng → prompt gain (fade gain shortcut 'v' đơn).
- **FIX (app.jsx):** thêm `!ctrl && !e.metaKey && !e.altKey` — chỉ 'v' đơn (không modifier) mới mở prompt; Ctrl+V → chạy paste notes piano roll (handler 7106).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070920), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+V → paste notes (KHÔNG prompt); nhấn 'v' đơn → prompt gain như cũ (sub-tab audio).

### [2026-08-07 09:25] Task: Ctrl+V dán notes tại vị trí con trỏ playhead
- **Yêu cầu user:** Ctrl-V dán các notes đã cut/copy bắt đầu ở vị trí con trỏ playhead đang đứng.
- **FIX (app.jsx keydown 7106 — Ctrl+V):** `pasteBeat = st.currentTime / (60/bpm)` (playhead → beat) — `offset = pasteBeat - minStart(clipboard)` — dán với `start_beat = n.start_beat + offset` (note đầu tiên nằm đúng playhead; clamp ≥ 0).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070925), `wiki.md`. Rebuild precompiled (build PASS — pasteBeat ×2).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — di chuyển playhead → Ctrl+V → notes dán bắt đầu đúng vị trí playhead.

### [2026-08-07 09:30] Task: PIANO ROLL — Shift+click toggle chọn; Ctrl+click+drag COPY nhanh; drag move pitch
- **Yêu cầu user:** (1) Ctrl+Click → Shift+Click để thêm/bớt note vào nhóm chọn; (2) Ctrl+Click giữ + drag → copy nhanh nhóm notes/note đến vị trí mới (cùng pitch — drag đổi vị trí/pitch); (3) drag note/nhóm → vị trí + pitch khác.
- **Trạng thái:** (3) ĐÃ CÓ (7895-7922 — mode 'move' — deltaBeat + deltaPitch).
- **FIX (app.jsx PianoRollTabEditor handleGridMouseDown):**
  (1) toggle selection: `e.ctrlKey` → `e.shiftKey` (Shift+click thêm/bớt note).
  (2) Ctrl+click TRÊN NOTE → clone notes (nhóm chọn nếu note trong nhóm, ngược lại note đơn) + setDraggedNote mode 'move' (selectedNotesOffset + clickedOriginalStartBeat) → kéo clones đến vị trí/pitch mới (gốc giữ) — mousemove move có sẵn.
  (3) Ctrl+click empty → marquee (giữ); Ctrl+Shift+click → split (giữ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070930), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Shift+click thêm/bớt note chọn; Ctrl+giữ+click note → kéo → copy nhóm đến vị trí mới; drag note (không modifier) → move vị trí + pitch.

### [2026-08-07 09:35] Task: FIX Ctrl+click+drag copy — notes không bám vị trí con trỏ
- **Báo cáo user:** ctrl-click-drag — notes không được copy ngay tại vị trí con trỏ chuột.
- **Nguyên nhân:** copy-drag set `startOffsetBeat: beat` (vị trí chuột) — move mousemove tính `deltaBeat = snap(beatMouse - startOffsetBeat) - refOrigStart` → delta bị lệch (Δ=0 → -noteStart → clones nhảy về beat 0 / không bám chuột). Move thường dùng `startOffsetBeat = beat - clickedNote.start_beat` (offset trong note).
- **FIX (app.jsx):** copy-drag → `startOffsetBeat: beat - clickedNote.start_beat` (giống move thường) → kéo → delta = snap(Δ) → clones bám chuột (vị trí + pitch).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070935), `wiki.md`. Rebuild precompiled (build PASS — 2 chỗ đồng bộ).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+click note → kéo → các bản copy theo đúng vị trí con trỏ (beat + pitch).

### [2026-08-07 09:40] Task: PIANO ROLL — (1) nút track column: active ghost → main + MUTE (2) follow playhead (3) status hint động
- **Yêu cầu user:**
  (1) Cột trái (nút tên track): click → active ghost notes của track đó thành MAIN notes để chỉnh sửa; cuối nút thêm nút MUTE (mặc định MUTE — unmute → play ghost cùng main notes — chức năng tương tự nút toggle cũ).
  (2) Khi play → playhead di chuyển GIỮA piano roll, notes trôi sang trái; stop → playhead về ĐẦU (luôn hiển thị trong view).
  (3) Status bar gợi ý: mouse trong piano roll → "Scroll: Up/Down | Drag: Draw notes"; giữ Shift → "Shift+Click: select/unselect..."; giữ Ctrl → "Ctrl+drag: fast copy notes...".
- **FIX (app.jsx):**
  (1) Track column (9270): nút tên track → `handleSwitchMidiItem(m.id)` (mở item — chỉnh sửa main notes); thêm nút MUTE (w-6 — mặc định 'M' zinc, unmute '♪' green — toggle activePlayTrackIds + onRealtimePlay).
  (2) Effect follow: `[isPlaying, st.currentTime]` — play → `scrollLeft = phBeat*ppb - clientWidth/2` (playhead giữa, notes trôi trái); stop → `scrollLeft = 0` (về đầu).
  (3) App state `prHint` + `window.__setPrHint`; piano roll: `prKeyStateRef`/`prMouseInRef` + keydown/keyup + container onMouseEnter/Leave → set hint theo Ctrl/Shift; status bar hiển thị `prHint` (cyan) thay "Scroll: Zoom".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070940), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — click tên track khác → mở item đó (sửa được); nút M — click → ♪ xanh (play cùng main); play → playhead giữa + trôi trái; stop → về đầu; hover trong piano roll → status bar đổi gợi ý theo Shift/Ctrl.

### [2026-08-07 09:45] Task: (1) Cân bằng tên track cột trái (2) Xóa nhãn "Ctrl+Scroll: Playhead" cố định
- **Yêu cầu user:** (1) sửa chiều cao nút tên track cột trái — tên nút cân bằng giữa nút; (2) xóa nhãn cố định "Ctrl+Scroll: Playhead" cuối status bar — để gợi ý adaptive hiển thị đủ nội dung.
- **FIX (app.jsx):**
  (1) Nút tên track: `h-[20px]` → `h-[26px]` + `flex items-center justify-center` + text `text-[13px] leading-none`; nút MUTE đồng bộ `h-[26px]` + flex center.
  (2) Status bar: xóa span "|" + span "Ctrl+Scroll: Playhead" (icon keyboard) — còn `prHint` (cyan) hoặc "Scroll: Zoom" — gợi ý adaptive có đủ chỗ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070945), `wiki.md`. Rebuild precompiled (build PASS — Ctrl+Scroll: Playhead = 0).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — tên track nằm cân bằng giữa nút (26px); status bar cuối không còn "Ctrl+Scroll: Playhead" — gợi ý adaptive hiển thị đầy đủ.

### [2026-08-07 09:50] Task: FIX Follow playhead chưa hoạt động — dùng sai isPlaying (main)
- **Báo cáo user:** follow playhead (0940) vẫn chưa thực hiện được.
- **Nguyên nhân:** effect dùng prop `isPlaying` = MAIN play — piano roll play chỉ set `st.isPlaying` (main vẫn false — handlePlayPause sub-tab stopAllPlayback) → effect luôn rơi nhánh "stop" → scroll về đầu mãi.
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.

### [2026-08-09] Task: Carla Bridge (native GUI VSTi) + dual-mode runtime + preset library
- **Tóm tắt thay đổi:** (1) Runtime tự phát hiện môi trường `app/core/runtime.py` (desktop Windows / docker headless; override `SF_RUNTIME`/`SF_DOCKER`) + `GET /api/v1/system/capabilities` (public) — frontend bật/tắt tính năng theo môi trường. (2) Carla Bridge: mục "🎛 Carla Bridge (mở Carla.exe)" trong dropdown nút Synth (chỉ hiện khi desktop + có Carla local) → spawn `carla.exe` qua `POST /api/v1/plugins/open-in-carla`; vì bản Windows là zip portable (không installer, không PATH) → Plugin Manager thêm section "Carla Bridge" + nút "Định vị Carla..." (`POST /api/v1/system/carla-path`, lưu `storage/carla_path.json` ưu tiên cao nhất; kèm registry + quét nông Downloads/Desktop/Documents giới hạn độ sâu). (3) Thư viện preset `app/api/v1/presets.py` (storage/presets: list/upload/download/delete, chống path traversal) + `apply_preset_to_plugin()` trong `vst_engine.py` (preset_data base64 → preset_id → preset_path) → render_engine nạp preset trước khi render VST3 → âm render = âm đã chỉnh trong Carla. (4) `POST /api/v1/plugins/preview` — quick-render preview (cùng code path pedalboard với export → âm thật). (5) Plugin Manager: bấm soundfont expand → liệt kê instrument (bank/program/name) + nút "Chèn vào Synth" gán vào track đang chọn. (6) `config.py` default VST_DIR/SOUNDFONT_DIR theo platform + `PRESET_DIR`; docker-compose `SF_DOCKER=1`; service `runtime.js` (capabilities lúc boot, cache preset) + api.js methods mới (getCapabilities/setCarlaPath/openInCarla/previewInstrument/listPresets/uploadPreset/deletePreset).
- **Các file ảnh hưởng:** `app/core/runtime.py` (mới), `app/api/v1/system.py` (mới), `app/api/v1/presets.py` (mới), `app/api/v1/plugins.py`, `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/config.py`, `app/main.py`, `app/static/js/services/runtime.js` (mới), `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html`, `.env.example`, `docker-compose.prod.yml`, `md/52_CARLA_BRIDGE.md` (mới), `wiki.md`
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test API: capabilities 200; preset CRUD + chặn path traversal; carla-path (thư mục/exe → resolve exe, invalid → 400); open-in-carla → 409 kèm hướng dẫn khi chưa có Carla; preview → 501 khi thiếu pedalboard. Rebuild bundle BUILD OK. Lưu ý: pedalboard 0.10+ đã bỏ VST2 (chỉ preset VST3 `.vstpreset` round-trip); render cùng sample rate với Carla để preview = export; Carla GPL-2.0+ → không bundle/nhúng, chỉ spawn tiến trình ngoài.

### [2026-08-09] Task: Carla Bridge — auto-load VSTi qua .carxs + khai báo thư mục Carla
- **Tóm tắt thay đổi:** (1) `POST /api/v1/plugins/open-in-carla` viết lại: sinh file project `.carxs` (định dạng XML chính thức từ source Carla — `carla.exe [FILE]` nhận project file) chứa `<Plugin><Info><Type>VST3</Type><Binary>path</Binary>...` → Carla mở lên **plugin đã load sẵn** kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime. VST3 (file `.vst3`/folder `X.vst3` Windows) auto-load; VST2 (`.dll`/`.so` ngoài `.vst3`) → Carla trống để Add Plugin thủ công (cần uniqueID). Project lưu `{STORAGE_DIR}/carla_projects/`, tự dọn sau 1 ngày. (2) Frontend: chọn VSTi trong dropdown Synth → **tự động** `openInCarla(v.id)` (chỉ khi desktop + carla_local) — nhấn nút Synth, thêm VSTi là Carla tự gọi và load luôn VSTi đó. (3) Plugin Manager section "Carla Bridge": thêm **ô nhập tay thư mục chứa carla.exe + nút Lưu** (bên cạnh "Định vị Carla..." dùng picker) — khai báo 1 lần là xong.
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+ `_write_carla_project`), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `md/52_CARLA_BRIDGE.md`, `wiki.md`
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test `_write_carla_project`: VST3 file → carxs hợp lệ (CARLA-PROJECT VERSION='2.5', Type VST3, Binary, Active Yes, ControlChannel 1); Windows VST3 folder → OK; VST2 .dll → '' (không sinh); XML escape tên đặc biệt (A&B <C>) parse OK. End-to-end endpoint với Carla giả: 200, project_file sinh + chứa Binary, cmd = [carla.exe, carxs]. Rebuild bundle BUILD OK (node qua PATH nvm v24).

### [2026-08-09] Task: Fix — Carla không được gọi + Soundfont không scan được trên Windows
- **Tóm tắt thay đổi:** (1) Bug backend: `GET /api/v1/plugins/available` chỉ gộp VST từ `plugin_dirs` user, KHÔNG gộp soundfont → nút Synth rỗng khi soundfont nằm trong thư mục user thêm. Fix: thêm `_scan_soundfonts_in_dirs()` (walk .sf2/.sf3 + meta) và merge vào `avail["soundfonts"]`. (2) Auto-launch Carla giờ phủ TẤT CẢ bề mặt click VSTi: modal "Select Instrument" (vst_instruments), dropdown track strip (đã có), Plugin Manager tab VST + kết quả Scan (thêm nút 🎛 mỗi dòng, scan dùng path trực tiếp). (3) Plugin Manager: Add Directory / remove dir → **tự động lưu + scan** (debounce 600ms) — không cần bấm Scan tay. (4) Sync file static mới (app.precompiled.js, api.js, runtime.js, app.jsx, index.html) vào `dist/daw_engine/_internal/app/` để bản packaged nhận fix frontend.
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `dist/daw_engine/_internal/app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** Test /available với thư mục sf2 user → soundfont xuất hiện (total 2). pytest: 86 passed, 7 skipped. Rebuild bundle BUILD OK. ⚠️ Backend fix (plugins.py) cần **rebuild PyInstaller trên Windows** (dist backend nằm trong exe); frontend đã patch sẵn trong dist.

### [2026-08-09] Task: MIDI ARM → Carla (OSC bridge) + fix instrument soundfont từ thư mục user
- **Tóm tắt thay đổi:** (1) **MIDI keyboard → Carla realtime**: `POST /api/v1/plugins/carla-midi` (note_on/note_off) gửi OSC UDP tới `/Carla/0/note_on|note_off` (pluginId 0 = plugin auto-load qua .carxs; cổng mặc định 22752 — đã xác minh từ source Carla: CarlaEngineOsc handleMsgNoteOn/NoteOff nhận `iii`/`ii`, CarlaEngineData oscPortUDP=22752, tên client standalone "Carla" từ carla_host.py; override qua env SF_CARLA_OSC_PORT hoặc `osc_port` trong carla_path.json). Frontend: `window.SonicCarlaMidi` (runtime.js) + hook vào **keybed piano roll** (onMouseDown/onMouseEnter) và **playDrawPreview** (vẽ/click note) — chỉ route khi track VSTi + ARM + carla_local. (2) **Soundfont từ thư mục user**: endpoint `/soundfont-instruments/{sf_id}` giờ truyền plugin_dirs → `PluginManager.list_soundfont_instruments` tìm cả thư mục user (bug "nhấn tên SF không thấy instrument"); `/available` truyền extra dirs; `render_engine._find_sf2_path` tìm thêm thư mục user (trước chỉ UPLOAD + /opt/daw_engine/soundfonts + static → soundfont user render câm).
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+carla-midi, _send_carla_osc, _carla_osc_port), `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/static/js/services/runtime.js` (+SonicCarlaMidi), `app/static/js/services/api.js` (+carlaMidi), `app/static/js/app.jsx` (keybed + playDrawPreview), `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `wiki.md`
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. OSC: hexdump 32B = `/Carla/0/note_on` + `,iii` + [0,60,100] (big-endian int32) — khớp expected; note_off = `,ii` + [ch,note]. Rebuild bundle BUILD OK. ⚠️ Cần rebuild PyInstaller trên Windows để nhận backend fix (dist backend nằm trong exe). Lưu ý: Carla phải ĐANG MỞ để nhận OSC; nếu đổi cổng OSC trong Carla → đặt SF_CARLA_OSC_PORT.

### [2026-08-09] Task: MIDI items play qua Carla + list instrument SF không cần libfluidsynth
- **Tóm tắt thay đổi:** (1) **Playback MIDI items → Carla**: `schedulePianoRollMidi` (app.jsx ~21133) giờ route note_on/note_off tới Carla qua `SonicCarlaMidi.shouldRoutePlayback()` (track VSTi + carla_local, KHÔNG cần ARM — user chủ động bấm Play) — schedule bằng setTimeout theo audio clock (preview, timing gần đúng). Keybed/draw preview vẫn yêu cầu ARM (shouldRoute). (2) **List instrument soundfont hoạt động không cần libfluidsynth**: `PluginManager.list_soundfont_instruments` bỏ early-return `if not ensure_pyfluidsynth(): return []`; thêm fallback đọc TRỰC TIẾP file SF2 qua `SoundFontInspector.inspect_sf2_file` (sf2utils — thuần Python, đọc preset header pdta/phdr → bank/program/name) — quan trọng trên Windows khi thiếu fluidsynth DLL. Giữ đường FluidSynth khi có lib.
- **Các file ảnh hưởng:** `app/core/vst_engine.py`, `app/static/js/services/runtime.js` (+shouldRoutePlayback), `app/static/js/app.jsx` (schedulePianoRollMidi), `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `wiki.md`
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. Test fallback trên máy KHÔNG có libfluidsynth (đúng kịch bản Windows): file sf2 thật → 137 presets (first: {bank:128, program:48, name:'Orchestra Kit', is_percussion:True}). Rebuild bundle BUILD OK; dist synced. ⚠️ Cần rebuild PyInstaller trên Windows để nhận backend fix (dist backend trong exe).

### [2026-08-09] Task: Carla auto-connect (Patchbay) + MIDI item play + fix note kẹt keybed
- **Tóm tắt thay đổi:** (1) **Carla auto-connect lần đầu**: `_write_carla_project` thêm section `<Patchbay>` vào .carxs — audio out plugin → `system:playback_1/2` (default speaker) + MIDI in → plugin `midi_in` (biến thể tên client Carla/carla/system:midi_capture_1; connection trỏ port không tồn tại bị Carla bỏ qua im lặng). Trước đây Carla load project KHÔNG connect gì → lần đầu không có âm/không nhận MIDI (phải unload/load lại). (2) **MIDI item play qua Carla**: nguyên nhân chính là (a) thiếu audio connection (đã fix ở trên) + (b) browser cache bundle cũ — bump `?v=` stamps trong index.html (runtime.js/api.js/app.precompiled.js → 202608091200) để chắc chắn load bundle mới có `SonicCarlaMidi.shouldRoutePlayback`. (3) **Keybed note kẹt**: onMouseUp + onMouseLeave (mới) giờ gọi `SonicSF.stopNote(kbCtx.ch, pitch)` (dừng âm soundfont khi thả/rời phím) + clear timer Carla + `noteOff` tới Carla. Không đụng MIDI item playback của soundfont (vẫn chạy tốt).
- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (_write_carla_project +Patchbay), `app/static/js/app.jsx` (keybed onMouseUp/onMouseLeave), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (?v= bump), `dist/daw_engine/_internal/app/**`, `wiki.md`
- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. Test .carxs: XML parse OK, 5 Connection đúng (audio_out1/2 → system:playback_1/2; Carla/carla/system:midi_capture_1 → plugin:midi_in). Rebuild bundle BUILD OK; dist synced (gồm index.html mới ?v=). ⚠️ Rebuild PyInstaller trên Windows để nhận backend fix (.carxs mới).
