### [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-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)
