Add generic headless plugin test host #1
@@ -0,0 +1,79 @@
|
|||||||
|
#ifndef TESTHOST_PERFORMANCESTATS_H_INCLUDED
|
||||||
|
#define TESTHOST_PERFORMANCESTATS_H_INCLUDED
|
||||||
|
|
||||||
|
#include <JuceHeader.h>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
/** Timing instrumentation around processBlock() calls.
|
||||||
|
|
||||||
|
This never paces the render loop to real-time - the entire point of a
|
||||||
|
headless host is to run faster than real-time - it only measures how
|
||||||
|
fast processBlock() actually ran, so regressions in render cost show up
|
||||||
|
without needing a GUI or a live audio device.
|
||||||
|
*/
|
||||||
|
class PerformanceStats
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PerformanceStats() {}
|
||||||
|
|
||||||
|
void reset (double sampleRateToUse)
|
||||||
|
{
|
||||||
|
sampleRate = sampleRateToUse;
|
||||||
|
blockTimesMs.clearQuick();
|
||||||
|
totalSamples = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap the call you want timed: stats.timeBlock(numSamples, [&]{ host.process(buffer, midi); }); */
|
||||||
|
template <typename Callable>
|
||||||
|
void timeBlock (int numSamples, Callable &&call)
|
||||||
|
{
|
||||||
|
const double start = Time::getMillisecondCounterHiRes();
|
||||||
|
call();
|
||||||
|
const double elapsedMs = Time::getMillisecondCounterHiRes() - start;
|
||||||
|
|
||||||
|
blockTimesMs.add (elapsedMs);
|
||||||
|
totalSamples += numSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
void printSummary() const
|
||||||
|
{
|
||||||
|
if (blockTimesMs.size() == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double sumMs = 0.0, minMs = blockTimesMs.getUnchecked (0), maxMs = blockTimesMs.getUnchecked (0);
|
||||||
|
for (int i = 0; i < blockTimesMs.size(); ++i)
|
||||||
|
{
|
||||||
|
const double t = blockTimesMs.getUnchecked (i);
|
||||||
|
sumMs += t;
|
||||||
|
minMs = jmin (minMs, t);
|
||||||
|
maxMs = jmax (maxMs, t);
|
||||||
|
}
|
||||||
|
const double meanMs = sumMs / blockTimesMs.size();
|
||||||
|
|
||||||
|
const double audioSeconds = totalSamples / sampleRate;
|
||||||
|
const double wallSeconds = sumMs / 1000.0;
|
||||||
|
const double realTimeFactor = wallSeconds > 0.0 ? audioSeconds / wallSeconds : 0.0;
|
||||||
|
|
||||||
|
printf ("testhost: performance - %d blocks, render time min/mean/max = %.3f/%.3f/%.3f ms, "
|
||||||
|
"%.3fs audio rendered in %.3fs wall time (%.1fx real-time)\n",
|
||||||
|
blockTimesMs.size(), minMs, meanMs, maxMs, audioSeconds, wallSeconds, realTimeFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional: one row per block, for tracking regressions over time in CI. */
|
||||||
|
bool writeCsv (const File &destFile) const
|
||||||
|
{
|
||||||
|
StringArray lines;
|
||||||
|
lines.add ("blockIndex,renderTimeMs");
|
||||||
|
for (int i = 0; i < blockTimesMs.size(); ++i)
|
||||||
|
lines.add (String (i) + "," + String (blockTimesMs.getUnchecked (i), 6));
|
||||||
|
|
||||||
|
return destFile.replaceWithText (lines.joinIntoString ("\n") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
double sampleRate = 44100.0;
|
||||||
|
Array<double> blockTimesMs;
|
||||||
|
int64 totalSamples = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // TESTHOST_PERFORMANCESTATS_H_INCLUDED
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#include "TestScenario.h"
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct EventSampleComparator
|
||||||
|
{
|
||||||
|
static int compareElements (const TestScenario::Event &a, const TestScenario::Event &b)
|
||||||
|
{
|
||||||
|
if (a.sample < b.sample) return -1;
|
||||||
|
if (a.sample > b.sample) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// var itself has no hasProperty() in this JUCE version - only the
|
||||||
|
// DynamicObject it may wrap does.
|
||||||
|
bool hasProperty (const var &v, const Identifier &name)
|
||||||
|
{
|
||||||
|
DynamicObject *obj = v.getDynamicObject();
|
||||||
|
return obj != nullptr && obj->hasProperty (name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TestScenario::loadFromFile (const File &jsonFile)
|
||||||
|
{
|
||||||
|
var root = JSON::parse (jsonFile);
|
||||||
|
|
||||||
|
if (root.isVoid())
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: could not parse scenario JSON '%s'\n", jsonFile.getFullPathName().toRawUTF8());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseTopLevel (root, jsonFile.getParentDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TestScenario::parseTopLevel (const var &root, const File &baseDir)
|
||||||
|
{
|
||||||
|
sampleRate = (double) root.getProperty ("sampleRate", sampleRate);
|
||||||
|
blockSize = (int) root.getProperty ("blockSize", blockSize);
|
||||||
|
|
||||||
|
if (hasProperty (root, "durationSamples"))
|
||||||
|
durationSamples = (int64) (int) root.getProperty ("durationSamples", 0);
|
||||||
|
else if (hasProperty (root, "durationSeconds"))
|
||||||
|
durationSamples = (int64) ((double) root.getProperty ("durationSeconds", 0.0) * sampleRate);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: scenario JSON needs either \"durationSamples\" or \"durationSeconds\"\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProperty (root, "input") && ! parseInput (root.getProperty ("input", var())))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (hasProperty (root, "loadPatch"))
|
||||||
|
initialLoadPatchFile = baseDir.getChildFile (root.getProperty ("loadPatch", var()).toString()).getFullPathName();
|
||||||
|
|
||||||
|
if (hasProperty (root, "loadBank"))
|
||||||
|
initialLoadBankFile = baseDir.getChildFile (root.getProperty ("loadBank", var()).toString()).getFullPathName();
|
||||||
|
|
||||||
|
if (hasProperty (root, "recordWav"))
|
||||||
|
recordWavFile = baseDir.getChildFile (root.getProperty ("recordWav", var()).toString()).getFullPathName();
|
||||||
|
|
||||||
|
if (hasProperty (root, "events") && ! parseEvents (root.getProperty ("events", var()), baseDir))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
EventSampleComparator comparator;
|
||||||
|
events.sort (comparator, true);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TestScenario::parseInput (const var &inputVar)
|
||||||
|
{
|
||||||
|
const String type = inputVar.getProperty ("type", "silence").toString();
|
||||||
|
inputAmplitude = (float) (double) inputVar.getProperty ("amplitude", 0.5);
|
||||||
|
|
||||||
|
if (type == "silence")
|
||||||
|
inputType = AudioInputSource::silence;
|
||||||
|
else if (type == "sine")
|
||||||
|
{
|
||||||
|
inputType = AudioInputSource::sine;
|
||||||
|
inputFrequencyHz = (double) inputVar.getProperty ("frequencyHz", 440.0);
|
||||||
|
}
|
||||||
|
else if (type == "noise")
|
||||||
|
{
|
||||||
|
const String kind = inputVar.getProperty ("kind", "white").toString();
|
||||||
|
inputType = (kind == "pink") ? AudioInputSource::pinkNoise : AudioInputSource::whiteNoise;
|
||||||
|
}
|
||||||
|
else if (type == "impulse")
|
||||||
|
inputType = AudioInputSource::impulse;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: unknown scenario input type '%s'\n", type.toRawUTF8());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TestScenario::parseEventType (const String &typeName, Event::Type &outType)
|
||||||
|
{
|
||||||
|
if (typeName == "noteOn") { outType = Event::noteOn; return true; }
|
||||||
|
if (typeName == "noteOff") { outType = Event::noteOff; return true; }
|
||||||
|
if (typeName == "controller") { outType = Event::controller; return true; }
|
||||||
|
if (typeName == "param") { outType = Event::param; return true; }
|
||||||
|
if (typeName == "loadPatch") { outType = Event::loadPatch; return true; }
|
||||||
|
if (typeName == "loadBank") { outType = Event::loadBank; return true; }
|
||||||
|
if (typeName == "savePatch") { outType = Event::savePatch; return true; }
|
||||||
|
if (typeName == "saveBank") { outType = Event::saveBank; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TestScenario::parseEvents (const var &eventsVar, const File &baseDir)
|
||||||
|
{
|
||||||
|
if (! eventsVar.isArray())
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: scenario \"events\" must be an array\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < eventsVar.size(); ++i)
|
||||||
|
{
|
||||||
|
const var &e = eventsVar[i];
|
||||||
|
const String typeName = e.getProperty ("type", var()).toString();
|
||||||
|
|
||||||
|
Event event;
|
||||||
|
if (! parseEventType (typeName, event.type))
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: unknown event type '%s' (event #%d)\n", typeName.toRawUTF8(), i);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.sample = (int64) (int) e.getProperty ("sample", 0);
|
||||||
|
|
||||||
|
switch (event.type)
|
||||||
|
{
|
||||||
|
case Event::noteOn:
|
||||||
|
event.channel = (int) e.getProperty ("channel", 1);
|
||||||
|
event.note = (int) e.getProperty ("note", 60);
|
||||||
|
event.velocity = (int) e.getProperty ("velocity", 100);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Event::noteOff:
|
||||||
|
event.channel = (int) e.getProperty ("channel", 1);
|
||||||
|
event.note = (int) e.getProperty ("note", 60);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Event::controller:
|
||||||
|
event.channel = (int) e.getProperty ("channel", 1);
|
||||||
|
event.controllerNumber = (int) e.getProperty ("controllerNumber", 0);
|
||||||
|
event.controllerValue = (int) e.getProperty ("controllerValue", 0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Event::param:
|
||||||
|
event.paramIndex = (int) e.getProperty ("index", 0);
|
||||||
|
event.paramValue = (float) (double) e.getProperty ("value", 0.0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Event::loadPatch:
|
||||||
|
case Event::loadBank:
|
||||||
|
case Event::savePatch:
|
||||||
|
case Event::saveBank:
|
||||||
|
event.file = baseDir.getChildFile (e.getProperty ("file", var()).toString()).getFullPathName();
|
||||||
|
if (event.file.isEmpty())
|
||||||
|
{
|
||||||
|
fprintf (stderr, "testhost: event #%d (%s) is missing \"file\"\n", i, typeName.toRawUTF8());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.add (event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#ifndef TESTHOST_TESTSCENARIO_H_INCLUDED
|
||||||
|
#define TESTHOST_TESTSCENARIO_H_INCLUDED
|
||||||
|
|
||||||
|
#include <JuceHeader.h>
|
||||||
|
#include "AudioInputSource.h"
|
||||||
|
|
||||||
|
/** A timed sequence of actions (MIDI events, parameter changes, patch/bank
|
||||||
|
load & save) plus render/I-O configuration, parsed from a JSON file.
|
||||||
|
|
||||||
|
This is what makes test runs repeatable and batchable instead of ad-hoc
|
||||||
|
CLI invocations - e.g. one file per regression case, run unattended.
|
||||||
|
Nothing in here is JaySynth-specific: events that don't apply to a given
|
||||||
|
plugin (MIDI events sent to a plugin with no MIDI input, say) are simply
|
||||||
|
harmless, since JUCE's MidiBuffer/AudioProcessor API already tolerates
|
||||||
|
that generically.
|
||||||
|
*/
|
||||||
|
class TestScenario
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TestScenario() {}
|
||||||
|
|
||||||
|
struct Event
|
||||||
|
{
|
||||||
|
enum Type
|
||||||
|
{
|
||||||
|
noteOn = 0,
|
||||||
|
noteOff,
|
||||||
|
controller,
|
||||||
|
param,
|
||||||
|
loadPatch,
|
||||||
|
loadBank,
|
||||||
|
savePatch,
|
||||||
|
saveBank
|
||||||
|
};
|
||||||
|
|
||||||
|
int64 sample = 0;
|
||||||
|
Type type = noteOn;
|
||||||
|
|
||||||
|
// MIDI note/controller fields
|
||||||
|
int channel = 1;
|
||||||
|
int note = 60;
|
||||||
|
int velocity = 100;
|
||||||
|
int controllerNumber = 0;
|
||||||
|
int controllerValue = 0;
|
||||||
|
|
||||||
|
// Parameter-change fields
|
||||||
|
int paramIndex = 0;
|
||||||
|
float paramValue = 0.0f;
|
||||||
|
|
||||||
|
// Patch/bank load & save fields
|
||||||
|
String file;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parses jsonFile. Relative file paths named inside it (input.wavFile,
|
||||||
|
loadPatch/loadBank, and any event's "file") are resolved relative to
|
||||||
|
the JSON file's own directory, not the process's working directory,
|
||||||
|
so a scenario file is portable regardless of where it's run from.
|
||||||
|
*/
|
||||||
|
bool loadFromFile (const File &jsonFile);
|
||||||
|
|
||||||
|
double sampleRate = 44100.0;
|
||||||
|
int blockSize = 512;
|
||||||
|
int64 durationSamples = 0;
|
||||||
|
|
||||||
|
AudioInputSource::Type inputType = AudioInputSource::silence;
|
||||||
|
double inputFrequencyHz = 440.0;
|
||||||
|
float inputAmplitude = 0.5f;
|
||||||
|
|
||||||
|
String initialLoadPatchFile;
|
||||||
|
String initialLoadBankFile;
|
||||||
|
String recordWavFile;
|
||||||
|
|
||||||
|
/** Sorted ascending by .sample. */
|
||||||
|
Array<Event> events;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool parseTopLevel (const var &root, const File &baseDir);
|
||||||
|
bool parseInput (const var &inputVar);
|
||||||
|
bool parseEvents (const var &eventsVar, const File &baseDir);
|
||||||
|
static bool parseEventType (const String &typeName, Event::Type &outType);
|
||||||
|
|
||||||
|
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TestScenario)
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // TESTHOST_TESTSCENARIO_H_INCLUDED
|
||||||
@@ -4,3 +4,4 @@ CXX_SRCS := main.cpp
|
|||||||
CXX_SRCS += PluginHost.cpp
|
CXX_SRCS += PluginHost.cpp
|
||||||
CXX_SRCS += AudioInputSource.cpp
|
CXX_SRCS += AudioInputSource.cpp
|
||||||
CXX_SRCS += WavRecorder.cpp
|
CXX_SRCS += WavRecorder.cpp
|
||||||
|
CXX_SRCS += TestScenario.cpp
|
||||||
|
|||||||
+199
-44
@@ -6,6 +6,8 @@
|
|||||||
#include "PluginHost.h"
|
#include "PluginHost.h"
|
||||||
#include "AudioInputSource.h"
|
#include "AudioInputSource.h"
|
||||||
#include "WavRecorder.h"
|
#include "WavRecorder.h"
|
||||||
|
#include "TestScenario.h"
|
||||||
|
#include "PerformanceStats.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -15,11 +17,19 @@ namespace
|
|||||||
"testhost - headless plugin test host\n"
|
"testhost - headless plugin test host\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Usage:\n"
|
"Usage:\n"
|
||||||
" testhost --plugin <path.so> [options]\n"
|
" testhost --plugin <path.so> --scenario <file.json> [--perfCsv <file>]\n"
|
||||||
|
" testhost --plugin <path.so> [ad-hoc options] [--perfCsv <file>]\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Options:\n"
|
"With --scenario, a JSON file drives everything (sample rate, block size,\n"
|
||||||
" --patch <file> load a .fxp/.fxb byte blob as the current program state\n"
|
"duration, input signal, a timed sequence of MIDI/param/patch/bank events) -\n"
|
||||||
" before rendering (see note below)\n"
|
"see TestScenario.h for the format. All other ad-hoc options below are ignored\n"
|
||||||
|
"when --scenario is given.\n"
|
||||||
|
"\n"
|
||||||
|
"Ad-hoc options:\n"
|
||||||
|
" --patch <file> load a patch (single-program) state before rendering\n"
|
||||||
|
" --bank <file> load a bank (full plugin) state before rendering\n"
|
||||||
|
" --savePatch <file> save the patch state after rendering\n"
|
||||||
|
" --saveBank <file> save the bank state after rendering\n"
|
||||||
" --note <note> <vel> MIDI note-on (0-127, 0-127) at sample 0, held for the\n"
|
" --note <note> <vel> MIDI note-on (0-127, 0-127) at sample 0, held for the\n"
|
||||||
" whole render, note-off in the final block\n"
|
" whole render, note-off in the final block\n"
|
||||||
" --param <index> <value> set parameter <index> to <value> (0.0-1.0) before rendering\n"
|
" --param <index> <value> set parameter <index> to <value> (0.0-1.0) before rendering\n"
|
||||||
@@ -30,18 +40,19 @@ namespace
|
|||||||
" --blockSize <n> default 512\n"
|
" --blockSize <n> default 512\n"
|
||||||
" --wav <file> record the plugin's output to a WAV file\n"
|
" --wav <file> record the plugin's output to a WAV file\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Note: --patch/--bank load the raw bytes JUCE's own AudioPluginInstance state\n"
|
"Patch/bank state is exactly what JUCE's AudioPluginInstance::get/setCurrentProgram-\n"
|
||||||
"calls read/write (AudioProcessor::setCurrentProgramStateInformation), not\n"
|
"StateInformation and get/setStateInformation read and write for a hosted VST2\n"
|
||||||
"JaySynth's own GUI-triggered .fxp/.xmp file parsing - a real .fxp/.fxb file's\n"
|
"plugin - i.e. real .fxp/.fxb file bytes.\n"
|
||||||
"bytes are exactly what those calls expect for a hosted VST2 plugin.\n"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Args
|
struct Args
|
||||||
{
|
{
|
||||||
String pluginPath;
|
String pluginPath;
|
||||||
String patchFile;
|
String scenarioFile;
|
||||||
String wavFile;
|
|
||||||
|
// Ad-hoc mode only (ignored if scenarioFile is set):
|
||||||
|
String patchFile, bankFile, savePatchFile, saveBankFile, wavFile;
|
||||||
double durationSeconds = 2.0;
|
double durationSeconds = 2.0;
|
||||||
double sampleRate = 44100.0;
|
double sampleRate = 44100.0;
|
||||||
int blockSize = 512;
|
int blockSize = 512;
|
||||||
@@ -53,6 +64,8 @@ namespace
|
|||||||
float paramValue = 0.0f;
|
float paramValue = 0.0f;
|
||||||
AudioInputSource::Type inputType = AudioInputSource::silence;
|
AudioInputSource::Type inputType = AudioInputSource::silence;
|
||||||
double inputFrequencyHz = 440.0;
|
double inputFrequencyHz = 440.0;
|
||||||
|
|
||||||
|
String perfCsvFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
bool parseArgs (int argc, char *argv[], Args &args)
|
bool parseArgs (int argc, char *argv[], Args &args)
|
||||||
@@ -63,8 +76,16 @@ namespace
|
|||||||
|
|
||||||
if (arg == "--plugin" && i + 1 < argc)
|
if (arg == "--plugin" && i + 1 < argc)
|
||||||
args.pluginPath = argv[++i];
|
args.pluginPath = argv[++i];
|
||||||
|
else if (arg == "--scenario" && i + 1 < argc)
|
||||||
|
args.scenarioFile = argv[++i];
|
||||||
else if (arg == "--patch" && i + 1 < argc)
|
else if (arg == "--patch" && i + 1 < argc)
|
||||||
args.patchFile = argv[++i];
|
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)
|
else if (arg == "--wav" && i + 1 < argc)
|
||||||
args.wavFile = argv[++i];
|
args.wavFile = argv[++i];
|
||||||
else if (arg == "--duration" && i + 1 < argc)
|
else if (arg == "--duration" && i + 1 < argc)
|
||||||
@@ -73,6 +94,8 @@ namespace
|
|||||||
args.sampleRate = String (argv[++i]).getDoubleValue();
|
args.sampleRate = String (argv[++i]).getDoubleValue();
|
||||||
else if (arg == "--blockSize" && i + 1 < argc)
|
else if (arg == "--blockSize" && i + 1 < argc)
|
||||||
args.blockSize = String (argv[++i]).getIntValue();
|
args.blockSize = String (argv[++i]).getIntValue();
|
||||||
|
else if (arg == "--perfCsv" && i + 1 < argc)
|
||||||
|
args.perfCsvFile = argv[++i];
|
||||||
else if (arg == "--note" && i + 2 < argc)
|
else if (arg == "--note" && i + 2 < argc)
|
||||||
{
|
{
|
||||||
args.hasNote = true;
|
args.hasNote = true;
|
||||||
@@ -126,6 +149,108 @@ namespace
|
|||||||
|
|
||||||
return true;
|
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[])
|
int main (int argc, char *argv[])
|
||||||
@@ -143,8 +268,19 @@ int main (int argc, char *argv[])
|
|||||||
// on juce_gui_basics.
|
// on juce_gui_basics.
|
||||||
ScopedJuceInitialiser_GUI juceInit;
|
ScopedJuceInitialiser_GUI juceInit;
|
||||||
|
|
||||||
|
TestScenario scenario;
|
||||||
|
if (args.scenarioFile.isNotEmpty())
|
||||||
|
{
|
||||||
|
if (! scenario.loadFromFile (File (args.scenarioFile)))
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buildScenarioFromArgs (args, scenario);
|
||||||
|
}
|
||||||
|
|
||||||
PluginHost host;
|
PluginHost host;
|
||||||
if (! host.load (args.pluginPath, args.sampleRate, args.blockSize))
|
if (! host.load (args.pluginPath, scenario.sampleRate, scenario.blockSize))
|
||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
const int numInputChannels = host.getNumInputChannels();
|
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",
|
printf ("testhost: loaded '%s' (%d in / %d out, %d parameters)\n",
|
||||||
args.pluginPath.toRawUTF8(), numInputChannels, numOutputChannels, host.getNumParameters());
|
args.pluginPath.toRawUTF8(), numInputChannels, numOutputChannels, host.getNumParameters());
|
||||||
|
|
||||||
if (args.patchFile.isNotEmpty())
|
if (scenario.initialLoadBankFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadBankFile, true, host))
|
||||||
{
|
|
||||||
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;
|
return 1;
|
||||||
}
|
|
||||||
host.setCurrentProgramStateInformation (patchData.getData(), (int) patchData.getSize());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.hasParam)
|
if (scenario.initialLoadPatchFile.isNotEmpty() && ! loadStateFile (scenario.initialLoadPatchFile, false, host))
|
||||||
host.setParameter (args.paramIndex, args.paramValue);
|
return 1;
|
||||||
|
|
||||||
AudioInputSource inputSource;
|
AudioInputSource inputSource;
|
||||||
inputSource.prepare (args.sampleRate);
|
inputSource.prepare (scenario.sampleRate);
|
||||||
switch (args.inputType)
|
switch (scenario.inputType)
|
||||||
{
|
{
|
||||||
case AudioInputSource::sine: inputSource.setSine (args.inputFrequencyHz, 0.5f); break;
|
case AudioInputSource::sine: inputSource.setSine (scenario.inputFrequencyHz, scenario.inputAmplitude); break;
|
||||||
case AudioInputSource::whiteNoise: inputSource.setWhiteNoise (0.5f); break;
|
case AudioInputSource::whiteNoise: inputSource.setWhiteNoise (scenario.inputAmplitude); break;
|
||||||
case AudioInputSource::pinkNoise: inputSource.setPinkNoise (0.5f); break;
|
case AudioInputSource::pinkNoise: inputSource.setPinkNoise (scenario.inputAmplitude); break;
|
||||||
case AudioInputSource::impulse: inputSource.setImpulse (1.0f); break;
|
case AudioInputSource::impulse: inputSource.setImpulse (scenario.inputAmplitude); break;
|
||||||
case AudioInputSource::silence:
|
case AudioInputSource::silence:
|
||||||
default: inputSource.setSilence(); break;
|
default: inputSource.setSilence(); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
WavRecorder recorder;
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int numChannels = jmax (numInputChannels, numOutputChannels, 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;
|
int64 samplesDone = 0;
|
||||||
bool sawNaN = false;
|
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();
|
buffer.clear();
|
||||||
if (numInputChannels > 0)
|
if (numInputChannels > 0)
|
||||||
inputSource.fillNextBlock (buffer, numInputChannels, numThisBlock);
|
inputSource.fillNextBlock (buffer, numInputChannels, numThisBlock);
|
||||||
|
|
||||||
MidiBuffer midi;
|
MidiBuffer midi;
|
||||||
if (args.hasNote)
|
|
||||||
{
|
|
||||||
if (samplesDone == 0)
|
|
||||||
midi.addEvent (MidiMessage::noteOn (1, args.note, (uint8) args.velocity), 0);
|
|
||||||
|
|
||||||
if (samplesDone + numThisBlock >= totalSamples)
|
// Non-MIDI actions (param/patch/bank) scheduled inside this block take
|
||||||
midi.addEvent (MidiMessage::noteOff (1, args.note), jmax (0, numThisBlock - 1));
|
// 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)
|
for (int ch = 0; ch < numOutputChannels && ! sawNaN; ++ch)
|
||||||
{
|
{
|
||||||
@@ -231,16 +367,35 @@ int main (int argc, char *argv[])
|
|||||||
samplesDone += numThisBlock;
|
samplesDone += numThisBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any trailing savePatch/saveBank/etc. events scheduled at or after the
|
||||||
|
// final rendered sample still get applied, in order.
|
||||||
|
while (nextEventIndex < scenario.events.size())
|
||||||
|
{
|
||||||
|
MidiBuffer unused;
|
||||||
|
applyEvent (host, scenario.events.getReference (nextEventIndex), unused, 0);
|
||||||
|
++nextEventIndex;
|
||||||
|
}
|
||||||
|
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
|
|
||||||
|
if (args.savePatchFile.isNotEmpty())
|
||||||
|
saveStateFile (args.savePatchFile, false, host);
|
||||||
|
if (args.saveBankFile.isNotEmpty())
|
||||||
|
saveStateFile (args.saveBankFile, true, host);
|
||||||
|
|
||||||
host.releaseResources();
|
host.releaseResources();
|
||||||
|
|
||||||
if (sawNaN)
|
if (sawNaN)
|
||||||
fprintf (stderr, "testhost: WARNING - output contained NaN/Inf samples\n");
|
fprintf (stderr, "testhost: WARNING - output contained NaN/Inf samples\n");
|
||||||
|
|
||||||
printf ("testhost: rendered %.3fs (%lld samples)%s%s\n",
|
printf ("testhost: rendered %.3fs (%lld samples)%s%s\n",
|
||||||
(double) samplesDone / args.sampleRate, (long long) samplesDone,
|
(double) samplesDone / scenario.sampleRate, (long long) samplesDone,
|
||||||
args.wavFile.isNotEmpty() ? " -> " : "",
|
scenario.recordWavFile.isNotEmpty() ? " -> " : "",
|
||||||
args.wavFile.isNotEmpty() ? args.wavFile.toRawUTF8() : "");
|
scenario.recordWavFile.isNotEmpty() ? scenario.recordWavFile.toRawUTF8() : "");
|
||||||
|
|
||||||
|
perfStats.printSummary();
|
||||||
|
if (args.perfCsvFile.isNotEmpty())
|
||||||
|
perfStats.writeCsv (File (args.perfCsvFile));
|
||||||
|
|
||||||
return sawNaN ? 2 : 0;
|
return sawNaN ? 2 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user