// native_bridge/src/RenderJob.cpp // Offline render mode (Phase 0 of PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md): // `daw_vst_bridge --render --out ` renders MIDI notes // through the same instrument engines as realtime (SF2/SFZ/VST3/VST2) and // writes a 16-bit stereo WAV. No GUI, no SHM, no Tauri parent — pure CLI. // // JSON parsing uses the public-domain sheredom/json.h vendored with the VST3 // SDK (vst3sdk/public.sdk/source/vst/moduleinfo/json.h) — no new dependency. #include "RenderJob.h" #include "NativeInstrumentEngine.h" // sheredom/json.h (public domain, vendored with the VST3 SDK) is header-only // and needed for SF2/SFZ render even without the VST3 SDK — include it directly. #include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h" #ifdef HAVE_VST3SDK #include "public.sdk/source/vst/vstpresetfile.h" #include "public.sdk/source/common/memorystream.h" using Steinberg::int32; using Steinberg::uint32; #endif #ifdef _WIN32 #ifndef NOMINMAX #define NOMINMAX #endif #include #endif #include #include #include #include #include #include #include #include #include #include namespace { // --- sheredom/json.h helpers ------------------------------------------------- const json_object_element_s* member(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; } const json_value_s* memberValue(const json_object_s* o, const char* key) { const json_object_element_s* m = member(o, key); return m ? m->value : nullptr; } std::string memberString(const json_object_s* o, const char* key, const std::string& def) { const json_value_s* v = memberValue(o, key); if (v && v->type == json_type_string) { const auto* s = static_cast(v->payload); return std::string(s->string, s->string_size); } return def; } bool memberNumber(const json_object_s* o, const char* key, double& out) { const json_value_s* v = memberValue(o, key); if (v && v->type == json_type_number) { out = std::atof(static_cast(v->payload)->number); return true; } return false; } int64_t memberInt(const json_object_s* o, const char* key, int64_t def) { double d; return memberNumber(o, key, d) ? (int64_t)d : def; } // --- WAV writer (stdlib only, 16-bit PCM stereo, little-endian) -------------- void writeU16(std::ofstream& f, uint16_t v) { char b[2] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF) }; f.write(b, 2); } void writeU32(std::ofstream& f, uint32_t v) { char b[4] = { (char)(v & 0xFF), (char)((v >> 8) & 0xFF), (char)((v >> 16) & 0xFF), (char)((v >> 24) & 0xFF) }; f.write(b, 4); } void writeWavHeader(std::ofstream& f, uint32_t sampleRate) { f.write("RIFF", 4); writeU32(f, 36); // patched in finishWav f.write("WAVE", 4); f.write("fmt ", 4); writeU32(f, 16); // fmt chunk size writeU16(f, 1); // PCM writeU16(f, 2); // stereo writeU32(f, sampleRate); writeU32(f, sampleRate * 4); // byte rate writeU16(f, 4); // block align writeU16(f, 16); // bits per sample f.write("data", 4); writeU32(f, 0); // patched in finishWav } void finishWav(std::ofstream& f, uint64_t dataBytes) { f.seekp(4); writeU32(f, (uint32_t)(36 + dataBytes)); f.seekp(40); writeU32(f, (uint32_t)dataBytes); f.flush(); } void writeFrames(std::ofstream& f, const float* L, const float* R, uint32_t n) { for (uint32_t i = 0; i < n; ++i) { auto cl = [](float v) -> int { if (v > 1.0f) v = 1.0f; else if (v < -1.0f) v = -1.0f; return (int)(v * 32767.0f); }; writeU16(f, (uint16_t)cl(L[i])); writeU16(f, (uint16_t)cl(R[i])); } } // One scheduled MIDI event at an absolute sample position. kind: 0=noteOn // (a=pitch,b=velocity 1..127), 1=noteOff (a=pitch), 2=CC (a=cc,b=value), // 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) { #ifdef _WIN32 // VST3 hosting needs COM on the loading thread (realtime mode's workers // call OleInitialize; here the main thread hosts the plugin). CoInitializeEx(nullptr, COINIT_MULTITHREADED); struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard; #endif int rc = 1; // default: job/argument error std::ofstream wav; auto fail = [&](const std::string& msg) { std::cerr << "[RenderJob] " << msg << std::endl; if (wav.is_open()) wav.close(); std::remove(outPath.c_str()); // never leave a partial clip behind }; struct RootHolder { json_value_s* p = nullptr; ~RootHolder() { if (p) std::free(p); } } rootH; // 1. Read + parse the job. std::ifstream in(jobPath, std::ios::binary); if (!in) { fail("cannot read job file: " + jobPath); return rc; } std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (data.empty()) { fail("job file is empty: " + jobPath); return rc; } json_parse_result_s pres = {}; json_value_s* root = json_parse_ex(data.data(), data.size(), json_parse_flags_default, nullptr, nullptr, &pres); if (!root) { fail("job JSON parse error (code " + std::to_string(pres.error) + ")"); return rc; } rootH.p = root; if (root->type != json_type_object) { fail("job root must be a JSON object"); return rc; } const json_object_s* job = static_cast(root->payload); // 2. Instrument. const int64_t itype = memberInt(job, "instrument_type", -1); if (itype < 0 || itype > 3) { fail("job missing instrument_type (0=VST3,1=VST2,2=SF2/SF3,3=SFZ)"); return rc; } const InstrumentType type = (InstrumentType)itype; 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", ""); // 3. Format. double srD = 44100.0; memberNumber(job, "sample_rate", srD); if (srD < 8000.0 || srD > 192000.0) { fail("bad sample_rate: " + std::to_string(srD)); return rc; } const uint32_t sampleRate = (uint32_t)srD; int64_t block = memberInt(job, "block_size", 256); if (block > 256) { std::cerr << "[RenderJob] block_size " << block << " > 256 — clamping (VST3 host buffers are prepared at 256)" << std::endl; block = 256; } const uint32_t chunk = (uint32_t)std::max(32, block); double bpm = 120.0; memberNumber(job, "bpm", bpm); bpm = std::max(30.0, bpm); // mirror plugins.py: max(30.0, bpm) const double beatSec = 60.0 / bpm; // 4. MIDI events (mirror vst_engine.midi_events_to_messages semantics: // CC0 bank select + program change at t=0, noteOn at start, noteOff at // start+duration, all sample-accurate). std::vector evs; if (memberValue(job, "soundfont_bank")) { evs.push_back({0, 2, 0, (uint32_t)(memberInt(job, "soundfont_bank", 0) & 0x7F)}); } if (memberValue(job, "soundfont_program")) { evs.push_back({0, 3, (uint32_t)(memberInt(job, "soundfont_program", 0) & 0x7F), 0}); } const json_value_s* nv = memberValue(job, "notes"); if (!nv || nv->type != json_type_array) { fail("job missing notes array"); return rc; } const json_array_s* notes = static_cast(nv->payload); if (notes->length == 0) { fail("job notes array is empty"); return rc; } uint64_t totalNeeded = 1024; // mirror plugins.py: max(total_needed, 1024) for (const json_array_element_s* el = notes->start; el; el = el->next) { const json_value_s* v = el->value; if (!v || v->type != json_type_object) continue; const json_object_s* n = static_cast(v->payload); const uint32_t pitch = (uint32_t)(memberInt(n, "pitch", 60) & 0x7F); double velF = 0.8; memberNumber(n, "velocity", velF); int vel = (int)(velF * 127.0); // mirror plugins.py: int(vel*127) vel = std::max(0, std::min(127, vel)); const double startBeat = [&] { double d = 0.0; memberNumber(n, "start_beat", d); return d; }(); const double durBeats = [&] { double d = 1.0; memberNumber(n, "duration_beats", d); return d; }(); const uint64_t startSample = (uint64_t)std::llround(startBeat * beatSec * sampleRate); const uint64_t endSample = (uint64_t)std::llround((startBeat + durBeats) * beatSec * sampleRate); evs.push_back({startSample, 0, pitch, (uint32_t)vel}); evs.push_back({endSample, 1, pitch, 0}); if (endSample > totalNeeded) totalNeeded = endSample; } std::stable_sort(evs.begin(), evs.end(), [](const Ev& a, const Ev& b) { return a.sample < b.sample; }); // 5. Load the instrument headless (same create_instrument path as realtime). InstrumentEngineManager mgr; if (!mgr.assign(0, type, pluginPath, sampleRate, chunk)) { rc = 2; // load failure fail("instrument load FAILED type=" + std::to_string((int)type) + " path=" + pluginPath); return rc; } std::cout << "[RenderJob] instrument loaded type=" << (int)type << " 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; #ifdef HAVE_VST3SDK } 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; } #else } else { std::cerr << "[RenderJob] preset ignored: built without VST3 SDK" << std::endl; } #endif } // 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 // the next rendered sub-block. wav.open(outPath, std::ios::binary); if (!wav) { fail("cannot open output: " + outPath); return rc; } writeWavHeader(wav, sampleRate); std::vector L(chunk), R(chunk); uint64_t dataBytes = 0; uint64_t pos = 0; size_t ei = 0; auto dispatch = [&](const Ev& e) { switch (e.kind) { case 0: mgr.noteOn(0, e.a, e.b / 127.0f); break; case 1: mgr.noteOff(0, e.a); break; case 2: mgr.controlChange(0, e.a, e.b); break; case 3: mgr.programChange(0, e.a); break; default: break; } }; auto render = [&](uint64_t from, uint64_t to) { const uint32_t n = (uint32_t)(to - from); mgr.renderAll(L.data(), R.data(), n); writeFrames(wav, L.data(), R.data(), n); dataBytes += (uint64_t)n * 4; }; while (pos < totalNeeded) { const uint64_t chunkEnd = std::min(pos + chunk, (uint64_t)totalNeeded); while (ei < evs.size() && evs[ei].sample <= pos) { dispatch(evs[ei]); ++ei; } while (ei < evs.size() && evs[ei].sample < chunkEnd) { const uint64_t off = evs[ei].sample; if (off > pos) { render(pos, off); pos = off; } dispatch(evs[ei]); ++ei; } if (pos < chunkEnd) { render(pos, chunkEnd); pos = chunkEnd; } } finishWav(wav, dataBytes); wav.close(); if (mgr.isCrashed(0)) { rc = 3; fail("plugin crashed inside processAudioBlock — clip invalid, muted channel"); return rc; } std::cout << "[RenderJob] wrote " << outPath << " (" << totalNeeded << " samples, " << (dataBytes / 4) << " frames)" << std::endl; rc = 0; return rc; }