Files
JaySynth/tools/testhost
jensandClaude Sonnet 5 ab29ad2820 Add README and TODO tracking for the testhost tool
- README.md: what the tool is and isn't (generic, not JaySynth-specific),
  build instructions, CLI quick-start, full JSON scenario schema/field
  reference, the patch/bank state-format caveat (this tool's load/save
  goes through JUCE's host-side AudioPluginInstance state calls, not
  JaySynth's own GUI-triggered loadPatchFromFile/patchImportXml - a
  different code path worth knowing about), and an architecture summary.
- TODO.md: tracks what's deferred by design (LV2/VST3/LADSPA format
  registration, Windows build), test coverage gaps (stereo effects
  untested, no regression-baseline tooling, no CI wiring, no JSON-parser
  unit tests, no polyphony testing), two things worth investigating
  (a non-fatal DPF plugin assertion seen on load, an unconfirmed
  WavAudioFormat channel-count limit), and one nice-to-have.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ
2026-07-27 19:31:54 +02:00
..

testhost

A generic, headless command-line host for testing audio plugins. It loads a plugin out-of-process and drives it entirely offline — no audio device, no GUI event loop, no real-time pacing — so a test run is bounded by CPU speed rather than wall-clock playback time (60-1000x+ real-time observed on this machine, depending on the plugin).

This tool is not JaySynth-specific. It was built to test JaySynth, but it talks to plugins purely through JUCE's own AudioPluginFormatManager / AudioPluginInstance hosting API, and it never assumes a fixed channel layout — it always asks the loaded plugin how many input/output channels it actually has. That means it drives an instrument (0 inputs, MIDI-triggered, e.g. JaySynth) and an audio effect (real input required, e.g. a reverb, EQ, or compressor) through the exact same code path. See /home/jens/.claude/plans/sharded-finding-tower.md for the original design plan and rationale.

Building

MAKE_HOME=$(realpath ../../submodule/make) make -C tools/testhost

This produces tools/testhost/build/linux/release/testhost. It has its own JuceLibraryCode/ package (compiling only the module amalgams a host needs - not juce_audio_plugin_client, which is the plugin-side VST entry point and would collide with this executable's own main()), but reuses the already-vendored sdk/juce/JUCE-3.1.1 rather than a second JUCE checkout. The host and any plugin it loads are decoupled at the VST2 ABI, not the JUCE source level, so this doesn't need to track JaySynth's own JUCE version.

make CONFIG=debug -C tools/testhost builds a debug variant the same way the main plugin build does.

Quick start

# Render 2s of a held note through JaySynth and record it:
./build/linux/release/testhost \
  --plugin ../../build/linux/release/JaySynth.so \
  --note 60 100 --duration 2 --wav out.wav

# Feed pink noise through an audio effect (no MIDI needed):
./build/linux/release/testhost \
  --plugin /usr/lib/vst/ZamDelay-vst.so \
  --input pink --duration 2 --wav out.wav

Run ./build/linux/release/testhost --help for the full flag reference (patch/bank load & save, parameters, sample rate/block size, performance CSV export, etc).

JSON test scenarios

For anything beyond a one-off check, --scenario file.json drives a full timed sequence of actions instead of a handful of CLI flags - this is what makes tests repeatable and batchable (e.g. one file per regression case, run unattended in CI).

{
  "sampleRate": 44100,
  "blockSize": 512,
  "durationSeconds": 2.0,
  "input": { "type": "noise", "kind": "pink", "amplitude": 0.5 },
  "loadBank": "some_bank.fxb",
  "events": [
    { "sample": 0,     "type": "noteOn",    "channel": 1, "note": 60, "velocity": 100 },
    { "sample": 22050, "type": "param",     "index": 5,   "value": 0.75 },
    { "sample": 44100, "type": "noteOff",   "channel": 1, "note": 60 },
    { "sample": 88199, "type": "savePatch", "file": "result_patch.bin" }
  ],
  "recordWav": "out.wav"
}

Relative file paths inside the JSON (loadBank, loadPatch, recordWav, and any event's file) resolve against the scenario file's own directory, not the process's working directory, so a scenario file stays portable no matter where it's run from.

Top-level fields

Field Type Notes
sampleRate number default 44100
blockSize number default 512
durationSamples or durationSeconds number one of these is required
input object {"type": "silence"} (default) / "sine" (+ frequencyHz) / "noise" (+ kind: "white"/"pink") / "impulse", all with optional amplitude (default 0.5)
loadPatch / loadBank string (file path) loaded once, before rendering starts
recordWav string (file path) omit to skip recording (e.g. pure performance runs)
events array see below; need not be pre-sorted, TestScenario sorts by sample

Event types

All events have sample (absolute sample index) and type:

type Extra fields Effect
noteOn channel, note, velocity MidiMessage::noteOn, timed to the exact sample within its block
noteOff channel, note MidiMessage::noteOff, same timing precision
controller channel, controllerNumber, controllerValue MidiMessage::controllerEvent
param index, value (0.0-1.0) AudioProcessor::setParameter, applied at the start of the block containing sample (see caveat below)
loadPatch / loadBank file reads the file's raw bytes and calls setCurrentProgramStateInformation/setStateInformation
savePatch / saveBank file calls getCurrentProgramStateInformation/getStateInformation and writes the raw bytes out

Caveat: MIDI events keep full per-sample timing (JUCE's MidiBuffer carries a sample offset natively). Non-MIDI events (param/loadPatch/ loadBank/savePatch/saveBank) scheduled for a sample in the middle of a block are applied at the start of that block, not their exact offset - fine for test purposes, but not sample-accurate. Use a smaller blockSize if you need tighter timing on those.

Patch/bank format note

loadPatch/savePatch/loadBank/saveBank read/write exactly the bytes that JUCE's host-side AudioPluginInstance::get/setCurrentProgramState Information and get/setStateInformation produce/consume for a hosted VST2 plugin - i.e. real .fxp/.fxb file bytes (JUCE's own VST2 host wrapper handles the fxb/fxp chunk header itself; this tool never parses that format directly). This is a different code path from JaySynth's own GUI-triggered "Load Patch from disk" button (JaySynthAudioProcessor:: loadPatchFromFile/patchImportXml in src/plug/PluginProcessor.cpp) - both are legitimate ways to get a patch into the plugin, but they aren't the same call chain, so a fix to one doesn't automatically test the other.

Architecture

  • PluginHost — owns the AudioPluginFormatManager, loads/prepares/drives one plugin instance. The only class that talks to AudioPluginInstance directly; everything else in this tool is format-agnostic by construction.
  • AudioInputSource — fills the plugin's input channels each block (silence/sine/noise/impulse).
  • WavRecorder — thin wrapper over WavAudioFormat/AudioFormatWriter.
  • TestScenario — parses the JSON format above into a sorted event list plus render/I-O config.
  • PerformanceStats — per-block timing, summary + optional CSV.
  • main.cpp — CLI parsing, and a single render loop shared by both the ad-hoc-flags path and the --scenario path (ad-hoc flags are converted into an equivalent in-memory TestScenario before rendering starts, so there's only one loop to maintain).

Known limitations

See TODO.md in this directory.