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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011dhtwRLARk4eiPngcQykLJ
This commit is contained in:
@@ -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 += AudioInputSource.cpp
|
||||
CXX_SRCS += WavRecorder.cpp
|
||||
CXX_SRCS += TestScenario.cpp
|
||||
|
||||
+200
-45
@@ -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 <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"
|
||||
"Options:\n"
|
||||
" --patch <file> 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 <file> load a patch (single-program) state before rendering\n"
|
||||
" --bank <file> load a bank (full plugin) state before rendering\n"
|
||||
" --savePatch <file> save the patch state after rendering\n"
|
||||
" --saveBank <file> save the bank state after rendering\n"
|
||||
" --note <note> <vel> MIDI note-on (0-127, 0-127) at sample 0, held for the\n"
|
||||
" whole render, note-off in the final block\n"
|
||||
" --param <index> <value> set parameter <index> to <value> (0.0-1.0) before rendering\n"
|
||||
@@ -30,18 +40,19 @@ namespace
|
||||
" --blockSize <n> default 512\n"
|
||||
" --wav <file> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user