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