a2f93138be
- native bridge WAV writers: 16-bit PCM truncation -> IEEE Float32 (fmt 3, bit-exact) - vendor sheredom_json.h (vst3sdk 3.8.1 lacks moduleinfo/json.h) - Dockerfile: drop nonexistent sfizz tag v1.2.3 - Python sf.write -> subtype=FLOAT everywhere - resample_poly -> kaiser(16) window (>=130dB stopband) - audio_editor._write_wav_lossless(): FLOAT passthrough, TPDF dither only for 8/16/24-bit final exports - app.jsx WAV encoders: Float32 default, TPDF dither for integer depths, fix 24-bit - tests: lossless compliance gate (null test < -140dBFS, float32 format) 9/9 pass - TASKS_WINDOWS_AUDIO_NORMALIZE.md: Windows-side remaining tasks
159 lines
7.8 KiB
Python
159 lines
7.8 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"
|
|
fmt_tag = struct.unpack_from("<H", b, 20)[0]
|
|
bits = struct.unpack_from("<H", b, 34)[0]
|
|
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"
|
|
if fmt_tag == 3: # IEEE float32 (lossless render output)
|
|
assert bits == 32 and data_size % 8 == 0, "float32 stereo"
|
|
frames = data_size // 8
|
|
samples = struct.unpack("<%df" % (frames * 2), b[44:])
|
|
rms = math.sqrt(sum(s * s for s in samples) / len(samples))
|
|
return frames, rms
|
|
# PCM 16-bit (legacy)
|
|
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")
|
|
|
|
# 5. VST3 render (Nexus) - baseline without preset
|
|
NEXUS = r"C:\Program Files\Common Files\VST3\Nexus.vst3"
|
|
GOLDEN = os.path.abspath(os.path.join(os.path.dirname(__file__), "golden", "nexus_default.vstpreset"))
|
|
if os.path.exists(NEXUS) and os.path.exists(GOLDEN):
|
|
import base64
|
|
with open(GOLDEN, "rb") as f:
|
|
preset_b64 = base64.b64encode(f.read()).decode("ascii")
|
|
|
|
job = os.path.join(td, "vst3.json"); out = os.path.join(td, "vst3.wav")
|
|
rc, log = run_render(make_job(0, NEXUS, td), out)
|
|
if rc == 0 and os.path.exists(out):
|
|
frames, rms = read_wav(out)
|
|
check("VST3 render exit 0", rc == 0, log.strip().splitlines()[-1:] or "")
|
|
check("VST3 exact duration", frames == TOTAL, f"{frames} == {TOTAL}")
|
|
check("VST3 non-silent", rms > 0.001, f"rms={rms:.5f}")
|
|
else:
|
|
check("VST3 render exit 0", False, f"rc={rc} {log[-400:]}")
|
|
|
|
# 6. VST3 render WITH .vstpreset - same default state, must still
|
|
# render (proves the parse + loadSerializedState path runs).
|
|
job = os.path.join(td, "vst3p.json"); out = os.path.join(td, "vst3p.wav")
|
|
jobdict = make_job(0, NEXUS, td); jobdict["preset"] = preset_b64
|
|
rc, log = run_render(jobdict, out)
|
|
if rc == 0 and os.path.exists(out):
|
|
frames, rms = read_wav(out)
|
|
check("VST3+preset exit 0", rc == 0, log.strip().splitlines()[-1:] or "")
|
|
check("VST3+preset exact duration", frames == TOTAL, f"{frames} == {TOTAL}")
|
|
check("VST3+preset non-silent", rms > 0.001, f"rms={rms:.5f}")
|
|
check("VST3+preset applied (log)", "preset imported" in log, "log has preset imported")
|
|
else:
|
|
check("VST3+preset exit 0", False, f"rc={rc} {log[-400:]}")
|
|
|
|
# 7. Corrupt preset (not base64) -> rc 1, no partial clip
|
|
job = os.path.join(td, "vst3bad.json"); out = os.path.join(td, "vst3bad.wav")
|
|
jobdict = make_job(0, NEXUS, td); jobdict["preset"] = "!!!not-base64!!!"
|
|
rc, log = run_render(jobdict, out)
|
|
check("VST3 bad preset -> rc 1", rc == 1, f"rc={rc}")
|
|
check("bad preset removes partial out", not os.path.exists(out), "no clip left behind")
|
|
|
|
# 8. Non-VST3 with preset: ignored (warning), still renders fine
|
|
job = os.path.join(td, "sf2p.json"); out = os.path.join(td, "sf2p.wav")
|
|
jobdict = make_job(2, SF2, td); jobdict["preset"] = preset_b64
|
|
rc, log = run_render(jobdict, out)
|
|
if rc == 0 and os.path.exists(out):
|
|
frames, rms = read_wav(out)
|
|
check("SF2+preset exit 0 (ignored)", rc == 0, log.strip().splitlines()[-1:] or "")
|
|
check("SF2+preset non-silent", rms > 0.001, f"rms={rms:.5f}")
|
|
else:
|
|
check("SF2+preset exit 0 (ignored)", False, f"rc={rc} {log[-400:]}")
|
|
|
|
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())
|