diff --git a/TASKS_DAW_A.md b/TASKS_DAW_A.md index e467278..def019e 100644 --- a/TASKS_DAW_A.md +++ b/TASKS_DAW_A.md @@ -12,15 +12,15 @@ Trạng thái: `[x]` hoàn thành, `[ ]` chưa. Cập nhật sau mỗi phase. - [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ẽ. +- [x] 2.1 File `native_bridge/src/BuiltinFxChain.{h,cpp}` — khung chain serial + SEH guard. +- [x] 2.2 Port EQ 4-band (eq) từ Python `_apply_eq4`. +- [x] 2.3 Port EQ Pro (eqpro) RBJ 8-band từ Python `_apply_eqpro`. +- [x] 2.4 Port compressor/limiter từ Python. +- [x] 2.5 Port exciter/rebalance từ Python. +- [x] 2.6 Port imager/maximizer từ JS WebAudio (MASTER_MODULE_IO) — Python không có. +- [x] 2.7 `RealtimeFxChain::buildChain` + RenderFxJob: nhận builtin slot → chạy BuiltinFxChain xen kẽ VST3 đúng thứ tự. +- [x] 2.8 SET_PARAM/REPORT_LATENCY trong bridge (SHM control ring). +- [x] 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). diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 30578bf..a21e2e8 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -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 = {} diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index b377e9d..3694b0a 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -106,6 +106,7 @@ add_executable(fx_vst_bridge src/main_fx.cpp src/RenderFxJob.cpp src/RealtimeFxLoop.cpp + src/BuiltinFxChain.cpp src/FxGuiServer.cpp ) if(VST3_SDK_TARGET) diff --git a/native_bridge/include/BuiltinFxChain.h b/native_bridge/include/BuiltinFxChain.h new file mode 100644 index 0000000..4fd9754 --- /dev/null +++ b/native_bridge/include/BuiltinFxChain.h @@ -0,0 +1,47 @@ +// native_bridge/include/BuiltinFxChain.h +// Builtin DSP chain (PLAN_DAW_A.md Phase 2): 8 custom FX được port từ Python +// (render_engine.py `_apply_builtin_fx_chain`) + JS WebAudio (imager/maximizer, +// MASTER_MODULE_IO). Serial, in-place, stereo — engine xử lý TOÀN chain; chạy +// xen kẽ VST3 đúng thứ tự UI trong RenderFxJob (offline) + RealtimeFxChain. +#pragma once +#include +#include +#include +#include + +// Forward: sheredom JSON object (params). Khai báo để factory dùng. +struct json_object_s; + +// Một FX builtin — stateful giữa các block (biquad z1/z2, env compressor...). +class BuiltinFx { +public: + virtual ~BuiltinFx() = default; + // Xử lý 1 block stereo in-place (L/R song song, interleaved-wise). + virtual void process(float* L, float* R, uint32_t n) = 0; + // SET_PARAM (SHM control ring, Phase 2.8): cập nhật tham số live. + // Trả false nếu slot không có param này (gọi từ realtime loop thread, + // cùng thread process → không cần lock). + virtual bool setParam(const std::string& key, double value) { (void)key; (void)value; return false; } +}; + +// Chain serial các BuiltinFx — giữ NGUYÊN thứ tự thêm vào. +class BuiltinFxChain { +public: + explicit BuiltinFxChain(double sampleRate) : sampleRate_(sampleRate) {} + void add(std::unique_ptr fx, bool bypass); + // Chạy toàn chain serial, in-place. + void process(float* L, float* R, uint32_t n); + bool setParam(int slot, const std::string& key, double value); + size_t size() const { return entries_.size(); } +private: + struct Entry { std::unique_ptr fx; bool bypass = false; }; + std::vector entries_; + double sampleRate_; +}; + +// Factory: id ∈ eq|eqpro|imager|maximizer|compressor|limiter|exciter|rebalance +// |gain|normalize (gain/normalize legacy giữ ở RenderFxJob — factory trả +// nullptr cho 2 loại này). params = JSON object (sheredom), có thể null. +std::unique_ptr createBuiltinFx(const std::string& id, + const json_object_s* params, + double sampleRate); diff --git a/native_bridge/include/FxRealtimeIPC.h b/native_bridge/include/FxRealtimeIPC.h index 3e6b17d..97f9919 100644 --- a/native_bridge/include/FxRealtimeIPC.h +++ b/native_bridge/include/FxRealtimeIPC.h @@ -10,11 +10,13 @@ #define FXRT_BLOCK 256 #define FXRT_IN_SLOTS 4 // engine -> bridge #define FXRT_OUT_SLOTS 8 // bridge -> engine +#define FXRT_CTRL_SLOTS 8 // SET_PARAM commands (engine -> bridge) +#define FXRT_LAT_SLOTS 8 // REPORT_LATENCY reports (bridge -> engine) #define FXRT_STATE_STARTING 0 #define FXRT_STATE_READY 1 #define FXRT_STATE_ERROR 2 -// Header 48 bytes (12 u32) — giữ thứ tự khớp fx_realtime.py. +// Header 72 bytes (18 u32) — giữ thứ tự khớp fx_realtime.py. struct FxRealtimeHeader { volatile uint32_t magic; volatile uint32_t state; @@ -28,12 +30,35 @@ struct FxRealtimeHeader { volatile uint32_t outWrite; // producer: bridge volatile uint32_t outRead; // consumer: engine uint32_t outSlots; + volatile uint32_t ctrlWrite; // producer: engine (SET_PARAM ring) + volatile uint32_t ctrlRead; // consumer: bridge + uint32_t ctrlSlots; + volatile uint32_t latWrite; // producer: bridge (REPORT_LATENCY ring) + volatile uint32_t latRead; // consumer: engine + uint32_t latSlots; }; +// SET_PARAM: {slot_idx, key[16], value} — 24 bytes. +struct FxCtrlCmd { + volatile uint32_t slot; + char key[16]; + volatile float value; +}; +static_assert(sizeof(FxCtrlCmd) == 24, "FxCtrlCmd must be 24 bytes"); + +// REPORT_LATENCY: {slot_idx, samples} — 8 bytes. +struct FxLatReport { + volatile uint32_t slot; + volatile uint32_t samples; +}; +static_assert(sizeof(FxLatReport) == 8, "FxLatReport must be 8 bytes"); + struct FxRealtimeIPC { FxRealtimeHeader h; float inL[FXRT_IN_SLOTS][FXRT_BLOCK]; float inR[FXRT_IN_SLOTS][FXRT_BLOCK]; float outL[FXRT_OUT_SLOTS][FXRT_BLOCK]; float outR[FXRT_OUT_SLOTS][FXRT_BLOCK]; + FxCtrlCmd ctrl[FXRT_CTRL_SLOTS]; + FxLatReport lat[FXRT_LAT_SLOTS]; }; diff --git a/native_bridge/include/RenderFxJob.h b/native_bridge/include/RenderFxJob.h index e06fff2..db3da4d 100644 --- a/native_bridge/include/RenderFxJob.h +++ b/native_bridge/include/RenderFxJob.h @@ -5,6 +5,7 @@ #include #include #include +#include // Offline FX render mode (Phase 0 of PLAN_MASTERBUS_FX_RACK_VST.md): // `daw_vst_bridge --render-fx --in --out ` @@ -72,6 +73,15 @@ public: // JSON: [{"path":"...","bypass":true|false},...] — chain entries replace // the previous chain atomically. May be called from any thread. void setChain(const std::string& json, double sampleRate, int32_t blockSize); + // SET_PARAM (Phase 2.8): áp dụng live vào builtin slot (slot index theo + // chain JSON). Gọi từ loop thread. + void setParam(int slot, const std::string& key, double value); + // Phase 2.8 (SHM control ring): chain generation — tăng mỗi lần worker + // swap chain; RealtimeFxLoop so gen → báo lại latency qua lat ring. + uint64_t chainGen(); + // Per-slot latency samples (thứ tự khớp chain JSON): builtin = 0 (IIR + // zero-latency), VST3 = getLatencySamples (đọc lúc load) — PDC Phase 3. + std::vector entryLatencies(); // Audio thread only: run the chain in-place over one stereo block. void process(float* inL, float* inR, uint32_t n); void shutdown(); diff --git a/native_bridge/src/BuiltinFxChain.cpp b/native_bridge/src/BuiltinFxChain.cpp new file mode 100644 index 0000000..151993c --- /dev/null +++ b/native_bridge/src/BuiltinFxChain.cpp @@ -0,0 +1,530 @@ +// native_bridge/src/BuiltinFxChain.cpp +// 8 builtin DSP (PLAN_DAW_A.md Phase 2) — port 1:1 từ: +// - Python app/core/render_engine.py `_apply_builtin_fx_chain` (6 loại: +// eq, eqpro, compressor, limiter, exciter, rebalance) +// - JS WebAudio MASTER_MODULE_IO (2 loại: imager, maximizer) +// Biquad: RBJ cookbook, Direct Form II transposed (cùng công thức Python +// `_rbj_*`). Block-wise, stateful giữa block — khớp lfilter full-file. +#define _USE_MATH_DEFINES +#include "BuiltinFxChain.h" + +#include +#include +#include +#include +#include +#include + +#include "sheredom_json.h" + +namespace { + +// ── JSON helpers (mirror RenderFxJob.cpp anonymous-namespace versions) ────── +const json_value_s* memberValue(const json_object_s* o, const char* name) { + for (const json_object_element_s* e = o->start; e; e = e->next) { + if (e->name && e->name->string && std::strcmp(e->name->string, name) == 0) + return e->value; + } + return nullptr; +} +double memberNumber(const json_object_s* o, const char* name, double def) { + const json_value_s* v = memberValue(o, name); + if (v && v->type == json_type_number) return std::atof(static_cast(v->payload)->number); + return def; +} +bool memberBool(const json_object_s* o, const char* name, bool def) { + const json_value_s* v = memberValue(o, name); + if (v && v->type == json_type_true) return true; + if (v && v->type == json_type_false) return false; + return def; +} +std::string memberString(const json_object_s* o, const char* name, const std::string& def) { + const json_value_s* v = memberValue(o, name); + if (v && v->type == json_type_string && static_cast(v->payload)->string) + return std::string(static_cast(v->payload)->string, static_cast(v->payload)->string_size); + return def; +} + +// ── Biquad (RBJ, Direct Form II transposed) ───────────────────────────────── +class Biquad { +public: + void setCoeffs(double B0, double B1, double B2, double A1, double A2) { + b0_ = B0; b1_ = B1; b2_ = B2; a1_ = A1; a2_ = A2; + } + // Reset state (đổi params → filter mới: state cũ vô nghĩa, tránh pop). + void reset() { z1_[0] = z1_[1] = 0; z2_[0] = z2_[1] = 0; } + float step(int ch, float x) { + const double y = b0_ * x + z1_[ch]; + z1_[ch] = b1_ * x - a1_ * y + z2_[ch]; + z2_[ch] = b2_ * x - a2_ * y; + return (float)y; + } + void process(float* L, float* R, uint32_t n) { + for (uint32_t i = 0; i < n; ++i) { L[i] = step(0, L[i]); R[i] = step(1, R[i]); } + } +private: + double b0_ = 1, b1_ = 0, b2_ = 0, a1_ = 0, a2_ = 0; + double z1_[2] = {0, 0}, z2_[2] = {0, 0}; +}; + +// RBJ coefficient generators — công thức y hệt Python `_rbj_*`. +struct RBJ { double b0, b1, b2, a1, a2; }; +RBJ rbjPeaking(double f0, double gdb, double q, double sr) { + const double A = std::pow(10.0, gdb / 40.0); + const double w0 = 2.0 * M_PI * f0 / sr; + const double alpha = std::sin(w0) / (2.0 * q); + const double cw = std::cos(w0); + const double a0 = 1.0 + alpha / A; + RBJ r; + r.b0 = (1.0 + alpha * A) / a0; + r.b1 = (-2.0 * cw) / a0; + r.b2 = (1.0 - alpha * A) / a0; + r.a1 = (-2.0 * cw) / a0; + r.a2 = (1.0 - alpha / A) / a0; + return r; +} +RBJ rbjShelf(double f0, double gdb, double q, double sr, bool low) { + const double A = std::pow(10.0, gdb / 40.0); + const double w0 = 2.0 * M_PI * f0 / sr; + const double alpha = std::sin(w0) / (2.0 * q); + const double cw = std::cos(w0); + const double sA = 2.0 * std::sqrt(A) * alpha; + RBJ r; + if (low) { + const double a0 = (A + 1) + (A - 1) * cw + sA; + r.b0 = A * ((A + 1) - (A - 1) * cw + sA) / a0; + r.b1 = 2.0 * A * ((A - 1) - (A + 1) * cw) / a0; + r.b2 = A * ((A + 1) - (A - 1) * cw - sA) / a0; + r.a1 = -2.0 * ((A - 1) + (A + 1) * cw) / a0; + r.a2 = ((A + 1) + (A - 1) * cw - sA) / a0; + } else { + const double a0 = (A + 1) - (A - 1) * cw + sA; + r.b0 = A * ((A + 1) + (A - 1) * cw + sA) / a0; + r.b1 = -2.0 * A * ((A - 1) + (A + 1) * cw) / a0; + r.b2 = A * ((A + 1) + (A - 1) * cw - sA) / a0; + r.a1 = 2.0 * ((A - 1) - (A + 1) * cw) / a0; + r.a2 = ((A + 1) - (A - 1) * cw - sA) / a0; + } + return r; +} +RBJ rbjHighpass(double f0, double q, double sr) { + const double w0 = 2.0 * M_PI * f0 / sr; + const double alpha = std::sin(w0) / (2.0 * q); + const double cw = std::cos(w0); + const double a0 = 1.0 + alpha; + RBJ r; + r.b0 = ((1.0 + cw) / 2.0) / a0; + r.b1 = (-(1.0 + cw)) / a0; + r.b2 = ((1.0 + cw) / 2.0) / a0; + r.a1 = (-2.0 * cw) / a0; + r.a2 = (1.0 - alpha) / a0; + return r; +} +RBJ rbjLowpass(double f0, double q, double sr) { + const double w0 = 2.0 * M_PI * f0 / sr; + const double alpha = std::sin(w0) / (2.0 * q); + const double cw = std::cos(w0); + const double a0 = 1.0 + alpha; + RBJ r; + r.b0 = ((1.0 - cw) / 2.0) / a0; + r.b1 = (1.0 - cw) / a0; + r.b2 = ((1.0 - cw) / 2.0) / a0; + r.a1 = (-2.0 * cw) / a0; + r.a2 = (1.0 - alpha) / a0; + return r; +} + +// ── EQ 4-band (track 'eq'): lowshelf 100Hz, peaking 800Hz Q0.7, peaking +// 3200Hz Q1.2, highshelf 10kHz — cùng thứ tự/đáp ứng Python `_apply_eq4`. +class Eq4Fx : public BuiltinFx { +public: + Eq4Fx(const json_object_s* params, double sr) : sr_(sr) { + static const double kF0[4] = {100, 800, 3200, 10000}; + static const double kQ[4] = {0.707, 0.7, 1.2, 0.707}; + static const bool kLow[4] = {true, false, false, false}; + for (int i = 0; i < 4; ++i) { f0_[i] = kF0[i]; q_[i] = kQ[i]; low_[i] = kLow[i]; } + if (params) for (int i = 0; i < 4; ++i) + gains_[i] = memberNumber(params, ("g" + std::to_string(i + 1)).c_str(), 0.0); + rebuild(); + } + void process(float* L, float* R, uint32_t n) override { + for (int i = 0; i < 4; ++i) if (gains_[i] != 0.0) bands_[i].process(L, R, n); + } + bool setParam(const std::string& key, double value) override { + if (key.size() == 2 && key[0] == 'g' && key[1] >= '1' && key[1] <= '4') { + gains_[key[1] - '1'] = value; + rebuild(); + return true; + } + return false; + } +private: + void rebuild() { + for (int i = 0; i < 4; ++i) { + const RBJ r = low_[i] ? rbjShelf(f0_[i], gains_[i], q_[i], sr_, true) + : (i == 3 ? rbjShelf(f0_[i], gains_[i], q_[i], sr_, false) + : rbjPeaking(f0_[i], gains_[i], q_[i], sr_)); + bands_[i].setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2); + bands_[i].reset(); + } + } + double sr_, gains_[4] = {0, 0, 0, 0}, f0_[4], q_[4]; + bool low_[4]; + Biquad bands_[4]; +}; + +// ── EQ Pro: RBJ per-band từ params.bands[] + amount — Python `_apply_eqpro`. +class EqProFx : public BuiltinFx { +public: + EqProFx(const json_object_s* params, double sr) : sr_(sr) { + if (params) { + amount_ = memberNumber(params, "amount", 100.0) / 100.0; + const json_value_s* bv = memberValue(params, "bands"); + if (bv && bv->type == json_type_array) { + const json_array_s* arr = static_cast(bv->payload); + for (const json_array_element_s* el = arr->start; el; el = el->next) { + if (!el->value || el->value->type != json_type_object) continue; + const json_object_s* bo = static_cast(el->value->payload); + Band b; + b.active = memberBool(bo, "active", true); + b.gain = memberNumber(bo, "gain", 0.0); + b.freq = memberNumber(bo, "freq", 1000.0); + b.q = memberNumber(bo, "q", 1.0); + b.type = memberString(bo, "type", "peaking"); + bands_.push_back(b); + } + } + } + rebuild(); + } + void process(float* L, float* R, uint32_t n) override { + for (size_t i = 0; i < bqs_.size(); ++i) { + if (!bands_[i].active || bands_[i].gain == 0.0) continue; + bqs_[i].process(L, R, n); + } + } + bool setParam(const std::string& key, double value) override { + if (key == "amount") { amount_ = value / 100.0; rebuild(); return true; } + return false; + } +private: + struct Band { bool active = true; double gain = 0, freq = 1000, q = 1; std::string type; }; + void rebuild() { + bqs_.clear(); + for (const auto& b : bands_) { + const double g = b.gain * amount_; + RBJ r; + if (b.type == "lowshelf") r = rbjShelf(b.freq, g, b.q, sr_, true); + else if (b.type == "highshelf") r = rbjShelf(b.freq, g, b.q, sr_, false); + else if (b.type == "highpass") r = rbjHighpass(b.freq, b.q, sr_); + else r = rbjPeaking(b.freq, g, b.q, sr_); // peaking/lowpass/notch/bandpass + Biquad bq; + bq.setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2); + bqs_.push_back(bq); + } + } + double sr_, amount_ = 1.0; + std::vector bands_; + std::vector bqs_; +}; + +// ── Compressor: block-256 peak env, release 250ms — mirror Python 1:1. +class CompressorFx : public BuiltinFx { +public: + explicit CompressorFx(const json_object_s* params, double sr) : sr_(sr) { + if (params) { + threshold_ = memberNumber(params, "threshold", -16.0); + ratio_ = std::max(1.0, memberNumber(params, "ratio", 3.0)); + makeupDb_ = memberNumber(params, "makeup", 0.0); + } + rel_ = std::exp(-1.0 / (sr_ * 0.25)); + makeup_ = std::pow(10.0, makeupDb_ / 20.0); + } + void process(float* L, float* R, uint32_t n) override { + const uint32_t block = 256; + float* chans[2] = {L, R}; + for (int c = 0; c < 2; ++c) { + float* x = chans[c]; + double env = env_[c]; + for (uint32_t pos = 0; pos < n; pos += block) { + const uint32_t nb = std::min(block, n - pos); + float peak = 0.f; + for (uint32_t i = 0; i < nb; ++i) peak = std::max(peak, std::fabs(x[pos + i])); + env = std::max((double)peak, env * rel_); + float g = (float)makeup_; + if (env > 1e-9) { + const double db = 20.0 * std::log10(env); + const double over = db - threshold_; + if (over > 0.0) { + const double gdb = -over * (1.0 - 1.0 / ratio_); + g = (float)(std::pow(10.0, gdb / 20.0) * makeup_); + } + } + for (uint32_t i = 0; i < nb; ++i) x[pos + i] *= g; + } + env_[c] = env; + } + } + bool setParam(const std::string& key, double value) override { + if (key == "threshold") { threshold_ = value; return true; } + if (key == "ratio") { ratio_ = std::max(1.0, value); return true; } + if (key == "makeup") { makeupDb_ = value; makeup_ = std::pow(10.0, makeupDb_ / 20.0); return true; } + return false; + } +private: + double sr_, threshold_ = -16.0, ratio_ = 3.0, makeupDb_ = 0.0, rel_, makeup_ = 1.0; + double env_[2] = {0, 0}; +}; + +// ── Limiter: tanh brickwall — mirror Python `_apply_limiter` / JS limNode. +class LimiterFx : public BuiltinFx { +public: + explicit LimiterFx(const json_object_s* params) { + if (params) ceilingDb_ = std::min(0.0, memberNumber(params, "ceiling", -1.0)); + rebuild(); + } + void process(float* L, float* R, uint32_t n) override { + for (uint32_t i = 0; i < n; ++i) { + const float x = std::max(-1.f, std::min(1.f, L[i])); + L[i] = (float)(std::tanh((double)x * k_) / tanhK_); + const float y = std::max(-1.f, std::min(1.f, R[i])); + R[i] = (float)(std::tanh((double)y * k_) / tanhK_); + } + } + bool setParam(const std::string& key, double value) override { + if (key == "ceiling") { ceilingDb_ = std::min(0.0, value); rebuild(); return true; } + return false; + } +private: + void rebuild() { + const double th = std::pow(10.0, ceilingDb_ / 20.0); + k_ = 1.0 / std::max(0.02, th); + tanhK_ = std::tanh(k_); + } + double ceilingDb_ = -1.0, k_, tanhK_; +}; + +// ── Exciter: highpass 2kHz Q0.7 + tanh — mirror Python `_apply_exciter`. +class ExciterFx : public BuiltinFx { +public: + explicit ExciterFx(const json_object_s* params, double sr) : sr_(sr) { + if (params) drive_ = memberNumber(params, "drive", 40.0); + wet_ = (drive_ / 100.0) * 0.6; + const RBJ r = rbjHighpass(2000.0, 0.7, sr_); + hp_.setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2); + } + void process(float* L, float* R, uint32_t n) override { + for (uint32_t i = 0; i < n; ++i) { + const float yl = hp_.step(0, L[i]); + L[i] = L[i] + (float)(std::tanh(yl * 3.0) * wet_); + const float yr = hp_.step(1, R[i]); + R[i] = R[i] + (float)(std::tanh(yr * 3.0) * wet_); + } + } + bool setParam(const std::string& key, double value) override { + if (key == "drive") { drive_ = value; wet_ = (drive_ / 100.0) * 0.6; return true; } + return false; + } +private: + double sr_, drive_ = 40.0, wet_; + Biquad hp_; +}; + +// ── Rebalance: M/S crossfeed L'=a·L+b·R, R'=b·L+a·R — Python `_apply_rebalance`. +class RebalanceFx : public BuiltinFx { +public: + explicit RebalanceFx(const json_object_s* params) { + if (params) { + mid_ = memberNumber(params, "mid", 0.0); + side_ = memberNumber(params, "side", 0.0); + } + rebuild(); + } + void process(float* L, float* R, uint32_t n) override { + for (uint32_t i = 0; i < n; ++i) { + const float l = L[i], r = R[i]; + L[i] = (float)(a_ * l + b_ * r); + R[i] = (float)(b_ * l + a_ * r); + } + } + bool setParam(const std::string& key, double value) override { + if (key == "mid") { mid_ = value; rebuild(); return true; } + if (key == "side") { side_ = value; rebuild(); return true; } + return false; + } +private: + void rebuild() { + const double mid = std::pow(10.0, mid_ / 20.0); + const double side = std::pow(10.0, side_ / 20.0); + a_ = (mid + side) / 2.0; + b_ = (mid - side) / 2.0; + } + double mid_ = 0, side_ = 0, a_ = 1, b_ = 0; +}; + +// ── Imager: 4-band crossover (100/1000/6000Hz, WebAudio biquad Q=1) + M/S +// width per band — JS MASTER_MODULE_IO imager (updateImagerBand). +// Band1=LP100, Band2=HP100+LP1000, Band3=HP1000+LP6000, Band4=HP6000. +class ImagerFx : public BuiltinFx { +public: + explicit ImagerFx(const json_object_s* params, double sr) : sr_(sr) { + for (int i = 0; i < 4; ++i) { + w_[i] = 100.0; + if (params) w_[i] = memberNumber(params, ("w" + std::to_string(i + 1)).c_str(), 100.0); + } + // Crossover filter graph (mỗi band 1-2 biquad nối tiếp). + // Mặc định: band 0/3 chỉ 1 stage; band 1/2 có 2 stage. Stage thừa bị + // tắt (active=false) — `{}` init sẽ zero mọi field nên phải set lại. + for (int b = 0; b < 4; ++b) for (int f = 0; f < 2; ++f) + band_[b][f].active = true; + const RBJ lp100 = rbjLowpass(100, 1.0, sr_), hp100 = rbjHighpass(100, 1.0, sr_); + const RBJ lp1k = rbjLowpass(1000, 1.0, sr_), hp1k = rbjHighpass(1000, 1.0, sr_); + const RBJ lp6k = rbjLowpass(6000, 1.0, sr_), hp6k = rbjHighpass(6000, 1.0, sr_); + apply(lp100, band_[0][0]); + apply(hp100, band_[1][0]); apply(lp1k, band_[1][1]); + apply(hp1k, band_[2][0]); apply(lp6k, band_[2][1]); + apply(hp6k, band_[3][0]); + band_[0][1].active = false; + band_[3][1].active = false; + } + void process(float* L, float* R, uint32_t n) override { + // Biquad xử lý in-place trên bản sao band (không hủy input). + std::vector bl(n), br(n); + std::vector accL(n), accR(n); + for (int b = 0; b < 4; ++b) { + std::memcpy(bl.data(), L, n * sizeof(float)); + std::memcpy(br.data(), R, n * sizeof(float)); + for (int f = 0; f < 2 && band_[b][f].active; ++f) { + band_[b][f].bq.process(bl.data(), br.data(), n); + } + const double width = std::max(0.0, std::min(200.0, w_[b])); + const double g1 = (width + 100.0) / 200.0; + const double g2 = (100.0 - width) / 200.0; + for (uint32_t i = 0; i < n; ++i) { + accL[i] += (float)(g1 * bl[i] + g2 * br[i]); + accR[i] += (float)(g1 * br[i] + g2 * bl[i]); + } + } + std::memcpy(L, accL.data(), n * sizeof(float)); + std::memcpy(R, accR.data(), n * sizeof(float)); + } + bool setParam(const std::string& key, double value) override { + if (key.size() == 2 && key[0] == 'w' && key[1] >= '1' && key[1] <= '4') { + w_[key[1] - '1'] = value; + return true; + } + return false; + } +private: + struct BandFilter { Biquad bq; bool active = true; }; + static void apply(const RBJ& r, BandFilter& f) { + f.bq.setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2); + } + double sr_, w_[4]; + BandFilter band_[4][2] = {}; // [band][stage]; stage 2 inactive → skip +}; + +// ── Maximizer: boost → soft-clip → (+upward comp) → ceiling clip — JS +// MASTER_MODULE_IO maximizer. Upward compressor approximates WebAudio +// DynamicsCompressor (soft-knee, attack/release) — `ponytail: port Chromium +// algorithm chính xác nếu cần bit-parity; default maxUpward=0 → no-op`. +class MaximizerFx : public BuiltinFx { +public: + explicit MaximizerFx(const json_object_s* params, double sr) : sr_(sr) { + if (params) { + boostDb_ = memberNumber(params, "boost_db", 0.0); + softClip_ = memberNumber(params, "soft_clip", 0.0); + upward_ = memberNumber(params, "upward", 0.0); + ceilingDb_ = memberNumber(params, "ceiling_db", -0.1); + } + rebuild(); + att_ = std::exp(-1.0 / (sr_ * 0.01)); + rel_ = std::exp(-1.0 / (sr_ * 0.1)); + } + void process(float* L, float* R, uint32_t n) override { + for (uint32_t i = 0; i < n; ++i) { + L[i] = processSample(0, L[i]); + R[i] = processSample(1, R[i]); + } + } + bool setParam(const std::string& key, double value) override { + if (key == "boost_db") { boostDb_ = value; rebuild(); return true; } + if (key == "soft_clip") { softClip_ = value; rebuild(); return true; } + if (key == "upward") { upward_ = value; rebuild(); return true; } + if (key == "ceiling_db") { ceilingDb_ = value; rebuild(); return true; } + return false; + } +private: + void rebuild() { + boost_ = std::pow(10.0, std::max(-60.0, std::min(30.0, boostDb_)) / 20.0); + const double p = std::max(0.0, std::min(100.0, softClip_)); + t_ = 1.0 - (p / 100.0) * 0.8; + inv_ = 1.0 - t_; + upwardGain_ = upward_ > 0 ? std::pow(10.0, std::max(0.0, std::min(30.0, upward_)) / 20.0) - 1.0 : 0.0; + ceiling_ = std::pow(10.0, std::max(-60.0, std::min(0.0, ceilingDb_)) / 20.0); + } + float softClip(float x) const { + const float ax = std::fabs(x); + if (ax < (float)t_) return x; + return (float)((x < 0 ? -1.0 : 1.0) * (t_ + inv_ * std::tanh((ax - t_) / inv_))); + } + // Soft-knee feedforward compressor (DynamicsCompressor-ish). + float upwardComp(int ch, float x) { + const float ax = std::fabs(x); + if (ax > env_[ch]) env_[ch] = att_ * env_[ch] + (1.0 - att_) * ax; + else env_[ch] = rel_ * env_[ch] + (1.0 - rel_) * ax; + double db = 20.0 * std::log10(std::max(env_[ch], 1e-12)); + double y = db - (-30.0); // threshold -30dB + double g = 1.0; + if (2.0 * y > 10.0) { // beyond knee → slope 1/ratio + g = std::pow(10.0, -y * (1.0 - 1.0 / 4.0) / 20.0); + } else if (2.0 * y > -10.0) { // soft knee + const double k = 10.0; + g = std::pow(10.0, -(1.0 - 1.0 / 4.0) * (y + k / 2.0) * (y + k / 2.0) / (2.0 * k) / 20.0); + } + return (float)(g * x); + } + float processSample(int ch, float x) { + const float xb = (float)(boost_ * x); + const float dry = softClip(xb); + const float wet = upwardGain_ * upwardComp(ch, xb); + const float sum = dry + wet; + return std::max(-(float)ceiling_, std::min((float)ceiling_, sum)); + } + double sr_, boostDb_ = 0, softClip_ = 0, upward_ = 0, ceilingDb_ = -0.1; + double boost_ = 1.0, t_ = 1.0, inv_ = 0.0, upwardGain_ = 0.0, ceiling_ = 0.9886; + double att_, rel_; + double env_[2] = {0, 0}; +}; + +} // namespace + +// ── BuiltinFxChain ────────────────────────────────────────────────────────── +void BuiltinFxChain::add(std::unique_ptr fx, bool bypass) { + entries_.push_back(Entry{std::move(fx), bypass}); +} +void BuiltinFxChain::process(float* L, float* R, uint32_t n) { + for (auto& e : entries_) { + if (e.bypass || !e.fx) continue; + e.fx->process(L, R, n); + } +} +bool BuiltinFxChain::setParam(int slot, const std::string& key, double value) { + if (slot < 0 || (size_t)slot >= entries_.size()) return false; + auto& e = entries_[(size_t)slot]; + return e.fx && e.fx->setParam(key, value); +} + +std::unique_ptr createBuiltinFx(const std::string& id, + const json_object_s* params, + double sampleRate) { + if (id == "eq") return std::make_unique(params, sampleRate); + if (id == "eqpro") return std::make_unique(params, sampleRate); + if (id == "compressor") return std::make_unique(params, sampleRate); + if (id == "limiter") return std::make_unique(params); + if (id == "exciter") return std::make_unique(params, sampleRate); + if (id == "rebalance") return std::make_unique(params); + if (id == "imager") return std::make_unique(params, sampleRate); + if (id == "maximizer") return std::make_unique(params, sampleRate); + return nullptr; // gain/normalize = legacy, xử lý riêng ở RenderFxJob +} diff --git a/native_bridge/src/RealtimeFxLoop.cpp b/native_bridge/src/RealtimeFxLoop.cpp index bb7cf8a..a3202fd 100644 --- a/native_bridge/src/RealtimeFxLoop.cpp +++ b/native_bridge/src/RealtimeFxLoop.cpp @@ -166,11 +166,41 @@ int run_realtime_fx_loop(const std::string& jobPath, const std::string& shmName, uint32_t inMask = ipc->h.inSlots - 1; uint32_t outMask = ipc->h.outSlots - 1; uint64_t processed = 0; + uint64_t lastGen = 0; // chain generation đã báo latency while (ipc->h.running) { if (parentPid && !parentAlive(parentPid)) { std::cerr << "[RealtimeFxLoop] parent gone — exiting" << std::endl; break; } + // SET_PARAM ring (engine -> bridge, Phase 2.8): drain mỗi iteration — + // áp dụng vào chain TRƯỚC block kế tiếp. Ring đầy → drop lệnh cũ. + while (ipc->h.ctrlRead < ipc->h.ctrlWrite) { + const uint32_t cs = ipc->h.ctrlRead & (FXRT_CTRL_SLOTS - 1); + const FxCtrlCmd& cmd = ipc->ctrl[cs]; + const char* kend = std::find(cmd.key, cmd.key + sizeof(cmd.key), '\0'); + chain.setParam((int)cmd.slot, + std::string(cmd.key, (size_t)(kend - cmd.key)), + (double)cmd.value); + MemoryBarrier(); + ipc->h.ctrlRead++; + } + // REPORT_LATENCY (bridge -> engine): chain swap (gen đổi) → báo lại + // latency từng slot (builtin=0; VST3=getLatencySamples). Ring 8 — + // engine drain định kỳ, đầy thì overwrite (drop cũ). + const uint64_t gen = chain.chainGen(); + if (gen != lastGen) { + const std::vector lats = chain.entryLatencies(); + for (size_t i = 0; i < lats.size(); ++i) { + const uint32_t ls = ipc->h.latWrite & (FXRT_LAT_SLOTS - 1); + ipc->lat[ls].slot = (uint32_t)i; + ipc->lat[ls].samples = lats[i]; + MemoryBarrier(); + ipc->h.latWrite++; + } + lastGen = gen; + std::cerr << "[RealtimeFxLoop] latency reported: " << lats.size() + << " slot(s)" << std::endl; + } const uint32_t avail = ipc->h.inWrite - ipc->h.inRead; if (avail == 0) { sleepMs(1); continue; } const uint32_t slot = ipc->h.inRead & inMask; diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index f8d7781..98ef77e 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.cpp @@ -38,8 +38,10 @@ #include #endif #include "FxRealtimeIPC.h" +#include "BuiltinFxChain.h" #include +#include #include #include #include @@ -382,6 +384,7 @@ struct Vst3FxState { HostComponentHandlerFx componentHandler; int32 inputChannels = 2; int32 outputChannels = 2; + int32 latencySamples = 0; // PDC (REPORT_LATENCY, Phase 2.8) bool controllerIsComponent = false; Steinberg::IPlugFrame* plugFrame = nullptr; // owned; freed in closeEditor IPtr view; // editor view while GUI open @@ -517,6 +520,8 @@ bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate, std::cerr << "[RenderFx] STEP setActive OK" << std::endl; processor->setProcessing(true); std::cerr << "[RenderFx] STEP setProcessing OK" << std::endl; + s->latencySamples = processor->getLatencySamples(); + std::cerr << "[RenderFx] STEP getLatencySamples = " << s->latencySamples << std::endl; if (!s->processData.prepare(*component, maxBlockSize, kSample32)) { err = "processData.prepare failed"; return false; } @@ -850,12 +855,22 @@ public: #endif } bool loaded() const { return state_ != nullptr; } + // Plugin-reported latency samples (PDC) — 0 nếu plugin không báo. + int32 latencySamples() const { +#ifndef HAVE_VST3SDK + return 0; +#else + return state_ ? static_cast(state_)->latencySamples : 0; +#endif + } private: void* state_ = nullptr; }; -// One FX slot from the job. builtin id: "gain" | "normalize". +// One FX slot from the job. builtin id: "gain" | "normalize" (legacy) hoặc 8 +// DSP (PLAN_DAW_A Phase 2) — id ∈ eq|eqpro|imager|maximizer|compressor|limiter +// |exciter|rebalance → bfx (BuiltinFxChain). struct FxSlot { bool vst = false; // true = VST3, false = builtin std::string path; // vst only @@ -865,6 +880,7 @@ struct FxSlot { double db = 0.0; // gain param double peak = 0.95; // normalize param std::unique_ptr fx; + std::unique_ptr bfx; // 8 DSP builtin (Phase 2) }; } // namespace @@ -887,6 +903,22 @@ static bool runFxSlotSafe(const FxSlot& sl, const float* inL, const float* inR, #endif } +// SEH-guarded builtin DSP slot (in-place). Frame chỉ raw pointers. +static bool runBuiltinSlotSafe(BuiltinFx* fx, float* L, float* R, uint32_t n, + uint32_t* crashCode) { +#ifdef _WIN32 + __try { + fx->process(L, R, n); + return true; + } __except (*crashCode = (uint32_t)GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER) { + return false; + } +#else + fx->process(L, R, n); + return true; +#endif +} + int run_render_fx_job(const std::string& jobPath, const std::string& inPath, const std::string& outPath) { #ifdef _WIN32 @@ -951,11 +983,18 @@ int run_render_fx_job(const std::string& jobPath, const std::string& inPath, } else if (type == "builtin") { sl.vst = false; sl.builtinId = memberString(o, "id", ""); + const json_object_s* po = nullptr; const json_value_s* pv = memberValue(o, "params"); - if (pv && pv->type == json_type_object) { - const json_object_s* po = static_cast(pv->payload); - double d = sl.db; memberNumber(po, "db", d); sl.db = d; - double pk = sl.peak; memberNumber(po, "peak", pk); sl.peak = pk; + if (pv && pv->type == json_type_object) + po = static_cast(pv->payload); + double d = sl.db; if (po) memberNumber(po, "db", d); sl.db = d; + double pk = sl.peak; if (po) memberNumber(po, "peak", pk); sl.peak = pk; + if (sl.builtinId != "gain" && sl.builtinId != "normalize") { + // 8 DSP (PLAN_DAW_A Phase 2) — khớp Python _apply_builtin_fx_chain. + sl.bfx = createBuiltinFx(sl.builtinId, po, srD); + if (!sl.bfx) { + fail("unknown builtin id: " + sl.builtinId); return rc; + } } } else { fail("unknown fx_chain slot type: " + type); return rc; @@ -1004,6 +1043,14 @@ int run_render_fx_job(const std::string& jobPath, const std::string& inPath, + std::to_string(crashCode) + ")"); return rc; } std::memcpy(in.L.data() + pos, L0.data(), n * sizeof(float)); std::memcpy(in.R.data() + pos, R0.data(), n * sizeof(float)); + } else if (sl.bfx) { + // Builtin DSP (8 loại) — in-place, xen kẽ VST3 đúng thứ tự UI. + bool ok = runBuiltinSlotSafe(sl.bfx.get(), in.L.data() + pos, + in.R.data() + pos, n, &crashCode); + if (!ok) { + rc = 3; fail("builtin DSP crashed (SEH code=0x" + + std::to_string(crashCode) + ")"); return rc; + } } else { for (uint32_t i = 0; i < n; ++i) { in.L[pos + i] = curL[i] * (float)gainLin; @@ -1058,32 +1105,38 @@ int run_render_fx_job(const std::string& jobPath, const std::string& inPath, namespace { // SEH frame holds only raw pointers / trivials (C2712 — no unwinding locals). -static void realtimeRunChain(Vst3Fx** fxs, const bool* bypass, uint32_t count, - float* inL, float* inR, uint32_t n) { +static void realtimeRunChain(Vst3Fx** vst, BuiltinFx** bfx, const bool* bypass, + uint32_t count, float* inL, float* inR, uint32_t n) { #ifdef _WIN32 __try { for (uint32_t i = 0; i < count; ++i) { - if (!fxs[i]) continue; if (bypass && bypass[i]) continue; - fxs[i]->processAudio(inL, inR, inL, inR, n); // in-place safe + if (vst[i]) vst[i]->processAudio(inL, inR, inL, inR, n); // in-place safe + else if (bfx[i]) bfx[i]->process(inL, inR, n); // builtin in-place } } __except (EXCEPTION_EXECUTE_HANDLER) { - std::cerr << "[RealtimeFx] plugin crashed in process — chain bypassed" << std::endl; + std::cerr << "[RealtimeFx] chain crashed in process — slot bypassed" << std::endl; } #else - (void)fxs; (void)bypass; (void)count; (void)inL; (void)inR; (void)n; + (void)vst; (void)bfx; (void)bypass; (void)count; (void)inL; (void)inR; (void)n; #endif } } // namespace struct RealtimeFxChain::Impl { - struct Entry { std::shared_ptr fx; bool bypass = false; }; + // VST3 (fx) hoặc builtin DSP (bfx) — 1 trong 2; xen kẽ đúng thứ tự UI. + struct Entry { + std::shared_ptr fx; + std::unique_ptr bfx; + bool bypass = false; + }; struct Chain { std::vector entries; }; std::mutex mutex_; // guards chain_ / retired_ std::shared_ptr chain_; std::shared_ptr retired_; // dtor deferred to the worker thread + std::atomic gen_{0}; // chain generation (Phase 2.8) std::mutex qmutex_; // guards q_ / quit_ std::condition_variable qcv_; @@ -1140,16 +1193,45 @@ void RealtimeFxChain::process(float* inL, float* inR, uint32_t n) { } if (!c || c->entries.empty()) return; constexpr uint32_t kMaxSlots = 16; // v1 cap - Vst3Fx* fxs[kMaxSlots]; + Vst3Fx* vst[kMaxSlots]; + BuiltinFx* bfx[kMaxSlots]; bool bypass[kMaxSlots]; uint32_t count = 0; for (const auto& e : c->entries) { if (count >= kMaxSlots) break; - fxs[count] = e.fx.get(); + vst[count] = e.fx.get(); + bfx[count] = e.bfx.get(); bypass[count] = e.bypass; ++count; } - realtimeRunChain(fxs, bypass, count, inL, inR, n); + realtimeRunChain(vst, bfx, bypass, count, inL, inR, n); +} + +void RealtimeFxChain::setParam(int slot, const std::string& key, double value) { + // SET_PARAM (SHM control ring, Phase 2.8): áp dụng live vào builtin slot. + // VST3 param automation qua VST3 API — ponytail: them khi co nhu cau. + if (!impl_) return; + std::shared_ptr c; + { std::lock_guard lk(impl_->mutex_); c = impl_->chain_; } + if (!c || slot < 0 || (size_t)slot >= c->entries.size()) return; + auto& e = c->entries[(size_t)slot]; + if (e.bfx) e.bfx->setParam(key, value); +} + +uint64_t RealtimeFxChain::chainGen() { + return impl_ ? impl_->gen_.load(std::memory_order_acquire) : 0; +} + +std::vector RealtimeFxChain::entryLatencies() { + std::vector out; + if (!impl_) return out; + std::shared_ptr c; + { std::lock_guard lk(impl_->mutex_); c = impl_->chain_; } + if (!c) return out; + out.reserve(c->entries.size()); + for (const auto& e : c->entries) + out.push_back(e.fx ? (uint32_t)e.fx->latencySamples() : 0u); + return out; } std::shared_ptr RealtimeFxChain::Impl::buildChain(const std::string& json) { @@ -1166,6 +1248,25 @@ std::shared_ptr RealtimeFxChain::Impl::buildChain( for (const json_array_element_s* e = arr->start; e; e = e->next) { if (!e->value || e->value->type != json_type_object) continue; const json_object_s* o = static_cast(e->value->payload); + const std::string type = memberString(o, "type", ""); + if (type == "builtin") { + // Builtin DSP (Phase 2): id ∈ 8 loại — xen kẽ VST3 đúng thứ tự. + const std::string id = memberString(o, "id", ""); + if (id.empty()) continue; + const json_object_s* po = nullptr; + const json_value_s* pv = memberValue(o, "params"); + if (pv && pv->type == json_type_object) + po = static_cast(pv->payload); + Entry ent; + ent.bypass = memberBool(o, "bypass", false); + ent.bfx = createBuiltinFx(id, po, sampleRate_); + if (!ent.bfx) { + std::cerr << "[RealtimeFx] unknown builtin id — slot skipped: " << id << std::endl; + continue; + } + chain->entries.push_back(std::move(ent)); + continue; + } const std::string path = memberString(o, "path", ""); if (path.empty()) continue; Entry ent; @@ -1213,6 +1314,7 @@ void RealtimeFxChain::Impl::workerLoop() { retired_ = std::move(chain_); // old chain destroyed on THIS thread chain_ = std::move(next); } + gen_.fetch_add(1, std::memory_order_release); // loop báo lại latency } #ifdef _WIN32 CoUninitialize(); diff --git a/tests/test_builtin_fx_golden.py b/tests/test_builtin_fx_golden.py new file mode 100644 index 0000000..f904fe4 --- /dev/null +++ b/tests/test_builtin_fx_golden.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""Golden test PLAN_DAW_A.md 2.9: C++ BuiltinFxChain vs Python +_apply_builtin_fx_chain (render_engine.py) — SNR per-chain, kể cả order test. + +Skip nếu thiếu fx_vst_bridge.exe (build/Release) hoặc scipy. +""" +import io, json, math, os, subprocess, sys, tempfile, wave + +import numpy as np +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +from app.core.render_engine import _apply_builtin_fx_chain # noqa: E402 + +BRIDGE_CANDIDATES = [ + os.environ.get("SF_FX_BRIDGE_PATH", ""), + os.path.join(REPO, "native_bridge", "build", "Release", "fx_vst_bridge.exe"), + os.path.join(REPO, "install", "fx_vst_bridge.exe"), +] + + +def _find_bridge(): + for c in BRIDGE_CANDIDATES: + if c and os.path.isfile(c): + return c + return None + + +@pytest.fixture(scope="module") +def bridge(): + b = _find_bridge() + if not b: + pytest.skip("fx_vst_bridge.exe not found (native_bridge/build/Release)") + return b + + +def _write_wav(path, data, sr): + data = np.clip(data, -1.0, 1.0) + # data shape (2, n) → interleave (n, 2) trước khi ghi + pcm = (data.T * 32767).astype(np.int16) + with wave.open(path, "wb") as w: + w.setnchannels(2) + w.setsampwidth(2) + w.setframerate(sr) + w.writeframes(pcm.tobytes()) + + +def _read_wav(path): + import soundfile as sf + data, sr = sf.read(path, dtype="float32", always_2d=True) + return data.T, sr + + +def _render_bridge(bridge, wav_in, sr, chain): + job = {"sample_rate": sr, "block_size": 512, "fx_chain": chain} + with tempfile.TemporaryDirectory() as td: + job_path = os.path.join(td, "job.json") + out_path = os.path.join(td, "out.wav") + with io.open(job_path, "w", encoding="utf-8") as f: + json.dump(job, f) + r = subprocess.run([bridge, "--render-fx", job_path, "--in", wav_in, + "--out", out_path], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, f"bridge rc={r.returncode}: {r.stdout} {r.stderr}" + assert os.path.isfile(out_path), f"no out wav: {r.stdout} {r.stderr}" + return _read_wav(out_path)[0] + + +def _snr_db(ref, got): + ref = ref.astype(np.float64) + got = got.astype(np.float64) + if ref.shape != got.shape: + raise AssertionError(f"shape mismatch {ref.shape} vs {got.shape}") + n = min(ref.shape[1], got.shape[1]) + if n == 0: + return math.inf + ref, got = ref[:, :n], got[:, :n] + denom = float(np.sum((ref - got) ** 2)) + if denom < 1e-12: + return math.inf + return 10.0 * math.log10(float(np.sum(ref ** 2)) / denom) + + +def _run_case(bridge, sr, seed, chain): + rng = np.random.default_rng(seed) + n = sr // 4 + t = np.arange(n) / sr + sig = (0.25 * np.sin(2 * np.pi * 220 * t) + + 0.12 * np.sin(2 * np.pi * 1100 * t) + + 0.06 * np.sin(2 * np.pi * 5000 * t)) + noise = 0.01 * rng.standard_normal(n) + audio = np.stack([sig + noise, 0.9 * sig + 0.02 * rng.standard_normal(n)]) + audio = audio.astype(np.float32) + + with tempfile.TemporaryDirectory() as td: + wav_in = os.path.join(td, "in.wav") + _write_wav(wav_in, audio, sr) + got = _render_bridge(bridge, wav_in, sr, chain) + + # Python reference dùng schema cũ {type: eq} — convert từ job schema mới + # {type: builtin, id: eq} (bridge nhận schema mới, spec Phase 2). + py_chain = [{"type": s.get("id", s.get("type")), "params": s.get("params") or {}} + for s in chain] + ref = _apply_builtin_fx_chain(audio.copy(), py_chain, sr) + snr = _snr_db(ref, got) + return snr + + +def _b(id, params): + return {"type": "builtin", "id": id, "params": params or {}} + + +CHAINS = { + "eq": [_b("eq", {"g1": 3.0, "g2": -2.0, "g3": 1.5, "g4": 2.0})], + "eqpro": [_b("eqpro", {"amount": 80, "bands": [ + {"active": True, "type": "lowshelf", "freq": 120, "gain": 2.5, "q": 0.8}, + {"active": True, "type": "peaking", "freq": 900, "gain": -3.0, "q": 1.2}, + {"active": True, "type": "highpass", "freq": 60, "gain": 0.0, "q": 0.7}, + {"active": True, "type": "highshelf", "freq": 8000, "gain": 1.8, "q": 0.7}, + ]})], + "compressor": [_b("compressor", {"threshold": -20.0, "ratio": 4.0, "makeup": 2.0})], + "limiter": [_b("limiter", {"ceiling": -3.0})], + "exciter": [_b("exciter", {"drive": 60.0})], + "rebalance": [_b("rebalance", {"mid": 2.0, "side": -1.5})], + "order_ec": [_b("eq", {"g1": 2.0, "g3": -1.0}), + _b("compressor", {"threshold": -18.0, "ratio": 3.0})], + "order_ce": [_b("compressor", {"threshold": -18.0, "ratio": 3.0}), + _b("eq", {"g1": 2.0, "g3": -1.0})], +} + +MIN_SNR = 30.0 + + +@pytest.mark.parametrize("name", sorted(CHAINS)) +def test_builtin_golden(bridge, name): + snr = _run_case(bridge, 44100, seed=1234, chain=CHAINS[name]) + assert snr >= MIN_SNR, f"{name}: SNR {snr:.1f} dB < {MIN_SNR}"