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:
@@ -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.
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user