diff --git a/.clang-format b/.clang-format index 1bb6dca..77c87f7 100644 --- a/.clang-format +++ b/.clang-format @@ -13,6 +13,9 @@ SpaceAfterTemplateKeyword: false BreakAfterOpenBracketFunction: true BreakBeforeCloseBracketFunction: true +AlignAfterOpenBracket: DontAlign +AlignOperands: DontAlign + Cpp11BracedListStyle: false IncludeBlocks: Regroup diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c3283a..b8c6e95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 @@ -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 diff --git a/README.md b/README.md index 31b1beb..09b9bad 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/Source/Automation.cpp b/Source/Automation.cpp index d9ec024..1d3338f 100644 --- a/Source/Automation.cpp +++ b/Source/Automation.cpp @@ -1,4 +1,5 @@ #include "Automation.h" +#include "Parsers.h" #include "Utils.h" ParameterAutomation Automation::parseAutomationDefinition(const std::string& jsonStr, @@ -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() + "'"); @@ -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() + "'"); } diff --git a/Source/Errors.h b/Source/Errors.h new file mode 100644 index 0000000..19bce37 --- /dev/null +++ b/Source/Errors.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include + +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) {} +}; \ No newline at end of file diff --git a/Source/Parsers.cpp b/Source/Parsers.cpp new file mode 100644 index 0000000..3cb08fc --- /dev/null +++ b/Source/Parsers.cpp @@ -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 diff --git a/Source/Parsers.h b/Source/Parsers.h new file mode 100644 index 0000000..40df849 --- /dev/null +++ b/Source/Parsers.h @@ -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 std::stof 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 std::stoul 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 :[: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 + diff --git a/Source/PluginProcess.cpp b/Source/PluginProcess.cpp new file mode 100644 index 0000000..a0ec51a --- /dev/null +++ b/Source/PluginProcess.cpp @@ -0,0 +1,152 @@ +#include "Automation.h" +#include "PluginProcess.h" +#include "Parsers.h" +#include "Errors.h" +#include "Utils.h" + +#include + +juce::OwnedArray +createAudioFileReaders(const std::vector& files, size_t& maxLengthInSamplesOut) { + maxLengthInSamplesOut = 0; + + // parse the input files + juce::AudioFormatManager audioFormatManager; + audioFormatManager.registerBasicFormats(); + + juce::OwnedArray 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& audioInputFileReaders, + const std::optional& 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& parameterFileOpt, const std::vector& 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; +} diff --git a/Source/PluginProcess.h b/Source/PluginProcess.h new file mode 100644 index 0000000..703af1e --- /dev/null +++ b/Source/PluginProcess.h @@ -0,0 +1,67 @@ +#pragma once + +#include "Automation.h" + +#include +#include + + +/** + * Creates readers for the given audio files, verifying that their sample rate matches. + * + * @param files The audio files. + * @param maxLengthInSamplesOut Variable that will be set to the length of the longest audio + * file, in samples. + * @return The audio file readers. + */ +juce::OwnedArray +createAudioFileReaders(const std::vector& files, size_t& maxLengthInSamplesOut); + +/** + * Parses the given MIDI file, with timestamps being converted seconds. + * + * @param midiFile The MIDI file. + * @param sampleRate The sample rate to use for timestamp conversion. + * @param lengthInSamplesOut Variable that will be set to the length of the MIDI file in + * samples. + * @return The parsed MIDI file. + */ +juce::MidiFile readMIDIFile(const juce::File& file, double sampleRate, size_t& lengthInSamplesOut); + +/** + * Creates a bus layout with one input bus for each input file. + * + * @param plugin The plugin to create the bus layout for. + * @param audioInputFileReaders The file readers for each input file. + * @param outputChannelCountOpt The amount of channels to use for the output bus. If not + * supplied, the same amount of channels as the main input file is used. If no such file exists, + * the plugin's default bus layout is used. + * @param totalNumInputChannelsOut Variable that will be populated with the total amount of + * input channels the returned layout has. + * @param totalNumOutputChannelsOut Variable that will be populated with the total amount of + * output channels the returned layout has. + * @return The bus layout. + */ +juce::AudioPluginInstance::BusesLayout createBusLayout( + const juce::AudioPluginInstance& plugin, + const juce::OwnedArray& audioInputFileReaders, + const std::optional& outputChannelCountOpt, + unsigned int& totalNumInputChannelsOut, unsigned int& totalNumOutputChannelsOut +); + +/** + * Parses and validates plugin parameters supplied via file and CLI. + * + * @param plugin The plugin to validate the parameters against. + * @param sampleRate The sample rate to use for time conversion purposes. + * @param inputLengthInSamples The total length of the input that will be supplied to the + * plugin, in samples. Used to warn the user about keyframes that lie outside of the range of + * input audio. + * @param parameterFileOpt The parameter file, if supplied. + * @param cliParameters The parameters supplied via CLI. + * @return The parsed plugin parameters. + */ +ParameterAutomation parseParameters( + const juce::AudioPluginInstance& plugin, double sampleRate, size_t inputLengthInSamples, + const std::optional& parameterFileOpt, const std::vector& cliParameters +); \ No newline at end of file diff --git a/Source/PresetLoadingExtensionsVisitor.cpp b/Source/PresetLoadingExtensionsVisitor.cpp index a7b505d..3ce796d 100644 --- a/Source/PresetLoadingExtensionsVisitor.cpp +++ b/Source/PresetLoadingExtensionsVisitor.cpp @@ -1,5 +1,7 @@ #include "PresetLoadingExtensionsVisitor.h" +#include "Errors.h" + PresetLoadingExtensionsVisitor::PresetLoadingExtensionsVisitor(const juce::MemoryBlock& presetDataToUse) : presetData(presetDataToUse) {} diff --git a/Source/ProcessCommand.cpp b/Source/ProcessCommand.cpp deleted file mode 100644 index 85e378f..0000000 --- a/Source/ProcessCommand.cpp +++ /dev/null @@ -1,424 +0,0 @@ -#include "ProcessCommand.h" - -#include "PresetLoadingExtensionsVisitor.h" -#include "Utils.h" - -struct ParameterCLIArgument { - std::string parameterName; - - // I'd use a union for this, but compiler says no - // https://stackoverflow.com/a/70428826 - std::string textValue; - float normalizedValue; - - bool isNormalizedValue; -}; - -/** - * Parses a plugin parameter string in the format :[:n]. - * - * @param str The string to parse. - * @return The parsed parameter argument. - * @throws CLIException If the input string is not formatted correctly. - */ -static ParameterCLIArgument parsePluginParameterArgument(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 = parseFloatStrict(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(), - .normalizedValue = normalizedValue, - .isNormalizedValue = true, - }; - } - - return { - .parameterName = tokens[0].toStdString(), - .textValue = valueStr, - .isNormalizedValue = false, - }; -} - -namespace Validator { -/** - * Validates the format of a plugin parameter passed via CLI to be ":". - * This does not validate if the parameter exists on a plugin. - */ -struct PluginParameterValidator : public CLI::Validator { - PluginParameterValidator() { - name_ = "PLUGIN_PARAM"; - func_ = [](const std::string& str) { - try { - parsePluginParameterArgument(str); - } catch (const std::runtime_error& e) { - return std::string(e.what()); - } - - return std::string(); - }; - } -}; - -const static PluginParameterValidator PluginParameter; - -struct BitDepthValidator : public CLI::Validator { - BitDepthValidator() { - name_ = "BIT_DEPTH"; - func_ = [](const std::string& str) { - try { - int value = std::stoi(str); - if (value != 8 && value != 16 && value != 24 && value != 32) { - return std::string("Bit depth must be 8, 16, 24, or 32"); - } - } catch (const std::exception& e) { - return std::string("Bit depth must be a valid integer"); - } - return std::string(); - }; - } -}; - -const static BitDepthValidator BitDepth; -} // namespace Validator - -std::shared_ptr ProcessCommand::createApp() { - // don't break these lines, please - // clang-format off - std::shared_ptr app = std::make_shared("Processes audio using a plugin", "process"); - - app->add_option("-p,--plugin", pluginPath, "Plugin path")->required()->check(CLI::ExistingPath); // not ExistingFile because on macOS, these bundles are directores - - auto* inputGroup = app->add_option_group("input"); - auto* audioInputOption = inputGroup->add_option("-i,--input", audioInputFiles, "Input audio file path")->check(CLI::ExistingFile); - inputGroup->add_option("-m,--midiInput", midiInputFileOpt, "Input MIDI file path")->check(CLI::ExistingFile); - // require at least one input of either kind - inputGroup->require_option(); - - app->add_option("--preset", presetFileOpt, "Preset file path. Currently only .vstpreset files for VST3 are supported.")->check(CLI::ExistingFile); - - app->add_option("-o,--output", outputFilePath, "Output audio file path")->required(); - app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); - - auto* sampleRateOption = app->add_option("-s,--sampleRate", sampleRate, "The sample rate to use for processing when no audio file is supplied"); - // sample rate is dictated by input audio files if they're provided - audioInputOption->excludes(sampleRateOption); - - app->add_option("-b,--blockSize", blockSize, "The buffer size to use when processing audio"); - app->add_option("-d,--bitDepth", outputBitDepthOpt, "The output file's bit depth. Defaults to the input file's bit depth if present, or 16 bits if no input file is provided.")->check(Validator::BitDepth); - app->add_option("-c,--outChannels", outputChannelCountOpt, "The amount of channels to use for the plugin's output bus"); - - app->add_option("--paramFile", paramsFileOpt, "Path to JSON file to read plugin parameters and automation data from")->check(CLI::ExistingFile); - app->add_option("--param", params, "Plugin parameters to set. Explicitly specified parameters take precedence over parameters read from file")->check(Validator::PluginParameter); - - return app; - // clang-format on -} - -void ProcessCommand::execute() { - size_t totalInputLength; - - unsigned int bitDepth = 16; - - // create audio file readers - auto audioInputFileReaders = createAudioFileReaders(audioInputFiles, totalInputLength); - // use the sample rate of input audio files if provided - if (!audioInputFileReaders.isEmpty()) { - sampleRate = audioInputFileReaders[0]->sampleRate; - bitDepth = audioInputFileReaders[0]->bitsPerSample; - } - - if (outputBitDepthOpt) { - bitDepth = *outputBitDepthOpt; - } - - // read MIDI input file - juce::MidiFile midiFile; - if (midiInputFileOpt) { - size_t midiLength; - midiFile = readMIDIFile(*midiInputFileOpt, sampleRate, midiLength); - totalInputLength = std::max(totalInputLength, midiLength); - } - - // create the plugin instance - auto plugin = PluginUtils::createPluginInstance(pluginPath, sampleRate, (int) blockSize); - - if (presetFileOpt) { - // read preset file into memory block - juce::MemoryBlock presetData; - const auto presetInputStream = presetFileOpt->createInputStream(); - presetInputStream->readIntoMemoryBlock(presetData); - // TODO: how to handle errors? - - // apply preset - PresetLoadingExtensionsVisitor presetLoader(presetData); - plugin->getExtensions(presetLoader); - } - - // create and apply the bus layout - unsigned int totalNumInputChannels, totalNumOutputChannels; - auto layout = createBusLayout(*plugin, audioInputFileReaders, outputChannelCountOpt, - totalNumInputChannels, totalNumOutputChannels); - if (!plugin->setBusesLayout(layout)) { - throw CLIException("Plugin does not support requested bus layout"); - } - - // parse plugin parameters - auto automation = parseParameters(*plugin, sampleRate, totalInputLength, paramsFileOpt, params); - - plugin->prepareToPlay(sampleRate, (int) blockSize); - const auto latency = plugin->getLatencySamples(); - - // open output stream - if (outputFilePath.exists() && !overwriteOutputFile) { - throw CLIException("Output file already exists! Use --overwrite to overwrite the file"); - } - - std::unique_ptr outWriter; - outputFilePath.deleteFile(); - if (std::unique_ptr outputStream{ outputFilePath.createOutputStream(static_cast(blockSize)) }) { - juce::WavAudioFormat outFormat; - outWriter = outFormat.createWriterFor( - outputStream, // stream is now managed by writer - juce::AudioFormatWriterOptions{} - .withSampleRate(sampleRate) - .withNumChannels(static_cast(totalNumOutputChannels)) - .withBitsPerSample(static_cast(bitDepth)) - ); - } else { - throw CLIException("Could not create output stream to write to file " + - outputFilePath.getFullPathName()); - } - - // process the input files with the plugin - juce::AudioBuffer sampleBuffer( - (int) std::max(totalNumInputChannels, totalNumOutputChannels), (int) blockSize); - - juce::MidiBuffer midiBuffer; - size_t sampleIndex = 0; - int samplesSkipped = 0; - while (sampleIndex < totalInputLength + static_cast(latency)) { - sampleBuffer.clear(); - - // read next segment of audio input files into buffer - unsigned int targetChannel = 0; - for (auto* inputFileReader : audioInputFileReaders) { - if (!inputFileReader->read(sampleBuffer.getArrayOfWritePointers() + targetChannel, - (int) inputFileReader->numChannels, static_cast(sampleIndex), - (int) blockSize)) { - throw CLIException("Error reading input file"); // TODO: more context, which file? - } - - targetChannel += inputFileReader->numChannels; - } - - // populate MIDI buffer with the MIDI events - // falling into the current processing block. - // we simply take MIDI events from all tracks and supply them to the buffer - - // if the user only wants a single track of a multi-track MIDI file, - // they should extract that track into a separate MIDI file. - midiBuffer.clear(); - for (int i = 0; i < midiFile.getNumTracks(); i++) { - auto midiTrack = midiFile.getTrack(i); - - for (auto& meh : *midiTrack) { - auto timestampSamples = secondsToSamples(meh->message.getTimeStamp(), sampleRate); - if (timestampSamples >= sampleIndex && timestampSamples < sampleIndex + static_cast(blockSize)) { - midiBuffer.addEvent(meh->message, (int) (timestampSamples - sampleIndex)); - } - } - } - - // apply automation - Automation::applyParameters(*plugin, automation, sampleIndex); - - // process with plugin - plugin->processBlock(sampleBuffer, midiBuffer); - - // skip the first samples that are just empty because of the plugin's latency - int startSample = 0; - if (samplesSkipped < latency) { - startSample = std::min(latency - samplesSkipped, blockSize); - samplesSkipped += startSample; - } - - // write to output - if (startSample < blockSize) { - outWriter->writeFromAudioSampleBuffer(sampleBuffer, startSample, - (int) blockSize - startSample); - } - - sampleIndex += static_cast(blockSize); - } -} - -juce::OwnedArray -ProcessCommand::createAudioFileReaders(const std::vector& files, - size_t& maxLengthInSamplesOut) { - maxLengthInSamplesOut = 0; - - // parse the input files - juce::AudioFormatManager audioFormatManager; - audioFormatManager.registerBasicFormats(); - - juce::OwnedArray 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 ProcessCommand::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 ProcessCommand::createBusLayout( - const juce::AudioPluginInstance& plugin, - const juce::OwnedArray& audioInputFileReaders, - const std::optional& 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 -ProcessCommand::parseParameters(const juce::AudioPluginInstance& plugin, double sampleRate, - size_t inputLengthInSamples, - const std::optional& parameterFileOpt, - const std::vector& 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] = - parsePluginParameterArgument(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::cout << "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; -} diff --git a/Source/ProcessCommand.h b/Source/ProcessCommand.h deleted file mode 100644 index 5ad1a30..0000000 --- a/Source/ProcessCommand.h +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once - -#include "Automation.h" -#include "CLICommand.h" -#include -#include - -class ProcessCommand : public CLICommand { - public: - std::shared_ptr createApp() override; - - void execute() override; - - private: - juce::String pluginPath; - std::vector audioInputFiles; - std::optional midiInputFileOpt; - std::optional presetFileOpt; - juce::File outputFilePath; - bool overwriteOutputFile; - double sampleRate = 44100; - int blockSize = 1024; - std::optional outputChannelCountOpt; - std::optional outputBitDepthOpt; - std::optional paramsFileOpt; - std::vector params; - - /** - * Creates readers for the given audio files, verifying that their sample rate matches. - * - * @param files The audio files. - * @param maxLengthInSamplesOut Variable that will be set to the length of the longest audio - * file, in samples. - * @return The audio file readers. - */ - static juce::OwnedArray - createAudioFileReaders(const std::vector& files, size_t& maxLengthInSamplesOut); - - /** - * Parses the given MIDI file, with timestamps being converted seconds. - * - * @param midiFile The MIDI file. - * @param sampleRate The sample rate to use for timestamp conversion. - * @param lengthInSamplesOut Variable that will be set to the length of the MIDI file in - * samples. - * @return The parsed MIDI file. - */ - static juce::MidiFile readMIDIFile(const juce::File& file, double sampleRate, - size_t& lengthInSamplesOut); - - /** - * Creates a bus layout with one input bus for each input file. - * - * @param plugin The plugin to create the bus layout for. - * @param audioInputFileReaders The file readers for each input file. - * @param outputChannelCountOpt The amount of channels to use for the output bus. If not - * supplied, the same amount of channels as the main input file is used. If no such file exists, - * the plugin's default bus layout is used. - * @param totalNumInputChannelsOut Variable that will be populated with the total amount of - * input channels the returned layout has. - * @param totalNumOutputChannelsOut Variable that will be populated with the total amount of - * output channels the returned layout has. - * @return The bus layout. - */ - static juce::AudioPluginInstance::BusesLayout - createBusLayout(const juce::AudioPluginInstance& plugin, - const juce::OwnedArray& audioInputFileReaders, - const std::optional& outputChannelCountOpt, - unsigned int& totalNumInputChannelsOut, - unsigned int& totalNumOutputChannelsOut); - - /** - * Parses and validates plugin parameters supplied via file and CLI. - * - * @param plugin The plugin to validate the parameters against. - * @param sampleRate The sample rate to use for time conversion purposes. - * @param inputLengthInSamples The total length of the input that will be supplied to the - * plugin, in samples. Used to warn the user about keyframes that lie outside of the range of - * input audio. - * @param parameterFileOpt The parameter file, if supplied. - * @param cliParameters The parameters supplied via CLI. - * @return The parsed plugin parameters. - */ - static ParameterAutomation parseParameters(const juce::AudioPluginInstance& plugin, - double sampleRate, size_t inputLengthInSamples, - const std::optional& parameterFileOpt, - const std::vector& cliParameters); -}; diff --git a/Source/Utils.cpp b/Source/Utils.cpp index dccf300..043a260 100644 --- a/Source/Utils.cpp +++ b/Source/Utils.cpp @@ -1,73 +1,15 @@ +#include "Errors.h" #include "Utils.h" #include #include #include -OutputFormat parseOutputFormat(const char* formatName) { - jassert(formatName != nullptr); - - static const std::unordered_map formatMap{ - { "text", OutputFormat::text }, { "json", OutputFormat::json } - }; - - if (formatMap.contains(formatName)) { - return formatMap.at(formatName); - } else { - return OutputFormat::text; - } -} - -OutputFormat parseOutputFormat(const std::string& formatName) { - static const std::unordered_map formatMap{ - { "text", OutputFormat::text }, { "json", OutputFormat::json } - }; - - 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); - } -} - -std::string validateOutputPath(const std::string& arg) { - auto file = stringToFile(arg); - if (!file.getParentDirectory().exists()) { - return "Output parent directory does not exist"; - } - return std::string(); -} - size_t secondsToSamples(double sec, double sampleRate) { return (size_t) (sec * sampleRate); } -#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 - - -float parseFloatStrict(const std::string &str) { - PARSE_STRICT(std::stof); -} - -unsigned long parseULongStrict(const std::string &str) { - PARSE_STRICT(std::stoul); -} - -std::unique_ptr -PluginUtils::createPluginInstance(const juce::String& pluginPath, double initialSampleRate, - int initialBlockSize) { +std::unique_ptr PluginUtils::createPluginInstance( + const juce::String& pluginPath, double initialSampleRate, int initialBlockSize +) { juce::AudioPluginFormatManager audioPluginFormatManager; addDefaultFormatsToManager(audioPluginFormatManager); @@ -77,8 +19,9 @@ PluginUtils::createPluginInstance(const juce::String& pluginPath, double initial juce::OwnedArray pluginDescriptions; juce::KnownPluginList kpl; - kpl.scanAndAddDragAndDroppedFiles(audioPluginFormatManager, juce::StringArray(pluginPath), - pluginDescriptions); + kpl.scanAndAddDragAndDroppedFiles( + audioPluginFormatManager, juce::StringArray(pluginPath), pluginDescriptions + ); // check if the requested plugin was found if (pluginDescriptions.isEmpty()) { @@ -92,8 +35,9 @@ PluginUtils::createPluginInstance(const juce::String& pluginPath, double initial std::unique_ptr plugin; { juce::String err; - plugin = audioPluginFormatManager.createPluginInstance(pluginDescription, initialSampleRate, - initialBlockSize, err); + plugin = audioPluginFormatManager.createPluginInstance( + pluginDescription, initialSampleRate, initialBlockSize, err + ); if (!plugin) { throw CLIException("Error creating plugin instance: " + err); @@ -103,14 +47,16 @@ PluginUtils::createPluginInstance(const juce::String& pluginPath, double initial return plugin; } -juce::AudioProcessorParameter* -PluginUtils::getPluginParameterByName(const juce::AudioPluginInstance& plugin, - const std::string& parameterName) { +juce::AudioProcessorParameter* PluginUtils::getPluginParameterByName( + const juce::AudioPluginInstance& plugin, const std::string& parameterName +) { - auto* paramIt = std::find_if(plugin.getParameters().begin(), plugin.getParameters().end(), - [¶meterName](juce::AudioProcessorParameter* parameter) { - return parameter->getName(1024).toStdString() == parameterName; - }); + auto* paramIt = std::find_if( + plugin.getParameters().begin(), plugin.getParameters().end(), + [¶meterName](juce::AudioProcessorParameter* parameter) { + return parameter->getName(1024).toStdString() == parameterName; + } + ); if (paramIt == plugin.getParameters().end()) { throw std::runtime_error("Unknown parameter identifier '" + parameterName + "'"); @@ -119,6 +65,21 @@ PluginUtils::getPluginParameterByName(const juce::AudioPluginInstance& plugin, return *paramIt; } +void loadPluginStateFromFile(juce::AudioPluginInstance& plugin, const juce::File& statePath, juce::MemoryBlock& state) { + if (statePath != juce::File{}) { + if (statePath.loadFileAsData(state)) { + plugin.setStateInformation(state.getData(), state.getSize()); + } else { + throw FileLoadError{ + std::format( + "Couldn't read file: {}", statePath.getFullPathName().toStdString() + ), + 150 + }; + } + } +} + juce::StringArray getDiscreteValueStrings(const juce::AudioProcessorParameter& param) { if (!param.isDiscrete()) return {}; @@ -241,3 +202,17 @@ void outputResult(const std::string& text, juce::File outPath, bool overwrite) { } } } + +void outputResult(const juce::MemoryBlock& data, juce::File outPath, bool overwrite) { + if (outPath == juce::File{}) { + std::cout.write( + static_cast(data.getData()), static_cast(data.getSize()) + ); + } else { + if (overwrite) { + outPath.replaceWithData(data.getData(), data.getSize()); + } else { + outPath.getNonexistentSibling(false).replaceWithData(data.getData(), data.getSize()); + } + } +} diff --git a/Source/Utils.h b/Source/Utils.h index d53cb98..3889202 100644 --- a/Source/Utils.h +++ b/Source/Utils.h @@ -3,39 +3,25 @@ #include #include -enum class OutputFormat { text, json }; +enum class OutputFormat { text, json, binary, xml }; -OutputFormat parseOutputFormat(const char* formatName); -OutputFormat parseOutputFormat(const std::string& formatName); +inline const std::unordered_map formatMap{ + { "text", OutputFormat::text }, + { "json", OutputFormat::json }, + { "binary", OutputFormat::binary }, + { "xml", OutputFormat::xml }, +}; -/** - * 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); -/** - * Validates that the parent directory of an output file path exists. - * You can use this as a non-mutating validator for the CLI option->check() function - * - * @param arg The file path to validate - * @return Empty string if valid, or an error message if the parent directory does - * not exist - */ -std::string validateOutputPath(const std::string& arg); +struct ParameterCLIArgument { + std::string parameterName; -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()) {} + // I'd use a union for this, but compiler says no + // https://stackoverflow.com/a/70428826 + std::string textValue; + float normalizedValue; + + bool isNormalizedValue; }; /** @@ -47,27 +33,6 @@ struct CLIException : std::runtime_error { */ size_t secondsToSamples(double sec, double sampleRate); -/** - * Parses a string into a floating-point number. - * If not the entire string is a number, an error is thrown, - * unlike std::stof 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 parseFloatStrict(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 std::stoul 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 parseULongStrict(const std::string& str); class PluginUtils { public: @@ -79,9 +44,9 @@ class PluginUtils { * @param initialBlockSize The buffer size to initialize the plugin with. * @return The initialized plugin. */ - static std::unique_ptr - createPluginInstance(const juce::String& pluginPath, double initialSampleRate, - int initialBlockSize); + static std::unique_ptr createPluginInstance( + const juce::String& pluginPath, double initialSampleRate, int initialBlockSize + ); /** * Finds the plugin's parameter with the given name. @@ -91,11 +56,23 @@ class PluginUtils { * @return The parameter. * @throws std::runtime_error If the plugin has no parameter with the given name. */ - static juce::AudioProcessorParameter* - getPluginParameterByName(const juce::AudioPluginInstance& plugin, - const std::string& parameterName); + static juce::AudioProcessorParameter* getPluginParameterByName( + const juce::AudioPluginInstance& plugin, const std::string& parameterName + ); }; +/** +* Loads a saved plugin state from file and applies it to the plugin. +* The state should have been saved as binary using the manage state command. +* Will throw if the file couldn't be opened, but has no idea if the plugin accepted/understood the binary blob it was given. +* +* @param plugin The plugin. +* @param statePath The path to the binary state +* @param state Memory into which to write the state +* @throws FileLoadError If the state file couldn't be opened. +*/ +void loadPluginStateFromFile(juce::AudioPluginInstance& plugin, const juce::File& statePath, juce::MemoryBlock& state); + /** * Get the possible values of a discrete parameter (choice, bool). * @@ -139,3 +116,14 @@ std::string getBusLayoutHumanReadable(const nlohmann::json& layoutJson); * unique name */ void outputResult(const std::string& text, juce::File outPath = {}, bool overwrite = true); + +/** + * Outputs binary data either to stdout or to a file. + * Used for outputting the final result of a command according to the options set by the user. + * + * @param data The binary data to output + * @param outPath The output file path. If not supplied, outputs to stdout instead + * @param overwrite If true, overwrites the file if it exists. If false, creates a new file with a + * unique name + */ +void outputResult(const juce::MemoryBlock& data, juce::File outPath = {}, bool overwrite = true); diff --git a/Source/Validators.cpp b/Source/Validators.cpp new file mode 100644 index 0000000..e08d216 --- /dev/null +++ b/Source/Validators.cpp @@ -0,0 +1,53 @@ +#include "Validators.h" + +#include "Parsers.h" +#include "Utils.h" + +// Validators for CLI11 +// Signature: +// std::string validate(const std::string& arg) +// Usage: +// option->check(validate); + +namespace validate +{ +std::string outputPath(const std::string& arg) { + auto file = parse::stringToFile(arg); + if (!file.getParentDirectory().exists()) { + return "Output parent directory does not exist"; + } + return std::string(); +} + +std::string binaryOrXml(const std::string& arg) { + if (arg == "binary" || arg == "xml") { + return std::string(); + } else { + return "Output format must be 'binary' or 'xml'"; + } +} + +std::string pluginParameter(const std::string& str) { + try { + parse::pluginParameterArgument(str); + } catch (const std::runtime_error& e) { + return std::string(e.what()); + } + + return std::string(); +} + +std::string bitDepth(const std::string& str) { + try { + int value = std::stoi(str); + if (value != 8 && value != 16 && value != 24 && value != 32) { + return std::string("Bit depth must be 8, 16, 24, or 32"); + } + } catch (const std::exception& e) { + return std::string("Bit depth must be a valid integer"); + } + return std::string(); +} + +} // namespace validate + diff --git a/Source/Validators.h b/Source/Validators.h new file mode 100644 index 0000000..8b543a5 --- /dev/null +++ b/Source/Validators.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +namespace validate { +/** + * Validates that the parent directory of an output file path exists. + * You can use this as a non-mutating validator for the CLI option->check() function + * + * @param arg The file path to validate + * @return Empty string if valid, or an error message if the parent directory does + * not exist + */ +std::string outputPath(const std::string& arg); + +/** + * Validates that the passed argument is either 'binary' or 'xml'. + * You can use this as a non-mutating validator for the CLI option->check() function + * + * @param arg The argument + * @return Empty string if valid, or an error message + */ +std::string binaryOrXml(const std::string& arg); + +/** + * Validates the format of a plugin parameter passed via CLI to be ":". + * This does not validate if the parameter exists on a plugin. + * + * @param str The plugin parameter argument + * @return Empty string if valid, or an error message + */ +std::string pluginParameter(const std::string& str); + +/** + * Supported bit depths: 8, 16, 24, or 32 + * + * @param str The bit depth argument + * @return Empty string if valid, or an error message + */ +std::string bitDepth(const std::string& str); + +} // namespace validate \ No newline at end of file diff --git a/Source/AudioDiffCommand.cpp b/Source/commands/AudioDiffCommand.cpp similarity index 100% rename from Source/AudioDiffCommand.cpp rename to Source/commands/AudioDiffCommand.cpp diff --git a/Source/AudioDiffCommand.h b/Source/commands/AudioDiffCommand.h similarity index 100% rename from Source/AudioDiffCommand.h rename to Source/commands/AudioDiffCommand.h diff --git a/Source/BusLayoutsCommand.cpp b/Source/commands/BusLayoutsCommand.cpp similarity index 90% rename from Source/BusLayoutsCommand.cpp rename to Source/commands/BusLayoutsCommand.cpp index 68c34fc..521f184 100644 --- a/Source/BusLayoutsCommand.cpp +++ b/Source/commands/BusLayoutsCommand.cpp @@ -1,6 +1,8 @@ #include "BusLayoutsCommand.h" +#include "Parsers.h" #include "Utils.h" +#include "Validators.h" #include #include @@ -73,12 +75,12 @@ std::shared_ptr BusLayoutsCommand::createApp() { app->add_option("-p,--plugin", argPluginPath, "Plugin path") ->required() ->check(CLI::ExistingPath) - ->each([&](std::string arg){ pluginPath = stringToFile(arg); }); + ->each([&](std::string arg){ pluginPath = parse::stringToFile(arg); }); app->add_option("-o,--output", argOutPath, "Output automation file path (json). Will output to stdout if not supplied.") - ->check(validateOutputPath) - ->each([&](std::string arg) { outputFilePath = stringToFile(arg); }); + ->check(validate::outputPath) + ->each([&](std::string arg) { outputFilePath = parse::stringToFile(arg); }); app->add_option("-f,--format", argOutFormat, "The output format (text, json)") - ->each([&](std::string arg) { outputFormat = parseOutputFormat(arg); }); + ->each([&](std::string arg) { outputFormat = parse::outputFormat(arg); }); app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); // clang-format on diff --git a/Source/BusLayoutsCommand.h b/Source/commands/BusLayoutsCommand.h similarity index 100% rename from Source/BusLayoutsCommand.h rename to Source/commands/BusLayoutsCommand.h diff --git a/Source/CLICommand.h b/Source/commands/CLICommand.h similarity index 100% rename from Source/CLICommand.h rename to Source/commands/CLICommand.h diff --git a/Source/GenerateAutomationCommand.cpp b/Source/commands/GenerateAutomationCommand.cpp similarity index 92% rename from Source/GenerateAutomationCommand.cpp rename to Source/commands/GenerateAutomationCommand.cpp index b17c01e..538d3ce 100644 --- a/Source/GenerateAutomationCommand.cpp +++ b/Source/commands/GenerateAutomationCommand.cpp @@ -1,7 +1,9 @@ #include "GenerateAutomationCommand.h" #include "Automation.h" +#include "Parsers.h" #include "Utils.h" +#include "Validators.h" #include #include @@ -73,10 +75,10 @@ std::shared_ptr GenerateAutomationCommand::createApp() { app->add_option("-p,--plugin", argPluginPath, "Plugin path") ->required() ->check(CLI::ExistingPath) - ->each([&](std::string arg){ pluginPath = stringToFile(arg); }); + ->each([&](std::string arg){ pluginPath = parse::stringToFile(arg); }); app->add_option("-o,--output", argOutPath, "Output automation file path (json). Will output to stdout if not supplied.") - ->check(validateOutputPath) - ->each([&](std::string arg) { outputFilePath = stringToFile(arg); }); + ->check(validate::outputPath) + ->each([&](std::string arg) { outputFilePath = parse::stringToFile(arg); }); app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); // clang-format on diff --git a/Source/GenerateAutomationCommand.h b/Source/commands/GenerateAutomationCommand.h similarity index 100% rename from Source/GenerateAutomationCommand.h rename to Source/commands/GenerateAutomationCommand.h diff --git a/Source/ListParametersCommand.cpp b/Source/commands/ListParametersCommand.cpp similarity index 94% rename from Source/ListParametersCommand.cpp rename to Source/commands/ListParametersCommand.cpp index 6504b19..5858642 100644 --- a/Source/ListParametersCommand.cpp +++ b/Source/commands/ListParametersCommand.cpp @@ -1,7 +1,9 @@ #include "ListParametersCommand.h" #include "Automation.h" +#include "Parsers.h" #include "Utils.h" +#include "Validators.h" #include #include @@ -117,12 +119,12 @@ std::shared_ptr ListParametersCommand::createApp() { app->add_option("-p,--plugin", argPluginPath, "Plugin path") ->required() ->check(CLI::ExistingPath) - ->each([&](std::string arg){ pluginPath = stringToFile(arg); }); + ->each([&](std::string arg){ pluginPath = parse::stringToFile(arg); }); app->add_option("-o,--output", argOutPath, "Output automation file path (json). Will output to stdout if not supplied.") - ->check(validateOutputPath) - ->each([&](std::string arg) { outputFilePath = stringToFile(arg); }); + ->check(validate::outputPath) + ->each([&](std::string arg) { outputFilePath = parse::stringToFile(arg); }); app->add_option("-f,--format", argOutFormat, "The output format (text, json)") - ->each([&](std::string arg) { outputFormat = parseOutputFormat(arg); }); + ->each([&](std::string arg) { outputFormat = parse::outputFormat(arg); }); app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); // clang-format on diff --git a/Source/ListParametersCommand.h b/Source/commands/ListParametersCommand.h similarity index 100% rename from Source/ListParametersCommand.h rename to Source/commands/ListParametersCommand.h diff --git a/Source/commands/ProcessCommand.cpp b/Source/commands/ProcessCommand.cpp new file mode 100644 index 0000000..2fbb148 --- /dev/null +++ b/Source/commands/ProcessCommand.cpp @@ -0,0 +1,179 @@ +#include "ProcessCommand.h" +#include "PluginProcess.h" + +#include "PresetLoadingExtensionsVisitor.h" +#include "Parsers.h" +#include "Utils.h" +#include "Validators.h" + + + +std::shared_ptr ProcessCommand::createApp() { + // don't break these lines, please + // clang-format off + std::shared_ptr app = std::make_shared("Processes audio using a plugin", "process"); + + app->add_option("-p,--plugin", pluginPath, "Plugin path")->required()->check(CLI::ExistingPath); // not ExistingFile because on macOS, these bundles are directores + + auto* inputGroup = app->add_option_group("input"); + auto* audioInputOption = inputGroup->add_option("-i,--input", audioInputFiles, "Input audio file path")->check(CLI::ExistingFile); + inputGroup->add_option("-m,--midiInput", midiInputFileOpt, "Input MIDI file path")->check(CLI::ExistingFile); + // require at least one input of either kind + inputGroup->require_option(); + + app->add_option("--preset", presetFileOpt, "Preset file path. Currently only .vstpreset files for VST3 are supported.")->check(CLI::ExistingFile); + + app->add_option("-o,--output", outputFilePath, "Output audio file path")->required(); + app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); + + auto* sampleRateOption = app->add_option("-s,--sampleRate", sampleRate, "The sample rate to use for processing when no audio file is supplied"); + // sample rate is dictated by input audio files if they're provided + audioInputOption->excludes(sampleRateOption); + + app->add_option("-b,--blockSize", blockSize, "The buffer size to use when processing audio"); + app->add_option("-d,--bitDepth", outputBitDepthOpt, "The output file's bit depth. Defaults to the input file's bit depth if present, or 16 bits if no input file is provided.")->check(validate::bitDepth); + app->add_option("-c,--outChannels", outputChannelCountOpt, "The amount of channels to use for the plugin's output bus"); + + app->add_option("--paramFile", paramsFileOpt, "Path to JSON file to read plugin parameters and automation data from")->check(CLI::ExistingFile); + app->add_option("--param", params, "Plugin parameters to set. Explicitly specified parameters take precedence over parameters read from file")->check(validate::pluginParameter); + + return app; + // clang-format on +} + +void ProcessCommand::execute() { + size_t totalInputLength; + + unsigned int bitDepth = 16; + + // create audio file readers + auto audioInputFileReaders = createAudioFileReaders(audioInputFiles, totalInputLength); + // use the sample rate of input audio files if provided + if (!audioInputFileReaders.isEmpty()) { + sampleRate = audioInputFileReaders[0]->sampleRate; + bitDepth = audioInputFileReaders[0]->bitsPerSample; + } + + if (outputBitDepthOpt) { + bitDepth = *outputBitDepthOpt; + } + + // read MIDI input file + juce::MidiFile midiFile; + if (midiInputFileOpt) { + size_t midiLength; + midiFile = readMIDIFile(*midiInputFileOpt, sampleRate, midiLength); + totalInputLength = std::max(totalInputLength, midiLength); + } + + // create the plugin instance + auto plugin = PluginUtils::createPluginInstance(pluginPath, sampleRate, (int) blockSize); + + if (presetFileOpt) { + // read preset file into memory block + juce::MemoryBlock presetData; + const auto presetInputStream = presetFileOpt->createInputStream(); + presetInputStream->readIntoMemoryBlock(presetData); + // TODO: how to handle errors? + + // apply preset + PresetLoadingExtensionsVisitor presetLoader(presetData); + plugin->getExtensions(presetLoader); + } + + // create and apply the bus layout + unsigned int totalNumInputChannels, totalNumOutputChannels; + auto layout = createBusLayout(*plugin, audioInputFileReaders, outputChannelCountOpt, + totalNumInputChannels, totalNumOutputChannels); + if (!plugin->setBusesLayout(layout)) { + throw CLIException("Plugin does not support requested bus layout"); + } + + // parse plugin parameters + auto automation = parseParameters(*plugin, sampleRate, totalInputLength, paramsFileOpt, params); + + plugin->prepareToPlay(sampleRate, (int) blockSize); + const auto latency = plugin->getLatencySamples(); + + // open output stream + if (outputFilePath.exists() && !overwriteOutputFile) { + throw CLIException("Output file already exists! Use --overwrite to overwrite the file"); + } + + std::unique_ptr outWriter; + outputFilePath.deleteFile(); + if (std::unique_ptr outputStream{ outputFilePath.createOutputStream(static_cast(blockSize)) }) { + juce::WavAudioFormat outFormat; + outWriter = outFormat.createWriterFor( + outputStream, // stream is now managed by writer + juce::AudioFormatWriterOptions{} + .withSampleRate(sampleRate) + .withNumChannels(static_cast(totalNumOutputChannels)) + .withBitsPerSample(static_cast(bitDepth)) + ); + } else { + throw CLIException("Could not create output stream to write to file " + + outputFilePath.getFullPathName()); + } + + // process the input files with the plugin + juce::AudioBuffer sampleBuffer( + (int) std::max(totalNumInputChannels, totalNumOutputChannels), (int) blockSize); + + juce::MidiBuffer midiBuffer; + size_t sampleIndex = 0; + int samplesSkipped = 0; + while (sampleIndex < totalInputLength + static_cast(latency)) { + sampleBuffer.clear(); + + // read next segment of audio input files into buffer + unsigned int targetChannel = 0; + for (auto* inputFileReader : audioInputFileReaders) { + if (!inputFileReader->read(sampleBuffer.getArrayOfWritePointers() + targetChannel, + (int) inputFileReader->numChannels, static_cast(sampleIndex), + (int) blockSize)) { + throw CLIException("Error reading input file"); // TODO: more context, which file? + } + + targetChannel += inputFileReader->numChannels; + } + + // populate MIDI buffer with the MIDI events + // falling into the current processing block. + // we simply take MIDI events from all tracks and supply them to the buffer - + // if the user only wants a single track of a multi-track MIDI file, + // they should extract that track into a separate MIDI file. + midiBuffer.clear(); + for (int i = 0; i < midiFile.getNumTracks(); i++) { + auto midiTrack = midiFile.getTrack(i); + + for (auto& meh : *midiTrack) { + auto timestampSamples = secondsToSamples(meh->message.getTimeStamp(), sampleRate); + if (timestampSamples >= sampleIndex && timestampSamples < sampleIndex + static_cast(blockSize)) { + midiBuffer.addEvent(meh->message, (int) (timestampSamples - sampleIndex)); + } + } + } + + // apply automation + Automation::applyParameters(*plugin, automation, sampleIndex); + + // process with plugin + plugin->processBlock(sampleBuffer, midiBuffer); + + // skip the first samples that are just empty because of the plugin's latency + int startSample = 0; + if (samplesSkipped < latency) { + startSample = std::min(latency - samplesSkipped, blockSize); + samplesSkipped += startSample; + } + + // write to output + if (startSample < blockSize) { + outWriter->writeFromAudioSampleBuffer(sampleBuffer, startSample, + (int) blockSize - startSample); + } + + sampleIndex += static_cast(blockSize); + } +} diff --git a/Source/commands/ProcessCommand.h b/Source/commands/ProcessCommand.h new file mode 100644 index 0000000..f1f2a3d --- /dev/null +++ b/Source/commands/ProcessCommand.h @@ -0,0 +1,35 @@ +#pragma once + +#include "Automation.h" +#include "CLICommand.h" +#include "Errors.h" + +#include +#include + +class ProcessCommand : public CLICommand { + public: + std::shared_ptr createApp() override; + + void execute() override; + + private: + // String from CLI to be parsed into a File object + std::string argOutPath; + // String from CLI to be parsed into a File object + std::string argStatePath; + + juce::String pluginPath; + std::vector audioInputFiles; + std::optional midiInputFileOpt; + std::optional presetFileOpt; + juce::File statePath; + juce::File outputFilePath; + bool overwriteOutputFile; + double sampleRate = 44100; + int blockSize = 1024; + std::optional outputChannelCountOpt; + std::optional outputBitDepthOpt; + std::optional paramsFileOpt; + std::vector params; +}; diff --git a/Source/commands/StateCommand.cpp b/Source/commands/StateCommand.cpp new file mode 100644 index 0000000..1db4e27 --- /dev/null +++ b/Source/commands/StateCommand.cpp @@ -0,0 +1,167 @@ +#include "StateCommand.h" + +#include "Automation.h" +#include "Errors.h" +#include "Parsers.h" +#include "PluginProcess.h" +#include "Utils.h" +#include "Validators.h" + +#include +#include + +using ParamArray = juce::Array; + +static nlohmann::json getParameterValuesAsJson(const ParamArray& params) { + nlohmann::json paramJson; + + for (const auto* param : params) { + const auto paramName = param->getName(100).toStdString(); + paramJson[paramName] = param->getCurrentValueAsText().toStdString(); + } + + return paramJson; +} + +static std::unique_ptr decodeState(juce::XmlElement* stateXml) { + using namespace juce; + constexpr const char* kJucePrivateDataIdentifier = "JUCEPrivateData"; + + auto rootElement = std::make_unique("VST3PluginState"); + auto iComponentElement = std::make_unique("IComponent"); + juce::MemoryBlock mb; + mb.fromBase64Encoding(stateXml->getAllSubText()); + + auto buffer = static_cast(mb.getData()); + auto size = static_cast(mb.getSize()); + + // Check for JUCE private data section + auto jucePrivDataIdentifierSize = std::strlen(kJucePrivateDataIdentifier); + uint64 privateDataSize = 0; + uint64 pluginStateSize = size; + + if ((size_t) size >= jucePrivDataIdentifierSize + sizeof(int64)) { + juce::String magic( + juce::CharPointer_UTF8(buffer + size - jucePrivDataIdentifierSize), + juce::CharPointer_UTF8(buffer + size) + ); + + if (magic == kJucePrivateDataIdentifier) { + std::memcpy( + &privateDataSize, + buffer + ((size_t) size - jucePrivDataIdentifierSize - sizeof(uint64)), + sizeof(uint64) + ); + + privateDataSize = juce::ByteOrder::swapIfBigEndian(privateDataSize); + pluginStateSize -= privateDataSize + jucePrivDataIdentifierSize + sizeof(uint64); + } + } + + if (pluginStateSize > 0) { + if (auto pluginStateXml = + juce::AudioProcessor::getXmlFromBinary(buffer, static_cast(pluginStateSize))) { + iComponentElement->addChildElement(pluginStateXml.release()); + } else { + throw FailedXmlError{ "Cannot decode the binary data into XML.", 210 }; + } + } + + if ((size_t) size >= jucePrivDataIdentifierSize + sizeof(int64)) { + juce::String magic( + juce::CharPointer_UTF8(buffer + size - jucePrivDataIdentifierSize), + juce::CharPointer_UTF8(buffer + size) + ); + + if (magic == kJucePrivateDataIdentifier && privateDataSize > 0) { + auto privateTree = juce::ValueTree::readFromData( + buffer + pluginStateSize, static_cast(privateDataSize) + ); + auto privateElement = std::make_unique("JucePrivateData"); + privateElement->addChildElement(privateTree.createXml().release()); + iComponentElement->addChildElement(privateElement.release()); + } + } + + rootElement->addChildElement(iComponentElement.release()); + + return rootElement; +} + +std::shared_ptr StateCommand::createApp() { + std::shared_ptr app = std::make_shared( + "Load parameter automation and save as binary plugin state, load plugin binary state and " + "save as (static) parameter automation, or simply output the default state. Binary state " + "can sometimes be decoded into readable XML format, but currently only for VST3 using JUCE " + "APVTS.", + "state" + ); + + // don't break these lines, please + // clang-format off + app->add_option("-p,--plugin", argPluginPath, "Plugin path") + ->required() + ->check(CLI::ExistingPath) + ->each([&](std::string arg){ pluginPath = parse::stringToFile(arg); }); + app->add_option("-i,--input", argInPath, "Input file path. Can be an automation file in json format (must have a .json extension), or a state file in binary format.") + ->check(CLI::ExistingPath) + ->each([&](std::string arg) { inputFilePath = parse::stringToFile(arg); }); + app->add_option("-o,--output", argOutPath, "Output file path. Will output to stdout if not supplied.") + ->check(validate::outputPath) + ->each([&](std::string arg) { outputFilePath = parse::stringToFile(arg); }); + app->add_option("-f,--format", argOutFormat, "The output format (json, binary, xml). If the input is JSON, only binary or XML are valid output formats and vice versa.") + ->check([](const std::string& arg) { return (arg == "binary" || arg == "xml" || arg == "json") ? std::string{} : "Output format must be 'binary', 'xml' or 'json'"; } ) + ->each([&](std::string arg) { outputFormat = parse::outputFormat(arg); }); + app->add_flag("-y,--overwrite", overwriteOutputFile, "Overwrite the output file if it exists"); + + // clang-format on + return app; +} + +void StateCommand::execute() { + const double dummySampleRate{ 48000.0 }; + const int dummyBlockSize{ 1024 }; + const size_t dummyInputLength{ 1024 }; + const size_t firstSampleIndex{ 0 }; + const auto plugin = PluginUtils::createPluginInstance( + pluginPath.getFullPathName(), dummySampleRate, dummyBlockSize + ); + juce::MemoryBlock state; + + if (inputFilePath == juce::File{}) { + // Default state only + plugin->getStateInformation(state); + } else if (inputFilePath.hasFileExtension("json")) { + // Parse the automation file and output the state + auto automation = parseParameters( + *plugin, dummySampleRate, dummyInputLength, std::optional{ inputFilePath }, {} + ); + Automation::applyParameters(*plugin, automation, firstSampleIndex); + plugin->getStateInformation(state); + + } else { + // Load the state and return the plugin's parameter values + loadPluginStateFromFile(*plugin, inputFilePath, state); + auto params = getParameterValuesAsJson(plugin->getParameters()); + outputResult(params.dump(4), outputFilePath, overwriteOutputFile); + return; + } + + if (outputFormat == OutputFormat::binary) { + outputResult(state, outputFilePath, overwriteOutputFile); + } else if (outputFormat == OutputFormat::xml) { + std::unique_ptr decodedState{}; + if (auto wrappedState = + juce::AudioProcessor::getXmlFromBinary(state.getData(), state.getSize())) { + decodedState = decodeState(wrappedState.get()); + outputResult( + decodedState->toString().toStdString(), outputFilePath, overwriteOutputFile + ); + } else { + outputResult( + state.toString().toStdString(), outputFilePath, overwriteOutputFile + ); + throw FailedXmlError{ "Couldn't decode the state into XML format.", 200 }; + }; + } +} diff --git a/Source/commands/StateCommand.h b/Source/commands/StateCommand.h new file mode 100644 index 0000000..8f7670d --- /dev/null +++ b/Source/commands/StateCommand.h @@ -0,0 +1,29 @@ +#pragma once + +#include "CLICommand.h" +#include "Utils.h" + +#include + +class StateCommand : public CLICommand { + public: + std::shared_ptr createApp() override; + + void execute() override; + + private: + // String from CLI to be parsed into a File object + std::string argPluginPath; + // String from CLI to be parsed into a File object + std::string argInPath; + // String from CLI to be parsed into a File object + std::string argOutPath; + // String from CLI to be parsed into an outputFormat + std::string argOutFormat; + + juce::File pluginPath; + juce::File inputFilePath; + juce::File outputFilePath; + OutputFormat outputFormat{ OutputFormat::binary }; + bool overwriteOutputFile{ false }; +}; diff --git a/Source/main.cpp b/Source/main.cpp index b5cdc66..3d38789 100644 --- a/Source/main.cpp +++ b/Source/main.cpp @@ -1,8 +1,9 @@ -#include "AudioDiffCommand.h" -#include "BusLayoutsCommand.h" -#include "GenerateAutomationCommand.h" -#include "ListParametersCommand.h" -#include "ProcessCommand.h" +#include "commands/AudioDiffCommand.h" +#include "commands/BusLayoutsCommand.h" +#include "commands/GenerateAutomationCommand.h" +#include "commands/ListParametersCommand.h" +#include "commands/StateCommand.h" +#include "commands/ProcessCommand.h" #include #include @@ -26,18 +27,21 @@ static int runCommandLine(const std::string& commandLineParameters) { ProcessCommand pc; registerSubcommand(app, pc); + AudioDiffCommand adc; + registerSubcommand(app, adc); + ListParametersCommand lpc; registerSubcommand(app, lpc); GenerateAutomationCommand gac; registerSubcommand(app, gac); + StateCommand msc; + registerSubcommand(app, msc); + BusLayoutsCommand blc; registerSubcommand(app, blc); - AudioDiffCommand adc; - registerSubcommand(app, adc); - try { app.parse(commandLineParameters, false); } catch (const CLI::Error& error) { diff --git a/test/PlugalyzeeAudio/.clang-format b/test/PlugalyzeeAudio/.clang-format new file mode 100644 index 0000000..3b1cbc8 --- /dev/null +++ b/test/PlugalyzeeAudio/.clang-format @@ -0,0 +1,85 @@ +Language: Cpp +AccessModifierOffset: -4 +AlignAfterOpenBracket: false +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: false +AlignTrailingComments: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: WithoutElse +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: false +BinPackParameters: false +BreakAfterJavaFieldAnnotations: false +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Custom +BreakBeforeCloseBracketFunction: true +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakInheritanceList: AfterColon +BreakStringLiterals: false +BraceWrapping: # Allman except for lambdas + AfterClass: true + AfterCaseLabel: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + BeforeElse: true + AfterControlStatement: Always + BeforeLambdaBody: false +ColumnLimit: 0 +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +IndentCaseLabels: true +IndentPPDirectives: BeforeHash +IndentWidth: 4 +IndentWrappedFunctionNames: true +KeepEmptyLinesAtTheStartOfBlocks: false +MaxEmptyLinesToKeep: 1 +FixNamespaceComments: false +NamespaceIndentation: None +PointerAlignment: Left +ReflowComments: false +SortIncludes: true +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceInEmptyParentheses: false +SpaceBeforeInheritanceColon: true +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: true +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: "c++20" +TabWidth: 4 +UseTab: Never +UseCRLF: false +--- +Language: ObjC +BasedOnStyle: Chromium +BreakBeforeBraces: Allman +ColumnLimit: 0 +IndentWidth: 4 +KeepEmptyLinesAtTheStartOfBlocks: false +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PointerAlignment: Left +SpacesBeforeTrailingComments: 1 +TabWidth: 4 +UseTab: Never \ No newline at end of file diff --git a/test/PlugalyzeeAudio/CMakeLists.txt b/test/PlugalyzeeAudio/CMakeLists.txt new file mode 100644 index 0000000..bf304bb --- /dev/null +++ b/test/PlugalyzeeAudio/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.22) + +project(TEST_AUDIO_PLUGIN VERSION 0.0.1) + +set(CMAKE_CXX_STANDARD 23) + +add_subdirectory(../../JUCE juce) + +juce_add_plugin(PlugalyzeeAudio + # VERSION ... + # ICON_BIG ... + # ICON_SMALL ... + COMPANY_NAME Plugalyzer + # IS_SYNTH TRUE/FALSE + # NEEDS_MIDI_INPUT TRUE/FALSE + # NEEDS_MIDI_OUTPUT TRUE/FALSE + # IS_MIDI_EFFECT TRUE/FALSE + # EDITOR_WANTS_KEYBOARD_FOCUS TRUE/FALSE + COPY_PLUGIN_AFTER_BUILD FALSE + PLUGIN_MANUFACTURER_CODE Plug + PLUGIN_CODE Kal6 + FORMATS AU VST3 LV2 + PRODUCT_NAME "PlugalyzeeAudio" + LV2URI "urn:CrushedPixel:Plugalyzee:Audio" +) + +target_sources(PlugalyzeeAudio + PRIVATE + PluginProcessor.cpp + PlugalyzeeAudio.cpp +) + +target_compile_definitions(PlugalyzeeAudio + PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 + JUCE_LOG_ASSERTIONS=1 + JUCE_DISABLE_JUCE_VERSION_PRINTING=1 +) + +target_link_libraries(PlugalyzeeAudio + PRIVATE + juce::juce_audio_processors + juce::juce_dsp + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags +) diff --git a/test/PlugalyzeeAudio/PlugalyzeeAudio.cpp b/test/PlugalyzeeAudio/PlugalyzeeAudio.cpp new file mode 100644 index 0000000..e104cea --- /dev/null +++ b/test/PlugalyzeeAudio/PlugalyzeeAudio.cpp @@ -0,0 +1,170 @@ +#include "PlugalyzeeAudio.h" + +#include +#include + +#include + +using namespace juce; + +juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout() +{ + using namespace juce; + + const auto makeGainParam = [](auto paramId) { + auto humanName = String{ paramId }.replaceCharacter('_', ' '); + return std::make_unique( + ParameterID{ paramId, parameterVersion }, + humanName, + NormalisableRange{ -96.f, 12.f }, + 0.0f, + AudioParameterFloatAttributes().withLabel(id::dB) + ); + }; + + const auto makeThresholdParam = [](auto paramId) { + auto humanName = String{ paramId }.replaceCharacter('_', ' '); + return std::make_unique( + ParameterID{ paramId, parameterVersion }, + humanName, + NormalisableRange{ -96.f, 0.f }, + 0.1f, + AudioParameterFloatAttributes().withLabel(id::dB) + ); + }; + + const auto makeMillisParam = [](auto paramId) { + auto humanName = String{ paramId }.replaceCharacter('_', ' '); + return std::make_unique( + ParameterID{ paramId, parameterVersion }, + humanName, + 0, + 200, + 0, + AudioParameterIntAttributes().withLabel(id::millis) + ); + }; + + const auto makeRatioParam = [](auto paramId) { + auto humanName = String{ paramId }.replaceCharacter('_', ' '); + + auto sfv = [](float val, int /* maxLength */) -> String { + return String{ id::ratioPrefix } + String{ val }; + }; + + auto vfs = [](String str) -> float { + auto val = str.replaceFirstOccurrenceOf(id::ratioPrefix, ""); + auto valRet = val.getFloatValue(); + return valRet; + }; + + return std::make_unique( + ParameterID{ paramId, parameterVersion }, + humanName, + NormalisableRange{ 1.f, 100.f, 1.f }, + 2.0f, + AudioParameterFloatAttributes().withStringFromValueFunction(sfv).withValueFromStringFunction(vfs) + ); + }; + + std::vector> params; + + auto group{ std::make_unique( + String{ "maingroup" }, // groupID + String{ "all" }, // groupName + String{ "|" } // subgroupSeparator + ) }; + + group->addChild(makeGainParam(id::inGainDecibels)); + group->addChild(makeThresholdParam(id::threshold)); + group->addChild(makeRatioParam(id::ratio)); + group->addChild(makeMillisParam(id::attack)); + group->addChild(makeMillisParam(id::release)); + group->addChild(makeGainParam(id::outGainDecibels)); + + params.push_back(std::move(group)); + + return { params.begin(), params.end() }; +} + +ParameterUpdateDebugger::ParameterUpdateDebugger( + juce::AudioProcessorValueTreeState& apvtsToUse, + PlugalyzeeParams& paramsToListenTo +) : apvts(apvtsToUse) +{ + paramsToListenTo.inGain->addListener(this); + params.push_back(paramsToListenTo.inGain); + paramsToListenTo.ratio->addListener(this); + params.push_back(paramsToListenTo.ratio); + paramsToListenTo.threshold->addListener(this); + params.push_back(paramsToListenTo.threshold); + paramsToListenTo.attack->addListener(this); + params.push_back(paramsToListenTo.attack); + paramsToListenTo.release->addListener(this); + params.push_back(paramsToListenTo.release); + paramsToListenTo.outGain->addListener(this); + params.push_back(paramsToListenTo.outGain); + + for (auto* p : id::paramIds) + { + apvts.addParameterListener(p, this); + } +} + +ParameterUpdateDebugger::~ParameterUpdateDebugger() +{ + for (auto* param : params) + { + param->removeListener(this); + } + for (auto* p : id::paramIds) + { + apvts.removeParameterListener(p, this); + } +} + +void ParameterUpdateDebugger::parameterValueChanged(int parameterIndex, float newValue) +{ + DBG(std::format( + "{} [{}]\tParam update: Index:\t{:<12} Value: {}", + juce::Time::getCurrentTime().formatted("%H:%M:%S").toStdString(), + juce::Time::getHighResolutionTicks(), + parameterIndex, + newValue + )); +} + +void ParameterUpdateDebugger::parameterChanged(const juce::String& parameterID, float newValue) +{ + DBG(std::format( + "{} [{}]\tAPVTS update: ID:\t{:<12} Value: {}", + juce::Time::getCurrentTime().formatted("%H:%M:%S").toStdString(), + juce::Time::getHighResolutionTicks(), + parameterID.toStdString(), + newValue + )); +} + +void PlugalyzeeDSP::prepare(const juce::dsp::ProcessSpec& spec) +{ + procInGain.prepare(spec); + procComp.prepare(spec); + procOutGain.prepare(spec); +} + +void PlugalyzeeDSP::setParams(const ParameterValues paramVals) +{ + procInGain.setGainLinear(juce::Decibels::decibelsToGain(paramVals.inGain, constants::minusInfinitydB)); + procComp.setRatio(static_cast(paramVals.ratio)); + procComp.setThreshold(paramVals.threshold); + procComp.setAttack(static_cast(paramVals.attack)); + procComp.setRelease(static_cast(paramVals.release)); + procOutGain.setGainLinear(juce::Decibels::decibelsToGain(paramVals.outGain, constants::minusInfinitydB)); +} + +void PlugalyzeeDSP::process(juce::dsp::ProcessContextReplacing& context) +{ + procInGain.process(context); + procComp.process(context); + procOutGain.process(context); +} \ No newline at end of file diff --git a/test/PlugalyzeeAudio/PlugalyzeeAudio.h b/test/PlugalyzeeAudio/PlugalyzeeAudio.h new file mode 100644 index 0000000..9628070 --- /dev/null +++ b/test/PlugalyzeeAudio/PlugalyzeeAudio.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include + +// Gain/trim measured in dB +using decibelFloat = float; +// Gain/trim measure in linear scale +using linearFloat = float; +// Indivisible milliseconds +using millisInt = int; +// Parameter Value normalized to 0-1 +using normalizedParameterValue = float; +// Parameter Value full scale +using denormalizedParameterValue = float; + +juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + +static constexpr auto pluginName{ "Plugalyzee Audio" }; +static constexpr int parameterVersion{ 1 }; + +namespace id +{ +// Parameters +static constexpr auto inGainDecibels{ "In_Gain" }; +static constexpr auto ratio{ "Ratio" }; +static constexpr auto threshold{ "Threshold" }; +static constexpr auto attack{ "Attack" }; +static constexpr auto release{ "Release" }; +static constexpr auto outGainDecibels{ "Out_Gain" }; + +constexpr std::array paramIds{ + inGainDecibels, + ratio, + threshold, + attack, + release, + outGainDecibels +}; + +// State +static const juce::Identifier version{ "v" }; +static const juce::Identifier apvtsRoot{ "PlugalyzeeAPVTS" }; +static const juce::Identifier extraState{ "extra" }; +static const juce::Identifier extraA{ "extraA" }; +static const juce::Identifier extraB{ "extraB" }; + +// Prefixes/Suffixes +static constexpr auto ratioPrefix{ "1:" }; +static constexpr auto dB{ "dB" }; +static constexpr auto millis{ "ms" }; +} // namespace id + +namespace constants +{ +static constexpr auto minusInfinitydB{ -96.f }; +} // constants + +struct PlugalyzeeParams +{ + juce::AudioParameterFloat* inGain{}; + juce::AudioParameterFloat* ratio{}; + juce::AudioParameterFloat* threshold{}; + juce::AudioParameterInt* attack{}; + juce::AudioParameterInt* release{}; + juce::AudioParameterFloat* outGain{}; +}; + +struct ParameterValues +{ + decibelFloat inGain{}; + float ratio{}; + float threshold{}; + millisInt attack{}; + millisInt release{}; + decibelFloat outGain{}; +}; + +class ParameterUpdateDebugger : + public juce::AudioProcessorParameter::Listener, + public juce::AudioProcessorValueTreeState::Listener +{ +public: + ParameterUpdateDebugger( + juce::AudioProcessorValueTreeState& apvtsToUse, + PlugalyzeeParams& paramsToListenTo + ); + ~ParameterUpdateDebugger(); + + // Parameter Listener + void parameterValueChanged(int parameterIndex, float newValue) override; + void parameterGestureChanged(int, bool) override {}; + // APVTS Listener + void parameterChanged(const juce::String& parameterID, float newValue) override; + +private: + std::vector params; + std::vector apvtsParams; + juce::AudioProcessorValueTreeState& apvts; +}; + +class PlugalyzeeDSP +{ +public: + void prepare(const juce::dsp::ProcessSpec& spec); + void setParams(const ParameterValues paramVals); + void process(juce::dsp::ProcessContextReplacing& context); + +private: + juce::dsp::Gain procInGain; + juce::dsp::Compressor procComp; + juce::dsp::Gain procOutGain; +}; diff --git a/test/PlugalyzeeAudio/PluginProcessor.cpp b/test/PlugalyzeeAudio/PluginProcessor.cpp new file mode 100644 index 0000000..908264f --- /dev/null +++ b/test/PlugalyzeeAudio/PluginProcessor.cpp @@ -0,0 +1,216 @@ +#include "PluginProcessor.h" + +#include "PlugalyzeeAudio.h" +#include +#include +#include + +using namespace juce; + +// clang-format off +PlugalyzeeAudioProcessor::PlugalyzeeAudioProcessor() : + AudioProcessor( + BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output", juce::AudioChannelSet::stereo(), true) + ), + state(*this, nullptr, id::apvtsRoot, createParameterLayout()) +// clang-format on +{ + state.state.getOrCreateChildWithName(id::version, nullptr).setProperty(id::version, parameterVersion, nullptr); + state.state.getOrCreateChildWithName(id::extraState, nullptr).addChild(ValueTree{ id::extraA }, -1, nullptr); + state.state.getChildWithName(id::extraState).addChild(ValueTree{ id::extraB }, -1, nullptr); + + params.inGain = static_cast(state.getParameter(id::inGainDecibels)); + params.ratio = static_cast(state.getParameter(id::ratio)); + params.threshold = static_cast(state.getParameter(id::threshold)); + params.attack = static_cast(state.getParameter(id::attack)); + params.release = static_cast(state.getParameter(id::release)); + params.outGain = static_cast(state.getParameter(id::outGainDecibels)); + + paramDebugger = std::make_unique(state, params); +} + +PlugalyzeeAudioProcessor::~PlugalyzeeAudioProcessor() +{ +} + +const juce::String PlugalyzeeAudioProcessor::getName() const +{ + return pluginName; +} + +bool PlugalyzeeAudioProcessor::acceptsMidi() const +{ +#if JucePlugin_WantsMidiInput + return true; +#else + return false; +#endif +} + +bool PlugalyzeeAudioProcessor::producesMidi() const +{ +#if JucePlugin_ProducesMidiOutput + return true; +#else + return false; +#endif +} + +bool PlugalyzeeAudioProcessor::isMidiEffect() const +{ +#if JucePlugin_IsMidiEffect + return true; +#else + return false; +#endif +} + +double PlugalyzeeAudioProcessor::getTailLengthSeconds() const +{ + return 0.0; +} + +int PlugalyzeeAudioProcessor::getNumPrograms() +{ + return 1; +} + +int PlugalyzeeAudioProcessor::getCurrentProgram() +{ + return 0; +} + +void PlugalyzeeAudioProcessor::setCurrentProgram(int index) +{ + juce::ignoreUnused(index); +} + +const juce::String PlugalyzeeAudioProcessor::getProgramName(int index) +{ + juce::ignoreUnused(index); + return {}; +} + +void PlugalyzeeAudioProcessor::changeProgramName(int index, const juce::String& newName) +{ + juce::ignoreUnused(index, newName); +} + +void PlugalyzeeAudioProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + DBG(std::format("Prepare to play({},{})", sampleRate, samplesPerBlock)); + jassert(getTotalNumInputChannels() == getTotalNumOutputChannels()); + dsp::ProcessSpec spec{ + .sampleRate = sampleRate, + .maximumBlockSize = static_cast(samplesPerBlock), + .numChannels = static_cast(getTotalNumInputChannels()) + }; + processor.prepare(spec); +} + +void PlugalyzeeAudioProcessor::releaseResources() +{ +} + +bool PlugalyzeeAudioProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const +{ +#if JucePlugin_IsMidiEffect + juce::ignoreUnused(layouts); + return true; +#else + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::mono() + && layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + + #if !JucePlugin_IsSynth + if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) + return false; + #endif + + return true; +#endif +} + +void PlugalyzeeAudioProcessor::processBlock( + juce::AudioBuffer& buffer, + juce::MidiBuffer& midiMessages +) +{ + juce::ignoreUnused(midiMessages); + + juce::ScopedNoDenormals noDenormals; + + processor.setParams(ParameterValues{ + .inGain = params.inGain->get(), + .ratio = params.ratio->get(), + .threshold = params.threshold->get(), + .attack = params.attack->get(), + .release = params.release->get(), + .outGain = params.outGain->get(), + }); + + dsp::AudioBlock block{ buffer }; + dsp::ProcessContextReplacing context{ block }; + + processor.process(context); +} + +bool PlugalyzeeAudioProcessor::hasEditor() const +{ + return true; +} + +juce::AudioProcessorEditor* PlugalyzeeAudioProcessor::createEditor() +{ + return new GenericAudioProcessorEditor(*this); +} + +void PlugalyzeeAudioProcessor::getStateInformation(juce::MemoryBlock& destData) +{ + DBG("getStateInformation()"); + const auto stateCopy = state.copyState(); + if (const auto xml{ stateCopy.createXml() }) + { + copyXmlToBinary(*xml, destData); + DBG("Saved state"); + } +} + +void PlugalyzeeAudioProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + DBG("setStateInformation()"); + auto xml{ getXmlFromBinary(data, sizeInBytes) }; + if (!xml) + { + DBG("Couldn't read state"); + return; + } + if (!xml->hasTagName(id::apvtsRoot)) + { + DBG("Wrong root tag in state"); + return; + } + auto candidateValueTree{ juce::ValueTree::fromXml(*xml) }; + auto candidateVersion{ candidateValueTree.getChildWithName(id::version) }; + if (!candidateVersion.isValid()) + { + DBG("Couldn't get version from state"); + return; + } + auto version{ candidateVersion.getProperty(id::version) }; + if (static_cast(version) != parameterVersion) + { + DBG("Version mismatch. State not loaded."); + return; + } + + state.state = juce::ValueTree::fromXml(*xml); + DBG("Loaded state"); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new PlugalyzeeAudioProcessor(); +} diff --git a/test/PlugalyzeeAudio/PluginProcessor.h b/test/PlugalyzeeAudio/PluginProcessor.h new file mode 100644 index 0000000..26d4a71 --- /dev/null +++ b/test/PlugalyzeeAudio/PluginProcessor.h @@ -0,0 +1,45 @@ +#pragma once + +#include "PlugalyzeeAudio.h" +#include + +class PlugalyzeeAudioProcessor final : public juce::AudioProcessor +{ +public: + PlugalyzeeAudioProcessor(); + ~PlugalyzeeAudioProcessor() override; + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + using AudioProcessor::processBlock; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram(int index) override; + const juce::String getProgramName(int index) override; + void changeProgramName(int index, const juce::String& newName) override; + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + +private: + juce::AudioProcessorValueTreeState state; + PlugalyzeeParams params; + PlugalyzeeDSP processor; + std::unique_ptr paramDebugger; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PlugalyzeeAudioProcessor) +};