feat(bridge): add --render offline CLI (Phase 0 of pedalboard replacement)
daw_vst_bridge --render <job.json> --out <clip.wav> renders MIDI notes through the same instrument engines as realtime (SF2/SFZ/VST3/VST2) to a 16-bit stereo WAV, headless, no SHM. Mirrors plugins.py/vst_engine.py note semantics (velocity int(vel*127), bank CC0 + program at t=0, beatSec 60/max(30,bpm), totalNeeded max(1024,endSample), no tail). Chunk clamped to 256 to match VST3 host prepared buffers. Uses vendored sheredom json.h (gated on HAVE_VST3SDK); rc: 0 ok, 1 arg/job error, 2 load fail, 3 crash. Partial output removed on failure. Verified: SF2, SFZ, Nexus VST3 render non-silent at exact duration; 9/9 tests pass.
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// native_bridge/include/RenderJob.h
|
||||
#ifndef RENDER_JOB_H
|
||||
#define RENDER_JOB_H
|
||||
|
||||
#include <string>
|
||||
|
||||
// Offline render mode: `daw_vst_bridge --render <job.json> --out <clip.wav>`.
|
||||
// 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
|
||||
@@ -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 <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"
|
||||
|
||||
#ifdef HAVE_VST3SDK
|
||||
#include "vst3sdk/public.sdk/source/vst/moduleinfo/json.h"
|
||||
#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 {
|
||||
|
||||
#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<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;
|
||||
}
|
||||
#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<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", "");
|
||||
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<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;
|
||||
|
||||
// 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 * 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
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "SharedMemoryIPC.h"
|
||||
#include "NativeInstrumentEngine.h"
|
||||
#include "StateStore.h"
|
||||
#include "RenderJob.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -599,6 +600,20 @@ int main(int argc, char* argv[]) {
|
||||
|
||||
#endif
|
||||
|
||||
// --render <job.json> --out <clip.wav>: 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 <clip.wav>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
return run_render_job(renderJob, renderOut);
|
||||
}
|
||||
|
||||
// 1. Shared memory name: argv --shm <name> | env SF_SHM_NAME | default
|
||||
std::string shmName = "SonicForge_DAW_IPC";
|
||||
for (int i = 1; i + 1 < argc; ++i) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
# Phase 0 offline render test: `daw_vst_bridge --render <job.json> --out <clip.wav>`
|
||||
# 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("<I", b, 40)[0]
|
||||
assert len(b) == 44 + data_size, "trailing bytes"
|
||||
assert data_size % 4 == 0, "stereo 16-bit: data must be multiple of 4"
|
||||
frames = data_size // 4
|
||||
samples = struct.unpack("<%dh" % (frames * 2), b[44:])
|
||||
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32767.0
|
||||
return frames, rms
|
||||
|
||||
def make_job(itype, path, outdir):
|
||||
return {
|
||||
"instrument_type": itype, "plugin_path": path,
|
||||
"sample_rate": SR, "bpm": BPM,
|
||||
"notes": [{"pitch": 60, "velocity": 0.8, "start_beat": 0.0, "duration_beats": 1.0}],
|
||||
}
|
||||
|
||||
def run_render(jobdict, out):
|
||||
job = os.path.join(outdir_of(out), os.path.basename(out) + ".job.json")
|
||||
with open(job, "w", encoding="utf-8") as f:
|
||||
json.dump(jobdict, f)
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
r = subprocess.run([BRIDGE, "--render", job, "--out", out],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
return r.returncode, (r.stdout + r.stderr)
|
||||
|
||||
def outdir_of(p):
|
||||
d = os.path.dirname(p)
|
||||
return d if d else "."
|
||||
|
||||
def main():
|
||||
results = []
|
||||
def check(name, ok, detail):
|
||||
results.append((name, ok, detail))
|
||||
print(("PASS" if ok else "FAIL"), name, "-", detail)
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 1. SF2 render
|
||||
job = os.path.join(td, "sf2.json"); out = os.path.join(td, "sf2.wav")
|
||||
rc, log = run_render(make_job(2, SF2, td), out)
|
||||
if rc == 0 and os.path.exists(out):
|
||||
frames, rms = read_wav(out)
|
||||
check("SF2 render exit 0", rc == 0, log.strip().splitlines()[-1:] or "")
|
||||
check("SF2 exact duration", frames == TOTAL, f"{frames} == {TOTAL}")
|
||||
check("SF2 non-silent", rms > 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())
|
||||
Reference in New Issue
Block a user