diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index ab3cd1f..71d3c81 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.cpp @@ -21,6 +21,7 @@ #include "public.sdk/source/vst/hosting/module.h" #include "public.sdk/source/vst/hosting/hostclasses.h" #include "public.sdk/source/vst/hosting/processdata.h" +#include "public.sdk/source/vst/hosting/parameterchanges.h" #include "public.sdk/source/vst/vstpresetfile.h" #include "public.sdk/source/common/memorystream.h" #include "pluginterfaces/vst/ivstaudioprocessor.h" @@ -54,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -388,6 +390,9 @@ struct Vst3FxState { bool controllerIsComponent = false; Steinberg::IPlugFrame* plugFrame = nullptr; // owned; freed in closeEditor IPtr view; // editor view while GUI open + Steinberg::Vst::ParameterChanges paramChanges; // live setParam queue -> inputParameterChanges + std::mutex paramMutex; // setParam (loop/worker) vs processAudio + std::map paramByName; // lower(title/shortTitle) -> id }; // Class selection for FX: any kVstAudioEffectClass whose subcategory does NOT @@ -534,6 +539,25 @@ bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate, if (component->getBusInfo(kAudio, kOutput, 0, bi) == kResultOk && bi.channelCount > 0) s->outputChannels = bi.channelCount; + // Live setParam: map lowercased title/shortTitle -> ParamID so the + // realtime chain can address VST3 params by name (no UI-side tags). + // Titles are UTF-16 (String128); keep the ASCII subset lowercased. + for (int32 pi = 0; pi < controller->getParameterCount(); ++pi) { + Steinberg::Vst::ParameterInfo info = {}; + if (controller->getParameterInfo(pi, info) != kResultOk) continue; + auto addName = [&](const Steinberg::Vst::TChar* nm) { + if (!nm) return; + std::string k; + for (const Steinberg::Vst::TChar* p = nm; p && *p && k.size() < 64; ++p) { + if (*p < 0x80) k.push_back((char)std::tolower((unsigned char)*p)); + } + if (k.empty()) return; + s->paramByName[k] = info.id; + }; + addName(info.title); + addName(info.shortTitle); + } + s->module = std::move(module); s->component = std::move(component); s->controller = std::move(controller); @@ -599,7 +623,10 @@ public: s->processData.processMode = kRealtime; s->processData.numSamples = (int32)n; s->processData.inputEvents = nullptr; - s->processData.inputParameterChanges = nullptr; + { + std::lock_guard lk(s->paramMutex); + s->processData.inputParameterChanges = &s->paramChanges; + } s->processData.processContext = &s->processContext; s->processContext.projectTimeSamples += n; s->processContext.projectTimeMusic = @@ -640,6 +667,12 @@ public: std::memset(outL, 0, n * sizeof(float)); std::memset(outR, 0, n * sizeof(float)); } + { + // Each block consumes the queue once; clear so the next setParam + // writes fresh points (adelay reads the LAST point). + std::lock_guard lk(s->paramMutex); + s->paramChanges.clearQueue(); + } #endif } // Apply preset: base64 of a raw .vstpreset FILE (VST3 magic + chunk list) @@ -745,6 +778,52 @@ public: append32((uint32_t)ctrl.size()); blob.insert(blob.end(), ctrl.begin(), ctrl.end()); base64EncodeTo(blob.data(), blob.size(), out); +#endif + } + // Live param change (realtime chain, NO restart): address by lowercase + // title/shortTitle or numeric ParamID; value normalized 0..1. Delivered + // via setParamNormalized (controller/UI state) + inputParameterChanges + // queue (split plugins read the queue in process() — same rule as + // Vst3Instrument). ponytail: beginEdit/endEdit automation gestures when a + // plugin ignores setParamNormalized (fallback: restart with new preset). + bool setParam(const std::string& key, double value) { +#ifndef HAVE_VST3SDK + (void)key; (void)value; + return false; +#else + auto* s = static_cast(state_); + if (!s || !s->controller) return false; + Steinberg::Vst::ParamID tag = 0; + bool found = false; + try { // numeric ParamID passes through + size_t consumed = 0; + unsigned long num = std::stoul(key, &consumed); + if (consumed == key.size()) { tag = (Steinberg::Vst::ParamID)num; found = true; } + } catch (...) {} + if (!found) { + std::string k = key; + std::transform(k.begin(), k.end(), k.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + auto it = s->paramByName.find(k); + if (it == s->paramByName.end()) { + std::cerr << "[RealtimeFx] vst3 setParam unknown key: " << key << std::endl; + return false; + } + tag = it->second; + } + Steinberg::Vst::ParamValue v = + (Steinberg::Vst::ParamValue)std::max(0.0, std::min(1.0, value)); + { + std::lock_guard lk(s->paramMutex); + s->controller->setParamNormalized(tag, v); + int32 idx = 0; + if (Steinberg::Vst::IParamValueQueue* q = + s->paramChanges.addParameterData(tag, idx)) + q->addPoint(0, v, idx); + } + std::cout << "[RealtimeFx] vst3 setParam '" << key << "' -> tag " << tag + << " = " << v << std::endl; + return true; #endif } // Native editor: attach the plugin's IPlugView to a host window (must run @@ -1233,6 +1312,7 @@ void RealtimeFxChain::setParam(int slot, const std::string& key, double value) { if (c && slot >= 0 && (size_t)slot < c->entries.size()) { auto& e = c->entries[(size_t)slot]; if (e.bfx) e.bfx->setParam(key, value); + else if (e.fx) e.fx->setParam(key, value); return; } // Chain chưa swap (SET_PARAM tới trước setChain) — stash, worker áp sau swap. @@ -1343,6 +1423,7 @@ void RealtimeFxChain::Impl::workerLoop() { if (p.slot < 0 || (size_t)p.slot >= chain_->entries.size()) continue; auto& e = chain_->entries[(size_t)p.slot]; if (e.bfx) e.bfx->setParam(p.key, p.value); + else if (e.fx) e.fx->setParam(p.key, p.value); } pending_.clear(); } diff --git a/tests/test_daw_phase5.py b/tests/test_daw_phase5.py index be95a35..dabafe1 100644 --- a/tests/test_daw_phase5.py +++ b/tests/test_daw_phase5.py @@ -528,3 +528,52 @@ def test_59_limiter_brickwall_oversample(fx_bridge): pk_low = float(np.max(np.abs(out_low))) assert 0.05 <= pk_low <= 0.15, f"low-level peak {pk_low:.4f} lech unity" +# ── 5.C Realtime VST3 setParam live (PLAN_MASTER_ENHANCE C) ─────────────── +def test_60_vst3_setparam_live(fx_bridge, adelay, monkeypatch): + """set_param runtime cho VST3: chain [vst3-delay, builtin limiter], đổi + param 'delay' giữa stream 1.0 → 0.25 → echo dịch ~1s → ~0.25s NGAY trên + session hiện tại (restart → plugin default 1s → echo vẫn ở 1s).""" + monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge) + from app.core import fx_realtime + + chain = [ + {"type": "vst3", "path": adelay}, + {"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}}, + ] + sess = fx_realtime.start_session(chain, SR) + try: + out = bytearray() + nblk = 280 # 1.24s — đủ phủ vùng echo cũ (1s) để chứng minh không còn + for i in range(nblk): + blk = np.zeros((2, 256), dtype=np.float32) + if i == 103: # impulse2 tại 0.6s (26368) + blk[0, 0] = 1.0 + blk[1, 0] = 1.0 + if i == 86: # 0.5s — setParam VST3 'delay' 1.0 -> 0.25 (normalized) + sess.set_param(0, "delay", 0.25) + inter = np.empty(512, dtype=np.float32) + inter[0::2] = blk[0] + inter[1::2] = blk[1] + sess.write_input(inter.tobytes()) + time.sleep(0.006) + for b in sess.read_output(): + out += b + time.sleep(0.05) + for b in sess.read_output(): + out += b + finally: + sess.close() + arr = np.frombuffer(bytes(out), dtype=np.float32) + assert arr.size > 0, "realtime loop không ra output" + got = arr.reshape(-1, 2).T + + def _peak(lo, hi): + if lo >= got.shape[1]: + return 0.0 + return float(np.max(np.abs(got[:, lo:hi]))) + + e2 = 103 * 256 + int(0.25 * SR) # echo delay 0.25s ≈ 37393 + assert _peak(e2 - 400, e2 + 400) > 0.5, f"echo delay 0.25s không thấy tại {e2} (peak {_peak(e2-400, e2+400):.3f})" + e_old = 103 * 256 + SR # echo delay cũ 1s ≈ 70468 (restart/no-apply) + assert _peak(e_old - 400, e_old + 400) < 0.3, "param không áp live — echo vẫn ở delay 1s" + assert _peak(0, e2 - 700) < 0.3, "có tín hiệu lạ trước echo 0.25s"