- 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
147 lines
6.7 KiB
Markdown
147 lines
6.7 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
# 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).
|
|
|
|
```json
|
|
{
|
|
"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.
|