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
+1 -1
View File
@@ -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
Binary file not shown.
+3
View File
@@ -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
+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
Binary file not shown.
@@ -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")