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:
2026-07-27 19:19:08 +02:00
co-authored by Claude Sonnet 5
parent 97b1c34cee
commit 39353751e6
5 changed files with 543 additions and 45 deletions
+79
View File
@@ -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
+178
View File
@@ -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;
}
+85
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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;
}