Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .clang-format
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ SpaceAfterTemplateKeyword: false
BreakAfterOpenBracketFunction: true
BreakBeforeCloseBracketFunction: true

AlignAfterOpenBracket: DontAlign
AlignOperands: DontAlign

Cpp11BracedListStyle: false

IncludeBlocks: Regroup
Expand Down
7 changes: 5 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ set(JSON_Install OFF CACHE INTERNAL "")
add_subdirectory(json)
target_include_directories(Plugalyzer PRIVATE json/include)

file(GLOB header_files LIST_DIRECTORIES FALSE "Source/*.h")
file(GLOB cpp_files LIST_DIRECTORIES FALSE "Source/*.cpp")
file(GLOB_RECURSE header_files LIST_DIRECTORIES FALSE "Source/*.h")
file(GLOB_RECURSE cpp_files LIST_DIRECTORIES FALSE "Source/*.cpp")
target_include_directories(Plugalyzer PRIVATE "Source")

target_sources(Plugalyzer
PRIVATE
Expand All @@ -50,6 +51,8 @@ target_compile_definitions(Plugalyzer
JUCE_PLUGINHOST_LADSPA=1
JUCE_PLUGINHOST_LV2=1
JUCE_DISABLE_JUCE_VERSION_PRINTING=1
JUCE_LOG_ASSERTIONS=1
JUCE_DISABLE_CAUTIOUS_PARAMETER_ID_CHECKING=1 # LV2 Param IDs are incompatible with AAX, which we are not hosting
)

target_link_libraries(Plugalyzer
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,11 @@ Example output:
}
}
```

## Operate on Plugin State
The `state` command can::
1. output the default state of the plugin.
1. take a parameter automation file (json) as input, set those parameters on the plugin and output the resulting plugin state.
1. take a previously exported plugin state (in binary format), apply it to the plugin and output the resulting parameters in json format.

Plugin state can be output in binary format or, if the state uses JUCE Value Trees, it can be serialized into XML so you can read it.
5 changes: 3 additions & 2 deletions Source/Automation.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "Automation.h"
#include "Parsers.h"
#include "Utils.h"

ParameterAutomation Automation::parseAutomationDefinition(const std::string& jsonStr,
Expand Down Expand Up @@ -79,7 +80,7 @@ size_t Automation::parseKeyframeTime(juce::String timeStr, double sampleRate,
// parse the floating-point number
float time;
try {
time = parseFloatStrict(timeStr.toStdString());
time = parse::floatStrict(timeStr.toStdString());
} catch (const std::invalid_argument& ia) {
throw std::runtime_error("Invalid floating-point number '" + timeStr.toStdString() +
"'");
Expand All @@ -95,7 +96,7 @@ size_t Automation::parseKeyframeTime(juce::String timeStr, double sampleRate,
// no known suffix was detected - parse as an integer sample value
size_t time;
try {
time = parseULongStrict(timeStr.toStdString());
time = parse::uLongStrict(timeStr.toStdString());
} catch (std::invalid_argument& ia) {
throw std::runtime_error("Invalid sample index '" + timeStr.toStdString() + "'");
}
Expand Down
25 changes: 25 additions & 0 deletions Source/Errors.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#pragma once

#include <CLI/CLI.hpp>
#include <juce_audio_processors/juce_audio_processors.h>

#include <stdexcept>

class FileLoadError : public CLI::Error {
public:
FileLoadError(std::string msg, int exit_code)
: CLI::Error("FileLoadError", std::move(msg), exit_code) {}
};

struct CLIException : std::runtime_error {
explicit CLIException(const std::string& message) : std::runtime_error(message) {}
explicit CLIException(const char* message) : std::runtime_error(message) {}
explicit CLIException(const juce::String& message)
: std::runtime_error(message.toStdString()) {}
};

class FailedXmlError : public CLI::Error {
public:
FailedXmlError(std::string msg, int exit_code)
: CLI::Error("FailedXmlError", std::move(msg), exit_code) {}
};
82 changes: 82 additions & 0 deletions Source/Parsers.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include "Parsers.h"

#include "Errors.h"
#include "Utils.h"

#define PARSE_STRICT(funName) \
size_t endPtr; \
auto num = (funName) (str, &endPtr); \
if (endPtr != str.size()) { \
throw std::invalid_argument("Invalid number: '" + str + "'"); \
} \
return num


namespace parse
{
OutputFormat outputFormat(const std::string& formatName) {
if (formatMap.contains(formatName)) {
return formatMap.at(formatName);
} else {
return OutputFormat::text;
}
}

juce::File stringToFile(const std::string& filePath) {
if (juce::File::isAbsolutePath(filePath)) {
return juce::File(filePath);
} else {
return juce::File::getCurrentWorkingDirectory().getChildFile(filePath);
}
}

float floatStrict(const std::string& str) { PARSE_STRICT(std::stof); }

unsigned long uLongStrict(const std::string& str) { PARSE_STRICT(std::stoul); }

ParameterCLIArgument pluginParameterArgument(const std::string& str) {
juce::StringArray tokens;
tokens.addTokens(str, ":", "\"'");

bool isNormalizedValue = tokens.size() == 3;

if (isNormalizedValue && tokens[2] != "n") {
throw CLIException("Invalid parameter modifier: '" + tokens[2] + "'. Only 'n' is allowed");
}

if (tokens.size() != 2 && tokens.size() != 3) {
throw CLIException("'" + str + "' is not a colon-separated key-value pair");
}

std::string valueStr = tokens[1].toStdString();
if (isNormalizedValue) {
float normalizedValue;
try {
normalizedValue = parse::floatStrict(valueStr);
} catch (const std::invalid_argument& ia) {
throw CLIException("Normalized parameter value must be a number, but is '" + valueStr +
"'");
}

if (normalizedValue < 0 || normalizedValue > 1) {
throw CLIException("Normalized parameter value must be between 0 and 1, but is " +
std::to_string(normalizedValue));
}

return {
.parameterName = tokens[0].toStdString(),
.textValue = {},
.normalizedValue = normalizedValue,
.isNormalizedValue = true,
};
}

return {
.parameterName = tokens[0].toStdString(),
.textValue = valueStr,
.normalizedValue = 0.f,
.isNormalizedValue = false,
};
}

} // namespace parse
60 changes: 60 additions & 0 deletions Source/Parsers.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#pragma once

#include "Utils.h"

// Parsers for CLI11
// Signature:
// YourObject parse(const std::string& arg)
// Usage:
// option->each([&](std::string arg){ memberVariable = parse(arg); });

namespace parse
{
OutputFormat outputFormat(const std::string& formatName);

/**
* Converts a string file path to a juce::File object.
* Useful for accepting relatve file path CLI arguments and
* making a juce Path without getting an assertion.
*
* @param filePath The file path as a string. Can be absolute or relative.
* @return The file path.
* If the path is absolute, it's used directly.
* If the path is relative, it's resolved relative to the current working
* directory.
*/
juce::File stringToFile(const std::string& filePath);

/**
* Parses a string into a floating-point number.
* If not the entire string is a number, an error is thrown,
* unlike <code>std::stof</code> which only returns the leading number in such a case.
*
* @param str The string to parse.
* @return The parsed number.
* @throws std::invalid_argument If the input is not a valid number.
*/
float floatStrict(const std::string& str);

/**
* Parses a string into an unsigned long number.
* If not the entire string is a number, an error is thrown,
* unlike <code>std::stoul</code> which only returns the leading number in such a case.
*
* @param str The string to parse.
* @return The parsed number.
* @throws std::invalid_argument If the input is not a valid number.
*/
unsigned long uLongStrict(const std::string& str);

/**
* Parses a plugin parameter string in the format <key>:<value>[:n].
*
* @param str The string to parse.
* @return The parsed parameter argument.
* @throws CLIException If the input string is not formatted correctly.
*/
ParameterCLIArgument pluginParameterArgument(const std::string& str);

} // namespace Parse

152 changes: 152 additions & 0 deletions Source/PluginProcess.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#include "Automation.h"
#include "PluginProcess.h"
#include "Parsers.h"
#include "Errors.h"
#include "Utils.h"

#include <CLI/CLI.hpp>

juce::OwnedArray<juce::AudioFormatReader>
createAudioFileReaders(const std::vector<juce::File>& files, size_t& maxLengthInSamplesOut) {
maxLengthInSamplesOut = 0;

// parse the input files
juce::AudioFormatManager audioFormatManager;
audioFormatManager.registerBasicFormats();

juce::OwnedArray<juce::AudioFormatReader> audioInputFileReaders;

double sampleRate{ 0.0 };
for (size_t i = 0; i < files.size(); i++) {
auto& inputFile = files[i];

auto inputFileReader = audioFormatManager.createReaderFor(inputFile);
if (!inputFileReader) {
throw CLIException("Could not read input file " + inputFile.getFullPathName());
}

audioInputFileReaders.add(inputFileReader);

// ensure the sample rate of all input files is the same
if (i == 0) {
sampleRate = inputFileReader->sampleRate;
} else if (!juce::exactlyEqual(inputFileReader->sampleRate, sampleRate)) {
throw CLIException("Mismatched sample rate between input files");
}

maxLengthInSamplesOut =
std::max(maxLengthInSamplesOut, (size_t) inputFileReader->lengthInSamples);
}

return audioInputFileReaders;
}

juce::MidiFile readMIDIFile(const juce::File& file, double sampleRate, size_t& lengthInSamplesOut) {
juce::MidiFile midiFile;
lengthInSamplesOut = 0;

auto inputStream = file.createInputStream();
if (!midiFile.readFrom(*inputStream, true)) {
throw CLIException("Error reading MIDI input file");
}

// since MIDI tick length is defined in the file header,
// let JUCE take care of the conversion for us and work with timestamps in seconds
midiFile.convertTimestampTicksToSeconds();

// find the timestamp of the last MIDI event in the file
// to ensure we process until that MIDI event is reached
for (int i = 0; i < midiFile.getNumTracks(); i++) {
auto midiTrack = midiFile.getTrack(i);
for (auto& meh : *midiTrack) {
auto timestampSamples = secondsToSamples(meh->message.getTimeStamp(), sampleRate);
lengthInSamplesOut = std::max(lengthInSamplesOut, timestampSamples);
}
}

return midiFile;
}

juce::AudioPluginInstance::BusesLayout createBusLayout(
const juce::AudioPluginInstance& plugin,
const juce::OwnedArray<juce::AudioFormatReader>& audioInputFileReaders,
const std::optional<unsigned int>& outputChannelCountOpt,
unsigned int& totalNumInputChannelsOut, unsigned int& totalNumOutputChannelsOut
) {

totalNumInputChannelsOut = 0;
juce::AudioPluginInstance::BusesLayout layout;
if (audioInputFileReaders.isEmpty()) {
// if no input files are provided, use the plugin's default input bus layout
// to maximize compatibility with synths that expect an input
layout.inputBuses = plugin.getBusesLayout().inputBuses;
for (auto& cs : layout.inputBuses) {
totalNumInputChannelsOut += (unsigned int) cs.size();
}

} else {
for (auto* inputFileReader : audioInputFileReaders) {
layout.inputBuses.add(
juce::AudioChannelSet::canonicalChannelSet((int) inputFileReader->numChannels));
totalNumInputChannelsOut += inputFileReader->numChannels;
}
}

// create an output bus with the desired amount of channels,
// defaulting to the same amount of channels as the main input file if one exists,
// or the plugin's default bus layout otherwise.
totalNumOutputChannelsOut = (outputChannelCountOpt.value_or(
audioInputFileReaders.isEmpty()
? (unsigned int) plugin.getBusesLayout().getMainOutputChannels()
: audioInputFileReaders[0]->numChannels));
layout.outputBuses.add(
juce::AudioChannelSet::canonicalChannelSet((int) totalNumOutputChannelsOut));

return layout;
}

ParameterAutomation parseParameters(
const juce::AudioPluginInstance& plugin, double sampleRate, size_t inputLengthInSamples,
const std::optional<juce::File>& parameterFileOpt, const std::vector<std::string>& cliParameters
) {
ParameterAutomation automation;

// read automation from file
if (parameterFileOpt) {
automation = Automation::parseAutomationDefinition(
parameterFileOpt->loadFileAsString().toStdString(), plugin, sampleRate,
inputLengthInSamples);
}

// parse command-line supplied parameters
for (auto& arg : cliParameters) {
auto [paramName, textValue, normalizedValue, isNormalizedValue] =
parse::pluginParameterArgument(arg);

// convert parameter value from text representation to a single keyframe,
// which causes the same value to be applied over the entire duration
auto* param = PluginUtils::getPluginParameterByName(plugin, paramName);

if (!isNormalizedValue) {
if (!Automation::parameterSupportsTextToValueConversion(param)) {
throw CLIException("Parameter '" + paramName +
"' does not support text values. Use :n suffix to supply "
"a normalized value instead");
}

normalizedValue = param->getValueForText(textValue);
}

// warn the user if the parameter overrides a parameter specified in the file
if (automation.contains(paramName)) {
std::cerr << "Plugin parameter '" << paramName
<< "' is specified in the parameter file and overridden by a command-line "
"parameter."
<< std::endl;
}

automation[paramName] = AutomationKeyframes({{0, normalizedValue}});
}

return automation;
}
Loading
Loading