Phase 2: BuiltinFxChain C++ (8 DSP) + control ring SET_PARAM/REPORT_LATENCY

- native_bridge/src/BuiltinFxChain.{h,cpp}: 8 builtin DSP port 1:1 từ
  Python _apply_builtin_fx_chain (eq/eqpro/compressor/limiter/exciter/rebalance)
  và JS MASTER_MODULE_IO (imager/maximizer). Biquad RBJ DF2T, block-wise
  stateful. createBuiltinFx trả nullptr cho gain/normalize (legacy).
- RenderFxJob: FxSlot thêm bfx, parse builtin 8 loại, runBuiltinSlotSafe SEH,
  realtimeRunChain nhánh builtin, chainGen()/entryLatencies(), setParam
  áp live lên bfx; Vst3Fx latencySamples từ getLatencySamples().
- RealtimeFxLoop: drain ctrl ring (SET_PARAM -> chain.setParam, drop cũ khi
  đầy) + REPORT_LATENCY khi gen đổi (ipc->lat[] ring, lastGen).
- FxRealtimeIPC.h: header 18 u32 (72B), ctrl/lat slots 8, FxCtrlCmd 24B,
  FxLatReport 8B, static_asserts.
- app/core/fx_realtime.py: mirror header 72B (ctrl_write/read, lat_write/read,
  slots), set_param ghi ctrl ring qua _ctrl_enqueue_locked (guard shm),
  get_latencies drain lat ring qua _lat_drain.
- tests/test_builtin_fx_golden.py: golden SNR C++ vs Python (8 cases, ngưỡng
  30dB) + order test eq/compressor xen kẽ.
- TASKS_DAW_A.md: tick 2.1-2.9.

Test: 136 passed (4 fx_realtime_chain + 8 builtin golden + regression).
This commit is contained in:
2026-08-22 19:55:08 +07:00
parent fd790c1dc6
commit 1390ad545b
10 changed files with 980 additions and 29 deletions
+71 -4
View File
@@ -4,9 +4,12 @@ spawn fx_vst_bridge --realtime-fx, đẩy/kéo block audio qua SHM ring.
Layout mirror chính xác native_bridge/include/FxRealtimeIPC.h (mọi field
u32/float 4-byte, không padding):
header 12 u32 (48B): magic state sampleRate blockSize running heartbeat
header 18 u32 (72B): magic state sampleRate blockSize running heartbeat
inWrite inRead inSlots outWrite outRead outSlots
ctrlWrite ctrlRead ctrlSlots latWrite latRead latSlots
inL[4][256] inR[4][256] outL[8][256] outR[8][256]
ctrl[8] (FxCtrlCmd 24B) — SET_PARAM ring (engine -> bridge)
lat[8] (FxLatReport 8B) — REPORT_LATENCY ring (bridge -> engine)
Luồng dữ liệu: browser → WS → write_input() → [bridge xử lý] → read_output()
→ WS → browser. Block = FXRT_BLOCK=256 stereo float32 = 2048 bytes.
@@ -29,6 +32,8 @@ FXRT_MAGIC = 0x46585254
FXRT_BLOCK = 256
FXRT_IN_SLOTS = 4
FXRT_OUT_SLOTS = 8
FXRT_CTRL_SLOTS = 8
FXRT_LAT_SLOTS = 8
FXRT_STATE_STARTING = 0
FXRT_STATE_READY = 1
FXRT_STATE_ERROR = 2
@@ -48,15 +53,38 @@ class FxRTHeader(ctypes.Structure):
("out_write", ctypes.c_uint32),
("out_read", ctypes.c_uint32),
("out_slots", ctypes.c_uint32),
("ctrl_write", ctypes.c_uint32),
("ctrl_read", ctypes.c_uint32),
("ctrl_slots", ctypes.c_uint32),
("lat_write", ctypes.c_uint32),
("lat_read", ctypes.c_uint32),
("lat_slots", ctypes.c_uint32),
]
HEADER_SIZE = ctypes.sizeof(FxRTHeader) # 48
IN_L_OFF = HEADER_SIZE # 48
class FxCtrlCmd(ctypes.Structure):
_fields_ = [
("slot", ctypes.c_uint32),
("key", ctypes.c_char * 16),
("value", ctypes.c_float),
]
class FxLatReport(ctypes.Structure):
_fields_ = [
("slot", ctypes.c_uint32),
("samples", ctypes.c_uint32),
]
HEADER_SIZE = ctypes.sizeof(FxRTHeader) # 72
IN_L_OFF = HEADER_SIZE # 72
IN_R_OFF = IN_L_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_L_OFF = IN_R_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_R_OFF = OUT_L_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
TOTAL_SIZE = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
CTRL_OFF = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
LAT_OFF = CTRL_OFF + FXRT_CTRL_SLOTS * ctypes.sizeof(FxCtrlCmd)
TOTAL_SIZE = LAT_OFF + FXRT_LAT_SLOTS * ctypes.sizeof(FxLatReport)
class FxRealtimeSession:
@@ -81,6 +109,8 @@ class FxRealtimeSession:
self.h.running = 1
self.h.in_slots = FXRT_IN_SLOTS
self.h.out_slots = FXRT_OUT_SLOTS
self.h.ctrl_slots = FXRT_CTRL_SLOTS
self.h.lat_slots = FXRT_LAT_SLOTS
self._np = np.frombuffer(self.shm.buf, dtype=np.float32)
self._lock = threading.Lock()
self.proc = None
@@ -225,6 +255,28 @@ class FxRealtimeSession:
def set_param(self, slot, key, value):
with self._lock:
self.pending_params.setdefault(slot, {})[str(key)] = value
# Phase 2.8: đẩy vào SHM control ring (bridge drain mỗi
# iteration). pending_params giữ làm fallback — fake test session
# (object.__new__) không có shm; drain_params vẫn hoạt động.
if getattr(self, "shm", None) is not None:
try:
self._ctrl_enqueue_locked(slot, str(key), value)
except Exception:
pass
def _ctrl_enqueue_locked(self, slot, key, value):
h = self.h
kb = key.encode("utf-8")[:15]
cmds = (FxCtrlCmd * FXRT_CTRL_SLOTS).from_buffer(self.shm.buf, CTRL_OFF)
while True:
if h.ctrl_write - h.ctrl_read < h.ctrl_slots:
cs = h.ctrl_write & (h.ctrl_slots - 1)
cmds[cs].slot = int(slot) & 0xFFFFFFFF
cmds[cs].key = kb + b"\00" * (16 - len(kb))
cmds[cs].value = float(value)
h.ctrl_write += 1
return
h.ctrl_read += 1 # ring đầy → drop lệnh cũ nhất
def report_latency(self, slot, samples):
with self._lock:
@@ -239,8 +291,23 @@ class FxRealtimeSession:
def get_latencies(self):
with self._lock:
self._lat_drain()
return dict(self.latencies)
def _lat_drain(self):
shm = getattr(self, "shm", None)
if shm is None:
return
try:
h = self.h
lats = (FxLatReport * FXRT_LAT_SLOTS).from_buffer(shm.buf, LAT_OFF)
while h.lat_read < h.lat_write:
ls = h.lat_read & (h.lat_slots - 1)
self.latencies[int(lats[ls].slot)] = int(lats[ls].samples)
h.lat_read += 1
except Exception:
pass
# Registry toàn tiến trình (desktop app 1 user).
_SESSIONS = {}