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
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user