diff --git a/tools/testhost/JuceLibraryCode/AppConfig.h b/tools/testhost/JuceLibraryCode/AppConfig.h new file mode 100644 index 0000000..5146c9e --- /dev/null +++ b/tools/testhost/JuceLibraryCode/AppConfig.h @@ -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__ diff --git a/tools/testhost/JuceLibraryCode/JuceHeader.h b/tools/testhost/JuceLibraryCode/JuceHeader.h new file mode 100644 index 0000000..0967cad --- /dev/null +++ b/tools/testhost/JuceLibraryCode/JuceHeader.h @@ -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 + +#endif // __TESTHOST_JUCEHEADER__ diff --git a/tools/testhost/JuceLibraryCode/Makefile b/tools/testhost/JuceLibraryCode/Makefile new file mode 100644 index 0000000..0a21697 --- /dev/null +++ b/tools/testhost/JuceLibraryCode/Makefile @@ -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 diff --git a/tools/testhost/JuceLibraryCode/config.mk b/tools/testhost/JuceLibraryCode/config.mk new file mode 100644 index 0000000..bbffea0 --- /dev/null +++ b/tools/testhost/JuceLibraryCode/config.mk @@ -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 diff --git a/tools/testhost/Makefile b/tools/testhost/Makefile new file mode 100644 index 0000000..7c796cf --- /dev/null +++ b/tools/testhost/Makefile @@ -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 diff --git a/tools/testhost/README.md b/tools/testhost/README.md new file mode 100644 index 0000000..a991156 --- /dev/null +++ b/tools/testhost/README.md @@ -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. diff --git a/tools/testhost/TODO.md b/tools/testhost/TODO.md new file mode 100644 index 0000000..2b87932 --- /dev/null +++ b/tools/testhost/TODO.md @@ -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 ` 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. diff --git a/tools/testhost/src/AudioInputSource.cpp b/tools/testhost/src/AudioInputSource.cpp new file mode 100644 index 0000000..ad8d080 --- /dev/null +++ b/tools/testhost/src/AudioInputSource.cpp @@ -0,0 +1,123 @@ +#include "AudioInputSource.h" +#include + +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; + } +} diff --git a/tools/testhost/src/AudioInputSource.h b/tools/testhost/src/AudioInputSource.h new file mode 100644 index 0000000..17ca7ec --- /dev/null +++ b/tools/testhost/src/AudioInputSource.h @@ -0,0 +1,58 @@ +#ifndef TESTHOST_AUDIOINPUTSOURCE_H_INCLUDED +#define TESTHOST_AUDIOINPUTSOURCE_H_INCLUDED + +#include + +/** 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 diff --git a/tools/testhost/src/Makefile b/tools/testhost/src/Makefile new file mode 100644 index 0000000..dfe86f6 --- /dev/null +++ b/tools/testhost/src/Makefile @@ -0,0 +1,2 @@ +include ./config.mk +include $(MAKE_HOME)/compile.mk diff --git a/tools/testhost/src/PerformanceStats.h b/tools/testhost/src/PerformanceStats.h new file mode 100644 index 0000000..22a179a --- /dev/null +++ b/tools/testhost/src/PerformanceStats.h @@ -0,0 +1,79 @@ +#ifndef TESTHOST_PERFORMANCESTATS_H_INCLUDED +#define TESTHOST_PERFORMANCESTATS_H_INCLUDED + +#include +#include + +/** 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 + 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 blockTimesMs; + int64 totalSamples = 0; +}; + +#endif // TESTHOST_PERFORMANCESTATS_H_INCLUDED diff --git a/tools/testhost/src/PluginHost.cpp b/tools/testhost/src/PluginHost.cpp new file mode 100644 index 0000000..1de9eee --- /dev/null +++ b/tools/testhost/src/PluginHost.cpp @@ -0,0 +1,107 @@ +#include "PluginHost.h" +#include + +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 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); +} diff --git a/tools/testhost/src/PluginHost.h b/tools/testhost/src/PluginHost.h new file mode 100644 index 0000000..dfe6cb2 --- /dev/null +++ b/tools/testhost/src/PluginHost.h @@ -0,0 +1,62 @@ +#ifndef TESTHOST_PLUGINHOST_H_INCLUDED +#define TESTHOST_PLUGINHOST_H_INCLUDED + +#include + +/** 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 plugin; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginHost) +}; + +#endif // TESTHOST_PLUGINHOST_H_INCLUDED diff --git a/tools/testhost/src/TestScenario.cpp b/tools/testhost/src/TestScenario.cpp new file mode 100644 index 0000000..6eff604 --- /dev/null +++ b/tools/testhost/src/TestScenario.cpp @@ -0,0 +1,178 @@ +#include "TestScenario.h" +#include + +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; +} diff --git a/tools/testhost/src/TestScenario.h b/tools/testhost/src/TestScenario.h new file mode 100644 index 0000000..b27aae4 --- /dev/null +++ b/tools/testhost/src/TestScenario.h @@ -0,0 +1,85 @@ +#ifndef TESTHOST_TESTSCENARIO_H_INCLUDED +#define TESTHOST_TESTSCENARIO_H_INCLUDED + +#include +#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 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 diff --git a/tools/testhost/src/WavRecorder.cpp b/tools/testhost/src/WavRecorder.cpp new file mode 100644 index 0000000..4b941ac --- /dev/null +++ b/tools/testhost/src/WavRecorder.cpp @@ -0,0 +1,55 @@ +#include "WavRecorder.h" +#include + +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 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 +} diff --git a/tools/testhost/src/WavRecorder.h b/tools/testhost/src/WavRecorder.h new file mode 100644 index 0000000..c8c6112 --- /dev/null +++ b/tools/testhost/src/WavRecorder.h @@ -0,0 +1,34 @@ +#ifndef TESTHOST_WAVRECORDER_H_INCLUDED +#define TESTHOST_WAVRECORDER_H_INCLUDED + +#include + +/** 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 writer; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WavRecorder) +}; + +#endif // TESTHOST_WAVRECORDER_H_INCLUDED diff --git a/tools/testhost/src/config.mk b/tools/testhost/src/config.mk new file mode 100644 index 0000000..39520cd --- /dev/null +++ b/tools/testhost/src/config.mk @@ -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 diff --git a/tools/testhost/src/main.cpp b/tools/testhost/src/main.cpp new file mode 100644 index 0000000..afc8e78 --- /dev/null +++ b/tools/testhost/src/main.cpp @@ -0,0 +1,413 @@ +#include +#include +#include +#include + +#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 --scenario [--perfCsv ]\n" + " testhost --plugin [ad-hoc options] [--perfCsv ]\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 load a patch (single-program) state before rendering\n" + " --bank load a bank (full plugin) state before rendering\n" + " --savePatch save the patch state after rendering\n" + " --saveBank save the bank state after rendering\n" + " --note MIDI note-on (0-127, 0-127) at sample 0, held for the\n" + " whole render, note-off in the final block\n" + " --param set parameter to (0.0-1.0) before rendering\n" + " --input fill the plugin's input channels with: silence (default),\n" + " sine[:freqHz], white, pink, or impulse\n" + " --duration how much audio to render (default 2.0)\n" + " --sampleRate default 44100\n" + " --blockSize default 512\n" + " --wav 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 \" 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; +} diff --git a/tools/testhost/tests/.gitignore b/tools/testhost/tests/.gitignore new file mode 100644 index 0000000..328ad90 --- /dev/null +++ b/tools/testhost/tests/.gitignore @@ -0,0 +1 @@ +scenarios/*.wav diff --git a/tools/testhost/tests/README.md b/tools/testhost/tests/README.md new file mode 100644 index 0000000..a9093bf --- /dev/null +++ b/tools/testhost/tests/README.md @@ -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 ` (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. diff --git a/tools/testhost/tests/run_tests.sh b/tools/testhost/tests/run_tests.sh new file mode 100755 index 0000000..deb1b4d --- /dev/null +++ b/tools/testhost/tests/run_tests.sh @@ -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 + 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 diff --git a/tools/testhost/tests/scenarios/nrpn_in_range.json b/tools/testhost/tests/scenarios/nrpn_in_range.json new file mode 100644 index 0000000..8a2145c --- /dev/null +++ b/tools/testhost/tests/scenarios/nrpn_in_range.json @@ -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" +} diff --git a/tools/testhost/tests/scenarios/nrpn_out_of_range.json b/tools/testhost/tests/scenarios/nrpn_out_of_range.json new file mode 100644 index 0000000..92e96f7 --- /dev/null +++ b/tools/testhost/tests/scenarios/nrpn_out_of_range.json @@ -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" +}