131 lines
11 KiB
Markdown
131 lines
11 KiB
Markdown
# JUCE_LATENCY_PLAN — Xử lí latency/delay master chain FX bằng JUCE
|
||
|
||
Ngày: 2026-08-27 · Repo: `C:/Users/locpham/SonicForgeStudio`
|
||
Trạng thái: KẾ HOẠCH — chưa implement. Tuân theo nguyên tắc `native_bridge/debug/ARCHITECTURE_ROADMAP.md`: viết test tái hiện TRƯỚC, implement tối thiểu, probe matrix + driver pass mới merge.
|
||
|
||
## 1. Hiện trạng (bằng chứng từ V38-V42A)
|
||
|
||
Master FX path hiện tại là vòng lặp round-trip qua 3 tiến trình:
|
||
|
||
```
|
||
WebAudio source → masterBus → worklet sf-fx-realtime.js
|
||
→ WS → Python fx_realtime.py → SHM ring (FxRealtimeIPC)
|
||
→ fx_vst_bridge (RealtimeFxLoop.cpp, VST3 chain in-place)
|
||
→ SHM out → WS → worklet outQueue (FILL 8 block ≈ 43ms) → WebAudio out
|
||
```
|
||
|
||
Latency path đo được ~0.22s (V40). Thành phần:
|
||
- Round-trip WS/SHM: ~2 block (256 mẫu) + jitter main-thread
|
||
- FILL buffer: 8 block ≈ 43ms (hấp thụ jitter — cần vì bridge chạy không realtime-strict)
|
||
- VST3 latency riêng từng plugin (REPORT_LATENCY qua SHM `FxLatReport`)
|
||
- Worklet CAP 32 block ≈ 170ms (chặn overflow khi bridge nhanh hơn realtime)
|
||
|
||
Hệ quả phải vá bằng heuristic:
|
||
- **PDC playhead**: freeze + snap (`__fxRtPlayheadFreeze`/`fx_play_first`, V40) — playhead bám tiếng chứ không bám vị trí tín hiệu
|
||
- **PDC dry-path**: `fxRtApplyPdc` trễ dry path `(7+8)*256 + latencyTotal` — hằng số ước lượng, không phải đo graph thật
|
||
- **Seam click loop (FX path)**: worklet wrap crossfade 256 mẫu (V42) — vá click do clear_queue + restart
|
||
- **Bypass click**: native WebAudio loop (V42A) — chỉ áp dụng 1 clip/speed=1; fallback dip 5ms
|
||
- **rAF ε**: event wrap (MIDI re-schedule, playhead reset) trễ ≤16ms so với audio wrap → flam MIDI beat 0
|
||
|
||
Gốc rễ: **transport + FX không nằm chung một engine realtime**; WebAudio không biết latency thật của chain; loop/restart phá liên tục tail.
|
||
|
||
## 2. Mục tiêu
|
||
|
||
1. Latency chain đo chính xác (mẫu), không heuristic — PDC playhead + dry-path tự động.
|
||
2. Hết seam click loop: Option A (mặc định) — 1 master clock duy nhất (WebAudio), engine FX thuần báo latency chính xác → worklet crossfade align đúng processed-tail. Option B (engine sở hữu transport/loop buffer) chỉ khi chấp nhận đổi JS/Python contract.
|
||
3. Playhead = vị trí thật engine báo (không freeze/snap, không rAF ε).
|
||
4. Bypass = bỏ native-loop hack (A) → quay về 1 code path duy nhất.
|
||
|
||
## 3. Kiến trúc đích (JUCE AudioProcessorGraph)
|
||
|
||
Thay `RealtimeFxLoop.cpp` (VST3 chain nối tay, in-place, latency tự report) bằng **JUCE engine**:
|
||
|
||
```
|
||
fx_juce_fx (C++ mới, thay --realtime-fx):
|
||
juce::AudioProcessorGraph
|
||
├─ input node (nhận block SHM in ring)
|
||
├─ master chain: mỗi VST3 FX = AudioPluginInstance (juce VST3 hosting built-in)
|
||
├─ latency node (tự động — graph PDC)
|
||
└─ output node → SHM out ring
|
||
+ getLatencySamples() sau prepareToPlay → REPORT_LATENCY (giữ SHM FxLatReport)
|
||
+ position = tổng samples đã xử lí (derived — KHÔNG phải clock riêng)
|
||
```
|
||
|
||
Giữ nguyên SHM `FxRealtimeIPC` làm ranh giới (không đổi Python/JS transport). Thay đổi nội bộ bridge.
|
||
|
||
**Ranh giới clock (chống split-brain): đúng 1 master clock.** WebAudio giữ clock + loop wrap; engine JUCE là FX processor thuần — không playhead riêng, không wrap. Position engine = derived (tổng samples xử lí), chỉ dùng align PDC/crossfade. Muốn loop wrap sample-accurate thật trong engine (Option B): engine phải sở hữu dry buffer/timeline + đổi SHM contract (upload loop region, WebAudio thành renderer) — NGOÀI phạm vi plan này.
|
||
|
||
### Tại sao JUCE
|
||
- `AudioProcessorGraph::getLatencySamples()` = tổng latency chính xác toàn graph sau `prepareToPlay` — thay hằng số `(7+8)*256` + report thủ công.
|
||
- Host VST3 có sẵn (`juce_VST3PluginFormat`), đã có sẵn trong vcpkg? chưa — cần thêm dependency (xem §6).
|
||
- Latency mẫu chính xác → worklet align wrap-crossfade (V42) đúng processed-tail; seam giảm theo mức đo được, không cần engine wrap (Option A).
|
||
- UI thread / audio thread tách sẵn (JUCE message loop) — khớp ROADMAP G1.
|
||
|
||
## 4. Giai đoạn (mỗi giai đoạn: test trước, implement tối thiểu, probe pass)
|
||
|
||
### G0 — Baseline đo (không code engine, làm TRƯỚC khi quyết định)
|
||
- [x] Đo lại latency path chính xác: log `fx_play_first` timestamp vs WebAudio output actual; ghi FILL/CAP thật dưới tải GUI mở.
|
||
- [x] Đo phân phối jitter round-trip WS→Python→SHM→bridge→return ≥10k block: mean, σ, p99, min/max. Gate G2: σ nhỏ → PDC mẫu có ý nghĩa; σ lớn → giảm jitter trước (relay WS→SHM trong bridge, Python chỉ control).
|
||
- [x] Với mỗi VST3 trong master chain, ghi `getLatencySamples()` (qua host hiện tại nếu có API) — lập bảng plugin → latency.
|
||
- Done khi: bảng latency từng plugin + tổng chain thực đo, xác nhận ~0.22s gồm gì.
|
||
|
||
### G1 — JUCE engine đọc/ghi SHM, chain rỗng (POC)
|
||
- [ ] Dựng project `native_bridge/juce_fx/` (CMake + JUCE via vcpkg hoặc submodule `JUCE/`).
|
||
- [ ] `JuceFxEngine`: mở SHM `FxRealtimeIPC` (reuse openShm/closeShm từ RealtimeFxLoop), audio thread đọc in ring → graph (rỗng) → ghi out ring. Heartbeat 100ms giữ nguyên.
|
||
- [ ] job.json format giữ nguyên `{sample_rate, block_size, fx_chain}`.
|
||
- [ ] Validate header mỗi iteration: `sampleRate`/`blockSize` đổi giữa chừng (đổi thiết bị audio / session mới khác rate) → teardown graph + `prepareToPlay(rate, block)` lại + report latency mới qua FxLatReport. (WebAudio `AudioContext.sampleRate` bất biến — browser resample khi đổi thiết bị; check này phòng header bị ghi lại.)
|
||
- Done khi: `--juce-fx` chạy với chain rỗng, Python `fx_realtime.py` chơi qua nó nghe bằng path cũ (regression: không crackle, không lag thêm).
|
||
|
||
### G2 — VST3 hosting trong graph + latency thật
|
||
- [ ] Nạp từng plugin `juce::AudioPluginInstance` theo chain (path, preset_b64, bypass).
|
||
- [ ] Sau `prepareToPlay` gọi `graph.getLatencySamples()` → ghi `FxLatReport` (giữ giao thức cũ).
|
||
- [ ] `fxRtApplyPdc` (app.jsx) đọc latency thật → bỏ hằng `(7+8)*256`.
|
||
- Done khi: master chain (delay/reverb comp) chạy qua JUCE; dry-path align đúng (test phase: sine + delay plugin, đo zero-crossing trùng).
|
||
|
||
### G3 — 1 clock: bỏ heuristic PDC, align crossfade theo latency thật (Option A)
|
||
- [ ] Engine báo `getLatencySamples()` + latency transport đo được (hằng số từ G0) → worklet align WRAP_FADE đúng processed-tail; không engine wrap, không playhead riêng.
|
||
- [ ] Playhead: app dùng position = samples processed (engine derived, đã trừ latency) → bỏ freeze/snap (V40); rAF ε giữ cho UI, bỏ cho audio path.
|
||
- [ ] Verify seam: loop selection + FX, 100 pass ghi âm so V42 — mục tiêu: crossfade không còn nghe được; nếu vẫn click → cân nhắc Option B (engine sở hữu dry loop buffer, đổi SHM contract — ngoài phạm vi).
|
||
- Done khi: 100 pass không click với crossfade tối thiểu; playhead bám vị trí mẫu không freeze/snap.
|
||
|
||
### G4 — Bypass path thống nhất
|
||
- [ ] Bypass = chain rỗng trong graph (không cần native WebAudio loop V42A, không dip V42).
|
||
- [ ] Gỡ `nativeLoopSrcsRef`/dip gate khi engine active (giữ fallback WebAudio-only cho chế độ không bridge).
|
||
- Done khi: bypass loop nghe sạch bằng native loop cũ; code path FX = bypass chỉ khác chain rỗng.
|
||
|
||
### G5 — MIDI + sections qua engine (tuỳ chọn, sau G3)
|
||
- [ ] MIDI/SF items schedule trong engine (JUCE MidiBuffer) — hết flam rAF ε, hết MIDI re-schedule trễ.
|
||
- [ ] SECTION items: sub-chain latency riêng (PDC per-section) — hiện đang dùng chung master PDC.
|
||
- Done khi: drums beat 0 loop không flam; section sync đúng với main.
|
||
|
||
## 5. Rủi ro + giảm thiểu
|
||
|
||
| Rủi ro | Giảm thiểu |
|
||
|---|---|
|
||
| JUCE VST3 host khác hành vi VST3 SDK host hiện tại (preset/GUI) | Giữ SandboxVst3Host cho GUI/editor; engine JUCE chỉ xử lí audio path; verify preset_b64 load giống |
|
||
| vcpkg JUCE nặng (build time) | Build riêng `juce_fx` target, không đụng main bridge; giữ main bridge deploy ổn định |
|
||
| AudioProcessorGraph PDC delay lớn → playhead lệch nếu chain đổi giữa play | Rebuild graph khi chain đổi (stop/play như cũ); báo latency mới qua SHM |
|
||
| WS round-trip vẫn tồn tại (Python trung gian) | Không bỏ — thay phần bridge là đủ cho latency; tối ưu WS sau nếu cần |
|
||
| Split-brain 2 clock khi G3 (WebAudio master + engine wrap) | 1 master clock duy nhất (Option A); engine không wrap, không playhead riêng |
|
||
| Param GUI (Sandbox instance) không tới processor JUCE | Mở rộng SET_PARAM ring: Sandbox implement IComponentHandler → performEdit/endEdit → `(slot, ParamID, normalizedValue)` → queue JUCE → inputParameterChanges trong processBlock; queue = lock-free SPSC (CẤM mutex trong processBlock — priority inversion → crackle); preset_b64 chỉ bulk/init (reload mỗi knob = chậm + reset state FX → click) |
|
||
| Jitter WS/Python làm mất ý nghĩa PDC mẫu | G0 đo σ round-trip ≥10k block; gate: σ nhỏ → FILL = mean+k·σ; σ lớn → giảm jitter trước (relay WS→SHM trong bridge process, Python chỉ control) |
|
||
|
||
## 6. Dependency
|
||
|
||
- JUCE (GPL/commercial — DAW nội bộ, GPLv3 repo OK; xác nhận license trước khi vendor).
|
||
- Vào `native_bridge/CMakeLists.txt` + `vcpkg.json`: `juce` (port hiện có) hoặc submodule `JUCE@master` + `juce_add_gui_app` tối thiểu (chỉ audio, không cần GUI app — dùng `juce::juce_audio_processors` + `juce_audio_utils`).
|
||
|
||
## 7. Tiêu chí hoàn thành (definition of done)
|
||
|
||
1. Master chain FX chạy trong JUCE graph, latency báo chính xác (mẫu) → dry-path align zero-crossing.
|
||
2. Loop selection + FX: 100 pass không click (so với V42 cần crossfade 256 mẫu để che).
|
||
3. Playhead bám vị trí thật, không freeze/snap.
|
||
4. Bypass = chain rỗng, không hack native loop/dip.
|
||
5. Regression: probe matrix + driver (theo ROADMAP) pass; bridge cũ vẫn build được.
|
||
|
||
## 8. Ghi chú ponytail
|
||
|
||
- G0-G2 không đòi hỏi đổi JS/Python — hoàn đảo được, rủi ro thấp. G3 (Option A) cũng không đổi contract.
|
||
- Nếu latency không phải vấn đề chính sau G0 (vd. click do nơi khác), dừng ở G2 — tiết kiệm G3-G5.
|
||
- Option B (engine sở hữu transport/loop buffer) = project riêng, cần đổi JS/Python contract; chỉ mở khi Option A không đủ.
|