From 97b1c34cee643300b4701bf7aa360a48a85a81de Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 27 Jul 2026 19:09:06 +0200 Subject: [PATCH 1/4] Add generic headless plugin test host (Phase 1: core + audio I/O) tools/testhost/ is a standalone CLI executable, not a JaySynth-specific tool: it drives any VST2 plugin offline (no audio device, no GUI event loop) via JUCE's own format-agnostic AudioPluginFormatManager / AudioPluginInstance hosting API, so adding another format later (VST3, LADSPA, eventually LV2) is just another addFormat() call - none of the host logic changes. - PluginHost: format-agnostic load/prepare/process/release, parameter get/set, and patch/bank state I/O, all delegating straight to AudioProcessor. Always queries the plugin's real input/output channel counts rather than assuming "0 in" (instrument) or a fixed channel layout - the same code path drives JaySynth (0 in/2 out, MIDI-driven) and a stereo/mono effect identically. - AudioInputSource: fills the plugin's input channels with silence (default, correct no-op for instruments), a sine tone, white/pink noise, or an impulse - what makes the host equally useful for effect plugins, which need a real input signal to do anything. - WavRecorder: records the plugin's actual output channel count to a WAV file via WavAudioFormat/AudioFormatWriter. - main.cpp: CLI (--plugin/--patch/--param/--note/--input/--duration/ --wav/--sampleRate/--blockSize), with basic NaN/Inf output detection. Has its own JuceLibraryCode-equivalent package that compiles only the module amalgams needed for hosting (not juce_audio_plugin_client, which is the plugin-side VST entry point and would collide with this executable's own main()), reusing the already-vendored sdk/juce/JUCE-3.1.1 rather than a second JUCE copy - 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 future JUCE version bump. Verified: builds clean (no new warnings) via `MAKE_HOME=... make -C tools/testhost`. Smoke-tested against two very different real plugins: - build/linux/release/JaySynth.so (0 in/2 out, MIDI-driven): --note 60 100 --duration 2 --wav produces correct-length, non-silent, NaN-free stereo audio. - /usr/lib/vst/ZamDelay-vst.so (1 in/1 out, real third-party VST2 effect, no MIDI): --input pink --duration 2 --wav correctly sizes input/output to mono and produces non-silent, NaN-free audio - proof the host is generic, not JaySynth-shaped. - /usr/lib/vst/ZamEQ2-vst.so: the host's own NaN/Inf detection caught a real NaN in this plugin's default state on first render - exactly the kind of regression this tool exists to catch without a GUI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ --- tools/testhost/JuceLibraryCode/AppConfig.h | 66 ++++++ tools/testhost/JuceLibraryCode/JuceHeader.h | 31 +++ tools/testhost/JuceLibraryCode/Makefile | 11 + tools/testhost/JuceLibraryCode/config.mk | 19 ++ tools/testhost/Makefile | 42 ++++ tools/testhost/src/AudioInputSource.cpp | 123 ++++++++++ tools/testhost/src/AudioInputSource.h | 58 +++++ tools/testhost/src/Makefile | 2 + tools/testhost/src/PluginHost.cpp | 107 +++++++++ tools/testhost/src/PluginHost.h | 62 +++++ tools/testhost/src/WavRecorder.cpp | 55 +++++ tools/testhost/src/WavRecorder.h | 34 +++ tools/testhost/src/config.mk | 6 + tools/testhost/src/main.cpp | 246 ++++++++++++++++++++ 14 files changed, 862 insertions(+) create mode 100644 tools/testhost/JuceLibraryCode/AppConfig.h create mode 100644 tools/testhost/JuceLibraryCode/JuceHeader.h create mode 100644 tools/testhost/JuceLibraryCode/Makefile create mode 100644 tools/testhost/JuceLibraryCode/config.mk create mode 100644 tools/testhost/Makefile create mode 100644 tools/testhost/src/AudioInputSource.cpp create mode 100644 tools/testhost/src/AudioInputSource.h create mode 100644 tools/testhost/src/Makefile create mode 100644 tools/testhost/src/PluginHost.cpp create mode 100644 tools/testhost/src/PluginHost.h create mode 100644 tools/testhost/src/WavRecorder.cpp create mode 100644 tools/testhost/src/WavRecorder.h create mode 100644 tools/testhost/src/config.mk create mode 100644 tools/testhost/src/main.cpp 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/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/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/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..fc5ed94 --- /dev/null +++ b/tools/testhost/src/config.mk @@ -0,0 +1,6 @@ +NAME := host + +CXX_SRCS := main.cpp +CXX_SRCS += PluginHost.cpp +CXX_SRCS += AudioInputSource.cpp +CXX_SRCS += WavRecorder.cpp diff --git a/tools/testhost/src/main.cpp b/tools/testhost/src/main.cpp new file mode 100644 index 0000000..0eda398 --- /dev/null +++ b/tools/testhost/src/main.cpp @@ -0,0 +1,246 @@ +#include +#include +#include +#include + +#include "PluginHost.h" +#include "AudioInputSource.h" +#include "WavRecorder.h" + +namespace +{ + void printUsage() + { + printf ( + "testhost - headless plugin test host\n" + "\n" + "Usage:\n" + " testhost --plugin [options]\n" + "\n" + "Options:\n" + " --patch load a .fxp/.fxb byte blob as the current program state\n" + " before rendering (see note below)\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" + "\n" + "Note: --patch/--bank load the raw bytes JUCE's own AudioPluginInstance state\n" + "calls read/write (AudioProcessor::setCurrentProgramStateInformation), not\n" + "JaySynth's own GUI-triggered .fxp/.xmp file parsing - a real .fxp/.fxb file's\n" + "bytes are exactly what those calls expect for a hosted VST2 plugin.\n" + ); + } + + struct Args + { + String pluginPath; + String patchFile; + String 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; + }; + + 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 == "--patch" && i + 1 < argc) + args.patchFile = 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 == "--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; + } +} + +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; + + PluginHost host; + if (! host.load (args.pluginPath, args.sampleRate, args.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 (args.patchFile.isNotEmpty()) + { + MemoryBlock patchData; + if (! File (args.patchFile).loadFileAsData (patchData) || patchData.getSize() == 0) + { + fprintf (stderr, "testhost: could not read patch file '%s'\n", args.patchFile.toRawUTF8()); + return 1; + } + host.setCurrentProgramStateInformation (patchData.getData(), (int) patchData.getSize()); + } + + if (args.hasParam) + host.setParameter (args.paramIndex, args.paramValue); + + AudioInputSource inputSource; + inputSource.prepare (args.sampleRate); + switch (args.inputType) + { + case AudioInputSource::sine: inputSource.setSine (args.inputFrequencyHz, 0.5f); break; + case AudioInputSource::whiteNoise: inputSource.setWhiteNoise (0.5f); break; + case AudioInputSource::pinkNoise: inputSource.setPinkNoise (0.5f); break; + case AudioInputSource::impulse: inputSource.setImpulse (1.0f); break; + case AudioInputSource::silence: + default: inputSource.setSilence(); break; + } + + WavRecorder recorder; + if (args.wavFile.isNotEmpty()) + { + if (! recorder.start (File (args.wavFile), args.sampleRate, numOutputChannels)) + return 1; + } + + const int numChannels = jmax (numInputChannels, numOutputChannels, 1); + AudioSampleBuffer buffer (numChannels, args.blockSize); + + const int64 totalSamples = (int64) (args.durationSeconds * args.sampleRate); + int64 samplesDone = 0; + bool sawNaN = false; + + while (samplesDone < totalSamples) + { + const int numThisBlock = (int) jmin ((int64) args.blockSize, totalSamples - samplesDone); + + buffer.clear(); + if (numInputChannels > 0) + inputSource.fillNextBlock (buffer, numInputChannels, numThisBlock); + + MidiBuffer midi; + if (args.hasNote) + { + if (samplesDone == 0) + midi.addEvent (MidiMessage::noteOn (1, args.note, (uint8) args.velocity), 0); + + if (samplesDone + numThisBlock >= totalSamples) + midi.addEvent (MidiMessage::noteOff (1, args.note), jmax (0, numThisBlock - 1)); + } + + 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; + } + + recorder.stop(); + 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 / args.sampleRate, (long long) samplesDone, + args.wavFile.isNotEmpty() ? " -> " : "", + args.wavFile.isNotEmpty() ? args.wavFile.toRawUTF8() : ""); + + return sawNaN ? 2 : 0; +} -- 2.54.0 From 39353751e67942c3f322b6a75482299d8e8c1a43 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 27 Jul 2026 19:19:08 +0200 Subject: [PATCH 2/4] Add JSON test scenarios, patch/bank scripting, and performance stats (Phases 2-4) - TestScenario: parses a JSON file describing sample rate/block size/ duration, an input signal config (silence/sine/noise/impulse), and a timed sequence of events (noteOn/noteOff/controller/param/loadPatch/ loadBank/savePatch/saveBank), sorted by sample position. Relative file paths inside the JSON resolve against the scenario file's own directory, not the process cwd, so scenario files are portable. Nothing here is JaySynth-specific: an event type a given plugin doesn't care about (e.g. MIDI sent to a plugin with no MIDI input) is simply inert, since AudioProcessor/MidiBuffer already tolerate that generically. - main.cpp: unified the ad-hoc CLI flags (--note/--param/--input/etc.) and --scenario JSON files through one render loop, by synthesizing a TestScenario from the CLI flags when no JSON file is given. Added --bank/--savePatch/--saveBank for the ad-hoc path (loadPatch/wav already existed). Non-MIDI events (param/patch/bank) scheduled inside a block apply at the start of that block; MIDI events keep full per-sample timing via MidiBuffer's own offset parameter. - PerformanceStats: wraps each processBlock call with a high-resolution timer, reports min/mean/max render time and a real-time factor (audio seconds rendered / wall-clock seconds), plus optional per-block CSV export for tracking regressions over time. Never paces the loop to real-time - the point is to run faster than that. Verified end-to-end: - Ad-hoc CLI path against JaySynth.so still renders correctly and now reports performance (e.g. ~74x real-time on this machine). - A JSON scenario driving JaySynth (timed noteOn/param/noteOff/ savePatch) produces correct WAV output and a valid saved patch file. - That saved patch round-trips back in via --patch and renders cleanly (proves the save/load path is consistent, not just one-directional). - A JSON scenario driving a real third-party effect (ZamComp-vst.so, 2 in/1 out - a genuinely asymmetric channel count, not just "0 in" or "stereo") with generated pink noise and a param event produces correct mono output at >1000x real-time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ --- tools/testhost/src/PerformanceStats.h | 79 +++++++++ tools/testhost/src/TestScenario.cpp | 178 +++++++++++++++++++ tools/testhost/src/TestScenario.h | 85 +++++++++ tools/testhost/src/config.mk | 1 + tools/testhost/src/main.cpp | 245 +++++++++++++++++++++----- 5 files changed, 543 insertions(+), 45 deletions(-) create mode 100644 tools/testhost/src/PerformanceStats.h create mode 100644 tools/testhost/src/TestScenario.cpp create mode 100644 tools/testhost/src/TestScenario.h 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/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/config.mk b/tools/testhost/src/config.mk index fc5ed94..39520cd 100644 --- a/tools/testhost/src/config.mk +++ b/tools/testhost/src/config.mk @@ -4,3 +4,4 @@ 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 index 0eda398..0397287 100644 --- a/tools/testhost/src/main.cpp +++ b/tools/testhost/src/main.cpp @@ -6,6 +6,8 @@ #include "PluginHost.h" #include "AudioInputSource.h" #include "WavRecorder.h" +#include "TestScenario.h" +#include "PerformanceStats.h" namespace { @@ -15,11 +17,19 @@ namespace "testhost - headless plugin test host\n" "\n" "Usage:\n" - " testhost --plugin [options]\n" + " testhost --plugin --scenario [--perfCsv ]\n" + " testhost --plugin [ad-hoc options] [--perfCsv ]\n" "\n" - "Options:\n" - " --patch load a .fxp/.fxb byte blob as the current program state\n" - " before rendering (see note below)\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" @@ -30,18 +40,19 @@ namespace " --blockSize default 512\n" " --wav record the plugin's output to a WAV file\n" "\n" - "Note: --patch/--bank load the raw bytes JUCE's own AudioPluginInstance state\n" - "calls read/write (AudioProcessor::setCurrentProgramStateInformation), not\n" - "JaySynth's own GUI-triggered .fxp/.xmp file parsing - a real .fxp/.fxb file's\n" - "bytes are exactly what those calls expect for a hosted VST2 plugin.\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 patchFile; - String wavFile; + 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; @@ -53,6 +64,8 @@ namespace float paramValue = 0.0f; AudioInputSource::Type inputType = AudioInputSource::silence; double inputFrequencyHz = 440.0; + + String perfCsvFile; }; bool parseArgs (int argc, char *argv[], Args &args) @@ -63,8 +76,16 @@ namespace 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) @@ -73,6 +94,8 @@ namespace 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 == "--note" && i + 2 < argc) { args.hasNote = true; @@ -126,6 +149,108 @@ namespace 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[]) @@ -143,8 +268,19 @@ int main (int argc, char *argv[]) // 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, args.sampleRate, args.blockSize)) + if (! host.load (args.pluginPath, scenario.sampleRate, scenario.blockSize)) return 1; const int numInputChannels = host.getNumInputChannels(); @@ -152,65 +288,65 @@ int main (int argc, char *argv[]) printf ("testhost: loaded '%s' (%d in / %d out, %d parameters)\n", args.pluginPath.toRawUTF8(), numInputChannels, numOutputChannels, host.getNumParameters()); - if (args.patchFile.isNotEmpty()) - { - MemoryBlock patchData; - if (! File (args.patchFile).loadFileAsData (patchData) || patchData.getSize() == 0) - { - fprintf (stderr, "testhost: could not read patch file '%s'\n", args.patchFile.toRawUTF8()); - return 1; - } - host.setCurrentProgramStateInformation (patchData.getData(), (int) patchData.getSize()); - } + if (scenario.initialLoadBankFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadBankFile, true, host)) + return 1; - if (args.hasParam) - host.setParameter (args.paramIndex, args.paramValue); + if (scenario.initialLoadPatchFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadPatchFile, false, host)) + return 1; AudioInputSource inputSource; - inputSource.prepare (args.sampleRate); - switch (args.inputType) + inputSource.prepare (scenario.sampleRate); + switch (scenario.inputType) { - case AudioInputSource::sine: inputSource.setSine (args.inputFrequencyHz, 0.5f); break; - case AudioInputSource::whiteNoise: inputSource.setWhiteNoise (0.5f); break; - case AudioInputSource::pinkNoise: inputSource.setPinkNoise (0.5f); break; - case AudioInputSource::impulse: inputSource.setImpulse (1.0f); break; + 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 (args.wavFile.isNotEmpty()) + if (scenario.recordWavFile.isNotEmpty()) { - if (! recorder.start (File (args.wavFile), args.sampleRate, numOutputChannels)) + if (! recorder.start (File (scenario.recordWavFile), scenario.sampleRate, numOutputChannels)) return 1; } const int numChannels = jmax (numInputChannels, numOutputChannels, 1); - AudioSampleBuffer buffer (numChannels, args.blockSize); + AudioSampleBuffer buffer (numChannels, scenario.blockSize); - const int64 totalSamples = (int64) (args.durationSeconds * args.sampleRate); + PerformanceStats perfStats; + perfStats.reset (scenario.sampleRate); + + int nextEventIndex = 0; int64 samplesDone = 0; bool sawNaN = false; - while (samplesDone < totalSamples) + while (samplesDone < scenario.durationSamples) { - const int numThisBlock = (int) jmin ((int64) args.blockSize, totalSamples - samplesDone); + const int numThisBlock = (int) jmin ((int64) scenario.blockSize, scenario.durationSamples - samplesDone); buffer.clear(); if (numInputChannels > 0) inputSource.fillNextBlock (buffer, numInputChannels, numThisBlock); MidiBuffer midi; - if (args.hasNote) - { - if (samplesDone == 0) - midi.addEvent (MidiMessage::noteOn (1, args.note, (uint8) args.velocity), 0); - if (samplesDone + numThisBlock >= totalSamples) - midi.addEvent (MidiMessage::noteOff (1, args.note), jmax (0, numThisBlock - 1)); + // 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; } - host.process (buffer, midi); + perfStats.timeBlock (numThisBlock, [&] { host.process (buffer, midi); }); for (int ch = 0; ch < numOutputChannels && ! sawNaN; ++ch) { @@ -231,16 +367,35 @@ int main (int argc, char *argv[]) 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 / args.sampleRate, (long long) samplesDone, - args.wavFile.isNotEmpty() ? " -> " : "", - args.wavFile.isNotEmpty() ? args.wavFile.toRawUTF8() : ""); + (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; } -- 2.54.0 From ab29ad282080d89ed228541ee2e7284c299355d3 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 27 Jul 2026 19:31:54 +0200 Subject: [PATCH 3/4] Add README and TODO tracking for the testhost tool - README.md: what the tool is and isn't (generic, not JaySynth-specific), build instructions, CLI quick-start, full JSON scenario schema/field reference, the patch/bank state-format caveat (this tool's load/save goes through JUCE's host-side AudioPluginInstance state calls, not JaySynth's own GUI-triggered loadPatchFromFile/patchImportXml - a different code path worth knowing about), and an architecture summary. - TODO.md: tracks what's deferred by design (LV2/VST3/LADSPA format registration, Windows build), test coverage gaps (stereo effects untested, no regression-baseline tooling, no CI wiring, no JSON-parser unit tests, no polyphony testing), two things worth investigating (a non-fatal DPF plugin assertion seen on load, an unconfirmed WavAudioFormat channel-count limit), and one nice-to-have. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ --- tools/testhost/README.md | 146 +++++++++++++++++++++++++++++++++++++++ tools/testhost/TODO.md | 72 +++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tools/testhost/README.md create mode 100644 tools/testhost/TODO.md diff --git a/tools/testhost/README.md b/tools/testhost/README.md new file mode 100644 index 0000000..553ad06 --- /dev/null +++ b/tools/testhost/README.md @@ -0,0 +1,146 @@ +# testhost + +A generic, headless command-line host for testing audio plugins. It loads a +plugin out-of-process and drives it entirely offline — no audio device, no +GUI event loop, no real-time pacing — so a test run is bounded by CPU speed +rather than wall-clock playback time (60-1000x+ real-time observed on this +machine, depending on the plugin). + +**This tool is not JaySynth-specific.** It was built to test JaySynth, but it +talks to plugins purely through JUCE's own `AudioPluginFormatManager` / +`AudioPluginInstance` hosting API, and it never assumes a fixed channel +layout — it always asks the loaded plugin how many input/output channels it +actually has. That means it drives an instrument (0 inputs, MIDI-triggered, +e.g. JaySynth) and an audio effect (real input required, e.g. a reverb, +EQ, or compressor) through the exact same code path. See +`/home/jens/.claude/plans/sharded-finding-tower.md` for the original design +plan and rationale. + +## Building + +```sh +MAKE_HOME=$(realpath ../../submodule/make) make -C tools/testhost +``` + +This produces `tools/testhost/build/linux/release/testhost`. It has its own +`JuceLibraryCode/` package (compiling only the module amalgams a host needs - +not `juce_audio_plugin_client`, which is the plugin-*side* VST entry point +and would collide with this executable's own `main()`), but reuses the +already-vendored `sdk/juce/JUCE-3.1.1` rather than a second JUCE checkout. +The host and any plugin it loads are decoupled at the VST2 ABI, not the JUCE +source level, so this doesn't need to track JaySynth's own JUCE version. + +`make CONFIG=debug -C tools/testhost` builds a debug variant the same way +the main plugin build does. + +## Quick start + +```sh +# Render 2s of a held note through JaySynth and record it: +./build/linux/release/testhost \ + --plugin ../../build/linux/release/JaySynth.so \ + --note 60 100 --duration 2 --wav out.wav + +# Feed pink noise through an audio effect (no MIDI needed): +./build/linux/release/testhost \ + --plugin /usr/lib/vst/ZamDelay-vst.so \ + --input pink --duration 2 --wav out.wav +``` + +Run `./build/linux/release/testhost --help` for the full flag reference +(patch/bank load & save, parameters, sample rate/block size, performance CSV +export, etc). + +## JSON test scenarios + +For anything beyond a one-off check, `--scenario file.json` drives a full +timed sequence of actions instead of a handful of CLI flags - this is what +makes tests repeatable and batchable (e.g. one file per regression case, run +unattended in CI). + +```json +{ + "sampleRate": 44100, + "blockSize": 512, + "durationSeconds": 2.0, + "input": { "type": "noise", "kind": "pink", "amplitude": 0.5 }, + "loadBank": "some_bank.fxb", + "events": [ + { "sample": 0, "type": "noteOn", "channel": 1, "note": 60, "velocity": 100 }, + { "sample": 22050, "type": "param", "index": 5, "value": 0.75 }, + { "sample": 44100, "type": "noteOff", "channel": 1, "note": 60 }, + { "sample": 88199, "type": "savePatch", "file": "result_patch.bin" } + ], + "recordWav": "out.wav" +} +``` + +Relative file paths inside the JSON (`loadBank`, `loadPatch`, `recordWav`, +and any event's `file`) resolve against **the scenario file's own +directory**, not the process's working directory, so a scenario file stays +portable no matter where it's run from. + +### Top-level fields + +| Field | Type | Notes | +|---|---|---| +| `sampleRate` | number | default 44100 | +| `blockSize` | number | default 512 | +| `durationSamples` **or** `durationSeconds` | number | one of these is required | +| `input` | object | `{"type": "silence"}` (default) / `"sine"` (+ `frequencyHz`) / `"noise"` (+ `kind`: `"white"`/`"pink"`) / `"impulse"`, all with optional `amplitude` (default 0.5) | +| `loadPatch` / `loadBank` | string (file path) | loaded once, before rendering starts | +| `recordWav` | string (file path) | omit to skip recording (e.g. pure performance runs) | +| `events` | array | see below; need not be pre-sorted, `TestScenario` sorts by `sample` | + +### Event types + +All events have `sample` (absolute sample index) and `type`: + +| `type` | Extra fields | Effect | +|---|---|---| +| `noteOn` | `channel`, `note`, `velocity` | `MidiMessage::noteOn`, timed to the exact sample within its block | +| `noteOff` | `channel`, `note` | `MidiMessage::noteOff`, same timing precision | +| `controller` | `channel`, `controllerNumber`, `controllerValue` | `MidiMessage::controllerEvent` | +| `param` | `index`, `value` (0.0-1.0) | `AudioProcessor::setParameter`, applied at the start of the block containing `sample` (see caveat below) | +| `loadPatch` / `loadBank` | `file` | reads the file's raw bytes and calls `setCurrentProgramStateInformation`/`setStateInformation` | +| `savePatch` / `saveBank` | `file` | calls `getCurrentProgramStateInformation`/`getStateInformation` and writes the raw bytes out | + +**Caveat:** MIDI events keep full per-sample timing (JUCE's `MidiBuffer` +carries a sample offset natively). Non-MIDI events (`param`/`loadPatch`/ +`loadBank`/`savePatch`/`saveBank`) scheduled for a sample in the middle of a +block are applied at the *start* of that block, not their exact offset - +fine for test purposes, but not sample-accurate. Use a smaller `blockSize` +if you need tighter timing on those. + +### Patch/bank format note + +`loadPatch`/`savePatch`/`loadBank`/`saveBank` read/write exactly the bytes +that JUCE's host-side `AudioPluginInstance::get/setCurrentProgramState +Information` and `get/setStateInformation` produce/consume for a hosted +VST2 plugin - i.e. real `.fxp`/`.fxb` file bytes (JUCE's own VST2 host +wrapper handles the fxb/fxp chunk header itself; this tool never parses +that format directly). This is a different code path from JaySynth's own +GUI-triggered "Load Patch from disk" button (`JaySynthAudioProcessor:: +loadPatchFromFile`/`patchImportXml` in `src/plug/PluginProcessor.cpp`) - +both are legitimate ways to get a patch into the plugin, but they aren't +the same call chain, so a fix to one doesn't automatically test the other. + +## Architecture + +- `PluginHost` — owns the `AudioPluginFormatManager`, loads/prepares/drives + one plugin instance. The only class that talks to `AudioPluginInstance` + directly; everything else in this tool is format-agnostic by construction. +- `AudioInputSource` — fills the plugin's input channels each block + (silence/sine/noise/impulse). +- `WavRecorder` — thin wrapper over `WavAudioFormat`/`AudioFormatWriter`. +- `TestScenario` — parses the JSON format above into a sorted event list + plus render/I-O config. +- `PerformanceStats` — per-block timing, summary + optional CSV. +- `main.cpp` — CLI parsing, and a single render loop shared by both the + ad-hoc-flags path and the `--scenario` path (ad-hoc flags are converted + into an equivalent in-memory `TestScenario` before rendering starts, so + there's only one loop to maintain). + +## Known limitations + +See `TODO.md` in this directory. diff --git a/tools/testhost/TODO.md b/tools/testhost/TODO.md new file mode 100644 index 0000000..124d05f --- /dev/null +++ b/tools/testhost/TODO.md @@ -0,0 +1,72 @@ +# 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 + +- [ ] **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) + +- [ ] 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. -- 2.54.0 From f54689f07169a024c4a6910148cff5d3511b9a99 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 27 Jul 2026 20:06:44 +0200 Subject: [PATCH 4/4] Add regression suite testing JaySynth's TODO.md findings via testhost tools/testhost/tests/run_tests.sh dynamically exercises the tracked hardening findings using testhost itself - no GUI, sub-second wall time: - oversized_blocksize (Critical #5): --blockSize 16384 under a timeout, catching either the overflow or the deadlock the original bug could cause. - nrpn_out_of_range/nrpn_in_range (Critical #1): JSON scenarios sending the 5-message MIDI NRPN CC sequence, once targeting the max 14-bit ID (16383) and once a legitimate in-range ID (500), so a regression that made the bounds check too aggressive would also show up. - patch_save_load_roundtrip / bank_save_load_roundtrip: save a distinctive continuous parameter value, reload in a fresh process, check it round-trips within tolerance - covers the setCurrentProgramStateInformation no-op bug and the patchDecodeXml/patchImportXml fixes. - stability_sweep: loads every bundled .fxp under extras/sounds/ and renders a held note through each, as a broad crash/NaN net. Added --printParams to main.cpp (prints every parameter's index and value) to make the round-trip tests possible - this was also already a noted nice-to-have in testhost/TODO.md. While building the oversized_blocksize test, found and fixed (on the hardening branch, commit d713ca5) a second, previously-unknown bug: VCF_CalcCoeff_LPF/_HPF/_BPF advance the 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 overflowed it for any block between 4097 and 8192 samples. Found via bisecting the crashing block size then confirming with an ASan build; see tests/README.md's debugging-notes section for exactly how, including the ASan symbolizer hang encountered along the way and the workaround. Also discovered (documented in tools/testhost/TODO.md's Investigate section, not fixed - out of scope): JaySynth's compiled VST2 wrapper never advertises chunk support to the host, so real .fxp/.fxb sample files have no effect when loaded through the generic AudioProcessor state API. The round-trip tests work around this by using testhost's own save output as the fixture rather than an external sample file. All 10 checks pass against the current hardening-branch build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ --- tools/testhost/README.md | 8 + tools/testhost/TODO.md | 23 +- tools/testhost/src/main.cpp | 12 + tools/testhost/tests/.gitignore | 1 + tools/testhost/tests/README.md | 122 +++++++ tools/testhost/tests/run_tests.sh | 318 ++++++++++++++++++ .../tests/scenarios/nrpn_in_range.json | 16 + .../tests/scenarios/nrpn_out_of_range.json | 16 + 8 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 tools/testhost/tests/.gitignore create mode 100644 tools/testhost/tests/README.md create mode 100755 tools/testhost/tests/run_tests.sh create mode 100644 tools/testhost/tests/scenarios/nrpn_in_range.json create mode 100644 tools/testhost/tests/scenarios/nrpn_out_of_range.json diff --git a/tools/testhost/README.md b/tools/testhost/README.md index 553ad06..a991156 100644 --- a/tools/testhost/README.md +++ b/tools/testhost/README.md @@ -141,6 +141,14 @@ the same call chain, so a fix to one doesn't automatically test the other. 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 index 124d05f..2b87932 100644 --- a/tools/testhost/TODO.md +++ b/tools/testhost/TODO.md @@ -47,6 +47,22 @@ verified (PR https://git.jayfield.org/jens/JaySynth/pulls/1). ## 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 @@ -67,6 +83,11 @@ verified (PR https://git.jayfield.org/jens/JaySynth/pulls/1). ## Nice-to-have (not planned, just noted) -- [ ] A `--list-params` or similar introspection mode (print every +- [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/main.cpp b/tools/testhost/src/main.cpp index 0397287..afc8e78 100644 --- a/tools/testhost/src/main.cpp +++ b/tools/testhost/src/main.cpp @@ -39,6 +39,9 @@ namespace " --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" @@ -66,6 +69,7 @@ namespace double inputFrequencyHz = 440.0; String perfCsvFile; + bool printParams = false; }; bool parseArgs (int argc, char *argv[], Args &args) @@ -96,6 +100,8 @@ namespace 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; @@ -294,6 +300,12 @@ int main (int argc, char *argv[]) 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) 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" +} -- 2.54.0