Add generic headless plugin test host #1

Merged
jens merged 4 commits from testhost into master 2026-07-27 20:44:49 +02:00
24 changed files with 2092 additions and 0 deletions
@@ -0,0 +1,66 @@
/*
AppConfig.h for the testhost tool.
Unlike JaySynth's own JuceLibraryCode/AppConfig.h, this one carries no
JucePlugin_* macros at all - those only matter to the plugin-client side
(juce_audio_plugin_client), which this executable never compiles or links.
*/
#ifndef __JUCE_APPCONFIG_TESTHOST__
#define __JUCE_APPCONFIG_TESTHOST__
//==============================================================================
#define JUCE_MODULE_AVAILABLE_juce_audio_basics 1
#define JUCE_MODULE_AVAILABLE_juce_audio_devices 1
#define JUCE_MODULE_AVAILABLE_juce_audio_formats 1
#define JUCE_MODULE_AVAILABLE_juce_audio_processors 1
#define JUCE_MODULE_AVAILABLE_juce_core 1
#define JUCE_MODULE_AVAILABLE_juce_data_structures 1
#define JUCE_MODULE_AVAILABLE_juce_events 1
#define JUCE_MODULE_AVAILABLE_juce_graphics 1
#define JUCE_MODULE_AVAILABLE_juce_gui_basics 1
#define JUCE_MODULE_AVAILABLE_juce_gui_extra 1
//==============================================================================
// juce_audio_devices flags: none of the real device backends are needed -
// the host never opens a soundcard, it only calls processBlock() directly.
#ifndef JUCE_ALSA
//#define JUCE_ALSA
#endif
#ifndef JUCE_JACK
//#define JUCE_JACK
#endif
//==============================================================================
// juce_audio_processors flags: this is the whole point of this tool - enable
// plugin hosting. VST2 today; VST3/LADSPA/(future LV2) can be added here the
// same way without touching any host source code.
#ifndef JUCE_PLUGINHOST_VST
#define JUCE_PLUGINHOST_VST 1
#endif
#ifndef JUCE_PLUGINHOST_VST3
//#define JUCE_PLUGINHOST_VST3
#endif
#ifndef JUCE_PLUGINHOST_AU
//#define JUCE_PLUGINHOST_AU
#endif
//==============================================================================
// juce_gui_basics flags: no window is ever shown, but the module still needs
// to compile/link (juce_audio_processors.h unconditionally includes it).
#ifndef JUCE_USE_XSHM
//#define JUCE_USE_XSHM
#endif
#ifndef JUCE_USE_XRENDER
//#define JUCE_USE_XRENDER
#endif
#ifndef JUCE_USE_XCURSOR
//#define JUCE_USE_XCURSOR
#endif
#endif // __JUCE_APPCONFIG_TESTHOST__
@@ -0,0 +1,31 @@
/*
JuceHeader.h for the testhost tool.
Deliberately omits juce_audio_plugin_client/juce_audio_plugin_client.h -
this executable hosts plugins, it isn't one - and the ProjectInfo block
that JaySynth's own JuceHeader.h derives from JucePlugin_* macros, since
none of those exist here.
*/
#ifndef __TESTHOST_JUCEHEADER__
#define __TESTHOST_JUCEHEADER__
#include "AppConfig.h"
#include "modules/juce_core/juce_core.h"
#include "modules/juce_events/juce_events.h"
#include "modules/juce_data_structures/juce_data_structures.h"
#include "modules/juce_graphics/juce_graphics.h"
#include "modules/juce_gui_basics/juce_gui_basics.h"
#include "modules/juce_gui_extra/juce_gui_extra.h"
#include "modules/juce_audio_basics/juce_audio_basics.h"
#include "modules/juce_audio_formats/juce_audio_formats.h"
#include "modules/juce_audio_devices/juce_audio_devices.h"
#include "modules/juce_audio_processors/juce_audio_processors.h"
#if ! DONT_SET_USING_JUCE_NAMESPACE
using namespace juce;
#endif
#include <stdint.h>
#endif // __TESTHOST_JUCEHEADER__
+11
View File
@@ -0,0 +1,11 @@
include $(MAKE_HOME)/defaults.mk
JUCE_PATH ?= $(realpath ../../../sdk/juce/JUCE-3.1.1)
VST2_SDK_PATH ?= $(realpath ../../../sdk/vst/vstsdk2.4)
include config.mk
CXX_SRCS := $(addprefix $(JUCE_MODULES)/, $(SOURCES))
INCLUDES += -I . -I $(JUCE_PATH) -I $(JUCE_PATH)/modules -I $(VST2_SDK_PATH) -I /usr/include/freetype2
include $(MAKE_HOME)/compile.mk
+19
View File
@@ -0,0 +1,19 @@
NAME := JuceLibraryCode
JUCE_MODULES := ${JUCE_PATH}/modules
# Deliberately does NOT include anything under juce_audio_plugin_client/ -
# those are the plugin-side VST/VST3 entry points (define main/VSTPluginMain,
# expect JucePlugin_* macros) and would collide with this executable's own
# main(). This package only compiles the module amalgams the host actually
# needs to load and drive a plugin.
SOURCES := juce_audio_basics/juce_audio_basics.cpp
SOURCES += juce_audio_devices/juce_audio_devices.cpp
SOURCES += juce_audio_formats/juce_audio_formats.cpp
SOURCES += juce_audio_processors/juce_audio_processors.cpp
SOURCES += juce_core/juce_core.cpp
SOURCES += juce_data_structures/juce_data_structures.cpp
SOURCES += juce_events/juce_events.cpp
SOURCES += juce_graphics/juce_graphics.cpp
SOURCES += juce_gui_basics/juce_gui_basics.cpp
SOURCES += juce_gui_extra/juce_gui_extra.cpp
+42
View File
@@ -0,0 +1,42 @@
include ../../make/defaults.mk
CONFIG ?= release
TARGET ?= testhost
JUCE_PATH := $(realpath ../../sdk/juce/JUCE-3.1.1)
VST2_SDK_PATH := $(realpath ../../sdk/vst/vstsdk2.4)
JUCE_LIBCODE_PATH := $(realpath ./JuceLibraryCode)
SRC_PATH := $(realpath ./src)
DEFINES := -D__cdecl=""
DEFINES += -DJUCE_GCC=1 -DLINUX=1
DEFINES += -DHAVE_LROUND
DEFINES += -DJUCE_PLUGINHOST_VST=1
DEFINES_debug += -DDEBUG=1 -D_DEBUG=1
DEFINES_release += -DNDEBUG
CXXFLAGS += -std=c++11
LIBS := -lstdc++ -lm -lGL -lX11 -lXext -lXinerama -lasound -ldl -lfreetype -lpthread -lrt
LDFLAGS += $(TARGET_ARCH) $(LIBDIR) -L/usr/X11R6/lib/
INCLUDES += -I $(JUCE_PATH) -I $(JUCE_PATH)/modules -I $(VST2_SDK_PATH) -I $(JUCE_LIBCODE_PATH) -I $(SRC_PATH)
PACKAGES := JuceLibraryCode host
export
all: app
app: objects link
.PHONY: ${PACKAGES}
objects: ${PACKAGES}
host:
@$(MAKE) -C $(SRC_PATH)
JuceLibraryCode:
@$(MAKE) -C $@
include $(MAKE_HOME)/link.mk
+154
View File
@@ -0,0 +1,154 @@
# 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).
## Regression suite
`tests/run_tests.sh` uses this tool to dynamically test the findings
tracked in the repo-root `TODO.md` - see `tests/README.md` for what each
test covers (and its limits). It's also how a real, previously-unknown VCF
buffer-sizing bug got found and fixed (see that file and the repo-root
`TODO.md`'s Critical section).
## Known limitations
See `TODO.md` in this directory.
+93
View File
@@ -0,0 +1,93 @@
# testhost TODO
Tracks known gaps and future work for `tools/testhost/`, the generic headless
plugin test host (see `README.md` for what it does and how to use it, and
`/home/jens/.claude/plans/sharded-finding-tower.md` for the original design
plan). Nothing here blocks current use — Phases 1-4 of the plan are done and
verified (PR https://git.jayfield.org/jens/JaySynth/pulls/1).
## Deferred by design (Phase 5 of the plan)
- [ ] **LV2 plugin format support.** JUCE 3.1.1 (the version currently
vendored at `sdk/juce/JUCE-3.1.1`) has no LV2 hosting backend. Once
JaySynth's own JUCE dependency is upgraded (or a custom `AudioPluginFormat`
subclass is written against the current version), register it in
`PluginHost`'s constructor alongside `VSTPluginFormat` - no other host code
should need to change, since everything downstream talks to the
format-agnostic `AudioPluginInstance`/`AudioProcessor` API already.
- [ ] **VST3/LADSPA format support.** JUCE already ships
`VST3PluginFormat`/`LADSPAPluginFormat` in this same vendored version -
registering them is a one-line addition to `PluginHost`'s constructor,
just not done yet since nothing has needed it.
- [ ] **Windows build (`Makefile.win`).** The plan calls for mirroring
`src/plug`'s Linux/Windows Makefile pattern; only the Linux side has been
built and tested so far.
## Test coverage gaps
- [ ] **Only mono (1 in/1 out) and asymmetric (2 in/1 out) effect channel
configs have been smoke-tested** (`ZamDelay-vst.so`, `ZamComp-vst.so`,
`ZamEQ2-vst.so`). A genuinely stereo-in/stereo-out effect hasn't been run
through yet - worth doing before relying on this tool for a stereo effect
plugin specifically.
- [ ] **No automated regression baseline comparison.** The plan's stretch
goal: batch-render every `.fxp`/`.xmp` under `extras/sounds/` through a
fixed note pattern and diff against stored baseline WAVs/checksums, to
catch DSP or save-load regressions automatically instead of via one-off
manual runs. Not built.
- [ ] **No CI wiring.** Nothing currently runs `testhost` automatically on
push/PR; it's a manual, opt-in tool for now.
- [ ] **No unit tests for `TestScenario`'s JSON parsing itself** (malformed
JSON, missing required fields, wrong types, out-of-range values) - only
exercised indirectly via a couple of hand-written scenario files during
development. Edge-case handling (e.g. a negative `sample`, an unterminated
`noteOn` with no matching `noteOff`) is untested.
- [ ] **Polyphony/multiple simultaneous notes untested** - only ever a single
held note in all smoke tests so far.
## Investigate
- [ ] **JaySynth's compiled VST2 wrapper never advertises chunk support to
the host, so real `.fxp`/`.fxb` sample files silently have no effect when
loaded generically.** Found while building `tests/run_tests.sh`: loading
a bundled sample under `extras/sounds/` via `--patch` produces byte-
identical output to not loading anything (confirmed by diffing
`--printParams`). The files are legitimately in VST2's opaque-chunk
("FPCh") format, but JUCE 3.1.1's plugin-side VST wrapper
(`juce_VST_Wrapper.cpp`, compiled into `JaySynth.so`) never sets
`effFlagsProgramChunks` on the `AEffect` struct, so JUCE's own host-side
`VSTPluginInstance::usesChunks()` returns false and silently no-ops both
load and save. Not one of the tracked `TODO.md` (repo root) findings, and
not attempted here - fixing it means patching JUCE's own VST wrapper,
a bigger and riskier change than this pass's scope. See
`tests/README.md` for the full writeup; `tests/run_tests.sh`'s round-trip
tests work around it by using `testhost`'s own save output as the
fixture instead of an external sample file.
- [ ] **DPF-based plugins print a non-fatal assertion on load.** All three
Zam*-vst.so plugins tested (built with the DISTRHO Plugin Framework) print
`assertion failure: "fIsActive" in .../DistrhoPluginInternal.hpp` during
`PluginHost::load()`, despite JUCE's `VSTPluginInstance::prepareToPlay`
already sending `effMainsChanged`/`effStartProcess`. Rendering still
completes correctly (confirmed via WAV output), so this looks like a
DPF/JUCE VST2 lifecycle-ordering quirk rather than a testhost bug, but it
hasn't been root-caused - worth a closer look if it turns out to affect
correctness (not just log noise) for some other plugin.
- [ ] **`WavAudioFormat`'s base-class doc comment says channel count "must
be either 1 or 2"** (`AudioFormat::createWriterFor`'s Doxygen comment).
`WavRecorder` passes through whatever `PluginHost::getNumOutputChannels()`
reports with no clamping, and this has worked fine for the 1- and 2-channel
plugins tested so far - but a plugin with more than 2 output channels
hasn't been tried, so it's unconfirmed whether JUCE's WAV writer actually
enforces that limit or the doc comment is just generic/stale boilerplate
shared across format subclasses.
## Nice-to-have (not planned, just noted)
- [x] A `--list-params` or similar introspection mode (print every
parameter's index/name/current value) would help writing new scenario
JSON files without cross-referencing the plugin's own GUI or source.
Done: `--printParams` (prints `PARAM <index> <value>` for every
parameter, after any initial `--patch`/`--bank` load) - added to make
the patch/bank round-trip regression tests in `tests/` possible; only
prints values, not names, so cross-referencing the plugin's own
parameter-name list is still occasionally needed.
+123
View File
@@ -0,0 +1,123 @@
#include "AudioInputSource.h"
#include <cmath>
AudioInputSource::AudioInputSource()
: type (silence), frequencyHz (440.0), amplitude (0.5), sampleRate (44100.0),
phase (0.0), samplePosition (0)
{
zeromem (pinkState, sizeof (pinkState));
}
void AudioInputSource::setSilence()
{
type = silence;
}
void AudioInputSource::setSine (double frequencyHzToUse, float amplitudeToUse)
{
type = sine;
frequencyHz = frequencyHzToUse;
amplitude = amplitudeToUse;
phase = 0.0;
}
void AudioInputSource::setWhiteNoise (float amplitudeToUse)
{
type = whiteNoise;
amplitude = amplitudeToUse;
}
void AudioInputSource::setPinkNoise (float amplitudeToUse)
{
type = pinkNoise;
amplitude = amplitudeToUse;
zeromem (pinkState, sizeof (pinkState));
}
void AudioInputSource::setImpulse (float amplitudeToUse)
{
type = impulse;
amplitude = amplitudeToUse;
samplePosition = 0;
}
void AudioInputSource::prepare (double sampleRateToUse)
{
sampleRate = sampleRateToUse;
phase = 0.0;
samplePosition = 0;
}
// Paul Kellet's "economy" pink noise filter - good enough for exercising a
// plugin's frequency-dependent behaviour, not intended to be metrologically
// accurate.
float AudioInputSource::nextPinkSample (float white)
{
pinkState[0] = 0.99886f * pinkState[0] + white * 0.0555179f;
pinkState[1] = 0.99332f * pinkState[1] + white * 0.0750759f;
pinkState[2] = 0.96900f * pinkState[2] + white * 0.1538520f;
pinkState[3] = 0.86650f * pinkState[3] + white * 0.3104856f;
pinkState[4] = 0.55000f * pinkState[4] + white * 0.5329522f;
pinkState[5] = -0.7616f * pinkState[5] - white * 0.0168980f;
float pink = pinkState[0] + pinkState[1] + pinkState[2] + pinkState[3]
+ pinkState[4] + pinkState[5] + pinkState[6] + white * 0.5362f;
pinkState[6] = white * 0.115926f;
return pink * 0.11f; // roughly normalises the summed gain back towards +-1
}
void AudioInputSource::fillNextBlock (AudioSampleBuffer &buffer, int numChannels, int numSamples)
{
switch (type)
{
case silence:
for (int ch = 0; ch < numChannels; ++ch)
buffer.clear (ch, 0, numSamples);
break;
case sine:
{
const double twoPi = 2.0 * double_Pi;
const double phaseIncrement = twoPi * frequencyHz / sampleRate;
double p = phase;
for (int i = 0; i < numSamples; ++i)
{
const float sample = amplitude * (float) std::sin (p);
for (int ch = 0; ch < numChannels; ++ch)
buffer.setSample (ch, i, sample);
p += phaseIncrement;
}
phase = std::fmod (p, twoPi);
break;
}
case whiteNoise:
for (int i = 0; i < numSamples; ++i)
{
const float sample = amplitude * (2.0f * random.nextFloat() - 1.0f);
for (int ch = 0; ch < numChannels; ++ch)
buffer.setSample (ch, i, sample);
}
break;
case pinkNoise:
for (int i = 0; i < numSamples; ++i)
{
const float white = 2.0f * random.nextFloat() - 1.0f;
const float sample = amplitude * nextPinkSample (white);
for (int ch = 0; ch < numChannels; ++ch)
buffer.setSample (ch, i, sample);
}
break;
case impulse:
for (int i = 0; i < numSamples; ++i)
{
const float sample = (samplePosition == 0) ? amplitude : 0.0f;
for (int ch = 0; ch < numChannels; ++ch)
buffer.setSample (ch, i, sample);
++samplePosition;
}
break;
}
}
+58
View File
@@ -0,0 +1,58 @@
#ifndef TESTHOST_AUDIOINPUTSOURCE_H_INCLUDED
#define TESTHOST_AUDIOINPUTSOURCE_H_INCLUDED
#include <JuceHeader.h>
/** Fills a plugin's input channels before each processBlock() call.
silence (the default) is the correct choice for MIDI-driven instruments
like JaySynth, which never read their input at all. sine/whiteNoise/
pinkNoise/impulse exist so the same host can also test audio-effect
plugins (reverbs, EQs, compressors) that need a real input signal to do
anything meaningful.
*/
class AudioInputSource
{
public:
enum Type
{
silence = 0,
sine,
whiteNoise,
pinkNoise,
impulse
};
AudioInputSource();
void setSilence();
void setSine (double frequencyHz, float amplitude);
void setWhiteNoise (float amplitude);
void setPinkNoise (float amplitude);
/** A single amplitude-1 sample at t=0 followed by silence - the standard
stimulus for capturing an effect's impulse response. */
void setImpulse (float amplitude);
void prepare (double sampleRateToUse);
/** Fills channels [0, numChannels) of buffer with the next numSamples of
signal, advancing internal phase/position state across calls. Leaves
buffer's content beyond numChannels untouched. */
void fillNextBlock (AudioSampleBuffer &buffer, int numChannels, int numSamples);
private:
Type type;
double frequencyHz;
float amplitude;
double sampleRate;
double phase;
Random random;
int64 samplePosition;
float pinkState[7];
float nextPinkSample (float whiteSample);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioInputSource)
};
#endif // TESTHOST_AUDIOINPUTSOURCE_H_INCLUDED
+2
View File
@@ -0,0 +1,2 @@
include ./config.mk
include $(MAKE_HOME)/compile.mk
+79
View File
@@ -0,0 +1,79 @@
#ifndef TESTHOST_PERFORMANCESTATS_H_INCLUDED
#define TESTHOST_PERFORMANCESTATS_H_INCLUDED
#include <JuceHeader.h>
#include <cstdio>
/** Timing instrumentation around processBlock() calls.
This never paces the render loop to real-time - the entire point of a
headless host is to run faster than real-time - it only measures how
fast processBlock() actually ran, so regressions in render cost show up
without needing a GUI or a live audio device.
*/
class PerformanceStats
{
public:
PerformanceStats() {}
void reset (double sampleRateToUse)
{
sampleRate = sampleRateToUse;
blockTimesMs.clearQuick();
totalSamples = 0;
}
/** Wrap the call you want timed: stats.timeBlock(numSamples, [&]{ host.process(buffer, midi); }); */
template <typename Callable>
void timeBlock (int numSamples, Callable &&call)
{
const double start = Time::getMillisecondCounterHiRes();
call();
const double elapsedMs = Time::getMillisecondCounterHiRes() - start;
blockTimesMs.add (elapsedMs);
totalSamples += numSamples;
}
void printSummary() const
{
if (blockTimesMs.size() == 0)
return;
double sumMs = 0.0, minMs = blockTimesMs.getUnchecked (0), maxMs = blockTimesMs.getUnchecked (0);
for (int i = 0; i < blockTimesMs.size(); ++i)
{
const double t = blockTimesMs.getUnchecked (i);
sumMs += t;
minMs = jmin (minMs, t);
maxMs = jmax (maxMs, t);
}
const double meanMs = sumMs / blockTimesMs.size();
const double audioSeconds = totalSamples / sampleRate;
const double wallSeconds = sumMs / 1000.0;
const double realTimeFactor = wallSeconds > 0.0 ? audioSeconds / wallSeconds : 0.0;
printf ("testhost: performance - %d blocks, render time min/mean/max = %.3f/%.3f/%.3f ms, "
"%.3fs audio rendered in %.3fs wall time (%.1fx real-time)\n",
blockTimesMs.size(), minMs, meanMs, maxMs, audioSeconds, wallSeconds, realTimeFactor);
}
/** Optional: one row per block, for tracking regressions over time in CI. */
bool writeCsv (const File &destFile) const
{
StringArray lines;
lines.add ("blockIndex,renderTimeMs");
for (int i = 0; i < blockTimesMs.size(); ++i)
lines.add (String (i) + "," + String (blockTimesMs.getUnchecked (i), 6));
return destFile.replaceWithText (lines.joinIntoString ("\n") + "\n");
}
private:
double sampleRate = 44100.0;
Array<double> blockTimesMs;
int64 totalSamples = 0;
};
#endif // TESTHOST_PERFORMANCESTATS_H_INCLUDED
+107
View File
@@ -0,0 +1,107 @@
#include "PluginHost.h"
#include <cstdio>
PluginHost::PluginHost()
{
// VST2 today. Adding VST3PluginFormat/LADSPAPluginFormat (or, later, an
// LV2 format) is just another addFormat() call here - load()/process()/
// the parameter and state APIs below never need to change.
formatManager.addFormat (new VSTPluginFormat());
}
PluginHost::~PluginHost()
{
releaseResources();
}
bool PluginHost::load (const String &pluginPath, double sampleRate, int blockSize)
{
for (int i = 0; i < formatManager.getNumFormats(); ++i)
{
AudioPluginFormat *format = formatManager.getFormat (i);
OwnedArray<PluginDescription> found;
format->findAllTypesForFile (found, pluginPath);
if (found.size() == 0)
continue;
String error;
plugin = formatManager.createPluginInstance (*found[0], sampleRate, blockSize, error);
if (plugin == nullptr)
{
fprintf (stderr, "testhost: %s recognised '%s' but failed to load it: %s\n",
format->getName().toRawUTF8(), pluginPath.toRawUTF8(), error.toRawUTF8());
return false;
}
plugin->prepareToPlay (sampleRate, blockSize);
return true;
}
fprintf (stderr, "testhost: no registered plugin format recognised '%s'\n", pluginPath.toRawUTF8());
return false;
}
void PluginHost::releaseResources()
{
if (plugin != nullptr)
plugin->releaseResources();
}
int PluginHost::getNumInputChannels() const
{
return plugin != nullptr ? plugin->getNumInputChannels() : 0;
}
int PluginHost::getNumOutputChannels() const
{
return plugin != nullptr ? plugin->getNumOutputChannels() : 0;
}
void PluginHost::process (AudioSampleBuffer &buffer, MidiBuffer &midiMessages)
{
jassert (plugin != nullptr);
plugin->processBlock (buffer, midiMessages);
}
int PluginHost::getNumParameters() const
{
return plugin != nullptr ? plugin->getNumParameters() : 0;
}
float PluginHost::getParameter (int index) const
{
return plugin != nullptr ? plugin->getParameter (index) : 0.0f;
}
void PluginHost::setParameter (int index, float value)
{
if (plugin != nullptr)
plugin->setParameter (index, value);
}
void PluginHost::getCurrentProgramStateInformation (MemoryBlock &destData)
{
if (plugin != nullptr)
plugin->getCurrentProgramStateInformation (destData);
}
void PluginHost::setCurrentProgramStateInformation (const void *data, int sizeInBytes)
{
if (plugin != nullptr)
plugin->setCurrentProgramStateInformation (data, sizeInBytes);
}
void PluginHost::getStateInformation (MemoryBlock &destData)
{
if (plugin != nullptr)
plugin->getStateInformation (destData);
}
void PluginHost::setStateInformation (const void *data, int sizeInBytes)
{
if (plugin != nullptr)
plugin->setStateInformation (data, sizeInBytes);
}
+62
View File
@@ -0,0 +1,62 @@
#ifndef TESTHOST_PLUGINHOST_H_INCLUDED
#define TESTHOST_PLUGINHOST_H_INCLUDED
#include <JuceHeader.h>
/** Loads and drives a single plugin instance, offline (no audio device).
Deliberately format-agnostic: it talks only to AudioPluginFormatManager /
AudioPluginInstance / AudioProcessor, never to a specific format's SDK
structs. Adding another format later (VST3, LADSPA, eventually LV2) is
just another addFormat() call in the constructor - nothing here changes.
Also deliberately makes no assumption about the plugin's channel layout:
an instrument (0 inputs) and an effect (N inputs) are handled identically
by always asking the loaded AudioProcessor for its actual channel counts.
*/
class PluginHost
{
public:
PluginHost();
~PluginHost();
/** Scans pluginPath against every registered format and instantiates the
first match. Returns false (with a message printed to stderr) if no
registered format recognises the file, or if instantiation fails.
*/
bool load (const String &pluginPath, double sampleRate, int blockSize);
void releaseResources();
int getNumInputChannels() const;
int getNumOutputChannels() const;
/** Runs one block through the plugin. buffer must already be sized to
at least max(getNumInputChannels(), getNumOutputChannels()) channels
by numSamples samples; input channels should already be filled
(AudioInputSource's job), output channels are overwritten in place.
*/
void process (AudioSampleBuffer &buffer, MidiBuffer &midiMessages);
int getNumParameters() const;
float getParameter (int index) const;
void setParameter (int index, float value);
// Patch (single program) state.
void getCurrentProgramStateInformation (MemoryBlock &destData);
void setCurrentProgramStateInformation (const void *data, int sizeInBytes);
// Bank (full plugin) state.
void getStateInformation (MemoryBlock &destData);
void setStateInformation (const void *data, int sizeInBytes);
AudioPluginInstance *getInstance() const { return plugin; }
private:
AudioPluginFormatManager formatManager;
ScopedPointer<AudioPluginInstance> plugin;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginHost)
};
#endif // TESTHOST_PLUGINHOST_H_INCLUDED
+178
View File
@@ -0,0 +1,178 @@
#include "TestScenario.h"
#include <cstdio>
namespace
{
struct EventSampleComparator
{
static int compareElements (const TestScenario::Event &a, const TestScenario::Event &b)
{
if (a.sample < b.sample) return -1;
if (a.sample > b.sample) return 1;
return 0;
}
};
// var itself has no hasProperty() in this JUCE version - only the
// DynamicObject it may wrap does.
bool hasProperty (const var &v, const Identifier &name)
{
DynamicObject *obj = v.getDynamicObject();
return obj != nullptr && obj->hasProperty (name);
}
}
bool TestScenario::loadFromFile (const File &jsonFile)
{
var root = JSON::parse (jsonFile);
if (root.isVoid())
{
fprintf (stderr, "testhost: could not parse scenario JSON '%s'\n", jsonFile.getFullPathName().toRawUTF8());
return false;
}
return parseTopLevel (root, jsonFile.getParentDirectory());
}
bool TestScenario::parseTopLevel (const var &root, const File &baseDir)
{
sampleRate = (double) root.getProperty ("sampleRate", sampleRate);
blockSize = (int) root.getProperty ("blockSize", blockSize);
if (hasProperty (root, "durationSamples"))
durationSamples = (int64) (int) root.getProperty ("durationSamples", 0);
else if (hasProperty (root, "durationSeconds"))
durationSamples = (int64) ((double) root.getProperty ("durationSeconds", 0.0) * sampleRate);
else
{
fprintf (stderr, "testhost: scenario JSON needs either \"durationSamples\" or \"durationSeconds\"\n");
return false;
}
if (hasProperty (root, "input") && ! parseInput (root.getProperty ("input", var())))
return false;
if (hasProperty (root, "loadPatch"))
initialLoadPatchFile = baseDir.getChildFile (root.getProperty ("loadPatch", var()).toString()).getFullPathName();
if (hasProperty (root, "loadBank"))
initialLoadBankFile = baseDir.getChildFile (root.getProperty ("loadBank", var()).toString()).getFullPathName();
if (hasProperty (root, "recordWav"))
recordWavFile = baseDir.getChildFile (root.getProperty ("recordWav", var()).toString()).getFullPathName();
if (hasProperty (root, "events") && ! parseEvents (root.getProperty ("events", var()), baseDir))
return false;
EventSampleComparator comparator;
events.sort (comparator, true);
return true;
}
bool TestScenario::parseInput (const var &inputVar)
{
const String type = inputVar.getProperty ("type", "silence").toString();
inputAmplitude = (float) (double) inputVar.getProperty ("amplitude", 0.5);
if (type == "silence")
inputType = AudioInputSource::silence;
else if (type == "sine")
{
inputType = AudioInputSource::sine;
inputFrequencyHz = (double) inputVar.getProperty ("frequencyHz", 440.0);
}
else if (type == "noise")
{
const String kind = inputVar.getProperty ("kind", "white").toString();
inputType = (kind == "pink") ? AudioInputSource::pinkNoise : AudioInputSource::whiteNoise;
}
else if (type == "impulse")
inputType = AudioInputSource::impulse;
else
{
fprintf (stderr, "testhost: unknown scenario input type '%s'\n", type.toRawUTF8());
return false;
}
return true;
}
bool TestScenario::parseEventType (const String &typeName, Event::Type &outType)
{
if (typeName == "noteOn") { outType = Event::noteOn; return true; }
if (typeName == "noteOff") { outType = Event::noteOff; return true; }
if (typeName == "controller") { outType = Event::controller; return true; }
if (typeName == "param") { outType = Event::param; return true; }
if (typeName == "loadPatch") { outType = Event::loadPatch; return true; }
if (typeName == "loadBank") { outType = Event::loadBank; return true; }
if (typeName == "savePatch") { outType = Event::savePatch; return true; }
if (typeName == "saveBank") { outType = Event::saveBank; return true; }
return false;
}
bool TestScenario::parseEvents (const var &eventsVar, const File &baseDir)
{
if (! eventsVar.isArray())
{
fprintf (stderr, "testhost: scenario \"events\" must be an array\n");
return false;
}
for (int i = 0; i < eventsVar.size(); ++i)
{
const var &e = eventsVar[i];
const String typeName = e.getProperty ("type", var()).toString();
Event event;
if (! parseEventType (typeName, event.type))
{
fprintf (stderr, "testhost: unknown event type '%s' (event #%d)\n", typeName.toRawUTF8(), i);
return false;
}
event.sample = (int64) (int) e.getProperty ("sample", 0);
switch (event.type)
{
case Event::noteOn:
event.channel = (int) e.getProperty ("channel", 1);
event.note = (int) e.getProperty ("note", 60);
event.velocity = (int) e.getProperty ("velocity", 100);
break;
case Event::noteOff:
event.channel = (int) e.getProperty ("channel", 1);
event.note = (int) e.getProperty ("note", 60);
break;
case Event::controller:
event.channel = (int) e.getProperty ("channel", 1);
event.controllerNumber = (int) e.getProperty ("controllerNumber", 0);
event.controllerValue = (int) e.getProperty ("controllerValue", 0);
break;
case Event::param:
event.paramIndex = (int) e.getProperty ("index", 0);
event.paramValue = (float) (double) e.getProperty ("value", 0.0);
break;
case Event::loadPatch:
case Event::loadBank:
case Event::savePatch:
case Event::saveBank:
event.file = baseDir.getChildFile (e.getProperty ("file", var()).toString()).getFullPathName();
if (event.file.isEmpty())
{
fprintf (stderr, "testhost: event #%d (%s) is missing \"file\"\n", i, typeName.toRawUTF8());
return false;
}
break;
}
events.add (event);
}
return true;
}
+85
View File
@@ -0,0 +1,85 @@
#ifndef TESTHOST_TESTSCENARIO_H_INCLUDED
#define TESTHOST_TESTSCENARIO_H_INCLUDED
#include <JuceHeader.h>
#include "AudioInputSource.h"
/** A timed sequence of actions (MIDI events, parameter changes, patch/bank
load & save) plus render/I-O configuration, parsed from a JSON file.
This is what makes test runs repeatable and batchable instead of ad-hoc
CLI invocations - e.g. one file per regression case, run unattended.
Nothing in here is JaySynth-specific: events that don't apply to a given
plugin (MIDI events sent to a plugin with no MIDI input, say) are simply
harmless, since JUCE's MidiBuffer/AudioProcessor API already tolerates
that generically.
*/
class TestScenario
{
public:
TestScenario() {}
struct Event
{
enum Type
{
noteOn = 0,
noteOff,
controller,
param,
loadPatch,
loadBank,
savePatch,
saveBank
};
int64 sample = 0;
Type type = noteOn;
// MIDI note/controller fields
int channel = 1;
int note = 60;
int velocity = 100;
int controllerNumber = 0;
int controllerValue = 0;
// Parameter-change fields
int paramIndex = 0;
float paramValue = 0.0f;
// Patch/bank load & save fields
String file;
};
/** Parses jsonFile. Relative file paths named inside it (input.wavFile,
loadPatch/loadBank, and any event's "file") are resolved relative to
the JSON file's own directory, not the process's working directory,
so a scenario file is portable regardless of where it's run from.
*/
bool loadFromFile (const File &jsonFile);
double sampleRate = 44100.0;
int blockSize = 512;
int64 durationSamples = 0;
AudioInputSource::Type inputType = AudioInputSource::silence;
double inputFrequencyHz = 440.0;
float inputAmplitude = 0.5f;
String initialLoadPatchFile;
String initialLoadBankFile;
String recordWavFile;
/** Sorted ascending by .sample. */
Array<Event> events;
private:
bool parseTopLevel (const var &root, const File &baseDir);
bool parseInput (const var &inputVar);
bool parseEvents (const var &eventsVar, const File &baseDir);
static bool parseEventType (const String &typeName, Event::Type &outType);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TestScenario)
};
#endif // TESTHOST_TESTSCENARIO_H_INCLUDED
+55
View File
@@ -0,0 +1,55 @@
#include "WavRecorder.h"
#include <cstdio>
WavRecorder::WavRecorder()
{
}
WavRecorder::~WavRecorder()
{
stop();
}
bool WavRecorder::start (const File &destFile, double sampleRate, int numChannels)
{
if (numChannels <= 0)
{
fprintf (stderr, "testhost: cannot record - plugin reports %d output channels\n", numChannels);
return false;
}
destFile.deleteFile();
ScopedPointer<FileOutputStream> stream (destFile.createOutputStream());
if (stream == nullptr)
{
fprintf (stderr, "testhost: could not open '%s' for writing\n", destFile.getFullPathName().toRawUTF8());
return false;
}
WavAudioFormat wavFormat;
AudioFormatWriter *newWriter = wavFormat.createWriterFor (stream, sampleRate,
(unsigned int) numChannels, 24, StringPairArray(), 0);
if (newWriter == nullptr)
{
fprintf (stderr, "testhost: WavAudioFormat rejected the requested format (rate=%.0f, channels=%d)\n",
sampleRate, numChannels);
return false;
}
stream.release(); // now owned by newWriter
writer = newWriter;
return true;
}
void WavRecorder::write (const AudioSampleBuffer &buffer, int numSamples)
{
if (writer != nullptr)
writer->writeFromAudioSampleBuffer (buffer, 0, numSamples);
}
void WavRecorder::stop()
{
writer = nullptr; // AudioFormatWriter's destructor flushes and finalises the file
}
+34
View File
@@ -0,0 +1,34 @@
#ifndef TESTHOST_WAVRECORDER_H_INCLUDED
#define TESTHOST_WAVRECORDER_H_INCLUDED
#include <JuceHeader.h>
/** Thin wrapper over WavAudioFormat/AudioFormatWriter. Only active once
start() has been called with a destination file - a pure performance run
has no reason to pay for disk I/O.
*/
class WavRecorder
{
public:
WavRecorder();
~WavRecorder();
/** Channel count comes from whatever the plugin under test actually
reports (PluginHost::getNumOutputChannels()) - never assumed. */
bool start (const File &destFile, double sampleRate, int numChannels);
bool isRecording() const { return writer != nullptr; }
/** Writes numSamples samples from buffer's first numChannels channels. */
void write (const AudioSampleBuffer &buffer, int numSamples);
/** Flushes and finalises the file. Also happens automatically on destruction. */
void stop();
private:
ScopedPointer<AudioFormatWriter> writer;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WavRecorder)
};
#endif // TESTHOST_WAVRECORDER_H_INCLUDED
+7
View File
@@ -0,0 +1,7 @@
NAME := host
CXX_SRCS := main.cpp
CXX_SRCS += PluginHost.cpp
CXX_SRCS += AudioInputSource.cpp
CXX_SRCS += WavRecorder.cpp
CXX_SRCS += TestScenario.cpp
+413
View File
@@ -0,0 +1,413 @@
#include <JuceHeader.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include "PluginHost.h"
#include "AudioInputSource.h"
#include "WavRecorder.h"
#include "TestScenario.h"
#include "PerformanceStats.h"
namespace
{
void printUsage()
{
printf (
"testhost - headless plugin test host\n"
"\n"
"Usage:\n"
" testhost --plugin <path.so> --scenario <file.json> [--perfCsv <file>]\n"
" testhost --plugin <path.so> [ad-hoc options] [--perfCsv <file>]\n"
"\n"
"With --scenario, a JSON file drives everything (sample rate, block size,\n"
"duration, input signal, a timed sequence of MIDI/param/patch/bank events) -\n"
"see TestScenario.h for the format. All other ad-hoc options below are ignored\n"
"when --scenario is given.\n"
"\n"
"Ad-hoc options:\n"
" --patch <file> load a patch (single-program) state before rendering\n"
" --bank <file> load a bank (full plugin) state before rendering\n"
" --savePatch <file> save the patch state after rendering\n"
" --saveBank <file> save the bank state after rendering\n"
" --note <note> <vel> MIDI note-on (0-127, 0-127) at sample 0, held for the\n"
" whole render, note-off in the final block\n"
" --param <index> <value> set parameter <index> to <value> (0.0-1.0) before rendering\n"
" --input <mode> fill the plugin's input channels with: silence (default),\n"
" sine[:freqHz], white, pink, or impulse\n"
" --duration <seconds> how much audio to render (default 2.0)\n"
" --sampleRate <hz> default 44100\n"
" --blockSize <n> default 512\n"
" --wav <file> record the plugin's output to a WAV file\n"
" --printParams print every parameter's index and current value\n"
" (after any --patch/--bank/--param has been applied,\n"
" before rendering) as \"PARAM <index> <value>\" lines\n"
"\n"
"Patch/bank state is exactly what JUCE's AudioPluginInstance::get/setCurrentProgram-\n"
"StateInformation and get/setStateInformation read and write for a hosted VST2\n"
"plugin - i.e. real .fxp/.fxb file bytes.\n"
);
}
struct Args
{
String pluginPath;
String scenarioFile;
// Ad-hoc mode only (ignored if scenarioFile is set):
String patchFile, bankFile, savePatchFile, saveBankFile, wavFile;
double durationSeconds = 2.0;
double sampleRate = 44100.0;
int blockSize = 512;
bool hasNote = false;
int note = 60;
int velocity = 100;
bool hasParam = false;
int paramIndex = 0;
float paramValue = 0.0f;
AudioInputSource::Type inputType = AudioInputSource::silence;
double inputFrequencyHz = 440.0;
String perfCsvFile;
bool printParams = false;
};
bool parseArgs (int argc, char *argv[], Args &args)
{
for (int i = 1; i < argc; ++i)
{
const String arg (argv[i]);
if (arg == "--plugin" && i + 1 < argc)
args.pluginPath = argv[++i];
else if (arg == "--scenario" && i + 1 < argc)
args.scenarioFile = argv[++i];
else if (arg == "--patch" && i + 1 < argc)
args.patchFile = argv[++i];
else if (arg == "--bank" && i + 1 < argc)
args.bankFile = argv[++i];
else if (arg == "--savePatch" && i + 1 < argc)
args.savePatchFile = argv[++i];
else if (arg == "--saveBank" && i + 1 < argc)
args.saveBankFile = argv[++i];
else if (arg == "--wav" && i + 1 < argc)
args.wavFile = argv[++i];
else if (arg == "--duration" && i + 1 < argc)
args.durationSeconds = String (argv[++i]).getDoubleValue();
else if (arg == "--sampleRate" && i + 1 < argc)
args.sampleRate = String (argv[++i]).getDoubleValue();
else if (arg == "--blockSize" && i + 1 < argc)
args.blockSize = String (argv[++i]).getIntValue();
else if (arg == "--perfCsv" && i + 1 < argc)
args.perfCsvFile = argv[++i];
else if (arg == "--printParams")
args.printParams = true;
else if (arg == "--note" && i + 2 < argc)
{
args.hasNote = true;
args.note = String (argv[++i]).getIntValue();
args.velocity = String (argv[++i]).getIntValue();
}
else if (arg == "--param" && i + 2 < argc)
{
args.hasParam = true;
args.paramIndex = String (argv[++i]).getIntValue();
args.paramValue = String (argv[++i]).getFloatValue();
}
else if (arg == "--input" && i + 1 < argc)
{
const String mode (argv[++i]);
if (mode.startsWith ("sine"))
{
args.inputType = AudioInputSource::sine;
const int colon = mode.indexOfChar (':');
if (colon >= 0)
args.inputFrequencyHz = mode.substring (colon + 1).getDoubleValue();
}
else if (mode == "white")
args.inputType = AudioInputSource::whiteNoise;
else if (mode == "pink")
args.inputType = AudioInputSource::pinkNoise;
else if (mode == "impulse")
args.inputType = AudioInputSource::impulse;
else if (mode == "silence")
args.inputType = AudioInputSource::silence;
else
{
fprintf (stderr, "testhost: unknown --input mode '%s'\n", mode.toRawUTF8());
return false;
}
}
else if (arg == "--help" || arg == "-h")
return false;
else
{
fprintf (stderr, "testhost: unrecognised argument '%s'\n", arg.toRawUTF8());
return false;
}
}
if (args.pluginPath.isEmpty())
{
fprintf (stderr, "testhost: --plugin is required\n");
return false;
}
return true;
}
/** Builds the internal TestScenario representation from the simple ad-hoc
CLI flags, so the render loop below only has to be written once,
whether it's driven by a JSON file or by --note/--param/etc. */
void buildScenarioFromArgs (const Args &args, TestScenario &scenario)
{
scenario.sampleRate = args.sampleRate;
scenario.blockSize = args.blockSize;
scenario.durationSamples = (int64) (args.durationSeconds * args.sampleRate);
scenario.inputType = args.inputType;
scenario.inputFrequencyHz = args.inputFrequencyHz;
scenario.inputAmplitude = 0.5f;
scenario.initialLoadPatchFile = args.patchFile;
scenario.initialLoadBankFile = args.bankFile;
scenario.recordWavFile = args.wavFile;
if (args.hasNote)
{
TestScenario::Event on;
on.sample = 0;
on.type = TestScenario::Event::noteOn;
on.note = args.note;
on.velocity = args.velocity;
scenario.events.add (on);
TestScenario::Event off;
off.sample = jmax ((int64) 0, scenario.durationSamples - 1);
off.type = TestScenario::Event::noteOff;
off.note = args.note;
scenario.events.add (off);
}
if (args.hasParam)
{
TestScenario::Event p;
p.sample = 0;
p.type = TestScenario::Event::param;
p.paramIndex = args.paramIndex;
p.paramValue = args.paramValue;
scenario.events.add (p);
}
}
bool loadStateFile (const String &path, bool isBank, PluginHost &host)
{
MemoryBlock data;
if (! File (path).loadFileAsData (data) || data.getSize() == 0)
{
fprintf (stderr, "testhost: could not read '%s'\n", path.toRawUTF8());
return false;
}
if (isBank)
host.setStateInformation (data.getData(), (int) data.getSize());
else
host.setCurrentProgramStateInformation (data.getData(), (int) data.getSize());
return true;
}
void saveStateFile (const String &path, bool isBank, PluginHost &host)
{
MemoryBlock data;
if (isBank)
host.getStateInformation (data);
else
host.getCurrentProgramStateInformation (data);
if (! File (path).replaceWithData (data.getData(), data.getSize()))
fprintf (stderr, "testhost: could not write '%s'\n", path.toRawUTF8());
}
void applyEvent (PluginHost &host, const TestScenario::Event &event, MidiBuffer &midi, int sampleOffsetInBlock)
{
switch (event.type)
{
case TestScenario::Event::noteOn:
midi.addEvent (MidiMessage::noteOn (event.channel, event.note, (uint8) event.velocity), sampleOffsetInBlock);
break;
case TestScenario::Event::noteOff:
midi.addEvent (MidiMessage::noteOff (event.channel, event.note), sampleOffsetInBlock);
break;
case TestScenario::Event::controller:
midi.addEvent (MidiMessage::controllerEvent (event.channel, event.controllerNumber, event.controllerValue), sampleOffsetInBlock);
break;
case TestScenario::Event::param:
host.setParameter (event.paramIndex, event.paramValue);
break;
case TestScenario::Event::loadPatch:
loadStateFile (event.file, false, host);
break;
case TestScenario::Event::loadBank:
loadStateFile (event.file, true, host);
break;
case TestScenario::Event::savePatch:
saveStateFile (event.file, false, host);
break;
case TestScenario::Event::saveBank:
saveStateFile (event.file, true, host);
break;
}
}
}
int main (int argc, char *argv[])
{
Args args;
if (! parseArgs (argc, argv, args))
{
printUsage();
return 1;
}
// Even a headless console app must bring up JUCE's message/GUI plumbing
// before touching any AudioProcessor/AudioPluginFormat class - no window
// is ever created, but juce_audio_processors.h unconditionally depends
// on juce_gui_basics.
ScopedJuceInitialiser_GUI juceInit;
TestScenario scenario;
if (args.scenarioFile.isNotEmpty())
{
if (! scenario.loadFromFile (File (args.scenarioFile)))
return 1;
}
else
{
buildScenarioFromArgs (args, scenario);
}
PluginHost host;
if (! host.load (args.pluginPath, scenario.sampleRate, scenario.blockSize))
return 1;
const int numInputChannels = host.getNumInputChannels();
const int numOutputChannels = host.getNumOutputChannels();
printf ("testhost: loaded '%s' (%d in / %d out, %d parameters)\n",
args.pluginPath.toRawUTF8(), numInputChannels, numOutputChannels, host.getNumParameters());
if (scenario.initialLoadBankFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadBankFile, true, host))
return 1;
if (scenario.initialLoadPatchFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadPatchFile, false, host))
return 1;
if (args.printParams)
{
for (int i = 0; i < host.getNumParameters(); ++i)
printf ("PARAM %d %.6f\n", i, host.getParameter (i));
}
AudioInputSource inputSource;
inputSource.prepare (scenario.sampleRate);
switch (scenario.inputType)
{
case AudioInputSource::sine: inputSource.setSine (scenario.inputFrequencyHz, scenario.inputAmplitude); break;
case AudioInputSource::whiteNoise: inputSource.setWhiteNoise (scenario.inputAmplitude); break;
case AudioInputSource::pinkNoise: inputSource.setPinkNoise (scenario.inputAmplitude); break;
case AudioInputSource::impulse: inputSource.setImpulse (scenario.inputAmplitude); break;
case AudioInputSource::silence:
default: inputSource.setSilence(); break;
}
WavRecorder recorder;
if (scenario.recordWavFile.isNotEmpty())
{
if (! recorder.start (File (scenario.recordWavFile), scenario.sampleRate, numOutputChannels))
return 1;
}
const int numChannels = jmax (numInputChannels, numOutputChannels, 1);
AudioSampleBuffer buffer (numChannels, scenario.blockSize);
PerformanceStats perfStats;
perfStats.reset (scenario.sampleRate);
int nextEventIndex = 0;
int64 samplesDone = 0;
bool sawNaN = false;
while (samplesDone < scenario.durationSamples)
{
const int numThisBlock = (int) jmin ((int64) scenario.blockSize, scenario.durationSamples - samplesDone);
buffer.clear();
if (numInputChannels > 0)
inputSource.fillNextBlock (buffer, numInputChannels, numThisBlock);
MidiBuffer midi;
// Non-MIDI actions (param/patch/bank) scheduled inside this block take
// effect at the start of the block, not at their exact sample offset -
// good enough for a test tool; MIDI events keep full per-sample timing
// via MidiBuffer's own sample-offset parameter.
while (nextEventIndex < scenario.events.size()
&& scenario.events.getReference (nextEventIndex).sample < samplesDone + numThisBlock)
{
const TestScenario::Event &event = scenario.events.getReference (nextEventIndex);
const int offset = (int) jlimit ((int64) 0, (int64) (numThisBlock - 1), event.sample - samplesDone);
applyEvent (host, event, midi, offset);
++nextEventIndex;
}
perfStats.timeBlock (numThisBlock, [&] { host.process (buffer, midi); });
for (int ch = 0; ch < numOutputChannels && ! sawNaN; ++ch)
{
const float *data = buffer.getReadPointer (ch);
for (int i = 0; i < numThisBlock; ++i)
{
if (! std::isfinite (data[i]))
{
sawNaN = true;
break;
}
}
}
if (recorder.isRecording())
recorder.write (buffer, numThisBlock);
samplesDone += numThisBlock;
}
// Any trailing savePatch/saveBank/etc. events scheduled at or after the
// final rendered sample still get applied, in order.
while (nextEventIndex < scenario.events.size())
{
MidiBuffer unused;
applyEvent (host, scenario.events.getReference (nextEventIndex), unused, 0);
++nextEventIndex;
}
recorder.stop();
if (args.savePatchFile.isNotEmpty())
saveStateFile (args.savePatchFile, false, host);
if (args.saveBankFile.isNotEmpty())
saveStateFile (args.saveBankFile, true, host);
host.releaseResources();
if (sawNaN)
fprintf (stderr, "testhost: WARNING - output contained NaN/Inf samples\n");
printf ("testhost: rendered %.3fs (%lld samples)%s%s\n",
(double) samplesDone / scenario.sampleRate, (long long) samplesDone,
scenario.recordWavFile.isNotEmpty() ? " -> " : "",
scenario.recordWavFile.isNotEmpty() ? scenario.recordWavFile.toRawUTF8() : "");
perfStats.printSummary();
if (args.perfCsvFile.isNotEmpty())
perfStats.writeCsv (File (args.perfCsvFile));
return sawNaN ? 2 : 0;
}
+1
View File
@@ -0,0 +1 @@
scenarios/*.wav
+122
View File
@@ -0,0 +1,122 @@
# Regression suite for JaySynth's hardening findings
`run_tests.sh` uses `testhost` itself to dynamically exercise the findings
tracked in the repo-root `TODO.md` (the "hardening" review) — no GUI, no
manual clicking, runs in well under a second of wall-clock time.
## Running
```sh
MAKE_HOME=/path/to/submodule/make ./run_tests.sh [path/to/JaySynth.so]
```
Builds `testhost` first if it isn't already built. Defaults to
`build/linux/release/JaySynth.so` if no path is given. Exit code is 0 if
everything passed, 1 otherwise (CI-friendly). On failure, per-test logs and
WAVs are kept in a `mktemp -d` workdir printed at the end; on success
that workdir is deleted.
## What each test actually covers
**`oversized_blocksize`** (Critical #5) — renders through `--blockSize
16384` (double `SYNTH_MAX_BUFSIZE`). Before the fix this either overflowed
`processBlock`'s fixed 8192-sample stack buffer or deadlocked the render
worker threads; `timeout` is what actually catches the deadlock case, since
a hung process would otherwise just sit here forever. **This test is also
what caught a second, previously-unknown bug**: `VCF_CalcCoeff_LPF/_HPF/_BPF`
advance their coefficient-buffer pointer once per *(sample, filter-section)*
pair, but the buffer was only ever allocated for `bufsize` samples, not
`bufsize × sections` — a 4th-order filter (2 sections) overflowed it for
any block between 4097 and 8192 samples, a range this fix's own "chunk to
≤8192" logic doesn't protect against on its own. Found via AddressSanitizer
after this test kept crashing at blockSize values *below* 8192; fixed in
`src/synth/vcf.c` (see TODO.md's Critical section). If this test ever
regresses again, don't assume it's the same bug — bisect the block size
first (see the debugging notes at the bottom of this file for how that
investigation went).
**`nrpn_out_of_range`/`nrpn_in_range`** (Critical #1) — sends the standard
5-message MIDI NRPN CC sequence (99/98/6/38/98) via a JSON scenario, once
targeting the maximum possible 14-bit NRPN ID (16383) and once a legitimate
in-range ID (500). Both must complete cleanly; the in-range case exists so
a regression that made the bounds check *too* aggressive (rejecting valid
IDs) would also be visible as a behavioural difference. **Caveat:** "doesn't
crash" is a necessary but not fully sufficient check here. The original bug
was an out-of-bounds *write* into `last_midiCC_info[]`, a plain array
embedded inside the larger heap-allocated `JaySynth` object — not a
separate heap allocation — so an overflow of a few hundred entries lands in
*other member fields of the same object* rather than past its heap
allocation. That means it can silently corrupt adjacent state without
necessarily crashing in a short test run, and it's why the ID is pushed to
the actual maximum (16383) rather than something more modest: to maximize
the chance that, absent the fix, the write would land far enough away to
hit unrelated memory and actually crash. The real guarantee is the
bounds-check line itself (`src/plug/JaySynth.cpp`, `handleController`), not
this test's silence.
**`patch_save_load_roundtrip`** / **`bank_save_load_roundtrip`** — covers
two related fixes: (a) `setCurrentProgramStateInformation` used to pass
`patchImportXml` an XML node one level too deep, making the VST2 "copy
plugin state" path a complete no-op; (b) the `patchDecodeXml`/
`patchDecodeXml_legacy` deduplication and `patchImportXml`'s legacy-branch
restructuring. Sets a continuous parameter (index 0 = `SYNTH_PARAM_VOLUME`)
to a distinctive value, saves, reloads in a fresh process, and checks the
value reads back within a small tolerance (not exact equality — the
slider/internal-value scaling curve, `toParam`/`toSlider`, isn't bit-exact
across a round trip). Uses `testhost`'s own save→load cycle as the fixture,
not an externally-provided `.fxp`/`.fxb` — see the chunk-format note below
for why.
**`stability_sweep`** — loads every bundled `.fxp` under `extras/sounds/`
and renders a held note through each, checking for crashes/hangs/NaN. A
broad regression net, not a targeted test for any one finding.
## Two things discovered while building this suite that are worth knowing
**The VCF coefficient-buffer bug above** (already fixed, see TODO.md).
**JaySynth's compiled VST2 wrapper never advertises chunk support to the
host**, so loading the bundled sample `.fxp` files through `testhost`'s
generic `--patch` (i.e. through `AudioProcessor::setCurrentProgramState-
Information`, exactly what any real VST2 host uses) has **zero effect**
confirmed by diffing `--printParams` output with and without `--patch`.
Those sample files are legitimately in VST2's opaque-chunk ("FPCh") format
(verified via hex dump: correct `CcnK`/`FPCh`/`Jsy1` header), but JUCE
3.1.1's plugin-side VST wrapper (`sdk/juce/JUCE-3.1.1/modules/
juce_audio_plugin_client/VST/juce_VST_Wrapper.cpp`, compiled into
`JaySynth.so`) never sets the VST2 `effFlagsProgramChunks` flag on the
`AEffect` struct, so JUCE's own host-side `VSTPluginInstance::usesChunks()`
sees false and `setChunkData()` silently no-ops — for both loading *and*
saving. This is **not** one of the tracked `TODO.md` findings and this
suite doesn't attempt to fix it (it would mean patching JUCE's own VST
wrapper, a materially bigger and riskier change). It's why
`stability_sweep` only checks "doesn't crash," not "changes the patch," and
why the round-trip tests use `testhost`'s own save output as the fixture
rather than a real sample file — `testhost`'s save path has the same
`usesChunks()`-is-false behaviour, so save and load are at least
consistent with *each other*, even though neither talks to the external
`.fxp` files' actual chunk data. Tracked as a discovered issue in
`tools/testhost/TODO.md`'s "Investigate" section.
## Debugging notes: how the VCF bug was actually found
`oversized_blocksize` initially failed with `free(): invalid next size
(normal)` - glibc's heap-corruption detector, tripped during process
teardown, not at the point of the actual overflow. Bisecting `--blockSize`
found the real threshold was 4096/4097, not 8192 as expected. Rather than
guess further, an ASan build of just `src/synth` + `src/plug` (env-var
override: `CFLAGS/CXXFLAGS="-fsanitize=address -fno-omit-frame-pointer -g
-O1" LDFLAGS="-fsanitize=address"`, `CONFIG=asan` to keep it in its own
build directory) run via `LD_PRELOAD=$(clang -print-file-name=libclang_rt.
asan-x86_64.so)` pinpointed it exactly. Two gotchas worth remembering if
this needs doing again: (1) `ASAN_OPTIONS=symbolize=0` is necessary in this
environment — the default external `llvm-symbolizer` fork hangs
indefinitely rather than resolving addresses, so use `symbolize=0` and
resolve the reported `JaySynth.so+0x...` offsets with `addr2line -e
JaySynth.so <offset>` (or just `nm`/`objdump` if `addr2line` needs exact
offsets adjusted for the binary's load bias) instead of trusting ASan's own
backtrace; (2) rebuilding the ASan variant needs a real `git worktree`
avoided here — `sdk/juce/JUCE-3.1.1` is vendored as a zip+patch pair
extracted on demand, not a plain tracked directory, so a fresh worktree
doesn't have it. Simpler to `git stash` uncommitted work and build the
ASan variant directly on the branch with the fix under investigation.
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env bash
#
# Regression suite for the findings tracked in JaySynth's TODO.md, run via
# the testhost tool itself. See tests/README.md for what each test actually
# covers, and - just as importantly - what it doesn't.
#
# Usage:
# MAKE_HOME=/path/to/submodule/make ./run_tests.sh [path/to/JaySynth.so]
#
# Exit code is 0 if every test passed, 1 otherwise (so this is CI-friendly).
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TESTHOST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$TESTHOST_DIR/../.." && pwd)"
TESTHOST_BIN="$TESTHOST_DIR/build/linux/release/testhost"
PLUGIN="${1:-$REPO_ROOT/build/linux/release/JaySynth.so}"
WORKDIR="$(mktemp -d /tmp/testhost-suite.XXXXXX)"
PASS_COUNT=0
FAIL_COUNT=0
FAILED_NAMES=()
log() { printf '%s\n' "$*"; }
pass() { log " PASS: $1"; PASS_COUNT=$((PASS_COUNT + 1)); }
fail() { log " FAIL: $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); FAILED_NAMES+=("$1"); }
# --- Preconditions -----------------------------------------------------
if [ ! -x "$TESTHOST_BIN" ]; then
log "Building testhost..."
if ! MAKE_HOME="${MAKE_HOME:-$(realpath "$REPO_ROOT/submodule/make")}" make -C "$TESTHOST_DIR" > "$WORKDIR/build.log" 2>&1; then
log "FATAL: testhost build failed - see $WORKDIR/build.log"
exit 1
fi
fi
if [ ! -f "$PLUGIN" ]; then
log "FATAL: plugin not found at '$PLUGIN' (build JaySynth first, or pass its path as \$1)"
exit 1
fi
run_testhost() {
# run_testhost <timeout_seconds> <args...>
local timeout_s="$1"; shift
timeout "${timeout_s}s" "$TESTHOST_BIN" --plugin "$PLUGIN" "$@"
}
wav_is_valid_and_nonsilent() {
# Very small, dependency-light sanity check: file exists, non-trivial
# size (a 0/near-0 byte file means recording never really started).
[ -s "$1" ] && [ "$(stat -c%s "$1" 2>/dev/null || echo 0)" -gt 1000 ]
}
# --- Test 1: oversized block size (Critical #5) -------------------------
# Before the fix, a host block size larger than SYNTH_MAX_BUFSIZE (8192)
# either overflowed processBlock's fixed 8192-sample stack buffer or
# deadlocked the render worker threads (JaySynthThread::go() silently
# no-op'd, and renderNextBlock's wait(-1) blocked forever). The `timeout`
# wrapper is what actually catches the deadlock case - a hang would
# otherwise just sit here forever.
test_oversized_blocksize() {
local name="oversized_blocksize (Critical #5)"
local out="$WORKDIR/oversized.wav"
local log_file="$WORKDIR/oversized.log"
if run_testhost 20 --blockSize 16384 --note 60 100 --duration 1.5 --wav "$out" > "$log_file" 2>&1; then
if grep -qi "WARNING - output contained NaN" "$log_file"; then
fail "$name (rendered but produced NaN/Inf - see $log_file)"
elif ! wav_is_valid_and_nonsilent "$out"; then
fail "$name (no valid WAV produced - see $log_file)"
else
pass "$name"
fi
else
local rc=$?
if [ "$rc" -eq 124 ]; then
fail "$name (HUNG - killed by timeout, matches the pre-fix deadlock)"
else
fail "$name (crashed, exit code $rc - see $log_file)"
fi
fi
}
# --- Test 2/3: NRPN controller ID bounds (Critical #1) -------------------
# handleController used to write last_midiCC_info[midiCC_info.ID] with no
# bounds check; NRPN IDs assemble to up to 16383 (14-bit) but the array is
# sized NUM_MIDI_CONTROLLERS=1024. This can't be observed from the outside
# without a memory sanitizer (the write lands inside the same heap object,
# not necessarily past its allocation), so "doesn't crash" is a necessary
# but not fully sufficient check - the real guarantee is the bounds-check
# line itself (see tests/README.md). Still worth running both an
# out-of-range and an in-range NRPN sequence, so a regression that made the
# check *too* aggressive (rejecting valid IDs) would also show up as a
# behavioural difference between the two runs.
test_nrpn_bounds() {
local name_oor="nrpn_out_of_range, ID=16383 (Critical #1)"
local name_ir="nrpn_in_range, ID=500 (Critical #1 regression guard)"
local out_oor="$WORKDIR/nrpn_oor.wav"
local log_oor="$WORKDIR/nrpn_oor.log"
if run_testhost 15 --scenario "$SCRIPT_DIR/scenarios/nrpn_out_of_range.json" > "$log_oor" 2>&1; then
# recordWav in the scenario JSON resolves relative to the scenario
# file's own directory, not $WORKDIR - move it out after the run.
mv "$SCRIPT_DIR/scenarios/out_nrpn_out_of_range.wav" "$out_oor" 2>/dev/null
if grep -qi "WARNING - output contained NaN" "$log_oor"; then
fail "$name_oor (NaN/Inf in output - see $log_oor)"
elif ! wav_is_valid_and_nonsilent "$out_oor"; then
fail "$name_oor (no valid WAV - see $log_oor)"
else
pass "$name_oor"
fi
else
local rc=$?
fail "$name_oor (exit code $rc, expected clean completion - see $log_oor)"
fi
local out_ir="$WORKDIR/nrpn_ir.wav"
local log_ir="$WORKDIR/nrpn_ir.log"
if run_testhost 15 --scenario "$SCRIPT_DIR/scenarios/nrpn_in_range.json" > "$log_ir" 2>&1; then
mv "$SCRIPT_DIR/scenarios/out_nrpn_in_range.wav" "$out_ir" 2>/dev/null
if grep -qi "WARNING - output contained NaN" "$log_ir"; then
fail "$name_ir (NaN/Inf in output - see $log_ir)"
elif ! wav_is_valid_and_nonsilent "$out_ir"; then
fail "$name_ir (no valid WAV - see $log_ir)"
else
pass "$name_ir"
fi
else
local rc=$?
fail "$name_ir (exit code $rc - see $log_ir)"
fi
}
# --- Test 4: patch state save/load round-trip ---------------------------
# Covers two related fixes together: (a) setCurrentProgramStateInformation
# was passing patchImportXml an XML node one level too deep, making the
# VST2 "copy plugin state" path a complete no-op (found while fixing the
# Patch/Bank import-export findings); (b) patchDecodeXml/patchDecodeXml_legacy
# deduplication and the patchImportXml legacy-branch restructuring. All of
# these sit on the exact save->load call chain exercised here. Uses a
# continuous parameter (index 0 = SYNTH_PARAM_VOLUME) rather than a
# stepped/boolean one, and a tolerance rather than exact equality, because
# the value/slider scaling curve (toParam/toSlider) is not bit-exact across
# a round trip - see tests/README.md.
test_patch_roundtrip() {
local name="patch_save_load_roundtrip"
local patch_file="$WORKDIR/roundtrip_patch.bin"
local log1="$WORKDIR/roundtrip_save.log"
local log2="$WORKDIR/roundtrip_load.log"
local target_value="0.37"
local tolerance="0.02"
if ! run_testhost 10 --param 0 "$target_value" --savePatch "$patch_file" --duration 0.05 > "$log1" 2>&1; then
fail "$name (save step failed, exit $? - see $log1)"
return
fi
if [ ! -s "$patch_file" ]; then
fail "$name (no patch file written)"
return
fi
if ! run_testhost 10 --patch "$patch_file" --printParams --duration 0.01 > "$log2" 2>&1; then
fail "$name (load step failed, exit $? - see $log2)"
return
fi
local readback
readback="$(grep '^PARAM 0 ' "$log2" | awk '{print $3}')"
if [ -z "$readback" ]; then
fail "$name (PARAM 0 not found in output - see $log2)"
return
fi
local diff
diff="$(awk -v a="$readback" -v b="$target_value" 'BEGIN { d = a - b; if (d < 0) d = -d; print d }')"
local within_tolerance
within_tolerance="$(awk -v d="$diff" -v t="$tolerance" 'BEGIN { print (d <= t) ? "1" : "0" }')"
if [ "$within_tolerance" = "1" ]; then
pass "$name (wrote $target_value, read back $readback, within tolerance)"
else
fail "$name (wrote $target_value, read back $readback - outside +-$tolerance tolerance - see $log1 / $log2)"
fi
}
# --- Test 5: bank state save/load round-trip -----------------------------
# Same idea as test 4 but through getStateInformation/setStateInformation
# (the whole-bank path) and bankEncodeXml/bankDecodeXml/bankImportXml,
# rather than the single-patch path.
test_bank_roundtrip() {
local name="bank_save_load_roundtrip"
local bank_file="$WORKDIR/roundtrip_bank.bin"
local log1="$WORKDIR/roundtrip_bank_save.log"
local log2="$WORKDIR/roundtrip_bank_load.log"
local target_value="0.62"
local tolerance="0.02"
if ! run_testhost 10 --param 0 "$target_value" --saveBank "$bank_file" --duration 0.05 > "$log1" 2>&1; then
fail "$name (save step failed, exit $? - see $log1)"
return
fi
if [ ! -s "$bank_file" ]; then
fail "$name (no bank file written)"
return
fi
if ! run_testhost 10 --bank "$bank_file" --printParams --duration 0.01 > "$log2" 2>&1; then
fail "$name (load step failed, exit $? - see $log2)"
return
fi
local readback
readback="$(grep '^PARAM 0 ' "$log2" | awk '{print $3}')"
if [ -z "$readback" ]; then
fail "$name (PARAM 0 not found in output - see $log2)"
return
fi
local diff
diff="$(awk -v a="$readback" -v b="$target_value" 'BEGIN { d = a - b; if (d < 0) d = -d; print d }')"
local within_tolerance
within_tolerance="$(awk -v d="$diff" -v t="$tolerance" 'BEGIN { print (d <= t) ? "1" : "0" }')"
if [ "$within_tolerance" = "1" ]; then
pass "$name (wrote $target_value, read back $readback, within tolerance)"
else
fail "$name (wrote $target_value, read back $readback - outside +-$tolerance tolerance - see $log1 / $log2)"
fi
}
# --- Test 6: stability sweep over bundled sample patches ------------------
# Broad regression net: load every bundled .fxp under extras/sounds/ and
# render a held note through it. NOTE: as discovered while building this
# suite, these particular sample files are in VST2's opaque-chunk ("FPCh")
# format, but JUCE 3.1.1's plugin-side VST wrapper (compiled into
# JaySynth.so) never sets effFlagsProgramChunks, so JUCE's *host*-side
# VSTPluginInstance::usesChunks() sees false and silently ignores the
# chunk data entirely - confirmed by diffing --printParams output with and
# without --patch: zero parameters differ. This is a real, separate
# finding (see tests/README.md and testhost/TODO.md) - NOT one of the
# tracked TODO.md findings, and NOT something this suite attempts to fix.
# So this test only checks "loading this file doesn't crash the process",
# not "loading this file changes the patch" - it still exercises the
# malloc/delete[] paths touched by the Memory-management fixes as a broad
# smoke test, just not the patch-content-application path.
test_stability_sweep() {
local sounds_dir="$REPO_ROOT/extras/sounds"
local any=0
while IFS= read -r -d '' fxp; do
any=1
local base
base="$(basename "$fxp")"
local log_file="$WORKDIR/sweep_$(echo "$base" | tr -c 'A-Za-z0-9._-' '_').log"
if run_testhost 15 --patch "$fxp" --note 60 100 --duration 0.5 > "$log_file" 2>&1; then
if grep -qi "WARNING - output contained NaN" "$log_file"; then
fail "stability_sweep: '$base' (NaN/Inf - see $log_file)"
else
pass "stability_sweep: '$base'"
fi
else
local rc=$?
fail "stability_sweep: '$base' (exit code $rc, expected 0 - see $log_file)"
fi
done < <(find "$sounds_dir" -maxdepth 1 -iname '*.fxp' -print0 2>/dev/null)
if [ "$any" -eq 0 ]; then
log " (no .fxp files found under $sounds_dir - skipping stability sweep)"
fi
}
# --- Run everything -------------------------------------------------------
log "testhost regression suite"
log " testhost: $TESTHOST_BIN"
log " plugin: $PLUGIN"
log " workdir: $WORKDIR"
log ""
log "Critical finding #5 - oversized block size:"
test_oversized_blocksize
log ""
log "Critical finding #1 - NRPN controller ID bounds:"
test_nrpn_bounds
log ""
log "Patch/Bank import-export fixes - state round-trip:"
test_patch_roundtrip
test_bank_roundtrip
log ""
log "General stability sweep over bundled sample patches:"
test_stability_sweep
log ""
log "----------------------------------------"
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if [ "$FAIL_COUNT" -gt 0 ]; then
log "Failed:"
for n in "${FAILED_NAMES[@]}"; do
log " - $n"
done
log "Artifacts (logs/WAVs) kept in: $WORKDIR"
exit 1
fi
log "All tests passed. Cleaning up $WORKDIR."
rm -rf "$WORKDIR"
exit 0
@@ -0,0 +1,16 @@
{
"_comment": "Companion to nrpn_out_of_range.json: sends a LEGITIMATE, in-range NRPN ID (3<<7 | 116 = 500, well within NUM_MIDI_CONTROLLERS=1024) through the same CC sequence, to confirm the bounds-check fix in JaySynth::handleController did not break normal NRPN handling for valid IDs. Success = process completes cleanly, same as the out-of-range case (this tool can't directly observe internal state, so this is a regression guard against the fix being overly broad, not a positive functional check).",
"sampleRate": 44100,
"blockSize": 512,
"durationSeconds": 1.0,
"events": [
{ "sample": 0, "type": "controller", "channel": 1, "controllerNumber": 99, "controllerValue": 3 },
{ "sample": 1, "type": "controller", "channel": 1, "controllerNumber": 98, "controllerValue": 116 },
{ "sample": 2, "type": "controller", "channel": 1, "controllerNumber": 6, "controllerValue": 64 },
{ "sample": 3, "type": "controller", "channel": 1, "controllerNumber": 38, "controllerValue": 0 },
{ "sample": 4, "type": "controller", "channel": 1, "controllerNumber": 98, "controllerValue": 116 },
{ "sample": 10, "type": "noteOn", "channel": 1, "note": 60, "velocity": 100 },
{ "sample": 44050, "type": "noteOff", "channel": 1, "note": 60 }
],
"recordWav": "out_nrpn_in_range.wav"
}
@@ -0,0 +1,16 @@
{
"_comment": "Regression test for the fixed NRPN out-of-bounds write (TODO.md Critical finding #1: JaySynth::handleController wrote last_midiCC_info[midiCC_info.ID] with no bounds check; NRPN IDs assemble to up to 16383 but the array is sized NUM_MIDI_CONTROLLERS=1024). This sends the maximum possible 14-bit NRPN ID (127<<7 | 127 = 16383) via the standard MIDI NRPN CC sequence (99=NRPN MSB, 98=NRPN LSB, 6=Data Entry MSB, 38=Data Entry LSB, 98 again to complete/trigger the sequence - see MidiNrpn::process). Before the fix this reliably wrote far outside the array; after the fix, handleController returns immediately for any out-of-range ID. Success = process completes without crashing/hanging and produces valid, NaN-free audio.",
"sampleRate": 44100,
"blockSize": 512,
"durationSeconds": 1.0,
"events": [
{ "sample": 0, "type": "controller", "channel": 1, "controllerNumber": 99, "controllerValue": 127 },
{ "sample": 1, "type": "controller", "channel": 1, "controllerNumber": 98, "controllerValue": 127 },
{ "sample": 2, "type": "controller", "channel": 1, "controllerNumber": 6, "controllerValue": 64 },
{ "sample": 3, "type": "controller", "channel": 1, "controllerNumber": 38, "controllerValue": 0 },
{ "sample": 4, "type": "controller", "channel": 1, "controllerNumber": 98, "controllerValue": 127 },
{ "sample": 10, "type": "noteOn", "channel": 1, "note": 60, "velocity": 100 },
{ "sample": 44050, "type": "noteOff", "channel": 1, "note": 60 }
],
"recordWav": "out_nrpn_out_of_range.wav"
}