ba9ceeea90
- finishWav call sites accumulated n*4 (stereo 16-bit) but float32 writer emits n*8 bytes -> RIFF/data chunk sizes half the real value; players and soundfile truncated renders to half length - gate test now asserts file size == 44 + data_size to catch this
417 lines
17 KiB
C++
417 lines
17 KiB
C++
// native_bridge/src/RenderJob.cpp
|
|
// Offline render mode (Phase 0 of PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md):
|
|
// `daw_vst_bridge --render <job.json> --out <clip.wav>` 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) is header-only and needed for SF2/SFZ render
|
|
// even without the VST3 SDK. Vendored copy: native_bridge/include/sheredom_json.h
|
|
// (the pinned VST3 SDK v3.8.1 has no moduleinfo/json.h); fall back to the SDK
|
|
// copy when a newer checkout provides it.
|
|
#if __has_include("sheredom_json.h")
|
|
#include "sheredom_json.h"
|
|
#elif __has_include("vst3sdk/public.sdk/source/vst/moduleinfo/json.h")
|
|
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
|
|
#else
|
|
#error "sheredom/json.h not found — vendored copy at native_bridge/include/sheredom_json.h"
|
|
#endif
|
|
#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 <windows.h>
|
|
#endif
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <iterator>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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<const json_string_s*>(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<const json_number_s*>(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, IEEE Float32 stereo, little-endian) ------------
|
|
// Lossless: keeps the full float32 render buffer bit-exact. No PCM truncation
|
|
// and no dither here — dither/quantization belongs ONLY to a final export
|
|
// stage (lossless-audio-compliance skill, Rules 4 & 7). Format tag 3.
|
|
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, 3); // IEEE float
|
|
writeU16(f, 2); // stereo
|
|
writeU32(f, sampleRate);
|
|
writeU32(f, sampleRate * 8); // byte rate = sr * 2ch * 4B
|
|
writeU16(f, 8); // block align
|
|
writeU16(f, 32); // 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) {
|
|
// IEEE float32 little-endian, bit-exact: no clamp, no quantization.
|
|
for (uint32_t i = 0; i < n; ++i) {
|
|
f.write(reinterpret_cast<const char*>(&L[i]), 4);
|
|
f.write(reinterpret_cast<const char*>(&R[i]), 4);
|
|
}
|
|
}
|
|
|
|
// 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<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) {
|
|
#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<char>(in)), std::istreambuf_iterator<char>());
|
|
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<const json_object_s*>(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<int64_t>(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<Ev> 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<const json_array_s*>(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<const json_object_s*>(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<float> 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 * 8; // stereo float32: 2ch * 4B
|
|
};
|
|
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;
|
|
}
|