diff --git a/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md b/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md index 4319e68..ef7a46d 100644 --- a/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md +++ b/PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md @@ -1,7 +1,7 @@ # PLAN: thay pedalboard (GPL-3.0) bằng native_bridge — render VSTi offline Ngày: 2026-08-15 -Trạng thái: Phase 0 xong (bridge --render CLI: SF2/SFZ/VST3 verify, test 9/9). Windows sửa code bridge/desktop; +Trạng thái: Phase 1 xong (import .vstpreset -> loadSerializedState trong --render, golden file Nexus, test 20/20). Windows sửa code bridge/desktop; Linux làm bridge Linux + Docker + test so sánh. ## 1. Mục tiêu diff --git a/install/daw_vst_bridge.exe b/install/daw_vst_bridge.exe index f2795f5..9508de2 100644 Binary files a/install/daw_vst_bridge.exe and b/install/daw_vst_bridge.exe differ diff --git a/native_bridge/include/RenderJob.h b/native_bridge/include/RenderJob.h index 78e1d84..680eea8 100644 --- a/native_bridge/include/RenderJob.h +++ b/native_bridge/include/RenderJob.h @@ -12,6 +12,9 @@ // bpm?, soundfont_bank?, soundfont_program?, // notes: [{pitch?, velocity?, start_beat?, duration_beats?}] } // instrument_type: 0=VST3, 1=VST2, 2=SF2/SF3, 3=SFZ (InstrumentType enum). +// preset: base64 of a .vstpreset file (VST3 only; SF2/SFZ ignore it). The +// bridge parses the SDK chunk format and applies component/controller +// state via loadSerializedState before rendering. // pitch/velocity: velocity 0..1 (float) or 0..127; start/duration in beats. // // Returns 0 on success. On failure prints a clear error to stderr and returns diff --git a/native_bridge/src/RenderJob.cpp b/native_bridge/src/RenderJob.cpp index fd61355..c148a76 100644 --- a/native_bridge/src/RenderJob.cpp +++ b/native_bridge/src/RenderJob.cpp @@ -11,6 +11,10 @@ #ifdef HAVE_VST3SDK #include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h" +#include "public.sdk/source/vst/vstpresetfile.h" +#include "public.sdk/source/common/memorystream.h" +using Steinberg::int32; +using Steinberg::uint32; #endif #ifdef _WIN32 @@ -120,6 +124,111 @@ void writeFrames(std::ofstream& f, const float* L, const float* R, uint32_t n) { // 3=program change (a=program). struct Ev { uint64_t sample; uint8_t kind; uint32_t a; uint32_t b; }; +#ifdef HAVE_VST3SDK +// --- base64 (RFC 4648) - same helpers as Vst3Instrument.cpp ----------------- +static const char* kBase64Tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +static std::string base64Encode(const uint8_t* data, size_t len) { + std::string out; + out.reserve(((len + 2) / 3) * 4); + for (size_t i = 0; i < len; i += 3) { + uint32_t n = (uint32_t)data[i] << 16; + if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8; + if (i + 2 < len) n |= (uint32_t)data[i + 2]; + out += kBase64Tbl[(n >> 18) & 63]; + out += kBase64Tbl[(n >> 12) & 63]; + out += (i + 1 < len) ? kBase64Tbl[(n >> 6) & 63] : '='; + out += (i + 2 < len) ? kBase64Tbl[n & 63] : '='; + } + return out; +} +static int b64val(unsigned char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} +static bool base64Decode(const std::string& in, std::vector& 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; +} + +// --- Phase 1: .vstpreset import (VST3 SDK binary chunk format, NOT ZIP) ---- +// Pedalboard's VST3Plugin.preset_data is exactly the SDK preset file: 'VST3' +// magic + class id + chunk list (Comp/Cont entries). Parse it with +// PresetFile, rewrap component/controller states into the bridge blob +// [4B BE compLen][comp][4B BE ctrlLen][ctrl] (loadSerializedState's format), +// and hand it to the engine so restoreState() applies it to the live instance. +bool applyVst3Preset(INativeInstrument* inst, const std::string& presetB64) { + std::vector file; + if (!base64Decode(presetB64, file) || file.size() < 8) { + std::cerr << "[RenderJob] preset: bad base64 payload" << std::endl; + return false; + } + if (std::memcmp(file.data(), "VST3", 4) != 0) { + std::cerr << "[RenderJob] preset: not a VST3 preset file (bad magic)" << std::endl; + return false; + } + Steinberg::MemoryStream stream; + if (stream.write(file.data(), (int32)file.size(), nullptr) != Steinberg::kResultOk) { + std::cerr << "[RenderJob] preset: cannot write memory stream" << std::endl; + return false; + } + stream.seek(0, Steinberg::IBStream::kIBSeekSet, nullptr); + Steinberg::Vst::PresetFile pf(&stream); + if (!pf.readChunkList()) { + std::cerr << "[RenderJob] preset: cannot read chunk list" << std::endl; + return false; + } + auto readChunk = [&](Steinberg::Vst::ChunkType which, std::vector& out) -> bool { + const Steinberg::Vst::PresetFile::Entry* e = pf.getEntry(which); + if (!e) return true; // chunk absent - leave empty + bool seeked = (which == Steinberg::Vst::kComponentState) ? pf.seekToComponentState() + : pf.seekToControllerState(); + if (!seeked) return false; + out.resize((size_t)e->size); + if (e->size == 0) return true; // empty chunk (e.g. Cont in Nexus) + int32 got = 0; + return stream.read(out.data(), (int32)e->size, &got) == Steinberg::kResultOk && + got == e->size; + }; + std::vector comp, ctrl; + if (!readChunk(Steinberg::Vst::kComponentState, comp) || + !readChunk(Steinberg::Vst::kControllerState, ctrl)) { + std::cerr << "[RenderJob] preset: failed reading component/controller chunks" << std::endl; + return false; + } + std::vector blob; + auto append32 = [&blob](uint32_t v) { + blob.push_back((uint8_t)(v >> 24)); + blob.push_back((uint8_t)(v >> 16)); + blob.push_back((uint8_t)(v >> 8)); + blob.push_back((uint8_t)v); + }; + append32((uint32_t)comp.size()); + blob.insert(blob.end(), comp.begin(), comp.end()); + append32((uint32_t)ctrl.size()); + blob.insert(blob.end(), ctrl.begin(), ctrl.end()); + inst->loadSerializedState(base64Encode(blob.data(), blob.size())); + return true; +} +#endif // HAVE_VST3SDK + } // namespace int run_render_job(const std::string& jobPath, const std::string& outPath) { @@ -171,8 +280,6 @@ int run_render_job(const std::string& jobPath, const std::string& outPath) { const std::string pluginPath = memberString(job, "plugin_path", ""); if (pluginPath.empty()) { fail("job missing plugin_path"); return rc; } const std::string preset = memberString(job, "preset", ""); - if (!preset.empty()) - std::cerr << "[RenderJob] preset import is Phase 1 — rendering with the plugin's default state" << std::endl; // 3. Format. double srD = 44100.0; @@ -236,6 +343,18 @@ int run_render_job(const std::string& jobPath, const std::string& outPath) { << " path=" << pluginPath << " sr=" << sampleRate << " samples=" << totalNeeded << " bpm=" << bpm << std::endl; + if (!preset.empty()) { + if (type != InstrumentType::VST3) { + std::cerr << "[RenderJob] preset ignored for non-VST3 type " << (int)type + << " (SF2/SFZ use soundfont_bank/soundfont_program)" << std::endl; + } else if (!applyVst3Preset(mgr.get(0), preset)) { + fail("preset import FAILED for " + pluginPath); + return rc; // rc stays 1: job/argument error + } else { + std::cout << "[RenderJob] preset imported (VST3 .vstpreset) type=" << (int)type << std::endl; + } + } + // 6. Offline render loop — same dispatch/render split as the realtime // loop: events whose sample time has passed are dispatched first, then // each upcoming event splits the chunk so it lands at sample offset 0 of diff --git a/native_bridge/tests/golden/nexus_default.vstpreset b/native_bridge/tests/golden/nexus_default.vstpreset new file mode 100644 index 0000000..36376ce Binary files /dev/null and b/native_bridge/tests/golden/nexus_default.vstpreset differ diff --git a/native_bridge/tests/test_offline_render.py b/native_bridge/tests/test_offline_render.py index 0fab287..b900907 100644 --- a/native_bridge/tests/test_offline_render.py +++ b/native_bridge/tests/test_offline_render.py @@ -90,6 +90,56 @@ def main(): check("bad plugin_path -> rc 2", rc == 2, f"rc={rc}") check("bad render removes partial out", not os.path.exists(out), "no clip left behind") + # 5. VST3 render (Nexus) - baseline without preset + NEXUS = r"C:\Program Files\Common Files\VST3\Nexus.vst3" + GOLDEN = os.path.abspath(os.path.join(os.path.dirname(__file__), "golden", "nexus_default.vstpreset")) + if os.path.exists(NEXUS) and os.path.exists(GOLDEN): + import base64 + with open(GOLDEN, "rb") as f: + preset_b64 = base64.b64encode(f.read()).decode("ascii") + + job = os.path.join(td, "vst3.json"); out = os.path.join(td, "vst3.wav") + rc, log = run_render(make_job(0, NEXUS, td), out) + if rc == 0 and os.path.exists(out): + frames, rms = read_wav(out) + check("VST3 render exit 0", rc == 0, log.strip().splitlines()[-1:] or "") + check("VST3 exact duration", frames == TOTAL, f"{frames} == {TOTAL}") + check("VST3 non-silent", rms > 0.001, f"rms={rms:.5f}") + else: + check("VST3 render exit 0", False, f"rc={rc} {log[-400:]}") + + # 6. VST3 render WITH .vstpreset - same default state, must still + # render (proves the parse + loadSerializedState path runs). + job = os.path.join(td, "vst3p.json"); out = os.path.join(td, "vst3p.wav") + jobdict = make_job(0, NEXUS, td); jobdict["preset"] = preset_b64 + rc, log = run_render(jobdict, out) + if rc == 0 and os.path.exists(out): + frames, rms = read_wav(out) + check("VST3+preset exit 0", rc == 0, log.strip().splitlines()[-1:] or "") + check("VST3+preset exact duration", frames == TOTAL, f"{frames} == {TOTAL}") + check("VST3+preset non-silent", rms > 0.001, f"rms={rms:.5f}") + check("VST3+preset applied (log)", "preset imported" in log, "log has preset imported") + else: + check("VST3+preset exit 0", False, f"rc={rc} {log[-400:]}") + + # 7. Corrupt preset (not base64) -> rc 1, no partial clip + job = os.path.join(td, "vst3bad.json"); out = os.path.join(td, "vst3bad.wav") + jobdict = make_job(0, NEXUS, td); jobdict["preset"] = "!!!not-base64!!!" + rc, log = run_render(jobdict, out) + check("VST3 bad preset -> rc 1", rc == 1, f"rc={rc}") + check("bad preset removes partial out", not os.path.exists(out), "no clip left behind") + + # 8. Non-VST3 with preset: ignored (warning), still renders fine + job = os.path.join(td, "sf2p.json"); out = os.path.join(td, "sf2p.wav") + jobdict = make_job(2, SF2, td); jobdict["preset"] = preset_b64 + rc, log = run_render(jobdict, out) + if rc == 0 and os.path.exists(out): + frames, rms = read_wav(out) + check("SF2+preset exit 0 (ignored)", rc == 0, log.strip().splitlines()[-1:] or "") + check("SF2+preset non-silent", rms > 0.001, f"rms={rms:.5f}") + else: + check("SF2+preset exit 0 (ignored)", False, f"rc={rc} {log[-400:]}") + failed = [n for n, ok, _ in results if not ok] print() print(f"{len(results) - len(failed)}/{len(results)} passed")