fix: Bug 1 keyboard preview cam lan 2+ — token race: stale check != token -> > token (claim-then-compare) trong _sfWasmFallbackNote + API path (T1)

This commit is contained in:
2026-08-11 15:31:56 +07:00
parent cadb5402a3
commit aa06d40b98
4 changed files with 246 additions and 4 deletions
+2 -2
View File
@@ -218,7 +218,7 @@ const _sfWasmFallbackNote = (track, pitch, velocity, durationMs, startTime, key,
const bank = (eng && eng.soundfont_bank) || 0; const bank = (eng && eng.soundfont_bank) || 0;
const program = (eng && eng.soundfont_program) || (track && track.instrumentProgram !== undefined ? track.instrumentProgram : 0); const program = (eng && eng.soundfont_program) || (track && track.instrumentProgram !== undefined ? track.instrumentProgram : 0);
const k = key || (track ? track.id : 'global'); const k = key || (track ? track.id : 'global');
if (_nativeSfPreviews[k] && token != null && _nativeSfPreviews[k].token !== token) return; // stale if (_nativeSfPreviews[k] && token != null && _nativeSfPreviews[k].token > token) return; // stale (claim-then-compare: chi bo neu co note MOI HON)
const S = window.SonicSF; const S = window.SonicSF;
if (!S) return; if (!S) return;
const ch = (track && track.midiChannel != null) ? track.midiChannel : 0; const ch = (track && track.midiChannel != null) ? track.midiChannel : 0;
@@ -264,7 +264,7 @@ const playNativeSfNote = (track, pitch, velocity, durationMs, startTime, key) =>
_sfWasmFallbackNote(track, pitch, velocity, durationMs, startTime, k, token); _sfWasmFallbackNote(track, pitch, velocity, durationMs, startTime, k, token);
return; return;
} }
if (_nativeSfPreviews[k] && _nativeSfPreviews[k].token !== token) return; // stale if (_nativeSfPreviews[k] && _nativeSfPreviews[k].token > token) return; // stale (claim-then-compare: chi bo neu co note MOI HON)
window.__nativeSfOk = true; window.__nativeSfOk = true;
const audio = new Audio(API_BASE_URL + res.url); const audio = new Audio(API_BASE_URL + res.url);
_nativeSfPreviews[k] = { audio: audio, token: token }; _nativeSfPreviews[k] = { audio: audio, token: token };
+2 -2
View File
@@ -49,9 +49,9 @@ const _nativeSfPreviews={};// WASM fallback: backend pyfluidsynth render HONG tr
// native path cam lang. Fallback choi note TRUC TIEP qua SonicSF (Web Audio // native path cam lang. Fallback choi note TRUC TIEP qua SonicSF (Web Audio
// + libfluidsynth WASM da bundle) - am ra _gainNode -> masterBus.input. // + libfluidsynth WASM da bundle) - am ra _gainNode -> masterBus.input.
// window.__nativeSfOk: undefined (chua biet) | true (native OK) | false (dung WASM). // window.__nativeSfOk: undefined (chua biet) | true (native OK) | false (dung WASM).
const _sfWasmFallbackNote=(track,pitch,velocity,durationMs,startTime,key,token)=>{try{const eng=track&&track.synth_engine;const sfId=eng&&eng.soundfont_id||(track&&track.instrumentId&&String(track.instrumentId).startsWith('sf_')?track.instrumentId:null);if(!sfId)return;const bank=eng&&eng.soundfont_bank||0;const program=eng&&eng.soundfont_program||(track&&track.instrumentProgram!==undefined?track.instrumentProgram:0);const k=key||(track?track.id:'global');if(_nativeSfPreviews[k]&&token!=null&&_nativeSfPreviews[k].token!==token)return;// stale const _sfWasmFallbackNote=(track,pitch,velocity,durationMs,startTime,key,token)=>{try{const eng=track&&track.synth_engine;const sfId=eng&&eng.soundfont_id||(track&&track.instrumentId&&String(track.instrumentId).startsWith('sf_')?track.instrumentId:null);if(!sfId)return;const bank=eng&&eng.soundfont_bank||0;const program=eng&&eng.soundfont_program||(track&&track.instrumentProgram!==undefined?track.instrumentProgram:0);const k=key||(track?track.id:'global');if(_nativeSfPreviews[k]&&token!=null&&_nativeSfPreviews[k].token>token)return;// stale (claim-then-compare: chi bo neu co note MOI HON)
const S=window.SonicSF;if(!S)return;const ch=track&&track.midiChannel!=null?track.midiChannel:0;const durMs=Math.max(200,durationMs||500);const ctx=getAudioContext();const delaySec=startTime?Math.max(0,startTime-ctx.currentTime):0;_nativeSfPreviews[k]={wasm:{ch:ch,pitch:pitch},token:token};const fire=function(){Promise.resolve(S.selectInstrument(ch,bank,program,sfId)).then(function(){S.playNote(pitch,velocity!=null?velocity:0.8,durMs,undefined,eng?undefined:program,null,ch,eng||undefined);}).catch(function(){try{S.playNote(pitch,velocity!=null?velocity:0.8,durMs,undefined,eng?undefined:program,null,ch,eng||undefined);}catch(e2){}});};if(delaySec>0){setTimeout(fire,delaySec*1000);}else{fire();}}catch(e){console.warn('[NativeSF] wasm fallback note error:',e);}};const playNativeSfNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const sfId=eng&&eng.soundfont_id||(track&&track.instrumentId&&String(track.instrumentId).startsWith('sf_')?track.instrumentId:null);if(!sfId)return;const bank=eng&&eng.soundfont_bank||0;const program=eng&&eng.soundfont_program||(track&&track.instrumentProgram!==undefined?track.instrumentProgram:0);const k=key||(track?track.id:'global');const prev=_nativeSfPreviews[k];const token=(prev?prev.token:0)+1;// Native hong da biet (501) -> di thang WASM fallback, khong goi API. const S=window.SonicSF;if(!S)return;const ch=track&&track.midiChannel!=null?track.midiChannel:0;const durMs=Math.max(200,durationMs||500);const ctx=getAudioContext();const delaySec=startTime?Math.max(0,startTime-ctx.currentTime):0;_nativeSfPreviews[k]={wasm:{ch:ch,pitch:pitch},token:token};const fire=function(){Promise.resolve(S.selectInstrument(ch,bank,program,sfId)).then(function(){S.playNote(pitch,velocity!=null?velocity:0.8,durMs,undefined,eng?undefined:program,null,ch,eng||undefined);}).catch(function(){try{S.playNote(pitch,velocity!=null?velocity:0.8,durMs,undefined,eng?undefined:program,null,ch,eng||undefined);}catch(e2){}});};if(delaySec>0){setTimeout(fire,delaySec*1000);}else{fire();}}catch(e){console.warn('[NativeSF] wasm fallback note error:',e);}};const playNativeSfNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const sfId=eng&&eng.soundfont_id||(track&&track.instrumentId&&String(track.instrumentId).startsWith('sf_')?track.instrumentId:null);if(!sfId)return;const bank=eng&&eng.soundfont_bank||0;const program=eng&&eng.soundfont_program||(track&&track.instrumentProgram!==undefined?track.instrumentProgram:0);const k=key||(track?track.id:'global');const prev=_nativeSfPreviews[k];const token=(prev?prev.token:0)+1;// Native hong da biet (501) -> di thang WASM fallback, khong goi API.
if(window.__nativeSfOk===false){_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);return;}const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:bank,program:program,bpm:120,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}]}).then(function(res){if(!res||!res.success||!res.url){window.__nativeSfOk=false;_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);return;}if(_nativeSfPreviews[k]&&_nativeSfPreviews[k].token!==token)return;// stale if(window.__nativeSfOk===false){_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);return;}const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:bank,program:program,bpm:120,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}]}).then(function(res){if(!res||!res.success||!res.url){window.__nativeSfOk=false;_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);return;}if(_nativeSfPreviews[k]&&_nativeSfPreviews[k].token>token)return;// stale (claim-then-compare: chi bo neu co note MOI HON)
window.__nativeSfOk=true;const audio=new Audio(API_BASE_URL+res.url);_nativeSfPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeSfPreviews[k]||_nativeSfPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._sfStopTimer&&clearTimeout(audio._sfStopTimer);audio._sfStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){window.__nativeSfOk=false;_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);});}catch(e){console.warn('[NativeSF] playNativeSfNote error:',e);}};const stopNativeSfNote=key=>{try{const k=key||'global';const prev=_nativeSfPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}try{if(prev.wasm&&window.SonicSF)window.SonicSF.stopNote(prev.wasm.ch,prev.wasm.pitch);}catch(e){}}catch(e){}};const stopAllNativeSfNotes=()=>{try{Object.keys(_nativeSfPreviews).forEach(function(k){stopNativeSfNote(k);});}catch(e){}};// ── Native soundfont item render (standalone, transport) ────────────────── window.__nativeSfOk=true;const audio=new Audio(API_BASE_URL+res.url);_nativeSfPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeSfPreviews[k]||_nativeSfPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._sfStopTimer&&clearTimeout(audio._sfStopTimer);audio._sfStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){window.__nativeSfOk=false;_sfWasmFallbackNote(track,pitch,velocity,durationMs,startTime,k,token);});}catch(e){console.warn('[NativeSF] playNativeSfNote error:',e);}};const stopNativeSfNote=key=>{try{const k=key||'global';const prev=_nativeSfPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}try{if(prev.wasm&&window.SonicSF)window.SonicSF.stopNote(prev.wasm.ch,prev.wasm.pitch);}catch(e){}}catch(e){}};const stopAllNativeSfNotes=()=>{try{Object.keys(_nativeSfPreviews).forEach(function(k){stopNativeSfNote(k);});}catch(e){}};// ── Native soundfont item render (standalone, transport) ──────────────────
// Render TOÀN BỘ MIDI item bằng native FluidSynth → decode AudioBuffer → // Render TOÀN BỘ MIDI item bằng native FluidSynth → decode AudioBuffer →
// schedule nguồn audio đúng vị trí item (giống audio clip). opts: // schedule nguồn audio đúng vị trí item (giống audio clip). opts:
+66
View File
@@ -0,0 +1,66 @@
# PLAN — Standalone playback 100% client-side WASM (no Carla dependency)
Goal: fix "mastering fx chain không xử lí" + "vsti không qua main out" bằng cách
chuyển live playback/preview của VSTi sang WASM (autosampled SF2 qua FluidSynth
WASM → track sfEntry → FX chain → masterBus mastering → main out). Export WAV
giữ nguyên backend Python (pedalboard VST3 + FluidSynth C++).
## 1. Server — autosample endpoint (VSTi → SF2)
- `tools/autosample_vsti.py`: tách core thành `autosample_sf2(...)` callable
(giữ `main()` argparse cho CLI).
- `app/api/v1/plugins.py`: `AutosampleRequest` + `POST /autosample` — render
preset qua từng note bằng pedalboard (đúng plugin+preset như export) → SF2
16-bit mono vào `app/storage/soundfonts/{uuid}.sf2` + `.meta` → trả `sf_id`.
- Chỉ SF2 (không SF3): client WASM load SF2 trực tiếp, endpoint download ưu
tiên SF2 sẵn có. 30-60 note ≈ 10-20MB — chấp nhận, IndexedDB cache.
ponytail: SF3 (OGG nhỏ hơn) khi cần size nhỏ — thêm `--sf3` vào endpoint.
## 2. Server — mastering FX chain trong render_project
- `app/core/mastering_engine.py` (mới): `apply_mastering(buffer, settings, fs)`
tái hiện chain WebAudio client (initMasterBus + applyMasteringSettings):
- `eq` (lowshelf 100 / peaking 822 Q0.7 / peaking 3200 Q1.2 / highshelf 10k)
- `imager` (M/S width 4 band: 20-100 / 100-1k / 1k-6k / 6k-20k)
- `maximizer` (boost → softclip atan → +upward comp → ceiling hard clip)
- `compressor` (threshold/ratio/knee/makeup)
- `limiter` (tanh soft clip)
- `exciter` (hp 2k + saturator, dry/wet)
- `rebalance` (M/S gains qua L/R crossfeed)
- chain order từ `settings['chain']`, gate `masterConnected && !isBypassed`.
- `app/core/render_engine.py`: `render_project` đọc `project_json['mastering_settings']`
→ áp lên master_buffer trước `sf.write`; khi có mastering → clip [-1,1]
(giống WAV encoder client), không mastering → normalize như cũ.
## 3. Client — VSTi live playback qua WASM autosample
- `app/static/js/services/api.js`: thêm `autosampleVsti(payload)`.
- `app/static/js/services/vstiAutosample.js` (mới): `SonicVstiAutosample`
`ensure(synthEngine)` POST autosample 1 lần, cache map plugin_id+preset →
sf_id (localStorage), `sfIdFor` sync lookup.
- `app/static/js/services/soundfontPlayer.js`: `_playNoteFluid` +
`applyAITrackInstrument` fallback `synthEngine.autosampled_sf_id` khi thiếu
`soundfont_id`; VSTi chưa sample → ensure rồi defer note (pendingNoteOns có sẵn).
- `app/static/js/services/runtime.js`: `SonicCarlaMidi.shouldRoute` /
`shouldRoutePlayback` trả false trừ khi `window.__enableCarlaLivePlayback`
(mặc định off) — playback không phụ thuộc Carla nữa.
- `app/static/js/app.jsx`:
- `shouldRouteCarla` → false (VSTi đi qua SonicSF → masterBus → main out).
- chọn instrument VSTi (applyTrackInstrument) → fire-and-forget
`SonicVstiAutosample.ensure(synth_engine)`.
- timeline/local-loop/ghost/preview: VSTi đã chạy nhánh `SonicSF.playNote`
sẵn (isSfTrackEngine false → else-if); autosampled_sf_id làm nó kêu đúng âm.
- `ensureMidiCapture` đã capture `node.sfEntry` → VSTi vào midiCache →
clientSideExport (offline mix qua track FX + mastering) hoạt động cho VSTi.
- `app/templates/index.html`: script tag `vstiAutosample.js`.
- Build: `npm run build` (app.jsx → app.precompiled.js).
## 4. Verify
- pytest `tests/` (kỳ vọng 108 pass / 1 skip / 1 env-specific fail như cũ).
- Test mastering: render project JSON có mastering_settings (chain eq+imager+
maximizer) → assert output peak ≤ ceiling & khác bản không mastering.
- Test autosample: gọi `autosample_sf2` với VSTi (Nexus.vst3) → sf2utils parse
sạch; endpoint return sf_id + file tồn tại.
- Manual (browser): track VSTi → Preview/Play → âm qua masterBus (VU master
nhảy), Export WAV server ra file có mastering.
## 5. Commit/push
- Commit trên branch `standalone`. Push vẫn chờ user cấu hình git auth
(credential.helper / token URL) — không retry khi chưa có.
+176
View File
@@ -0,0 +1,176 @@
# WALKTHROUGH — Thực thi tuần tự (SonicForgeStudio standalone)
> Nạp file này mỗi session mới. Làm ĐÚNG 1 task, cập nhật bảng trạng thái, commit, dừng.
> Kiến trúc/lý do: `GIAI_PHAP.md`. File này chỉ chứa thao tác + trạng thái → context nhỏ.
## 1. Thông tin cố định (mọi session)
- Repo: `C:/Users/locpham/SonicForgeStudio` — branch: `standalone` — HEAD: `cadb540` (đã commit; **KHÔNG push**)
- Build JS: `node node_modules/@babel/cli/bin/babel.js app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js`
- Test: `python -m pytest tests/` → 110 passed, 1 skipped, 1 failed (`test_vst_engine.py::TestPluginManager::test_init` — env-fail Linux path, **bỏ qua, không sửa**)
- Rust check: `cd src-tauri && cargo check` (nếu task C)
- **CRLF rule**: file repo dùng CRLF. Đọc = `sed -n`/`grep` (không dùng read_file). Sửa = python: `s = open(p, encoding='utf-8', newline='').read()` → thay chuỗi `\n``s.replace('\n','\r\n')``open(p,'w',encoding='utf-8',newline='').write(s)`. Verify lại bằng grep/repr.
- Commit rule: sau mỗi task xanh (build+test OK) → `git add` + commit `standalone`. Trước mỗi task: `git status` phải sạch.
- Trình tự: A (T1T3) → B (T4T7) → C (T8T15) → D (T16).
## 2. Trạng thái
| ID | Phase | Task | Trạng thái |
| --- | --- | --- | --- |
| T1 | A | Bug 1: token fix | [ ] |
| T2 | A | Bug 2: chain re-apply + sig order + neutral add | [ ] |
| T3 | A | Bug 3: autosample pre-warm + toast + re-sample | [ ] |
| T4 | B | `unifiedMidiRouter.js` | [ ] |
| T5 | B | `TrackInstrument` + keybed → router; xóa native preview/token | [ ] |
| T6 | B | Scheduler items → router | [ ] |
| T7 | B | Hardware MIDI → router | [ ] |
| T8 | C | Rust `vst_gui.rs` + `raw-window-handle` | [ ] |
| T9 | C | VST3 GUI bridge (`vst3_host_bridge.cpp` + DLL) | [ ] |
| T10 | C | VST2 engine (`VST2AudioEngine.cpp`, vestige.h) | [ ] |
| T11 | C | IPC param sync 2 chiều | [ ] |
| T12 | C | Native audio loop: SPSC + driver WASAPI/ASIO | [ ] |
| T13 | C | `NativeMixer` mirror `mastering_engine.py` | [ ] |
| T14 | C | Port FluidSynth native (hoặc loopback) | [ ] |
| T15 | C | Bỏ WebAudio master; IPC gain/pan; test matrix | [ ] |
| T16 | D | Autosample hoàn thiện (VST2 `.sf3`, preset, re-sample) | [ ] |
Trạng thái dùng: `[ ]` chưa làm, `[▶]` đang làm/dang dở, `[x]` xong, `[!]` fail — ghi lỗi vào Ghi chú.
## 3. Tiếp tục sau fail
1. Đọc file này. Tìm task đầu tiên có `[ ]` hoặc `[▶]`.
2. Nếu `[▶]`: đọc Ghi chú (lỗi đã ghi), sửa tiếp. Nếu `[ ]`: nạp đúng mục "Context tối thiểu" của task (chỉ đọc các file/line đó — không đọc cả app.jsx).
3. Làm xong → Verify → build → pytest → cập nhật bảng `[x]` + Ghi chú 1 dòng → commit.
4. Fail không gỡ được: cập nhật `[!]` + Ghi chú lỗi đầy đủ (command, output, đoạn code), dừng, báo user.
## 4. Các task
### T1 — Bug 1: token fix (Phase A)
- **Mục tiêu**: bấm phím keyboard preview lần 2+ không câm.
- **File**: `app/static/js/app.jsx`
- **Context tối thiểu**: `grep -n '_sfWasmFallbackNote\|playNativeSfNote\|_nativeSfPreviews' app/static/js/app.jsx``_sfWasmFallbackNote` L213-236, `playNativeSfNote` L239-283.
- **Bước**:
1. Trong `_sfWasmFallbackNote`: stale check `_nativeSfPreviews[k].token !== token` → đổi thành `> token` (claim-then-compare) và LUÔN ghi đè `_nativeSfPreviews[k] = { wasm, token }`.
2. Cùng pattern `!== token` ở path API-success (~L257): sửa giống hệt.
- **Verify**: build babel; mở app; bấm cùng phím 25 lần liên tiếp → mọi lần đều có tiếng; bấm nhanh nhiều phím → không câm.
- **Done khi**: không còn câm; không đổi hành vi khác.
- **Rollback**: `git checkout -- app/static/js/app.jsx`
- **Ghi chú**: Nếu T4T5 đã xong (router xóa cơ chế này) → bỏ qua task này, đánh `[x]` kèm "đã xóa tận gốc ở T5".
### T2 — Bug 2: chain mastering (Phase A)
- **Mục tiêu**: thêm/sắp xếp module mastering không làm volume nhỏ đi; reorder đúng thứ tự.
- **File**: `app/static/js/app.jsx`
- **Context tối thiểu**: `grep -n 'applyMasteringSettings\|_lastMasteringSig\|chainSignature\|addModuleToChain\|_buildLimCurve\|_buildMaxCurve\|toggleMasteringOnMaster' app/static/js/app.jsx` (L635+, L1097, L1486, L913-935, L12446).
- **Bước**:
1. `chainSignature` (L1097): order-sensitive — signature phải gồm thứ tự module (không phải `(type+active).join(',')` set-insensitive).
2. `applyMasteringSettings` (L635+, early-return L642): bỏ phụ thuộc sig; mọi thay đổi chain → rebuild + re-apply TOÀN BỘ tham số (debounce ~50ms thay vì skip).
3. Thêm module (limiter/compressor) qua `addModuleToChain`: neutral-then-apply — limiter threshold mặc định 0dB (không clamp 1dB), compressor makeup bù loss (hoặc threshold cao hơn), để "thêm module" không tự cắt volume.
- **Verify**: build; thêm limiter → volume không giảm rõ rệt; thêm compressor → không giảm; reorder limiter↔compressor → nghe đổi thứ tự; master fader vẫn hoạt động đúng.
- **Done khi**: 3 bước trên đúng; `python -m pytest tests/` xanh.
- **Rollback**: `git checkout -- app/static/js/app.jsx`
### T3 — Bug 3: autosample UX (Phase A)
- **Mục tiêu**: VSTi không autosample được phải hiện rõ, không âm thầm ra oscillator default.
- **File**: `app/static/js/services/vstiAutosample.js`, `app/static/js/app.jsx` (nơi chọn instrument), util toast hiện có.
- **Context tối thiểu**: `grep -rn 'SonicVstiAutosample\|ensure' app/static/js/` + `grep -n '_playNoteFallback' app/static/js/soundfontPlayer.js` (L787+).
- **Bước**:
1. `ensure()`: bắt lỗi (404/501/import fail) → trả về object `{ ok:false, reason }`, không throw âm thầm.
2. UI: khi chọn instrument VSTi → gọi ensure NGAY (pre-warm) + progress indicator; note đầu tiên chờ ready (hoặc disable tới ready) — không rơi vào oscillator.
3. Khi fail → toast "không autosample được VSTi: <reason>".
4. Nút re-sample trong panel instrument.
- **Verify**: chọn VSTi path sai → toast hiện reason, không ra âm oscillator; chọn VSTi hợp lệ → pre-warm xong, note đầu tiên ra đúng âm; bấm re-sample → chạy lại.
- **Done khi**: không còn fail âm thầm; pre-warm hoạt động.
- **Rollback**: `git checkout -- app/static/js/services/vstiAutosample.js app/static/js/app.jsx`
### T4 — `unifiedMidiRouter.js` (Phase B)
- **Mục tiêu**: một điểm dispatch MIDI cho mọi nguồn (spec Part II).
- **File mới**: `app/static/js/services/unifiedMidiRouter.js`
- **Nội dung**: `UnifiedMidiEvent` (trackId, channel, command, pitch int 0-127, velocity int 1-127, sourceType, timestampNs) + `UnifiedMidiRouter` với `engineRegistry: Map<trackId, TrackInstrument>` (thay "1 engine toàn cục" của spec — route theo trackId); `dispatchMidiEvent`; `activeVoiceTracker` key `${channel}_${pitch}` chống stuck notes; `panicAllNotesOff()`.
- **Verify**: `node --check app/static/js/services/unifiedMidiRouter.js`; viết 1 test assert nhỏ (pitch clamp, velocity normalize float→int, tracker đếm đúng, panic clear) — chạy bằng node.
- **Done khi**: file + test chạy xanh; chưa nối vào app.
- **Rollback**: xóa file.
### T5 — `TrackInstrument` + keybed → router (Phase B)
- **Mục tiêu**: keyboard preview đi router; xóa cơ chế token/native.
- **File**: `app/static/js/app.jsx`, có thể thêm `app/static/js/services/trackInstrument.js`
- **Bước**:
1. `TrackInstrument`: bọc FluidSynth channel per-track — `playNote(pitch, vel, dur)`; dùng cho cả preview lẫn items.
2. Keybed `onMouseDown`/`onMouseUp` → router.dispatchMidiEvent (sourceType VIRTUAL_KEYBOARD).
3. XÓA: `playNativeSfNote`, `_sfWasmFallbackNote`, `_nativeSfPreviews`, mọi `__nativeSfOk`/token logic.
- **Verify**: bấm phím liên tiếp 10 lần → không câm; không còn `_nativeSfPreviews` trong code (`grep`); build OK.
- **Done khi**: Bug 1 hết tận gốc; preview vẫn ra tiếng đúng track/channel.
- **Rollback**: `git checkout -- app/static/js/app.jsx` (xóa file mới nếu thêm).
### T6 — Scheduler items → router (Phase B)
- **Mục tiêu**: MIDI items khi chạy timeline đi cùng router.
- **File**: `app/static/js/app.jsx` (scheduler/playhead)
- **Bước**: note-on/note-off của scheduler → `router.dispatchMidiEvent` (TIMELINE_SCHEDULER); note-off dùng tracker đối xứng.
- **Verify**: chạy timeline → items phát đúng pitch/vel; dừng giữa chừng → `panicAllNotesOff` không stuck note.
- **Done khi**: preview + timeline cùng engine, không nuốt voice.
### T7 — Hardware MIDI → router (Phase B)
- **File**: `app/static/js/app.jsx` (L15689 `navigator.requestMIDIAccess`/`onmidimessage`)
- **Bước**: `onmidimessage``router.handleHardwareKeyboardMessage(...)` → dispatch (HARDWARE_KEYBOARD); xóa nhánh xử lý cũ.
- **Verify**: bấm phím MIDI hardware → ra tiếng qua router đúng track active; log sourceType.
- **Done khi**: 3 nguồn (keybed/scheduler/hardware) cùng một router.
### T8 — Rust `vst_gui.rs` (Phase C)
- **Mục tiêu**: command `open_vst_gui(plugin_id, track_id)` tạo floating child window.
- **File**: `src-tauri/Cargo.toml` (+ `raw-window-handle`), `src-tauri/src/vst_gui.rs`, `src-tauri/src/lib.rs` (register command).
- **Nội dung** (spec vsti_gui): `WebviewWindowBuilder` label `vst_gui_{track}_{plugin}`, 800×600, always_on_top, mở lại thì focus; `raw_window_handle()` lấy HWND.
- **Verify**: `cargo check`; gọi từ JS `invoke('open_vst_gui', {...})` → cửa sổ nổi mở/đóng không crash.
- **Done khi**: window nổi mở được; chưa gắn plugin GUI (T9).
### T9 — VST3 GUI bridge (Phase C)
- **File mới**: `native_host/vst3_host_bridge.cpp` + build DLL (MSVC/CMake)
- **Nội dung** (spec): `attach_vst3_editor_to_handle(plugin_id, parent_handle)``createView(kEditor)``attached(hwnd, kPlatformTypeHWND)``getSize()` → resize. Đóng: `removed()` + `release()`.
- **Quyết định trước khi làm**: VST3 SDK thuần (nhẹ) HAY JUCE (gộp luôn VST2 — xem T10). Khuyến nghị: JUCE nếu muốn 1 engine cho cả 2; SDK thuần nếu chỉ VST3.
- **Verify**: mở GUI VST3 trong cửa sổ nổi, chỉnh param, đóng → không leak/crash.
- **Done khi**: GUI plugin hiển thị + đóng sạch.
### T10 — VST2 engine (Phase C)
- **File mới**: `native_host/VST2AudioEngine.cpp` (dùng `vestige.h` — clean-room, KHÔNG dùng aeffect.h chính thức; legal note spec III)
- **Nội dung** (spec VST2): `loadPlugin` (LoadLibraryW + `VSTPluginMain`/`main` + dispatcher init), `sendMidiNoteOn` (`effProcessEvents`, `deltaFrames`), `attachGUI` (`effEditOpen` + `effEditGetRect`), `processAudioBlock` (`processReplacing`), destructor teardown. `HostAudioMaster` phải trả lời opcode version/sampleRate/blockSize.
- **Verify**: load .dll VST2 → mở GUI → processReplacing ra tín hiệu.
- **Done khi**: VST2 GUI + audio thô chạy (chưa vào mixer).
### T11 — IPC param sync (Phase C)
- **File**: `src-tauri/src/vst_gui.rs` + JS side
- **Nội dung**: native param đổi → `vst_param_changed` → JS cập nhật state DAW; JS automation → `set_param`/`setParamNormalized` → native.
- **Verify**: xoay knob plugin → JS state đổi; đổi tham số từ DAW → plugin đổi.
- **Done khi**: sync 2 chiều không lag, không vòng lặp vô hạn (guard flag).
### T12 — Native audio loop (Phase C)
- **File mới**: `native_host/VST3AudioEngine.cpp` (hoặc gộp với T9/T10 theo quyết định JUCE), SPSC ring buffer
- **Nội dung** (spec audio): UI thread → SPSC queue → audio thread build EventList/VstEvents → `process()`/`processReplacing` → pre-alloc buffers; zero-allocation trong audio thread; driver WASAPI Exclusive/ASIO 128256 samples; `setProcessing(true)` 1 lần lúc init; sampleRate từ negotiate.
- **Verify**: phát preview qua native driver, 0 underrun; đo latency ≤ ~5ms.
- **Done khi**: VSTi ra loa native, không qua WebAudio.
### T13 — `NativeMixer` (Phase C)
- **File mới**: `native_host/NativeMixer.cpp`
- **Nội dung** (spec audio tier 3): track gain `10^(dB/20)`, pan constant-power, master sum, brickwall limiter — MIRROR `app/core/mastering_engine.py` (đọc file đó trước).
- **Verify**: golden test — render cùng 1 đoạn bằng native mixer và server `render_project` (cùng mastering settings) → so RMS/peak sai lệch < 0.1dB.
- **Done khi**: native mixer khớp server; track gain/pan áp cho cả VST2/VST3.
### T14 — Port FluidSynth native (Phase C)
- **Mục tiêu**: SF track vào CÙNG native mixer (một master duy nhất).
- **Bước**: tích hợp libfluidsynth C++ vào native host; track SF → FluidSynth native → NativeMixer. (Nếu không port được: loopback WASM→native — phức tạp, ghi quyết định vào Ghi chú.)
- **Verify**: SF track + VSTi track cùng master; gain/pan/mastering áp đúng cả 2; preview + items + offline nhất quán.
- **Done khi**: một mixer duy nhất, hết WebAudio cho track instrument.
### T15 — Bỏ WebAudio master (Phase C)
- **Bước**: track instrument không còn đi `masterBus` WebAudio; IPC `set_master_gain`, `set_track_gain_pan` từ JS → native; giữ WebAudio chỉ cho UI/aux nếu còn.
- **Verify**: test matrix spec V: VST3/VST2/SF × preview/live/offline đều đúng âm lượng và mastering.
- **Done khi**: Bug 2 không còn drift (một engine duy nhất); checklist spec 2 (negotiate SR/buffer, jitter <0.1ms, 0% underrun) đạt.
### T16 — Autosample hoàn thiện (Phase D)
- **File**: `tools/autosample_vsti.py`, `app/static/js/services/vstiAutosample.js`
- **Nội dung**: autosample VST2 → `.sf3` cho offline (Pedalboard không hỗ trợ VST2 → giữ con đường này cho export); tôn trọng preset_path/preset_data; hiển thị note_count/size; re-sample khi đổi preset.
- **Verify**: export track VST2 ra file đúng âm; đổi preset → re-sample tự động; UI hiện size.
- **Done khi**: matrix hoàn chỉnh; toàn bộ test xanh; commit cuối.
## 5. Checkpoint tổng
- Sau T3: 3 bug đã vá (tạm) — bản phát hành an toàn.
- Sau T7: unified MIDI pipeline xong phía client — Bug 1 hết tận gốc.
- Sau T15: native audio hoàn chỉnh — Bug 2 hết drift, Bug 3 hết fallback mặc định.
- Sau T16: matrix spec V đầy đủ.