Files

648 lines
26 KiB
C++

// native_bridge/juce_fx/JuceFxEngine.cpp
// Implementation: AudioProcessorGraph với input/output node — chain rỗng G1.
// G2: nạp VST3 theo fx_chain (path, preset_b64, bypass), chèn node giữa
// input/output; latency thật = graph.getLatencySamples() (PDC nội bộ graph).
#include "JuceFxEngine.h"
#include <juce_audio_processors/juce_audio_processors.h>
#include "sheredom_json.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <mutex>
#include <string>
#include <vector>
namespace {
// --- base64 decode (RFC 4648) — như RenderFxJob.cpp -------------------------
int b64val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return (int)(c - 'A');
if (c >= 'a' && c <= 'z') return (int)(c - 'a') + 26;
if (c >= '0' && c <= '9') return (int)(c - '0') + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
bool base64Decode(const std::string& in, std::vector<uint8_t>& out) {
out.clear();
out.reserve((in.size() / 4) * 3);
uint32_t acc = 0;
int bits = 0;
for (unsigned char ch : in) {
if (ch == '=' || ch == '\n' || ch == '\r' || ch == ' ') continue;
int v = b64val(ch);
if (v < 0) return false;
acc = (acc << 6) | (uint32_t)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back((uint8_t)((acc >> bits) & 0xFF));
}
}
return true;
}
// --- sheredom helpers (giống RenderFxJob.cpp) -------------------------------
const json_object_element_s* jMember(const json_object_s* o, const char* key) {
for (const json_object_element_s* e = o ? o->start : nullptr; e; e = e->next)
if (e->name && e->name->string && std::strcmp(e->name->string, key) == 0)
return e;
return nullptr;
}
std::string jString(const json_object_s* o, const char* key, const std::string& def) {
const json_object_element_s* m = jMember(o, key);
if (m && m->value && m->value->type == json_type_string) {
const auto* s = static_cast<const json_string_s*>(m->value->payload);
return std::string(s->string, s->string_size);
}
return def;
}
bool jBool(const json_object_s* o, const char* key, bool def) {
const json_object_element_s* m = jMember(o, key);
if (m && m->value) {
if (m->value->type == json_type_true) return true;
if (m->value->type == json_type_false) return false;
}
return def;
}
int64_t jInt64(const json_object_s* o, const char* key, int64_t def) {
const json_object_element_s* m = jMember(o, key);
if (m && m->value && m->value->type == json_type_number) {
const auto* n = static_cast<const json_number_s*>(m->value->payload);
return (int64_t)std::strtoll(n->number, nullptr, 10);
}
return def;
}
uint32_t jUInt32(const json_object_s* o, const char* key, uint32_t def) {
const json_object_element_s* m = jMember(o, key);
if (m && m->value && m->value->type == json_type_number) {
const auto* n = static_cast<const json_number_s*>(m->value->payload);
return (uint32_t)std::strtoul(n->number, nullptr, 10);
}
return def;
}
std::string jsonEscape(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '\\' || c == '"') { out += '\\'; out += c; }
else out += c;
}
return out;
}
// --- VST3 description cache -------------------------------------------------
// findAllTypesForFile() slow path load DLL + quét factory + instantiate
// component (hàng trăm ms/plugin mỗi session). createInstanceFromDescription
// chỉ cần {fileOrIdentifier, name, uniqueId, deprecatedUid} —
// findClassMatchingDescription so name+uid trên class info, KHÔNG instantiate.
// Cache JSON theo path+mtime: hit → dựng desc trực tiếp, bỏ qua scan.
struct DescCacheEntry {
std::string path;
int64_t mtime = 0;
std::string name;
uint32_t uniqueId = 0;
uint32_t deprecatedUid = 0;
};
std::string descCachePath() {
return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("SonicForgeDAW")
.getChildFile("juce_vst3_desc_cache.json")
.getFullPathName()
.toStdString();
}
std::vector<DescCacheEntry> loadDescCache() {
std::vector<DescCacheEntry> out;
const juce::File f(descCachePath());
if (!f.existsAsFile()) return out;
const juce::String content = f.loadFileAsString();
json_value_s* root = json_parse(content.toRawUTF8(), content.getNumBytesAsUTF8());
if (root && root->type == json_type_array) {
const auto* arr = static_cast<const json_array_s*>(root->payload);
for (const json_array_element_s* el = arr->start; el; el = el->next) {
const json_value_s* v = el->value;
if (!v || v->type != json_type_object) continue;
const auto* o = static_cast<const json_object_s*>(v->payload);
DescCacheEntry e;
e.path = jString(o, "path", "");
e.mtime = jInt64(o, "mtime", 0);
e.name = jString(o, "name", "");
e.uniqueId = jUInt32(o, "unique_id", 0);
e.deprecatedUid = jUInt32(o, "deprecated_uid", 0);
if (!e.path.empty() && !e.name.empty())
out.push_back(std::move(e));
}
}
if (root) free(root);
return out;
}
void saveDescCache(const std::vector<DescCacheEntry>& cache) {
std::string json = "[\n";
for (size_t i = 0; i < cache.size(); ++i) {
const auto& e = cache[i];
if (i) json += ",\n";
json += " {\"path\":\"" + jsonEscape(e.path) +
"\",\"mtime\":" + std::to_string(e.mtime) +
",\"name\":\"" + jsonEscape(e.name) +
"\",\"unique_id\":" + std::to_string(e.uniqueId) +
",\"deprecated_uid\":" + std::to_string(e.deprecatedUid) + "}";
}
json += "\n]\n";
try {
const juce::File f(descCachePath());
f.getParentDirectory().createDirectory();
f.replaceWithText(juce::String(json));
} catch (...) {
// Cache lỗi không được làm hỏng prepare() — lần sau scan lại.
}
}
} // namespace
struct JuceFxEngine::Slot {
std::string type;
std::string path;
std::string presetB64;
bool bypass = false;
};
struct JuceFxEngine::Impl {
juce::AudioProcessorGraph graph;
juce::AudioBuffer<float> buf;
uint32_t sampleRate = 0;
uint32_t blockSize = 0;
bool prepared = false;
std::vector<Slot> slots; // chain đã parse (thứ tự slot)
std::vector<juce::AudioProcessorGraph::Node::Ptr> pluginNodes; // node theo slot (null = bỏ qua)
std::vector<uint32_t> slotLatencies; // latency thật từng slot (0 = skip/bypass/fail)
uint32_t totalLatency = 0;
// ── Editor host (M1): slot -> processor + editor (chỉ GUI thread đụng).
struct SlotEditor {
juce::AudioProcessor* proc = nullptr; // processor slot (không sở hữu)
std::unique_ptr<juce::AudioProcessorEditor> editor; // sở hữu editor (GUI thread)
bool open = false;
bool visible = true; // M2: trạng thái Show/Hide (GuiHost sync window)
std::string captured; // state capture gần nhất (raw)
};
struct EditorCmd {
uint32_t slot = 0;
EditorAction action = EditorAction::Open;
int w = 0, h = 0;
};
std::vector<SlotEditor> editors; // theo slot (dựng lại mỗi prepare)
std::vector<EditorCmd> editorQueue; // lệnh chờ (requestEditor)
mutable std::mutex editorMutex; // editors + captured
std::mutex queueMutex; // editorQueue
// M14: retry Open khi editor chưa mở (lần Open trước fail/trễ).
struct RetryOpen {
uint32_t slot = 0;
int w = 0, h = 0;
uint64_t dueMs = 0;
int tries = 0;
};
std::vector<RetryOpen> retryOpens; // M14 GUI-thread-only
};
JuceFxEngine::JuceFxEngine() : impl_(std::make_unique<Impl>()) {}
JuceFxEngine::~JuceFxEngine() { shutdown(); }
void JuceFxEngine::requestEditor(uint32_t slot, EditorAction action, int w, int h) {
// Chỉ xếp lệnh (thread-safe); validation + thực thi ở pumpEditorQueue()
// trên GUI thread — không log ở đây (có thể gọi từ thread khác).
std::lock_guard<std::mutex> lk(impl_->queueMutex);
impl_->editorQueue.push_back({slot, action, w, h});
}
bool JuceFxEngine::pumpEditorQueue() {
std::vector<Impl::EditorCmd> cmds;
{
// M14: retry drain cần chạy kể cả khi queue rỗng (retry pending)
// — bỏ early-return, chỉ đổi queue nếu có lệnh.
std::lock_guard<std::mutex> lk(impl_->queueMutex);
if (!impl_->editorQueue.empty()) cmds.swap(impl_->editorQueue);
}
bool any = false;
// M14: đồng hồ + retry-open helpers (GUI-thread-only).
const auto nowMs = []() -> uint64_t {
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
};
const auto retryClearSlot = [this](uint32_t slot) {
auto& v = impl_->retryOpens;
for (auto it = v.begin(); it != v.end(); ++it) {
if (it->slot == slot) { v.erase(it); return; }
}
};
const auto retrySchedule = [this, &nowMs](uint32_t slot, int w, int h) {
for (const auto& r : impl_->retryOpens)
if (r.slot == slot) return; // không xếp chồng
impl_->retryOpens.push_back({slot, w, h, nowMs() + 1000, 0});
};
for (const auto& c : cmds) {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
if (c.slot >= impl_->editors.size()) {
std::cerr << "[JuceFxEngine] editor cmd slot " << c.slot
<< " ngoài chain (" << impl_->editors.size()
<< ") — bỏ qua" << std::endl;
continue;
}
auto& se = impl_->editors[c.slot];
if (!se.proc) {
if (c.action == EditorAction::Open)
std::cerr << "[JuceFxEngine] slot " << c.slot
<< " không có processor (bypass/fail) — không mở editor"
<< std::endl;
continue;
}
switch (c.action) {
case EditorAction::Open: {
if (!se.editor) {
juce::AudioProcessorEditor* ed = se.proc->createEditorIfNeeded();
if (!ed) {
std::cerr << "[JuceFxEngine] slot " << c.slot
<< " createEditorIfNeeded null (plugin không có UI)"
<< std::endl;
retrySchedule(c.slot, c.w, c.h); // M14: thử lại sau
break;
}
se.editor.reset(ed); // engine sở hữu editor (M1)
se.open = true;
se.visible = true; // M2: editor mở = hiển thị (GuiHost mở window)
if (c.w > 0 && c.h > 0) se.editor->setSize(c.w, c.h);
std::cerr << "[JuceFxEngine] slot " << c.slot << " editor open "
<< se.editor->getWidth() << "x"
<< se.editor->getHeight() << std::endl;
retryClearSlot(c.slot); // M14: mở thành công = hết retry
} else {
// Đã mở — Show lại (Reaper: editor còn sống, mở window lại).
se.visible = true;
se.editor->setVisible(true);
if (c.w > 0 && c.h > 0) se.editor->setSize(c.w, c.h);
retryClearSlot(c.slot); // M14
std::cerr << "[JuceFxEngine] slot " << c.slot
<< " editor ALREADY open — re-show" << std::endl;
}
any = true;
break;
}
case EditorAction::Close: {
if (se.editor) {
se.editor.reset(); // ~editor -> processor->editorBeingDeleted
se.open = false;
std::cerr << "[JuceFxEngine] slot " << c.slot
<< " editor closed" << std::endl;
any = true;
}
retryClearSlot(c.slot); // M14: đóng = huỷ retry mở dang dở
break;
}
case EditorAction::Show:
case EditorAction::Hide: {
// M2: GuiHost đồng bộ window theo flag visible (editor setVisible
// cho nội dung). Show = hiện window, Hide = ẩn (giữ editor+instance).
const bool vis = (c.action == EditorAction::Show);
se.visible = vis;
if (se.editor) se.editor->setVisible(vis);
any = true;
break;
}
case EditorAction::Resize: {
if (se.editor && c.w > 0 && c.h > 0) {
se.editor->setSize(c.w, c.h);
any = true;
}
break;
}
case EditorAction::Capture: {
juce::MemoryBlock mb;
se.proc->getStateInformation(mb);
se.captured.assign(static_cast<const char*>(mb.getData()), mb.getSize());
std::cerr << "[JuceFxEngine] slot " << c.slot << " captured "
<< se.captured.size() << " bytes" << std::endl;
any = true;
break;
}
}
}
// M14: Drain retry-open — editor chưa mở do lần Open trước fail/trễ.
{
auto& rv = impl_->retryOpens;
for (auto it = rv.begin(); it != rv.end();) {
bool gone;
{
std::lock_guard<std::mutex> lk(impl_->editorMutex);
gone = (it->slot >= impl_->editors.size()) ||
!impl_->editors[it->slot].proc ||
impl_->editors[it->slot].open;
}
if (gone || it->tries >= 2) {
it = rv.erase(it);
continue;
}
if (nowMs() >= it->dueMs) {
std::cerr << "[JuceFxEngine] M14 retry open slot " << it->slot
<< " lần " << (it->tries + 1) << std::endl;
{
std::lock_guard<std::mutex> lk(impl_->queueMutex);
impl_->editorQueue.push_back({it->slot, EditorAction::Open, it->w, it->h});
}
it->tries++;
it->dueMs = nowMs() + 1000;
}
++it;
}
}
return any;
}
bool JuceFxEngine::isEditorOpen(uint32_t slot) const {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
if (slot >= impl_->editors.size()) return false;
return impl_->editors[slot].open;
}
bool JuceFxEngine::guiBusy() const {
{
std::lock_guard<std::mutex> lk(impl_->queueMutex);
if (!impl_->editorQueue.empty()) return true;
}
{
std::lock_guard<std::mutex> lk(impl_->editorMutex);
for (const auto& se : impl_->editors)
if (se.open) return true;
}
return !impl_->retryOpens.empty(); // GUI-thread-only — chỉ GUI thread gọi
}
std::string JuceFxEngine::takeCapturedState(uint32_t slot) {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
if (slot >= impl_->editors.size()) return {};
std::string s = std::move(impl_->editors[slot].captured);
impl_->editors[slot].captured.clear();
return s;
}
void* JuceFxEngine::editorHandle(uint32_t slot) {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
if (slot >= impl_->editors.size()) return nullptr;
return impl_->editors[slot].editor.get();
}
uint32_t JuceFxEngine::slotCount() const {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
return (uint32_t)impl_->editors.size();
}
bool JuceFxEngine::hasOpenEditor() const {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
for (const auto& se : impl_->editors)
if (se.open) return true;
return false;
}
void JuceFxEngine::closeAllEditors() {
// Gom slot đang mở (editorMutex) rồi enqueue Close (queueMutex) — gọi từ
// main thread trước khi prepare()/shutdown() khi GUI thread đang chạy.
std::vector<uint32_t> open;
{
std::lock_guard<std::mutex> lk(impl_->editorMutex);
for (size_t i = 0; i < impl_->editors.size(); ++i)
if (impl_->editors[i].open) open.push_back((uint32_t)i);
}
{
std::lock_guard<std::mutex> lk(impl_->queueMutex);
for (uint32_t s : open)
impl_->editorQueue.push_back({s, EditorAction::Close, 0, 0});
}
}
bool JuceFxEngine::isEditorVisible(uint32_t slot) const {
std::lock_guard<std::mutex> lk(impl_->editorMutex);
if (slot >= impl_->editors.size()) return false;
return impl_->editors[slot].open && impl_->editors[slot].visible;
}
void JuceFxEngine::shutdown() {
// Đóng mọi editor TRƯỚC khi clear graph — JUCE assert editor phải xoá
// trước processor. Editor ops ở đây chạy trên thread gọi shutdown; M1
// chưa có GUI thread đồng thời nên an toàn. Khi M2 có GUI thread: chain
// đổi phải gửi ed.close trước khi re-prepare (M4/M5).
{
std::lock_guard<std::mutex> lk(impl_->editorMutex);
for (auto& se : impl_->editors) {
if (se.editor) se.editor.reset(); // ~editor -> processor->editorBeingDeleted
se.proc = nullptr;
se.open = false;
se.visible = false;
se.captured.clear();
}
impl_->editors.clear();
}
impl_->retryOpens.clear(); // M14
if (impl_->prepared) {
impl_->graph.releaseResources();
impl_->graph.clear();
impl_->prepared = false;
impl_->sampleRate = 0;
impl_->blockSize = 0;
}
impl_->pluginNodes.clear();
impl_->slotLatencies.assign(impl_->slots.size(), 0);
impl_->totalLatency = 0;
}
void JuceFxEngine::setChain(const std::string& chainJson) {
// Parse mảng JSON fx_chain: [ {type,path,preset_b64,bypass}, ... ].
impl_->slots.clear();
json_value_s* root = json_parse(chainJson.data(), chainJson.size());
if (root && root->type == json_type_array) {
const auto* arr = static_cast<const json_array_s*>(root->payload);
for (const json_array_element_s* el = arr->start; el; el = el->next) {
const json_value_s* v = el->value;
if (!v || v->type != json_type_object) continue;
const auto* o = static_cast<const json_object_s*>(v->payload);
Slot s;
s.type = jString(o, "type", "");
s.path = jString(o, "path", "");
s.presetB64 = jString(o, "preset_b64", "");
s.bypass = jBool(o, "bypass", false);
impl_->slots.push_back(std::move(s));
}
}
if (root) free(root);
}
void JuceFxEngine::prepare(uint32_t sampleRate, uint32_t blockSize) {
if (impl_->prepared && impl_->sampleRate == sampleRate &&
impl_->blockSize == blockSize)
return; // không đổi — giữ graph
shutdown();
impl_->sampleRate = sampleRate;
impl_->blockSize = blockSize;
impl_->graph.setPlayConfigDetails(2, 2, (double)sampleRate, (int)blockSize);
auto& g = impl_->graph;
const auto inNode = g.addNode(std::make_unique<
juce::AudioProcessorGraph::AudioGraphIOProcessor>(
juce::AudioProcessorGraph::AudioGraphIOProcessor::audioInputNode));
const auto outNode = g.addNode(std::make_unique<
juce::AudioProcessorGraph::AudioGraphIOProcessor>(
juce::AudioProcessorGraph::AudioGraphIOProcessor::audioOutputNode));
using NCh = juce::AudioProcessorGraph::NodeAndChannel;
using Conn = juce::AudioProcessorGraph::Connection;
// G2: nạp VST3 theo chain — nối nối tiếp in -> p0 -> p1 -> ... -> out.
// Slot bypass / type khác vst3 / load fail → passthrough (latency 0).
impl_->pluginNodes.assign(impl_->slots.size(), nullptr);
impl_->slotLatencies.assign(impl_->slots.size(), 0);
static std::vector<DescCacheEntry> descCache = loadDescCache();
bool cacheDirty = false;
juce::AudioProcessorGraph::Node::Ptr prev = inNode;
for (size_t i = 0; i < impl_->slots.size(); ++i) {
const Slot& s = impl_->slots[i];
if (s.bypass || (s.type != "vst3" && s.type != "vst") || s.path.empty())
continue;
juce::VST3PluginFormat fmt;
juce::OwnedArray<juce::PluginDescription> descs;
const juce::File pluginFile(s.path);
const int64_t mtime = pluginFile.getLastModificationTime().toMilliseconds();
const DescCacheEntry* hit = nullptr;
for (const auto& e : descCache)
if (e.path == s.path && e.mtime == mtime) { hit = &e; break; }
if (hit) {
// Cache hit: dựng desc (name+uid) — không scan factory, không
// instantiate 2 lần như findAllTypesForFile slow path.
auto* d = new juce::PluginDescription();
d->fileOrIdentifier = juce::String(hit->path);
d->name = juce::String(hit->name);
d->uniqueId = hit->uniqueId;
d->deprecatedUid = hit->deprecatedUid;
d->pluginFormatName = "VST3";
descs.add(d);
} else {
fmt.findAllTypesForFile(descs, pluginFile.getFullPathName());
if (!descs.isEmpty()) {
const auto* d = descs.getFirst();
DescCacheEntry e;
e.path = s.path;
e.mtime = mtime;
e.name = d->name.toStdString();
e.uniqueId = d->uniqueId;
e.deprecatedUid = d->deprecatedUid;
descCache.push_back(std::move(e));
cacheDirty = true;
}
}
if (descs.isEmpty()) {
std::cerr << "[JuceFxEngine] slot " << i << " no VST3 desc: "
<< s.path << std::endl;
continue;
}
auto inst = fmt.createInstanceFromDescription(*descs.getFirst(),
(double)sampleRate,
(int)blockSize);
if (!inst) {
std::cerr << "[JuceFxEngine] slot " << i << " createInstance fail: "
<< s.path << std::endl;
continue;
}
if (!s.presetB64.empty()) {
std::vector<uint8_t> bytes;
if (base64Decode(s.presetB64, bytes) && !bytes.empty()) {
juce::MemoryBlock mb(bytes.data(), bytes.size());
if (juce::VST3PluginFormat::setStateFromVSTPresetFile(inst.get(), mb))
std::cerr << "[JuceFxEngine] slot " << i << " preset applied (vstpreset)" << std::endl;
else {
// M13: preset_b64 tu editor capture (getStateInformation) la
// JUCE VST3 state raw (4B len + "VstW" + payload), KHONG
// phai file .vstpreset -> setStateFromVSTPresetFile luon FAIL
// -> plugin mo default moi lan spawn (preset da luu mat).
// Ap thang setStateInformation (doi xung capture - cung wrapper).
inst->setStateInformation(mb.getData(), (int)mb.getSize());
std::cerr << "[JuceFxEngine] slot " << i << " preset applied (state)" << std::endl;
}
}
}
auto node = g.addNode(std::unique_ptr<juce::AudioProcessor>(std::move(inst)));
impl_->pluginNodes[i] = node;
for (int ch = 0; ch < 2; ++ch) {
NCh src; src.nodeID = prev->nodeID; src.channelIndex = ch;
NCh dst; dst.nodeID = node->nodeID; dst.channelIndex = ch;
g.addConnection(Conn(src, dst));
}
prev = node;
}
if (cacheDirty) saveDescCache(descCache);
// Editor mapping: slot -> processor (Reaper-style — editor dùng ĐÚNG
// instance trong graph). Editors cũ đã đóng ở shutdown() khi graph đổi;
// chain không đổi (early-return trên) thì editors giữ nguyên + proc sống.
// Lock editorMutex: GUI thread (GuiHost tick reconcile) có thể đang đọc.
{
std::lock_guard<std::mutex> lk(impl_->editorMutex);
impl_->editors.clear();
impl_->editors.resize(impl_->slots.size());
for (size_t i = 0; i < impl_->pluginNodes.size(); ++i)
if (impl_->pluginNodes[i])
impl_->editors[i].proc = impl_->pluginNodes[i]->getProcessor();
}
for (int ch = 0; ch < 2; ++ch) {
NCh src; src.nodeID = prev->nodeID; src.channelIndex = ch;
NCh dst; dst.nodeID = outNode->nodeID; dst.channelIndex = ch;
g.addConnection(Conn(src, dst));
}
g.prepareToPlay((double)sampleRate, (int)(blockSize * 4)); // batch tối đa 4 block
impl_->buf.setSize(2, (int)(blockSize * 4), false, false, true);
// Latency thật từng slot sau prepareToPlay (plugin set trong prepare).
for (size_t i = 0; i < impl_->pluginNodes.size(); ++i)
if (impl_->pluginNodes[i])
impl_->slotLatencies[i] =
(uint32_t)impl_->pluginNodes[i]->getProcessor()->getLatencySamples();
impl_->totalLatency = (uint32_t)g.getLatencySamples();
impl_->prepared = true;
}
void JuceFxEngine::process(float* inL, float* inR, uint32_t n) {
if (!impl_->prepared || n == 0) return;
auto& b = impl_->buf;
b.setSize(2, (int)n, false, false, true);
b.copyFrom(0, 0, inL, (int)n);
b.copyFrom(1, 0, inR, (int)n);
juce::MidiBuffer midi;
impl_->graph.processBlock(b, midi);
// Graph PDC nội bộ: output đã align dry/wet — copy về in-place như contract.
const auto* outL = b.getReadPointer(0);
const auto* outR = b.getReadPointer(1);
std::copy(outL, outL + n, inL);
std::copy(outR, outR + n, inR);
}
uint32_t JuceFxEngine::latencySamples() const {
return impl_->prepared ? impl_->totalLatency : 0u;
}
std::vector<uint32_t> JuceFxEngine::entryLatencies() const {
return impl_->slotLatencies;
}