diff --git a/install/daw_vst_bridge.exe b/install/daw_vst_bridge.exe index 8b23825..f2795f5 100644 Binary files a/install/daw_vst_bridge.exe and b/install/daw_vst_bridge.exe differ diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index 2459a53..02abd27 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -56,6 +56,7 @@ include_directories( add_executable(daw_vst_bridge src/main.cpp + src/RenderJob.cpp src/NativeInstrumentEngine.cpp src/SharedMemoryIPC.cpp src/Vst3Instrument.cpp diff --git a/native_bridge/include/RenderJob.h b/native_bridge/include/RenderJob.h new file mode 100644 index 0000000..78e1d84 --- /dev/null +++ b/native_bridge/include/RenderJob.h @@ -0,0 +1,22 @@ +// native_bridge/include/RenderJob.h +#ifndef RENDER_JOB_H +#define RENDER_JOB_H + +#include + +// Offline render mode: `daw_vst_bridge --render --out `. +// Parses the job, creates the instrument headless, renders the MIDI notes to +// a 16-bit stereo WAV, and exits. Realtime SHM mode is untouched. +// +// job.json: { instrument_type, plugin_path, preset?, sample_rate?, block_size?, +// 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). +// 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 +// non-zero (1 = job/argument error, 2 = instrument load failure, 3 = plugin +// crashed mid-render). Any partial output file is removed. +int run_render_job(const std::string& jobPath, const std::string& outPath); + +#endif // RENDER_JOB_H diff --git a/native_bridge/src/RenderJob.cpp b/native_bridge/src/RenderJob.cpp new file mode 100644 index 0000000..fd61355 --- /dev/null +++ b/native_bridge/src/RenderJob.cpp @@ -0,0 +1,289 @@ +// 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" + +#ifdef HAVE_VST3SDK +#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h" +#endif + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +#ifdef HAVE_VST3SDK +// --- 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; +} +#endif // HAVE_VST3SDK + +// --- 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; }; + +} // 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 + }; + +#ifndef HAVE_VST3SDK + fail("built without the VST3 SDK submodule — JSON parser unavailable, --render disabled"); + return rc; +#else + 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", ""); + 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; + 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; + + // 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; +#endif // HAVE_VST3SDK +} diff --git a/native_bridge/src/main.cpp b/native_bridge/src/main.cpp index 23e2e2f..87477e0 100644 --- a/native_bridge/src/main.cpp +++ b/native_bridge/src/main.cpp @@ -5,6 +5,7 @@ #include "SharedMemoryIPC.h" #include "NativeInstrumentEngine.h" #include "StateStore.h" +#include "RenderJob.h" #ifdef _WIN32 #include @@ -599,6 +600,20 @@ int main(int argc, char* argv[]) { #endif + // --render --out : one-shot offline render, no SHM/loop. + std::string renderJob, renderOut; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--render") == 0 && i + 1 < argc) renderJob = argv[i + 1]; + else if (std::strcmp(argv[i], "--out") == 0 && i + 1 < argc) renderOut = argv[i + 1]; + } + if (!renderJob.empty()) { + if (renderOut.empty()) { + std::cerr << "[RenderJob] --render requires --out " << std::endl; + return 1; + } + return run_render_job(renderJob, renderOut); + } + // 1. Shared memory name: argv --shm | env SF_SHM_NAME | default std::string shmName = "SonicForge_DAW_IPC"; for (int i = 1; i + 1 < argc; ++i) { diff --git a/native_bridge/tests/test_offline_render.py b/native_bridge/tests/test_offline_render.py new file mode 100644 index 0000000..0fab287 --- /dev/null +++ b/native_bridge/tests/test_offline_render.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Phase 0 offline render test: `daw_vst_bridge --render --out ` +# SF2 + SFZ through the same engines as realtime. Asserts: +# - exit 0, WAV exists, valid RIFF, exact duration (totalNeeded frames) +# - audio non-silent (RMS > 0.001) +# - error paths: missing --out -> rc 1; bad plugin_path -> rc 2 +# Usage: python tests/test_offline_render.py +import json, math, os, struct, subprocess, sys, tempfile + +BRIDGE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "build", "Release", "daw_vst_bridge.exe")) +SF2 = r"C:\Users\locpham\SonicForgeStudio\app\storage\soundfonts\518e850f-a5d3-4790-b1f9-0c90c203c524.sf2" +SFZ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "sfizz-src", "tests", "TestFiles", "g2_test.sfz")) + +BPM, SR = 120.0, 44100 +BEAT_SEC = 60.0 / BPM +TOTAL = int(max(1024, round((0.0 + 1.0) * BEAT_SEC * SR))) # 22050 + +def read_wav(path): + b = open(path, "rb").read() + assert b[:4] == b"RIFF" and b[8:12] == b"WAVE", "not a RIFF/WAVE file" + assert b[36:40] == b"data", "no data chunk" + data_size = struct.unpack_from(" 0.001, f"rms={rms:.5f}") + else: + check("SF2 render exit 0", False, f"rc={rc} {log[-400:]}") + + # 2. SFZ render (g2_test.sfz one-shot decays ~200ms; 0.5s clip still has signal) + job = os.path.join(td, "sfz.json"); out = os.path.join(td, "sfz.wav") + rc, log = run_render(make_job(3, SFZ, td), out) + if rc == 0 and os.path.exists(out): + frames, rms = read_wav(out) + check("SFZ render exit 0", rc == 0, log.strip().splitlines()[-1:] or "") + check("SFZ exact duration", frames == TOTAL, f"{frames} == {TOTAL}") + check("SFZ non-silent", rms > 0.001, f"rms={rms:.5f}") + else: + check("SFZ render exit 0", False, f"rc={rc} {log[-400:]}") + + # 3. Missing --out -> rc 1 + job = os.path.join(td, "noout.json") + with open(job, "w", encoding="utf-8") as f: + json.dump(make_job(2, SF2, td), f) + r = subprocess.run([BRIDGE, "--render", job], capture_output=True, text=True, timeout=30) + check("missing --out -> rc 1", r.returncode == 1, f"rc={r.returncode}") + + # 4. Bad plugin path -> rc 2 + job = os.path.join(td, "bad.json"); out = os.path.join(td, "bad.wav") + rc, log = run_render(make_job(2, r"C:\nonexistent\no.sf2", td), out) + 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") + + failed = [n for n, ok, _ in results if not ok] + print() + print(f"{len(results) - len(failed)}/{len(results)} passed") + return 1 if failed else 0 + +if __name__ == "__main__": + sys.exit(main())