Phase 1: import .vstpreset in offline render (loadSerializedState)

daw_vst_bridge --render now applies a VST3 preset when job has 'preset'
(base64 of a .vstpreset file). Parse the SDK binary chunk format with
PresetFile (vst3sdk), rewrap component/controller state into the bridge
blob [4B BE compLen][comp][4B BE ctrlLen][ctrl] and apply via
loadSerializedState before rendering. SF2/SFZ ignore preset (warning).
Bad base64/magic/chunks fail the job with rc 1 and no partial clip.

Tests: golden Nexus .vstpreset fixture; VST3 with/without preset,
bad preset -> rc 1, non-VST3 preset ignored. 20/20 pass.
This commit is contained in:
2026-08-17 10:44:13 +07:00
parent 4d1a2ed54a
commit bbb11f8066
6 changed files with 175 additions and 3 deletions
+121 -2
View File
@@ -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<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;
}
// --- 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<uint8_t> 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<uint8_t>& 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<uint8_t> 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<uint8_t> 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