feat(fx): Phase 1 DAW chain hop nhat — fx_realtime chain JSON giu thu tu, SET_PARAM/REPORT_LATENCY qua WS text, native_render chain builtin+vst3 xen ke, pytest

This commit is contained in:
2026-08-22 19:26:43 +07:00
parent 5ad2c681e7
commit fd790c1dc6
6 changed files with 365 additions and 10 deletions
+164
View File
@@ -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":<idx>,"key":"threshold","value":-18.0}`
- Bridge → engine → client: `{"cmd":"latency","slot":<idx>,"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.
+57
View File
@@ -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.
+16 -2
View File
@@ -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
+46 -8
View File
@@ -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 = {}
+10
View File
@@ -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"))})
+72
View File
@@ -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}}