Status: Phase 1 implemented. The
pluginval test <config.json>mode described here is built (source/acceptance/, wired into the CLI dispatcher) with thesamplecomparator, record-or-compare, float-WAV + JSON-sidecar references, a dogfood tone-generator plugin (tests/test_plugins/tone_generator/, behindPLUGINVAL_BUILD_TEST_PLUGINS) and CTest self-tests (tests/acceptance/). The Phase 2 items in §10 remain stubs/notes. This document is the reference spec for both the implementation and future extension.
The existing validate mode is a graded unit-test suite (PluginTests : juce::UnitTest, self-registering PluginTest instances, pass/fail via
expect). It answers "does this plugin conform to the host API and behave
safely?"
Acceptance testing answers a different question: "does this plugin produce the output I expect for a known input and state?" It is a deterministic render + golden-file comparison:
- Load a plugin, apply a known state / parameter set.
- Feed a known input (audio and/or MIDI, or silence).
- Render a fixed duration of audio.
- If no reference exists, record one. If a reference exists, compare the freshly rendered output against it and emit a pass/fail verdict.
Because it is a fundamentally different activity from validate, it is built as
a parallel subsystem rather than as another PluginTest. It reuses the
plugin-loading, lifecycle, and render infrastructure but has its own CLI
command, config schema, and result model.
- Acceptance testing is only meaningful for plugins that are deterministic given a fixed input + state. A plugin with free-running internal randomness cannot be golden-tested reliably. The planned playhead / seed controls help, but cannot make a non-deterministic plugin deterministic.
- The cross-platform / cross-version portability goal (matching a reference produced on a different OS, CPU, or plugin version) is realistic only with tolerant comparison methods. Exact / per-sample comparison rarely survives SIMD and platform floating-point differences. This is precisely why the comparator is a pluggable abstraction (see §5): spectrum, cross-correlation and fingerprint methods are what make portable references viable.
A new subcommand sits alongside the existing verbs:
pluginval test <config.json>
<config.json>is the positional acceptance-test definition (see §4). It is parsed by its own loader.- If the config's reference file does not exist, the command fails, unless
--record-missing(orRECORD_MISSING=1) is given, in which case it is created (record mode) and the command reports success. Failing by default stops a deleted or mis-pathed reference from silently passing in CI. --timeout-ms <ms>(default 30000,-1= never; alsoTIMEOUT_MS) bounds the whole run. A watchdog reports the timeout and exits1, since a hung plugin call can't be interrupted.- If the reference exists, the rendered output is compared against it and
the command exits
0(match) or1(mismatch), consistent withvalidate.
Do not confuse the acceptance-test config with the existing --config
flag. They are unrelated:
--config file.json |
pluginval test file.json |
|
|---|---|---|
| Purpose | A settings layer for a validate run | The full acceptance-test definition |
| Schema | PluginvalSettings (strictness, timeouts, sample-rate lists, …) |
Plugin + input + reference + state + comparison |
| Merge behaviour | merge_patched into the settings layering |
Loaded standalone |
| How supplied | --config option |
Positional argument to the test verb |
The acceptance-test definition is never routed through the
PluginvalSettings / --config layering.
The CLI was rewritten (PRs #175 and #176) onto CLI11 + an nlohmann/json settings pipeline with a subcommand dispatcher. Acceptance testing plugs into that dispatcher; it does not touch the validate settings-layering at all.
Required edits (small and localised):
| Location | Edit |
|---|---|
SettingsParser.h - enum class Command |
add test |
SettingsParser.cpp - dispatch() |
recognise the verb test, peel it, capture the positional config path(s) |
SettingsParser.cpp - isCommandLine() |
recognise test so CLI mode is triggered |
SettingsParser.cpp - getFooterText() |
add test to the Commands: help block |
CommandLine.cpp - performCommandLine() |
add a Command::test branch that runs the acceptance runner and async-quits |
Because test is brand new there are no deprecated-alias concerns.
For v1, acceptance tests run in-process: record and compare both need the
rendered buffer back in the calling process, so in-process is the natural
choice. The render runs on a background thread (acceptance::TestRunner) so the
message thread stays free to service the plugin (VST3 lifecycle calls,
async-only creation, work the plugin posts to the message thread); the plugin is
deleted on the message thread. Crash isolation can be layered on later by mirroring the validate
child-process handoff (createChildProcessCommandLine in SettingsParser.cpp,
which passes an authoritative base64-JSON blob via --config-base64); the
equivalent would be test --config-base64 <b64> with the child writing the
reference / diff to disk and returning an exit code.
The config is a plain, std-typed struct that follows the same nlohmann/json
(de)serialisation pattern as PluginvalSettings.h, plus a toX() boundary
conversion to JUCE types. JSON keys are snake_case (e.g. sample_rate).
Because the C++ members stay JUCE-style camelCase, the snake_case keys are
mapped explicitly via per-struct to_json / from_json (rather than the bare
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT macro, which would emit the
member names verbatim). Missing keys still fall back to the defaults.
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | reference's basename | Used to derive the default reference path and to label results. name or reference is required. |
plugin |
string | (required) | Absolute/relative path, or an AU identifier string. Relative paths resolve against the working directory. --plugin <plugin> on the command line overrides it, in which case it may be omitted. |
input.audio |
string | none | Path to an input audio file (WAV/AIFF/FLAC via AudioFormatManager). Used for effects. Its sample rate must equal sample_rate. File channel N feeds plugin input channel N; a mono file feeds channel 0 only (deliberately not duplicated). |
input.midi |
string | none | Path to a .mid file (juce::MidiFile). Used for instruments. Meta events are skipped. |
reference |
string | <dir-of-config>/<name>.wav |
The golden file. Absent -> record mode; present -> compare mode. |
state.file |
string | none | Binary blob from getStateInformation, applied via the VST3-safe helper. |
state.parameters |
object | none | name-or-index -> normalised value, applied in file order (parsed with nlohmann::ordered_json). Applied after state.file if both are given. Digits-only keys are indices; otherwise the format's parameter ID, then the display name, case-insensitively. |
sample_rate |
number | 44100 | Single value (not a list, unlike validate). Must be positive and finite. |
block_size |
number | 512 | Single value, 1 to 65536. |
render_duration |
number | input length | Seconds. num_samples = round(duration * sample_rate). Input shorter than the duration is padded with silence; longer is truncated. |
comparison |
object | { "sample": 0.0000305 } |
Map of comparator name -> its sub-config. See §5. An empty object is rejected. The sample comparator fails on any NaN / inf difference. |
playhead |
object | none | Fixed transport for time-dependent plugins. { "bpm": <number>, "time_signature": { "numerator": N, "denominator": D }, "start_ppq": <number> }. time_signature defaults to 4/4, start_ppq to 0. Omitted -> no playhead is set (the plugin sees getPlayHead() == nullptr). The tempo / time signature are constant; the position advances with the render. |
automation |
array | none | Phase 2. Parameter changes scheduled at sample positions. |
If both state.file and state.parameters are present, the binary state is
applied first (it represents the full plugin state), then the parameter map
is applied on top as overrides.
The loader accepts either a single object or a top-level array of configs.
v1 may execute only the single / first entry; phase 2 iterates the array to
multiplex many test cases from one file. Designing the parser for arrays now
costs nothing and avoids a schema break later. Each array entry should carry a
name so its reference path and result are uniquely identifiable.
Comparison is pluggable. The config selects one or more methods; v1 ships
only sample, and additional methods register later with zero changes to
config parsing or the runner.
struct ComparisonResult
{
bool passed = false;
double score = 0.0; // method-specific metric (e.g. max abs diff)
juce::String summary; // human-readable
nlohmann::json details; // structured, for the machine-readable report
};
struct Comparator
{
virtual ~Comparator() = default;
virtual juce::String getName() const = 0; // "sample", "spectrum", ...
virtual ComparisonResult compare (const juce::AudioBuffer<float>& reference,
const juce::AudioBuffer<float>& output,
const nlohmann::json& config) = 0; // value under comparison[name]
};- A small registry maps method name -> factory.
configis whatever sits under the comparator's key - a bare number (1e-6) forsample, or an object for richer methods (e.g."spectrum": { "tolerance": 0.05, "fftSize": 2048 }).- When multiple methods are listed, all must pass (logical AND); each is reported separately.
When comparison is omitted, the default is:
"comparison": { "sample": 0.0000305 } // 1.0 / 32768.0, i.e. one 16-bit LSB| Method | Phase | Description |
|---|---|---|
sample |
v1 | Per-sample absolute-difference tolerance (0 = bit-exact) plus a length check. |
peakrms |
future | Peak and RMS difference thresholds. |
spectrum |
future | FFT-magnitude comparison with tolerance; robust to small phase/SIMD differences. |
crosscorr |
future | Cross-correlation, tolerant to small latency offsets. |
fingerprint |
future | Acoustic fingerprint match; most robust for cross-platform/version. |
The spectrum / crosscorr / fingerprint methods are what make the
cross-platform-portable reference goal practical.
A reference is two files:
<name>.wav- the rendered output as a 32-bit float WAV (preserves full precision; it is the source of truth for comparison).<name>.wav.json- a sidecar manifest:
{
"plugin": { "name": "...", "manufacturer": "...", "version": "...",
"format": "VST3", "uid": "..." },
"render": { "sample_rate": 48000, "block_size": 512, "num_channels": 2,
"num_samples": 96000, "length_seconds": 2.0 },
"pluginval_version": "2.0.0",
"config_hash": "…", // XXH3 of the config, paths excluded (see below)
"created_on": { "os": "macOS", "arch": "arm64", "date": "2026-…" } // informational only
}created_onis informational only - it is never used for matching, because references are meant to be portable and shared.config_hashlets the runner detect a stale reference (one produced from a different config than the one now being run) and warn. It is an XXH3-64 hash (xxHash, header-only via CPM) of the config with thepluginandreferencepaths removed and theinput.audio/input.midi/state.filepaths replaced by XXH3 hashes of the files' contents. Paths are machine-specific, so hashing them would make every checked-in reference look stale elsewhere; the plugin binary is what's under test, so it is deliberately not part of the hash.- The default reference path deliberately contains no platform/arch, since references are intended to be portable and checked into a repo.
When a comparison fails, the runner also writes a diff WAV (output − reference)
next to the result to aid debugging.
source/acceptance/
TestConfig.h/.cpp // std-typed struct + NLOHMANN_DEFINE_TYPE..._WITH_DEFAULT
// + toRenderSpec() boundary conversion (mirrors PluginvalSettings.h)
AcceptanceTest.h/.cpp // resolve + load plugin, apply state, feed input, render -> buffer
ReferenceComparator.h/.cpp // Comparator interface + registry + SampleComparator; record-or-compare
TestReporter.h/.cpp // text + JSON result, exit code
Reused infrastructure:
- Plugin loading / scanning:
AudioPluginFormatManager::createPluginInstanceandKnownPluginList(as inPluginTests.cpp). - VST3-safe lifecycle helpers in
TestUtilities.h:callPrepareToPlayOnMessageThreadIfVST3,callSetStateInformationOnMessageThreadIfVST3, etc. (VST3 requires several operations on the message thread). - The render-loop shape from the
AudioProcessingTestinsource/tests/BasicTests.cpp(prepareToPlay->processBlockloop withAudioBuffer+MidiBuffer).
Build wiring: add the new .cpp files to the SourceFiles list in
CMakeLists.txt, and add acceptance-mode cases to source/CommandLineTests.cpp
following the existing test style.
To develop and self-test the acceptance feature we need a device under test that is fully deterministic - something whose output is known exactly, doesn't depend on a third-party binary, and is stable across runs and platforms. We build a tiny in-repo tone-generator plugin for this purpose ("dogfooding" the feature with our own plugin).
A minimal JUCE plugin (built with juce_add_plugin) that synthesises a simple,
deterministic tone:
- Parameters (so we can exercise state / parameter application):
waveform- choice:sine,square(extendable tosaw,triangle).frequency- Hz (e.g. 20-20000, default 440).gain- linear or dB (default −6 dB).
- Determinism: the oscillator phase resets to 0 on
prepareToPlayand the waveform is computed in closed form from the sample index, so the same config always renders the identical buffer (ideal for thesamplecomparator, including bit-exact). No randomness, no denormal-sensitive feedback paths. - No audio input required: it is a generator, so acceptance configs for it
use silence input (or omit
input) and a fixedrender_duration. - Produces a VST3 on all platforms (and an AU on macOS) so the same plugin exercises both formats.
A new CMake target alongside the existing tests/test_plugins/, e.g.
pluginval_tone_generator, built on demand (guarded behind an option such as
PLUGINVAL_BUILD_TEST_PLUGINS, off for normal release builds). Suggested
layout:
tests/test_plugins/tone_generator/
CMakeLists.txt // juce_add_plugin target
ToneGeneratorPlugin.h/.cpp
Check in a handful of acceptance configs plus their recorded reference WAVs and wire them into CTest, so CI both validates the tone generator's stability and exercises the full record/compare path:
tests/acceptance/
sine-440.json.in // tone gen, waveform=sine, state.parameters
square-220.json.in // tone gen, waveform=square, state.parameters, bit-exact
square-state.json.in // tone gen, state.file blob + a gain parameter override
gain-half.json.in // gain effect, input.audio = a full-height sine, bit-exact
playhead-120.json.in // playhead probe, fixed transport (bpm 120, 4/4), bit-exact
inputs/sine-full.wav // checked-in input for gain-half (±1.0 sine)
refs/<case>.wav (+ .wav.json) // checked-in references + sidecar manifests
refs/square-state.state // checked-in getStateInformation blob
The configs are checked-in templates (*.json.in): the plugin artefact
paths and the input/reference directories are substituted at CMake configure
time (the tests can't hardcode a build-tree plugin path). Each pluginval test <config> CTest case asserts exit code 0 against the checked-in reference.
Because the dogfood plugins are closed-form deterministic, these references are
stable enough to commit - a first smoke test of cross-platform portability for
the sample comparator and the baseline for future comparators (spectrum,
crosscorr, …).
The five cases cover the distinct render paths:
sine-440/square-220- thestate.parameters(name/index → normalised value) path.square-220compares bit-exact ("sample": 0).square-state- the binarystate.file(setStateInformation) path, plus astate.parametersgainoverride on top, exercising the state-then-parameters precedence of §4. The blob is captured once and checked in alongside its reference WAV.gain-half- theinput.audioeffect path: a second dogfood plugin (tests/test_plugins/gain/, a deterministic gain effect) gains a checked-in full-height (±1.0) sine by 0.5 and is compared bit-exact (0.5 is exact in float). It also omitsrender_duration, so the render length is derived from the input file.playhead-120- theplayheadpath: a third dogfood plugin (tests/test_plugins/playhead_probe/) writes the host transport into its output (channel 0 =ppqPosition, channel 1 = tempo), so the recorded reference is a direct check that the fixed transport reached the plugin. If the playhead regressed, the probe would output silence and the compare would fail.
- Parse
pluginval test <config.json>into one or moreTestConfig. - Resolve the plugin (path or ID); create the instance at the config's
sample_rate/block_size. - Apply
state.filethenstate.parameters. - Load
input.audioand/orinput.midi; otherwise use silence. - If a
playheadis configured, point a fixed-tempo transport at the plugin (its position advances each block). prepareToPlay, renderrender_durationworth of blocks, accumulating the output into a single buffer.- No reference exists -> without
--record-missing, fail before rendering; with it, write the float WAV + manifest (record mode) and report "reference created". - Reference exists -> run each configured comparator; report each verdict
and the overall pass/fail; on failure write a diff WAV; exit
0/1.
Phase 1 (initial implementation)
pluginval test <config.json>subcommand wired intosettings_parser.- Single-config (object) execution; parser already accepts arrays.
- Plugin load + state (file and/or parameter map) + file-based audio/MIDI input (or silence).
- Fixed-duration render in-process.
- Record-or-compare with float-WAV + JSON-sidecar references.
samplecomparator only (default tolerance = one 16-bit LSB).- Text + JSON result reporting; diff WAV on failure.
- Fixed
playhead(tempo / time signature) for time-dependent plugins. - Dogfood test plugins (§8) plus CTest self-tests that run the full record/compare path against checked-in references.
Phase 2 and beyond
- Multiplexed execution of config arrays.
- Parameter
automationtimelines. - Additional comparators:
peakrms,spectrum,crosscorr,fingerprint. - Synthesised inputs (
input.generator: noise / sine, with seed). - Optional child-process isolation mirroring the validate handoff.
{ "name": "myReverb-default", "plugin": "/path/to/Plugin.vst3", // path or AU identifier string "input": { "audio": "in.wav", "midi": "in.mid" }, // either / both / omitted -> silence "reference": "refs/myReverb-default.wav", // optional; default derived from "name" "state": { "file": "preset.state" }, // OR "parameters": { "Mix": 0.5, "3": 1.0 } "sample_rate": 48000, "block_size": 512, "render_duration": 2.0, // seconds; omitted -> use input length "comparison": { "sample": 1e-6 }, // omitted -> default 1/32768 (16-bit LSB) "playhead": { "bpm": 120, "time_signature": { "numerator": 4, "denominator": 4 } }, "automation": [ /* phase 2 */ ] }