Files
SonicForgeStudio/native_bridge/tests/test_offline_render.py
T
admin 0cca84c5f9 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.
2026-08-17 08:04:25 +07:00

100 lines
4.4 KiB
Python

#!/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())