From 97b1c34cee643300b4701bf7aa360a48a85a81de Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 27 Jul 2026 19:09:06 +0200 Subject: [PATCH] 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; +}