From fd790c1dc6e320fe316c4ff27937ac5bd2fbbc58 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sat, 22 Aug 2026 19:26:43 +0700 Subject: [PATCH] =?UTF-8?q?feat(fx):=20Phase=201=20DAW=20chain=20hop=20nha?= =?UTF-8?q?t=20=E2=80=94=20fx=5Frealtime=20chain=20JSON=20giu=20thu=20tu,?= =?UTF-8?q?=20SET=5FPARAM/REPORT=5FLATENCY=20qua=20WS=20text,=20native=5Fr?= =?UTF-8?q?ender=20chain=20builtin+vst3=20xen=20ke,=20pytest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN_DAW_A.md | 164 ++++++++++++++++++++++++++++++++ TASKS_DAW_A.md | 57 +++++++++++ app/api/v1/plugins.py | 18 +++- app/core/fx_realtime.py | 54 +++++++++-- app/core/native_render.py | 10 ++ tests/test_fx_realtime_chain.py | 72 ++++++++++++++ 6 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 PLAN_DAW_A.md create mode 100644 TASKS_DAW_A.md create mode 100644 tests/test_fx_realtime_chain.py diff --git a/PLAN_DAW_A.md b/PLAN_DAW_A.md new file mode 100644 index 0000000..065f565 --- /dev/null +++ b/PLAN_DAW_A.md @@ -0,0 +1,164 @@ +# PLAN — Phương án A: Engine xử lý toàn chain (chuẩn DAW) + +Repo: C:/Users/locpham/SonicForgeStudio (branch standalone-shm-bridge, HEAD 5ad2c68) + +## 0. Chuẩn DAW phải tuân thủ (KHÔNG bỏ qua) +1. **Track insert chain serial đúng thứ tự UI**: builtin + VST3 xen kẽ đúng vị trí + người dùng kéo; slot bypass chỉ bỏ qua slot đó, không đổi thứ tự phần còn lại. +2. **Track gain/pan sau track chain**, trước khi sum vào master bus. +3. **Master insert chain serial đúng thứ tự UI**, chạy trên mix tổng. +4. **Master fader là gain cuối cùng** trước metering/hardware out (sau master chain). +5. **Live = Export**: cùng 1 engine, cùng 1 chain descriptor, cùng 1 code DSP. +6. **PDC (Plugin Delay Compensation)**: plugin khai latency → track được align + (đã có sườn `_track_latency_samples` render_engine.py:286; live chưa có). +7. **Gain staging rõ**: track input → chain → gain/pan → sum → master chain → fader. +8. **Mono/Stereo**: SF/mono track được pad thành stereo tại input engine; mọi xử + lý stereo 2ch (bridge hiện đã stereo). + +## 1. Kiến trúc đích +WebAudio chỉ còn I/O shim; mọi DSP (builtin + VST3) chạy trong `fx_vst_bridge`: + +``` +track instrument (SF/audio-clip) ─► [worklet track: capture raw] + │ WS/SHM + ▼ + fx_vst_bridge: TRACK CHAIN (builtin DSP C++ + VST3, đúng thứ tự) + PDC + │ WS/SHM + ▼ + WebAudio: track gain/pan ─► masterBus.input (sum) + │ [worklet master: capture sum] + │ WS/SHM + ▼ + fx_vst_bridge: MASTER CHAIN (builtin DSP C++ + VST3, đúng thứ tự) + │ WS/SHM + ▼ + WebAudio: master fader ─► analyser ─► ctx.destination +``` + +- Chain descriptor DUY NHẤT `{type, path?, preset_b64?, params?, bypass}` cho cả + track lẫn master, live lẫn offline. +- Engine dùng chung 1 code path: realtime loop (SHM) và `--render-fx` (WAV) + cùng `BuiltinFxChain` + `RealtimeFxChain`. + +## 2. Hiện trạng (đã xác minh) +- Bridge realtime `RealtimeFxChain` (RenderFxJob.cpp:1051-1190) CHỈ VST3, + in-place, SEH-guarded, worker thread swap chain. Không builtin DSP. +- `--render-fx` slots chỉ nhận `vst3` + `builtin`(gain/normalize) — native_render.py:219-234. +- Frontend master: builtin chain chạy WebAudio (MASTER_MODULE_IO graph, + app.jsx:1261-1310, rebuildMasteringGraph:1839); VST gom cuối qua fxRtStart:1682, + worklet sau fader (output→worklet→analyser) — SAI: VST sau fader + order cố định. +- Frontend track: builtin FX WebAudio (createFXModule:1450-1520, fxMods/sfMods + app.jsx:23058-23104), VST splice SAU builtin (fxRtTrackSplice:23250-23270) — + order cố định, bỏ qua vị trí UI. +- Offline render_engine.py: track `fx_chain`(Python DSP eq/eqpro/comp/lim/exciter/ + rebalance — KHÔNG có imager/maximizer) → `vst_fx_chain`(bridge); master chỉ + `master.fx_chain` = vstFxChain từ app.jsx:11409 → **builtin master chain + imager/maximizer KHÔNG áp dụng offline** → live ≠ export. +- Worklet sf-fx-realtime.js: gom 128→256 block, WS round-trip, outQueue drop >32. +- Python builtin DSP: `_apply_builtin_fx_chain` (render_engine.py:228-260) — + 6 loại; JS WebAudio: 8 loại (MODULE_META app.jsx:13457-13468). +- PDC: chỉ offline skeleton, plugin chưa báo latency (0). + +## 3. Các bước + +### Phase 1 — Chain descriptor + protocol hợp nhất +- Đặc tả JSON: slot = `{type: vst3|eq|eqpro|imager|maximizer|compressor|limiter| + exciter|rebalance, path?, preset_b64?, params?, bypass, active}`. `active:false` + = tắt hẳn (UI), `bypass` = bỏ qua slot giữ nguyên order. +- job.json realtime + render-fx nhận chain hợp nhất (thay `fx_chain` cũ). +- Thêm protocol: `SET_PARAM {slot, key, value}` (realtime builtin automation), + `REPORT_LATENCY {slot, samples}` → engine track align (PDC). +- Update `_chain_json_for_bridge` fx_realtime.py:33 + native_render.py:230 + job. + +#### Chain slot JSON (đặc tả chính thức — chuẩn cho live + offline) +```json +{ + "type": "vst3|eq|eqpro|imager|maximizer|compressor|limiter|exciter|rebalance", + "path": "C:/.../plugin.vst3", // bắt buộc khi type=vst3 + "preset_b64": "", // vst3: preset nội bộ (base64) + "params": { }, // builtin: tham số DSP (xem bảng dưới) + "bypass": false, // bỏ qua slot khi process, GIỮ vị trí + "active": true // false = slot bị tắt hẳn (UI xóa khỏi + // chain payload trước khi gửi engine) +} +``` +Tham số builtin (khớp Python `_apply_*` + JS `MASTER_MODULE_IO`): +| type | params | +|---|---| +| eq | `g1..g4` dB (lowshelf100/peaking800/peaking3200/highshelf10000) | +| eqpro | `amount` 0-200, `bands[]` {type,freq,q,gain,active} (RBJ) | +| imager | `w1..w4` width 4-band (JS gainLL/RL/LR/RR) | +| maximizer | `boost_db`, `ceiling_db`, `soft_clip` bool | +| compressor | `threshold` -60..0, `ratio` 1..20, `makeup` 0..12 | +| limiter | `threshold` -24..0, `ceiling` | +| exciter | `wet` 0..100, `hp_hz` | +| rebalance | `g1..g4` dB 4-band (M/S) | + +Control protocol (qua WS text frame, cùng socket audio): +- Client → engine: `{"cmd":"set_param","slot":,"key":"threshold","value":-18.0}` +- Bridge → engine → client: `{"cmd":"latency","slot":,"samples":1024}` +- Engine giữ `pending_params[slot][key]` + `latencies[slot]` per-session; C++ + consume ở Phase 2.8 (SHM control ring). + +### Phase 2 — Bridge C++: BuiltinFxChain (8 DSP) +- File mới `native_bridge/src/BuiltinFxChain.cpp` + header, port từ Python + numpy (`_apply_eq4/_apply_eqpro/_apply_compressor/_apply_limiter/_apply_exciter/ + _apply_rebalance`) VÀ JS WebAudio (imager/maximizer — MASTER_MODULE_IO graph + app.jsx:1261-1310 + applyMasteringSettings:816-935) sang C++ stereo float. + Khớp tham số: eq g1..g4 dB; eqpro bands(8, RBJ); compressor threshold/ratio/ + makeup; limiter threshold/ceiling; exciter wet/hp; rebalance 4-band gains; + imager 4-band width; maximizer boost/ceiling/soft-clip. +- `RealtimeFxChain::buildChain` + `RenderFxJob` slots: nhận builtin → chạy + BuiltinFxChain trước VST3 theo đúng thứ tự array. +- SEH-guard chung; bypass giữ vị trí. +- Test golden: `native_bridge/tests/` — input WAV → C++ chain vs Python + `_apply_builtin_fx_chain` same params → so SNR (mục tiêu > 60dB hoặc + diff < 1e-4; biquad RMS khác nhau do floating — chấp nhận ngưỡng). + Thêm case: chain xen kẽ builtin→vst3→builtin verify thứ tự (vst3 dummy delay). + +### Phase 3 — Engine Python: render + realtime dùng chain hợp nhất +- `render_engine.py`: track/master đọc chain hợp nhất từ session payload; + `_apply_builtin_fx_chain` Python chỉ giữ làm fallback khi RENDER_ENGINE≠bridge + (ponytail: xóa hẳn khi bridge bắt buộc). Bỏ bất đối xứng imager/maximizer. +- `fx_realtime.py`: session nhận chain hợp nhất, chuyển SET_PARAM/REPORT_LATENCY; + master + track session chung protocol. +- PDC offline: đọc `latency_samples` từ slot → align (render_engine.py:333 đã có + pre-scan — nối với REPORT_LATENCY). + +### Phase 4 — Frontend: WebAudio chỉ I/O +- Chain model: 1 mảng duy nhất `chain` (builtin + vst3 theo UI order). Bỏ tách + `vstFxChain`/`vstBypass`/`vst_fx_bypass` (giữ backward: project cũ có + vstFxChain → merge vào chain theo vị trí cũ: builtin → vst, đánh dấu migration). +- Master: xóa MASTER_MODULE_IO graph + rebuildMasteringGraph node-web; thay 1 + worklet master (`masterBus.output` → worklet → fader gain → analyser → dest). + Fader (masterBus.output) chuyển SAU worklet — đúng chuẩn #4. applyMastering + param thay bằng SET_PARAM push. +- Track: `rebuildTrackFxGraph` bỏ fxMods/sfMods WebAudio builtin (giữ + gain/panner/analyser); 1 worklet track ở chain input, engine trả chain output + → legacy chorus/reverb (nếu còn) → gain/pan → masterBus.input. +- `fxRtChain()`/`fxRtTrackChain()` đọc chain hợp nhất; `chainKey` so toàn chain + (không chỉ VST) → restart session khi đổi thứ tự/param builtin. +- Worklet: thêm `SET_PARAM` forward + queue drop → adaptive (ponytail), giữ cap. + +### Phase 5 — Verify (chuẩn DAW checklist) +1. Build frontend `node build.mjs` OK. +2. pytest tests/ (render_engine golden so bridge; plugin_api; fx_realtime). +3. Golden: export WAV offline vs live capture (record masterBus) — diff < ngưỡng + (live=export, chuẩn #5). Có test g2_bridge_smoke mẫu. +4. Order test: chain [eq, vst3-delay, limiter] — verify delay chỉ áp sau EQ + (impulse response), không gom cuối. +5. Fader test: master chain active, fader -6dB → đỉnh giảm đúng, chain input + không đổi (chuẩn #4). +6. PDC: plugin latency 1024 samples → track bù, xuyên pha biến mất. +7. Smoke: server + /capabilities + 1 render thật (master chain imager+maximizer + + vst3) — trước đây offline thiếu → giờ có. +8. Commit + push standalone-shm-bridge. + +## 4. Rủi ro / quyết định +- **Latency live**: +1 round-trip track + 1 master (~2-4 block mỗi hop). Chấp + nhận (bản chất bridge); giữ queue cap, giảm blockMs. +- **Param automation realtime cho VST3** (preset thay đổi qua GUI): hiện chỉ + preset_b64 lúc start — giữ nguyên phase này (ponytail: MIDI CC→param sau). +- **Migration project cũ**: schema vẫn nhận vstFxChain (đọc cũ) nhưng ghi chain + hợp nhất; không phá project cũ. +- **Không bỏ**: bypass giữ order, PDC, fader cuối, live=export, gain staging. diff --git a/TASKS_DAW_A.md b/TASKS_DAW_A.md new file mode 100644 index 0000000..e467278 --- /dev/null +++ b/TASKS_DAW_A.md @@ -0,0 +1,57 @@ +# TASKS — Phương án A: Engine xử lý toàn chain (chuẩn DAW) + +Trạng thái: `[x]` hoàn thành, `[ ]` chưa. Cập nhật sau mỗi phase. + +## Phase 1 — Chain descriptor + protocol hợp nhất +- [x] 1.1 Đặc tả chain slot JSON hợp nhất (type/path/preset_b64/params/bypass/active) — ghi vào doc. +- [x] 1.2 `app/core/fx_realtime.py`: `_chain_json_for_bridge` nhận chain hợp nhất + field params/latency. +- [x] 1.3 `app/core/native_render.py`: `render_fx_chain` nhận chain hợp nhất (builtin+vst3 xen kẽ, giữ order), bỏ giới hạn type vst3/builtin. +- [x] 1.4 job.json realtime + render-fx: schema chain hợp nhất (thay fx_chain cũ). +- [x] 1.5 Protocol SET_PARAM {slot,key,value} (realtime builtin automation) — engine Python nhận + forward. +- [x] 1.6 Protocol REPORT_LATENCY {slot,samples} — engine Python nhận, lưu per-session. +- [x] 1.7 Pytest: chain hợp nhất → job.json đúng schema; SET_PARAM/REPORT_LATENCY round-trip. + +## Phase 2 — Bridge C++: BuiltinFxChain (8 DSP) +- [ ] 2.1 File `native_bridge/src/BuiltinFxChain.{h,cpp}` — khung chain serial + SEH guard. +- [ ] 2.2 Port EQ 4-band (eq) từ Python `_apply_eq4`. +- [ ] 2.3 Port EQ Pro (eqpro) RBJ 8-band từ Python `_apply_eqpro`. +- [ ] 2.4 Port compressor/limiter từ Python. +- [ ] 2.5 Port exciter/rebalance từ Python. +- [ ] 2.6 Port imager/maximizer từ JS WebAudio (MASTER_MODULE_IO) — Python không có. +- [ ] 2.7 `RealtimeFxChain::buildChain` + RenderFxJob: nhận builtin slot → chạy BuiltinFxChain xen kẽ VST3 đúng thứ tự. +- [ ] 2.8 SET_PARAM/REPORT_LATENCY trong bridge (SHM control ring). +- [ ] 2.9 Golden test: C++ vs Python `_apply_builtin_fx_chain` (SNR/diff ngưỡng) + order test xen kẽ. + +## Phase 3 — Engine Python: render + realtime chain hợp nhất +- [ ] 3.1 `render_engine.py`: track/master đọc chain hợp nhất; Python DSP giữ làm fallback (RENDER_ENGINE≠bridge). +- [ ] 3.2 Bỏ bất đối xứng imager/maximizer (offline master chain đầy đủ 8 loại qua bridge). +- [ ] 3.3 PDC: nối REPORT_LATENCY → align track (render_engine.py:286+333). +- [ ] 3.4 Pytest: render offline có imager/maximizer master giống live. + +## Phase 4 — Frontend: WebAudio chỉ I/O +- [ ] 4.1 Chain model 1 mảng duy nhất (builtin+vst3 theo UI order); migration project cũ vstFxChain. +- [ ] 4.2 Master: bỏ MASTER_MODULE_IO graph + rebuildMasteringGraph node-web; 1 worklet master; fader SAU worklet. +- [ ] 4.3 Track: bỏ fxMods/sfMods WebAudio builtin; 1 worklet track; gain/pan giữ. +- [ ] 4.4 `fxRtChain()`/`fxRtTrackChain()` đọc chain hợp nhất; chainKey so toàn chain. +- [ ] 4.5 applyMastering param → SET_PARAM push. +- [ ] 4.6 Worklet: forward SET_PARAM; queue drop → adaptive (ponytail). + +## Phase 5 — Verify (chuẩn DAW checklist) +- [ ] 5.1 Build frontend `node build.mjs`. +- [ ] 5.2 Pytest toàn bộ. +- [ ] 5.3 Golden live=export (record masterBus vs offline WAV) diff ngưỡng. +- [ ] 5.4 Order test: [eq, vst3-delay, limiter] — impulse xác nhận delay sau EQ. +- [ ] 5.5 Fader test: chain active, fader -6dB → đỉnh giảm, chain input không đổi. +- [ ] 5.6 PDC test: plugin latency 1024 → xuyên pha hết. +- [ ] 5.7 Smoke server + render master imager+maximizer+vst3. +- [ ] 5.8 Commit + push standalone-shm-bridge. + +## Review DAW-standard (sau Phase 5, bổ sung nếu thiếu) +- [ ] R1 Track insert chain serial đúng thứ tự UI (builtin+VST xen kẽ). +- [ ] R2 Gain/pan sau track chain, trước sum. +- [ ] R3 Master chain serial đúng thứ tự trên mix tổng. +- [ ] R4 Master fader cuối cùng trước metering/out. +- [ ] R5 Live = Export (cùng engine, cùng chain, cùng DSP). +- [ ] R6 PDC. +- [ ] R7 Gain staging rõ. +- [ ] R8 Mono/Stereo xử lý đúng. diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index fc9adbf..587ed35 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1069,9 +1069,23 @@ async def ws_fx_realtime(websocket: WebSocket, session_id: str): pump_task = asyncio.create_task(_pump_output()) try: while True: - data = await websocket.receive_bytes() - if not sess.alive(): + msg = await websocket.receive() + if msg.get("type") == "websocket.disconnect" or not sess.alive(): break + if msg.get("text") is not None: + # Control protocol Phase 1: SET_PARAM / REPORT_LATENCY qua WS text. + try: + cmd = json.loads(msg["text"]) + except Exception: + continue + if cmd.get("cmd") == "set_param": + sess.set_param(cmd.get("slot"), cmd.get("key"), cmd.get("value")) + elif cmd.get("cmd") == "latency": + sess.report_latency(cmd.get("slot"), cmd.get("samples")) + continue + data = msg.get("bytes") + if data is None: + continue sess.write_input(data) except WebSocketDisconnect: pass diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 02aec82..30578bf 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -86,21 +86,37 @@ class FxRealtimeSession: self.proc = None self._job_path = "" self._started_at = time.time() + # Chain hợp nhất (spec PLAN_DAW_A.md Phase 1): giữ đúng thứ tự slot. + self.chain = list(self.fx_chain or []) + # Control protocol (SET_PARAM / REPORT_LATENCY) — Phase 1: giữ ở engine; + # Phase 2.8: C++ consume qua SHM control ring. + self.pending_params = {} # {slot_idx: {key: value}} + self.latencies = {} # {slot_idx: samples} self._spawn() # ── process ───────────────────────────────────────────────────────────── def _chain_json_for_bridge(self): - """JSON mảng {path,bypass,preset_b64} — RealtimeFxChain::buildChain đọc - đúng các field này (type/name bỏ qua).""" + """JSON mảng slot theo spec Phase 1 — giữ NGUYÊN thứ tự + type/params; + RealtimeFxChain::buildChain (C++) đọc {path,bypass,preset_b64} cho vst3 + và {type,params} cho builtin (Phase 2). Slot active=false bị loại trước + khi gửi (UI tắt hẳn — không gửi engine).""" out = [] - for s in self.fx_chain: - if not s or not s.get("path"): + for s in self.chain: + if not s or not isinstance(s, dict): continue - out.append({ - "path": s["path"], + if s.get("active") is False: + continue + slot = { + "type": s.get("type", ""), "bypass": bool(s.get("bypass")), - "preset_b64": s.get("preset_b64") or "", - }) + } + if s.get("path"): + slot["path"] = s["path"] + if s.get("preset_b64"): + slot["preset_b64"] = s["preset_b64"] + if isinstance(s.get("params"), dict) and s["params"]: + slot["params"] = s["params"] + out.append(slot) return out def _spawn(self): @@ -203,6 +219,28 @@ class FxRealtimeSession: def heartbeat_age(self): return time.time() - self._started_at # placeholder: engine đọc h.heartbeat + # ── control protocol (SET_PARAM / REPORT_LATENCY) ──────────────────────── + # Phase 1: giữ ở engine, client gọi qua WS text frame. Phase 2.8: bridge + # C++ đọc pending_params từ SHM control ring + ghi latency vào ring. + def set_param(self, slot, key, value): + with self._lock: + self.pending_params.setdefault(slot, {})[str(key)] = value + + def report_latency(self, slot, samples): + with self._lock: + self.latencies[int(slot)] = int(samples) + + def drain_params(self): + """Trả + xóa toàn bộ pending param (bridge consume 1 lần).""" + with self._lock: + out = self.pending_params + self.pending_params = {} + return out + + def get_latencies(self): + with self._lock: + return dict(self.latencies) + # Registry toàn tiến trình (desktop app 1 user). _SESSIONS = {} diff --git a/app/core/native_render.py b/app/core/native_render.py index 4499f0e..eee3c72 100644 --- a/app/core/native_render.py +++ b/app/core/native_render.py @@ -232,6 +232,8 @@ def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None) -> str: ) slots = [] for fx in fx_chain or []: + if not isinstance(fx, dict) or fx.get("active") is False: + continue typ = fx.get("type") if typ == "vst3": p = (fx.get("path") or "").strip() @@ -244,7 +246,15 @@ def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None) -> str: slots.append({"type": "vst3", "path": resolved, "preset_b64": fx.get("preset_b64", "") or "", "bypass": bool(fx.get("bypass"))}) + elif typ in ("eq", "eqpro", "imager", "maximizer", "compressor", + "limiter", "exciter", "rebalance"): + # Builtin DSP — chain hợp nhất (spec PLAN_DAW_A.md Phase 1). C++ + # RenderFxJob xử lý theo id/params (Phase 2); giữ NGUYÊN thứ tự. + slots.append({"type": "builtin", "id": typ, + "params": dict(fx.get("params") or {}), + "bypass": bool(fx.get("bypass"))}) elif typ == "builtin": + # Legacy: {type: builtin, id: gain} — giữ cho project cũ. slots.append({"type": "builtin", "id": fx.get("id", "gain"), "params": dict(fx.get("params") or {}), "bypass": bool(fx.get("bypass"))}) diff --git a/tests/test_fx_realtime_chain.py b/tests/test_fx_realtime_chain.py new file mode 100644 index 0000000..5b77ceb --- /dev/null +++ b/tests/test_fx_realtime_chain.py @@ -0,0 +1,72 @@ +"""Phase 1 chain hợp nhất + control protocol (PLAN_DAW_A.md). + +Unit test không spawn bridge: dùng object.__new__ để bỏ qua __init__/subprocess, +chỉ test logic thuần của FxRealtimeSession. +""" +import threading + +from app.core import fx_realtime + + +def make_session(chain): + s = object.__new__(fx_realtime.FxRealtimeSession) + s.chain = list(chain) + s._lock = threading.Lock() + s.pending_params = {} + s.latencies = {} + return s + + +CHAIN = [ + {"type": "vst3", "path": "C:\\Plugins\\Eq.vst3", + "preset_b64": "AAA=", "bypass": False, "active": True}, + {"type": "eq", "params": {"g1": 0.5}, "bypass": False, "active": True}, + {"type": "compressor", "params": {"threshold": -12.0, "ratio": 4.0}, + "bypass": True, "active": True}, + {"type": "vst3", "path": "C:\\Plugins\\Disabled.vst3", "active": False}, + {"type": "limiter", "params": {}, "bypass": False, "active": True}, +] + + +def test_chain_json_keeps_order_and_skips_inactive(): + """_chain_json_for_bridge: giữ NGUYÊN thứ tự UI (vst3+builtin xen kẽ), + loại slot active=false, giữ type/path/preset_b64/params/bypass.""" + s = make_session(CHAIN) + out = s._chain_json_for_bridge() + assert [o["type"] for o in out] == ["vst3", "eq", "compressor", "limiter"] + assert out[0]["path"] == "C:\\Plugins\\Eq.vst3" + assert out[0]["preset_b64"] == "AAA=" + assert out[1]["params"] == {"g1": 0.5} + assert out[2]["bypass"] is True + assert out[2]["params"] == {"threshold": -12.0, "ratio": 4.0} + # params rỗng → không đưa key params (khớp job schema) + assert out[3]["type"] == "limiter" + assert "params" not in out[3] + + +def test_chain_json_handles_malformed_slots(): + s = make_session([None, "x", {"active": False}, {}, {"type": "eq"}]) + out = s._chain_json_for_bridge() + assert [o["type"] for o in out] == ["eq"] + + +def test_set_param_report_latency_drain_roundtrip(): + """Control protocol Phase 1: set_param/report_latency/drain hoạt động + đúng (engine nhận từ WS text, giữ pending_params + latencies).""" + s = make_session([]) + s.set_param(1, "g1", 0.75) + s.set_param(1, "g2", -1.5) + s.set_param(3, "threshold", -20.0) + s.report_latency(1, 128) + s.report_latency(3, 256) + assert s.get_latencies() == {1: 128, 3: 256} + assert s.drain_params() == {1: {"g1": 0.75, "g2": -1.5}, + 3: {"threshold": -20.0}} + assert s.drain_params() == {} + + +def test_set_param_overwrites_same_key(): + s = make_session([]) + s.set_param(0, "wet", 0.5) + s.set_param(0, "wet", 0.9) + assert s.drain_params() == {0: {"wet": 0.9}}